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.
13322 lines
464 KiB
13322 lines
464 KiB
package mock
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"tera-clone/services/tera-api/internal/alarmdelivery"
|
|
"tera-clone/services/tera-api/internal/diagnostics"
|
|
"tera-clone/services/tera-api/internal/directstream"
|
|
"tera-clone/services/tera-api/internal/domain"
|
|
"tera-clone/services/tera-api/internal/externalauth"
|
|
"tera-clone/services/tera-api/internal/hardware/systemcontrol"
|
|
"tera-clone/services/tera-api/internal/logpolicy"
|
|
"tera-clone/services/tera-api/internal/logservice"
|
|
"tera-clone/services/tera-api/internal/logstorage"
|
|
"tera-clone/services/tera-api/internal/managementaccess"
|
|
"tera-clone/services/tera-api/internal/networkconfig"
|
|
"tera-clone/services/tera-api/internal/secretstore"
|
|
"tera-clone/services/tera-api/internal/security/password"
|
|
"tera-clone/services/tera-api/internal/serial"
|
|
"tera-clone/services/tera-api/internal/snmpagent"
|
|
"tera-clone/services/tera-api/internal/storage/atomicfile"
|
|
"tera-clone/services/tera-api/internal/supportbundle"
|
|
"tera-clone/services/tera-api/internal/systemtime"
|
|
"tera-clone/services/tera-api/internal/tlscert"
|
|
)
|
|
|
|
type Clock func() time.Time
|
|
|
|
type StoreOptions struct {
|
|
LogJournal logservice.Journal
|
|
AllowSampleLogs bool
|
|
AuditPath string
|
|
AuditMaxEvents int
|
|
SystemLogPath string
|
|
SystemLogMaxEvents int
|
|
PortLogPath string
|
|
PortLogMaxEvents int
|
|
LinkLogPath string
|
|
LinkLogMaxEvents int
|
|
LinkSnapshotInterval time.Duration
|
|
ConfigPath string
|
|
OperationPath string
|
|
OperationMaxEvents int
|
|
AuthPath string
|
|
SessionPath string
|
|
LoginFailurePath string
|
|
SecretPath string
|
|
AuthMaxSessions int
|
|
AuthMaxTokens int
|
|
AuthMaxLoginFailures int
|
|
AuthMaxUsers int
|
|
MinimumPasswordLength int
|
|
PasswordKDFs password.Registry
|
|
PasswordKDF password.KDF
|
|
TimeController systemtime.Controller
|
|
NetworkController networkconfig.Controller
|
|
ManagementController managementaccess.Controller
|
|
SystemController systemcontrol.Controller
|
|
AlarmDeliveryController alarmdelivery.Controller
|
|
SNMPAgentController snmpagent.Controller
|
|
TLSCertificateStore *tlscert.Repository
|
|
DiagnosticsRunner diagnostics.Runner
|
|
SupportBundleService supportbundle.Service
|
|
ExternalAuthenticator externalauth.Authenticator
|
|
MACResolver func(context.Context, netip.Addr) (string, error)
|
|
DiagnosticsWorkers int
|
|
DiagnosticsQueue int
|
|
RemoveLegacyAuthUsers bool
|
|
}
|
|
|
|
const (
|
|
failedLoginLockThreshold = 5
|
|
failedLoginLockDuration = 15 * time.Minute
|
|
maximumLoginSecurityHitCount = 100
|
|
defaultOperationMaxEvents = 256
|
|
defaultAuthMaxSessions = 128
|
|
defaultAuthMaxTokens = 128
|
|
defaultAuthMaxLoginFailures = 1024
|
|
defaultAuthMaxUsers = 64
|
|
defaultMinimumPasswordLength = 8
|
|
minimumAllowedPasswordLength = 8
|
|
maximumAllowedPasswordLength = 128
|
|
defaultLogEventLimit = 100
|
|
maxLogEventLimit = 500
|
|
defaultLogExportMaxRows = 1000
|
|
defaultPersistentLogMaxEvents = 10000
|
|
maxLogExportRows = 10000
|
|
maxLogExportKeywordLength = 128
|
|
defaultDiagnosticsTimeoutSeconds = 30
|
|
maxDiagnosticsTimeoutSeconds = 300
|
|
defaultDiagnosticsMaxOutputBytes = 64 * 1024
|
|
maxDiagnosticsMaxOutputBytes = 1024 * 1024
|
|
defaultDiagnosticsWorkers = 2
|
|
maxDiagnosticsWorkers = 4
|
|
defaultDiagnosticsQueue = 16
|
|
maxDiagnosticsQueue = 64
|
|
mockSupportBundleArtifactDir = "/var/lib/tera-api/diagnostics"
|
|
mockSupportBundleRetention = 24 * 60 * 60
|
|
)
|
|
|
|
type defaultAuthSeedCacheKeyer interface {
|
|
DefaultAuthSeedCacheKey() string
|
|
}
|
|
|
|
var defaultAuthSeedCache = struct {
|
|
sync.Mutex
|
|
users map[string]map[string]authUserRecord
|
|
}{
|
|
users: make(map[string]map[string]authUserRecord),
|
|
}
|
|
|
|
var mockSupportBundleIncludedSections = []string{
|
|
"system_status",
|
|
"service_health",
|
|
"network",
|
|
"ports",
|
|
"alarms",
|
|
"logs",
|
|
"config_summary",
|
|
}
|
|
|
|
type Store struct {
|
|
clock Clock
|
|
timeController systemtime.Controller
|
|
ntpController systemtime.NTPController
|
|
networkController networkconfig.Controller
|
|
managementController managementaccess.Controller
|
|
systemController systemcontrol.Controller
|
|
alarmDeliveryController alarmdelivery.Controller
|
|
snmpAgentController snmpagent.Controller
|
|
tlsCertificates *tlscert.Repository
|
|
diagnosticsRunner diagnostics.Runner
|
|
supportBundleService supportbundle.Service
|
|
externalAuthenticator externalauth.Authenticator
|
|
macResolver func(context.Context, netip.Addr) (string, error)
|
|
diagnosticsWorkers int
|
|
diagnosticsQueue chan diagnosticsJob
|
|
diagnosticsContext context.Context
|
|
diagnosticsCancel context.CancelFunc
|
|
diagnosticsWG sync.WaitGroup
|
|
diagnosticsClose sync.Once
|
|
mu sync.Mutex
|
|
logJournal logservice.Journal
|
|
allowSampleLogs bool
|
|
journalLast map[string]string
|
|
journalSequence map[string]uint64
|
|
portClearRevision uint64
|
|
systemClearRevision uint64
|
|
journalLocalSequences map[string]map[uint64]struct{}
|
|
storagePressureActive bool
|
|
|
|
operations map[string]domain.Operation
|
|
auditEvents []domain.AuditEvent
|
|
auditPath string
|
|
auditMax int
|
|
auditSeq int
|
|
configPath string
|
|
operationPath string
|
|
operationMax int
|
|
authPath string
|
|
sessionPath string
|
|
loginFailurePath string
|
|
secrets *secretstore.Store
|
|
authMaxSessions int
|
|
authMaxTokens int
|
|
authMaxLoginFailures int
|
|
authMaxUsers int
|
|
minimumPasswordLength int
|
|
passwordKDFs password.Registry
|
|
passwordKDF password.KDF
|
|
operationSeq int
|
|
systemLogPath string
|
|
systemLogMax int
|
|
systemLogLoaded bool
|
|
systemLogEvents []domain.SystemLogEvent
|
|
portLogPath string
|
|
portLogMax int
|
|
portLogLoaded bool
|
|
portLogEvents []domain.PortLogEvent
|
|
linkLogPath string
|
|
linkLogMax int
|
|
linkLogLoaded bool
|
|
linkLogEvents []domain.LinkLogEvent
|
|
linkTracker *logpolicy.LinkTracker
|
|
authUsers map[string]authUserRecord
|
|
authGroupOverrides map[string]authGroupRecord
|
|
authTokens map[string]authTokenRecord
|
|
addressFilters domain.AddressFilterSettings
|
|
authTokenSeq int
|
|
sessions map[string]sessionRecord
|
|
sessionSeq int
|
|
loginFailures map[string]loginFailureRecord
|
|
direct map[string]directState
|
|
directSessions map[string]directSessionRecord
|
|
directTickets *directstream.TicketStore
|
|
running map[string]portConfig
|
|
persistent map[string]portConfig
|
|
pending map[string]portConfig
|
|
runningAlarms map[string]alarmRuleConfig
|
|
persistentAlarms map[string]alarmRuleConfig
|
|
pendingAlarms map[string]alarmRuleConfig
|
|
runningAlarmDelivery alarmDeliveryPolicyConfig
|
|
persistentAlarmDelivery alarmDeliveryPolicyConfig
|
|
pendingAlarmDelivery *alarmDeliveryPolicyConfig
|
|
runningEmail emailNotificationConfig
|
|
persistentEmail emailNotificationConfig
|
|
pendingEmail *emailNotificationConfig
|
|
runningSNMPAgent snmpAgentConfig
|
|
persistentSNMPAgent snmpAgentConfig
|
|
pendingSNMPAgent *snmpAgentConfig
|
|
runningSNMPTraps []snmpTrapTargetConfig
|
|
persistentSNMPTraps []snmpTrapTargetConfig
|
|
pendingSNMPTraps *[]snmpTrapTargetConfig
|
|
runningNetwork map[string]networkInterfaceConfig
|
|
persistentNetwork map[string]networkInterfaceConfig
|
|
pendingNetwork map[string]networkInterfaceConfig
|
|
networkRollback map[string]networkInterfaceConfig
|
|
networkTransactionID string
|
|
runningTime timeSettingsConfig
|
|
persistentTime timeSettingsConfig
|
|
pendingTime *timeSettingsConfig
|
|
runningRetention logRetentionConfig
|
|
persistentRetention logRetentionConfig
|
|
pendingRetention *logRetentionConfig
|
|
runningManagement managementAccessConfig
|
|
persistentManagement managementAccessConfig
|
|
pendingManagement *managementAccessConfig
|
|
runningSyslog remoteSyslogConfig
|
|
persistentSyslog remoteSyslogConfig
|
|
pendingSyslog *remoteSyslogConfig
|
|
runningTLS tlsCertificateConfig
|
|
persistentTLS tlsCertificateConfig
|
|
pendingTLS *tlsCertificateConfig
|
|
runningExternalAuth externalAuthConfig
|
|
persistentExternalAuth externalAuthConfig
|
|
pendingExternalAuth *externalAuthConfig
|
|
|
|
runningRevision int
|
|
persistentRevision int
|
|
}
|
|
|
|
type diagnosticsJob struct {
|
|
operationID string
|
|
action string
|
|
actor string
|
|
request diagnostics.Request
|
|
bundleID string
|
|
timeoutSeconds int
|
|
maxOutputBytes int
|
|
}
|
|
|
|
func NewStore(clock Clock) *Store {
|
|
store, err := NewStoreWithOptions(clock, StoreOptions{AllowSampleLogs: true})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func NewStoreWithAuditLog(clock Clock, auditPath string, maxEvents int) (*Store, error) {
|
|
return NewStoreWithOptions(clock, StoreOptions{AuditPath: auditPath, AuditMaxEvents: maxEvents})
|
|
}
|
|
|
|
func NewStoreWithConfigFile(clock Clock, configPath string) (*Store, error) {
|
|
return NewStoreWithOptions(clock, StoreOptions{ConfigPath: configPath})
|
|
}
|
|
|
|
func NewStoreWithOperationFile(clock Clock, operationPath string) (*Store, error) {
|
|
return NewStoreWithOptions(clock, StoreOptions{OperationPath: operationPath})
|
|
}
|
|
|
|
func normalizePositiveOption(value, fallback int) int {
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func normalizeBoundedOption(value, fallback, maximum int) int {
|
|
value = normalizePositiveOption(value, fallback)
|
|
if value > maximum {
|
|
return maximum
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeMinimumPasswordLength(value int) int {
|
|
if value == 0 {
|
|
return defaultMinimumPasswordLength
|
|
}
|
|
if value < minimumAllowedPasswordLength {
|
|
return minimumAllowedPasswordLength
|
|
}
|
|
if value > maximumAllowedPasswordLength {
|
|
return maximumAllowedPasswordLength
|
|
}
|
|
return value
|
|
}
|
|
|
|
func storePasswordKDFs(registry password.Registry, kdf password.KDF) password.Registry {
|
|
if len(registry.Algorithms()) > 0 {
|
|
return registry
|
|
}
|
|
if kdf != nil {
|
|
return password.NewRegistry(kdf, password.PrototypeSHA256{})
|
|
}
|
|
return password.NewProductionRegistry()
|
|
}
|
|
|
|
func storePasswordKDF(kdf password.KDF) password.KDF {
|
|
if kdf != nil {
|
|
return kdf
|
|
}
|
|
return password.NewArgon2id(password.Argon2idDefaultParams())
|
|
}
|
|
|
|
func NewStoreWithOptions(clock Clock, options StoreOptions) (*Store, error) {
|
|
passwordKDF := storePasswordKDF(options.PasswordKDF)
|
|
passwordKDFs := storePasswordKDFs(options.PasswordKDFs, options.PasswordKDF)
|
|
authUsers, err := defaultAuthUsers(passwordKDF)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
secrets, err := secretstore.Open(options.SecretPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlsCertificates := options.TLSCertificateStore
|
|
if tlsCertificates == nil {
|
|
tlsCertificates, err = tlscert.Open(tlscert.Options{Clock: func() time.Time { return clock() }})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
base := basePortConfig()
|
|
baseAlarms := baseAlarmRuleConfig()
|
|
baseAlarmDelivery := baseAlarmDeliveryPolicy()
|
|
baseEmail := baseEmailNotification()
|
|
baseSNMPAgent := baseSNMPAgent()
|
|
baseSNMPTraps := baseSNMPTrapTargets()
|
|
baseNetwork := baseNetworkConfig()
|
|
baseTime := baseTimeSettings()
|
|
baseRetention := baseLogRetention()
|
|
baseManagement := baseManagementAccess()
|
|
baseSyslog := baseRemoteSyslog()
|
|
baseTLS := baseTLSCertificate()
|
|
if metadata, metadataErr := tlsCertificates.Metadata(tlscert.FactoryCertificateID); metadataErr == nil {
|
|
baseTLS = tlsCertificateConfigFromMetadata(metadata)
|
|
}
|
|
baseExternalAuth := baseExternalAuth()
|
|
store := &Store{
|
|
clock: clock,
|
|
timeController: storeTimeController(options.TimeController),
|
|
ntpController: storeNTPController(options.TimeController),
|
|
networkController: storeNetworkController(options.NetworkController),
|
|
managementController: storeManagementController(options.ManagementController),
|
|
systemController: storeSystemController(options.SystemController),
|
|
alarmDeliveryController: storeAlarmDeliveryController(options.AlarmDeliveryController, clock),
|
|
snmpAgentController: storeSNMPAgentController(options.SNMPAgentController),
|
|
tlsCertificates: tlsCertificates,
|
|
diagnosticsRunner: options.DiagnosticsRunner,
|
|
supportBundleService: options.SupportBundleService,
|
|
externalAuthenticator: options.ExternalAuthenticator,
|
|
macResolver: options.MACResolver,
|
|
diagnosticsWorkers: normalizeBoundedOption(options.DiagnosticsWorkers, defaultDiagnosticsWorkers, maxDiagnosticsWorkers),
|
|
operations: make(map[string]domain.Operation),
|
|
logJournal: options.LogJournal,
|
|
allowSampleLogs: options.AllowSampleLogs,
|
|
journalLast: make(map[string]string),
|
|
journalSequence: make(map[string]uint64),
|
|
journalLocalSequences: make(map[string]map[uint64]struct{}),
|
|
auditEvents: make([]domain.AuditEvent, 0),
|
|
auditPath: options.AuditPath,
|
|
auditMax: options.AuditMaxEvents,
|
|
configPath: options.ConfigPath,
|
|
operationPath: options.OperationPath,
|
|
operationMax: normalizePositiveOption(options.OperationMaxEvents, defaultOperationMaxEvents),
|
|
authPath: options.AuthPath,
|
|
sessionPath: options.SessionPath,
|
|
loginFailurePath: options.LoginFailurePath,
|
|
secrets: secrets,
|
|
systemLogPath: options.SystemLogPath,
|
|
systemLogMax: normalizePositiveOption(options.SystemLogMaxEvents, defaultPersistentLogMaxEvents),
|
|
portLogPath: options.PortLogPath,
|
|
portLogMax: normalizePositiveOption(options.PortLogMaxEvents, defaultPersistentLogMaxEvents),
|
|
linkLogPath: options.LinkLogPath,
|
|
linkLogMax: normalizePositiveOption(options.LinkLogMaxEvents, defaultPersistentLogMaxEvents),
|
|
linkTracker: logpolicy.NewLinkTracker(options.LinkSnapshotInterval),
|
|
authMaxSessions: normalizePositiveOption(options.AuthMaxSessions, defaultAuthMaxSessions),
|
|
authMaxTokens: normalizePositiveOption(options.AuthMaxTokens, defaultAuthMaxTokens),
|
|
authMaxLoginFailures: normalizePositiveOption(options.AuthMaxLoginFailures, defaultAuthMaxLoginFailures),
|
|
authMaxUsers: normalizePositiveOption(options.AuthMaxUsers, defaultAuthMaxUsers),
|
|
minimumPasswordLength: normalizeMinimumPasswordLength(options.MinimumPasswordLength),
|
|
passwordKDFs: passwordKDFs,
|
|
passwordKDF: passwordKDF,
|
|
authUsers: authUsers,
|
|
authGroupOverrides: make(map[string]authGroupRecord),
|
|
authTokens: make(map[string]authTokenRecord),
|
|
addressFilters: domain.AddressFilterSettings{Mode: "blacklist", Rules: []domain.AddressFilterRule{}},
|
|
sessions: make(map[string]sessionRecord),
|
|
loginFailures: make(map[string]loginFailureRecord),
|
|
direct: make(map[string]directState),
|
|
directSessions: make(map[string]directSessionRecord),
|
|
directTickets: directstream.NewTicketStore(directstream.TicketStoreOptions{Clock: func() time.Time { return clock() }}),
|
|
running: clonePortConfig(base),
|
|
persistent: clonePortConfig(base),
|
|
pending: make(map[string]portConfig),
|
|
runningAlarms: cloneAlarmRuleConfig(baseAlarms),
|
|
persistentAlarms: cloneAlarmRuleConfig(baseAlarms),
|
|
pendingAlarms: make(map[string]alarmRuleConfig),
|
|
runningAlarmDelivery: cloneAlarmDeliveryPolicy(baseAlarmDelivery),
|
|
persistentAlarmDelivery: cloneAlarmDeliveryPolicy(baseAlarmDelivery),
|
|
runningEmail: cloneEmailNotification(baseEmail),
|
|
persistentEmail: cloneEmailNotification(baseEmail),
|
|
runningSNMPAgent: cloneSNMPAgent(baseSNMPAgent),
|
|
persistentSNMPAgent: cloneSNMPAgent(baseSNMPAgent),
|
|
runningSNMPTraps: cloneSNMPTrapTargets(baseSNMPTraps),
|
|
persistentSNMPTraps: cloneSNMPTrapTargets(baseSNMPTraps),
|
|
runningNetwork: cloneNetworkConfig(baseNetwork),
|
|
persistentNetwork: cloneNetworkConfig(baseNetwork),
|
|
pendingNetwork: make(map[string]networkInterfaceConfig),
|
|
runningTime: cloneTimeSettings(baseTime),
|
|
persistentTime: cloneTimeSettings(baseTime),
|
|
runningRetention: cloneLogRetention(baseRetention),
|
|
persistentRetention: cloneLogRetention(baseRetention),
|
|
runningManagement: cloneManagementAccess(baseManagement),
|
|
persistentManagement: cloneManagementAccess(baseManagement),
|
|
runningSyslog: cloneRemoteSyslog(baseSyslog),
|
|
persistentSyslog: cloneRemoteSyslog(baseSyslog),
|
|
runningTLS: cloneTLSCertificate(baseTLS),
|
|
persistentTLS: cloneTLSCertificate(baseTLS),
|
|
runningExternalAuth: cloneExternalAuth(baseExternalAuth),
|
|
persistentExternalAuth: cloneExternalAuth(baseExternalAuth),
|
|
runningRevision: 1,
|
|
persistentRevision: 1,
|
|
}
|
|
if store.externalAuthenticator == nil {
|
|
store.externalAuthenticator = externalauth.Client{}
|
|
}
|
|
if store.macResolver == nil {
|
|
store.macResolver = resolveARP
|
|
}
|
|
if options.LogJournal != nil && (options.AuditPath != "" || options.SystemLogPath != "" || options.PortLogPath != "" || options.LinkLogPath != "") {
|
|
return nil, errors.New("production log journal cannot be combined with prototype JSONL log paths")
|
|
}
|
|
if options.ConfigPath != "" {
|
|
if err := store.loadConfigStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistConfigStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.OperationPath != "" {
|
|
if err := store.loadOperationStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistOperationStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.AuthPath != "" {
|
|
if err := store.loadAuthStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistAuthStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.SessionPath != "" {
|
|
if err := store.loadSessionStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistSessionStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.LoginFailurePath != "" {
|
|
if err := store.loadLoginFailureStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistLoginFailureStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.RemoveLegacyAuthUsers {
|
|
store.removeLegacyAuthUsersLocked()
|
|
if err := store.persistAuthStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistSessionStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistLoginFailureStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.LogJournal != nil {
|
|
if err := store.applyJournalRetentionLocked(store.runningRetention); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.loadJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if options.AuditPath != "" {
|
|
if err := store.loadAuditLog(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.persistAuditLogLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.SystemLogPath != "" {
|
|
if err := store.loadSystemLogStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.PortLogPath != "" {
|
|
if err := store.loadPortLogStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if options.LinkLogPath != "" {
|
|
if err := store.loadLinkLogStore(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if store.diagnosticsRunner != nil || store.supportBundleService != nil {
|
|
store.startDiagnosticsExecutor(normalizeBoundedOption(options.DiagnosticsQueue, defaultDiagnosticsQueue, maxDiagnosticsQueue))
|
|
store.mu.Lock()
|
|
store.recoverInterruptedDiagnosticsLocked("diagnostic operation interrupted by service restart")
|
|
if err := store.persistOperationStoreLocked(); err != nil {
|
|
store.mu.Unlock()
|
|
store.Close()
|
|
return nil, err
|
|
}
|
|
store.mu.Unlock()
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func storeTimeController(controller systemtime.Controller) systemtime.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return systemtime.MockController{}
|
|
}
|
|
|
|
func storeNTPController(controller systemtime.Controller) systemtime.NTPController {
|
|
if ntpController, ok := controller.(systemtime.NTPController); ok {
|
|
return ntpController
|
|
}
|
|
if controller == nil {
|
|
return systemtime.MockController{}
|
|
}
|
|
return systemtime.UnavailableController{Reason: "tera-timed NTP control is not configured"}
|
|
}
|
|
|
|
func storeNetworkController(controller networkconfig.Controller) networkconfig.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return &networkconfig.MockController{}
|
|
}
|
|
|
|
func storeManagementController(controller managementaccess.Controller) managementaccess.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return managementaccess.NoopController{}
|
|
}
|
|
|
|
func storeSystemController(controller systemcontrol.Controller) systemcontrol.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return systemcontrol.MockController{}
|
|
}
|
|
|
|
func storeAlarmDeliveryController(controller alarmdelivery.Controller, clock Clock) alarmdelivery.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return alarmdelivery.MockController{Clock: func() time.Time { return clock() }}
|
|
}
|
|
|
|
func storeSNMPAgentController(controller snmpagent.Controller) snmpagent.Controller {
|
|
if controller != nil {
|
|
return controller
|
|
}
|
|
return &snmpagent.MockController{}
|
|
}
|
|
|
|
func (s *Store) Login(ctx context.Context, request domain.AuthLoginRequest) (domain.AuthSession, error) {
|
|
username := normalizeUsername(request.Username)
|
|
now := s.clock().UTC()
|
|
if username == "" || strings.TrimSpace(request.Password) == "" {
|
|
s.mu.Lock()
|
|
s.recordLoginFailureLocked(username, now)
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: auditActorForUsername(username),
|
|
Action: "auth.login",
|
|
Target: auditActorForUsername(username),
|
|
Result: "authentication_failed",
|
|
Message: "login rejected",
|
|
}))
|
|
persistErr := s.persistLoginFailureStoreLocked()
|
|
forward := s.remoteSyslogRequestLocked("system", fmt.Sprintf("login rejected for %s", auditActorForUsername(username)))
|
|
s.mu.Unlock()
|
|
if persistErr == nil {
|
|
s.deliverBestEffort(ctx, forward)
|
|
}
|
|
if persistErr != nil {
|
|
return domain.AuthSession{}, persistErr
|
|
}
|
|
return domain.AuthSession{}, fmt.Errorf("authentication failed")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
locked, changed := s.loginLockedLocked(username, now)
|
|
if changed {
|
|
if err := s.persistLoginFailureStoreLocked(); err != nil {
|
|
s.mu.Unlock()
|
|
return domain.AuthSession{}, err
|
|
}
|
|
}
|
|
if locked {
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: auditActorForUsername(username),
|
|
Action: "auth.login",
|
|
Target: auditActorForUsername(username),
|
|
Result: "authentication_locked",
|
|
Message: "login temporarily locked",
|
|
}))
|
|
forward := s.remoteSyslogRequestLocked("system", fmt.Sprintf("login temporarily locked for %s", auditActorForUsername(username)))
|
|
s.mu.Unlock()
|
|
s.deliverBestEffort(ctx, forward)
|
|
return domain.AuthSession{}, fmt.Errorf("authentication failed")
|
|
}
|
|
externalConfig := cloneExternalAuth(s.runningExternalAuth)
|
|
s.mu.Unlock()
|
|
|
|
externalActor, externalVerified, externalAttempted := s.authenticateExternal(ctx, externalConfig, username, request.Password)
|
|
s.mu.Lock()
|
|
var loginAlarmRequests []alarmdelivery.Request
|
|
defer func() {
|
|
s.mu.Unlock()
|
|
for index := range loginAlarmRequests {
|
|
s.deliverBestEffort(ctx, &loginAlarmRequests[index])
|
|
}
|
|
}()
|
|
user, ok := s.authUsers[username]
|
|
verified := false
|
|
if externalVerified {
|
|
user = authUserRecord{Username: username, Actor: externalActor.Name, Role: externalActor.Role, Permissions: sortedPermissions(externalActor.Permissions), Groups: []string{groupIDForRole(externalActor.Role)}, PortAccess: clonePortAccess(externalActor.PortAccess)}
|
|
ok, verified = true, true
|
|
} else if !externalAttempted || externalConfig.LocalFallback {
|
|
if ok {
|
|
var err error
|
|
verified, err = s.passwordKDFs.Verify(user.Password, request.Password)
|
|
if err != nil {
|
|
verified = false
|
|
}
|
|
}
|
|
}
|
|
if !ok || user.Disabled || !verified {
|
|
locked := s.recordLoginFailureLocked(username, now)
|
|
message := "login rejected"
|
|
if locked {
|
|
message = "login rejected; temporary lock applied"
|
|
loginAlarmRequests = s.alarmRequestsForRulesLocked([]string{"auth_failures"}, fmt.Sprintf("authentication failure threshold reached for %s", auditActorForUsername(username)))
|
|
}
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: auditActorForUsername(username),
|
|
Action: "auth.login",
|
|
Target: auditActorForUsername(username),
|
|
Result: "authentication_failed",
|
|
Message: message,
|
|
}))
|
|
if forward := s.remoteSyslogRequestLocked("system", fmt.Sprintf("%s for %s", message, auditActorForUsername(username))); forward != nil {
|
|
loginAlarmRequests = append(loginAlarmRequests, *forward)
|
|
}
|
|
if err := s.persistLoginFailureStoreLocked(); err != nil {
|
|
return domain.AuthSession{}, err
|
|
}
|
|
return domain.AuthSession{}, fmt.Errorf("authentication failed")
|
|
}
|
|
|
|
sessionToken, err := newSessionToken()
|
|
if err != nil {
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: user.Actor,
|
|
Action: "auth.login",
|
|
Target: username,
|
|
Result: "failed",
|
|
Message: "session token generation failed",
|
|
}))
|
|
return domain.AuthSession{}, err
|
|
}
|
|
actor := s.actorFromAuthUserLocked(user)
|
|
if _, ok := s.loginFailures[username]; ok {
|
|
delete(s.loginFailures, username)
|
|
if err := s.persistLoginFailureStoreLocked(); err != nil {
|
|
return domain.AuthSession{}, err
|
|
}
|
|
}
|
|
s.pruneExpiredSessionsLocked(now)
|
|
s.evictOldestSessionsForLimitLocked(ctx)
|
|
expiresAt := now.Add(8 * time.Hour)
|
|
s.sessionSeq++
|
|
sessionID := fmt.Sprintf("session_%06d", s.sessionSeq)
|
|
s.sessions[sessionTokenHash(sessionToken)] = sessionRecord{
|
|
SessionID: sessionID,
|
|
Actor: actor,
|
|
CreatedAt: now,
|
|
ExpiresAt: expiresAt,
|
|
Persistent: true,
|
|
}
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: actor.Name,
|
|
Action: "auth.login",
|
|
Target: username,
|
|
Result: "succeeded",
|
|
Message: "login accepted",
|
|
}))
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return domain.AuthSession{}, err
|
|
}
|
|
|
|
return domain.AuthSession{
|
|
SessionToken: sessionToken,
|
|
Actor: actor.Name,
|
|
Role: actor.Role,
|
|
Permissions: sortedPermissions(actor.Permissions),
|
|
Prototype: true,
|
|
ExpiresAt: expiresAt,
|
|
Persistent: true,
|
|
PortPermissions: portPermissionsFromMap(actor.PortAccess),
|
|
MustChangePassword: actor.MustChangePassword,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) authenticateExternal(ctx context.Context, config externalAuthConfig, username, credential string) (domain.Actor, bool, bool) {
|
|
if !config.Enabled || config.PrimaryMethod == "local" {
|
|
return domain.Actor{}, false, false
|
|
}
|
|
return s.authenticateExternalMethod(ctx, config, config.PrimaryMethod, username, credential)
|
|
}
|
|
|
|
func (s *Store) authenticateExternalMethod(ctx context.Context, config externalAuthConfig, method, username, credential string) (domain.Actor, bool, bool) {
|
|
method = strings.ToLower(strings.TrimSpace(method))
|
|
if !config.Enabled || (method != "radius" && method != "tacacs") {
|
|
return domain.Actor{}, false, false
|
|
}
|
|
servers, kind := config.RadiusServers, "radius"
|
|
if method == "tacacs" {
|
|
servers, kind = config.TACACSServers, "tacacs"
|
|
}
|
|
attempted := false
|
|
for _, server := range servers {
|
|
if !server.Enabled {
|
|
continue
|
|
}
|
|
secret, ok := s.secrets.Get(externalAuthSecretKey("running", kind, server.ServerID))
|
|
if !ok {
|
|
continue
|
|
}
|
|
attempted = true
|
|
accepted, err := s.externalAuthenticator.Authenticate(ctx, kind, externalauth.Server{Host: server.Host, Port: server.Port, Secret: string(secret)}, username, credential, time.Duration(config.TimeoutSeconds)*time.Second)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !accepted {
|
|
return domain.Actor{}, false, true
|
|
}
|
|
role := normalizeRole(config.DefaultRole)
|
|
if role == "" {
|
|
role = "viewer"
|
|
}
|
|
return domain.Actor{Name: "external-" + username, Role: role, Permissions: permissionsMap(permissionsForRole(role)), PortAccess: defaultPortAccessForRole(role)}, true, true
|
|
}
|
|
return domain.Actor{}, false, attempted || config.Enabled
|
|
}
|
|
|
|
// AuthenticatePort applies the authentication method selected for a reverse
|
|
// serial listener. It intentionally does not create a Web/API session.
|
|
func (s *Store) AuthenticatePort(ctx context.Context, method, username, credential, channel string) (string, error) {
|
|
method = strings.ToLower(strings.TrimSpace(method))
|
|
username = normalizeUsername(username)
|
|
if username == "" || strings.TrimSpace(credential) == "" {
|
|
return "", fmt.Errorf("authentication failed")
|
|
}
|
|
|
|
var actor domain.Actor
|
|
switch method {
|
|
case "local":
|
|
s.mu.Lock()
|
|
user, ok := s.authUsers[username]
|
|
verified := false
|
|
if ok && !user.Disabled {
|
|
verified, _ = s.passwordKDFs.Verify(user.Password, credential)
|
|
}
|
|
if ok && verified && !user.Disabled {
|
|
actor = s.actorFromAuthUserLocked(user)
|
|
}
|
|
s.mu.Unlock()
|
|
if actor.Name == "" {
|
|
return "", fmt.Errorf("authentication failed")
|
|
}
|
|
case "radius", "tacacs":
|
|
s.mu.Lock()
|
|
config := cloneExternalAuth(s.runningExternalAuth)
|
|
s.mu.Unlock()
|
|
var verified bool
|
|
actor, verified, _ = s.authenticateExternalMethod(ctx, config, method, username, credential)
|
|
if !verified {
|
|
return "", fmt.Errorf("authentication failed")
|
|
}
|
|
default:
|
|
return "", fmt.Errorf("authentication must be local, radius, or tacacs")
|
|
}
|
|
if actor.MustChangePassword {
|
|
return "", fmt.Errorf("password change is required before serial access")
|
|
}
|
|
if !actor.Permissions["direct.write"] {
|
|
return "", fmt.Errorf("user is not authorized for serial channel %s", channel)
|
|
}
|
|
if err := s.AuthorizePort(ctx, actor, channel, "write"); err != nil {
|
|
return "", err
|
|
}
|
|
return actor.Name, nil
|
|
}
|
|
|
|
func (s *Store) ActorBySession(ctx context.Context, sessionToken string) (domain.Actor, error) {
|
|
token := strings.TrimSpace(sessionToken)
|
|
if token == "" {
|
|
return domain.Actor{}, fmt.Errorf("session token required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
tokenHash := sessionTokenHash(token)
|
|
session, ok := s.sessions[tokenHash]
|
|
if ok {
|
|
if !session.Persistent && !s.clock().UTC().Before(session.ExpiresAt) {
|
|
delete(s.sessions, tokenHash)
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return domain.Actor{}, err
|
|
}
|
|
return domain.Actor{}, fmt.Errorf("session token expired")
|
|
}
|
|
return session.Actor, nil
|
|
}
|
|
|
|
apiToken, ok := s.authTokens[tokenHash]
|
|
if !ok {
|
|
return domain.Actor{}, fmt.Errorf("session token not found")
|
|
}
|
|
now := s.clock().UTC()
|
|
if apiToken.ExpiresAt != nil && !now.Before(*apiToken.ExpiresAt) {
|
|
delete(s.authTokens, tokenHash)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.Actor{}, err
|
|
}
|
|
return domain.Actor{}, fmt.Errorf("api token expired")
|
|
}
|
|
apiToken.LastUsedAt = &now
|
|
s.authTokens[tokenHash] = apiToken
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.Actor{}, err
|
|
}
|
|
return authTokenActor(apiToken), nil
|
|
}
|
|
|
|
func (s *Store) AuthTokens(ctx context.Context) ([]domain.AuthTokenInfo, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := s.clock().UTC()
|
|
if s.pruneExpiredAuthTokensLocked(now) {
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
tokens := make([]domain.AuthTokenInfo, 0, len(s.authTokens))
|
|
for _, token := range s.authTokens {
|
|
tokens = append(tokens, authTokenInfo(token))
|
|
}
|
|
sort.Slice(tokens, func(left, right int) bool {
|
|
return tokens[left].TokenID < tokens[right].TokenID
|
|
})
|
|
return tokens, nil
|
|
}
|
|
|
|
func (s *Store) CreateAuthToken(ctx context.Context, request domain.AuthTokenCreateRequest) (domain.AuthTokenCreateResult, error) {
|
|
name := strings.TrimSpace(request.Name)
|
|
username := normalizeUsername(request.Username)
|
|
now := s.clock().UTC()
|
|
|
|
if name == "" {
|
|
err := fmt.Errorf("token name must not be empty")
|
|
return domain.AuthTokenCreateResult{}, s.rejectOperation(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
if len(name) > 80 {
|
|
err := fmt.Errorf("token name must be at most 80 characters")
|
|
return domain.AuthTokenCreateResult{}, s.rejectOperation(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
if request.ExpiresAt != nil {
|
|
expiresAt := request.ExpiresAt.UTC()
|
|
if !expiresAt.After(now) {
|
|
err := fmt.Errorf("token expires_at must be in the future")
|
|
return domain.AuthTokenCreateResult{}, s.rejectOperation(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
if expiresAt.After(now.Add(365 * 24 * time.Hour)) {
|
|
err := fmt.Errorf("token expires_at must be within 365 days")
|
|
return domain.AuthTokenCreateResult{}, s.rejectOperation(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
request.ExpiresAt = &expiresAt
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if username == "" {
|
|
actorName := domain.ActorName(ctx)
|
|
for candidate, user := range s.authUsers {
|
|
if user.Actor == actorName {
|
|
username = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if username == "" {
|
|
err := fmt.Errorf("username must not be empty")
|
|
return domain.AuthTokenCreateResult{}, s.rejectCommandLocked(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
if len(s.authTokens) >= s.authMaxTokens {
|
|
err := fmt.Errorf("auth token limit reached")
|
|
return domain.AuthTokenCreateResult{}, s.rejectCommandLocked(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
user, ok := s.authUsers[username]
|
|
if !ok {
|
|
err := fmt.Errorf("auth user not found")
|
|
return domain.AuthTokenCreateResult{}, s.rejectCommandLocked(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
if user.Disabled {
|
|
err := fmt.Errorf("auth user is disabled")
|
|
return domain.AuthTokenCreateResult{}, s.rejectCommandLocked(ctx, "auth.token.create", "auth", err)
|
|
}
|
|
rawToken, err := newSessionToken()
|
|
if err != nil {
|
|
return domain.AuthTokenCreateResult{}, err
|
|
}
|
|
actor := s.actorFromAuthUserLocked(user)
|
|
s.authTokenSeq++
|
|
tokenID := fmt.Sprintf("token_%06d", s.authTokenSeq)
|
|
record := authTokenRecord{
|
|
TokenID: tokenID,
|
|
Name: name,
|
|
Actor: actor.Name,
|
|
Role: actor.Role,
|
|
Permissions: sortedPermissions(actor.Permissions),
|
|
PortAccess: clonePortAccess(actor.PortAccess),
|
|
MustChangePassword: actor.MustChangePassword,
|
|
CreatedAt: now,
|
|
ExpiresAt: request.ExpiresAt,
|
|
}
|
|
s.authTokens[sessionTokenHash(rawToken)] = record
|
|
s.recordAuditLocked(ctx, "auth.token.create", tokenID, "succeeded", "api token created for "+record.Actor)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthTokenCreateResult{}, err
|
|
}
|
|
return domain.AuthTokenCreateResult{
|
|
TokenID: tokenID,
|
|
Token: rawToken,
|
|
Name: record.Name,
|
|
Actor: record.Actor,
|
|
Role: record.Role,
|
|
CreatedAt: record.CreatedAt,
|
|
ExpiresAt: record.ExpiresAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) RevokeAuthToken(ctx context.Context, tokenID string) (domain.CommandResult, error) {
|
|
normalized := strings.TrimSpace(tokenID)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("token_id must not be empty")
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.token.revoke", "auth", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for tokenHash, token := range s.authTokens {
|
|
if token.TokenID != normalized {
|
|
continue
|
|
}
|
|
delete(s.authTokens, tokenHash)
|
|
message := "api token revoked"
|
|
auditID := s.recordAuditLocked(ctx, "auth.token.revoke", token.TokenID, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("auth_token_revoked", message, auditID, nil), nil
|
|
}
|
|
err := fmt.Errorf("token not found")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.token.revoke", normalized, err)
|
|
}
|
|
|
|
func (s *Store) pruneExpiredAuthTokensLocked(now time.Time) bool {
|
|
changed := false
|
|
for tokenHash, token := range s.authTokens {
|
|
if token.ExpiresAt != nil && !now.Before(*token.ExpiresAt) {
|
|
delete(s.authTokens, tokenHash)
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Store) revokeAuthTokensForActorLocked(actorName string) bool {
|
|
changed := false
|
|
for tokenHash, token := range s.authTokens {
|
|
if token.Actor == actorName {
|
|
delete(s.authTokens, tokenHash)
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Store) persistRevokedAuthAccessLocked(actorName string) error {
|
|
if s.revokeSessionsForActorLocked(actorName) {
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s.revokeAuthTokensForActorLocked(actorName) {
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Logout(ctx context.Context, sessionToken string) error {
|
|
token := strings.TrimSpace(sessionToken)
|
|
if token == "" {
|
|
return fmt.Errorf("session token required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
key := sessionTokenHash(token)
|
|
session, ok := s.sessions[key]
|
|
if !ok {
|
|
return fmt.Errorf("session token not found")
|
|
}
|
|
delete(s.sessions, key)
|
|
s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: session.Actor.Name,
|
|
Action: "auth.logout",
|
|
Target: session.Actor.Name,
|
|
Result: "succeeded",
|
|
Message: "session revoked",
|
|
}))
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) AuthSessions(ctx context.Context) ([]domain.AuthSessionInfo, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := s.clock().UTC()
|
|
if s.pruneExpiredSessionsLocked(now) {
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sessions := make([]domain.AuthSessionInfo, 0, len(s.sessions))
|
|
for _, session := range s.sessions {
|
|
sessions = append(sessions, authSessionInfo(session))
|
|
}
|
|
sort.Slice(sessions, func(left, right int) bool {
|
|
return sessions[left].SessionID < sessions[right].SessionID
|
|
})
|
|
return sessions, nil
|
|
}
|
|
|
|
func (s *Store) pruneExpiredSessionsLocked(now time.Time) bool {
|
|
changed := false
|
|
for tokenHash, session := range s.sessions {
|
|
if !session.Persistent && !now.Before(session.ExpiresAt) {
|
|
delete(s.sessions, tokenHash)
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Store) evictOldestSessionsForLimitLocked(ctx context.Context) bool {
|
|
changed := false
|
|
for s.authMaxSessions > 0 && len(s.sessions) >= s.authMaxSessions {
|
|
oldestKey := ""
|
|
var oldest sessionRecord
|
|
for tokenHash, session := range s.sessions {
|
|
if oldestKey == "" || session.CreatedAt.Before(oldest.CreatedAt) || (session.CreatedAt.Equal(oldest.CreatedAt) && session.SessionID < oldest.SessionID) {
|
|
oldestKey = tokenHash
|
|
oldest = session
|
|
}
|
|
}
|
|
if oldestKey == "" {
|
|
return changed
|
|
}
|
|
delete(s.sessions, oldestKey)
|
|
changed = true
|
|
s.recordAuditLocked(ctx, "auth.session.evict", oldest.Actor.Name, "succeeded", fmt.Sprintf("oldest session evicted at auth session cap %d", s.authMaxSessions))
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (s *Store) RevokeAuthSession(ctx context.Context, sessionID string) (domain.CommandResult, error) {
|
|
normalized := strings.TrimSpace(sessionID)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("session_id must not be empty")
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.session.revoke", "auth", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for tokenHash, session := range s.sessions {
|
|
if session.SessionID != normalized {
|
|
continue
|
|
}
|
|
delete(s.sessions, tokenHash)
|
|
message := "session revoked by administrator"
|
|
auditID := s.recordAuditLocked(ctx, "auth.session.revoke", session.Actor.Name, "succeeded", message)
|
|
if err := s.persistSessionStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("auth_session_revoked", message, auditID, nil), nil
|
|
}
|
|
err := fmt.Errorf("session not found")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.session.revoke", normalized, err)
|
|
}
|
|
|
|
func (s *Store) AuthUsers(ctx context.Context) ([]domain.AuthUser, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
usernames := sortedAuthUsernames(s.authUsers)
|
|
users := make([]domain.AuthUser, 0, len(usernames))
|
|
for _, username := range usernames {
|
|
user := s.authUsers[username]
|
|
users = append(users, s.authUserSummaryLocked(user))
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
// removeLegacyAuthUsersLocked removes the prototype accounts that shipped in
|
|
// early validation images. The real administrator account is preserved, while
|
|
// related tokens, sessions and login-failure state are revoked as part of the
|
|
// same startup migration.
|
|
func (s *Store) removeLegacyAuthUsersLocked() {
|
|
legacy := map[string]string{
|
|
"port-operator": "mock-port-operator",
|
|
"viewer": "mock-viewer",
|
|
}
|
|
removedActors := make(map[string]struct{}, len(legacy))
|
|
for username, actor := range legacy {
|
|
user, exists := s.authUsers[username]
|
|
if !exists || user.Actor != actor {
|
|
continue
|
|
}
|
|
delete(s.authUsers, username)
|
|
delete(s.loginFailures, username)
|
|
removedActors[actor] = struct{}{}
|
|
}
|
|
if len(removedActors) == 0 {
|
|
return
|
|
}
|
|
for tokenHash, token := range s.authTokens {
|
|
if _, removed := removedActors[token.Actor]; removed {
|
|
delete(s.authTokens, tokenHash)
|
|
}
|
|
}
|
|
for tokenHash, session := range s.sessions {
|
|
if _, removed := removedActors[session.Actor.Name]; removed {
|
|
delete(s.sessions, tokenHash)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) AuthGroups(ctx context.Context) ([]domain.AuthGroup, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.authGroupsLocked(), nil
|
|
}
|
|
|
|
func (s *Store) AuthPermissions(ctx context.Context) ([]domain.AuthPermission, error) {
|
|
permissions := baseAuthPermissions()
|
|
result := make([]domain.AuthPermission, len(permissions))
|
|
copy(result, permissions)
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) AuthorizePort(ctx context.Context, actor domain.Actor, channel string, access string) error {
|
|
channel = strings.ToUpper(strings.TrimSpace(channel))
|
|
if _, err := parseChannel(channel); err != nil {
|
|
return err
|
|
}
|
|
required := portAccessRank(strings.ToLower(strings.TrimSpace(access)))
|
|
if required == 0 {
|
|
return fmt.Errorf("port access must be read or write")
|
|
}
|
|
granted := actor.PortAccess[channel]
|
|
if granted == "" && actor.PortAccess == nil {
|
|
// Mock header actors and older persisted sessions predate per-port policy.
|
|
// Their established role remains the safe migration default.
|
|
granted = defaultPortAccessForRole(actor.Role)[channel]
|
|
}
|
|
if portAccessRank(granted) < required {
|
|
return fmt.Errorf("actor is not authorized for %s %s access", channel, access)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) validateAuthGroupIDsLocked(groupIDs []string) error {
|
|
known := baseAuthGroups()
|
|
for groupID := range s.authGroupOverrides {
|
|
known[groupID] = domain.AuthGroup{}
|
|
}
|
|
for _, groupID := range groupIDs {
|
|
if _, ok := known[groupID]; !ok {
|
|
return fmt.Errorf("auth group %s not found", groupID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) validateAuthGroupMembershipStoreLocked() error {
|
|
for username, user := range s.authUsers {
|
|
if err := s.validateAuthGroupIDsLocked(user.Groups); err != nil {
|
|
return fmt.Errorf("auth user %s: %w", username, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) revokeUsersInGroupLocked(groupID string) {
|
|
actors := make(map[string]struct{})
|
|
for _, user := range s.authUsers {
|
|
if containsString(user.Groups, groupID) {
|
|
actors[user.Actor] = struct{}{}
|
|
}
|
|
}
|
|
for tokenHash, session := range s.sessions {
|
|
if _, ok := actors[session.Actor.Name]; ok {
|
|
delete(s.sessions, tokenHash)
|
|
}
|
|
}
|
|
for tokenHash, token := range s.authTokens {
|
|
if _, ok := actors[token.Actor]; ok {
|
|
delete(s.authTokens, tokenHash)
|
|
}
|
|
}
|
|
_ = s.persistSessionStoreLocked()
|
|
}
|
|
|
|
func (s *Store) ExternalAuth(ctx context.Context) (domain.ExternalAuthSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
settings := makeExternalAuthSettings(s.runningExternalAuth)
|
|
// The persisted marker can outlive the protected value on older firmware.
|
|
// Report actual availability so the UI prompts for a missing shared secret.
|
|
for index := range settings.RadiusServers {
|
|
server := &settings.RadiusServers[index]
|
|
server.SecretStored = s.secrets.Has(externalAuthSecretKey("running", "radius", server.ServerID))
|
|
}
|
|
for index := range settings.TACACSServers {
|
|
server := &settings.TACACSServers[index]
|
|
server.SecretStored = s.secrets.Has(externalAuthSecretKey("running", "tacacs", server.ServerID))
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *Store) AddressFilters(ctx context.Context) (domain.AddressFilterSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return cloneAddressFilters(s.addressFilters), nil
|
|
}
|
|
|
|
func (s *Store) UpdateAddressFilters(ctx context.Context, settings domain.AddressFilterSettings) (domain.CommandResult, error) {
|
|
next, err := normalizeAddressFilters(settings)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.address_filter.update", "address_filters", err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.addressFilters = next
|
|
auditID := s.recordAuditLocked(ctx, "auth.address_filter.update", "address_filters", "succeeded", "management address filter policy updated")
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("address_filters_updated", "management address filter policy updated", auditID, nil), nil
|
|
}
|
|
|
|
func (s *Store) AuthorizeClient(ctx context.Context, address, protocol string, destinationPort int) error {
|
|
ip, err := netip.ParseAddr(strings.TrimSpace(address))
|
|
if err != nil {
|
|
return fmt.Errorf("client IP address is invalid")
|
|
}
|
|
if ip.IsLoopback() {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
settings := cloneAddressFilters(s.addressFilters)
|
|
resolver := s.macResolver
|
|
s.mu.Unlock()
|
|
if !settings.Enabled {
|
|
return nil
|
|
}
|
|
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
|
matched := false
|
|
mac := ""
|
|
for _, rule := range settings.Rules {
|
|
if !rule.Enabled || (rule.Protocol != "all" && rule.Protocol != protocol) || (rule.DestinationPort != 0 && rule.DestinationPort != destinationPort) {
|
|
continue
|
|
}
|
|
switch rule.AddressType {
|
|
case "ipv4":
|
|
prefix, parseErr := parseIPv4Prefix(rule.Address)
|
|
if parseErr == nil && prefix.Contains(ip) {
|
|
matched = true
|
|
}
|
|
case "ipv6":
|
|
prefix, parseErr := parseIPv6Prefix(rule.Address)
|
|
if parseErr == nil && prefix.Contains(ip) {
|
|
matched = true
|
|
}
|
|
case "mac":
|
|
if mac == "" && resolver != nil {
|
|
mac, _ = resolver(ctx, ip)
|
|
}
|
|
if normalizeMAC(mac) == normalizeMAC(rule.Address) {
|
|
matched = true
|
|
}
|
|
}
|
|
if matched {
|
|
break
|
|
}
|
|
}
|
|
if (settings.Mode == "whitelist" && !matched) || (settings.Mode == "blacklist" && matched) {
|
|
return fmt.Errorf("client address is denied by management address filter")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpdateExternalAuth(ctx context.Context, update domain.ExternalAuthUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.Enabled == nil && update.PrimaryMethod == nil && update.LocalFallback == nil && update.TimeoutSeconds == nil && update.DefaultRole == nil && len(update.RadiusServers) == 0 && len(update.TACACSServers) == 0 {
|
|
err := fmt.Errorf("external auth update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
|
|
next := s.effectivePendingExternalAuthLocked()
|
|
if update.Enabled != nil {
|
|
next.Enabled = *update.Enabled
|
|
}
|
|
if update.PrimaryMethod != nil {
|
|
method, err := normalizeExternalAuthMethod(*update.PrimaryMethod)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
next.PrimaryMethod = method
|
|
}
|
|
if update.LocalFallback != nil {
|
|
next.LocalFallback = *update.LocalFallback
|
|
}
|
|
if update.TimeoutSeconds != nil {
|
|
next.TimeoutSeconds = *update.TimeoutSeconds
|
|
}
|
|
if update.DefaultRole != nil {
|
|
role := normalizeRole(*update.DefaultRole)
|
|
if role == "" {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", fmt.Errorf("external auth default_role must be administrator, port_operator, or viewer"))
|
|
}
|
|
next.DefaultRole = role
|
|
}
|
|
// Ver.20260507_01 permits Local, Local + RADIUS, and Local + TACACS+.
|
|
// Reject a non-local recovery path before preparing server secrets so an
|
|
// invalid request cannot leave orphaned pending credentials behind.
|
|
if next.Enabled && !next.LocalFallback {
|
|
err := fmt.Errorf("external auth requires local fallback")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
if len(update.RadiusServers) > 0 {
|
|
servers, err := s.prepareExternalAuthServersLocked(update.RadiusServers, next.RadiusServers, "radius")
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
next.RadiusServers = servers
|
|
}
|
|
if len(update.TACACSServers) > 0 {
|
|
servers, err := s.prepareExternalAuthServersLocked(update.TACACSServers, next.TACACSServers, "tacacs")
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
next.TACACSServers = servers
|
|
}
|
|
if err := validateExternalAuth(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
if err := s.stageExternalAuthSecretsLocked(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.external.update", "external_auth", err)
|
|
}
|
|
|
|
s.pendingExternalAuth = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "external auth policy update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "auth.external.update", "external_auth", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("external_auth_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdateAuthGroup(ctx context.Context, groupID string, request domain.AuthGroupUpdateRequest) (domain.AuthGroup, error) {
|
|
normalized := normalizeGroupID(groupID)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("auth group id must not be empty")
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", "auth", err)
|
|
}
|
|
if request.Name == nil && request.Role == nil && request.Permissions == nil && request.PortPermissions == nil {
|
|
err := fmt.Errorf("auth group name must be provided")
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", normalized, err)
|
|
}
|
|
name, role := "", ""
|
|
if request.Name != nil {
|
|
name = strings.TrimSpace(*request.Name)
|
|
if name == "" || len(name) > 80 {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", normalized, fmt.Errorf("auth group name must be between 1 and 80 characters"))
|
|
}
|
|
}
|
|
if request.Role != nil {
|
|
role = normalizeRole(*request.Role)
|
|
if role == "" {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", normalized, fmt.Errorf("role must be administrator, port_operator, or viewer"))
|
|
}
|
|
}
|
|
permissions := []string(nil)
|
|
if request.Permissions != nil {
|
|
var err error
|
|
permissions, err = normalizeFunctionPermissions(*request.Permissions)
|
|
if err != nil {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", normalized, err)
|
|
}
|
|
}
|
|
var portAccess map[string]string
|
|
if request.PortPermissions != nil {
|
|
var err error
|
|
portAccess, err = normalizePortPermissions(*request.PortPermissions)
|
|
if err != nil {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.update", normalized, err)
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
base, system := baseAuthGroups()[normalized]
|
|
current, custom := s.authGroupOverrides[normalized]
|
|
if !system && !custom {
|
|
err := fmt.Errorf("auth group not found")
|
|
return domain.AuthGroup{}, s.rejectCommandLocked(ctx, "auth.group.update", normalized, err)
|
|
}
|
|
if system && current.Name == "" {
|
|
current = authGroupRecord{Name: base.Name, Role: base.Role, Permissions: base.Permissions, PortAccess: portPermissionMap(base.PortPermissions), System: true}
|
|
}
|
|
if request.Name != nil {
|
|
current.Name = name
|
|
}
|
|
if request.Role != nil {
|
|
current.Role = role
|
|
}
|
|
if request.Permissions != nil {
|
|
current.Permissions = permissions
|
|
}
|
|
if request.PortPermissions != nil {
|
|
current.PortAccess = portAccess
|
|
}
|
|
current.System = system
|
|
s.authGroupOverrides[normalized] = current
|
|
s.revokeUsersInGroupLocked(normalized)
|
|
message := "local auth group updated"
|
|
s.recordAuditLocked(ctx, "auth.group.update", normalized, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthGroup{}, err
|
|
}
|
|
return s.authGroupLocked(normalized), nil
|
|
}
|
|
|
|
func (s *Store) CreateAuthGroup(ctx context.Context, request domain.AuthGroupCreateRequest) (domain.AuthGroup, error) {
|
|
groupID := normalizeGroupID(request.GroupID)
|
|
name := strings.TrimSpace(request.Name)
|
|
role := normalizeRole(request.Role)
|
|
if err := validateGroupID(groupID); err != nil {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.create", groupID, err)
|
|
}
|
|
if name == "" || len(name) > 80 {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.create", groupID, fmt.Errorf("auth group name must be between 1 and 80 characters"))
|
|
}
|
|
if role == "" {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.create", groupID, fmt.Errorf("role must be administrator, port_operator, or viewer"))
|
|
}
|
|
permissions, err := normalizeFunctionPermissions(request.Permissions)
|
|
if err != nil {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.create", groupID, err)
|
|
}
|
|
if request.Permissions == nil {
|
|
permissions = permissionsForRole(role)
|
|
}
|
|
portAccess, err := normalizePortPermissions(request.PortPermissions)
|
|
if err != nil {
|
|
return domain.AuthGroup{}, s.rejectOperation(ctx, "auth.group.create", groupID, err)
|
|
}
|
|
if request.PortPermissions == nil {
|
|
portAccess = defaultPortAccessForRole(role)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, exists := baseAuthGroups()[groupID]; exists {
|
|
return domain.AuthGroup{}, s.rejectCommandLocked(ctx, "auth.group.create", groupID, fmt.Errorf("auth group already exists"))
|
|
}
|
|
if _, exists := s.authGroupOverrides[groupID]; exists {
|
|
return domain.AuthGroup{}, s.rejectCommandLocked(ctx, "auth.group.create", groupID, fmt.Errorf("auth group already exists"))
|
|
}
|
|
if len(s.authGroupOverrides) >= 64 {
|
|
return domain.AuthGroup{}, s.rejectCommandLocked(ctx, "auth.group.create", groupID, fmt.Errorf("auth group limit reached"))
|
|
}
|
|
s.authGroupOverrides[groupID] = authGroupRecord{Name: name, Role: role, Permissions: permissions, PortAccess: portAccess}
|
|
s.recordAuditLocked(ctx, "auth.group.create", groupID, "succeeded", "local auth group created")
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthGroup{}, err
|
|
}
|
|
return s.authGroupLocked(groupID), nil
|
|
}
|
|
|
|
func (s *Store) DeleteAuthGroup(ctx context.Context, groupID string) (domain.CommandResult, error) {
|
|
normalized := normalizeGroupID(groupID)
|
|
if err := validateGroupID(normalized); err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.group.delete", normalized, err)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, system := baseAuthGroups()[normalized]; system {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.group.delete", normalized, fmt.Errorf("system auth group cannot be deleted"))
|
|
}
|
|
if _, exists := s.authGroupOverrides[normalized]; !exists {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.group.delete", normalized, fmt.Errorf("auth group not found"))
|
|
}
|
|
for _, user := range s.authUsers {
|
|
if containsString(user.Groups, normalized) {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.group.delete", normalized, fmt.Errorf("auth group still has members"))
|
|
}
|
|
}
|
|
delete(s.authGroupOverrides, normalized)
|
|
auditID := s.recordAuditLocked(ctx, "auth.group.delete", normalized, "succeeded", "local auth group deleted")
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("auth_group_deleted", "local auth group deleted", auditID, nil), nil
|
|
}
|
|
|
|
func (s *Store) CreateAuthUser(ctx context.Context, request domain.AuthUserCreateRequest) (domain.AuthUser, error) {
|
|
username := normalizeUsername(request.Username)
|
|
role := normalizeRole(request.Role)
|
|
passwordValue := request.Password
|
|
|
|
if username == "" {
|
|
err := fmt.Errorf("username must not be empty")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", "auth", err)
|
|
}
|
|
if err := validateUsername(username); err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", "auth", err)
|
|
}
|
|
if role == "" {
|
|
err := fmt.Errorf("role must be administrator, port_operator, or viewer")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", "auth", err)
|
|
}
|
|
if len(passwordValue) < s.minimumPasswordLength {
|
|
err := fmt.Errorf("password must be at least %d characters", s.minimumPasswordLength)
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", "auth", err)
|
|
}
|
|
groups, err := normalizeGroupIDs(request.Groups)
|
|
if err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", username, err)
|
|
}
|
|
portAccess, err := normalizePortPermissions(request.PortPermissions)
|
|
if err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.create", username, err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, exists := s.authUsers[username]; exists {
|
|
err := fmt.Errorf("auth user already exists")
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.create", username, err)
|
|
}
|
|
if len(s.authUsers) >= s.authMaxUsers {
|
|
err := fmt.Errorf("auth user limit reached")
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.create", "auth", err)
|
|
}
|
|
if len(groups) == 0 {
|
|
groups = []string{groupIDForRole(role)}
|
|
}
|
|
if err := s.validateAuthGroupIDsLocked(groups); err != nil {
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.create", username, err)
|
|
}
|
|
|
|
user, err := newAuthUser(username, "local-"+username, role, permissionsForRole(role), passwordValue, s.passwordKDF)
|
|
if err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
user.Groups = groups
|
|
user.PortAccess = portAccess
|
|
user.MustChangePassword = true
|
|
if request.ForceChange != nil {
|
|
user.MustChangePassword = *request.ForceChange
|
|
}
|
|
s.authUsers[username] = user
|
|
message := "local auth user created"
|
|
s.recordAuditLocked(ctx, "auth.user.create", username, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
return s.authUserSummaryLocked(user), nil
|
|
}
|
|
|
|
func (s *Store) UpdateAuthUser(ctx context.Context, username string, request domain.AuthUserUpdateRequest) (domain.AuthUser, error) {
|
|
normalized := normalizeUsername(username)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("username must not be empty")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", "auth", err)
|
|
}
|
|
if err := validateUsername(normalized); err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
if request.Enabled == nil && request.Role == nil && request.Groups == nil && request.PortPermissions == nil {
|
|
err := fmt.Errorf("enabled or role must be provided")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
role := ""
|
|
if request.Role != nil {
|
|
role = normalizeRole(*request.Role)
|
|
if role == "" {
|
|
err := fmt.Errorf("role must be administrator, port_operator, or viewer")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
}
|
|
var groups []string
|
|
var err error
|
|
if request.Groups != nil {
|
|
groups, err = normalizeGroupIDs(*request.Groups)
|
|
if err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
}
|
|
var portAccess map[string]string
|
|
if request.PortPermissions != nil {
|
|
portAccess, err = normalizePortPermissions(*request.PortPermissions)
|
|
if err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
user, ok := s.authUsers[normalized]
|
|
if !ok {
|
|
err := fmt.Errorf("auth user not found")
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
if request.Groups != nil {
|
|
if err := s.validateAuthGroupIDsLocked(groups); err != nil {
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
}
|
|
wasEnabledAdmin := user.Role == "administrator" && !user.Disabled
|
|
nextEnabled := !user.Disabled
|
|
if request.Enabled != nil {
|
|
nextEnabled = *request.Enabled
|
|
}
|
|
nextRole := user.Role
|
|
if request.Role != nil {
|
|
nextRole = role
|
|
}
|
|
if wasEnabledAdmin && (!nextEnabled || nextRole != "administrator") && s.enabledAdministratorCountLocked() <= 1 {
|
|
err := fmt.Errorf("cannot modify last enabled administrator")
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.update", normalized, err)
|
|
}
|
|
|
|
wasEnabled := !user.Disabled
|
|
roleChanged := false
|
|
if request.Role != nil && user.Role != role {
|
|
oldSystemGroup := groupIDForRole(user.Role)
|
|
user.Role = role
|
|
user.Permissions = permissionsForRole(role)
|
|
if request.Groups == nil {
|
|
user.Groups = replaceGroupID(user.Groups, oldSystemGroup, groupIDForRole(role))
|
|
}
|
|
roleChanged = true
|
|
}
|
|
if request.Groups != nil {
|
|
user.Groups = groups
|
|
}
|
|
if request.PortPermissions != nil {
|
|
user.PortAccess = portAccess
|
|
}
|
|
if request.Enabled != nil {
|
|
user.Disabled = !*request.Enabled
|
|
}
|
|
s.authUsers[normalized] = user
|
|
if (wasEnabled && user.Disabled) || roleChanged {
|
|
if err := s.persistRevokedAuthAccessLocked(user.Actor); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
}
|
|
|
|
message := "local auth user updated"
|
|
s.recordAuditLocked(ctx, "auth.user.update", normalized, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
return s.authUserSummaryLocked(user), nil
|
|
}
|
|
|
|
func (s *Store) UpdateAuthUserPassword(ctx context.Context, username string, request domain.AuthUserPasswordUpdateRequest) (domain.AuthUser, error) {
|
|
normalized := normalizeUsername(username)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("username must not be empty")
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.password.update", "auth", err)
|
|
}
|
|
if err := validateUsername(normalized); err != nil {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.password.update", normalized, err)
|
|
}
|
|
if len(request.Password) < s.minimumPasswordLength {
|
|
err := fmt.Errorf("password must be at least %d characters", s.minimumPasswordLength)
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.user.password.update", normalized, err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
user, ok := s.authUsers[normalized]
|
|
if !ok {
|
|
err := fmt.Errorf("auth user not found")
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.user.password.update", normalized, err)
|
|
}
|
|
salt, err := password.NewSalt()
|
|
if err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
record, err := s.passwordKDF.Hash(request.Password, salt)
|
|
if err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
user.Password = record
|
|
forceChange := true
|
|
if request.ForceChange != nil {
|
|
forceChange = *request.ForceChange
|
|
}
|
|
user.MustChangePassword = forceChange
|
|
s.authUsers[normalized] = user
|
|
if err := s.persistRevokedAuthAccessLocked(user.Actor); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
|
|
message := "local auth user password updated"
|
|
s.recordAuditLocked(ctx, "auth.user.password.update", normalized, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
return s.authUserSummaryLocked(user), nil
|
|
}
|
|
|
|
func (s *Store) ChangeOwnPassword(ctx context.Context, request domain.AuthSelfPasswordUpdateRequest) (domain.AuthUser, error) {
|
|
if request.NewPassword != request.ConfirmPassword {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.password.change", "self", fmt.Errorf("new password confirmation does not match"))
|
|
}
|
|
if len(request.NewPassword) < s.minimumPasswordLength {
|
|
return domain.AuthUser{}, s.rejectOperation(ctx, "auth.password.change", "self", fmt.Errorf("password must be at least %d characters", s.minimumPasswordLength))
|
|
}
|
|
actor, ok := domain.ActorFromContext(ctx)
|
|
if !ok || actor.Name == "" {
|
|
return domain.AuthUser{}, fmt.Errorf("authentication required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
username := ""
|
|
var user authUserRecord
|
|
for candidate, item := range s.authUsers {
|
|
if item.Actor == actor.Name {
|
|
username, user = candidate, item
|
|
break
|
|
}
|
|
}
|
|
if username == "" {
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.password.change", "self", fmt.Errorf("local auth user not found"))
|
|
}
|
|
verified, err := s.passwordKDFs.Verify(user.Password, request.CurrentPassword)
|
|
if err != nil || !verified {
|
|
return domain.AuthUser{}, s.rejectCommandLocked(ctx, "auth.password.change", username, fmt.Errorf("current password is incorrect"))
|
|
}
|
|
salt, err := password.NewSalt()
|
|
if err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
record, err := s.passwordKDF.Hash(request.NewPassword, salt)
|
|
if err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
user.Password = record
|
|
user.MustChangePassword = false
|
|
s.authUsers[username] = user
|
|
if err := s.persistRevokedAuthAccessLocked(user.Actor); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
s.recordAuditLocked(ctx, "auth.password.change", username, "succeeded", "local user changed own password")
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.AuthUser{}, err
|
|
}
|
|
return s.authUserSummaryLocked(user), nil
|
|
}
|
|
|
|
func (s *Store) DeleteAuthUser(ctx context.Context, username string) (domain.CommandResult, error) {
|
|
normalized := normalizeUsername(username)
|
|
if normalized == "" {
|
|
err := fmt.Errorf("username must not be empty")
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.user.delete", "auth", err)
|
|
}
|
|
if err := validateUsername(normalized); err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "auth.user.delete", normalized, err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
user, ok := s.authUsers[normalized]
|
|
if !ok {
|
|
err := fmt.Errorf("auth user not found")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.user.delete", normalized, err)
|
|
}
|
|
if user.Role == "administrator" && !user.Disabled && s.enabledAdministratorCountLocked() <= 1 {
|
|
err := fmt.Errorf("cannot delete last enabled administrator")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "auth.user.delete", normalized, err)
|
|
}
|
|
|
|
delete(s.authUsers, normalized)
|
|
if _, ok := s.loginFailures[normalized]; ok {
|
|
delete(s.loginFailures, normalized)
|
|
if err := s.persistLoginFailureStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
}
|
|
if err := s.persistRevokedAuthAccessLocked(user.Actor); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
message := "local auth user deleted"
|
|
auditID := s.recordAuditLocked(ctx, "auth.user.delete", normalized, "succeeded", message)
|
|
if err := s.persistAuthStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("auth_user_deleted", message, auditID, nil), nil
|
|
}
|
|
|
|
func (s *Store) SystemStatus(ctx context.Context) (domain.SystemStatus, error) {
|
|
status := systemStatus()
|
|
if s.logJournal != nil {
|
|
if snapshot, err := s.logJournal.Snapshot(); err == nil {
|
|
status.ProtectedLogUsagePercent = protectedLogUsagePercent(snapshot)
|
|
}
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func protectedLogUsagePercent(snapshot logstorage.Snapshot) int {
|
|
var used, capacity int64
|
|
for _, class := range []string{"audit", "system", "port"} {
|
|
used += snapshot.UsageBytes[class]
|
|
capacity += snapshot.CapBytes[class]
|
|
}
|
|
if capacity <= 0 {
|
|
return 0
|
|
}
|
|
return int((used*100 + capacity - 1) / capacity)
|
|
}
|
|
|
|
// EvaluateOperationalAlarms evaluates stateful alarms that are not tied to a
|
|
// single API request. It is safe to call periodically; storage pressure emits
|
|
// once per threshold crossing and rearms only after usage recovers.
|
|
func (s *Store) EvaluateOperationalAlarms(ctx context.Context) error {
|
|
if s.logJournal == nil {
|
|
return nil
|
|
}
|
|
snapshot, err := s.logJournal.Snapshot()
|
|
if err != nil {
|
|
return fmt.Errorf("read protected log storage usage: %w", err)
|
|
}
|
|
usagePercent := protectedLogUsagePercent(snapshot)
|
|
s.mu.Lock()
|
|
pressure := usagePercent >= s.runningRetention.ProtectedUsageWarningPct
|
|
requests := []alarmdelivery.Request(nil)
|
|
if pressure && !s.storagePressureActive {
|
|
message := fmt.Sprintf("protected log usage reached %d%% (warning threshold %d%%)", usagePercent, s.runningRetention.ProtectedUsageWarningPct)
|
|
requests = s.alarmRequestsForRulesLocked([]string{"storage_pressure"}, message)
|
|
}
|
|
s.storagePressureActive = pressure
|
|
s.mu.Unlock()
|
|
for index := range requests {
|
|
s.deliverBestEffort(ctx, &requests[index])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func systemStatus() domain.SystemStatus {
|
|
return domain.SystemStatus{
|
|
Model: "TERA CORA-X48 PRO K",
|
|
HardwareProfile: "fet3568j-c",
|
|
FirmwareVersion: "0.1.0-dev",
|
|
SerialPortCount: 48,
|
|
ActiveSessions: 0,
|
|
ActiveAlarms: 0,
|
|
ProtectedLogUsagePercent: 0,
|
|
OOB: oobStatus(baseNetworkConfig()["Interface-1"]),
|
|
}
|
|
}
|
|
|
|
func (s *Store) ServiceHealth(ctx context.Context) ([]domain.ServiceHealth, error) {
|
|
health := s.serviceHealth(s.clock().UTC())
|
|
s.probeSNMPAgentHealth(ctx, health)
|
|
return health, nil
|
|
}
|
|
|
|
func (s *Store) probeSNMPAgentHealth(ctx context.Context, services []domain.ServiceHealth) {
|
|
checker, ok := s.snmpAgentController.(snmpagent.HealthChecker)
|
|
if !ok {
|
|
return
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
defer cancel()
|
|
err := checker.Health(probeCtx)
|
|
for index := range services {
|
|
if services[index].Service != "tera-snmpd" {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "SNMP agent control service is not reachable"
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *Store) serviceHealth(now time.Time) []domain.ServiceHealth {
|
|
services := []domain.ServiceHealth{
|
|
{Service: "tera-api", Status: "ok", Detail: "management API and configuration store responding", LastCheckedAt: now},
|
|
{Service: "tera-web", Status: "ok", Detail: "static web console route configured", LastCheckedAt: now},
|
|
{Service: "tera-authd", Status: "ok", Detail: "local auth policy loaded", LastCheckedAt: now},
|
|
{Service: "tera-sessiond", Status: "degraded", Detail: "session broker health adapter unavailable", LastCheckedAt: now},
|
|
{Service: "tera-logd", Status: "degraded", Detail: "authoritative log service health unavailable", LastCheckedAt: now},
|
|
{Service: "tera-alarmd", Status: "degraded", Detail: "alarm delivery service health unavailable", LastCheckedAt: now},
|
|
{Service: "tera-netd", Status: "degraded", Detail: "network configuration service health unavailable", LastCheckedAt: now},
|
|
{Service: "tera-timed", Status: "degraded", Detail: "system time service health unavailable", LastCheckedAt: now},
|
|
{Service: "tera-seriald", Status: "degraded", Detail: "serial hardware health adapter unavailable", LastCheckedAt: now},
|
|
{Service: "tera-updated", Status: "degraded", Detail: "firmware upload and validation adapter deferred", LastCheckedAt: now},
|
|
{Service: "tera-snmpd", Status: "degraded", Detail: "SNMP agent control service health unavailable", LastCheckedAt: now},
|
|
{Service: "tera-hwd", Status: "degraded", Detail: "hardware control adapter not attached", LastCheckedAt: now},
|
|
}
|
|
for index := range services {
|
|
switch services[index].Service {
|
|
case "tera-alarmd":
|
|
switch s.alarmDeliveryController.(type) {
|
|
case alarmdelivery.MockController:
|
|
case alarmdelivery.UnavailableController:
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "alarm delivery service is not configured"
|
|
default:
|
|
services[index].Status = "ok"
|
|
services[index].Detail = "bounded durable delivery queue and local adapter are configured"
|
|
}
|
|
case "tera-snmpd":
|
|
switch s.snmpAgentController.(type) {
|
|
case *snmpagent.MockController:
|
|
case snmpagent.UnavailableController:
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "SNMP agent control service is not configured"
|
|
default:
|
|
services[index].Status = "ok"
|
|
services[index].Detail = "privileged Net-SNMP configuration adapter is configured"
|
|
}
|
|
case "tera-timed":
|
|
switch s.timeController.(type) {
|
|
case systemtime.MockController:
|
|
case systemtime.UnavailableController:
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "system time service is not configured"
|
|
default:
|
|
services[index].Status = "ok"
|
|
services[index].Detail = "privileged system time adapter is configured"
|
|
}
|
|
case "tera-netd":
|
|
switch s.networkController.(type) {
|
|
case *networkconfig.MockController:
|
|
case networkconfig.UnavailableController:
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "network configuration service is not configured"
|
|
default:
|
|
status, err := s.networkController.Status(context.Background())
|
|
if err != nil {
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "network configuration service is unreachable: " + err.Error()
|
|
} else {
|
|
services[index].Status = "ok"
|
|
services[index].Detail = "active/active routing adapter is configured; transaction=" + status.State
|
|
}
|
|
}
|
|
case "tera-logd":
|
|
if s.logJournal == nil {
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "authoritative log service is not configured"
|
|
} else if snapshot, err := s.logJournal.Snapshot(); err != nil {
|
|
services[index].Status = "degraded"
|
|
services[index].Detail = "authoritative log service is unreachable: " + err.Error()
|
|
} else {
|
|
services[index].Status = "ok"
|
|
services[index].Detail = fmt.Sprintf("authoritative segments healthy; free=%d bytes floor=%d bytes", snapshot.FreeBytes, snapshot.GlobalFreeFloor)
|
|
}
|
|
}
|
|
}
|
|
return services
|
|
}
|
|
|
|
func (s *Store) NetworkState(ctx context.Context) (domain.NetworkState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeNetworkState(s.runningNetwork), nil
|
|
}
|
|
|
|
func (s *Store) PrepareNetworkRuntimeApply(_ context.Context, transactionID string) error {
|
|
transactionID = strings.TrimSpace(transactionID)
|
|
if transactionID == "" {
|
|
return errors.New("network transaction id is required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.networkRollback = cloneNetworkConfig(s.runningNetwork)
|
|
s.networkTransactionID = transactionID
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ConfirmNetworkRuntime(_ context.Context, transactionID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.networkTransactionID != "" && s.networkTransactionID != strings.TrimSpace(transactionID) {
|
|
return errors.New("network transaction id does not match the running snapshot")
|
|
}
|
|
s.networkRollback = nil
|
|
s.networkTransactionID = ""
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) RollbackNetworkRuntime(ctx context.Context) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.networkRollback) == 0 {
|
|
return false, nil
|
|
}
|
|
s.runningNetwork = cloneNetworkConfig(s.networkRollback)
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.networkRollback = nil
|
|
s.networkTransactionID = ""
|
|
s.runningRevision++
|
|
s.recordAuditLocked(ctx, "network.rollback.reconcile", "network", "succeeded", "running network state restored from transaction snapshot")
|
|
return true, s.persistConfigStoreLocked()
|
|
}
|
|
|
|
// ReconcileNetworkRuntime restores the API's running view after tera-netd has
|
|
// rolled an unconfirmed network transaction back at the operating-system layer.
|
|
func (s *Store) ReconcileNetworkRuntime(ctx context.Context, config networkconfig.Config) error {
|
|
normalized, err := networkconfig.Normalize(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
reconciled := networkStoreConfigFromController(normalized, s.runningNetwork)
|
|
if len(s.networkRollback) > 0 {
|
|
reconciled = cloneNetworkConfig(s.networkRollback)
|
|
}
|
|
changed := !equalNetworkConfig(s.runningNetwork, reconciled) || len(s.pendingNetwork) > 0
|
|
s.runningNetwork = reconciled
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.networkRollback = nil
|
|
s.networkTransactionID = ""
|
|
if changed {
|
|
s.runningRevision++
|
|
s.recordAuditLocked(ctx, "network.rollback.reconcile", "network", "succeeded", "running network state restored from tera-netd")
|
|
}
|
|
return s.persistConfigStoreLocked()
|
|
}
|
|
|
|
func (s *Store) RecordNetworkTransaction(ctx context.Context, action, transactionID, result, message string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if action != "network.confirm" && action != "network.rollback" {
|
|
return "", fmt.Errorf("unsupported network transaction audit action")
|
|
}
|
|
transactionID = strings.TrimSpace(transactionID)
|
|
if len(transactionID) > 128 {
|
|
transactionID = transactionID[:128]
|
|
}
|
|
message = strings.TrimSpace(message)
|
|
if len(message) > 512 {
|
|
message = message[:512]
|
|
}
|
|
auditID := s.recordAuditLocked(ctx, action, "network/"+transactionID, result, message)
|
|
return auditID, nil
|
|
}
|
|
|
|
func (s *Store) UpdateNetwork(ctx context.Context, update domain.NetworkUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if len(update.Interfaces) == 0 && update.HostName == nil && update.OperatingMode == nil && update.OOB == nil && update.Advanced == nil && update.DDNS == nil {
|
|
err := fmt.Errorf("network update must include host_name, operating_mode, advanced, ddns, oob, or at least one interface")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
|
|
updated := 0
|
|
staged := cloneNetworkConfig(s.pendingNetwork)
|
|
if update.HostName != nil || update.OperatingMode != nil || update.OOB != nil || update.Advanced != nil || update.DDNS != nil {
|
|
const globalName = "Interface-1"
|
|
next := s.effectivePendingNetworkLocked(globalName)
|
|
if pending, ok := staged[globalName]; ok {
|
|
next = pending
|
|
}
|
|
if update.HostName != nil {
|
|
next.HostName = strings.ToLower(strings.TrimSpace(*update.HostName))
|
|
}
|
|
if update.OperatingMode != nil {
|
|
next.OperatingMode = strings.ToLower(strings.TrimSpace(*update.OperatingMode))
|
|
}
|
|
if update.Advanced != nil {
|
|
if update.Advanced.Mode != nil {
|
|
next.AdvMode = strings.ToLower(strings.TrimSpace(*update.Advanced.Mode))
|
|
}
|
|
if update.Advanced.BondMode != nil {
|
|
next.BondMode = strings.ToLower(strings.TrimSpace(*update.Advanced.BondMode))
|
|
}
|
|
if update.Advanced.BondMii != nil {
|
|
next.BondMii = *update.Advanced.BondMii
|
|
}
|
|
if update.Advanced.BridgeSTP != nil {
|
|
next.BridgeSTP = *update.Advanced.BridgeSTP
|
|
}
|
|
}
|
|
if update.DDNS != nil {
|
|
if update.DDNS.Enabled != nil {
|
|
next.DDNSEnabled = *update.DDNS.Enabled
|
|
}
|
|
if update.DDNS.Provider != nil {
|
|
next.DDNSProvider = strings.ToLower(strings.TrimSpace(*update.DDNS.Provider))
|
|
}
|
|
if update.DDNS.IPv4Enabled != nil {
|
|
next.DDNSIPv4Enabled = *update.DDNS.IPv4Enabled
|
|
}
|
|
if update.DDNS.IPv6Enabled != nil {
|
|
next.DDNSIPv6Enabled = *update.DDNS.IPv6Enabled
|
|
}
|
|
if update.DDNS.Username != nil {
|
|
next.DDNSUsername = strings.TrimSpace(*update.DDNS.Username)
|
|
}
|
|
if update.DDNS.Password != nil {
|
|
next.DDNSPassword = strings.TrimSpace(*update.DDNS.Password)
|
|
}
|
|
if update.DDNS.IPv4Domain != nil {
|
|
next.DDNSIPv4Domain = strings.TrimSpace(*update.DDNS.IPv4Domain)
|
|
}
|
|
if update.DDNS.IPv6Domain != nil {
|
|
next.DDNSIPv6Domain = strings.TrimSpace(*update.DDNS.IPv6Domain)
|
|
}
|
|
if update.DDNS.CheckIPURL != nil {
|
|
next.DDNSCheckIPURL = strings.TrimSpace(*update.DDNS.CheckIPURL)
|
|
}
|
|
if update.DDNS.CustomProvider != nil {
|
|
next.DDNSCustomProvider = strings.TrimSpace(*update.DDNS.CustomProvider)
|
|
}
|
|
if update.DDNS.CustomPath != nil {
|
|
next.DDNSCustomPath = strings.TrimSpace(*update.DDNS.CustomPath)
|
|
}
|
|
}
|
|
if update.OOB != nil {
|
|
if update.OOB.PrimaryInterface != nil {
|
|
next.OOBPrimaryInterface = strings.TrimSpace(*update.OOB.PrimaryInterface)
|
|
}
|
|
if update.OOB.FallbackInterface != nil {
|
|
next.OOBFallbackInterface = strings.TrimSpace(*update.OOB.FallbackInterface)
|
|
}
|
|
if update.OOB.HealthCheckTarget != nil {
|
|
next.OOBHealthCheckTarget = strings.TrimSpace(*update.OOB.HealthCheckTarget)
|
|
}
|
|
if update.OOB.HealthCheckIntervalSecond != nil {
|
|
next.OOBHealthCheckSecond = *update.OOB.HealthCheckIntervalSecond
|
|
}
|
|
if update.OOB.FailureThreshold != nil {
|
|
next.OOBFailureThreshold = *update.OOB.FailureThreshold
|
|
}
|
|
if update.OOB.PrimaryInterface == nil && update.OOB.FallbackInterface == nil && update.OOB.HealthCheckTarget == nil && update.OOB.HealthCheckIntervalSecond == nil && update.OOB.FailureThreshold == nil {
|
|
err := fmt.Errorf("oob update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
}
|
|
staged[globalName] = next
|
|
updated++
|
|
}
|
|
// The product UI defines Single as two independent interfaces with the
|
|
// factory topology (Interface-1 internal, Interface-2 external). OOB
|
|
// temporarily promotes both links to external so tera-netd can fail over.
|
|
// Restore the canonical roles when an administrator switches back to
|
|
// Single; otherwise the previous OOB roles would incorrectly leave ECMP
|
|
// active after the mode change.
|
|
if update.OperatingMode != nil && strings.EqualFold(strings.TrimSpace(*update.OperatingMode), networkconfig.OperatingModeSingle) {
|
|
for name, role := range map[string]string{"Interface-1": networkconfig.RoleInternal, "Interface-2": networkconfig.RoleExternal} {
|
|
next := s.effectivePendingNetworkLocked(name)
|
|
if pending, ok := staged[name]; ok {
|
|
next = pending
|
|
}
|
|
next.Role = role
|
|
staged[name] = next
|
|
}
|
|
}
|
|
for _, item := range update.Interfaces {
|
|
name := normalizeNetworkInterfaceName(item.Name)
|
|
if name == "" {
|
|
err := fmt.Errorf("interface name must not be empty")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
if _, ok := s.runningNetwork[name]; !ok {
|
|
err := fmt.Errorf("network interface not found")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
next := s.effectivePendingNetworkLocked(name)
|
|
if pending, ok := staged[name]; ok {
|
|
next = pending
|
|
}
|
|
if item.AddressMode != nil {
|
|
next.AddressMode = strings.ToLower(strings.TrimSpace(*item.AddressMode))
|
|
}
|
|
if item.Address != nil {
|
|
address := strings.TrimSpace(*item.Address)
|
|
if address != "" {
|
|
if _, err := netip.ParsePrefix(address); err != nil {
|
|
err := fmt.Errorf("address must be CIDR notation")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
}
|
|
next.Address = address
|
|
}
|
|
if item.DNSServers != nil {
|
|
dns, err := normalizeIPAddressList(*item.DNSServers, true)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", fmt.Errorf("dns_servers: %w", err))
|
|
}
|
|
next.DNS1, next.DNS2 = splitPair(dns)
|
|
}
|
|
if item.IPv6Mode != nil {
|
|
next.IPv6Mode = strings.ToLower(strings.TrimSpace(*item.IPv6Mode))
|
|
}
|
|
if item.IPv6Address != nil {
|
|
address := strings.TrimSpace(*item.IPv6Address)
|
|
if address != "" {
|
|
if _, err := netip.ParsePrefix(address); err != nil {
|
|
err := fmt.Errorf("ipv6_address must be CIDR notation or empty")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
}
|
|
next.IPv6Address = address
|
|
}
|
|
if item.IPv6Gateway != nil {
|
|
gateway := strings.TrimSpace(*item.IPv6Gateway)
|
|
if gateway != "" {
|
|
if _, err := netip.ParseAddr(gateway); err != nil {
|
|
err := fmt.Errorf("ipv6_gateway must be an IP address or empty")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
}
|
|
next.IPv6Gateway = gateway
|
|
}
|
|
if item.IPv6DNSServers != nil {
|
|
dns, err := normalizeIPAddressList(*item.IPv6DNSServers, false)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", fmt.Errorf("ipv6_dns_servers: %w", err))
|
|
}
|
|
next.IPv6DNS1, next.IPv6DNS2 = splitPair(dns)
|
|
}
|
|
if item.Gateway != nil {
|
|
gateway := strings.TrimSpace(*item.Gateway)
|
|
if gateway != "" {
|
|
if _, err := netip.ParseAddr(gateway); err != nil {
|
|
err := fmt.Errorf("gateway must be an IP address or empty")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
}
|
|
next.Gateway = gateway
|
|
}
|
|
if item.Role != nil {
|
|
role := strings.ToLower(strings.TrimSpace(*item.Role))
|
|
if role != networkconfig.RoleInternal && role != networkconfig.RoleExternal {
|
|
err := fmt.Errorf("role must be internal or external")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
next.Role = role
|
|
}
|
|
if item.Weight != nil {
|
|
if *item.Weight < 1 || *item.Weight > 16 {
|
|
err := fmt.Errorf("weight must be between 1 and 16")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
next.Weight = *item.Weight
|
|
}
|
|
if item.AddressMode == nil && item.Address == nil && item.Gateway == nil && item.DNSServers == nil && item.IPv6Mode == nil && item.IPv6Address == nil && item.IPv6Gateway == nil && item.IPv6DNSServers == nil && item.Role == nil && item.Weight == nil {
|
|
err := fmt.Errorf("network interface update must include an IPv4, IPv6, role, or weight field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
staged[name] = next
|
|
updated++
|
|
}
|
|
candidate := cloneNetworkConfig(s.runningNetwork)
|
|
for name, config := range staged {
|
|
candidate[name] = config
|
|
}
|
|
normalized, err := networkconfig.Normalize(networkConfigForController(candidate))
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "network.update", "network", err)
|
|
}
|
|
// Persist the canonical two-interface topology selected by Normalize so the
|
|
// API view and tera-netd always agree about OOB priority/fallback roles.
|
|
s.pendingNetwork = networkStoreConfigFromController(normalized, candidate)
|
|
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("%d network interface update(s) staged in pending changes", updated)
|
|
auditID := s.recordAuditLocked(ctx, "network.update", "network", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("network_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) TimeSettings(ctx context.Context) (domain.TimeSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeTimeSettings(s.runningTime), nil
|
|
}
|
|
|
|
func (s *Store) UpdateTimeSettings(ctx context.Context, update domain.TimeSettingsUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.Mode == nil && update.Timezone == nil && update.NTPServers == nil {
|
|
err := fmt.Errorf("time settings update must include mode, timezone, or ntp_servers")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "time.settings.update", "time", err)
|
|
}
|
|
|
|
next := s.effectivePendingTimeLocked()
|
|
if update.Mode != nil {
|
|
mode, err := normalizeTimeMode(*update.Mode)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "time.settings.update", "time", err)
|
|
}
|
|
next.Mode = mode
|
|
}
|
|
if update.Timezone != nil {
|
|
timezone, err := normalizeTimezone(*update.Timezone)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "time.settings.update", "time", err)
|
|
}
|
|
next.Timezone = timezone
|
|
}
|
|
if update.NTPServers != nil {
|
|
servers, err := normalizeNTPServers(update.NTPServers)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "time.settings.update", "time", err)
|
|
}
|
|
next.NTPServers = servers
|
|
}
|
|
if next.Mode == "ntp" && len(next.NTPServers) == 0 {
|
|
err := fmt.Errorf("ntp_servers must include at least one server when mode is ntp")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "time.settings.update", "time", err)
|
|
}
|
|
|
|
s.pendingTime = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "time settings update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "time.settings.update", "time", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("time_settings_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) ManagementAccess(ctx context.Context) (domain.ManagementAccessSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeManagementAccessSettings(s.runningManagement), nil
|
|
}
|
|
|
|
func (s *Store) UpdateManagementAccess(ctx context.Context, update domain.ManagementAccessUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.SSHEnabled != nil || update.SSHPort != nil || update.TelnetEnabled != nil || update.TelnetPort != nil {
|
|
err := fmt.Errorf("management access SSH and Telnet are excluded; use per-channel SSH or Telnet access")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "security.management_access.update", "management_access", err)
|
|
}
|
|
if update.HTTPEnabled == nil && update.HTTPPort == nil && update.HTTPSEnabled == nil && update.HTTPSPort == nil && update.SerialConsoleEnabled == nil && update.IdleTimeoutMinutes == nil && update.CaptchaEnabled == nil && update.LoginDetectionMinutes == nil && update.LoginSecurityHitCount == nil {
|
|
err := fmt.Errorf("management access update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "security.management_access.update", "management_access", err)
|
|
}
|
|
|
|
next := s.effectivePendingManagementLocked()
|
|
if update.HTTPEnabled != nil {
|
|
next.HTTPEnabled = *update.HTTPEnabled
|
|
}
|
|
if update.HTTPPort != nil {
|
|
next.HTTPPort = *update.HTTPPort
|
|
}
|
|
if update.HTTPSEnabled != nil {
|
|
next.HTTPSEnabled = *update.HTTPSEnabled
|
|
}
|
|
if update.HTTPSPort != nil {
|
|
next.HTTPSPort = *update.HTTPSPort
|
|
}
|
|
if update.SerialConsoleEnabled != nil {
|
|
next.SerialConsoleEnabled = *update.SerialConsoleEnabled
|
|
}
|
|
if update.IdleTimeoutMinutes != nil {
|
|
next.IdleTimeoutMinutes = *update.IdleTimeoutMinutes
|
|
}
|
|
if update.CaptchaEnabled != nil {
|
|
next.CaptchaEnabled = *update.CaptchaEnabled
|
|
}
|
|
if update.LoginDetectionMinutes != nil {
|
|
next.LoginDetectionMinutes = *update.LoginDetectionMinutes
|
|
}
|
|
if update.LoginSecurityHitCount != nil {
|
|
next.LoginSecurityHitCount = *update.LoginSecurityHitCount
|
|
}
|
|
if err := validateManagementAccess(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "security.management_access.update", "management_access", err)
|
|
}
|
|
|
|
s.pendingManagement = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "management access policy update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "security.management_access.update", "management_access", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("management_access_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) TLSCertificate(ctx context.Context) (domain.TLSCertificateSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeTLSCertificateSettings(s.runningTLS), nil
|
|
}
|
|
|
|
func (s *Store) TLSCertificates(ctx context.Context) (domain.TLSCertificateInventory, error) {
|
|
s.mu.Lock()
|
|
activeID := s.runningTLS.ActiveCertificateID
|
|
s.mu.Unlock()
|
|
metadata := s.tlsCertificates.List(activeID)
|
|
entries := make([]domain.TLSCertificateEntry, 0, len(metadata))
|
|
for _, item := range metadata {
|
|
entries = append(entries, makeTLSCertificateEntry(item, item.CertificateID == activeID))
|
|
}
|
|
return domain.TLSCertificateInventory{ActiveCertificateID: activeID, Certificates: entries}, nil
|
|
}
|
|
|
|
func (s *Store) CreateTLSCertificate(ctx context.Context, request domain.TLSCertificateCreateRequest) (domain.TLSCertificateEntry, error) {
|
|
metadata, err := s.tlsCertificates.Generate(request.CertificateID, makeTLSCertificateSubject(request.TLSCertificateSubject))
|
|
if err != nil {
|
|
return domain.TLSCertificateEntry{}, s.rejectOperation(ctx, "security.tls_certificate.create", request.CertificateID, err)
|
|
}
|
|
s.mu.Lock()
|
|
s.recordAuditLocked(ctx, "security.tls_certificate.create", metadata.CertificateID, "succeeded", "RSA-2048 TLS key and self-signed certificate created")
|
|
s.mu.Unlock()
|
|
return makeTLSCertificateEntry(metadata, false), nil
|
|
}
|
|
|
|
func (s *Store) ImportTLSCertificate(ctx context.Context, request domain.TLSCertificateImportRequest) (domain.TLSCertificateEntry, error) {
|
|
metadata, err := s.tlsCertificates.Import(tlscert.ImportRequest{
|
|
CertificateID: request.CertificateID, CertificatePEM: []byte(request.CertificatePEM),
|
|
PrivateKeyPEM: []byte(request.PrivateKeyPEM), Password: request.Password,
|
|
})
|
|
request.Password, request.PrivateKeyPEM = "", ""
|
|
if err != nil {
|
|
return domain.TLSCertificateEntry{}, s.rejectOperation(ctx, "security.tls_certificate.import", request.CertificateID, err)
|
|
}
|
|
s.mu.Lock()
|
|
s.recordAuditLocked(ctx, "security.tls_certificate.import", metadata.CertificateID, "succeeded", "PEM TLS certificate and matching private key imported")
|
|
s.mu.Unlock()
|
|
return makeTLSCertificateEntry(metadata, false), nil
|
|
}
|
|
|
|
func (s *Store) ExportTLSCSR(ctx context.Context, certificateID string, request domain.TLSCSRRequest) (domain.TLSCSRArtifact, error) {
|
|
payload, err := s.tlsCertificates.CSR(certificateID, makeTLSCertificateSubject(request.TLSCertificateSubject))
|
|
if err != nil {
|
|
return domain.TLSCSRArtifact{}, s.rejectOperation(ctx, "security.tls_certificate.csr", certificateID, err)
|
|
}
|
|
s.mu.Lock()
|
|
s.recordAuditLocked(ctx, "security.tls_certificate.csr", certificateID, "succeeded", "TLS certificate signing request exported")
|
|
s.mu.Unlock()
|
|
return domain.TLSCSRArtifact{CertificateID: certificateID, Filename: strings.ToLower(strings.TrimSpace(certificateID)) + ".csr", PEM: string(payload)}, nil
|
|
}
|
|
|
|
func (s *Store) DeleteTLSCertificate(ctx context.Context, certificateID string) (domain.CommandResult, error) {
|
|
id := strings.ToLower(strings.TrimSpace(certificateID))
|
|
s.mu.Lock()
|
|
if id == s.runningTLS.ActiveCertificateID || id == s.persistentTLS.ActiveCertificateID || s.pendingTLS != nil && id == s.pendingTLS.ActiveCertificateID {
|
|
err := errors.New("active, saved, or pending TLS certificate cannot be deleted")
|
|
result := s.rejectCommandLocked(ctx, "security.tls_certificate.delete", id, err)
|
|
s.mu.Unlock()
|
|
return domain.CommandResult{}, result
|
|
}
|
|
s.mu.Unlock()
|
|
if err := s.tlsCertificates.Delete(id); err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "security.tls_certificate.delete", id, err)
|
|
}
|
|
s.mu.Lock()
|
|
message := "TLS certificate and protected private key deleted"
|
|
auditID := s.recordAuditLocked(ctx, "security.tls_certificate.delete", id, "succeeded", message)
|
|
s.mu.Unlock()
|
|
return commandResult("tls_certificate_deleted", message, auditID, nil), nil
|
|
}
|
|
|
|
func (s *Store) UpdateTLSCertificate(ctx context.Context, update domain.TLSCertificateUpdate) (domain.CommandResult, error) {
|
|
if update.ActiveCertificateID == nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "security.tls_certificate.update", "tls_certificate", fmt.Errorf("tls certificate update must include active_certificate_id"))
|
|
}
|
|
metadata, err := s.tlsCertificates.Metadata(*update.ActiveCertificateID)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "security.tls_certificate.update", "tls_certificate", fmt.Errorf("tls certificate active_certificate_id must reference an available certificate: %w", err))
|
|
}
|
|
next := tlsCertificateConfigFromMetadata(metadata)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.pendingTLS = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "tls certificate activation staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "security.tls_certificate.update", "tls_certificate", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("tls_certificate_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) RemoteSyslog(ctx context.Context) (domain.RemoteSyslogSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeRemoteSyslogSettings(s.runningSyslog), nil
|
|
}
|
|
|
|
func (s *Store) UpdateRemoteSyslog(ctx context.Context, update domain.RemoteSyslogUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.Enabled == nil && update.Host == nil && update.Port == nil && update.Protocol == nil && update.Facility == nil {
|
|
err := fmt.Errorf("remote syslog update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.remote_syslog.update", "remote_syslog", err)
|
|
}
|
|
|
|
next := s.effectivePendingSyslogLocked()
|
|
if update.Enabled != nil {
|
|
next.Enabled = *update.Enabled
|
|
}
|
|
if update.Host != nil {
|
|
next.Host = strings.TrimSpace(*update.Host)
|
|
}
|
|
if update.Port != nil {
|
|
next.Port = *update.Port
|
|
}
|
|
if update.Protocol != nil {
|
|
protocol, err := normalizeSyslogProtocol(*update.Protocol)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.remote_syslog.update", "remote_syslog", err)
|
|
}
|
|
next.Protocol = protocol
|
|
}
|
|
if update.Facility != nil {
|
|
facility, err := normalizeSyslogFacility(*update.Facility)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.remote_syslog.update", "remote_syslog", err)
|
|
}
|
|
next.Facility = facility
|
|
}
|
|
if err := validateRemoteSyslog(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.remote_syslog.update", "remote_syslog", err)
|
|
}
|
|
|
|
s.pendingSyslog = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "remote syslog update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "log.remote_syslog.update", "remote_syslog", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("remote_syslog_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) Ports(ctx context.Context) ([]domain.Port, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ports := make([]domain.Port, 0, 48)
|
|
for index := 1; index <= 48; index++ {
|
|
channel := channelName(index)
|
|
ports = append(ports, makePort(index, s.running[channel]))
|
|
}
|
|
return ports, nil
|
|
}
|
|
|
|
func (s *Store) Port(ctx context.Context, channel string) (domain.Port, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.Port{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makePort(index, s.running[channelName(index)]), nil
|
|
}
|
|
|
|
func (s *Store) ResolvePortServiceCredentials(ctx context.Context, phase string, ports []domain.Port) ([]domain.Port, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
phase = strings.ToLower(strings.TrimSpace(phase))
|
|
if phase != "pending" && phase != "running" && phase != "persistent" {
|
|
return nil, fmt.Errorf("port service credential phase %q is invalid", phase)
|
|
}
|
|
result := append([]domain.Port(nil), ports...)
|
|
for portIndex := range result {
|
|
for number := 1; number <= 2; number++ {
|
|
operation := &result[portIndex].Operation1
|
|
if number == 2 {
|
|
operation = &result[portIndex].Operation2
|
|
}
|
|
credential, found := s.portCredentialForPhase(phase, result[portIndex].Channel, number)
|
|
if found {
|
|
operation.Credential = string(credential)
|
|
operation.PasswordStored = true
|
|
} else {
|
|
operation.Credential = ""
|
|
operation.PasswordStored = false
|
|
}
|
|
if operation.Mode != "" && operation.Mode != "disabled" && (operation.Authentication == "password" || operation.Authentication == "key") && !found {
|
|
return nil, fmt.Errorf("%s operation %d requires stored %s credential", result[portIndex].Channel, number, operation.Authentication)
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) portCredentialForPhase(phase, channel string, operation int) ([]byte, bool) {
|
|
if value, ok := s.secrets.Get(portCredentialKey(phase, channel, operation)); ok {
|
|
return value, true
|
|
}
|
|
if s.secrets.Has(portCredentialClearKey(phase, channel, operation)) {
|
|
return nil, false
|
|
}
|
|
if phase == "pending" {
|
|
return s.portCredentialForPhase("running", channel, operation)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (s *Store) AlarmRules(ctx context.Context) ([]domain.AlarmRule, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
ruleIDs := sortedAlarmRuleIDs(s.runningAlarms)
|
|
rules := make([]domain.AlarmRule, 0, len(ruleIDs))
|
|
for _, ruleID := range ruleIDs {
|
|
rules = append(rules, makeAlarmRule(ruleID, s.runningAlarms[ruleID]))
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func (s *Store) AlarmRule(ctx context.Context, ruleID string) (domain.AlarmRule, error) {
|
|
normalized := normalizeRuleID(ruleID)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
config, ok := s.runningAlarms[normalized]
|
|
if !ok {
|
|
return domain.AlarmRule{}, fmt.Errorf("alarm rule not found")
|
|
}
|
|
return makeAlarmRule(normalized, config), nil
|
|
}
|
|
|
|
func (s *Store) AlarmDeliveryPolicy(ctx context.Context) (domain.AlarmDeliveryPolicy, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeAlarmDeliveryPolicy(s.runningAlarmDelivery), nil
|
|
}
|
|
|
|
func (s *Store) EmailNotification(ctx context.Context) (domain.EmailNotificationSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeEmailNotificationSettings(s.runningEmail), nil
|
|
}
|
|
|
|
func (s *Store) SNMPAgent(ctx context.Context) (domain.SNMPAgentSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeSNMPAgentSettings(s.runningSNMPAgent), nil
|
|
}
|
|
|
|
func (s *Store) SNMPTrapTargets(ctx context.Context) (domain.SNMPTrapTargetSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeSNMPTrapTargetSettings(s.runningSNMPTraps), nil
|
|
}
|
|
|
|
func (s *Store) ConfigStatus(ctx context.Context) (domain.ConfigSummary, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.configSummaryLocked(), nil
|
|
}
|
|
|
|
func (s *Store) ConfigExport(ctx context.Context) (domain.ConfigExport, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.configExportLocked(s.clock().UTC()), nil
|
|
}
|
|
|
|
func (s *Store) configExportLocked(now time.Time) domain.ConfigExport {
|
|
return domain.ConfigExport{
|
|
FormatVersion: 1,
|
|
GeneratedAt: now,
|
|
Summary: s.configSummaryLocked(),
|
|
Running: s.configExportStateLocked(s.running, s.runningAlarms, s.runningAlarmDelivery, s.runningEmail, s.runningSNMPAgent, s.runningSNMPTraps, s.runningNetwork, s.runningTime, s.runningRetention, s.runningManagement, s.runningSyslog, s.runningTLS, s.runningExternalAuth),
|
|
Persistent: s.configExportStateLocked(s.persistent, s.persistentAlarms, s.persistentAlarmDelivery, s.persistentEmail, s.persistentSNMPAgent, s.persistentSNMPTraps, s.persistentNetwork, s.persistentTime, s.persistentRetention, s.persistentManagement, s.persistentSyslog, s.persistentTLS, s.persistentExternalAuth),
|
|
Pending: domain.ConfigExportPending{
|
|
Ports: makePendingPorts(s.pending),
|
|
AlarmRules: makeAlarmRules(s.pendingAlarms),
|
|
AlarmDelivery: makeOptionalAlarmDeliveryPolicy(s.pendingAlarmDelivery),
|
|
EmailNotification: makeOptionalEmailNotificationSettings(s.pendingEmail),
|
|
SNMPAgent: makeOptionalSNMPAgentSettings(s.pendingSNMPAgent),
|
|
SNMPTrapTargets: makeOptionalSNMPTrapTargetSettings(s.pendingSNMPTraps),
|
|
Network: makeOptionalNetworkState(s.pendingNetwork),
|
|
NetworkInterfaces: makeNetworkInterfaces(s.pendingNetwork),
|
|
Time: makeOptionalTimeSettings(s.pendingTime),
|
|
Logs: makeOptionalLogRetentionSettings(s.pendingRetention),
|
|
Management: makeOptionalManagementAccessSettings(s.pendingManagement),
|
|
RemoteSyslog: makeOptionalRemoteSyslogSettings(s.pendingSyslog),
|
|
TLSCertificate: makeOptionalTLSCertificateSettings(s.pendingTLS),
|
|
ExternalAuth: makeOptionalExternalAuthSettings(s.pendingExternalAuth),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Store) RestoreConfig(ctx context.Context, snapshot domain.ConfigExport) (domain.CommandResult, error) {
|
|
if snapshot.FormatVersion != 1 {
|
|
err := fmt.Errorf("config restore format_version must be 1")
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
|
|
ports, err := portConfigFromExport(snapshot.Persistent.Ports)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
alarms, err := alarmRuleConfigFromExport(snapshot.Persistent.AlarmRules)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
alarmDelivery, err := alarmDeliveryConfigFromExport(snapshot.Persistent.AlarmDelivery)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
email, err := emailNotificationFromExport(snapshot.Persistent.EmailNotification)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
snmpAgent, err := snmpAgentFromExport(snapshot.Persistent.SNMPAgent)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
snmpTraps, err := snmpTrapTargetsFromExport(snapshot.Persistent.SNMPTrapTargets)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
network, err := networkConfigFromExport(snapshot.Persistent.Network)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
timeConfig, err := timeConfigFromExport(snapshot.Persistent.Time)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
retention, err := logRetentionConfigFromExport(snapshot.Persistent.Logs)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
management, err := managementConfigFromExport(snapshot.Persistent.Management)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
syslog, err := remoteSyslogConfigFromExport(snapshot.Persistent.RemoteSyslog)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
tlsCertificate, err := tlsCertificateConfigFromExport(snapshot.Persistent.TLSCertificate)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
externalAuth, err := externalAuthConfigFromExport(snapshot.Persistent.ExternalAuth)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectOperation(ctx, "config.restore", "config", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Configuration exports intentionally contain only secret-presence markers,
|
|
// never the protected values themselves. When a changed section cannot reuse
|
|
// its protected value from the appliance, preserve that complete running
|
|
// section and continue restoring everything else. This keeps backups useful
|
|
// for unrelated settings without manufacturing an invalid pending config.
|
|
preservedSecretSections := s.preserveUnrestorableSecretSectionsLocked(ports, &email, &snmpAgent, &snmpTraps, &externalAuth)
|
|
if err := s.clearPendingSecretsLocked(); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "config.restore", "config", err)
|
|
}
|
|
if err := s.restorePendingSecretsLocked(ports, &email, &snmpAgent, snmpTraps, &externalAuth); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "config.restore", "config", err)
|
|
}
|
|
s.pending = make(map[string]portConfig)
|
|
for channel, config := range ports {
|
|
if running, exists := s.running[channel]; !exists || !reflect.DeepEqual(running, config) {
|
|
s.pending[channel] = config
|
|
}
|
|
}
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
for ruleID, config := range alarms {
|
|
if running, exists := s.runningAlarms[ruleID]; !exists || !equalAlarmRule(running, config) {
|
|
s.pendingAlarms[ruleID] = config
|
|
}
|
|
}
|
|
if equalAlarmDeliveryPolicy(s.runningAlarmDelivery, alarmDelivery) {
|
|
s.pendingAlarmDelivery = nil
|
|
} else {
|
|
s.pendingAlarmDelivery = &alarmDelivery
|
|
}
|
|
if equalEmailNotification(s.runningEmail, email) {
|
|
s.pendingEmail = nil
|
|
} else {
|
|
s.pendingEmail = &email
|
|
}
|
|
if equalSNMPAgent(s.runningSNMPAgent, snmpAgent) {
|
|
s.pendingSNMPAgent = nil
|
|
} else {
|
|
s.pendingSNMPAgent = &snmpAgent
|
|
}
|
|
if equalSNMPTrapTargets(s.runningSNMPTraps, snmpTraps) {
|
|
s.pendingSNMPTraps = nil
|
|
} else {
|
|
s.pendingSNMPTraps = &snmpTraps
|
|
}
|
|
// Do not manufacture a network transaction when a restored snapshot has
|
|
// the same interfaces as the running appliance. This is particularly
|
|
// important for encrypted restores with include_network=false: applying
|
|
// an otherwise harmless backup must not interrupt the management link.
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
for name, config := range network {
|
|
if running, exists := s.runningNetwork[name]; !exists || running != config {
|
|
s.pendingNetwork[name] = config
|
|
}
|
|
}
|
|
if equalTimeSettings(s.runningTime, timeConfig) {
|
|
s.pendingTime = nil
|
|
} else {
|
|
s.pendingTime = &timeConfig
|
|
}
|
|
if equalLogRetention(s.runningRetention, retention) {
|
|
s.pendingRetention = nil
|
|
} else {
|
|
s.pendingRetention = &retention
|
|
}
|
|
if equalManagementAccess(s.runningManagement, management) {
|
|
s.pendingManagement = nil
|
|
} else {
|
|
s.pendingManagement = &management
|
|
}
|
|
if equalRemoteSyslog(s.runningSyslog, syslog) {
|
|
s.pendingSyslog = nil
|
|
} else {
|
|
s.pendingSyslog = &syslog
|
|
}
|
|
if equalTLSCertificate(s.runningTLS, tlsCertificate) {
|
|
s.pendingTLS = nil
|
|
} else {
|
|
s.pendingTLS = &tlsCertificate
|
|
}
|
|
if equalExternalAuth(s.runningExternalAuth, externalAuth) {
|
|
s.pendingExternalAuth = nil
|
|
} else {
|
|
s.pendingExternalAuth = &externalAuth
|
|
}
|
|
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("configuration restore staged %d pending change(s)", summary.PendingChanges)
|
|
if len(preservedSecretSections) > 0 {
|
|
message += "; preserved current settings for sections missing protected values: " + strings.Join(preservedSecretSections, ", ")
|
|
}
|
|
auditID := s.recordAuditLocked(ctx, "config.restore", "config", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("config_restore_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) AuditEvents(ctx context.Context) ([]domain.AuditEvent, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.logJournal != nil {
|
|
if err := s.refreshAuditJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.seedJournalLastLocked()
|
|
}
|
|
|
|
events := make([]domain.AuditEvent, len(s.auditEvents))
|
|
copy(events, s.auditEvents)
|
|
return events, nil
|
|
}
|
|
|
|
func (s *Store) SystemLogEvents(ctx context.Context, filter domain.SystemLogFilter) ([]domain.SystemLogEvent, error) {
|
|
limit, offset, err := normalizeLogWindow(filter.Limit, filter.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateLogTimeRange(filter.From, filter.To); err != nil {
|
|
return nil, err
|
|
}
|
|
severity := strings.ToLower(strings.TrimSpace(filter.Severity))
|
|
if severity != "" && severity != "info" && severity != "warning" && severity != "critical" {
|
|
return nil, fmt.Errorf("severity must be info, warning, or critical")
|
|
}
|
|
category := strings.ToLower(strings.TrimSpace(filter.Category))
|
|
if category != "" && category != "system" && category != "configuration" && category != "operation" && category != "status" {
|
|
return nil, fmt.Errorf("category must be system, configuration, operation, or status")
|
|
}
|
|
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
|
|
|
s.mu.Lock()
|
|
if s.logJournal != nil {
|
|
if refreshErr := s.refreshSystemJournalLocked(); refreshErr != nil {
|
|
s.mu.Unlock()
|
|
return nil, refreshErr
|
|
}
|
|
if refreshErr := s.refreshAuditJournalLocked(); refreshErr != nil {
|
|
s.mu.Unlock()
|
|
return nil, refreshErr
|
|
}
|
|
s.seedJournalLastLocked()
|
|
}
|
|
source := append([]domain.SystemLogEvent(nil), s.systemLogEvents...)
|
|
audits := append([]domain.AuditEvent(nil), s.auditEvents...)
|
|
loaded := s.systemLogLoaded
|
|
s.mu.Unlock()
|
|
if !loaded && s.allowSampleLogs {
|
|
source = sampleSystemLogEvents(s.clock().UTC())
|
|
}
|
|
for _, audit := range audits {
|
|
if event, ok := auditSystemLog(audit); ok {
|
|
event.AuditProtected = true
|
|
source = append(source, event)
|
|
}
|
|
}
|
|
sort.SliceStable(source, func(left, right int) bool {
|
|
return source[left].Timestamp.After(source[right].Timestamp)
|
|
})
|
|
events := make([]domain.SystemLogEvent, 0)
|
|
for _, event := range source {
|
|
if !timestampInLogRange(event.Timestamp, filter.From, filter.To) {
|
|
continue
|
|
}
|
|
if severity != "" && event.Severity != severity {
|
|
continue
|
|
}
|
|
if category != "" && event.Category != category {
|
|
continue
|
|
}
|
|
searchText := strings.ToLower(strings.Join([]string{
|
|
event.Message, event.Source, event.Actor, event.Action, event.Target, event.AuditID,
|
|
}, " "))
|
|
if keyword != "" && !strings.Contains(searchText, keyword) {
|
|
continue
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return windowSystemLogEvents(events, limit, offset), nil
|
|
}
|
|
|
|
func auditSystemLog(audit domain.AuditEvent) (domain.SystemLogEvent, bool) {
|
|
action := strings.ToLower(strings.TrimSpace(audit.Action))
|
|
result := strings.ToLower(strings.TrimSpace(audit.Result))
|
|
if action == "auth.login" && (result == "authentication_failed" || result == "authentication_locked" || result == "failed") {
|
|
return domain.SystemLogEvent{
|
|
Timestamp: audit.Timestamp,
|
|
Category: "system",
|
|
Severity: "warning",
|
|
Source: "authentication",
|
|
Message: audit.Message,
|
|
Actor: audit.Actor,
|
|
Action: action,
|
|
Target: audit.Target,
|
|
AuditID: audit.AuditID,
|
|
}, true
|
|
}
|
|
if result != "succeeded" || !isConfigurationAuditAction(action) {
|
|
return domain.SystemLogEvent{}, false
|
|
}
|
|
return domain.SystemLogEvent{
|
|
Timestamp: audit.Timestamp,
|
|
Category: "configuration",
|
|
Severity: "info",
|
|
Source: configurationAuditSource(action),
|
|
Message: audit.Message,
|
|
Actor: audit.Actor,
|
|
Action: action,
|
|
Target: audit.Target,
|
|
AuditID: audit.AuditID,
|
|
}, true
|
|
}
|
|
|
|
func isConfigurationAuditAction(action string) bool {
|
|
switch action {
|
|
case "port.update", "port.bulk_update":
|
|
return true
|
|
}
|
|
for _, prefix := range []string{
|
|
"network.", "config.", "time.settings.",
|
|
"security.management_access.", "security.tls_certificate.",
|
|
"log.retention.", "log.remote_syslog.",
|
|
"alarm.rule.", "alarm.delivery_policy.", "alarm.email.", "alarm.snmp_trap.",
|
|
"snmp.agent.", "auth.address_filter.", "auth.external.", "auth.group.", "auth.user.",
|
|
} {
|
|
if strings.HasPrefix(action, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func configurationAuditSource(action string) string {
|
|
switch {
|
|
case strings.HasPrefix(action, "network."):
|
|
return "network"
|
|
case strings.HasPrefix(action, "port."):
|
|
return "serial"
|
|
case strings.HasPrefix(action, "time."):
|
|
return "time"
|
|
case strings.HasPrefix(action, "log."):
|
|
return "logs"
|
|
case strings.HasPrefix(action, "alarm."):
|
|
return "alarm"
|
|
case strings.HasPrefix(action, "snmp."):
|
|
return "snmp"
|
|
case strings.HasPrefix(action, "auth."):
|
|
return "authentication"
|
|
case strings.HasPrefix(action, "security."):
|
|
return "security"
|
|
case strings.HasPrefix(action, "config."):
|
|
return "configuration"
|
|
default:
|
|
return "tera-api"
|
|
}
|
|
}
|
|
|
|
func (s *Store) PortLogEvents(ctx context.Context, filter domain.PortLogFilter) ([]domain.PortLogEvent, error) {
|
|
limit, offset, err := normalizeLogWindow(filter.Limit, filter.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateLogTimeRange(filter.From, filter.To); err != nil {
|
|
return nil, err
|
|
}
|
|
channel := strings.ToUpper(strings.TrimSpace(filter.Channel))
|
|
if channel != "" {
|
|
if _, err := parseChannel(channel); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
|
actor := strings.ToLower(strings.TrimSpace(filter.Actor))
|
|
eventType := strings.ToLower(strings.TrimSpace(filter.EventType))
|
|
if eventType != "" && eventType != "traffic" && eventType != "command" && eventType != "session" {
|
|
return nil, fmt.Errorf("event_type must be traffic, command, or session")
|
|
}
|
|
direction := strings.ToUpper(strings.TrimSpace(filter.Direction))
|
|
if direction != "" && direction != "RX" && direction != "TX" {
|
|
return nil, fmt.Errorf("direction must be RX or TX")
|
|
}
|
|
s.mu.Lock()
|
|
if s.logJournal != nil {
|
|
if refreshErr := s.refreshPortJournalLocked(); refreshErr != nil {
|
|
s.mu.Unlock()
|
|
return nil, refreshErr
|
|
}
|
|
s.seedJournalLastLocked()
|
|
}
|
|
source := append([]domain.PortLogEvent(nil), s.portLogEvents...)
|
|
loaded := s.portLogLoaded
|
|
s.mu.Unlock()
|
|
if !loaded && s.allowSampleLogs {
|
|
source = samplePortLogEvents(s.clock().UTC())
|
|
}
|
|
events := make([]domain.PortLogEvent, 0)
|
|
for _, event := range source {
|
|
if !timestampInLogRange(event.Timestamp, filter.From, filter.To) {
|
|
continue
|
|
}
|
|
if channel != "" && event.Channel != channel {
|
|
continue
|
|
}
|
|
if eventType != "" && normalizedPortEventType(event) != eventType {
|
|
continue
|
|
}
|
|
if direction != "" && event.Direction != direction {
|
|
continue
|
|
}
|
|
if actor != "" && !strings.Contains(strings.ToLower(event.Actor), actor) {
|
|
continue
|
|
}
|
|
searchText := strings.ToLower(event.Message + " " + event.Command + " " + event.ReturnValue)
|
|
if keyword != "" && !strings.Contains(searchText, keyword) {
|
|
continue
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return windowPortLogEvents(events, limit, offset), nil
|
|
}
|
|
|
|
func (s *Store) LinkLogEvents(ctx context.Context, filter domain.LinkLogFilter) ([]domain.LinkLogEvent, error) {
|
|
limit, offset, err := normalizeLogWindow(filter.Limit, filter.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateLogTimeRange(filter.From, filter.To); err != nil {
|
|
return nil, err
|
|
}
|
|
kind := strings.ToLower(strings.TrimSpace(filter.Kind))
|
|
if kind != "" && kind != "serial" && kind != "ethernet" {
|
|
return nil, fmt.Errorf("kind must be serial or ethernet")
|
|
}
|
|
state := strings.ToLower(strings.TrimSpace(filter.State))
|
|
if state != "" && state != "up" && state != "down" && state != "unknown" {
|
|
return nil, fmt.Errorf("state must be up, down, or unknown")
|
|
}
|
|
eventType := strings.ToLower(strings.TrimSpace(filter.EventType))
|
|
if eventType != "" && eventType != "snapshot" && eventType != "transition" {
|
|
return nil, fmt.Errorf("event_type must be snapshot or transition")
|
|
}
|
|
resource := strings.TrimSpace(filter.Resource)
|
|
keyword := strings.ToLower(strings.TrimSpace(filter.Keyword))
|
|
s.mu.Lock()
|
|
if s.logJournal != nil {
|
|
if refreshErr := s.refreshLinkJournalLocked(); refreshErr != nil {
|
|
s.mu.Unlock()
|
|
return nil, refreshErr
|
|
}
|
|
s.seedJournalLastLocked()
|
|
}
|
|
source := append([]domain.LinkLogEvent(nil), s.linkLogEvents...)
|
|
loaded := s.linkLogLoaded
|
|
s.mu.Unlock()
|
|
if !loaded && s.allowSampleLogs {
|
|
source = sampleLinkLogEvents(s.clock().UTC())
|
|
}
|
|
events := make([]domain.LinkLogEvent, 0)
|
|
for _, event := range source {
|
|
if !timestampInLogRange(event.Timestamp, filter.From, filter.To) {
|
|
continue
|
|
}
|
|
if kind != "" && event.Kind != kind || resource != "" && event.Resource != resource || state != "" && event.State != state {
|
|
continue
|
|
}
|
|
if eventType != "" && event.EventType != eventType {
|
|
continue
|
|
}
|
|
searchText := strings.ToLower(event.Kind + " " + event.Resource + " " + event.State + " " + event.Signal + " " + event.EventType + " " + event.Message)
|
|
if keyword != "" && !strings.Contains(searchText, keyword) {
|
|
continue
|
|
}
|
|
events = append(events, event)
|
|
}
|
|
return windowLinkLogEvents(events, limit, offset), nil
|
|
}
|
|
|
|
func normalizeLogWindow(limit, offset int) (int, int, error) {
|
|
if offset < 0 {
|
|
return 0, 0, fmt.Errorf("offset must be a non-negative integer")
|
|
}
|
|
if limit < 0 {
|
|
return 0, 0, fmt.Errorf("limit must be a non-negative integer")
|
|
}
|
|
if limit == 0 {
|
|
limit = defaultLogEventLimit
|
|
}
|
|
if limit > maxLogEventLimit {
|
|
return 0, 0, fmt.Errorf("limit must be at most %d", maxLogEventLimit)
|
|
}
|
|
return limit, offset, nil
|
|
}
|
|
|
|
func validateLogTimeRange(from, to time.Time) error {
|
|
if !from.IsZero() && !to.IsZero() && from.After(to) {
|
|
return fmt.Errorf("from must not be after to")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func timestampInLogRange(timestamp, from, to time.Time) bool {
|
|
if !from.IsZero() && timestamp.Before(from) {
|
|
return false
|
|
}
|
|
if !to.IsZero() && timestamp.After(to) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func windowSystemLogEvents(events []domain.SystemLogEvent, limit, offset int) []domain.SystemLogEvent {
|
|
if offset >= len(events) {
|
|
return []domain.SystemLogEvent{}
|
|
}
|
|
end := offset + limit
|
|
if end > len(events) {
|
|
end = len(events)
|
|
}
|
|
return events[offset:end]
|
|
}
|
|
|
|
func windowPortLogEvents(events []domain.PortLogEvent, limit, offset int) []domain.PortLogEvent {
|
|
if offset >= len(events) {
|
|
return []domain.PortLogEvent{}
|
|
}
|
|
end := offset + limit
|
|
if end > len(events) {
|
|
end = len(events)
|
|
}
|
|
return events[offset:end]
|
|
}
|
|
|
|
func windowLinkLogEvents(events []domain.LinkLogEvent, limit, offset int) []domain.LinkLogEvent {
|
|
if offset >= len(events) {
|
|
return []domain.LinkLogEvent{}
|
|
}
|
|
end := offset + limit
|
|
if end > len(events) {
|
|
end = len(events)
|
|
}
|
|
return events[offset:end]
|
|
}
|
|
|
|
func (s *Store) LogPolicy(ctx context.Context) (domain.LogPolicySettings, error) {
|
|
s.mu.Lock()
|
|
retention := cloneLogRetention(s.runningRetention)
|
|
syslog := cloneRemoteSyslog(s.runningSyslog)
|
|
s.mu.Unlock()
|
|
linkPollInterval := time.Duration(retention.SinglePortStatusIntervalMinutes) * time.Minute
|
|
if retention.SinglePortStatusRealtime {
|
|
linkPollInterval = time.Second
|
|
}
|
|
remoteCategoryEnabled := retention.RemoteSystemEnabled || retention.RemoteConfigurationEnabled || retention.RemoteOperationEnabled || retention.RemoteStatusEnabled
|
|
remoteSyslogForwarding := syslog.Enabled && strings.TrimSpace(syslog.Host) != "" && remoteCategoryEnabled
|
|
return domain.LogPolicySettings{
|
|
MaxRetentionDays: logpolicy.MaxRetentionDays, CommandResultCapture: true,
|
|
PhysicalLinkLogging: retention.SinglePortStatusEnabled || retention.AllPortStatusEnabled,
|
|
PeriodicStatusLogging: retention.KeepaliveEnabled || retention.SinglePortStatusEnabled || retention.AllPortStatusEnabled,
|
|
LinkPollIntervalSeconds: int(linkPollInterval / time.Second),
|
|
KeepaliveIntervalSeconds: retention.KeepaliveIntervalMinutes * 60,
|
|
SnapshotIntervalSeconds: retention.AllPortStatusIntervalMinutes * 60,
|
|
AtRestEncryption: false, LocalStorage: true, RemoteSyslogForwarding: remoteSyslogForwarding,
|
|
RawUARTPayloadCapture: true, DirectModePayloadCapture: true,
|
|
MaxPayloadBytes: logpolicy.MaxSerialPayloadBytes,
|
|
MaxCommandCharacters: logpolicy.MaxCommandCharacters, MaxReturnCharacters: logpolicy.MaxReturnCharacters,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) LogRetention(ctx context.Context) (domain.LogRetentionSettings, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return makeLogRetentionSettings(s.runningRetention), nil
|
|
}
|
|
|
|
func (s *Store) UpdateLogRetention(ctx context.Context, update domain.LogRetentionUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if logRetentionUpdateEmpty(update) {
|
|
err := fmt.Errorf("log retention update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
|
|
next := s.effectivePendingRetentionLocked()
|
|
if update.SystemDays != nil {
|
|
if err := validateRetentionDays("system_days", *update.SystemDays); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
next.SystemDays = *update.SystemDays
|
|
}
|
|
if update.AuditDays != nil {
|
|
if err := validateRetentionDays("audit_days", *update.AuditDays); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
next.AuditDays = *update.AuditDays
|
|
}
|
|
if update.PortDays != nil {
|
|
if err := validateRetentionDays("port_days", *update.PortDays); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
next.PortDays = *update.PortDays
|
|
}
|
|
if update.ProtectedUsageWarningPct != nil {
|
|
if *update.ProtectedUsageWarningPct < 50 || *update.ProtectedUsageWarningPct > 95 {
|
|
err := fmt.Errorf("protected_usage_warning_pct must be between 50 and 95")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
next.ProtectedUsageWarningPct = *update.ProtectedUsageWarningPct
|
|
}
|
|
if update.PreserveOnFactoryReset != nil {
|
|
next.PreserveOnFactoryReset = *update.PreserveOnFactoryReset
|
|
}
|
|
applyLogEventUpdate(&next, update)
|
|
if err := validateLogRetention(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", err)
|
|
}
|
|
|
|
s.pendingRetention = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "log retention update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "log.retention.update", "logs", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("log_retention_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func logRetentionUpdateEmpty(update domain.LogRetentionUpdate) bool {
|
|
return update.SystemDays == nil && update.AuditDays == nil && update.PortDays == nil &&
|
|
update.ProtectedUsageWarningPct == nil && update.PreserveOnFactoryReset == nil &&
|
|
update.LocalSystemEnabled == nil && update.LocalConfigurationEnabled == nil &&
|
|
update.LocalOperationEnabled == nil && update.LocalStatusEnabled == nil &&
|
|
update.RemoteSystemEnabled == nil && update.RemoteConfigurationEnabled == nil &&
|
|
update.RemoteOperationEnabled == nil && update.RemoteStatusEnabled == nil &&
|
|
update.KeepaliveEnabled == nil && update.KeepaliveIntervalMinutes == nil &&
|
|
update.AllPortStatusEnabled == nil && update.AllPortStatusIntervalMinutes == nil &&
|
|
update.SinglePortStatusEnabled == nil && update.SinglePortStatusRealtime == nil &&
|
|
update.SinglePortStatusIntervalMinutes == nil
|
|
}
|
|
|
|
func applyLogEventUpdate(next *logRetentionConfig, update domain.LogRetentionUpdate) {
|
|
if update.LocalSystemEnabled != nil {
|
|
next.LocalSystemEnabled = *update.LocalSystemEnabled
|
|
}
|
|
if update.LocalConfigurationEnabled != nil {
|
|
next.LocalConfigurationEnabled = *update.LocalConfigurationEnabled
|
|
}
|
|
if update.LocalOperationEnabled != nil {
|
|
next.LocalOperationEnabled = *update.LocalOperationEnabled
|
|
}
|
|
if update.LocalStatusEnabled != nil {
|
|
next.LocalStatusEnabled = *update.LocalStatusEnabled
|
|
}
|
|
if update.RemoteSystemEnabled != nil {
|
|
next.RemoteSystemEnabled = *update.RemoteSystemEnabled
|
|
}
|
|
if update.RemoteConfigurationEnabled != nil {
|
|
next.RemoteConfigurationEnabled = *update.RemoteConfigurationEnabled
|
|
}
|
|
if update.RemoteOperationEnabled != nil {
|
|
next.RemoteOperationEnabled = *update.RemoteOperationEnabled
|
|
}
|
|
if update.RemoteStatusEnabled != nil {
|
|
next.RemoteStatusEnabled = *update.RemoteStatusEnabled
|
|
}
|
|
if update.KeepaliveEnabled != nil {
|
|
next.KeepaliveEnabled = *update.KeepaliveEnabled
|
|
}
|
|
if update.KeepaliveIntervalMinutes != nil {
|
|
next.KeepaliveIntervalMinutes = *update.KeepaliveIntervalMinutes
|
|
}
|
|
if update.AllPortStatusEnabled != nil {
|
|
next.AllPortStatusEnabled = *update.AllPortStatusEnabled
|
|
}
|
|
if update.AllPortStatusIntervalMinutes != nil {
|
|
next.AllPortStatusIntervalMinutes = *update.AllPortStatusIntervalMinutes
|
|
}
|
|
if update.SinglePortStatusEnabled != nil {
|
|
next.SinglePortStatusEnabled = *update.SinglePortStatusEnabled
|
|
}
|
|
if update.SinglePortStatusRealtime != nil {
|
|
next.SinglePortStatusRealtime = *update.SinglePortStatusRealtime
|
|
}
|
|
if update.SinglePortStatusIntervalMinutes != nil {
|
|
next.SinglePortStatusIntervalMinutes = *update.SinglePortStatusIntervalMinutes
|
|
}
|
|
}
|
|
|
|
func (s *Store) RecordAudit(ctx context.Context, event domain.AuditEvent) (string, error) {
|
|
s.mu.Lock()
|
|
if event.Actor == "" {
|
|
event.Actor = domain.ActorName(ctx)
|
|
}
|
|
event = enrichAuditEvent(ctx, event)
|
|
auditID := s.recordAuditEventLocked(event)
|
|
err := s.persistAuditLogLocked()
|
|
stored := s.auditEvents[len(s.auditEvents)-1]
|
|
forward := s.remoteSyslogRequestLocked(auditEventLogType(stored.Action), fmt.Sprintf("actor=%s command=%q result=%s return=%q", stored.Actor, stored.Command, stored.Result, stored.ReturnValue))
|
|
s.mu.Unlock()
|
|
if err == nil {
|
|
s.deliverBestEffort(ctx, forward)
|
|
}
|
|
return auditID, err
|
|
}
|
|
|
|
func (s *Store) RecordSystemLog(ctx context.Context, event domain.SystemLogEvent) (domain.SystemLogEvent, error) {
|
|
if event.Timestamp.IsZero() {
|
|
event.Timestamp = s.clock().UTC()
|
|
}
|
|
event.Source, _ = logpolicy.Sanitize(event.Source, 64)
|
|
event.Message, _ = logpolicy.Sanitize(event.Message, logpolicy.MaxReturnCharacters)
|
|
event.Category = strings.ToLower(strings.TrimSpace(event.Category))
|
|
if event.Category == "" {
|
|
event.Category = "system"
|
|
}
|
|
if err := validateSystemLogEvent(event); err != nil {
|
|
return domain.SystemLogEvent{}, err
|
|
}
|
|
s.mu.Lock()
|
|
var err error
|
|
if s.localLogCategoryEnabledLocked(event.Category) {
|
|
s.systemLogLoaded = true
|
|
s.systemLogEvents = append(s.systemLogEvents, event)
|
|
s.trimSystemLogEventsLocked()
|
|
err = s.persistSystemLogStoreLocked()
|
|
}
|
|
forward := s.remoteSyslogRequestLocked(event.Category, event.Source+": "+event.Message)
|
|
s.mu.Unlock()
|
|
if err == nil {
|
|
s.deliverBestEffort(ctx, forward)
|
|
}
|
|
return event, err
|
|
}
|
|
|
|
func (s *Store) localLogCategoryEnabledLocked(category string) bool {
|
|
switch category {
|
|
case "system":
|
|
return s.runningRetention.LocalSystemEnabled
|
|
case "configuration":
|
|
return s.runningRetention.LocalConfigurationEnabled
|
|
case "operation":
|
|
return s.runningRetention.LocalOperationEnabled
|
|
case "status":
|
|
return s.runningRetention.LocalStatusEnabled
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Store) RecordPortLog(ctx context.Context, event domain.PortLogEvent) (domain.PortLogEvent, error) {
|
|
if event.Timestamp.IsZero() {
|
|
event.Timestamp = s.clock().UTC()
|
|
}
|
|
event.Channel = strings.ToUpper(strings.TrimSpace(event.Channel))
|
|
if event.Actor == "" {
|
|
event.Actor = domain.ActorName(ctx)
|
|
}
|
|
if event.EventType == "" {
|
|
if strings.TrimSpace(event.Command) != "" {
|
|
event.EventType = "command"
|
|
} else {
|
|
event.EventType = "traffic"
|
|
}
|
|
}
|
|
event.EventType = strings.ToLower(strings.TrimSpace(event.EventType))
|
|
event.Direction = strings.ToUpper(strings.TrimSpace(event.Direction))
|
|
command, commandRedacted := logpolicy.Sanitize(event.Command, logpolicy.MaxCommandCharacters)
|
|
returnValue, resultRedacted := logpolicy.Sanitize(event.ReturnValue, logpolicy.MaxReturnCharacters)
|
|
message, messageRedacted := logpolicy.Sanitize(event.Message, logpolicy.MaxReturnCharacters)
|
|
event.Command, event.ReturnValue, event.Message = command, returnValue, message
|
|
event.Redacted = event.Redacted || commandRedacted || resultRedacted || messageRedacted
|
|
|
|
s.mu.Lock()
|
|
persistLocally := s.runningRetention.LocalOperationEnabled || event.EventType == "session"
|
|
if s.logJournal != nil && persistLocally {
|
|
if err := s.refreshPortJournalLocked(); err != nil {
|
|
s.mu.Unlock()
|
|
return domain.PortLogEvent{}, err
|
|
}
|
|
}
|
|
keywordContent := event.Command
|
|
if event.EventType == "traffic" && event.Direction == "RX" && len(event.Payload) > 0 {
|
|
keywordContent = strings.ToValidUTF8(string(event.Payload), "")
|
|
}
|
|
matchedRules := s.matchPortKeywordRulesLocked(event.Channel, keywordContent)
|
|
event.KeywordMatches = matchedRules
|
|
event.AlarmTriggered = len(matchedRules) > 0
|
|
if event.AlarmTriggered {
|
|
event.Match = "keyword"
|
|
}
|
|
if err := validatePortLogEvent(event); err != nil {
|
|
s.mu.Unlock()
|
|
return domain.PortLogEvent{}, err
|
|
}
|
|
var err error
|
|
if persistLocally {
|
|
s.portLogLoaded = true
|
|
s.portLogEvents = append(s.portLogEvents, event)
|
|
s.trimPortLogEventsLocked()
|
|
err = s.persistPortLogStoreLocked()
|
|
}
|
|
requests := s.alarmRequestsForRulesLocked(matchedRules, fmt.Sprintf("%s received keyword matched", event.Channel))
|
|
forwardMessage := event.Message
|
|
if event.EventType == "command" {
|
|
forwardMessage = fmt.Sprintf("%s actor=%s command=%q return=%q", event.Channel, event.Actor, event.Command, event.ReturnValue)
|
|
}
|
|
if forward := s.remoteSyslogRequestLocked("operation", forwardMessage); forward != nil {
|
|
requests = append(requests, *forward)
|
|
}
|
|
s.mu.Unlock()
|
|
if err == nil {
|
|
for index := range requests {
|
|
s.deliverBestEffort(ctx, &requests[index])
|
|
}
|
|
}
|
|
return event, err
|
|
}
|
|
|
|
func (s *Store) RecordLinkState(ctx context.Context, observation domain.LinkStateObservation) (*domain.LinkLogEvent, error) {
|
|
if observation.ObservedAt.IsZero() {
|
|
observation.ObservedAt = s.clock().UTC()
|
|
}
|
|
s.mu.Lock()
|
|
tracked, err := s.linkTracker.Observe(logpolicy.LinkObservation{
|
|
Kind: observation.Kind, Resource: observation.Resource, State: observation.State,
|
|
Signal: observation.Signal, ObservedAt: observation.ObservedAt,
|
|
})
|
|
if err != nil || tracked == nil {
|
|
s.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
event := domain.LinkLogEvent{
|
|
Timestamp: tracked.Timestamp, Kind: tracked.Kind, Resource: tracked.Resource,
|
|
State: tracked.State, PreviousState: tracked.PreviousState, Signal: tracked.Signal,
|
|
EventType: tracked.EventType, AlarmTriggered: tracked.AlarmTriggered,
|
|
Message: fmt.Sprintf("%s %s link %s", tracked.Kind, tracked.Resource, tracked.State),
|
|
}
|
|
if event.EventType == "transition" {
|
|
event.MatchedRuleIDs = s.matchLinkRulesLocked(event)
|
|
}
|
|
event.AlarmTriggered = len(event.MatchedRuleIDs) > 0
|
|
if err := validateLinkLogEvent(event); err != nil {
|
|
s.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
if s.runningRetention.LocalStatusEnabled {
|
|
s.linkLogLoaded = true
|
|
s.linkLogEvents = append(s.linkLogEvents, event)
|
|
s.trimLinkLogEventsLocked()
|
|
err = s.persistLinkLogStoreLocked()
|
|
}
|
|
requests := s.alarmRequestsForRulesLocked(event.MatchedRuleIDs, event.Message)
|
|
if forward := s.remoteSyslogRequestLocked("status", event.Message); forward != nil {
|
|
requests = append(requests, *forward)
|
|
}
|
|
s.mu.Unlock()
|
|
if err == nil {
|
|
for index := range requests {
|
|
s.deliverBestEffort(ctx, &requests[index])
|
|
}
|
|
}
|
|
return &event, err
|
|
}
|
|
|
|
func (s *Store) ExportLogs(ctx context.Context, request domain.LogExportRequest) (domain.Operation, error) {
|
|
normalized, err := normalizeLogExportRequest(request)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "log.export", "logs", err)
|
|
}
|
|
return s.logExportOperation(ctx, normalized)
|
|
}
|
|
|
|
type normalizedLogExportRequest struct {
|
|
logType string
|
|
category string
|
|
severity string
|
|
channel string
|
|
kind string
|
|
resource string
|
|
state string
|
|
keyword string
|
|
eventType string
|
|
direction string
|
|
actor string
|
|
from time.Time
|
|
to time.Time
|
|
maxRows int
|
|
}
|
|
|
|
func normalizeLogExportRequest(request domain.LogExportRequest) (normalizedLogExportRequest, error) {
|
|
normalized := normalizedLogExportRequest{
|
|
logType: strings.ToLower(strings.TrimSpace(request.LogType)),
|
|
category: strings.ToLower(strings.TrimSpace(request.Category)),
|
|
severity: strings.ToLower(strings.TrimSpace(request.Severity)),
|
|
channel: strings.ToUpper(strings.TrimSpace(request.Channel)),
|
|
kind: strings.ToLower(strings.TrimSpace(request.Kind)),
|
|
resource: strings.TrimSpace(request.Resource),
|
|
state: strings.ToLower(strings.TrimSpace(request.State)),
|
|
keyword: strings.TrimSpace(request.Keyword),
|
|
eventType: strings.ToLower(strings.TrimSpace(request.EventType)),
|
|
direction: strings.ToUpper(strings.TrimSpace(request.Direction)),
|
|
actor: strings.ToLower(strings.TrimSpace(request.Actor)),
|
|
from: request.From,
|
|
to: request.To,
|
|
maxRows: request.MaxRows,
|
|
}
|
|
if err := validateLogTimeRange(normalized.from, normalized.to); err != nil {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export %w", err)
|
|
}
|
|
if normalized.logType == "" {
|
|
normalized.logType = "port"
|
|
}
|
|
if normalized.logType != "port" && normalized.logType != "audit" && normalized.logType != "system" && normalized.logType != "link" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export log_type must be port, audit, system, or link")
|
|
}
|
|
if normalized.severity != "" && normalized.severity != "info" && normalized.severity != "warning" && normalized.severity != "critical" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export severity must be info, warning, or critical")
|
|
}
|
|
if normalized.category != "" && normalized.category != "system" && normalized.category != "configuration" && normalized.category != "operation" && normalized.category != "status" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export category must be system, configuration, operation, or status")
|
|
}
|
|
if normalized.logType != "system" && normalized.severity != "" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export severity requires log_type system")
|
|
}
|
|
if normalized.logType != "system" && normalized.category != "" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export category requires log_type system")
|
|
}
|
|
if normalized.channel != "" {
|
|
if _, err := parseChannel(normalized.channel); err != nil {
|
|
return normalizedLogExportRequest{}, err
|
|
}
|
|
}
|
|
if len(normalized.keyword) > maxLogExportKeywordLength {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export keyword must be at most %d characters", maxLogExportKeywordLength)
|
|
}
|
|
validPortEventType := normalized.eventType == "" || normalized.eventType == "traffic" || normalized.eventType == "command" || normalized.eventType == "session"
|
|
validLinkEventType := normalized.eventType == "" || normalized.eventType == "snapshot" || normalized.eventType == "transition"
|
|
if normalized.logType == "port" && !validPortEventType {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export event_type must be traffic, command, or session for port logs")
|
|
}
|
|
if normalized.logType == "link" && !validLinkEventType {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export event_type must be snapshot or transition for link logs")
|
|
}
|
|
if normalized.direction != "" && normalized.direction != "RX" && normalized.direction != "TX" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export direction must be RX or TX")
|
|
}
|
|
if (normalized.logType != "port" && normalized.logType != "link" && normalized.eventType != "") || (normalized.logType != "port" && normalized.direction != "") {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export event_type requires port or link logs and direction requires port logs")
|
|
}
|
|
if (normalized.logType == "system" || normalized.logType == "link") && normalized.actor != "" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export actor requires log_type port or audit")
|
|
}
|
|
if normalized.kind != "" && normalized.kind != "serial" && normalized.kind != "ethernet" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export kind must be serial or ethernet")
|
|
}
|
|
if normalized.state != "" && normalized.state != "up" && normalized.state != "down" && normalized.state != "unknown" {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export state must be up, down, or unknown")
|
|
}
|
|
if normalized.logType != "link" && (normalized.kind != "" || normalized.resource != "" || normalized.state != "") {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export kind, resource, and state require log_type link")
|
|
}
|
|
if normalized.maxRows == 0 {
|
|
normalized.maxRows = defaultLogExportMaxRows
|
|
}
|
|
if normalized.maxRows < 0 || normalized.maxRows > maxLogExportRows {
|
|
return normalizedLogExportRequest{}, fmt.Errorf("log export max_rows must be between 1 and %d", maxLogExportRows)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func (s *Store) logExportOperation(ctx context.Context, request normalizedLogExportRequest) (domain.Operation, error) {
|
|
target := "logs"
|
|
message := fmt.Sprintf("%s log export completed with max_rows=%d", request.logType, request.maxRows)
|
|
if request.channel != "" {
|
|
message = fmt.Sprintf("%s log export completed for %s with max_rows=%d", request.logType, request.channel, request.maxRows)
|
|
}
|
|
result := map[string]any{
|
|
"log_type": request.logType,
|
|
"max_rows": request.maxRows,
|
|
}
|
|
if request.severity != "" {
|
|
result["severity"] = request.severity
|
|
}
|
|
if request.category != "" {
|
|
result["category"] = request.category
|
|
}
|
|
if request.channel != "" {
|
|
result["channel"] = request.channel
|
|
}
|
|
if request.kind != "" {
|
|
result["kind"] = request.kind
|
|
}
|
|
if request.resource != "" {
|
|
result["resource"] = request.resource
|
|
}
|
|
if request.state != "" {
|
|
result["state"] = request.state
|
|
}
|
|
if request.keyword != "" {
|
|
result["keyword"] = request.keyword
|
|
}
|
|
if request.eventType != "" {
|
|
result["event_type"] = request.eventType
|
|
}
|
|
if request.direction != "" {
|
|
result["direction"] = request.direction
|
|
}
|
|
if request.actor != "" {
|
|
result["actor"] = request.actor
|
|
}
|
|
if !request.from.IsZero() {
|
|
result["from"] = request.from.Format(time.RFC3339)
|
|
}
|
|
if !request.to.IsZero() {
|
|
result["to"] = request.to.Format(time.RFC3339)
|
|
}
|
|
return s.newOperationWithResult(ctx, "log.export", target, message, result)
|
|
}
|
|
|
|
func (s *Store) StreamLogExport(ctx context.Context, request domain.LogExportRequest, writer io.Writer) (domain.Operation, error) {
|
|
if writer == nil {
|
|
return domain.Operation{}, errors.New("log export writer is required")
|
|
}
|
|
normalized, err := normalizeLogExportRequest(request)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "log.export", "logs", err)
|
|
}
|
|
rows, err := s.exportLogRows(normalized)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "log.export", "logs", err)
|
|
}
|
|
operation, err := s.logExportOperation(ctx, normalized)
|
|
if err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
encoder := json.NewEncoder(writer)
|
|
for _, row := range rows {
|
|
if err := encoder.Encode(row); err != nil {
|
|
return operation, fmt.Errorf("stream log export: %w", err)
|
|
}
|
|
}
|
|
return operation, nil
|
|
}
|
|
|
|
func (s *Store) exportLogRows(request normalizedLogExportRequest) ([]any, error) {
|
|
keyword := strings.ToLower(request.keyword)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
appendMatching := func(result []any, value any, searchable string) []any {
|
|
if len(result) >= request.maxRows || keyword != "" && !strings.Contains(strings.ToLower(searchable), keyword) {
|
|
return result
|
|
}
|
|
return append(result, value)
|
|
}
|
|
result := make([]any, 0, min(request.maxRows, 256))
|
|
switch request.logType {
|
|
case "audit":
|
|
if s.logJournal != nil {
|
|
if err := s.refreshAuditJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
for _, event := range s.auditEvents {
|
|
if !timestampInLogRange(event.Timestamp, request.from, request.to) || request.actor != "" && !strings.Contains(strings.ToLower(event.Actor), request.actor) {
|
|
continue
|
|
}
|
|
result = appendMatching(result, event, event.Actor+" "+event.Action+" "+event.Target+" "+event.Command+" "+event.ReturnValue+" "+event.Message)
|
|
}
|
|
case "system":
|
|
if s.logJournal != nil {
|
|
if err := s.refreshSystemJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
events := append([]domain.SystemLogEvent(nil), s.systemLogEvents...)
|
|
if !s.systemLogLoaded && s.allowSampleLogs {
|
|
events = sampleSystemLogEvents(s.clock().UTC())
|
|
}
|
|
for _, audit := range s.auditEvents {
|
|
if event, ok := auditSystemLog(audit); ok {
|
|
events = append(events, event)
|
|
}
|
|
}
|
|
sort.SliceStable(events, func(left, right int) bool {
|
|
return events[left].Timestamp.After(events[right].Timestamp)
|
|
})
|
|
for _, event := range events {
|
|
if !timestampInLogRange(event.Timestamp, request.from, request.to) || request.severity != "" && event.Severity != request.severity || request.category != "" && event.Category != request.category {
|
|
continue
|
|
}
|
|
result = appendMatching(result, event, event.Category+" "+event.Source+" "+event.Message)
|
|
}
|
|
case "port":
|
|
if s.logJournal != nil {
|
|
if err := s.refreshPortJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
events := s.portLogEvents
|
|
if !s.portLogLoaded && s.allowSampleLogs {
|
|
events = samplePortLogEvents(s.clock().UTC())
|
|
}
|
|
for _, event := range events {
|
|
if !timestampInLogRange(event.Timestamp, request.from, request.to) || request.actor != "" && !strings.Contains(strings.ToLower(event.Actor), request.actor) {
|
|
continue
|
|
}
|
|
if request.channel != "" && event.Channel != request.channel {
|
|
continue
|
|
}
|
|
if request.eventType != "" && normalizedPortEventType(event) != request.eventType {
|
|
continue
|
|
}
|
|
if request.direction != "" && event.Direction != request.direction {
|
|
continue
|
|
}
|
|
result = appendMatching(result, event, event.Channel+" "+event.Actor+" "+event.Command+" "+event.ReturnValue+" "+event.Message)
|
|
}
|
|
case "link":
|
|
if s.logJournal != nil {
|
|
if err := s.refreshLinkJournalLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
events := s.linkLogEvents
|
|
if !s.linkLogLoaded && s.allowSampleLogs {
|
|
events = sampleLinkLogEvents(s.clock().UTC())
|
|
}
|
|
for _, event := range events {
|
|
if !timestampInLogRange(event.Timestamp, request.from, request.to) {
|
|
continue
|
|
}
|
|
if request.kind != "" && event.Kind != request.kind || request.resource != "" && event.Resource != request.resource || request.state != "" && event.State != request.state {
|
|
continue
|
|
}
|
|
if request.eventType != "" && event.EventType != request.eventType {
|
|
continue
|
|
}
|
|
result = appendMatching(result, event, event.Kind+" "+event.Resource+" "+event.State+" "+event.Signal+" "+event.EventType+" "+event.Message)
|
|
}
|
|
}
|
|
if result == nil {
|
|
result = []any{}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) SyncBrowserTime(ctx context.Context, request domain.BrowserTimeSyncRequest) (domain.Operation, error) {
|
|
if request.BrowserTime.IsZero() {
|
|
err := fmt.Errorf("browser_time must be an RFC3339 timestamp")
|
|
return domain.Operation{}, s.rejectOperation(ctx, "time.sync_browser", "time", err)
|
|
}
|
|
timezone, err := normalizeTimezone(request.Timezone)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "time.sync_browser", "time", err)
|
|
}
|
|
|
|
result, err := s.timeController.Sync(ctx, systemtime.Request{BrowserTime: request.BrowserTime, Timezone: timezone})
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "time.sync_browser", "time", err)
|
|
}
|
|
message := fmt.Sprintf("browser time sync completed for %s (%s) through tera-timed", result.AppliedTime.UTC().Format(time.RFC3339), result.Timezone)
|
|
return s.newOperationWithResult(ctx, "time.sync_browser", "time", message, map[string]any{
|
|
"browser_time": result.AppliedTime.UTC().Format(time.RFC3339),
|
|
"timezone": result.Timezone,
|
|
})
|
|
}
|
|
|
|
func (s *Store) SyncNTPTime(ctx context.Context) (domain.Operation, error) {
|
|
s.mu.Lock()
|
|
settings := cloneTimeSettings(s.runningTime)
|
|
s.mu.Unlock()
|
|
if settings.Mode != "ntp" {
|
|
err := fmt.Errorf("time mode must be ntp before synchronization")
|
|
return domain.Operation{}, s.rejectOperation(ctx, "time.sync_ntp", "time", err)
|
|
}
|
|
|
|
result, err := s.ntpController.SyncNTP(ctx, systemtime.NTPRequest{
|
|
Servers: settings.NTPServers, Timezone: settings.Timezone,
|
|
})
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "time.sync_ntp", "time", err)
|
|
}
|
|
message := fmt.Sprintf("NTP synchronization completed for %s (%s) through tera-timed", result.AppliedTime.UTC().Format(time.RFC3339), result.Timezone)
|
|
return s.newOperationWithResult(ctx, "time.sync_ntp", "time", message, map[string]any{
|
|
"applied_time": result.AppliedTime.UTC().Format(time.RFC3339),
|
|
"timezone": result.Timezone,
|
|
"ntp_servers": append([]string(nil), result.Servers...),
|
|
})
|
|
}
|
|
|
|
func (s *Store) RunDiagnostics(ctx context.Context, kind string, request domain.DiagnosticsRequest) (domain.Operation, error) {
|
|
normalized, err := normalizeDiagnosticsKind(kind)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics.run", "diagnostics", err)
|
|
}
|
|
target, err := normalizeDiagnosticsTarget(normalized, request.Target)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
port, err := normalizeDiagnosticsPort(normalized, request.Port)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
count, err := normalizeDiagnosticsCount(normalized, request.Count)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
maxHops, err := normalizeDiagnosticsMaxHops(normalized, request.MaxHops)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
timeoutSeconds, err := normalizeDiagnosticsTimeout(request.TimeoutSeconds)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
if normalized == diagnostics.KindTraceroute {
|
|
timeoutSeconds = diagnostics.TracerouteOperationTimeoutSeconds(maxHops)
|
|
}
|
|
maxOutputBytes, err := normalizeDiagnosticsMaxOutput(request.MaxOutputBytes)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+normalized, "diagnostics", err)
|
|
}
|
|
if normalized == "support_bundle" && s.supportBundleService != nil {
|
|
return s.enqueueSupportBundle(ctx, timeoutSeconds, maxOutputBytes)
|
|
}
|
|
if s.diagnosticsRunner != nil && normalized != "support_bundle" {
|
|
return s.enqueueDiagnostics(ctx, diagnostics.Request{
|
|
Kind: normalized,
|
|
Target: target,
|
|
Port: port,
|
|
Count: count,
|
|
MaxHops: maxHops,
|
|
TimeoutSeconds: timeoutSeconds,
|
|
MaxOutputBytes: maxOutputBytes,
|
|
})
|
|
}
|
|
|
|
message := fmt.Sprintf("%s diagnostic completed through mock adapter with timeout_seconds=%d max_output_bytes=%d", strings.ReplaceAll(normalized, "_", " "), timeoutSeconds, maxOutputBytes)
|
|
if target != "" {
|
|
displayTarget := target
|
|
if port != 0 {
|
|
displayTarget = net.JoinHostPort(target, strconv.Itoa(port))
|
|
}
|
|
message = fmt.Sprintf("%s diagnostic completed for %s through mock adapter with timeout_seconds=%d max_output_bytes=%d", strings.ReplaceAll(normalized, "_", " "), displayTarget, timeoutSeconds, maxOutputBytes)
|
|
}
|
|
result := map[string]any{
|
|
"kind": normalized,
|
|
"target": target,
|
|
"port": port,
|
|
"count": count,
|
|
"max_hops": maxHops,
|
|
"timeout_seconds": timeoutSeconds,
|
|
"max_output_bytes": maxOutputBytes,
|
|
}
|
|
if normalized == "support_bundle" {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.newOperationLockedWithResultBuilder(ctx, "diagnostics."+normalized, "diagnostics", message, func(now time.Time, sequence int) map[string]any {
|
|
return withMockSupportBundleManifest(result, now, sequence)
|
|
})
|
|
}
|
|
return s.newOperationWithResult(ctx, "diagnostics."+normalized, "diagnostics", message, result)
|
|
}
|
|
|
|
func (s *Store) enqueueSupportBundle(ctx context.Context, timeoutSeconds, maxOutputBytes int) (domain.Operation, error) {
|
|
const kind = "support_bundle"
|
|
const action = "diagnostics.support_bundle"
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.diagnosticsQueue == nil || s.supportBundleService == nil {
|
|
err := errors.New("support bundle service is unavailable")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
if len(s.diagnosticsQueue) >= cap(s.diagnosticsQueue) {
|
|
err := errors.New("diagnostic operation queue is full")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
|
|
s.operationSeq++
|
|
now := s.clock().UTC()
|
|
operationID := fmt.Sprintf("op_%s_%06d", now.Format("20060102"), s.operationSeq)
|
|
bundleID := fmt.Sprintf("support_bundle_%s_%06d", now.Format("20060102"), s.operationSeq)
|
|
message := "support bundle collection queued"
|
|
auditID := s.recordAuditLocked(ctx, action, "diagnostics", "accepted", message)
|
|
operation := domain.Operation{
|
|
OperationID: operationID,
|
|
State: "queued",
|
|
Message: message,
|
|
AuditID: auditID,
|
|
Result: map[string]any{
|
|
"kind": kind,
|
|
"bundle_id": bundleID,
|
|
"timeout_seconds": timeoutSeconds,
|
|
"max_output_bytes": maxOutputBytes,
|
|
"adapter": "support_bundle",
|
|
},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
s.operations[operationID] = operation
|
|
s.trimOperationsLocked()
|
|
if err := s.persistOperationStoreLocked(); err != nil {
|
|
delete(s.operations, operationID)
|
|
return domain.Operation{}, err
|
|
}
|
|
job := diagnosticsJob{
|
|
operationID: operationID, action: action, actor: domain.ActorName(ctx),
|
|
request: diagnostics.Request{Kind: kind, TimeoutSeconds: timeoutSeconds, MaxOutputBytes: maxOutputBytes},
|
|
bundleID: bundleID, timeoutSeconds: timeoutSeconds, maxOutputBytes: maxOutputBytes,
|
|
}
|
|
select {
|
|
case s.diagnosticsQueue <- job:
|
|
return cloneOperation(operation), nil
|
|
default:
|
|
delete(s.operations, operationID)
|
|
_ = s.persistOperationStoreLocked()
|
|
err := errors.New("diagnostic operation queue is full")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
}
|
|
|
|
func (s *Store) enqueueDiagnostics(ctx context.Context, request diagnostics.Request) (domain.Operation, error) {
|
|
if err := diagnostics.ValidateRequest(request); err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "diagnostics."+request.Kind, "diagnostics", err)
|
|
}
|
|
action := "diagnostics." + request.Kind
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.diagnosticsQueue == nil {
|
|
err := errors.New("diagnostic operation executor is unavailable")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
if len(s.diagnosticsQueue) >= cap(s.diagnosticsQueue) {
|
|
err := errors.New("diagnostic operation queue is full")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
|
|
s.operationSeq++
|
|
now := s.clock().UTC()
|
|
operationID := fmt.Sprintf("op_%s_%06d", now.Format("20060102"), s.operationSeq)
|
|
message := fmt.Sprintf("%s diagnostic queued for %s", strings.ReplaceAll(request.Kind, "_", " "), request.Target)
|
|
auditID := s.recordAuditLocked(ctx, action, "diagnostics", "accepted", message)
|
|
operation := domain.Operation{
|
|
OperationID: operationID,
|
|
State: "queued",
|
|
Message: message,
|
|
AuditID: auditID,
|
|
Result: map[string]any{
|
|
"kind": request.Kind,
|
|
"target": request.Target,
|
|
"max_hops": request.MaxHops,
|
|
"timeout_seconds": request.TimeoutSeconds,
|
|
"max_output_bytes": request.MaxOutputBytes,
|
|
"adapter": "process",
|
|
},
|
|
ProgressPercent: 0,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
s.operations[operationID] = operation
|
|
s.trimOperationsLocked()
|
|
if err := s.persistOperationStoreLocked(); err != nil {
|
|
delete(s.operations, operationID)
|
|
return domain.Operation{}, err
|
|
}
|
|
job := diagnosticsJob{
|
|
operationID: operationID,
|
|
action: action,
|
|
actor: domain.ActorName(ctx),
|
|
request: request,
|
|
timeoutSeconds: request.TimeoutSeconds,
|
|
maxOutputBytes: request.MaxOutputBytes,
|
|
}
|
|
select {
|
|
case s.diagnosticsQueue <- job:
|
|
return cloneOperation(operation), nil
|
|
default:
|
|
delete(s.operations, operationID)
|
|
_ = s.persistOperationStoreLocked()
|
|
err := errors.New("diagnostic operation queue is full")
|
|
return domain.Operation{}, s.rejectCommandLocked(ctx, action, "diagnostics", err)
|
|
}
|
|
}
|
|
|
|
func (s *Store) startDiagnosticsExecutor(queueSize int) {
|
|
s.diagnosticsContext, s.diagnosticsCancel = context.WithCancel(context.Background())
|
|
s.diagnosticsQueue = make(chan diagnosticsJob, queueSize)
|
|
for worker := 0; worker < s.diagnosticsWorkers; worker++ {
|
|
s.diagnosticsWG.Add(1)
|
|
go s.diagnosticsWorker()
|
|
}
|
|
}
|
|
|
|
func (s *Store) diagnosticsWorker() {
|
|
defer s.diagnosticsWG.Done()
|
|
for {
|
|
select {
|
|
case <-s.diagnosticsContext.Done():
|
|
return
|
|
case job := <-s.diagnosticsQueue:
|
|
if s.diagnosticsContext.Err() != nil {
|
|
return
|
|
}
|
|
s.executeDiagnostics(job)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) executeDiagnostics(job diagnosticsJob) {
|
|
s.mu.Lock()
|
|
operation, ok := s.operations[job.operationID]
|
|
if !ok || operation.State != "queued" {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
now := s.clock().UTC()
|
|
operation.State = "running"
|
|
if job.request.Kind == "support_bundle" {
|
|
operation.Message = "support bundle collection running"
|
|
} else {
|
|
operation.Message = fmt.Sprintf("%s diagnostic running for %s", strings.ReplaceAll(job.request.Kind, "_", " "), job.request.Target)
|
|
}
|
|
operation.ProgressPercent = 10
|
|
operation.UpdatedAt = now
|
|
s.operations[job.operationID] = operation
|
|
s.trimOperationsLocked()
|
|
_ = s.persistOperationStoreLocked()
|
|
var bundleSections []supportbundle.Section
|
|
if job.request.Kind == "support_bundle" {
|
|
bundleSections = s.supportBundleSectionsLocked(now)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(s.diagnosticsContext, time.Duration(job.timeoutSeconds)*time.Second)
|
|
if job.request.Kind != "support_bundle" {
|
|
ctx = diagnostics.WithOutputReporter(ctx, func(progress diagnostics.OutputProgress) {
|
|
s.updateDiagnosticLiveOutput(job.operationID, progress)
|
|
})
|
|
}
|
|
var operationResult map[string]any
|
|
var runErr error
|
|
if job.request.Kind == "support_bundle" {
|
|
var artifact supportbundle.Artifact
|
|
artifact, runErr = s.supportBundleService.Create(ctx, supportbundle.CreateRequest{
|
|
BundleID: job.bundleID, GeneratedAt: now, Sections: bundleSections,
|
|
})
|
|
if runErr == nil {
|
|
operationResult = map[string]any{
|
|
"kind": job.request.Kind,
|
|
"bundle_id": artifact.BundleID,
|
|
"filename": artifact.Filename,
|
|
"size_bytes": artifact.SizeBytes,
|
|
"sha256": artifact.SHA256,
|
|
"included_sections": artifact.Sections,
|
|
"download_url": "/api/v1/diagnostics/support-bundles/" + artifact.BundleID,
|
|
"adapter": "support_bundle",
|
|
}
|
|
}
|
|
} else {
|
|
var result diagnostics.Result
|
|
result, runErr = s.diagnosticsRunner.Run(ctx, job.request)
|
|
if runErr == nil {
|
|
operationResult = result.OperationResult(job.timeoutSeconds, job.maxOutputBytes)
|
|
}
|
|
}
|
|
cancel()
|
|
if runErr != nil && errors.Is(runErr, context.Canceled) && s.diagnosticsContext.Err() != nil {
|
|
runErr = errors.New("diagnostic operation interrupted by service shutdown")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
operation, ok = s.operations[job.operationID]
|
|
if !ok {
|
|
return
|
|
}
|
|
now = s.clock().UTC()
|
|
completedAt := now
|
|
operation.ProgressPercent = 100
|
|
operation.UpdatedAt = now
|
|
operation.CompletedAt = &completedAt
|
|
actor := job.actor
|
|
if actor == "" {
|
|
actor = "system"
|
|
}
|
|
if runErr == nil {
|
|
operation.State = "succeeded"
|
|
if job.request.Kind == "support_bundle" {
|
|
operation.Message = "support bundle collection completed"
|
|
} else {
|
|
operation.Message = fmt.Sprintf("%s diagnostic completed for %s through process adapter", strings.ReplaceAll(job.request.Kind, "_", " "), job.request.Target)
|
|
}
|
|
operation.Result = operationResult
|
|
operation.AuditID = s.recordAuditEventLocked(domain.AuditEvent{
|
|
Actor: actor, Action: job.action, Target: "diagnostics", Result: "succeeded", Message: operation.Message,
|
|
})
|
|
} else {
|
|
operation.State = "failed"
|
|
failure := boundedOperationError(runErr)
|
|
partialResult := operation.Result
|
|
if job.request.Kind == "support_bundle" {
|
|
operation.Message = "support bundle collection failed: " + failure
|
|
} else {
|
|
operation.Message = fmt.Sprintf("%s diagnostic failed for %s: %s", strings.ReplaceAll(job.request.Kind, "_", " "), job.request.Target, failure)
|
|
}
|
|
operation.Result = map[string]any{
|
|
"kind": job.request.Kind,
|
|
"target": job.request.Target,
|
|
"timeout_seconds": job.timeoutSeconds,
|
|
"max_output_bytes": job.maxOutputBytes,
|
|
"adapter": diagnosticAdapter(job.request.Kind),
|
|
"error": failure,
|
|
}
|
|
for _, key := range []string{"output", "output_bytes", "output_truncated"} {
|
|
if value, exists := partialResult[key]; exists {
|
|
operation.Result[key] = value
|
|
}
|
|
}
|
|
if job.bundleID != "" {
|
|
operation.Result["bundle_id"] = job.bundleID
|
|
}
|
|
operation.AuditID = s.recordAuditEventLocked(domain.AuditEvent{
|
|
Actor: actor, Action: job.action, Target: "diagnostics", Result: "failed", Message: operation.Message,
|
|
})
|
|
}
|
|
s.operations[job.operationID] = operation
|
|
s.trimOperationsLocked()
|
|
_ = s.persistOperationStoreLocked()
|
|
}
|
|
|
|
func (s *Store) updateDiagnosticLiveOutput(operationID string, progress diagnostics.OutputProgress) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
operation, ok := s.operations[operationID]
|
|
if !ok || operation.State != "running" {
|
|
return
|
|
}
|
|
if operation.Result == nil {
|
|
operation.Result = make(map[string]any)
|
|
}
|
|
operation.Result["output"] = progress.Output
|
|
operation.Result["output_bytes"] = progress.Bytes
|
|
operation.Result["output_truncated"] = progress.Truncated
|
|
operation.UpdatedAt = s.clock().UTC()
|
|
s.operations[operationID] = operation
|
|
}
|
|
|
|
func boundedOperationError(err error) string {
|
|
if err == nil {
|
|
return "unknown failure"
|
|
}
|
|
message := strings.TrimSpace(err.Error())
|
|
message = strings.ReplaceAll(message, "\r", " ")
|
|
message = strings.ReplaceAll(message, "\n", " ")
|
|
runes := []rune(message)
|
|
if len(runes) > 256 {
|
|
message = string(runes[:256])
|
|
}
|
|
if message == "" {
|
|
return "unknown failure"
|
|
}
|
|
return message
|
|
}
|
|
|
|
func diagnosticAdapter(kind string) string {
|
|
if kind == "support_bundle" {
|
|
return "support_bundle"
|
|
}
|
|
return "process"
|
|
}
|
|
|
|
func (s *Store) supportBundleSectionsLocked(now time.Time) []supportbundle.Section {
|
|
auditEvents := make([]domain.AuditEvent, len(s.auditEvents))
|
|
copy(auditEvents, s.auditEvents)
|
|
systemLogEvents := s.systemLogEvents
|
|
if !s.systemLogLoaded && s.allowSampleLogs {
|
|
systemLogEvents = sampleSystemLogEvents(now)
|
|
}
|
|
systemLogs := make([]domain.SystemLogEvent, len(systemLogEvents))
|
|
copy(systemLogs, systemLogEvents)
|
|
portLogEvents := s.portLogEvents
|
|
if !s.portLogLoaded && s.allowSampleLogs {
|
|
portLogEvents = samplePortLogEvents(now)
|
|
}
|
|
portLogs := make([]domain.PortLogEvent, len(portLogEvents))
|
|
copy(portLogs, portLogEvents)
|
|
linkLogEvents := s.linkLogEvents
|
|
if !s.linkLogLoaded && s.allowSampleLogs {
|
|
linkLogEvents = sampleLinkLogEvents(now)
|
|
}
|
|
linkLogs := make([]domain.LinkLogEvent, len(linkLogEvents))
|
|
copy(linkLogs, linkLogEvents)
|
|
operationIDs := sortedOperationIDs(s.operations)
|
|
operations := make([]domain.Operation, 0, len(operationIDs))
|
|
for _, operationID := range operationIDs {
|
|
operations = append(operations, cloneOperation(s.operations[operationID]))
|
|
}
|
|
return []supportbundle.Section{
|
|
{Name: "system-status.json", Value: systemStatus()},
|
|
{Name: "service-health.json", Value: s.serviceHealth(now)},
|
|
{Name: "config-export.json", Value: s.configExportLocked(now)},
|
|
{Name: "audit-events.json", Value: auditEvents},
|
|
{Name: "system-log-events.json", Value: systemLogs},
|
|
{Name: "port-log-events.json", Value: portLogs},
|
|
{Name: "link-log-events.json", Value: linkLogs},
|
|
{Name: "operations.json", Value: operations},
|
|
}
|
|
}
|
|
|
|
func (s *Store) SupportBundle(ctx context.Context, bundleID string) (domain.SupportBundleArtifact, error) {
|
|
if s.supportBundleService == nil {
|
|
return domain.SupportBundleArtifact{}, errors.New("support bundle service is unavailable")
|
|
}
|
|
artifact, err := s.supportBundleService.Read(ctx, strings.TrimSpace(bundleID))
|
|
if err != nil {
|
|
return domain.SupportBundleArtifact{}, err
|
|
}
|
|
if _, err := s.RecordAudit(ctx, domain.AuditEvent{
|
|
Action: "diagnostics.support_bundle.download", Target: artifact.BundleID,
|
|
Result: "succeeded", Message: "support bundle downloaded",
|
|
}); err != nil {
|
|
return domain.SupportBundleArtifact{}, err
|
|
}
|
|
return domain.SupportBundleArtifact{
|
|
BundleID: artifact.BundleID, Filename: artifact.Filename, SizeBytes: artifact.SizeBytes,
|
|
SHA256: artifact.SHA256, Content: artifact.Content,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) recoverInterruptedDiagnosticsLocked(message string) {
|
|
now := s.clock().UTC()
|
|
for operationID, operation := range s.operations {
|
|
if !isAsyncDiagnosticOperation(operation) || (operation.State != "queued" && operation.State != "running") {
|
|
continue
|
|
}
|
|
kind, _ := operation.Result["kind"].(string)
|
|
operation.State = "failed"
|
|
operation.Message = message
|
|
operation.ProgressPercent = 100
|
|
operation.UpdatedAt = now
|
|
completedAt := now
|
|
operation.CompletedAt = &completedAt
|
|
operation.Result["error"] = message
|
|
operation.AuditID = s.recordAuditEventLocked(domain.AuditEvent{
|
|
Actor: "system", Action: "diagnostics." + kind, Target: "diagnostics", Result: "failed", Message: message,
|
|
})
|
|
s.operations[operationID] = operation
|
|
}
|
|
s.trimOperationsLocked()
|
|
}
|
|
|
|
func isAsyncDiagnosticOperation(operation domain.Operation) bool {
|
|
if operation.Result == nil {
|
|
return false
|
|
}
|
|
adapter, _ := operation.Result["adapter"].(string)
|
|
kind, _ := operation.Result["kind"].(string)
|
|
return (adapter == "process" && (kind == diagnostics.KindNetworkTest || kind == diagnostics.KindTraceroute)) ||
|
|
(adapter == "support_bundle" && kind == "support_bundle")
|
|
}
|
|
|
|
func (s *Store) Close() {
|
|
s.diagnosticsClose.Do(func() {
|
|
if s.diagnosticsCancel == nil {
|
|
return
|
|
}
|
|
s.diagnosticsCancel()
|
|
s.diagnosticsWG.Wait()
|
|
s.mu.Lock()
|
|
s.recoverInterruptedDiagnosticsLocked("diagnostic operation interrupted by service shutdown")
|
|
_ = s.persistOperationStoreLocked()
|
|
s.mu.Unlock()
|
|
})
|
|
}
|
|
|
|
func withMockSupportBundleManifest(result map[string]any, now time.Time, sequence int) map[string]any {
|
|
manifest := cloneOperationResult(result)
|
|
bundleID := fmt.Sprintf("support_bundle_%s_%06d", now.Format("20060102"), sequence)
|
|
sections := make([]string, len(mockSupportBundleIncludedSections))
|
|
copy(sections, mockSupportBundleIncludedSections)
|
|
manifest["bundle_id"] = bundleID
|
|
manifest["artifact_path"] = mockSupportBundleArtifactDir + "/" + bundleID + ".json"
|
|
manifest["retention_seconds"] = mockSupportBundleRetention
|
|
manifest["included_sections"] = sections
|
|
return manifest
|
|
}
|
|
|
|
func (s *Store) TestAlarmDelivery(ctx context.Context, request domain.AlarmDeliveryTestRequest) (domain.Operation, error) {
|
|
ruleID := strings.ToLower(strings.TrimSpace(request.RuleID))
|
|
message := normalizeAlarmDeliveryTestMessage(request.Message)
|
|
|
|
s.mu.Lock()
|
|
delivery := request.Delivery
|
|
if ruleID != "" {
|
|
rule, ok := s.runningAlarms[ruleID]
|
|
if !ok {
|
|
err := fmt.Errorf("alarm rule not found")
|
|
s.mu.Unlock()
|
|
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
|
|
}
|
|
if !rule.Enabled {
|
|
err := fmt.Errorf("alarm rule must be enabled for delivery test")
|
|
s.mu.Unlock()
|
|
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
|
|
}
|
|
if len(delivery) == 0 {
|
|
delivery = rule.Delivery
|
|
}
|
|
}
|
|
if len(delivery) == 0 {
|
|
delivery = []string{"web"}
|
|
}
|
|
targets, err := normalizeDelivery(delivery)
|
|
if err != nil {
|
|
s.mu.Unlock()
|
|
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
|
|
}
|
|
if err := s.validateAlarmDeliveryTargetsLocked(targets); err != nil {
|
|
s.mu.Unlock()
|
|
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
|
|
}
|
|
policy := s.runningAlarmDelivery
|
|
var syslogTarget *alarmdelivery.SyslogTarget
|
|
var emailTarget *alarmdelivery.EmailTarget
|
|
var snmpTargets []alarmdelivery.SNMPTrapTarget
|
|
for _, target := range targets {
|
|
if target == alarmdelivery.ChannelSyslog {
|
|
syslogTarget = &alarmdelivery.SyslogTarget{
|
|
Host: s.runningSyslog.Host, Port: s.runningSyslog.Port,
|
|
Protocol: s.runningSyslog.Protocol, Facility: s.runningSyslog.Facility,
|
|
}
|
|
}
|
|
if target == alarmdelivery.ChannelEmail {
|
|
emailTarget = &alarmdelivery.EmailTarget{Host: s.runningEmail.SMTPHost, Port: s.runningEmail.SMTPPort, STARTTLS: s.runningEmail.STARTTLSEnabled, Auth: s.runningEmail.AuthEnabled, Username: s.runningEmail.Username, PasswordKey: configSecretKey("running", "smtp/password"), From: s.runningEmail.From, To: append([]string(nil), s.runningEmail.To...), CC: append([]string(nil), s.runningEmail.CC...), Subject: s.runningEmail.CustomTitle, Content: strings.ReplaceAll(s.runningEmail.CustomContent, "{{message}}", message)}
|
|
}
|
|
if target == alarmdelivery.ChannelSNMPTrap {
|
|
for _, configured := range s.runningSNMPTraps {
|
|
if configured.Enabled {
|
|
prefix := "snmp/trap/" + strings.ToLower(configured.TargetID)
|
|
snmpTargets = append(snmpTargets, alarmdelivery.SNMPTrapTarget{
|
|
TargetID: configured.TargetID, Host: configured.Host, Port: configured.Port,
|
|
Version: configured.Version, Username: configured.Username,
|
|
AuthProtocol: configured.AuthProtocol, PrivacyProtocol: configured.PrivacyProtocol,
|
|
EngineID: configured.EngineID, CredentialKey: configSecretKey("running", prefix+"/credential"),
|
|
PrivacyKeyRef: configSecretKey("running", prefix+"/privacy"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
targetText := strings.Join(targets, ",")
|
|
if ruleID != "" {
|
|
message = fmt.Sprintf("%s for rule %s", message, ruleID)
|
|
}
|
|
deliveryResult, err := s.alarmDeliveryController.Deliver(ctx, alarmdelivery.Request{
|
|
RuleID: ruleID, Message: message, Channels: targets,
|
|
RetryCount: policy.RetryCount, RetryIntervalSeconds: policy.RetryIntervalSeconds,
|
|
QueueLimit: policy.PendingQueueLimit, Syslog: syslogTarget, Email: emailTarget, SNMPTraps: snmpTargets,
|
|
})
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "alarm.delivery.test", "alarm_delivery", err)
|
|
}
|
|
adapter := "tera-alarmd"
|
|
if _, ok := s.alarmDeliveryController.(alarmdelivery.MockController); ok {
|
|
adapter = "mock"
|
|
message = fmt.Sprintf("%s through %s mock delivery", message, targetText)
|
|
} else {
|
|
message = fmt.Sprintf("%s through %s delivery queued by tera-alarmd", message, targetText)
|
|
}
|
|
return s.newOperationWithResult(ctx, "alarm.delivery.test", "alarm_delivery", message, map[string]any{
|
|
"delivery_id": deliveryResult.DeliveryID,
|
|
"state": deliveryResult.State,
|
|
"attempts": deliveryResult.Attempts,
|
|
"channels": targets,
|
|
"adapter": adapter,
|
|
})
|
|
}
|
|
|
|
func (s *Store) RebootSystem(ctx context.Context) (domain.Operation, error) {
|
|
if err := s.systemController.Reboot(ctx); err != nil {
|
|
s.mu.Lock()
|
|
auditID := s.recordAuditLocked(ctx, "system.reboot", "system", "failed", err.Error())
|
|
s.mu.Unlock()
|
|
return domain.Operation{AuditID: auditID}, err
|
|
}
|
|
_ = s.EmitLifecycleAlarm(ctx, "warm_start", "device warm restart scheduled")
|
|
return s.newOperation(ctx, "system.reboot", "system", "device reboot scheduled by privileged system control")
|
|
}
|
|
|
|
// EmitLifecycleAlarm records a lifecycle event and dispatches the configured
|
|
// rule. The caller is responsible for distinguishing a new kernel boot from a
|
|
// daemon-only restart before emitting cold_start.
|
|
func (s *Store) EmitLifecycleAlarm(ctx context.Context, ruleID, message string) error {
|
|
ruleID = normalizeRuleID(ruleID)
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
return errors.New("lifecycle alarm message must not be empty")
|
|
}
|
|
if _, err := s.RecordSystemLog(ctx, domain.SystemLogEvent{
|
|
Timestamp: s.clock().UTC(), Category: "system", Severity: "info",
|
|
Source: "system-lifecycle", Message: message,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
rule, ok := s.runningAlarms[ruleID]
|
|
if !ok || rule.Source != "lifecycle" {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("lifecycle alarm rule not found")
|
|
}
|
|
requests := s.alarmRequestsForRulesLocked([]string{ruleID}, message)
|
|
s.mu.Unlock()
|
|
for index := range requests {
|
|
s.deliverBestEffort(ctx, &requests[index])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) FactoryReset(ctx context.Context) (domain.Operation, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.resetConfigLocked()
|
|
if err := s.applyJournalRetentionLocked(s.runningRetention); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
return s.newOperationLocked(ctx, "system.factory_reset", "system", "factory reset completed; protected logs preserved")
|
|
}
|
|
|
|
func (s *Store) OpenDirectSession(ctx context.Context, channel string, request domain.DirectSessionRequest) (domain.DirectSession, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.DirectSession{}, err
|
|
}
|
|
|
|
normalized := channelName(index)
|
|
requestedAccess := strings.ToLower(strings.TrimSpace(request.Access))
|
|
if requestedAccess != "" && requestedAccess != directstream.AccessViewer && requestedAccess != directstream.AccessWriter {
|
|
s.mu.Lock()
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "direct access must be viewer or writer")
|
|
s.mu.Unlock()
|
|
return domain.DirectSession{AuditID: auditID}, errors.New("direct access must be viewer or writer")
|
|
}
|
|
access := normalizeDirectAccess(request.Access)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
state := s.direct[normalized]
|
|
if state.MaxViewers == 0 {
|
|
state.MaxViewers = 4
|
|
}
|
|
if state.ViewerCount >= state.MaxViewers {
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "Direct Mode viewer limit reached")
|
|
return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct viewer limit reached")
|
|
}
|
|
if access == directstream.AccessWriter && state.Writer != "" && state.Writer != domain.ActorName(ctx) {
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "Direct Mode writer is already owned")
|
|
return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct writer already owned")
|
|
}
|
|
|
|
state.ViewerCount++
|
|
state.Sequence++
|
|
sessionID := directSessionID(normalized, state.Sequence)
|
|
message := fmt.Sprintf("Direct Mode %s session %s opened through tera-sessiond", access, sessionID)
|
|
ticket, err := s.directTickets.Issue(directstream.TicketClaims{SessionID: sessionID, Channel: normalized, Actor: domain.ActorName(ctx), Access: access})
|
|
if err != nil {
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "rejected", "Direct Mode stream ticket could not be issued")
|
|
return domain.DirectSession{AuditID: auditID}, err
|
|
}
|
|
if access == directstream.AccessWriter {
|
|
state.Writer = domain.ActorName(ctx)
|
|
state.WriterSessionID = sessionID
|
|
}
|
|
s.direct[normalized] = state
|
|
s.directSessions[sessionID] = directSessionRecord{SessionID: sessionID, Channel: normalized, Actor: domain.ActorName(ctx), Access: access}
|
|
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.open", normalized, "succeeded", message)
|
|
return directSession(normalized, domain.ActorName(ctx), access, state, message, auditID, ticket), nil
|
|
}
|
|
|
|
func (s *Store) CloseDirectSession(ctx context.Context, channel string, sessionID string) (domain.DirectSession, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.DirectSession{}, err
|
|
}
|
|
normalized := channelName(index)
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return domain.DirectSession{}, errors.New("session_id is required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
record, ok := s.directSessions[sessionID]
|
|
if !ok || record.Channel != normalized {
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.close", normalized, "rejected", "Direct Mode session not found")
|
|
return domain.DirectSession{AuditID: auditID}, errors.New("direct session not found")
|
|
}
|
|
if record.Actor != domain.ActorName(ctx) {
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.close", normalized, "authorization_denied", "Direct Mode session is owned by another actor")
|
|
return domain.DirectSession{AuditID: auditID}, errors.New("direct session is owned by another actor")
|
|
}
|
|
|
|
state := s.direct[normalized]
|
|
if state.ViewerCount > 0 {
|
|
state.ViewerCount--
|
|
}
|
|
if state.WriterSessionID == sessionID {
|
|
state.Writer = ""
|
|
state.WriterSessionID = ""
|
|
}
|
|
delete(s.directSessions, sessionID)
|
|
s.direct[normalized] = state
|
|
message := fmt.Sprintf("Direct Mode session %s closed", sessionID)
|
|
auditID := s.recordAuditLocked(ctx, "direct.session.close", normalized, "succeeded", message)
|
|
return directLifecycleSession(sessionID, normalized, record.Actor, record.Access, state, "closed", message, auditID), nil
|
|
}
|
|
|
|
func (s *Store) ClaimDirectWriter(ctx context.Context, channel string) (domain.DirectSession, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.DirectSession{}, err
|
|
}
|
|
|
|
normalized := channelName(index)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
state := s.direct[normalized]
|
|
if state.MaxViewers == 0 {
|
|
state.MaxViewers = 4
|
|
}
|
|
if state.Writer != "" && state.Writer != domain.ActorName(ctx) {
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.claim", normalized, "rejected", "Direct Mode writer is already owned")
|
|
return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct writer already owned")
|
|
}
|
|
record, found := s.firstDirectSessionLocked(normalized, domain.ActorName(ctx))
|
|
if !found {
|
|
if state.ViewerCount >= state.MaxViewers {
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.claim", normalized, "rejected", "Direct Mode viewer limit reached")
|
|
return domain.DirectSession{AuditID: auditID}, fmt.Errorf("direct viewer limit reached")
|
|
}
|
|
state.ViewerCount++
|
|
state.Sequence++
|
|
record = directSessionRecord{SessionID: directSessionID(normalized, state.Sequence), Channel: normalized, Actor: domain.ActorName(ctx), Access: directstream.AccessViewer}
|
|
}
|
|
record.Access = directstream.AccessWriter
|
|
state.Writer = domain.ActorName(ctx)
|
|
state.WriterSessionID = record.SessionID
|
|
message := fmt.Sprintf("Direct Mode writer session %s claimed through tera-sessiond", record.SessionID)
|
|
ticket, err := s.directTickets.Issue(directstream.TicketClaims{SessionID: record.SessionID, Channel: normalized, Actor: domain.ActorName(ctx), Access: directstream.AccessWriter})
|
|
if err != nil {
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.claim", normalized, "rejected", "Direct Mode stream ticket could not be issued")
|
|
return domain.DirectSession{AuditID: auditID}, err
|
|
}
|
|
s.direct[normalized] = state
|
|
s.directSessions[record.SessionID] = record
|
|
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.claim", normalized, "succeeded", message)
|
|
return directSessionWithID(record.SessionID, normalized, domain.ActorName(ctx), directstream.AccessWriter, state, message, auditID, ticket), nil
|
|
}
|
|
|
|
func (s *Store) ReleaseDirectWriter(ctx context.Context, channel string) (domain.DirectSession, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.DirectSession{}, err
|
|
}
|
|
normalized := channelName(index)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
state := s.direct[normalized]
|
|
if state.Writer == "" || state.WriterSessionID == "" {
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "rejected", "Direct Mode writer not found")
|
|
return domain.DirectSession{AuditID: auditID}, errors.New("direct writer not found")
|
|
}
|
|
if state.Writer != domain.ActorName(ctx) {
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "authorization_denied", "Direct Mode writer is owned by another actor")
|
|
return domain.DirectSession{AuditID: auditID}, errors.New("direct writer is owned by another actor")
|
|
}
|
|
record := s.directSessions[state.WriterSessionID]
|
|
record.Access = directstream.AccessViewer
|
|
s.directSessions[record.SessionID] = record
|
|
state.Writer = ""
|
|
state.WriterSessionID = ""
|
|
s.direct[normalized] = state
|
|
message := fmt.Sprintf("Direct Mode writer session %s ownership released", record.SessionID)
|
|
auditID := s.recordAuditLocked(ctx, "direct.writer.release", normalized, "succeeded", message)
|
|
return directLifecycleSession(record.SessionID, normalized, record.Actor, directstream.AccessViewer, state, "open", message, auditID), nil
|
|
}
|
|
|
|
func (s *Store) UpdatePort(ctx context.Context, channel string, update domain.PortUpdate) (domain.CommandResult, error) {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
normalized := channelName(index)
|
|
update, secretChanges, err := s.preparePortCredentialUpdateLocked(normalized, update)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.update", normalized, err)
|
|
}
|
|
next, err := applyPortUpdate(s.effectivePendingLocked(normalized), index, update)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.update", normalized, err)
|
|
}
|
|
effective := s.effectivePortConfigsLocked()
|
|
effective[normalized] = next
|
|
if err := validatePortOperationConflicts(effective); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.update", normalized, err)
|
|
}
|
|
|
|
previous, previousFound := s.pending[normalized]
|
|
previousAlarms := cloneAlarmRuleConfig(s.pendingAlarms)
|
|
s.pending[normalized] = next
|
|
if update.AlarmKeyword != nil {
|
|
s.stagePortKeywordRuleLocked(normalized, next.AlarmKeyword)
|
|
}
|
|
summary := s.configSummaryLocked()
|
|
message := normalized + " update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "port.update", normalized, "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
if previousFound {
|
|
s.pending[normalized] = previous
|
|
} else {
|
|
delete(s.pending, normalized)
|
|
}
|
|
s.pendingAlarms = previousAlarms
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.secrets.Update(secretChanges); err != nil {
|
|
if previousFound {
|
|
s.pending[normalized] = previous
|
|
} else {
|
|
delete(s.pending, normalized)
|
|
}
|
|
s.pendingAlarms = previousAlarms
|
|
_ = s.persistConfigStoreLocked()
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("port_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdatePorts(ctx context.Context, channels []string, update domain.PortUpdate) (domain.CommandResult, error) {
|
|
if len(channels) == 0 || len(channels) > serial.ChannelCount {
|
|
return domain.CommandResult{}, fmt.Errorf("channels must include 1 through 48 unique channels")
|
|
}
|
|
type target struct {
|
|
channel string
|
|
index int
|
|
}
|
|
targets := make([]target, 0, len(channels))
|
|
seen := make(map[string]bool, len(channels))
|
|
for _, value := range channels {
|
|
index, err := parseChannel(value)
|
|
if err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
channel := channelName(index)
|
|
if seen[channel] {
|
|
return domain.CommandResult{}, fmt.Errorf("channels must not include duplicates")
|
|
}
|
|
seen[channel] = true
|
|
targets = append(targets, target{channel: channel, index: index})
|
|
}
|
|
sort.Slice(targets, func(left, right int) bool { return targets[left].index < targets[right].index })
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
candidates := make(map[string]portConfig, len(targets))
|
|
secretChanges := make(map[string]*[]byte)
|
|
for _, item := range targets {
|
|
sanitized, changes, err := s.preparePortCredentialUpdateLocked(item.channel, update)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.bulk_update", item.channel, err)
|
|
}
|
|
for key, value := range changes {
|
|
secretChanges[key] = value
|
|
}
|
|
next, err := applyPortUpdate(s.effectivePendingLocked(item.channel), item.index, sanitized)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.bulk_update", item.channel, err)
|
|
}
|
|
candidates[item.channel] = next
|
|
}
|
|
effective := s.effectivePortConfigsLocked()
|
|
for channel, config := range candidates {
|
|
effective[channel] = config
|
|
}
|
|
if err := validatePortOperationConflicts(effective); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "port.bulk_update", "ports", err)
|
|
}
|
|
previous := clonePortConfig(s.pending)
|
|
previousAlarms := cloneAlarmRuleConfig(s.pendingAlarms)
|
|
for channel, config := range candidates {
|
|
s.pending[channel] = config
|
|
if update.AlarmKeyword != nil {
|
|
s.stagePortKeywordRuleLocked(channel, config.AlarmKeyword)
|
|
}
|
|
}
|
|
summary := s.configSummaryLocked()
|
|
targetNames := make([]string, 0, len(targets))
|
|
for _, item := range targets {
|
|
targetNames = append(targetNames, item.channel)
|
|
}
|
|
targetName := strings.Join(targetNames, ",")
|
|
message := fmt.Sprintf("%d port updates staged in pending changes", len(targets))
|
|
auditID := s.recordAuditLocked(ctx, "port.bulk_update", targetName, "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
s.pending = previous
|
|
s.pendingAlarms = previousAlarms
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.secrets.Update(secretChanges); err != nil {
|
|
s.pending = previous
|
|
s.pendingAlarms = previousAlarms
|
|
_ = s.persistConfigStoreLocked()
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("port_bulk_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) preparePortCredentialUpdateLocked(channel string, update domain.PortUpdate) (domain.PortUpdate, map[string]*[]byte, error) {
|
|
changes := make(map[string]*[]byte)
|
|
for number, operationPointer := range []*domain.PortOperation{update.Operation1, update.Operation2} {
|
|
if operationPointer == nil {
|
|
continue
|
|
}
|
|
operation := *operationPointer
|
|
if operation.Credential != "" || operation.ClearCredential {
|
|
return domain.PortUpdate{}, nil, fmt.Errorf("operation_%d embedded credentials are not supported; use Local, RADIUS, or TACACS", number+1)
|
|
}
|
|
secretKey := portCredentialKey("pending", channel, number+1)
|
|
clearKey := portCredentialClearKey("pending", channel, number+1)
|
|
changes[secretKey] = nil
|
|
changes[clearKey] = nil
|
|
operation.PasswordStored = false
|
|
operation.Credential = ""
|
|
operation.ClearCredential = false
|
|
if number == 0 {
|
|
update.Operation1 = &operation
|
|
} else {
|
|
update.Operation2 = &operation
|
|
}
|
|
}
|
|
return update, changes, nil
|
|
}
|
|
|
|
func portCredentialKey(phase, channel string, operation int) string {
|
|
return fmt.Sprintf("port.%s.%s.%d", phase, strings.ToLower(channel), operation)
|
|
}
|
|
|
|
func portCredentialClearKey(phase, channel string, operation int) string {
|
|
return portCredentialKey(phase, channel, operation) + ".clear"
|
|
}
|
|
|
|
func applyPortUpdate(next portConfig, index int, update domain.PortUpdate) (portConfig, error) {
|
|
if update.Alias != nil {
|
|
next.Alias = *update.Alias
|
|
}
|
|
if update.Mode != nil {
|
|
next.Mode = normalizeMode(*update.Mode)
|
|
if !validPortOperationMode(next.Mode) {
|
|
return portConfig{}, fmt.Errorf("mode must be disabled, reverse_ssh, or reverse_telnet")
|
|
}
|
|
next.Operation1 = defaultPortOperation(index, next.Mode)
|
|
next.ListenerPort = next.Operation1.Port
|
|
}
|
|
if update.SerialType != nil {
|
|
next.SerialType = *update.SerialType
|
|
}
|
|
if update.BaudRate != nil {
|
|
if *update.BaudRate < 110 || *update.BaudRate > 460800 {
|
|
return portConfig{}, fmt.Errorf("baud_rate must be between 110 and 460800")
|
|
}
|
|
if !isSupportedBaudRate(*update.BaudRate) {
|
|
return portConfig{}, fmt.Errorf("baud_rate is not supported")
|
|
}
|
|
next.BaudRate = *update.BaudRate
|
|
}
|
|
if update.DataBits != nil {
|
|
next.DataBits = *update.DataBits
|
|
}
|
|
if update.Parity != nil {
|
|
next.Parity = strings.ToLower(strings.TrimSpace(*update.Parity))
|
|
}
|
|
if update.StopBits != nil {
|
|
next.StopBits = *update.StopBits
|
|
next.StopBitsMode = strconv.Itoa(*update.StopBits)
|
|
}
|
|
if update.StopBitsMode != nil {
|
|
next.StopBitsMode = strings.TrimSpace(*update.StopBitsMode)
|
|
}
|
|
if update.FlowControl != nil {
|
|
next.FlowControl = strings.ToLower(strings.TrimSpace(*update.FlowControl))
|
|
}
|
|
if update.RTSControl != nil {
|
|
next.RTSControl = *update.RTSControl
|
|
}
|
|
if update.DTRControl != nil {
|
|
next.DTRControl = *update.DTRControl
|
|
}
|
|
if update.IgnoreJammed != nil {
|
|
next.IgnoreJammed = *update.IgnoreJammed
|
|
}
|
|
if update.AlarmKeyword != nil {
|
|
next.AlarmKeyword = strings.TrimSpace(*update.AlarmKeyword)
|
|
}
|
|
if update.DataLogMode != nil {
|
|
next.DataLogMode = strings.ToLower(strings.TrimSpace(*update.DataLogMode))
|
|
if !validDataLogMode(next.DataLogMode) {
|
|
return portConfig{}, fmt.Errorf("data_log_mode must be off, rx, tx, or rx_tx")
|
|
}
|
|
next.DataLog.Read = next.DataLogMode == serial.CaptureModeRX || next.DataLogMode == serial.CaptureModeRXTX
|
|
next.DataLog.Send = next.DataLogMode == serial.CaptureModeTX || next.DataLogMode == serial.CaptureModeRXTX
|
|
}
|
|
// Data Package is removed from the product UI and is always disabled.
|
|
next.DataPackage = disabledDataPackage()
|
|
if update.DataLog != nil {
|
|
next.DataLog = *update.DataLog
|
|
next.DataLogMode = dataLogCaptureMode(next.DataLog)
|
|
}
|
|
if update.Operation1 != nil {
|
|
next.Operation1 = *update.Operation1
|
|
next.Mode = next.Operation1.Mode
|
|
next.ListenerPort = next.Operation1.Port
|
|
}
|
|
if update.Operation2 != nil {
|
|
next.Operation2 = *update.Operation2
|
|
}
|
|
if update.RedirectTo != nil {
|
|
next.RedirectTo = append([]string(nil), update.RedirectTo...)
|
|
}
|
|
validated, err := normalizePortConfig(next, index, false)
|
|
if err != nil {
|
|
return portConfig{}, err
|
|
}
|
|
return validated, nil
|
|
}
|
|
|
|
func (s *Store) effectivePortConfigsLocked() map[string]portConfig {
|
|
result := clonePortConfig(s.running)
|
|
for channel, config := range s.pending {
|
|
result[channel] = config
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validatePortOperationConflicts(configs map[string]portConfig) error {
|
|
owners := make(map[int]string)
|
|
channels := make([]string, 0, len(configs))
|
|
for channel := range configs {
|
|
channels = append(channels, channel)
|
|
}
|
|
sort.Strings(channels)
|
|
for _, channel := range channels {
|
|
config := configs[channel]
|
|
for number, operation := range []domain.PortOperation{config.Operation1, config.Operation2} {
|
|
if operation.Mode != "reverse_ssh" && operation.Mode != "reverse_telnet" {
|
|
continue
|
|
}
|
|
owner := fmt.Sprintf("%s operation_%d", channel, number+1)
|
|
if previous := owners[operation.Port]; previous != "" {
|
|
return fmt.Errorf("listener port %d conflicts between %s and %s", operation.Port, previous, owner)
|
|
}
|
|
owners[operation.Port] = owner
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func dataLogCaptureMode(settings domain.DataLog) string {
|
|
switch {
|
|
case settings.Read && settings.Send:
|
|
return serial.CaptureModeRXTX
|
|
case settings.Read:
|
|
return serial.CaptureModeRX
|
|
case settings.Send:
|
|
return serial.CaptureModeTX
|
|
default:
|
|
return serial.CaptureModeOff
|
|
}
|
|
}
|
|
|
|
func (s *Store) UpdateAlarmRule(ctx context.Context, ruleID string, update domain.AlarmRuleUpdate) (domain.CommandResult, error) {
|
|
normalized := normalizeRuleID(ruleID)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.runningAlarms[normalized]; !ok {
|
|
err := fmt.Errorf("alarm rule not found")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.rule.update", normalized, err)
|
|
}
|
|
|
|
next := s.effectivePendingAlarmLocked(normalized)
|
|
previousPorts := clonePortConfig(s.pending)
|
|
if update.Trigger != nil {
|
|
trigger := strings.TrimSpace(*update.Trigger)
|
|
if trigger == "" {
|
|
if next.Source != "port_log" || update.Enabled == nil || *update.Enabled {
|
|
err := fmt.Errorf("trigger must not be empty")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.rule.update", normalized, err)
|
|
}
|
|
trigger = "(not configured)"
|
|
}
|
|
next.Trigger = trigger
|
|
if next.Source == "port_log" && next.Channel != "" {
|
|
index, err := parseChannel(next.Channel)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.rule.update", normalized, err)
|
|
}
|
|
port := s.effectivePendingLocked(next.Channel)
|
|
if trigger == "(not configured)" {
|
|
port.AlarmKeyword = ""
|
|
} else {
|
|
port.AlarmKeyword = trigger
|
|
}
|
|
s.pending[channelName(index)] = port
|
|
}
|
|
}
|
|
if update.Delivery != nil {
|
|
delivery, err := normalizeDelivery(update.Delivery)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.rule.update", normalized, err)
|
|
}
|
|
next.Delivery = delivery
|
|
}
|
|
if update.Enabled != nil {
|
|
next.Enabled = *update.Enabled
|
|
}
|
|
|
|
s.pendingAlarms[normalized] = next
|
|
summary := s.configSummaryLocked()
|
|
message := normalized + " alarm rule update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "alarm.rule.update", normalized, "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
s.pending = previousPorts
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("alarm_rule_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdateAlarmDeliveryPolicy(ctx context.Context, update domain.AlarmDeliveryPolicyUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.RetryCount == nil && update.RetryIntervalSeconds == nil && update.PendingQueueLimit == nil && update.NotifyOnRetryExhausted == nil {
|
|
err := fmt.Errorf("alarm delivery policy update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.delivery_policy.update", "alarm_delivery", err)
|
|
}
|
|
|
|
next := s.effectivePendingAlarmDeliveryLocked()
|
|
if update.RetryCount != nil {
|
|
next.RetryCount = *update.RetryCount
|
|
}
|
|
if update.RetryIntervalSeconds != nil {
|
|
next.RetryIntervalSeconds = *update.RetryIntervalSeconds
|
|
}
|
|
if update.PendingQueueLimit != nil {
|
|
next.PendingQueueLimit = *update.PendingQueueLimit
|
|
}
|
|
if update.NotifyOnRetryExhausted != nil {
|
|
next.NotifyOnRetryExhausted = *update.NotifyOnRetryExhausted
|
|
}
|
|
if err := validateAlarmDeliveryPolicy(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.delivery_policy.update", "alarm_delivery", err)
|
|
}
|
|
|
|
s.pendingAlarmDelivery = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "alarm delivery policy update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "alarm.delivery_policy.update", "alarm_delivery", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("alarm_delivery_policy_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdateEmailNotification(ctx context.Context, update domain.EmailNotificationUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.Enabled == nil && update.SMTPHost == nil && update.SMTPPort == nil && update.STARTTLSEnabled == nil && update.AuthEnabled == nil && update.Username == nil && update.PasswordStored == nil && update.From == nil && update.To == nil && update.CC == nil && update.CustomTitle == nil && update.CustomContent == nil && update.Password == "" && !update.ClearPassword {
|
|
err := fmt.Errorf("email notification update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.email.update", "email_notification", err)
|
|
}
|
|
|
|
next := s.effectivePendingEmailLocked()
|
|
passwordStored, err := s.preparePendingSecretLocked("smtp/password", update.Password, update.ClearPassword, next.PasswordStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.email.update", "email_notification", err)
|
|
}
|
|
next.PasswordStored = passwordStored
|
|
if update.Enabled != nil {
|
|
next.Enabled = *update.Enabled
|
|
}
|
|
if update.SMTPHost != nil {
|
|
next.SMTPHost = strings.TrimSpace(*update.SMTPHost)
|
|
}
|
|
if update.SMTPPort != nil {
|
|
next.SMTPPort = *update.SMTPPort
|
|
}
|
|
if update.STARTTLSEnabled != nil {
|
|
next.STARTTLSEnabled = *update.STARTTLSEnabled
|
|
}
|
|
if update.AuthEnabled != nil {
|
|
next.AuthEnabled = *update.AuthEnabled
|
|
}
|
|
if update.Username != nil {
|
|
next.Username = strings.TrimSpace(*update.Username)
|
|
}
|
|
if update.PasswordStored != nil && update.Password == "" && !update.ClearPassword {
|
|
next.PasswordStored = *update.PasswordStored
|
|
}
|
|
if update.From != nil {
|
|
next.From = strings.TrimSpace(*update.From)
|
|
}
|
|
if update.To != nil {
|
|
next.To = normalizeEmailList(update.To)
|
|
}
|
|
if update.CC != nil {
|
|
next.CC = normalizeEmailList(update.CC)
|
|
}
|
|
if update.CustomTitle != nil {
|
|
next.CustomTitle = strings.TrimSpace(*update.CustomTitle)
|
|
}
|
|
if update.CustomContent != nil {
|
|
next.CustomContent = strings.TrimSpace(*update.CustomContent)
|
|
}
|
|
if err := validateEmailNotification(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.email.update", "email_notification", err)
|
|
}
|
|
|
|
s.pendingEmail = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "email notification update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "alarm.email.update", "email_notification", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("email_notification_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdateSNMPAgent(ctx context.Context, update domain.SNMPAgentUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if update.Enabled == nil && update.Location == nil && update.Contact == nil && update.V1Enabled == nil && update.V2CEnabled == nil && update.V3Enabled == nil && update.ReadOnlyCommunityStored == nil && update.ReadWriteCommunityStored == nil && update.V3Users == nil && update.ReadOnlyCommunity == "" && update.ReadWriteCommunity == "" && !update.ClearReadOnlyCommunity && !update.ClearReadWriteCommunity {
|
|
err := fmt.Errorf("snmp agent update must include at least one field")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
|
|
next := s.effectivePendingSNMPAgentLocked()
|
|
readOnlyStored, err := s.preparePendingSecretLocked("snmp/agent/ro", update.ReadOnlyCommunity, update.ClearReadOnlyCommunity, next.ReadOnlyCommunityStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
readWriteStored, err := s.preparePendingSecretLocked("snmp/agent/rw", update.ReadWriteCommunity, update.ClearReadWriteCommunity, next.ReadWriteCommunityStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
next.ReadOnlyCommunityStored, next.ReadWriteCommunityStored = readOnlyStored, readWriteStored
|
|
if update.Enabled != nil {
|
|
next.Enabled = *update.Enabled
|
|
}
|
|
if update.Location != nil {
|
|
next.Location = strings.TrimSpace(*update.Location)
|
|
}
|
|
if update.Contact != nil {
|
|
next.Contact = strings.TrimSpace(*update.Contact)
|
|
}
|
|
if update.V1Enabled != nil {
|
|
next.V1Enabled = *update.V1Enabled
|
|
}
|
|
if update.V2CEnabled != nil {
|
|
next.V2CEnabled = *update.V2CEnabled
|
|
}
|
|
if update.V3Enabled != nil {
|
|
next.V3Enabled = *update.V3Enabled
|
|
}
|
|
if update.ReadOnlyCommunityStored != nil && update.ReadOnlyCommunity == "" && !update.ClearReadOnlyCommunity {
|
|
next.ReadOnlyCommunityStored = *update.ReadOnlyCommunityStored
|
|
}
|
|
if update.ReadWriteCommunityStored != nil && update.ReadWriteCommunity == "" && !update.ClearReadWriteCommunity {
|
|
next.ReadWriteCommunityStored = *update.ReadWriteCommunityStored
|
|
}
|
|
if update.V3Users != nil {
|
|
for index := range update.V3Users {
|
|
user := &update.V3Users[index]
|
|
user.AuthKeyStored, err = s.preparePendingSecretLocked("snmp/agent/v3/"+strings.ToLower(strings.TrimSpace(user.Username))+"/auth", user.AuthKey, user.ClearAuthKey, user.AuthKeyStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
user.PrivacyKeyStored, err = s.preparePendingSecretLocked("snmp/agent/v3/"+strings.ToLower(strings.TrimSpace(user.Username))+"/privacy", user.PrivacyKey, user.ClearPrivacyKey, user.PrivacyKeyStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
user.AuthKey, user.PrivacyKey, user.ClearAuthKey, user.ClearPrivacyKey = "", "", false, false
|
|
}
|
|
users, err := snmpV3UsersFromDomain(update.V3Users)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
next.V3Users = users
|
|
}
|
|
if err := validateSNMPAgent(next); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "snmp.agent.update", "snmp_agent", err)
|
|
}
|
|
|
|
s.pendingSNMPAgent = &next
|
|
summary := s.configSummaryLocked()
|
|
message := "snmp agent update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "snmp.agent.update", "snmp_agent", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("snmp_agent_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) UpdateSNMPTrapTargets(ctx context.Context, update domain.SNMPTrapTargetUpdate) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if len(update.Targets) == 0 {
|
|
err := fmt.Errorf("snmp trap target update must include at least one target")
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.snmp_trap.update", "snmp_trap_targets", err)
|
|
}
|
|
var err error
|
|
for index := range update.Targets {
|
|
target := &update.Targets[index]
|
|
secretPrefix := "snmp/trap/" + strings.ToLower(strings.TrimSpace(target.TargetID))
|
|
target.CredentialStored, err = s.preparePendingSecretLocked(secretPrefix+"/credential", target.Credential, target.ClearCredential, target.CredentialStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.snmp_trap.update", "snmp_trap_targets", err)
|
|
}
|
|
target.PrivacyKeyStored, err = s.preparePendingSecretLocked(secretPrefix+"/privacy", target.PrivacyKey, target.ClearPrivacyKey, target.PrivacyKeyStored)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.snmp_trap.update", "snmp_trap_targets", err)
|
|
}
|
|
target.Credential, target.PrivacyKey, target.ClearCredential, target.ClearPrivacyKey = "", "", false, false
|
|
}
|
|
targets, err := snmpTrapTargetsFromDomain(update.Targets)
|
|
if err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "alarm.snmp_trap.update", "snmp_trap_targets", err)
|
|
}
|
|
|
|
s.pendingSNMPTraps = &targets
|
|
summary := s.configSummaryLocked()
|
|
message := "snmp trap targets update staged in pending changes"
|
|
auditID := s.recordAuditLocked(ctx, "alarm.snmp_trap.update", "snmp_trap_targets", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("snmp_trap_targets_update_staged", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) ApplyConfig(ctx context.Context) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := s.pendingChangeCountLocked()
|
|
if err := s.applyPendingTLSRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.applyPendingManagementRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.applyPendingSNMPAgentRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.copyPendingPortSecretsLocked([]string{"running"}, true); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
for channel, config := range s.pending {
|
|
s.running[channel] = config
|
|
}
|
|
for ruleID, config := range s.pendingAlarms {
|
|
s.runningAlarms[ruleID] = config
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
s.runningAlarmDelivery = cloneAlarmDeliveryPolicy(*s.pendingAlarmDelivery)
|
|
}
|
|
if s.pendingEmail != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", []string{"smtp/password"}); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningEmail = cloneEmailNotification(*s.pendingEmail)
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpAgentSecretSuffixes(*s.pendingSNMPAgent)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningSNMPAgent = cloneSNMPAgent(*s.pendingSNMPAgent)
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpTrapSecretSuffixes(*s.pendingSNMPTraps)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningSNMPTraps = cloneSNMPTrapTargets(*s.pendingSNMPTraps)
|
|
}
|
|
for name, config := range s.pendingNetwork {
|
|
s.runningNetwork[name] = config
|
|
}
|
|
if s.pendingTime != nil {
|
|
s.runningTime = cloneTimeSettings(*s.pendingTime)
|
|
}
|
|
if s.pendingRetention != nil {
|
|
if err := s.applyJournalRetentionLocked(*s.pendingRetention); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningRetention = cloneLogRetention(*s.pendingRetention)
|
|
s.syncLinkTrackerIntervalLocked()
|
|
}
|
|
if s.pendingManagement != nil {
|
|
s.runningManagement = cloneManagementAccess(*s.pendingManagement)
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
s.runningSyslog = cloneRemoteSyslog(*s.pendingSyslog)
|
|
}
|
|
if s.pendingTLS != nil {
|
|
s.runningTLS = cloneTLSCertificate(*s.pendingTLS)
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
if err := s.syncExternalAuthSecretsLocked("pending", "running", *s.pendingExternalAuth); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningExternalAuth = cloneExternalAuth(*s.pendingExternalAuth)
|
|
}
|
|
if pendingChanges > 0 {
|
|
if err := s.clearPendingSecretsLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.pending = make(map[string]portConfig)
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
s.pendingAlarmDelivery = nil
|
|
s.pendingEmail = nil
|
|
s.pendingSNMPAgent = nil
|
|
s.pendingSNMPTraps = nil
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.pendingTime = nil
|
|
s.pendingRetention = nil
|
|
s.pendingManagement = nil
|
|
s.pendingSyslog = nil
|
|
s.pendingTLS = nil
|
|
s.pendingExternalAuth = nil
|
|
s.runningRevision++
|
|
}
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("%d pending change(s) applied to running config; persistent config unchanged", pendingChanges)
|
|
auditID := s.recordAuditLocked(ctx, "config.apply", "config", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("apply", message, auditID, &summary), nil
|
|
}
|
|
|
|
// ApplyPendingNetwork promotes only the staged network configuration. It is
|
|
// intentionally separate from ApplyConfig so an administrator can change the
|
|
// management address without activating unrelated staged port or alarm edits.
|
|
func (s *Store) ApplyPendingNetwork(ctx context.Context) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := len(s.pendingNetwork)
|
|
for name, config := range s.pendingNetwork {
|
|
s.runningNetwork[name] = config
|
|
}
|
|
if pendingChanges > 0 {
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.runningRevision++
|
|
}
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("%d pending network change(s) applied; unrelated pending config unchanged", pendingChanges)
|
|
auditID := s.recordAuditLocked(ctx, "network.apply", "network", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("network_apply", message, auditID, &summary), nil
|
|
}
|
|
|
|
// SaveRunningNetwork persists only the confirmed running network state. Any
|
|
// unrelated staged changes stay pending and are not copied to persistent config.
|
|
func (s *Store) SaveRunningNetwork(_ context.Context) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.persistentNetwork = cloneNetworkConfig(s.runningNetwork)
|
|
s.persistentRevision++
|
|
return s.persistConfigStoreLocked()
|
|
}
|
|
|
|
func (s *Store) SaveConfig(ctx context.Context) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := s.pendingChangeCountLocked()
|
|
if pendingChanges > 0 {
|
|
if err := s.copyPendingPortSecretsLocked([]string{"persistent"}, false); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
for channel, config := range s.pending {
|
|
s.persistent[channel] = config
|
|
}
|
|
for ruleID, config := range s.pendingAlarms {
|
|
s.persistentAlarms[ruleID] = config
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(*s.pendingAlarmDelivery)
|
|
}
|
|
if s.pendingEmail != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "persistent", []string{"smtp/password"}); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentEmail = cloneEmailNotification(*s.pendingEmail)
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "persistent", snmpAgentSecretSuffixes(*s.pendingSNMPAgent)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentSNMPAgent = cloneSNMPAgent(*s.pendingSNMPAgent)
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "persistent", snmpTrapSecretSuffixes(*s.pendingSNMPTraps)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(*s.pendingSNMPTraps)
|
|
}
|
|
for name, config := range s.pendingNetwork {
|
|
s.persistentNetwork[name] = config
|
|
}
|
|
if s.pendingTime != nil {
|
|
s.persistentTime = cloneTimeSettings(*s.pendingTime)
|
|
}
|
|
if s.pendingRetention != nil {
|
|
s.persistentRetention = cloneLogRetention(*s.pendingRetention)
|
|
}
|
|
if s.pendingManagement != nil {
|
|
s.persistentManagement = cloneManagementAccess(*s.pendingManagement)
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
s.persistentSyslog = cloneRemoteSyslog(*s.pendingSyslog)
|
|
}
|
|
if s.pendingTLS != nil {
|
|
s.persistentTLS = cloneTLSCertificate(*s.pendingTLS)
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
if err := s.syncExternalAuthSecretsLocked("pending", "persistent", *s.pendingExternalAuth); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentExternalAuth = cloneExternalAuth(*s.pendingExternalAuth)
|
|
}
|
|
} else {
|
|
s.persistent = clonePortConfig(s.running)
|
|
s.persistentAlarms = cloneAlarmRuleConfig(s.runningAlarms)
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(s.runningAlarmDelivery)
|
|
if err := s.syncConfigSecretSuffixesLocked("running", "persistent", notificationSecretSuffixes(s.runningEmail, s.runningSNMPAgent, s.runningSNMPTraps)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentEmail = cloneEmailNotification(s.runningEmail)
|
|
s.persistentSNMPAgent = cloneSNMPAgent(s.runningSNMPAgent)
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(s.runningSNMPTraps)
|
|
s.persistentNetwork = cloneNetworkConfig(s.runningNetwork)
|
|
s.persistentTime = cloneTimeSettings(s.runningTime)
|
|
s.persistentRetention = cloneLogRetention(s.runningRetention)
|
|
s.persistentManagement = cloneManagementAccess(s.runningManagement)
|
|
s.persistentSyslog = cloneRemoteSyslog(s.runningSyslog)
|
|
s.persistentTLS = cloneTLSCertificate(s.runningTLS)
|
|
if err := s.syncExternalAuthSecretsLocked("running", "persistent", s.runningExternalAuth); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentExternalAuth = cloneExternalAuth(s.runningExternalAuth)
|
|
}
|
|
s.persistentRevision++
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("persistent config saved; %d pending change(s) not applied to running config", pendingChanges)
|
|
auditID := s.recordAuditLocked(ctx, "config.save", "config", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("save", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) SaveAndApplyConfig(ctx context.Context) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := s.pendingChangeCountLocked()
|
|
if err := s.applyPendingTLSRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.applyPendingManagementRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.applyPendingSNMPAgentRuntimeLocked(ctx); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
if err := s.copyPendingPortSecretsLocked([]string{"running", "persistent"}, true); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
for channel, config := range s.pending {
|
|
s.running[channel] = config
|
|
}
|
|
for ruleID, config := range s.pendingAlarms {
|
|
s.runningAlarms[ruleID] = config
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
s.runningAlarmDelivery = cloneAlarmDeliveryPolicy(*s.pendingAlarmDelivery)
|
|
}
|
|
if s.pendingEmail != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", []string{"smtp/password"}); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningEmail = cloneEmailNotification(*s.pendingEmail)
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpAgentSecretSuffixes(*s.pendingSNMPAgent)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningSNMPAgent = cloneSNMPAgent(*s.pendingSNMPAgent)
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpTrapSecretSuffixes(*s.pendingSNMPTraps)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningSNMPTraps = cloneSNMPTrapTargets(*s.pendingSNMPTraps)
|
|
}
|
|
for name, config := range s.pendingNetwork {
|
|
s.runningNetwork[name] = config
|
|
}
|
|
if s.pendingTime != nil {
|
|
s.runningTime = cloneTimeSettings(*s.pendingTime)
|
|
}
|
|
if s.pendingRetention != nil {
|
|
if err := s.applyJournalRetentionLocked(*s.pendingRetention); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningRetention = cloneLogRetention(*s.pendingRetention)
|
|
s.syncLinkTrackerIntervalLocked()
|
|
}
|
|
if s.pendingManagement != nil {
|
|
s.runningManagement = cloneManagementAccess(*s.pendingManagement)
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
s.runningSyslog = cloneRemoteSyslog(*s.pendingSyslog)
|
|
}
|
|
if s.pendingTLS != nil {
|
|
s.runningTLS = cloneTLSCertificate(*s.pendingTLS)
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
if err := s.syncExternalAuthSecretsLocked("pending", "running", *s.pendingExternalAuth); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.runningExternalAuth = cloneExternalAuth(*s.pendingExternalAuth)
|
|
}
|
|
s.persistent = clonePortConfig(s.running)
|
|
s.persistentAlarms = cloneAlarmRuleConfig(s.runningAlarms)
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(s.runningAlarmDelivery)
|
|
if err := s.syncConfigSecretSuffixesLocked("running", "persistent", notificationSecretSuffixes(s.runningEmail, s.runningSNMPAgent, s.runningSNMPTraps)); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentEmail = cloneEmailNotification(s.runningEmail)
|
|
s.persistentSNMPAgent = cloneSNMPAgent(s.runningSNMPAgent)
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(s.runningSNMPTraps)
|
|
s.persistentNetwork = cloneNetworkConfig(s.runningNetwork)
|
|
s.persistentTime = cloneTimeSettings(s.runningTime)
|
|
s.persistentRetention = cloneLogRetention(s.runningRetention)
|
|
s.persistentManagement = cloneManagementAccess(s.runningManagement)
|
|
s.persistentSyslog = cloneRemoteSyslog(s.runningSyslog)
|
|
s.persistentTLS = cloneTLSCertificate(s.runningTLS)
|
|
if err := s.syncExternalAuthSecretsLocked("running", "persistent", s.runningExternalAuth); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.persistentExternalAuth = cloneExternalAuth(s.runningExternalAuth)
|
|
if pendingChanges > 0 {
|
|
if err := s.clearPendingSecretsLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
s.pending = make(map[string]portConfig)
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
s.pendingAlarmDelivery = nil
|
|
s.pendingEmail = nil
|
|
s.pendingSNMPAgent = nil
|
|
s.pendingSNMPTraps = nil
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.pendingTime = nil
|
|
s.pendingRetention = nil
|
|
s.pendingManagement = nil
|
|
s.pendingSyslog = nil
|
|
s.pendingTLS = nil
|
|
s.pendingExternalAuth = nil
|
|
s.runningRevision++
|
|
}
|
|
s.persistentRevision++
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("persistent config saved and %d pending change(s) applied to running config", pendingChanges)
|
|
auditID := s.recordAuditLocked(ctx, "config.save_and_apply", "config", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("save_and_apply", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) DiscardConfig(ctx context.Context) (domain.CommandResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := s.pendingChangeCountLocked()
|
|
if err := s.clearPendingSecretsLocked(); err != nil {
|
|
return domain.CommandResult{}, s.rejectCommandLocked(ctx, "config.discard", "config", err)
|
|
}
|
|
s.clearPendingConfigLocked()
|
|
summary := s.configSummaryLocked()
|
|
message := fmt.Sprintf("%d pending change(s) discarded; running and persistent config unchanged", pendingChanges)
|
|
auditID := s.recordAuditLocked(ctx, "config.discard", "config", "succeeded", message)
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.CommandResult{}, err
|
|
}
|
|
return commandResult("discard", message, auditID, &summary), nil
|
|
}
|
|
|
|
func (s *Store) SaveAndRestart(ctx context.Context) (domain.Operation, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
pendingChanges := s.pendingChangeCountLocked()
|
|
if err := s.applyPendingTLSRuntimeLocked(ctx); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
if err := s.applyPendingManagementRuntimeLocked(ctx); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
if err := s.applyPendingSNMPAgentRuntimeLocked(ctx); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
if err := s.copyPendingPortSecretsLocked([]string{"running", "persistent"}, true); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
for channel, config := range s.pending {
|
|
s.running[channel] = config
|
|
}
|
|
for ruleID, config := range s.pendingAlarms {
|
|
s.runningAlarms[ruleID] = config
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
s.runningAlarmDelivery = cloneAlarmDeliveryPolicy(*s.pendingAlarmDelivery)
|
|
}
|
|
if s.pendingEmail != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", []string{"smtp/password"}); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.runningEmail = cloneEmailNotification(*s.pendingEmail)
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpAgentSecretSuffixes(*s.pendingSNMPAgent)); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.runningSNMPAgent = cloneSNMPAgent(*s.pendingSNMPAgent)
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
if err := s.syncConfigSecretSuffixesLocked("pending", "running", snmpTrapSecretSuffixes(*s.pendingSNMPTraps)); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.runningSNMPTraps = cloneSNMPTrapTargets(*s.pendingSNMPTraps)
|
|
}
|
|
for name, config := range s.pendingNetwork {
|
|
s.runningNetwork[name] = config
|
|
}
|
|
if s.pendingTime != nil {
|
|
s.runningTime = cloneTimeSettings(*s.pendingTime)
|
|
}
|
|
if s.pendingRetention != nil {
|
|
if err := s.applyJournalRetentionLocked(*s.pendingRetention); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.runningRetention = cloneLogRetention(*s.pendingRetention)
|
|
s.syncLinkTrackerIntervalLocked()
|
|
}
|
|
if s.pendingManagement != nil {
|
|
s.runningManagement = cloneManagementAccess(*s.pendingManagement)
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
s.runningSyslog = cloneRemoteSyslog(*s.pendingSyslog)
|
|
}
|
|
if s.pendingTLS != nil {
|
|
s.runningTLS = cloneTLSCertificate(*s.pendingTLS)
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
if err := s.syncExternalAuthSecretsLocked("pending", "running", *s.pendingExternalAuth); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.runningExternalAuth = cloneExternalAuth(*s.pendingExternalAuth)
|
|
}
|
|
s.persistent = clonePortConfig(s.running)
|
|
s.persistentAlarms = cloneAlarmRuleConfig(s.runningAlarms)
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(s.runningAlarmDelivery)
|
|
if err := s.syncConfigSecretSuffixesLocked("running", "persistent", notificationSecretSuffixes(s.runningEmail, s.runningSNMPAgent, s.runningSNMPTraps)); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.persistentEmail = cloneEmailNotification(s.runningEmail)
|
|
s.persistentSNMPAgent = cloneSNMPAgent(s.runningSNMPAgent)
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(s.runningSNMPTraps)
|
|
s.persistentNetwork = cloneNetworkConfig(s.runningNetwork)
|
|
s.persistentTime = cloneTimeSettings(s.runningTime)
|
|
s.persistentRetention = cloneLogRetention(s.runningRetention)
|
|
s.persistentManagement = cloneManagementAccess(s.runningManagement)
|
|
s.persistentSyslog = cloneRemoteSyslog(s.runningSyslog)
|
|
s.persistentTLS = cloneTLSCertificate(s.runningTLS)
|
|
if err := s.syncExternalAuthSecretsLocked("running", "persistent", s.runningExternalAuth); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.persistentExternalAuth = cloneExternalAuth(s.runningExternalAuth)
|
|
if pendingChanges > 0 {
|
|
if err := s.clearPendingSecretsLocked(); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
s.pending = make(map[string]portConfig)
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
s.pendingAlarmDelivery = nil
|
|
s.pendingEmail = nil
|
|
s.pendingSNMPAgent = nil
|
|
s.pendingSNMPTraps = nil
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.pendingTime = nil
|
|
s.pendingRetention = nil
|
|
s.pendingManagement = nil
|
|
s.pendingSyslog = nil
|
|
s.pendingTLS = nil
|
|
s.pendingExternalAuth = nil
|
|
s.runningRevision++
|
|
}
|
|
s.persistentRevision++
|
|
if err := s.persistConfigStoreLocked(); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
return s.newOperationLocked(ctx, "config.save_and_restart", "config", fmt.Sprintf("affected services restart queued after saving %d pending change(s)", pendingChanges))
|
|
}
|
|
|
|
func (s *Store) applyPendingManagementRuntimeLocked(ctx context.Context) error {
|
|
if s.pendingManagement == nil {
|
|
return nil
|
|
}
|
|
settings := makeManagementAccessSettings(*s.pendingManagement)
|
|
if err := s.managementController.Apply(ctx, settings); err != nil {
|
|
return s.rejectCommandLocked(ctx, "management.access.apply", "management_access", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) applyPendingTLSRuntimeLocked(ctx context.Context) error {
|
|
if s.pendingTLS == nil {
|
|
return nil
|
|
}
|
|
if _, err := s.tlsCertificates.Activate(s.pendingTLS.ActiveCertificateID); err != nil {
|
|
return s.rejectCommandLocked(ctx, "security.tls_certificate.apply", "tls_certificate", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ReconcileTLSRuntime(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
certificateID := s.runningTLS.ActiveCertificateID
|
|
s.mu.Unlock()
|
|
_, err := s.tlsCertificates.Activate(certificateID)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) applyPendingSNMPAgentRuntimeLocked(ctx context.Context) error {
|
|
if s.pendingSNMPAgent == nil {
|
|
return nil
|
|
}
|
|
config, err := s.snmpAgentRuntimeConfigLocked("pending", *s.pendingSNMPAgent)
|
|
if err != nil {
|
|
return s.rejectCommandLocked(ctx, "snmp.agent.apply", "snmp_agent", err)
|
|
}
|
|
if err := s.snmpAgentController.Apply(ctx, config); err != nil {
|
|
return s.rejectCommandLocked(ctx, "snmp.agent.apply", "snmp_agent", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ReconcileSNMPAgentRuntime(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
config, err := s.snmpAgentRuntimeConfigLocked("running", s.runningSNMPAgent)
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.snmpAgentController.Apply(ctx, config)
|
|
}
|
|
|
|
func (s *Store) snmpAgentRuntimeConfigLocked(phase string, settings snmpAgentConfig) (snmpagent.Config, error) {
|
|
config := snmpagent.Config{
|
|
Enabled: settings.Enabled, Location: settings.Location, Contact: settings.Contact,
|
|
V1Enabled: settings.V1Enabled, V2CEnabled: settings.V2CEnabled, V3Enabled: settings.V3Enabled,
|
|
V3Users: make([]snmpagent.User, 0, len(settings.V3Users)),
|
|
}
|
|
var err error
|
|
if settings.V1Enabled || settings.V2CEnabled {
|
|
config.ReadOnlyCommunity, err = s.requiredConfigSecretLocked(phase, "snmp/agent/ro")
|
|
if err != nil {
|
|
return snmpagent.Config{}, err
|
|
}
|
|
if settings.ReadWriteCommunityStored {
|
|
config.ReadWriteCommunity, err = s.requiredConfigSecretLocked(phase, "snmp/agent/rw")
|
|
if err != nil {
|
|
return snmpagent.Config{}, err
|
|
}
|
|
}
|
|
}
|
|
for _, user := range settings.V3Users {
|
|
runtimeUser := snmpagent.User{
|
|
Username: user.Username, Access: normalizeSNMPAccess(user.Access),
|
|
AuthProtocol: user.AuthProtocol, PrivacyProtocol: user.PrivacyProtocol, Enabled: user.Enabled,
|
|
}
|
|
if user.Enabled && user.AuthProtocol != "none" {
|
|
runtimeUser.AuthKey, err = s.requiredConfigSecretLocked(phase, "snmp/agent/v3/"+strings.ToLower(user.Username)+"/auth")
|
|
if err != nil {
|
|
return snmpagent.Config{}, err
|
|
}
|
|
}
|
|
if user.Enabled && user.PrivacyProtocol != "none" {
|
|
runtimeUser.PrivacyKey, err = s.requiredConfigSecretLocked(phase, "snmp/agent/v3/"+strings.ToLower(user.Username)+"/privacy")
|
|
if err != nil {
|
|
return snmpagent.Config{}, err
|
|
}
|
|
}
|
|
config.V3Users = append(config.V3Users, runtimeUser)
|
|
}
|
|
if err := snmpagent.Validate(config); err != nil {
|
|
return snmpagent.Config{}, err
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func (s *Store) requiredConfigSecretLocked(phase, suffix string) (string, error) {
|
|
value, ok := s.secrets.Get(configSecretKey(phase, suffix))
|
|
if !ok || len(value) == 0 {
|
|
return "", fmt.Errorf("protected secret %s is not available", suffix)
|
|
}
|
|
return string(value), nil
|
|
}
|
|
|
|
func (s *Store) copyPendingPortSecretsLocked(destinations []string, clearPending bool) error {
|
|
if len(s.pending) == 0 {
|
|
return nil
|
|
}
|
|
changes := make(map[string]*[]byte)
|
|
for channel := range s.pending {
|
|
for operation := 1; operation <= 2; operation++ {
|
|
sourceKey := portCredentialKey("pending", channel, operation)
|
|
clearKey := portCredentialClearKey("pending", channel, operation)
|
|
value, hasValue := s.secrets.Get(sourceKey)
|
|
clear := s.secrets.Has(clearKey)
|
|
if hasValue || clear {
|
|
for _, destination := range destinations {
|
|
destinationKey := portCredentialKey(destination, channel, operation)
|
|
if hasValue {
|
|
copyValue := append([]byte(nil), value...)
|
|
changes[destinationKey] = ©Value
|
|
} else {
|
|
changes[destinationKey] = nil
|
|
}
|
|
changes[portCredentialClearKey(destination, channel, operation)] = nil
|
|
}
|
|
}
|
|
if clearPending {
|
|
changes[sourceKey] = nil
|
|
changes[clearKey] = nil
|
|
}
|
|
}
|
|
}
|
|
return s.secrets.Update(changes)
|
|
}
|
|
|
|
func (s *Store) clearPendingSecretsLocked() error {
|
|
if err := s.secrets.DeletePrefixes("port.pending.", "config/pending/", "external/pending/"); err != nil {
|
|
return fmt.Errorf("clear pending protected secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) preserveUnrestorableSecretSectionsLocked(ports map[string]portConfig, email *emailNotificationConfig, agent *snmpAgentConfig, traps *[]snmpTrapTargetConfig, external *externalAuthConfig) []string {
|
|
hasExisting := func(keys ...string) bool {
|
|
for _, key := range keys {
|
|
if _, ok := s.secrets.Get(key); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
configSecretExists := func(suffix string) bool {
|
|
return hasExisting(configSecretKey("running", suffix), configSecretKey("persistent", suffix))
|
|
}
|
|
preserved := make([]string, 0)
|
|
|
|
for channel, config := range ports {
|
|
if running, exists := s.running[channel]; exists && reflect.DeepEqual(running, config) {
|
|
continue
|
|
}
|
|
missing := false
|
|
for index, operation := range []domain.PortOperation{config.Operation1, config.Operation2} {
|
|
if operation.PasswordStored && !hasExisting(
|
|
portCredentialKey("running", channel, index+1),
|
|
portCredentialKey("persistent", channel, index+1),
|
|
) {
|
|
missing = true
|
|
break
|
|
}
|
|
}
|
|
if missing {
|
|
if running, exists := s.running[channel]; exists {
|
|
ports[channel] = running
|
|
preserved = append(preserved, channel+" credentials")
|
|
}
|
|
}
|
|
}
|
|
if !equalEmailNotification(s.runningEmail, *email) && email.PasswordStored && !configSecretExists("smtp/password") {
|
|
*email = cloneEmailNotification(s.runningEmail)
|
|
preserved = append(preserved, "email notification")
|
|
}
|
|
if !equalSNMPAgent(s.runningSNMPAgent, *agent) {
|
|
missing := false
|
|
if agent.ReadOnlyCommunityStored && !configSecretExists("snmp/agent/ro") {
|
|
missing = true
|
|
}
|
|
if agent.ReadWriteCommunityStored && !configSecretExists("snmp/agent/rw") {
|
|
missing = true
|
|
}
|
|
for _, user := range agent.V3Users {
|
|
prefix := "snmp/agent/v3/" + strings.ToLower(user.Username)
|
|
if user.AuthKeyStored && !configSecretExists(prefix+"/auth") {
|
|
missing = true
|
|
}
|
|
if user.PrivacyKeyStored && !configSecretExists(prefix+"/privacy") {
|
|
missing = true
|
|
}
|
|
}
|
|
if missing {
|
|
*agent = cloneSNMPAgent(s.runningSNMPAgent)
|
|
preserved = append(preserved, "SNMP agent")
|
|
}
|
|
}
|
|
if !equalSNMPTrapTargets(s.runningSNMPTraps, *traps) {
|
|
missing := false
|
|
for _, target := range *traps {
|
|
prefix := "snmp/trap/" + strings.ToLower(target.TargetID)
|
|
if target.CredentialStored && !configSecretExists(prefix+"/credential") {
|
|
missing = true
|
|
}
|
|
if target.PrivacyKeyStored && !configSecretExists(prefix+"/privacy") {
|
|
missing = true
|
|
}
|
|
}
|
|
if missing {
|
|
*traps = cloneSNMPTrapTargets(s.runningSNMPTraps)
|
|
preserved = append(preserved, "SNMP trap targets")
|
|
}
|
|
}
|
|
if !equalExternalAuth(s.runningExternalAuth, *external) {
|
|
missing := false
|
|
for kind, servers := range map[string][]externalAuthServerConfig{"radius": external.RadiusServers, "tacacs": external.TACACSServers} {
|
|
for _, server := range servers {
|
|
if server.SecretStored && !hasExisting(
|
|
externalAuthSecretKey("running", kind, server.ServerID),
|
|
externalAuthSecretKey("persistent", kind, server.ServerID),
|
|
) {
|
|
missing = true
|
|
}
|
|
}
|
|
}
|
|
if missing {
|
|
*external = cloneExternalAuth(s.runningExternalAuth)
|
|
preserved = append(preserved, "external authentication")
|
|
}
|
|
}
|
|
sort.Strings(preserved)
|
|
return preserved
|
|
}
|
|
|
|
func (s *Store) restorePendingSecretsLocked(ports map[string]portConfig, email *emailNotificationConfig, agent *snmpAgentConfig, traps []snmpTrapTargetConfig, external *externalAuthConfig) error {
|
|
changes := make(map[string]*[]byte)
|
|
copyExisting := func(destination string, sources ...string) bool {
|
|
for _, source := range sources {
|
|
if value, ok := s.secrets.Get(source); ok {
|
|
copyValue := append([]byte(nil), value...)
|
|
changes[destination] = ©Value
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
configSecret := func(suffix string) bool {
|
|
return copyExisting(configSecretKey("pending", suffix), configSecretKey("running", suffix), configSecretKey("persistent", suffix))
|
|
}
|
|
|
|
for channel, config := range ports {
|
|
operations := []*domain.PortOperation{&config.Operation1, &config.Operation2}
|
|
for index, operation := range operations {
|
|
if operation.PasswordStored {
|
|
copyExisting(
|
|
portCredentialKey("pending", channel, index+1),
|
|
portCredentialKey("running", channel, index+1),
|
|
portCredentialKey("persistent", channel, index+1),
|
|
)
|
|
}
|
|
}
|
|
ports[channel] = config
|
|
}
|
|
if email.PasswordStored {
|
|
configSecret("smtp/password")
|
|
}
|
|
if agent.ReadOnlyCommunityStored {
|
|
configSecret("snmp/agent/ro")
|
|
}
|
|
if agent.ReadWriteCommunityStored {
|
|
configSecret("snmp/agent/rw")
|
|
}
|
|
for index := range agent.V3Users {
|
|
prefix := "snmp/agent/v3/" + strings.ToLower(agent.V3Users[index].Username)
|
|
if agent.V3Users[index].AuthKeyStored {
|
|
configSecret(prefix + "/auth")
|
|
}
|
|
if agent.V3Users[index].PrivacyKeyStored {
|
|
configSecret(prefix + "/privacy")
|
|
}
|
|
}
|
|
for index := range traps {
|
|
prefix := "snmp/trap/" + strings.ToLower(traps[index].TargetID)
|
|
if traps[index].CredentialStored {
|
|
configSecret(prefix + "/credential")
|
|
}
|
|
if traps[index].PrivacyKeyStored {
|
|
configSecret(prefix + "/privacy")
|
|
}
|
|
}
|
|
for kind, servers := range map[string][]externalAuthServerConfig{"radius": external.RadiusServers, "tacacs": external.TACACSServers} {
|
|
for index := range servers {
|
|
if servers[index].SecretStored {
|
|
copyExisting(
|
|
externalAuthSecretKey("pending", kind, servers[index].ServerID),
|
|
externalAuthSecretKey("running", kind, servers[index].ServerID),
|
|
externalAuthSecretKey("persistent", kind, servers[index].ServerID),
|
|
)
|
|
}
|
|
}
|
|
if kind == "radius" {
|
|
external.RadiusServers = servers
|
|
} else {
|
|
external.TACACSServers = servers
|
|
}
|
|
}
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return fmt.Errorf("restore pending protected secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) clearPendingConfigLocked() {
|
|
s.pending = make(map[string]portConfig)
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
s.pendingAlarmDelivery = nil
|
|
s.pendingEmail = nil
|
|
s.pendingSNMPAgent = nil
|
|
s.pendingSNMPTraps = nil
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.pendingTime = nil
|
|
s.pendingRetention = nil
|
|
s.pendingManagement = nil
|
|
s.pendingSyslog = nil
|
|
s.pendingTLS = nil
|
|
s.pendingExternalAuth = nil
|
|
}
|
|
|
|
func (s *Store) RestartPort(ctx context.Context, channel string) (domain.Operation, error) {
|
|
if _, err := parseChannel(channel); err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "port.restart", strings.ToUpper(strings.TrimSpace(channel)), err)
|
|
}
|
|
normalized := strings.ToUpper(channel)
|
|
return s.newOperation(ctx, "port.restart", normalized, fmt.Sprintf("%s restart completed", normalized))
|
|
}
|
|
|
|
func (s *Store) RestartPorts(ctx context.Context, channels []string) (domain.Operation, error) {
|
|
normalized, err := normalizeRestartChannels(channels)
|
|
if err != nil {
|
|
return domain.Operation{}, s.rejectOperation(ctx, "port.bulk_restart", "ports", err)
|
|
}
|
|
message := fmt.Sprintf("%d ports restarted: %s", len(normalized), strings.Join(normalized, ","))
|
|
return s.newOperationWithResult(ctx, "port.bulk_restart", "ports", message, map[string]any{
|
|
"channels": normalized,
|
|
"count": len(normalized),
|
|
})
|
|
}
|
|
|
|
func normalizeRestartChannels(channels []string) ([]string, error) {
|
|
if len(channels) == 0 || len(channels) > serial.ChannelCount {
|
|
return nil, errors.New("channels must include 1 through 48 unique channels")
|
|
}
|
|
seen := make(map[string]bool, len(channels))
|
|
normalized := make([]string, 0, len(channels))
|
|
for _, channel := range channels {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
value := channelName(index)
|
|
if seen[value] {
|
|
return nil, errors.New("channels must not include duplicates")
|
|
}
|
|
seen[value] = true
|
|
normalized = append(normalized, value)
|
|
}
|
|
sort.Slice(normalized, func(left, right int) bool {
|
|
leftIndex, _ := parseChannel(normalized[left])
|
|
rightIndex, _ := parseChannel(normalized[right])
|
|
return leftIndex < rightIndex
|
|
})
|
|
return normalized, nil
|
|
}
|
|
|
|
func (s *Store) Operations(ctx context.Context) ([]domain.Operation, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
operationIDs := sortedOperationIDs(s.operations)
|
|
operations := make([]domain.Operation, 0, len(operationIDs))
|
|
for _, operationID := range operationIDs {
|
|
operations = append(operations, cloneOperation(s.operations[operationID]))
|
|
}
|
|
return operations, nil
|
|
}
|
|
|
|
func (s *Store) Operation(ctx context.Context, operationID string) (domain.Operation, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
operation, ok := s.operations[operationID]
|
|
if !ok {
|
|
return domain.Operation{}, fmt.Errorf("operation not found")
|
|
}
|
|
return cloneOperation(operation), nil
|
|
}
|
|
|
|
func (s *Store) newOperation(ctx context.Context, action, target, message string) (domain.Operation, error) {
|
|
return s.newOperationWithResult(ctx, action, target, message, nil)
|
|
}
|
|
|
|
func (s *Store) newOperationWithResult(ctx context.Context, action, target, message string, result map[string]any) (domain.Operation, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.newOperationLockedWithResult(ctx, action, target, message, result)
|
|
}
|
|
|
|
func (s *Store) newOperationLocked(ctx context.Context, action, target, message string) (domain.Operation, error) {
|
|
return s.newOperationLockedWithResult(ctx, action, target, message, nil)
|
|
}
|
|
|
|
func (s *Store) newOperationLockedWithResult(ctx context.Context, action, target, message string, result map[string]any) (domain.Operation, error) {
|
|
return s.newOperationLockedWithResultBuilder(ctx, action, target, message, func(time.Time, int) map[string]any {
|
|
return result
|
|
})
|
|
}
|
|
|
|
func (s *Store) newOperationLockedWithResultBuilder(ctx context.Context, action, target, message string, buildResult func(time.Time, int) map[string]any) (domain.Operation, error) {
|
|
s.operationSeq++
|
|
now := s.clock().UTC()
|
|
id := fmt.Sprintf("op_%s_%06d", now.Format("20060102"), s.operationSeq)
|
|
auditID := s.recordAuditLocked(ctx, action, target, "succeeded", message)
|
|
var result map[string]any
|
|
if buildResult != nil {
|
|
result = buildResult(now, s.operationSeq)
|
|
}
|
|
|
|
operation := domain.Operation{
|
|
OperationID: id,
|
|
State: "succeeded",
|
|
Message: message,
|
|
AuditID: auditID,
|
|
Result: cloneOperationResult(result),
|
|
ProgressPercent: 100,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
CompletedAt: &now,
|
|
}
|
|
s.operations[id] = operation
|
|
s.trimOperationsLocked()
|
|
if err := s.persistOperationStoreLocked(); err != nil {
|
|
return domain.Operation{}, err
|
|
}
|
|
return cloneOperation(operation), nil
|
|
}
|
|
|
|
func (s *Store) trimOperationsLocked() {
|
|
if s.operationMax <= 0 || len(s.operations) <= s.operationMax {
|
|
return
|
|
}
|
|
operationIDs := sortedOperationIDs(s.operations)
|
|
for _, operationID := range operationIDs {
|
|
if len(s.operations) <= s.operationMax {
|
|
return
|
|
}
|
|
operation := s.operations[operationID]
|
|
if operation.State == "queued" || operation.State == "running" {
|
|
continue
|
|
}
|
|
delete(s.operations, operationID)
|
|
}
|
|
}
|
|
|
|
func cloneOperation(operation domain.Operation) domain.Operation {
|
|
operation.Result = cloneOperationResult(operation.Result)
|
|
return operation
|
|
}
|
|
|
|
func cloneOperationResult(result map[string]any) map[string]any {
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
clone := make(map[string]any, len(result))
|
|
for key, value := range result {
|
|
clone[key] = cloneOperationResultValue(value)
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func cloneOperationResultValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
return cloneOperationResult(typed)
|
|
case []any:
|
|
clone := make([]any, len(typed))
|
|
for i, item := range typed {
|
|
clone[i] = cloneOperationResultValue(item)
|
|
}
|
|
return clone
|
|
case []string:
|
|
clone := make([]string, len(typed))
|
|
copy(clone, typed)
|
|
return clone
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func (s *Store) recordAuditLocked(ctx context.Context, action, target, result, message string) string {
|
|
return s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{
|
|
Actor: domain.ActorName(ctx),
|
|
Action: action,
|
|
Target: target,
|
|
Result: result,
|
|
Message: message,
|
|
}))
|
|
}
|
|
|
|
func (s *Store) rejectCommandLocked(ctx context.Context, action, target string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
s.recordAuditLocked(ctx, action, target, "rejected", err.Error())
|
|
return err
|
|
}
|
|
|
|
func (s *Store) rejectOperation(ctx context.Context, action, target string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.rejectCommandLocked(ctx, action, target, err)
|
|
}
|
|
|
|
func (s *Store) recordAuditEventLocked(event domain.AuditEvent) string {
|
|
s.auditSeq++
|
|
now := s.clock().UTC()
|
|
auditID := fmt.Sprintf("audit_%s_%06d", now.Format("20060102"), s.auditSeq)
|
|
if event.Actor == "" {
|
|
event.Actor = "anonymous"
|
|
}
|
|
if event.Surface == "" {
|
|
event.Surface = "service"
|
|
}
|
|
if event.Command == "" {
|
|
event.Command = strings.TrimSpace(event.Action + " " + event.Target)
|
|
}
|
|
if event.ReturnValue == "" {
|
|
event.ReturnValue = event.Message
|
|
}
|
|
event.Command, event.Redacted = sanitizeAuditField(event.Command, logpolicy.MaxCommandCharacters, event.Redacted)
|
|
event.ReturnValue, event.Redacted = sanitizeAuditField(event.ReturnValue, logpolicy.MaxReturnCharacters, event.Redacted)
|
|
event.Message, event.Redacted = sanitizeAuditField(event.Message, logpolicy.MaxReturnCharacters, event.Redacted)
|
|
event.AuditID = auditID
|
|
event.Timestamp = now
|
|
s.auditEvents = append(s.auditEvents, domain.AuditEvent{
|
|
AuditID: event.AuditID,
|
|
Timestamp: event.Timestamp,
|
|
Actor: event.Actor,
|
|
Surface: event.Surface,
|
|
Action: event.Action,
|
|
Target: event.Target,
|
|
Command: event.Command,
|
|
Result: event.Result,
|
|
ReturnValue: event.ReturnValue,
|
|
Message: event.Message,
|
|
Redacted: event.Redacted,
|
|
})
|
|
s.trimAuditEventsLocked()
|
|
_ = s.persistAuditLogLocked()
|
|
return auditID
|
|
}
|
|
|
|
func enrichAuditEvent(ctx context.Context, event domain.AuditEvent) domain.AuditEvent {
|
|
if metadata, ok := domain.CommandMetadataFromContext(ctx); ok {
|
|
if event.Surface == "" {
|
|
event.Surface = metadata.Surface
|
|
}
|
|
if event.Command == "" {
|
|
event.Command = metadata.Command
|
|
}
|
|
}
|
|
return event
|
|
}
|
|
|
|
func sanitizeAuditField(value string, maximum int, alreadyRedacted bool) (string, bool) {
|
|
value, redacted := logpolicy.Sanitize(value, maximum)
|
|
return value, alreadyRedacted || redacted
|
|
}
|
|
|
|
func (s *Store) loadAuditLog() error {
|
|
content, err := os.ReadFile(s.auditPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load audit log: %w", err)
|
|
}
|
|
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var event domain.AuditEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
return fmt.Errorf("decode audit log: %w", err)
|
|
}
|
|
if err := validateAuditEvent(event); err != nil {
|
|
return err
|
|
}
|
|
s.auditEvents = append(s.auditEvents, event)
|
|
if sequence := auditSequence(event.AuditID); sequence > s.auditSeq {
|
|
s.auditSeq = sequence
|
|
}
|
|
}
|
|
s.trimAuditEventsLocked()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadJournalLocked() error {
|
|
if err := s.refreshAuditJournalLocked(); err != nil {
|
|
return fmt.Errorf("load authoritative audit journal: %w", err)
|
|
}
|
|
if err := s.refreshSystemJournalLocked(); err != nil {
|
|
return fmt.Errorf("load authoritative system journal: %w", err)
|
|
}
|
|
if err := s.refreshPortJournalLocked(); err != nil {
|
|
return fmt.Errorf("load authoritative port journal: %w", err)
|
|
}
|
|
if err := s.refreshLinkJournalLocked(); err != nil {
|
|
return fmt.Errorf("load authoritative link journal: %w", err)
|
|
}
|
|
for _, event := range s.linkLogEvents {
|
|
s.linkTracker.Seed(logpolicy.LinkEvent{
|
|
Kind: event.Kind, Resource: event.Resource, State: event.State,
|
|
Signal: event.Signal, Timestamp: event.Timestamp,
|
|
})
|
|
}
|
|
s.seedJournalLastLocked()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) refreshAuditJournalLocked() error {
|
|
events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassAudit, s.journalSequence[logstorage.ClassAudit], s.auditMax, validateAuditEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if incremental {
|
|
s.auditEvents = mergeIncrementalJournalEvents(s.auditEvents, events, sequences, s.journalLocalSequences[logstorage.ClassAudit])
|
|
s.journalSequence[logstorage.ClassAudit] = last
|
|
} else {
|
|
s.auditEvents = events
|
|
}
|
|
for _, event := range events {
|
|
if sequence := auditSequence(event.AuditID); sequence > s.auditSeq {
|
|
s.auditSeq = sequence
|
|
}
|
|
}
|
|
s.trimAuditEventsLocked()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) refreshSystemJournalLocked() error {
|
|
if cleaner, ok := s.logJournal.(logservice.SystemLogCleaner); ok {
|
|
revision, err := cleaner.SystemClearRevision()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision != s.systemClearRevision {
|
|
s.systemLogEvents = nil
|
|
s.journalSequence[logstorage.ClassSystem] = 0
|
|
delete(s.journalLocalSequences, logstorage.ClassSystem)
|
|
delete(s.journalLast, logstorage.ClassSystem)
|
|
s.systemClearRevision = revision
|
|
}
|
|
}
|
|
events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassSystem, s.journalSequence[logstorage.ClassSystem], s.systemLogMax, validateSystemLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if incremental {
|
|
s.systemLogEvents = mergeIncrementalJournalEvents(s.systemLogEvents, events, sequences, s.journalLocalSequences[logstorage.ClassSystem])
|
|
s.journalSequence[logstorage.ClassSystem] = last
|
|
} else {
|
|
s.systemLogEvents = events
|
|
}
|
|
s.systemLogEvents, s.systemLogLoaded = trimRecent(s.systemLogEvents, s.systemLogMax), true
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) refreshPortJournalLocked() error {
|
|
if cleaner, ok := s.logJournal.(logservice.PortLogCleaner); ok {
|
|
revision, err := cleaner.PortClearRevision()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if revision != s.portClearRevision {
|
|
s.portLogEvents = nil
|
|
s.journalSequence[logstorage.ClassPort] = 0
|
|
delete(s.journalLocalSequences, logstorage.ClassPort)
|
|
delete(s.journalLast, logstorage.ClassPort)
|
|
s.portClearRevision = revision
|
|
}
|
|
}
|
|
events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassPort, s.journalSequence[logstorage.ClassPort], s.portLogMax, validatePortLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for index := range events {
|
|
events[index].EventType = normalizedPortEventType(events[index])
|
|
}
|
|
if incremental {
|
|
s.portLogEvents = mergeIncrementalJournalEvents(s.portLogEvents, events, sequences, s.journalLocalSequences[logstorage.ClassPort])
|
|
s.journalSequence[logstorage.ClassPort] = last
|
|
} else {
|
|
s.portLogEvents = events
|
|
}
|
|
s.portLogEvents, s.portLogLoaded = trimRecent(s.portLogEvents, s.portLogMax), true
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) refreshLinkJournalLocked() error {
|
|
events, sequences, last, incremental, err := readJournalEventsSince(s.logJournal, logstorage.ClassLink, s.journalSequence[logstorage.ClassLink], s.linkLogMax, validateLinkLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if incremental {
|
|
s.linkLogEvents = mergeIncrementalJournalEvents(s.linkLogEvents, events, sequences, s.journalLocalSequences[logstorage.ClassLink])
|
|
s.journalSequence[logstorage.ClassLink] = last
|
|
} else {
|
|
s.linkLogEvents = events
|
|
}
|
|
s.linkLogEvents, s.linkLogLoaded = trimRecent(s.linkLogEvents, s.linkLogMax), true
|
|
return nil
|
|
}
|
|
|
|
func mergeIncrementalJournalEvents[T any](current, updates []T, sequences []uint64, locallyCached map[uint64]struct{}) []T {
|
|
for index, event := range updates {
|
|
if index < len(sequences) {
|
|
if _, exists := locallyCached[sequences[index]]; exists {
|
|
delete(locallyCached, sequences[index])
|
|
continue
|
|
}
|
|
}
|
|
current = append(current, event)
|
|
}
|
|
return current
|
|
}
|
|
|
|
func readJournalEventsSince[T any](journal logservice.Journal, class string, afterSequence uint64, recentLimit int, validate func(T) error) ([]T, []uint64, uint64, bool, error) {
|
|
if journal == nil {
|
|
return []T{}, nil, afterSequence, false, nil
|
|
}
|
|
lastSequence := afterSequence
|
|
incremental := false
|
|
payloads := make([]json.RawMessage, 0)
|
|
sequences := make([]uint64, 0)
|
|
if sequenced, ok := journal.(logservice.SequencedJournal); ok {
|
|
readAfter := afterSequence
|
|
// On startup the in-memory view is empty, but it is bounded by the
|
|
// configured event limit. Start close to tera-logd's current sequence
|
|
// instead of transferring and decoding the entire journal before trim.
|
|
if afterSequence == 0 && recentLimit > 0 {
|
|
if snapshot, snapshotErr := journal.Snapshot(); snapshotErr == nil {
|
|
nextSequence := snapshot.NextSequence[class]
|
|
if nextSequence > uint64(recentLimit)+1 {
|
|
readAfter = nextSequence - uint64(recentLimit) - 1
|
|
}
|
|
}
|
|
}
|
|
records, err := sequenced.ReadAfter(class, readAfter)
|
|
if err != nil {
|
|
return nil, nil, afterSequence, true, err
|
|
}
|
|
lastSequence = readAfter
|
|
incremental = true
|
|
payloads = make([]json.RawMessage, 0, len(records))
|
|
sequences = make([]uint64, 0, len(records))
|
|
for _, record := range records {
|
|
if record.Sequence <= lastSequence {
|
|
return nil, nil, afterSequence, true, errors.New("authoritative journal sequence is not increasing")
|
|
}
|
|
lastSequence = record.Sequence
|
|
payloads = append(payloads, record.Payload)
|
|
sequences = append(sequences, record.Sequence)
|
|
}
|
|
} else {
|
|
var err error
|
|
payloads, err = journal.Read(class)
|
|
if err != nil {
|
|
return nil, nil, afterSequence, false, err
|
|
}
|
|
}
|
|
items := make([]T, 0, len(payloads))
|
|
for _, payload := range payloads {
|
|
var item T
|
|
if err := json.Unmarshal(payload, &item); err != nil {
|
|
return nil, nil, afterSequence, incremental, fmt.Errorf("decode %s journal record: %w", class, err)
|
|
}
|
|
if err := validate(item); err != nil {
|
|
return nil, nil, afterSequence, incremental, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, sequences, lastSequence, incremental, nil
|
|
}
|
|
|
|
func journalEventKey(value any) string {
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func (s *Store) seedJournalLastLocked() {
|
|
for class, value := range map[string]any{
|
|
logstorage.ClassAudit: lastValue(s.auditEvents), logstorage.ClassSystem: lastValue(s.systemLogEvents),
|
|
logstorage.ClassPort: lastValue(s.portLogEvents), logstorage.ClassLink: lastValue(s.linkLogEvents),
|
|
} {
|
|
if value != nil {
|
|
s.journalLast[class] = journalEventKey(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func lastValue[T any](items []T) any {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
return items[len(items)-1]
|
|
}
|
|
|
|
func (s *Store) appendJournalLocked(class string, timestamp time.Time, value any) error {
|
|
if s.logJournal == nil {
|
|
return nil
|
|
}
|
|
key := journalEventKey(value)
|
|
if key == "" {
|
|
return fmt.Errorf("encode %s journal record", class)
|
|
}
|
|
if s.journalLast[class] == key {
|
|
return nil
|
|
}
|
|
if sequenced, ok := s.logJournal.(logservice.SequencedJournal); ok {
|
|
sequence, err := sequenced.AppendSequence(class, timestamp, value)
|
|
if err != nil {
|
|
return fmt.Errorf("append authoritative %s journal: %w", class, err)
|
|
}
|
|
if sequence <= s.journalSequence[class] {
|
|
return fmt.Errorf("append authoritative %s journal returned a stale sequence", class)
|
|
}
|
|
if s.journalLocalSequences[class] == nil {
|
|
s.journalLocalSequences[class] = make(map[uint64]struct{})
|
|
}
|
|
s.journalLocalSequences[class][sequence] = struct{}{}
|
|
} else if err := s.logJournal.Append(class, timestamp, value); err != nil {
|
|
return fmt.Errorf("append authoritative %s journal: %w", class, err)
|
|
}
|
|
s.journalLast[class] = key
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) applyJournalRetentionLocked(config logRetentionConfig) error {
|
|
if s.logJournal == nil {
|
|
return nil
|
|
}
|
|
if err := s.logJournal.ApplyRetention(logstorage.Retention{
|
|
AuditDays: config.AuditDays, SystemDays: config.SystemDays, PortDays: config.PortDays,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, ok := s.logJournal.(logservice.SequencedJournal); !ok || len(s.auditEvents) == 0 && !s.systemLogLoaded && !s.portLogLoaded && !s.linkLogLoaded {
|
|
return nil
|
|
}
|
|
|
|
// Retention may remove the oldest authoritative segments. Rebuild the
|
|
// bounded API views once after such an administrative change so deleted
|
|
// records cannot remain visible from memory.
|
|
s.auditEvents = nil
|
|
s.systemLogEvents, s.systemLogLoaded = nil, false
|
|
s.portLogEvents, s.portLogLoaded = nil, false
|
|
s.linkLogEvents, s.linkLogLoaded = nil, false
|
|
s.journalSequence = make(map[string]uint64)
|
|
s.journalLocalSequences = make(map[string]map[uint64]struct{})
|
|
s.journalLast = make(map[string]string)
|
|
return s.loadJournalLocked()
|
|
}
|
|
|
|
func (s *Store) persistAuditLogLocked() error {
|
|
if s.logJournal != nil {
|
|
if len(s.auditEvents) == 0 {
|
|
return nil
|
|
}
|
|
event := s.auditEvents[len(s.auditEvents)-1]
|
|
return s.appendJournalLocked(logstorage.ClassAudit, event.Timestamp, event)
|
|
}
|
|
if s.auditPath == "" {
|
|
return nil
|
|
}
|
|
|
|
var builder strings.Builder
|
|
encoder := json.NewEncoder(&builder)
|
|
for _, event := range s.auditEvents {
|
|
if err := encoder.Encode(event); err != nil {
|
|
return fmt.Errorf("encode audit log: %w", err)
|
|
}
|
|
}
|
|
if err := atomicfile.Write(s.auditPath, []byte(builder.String()), 0o600); err != nil {
|
|
return fmt.Errorf("write audit log: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAuditEvent(event domain.AuditEvent) error {
|
|
if event.AuditID == "" || event.Actor == "" || event.Action == "" || event.Target == "" || event.Result == "" || event.Message == "" {
|
|
return fmt.Errorf("audit log contains invalid event identity")
|
|
}
|
|
if event.Timestamp.IsZero() {
|
|
return fmt.Errorf("audit log contains invalid event timestamp")
|
|
}
|
|
if auditSequence(event.AuditID) <= 0 {
|
|
return fmt.Errorf("audit log contains invalid audit id %s", event.AuditID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadSystemLogStore() error {
|
|
events, err := loadJSONLines(s.systemLogPath, "system log store", validateSystemLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.systemLogEvents = events
|
|
s.trimSystemLogEventsLocked()
|
|
s.systemLogLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadPortLogStore() error {
|
|
events, err := loadJSONLines(s.portLogPath, "port log store", validatePortLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.portLogEvents = events
|
|
for index := range s.portLogEvents {
|
|
s.portLogEvents[index].EventType = normalizedPortEventType(s.portLogEvents[index])
|
|
}
|
|
s.trimPortLogEventsLocked()
|
|
s.portLogLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadLinkLogStore() error {
|
|
events, err := loadJSONLines(s.linkLogPath, "link log store", validateLinkLogEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.linkLogEvents = events
|
|
s.trimLinkLogEventsLocked()
|
|
for _, event := range s.linkLogEvents {
|
|
s.linkTracker.Seed(logpolicy.LinkEvent{
|
|
Kind: event.Kind, Resource: event.Resource, State: event.State,
|
|
Signal: event.Signal, Timestamp: event.Timestamp,
|
|
})
|
|
}
|
|
s.linkLogLoaded = true
|
|
return nil
|
|
}
|
|
|
|
func persistJSONLines[T any](path, label string, items []T) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
var builder strings.Builder
|
|
encoder := json.NewEncoder(&builder)
|
|
for _, item := range items {
|
|
if err := encoder.Encode(item); err != nil {
|
|
return fmt.Errorf("encode %s: %w", label, err)
|
|
}
|
|
}
|
|
if err := atomicfile.Write(path, []byte(builder.String()), 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", label, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistSystemLogStoreLocked() error {
|
|
if s.logJournal != nil {
|
|
if len(s.systemLogEvents) == 0 {
|
|
return nil
|
|
}
|
|
event := s.systemLogEvents[len(s.systemLogEvents)-1]
|
|
return s.appendJournalLocked(logstorage.ClassSystem, event.Timestamp, event)
|
|
}
|
|
return persistJSONLines(s.systemLogPath, "system log store", s.systemLogEvents)
|
|
}
|
|
|
|
func (s *Store) persistPortLogStoreLocked() error {
|
|
if s.logJournal != nil {
|
|
if len(s.portLogEvents) == 0 {
|
|
return nil
|
|
}
|
|
event := s.portLogEvents[len(s.portLogEvents)-1]
|
|
return s.appendJournalLocked(logstorage.ClassPort, event.Timestamp, event)
|
|
}
|
|
return persistJSONLines(s.portLogPath, "port log store", s.portLogEvents)
|
|
}
|
|
|
|
func (s *Store) persistLinkLogStoreLocked() error {
|
|
if s.logJournal != nil {
|
|
if len(s.linkLogEvents) == 0 {
|
|
return nil
|
|
}
|
|
event := s.linkLogEvents[len(s.linkLogEvents)-1]
|
|
return s.appendJournalLocked(logstorage.ClassLink, event.Timestamp, event)
|
|
}
|
|
return persistJSONLines(s.linkLogPath, "link log store", s.linkLogEvents)
|
|
}
|
|
|
|
func trimRecent[T any](items []T, maximum int) []T {
|
|
if maximum <= 0 || len(items) <= maximum {
|
|
return items
|
|
}
|
|
trimmed := make([]T, maximum)
|
|
copy(trimmed, items[len(items)-maximum:])
|
|
return trimmed
|
|
}
|
|
|
|
func (s *Store) trimSystemLogEventsLocked() {
|
|
s.systemLogEvents = trimRecent(s.systemLogEvents, s.systemLogMax)
|
|
}
|
|
func (s *Store) trimPortLogEventsLocked() {
|
|
s.portLogEvents = trimRecent(s.portLogEvents, s.portLogMax)
|
|
}
|
|
func (s *Store) trimLinkLogEventsLocked() {
|
|
s.linkLogEvents = trimRecent(s.linkLogEvents, s.linkLogMax)
|
|
}
|
|
|
|
func loadJSONLines[T any](path, label string, validate func(T) error) ([]T, error) {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []T{}, nil
|
|
}
|
|
return nil, fmt.Errorf("load %s: %w", label, err)
|
|
}
|
|
|
|
items := make([]T, 0)
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var item T
|
|
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
|
return nil, fmt.Errorf("decode %s: %w", label, err)
|
|
}
|
|
if err := validate(item); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func validateSystemLogEvent(event domain.SystemLogEvent) error {
|
|
if event.Timestamp.IsZero() {
|
|
return fmt.Errorf("system log store contains invalid timestamp")
|
|
}
|
|
if event.Source == "" || event.Message == "" {
|
|
return fmt.Errorf("system log store contains invalid event identity")
|
|
}
|
|
if event.Category != "" && event.Category != "system" && event.Category != "configuration" && event.Category != "operation" && event.Category != "status" {
|
|
return fmt.Errorf("system log store contains invalid category %s", event.Category)
|
|
}
|
|
switch event.Severity {
|
|
case "info", "warning", "critical":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("system log store contains invalid severity %s", event.Severity)
|
|
}
|
|
}
|
|
|
|
func validatePortLogEvent(event domain.PortLogEvent) error {
|
|
if event.Timestamp.IsZero() {
|
|
return fmt.Errorf("port log store contains invalid timestamp")
|
|
}
|
|
if _, err := parseChannel(event.Channel); err != nil {
|
|
return fmt.Errorf("port log store contains invalid channel %s", event.Channel)
|
|
}
|
|
if event.Actor == "" || event.Message == "" {
|
|
return fmt.Errorf("port log store contains invalid event identity")
|
|
}
|
|
switch normalizedPortEventType(event) {
|
|
case "traffic":
|
|
switch event.Direction {
|
|
case "RX", "TX":
|
|
default:
|
|
return fmt.Errorf("port log store contains invalid direction %s", event.Direction)
|
|
}
|
|
if len(event.Payload) > logpolicy.MaxSerialPayloadBytes {
|
|
return fmt.Errorf("port traffic payload exceeds %d bytes", logpolicy.MaxSerialPayloadBytes)
|
|
}
|
|
if len(event.Payload) > 0 {
|
|
if event.PayloadBytes != len(event.Payload) {
|
|
return fmt.Errorf("port traffic payload byte count does not match content")
|
|
}
|
|
if event.PayloadEncoding != "base64" {
|
|
return fmt.Errorf("port traffic payload encoding must be base64")
|
|
}
|
|
} else if event.PayloadBytes != 0 || event.PayloadEncoding != "" {
|
|
return fmt.Errorf("port traffic payload metadata requires content")
|
|
}
|
|
case "command":
|
|
if strings.TrimSpace(event.Command) == "" || strings.TrimSpace(event.ReturnValue) == "" {
|
|
return fmt.Errorf("port command log must contain command and return_value")
|
|
}
|
|
case "session":
|
|
if event.Direction != "" || event.Command != "" || event.ReturnValue != "" || len(event.Payload) != 0 {
|
|
return fmt.Errorf("port session log must not contain traffic or command fields")
|
|
}
|
|
default:
|
|
return fmt.Errorf("port log store contains invalid event_type %s", event.EventType)
|
|
}
|
|
switch event.Match {
|
|
case "", "keyword", "port":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("port log store contains invalid match %s", event.Match)
|
|
}
|
|
}
|
|
|
|
func normalizedPortEventType(event domain.PortLogEvent) string {
|
|
if event.EventType == "" {
|
|
return "traffic"
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(event.EventType))
|
|
}
|
|
|
|
func validateLinkLogEvent(event domain.LinkLogEvent) error {
|
|
if event.Timestamp.IsZero() || event.Resource == "" || event.Message == "" {
|
|
return fmt.Errorf("link log store contains invalid event identity")
|
|
}
|
|
if event.Kind != "serial" && event.Kind != "ethernet" {
|
|
return fmt.Errorf("link log store contains invalid kind %s", event.Kind)
|
|
}
|
|
if event.Kind == "serial" {
|
|
if _, err := parseChannel(event.Resource); err != nil {
|
|
return fmt.Errorf("link log store contains invalid serial resource %s: %w", event.Resource, err)
|
|
}
|
|
}
|
|
if event.State != "up" && event.State != "down" && event.State != "unknown" {
|
|
return fmt.Errorf("link log store contains invalid state %s", event.State)
|
|
}
|
|
if event.Kind == "serial" && event.Signal != "dcd" && event.Signal != "dsr" && event.Signal != "cts" && event.Signal != "cr" && event.Signal != "unknown" {
|
|
return fmt.Errorf("link log store contains invalid serial signal %s", event.Signal)
|
|
}
|
|
if event.Kind == "ethernet" && event.Signal != "carrier" && event.Signal != "unknown" {
|
|
return fmt.Errorf("link log store contains invalid signal %s", event.Signal)
|
|
}
|
|
if event.Signal == "unknown" && event.State != "unknown" {
|
|
return fmt.Errorf("link log store unknown signal requires unknown state")
|
|
}
|
|
if event.EventType != "snapshot" && event.EventType != "transition" {
|
|
return fmt.Errorf("link log store contains invalid event_type %s", event.EventType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadConfigStore() error {
|
|
content, err := os.ReadFile(s.configPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load config store: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(content))) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshot configSnapshot
|
|
if err := json.Unmarshal(content, &snapshot); err != nil {
|
|
return fmt.Errorf("decode config store: %w", err)
|
|
}
|
|
if err := validateConfigSnapshot(&snapshot); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.running = clonePortConfig(snapshot.Running)
|
|
s.persistent = clonePortConfig(snapshot.Persistent)
|
|
s.pending = clonePortConfig(snapshot.Pending)
|
|
s.runningAlarms = cloneAlarmRuleConfig(snapshot.RunningAlarms)
|
|
s.persistentAlarms = cloneAlarmRuleConfig(snapshot.PersistentAlarms)
|
|
s.pendingAlarms = cloneAlarmRuleConfig(snapshot.PendingAlarms)
|
|
s.runningAlarmDelivery = cloneAlarmDeliveryPolicy(*snapshot.RunningAlarmDelivery)
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(*snapshot.PersistentAlarmDelivery)
|
|
if snapshot.PendingAlarmDelivery != nil {
|
|
pendingAlarmDelivery := cloneAlarmDeliveryPolicy(*snapshot.PendingAlarmDelivery)
|
|
s.pendingAlarmDelivery = &pendingAlarmDelivery
|
|
} else {
|
|
s.pendingAlarmDelivery = nil
|
|
}
|
|
s.runningEmail = cloneEmailNotification(*snapshot.RunningEmail)
|
|
s.persistentEmail = cloneEmailNotification(*snapshot.PersistentEmail)
|
|
if snapshot.PendingEmail != nil {
|
|
pendingEmail := cloneEmailNotification(*snapshot.PendingEmail)
|
|
s.pendingEmail = &pendingEmail
|
|
} else {
|
|
s.pendingEmail = nil
|
|
}
|
|
s.runningSNMPAgent = cloneSNMPAgent(*snapshot.RunningSNMPAgent)
|
|
s.persistentSNMPAgent = cloneSNMPAgent(*snapshot.PersistentSNMPAgent)
|
|
if snapshot.PendingSNMPAgent != nil {
|
|
pendingSNMPAgent := cloneSNMPAgent(*snapshot.PendingSNMPAgent)
|
|
s.pendingSNMPAgent = &pendingSNMPAgent
|
|
} else {
|
|
s.pendingSNMPAgent = nil
|
|
}
|
|
s.runningSNMPTraps = cloneSNMPTrapTargets(snapshot.RunningSNMPTraps)
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(snapshot.PersistentSNMPTraps)
|
|
if snapshot.PendingSNMPTraps != nil {
|
|
pendingSNMPTraps := cloneSNMPTrapTargets(snapshot.PendingSNMPTraps)
|
|
s.pendingSNMPTraps = &pendingSNMPTraps
|
|
} else {
|
|
s.pendingSNMPTraps = nil
|
|
}
|
|
s.runningNetwork = cloneNetworkConfig(snapshot.RunningNetwork)
|
|
s.persistentNetwork = cloneNetworkConfig(snapshot.PersistentNetwork)
|
|
s.pendingNetwork = cloneNetworkConfig(snapshot.PendingNetwork)
|
|
s.runningTime = cloneTimeSettings(*snapshot.RunningTime)
|
|
s.persistentTime = cloneTimeSettings(*snapshot.PersistentTime)
|
|
if snapshot.PendingTime != nil {
|
|
pendingTime := cloneTimeSettings(*snapshot.PendingTime)
|
|
s.pendingTime = &pendingTime
|
|
} else {
|
|
s.pendingTime = nil
|
|
}
|
|
s.runningRetention = cloneLogRetention(*snapshot.RunningRetention)
|
|
s.syncLinkTrackerIntervalLocked()
|
|
s.persistentRetention = cloneLogRetention(*snapshot.PersistentRetention)
|
|
if snapshot.PendingRetention != nil {
|
|
pendingRetention := cloneLogRetention(*snapshot.PendingRetention)
|
|
s.pendingRetention = &pendingRetention
|
|
} else {
|
|
s.pendingRetention = nil
|
|
}
|
|
s.runningManagement = cloneManagementAccess(*snapshot.RunningManagement)
|
|
s.persistentManagement = cloneManagementAccess(*snapshot.PersistentManagement)
|
|
if snapshot.PendingManagement != nil {
|
|
pendingManagement := cloneManagementAccess(*snapshot.PendingManagement)
|
|
s.pendingManagement = &pendingManagement
|
|
} else {
|
|
s.pendingManagement = nil
|
|
}
|
|
s.runningSyslog = cloneRemoteSyslog(*snapshot.RunningSyslog)
|
|
s.persistentSyslog = cloneRemoteSyslog(*snapshot.PersistentSyslog)
|
|
if snapshot.PendingSyslog != nil {
|
|
pendingSyslog := cloneRemoteSyslog(*snapshot.PendingSyslog)
|
|
s.pendingSyslog = &pendingSyslog
|
|
} else {
|
|
s.pendingSyslog = nil
|
|
}
|
|
s.runningTLS = cloneTLSCertificate(*snapshot.RunningTLS)
|
|
s.persistentTLS = cloneTLSCertificate(*snapshot.PersistentTLS)
|
|
if snapshot.PendingTLS != nil {
|
|
pendingTLS := cloneTLSCertificate(*snapshot.PendingTLS)
|
|
s.pendingTLS = &pendingTLS
|
|
} else {
|
|
s.pendingTLS = nil
|
|
}
|
|
s.runningExternalAuth = cloneExternalAuth(*snapshot.RunningExternalAuth)
|
|
s.persistentExternalAuth = cloneExternalAuth(*snapshot.PersistentExternalAuth)
|
|
if snapshot.PendingExternalAuth != nil {
|
|
pendingExternalAuth := cloneExternalAuth(*snapshot.PendingExternalAuth)
|
|
s.pendingExternalAuth = &pendingExternalAuth
|
|
} else {
|
|
s.pendingExternalAuth = nil
|
|
}
|
|
s.runningRevision = snapshot.RunningRevision
|
|
s.persistentRevision = snapshot.PersistentRevision
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistConfigStoreLocked() error {
|
|
if s.configPath == "" {
|
|
return nil
|
|
}
|
|
|
|
snapshot := configSnapshot{
|
|
Running: clonePortConfig(s.running),
|
|
Persistent: clonePortConfig(s.persistent),
|
|
Pending: clonePortConfig(s.pending),
|
|
RunningAlarms: cloneAlarmRuleConfig(s.runningAlarms),
|
|
PersistentAlarms: cloneAlarmRuleConfig(s.persistentAlarms),
|
|
PendingAlarms: cloneAlarmRuleConfig(s.pendingAlarms),
|
|
RunningAlarmDelivery: cloneAlarmDeliveryPolicyPointer(s.runningAlarmDelivery),
|
|
PersistentAlarmDelivery: cloneAlarmDeliveryPolicyPointer(s.persistentAlarmDelivery),
|
|
PendingAlarmDelivery: cloneOptionalAlarmDeliveryPolicyPointer(s.pendingAlarmDelivery),
|
|
RunningEmail: cloneEmailNotificationPointer(s.runningEmail),
|
|
PersistentEmail: cloneEmailNotificationPointer(s.persistentEmail),
|
|
PendingEmail: cloneOptionalEmailNotificationPointer(s.pendingEmail),
|
|
RunningSNMPAgent: cloneSNMPAgentPointer(s.runningSNMPAgent),
|
|
PersistentSNMPAgent: cloneSNMPAgentPointer(s.persistentSNMPAgent),
|
|
PendingSNMPAgent: cloneOptionalSNMPAgentPointer(s.pendingSNMPAgent),
|
|
RunningSNMPTraps: cloneSNMPTrapTargets(s.runningSNMPTraps),
|
|
PersistentSNMPTraps: cloneSNMPTrapTargets(s.persistentSNMPTraps),
|
|
PendingSNMPTraps: cloneOptionalSNMPTrapTargets(s.pendingSNMPTraps),
|
|
RunningNetwork: cloneNetworkConfig(s.runningNetwork),
|
|
PersistentNetwork: cloneNetworkConfig(s.persistentNetwork),
|
|
PendingNetwork: cloneNetworkConfig(s.pendingNetwork),
|
|
RunningTime: cloneTimeSettingsPointer(s.runningTime),
|
|
PersistentTime: cloneTimeSettingsPointer(s.persistentTime),
|
|
PendingTime: cloneOptionalTimeSettingsPointer(s.pendingTime),
|
|
RunningRetention: cloneLogRetentionPointer(s.runningRetention),
|
|
PersistentRetention: cloneLogRetentionPointer(s.persistentRetention),
|
|
PendingRetention: cloneOptionalLogRetentionPointer(s.pendingRetention),
|
|
RunningManagement: cloneManagementAccessPointer(s.runningManagement),
|
|
PersistentManagement: cloneManagementAccessPointer(s.persistentManagement),
|
|
PendingManagement: cloneOptionalManagementAccessPointer(s.pendingManagement),
|
|
RunningSyslog: cloneRemoteSyslogPointer(s.runningSyslog),
|
|
PersistentSyslog: cloneRemoteSyslogPointer(s.persistentSyslog),
|
|
PendingSyslog: cloneOptionalRemoteSyslogPointer(s.pendingSyslog),
|
|
RunningTLS: cloneTLSCertificatePointer(s.runningTLS),
|
|
PersistentTLS: cloneTLSCertificatePointer(s.persistentTLS),
|
|
PendingTLS: cloneOptionalTLSCertificatePointer(s.pendingTLS),
|
|
RunningExternalAuth: cloneExternalAuthPointer(s.runningExternalAuth),
|
|
PersistentExternalAuth: cloneExternalAuthPointer(s.persistentExternalAuth),
|
|
PendingExternalAuth: cloneOptionalExternalAuthPointer(s.pendingExternalAuth),
|
|
RunningRevision: s.runningRevision,
|
|
PersistentRevision: s.persistentRevision,
|
|
}
|
|
content, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode config store: %w", err)
|
|
}
|
|
if err := atomicfile.Write(s.configPath, append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write config store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateConfigSnapshot(snapshot *configSnapshot) error {
|
|
if len(snapshot.Running) != 48 || len(snapshot.Persistent) != 48 {
|
|
return fmt.Errorf("config store must contain 48 running and persistent channels")
|
|
}
|
|
for index := 1; index <= 48; index++ {
|
|
channel := channelName(index)
|
|
running, ok := snapshot.Running[channel]
|
|
if !ok {
|
|
return fmt.Errorf("config store missing running %s", channel)
|
|
}
|
|
normalized, err := normalizeStoredPortConfig(running, index)
|
|
if err != nil {
|
|
return fmt.Errorf("config store running %s: %w", channel, err)
|
|
}
|
|
snapshot.Running[channel] = normalized
|
|
|
|
persistent, ok := snapshot.Persistent[channel]
|
|
if !ok {
|
|
return fmt.Errorf("config store missing persistent %s", channel)
|
|
}
|
|
normalized, err = normalizeStoredPortConfig(persistent, index)
|
|
if err != nil {
|
|
return fmt.Errorf("config store persistent %s: %w", channel, err)
|
|
}
|
|
snapshot.Persistent[channel] = normalized
|
|
}
|
|
if snapshot.Pending == nil {
|
|
snapshot.Pending = make(map[string]portConfig)
|
|
}
|
|
for channel, pending := range snapshot.Pending {
|
|
if _, err := parseChannel(channel); err != nil {
|
|
return fmt.Errorf("config store pending channel: %w", err)
|
|
}
|
|
index, _ := parseChannel(channel)
|
|
normalized, err := normalizeStoredPortConfig(pending, index)
|
|
if err != nil {
|
|
return fmt.Errorf("config store pending %s: %w", channel, err)
|
|
}
|
|
snapshot.Pending[channel] = normalized
|
|
}
|
|
baseAlarms := baseAlarmRuleConfig()
|
|
if snapshot.RunningAlarms == nil {
|
|
snapshot.RunningAlarms = cloneAlarmRuleConfig(baseAlarms)
|
|
} else {
|
|
mergeMissingAlarmRuleConfig(snapshot.RunningAlarms, baseAlarms)
|
|
}
|
|
if snapshot.PersistentAlarms == nil {
|
|
snapshot.PersistentAlarms = cloneAlarmRuleConfig(baseAlarms)
|
|
} else {
|
|
mergeMissingAlarmRuleConfig(snapshot.PersistentAlarms, baseAlarms)
|
|
}
|
|
if snapshot.PendingAlarms == nil {
|
|
snapshot.PendingAlarms = make(map[string]alarmRuleConfig)
|
|
}
|
|
baseAlarmDelivery := baseAlarmDeliveryPolicy()
|
|
if snapshot.RunningAlarmDelivery == nil {
|
|
snapshot.RunningAlarmDelivery = cloneAlarmDeliveryPolicyPointer(baseAlarmDelivery)
|
|
}
|
|
if snapshot.PersistentAlarmDelivery == nil {
|
|
snapshot.PersistentAlarmDelivery = cloneAlarmDeliveryPolicyPointer(baseAlarmDelivery)
|
|
}
|
|
if err := validateAlarmDeliveryPolicy(*snapshot.RunningAlarmDelivery); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAlarmDeliveryPolicy(*snapshot.PersistentAlarmDelivery); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingAlarmDelivery != nil {
|
|
if err := validateAlarmDeliveryPolicy(*snapshot.PendingAlarmDelivery); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseEmail := baseEmailNotification()
|
|
if snapshot.RunningEmail == nil {
|
|
snapshot.RunningEmail = cloneEmailNotificationPointer(baseEmail)
|
|
}
|
|
if snapshot.PersistentEmail == nil {
|
|
snapshot.PersistentEmail = cloneEmailNotificationPointer(baseEmail)
|
|
}
|
|
if err := validateEmailNotification(*snapshot.RunningEmail); err != nil {
|
|
return err
|
|
}
|
|
if err := validateEmailNotification(*snapshot.PersistentEmail); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingEmail != nil {
|
|
if err := validateEmailNotification(*snapshot.PendingEmail); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseSNMPAgent := baseSNMPAgent()
|
|
if snapshot.RunningSNMPAgent == nil {
|
|
snapshot.RunningSNMPAgent = cloneSNMPAgentPointer(baseSNMPAgent)
|
|
}
|
|
if snapshot.PersistentSNMPAgent == nil {
|
|
snapshot.PersistentSNMPAgent = cloneSNMPAgentPointer(baseSNMPAgent)
|
|
}
|
|
if err := validateSNMPAgent(*snapshot.RunningSNMPAgent); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSNMPAgent(*snapshot.PersistentSNMPAgent); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingSNMPAgent != nil {
|
|
if err := validateSNMPAgent(*snapshot.PendingSNMPAgent); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseSNMPTraps := baseSNMPTrapTargets()
|
|
if snapshot.RunningSNMPTraps == nil {
|
|
snapshot.RunningSNMPTraps = cloneSNMPTrapTargets(baseSNMPTraps)
|
|
}
|
|
if snapshot.PersistentSNMPTraps == nil {
|
|
snapshot.PersistentSNMPTraps = cloneSNMPTrapTargets(baseSNMPTraps)
|
|
}
|
|
if err := validateSNMPTrapTargets(snapshot.RunningSNMPTraps); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSNMPTrapTargets(snapshot.PersistentSNMPTraps); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingSNMPTraps != nil {
|
|
if err := validateSNMPTrapTargets(snapshot.PendingSNMPTraps); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseNetwork := baseNetworkConfig()
|
|
if snapshot.RunningNetwork == nil {
|
|
snapshot.RunningNetwork = cloneNetworkConfig(baseNetwork)
|
|
}
|
|
if snapshot.PersistentNetwork == nil {
|
|
snapshot.PersistentNetwork = cloneNetworkConfig(baseNetwork)
|
|
}
|
|
if snapshot.PendingNetwork == nil {
|
|
snapshot.PendingNetwork = make(map[string]networkInterfaceConfig)
|
|
}
|
|
migrateNetworkDefaults(snapshot.RunningNetwork, baseNetwork)
|
|
migrateNetworkDefaults(snapshot.PersistentNetwork, baseNetwork)
|
|
migrateNetworkDefaults(snapshot.PendingNetwork, baseNetwork)
|
|
if _, err := networkconfig.Normalize(networkConfigForController(snapshot.RunningNetwork)); err != nil {
|
|
return fmt.Errorf("invalid running network configuration: %w", err)
|
|
}
|
|
if _, err := networkconfig.Normalize(networkConfigForController(snapshot.PersistentNetwork)); err != nil {
|
|
return fmt.Errorf("invalid persistent network configuration: %w", err)
|
|
}
|
|
baseTime := baseTimeSettings()
|
|
if snapshot.RunningTime == nil {
|
|
snapshot.RunningTime = cloneTimeSettingsPointer(baseTime)
|
|
}
|
|
if snapshot.PersistentTime == nil {
|
|
snapshot.PersistentTime = cloneTimeSettingsPointer(baseTime)
|
|
}
|
|
if err := validateTimeSettings(*snapshot.RunningTime); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTimeSettings(*snapshot.PersistentTime); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingTime != nil {
|
|
if err := validateTimeSettings(*snapshot.PendingTime); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseRetention := baseLogRetention()
|
|
if snapshot.RunningRetention == nil {
|
|
snapshot.RunningRetention = cloneLogRetentionPointer(baseRetention)
|
|
}
|
|
if snapshot.PersistentRetention == nil {
|
|
snapshot.PersistentRetention = cloneLogRetentionPointer(baseRetention)
|
|
}
|
|
migrateLogEventDefaults(snapshot.RunningRetention)
|
|
migrateLogEventDefaults(snapshot.PersistentRetention)
|
|
migrateLogEventDefaults(snapshot.PendingRetention)
|
|
if err := validateLogRetention(*snapshot.RunningRetention); err != nil {
|
|
return err
|
|
}
|
|
if err := validateLogRetention(*snapshot.PersistentRetention); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingRetention != nil {
|
|
if err := validateLogRetention(*snapshot.PendingRetention); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseManagement := baseManagementAccess()
|
|
if snapshot.RunningManagement == nil {
|
|
snapshot.RunningManagement = cloneManagementAccessPointer(baseManagement)
|
|
}
|
|
if snapshot.PersistentManagement == nil {
|
|
snapshot.PersistentManagement = cloneManagementAccessPointer(baseManagement)
|
|
}
|
|
migrateManagementAccessPortsPointer(snapshot.RunningManagement)
|
|
migrateManagementAccessPortsPointer(snapshot.PersistentManagement)
|
|
migrateManagementAccessPortsPointer(snapshot.PendingManagement)
|
|
if err := validateManagementAccess(*snapshot.RunningManagement); err != nil {
|
|
return err
|
|
}
|
|
if err := validateManagementAccess(*snapshot.PersistentManagement); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingManagement != nil {
|
|
if err := validateManagementAccess(*snapshot.PendingManagement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseSyslog := baseRemoteSyslog()
|
|
if snapshot.RunningSyslog == nil {
|
|
snapshot.RunningSyslog = cloneRemoteSyslogPointer(baseSyslog)
|
|
}
|
|
if snapshot.PersistentSyslog == nil {
|
|
snapshot.PersistentSyslog = cloneRemoteSyslogPointer(baseSyslog)
|
|
}
|
|
if err := validateRemoteSyslog(*snapshot.RunningSyslog); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRemoteSyslog(*snapshot.PersistentSyslog); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingSyslog != nil {
|
|
if err := validateRemoteSyslog(*snapshot.PendingSyslog); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseTLS := baseTLSCertificate()
|
|
if snapshot.RunningTLS == nil {
|
|
snapshot.RunningTLS = cloneTLSCertificatePointer(baseTLS)
|
|
}
|
|
if snapshot.PersistentTLS == nil {
|
|
snapshot.PersistentTLS = cloneTLSCertificatePointer(baseTLS)
|
|
}
|
|
if err := validateTLSCertificate(*snapshot.RunningTLS); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTLSCertificate(*snapshot.PersistentTLS); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingTLS != nil {
|
|
if err := validateTLSCertificate(*snapshot.PendingTLS); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
baseExternalAuth := baseExternalAuth()
|
|
if snapshot.RunningExternalAuth == nil {
|
|
snapshot.RunningExternalAuth = cloneExternalAuthPointer(baseExternalAuth)
|
|
}
|
|
if snapshot.PersistentExternalAuth == nil {
|
|
snapshot.PersistentExternalAuth = cloneExternalAuthPointer(baseExternalAuth)
|
|
}
|
|
if err := migrateStoredExternalAuthRole(snapshot.RunningExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
if err := migrateStoredExternalAuthRole(snapshot.PersistentExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
if err := validateExternalAuth(*snapshot.RunningExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
if err := validateExternalAuth(*snapshot.PersistentExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.PendingExternalAuth != nil {
|
|
if err := migrateStoredExternalAuthRole(snapshot.PendingExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
if err := validateExternalAuth(*snapshot.PendingExternalAuth); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for ruleID := range baseAlarms {
|
|
if _, ok := snapshot.RunningAlarms[ruleID]; !ok {
|
|
return fmt.Errorf("config store missing running alarm rule %s", ruleID)
|
|
}
|
|
if _, ok := snapshot.PersistentAlarms[ruleID]; !ok {
|
|
return fmt.Errorf("config store missing persistent alarm rule %s", ruleID)
|
|
}
|
|
}
|
|
for name := range baseNetwork {
|
|
if _, ok := snapshot.RunningNetwork[name]; !ok {
|
|
return fmt.Errorf("config store missing running network interface %s", name)
|
|
}
|
|
if _, ok := snapshot.PersistentNetwork[name]; !ok {
|
|
return fmt.Errorf("config store missing persistent network interface %s", name)
|
|
}
|
|
}
|
|
if snapshot.RunningRevision <= 0 || snapshot.PersistentRevision <= 0 {
|
|
return fmt.Errorf("config store revisions must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadOperationStore() error {
|
|
content, err := os.ReadFile(s.operationPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load operation store: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(content))) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshot operationSnapshot
|
|
if err := json.Unmarshal(content, &snapshot); err != nil {
|
|
return fmt.Errorf("decode operation store: %w", err)
|
|
}
|
|
if snapshot.Operations == nil {
|
|
snapshot.Operations = make(map[string]domain.Operation)
|
|
}
|
|
for id, operation := range snapshot.Operations {
|
|
if operation.OperationID == "" {
|
|
operation.OperationID = id
|
|
}
|
|
if operation.OperationID != id {
|
|
return fmt.Errorf("operation store key %s does not match operation id %s", id, operation.OperationID)
|
|
}
|
|
operation = normalizeStoredOperation(operation, s.clock().UTC())
|
|
if err := validateStoredOperation(operation); err != nil {
|
|
return err
|
|
}
|
|
s.operations[operation.OperationID] = operation
|
|
if sequence := idSequence(operation.OperationID); sequence > s.operationSeq {
|
|
s.operationSeq = sequence
|
|
}
|
|
}
|
|
s.trimOperationsLocked()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistOperationStoreLocked() error {
|
|
if s.operationPath == "" {
|
|
return nil
|
|
}
|
|
|
|
snapshot := operationSnapshot{
|
|
Operations: make(map[string]domain.Operation, len(s.operations)),
|
|
}
|
|
for id, operation := range s.operations {
|
|
snapshot.Operations[id] = operation
|
|
}
|
|
content, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode operation store: %w", err)
|
|
}
|
|
if err := atomicfile.Write(s.operationPath, append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write operation store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) loadSessionStore() error {
|
|
content, err := os.ReadFile(s.sessionPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load session store: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(content))) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshot sessionSnapshot
|
|
if err := json.Unmarshal(content, &snapshot); err != nil {
|
|
return fmt.Errorf("decode session store: %w", err)
|
|
}
|
|
for tokenHash, session := range snapshot.Sessions {
|
|
if err := validateSessionRecord(tokenHash, session); err != nil {
|
|
return err
|
|
}
|
|
s.sessions[tokenHash] = session
|
|
if sequence := idSequence(session.SessionID); sequence > s.sessionSeq {
|
|
s.sessionSeq = sequence
|
|
}
|
|
}
|
|
if snapshot.Sequence > s.sessionSeq {
|
|
s.sessionSeq = snapshot.Sequence
|
|
}
|
|
s.pruneExpiredSessionsLocked(s.clock().UTC())
|
|
s.trimSessionsToLimitLocked()
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistSessionStoreLocked() error {
|
|
if s.sessionPath == "" {
|
|
return nil
|
|
}
|
|
|
|
snapshot := sessionSnapshot{
|
|
Sequence: s.sessionSeq,
|
|
Sessions: make(map[string]sessionRecord, len(s.sessions)),
|
|
}
|
|
for tokenHash, session := range s.sessions {
|
|
snapshot.Sessions[tokenHash] = session
|
|
}
|
|
content, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode session store: %w", err)
|
|
}
|
|
if err := atomicfile.Write(s.sessionPath, append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write session store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSessionRecord(tokenHash string, session sessionRecord) error {
|
|
if !isTokenHashKey(tokenHash) {
|
|
return fmt.Errorf("session store contains invalid token hash")
|
|
}
|
|
if session.SessionID == "" || session.Actor.Name == "" || session.Actor.Role == "" {
|
|
return fmt.Errorf("session store contains invalid session identity")
|
|
}
|
|
if session.CreatedAt.IsZero() || session.ExpiresAt.IsZero() {
|
|
return fmt.Errorf("session store contains invalid session timestamps")
|
|
}
|
|
if len(session.Actor.Permissions) == 0 {
|
|
return fmt.Errorf("session store contains session without permissions")
|
|
}
|
|
if err := validateRolePermissionSubset(session.Actor.Role, sortedPermissions(session.Actor.Permissions), "session store session "+session.SessionID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isTokenHashKey(tokenHash string) bool {
|
|
if len(tokenHash) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(tokenHash)
|
|
return err == nil
|
|
}
|
|
|
|
func (s *Store) loadLoginFailureStore() error {
|
|
content, err := os.ReadFile(s.loginFailurePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load login failure store: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(content))) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshot loginFailureSnapshot
|
|
if err := json.Unmarshal(content, &snapshot); err != nil {
|
|
return fmt.Errorf("decode login failure store: %w", err)
|
|
}
|
|
for username, failure := range snapshot.Failures {
|
|
normalized := normalizeUsername(username)
|
|
if normalized == "" {
|
|
return fmt.Errorf("login failure store contains empty username")
|
|
}
|
|
if username != normalized {
|
|
return fmt.Errorf("login failure store contains non-canonical username %s", username)
|
|
}
|
|
if err := validateLoginFailureRecord(failure); err != nil {
|
|
return err
|
|
}
|
|
s.loginFailures[normalized] = failure
|
|
}
|
|
s.trimLoginFailuresLocked("")
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistLoginFailureStoreLocked() error {
|
|
if s.loginFailurePath == "" {
|
|
return nil
|
|
}
|
|
|
|
snapshot := loginFailureSnapshot{
|
|
Failures: make(map[string]loginFailureRecord, len(s.loginFailures)),
|
|
}
|
|
for username, failure := range s.loginFailures {
|
|
snapshot.Failures[username] = failure
|
|
}
|
|
content, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode login failure store: %w", err)
|
|
}
|
|
if err := atomicfile.Write(s.loginFailurePath, append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write login failure store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateLoginFailureRecord(failure loginFailureRecord) error {
|
|
if failure.Count <= 0 || failure.Count > maximumLoginSecurityHitCount {
|
|
return fmt.Errorf("login failure store contains invalid failure count")
|
|
}
|
|
if failure.LastAttempt.IsZero() {
|
|
return fmt.Errorf("login failure store contains invalid last attempt")
|
|
}
|
|
if !failure.LockedUntil.IsZero() {
|
|
if !failure.LockedUntil.After(failure.LastAttempt) {
|
|
return fmt.Errorf("login failure store contains invalid lockout window")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) trimSessionsToLimitLocked() {
|
|
for s.authMaxSessions > 0 && len(s.sessions) > s.authMaxSessions {
|
|
oldestKey := s.oldestSessionKeyLocked()
|
|
if oldestKey == "" {
|
|
return
|
|
}
|
|
delete(s.sessions, oldestKey)
|
|
}
|
|
}
|
|
|
|
func (s *Store) oldestSessionKeyLocked() string {
|
|
oldestKey := ""
|
|
var oldest sessionRecord
|
|
for tokenHash, session := range s.sessions {
|
|
if oldestKey == "" || session.CreatedAt.Before(oldest.CreatedAt) || (session.CreatedAt.Equal(oldest.CreatedAt) && session.SessionID < oldest.SessionID) {
|
|
oldestKey = tokenHash
|
|
oldest = session
|
|
}
|
|
}
|
|
return oldestKey
|
|
}
|
|
|
|
func normalizeStoredOperation(operation domain.Operation, fallback time.Time) domain.Operation {
|
|
if operation.ProgressPercent == 0 && operation.State == "succeeded" {
|
|
operation.ProgressPercent = 100
|
|
}
|
|
if operation.CreatedAt.IsZero() {
|
|
operation.CreatedAt = fallback
|
|
}
|
|
if operation.UpdatedAt.IsZero() {
|
|
operation.UpdatedAt = operation.CreatedAt
|
|
}
|
|
if operation.State == "succeeded" && operation.CompletedAt == nil {
|
|
completed := operation.UpdatedAt
|
|
operation.CompletedAt = &completed
|
|
}
|
|
return operation
|
|
}
|
|
|
|
func validateStoredOperation(operation domain.Operation) error {
|
|
if operation.OperationID == "" || operation.State == "" || operation.Message == "" || operation.AuditID == "" {
|
|
return fmt.Errorf("operation store contains invalid operation identity")
|
|
}
|
|
if !isOperationState(operation.State) {
|
|
return fmt.Errorf("operation store contains invalid operation state %s", operation.State)
|
|
}
|
|
if operation.ProgressPercent < 0 || operation.ProgressPercent > 100 {
|
|
return fmt.Errorf("operation store contains invalid operation progress")
|
|
}
|
|
if operation.CreatedAt.IsZero() || operation.UpdatedAt.IsZero() || operation.UpdatedAt.Before(operation.CreatedAt) {
|
|
return fmt.Errorf("operation store contains invalid operation timestamps")
|
|
}
|
|
completed := operation.State == "succeeded" || operation.State == "failed"
|
|
if completed {
|
|
if operation.CompletedAt == nil || operation.CompletedAt.Before(operation.UpdatedAt) {
|
|
return fmt.Errorf("operation store contains invalid completed operation timestamps")
|
|
}
|
|
return nil
|
|
}
|
|
if operation.CompletedAt != nil {
|
|
return fmt.Errorf("operation store contains incomplete operation with completion timestamp")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isOperationState(state string) bool {
|
|
switch state {
|
|
case "queued", "running", "succeeded", "failed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Store) loadAuthStore() error {
|
|
content, err := os.ReadFile(s.authPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("load auth store: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(content))) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshot authSnapshot
|
|
if err := json.Unmarshal(content, &snapshot); err != nil {
|
|
return fmt.Errorf("decode auth store: %w", err)
|
|
}
|
|
if len(snapshot.Users) == 0 && len(snapshot.Groups) == 0 && len(snapshot.Tokens) == 0 {
|
|
if snapshot.AddressFilters == nil {
|
|
return nil
|
|
}
|
|
}
|
|
if len(snapshot.Users) > s.authMaxUsers {
|
|
return fmt.Errorf("auth user limit exceeded in auth store")
|
|
}
|
|
if len(snapshot.Tokens) > s.authMaxTokens {
|
|
return fmt.Errorf("auth token limit exceeded in auth store")
|
|
}
|
|
for username, user := range snapshot.Users {
|
|
normalized := normalizeUsername(username)
|
|
if normalized == "" {
|
|
return fmt.Errorf("auth store contains empty username")
|
|
}
|
|
user.Username = normalized
|
|
// Migrate only exact historical administrator permission sets. Any
|
|
// other drift remains a fail-closed store error. Existing bearer sessions
|
|
// and API tokens remain permission snapshots until re-authentication.
|
|
if user.Role == "administrator" && (matchesPermissions(user.Permissions, legacyAdministratorPermissions()) || matchesPermissions(user.Permissions, preLogClearAdministratorPermissions())) {
|
|
user.Permissions = permissionsForRole(user.Role)
|
|
}
|
|
if user.Role == "port_operator" && matchesPermissions(user.Permissions, legacyPortOperatorPermissions()) {
|
|
user.Permissions = permissionsForRole(user.Role)
|
|
}
|
|
if len(user.Groups) == 0 {
|
|
user.Groups = []string{groupIDForRole(user.Role)}
|
|
}
|
|
var err error
|
|
user.Groups, err = normalizeGroupIDs(user.Groups)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.PortAccess == nil {
|
|
user.PortAccess = make(map[string]string)
|
|
}
|
|
for channel, access := range user.PortAccess {
|
|
if _, err := normalizePortPermissions([]domain.PortPermission{{Channel: channel, Access: access}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := validateAuthUser(user, s.passwordKDFs); err != nil {
|
|
return err
|
|
}
|
|
s.authUsers[normalized] = user
|
|
}
|
|
for groupID, group := range snapshot.Groups {
|
|
normalized := normalizeGroupID(groupID)
|
|
if normalized == "" {
|
|
return fmt.Errorf("auth store contains empty group id")
|
|
}
|
|
_, system := baseAuthGroups()[normalized]
|
|
name := strings.TrimSpace(group.Name)
|
|
if name == "" {
|
|
return fmt.Errorf("auth group name must not be empty")
|
|
}
|
|
if len(name) > 80 {
|
|
return fmt.Errorf("auth group name must be at most 80 characters")
|
|
}
|
|
role := normalizeRole(group.Role)
|
|
if role == "" && system {
|
|
role = baseAuthGroups()[normalized].Role
|
|
}
|
|
if role == "" {
|
|
return fmt.Errorf("auth group role is invalid")
|
|
}
|
|
permissions, err := normalizeFunctionPermissions(group.Permissions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if group.Permissions == nil && system {
|
|
permissions = baseAuthGroups()[normalized].Permissions
|
|
}
|
|
if system && role == "administrator" && matchesPermissions(permissions, preLogClearAdministratorPermissions()) {
|
|
permissions = permissionsForRole(role)
|
|
}
|
|
if group.PortAccess == nil && system {
|
|
group.PortAccess = portPermissionMap(baseAuthGroups()[normalized].PortPermissions)
|
|
}
|
|
for channel, access := range group.PortAccess {
|
|
if _, err := normalizePortPermissions([]domain.PortPermission{{Channel: channel, Access: access}}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.authGroupOverrides[normalized] = authGroupRecord{Name: name, Role: role, Permissions: permissions, PortAccess: group.PortAccess, System: system}
|
|
}
|
|
if err := s.validateAuthGroupMembershipStoreLocked(); err != nil {
|
|
return err
|
|
}
|
|
s.authTokenSeq = snapshot.TokenSequence
|
|
for tokenHash, token := range snapshot.Tokens {
|
|
hash := strings.TrimSpace(tokenHash)
|
|
if hash == "" {
|
|
return fmt.Errorf("auth store contains empty token hash")
|
|
}
|
|
if !isTokenHashKey(hash) {
|
|
return fmt.Errorf("auth store contains invalid token hash")
|
|
}
|
|
if err := validateAuthToken(token); err != nil {
|
|
return err
|
|
}
|
|
s.authTokens[hash] = token
|
|
if sequence := idSequence(token.TokenID); sequence > s.authTokenSeq {
|
|
s.authTokenSeq = sequence
|
|
}
|
|
}
|
|
if snapshot.AddressFilters != nil {
|
|
filters, err := normalizeAddressFilters(*snapshot.AddressFilters)
|
|
if err != nil {
|
|
return fmt.Errorf("auth address filters: %w", err)
|
|
}
|
|
s.addressFilters = filters
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) persistAuthStoreLocked() error {
|
|
if s.authPath == "" {
|
|
return nil
|
|
}
|
|
|
|
snapshot := authSnapshot{
|
|
Users: make(map[string]authUserRecord, len(s.authUsers)),
|
|
TokenSequence: s.authTokenSeq,
|
|
}
|
|
filters := cloneAddressFilters(s.addressFilters)
|
|
snapshot.AddressFilters = &filters
|
|
for username, user := range s.authUsers {
|
|
snapshot.Users[username] = user
|
|
}
|
|
if len(s.authGroupOverrides) > 0 {
|
|
snapshot.Groups = make(map[string]authGroupRecord, len(s.authGroupOverrides))
|
|
for groupID, group := range s.authGroupOverrides {
|
|
snapshot.Groups[groupID] = group
|
|
}
|
|
}
|
|
if len(s.authTokens) > 0 {
|
|
snapshot.Tokens = make(map[string]authTokenRecord, len(s.authTokens))
|
|
for tokenHash, token := range s.authTokens {
|
|
snapshot.Tokens[tokenHash] = token
|
|
}
|
|
}
|
|
content, err := json.MarshalIndent(snapshot, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode auth store: %w", err)
|
|
}
|
|
if err := atomicfile.Write(s.authPath, append(content, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write auth store: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAuthUser(user authUserRecord, registry password.Registry) error {
|
|
if user.Username == "" || user.Actor == "" || user.Role == "" {
|
|
return fmt.Errorf("auth store user identity fields must not be empty")
|
|
}
|
|
if len(user.Permissions) == 0 {
|
|
return fmt.Errorf("auth store user %s must include permissions", user.Username)
|
|
}
|
|
if err := validateRolePermissions(user.Role, user.Permissions, "auth store user "+user.Username); err != nil {
|
|
return err
|
|
}
|
|
if !registry.Supports(user.Password.Algorithm) {
|
|
return fmt.Errorf("auth store user %s uses unavailable password KDF %s", user.Username, user.Password.Algorithm)
|
|
}
|
|
if user.Password.Version != 1 || user.Password.Salt == "" || user.Password.Hash == "" {
|
|
return fmt.Errorf("auth store user %s has invalid password hash metadata", user.Username)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAuthToken(token authTokenRecord) error {
|
|
if token.TokenID == "" || token.Name == "" || token.Actor == "" || token.Role == "" || token.CreatedAt.IsZero() {
|
|
return fmt.Errorf("auth store token identity fields must not be empty")
|
|
}
|
|
if len(token.Permissions) == 0 {
|
|
return fmt.Errorf("auth store token %s must include permissions", token.TokenID)
|
|
}
|
|
if err := validateRolePermissionSubset(token.Role, token.Permissions, "auth store token "+token.TokenID); err != nil {
|
|
return err
|
|
}
|
|
if len(token.Name) > 80 {
|
|
return fmt.Errorf("token name must be at most 80 characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRolePermissions(role string, permissions []string, subject string) error {
|
|
if !isAuthRole(role) {
|
|
return fmt.Errorf("%s has invalid role %s", subject, role)
|
|
}
|
|
expected := sortedPermissionList(permissionsForRole(role))
|
|
actual := sortedPermissionList(permissions)
|
|
if len(actual) != len(expected) {
|
|
return fmt.Errorf("%s permissions do not match role %s", subject, role)
|
|
}
|
|
for index := range expected {
|
|
if actual[index] != expected[index] {
|
|
return fmt.Errorf("%s permissions do not match role %s", subject, role)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRolePermissionSubset(role string, permissions []string, subject string) error {
|
|
if !isAuthRole(role) {
|
|
return fmt.Errorf("%s has invalid role %s", subject, role)
|
|
}
|
|
allowed := make(map[string]bool)
|
|
for _, permission := range permissionsForRole(role) {
|
|
allowed[permission] = true
|
|
}
|
|
seen := make(map[string]bool)
|
|
for _, permission := range permissions {
|
|
if !allowed[permission] || seen[permission] {
|
|
return fmt.Errorf("%s permissions do not match role %s", subject, role)
|
|
}
|
|
seen[permission] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func matchesPermissions(left, right []string) bool {
|
|
left = sortedPermissionList(left)
|
|
right = sortedPermissionList(right)
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func legacyAdministratorPermissions() []string {
|
|
permissions := permissionsForRole("administrator")
|
|
result := make([]string, 0, len(permissions)-3)
|
|
for _, permission := range permissions {
|
|
if permission != "firmware.apply" && permission != "firmware.read" && permission != "firmware.upload" && permission != "log.clear" {
|
|
result = append(result, permission)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func legacyPortOperatorPermissions() []string {
|
|
return []string{"direct.view", "direct.write", "port.read", "port.restart", "port.write"}
|
|
}
|
|
|
|
func preLogClearAdministratorPermissions() []string {
|
|
var result []string
|
|
for _, permission := range permissionsForRole("administrator") {
|
|
if permission != "log.clear" {
|
|
result = append(result, permission)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Store) trimAuditEventsLocked() {
|
|
if s.auditMax <= 0 || len(s.auditEvents) <= s.auditMax {
|
|
return
|
|
}
|
|
s.auditEvents = append([]domain.AuditEvent(nil), s.auditEvents[len(s.auditEvents)-s.auditMax:]...)
|
|
}
|
|
|
|
func auditSequence(auditID string) int {
|
|
return idSequence(auditID)
|
|
}
|
|
|
|
func idSequence(id string) int {
|
|
index := strings.LastIndex(id, "_")
|
|
if index < 0 || index == len(id)-1 {
|
|
return 0
|
|
}
|
|
sequence, err := strconv.Atoi(id[index+1:])
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return sequence
|
|
}
|
|
|
|
func (s *Store) effectivePendingLocked(channel string) portConfig {
|
|
if config, ok := s.pending[channel]; ok {
|
|
return config
|
|
}
|
|
return s.running[channel]
|
|
}
|
|
|
|
func (s *Store) effectivePendingAlarmLocked(ruleID string) alarmRuleConfig {
|
|
if config, ok := s.pendingAlarms[ruleID]; ok {
|
|
return config
|
|
}
|
|
return s.runningAlarms[ruleID]
|
|
}
|
|
|
|
func (s *Store) effectivePendingAlarmDeliveryLocked() alarmDeliveryPolicyConfig {
|
|
if s.pendingAlarmDelivery != nil {
|
|
return cloneAlarmDeliveryPolicy(*s.pendingAlarmDelivery)
|
|
}
|
|
return cloneAlarmDeliveryPolicy(s.runningAlarmDelivery)
|
|
}
|
|
|
|
func (s *Store) effectivePendingEmailLocked() emailNotificationConfig {
|
|
if s.pendingEmail != nil {
|
|
return cloneEmailNotification(*s.pendingEmail)
|
|
}
|
|
return cloneEmailNotification(s.runningEmail)
|
|
}
|
|
|
|
func (s *Store) effectivePendingSNMPAgentLocked() snmpAgentConfig {
|
|
if s.pendingSNMPAgent != nil {
|
|
return cloneSNMPAgent(*s.pendingSNMPAgent)
|
|
}
|
|
return cloneSNMPAgent(s.runningSNMPAgent)
|
|
}
|
|
|
|
func (s *Store) effectivePendingNetworkLocked(name string) networkInterfaceConfig {
|
|
if config, ok := s.pendingNetwork[name]; ok {
|
|
return config
|
|
}
|
|
return s.runningNetwork[name]
|
|
}
|
|
|
|
func (s *Store) effectivePendingTimeLocked() timeSettingsConfig {
|
|
if s.pendingTime != nil {
|
|
return cloneTimeSettings(*s.pendingTime)
|
|
}
|
|
return cloneTimeSettings(s.runningTime)
|
|
}
|
|
|
|
func (s *Store) effectivePendingRetentionLocked() logRetentionConfig {
|
|
if s.pendingRetention != nil {
|
|
return cloneLogRetention(*s.pendingRetention)
|
|
}
|
|
return cloneLogRetention(s.runningRetention)
|
|
}
|
|
|
|
func (s *Store) syncLinkTrackerIntervalLocked() {
|
|
if s.linkTracker == nil {
|
|
return
|
|
}
|
|
s.linkTracker.SetSnapshotInterval(time.Duration(s.runningRetention.AllPortStatusIntervalMinutes) * time.Minute)
|
|
s.linkTracker.SetSnapshotsEnabled(s.runningRetention.AllPortStatusEnabled)
|
|
}
|
|
|
|
func (s *Store) effectivePendingManagementLocked() managementAccessConfig {
|
|
if s.pendingManagement != nil {
|
|
return cloneManagementAccess(*s.pendingManagement)
|
|
}
|
|
return cloneManagementAccess(s.runningManagement)
|
|
}
|
|
|
|
func (s *Store) effectivePendingSyslogLocked() remoteSyslogConfig {
|
|
if s.pendingSyslog != nil {
|
|
return cloneRemoteSyslog(*s.pendingSyslog)
|
|
}
|
|
return cloneRemoteSyslog(s.runningSyslog)
|
|
}
|
|
|
|
func (s *Store) effectivePendingExternalAuthLocked() externalAuthConfig {
|
|
if s.pendingExternalAuth != nil {
|
|
return cloneExternalAuth(*s.pendingExternalAuth)
|
|
}
|
|
return cloneExternalAuth(s.runningExternalAuth)
|
|
}
|
|
|
|
func (s *Store) pendingChangeCountLocked() int {
|
|
count := len(s.pending) + len(s.pendingAlarms)
|
|
if len(s.pendingNetwork) > 0 {
|
|
// Both physical interfaces form one protected network transaction.
|
|
count++
|
|
}
|
|
if s.pendingTime != nil {
|
|
count++
|
|
}
|
|
if s.pendingRetention != nil {
|
|
count++
|
|
}
|
|
if s.pendingManagement != nil {
|
|
count++
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
count++
|
|
}
|
|
if s.pendingEmail != nil {
|
|
count++
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
count++
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
count++
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
count++
|
|
}
|
|
if s.pendingTLS != nil {
|
|
count++
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *Store) pendingSectionsLocked() []string {
|
|
sections := make([]string, 0, 13)
|
|
if len(s.pending) > 0 {
|
|
sections = append(sections, "ports")
|
|
}
|
|
if len(s.pendingAlarms) > 0 {
|
|
sections = append(sections, "alarm_rules")
|
|
}
|
|
if s.pendingAlarmDelivery != nil {
|
|
sections = append(sections, "alarm_delivery")
|
|
}
|
|
if s.pendingEmail != nil {
|
|
sections = append(sections, "email_notification")
|
|
}
|
|
if s.pendingSNMPAgent != nil {
|
|
sections = append(sections, "snmp_agent")
|
|
}
|
|
if s.pendingSNMPTraps != nil {
|
|
sections = append(sections, "snmp_trap_targets")
|
|
}
|
|
if len(s.pendingNetwork) > 0 {
|
|
sections = append(sections, "network_interfaces")
|
|
}
|
|
if s.pendingTime != nil {
|
|
sections = append(sections, "time")
|
|
}
|
|
if s.pendingRetention != nil {
|
|
sections = append(sections, "logs")
|
|
}
|
|
if s.pendingManagement != nil {
|
|
sections = append(sections, "management")
|
|
}
|
|
if s.pendingSyslog != nil {
|
|
sections = append(sections, "remote_syslog")
|
|
}
|
|
if s.pendingTLS != nil {
|
|
sections = append(sections, "tls_certificate")
|
|
}
|
|
if s.pendingExternalAuth != nil {
|
|
sections = append(sections, "external_auth")
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func (s *Store) configSummaryLocked() domain.ConfigSummary {
|
|
return domain.ConfigSummary{
|
|
PendingChanges: s.pendingChangeCountLocked(),
|
|
PendingSections: s.pendingSectionsLocked(),
|
|
RunningRevision: s.runningRevision,
|
|
PersistentRevision: s.persistentRevision,
|
|
RunningDiffersFromPersistent: !equalPortConfig(s.running, s.persistent) || !equalAlarmRuleConfig(s.runningAlarms, s.persistentAlarms) || !equalAlarmDeliveryPolicy(s.runningAlarmDelivery, s.persistentAlarmDelivery) || !equalEmailNotification(s.runningEmail, s.persistentEmail) || !equalSNMPAgent(s.runningSNMPAgent, s.persistentSNMPAgent) || !equalSNMPTrapTargets(s.runningSNMPTraps, s.persistentSNMPTraps) || !equalNetworkConfig(s.runningNetwork, s.persistentNetwork) || !equalTimeSettings(s.runningTime, s.persistentTime) || !equalLogRetention(s.runningRetention, s.persistentRetention) || !equalManagementAccess(s.runningManagement, s.persistentManagement) || !equalRemoteSyslog(s.runningSyslog, s.persistentSyslog) || !equalTLSCertificate(s.runningTLS, s.persistentTLS) || !equalExternalAuth(s.runningExternalAuth, s.persistentExternalAuth),
|
|
}
|
|
}
|
|
|
|
func (s *Store) resetConfigLocked() {
|
|
basePorts := basePortConfig()
|
|
baseAlarms := baseAlarmRuleConfig()
|
|
baseAlarmDelivery := baseAlarmDeliveryPolicy()
|
|
baseEmail := baseEmailNotification()
|
|
baseSNMPAgent := baseSNMPAgent()
|
|
baseSNMPTraps := baseSNMPTrapTargets()
|
|
baseNetwork := baseNetworkConfig()
|
|
baseTime := baseTimeSettings()
|
|
baseRetention := baseLogRetention()
|
|
baseManagement := baseManagementAccess()
|
|
baseSyslog := baseRemoteSyslog()
|
|
baseTLS := baseTLSCertificate()
|
|
baseExternalAuth := baseExternalAuth()
|
|
|
|
s.running = clonePortConfig(basePorts)
|
|
s.persistent = clonePortConfig(basePorts)
|
|
s.pending = make(map[string]portConfig)
|
|
s.runningAlarms = cloneAlarmRuleConfig(baseAlarms)
|
|
s.persistentAlarms = cloneAlarmRuleConfig(baseAlarms)
|
|
s.pendingAlarms = make(map[string]alarmRuleConfig)
|
|
s.runningAlarmDelivery = cloneAlarmDeliveryPolicy(baseAlarmDelivery)
|
|
s.persistentAlarmDelivery = cloneAlarmDeliveryPolicy(baseAlarmDelivery)
|
|
s.pendingAlarmDelivery = nil
|
|
s.runningEmail = cloneEmailNotification(baseEmail)
|
|
s.persistentEmail = cloneEmailNotification(baseEmail)
|
|
s.pendingEmail = nil
|
|
s.runningSNMPAgent = cloneSNMPAgent(baseSNMPAgent)
|
|
s.persistentSNMPAgent = cloneSNMPAgent(baseSNMPAgent)
|
|
s.pendingSNMPAgent = nil
|
|
s.runningSNMPTraps = cloneSNMPTrapTargets(baseSNMPTraps)
|
|
s.persistentSNMPTraps = cloneSNMPTrapTargets(baseSNMPTraps)
|
|
s.pendingSNMPTraps = nil
|
|
s.runningNetwork = cloneNetworkConfig(baseNetwork)
|
|
s.persistentNetwork = cloneNetworkConfig(baseNetwork)
|
|
s.pendingNetwork = make(map[string]networkInterfaceConfig)
|
|
s.runningTime = cloneTimeSettings(baseTime)
|
|
s.persistentTime = cloneTimeSettings(baseTime)
|
|
s.pendingTime = nil
|
|
s.runningRetention = cloneLogRetention(baseRetention)
|
|
s.syncLinkTrackerIntervalLocked()
|
|
s.persistentRetention = cloneLogRetention(baseRetention)
|
|
s.pendingRetention = nil
|
|
s.runningManagement = cloneManagementAccess(baseManagement)
|
|
s.persistentManagement = cloneManagementAccess(baseManagement)
|
|
s.pendingManagement = nil
|
|
s.runningSyslog = cloneRemoteSyslog(baseSyslog)
|
|
s.persistentSyslog = cloneRemoteSyslog(baseSyslog)
|
|
s.pendingSyslog = nil
|
|
s.runningTLS = cloneTLSCertificate(baseTLS)
|
|
s.persistentTLS = cloneTLSCertificate(baseTLS)
|
|
s.pendingTLS = nil
|
|
s.runningExternalAuth = cloneExternalAuth(baseExternalAuth)
|
|
s.persistentExternalAuth = cloneExternalAuth(baseExternalAuth)
|
|
s.pendingExternalAuth = nil
|
|
s.direct = make(map[string]directState)
|
|
s.runningRevision++
|
|
s.persistentRevision++
|
|
}
|
|
|
|
func (s *Store) configExportStateLocked(ports map[string]portConfig, alarms map[string]alarmRuleConfig, alarmDelivery alarmDeliveryPolicyConfig, email emailNotificationConfig, snmpAgent snmpAgentConfig, snmpTraps []snmpTrapTargetConfig, network map[string]networkInterfaceConfig, timeConfig timeSettingsConfig, retention logRetentionConfig, management managementAccessConfig, syslog remoteSyslogConfig, tlsCertificate tlsCertificateConfig, externalAuth externalAuthConfig) domain.ConfigExportState {
|
|
return domain.ConfigExportState{
|
|
Ports: makePorts(ports),
|
|
AlarmRules: makeAlarmRules(alarms),
|
|
AlarmDelivery: makeAlarmDeliveryPolicy(alarmDelivery),
|
|
EmailNotification: makeEmailNotificationSettings(email),
|
|
SNMPAgent: makeSNMPAgentSettings(snmpAgent),
|
|
SNMPTrapTargets: makeSNMPTrapTargetSettings(snmpTraps),
|
|
Network: makeNetworkState(network),
|
|
Time: makeTimeSettings(timeConfig),
|
|
Logs: makeLogRetentionSettings(retention),
|
|
Management: makeManagementAccessSettings(management),
|
|
RemoteSyslog: makeRemoteSyslogSettings(syslog),
|
|
TLSCertificate: makeTLSCertificateSettings(tlsCertificate),
|
|
ExternalAuth: makeExternalAuthSettings(externalAuth),
|
|
}
|
|
}
|
|
|
|
func commandResult(result, message, auditID string, config *domain.ConfigSummary) domain.CommandResult {
|
|
return domain.CommandResult{
|
|
Result: result,
|
|
Message: message,
|
|
AuditID: auditID,
|
|
Config: config,
|
|
}
|
|
}
|
|
|
|
func oobStatus(config networkInterfaceConfig) domain.OOBStatus {
|
|
return domain.OOBStatus{
|
|
PrimaryInterface: config.OOBPrimaryInterface,
|
|
FallbackInterface: config.OOBFallbackInterface,
|
|
HealthCheckTarget: config.OOBHealthCheckTarget,
|
|
HealthCheckIntervalSecond: config.OOBHealthCheckSecond,
|
|
FailureThreshold: config.OOBFailureThreshold,
|
|
PrimaryLink: "up",
|
|
FallbackAvailable: false,
|
|
FallbackActive: false,
|
|
FailoverReason: "none",
|
|
LastFailoverAt: nil,
|
|
}
|
|
}
|
|
|
|
type portConfig struct {
|
|
Alias string
|
|
Mode string
|
|
ListenerPort int
|
|
SerialType string
|
|
BaudRate int
|
|
DataBits int
|
|
Parity string
|
|
StopBits int
|
|
StopBitsMode string
|
|
FlowControl string
|
|
RTSControl string
|
|
DTRControl string
|
|
IgnoreJammed bool
|
|
AlarmKeyword string
|
|
DataLogMode string
|
|
DataPackage domain.DataPackage
|
|
DataLog domain.DataLog
|
|
Operation1 domain.PortOperation
|
|
Operation2 domain.PortOperation
|
|
RedirectTo []string
|
|
}
|
|
|
|
type alarmRuleConfig struct {
|
|
Name string
|
|
Source string
|
|
Channel string
|
|
Trigger string
|
|
Severity string
|
|
Delivery []string
|
|
Enabled bool
|
|
}
|
|
|
|
type networkInterfaceConfig struct {
|
|
Name string
|
|
Device string
|
|
DeviceTreeNode string
|
|
PHY string
|
|
IdentitySource string
|
|
ActiveMedium string
|
|
Link string
|
|
Speed string
|
|
Address string
|
|
Gateway string
|
|
AddressMode string
|
|
DNS1 string
|
|
DNS2 string
|
|
IPv6Mode string
|
|
IPv6Address string
|
|
IPv6Gateway string
|
|
IPv6DNS1 string
|
|
IPv6DNS2 string
|
|
Role string
|
|
Weight int
|
|
HostName string
|
|
OperatingMode string
|
|
AdvMode string
|
|
BondMode string
|
|
BondMii int
|
|
BridgeSTP bool
|
|
DDNSEnabled bool
|
|
DDNSProvider string
|
|
DDNSIPv4Enabled bool
|
|
DDNSIPv6Enabled bool
|
|
DDNSUsername string
|
|
DDNSPassword string
|
|
DDNSIPv4Domain string
|
|
DDNSIPv6Domain string
|
|
DDNSCheckIPURL string
|
|
DDNSCustomProvider string
|
|
DDNSCustomPath string
|
|
OOBPrimaryInterface string
|
|
OOBFallbackInterface string
|
|
OOBHealthCheckTarget string
|
|
OOBHealthCheckSecond int
|
|
OOBFailureThreshold int
|
|
}
|
|
|
|
type timeSettingsConfig struct {
|
|
Mode string
|
|
Timezone string
|
|
NTPServers []string
|
|
}
|
|
|
|
type logRetentionConfig struct {
|
|
SystemDays int
|
|
AuditDays int
|
|
PortDays int
|
|
ProtectedUsageWarningPct int
|
|
PreserveOnFactoryReset bool
|
|
LocalSystemEnabled bool
|
|
LocalConfigurationEnabled bool
|
|
LocalOperationEnabled bool
|
|
LocalStatusEnabled bool
|
|
RemoteSystemEnabled bool
|
|
RemoteConfigurationEnabled bool
|
|
RemoteOperationEnabled bool
|
|
RemoteStatusEnabled bool
|
|
KeepaliveEnabled bool
|
|
KeepaliveIntervalMinutes int
|
|
AllPortStatusEnabled bool
|
|
AllPortStatusIntervalMinutes int
|
|
SinglePortStatusEnabled bool
|
|
SinglePortStatusRealtime bool
|
|
SinglePortStatusIntervalMinutes int
|
|
}
|
|
|
|
type alarmDeliveryPolicyConfig struct {
|
|
RetryCount int
|
|
RetryIntervalSeconds int
|
|
PendingQueueLimit int
|
|
NotifyOnRetryExhausted bool
|
|
}
|
|
|
|
type emailNotificationConfig struct {
|
|
Enabled bool
|
|
SMTPHost string
|
|
SMTPPort int
|
|
STARTTLSEnabled bool
|
|
AuthEnabled bool
|
|
Username string
|
|
PasswordStored bool
|
|
From string
|
|
To []string
|
|
CC []string
|
|
CustomTitle string
|
|
CustomContent string
|
|
}
|
|
|
|
type snmpAgentConfig struct {
|
|
Enabled bool
|
|
Location string
|
|
Contact string
|
|
V1Enabled bool
|
|
V2CEnabled bool
|
|
V3Enabled bool
|
|
ReadOnlyCommunityStored bool
|
|
ReadWriteCommunityStored bool
|
|
V3Users []snmpV3UserConfig
|
|
}
|
|
|
|
type snmpV3UserConfig struct {
|
|
Username string
|
|
Access string
|
|
AuthProtocol string
|
|
PrivacyProtocol string
|
|
AuthKeyStored bool
|
|
PrivacyKeyStored bool
|
|
Enabled bool
|
|
}
|
|
|
|
type snmpTrapTargetConfig struct {
|
|
TargetID string
|
|
Host string
|
|
Port int
|
|
Version string
|
|
Username string
|
|
AuthProtocol string
|
|
PrivacyProtocol string
|
|
EngineID string
|
|
CredentialStored bool
|
|
PrivacyKeyStored bool
|
|
Enabled bool
|
|
}
|
|
|
|
type managementAccessConfig struct {
|
|
HTTPEnabled bool
|
|
HTTPPort int
|
|
HTTPSEnabled bool
|
|
HTTPSPort int
|
|
SSHEnabled bool
|
|
SSHPort int
|
|
TelnetEnabled bool
|
|
TelnetPort int
|
|
SerialConsoleEnabled bool
|
|
IdleTimeoutMinutes int
|
|
CaptchaEnabled bool
|
|
LoginDetectionMinutes int
|
|
LoginSecurityHitCount int
|
|
}
|
|
|
|
type remoteSyslogConfig struct {
|
|
Enabled bool
|
|
Host string
|
|
Port int
|
|
Protocol string
|
|
Facility string
|
|
}
|
|
|
|
type tlsCertificateConfig struct {
|
|
ActiveCertificateID string
|
|
Subject string
|
|
Issuer string
|
|
NotBefore time.Time
|
|
NotAfter time.Time
|
|
FingerprintSHA256 string
|
|
Source string
|
|
PrivateKeyStored bool
|
|
}
|
|
|
|
type externalAuthConfig struct {
|
|
Enabled bool
|
|
PrimaryMethod string
|
|
LocalFallback bool
|
|
TimeoutSeconds int
|
|
RadiusServers []externalAuthServerConfig
|
|
TACACSServers []externalAuthServerConfig
|
|
DefaultRole string
|
|
}
|
|
|
|
type externalAuthServerConfig struct {
|
|
ServerID string
|
|
Host string
|
|
Port int
|
|
SecretStored bool
|
|
Enabled bool
|
|
}
|
|
|
|
type directState struct {
|
|
ViewerCount int
|
|
MaxViewers int
|
|
Writer string
|
|
WriterSessionID string
|
|
Sequence int
|
|
}
|
|
|
|
type directSessionRecord struct {
|
|
SessionID string
|
|
Channel string
|
|
Actor string
|
|
Access string
|
|
}
|
|
|
|
type sessionRecord struct {
|
|
SessionID string
|
|
Actor domain.Actor
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
Persistent bool
|
|
}
|
|
|
|
type loginFailureRecord struct {
|
|
Count int `json:"count"`
|
|
LockedUntil time.Time `json:"locked_until,omitempty"`
|
|
LastAttempt time.Time `json:"last_attempt"`
|
|
}
|
|
|
|
type configSnapshot struct {
|
|
Running map[string]portConfig `json:"running"`
|
|
Persistent map[string]portConfig `json:"persistent"`
|
|
Pending map[string]portConfig `json:"pending"`
|
|
RunningAlarms map[string]alarmRuleConfig `json:"running_alarms"`
|
|
PersistentAlarms map[string]alarmRuleConfig `json:"persistent_alarms"`
|
|
PendingAlarms map[string]alarmRuleConfig `json:"pending_alarms"`
|
|
RunningAlarmDelivery *alarmDeliveryPolicyConfig `json:"running_alarm_delivery,omitempty"`
|
|
PersistentAlarmDelivery *alarmDeliveryPolicyConfig `json:"persistent_alarm_delivery,omitempty"`
|
|
PendingAlarmDelivery *alarmDeliveryPolicyConfig `json:"pending_alarm_delivery,omitempty"`
|
|
RunningEmail *emailNotificationConfig `json:"running_email_notification,omitempty"`
|
|
PersistentEmail *emailNotificationConfig `json:"persistent_email_notification,omitempty"`
|
|
PendingEmail *emailNotificationConfig `json:"pending_email_notification,omitempty"`
|
|
RunningSNMPAgent *snmpAgentConfig `json:"running_snmp_agent,omitempty"`
|
|
PersistentSNMPAgent *snmpAgentConfig `json:"persistent_snmp_agent,omitempty"`
|
|
PendingSNMPAgent *snmpAgentConfig `json:"pending_snmp_agent,omitempty"`
|
|
RunningSNMPTraps []snmpTrapTargetConfig `json:"running_snmp_trap_targets,omitempty"`
|
|
PersistentSNMPTraps []snmpTrapTargetConfig `json:"persistent_snmp_trap_targets,omitempty"`
|
|
PendingSNMPTraps []snmpTrapTargetConfig `json:"pending_snmp_trap_targets,omitempty"`
|
|
RunningNetwork map[string]networkInterfaceConfig `json:"running_network"`
|
|
PersistentNetwork map[string]networkInterfaceConfig `json:"persistent_network"`
|
|
PendingNetwork map[string]networkInterfaceConfig `json:"pending_network"`
|
|
RunningTime *timeSettingsConfig `json:"running_time,omitempty"`
|
|
PersistentTime *timeSettingsConfig `json:"persistent_time,omitempty"`
|
|
PendingTime *timeSettingsConfig `json:"pending_time,omitempty"`
|
|
RunningRetention *logRetentionConfig `json:"running_log_retention,omitempty"`
|
|
PersistentRetention *logRetentionConfig `json:"persistent_log_retention,omitempty"`
|
|
PendingRetention *logRetentionConfig `json:"pending_log_retention,omitempty"`
|
|
RunningManagement *managementAccessConfig `json:"running_management_access,omitempty"`
|
|
PersistentManagement *managementAccessConfig `json:"persistent_management_access,omitempty"`
|
|
PendingManagement *managementAccessConfig `json:"pending_management_access,omitempty"`
|
|
RunningSyslog *remoteSyslogConfig `json:"running_remote_syslog,omitempty"`
|
|
PersistentSyslog *remoteSyslogConfig `json:"persistent_remote_syslog,omitempty"`
|
|
PendingSyslog *remoteSyslogConfig `json:"pending_remote_syslog,omitempty"`
|
|
RunningTLS *tlsCertificateConfig `json:"running_tls_certificate,omitempty"`
|
|
PersistentTLS *tlsCertificateConfig `json:"persistent_tls_certificate,omitempty"`
|
|
PendingTLS *tlsCertificateConfig `json:"pending_tls_certificate,omitempty"`
|
|
RunningExternalAuth *externalAuthConfig `json:"running_external_auth,omitempty"`
|
|
PersistentExternalAuth *externalAuthConfig `json:"persistent_external_auth,omitempty"`
|
|
PendingExternalAuth *externalAuthConfig `json:"pending_external_auth,omitempty"`
|
|
RunningRevision int `json:"running_revision"`
|
|
PersistentRevision int `json:"persistent_revision"`
|
|
}
|
|
|
|
type operationSnapshot struct {
|
|
Operations map[string]domain.Operation `json:"operations"`
|
|
}
|
|
|
|
type sessionSnapshot struct {
|
|
Sequence int `json:"sequence"`
|
|
Sessions map[string]sessionRecord `json:"sessions"`
|
|
}
|
|
|
|
type loginFailureSnapshot struct {
|
|
Failures map[string]loginFailureRecord `json:"failures"`
|
|
}
|
|
|
|
type authSnapshot struct {
|
|
Users map[string]authUserRecord `json:"users"`
|
|
Groups map[string]authGroupRecord `json:"groups,omitempty"`
|
|
TokenSequence int `json:"token_sequence,omitempty"`
|
|
Tokens map[string]authTokenRecord `json:"tokens,omitempty"`
|
|
AddressFilters *domain.AddressFilterSettings `json:"address_filters,omitempty"`
|
|
}
|
|
|
|
type authGroupRecord struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role,omitempty"`
|
|
Permissions []string `json:"permissions,omitempty"`
|
|
PortAccess map[string]string `json:"port_access,omitempty"`
|
|
System bool `json:"system,omitempty"`
|
|
}
|
|
|
|
type authUserRecord struct {
|
|
Username string `json:"username"`
|
|
Actor string `json:"actor"`
|
|
Role string `json:"role"`
|
|
Permissions []string `json:"permissions"`
|
|
Password password.Record `json:"password"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
PortAccess map[string]string `json:"port_access,omitempty"`
|
|
MustChangePassword bool `json:"must_change_password,omitempty"`
|
|
}
|
|
|
|
type authTokenRecord struct {
|
|
TokenID string `json:"token_id"`
|
|
Name string `json:"name"`
|
|
Actor string `json:"actor"`
|
|
Role string `json:"role"`
|
|
Permissions []string `json:"permissions"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
PortAccess map[string]string `json:"port_access,omitempty"`
|
|
MustChangePassword bool `json:"must_change_password,omitempty"`
|
|
}
|
|
|
|
func defaultAuthUsers(kdf password.KDF) (map[string]authUserRecord, error) {
|
|
keyer, ok := kdf.(defaultAuthSeedCacheKeyer)
|
|
if !ok {
|
|
return buildDefaultAuthUsers(kdf)
|
|
}
|
|
key := strings.TrimSpace(keyer.DefaultAuthSeedCacheKey())
|
|
if key == "" {
|
|
return buildDefaultAuthUsers(kdf)
|
|
}
|
|
|
|
defaultAuthSeedCache.Lock()
|
|
cached, ok := defaultAuthSeedCache.users[key]
|
|
defaultAuthSeedCache.Unlock()
|
|
if ok {
|
|
return cloneAuthUserRecords(cached), nil
|
|
}
|
|
|
|
users, err := buildDefaultAuthUsers(kdf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defaultAuthSeedCache.Lock()
|
|
if cached, ok := defaultAuthSeedCache.users[key]; ok {
|
|
defaultAuthSeedCache.Unlock()
|
|
return cloneAuthUserRecords(cached), nil
|
|
}
|
|
defaultAuthSeedCache.users[key] = cloneAuthUserRecords(users)
|
|
defaultAuthSeedCache.Unlock()
|
|
|
|
return cloneAuthUserRecords(users), nil
|
|
}
|
|
|
|
func buildDefaultAuthUsers(kdf password.KDF) (map[string]authUserRecord, error) {
|
|
users := make(map[string]authUserRecord)
|
|
for _, item := range []struct {
|
|
username string
|
|
actor string
|
|
role string
|
|
permissions []string
|
|
passwordText string
|
|
}{
|
|
{username: "admin", actor: "mock-admin", role: "administrator", permissions: []string{
|
|
"alarm.read",
|
|
"alarm.write",
|
|
"audit.read",
|
|
"auth.read",
|
|
"auth.write",
|
|
"config.read",
|
|
"config.write",
|
|
"diagnostics.run",
|
|
"direct.view",
|
|
"direct.write",
|
|
"hardware.read",
|
|
"hardware.write",
|
|
"firmware.apply",
|
|
"firmware.read",
|
|
"firmware.upload",
|
|
"log.clear",
|
|
"log.export",
|
|
"log.read",
|
|
"network.read",
|
|
"network.write",
|
|
"operation.read",
|
|
"port.read",
|
|
"port.restart",
|
|
"port.write",
|
|
"system.factory_reset",
|
|
"system.reboot",
|
|
"system.read",
|
|
"time.sync",
|
|
}, passwordText: "mock-admin-secret"},
|
|
{username: "port-operator", actor: "mock-port-operator", role: "port_operator", permissions: []string{
|
|
"direct.view",
|
|
"direct.write",
|
|
"port.read",
|
|
"port.restart",
|
|
"port.write",
|
|
}, passwordText: "mock-port-operator-secret"},
|
|
{username: "viewer", actor: "mock-viewer", role: "viewer", permissions: []string{
|
|
"direct.view",
|
|
}, passwordText: "mock-viewer-secret"},
|
|
} {
|
|
user, err := newAuthUser(item.username, item.actor, item.role, item.permissions, item.passwordText, kdf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
users[user.Username] = user
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func cloneAuthUserRecords(source map[string]authUserRecord) map[string]authUserRecord {
|
|
clone := make(map[string]authUserRecord, len(source))
|
|
for username, user := range source {
|
|
user.Permissions = append([]string(nil), user.Permissions...)
|
|
user.Groups = append([]string(nil), user.Groups...)
|
|
user.PortAccess = clonePortAccess(user.PortAccess)
|
|
clone[username] = user
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func baseAuthGroups() map[string]domain.AuthGroup {
|
|
return map[string]domain.AuthGroup{
|
|
"administrators": {
|
|
GroupID: "administrators",
|
|
Name: "Administrators",
|
|
Role: "administrator",
|
|
Permissions: permissionsForRole("administrator"),
|
|
Members: []string{},
|
|
PortPermissions: allPortPermissions("write"), System: true,
|
|
},
|
|
"port_operators": {
|
|
GroupID: "port_operators",
|
|
Name: "Port operators",
|
|
Role: "port_operator",
|
|
Permissions: permissionsForRole("port_operator"),
|
|
Members: []string{},
|
|
PortPermissions: allPortPermissions("write"), System: true,
|
|
},
|
|
"viewers": {
|
|
GroupID: "viewers",
|
|
Name: "Viewers",
|
|
Role: "viewer",
|
|
Permissions: permissionsForRole("viewer"),
|
|
Members: []string{},
|
|
PortPermissions: allPortPermissions("read"), System: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func baseAuthPermissions() []domain.AuthPermission {
|
|
permissions := []domain.AuthPermission{
|
|
{Permission: "alarm.read", Description: "Read alarm rule configuration", Sensitive: true},
|
|
{Permission: "alarm.write", Description: "Stage alarm rule configuration changes", Sensitive: true},
|
|
{Permission: "audit.read", Description: "Read protected audit events", Sensitive: true},
|
|
{Permission: "auth.read", Description: "Read local user, group, and permission policy", Sensitive: true},
|
|
{Permission: "auth.write", Description: "Create and manage local users and groups", Sensitive: true},
|
|
{Permission: "config.read", Description: "Read configuration transaction state", Sensitive: true},
|
|
{Permission: "config.write", Description: "Apply and persist product configuration changes", Sensitive: true},
|
|
{Permission: "diagnostics.run", Description: "Run network and support diagnostics", Sensitive: true},
|
|
{Permission: "hardware.read", Description: "Read green LED and validated serial hardware status", Sensitive: true},
|
|
{Permission: "hardware.write", Description: "Force or pulse a PCF8574 green LED override", Sensitive: true},
|
|
{Permission: "direct.view", Description: "Open Direct Mode viewer sessions", Sensitive: false},
|
|
{Permission: "direct.write", Description: "Claim Direct Mode writer ownership", Sensitive: true},
|
|
{Permission: "firmware.apply", Description: "Apply a validated firmware package when the target recovery policy permits it", Sensitive: true},
|
|
{Permission: "firmware.read", Description: "Read firmware upload, validation, and recovery status", Sensitive: true},
|
|
{Permission: "firmware.upload", Description: "Upload and validate a signed local firmware package", Sensitive: true},
|
|
{Permission: "log.export", Description: "Export protected logs", Sensitive: true},
|
|
{Permission: "log.clear", Description: "Clear one port's communication logs while preserving protected system and audit logs", Sensitive: true},
|
|
{Permission: "log.read", Description: "Read protected port communication logs", Sensitive: true},
|
|
{Permission: "network.read", Description: "Read network interface and OOB status", Sensitive: true},
|
|
{Permission: "network.write", Description: "Stage network interface configuration changes", Sensitive: true},
|
|
{Permission: "operation.read", Description: "Read operation progress and result state", Sensitive: true},
|
|
{Permission: "port.read", Description: "Read serial channel configuration and status", Sensitive: true},
|
|
{Permission: "port.restart", Description: "Restart a serial channel service path", Sensitive: true},
|
|
{Permission: "port.write", Description: "Stage serial channel configuration changes", Sensitive: true},
|
|
{Permission: "system.factory_reset", Description: "Run factory reset while preserving protected logs", Sensitive: true},
|
|
{Permission: "system.reboot", Description: "Queue a device reboot operation", Sensitive: true},
|
|
{Permission: "system.read", Description: "Read dashboard system status", Sensitive: true},
|
|
{Permission: "time.sync", Description: "Synchronize device time from an operator browser", Sensitive: true},
|
|
}
|
|
sort.Slice(permissions, func(left, right int) bool {
|
|
return permissions[left].Permission < permissions[right].Permission
|
|
})
|
|
return permissions
|
|
}
|
|
|
|
func permissionsForRole(role string) []string {
|
|
switch role {
|
|
case "administrator":
|
|
return []string{
|
|
"alarm.read",
|
|
"alarm.write",
|
|
"audit.read",
|
|
"auth.read",
|
|
"auth.write",
|
|
"config.read",
|
|
"config.write",
|
|
"diagnostics.run",
|
|
"direct.view",
|
|
"direct.write",
|
|
"hardware.read",
|
|
"hardware.write",
|
|
"firmware.apply",
|
|
"firmware.read",
|
|
"firmware.upload",
|
|
"log.clear",
|
|
"log.export",
|
|
"log.read",
|
|
"network.read",
|
|
"network.write",
|
|
"operation.read",
|
|
"port.read",
|
|
"port.restart",
|
|
"port.write",
|
|
"system.factory_reset",
|
|
"system.reboot",
|
|
"system.read",
|
|
"time.sync",
|
|
}
|
|
case "port_operator":
|
|
return []string{
|
|
"config.read",
|
|
"config.write",
|
|
"direct.view",
|
|
"direct.write",
|
|
"network.read",
|
|
"port.read",
|
|
"port.restart",
|
|
"port.write",
|
|
"system.read",
|
|
}
|
|
default:
|
|
return []string{"direct.view"}
|
|
}
|
|
}
|
|
|
|
func isAuthRole(role string) bool {
|
|
switch role {
|
|
case "administrator", "port_operator", "viewer":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func groupIDForRole(role string) string {
|
|
switch role {
|
|
case "administrator":
|
|
return "administrators"
|
|
case "port_operator":
|
|
return "port_operators"
|
|
default:
|
|
return "viewers"
|
|
}
|
|
}
|
|
|
|
func (s *Store) loginLockedLocked(username string, now time.Time) (bool, bool) {
|
|
if username == "" {
|
|
return false, false
|
|
}
|
|
failure := s.loginFailures[username]
|
|
if failure.LockedUntil.IsZero() {
|
|
return false, false
|
|
}
|
|
if now.Before(failure.LockedUntil) {
|
|
return true, false
|
|
}
|
|
delete(s.loginFailures, username)
|
|
return false, true
|
|
}
|
|
|
|
func (s *Store) recordLoginFailureLocked(username string, now time.Time) bool {
|
|
if username == "" {
|
|
return false
|
|
}
|
|
failure := s.loginFailures[username]
|
|
threshold, detectionWindow := s.loginProtectionParametersLocked()
|
|
if !failure.LastAttempt.IsZero() && now.Sub(failure.LastAttempt) > detectionWindow {
|
|
failure = loginFailureRecord{}
|
|
}
|
|
if !failure.LockedUntil.IsZero() && !now.Before(failure.LockedUntil) {
|
|
failure = loginFailureRecord{}
|
|
}
|
|
failure.Count++
|
|
failure.LastAttempt = now
|
|
if failure.Count >= threshold {
|
|
failure.Count = threshold
|
|
failure.LockedUntil = now.Add(detectionWindow)
|
|
s.loginFailures[username] = failure
|
|
s.trimLoginFailuresLocked(username)
|
|
return true
|
|
}
|
|
s.loginFailures[username] = failure
|
|
s.trimLoginFailuresLocked(username)
|
|
return false
|
|
}
|
|
|
|
func (s *Store) loginProtectionParametersLocked() (int, time.Duration) {
|
|
threshold := failedLoginLockThreshold
|
|
detectionWindow := failedLoginLockDuration
|
|
if s.runningManagement.LoginSecurityHitCount > 0 {
|
|
threshold = s.runningManagement.LoginSecurityHitCount
|
|
}
|
|
if s.runningManagement.LoginDetectionMinutes > 0 {
|
|
detectionWindow = time.Duration(s.runningManagement.LoginDetectionMinutes) * time.Minute
|
|
}
|
|
return threshold, detectionWindow
|
|
}
|
|
|
|
func (s *Store) trimLoginFailuresLocked(preserveUsername string) {
|
|
if s.authMaxLoginFailures <= 0 || len(s.loginFailures) <= s.authMaxLoginFailures {
|
|
return
|
|
}
|
|
type failureEntry struct {
|
|
username string
|
|
record loginFailureRecord
|
|
}
|
|
entries := make([]failureEntry, 0, len(s.loginFailures))
|
|
for username, record := range s.loginFailures {
|
|
entries = append(entries, failureEntry{username: username, record: record})
|
|
}
|
|
sort.Slice(entries, func(left, right int) bool {
|
|
leftRecord := entries[left].record
|
|
rightRecord := entries[right].record
|
|
if !leftRecord.LastAttempt.Equal(rightRecord.LastAttempt) {
|
|
return leftRecord.LastAttempt.Before(rightRecord.LastAttempt)
|
|
}
|
|
return entries[left].username < entries[right].username
|
|
})
|
|
for _, entry := range entries {
|
|
if len(s.loginFailures) <= s.authMaxLoginFailures {
|
|
return
|
|
}
|
|
if entry.username == preserveUsername {
|
|
continue
|
|
}
|
|
delete(s.loginFailures, entry.username)
|
|
}
|
|
}
|
|
|
|
func newAuthUser(username, actor, role string, permissions []string, passwordValue string, kdf password.KDF) (authUserRecord, error) {
|
|
normalized := normalizeUsername(username)
|
|
salt, err := password.NewSalt()
|
|
if err != nil {
|
|
return authUserRecord{}, err
|
|
}
|
|
record, err := kdf.Hash(passwordValue, salt)
|
|
if err != nil {
|
|
return authUserRecord{}, err
|
|
}
|
|
return authUserRecord{
|
|
Username: normalized,
|
|
Actor: actor,
|
|
Role: role,
|
|
Permissions: sortedPermissionList(permissions),
|
|
Password: record,
|
|
Groups: []string{groupIDForRole(role)},
|
|
PortAccess: make(map[string]string),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Store) actorFromAuthUserLocked(user authUserRecord) domain.Actor {
|
|
permissions, portAccess := s.effectiveAuthPolicyLocked(user)
|
|
return domain.Actor{
|
|
Name: user.Actor,
|
|
Role: user.Role,
|
|
Permissions: permissionsMap(permissions),
|
|
PortAccess: portAccess,
|
|
MustChangePassword: user.MustChangePassword,
|
|
}
|
|
}
|
|
|
|
func newSessionToken() (string, error) {
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", fmt.Errorf("generate session token: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
func sessionTokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func normalizeUsername(username string) string {
|
|
return strings.ToLower(strings.TrimSpace(username))
|
|
}
|
|
|
|
func normalizeGroupID(groupID string) string {
|
|
return strings.ToLower(strings.TrimSpace(groupID))
|
|
}
|
|
|
|
func validateGroupID(groupID string) error {
|
|
if groupID == "" {
|
|
return fmt.Errorf("auth group id must not be empty")
|
|
}
|
|
if len(groupID) > 64 {
|
|
return fmt.Errorf("auth group id must be at most 64 characters")
|
|
}
|
|
for _, char := range groupID {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '_' {
|
|
continue
|
|
}
|
|
return fmt.Errorf("auth group id must contain only lowercase letters, numbers, hyphen, or underscore")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeGroupIDs(values []string) ([]string, error) {
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
groupID := normalizeGroupID(value)
|
|
if err := validateGroupID(groupID); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, duplicate := seen[groupID]; duplicate {
|
|
return nil, fmt.Errorf("duplicate auth group %s", groupID)
|
|
}
|
|
seen[groupID] = struct{}{}
|
|
result = append(result, groupID)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func replaceGroupID(values []string, oldID, newID string) []string {
|
|
result := make([]string, 0, len(values)+1)
|
|
found := false
|
|
for _, value := range values {
|
|
if value == oldID {
|
|
value, found = newID, true
|
|
}
|
|
if !containsString(result, value) {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
if !found && !containsString(result, newID) {
|
|
result = append(result, newID)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func containsString(values []string, expected string) bool {
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeFunctionPermissions(values []string) ([]string, error) {
|
|
allowed := make(map[string]struct{})
|
|
for _, item := range baseAuthPermissions() {
|
|
allowed[item.Permission] = struct{}{}
|
|
}
|
|
result := sortedPermissionList(values)
|
|
for _, permission := range result {
|
|
if _, ok := allowed[permission]; !ok {
|
|
return nil, fmt.Errorf("unknown auth permission %s", permission)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func portPermissionMap(items []domain.PortPermission) map[string]string {
|
|
result := make(map[string]string, len(items))
|
|
for _, item := range items {
|
|
result[item.Channel] = item.Access
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validateUsername(username string) error {
|
|
if len(username) > 64 {
|
|
return fmt.Errorf("username must be at most 64 characters")
|
|
}
|
|
for _, char := range username {
|
|
if char >= 'a' && char <= 'z' {
|
|
continue
|
|
}
|
|
if char >= '0' && char <= '9' {
|
|
continue
|
|
}
|
|
if char == '-' || char == '_' || char == '.' {
|
|
continue
|
|
}
|
|
return fmt.Errorf("username must contain only lowercase letters, numbers, dot, hyphen, or underscore")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeRole(role string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
|
switch normalized {
|
|
case "administrator", "admin":
|
|
return "administrator"
|
|
case "port_operator", "port-operator", "port operator":
|
|
return "port_operator"
|
|
case "viewer":
|
|
return "viewer"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (s *Store) authUserSummaryLocked(user authUserRecord) domain.AuthUser {
|
|
permissions, portAccess := s.effectiveAuthPolicyLocked(user)
|
|
return domain.AuthUser{
|
|
Username: user.Username,
|
|
Actor: user.Actor,
|
|
Role: user.Role,
|
|
Groups: append([]string(nil), user.Groups...),
|
|
Permissions: permissions,
|
|
Enabled: !user.Disabled,
|
|
PortPermissions: portPermissionsFromMap(portAccess),
|
|
MustChangePassword: user.MustChangePassword,
|
|
}
|
|
}
|
|
|
|
func authSessionInfo(session sessionRecord) domain.AuthSessionInfo {
|
|
return domain.AuthSessionInfo{
|
|
SessionID: session.SessionID,
|
|
Actor: session.Actor.Name,
|
|
Role: session.Actor.Role,
|
|
CreatedAt: session.CreatedAt,
|
|
ExpiresAt: session.ExpiresAt,
|
|
Persistent: session.Persistent,
|
|
}
|
|
}
|
|
|
|
func authTokenInfo(token authTokenRecord) domain.AuthTokenInfo {
|
|
return domain.AuthTokenInfo{
|
|
TokenID: token.TokenID,
|
|
Name: token.Name,
|
|
Actor: token.Actor,
|
|
Role: token.Role,
|
|
CreatedAt: token.CreatedAt,
|
|
ExpiresAt: token.ExpiresAt,
|
|
LastUsedAt: token.LastUsedAt,
|
|
}
|
|
}
|
|
|
|
func authTokenActor(token authTokenRecord) domain.Actor {
|
|
return domain.Actor{
|
|
Name: token.Actor,
|
|
Role: token.Role,
|
|
Permissions: permissionsMap(token.Permissions),
|
|
PortAccess: clonePortAccess(token.PortAccess),
|
|
MustChangePassword: token.MustChangePassword,
|
|
}
|
|
}
|
|
|
|
func (s *Store) authGroupsLocked() []domain.AuthGroup {
|
|
groups := baseAuthGroups()
|
|
for groupID, override := range s.authGroupOverrides {
|
|
group, ok := groups[groupID]
|
|
if !ok {
|
|
group = domain.AuthGroup{GroupID: groupID, System: false}
|
|
}
|
|
if override.Name != "" {
|
|
group.Name = override.Name
|
|
}
|
|
if override.Role != "" {
|
|
group.Role = override.Role
|
|
}
|
|
if override.Permissions != nil {
|
|
group.Permissions = sortedPermissionList(override.Permissions)
|
|
}
|
|
if override.PortAccess != nil {
|
|
group.PortPermissions = portPermissionsFromMap(override.PortAccess)
|
|
}
|
|
group.System = ok
|
|
groups[groupID] = group
|
|
}
|
|
for _, username := range sortedAuthUsernames(s.authUsers) {
|
|
user := s.authUsers[username]
|
|
for _, groupID := range user.Groups {
|
|
group, ok := groups[groupID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
group.Members = append(group.Members, username)
|
|
groups[groupID] = group
|
|
}
|
|
}
|
|
|
|
groupIDs := make([]string, 0, len(groups))
|
|
for groupID := range groups {
|
|
groupIDs = append(groupIDs, groupID)
|
|
}
|
|
sort.Strings(groupIDs)
|
|
|
|
result := make([]domain.AuthGroup, 0, len(groupIDs))
|
|
for _, groupID := range groupIDs {
|
|
group := groups[groupID]
|
|
sort.Strings(group.Members)
|
|
result = append(result, group)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Store) authGroupLocked(groupID string) domain.AuthGroup {
|
|
for _, group := range s.authGroupsLocked() {
|
|
if group.GroupID == groupID {
|
|
return group
|
|
}
|
|
}
|
|
return domain.AuthGroup{}
|
|
}
|
|
|
|
func (s *Store) effectiveAuthPolicyLocked(user authUserRecord) ([]string, map[string]string) {
|
|
permissions := append([]string(nil), user.Permissions...)
|
|
portAccess := make(map[string]string, 48)
|
|
groups := s.authGroupsLocked()
|
|
byID := make(map[string]domain.AuthGroup, len(groups))
|
|
for _, group := range groups {
|
|
byID[group.GroupID] = group
|
|
}
|
|
for _, groupID := range user.Groups {
|
|
group, ok := byID[groupID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
permissions = append(permissions, group.Permissions...)
|
|
for _, grant := range group.PortPermissions {
|
|
if portAccessRank(grant.Access) > portAccessRank(portAccess[grant.Channel]) {
|
|
portAccess[grant.Channel] = grant.Access
|
|
}
|
|
}
|
|
}
|
|
for channel, access := range user.PortAccess {
|
|
portAccess[channel] = access
|
|
}
|
|
return sortedPermissionList(permissions), portAccess
|
|
}
|
|
|
|
func defaultPortAccessForRole(role string) map[string]string {
|
|
access := "read"
|
|
if role == "administrator" || role == "port_operator" {
|
|
access = "write"
|
|
}
|
|
result := make(map[string]string, 48)
|
|
for index := 1; index <= 48; index++ {
|
|
result[channelName(index)] = access
|
|
}
|
|
return result
|
|
}
|
|
|
|
func allPortPermissions(access string) []domain.PortPermission {
|
|
return portPermissionsFromMap(defaultPortAccessForRole(map[string]string{"read": "viewer", "write": "administrator"}[access]))
|
|
}
|
|
|
|
func clonePortAccess(source map[string]string) map[string]string {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]string, len(source))
|
|
for channel, access := range source {
|
|
result[channel] = access
|
|
}
|
|
return result
|
|
}
|
|
|
|
func portPermissionsFromMap(source map[string]string) []domain.PortPermission {
|
|
result := make([]domain.PortPermission, 0, 48)
|
|
for index := 1; index <= 48; index++ {
|
|
channel := channelName(index)
|
|
access := source[channel]
|
|
if access == "" {
|
|
access = "deny"
|
|
}
|
|
result = append(result, domain.PortPermission{Channel: channel, Access: access})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizePortPermissions(items []domain.PortPermission) (map[string]string, error) {
|
|
result := make(map[string]string, len(items))
|
|
for _, item := range items {
|
|
channel := strings.ToUpper(strings.TrimSpace(item.Channel))
|
|
if _, err := parseChannel(channel); err != nil {
|
|
return nil, err
|
|
}
|
|
access := strings.ToLower(strings.TrimSpace(item.Access))
|
|
if access != "deny" && access != "read" && access != "write" {
|
|
return nil, fmt.Errorf("port access must be deny, read, or write")
|
|
}
|
|
if _, duplicate := result[channel]; duplicate {
|
|
return nil, fmt.Errorf("duplicate port permission for %s", channel)
|
|
}
|
|
result[channel] = access
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func portAccessRank(access string) int {
|
|
switch access {
|
|
case "write":
|
|
return 2
|
|
case "read":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func cloneAddressFilters(source domain.AddressFilterSettings) domain.AddressFilterSettings {
|
|
source.Rules = append([]domain.AddressFilterRule(nil), source.Rules...)
|
|
return source
|
|
}
|
|
|
|
func normalizeAddressFilters(source domain.AddressFilterSettings) (domain.AddressFilterSettings, error) {
|
|
source.Mode = strings.ToLower(strings.TrimSpace(source.Mode))
|
|
if source.Mode != "blacklist" && source.Mode != "whitelist" {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter mode must be blacklist or whitelist")
|
|
}
|
|
if len(source.Rules) > 128 {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter rule limit exceeded")
|
|
}
|
|
seen := make(map[string]struct{}, len(source.Rules))
|
|
for index := range source.Rules {
|
|
rule := &source.Rules[index]
|
|
rule.RuleID = strings.ToLower(strings.TrimSpace(rule.RuleID))
|
|
rule.AddressType = strings.ToLower(strings.TrimSpace(rule.AddressType))
|
|
rule.Address = strings.TrimSpace(rule.Address)
|
|
rule.Protocol = strings.ToLower(strings.TrimSpace(rule.Protocol))
|
|
if err := validateGroupID(rule.RuleID); err != nil {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter rule_id: %w", err)
|
|
}
|
|
if _, duplicate := seen[rule.RuleID]; duplicate {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter rule_id must be unique")
|
|
}
|
|
seen[rule.RuleID] = struct{}{}
|
|
if rule.AddressType == "ipv4" {
|
|
if _, err := parseIPv4Prefix(rule.Address); err != nil {
|
|
return domain.AddressFilterSettings{}, err
|
|
}
|
|
} else if rule.AddressType == "ipv6" {
|
|
if _, err := parseIPv6Prefix(rule.Address); err != nil {
|
|
return domain.AddressFilterSettings{}, err
|
|
}
|
|
} else if rule.AddressType == "mac" {
|
|
if normalizeMAC(rule.Address) == "" {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter MAC address is invalid")
|
|
}
|
|
} else {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter address_type must be ipv4, ipv6, or mac")
|
|
}
|
|
if !containsString([]string{"all", "http", "https", "ssh", "telnet"}, rule.Protocol) {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter protocol is invalid")
|
|
}
|
|
if rule.DestinationPort < 0 || rule.DestinationPort > 65535 {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("address filter destination_port must be 0 through 65535")
|
|
}
|
|
}
|
|
sort.Slice(source.Rules, func(left, right int) bool { return source.Rules[left].RuleID < source.Rules[right].RuleID })
|
|
if source.Enabled && source.Mode == "whitelist" {
|
|
enabled := false
|
|
for _, rule := range source.Rules {
|
|
if rule.Enabled {
|
|
enabled = true
|
|
break
|
|
}
|
|
}
|
|
if !enabled {
|
|
return domain.AddressFilterSettings{}, fmt.Errorf("enabled whitelist requires at least one enabled rule")
|
|
}
|
|
}
|
|
return source, nil
|
|
}
|
|
|
|
func parseIPv4Prefix(value string) (netip.Prefix, error) {
|
|
if !strings.Contains(value, "/") {
|
|
value += "/32"
|
|
}
|
|
prefix, err := netip.ParsePrefix(value)
|
|
if err != nil || !prefix.Addr().Is4() {
|
|
return netip.Prefix{}, fmt.Errorf("address filter requires an IPv4 address or CIDR prefix")
|
|
}
|
|
return prefix.Masked(), nil
|
|
}
|
|
|
|
func parseIPv6Prefix(value string) (netip.Prefix, error) {
|
|
if !strings.Contains(value, "/") {
|
|
value += "/128"
|
|
}
|
|
prefix, err := netip.ParsePrefix(value)
|
|
if err != nil || !prefix.Addr().Is6() {
|
|
return netip.Prefix{}, fmt.Errorf("address filter requires an IPv6 address or CIDR prefix")
|
|
}
|
|
return prefix.Masked(), nil
|
|
}
|
|
|
|
func normalizeMAC(value string) string {
|
|
hardware, err := net.ParseMAC(strings.TrimSpace(value))
|
|
if err != nil || len(hardware) != 6 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(hardware.String())
|
|
}
|
|
|
|
func resolveARP(ctx context.Context, address netip.Addr) (string, error) {
|
|
content, err := os.ReadFile("/proc/net/arp")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 4 && fields[0] == address.String() {
|
|
return normalizeMAC(fields[3]), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("MAC address is not present in neighbor table")
|
|
}
|
|
|
|
func (s *Store) enabledAdministratorCountLocked() int {
|
|
count := 0
|
|
for _, user := range s.authUsers {
|
|
if user.Role == "administrator" && !user.Disabled {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *Store) revokeSessionsForActorLocked(actorName string) bool {
|
|
changed := false
|
|
for tokenHash, session := range s.sessions {
|
|
if session.Actor.Name == actorName {
|
|
delete(s.sessions, tokenHash)
|
|
changed = true
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func auditActorForUsername(username string) string {
|
|
if username == "" {
|
|
return "anonymous"
|
|
}
|
|
return username
|
|
}
|
|
|
|
func permissionsMap(permissions []string) map[string]bool {
|
|
result := make(map[string]bool, len(permissions))
|
|
for _, permission := range permissions {
|
|
if permission != "" {
|
|
result[permission] = true
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func sortedPermissions(permissions map[string]bool) []string {
|
|
result := make([]string, 0, len(permissions))
|
|
for permission, allowed := range permissions {
|
|
if allowed {
|
|
result = append(result, permission)
|
|
}
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func sortedPermissionList(permissions []string) []string {
|
|
seen := make(map[string]struct{}, len(permissions))
|
|
result := make([]string, 0, len(permissions))
|
|
for _, permission := range permissions {
|
|
permission = strings.TrimSpace(permission)
|
|
if permission == "" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[permission]; duplicate {
|
|
continue
|
|
}
|
|
seen[permission] = struct{}{}
|
|
result = append(result, permission)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func sortedAuthUsernames(users map[string]authUserRecord) []string {
|
|
usernames := make([]string, 0, len(users))
|
|
for username := range users {
|
|
usernames = append(usernames, username)
|
|
}
|
|
sort.Strings(usernames)
|
|
return usernames
|
|
}
|
|
|
|
func basePortConfig() map[string]portConfig {
|
|
configs := make(map[string]portConfig, 48)
|
|
for index := 1; index <= 48; index++ {
|
|
// Reverse listeners are the only network-facing serial operations.
|
|
// Disabled remains the fail-closed factory baseline.
|
|
mode := "disabled"
|
|
|
|
keyword := ""
|
|
if index == 1 {
|
|
keyword = "LOGIN FAILED"
|
|
} else if index == 7 {
|
|
keyword = "CRITICAL"
|
|
}
|
|
|
|
configs[channelName(index)] = portConfig{
|
|
Alias: fmt.Sprintf("Port %d", index),
|
|
Mode: mode,
|
|
ListenerPort: listenerPortFor(index, mode),
|
|
SerialType: "rs232",
|
|
BaudRate: 115200,
|
|
DataBits: 8,
|
|
Parity: "none",
|
|
StopBits: 1,
|
|
StopBitsMode: "1",
|
|
FlowControl: "none",
|
|
RTSControl: "auto",
|
|
DTRControl: "auto",
|
|
AlarmKeyword: keyword,
|
|
DataLogMode: serial.CaptureModeRXTX,
|
|
DataPackage: domain.DataPackage{
|
|
Mode: "disabled", Length: 1, TimeoutMillis: 0,
|
|
DelimiterProcess: "keep", Delimiter1: -1, Delimiter2: -1,
|
|
},
|
|
DataLog: domain.DataLog{
|
|
Read: true, Send: true, ShowTimestamp: true, ShowDirection: true, Format: "ascii",
|
|
ReadTrigger: domain.DataLogTrigger{Process: "keep", Character1: -1, Character2: -1},
|
|
SendTrigger: domain.DataLogTrigger{Process: "keep", Character1: -1, Character2: -1},
|
|
},
|
|
Operation1: defaultPortOperation(index, migratableOperationMode(mode)),
|
|
Operation2: defaultPortOperation(index, "disabled"),
|
|
}
|
|
}
|
|
return configs
|
|
}
|
|
|
|
func clonePortConfig(source map[string]portConfig) map[string]portConfig {
|
|
clone := make(map[string]portConfig, len(source))
|
|
for channel, config := range source {
|
|
config.RedirectTo = append([]string(nil), config.RedirectTo...)
|
|
clone[channel] = config
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func equalPortConfig(left, right map[string]portConfig) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for channel, leftConfig := range left {
|
|
if !reflect.DeepEqual(right[channel], leftConfig) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func baseAlarmRuleConfig() map[string]alarmRuleConfig {
|
|
rules := map[string]alarmRuleConfig{
|
|
"cold_start": {
|
|
Name: "Cold start", Source: "lifecycle", Trigger: "new kernel boot",
|
|
Severity: "info", Delivery: []string{"web"}, Enabled: true,
|
|
},
|
|
"warm_start": {
|
|
Name: "Warm start", Source: "lifecycle", Trigger: "operator restart",
|
|
Severity: "info", Delivery: []string{"web"}, Enabled: true,
|
|
},
|
|
"auth_failures": {
|
|
Name: "Auth threshold",
|
|
Source: "auth",
|
|
Channel: "",
|
|
Trigger: "5 failures / 10 min",
|
|
Severity: "warning",
|
|
Delivery: []string{"web", "snmp_trap"},
|
|
Enabled: true,
|
|
},
|
|
"link_failover": {
|
|
Name: "Link failover",
|
|
Source: "network",
|
|
Channel: "",
|
|
Trigger: "Primary link down",
|
|
Severity: "critical",
|
|
Delivery: []string{"web", "syslog"},
|
|
Enabled: true,
|
|
},
|
|
"serial_link_down": {
|
|
Name: "Serial physical link down",
|
|
Source: "serial_link",
|
|
Channel: "",
|
|
Trigger: "up to down",
|
|
Severity: "critical",
|
|
Delivery: []string{"web", "syslog"},
|
|
Enabled: true,
|
|
},
|
|
"ethernet_link_down": {
|
|
Name: "Ethernet physical link down",
|
|
Source: "ethernet_link",
|
|
Channel: "",
|
|
Trigger: "up to down",
|
|
Severity: "critical",
|
|
Delivery: []string{"web", "syslog"},
|
|
Enabled: true,
|
|
},
|
|
"storage_pressure": {
|
|
Name: "Storage pressure",
|
|
Source: "storage",
|
|
Channel: "",
|
|
Trigger: "Protected log store > 80%",
|
|
Severity: "warning",
|
|
Delivery: []string{"web", "email"},
|
|
Enabled: true,
|
|
},
|
|
}
|
|
ports := basePortConfig()
|
|
for index := 1; index <= serial.ChannelCount; index++ {
|
|
channel := channelName(index)
|
|
keyword := strings.TrimSpace(ports[channel].AlarmKeyword)
|
|
trigger := keyword
|
|
if trigger == "" {
|
|
trigger = "(not configured)"
|
|
}
|
|
keywordDelivery := []string{"web"}
|
|
keywordSeverity := "warning"
|
|
if channel == "CH01" {
|
|
keywordDelivery = []string{"web", "syslog", "snmp_trap"}
|
|
keywordSeverity = "critical"
|
|
}
|
|
rules[portKeywordRuleID(channel)] = alarmRuleConfig{
|
|
Name: channel + " received keyword", Source: "port_log", Channel: channel,
|
|
Trigger: trigger, Severity: keywordSeverity, Delivery: keywordDelivery, Enabled: keyword != "",
|
|
}
|
|
rules[portSignalRuleID(channel, "dcd")] = alarmRuleConfig{
|
|
Name: channel + " DCD changed", Source: "serial_dcd", Channel: channel,
|
|
Trigger: "state changed", Severity: "warning", Delivery: []string{"web"}, Enabled: false,
|
|
}
|
|
rules[portSignalRuleID(channel, "dsr")] = alarmRuleConfig{
|
|
Name: channel + " DSR changed", Source: "serial_dsr", Channel: channel,
|
|
Trigger: "state changed", Severity: "warning", Delivery: []string{"web"}, Enabled: false,
|
|
}
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func portKeywordRuleID(channel string) string {
|
|
return "port_" + strings.ToLower(channel) + "_keyword"
|
|
}
|
|
|
|
func portSignalRuleID(channel, signal string) string {
|
|
return "port_" + strings.ToLower(channel) + "_" + strings.ToLower(signal)
|
|
}
|
|
|
|
func (s *Store) stagePortKeywordRuleLocked(channel, keyword string) {
|
|
ruleID := portKeywordRuleID(channel)
|
|
rule, ok := s.runningAlarms[ruleID]
|
|
if !ok {
|
|
return
|
|
}
|
|
if pending, exists := s.pendingAlarms[ruleID]; exists {
|
|
rule = pending
|
|
}
|
|
keyword = strings.TrimSpace(keyword)
|
|
wasConfigured := rule.Trigger != "" && rule.Trigger != "(not configured)"
|
|
if keyword == "" {
|
|
rule.Trigger = "(not configured)"
|
|
rule.Enabled = false
|
|
} else {
|
|
rule.Trigger = keyword
|
|
if !wasConfigured {
|
|
rule.Enabled = true
|
|
}
|
|
}
|
|
s.pendingAlarms[ruleID] = rule
|
|
}
|
|
|
|
func cloneAlarmRuleConfig(source map[string]alarmRuleConfig) map[string]alarmRuleConfig {
|
|
clone := make(map[string]alarmRuleConfig, len(source))
|
|
for ruleID, config := range source {
|
|
clone[ruleID] = alarmRuleConfig{
|
|
Name: config.Name,
|
|
Source: config.Source,
|
|
Channel: config.Channel,
|
|
Trigger: config.Trigger,
|
|
Severity: config.Severity,
|
|
Delivery: append([]string(nil), config.Delivery...),
|
|
Enabled: config.Enabled,
|
|
}
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func mergeMissingAlarmRuleConfig(target, defaults map[string]alarmRuleConfig) {
|
|
for ruleID, config := range cloneAlarmRuleConfig(defaults) {
|
|
if _, exists := target[ruleID]; !exists {
|
|
target[ruleID] = config
|
|
}
|
|
}
|
|
}
|
|
|
|
func equalAlarmRuleConfig(left, right map[string]alarmRuleConfig) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for ruleID, leftConfig := range left {
|
|
rightConfig, ok := right[ruleID]
|
|
if !ok || !equalAlarmRule(leftConfig, rightConfig) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalAlarmRule(left, right alarmRuleConfig) bool {
|
|
if left.Name != right.Name || left.Source != right.Source || left.Channel != right.Channel || left.Trigger != right.Trigger || left.Severity != right.Severity || left.Enabled != right.Enabled {
|
|
return false
|
|
}
|
|
if len(left.Delivery) != len(right.Delivery) {
|
|
return false
|
|
}
|
|
for index := range left.Delivery {
|
|
if left.Delivery[index] != right.Delivery[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func baseNetworkConfig() map[string]networkInterfaceConfig {
|
|
return map[string]networkInterfaceConfig{
|
|
"Interface-1": {
|
|
Name: "Interface-1",
|
|
Device: "eth0",
|
|
DeviceTreeNode: "fe010000.ethernet",
|
|
PHY: "YT8531S",
|
|
IdentitySource: "board-profile",
|
|
ActiveMedium: "RJ45",
|
|
Link: "up",
|
|
Speed: "1000 Mbps",
|
|
Address: "192.168.0.223/24",
|
|
Gateway: "",
|
|
AddressMode: networkconfig.AddressModeStatic,
|
|
IPv6Mode: networkconfig.IPv6ModeAuto,
|
|
Role: networkconfig.RoleInternal,
|
|
Weight: 1,
|
|
HostName: "iconsole-server",
|
|
OperatingMode: networkconfig.OperatingModeSingle,
|
|
AdvMode: "single",
|
|
BondMode: "active-backup",
|
|
BondMii: 50,
|
|
BridgeSTP: false,
|
|
DDNSEnabled: false,
|
|
DDNSProvider: "dnspod",
|
|
DDNSIPv4Enabled: true,
|
|
DDNSIPv6Enabled: false,
|
|
DDNSUsername: "",
|
|
DDNSPassword: "",
|
|
DDNSIPv4Domain: "",
|
|
DDNSIPv6Domain: "",
|
|
DDNSCheckIPURL: "",
|
|
DDNSCustomProvider: "",
|
|
DDNSCustomPath: "",
|
|
OOBPrimaryInterface: "Interface-1",
|
|
OOBFallbackInterface: "Interface-2",
|
|
OOBHealthCheckTarget: "1.1.1.1",
|
|
OOBHealthCheckSecond: 10,
|
|
OOBFailureThreshold: 3,
|
|
},
|
|
"Interface-2": {
|
|
Name: "Interface-2",
|
|
Device: "eth1",
|
|
DeviceTreeNode: "fe2a0000.ethernet",
|
|
PHY: "YT8531S",
|
|
IdentitySource: "board-profile",
|
|
ActiveMedium: "SFP",
|
|
Link: "down",
|
|
Speed: "",
|
|
Address: "192.168.78.55/24",
|
|
Gateway: "192.168.78.1",
|
|
AddressMode: networkconfig.AddressModeStatic,
|
|
IPv6Mode: networkconfig.IPv6ModeAuto,
|
|
Role: networkconfig.RoleExternal,
|
|
Weight: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
func baseTimeSettings() timeSettingsConfig {
|
|
return timeSettingsConfig{
|
|
Mode: "ntp",
|
|
Timezone: "Asia/Seoul",
|
|
NTPServers: []string{"ntp.kriss.re.kr", "time.bora.net"},
|
|
}
|
|
}
|
|
|
|
func baseLogRetention() logRetentionConfig {
|
|
return logRetentionConfig{
|
|
SystemDays: 365,
|
|
AuditDays: 365,
|
|
PortDays: 365,
|
|
ProtectedUsageWarningPct: 80,
|
|
PreserveOnFactoryReset: true,
|
|
LocalSystemEnabled: true,
|
|
LocalConfigurationEnabled: true,
|
|
LocalOperationEnabled: true,
|
|
LocalStatusEnabled: true,
|
|
RemoteSystemEnabled: false,
|
|
RemoteConfigurationEnabled: false,
|
|
RemoteOperationEnabled: false,
|
|
RemoteStatusEnabled: false,
|
|
KeepaliveEnabled: true,
|
|
KeepaliveIntervalMinutes: int(logpolicy.DefaultKeepaliveInterval / time.Minute),
|
|
AllPortStatusEnabled: true,
|
|
AllPortStatusIntervalMinutes: int(logpolicy.DefaultSnapshotInterval / time.Minute),
|
|
SinglePortStatusEnabled: true,
|
|
SinglePortStatusRealtime: false,
|
|
SinglePortStatusIntervalMinutes: int(logpolicy.DefaultLinkPollInterval / time.Minute),
|
|
}
|
|
}
|
|
|
|
func baseAlarmDeliveryPolicy() alarmDeliveryPolicyConfig {
|
|
return alarmDeliveryPolicyConfig{
|
|
RetryCount: 3,
|
|
RetryIntervalSeconds: 60,
|
|
PendingQueueLimit: 100,
|
|
NotifyOnRetryExhausted: true,
|
|
}
|
|
}
|
|
|
|
func baseEmailNotification() emailNotificationConfig {
|
|
return emailNotificationConfig{
|
|
Enabled: false,
|
|
SMTPHost: "smtp.example.local",
|
|
SMTPPort: 587,
|
|
STARTTLSEnabled: true,
|
|
AuthEnabled: false,
|
|
Username: "tera-alerts",
|
|
PasswordStored: false,
|
|
From: "tera-alerts@example.local",
|
|
To: []string{"ops@example.local"},
|
|
CC: []string{},
|
|
CustomTitle: "Tera alert",
|
|
CustomContent: "Alarm: {{message}}",
|
|
}
|
|
}
|
|
|
|
func baseSNMPAgent() snmpAgentConfig {
|
|
return snmpAgentConfig{
|
|
Enabled: false,
|
|
Location: "Data center",
|
|
Contact: "ops@example.local",
|
|
V1Enabled: false,
|
|
V2CEnabled: false,
|
|
V3Enabled: false,
|
|
ReadOnlyCommunityStored: false,
|
|
ReadWriteCommunityStored: false,
|
|
V3Users: []snmpV3UserConfig{
|
|
{Username: "snmp-admin", Access: "write", AuthProtocol: "sha256", PrivacyProtocol: "aes", AuthKeyStored: false, PrivacyKeyStored: false, Enabled: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
func baseSNMPTrapTargets() []snmpTrapTargetConfig {
|
|
return []snmpTrapTargetConfig{
|
|
{TargetID: "snmp-primary", Host: "snmp.example.local", Port: 162, Version: "v2c", AuthProtocol: "none", PrivacyProtocol: "none", CredentialStored: false, Enabled: false},
|
|
}
|
|
}
|
|
|
|
func baseManagementAccess() managementAccessConfig {
|
|
return managementAccessConfig{
|
|
HTTPEnabled: false,
|
|
HTTPPort: 80,
|
|
HTTPSEnabled: true,
|
|
HTTPSPort: 443,
|
|
SSHEnabled: false,
|
|
SSHPort: 22,
|
|
TelnetEnabled: false,
|
|
TelnetPort: 23,
|
|
SerialConsoleEnabled: true,
|
|
IdleTimeoutMinutes: 15,
|
|
CaptchaEnabled: false,
|
|
LoginDetectionMinutes: 0,
|
|
LoginSecurityHitCount: 0,
|
|
}
|
|
}
|
|
|
|
func baseRemoteSyslog() remoteSyslogConfig {
|
|
return remoteSyslogConfig{
|
|
Enabled: false,
|
|
Host: "syslog.example.local",
|
|
Port: 514,
|
|
Protocol: "udp",
|
|
Facility: "local0",
|
|
}
|
|
}
|
|
|
|
func baseTLSCertificate() tlsCertificateConfig {
|
|
return mustTLSCertificateConfig("factory-default")
|
|
}
|
|
|
|
func baseExternalAuth() externalAuthConfig {
|
|
return externalAuthConfig{
|
|
Enabled: false,
|
|
PrimaryMethod: "local",
|
|
LocalFallback: true,
|
|
TimeoutSeconds: 5,
|
|
DefaultRole: "viewer",
|
|
RadiusServers: []externalAuthServerConfig{
|
|
{ServerID: "radius-primary", Host: "radius.example.local", Port: 1645, SecretStored: false, Enabled: false},
|
|
},
|
|
TACACSServers: []externalAuthServerConfig{
|
|
{ServerID: "tacacs-primary", Host: "tacacs.example.local", Port: 49, SecretStored: false, Enabled: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
func mustTLSCertificateConfig(certificateID string) tlsCertificateConfig {
|
|
config, err := tlsCertificateConfigByID(certificateID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return config
|
|
}
|
|
|
|
func tlsCertificateConfigByID(certificateID string) (tlsCertificateConfig, error) {
|
|
id := strings.ToLower(strings.TrimSpace(certificateID))
|
|
switch id {
|
|
case "factory-default":
|
|
return tlsCertificateConfig{
|
|
ActiveCertificateID: "factory-default",
|
|
Subject: "CN=tera-management.local",
|
|
Issuer: "CN=Tera Factory CA",
|
|
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
NotAfter: time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
FingerprintSHA256: "9f2c5a4d8e1b7c0392a5d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a",
|
|
Source: "factory",
|
|
PrivateKeyStored: true,
|
|
}, nil
|
|
case "operator-imported-2026":
|
|
return tlsCertificateConfig{
|
|
ActiveCertificateID: "operator-imported-2026",
|
|
Subject: "CN=console.example.local",
|
|
Issuer: "CN=Example Operations CA",
|
|
NotBefore: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
NotAfter: time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC),
|
|
FingerprintSHA256: "4a9f13bd2c7e8901a6b5c4d3e2f1098776655443322110ffeeddccbbaa998877",
|
|
Source: "protected-store",
|
|
PrivateKeyStored: true,
|
|
}, nil
|
|
default:
|
|
return tlsCertificateConfig{}, fmt.Errorf("tls certificate active_certificate_id must reference an available certificate")
|
|
}
|
|
}
|
|
|
|
func cloneNetworkConfig(source map[string]networkInterfaceConfig) map[string]networkInterfaceConfig {
|
|
clone := make(map[string]networkInterfaceConfig, len(source))
|
|
for name, config := range source {
|
|
clone[name] = config
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func migrateNetworkDefaults(configs, defaults map[string]networkInterfaceConfig) {
|
|
for name, config := range configs {
|
|
fallback, ok := defaults[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Logical identities never follow the front-panel drawing order:
|
|
// Interface-1 is eth0 and Interface-2 is eth1. Force those identities
|
|
// while preserving operator-entered addresses so a build that briefly
|
|
// swapped the devices repairs its persisted configuration at startup.
|
|
config.Name = fallback.Name
|
|
config.Device = fallback.Device
|
|
config.DeviceTreeNode = fallback.DeviceTreeNode
|
|
config.PHY = fallback.PHY
|
|
config.IdentitySource = fallback.IdentitySource
|
|
config.ActiveMedium = fallback.ActiveMedium
|
|
if config.Role == "" {
|
|
config.Role = fallback.Role
|
|
}
|
|
if config.AddressMode == "" {
|
|
config.AddressMode = fallback.AddressMode
|
|
}
|
|
if config.IPv6Mode == "" {
|
|
config.IPv6Mode = fallback.IPv6Mode
|
|
}
|
|
if config.HostName == "" {
|
|
config.HostName = fallback.HostName
|
|
}
|
|
if config.OperatingMode == "" {
|
|
config.OperatingMode = fallback.OperatingMode
|
|
}
|
|
if config.OOBPrimaryInterface == "" {
|
|
config.OOBPrimaryInterface = fallback.OOBPrimaryInterface
|
|
}
|
|
if config.OOBFallbackInterface == "" {
|
|
config.OOBFallbackInterface = fallback.OOBFallbackInterface
|
|
}
|
|
if config.OOBHealthCheckTarget == "" {
|
|
config.OOBHealthCheckTarget = fallback.OOBHealthCheckTarget
|
|
}
|
|
if config.OOBHealthCheckSecond == 0 {
|
|
config.OOBHealthCheckSecond = fallback.OOBHealthCheckSecond
|
|
}
|
|
if config.OOBFailureThreshold == 0 {
|
|
config.OOBFailureThreshold = fallback.OOBFailureThreshold
|
|
}
|
|
if config.AdvMode == "" {
|
|
config.AdvMode = fallback.AdvMode
|
|
}
|
|
if config.BondMode == "" {
|
|
config.BondMode = fallback.BondMode
|
|
}
|
|
if config.BondMii == 0 {
|
|
config.BondMii = fallback.BondMii
|
|
}
|
|
if config.DDNSProvider == "" {
|
|
config.DDNSProvider = fallback.DDNSProvider
|
|
}
|
|
if config.Weight == 0 {
|
|
config.Weight = 1
|
|
}
|
|
configs[name] = config
|
|
}
|
|
primary, primaryOK := configs[networkconfig.Interface1Name]
|
|
fallback, fallbackOK := configs[networkconfig.Interface2Name]
|
|
if !primaryOK || !fallbackOK {
|
|
return
|
|
}
|
|
// Canonicalize legacy Interface-2/wwan0 selections. Older releases also
|
|
// allowed DHCP OOB paths, but their dynamic gateways were unavailable to
|
|
// the failover route installer. Migrate that unsafe persisted state to the
|
|
// independent Single topology so an upgraded API can start and the operator
|
|
// can explicitly configure two static OOB gateways.
|
|
primary.OOBPrimaryInterface = networkconfig.Interface1Name
|
|
primary.OOBFallbackInterface = networkconfig.Interface2Name
|
|
if strings.EqualFold(primary.OperatingMode, networkconfig.OperatingModeOOB) {
|
|
usableStaticOOB := primary.AddressMode == networkconfig.AddressModeStatic && primary.Gateway != "" &&
|
|
fallback.AddressMode == networkconfig.AddressModeStatic && fallback.Gateway != ""
|
|
if usableStaticOOB {
|
|
primary.Role = networkconfig.RoleExternal
|
|
fallback.Role = networkconfig.RoleExternal
|
|
} else {
|
|
primary.OperatingMode = networkconfig.OperatingModeSingle
|
|
primary.Role = networkconfig.RoleInternal
|
|
fallback.Role = networkconfig.RoleExternal
|
|
}
|
|
}
|
|
configs[networkconfig.Interface1Name] = primary
|
|
configs[networkconfig.Interface2Name] = fallback
|
|
}
|
|
|
|
func networkConfigForController(configs map[string]networkInterfaceConfig) networkconfig.Config {
|
|
names := make([]string, 0, len(configs))
|
|
for name := range configs {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
global := configs["Interface-1"]
|
|
advMode := global.AdvMode
|
|
if advMode == "" {
|
|
advMode = "single"
|
|
}
|
|
bondMode := global.BondMode
|
|
if bondMode == "" {
|
|
bondMode = "active-backup"
|
|
}
|
|
bondMii := global.BondMii
|
|
if bondMii == 0 {
|
|
bondMii = 50
|
|
}
|
|
ddnsProvider := global.DDNSProvider
|
|
if ddnsProvider == "" {
|
|
ddnsProvider = "dnspod"
|
|
}
|
|
config := networkconfig.Config{
|
|
HostName: global.HostName,
|
|
OperatingMode: global.OperatingMode,
|
|
Advanced: networkconfig.AdvancedConfig{
|
|
Mode: advMode,
|
|
BondMode: bondMode,
|
|
BondMii: bondMii,
|
|
BridgeSTP: global.BridgeSTP,
|
|
},
|
|
DDNS: networkconfig.DDNSConfig{
|
|
Enabled: global.DDNSEnabled,
|
|
Provider: ddnsProvider,
|
|
IPv4Enabled: global.DDNSIPv4Enabled,
|
|
IPv6Enabled: global.DDNSIPv6Enabled,
|
|
Username: global.DDNSUsername,
|
|
Password: global.DDNSPassword,
|
|
IPv4Domain: global.DDNSIPv4Domain,
|
|
IPv6Domain: global.DDNSIPv6Domain,
|
|
CheckIPURL: global.DDNSCheckIPURL,
|
|
CustomProvider: global.DDNSCustomProvider,
|
|
CustomPath: global.DDNSCustomPath,
|
|
},
|
|
OOBPrimaryInterface: global.OOBPrimaryInterface,
|
|
OOBFallbackInterface: global.OOBFallbackInterface,
|
|
OOBHealthCheckTarget: global.OOBHealthCheckTarget,
|
|
OOBHealthCheckSecond: global.OOBHealthCheckSecond,
|
|
OOBFailureThreshold: global.OOBFailureThreshold,
|
|
RoutingMode: global.OperatingMode,
|
|
RollbackTimeoutSecond: networkconfig.DefaultRollbackSecond,
|
|
Interfaces: make([]networkconfig.Interface, 0, len(names)),
|
|
}
|
|
for _, name := range names {
|
|
item := configs[name]
|
|
config.Interfaces = append(config.Interfaces, networkconfig.Interface{
|
|
Name: item.Name, Device: item.Device, Role: item.Role, AddressMode: item.AddressMode,
|
|
Address: item.Address, Gateway: item.Gateway, DNSServers: compactStrings(item.DNS1, item.DNS2),
|
|
IPv6Mode: item.IPv6Mode, IPv6Address: item.IPv6Address, IPv6Gateway: item.IPv6Gateway,
|
|
IPv6DNSServers: compactStrings(item.IPv6DNS1, item.IPv6DNS2), Weight: item.Weight,
|
|
})
|
|
}
|
|
return config
|
|
}
|
|
|
|
func networkStoreConfigFromController(config networkconfig.Config, metadata map[string]networkInterfaceConfig) map[string]networkInterfaceConfig {
|
|
result := make(map[string]networkInterfaceConfig, len(config.Interfaces))
|
|
defaults := baseNetworkConfig()
|
|
for _, item := range config.Interfaces {
|
|
stored, ok := metadata[item.Name]
|
|
if !ok {
|
|
stored = defaults[item.Name]
|
|
}
|
|
dns1, dns2 := splitPair(item.DNSServers)
|
|
ipv6DNS1, ipv6DNS2 := splitPair(item.IPv6DNSServers)
|
|
stored.Name = item.Name
|
|
stored.Device = item.Device
|
|
stored.AddressMode = item.AddressMode
|
|
stored.Address = item.Address
|
|
stored.Gateway = item.Gateway
|
|
stored.DNS1, stored.DNS2 = dns1, dns2
|
|
stored.IPv6Mode = item.IPv6Mode
|
|
stored.IPv6Address = item.IPv6Address
|
|
stored.IPv6Gateway = item.IPv6Gateway
|
|
stored.IPv6DNS1, stored.IPv6DNS2 = ipv6DNS1, ipv6DNS2
|
|
stored.Role = item.Role
|
|
stored.Weight = item.Weight
|
|
result[item.Name] = stored
|
|
}
|
|
global := result["Interface-1"]
|
|
global.HostName = config.HostName
|
|
global.OperatingMode = config.OperatingMode
|
|
global.AdvMode = config.Advanced.Mode
|
|
global.BondMode = config.Advanced.BondMode
|
|
global.BondMii = config.Advanced.BondMii
|
|
global.BridgeSTP = config.Advanced.BridgeSTP
|
|
global.DDNSEnabled = config.DDNS.Enabled
|
|
global.DDNSProvider = config.DDNS.Provider
|
|
global.DDNSIPv4Enabled = config.DDNS.IPv4Enabled
|
|
global.DDNSIPv6Enabled = config.DDNS.IPv6Enabled
|
|
global.DDNSUsername = config.DDNS.Username
|
|
global.DDNSPassword = config.DDNS.Password
|
|
global.DDNSIPv4Domain = config.DDNS.IPv4Domain
|
|
global.DDNSIPv6Domain = config.DDNS.IPv6Domain
|
|
global.DDNSCheckIPURL = config.DDNS.CheckIPURL
|
|
global.DDNSCustomProvider = config.DDNS.CustomProvider
|
|
global.DDNSCustomPath = config.DDNS.CustomPath
|
|
global.OOBPrimaryInterface = config.OOBPrimaryInterface
|
|
global.OOBFallbackInterface = config.OOBFallbackInterface
|
|
global.OOBHealthCheckTarget = config.OOBHealthCheckTarget
|
|
global.OOBHealthCheckSecond = config.OOBHealthCheckSecond
|
|
global.OOBFailureThreshold = config.OOBFailureThreshold
|
|
result["Interface-1"] = global
|
|
return result
|
|
}
|
|
|
|
func cloneTimeSettings(source timeSettingsConfig) timeSettingsConfig {
|
|
return timeSettingsConfig{
|
|
Mode: source.Mode,
|
|
Timezone: source.Timezone,
|
|
NTPServers: append([]string(nil), source.NTPServers...),
|
|
}
|
|
}
|
|
|
|
func cloneTimeSettingsPointer(source timeSettingsConfig) *timeSettingsConfig {
|
|
clone := cloneTimeSettings(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalTimeSettingsPointer(source *timeSettingsConfig) *timeSettingsConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneTimeSettingsPointer(*source)
|
|
}
|
|
|
|
func cloneLogRetention(source logRetentionConfig) logRetentionConfig {
|
|
return source
|
|
}
|
|
|
|
func cloneAlarmDeliveryPolicy(source alarmDeliveryPolicyConfig) alarmDeliveryPolicyConfig {
|
|
return source
|
|
}
|
|
|
|
func cloneEmailNotification(source emailNotificationConfig) emailNotificationConfig {
|
|
return emailNotificationConfig{
|
|
Enabled: source.Enabled,
|
|
SMTPHost: source.SMTPHost,
|
|
SMTPPort: source.SMTPPort,
|
|
STARTTLSEnabled: source.STARTTLSEnabled,
|
|
AuthEnabled: source.AuthEnabled,
|
|
Username: source.Username,
|
|
PasswordStored: source.PasswordStored,
|
|
From: source.From,
|
|
To: append([]string(nil), source.To...),
|
|
CC: append([]string(nil), source.CC...),
|
|
CustomTitle: source.CustomTitle,
|
|
CustomContent: source.CustomContent,
|
|
}
|
|
}
|
|
|
|
func cloneSNMPAgent(source snmpAgentConfig) snmpAgentConfig {
|
|
return snmpAgentConfig{
|
|
Enabled: source.Enabled,
|
|
Location: source.Location,
|
|
Contact: source.Contact,
|
|
V1Enabled: source.V1Enabled,
|
|
V2CEnabled: source.V2CEnabled,
|
|
V3Enabled: source.V3Enabled,
|
|
ReadOnlyCommunityStored: source.ReadOnlyCommunityStored,
|
|
ReadWriteCommunityStored: source.ReadWriteCommunityStored,
|
|
V3Users: cloneSNMPV3Users(source.V3Users),
|
|
}
|
|
}
|
|
|
|
func cloneSNMPV3Users(source []snmpV3UserConfig) []snmpV3UserConfig {
|
|
return append([]snmpV3UserConfig(nil), source...)
|
|
}
|
|
|
|
func cloneSNMPTrapTargets(source []snmpTrapTargetConfig) []snmpTrapTargetConfig {
|
|
return append([]snmpTrapTargetConfig(nil), source...)
|
|
}
|
|
|
|
func cloneOptionalSNMPTrapTargets(source *[]snmpTrapTargetConfig) []snmpTrapTargetConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneSNMPTrapTargets(*source)
|
|
}
|
|
|
|
func cloneAlarmDeliveryPolicyPointer(source alarmDeliveryPolicyConfig) *alarmDeliveryPolicyConfig {
|
|
clone := cloneAlarmDeliveryPolicy(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalAlarmDeliveryPolicyPointer(source *alarmDeliveryPolicyConfig) *alarmDeliveryPolicyConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneAlarmDeliveryPolicyPointer(*source)
|
|
}
|
|
|
|
func cloneEmailNotificationPointer(source emailNotificationConfig) *emailNotificationConfig {
|
|
clone := cloneEmailNotification(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalEmailNotificationPointer(source *emailNotificationConfig) *emailNotificationConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneEmailNotificationPointer(*source)
|
|
}
|
|
|
|
func cloneSNMPAgentPointer(source snmpAgentConfig) *snmpAgentConfig {
|
|
clone := cloneSNMPAgent(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalSNMPAgentPointer(source *snmpAgentConfig) *snmpAgentConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneSNMPAgentPointer(*source)
|
|
}
|
|
|
|
func cloneLogRetentionPointer(source logRetentionConfig) *logRetentionConfig {
|
|
clone := cloneLogRetention(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalLogRetentionPointer(source *logRetentionConfig) *logRetentionConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneLogRetentionPointer(*source)
|
|
}
|
|
|
|
func cloneManagementAccess(source managementAccessConfig) managementAccessConfig {
|
|
return source
|
|
}
|
|
|
|
func cloneManagementAccessPointer(source managementAccessConfig) *managementAccessConfig {
|
|
clone := cloneManagementAccess(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalManagementAccessPointer(source *managementAccessConfig) *managementAccessConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneManagementAccessPointer(*source)
|
|
}
|
|
|
|
func cloneRemoteSyslog(source remoteSyslogConfig) remoteSyslogConfig {
|
|
return source
|
|
}
|
|
|
|
func cloneTLSCertificate(source tlsCertificateConfig) tlsCertificateConfig {
|
|
return source
|
|
}
|
|
|
|
func cloneExternalAuth(source externalAuthConfig) externalAuthConfig {
|
|
source.RadiusServers = cloneExternalAuthServers(source.RadiusServers)
|
|
source.TACACSServers = cloneExternalAuthServers(source.TACACSServers)
|
|
return source
|
|
}
|
|
|
|
func cloneExternalAuthServers(source []externalAuthServerConfig) []externalAuthServerConfig {
|
|
return append([]externalAuthServerConfig(nil), source...)
|
|
}
|
|
|
|
func cloneRemoteSyslogPointer(source remoteSyslogConfig) *remoteSyslogConfig {
|
|
clone := cloneRemoteSyslog(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneTLSCertificatePointer(source tlsCertificateConfig) *tlsCertificateConfig {
|
|
clone := cloneTLSCertificate(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneExternalAuthPointer(source externalAuthConfig) *externalAuthConfig {
|
|
clone := cloneExternalAuth(source)
|
|
return &clone
|
|
}
|
|
|
|
func cloneOptionalRemoteSyslogPointer(source *remoteSyslogConfig) *remoteSyslogConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneRemoteSyslogPointer(*source)
|
|
}
|
|
|
|
func cloneOptionalTLSCertificatePointer(source *tlsCertificateConfig) *tlsCertificateConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneTLSCertificatePointer(*source)
|
|
}
|
|
|
|
func cloneOptionalExternalAuthPointer(source *externalAuthConfig) *externalAuthConfig {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
return cloneExternalAuthPointer(*source)
|
|
}
|
|
|
|
func equalNetworkConfig(left, right map[string]networkInterfaceConfig) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for name, leftConfig := range left {
|
|
if right[name] != leftConfig {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalTimeSettings(left, right timeSettingsConfig) bool {
|
|
if left.Mode != right.Mode || left.Timezone != right.Timezone || len(left.NTPServers) != len(right.NTPServers) {
|
|
return false
|
|
}
|
|
for index := range left.NTPServers {
|
|
if left.NTPServers[index] != right.NTPServers[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalLogRetention(left, right logRetentionConfig) bool {
|
|
return left == right
|
|
}
|
|
|
|
func equalAlarmDeliveryPolicy(left, right alarmDeliveryPolicyConfig) bool {
|
|
return left == right
|
|
}
|
|
|
|
func equalEmailNotification(left, right emailNotificationConfig) bool {
|
|
if left.Enabled != right.Enabled || left.SMTPHost != right.SMTPHost || left.SMTPPort != right.SMTPPort || left.STARTTLSEnabled != right.STARTTLSEnabled || left.AuthEnabled != right.AuthEnabled || left.Username != right.Username || left.PasswordStored != right.PasswordStored || left.From != right.From || left.CustomTitle != right.CustomTitle || left.CustomContent != right.CustomContent {
|
|
return false
|
|
}
|
|
return equalStringSlice(left.To, right.To) && equalStringSlice(left.CC, right.CC)
|
|
}
|
|
|
|
func equalSNMPAgent(left, right snmpAgentConfig) bool {
|
|
if left.Enabled != right.Enabled || left.Location != right.Location || left.Contact != right.Contact || left.V1Enabled != right.V1Enabled || left.V2CEnabled != right.V2CEnabled || left.V3Enabled != right.V3Enabled || left.ReadOnlyCommunityStored != right.ReadOnlyCommunityStored || left.ReadWriteCommunityStored != right.ReadWriteCommunityStored {
|
|
return false
|
|
}
|
|
if len(left.V3Users) != len(right.V3Users) {
|
|
return false
|
|
}
|
|
for index := range left.V3Users {
|
|
if left.V3Users[index] != right.V3Users[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalStringSlice(left, right []string) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalSNMPTrapTargets(left, right []snmpTrapTargetConfig) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalManagementAccess(left, right managementAccessConfig) bool {
|
|
return left == right
|
|
}
|
|
|
|
func equalRemoteSyslog(left, right remoteSyslogConfig) bool {
|
|
return left == right
|
|
}
|
|
|
|
func equalTLSCertificate(left, right tlsCertificateConfig) bool {
|
|
return left == right
|
|
}
|
|
|
|
func equalExternalAuth(left, right externalAuthConfig) bool {
|
|
if left.Enabled != right.Enabled || left.PrimaryMethod != right.PrimaryMethod || left.LocalFallback != right.LocalFallback || left.TimeoutSeconds != right.TimeoutSeconds {
|
|
return false
|
|
}
|
|
return equalExternalAuthServers(left.RadiusServers, right.RadiusServers) && equalExternalAuthServers(left.TACACSServers, right.TACACSServers)
|
|
}
|
|
|
|
func equalExternalAuthServers(left, right []externalAuthServerConfig) bool {
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] != right[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func makeNetworkState(configs map[string]networkInterfaceConfig) domain.NetworkState {
|
|
global := configs["Interface-1"]
|
|
advMode := global.AdvMode
|
|
if advMode == "" {
|
|
advMode = "single"
|
|
}
|
|
bondMode := global.BondMode
|
|
if bondMode == "" {
|
|
bondMode = "active-backup"
|
|
}
|
|
bondMii := global.BondMii
|
|
if bondMii == 0 {
|
|
bondMii = 50
|
|
}
|
|
ddnsProvider := global.DDNSProvider
|
|
if ddnsProvider == "" {
|
|
ddnsProvider = "dnspod"
|
|
}
|
|
return domain.NetworkState{
|
|
HostName: global.HostName,
|
|
OperatingMode: global.OperatingMode,
|
|
Advanced: domain.NetworkAdvancedSettings{
|
|
Mode: advMode,
|
|
BondMode: bondMode,
|
|
BondMii: bondMii,
|
|
BridgeSTP: global.BridgeSTP,
|
|
},
|
|
DDNS: domain.DDNSSettings{
|
|
Enabled: global.DDNSEnabled,
|
|
Provider: ddnsProvider,
|
|
IPv4Enabled: global.DDNSIPv4Enabled,
|
|
IPv6Enabled: global.DDNSIPv6Enabled,
|
|
Username: global.DDNSUsername,
|
|
Password: global.DDNSPassword,
|
|
IPv4Domain: global.DDNSIPv4Domain,
|
|
IPv6Domain: global.DDNSIPv6Domain,
|
|
CheckIPURL: global.DDNSCheckIPURL,
|
|
CustomProvider: global.DDNSCustomProvider,
|
|
CustomPath: global.DDNSCustomPath,
|
|
},
|
|
Interfaces: makeNetworkInterfaces(configs),
|
|
OOB: oobStatus(global),
|
|
RoutingMode: global.OperatingMode,
|
|
RollbackTimeoutSeconds: networkconfig.DefaultRollbackSecond,
|
|
}
|
|
}
|
|
|
|
func makeOptionalNetworkState(configs map[string]networkInterfaceConfig) *domain.NetworkState {
|
|
if len(configs) == 0 {
|
|
return nil
|
|
}
|
|
state := makeNetworkState(configs)
|
|
return &state
|
|
}
|
|
|
|
func makeNetworkInterfaces(configs map[string]networkInterfaceConfig) []domain.NetworkInterface {
|
|
names := make([]string, 0, len(configs))
|
|
for name := range configs {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
interfaces := make([]domain.NetworkInterface, 0, len(names))
|
|
for _, name := range names {
|
|
config := configs[name]
|
|
interfaces = append(interfaces, domain.NetworkInterface{
|
|
Name: config.Name,
|
|
Device: config.Device,
|
|
DeviceTreeNode: config.DeviceTreeNode,
|
|
PHY: config.PHY,
|
|
IdentitySource: config.IdentitySource,
|
|
ActiveMedium: config.ActiveMedium,
|
|
Link: config.Link,
|
|
Speed: config.Speed,
|
|
Address: config.Address,
|
|
Gateway: config.Gateway,
|
|
Role: config.Role,
|
|
AddressMode: config.AddressMode,
|
|
DNSServers: compactStrings(config.DNS1, config.DNS2),
|
|
IPv6Mode: config.IPv6Mode,
|
|
IPv6Address: config.IPv6Address,
|
|
IPv6Gateway: config.IPv6Gateway,
|
|
IPv6DNSServers: compactStrings(config.IPv6DNS1, config.IPv6DNS2),
|
|
Weight: config.Weight,
|
|
})
|
|
}
|
|
return interfaces
|
|
}
|
|
|
|
func compactStrings(values ...string) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func splitPair(values []string) (string, string) {
|
|
var first, second string
|
|
if len(values) > 0 {
|
|
first = values[0]
|
|
}
|
|
if len(values) > 1 {
|
|
second = values[1]
|
|
}
|
|
return first, second
|
|
}
|
|
|
|
func normalizeIPAddressList(values []string, ipv4 bool) ([]string, error) {
|
|
if len(values) > 2 {
|
|
return nil, fmt.Errorf("must contain at most two addresses")
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]bool, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
address, err := netip.ParseAddr(value)
|
|
if err != nil || address.Is4() != ipv4 || address.IsUnspecified() || address.IsMulticast() {
|
|
return nil, fmt.Errorf("must contain usable addresses of the requested family")
|
|
}
|
|
if !seen[address.String()] {
|
|
seen[address.String()] = true
|
|
result = append(result, address.String())
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func makeTimeSettings(config timeSettingsConfig) domain.TimeSettings {
|
|
return domain.TimeSettings{
|
|
Mode: config.Mode,
|
|
Timezone: config.Timezone,
|
|
NTPServers: append([]string(nil), config.NTPServers...),
|
|
}
|
|
}
|
|
|
|
func makeOptionalTimeSettings(config *timeSettingsConfig) *domain.TimeSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeTimeSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeLogRetentionSettings(config logRetentionConfig) domain.LogRetentionSettings {
|
|
return domain.LogRetentionSettings{
|
|
SystemDays: config.SystemDays,
|
|
AuditDays: config.AuditDays,
|
|
PortDays: config.PortDays,
|
|
ProtectedUsageWarningPct: config.ProtectedUsageWarningPct,
|
|
PreserveOnFactoryReset: config.PreserveOnFactoryReset,
|
|
LocalSystemEnabled: config.LocalSystemEnabled,
|
|
LocalConfigurationEnabled: config.LocalConfigurationEnabled,
|
|
LocalOperationEnabled: config.LocalOperationEnabled,
|
|
LocalStatusEnabled: config.LocalStatusEnabled,
|
|
RemoteSystemEnabled: config.RemoteSystemEnabled,
|
|
RemoteConfigurationEnabled: config.RemoteConfigurationEnabled,
|
|
RemoteOperationEnabled: config.RemoteOperationEnabled,
|
|
RemoteStatusEnabled: config.RemoteStatusEnabled,
|
|
KeepaliveEnabled: config.KeepaliveEnabled,
|
|
KeepaliveIntervalMinutes: config.KeepaliveIntervalMinutes,
|
|
AllPortStatusEnabled: config.AllPortStatusEnabled,
|
|
AllPortStatusIntervalMinutes: config.AllPortStatusIntervalMinutes,
|
|
SinglePortStatusEnabled: config.SinglePortStatusEnabled,
|
|
SinglePortStatusRealtime: config.SinglePortStatusRealtime,
|
|
SinglePortStatusIntervalMinutes: config.SinglePortStatusIntervalMinutes,
|
|
}
|
|
}
|
|
|
|
func makeOptionalLogRetentionSettings(config *logRetentionConfig) *domain.LogRetentionSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeLogRetentionSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeAlarmDeliveryPolicy(config alarmDeliveryPolicyConfig) domain.AlarmDeliveryPolicy {
|
|
return domain.AlarmDeliveryPolicy{
|
|
RetryCount: config.RetryCount,
|
|
RetryIntervalSeconds: config.RetryIntervalSeconds,
|
|
PendingQueueLimit: config.PendingQueueLimit,
|
|
NotifyOnRetryExhausted: config.NotifyOnRetryExhausted,
|
|
}
|
|
}
|
|
|
|
func makeOptionalAlarmDeliveryPolicy(config *alarmDeliveryPolicyConfig) *domain.AlarmDeliveryPolicy {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
policy := makeAlarmDeliveryPolicy(*config)
|
|
return &policy
|
|
}
|
|
|
|
func makeEmailNotificationSettings(config emailNotificationConfig) domain.EmailNotificationSettings {
|
|
return domain.EmailNotificationSettings{
|
|
Enabled: config.Enabled,
|
|
SMTPHost: config.SMTPHost,
|
|
SMTPPort: config.SMTPPort,
|
|
STARTTLSEnabled: config.STARTTLSEnabled,
|
|
AuthEnabled: config.AuthEnabled,
|
|
Username: config.Username,
|
|
PasswordStored: config.PasswordStored,
|
|
From: config.From,
|
|
To: append([]string(nil), config.To...),
|
|
CC: append([]string(nil), config.CC...),
|
|
CustomTitle: config.CustomTitle,
|
|
CustomContent: config.CustomContent,
|
|
}
|
|
}
|
|
|
|
func makeOptionalEmailNotificationSettings(config *emailNotificationConfig) *domain.EmailNotificationSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeEmailNotificationSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeSNMPAgentSettings(config snmpAgentConfig) domain.SNMPAgentSettings {
|
|
return domain.SNMPAgentSettings{
|
|
Enabled: config.Enabled,
|
|
Location: config.Location,
|
|
Contact: config.Contact,
|
|
V1Enabled: config.V1Enabled,
|
|
V2CEnabled: config.V2CEnabled,
|
|
V3Enabled: config.V3Enabled,
|
|
ReadOnlyCommunityStored: config.ReadOnlyCommunityStored,
|
|
ReadWriteCommunityStored: config.ReadWriteCommunityStored,
|
|
V3Users: makeSNMPV3Users(config.V3Users),
|
|
}
|
|
}
|
|
|
|
func makeOptionalSNMPAgentSettings(config *snmpAgentConfig) *domain.SNMPAgentSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeSNMPAgentSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeSNMPV3Users(configs []snmpV3UserConfig) []domain.SNMPV3User {
|
|
users := make([]domain.SNMPV3User, 0, len(configs))
|
|
for _, config := range configs {
|
|
users = append(users, domain.SNMPV3User{
|
|
Username: config.Username,
|
|
Access: config.Access,
|
|
AuthProtocol: config.AuthProtocol,
|
|
PrivacyProtocol: config.PrivacyProtocol,
|
|
AuthKeyStored: config.AuthKeyStored,
|
|
PrivacyKeyStored: config.PrivacyKeyStored,
|
|
Enabled: config.Enabled,
|
|
})
|
|
}
|
|
return users
|
|
}
|
|
|
|
func makeSNMPTrapTargetSettings(configs []snmpTrapTargetConfig) domain.SNMPTrapTargetSettings {
|
|
return domain.SNMPTrapTargetSettings{
|
|
Targets: makeSNMPTrapTargets(configs),
|
|
}
|
|
}
|
|
|
|
func makeOptionalSNMPTrapTargetSettings(configs *[]snmpTrapTargetConfig) *domain.SNMPTrapTargetSettings {
|
|
if configs == nil {
|
|
return nil
|
|
}
|
|
settings := makeSNMPTrapTargetSettings(*configs)
|
|
return &settings
|
|
}
|
|
|
|
func makeSNMPTrapTargets(configs []snmpTrapTargetConfig) []domain.SNMPTrapTarget {
|
|
targets := make([]domain.SNMPTrapTarget, 0, len(configs))
|
|
for _, config := range configs {
|
|
targets = append(targets, domain.SNMPTrapTarget{
|
|
TargetID: config.TargetID,
|
|
Host: config.Host,
|
|
Port: config.Port,
|
|
Version: config.Version,
|
|
Username: config.Username,
|
|
AuthProtocol: config.AuthProtocol,
|
|
PrivacyProtocol: config.PrivacyProtocol,
|
|
EngineID: config.EngineID,
|
|
CredentialStored: config.CredentialStored,
|
|
PrivacyKeyStored: config.PrivacyKeyStored,
|
|
Enabled: config.Enabled,
|
|
})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
func makeManagementAccessSettings(config managementAccessConfig) domain.ManagementAccessSettings {
|
|
config = migrateManagementAccessPorts(config)
|
|
return domain.ManagementAccessSettings{
|
|
HTTPEnabled: config.HTTPEnabled,
|
|
HTTPPort: config.HTTPPort,
|
|
HTTPSEnabled: config.HTTPSEnabled,
|
|
HTTPSPort: config.HTTPSPort,
|
|
SSHEnabled: config.SSHEnabled,
|
|
SSHPort: config.SSHPort,
|
|
TelnetEnabled: config.TelnetEnabled,
|
|
TelnetPort: config.TelnetPort,
|
|
SerialConsoleEnabled: config.SerialConsoleEnabled,
|
|
IdleTimeoutMinutes: config.IdleTimeoutMinutes,
|
|
CaptchaEnabled: config.CaptchaEnabled,
|
|
LoginDetectionMinutes: config.LoginDetectionMinutes,
|
|
LoginSecurityHitCount: config.LoginSecurityHitCount,
|
|
}
|
|
}
|
|
|
|
func makeOptionalManagementAccessSettings(config *managementAccessConfig) *domain.ManagementAccessSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeManagementAccessSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeRemoteSyslogSettings(config remoteSyslogConfig) domain.RemoteSyslogSettings {
|
|
return domain.RemoteSyslogSettings{
|
|
Enabled: config.Enabled,
|
|
Host: config.Host,
|
|
Port: config.Port,
|
|
Protocol: config.Protocol,
|
|
Facility: config.Facility,
|
|
}
|
|
}
|
|
|
|
func makeTLSCertificateSettings(config tlsCertificateConfig) domain.TLSCertificateSettings {
|
|
return domain.TLSCertificateSettings{
|
|
ActiveCertificateID: config.ActiveCertificateID,
|
|
Subject: config.Subject,
|
|
Issuer: config.Issuer,
|
|
NotBefore: config.NotBefore,
|
|
NotAfter: config.NotAfter,
|
|
FingerprintSHA256: config.FingerprintSHA256,
|
|
Source: config.Source,
|
|
PrivateKeyStored: config.PrivateKeyStored,
|
|
}
|
|
}
|
|
|
|
func makeTLSCertificateEntry(metadata tlscert.Metadata, active bool) domain.TLSCertificateEntry {
|
|
return domain.TLSCertificateEntry{
|
|
CertificateID: metadata.CertificateID, Subject: metadata.Subject, Issuer: metadata.Issuer,
|
|
CommonName: metadata.CommonName, NotBefore: metadata.NotBefore, NotAfter: metadata.NotAfter,
|
|
FingerprintSHA256: metadata.FingerprintSHA256, Source: metadata.Source,
|
|
PrivateKeyStored: metadata.PrivateKeyStored, KeyBits: metadata.KeyBits, Active: active,
|
|
}
|
|
}
|
|
|
|
func makeTLSCertificateSubject(subject domain.TLSCertificateSubject) tlscert.Subject {
|
|
return tlscert.Subject{
|
|
Country: subject.Country, Province: subject.Province, Locality: subject.Locality,
|
|
Organization: subject.Organization, OrganizationalUnit: subject.OrganizationalUnit,
|
|
CommonName: subject.CommonName, Email: subject.Email,
|
|
}
|
|
}
|
|
|
|
func tlsCertificateConfigFromMetadata(metadata tlscert.Metadata) tlsCertificateConfig {
|
|
return tlsCertificateConfig{
|
|
ActiveCertificateID: metadata.CertificateID, Subject: metadata.Subject, Issuer: metadata.Issuer,
|
|
NotBefore: metadata.NotBefore, NotAfter: metadata.NotAfter, FingerprintSHA256: metadata.FingerprintSHA256,
|
|
Source: metadata.Source, PrivateKeyStored: metadata.PrivateKeyStored,
|
|
}
|
|
}
|
|
|
|
func makeOptionalRemoteSyslogSettings(config *remoteSyslogConfig) *domain.RemoteSyslogSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeRemoteSyslogSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeOptionalTLSCertificateSettings(config *tlsCertificateConfig) *domain.TLSCertificateSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeTLSCertificateSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeExternalAuthSettings(config externalAuthConfig) domain.ExternalAuthSettings {
|
|
return domain.ExternalAuthSettings{
|
|
Enabled: config.Enabled,
|
|
PrimaryMethod: config.PrimaryMethod,
|
|
LocalFallback: config.LocalFallback,
|
|
TimeoutSeconds: config.TimeoutSeconds,
|
|
RadiusServers: makeExternalAuthServers(config.RadiusServers),
|
|
TACACSServers: makeExternalAuthServers(config.TACACSServers),
|
|
DefaultRole: config.DefaultRole,
|
|
}
|
|
}
|
|
|
|
func makeOptionalExternalAuthSettings(config *externalAuthConfig) *domain.ExternalAuthSettings {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
settings := makeExternalAuthSettings(*config)
|
|
return &settings
|
|
}
|
|
|
|
func makeExternalAuthServers(configs []externalAuthServerConfig) []domain.ExternalAuthServer {
|
|
servers := make([]domain.ExternalAuthServer, 0, len(configs))
|
|
for _, config := range configs {
|
|
servers = append(servers, domain.ExternalAuthServer{
|
|
ServerID: config.ServerID,
|
|
Host: config.Host,
|
|
Port: config.Port,
|
|
SecretStored: config.SecretStored,
|
|
Enabled: config.Enabled,
|
|
})
|
|
}
|
|
return servers
|
|
}
|
|
|
|
func makePorts(configs map[string]portConfig) []domain.Port {
|
|
ports := make([]domain.Port, 0, len(configs))
|
|
for index := 1; index <= 48; index++ {
|
|
channel := channelName(index)
|
|
config, ok := configs[channel]
|
|
if !ok {
|
|
continue
|
|
}
|
|
ports = append(ports, makePort(index, config))
|
|
}
|
|
return ports
|
|
}
|
|
|
|
func makePendingPorts(configs map[string]portConfig) []domain.Port {
|
|
channels := make([]string, 0, len(configs))
|
|
for channel := range configs {
|
|
channels = append(channels, channel)
|
|
}
|
|
sort.Strings(channels)
|
|
|
|
ports := make([]domain.Port, 0, len(channels))
|
|
for _, channel := range channels {
|
|
index, err := parseChannel(channel)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ports = append(ports, makePort(index, configs[channel]))
|
|
}
|
|
return ports
|
|
}
|
|
|
|
func portConfigFromExport(ports []domain.Port) (map[string]portConfig, error) {
|
|
if len(ports) != 48 {
|
|
return nil, fmt.Errorf("config restore ports must include CH01 through CH48")
|
|
}
|
|
result := make(map[string]portConfig, len(ports))
|
|
for _, port := range ports {
|
|
index, err := parseChannel(port.Channel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel := channelName(index)
|
|
if _, exists := result[channel]; exists {
|
|
return nil, fmt.Errorf("config restore ports must not include duplicate channels")
|
|
}
|
|
mode := normalizeMode(port.Mode)
|
|
operation1 := port.Operation1
|
|
stopBitsMode := strings.TrimSpace(port.StopBitsMode)
|
|
if stopBitsMode != "1.5" && stopBitsMode != strconv.Itoa(port.StopBits) {
|
|
stopBitsMode = strconv.Itoa(port.StopBits)
|
|
}
|
|
if port.BaudRate < 110 || port.BaudRate > 460800 {
|
|
return nil, fmt.Errorf("baud_rate must be between 110 and 460800")
|
|
}
|
|
if !isSupportedBaudRate(port.BaudRate) {
|
|
return nil, fmt.Errorf("baud_rate is not supported")
|
|
}
|
|
config, err := normalizePortConfig(portConfig{
|
|
Alias: port.Alias,
|
|
Mode: mode,
|
|
ListenerPort: listenerPortFor(index, mode),
|
|
SerialType: port.SerialType,
|
|
BaudRate: port.BaudRate,
|
|
DataBits: port.DataBits,
|
|
Parity: port.Parity,
|
|
StopBits: port.StopBits,
|
|
StopBitsMode: stopBitsMode,
|
|
FlowControl: port.FlowControl,
|
|
RTSControl: port.RTSControl,
|
|
DTRControl: port.DTRControl,
|
|
IgnoreJammed: port.IgnoreJammed,
|
|
AlarmKeyword: strings.TrimSpace(port.AlarmKeyword),
|
|
DataLogMode: port.DataLogMode,
|
|
DataPackage: port.DataPackage,
|
|
DataLog: port.DataLog,
|
|
Operation1: operation1,
|
|
Operation2: port.Operation2,
|
|
RedirectTo: append([]string(nil), port.RedirectTo...),
|
|
}, index, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[channel] = config
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeNetworkInterfaceName(name string) string {
|
|
trimmed := strings.TrimSpace(name)
|
|
for configured := range baseNetworkConfig() {
|
|
if strings.EqualFold(configured, trimmed) {
|
|
return configured
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func networkConfigFromExport(state domain.NetworkState) (map[string]networkInterfaceConfig, error) {
|
|
interfaces := state.Interfaces
|
|
if len(interfaces) == 0 {
|
|
return nil, fmt.Errorf("config restore network interfaces must not be empty")
|
|
}
|
|
result := make(map[string]networkInterfaceConfig, len(interfaces))
|
|
for _, item := range interfaces {
|
|
name := normalizeNetworkInterfaceName(item.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("interface name must not be empty")
|
|
}
|
|
if _, exists := result[name]; exists {
|
|
return nil, fmt.Errorf("config restore network interfaces must not include duplicate names")
|
|
}
|
|
addressMode := strings.ToLower(strings.TrimSpace(item.AddressMode))
|
|
if addressMode == "" {
|
|
addressMode = networkconfig.AddressModeStatic
|
|
}
|
|
if addressMode == networkconfig.AddressModeStatic {
|
|
if _, err := netip.ParsePrefix(strings.TrimSpace(item.Address)); err != nil {
|
|
return nil, fmt.Errorf("address must be CIDR notation")
|
|
}
|
|
}
|
|
if strings.TrimSpace(item.Gateway) != "" {
|
|
if _, err := netip.ParseAddr(strings.TrimSpace(item.Gateway)); err != nil {
|
|
return nil, fmt.Errorf("gateway must be an IP address or empty")
|
|
}
|
|
}
|
|
if addressMode != networkconfig.AddressModeStatic && addressMode != networkconfig.AddressModeDHCP {
|
|
return nil, fmt.Errorf("address_mode must be static or dhcp")
|
|
}
|
|
role := strings.ToLower(strings.TrimSpace(item.Role))
|
|
if role == "" {
|
|
role = baseNetworkConfig()[name].Role
|
|
}
|
|
weight := item.Weight
|
|
if weight == 0 {
|
|
weight = 1
|
|
}
|
|
if role != networkconfig.RoleInternal && role != networkconfig.RoleExternal {
|
|
return nil, fmt.Errorf("role must be internal or external")
|
|
}
|
|
if weight < 1 || weight > 16 {
|
|
return nil, fmt.Errorf("weight must be between 1 and 16")
|
|
}
|
|
base := baseNetworkConfig()[name]
|
|
dns, err := normalizeIPAddressList(item.DNSServers, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dns_servers: %w", err)
|
|
}
|
|
ipv6DNS, err := normalizeIPAddressList(item.IPv6DNSServers, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipv6_dns_servers: %w", err)
|
|
}
|
|
dns1, dns2 := splitPair(dns)
|
|
ipv6DNS1, ipv6DNS2 := splitPair(ipv6DNS)
|
|
result[name] = networkInterfaceConfig{
|
|
Name: name,
|
|
Device: coalesceTrimmed(item.Device, base.Device),
|
|
DeviceTreeNode: coalesceTrimmed(item.DeviceTreeNode, base.DeviceTreeNode),
|
|
PHY: coalesceTrimmed(item.PHY, base.PHY),
|
|
IdentitySource: coalesceTrimmed(item.IdentitySource, base.IdentitySource),
|
|
ActiveMedium: coalesceTrimmed(item.ActiveMedium, base.ActiveMedium),
|
|
Link: coalesceTrimmed(item.Link, base.Link),
|
|
Speed: coalesceTrimmed(item.Speed, base.Speed),
|
|
Address: strings.TrimSpace(item.Address),
|
|
Gateway: strings.TrimSpace(item.Gateway),
|
|
AddressMode: addressMode,
|
|
DNS1: dns1,
|
|
DNS2: dns2,
|
|
IPv6Mode: strings.ToLower(strings.TrimSpace(item.IPv6Mode)),
|
|
IPv6Address: strings.TrimSpace(item.IPv6Address),
|
|
IPv6Gateway: strings.TrimSpace(item.IPv6Gateway),
|
|
IPv6DNS1: ipv6DNS1,
|
|
IPv6DNS2: ipv6DNS2,
|
|
Role: role,
|
|
Weight: weight,
|
|
}
|
|
}
|
|
global := result["Interface-1"]
|
|
global.HostName = strings.ToLower(strings.TrimSpace(state.HostName))
|
|
global.OperatingMode = strings.ToLower(strings.TrimSpace(state.OperatingMode))
|
|
global.AdvMode = strings.ToLower(strings.TrimSpace(state.Advanced.Mode))
|
|
global.BondMode = strings.ToLower(strings.TrimSpace(state.Advanced.BondMode))
|
|
global.BondMii = state.Advanced.BondMii
|
|
global.BridgeSTP = state.Advanced.BridgeSTP
|
|
global.DDNSEnabled = state.DDNS.Enabled
|
|
global.DDNSProvider = strings.ToLower(strings.TrimSpace(state.DDNS.Provider))
|
|
global.DDNSIPv4Enabled = state.DDNS.IPv4Enabled
|
|
global.DDNSIPv6Enabled = state.DDNS.IPv6Enabled
|
|
global.DDNSUsername = strings.TrimSpace(state.DDNS.Username)
|
|
global.DDNSPassword = strings.TrimSpace(state.DDNS.Password)
|
|
global.DDNSIPv4Domain = strings.TrimSpace(state.DDNS.IPv4Domain)
|
|
global.DDNSIPv6Domain = strings.TrimSpace(state.DDNS.IPv6Domain)
|
|
global.DDNSCheckIPURL = strings.TrimSpace(state.DDNS.CheckIPURL)
|
|
global.DDNSCustomProvider = strings.TrimSpace(state.DDNS.CustomProvider)
|
|
global.DDNSCustomPath = strings.TrimSpace(state.DDNS.CustomPath)
|
|
global.OOBPrimaryInterface = strings.TrimSpace(state.OOB.PrimaryInterface)
|
|
global.OOBFallbackInterface = strings.TrimSpace(state.OOB.FallbackInterface)
|
|
global.OOBHealthCheckTarget = strings.TrimSpace(state.OOB.HealthCheckTarget)
|
|
global.OOBHealthCheckSecond = state.OOB.HealthCheckIntervalSecond
|
|
global.OOBFailureThreshold = state.OOB.FailureThreshold
|
|
result["Interface-1"] = global
|
|
migrateNetworkDefaults(result, baseNetworkConfig())
|
|
if _, err := networkconfig.Normalize(networkConfigForController(result)); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func coalesceTrimmed(value, fallback string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return fallback
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func makePort(index int, config portConfig) domain.Port {
|
|
return domain.Port{
|
|
Channel: channelName(index),
|
|
Alias: config.Alias,
|
|
Status: "unknown",
|
|
Mode: config.Mode,
|
|
ListenerPort: config.ListenerPort,
|
|
SerialType: config.SerialType,
|
|
BaudRate: config.BaudRate,
|
|
DataBits: config.DataBits,
|
|
Parity: config.Parity,
|
|
StopBits: config.StopBits,
|
|
StopBitsMode: config.StopBitsMode,
|
|
FlowControl: config.FlowControl,
|
|
RTSControl: config.RTSControl,
|
|
DTRControl: config.DTRControl,
|
|
IgnoreJammed: config.IgnoreJammed,
|
|
ConnectedUsers: 0,
|
|
MaxViewers: 4,
|
|
Writer: "",
|
|
AlarmKeyword: config.AlarmKeyword,
|
|
DataLogMode: config.DataLogMode,
|
|
DataPackage: config.DataPackage,
|
|
DataLog: config.DataLog,
|
|
Operation1: config.Operation1,
|
|
Operation2: config.Operation2,
|
|
RedirectTo: append([]string(nil), config.RedirectTo...),
|
|
}
|
|
}
|
|
|
|
func normalizePortConfig(config portConfig, index int, allowDefaults bool) (portConfig, error) {
|
|
return normalizePortConfigWithMigration(config, index, allowDefaults, false)
|
|
}
|
|
|
|
func normalizeStoredPortConfig(config portConfig, index int) (portConfig, error) {
|
|
return normalizePortConfigWithMigration(config, index, true, true)
|
|
}
|
|
|
|
func normalizePortConfigWithMigration(config portConfig, index int, allowDefaults, migrateStored bool) (portConfig, error) {
|
|
defaults := basePortConfig()[channelName(index)]
|
|
if allowDefaults {
|
|
if strings.TrimSpace(config.Alias) == "" {
|
|
config.Alias = defaults.Alias
|
|
}
|
|
if strings.TrimSpace(config.SerialType) == "" {
|
|
config.SerialType = "rs232"
|
|
}
|
|
if config.DataBits == 0 {
|
|
config.DataBits = 8
|
|
}
|
|
if strings.TrimSpace(config.Parity) == "" {
|
|
config.Parity = "none"
|
|
}
|
|
if config.StopBits == 0 {
|
|
config.StopBits = 1
|
|
}
|
|
if strings.TrimSpace(config.StopBitsMode) == "" {
|
|
config.StopBitsMode = strconv.Itoa(config.StopBits)
|
|
}
|
|
if strings.TrimSpace(config.FlowControl) == "" {
|
|
config.FlowControl = "none"
|
|
}
|
|
if strings.TrimSpace(config.RTSControl) == "" {
|
|
config.RTSControl = "auto"
|
|
}
|
|
if strings.TrimSpace(config.DTRControl) == "" {
|
|
config.DTRControl = "auto"
|
|
}
|
|
if strings.TrimSpace(config.DataLogMode) == "" {
|
|
config.DataLogMode = serial.CaptureModeRXTX
|
|
}
|
|
if strings.TrimSpace(config.DataPackage.Mode) == "" {
|
|
config.DataPackage = defaults.DataPackage
|
|
}
|
|
if strings.TrimSpace(config.DataLog.Format) == "" {
|
|
config.DataLog = defaults.DataLog
|
|
}
|
|
if strings.TrimSpace(config.Operation1.Mode) == "" {
|
|
mode := normalizeMode(config.Mode)
|
|
if migrateStored {
|
|
mode = migratableOperationMode(mode)
|
|
} else {
|
|
if mode == "" {
|
|
mode = "disabled"
|
|
}
|
|
if !validPortOperationMode(mode) {
|
|
return portConfig{}, fmt.Errorf("mode must be disabled, reverse_ssh, or reverse_telnet")
|
|
}
|
|
}
|
|
config.Operation1 = defaultPortOperation(index, mode)
|
|
} else if migrateStored {
|
|
config.Operation1 = migrateStoredPortOperation(config.Operation1, index)
|
|
}
|
|
if strings.TrimSpace(config.Operation2.Mode) == "" {
|
|
config.Operation2 = defaultPortOperation(index, "disabled")
|
|
} else if migrateStored {
|
|
config.Operation2 = migrateStoredPortOperation(config.Operation2, index)
|
|
}
|
|
}
|
|
config.Alias = strings.TrimSpace(config.Alias)
|
|
config.SerialType = strings.ToLower(strings.TrimSpace(config.SerialType))
|
|
config.Parity = strings.ToLower(strings.TrimSpace(config.Parity))
|
|
config.StopBitsMode = strings.TrimSpace(config.StopBitsMode)
|
|
config.FlowControl = strings.ToLower(strings.TrimSpace(config.FlowControl))
|
|
config.RTSControl = strings.ToLower(strings.TrimSpace(config.RTSControl))
|
|
config.DTRControl = strings.ToLower(strings.TrimSpace(config.DTRControl))
|
|
config.DataLogMode = strings.ToLower(strings.TrimSpace(config.DataLogMode))
|
|
if config.Alias == "" || len(config.Alias) > 64 || strings.ContainsAny(config.Alias, "\r\n\x00") {
|
|
return portConfig{}, fmt.Errorf("alias must be 1 through 64 printable characters")
|
|
}
|
|
if config.SerialType != "rs232" {
|
|
return portConfig{}, fmt.Errorf("serial_type must be rs232")
|
|
}
|
|
if !isSupportedBaudRate(config.BaudRate) {
|
|
return portConfig{}, fmt.Errorf("baud_rate is not supported")
|
|
}
|
|
if config.DataBits < 5 || config.DataBits > 8 {
|
|
return portConfig{}, fmt.Errorf("data_bits must be between 5 and 8")
|
|
}
|
|
switch config.Parity {
|
|
case "none", "even", "odd", "mark", "space":
|
|
default:
|
|
return portConfig{}, fmt.Errorf("parity must be none, even, odd, mark, or space")
|
|
}
|
|
if config.StopBitsMode != "1" && config.StopBitsMode != "1.5" && config.StopBitsMode != "2" {
|
|
return portConfig{}, fmt.Errorf("stop_bits_mode must be 1, 1.5, or 2")
|
|
}
|
|
if config.StopBitsMode == "1.5" && config.DataBits != 5 {
|
|
return portConfig{}, fmt.Errorf("1.5 stop bits requires 5 data bits")
|
|
}
|
|
if config.StopBitsMode == "2" {
|
|
config.StopBits = 2
|
|
} else {
|
|
config.StopBits = 1
|
|
}
|
|
switch config.FlowControl {
|
|
case "none", "rtscts", "xonxoff":
|
|
default:
|
|
return portConfig{}, fmt.Errorf("flow_control must be none, rtscts, or xonxoff")
|
|
}
|
|
for name, value := range map[string]string{"rts_control": config.RTSControl, "dtr_control": config.DTRControl} {
|
|
if value != "auto" && value != "on" && value != "off" {
|
|
return portConfig{}, fmt.Errorf("%s must be auto, on, or off", name)
|
|
}
|
|
}
|
|
if !validDataLogMode(config.DataLogMode) {
|
|
return portConfig{}, fmt.Errorf("data_log_mode must be off, rx, tx, or rx_tx")
|
|
}
|
|
config.DataPackage = disabledDataPackage()
|
|
dataLog, err := normalizeDataLog(config.DataLog)
|
|
if err != nil {
|
|
return portConfig{}, err
|
|
}
|
|
config.DataLog = dataLog
|
|
operation1, err := normalizePortOperation(config.Operation1)
|
|
if err != nil {
|
|
return portConfig{}, fmt.Errorf("operation_1: %w", err)
|
|
}
|
|
operation2, err := normalizePortOperation(config.Operation2)
|
|
if err != nil {
|
|
return portConfig{}, fmt.Errorf("operation_2: %w", err)
|
|
}
|
|
config.Operation1 = operation1
|
|
config.Operation2 = operation2
|
|
if migrateStored {
|
|
// Operation1 is canonical in the current schema. Older snapshots kept
|
|
// an independent compatibility Mode/ListenerPort pair and may contain a
|
|
// now-removed RealCom/TCP/UDP/RFC2217 value there. Never let that stale
|
|
// mirror prevent an otherwise safe upgrade.
|
|
config.Mode = operation1.Mode
|
|
config.ListenerPort = operation1.Port
|
|
} else {
|
|
config.Mode = normalizeMode(config.Mode)
|
|
if config.Mode == "" {
|
|
config.Mode = operation1.Mode
|
|
}
|
|
if config.Mode != operation1.Mode {
|
|
return portConfig{}, fmt.Errorf("mode must match operation_1 mode")
|
|
}
|
|
config.ListenerPort = operation1.Port
|
|
}
|
|
if !validPortOperationMode(config.Mode) {
|
|
return portConfig{}, fmt.Errorf("mode must be disabled, reverse_ssh, or reverse_telnet")
|
|
}
|
|
redirectTo, err := normalizeRedirectChannels(config.RedirectTo, index)
|
|
if err != nil {
|
|
return portConfig{}, err
|
|
}
|
|
config.RedirectTo = redirectTo
|
|
return config, nil
|
|
}
|
|
|
|
func normalizeDataPackage(value domain.DataPackage) (domain.DataPackage, error) {
|
|
return disabledDataPackage(), nil
|
|
}
|
|
|
|
func disabledDataPackage() domain.DataPackage {
|
|
return domain.DataPackage{Mode: "disabled", Length: 1, TimeoutMillis: 0, DelimiterProcess: "keep", Delimiter1: -1, Delimiter2: -1}
|
|
}
|
|
|
|
func normalizeDataLog(value domain.DataLog) (domain.DataLog, error) {
|
|
value.Format = strings.ToLower(strings.TrimSpace(value.Format))
|
|
if value.Format != "ascii" && value.Format != "hex" && value.Format != "ascii_hex" {
|
|
return domain.DataLog{}, fmt.Errorf("data_log format must be ascii, hex, or ascii_hex")
|
|
}
|
|
read, err := normalizeDataLogTrigger(value.ReadTrigger, "read")
|
|
if err != nil {
|
|
return domain.DataLog{}, err
|
|
}
|
|
send, err := normalizeDataLogTrigger(value.SendTrigger, "send")
|
|
if err != nil {
|
|
return domain.DataLog{}, err
|
|
}
|
|
value.ReadTrigger = read
|
|
value.SendTrigger = send
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDataLogTrigger(value domain.DataLogTrigger, name string) (domain.DataLogTrigger, error) {
|
|
value.Process = strings.ToLower(strings.TrimSpace(value.Process))
|
|
if value.Length < 0 || value.Length > 65535 || value.TimeoutMillis < 0 || value.TimeoutMillis > 60000 {
|
|
return domain.DataLogTrigger{}, fmt.Errorf("data_log %s trigger length/timeout is out of range", name)
|
|
}
|
|
if value.Process != "keep" && value.Process != "remove" {
|
|
return domain.DataLogTrigger{}, fmt.Errorf("data_log %s trigger process must be keep or remove", name)
|
|
}
|
|
if !validByteOrDisabled(value.Character1) || !validByteOrDisabled(value.Character2) {
|
|
return domain.DataLogTrigger{}, fmt.Errorf("data_log %s trigger characters must be -1 or 0 through 255", name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizePortOperation(value domain.PortOperation) (domain.PortOperation, error) {
|
|
// Secret material is accepted only by the update path, transferred into the
|
|
// OS-protected secret store, and never retained in configuration snapshots.
|
|
value.Credential = ""
|
|
value.ClearCredential = false
|
|
value.Mode = normalizeMode(value.Mode)
|
|
value.Host = strings.TrimSpace(value.Host)
|
|
value.Authentication = strings.ToLower(strings.TrimSpace(value.Authentication))
|
|
value.Username = strings.TrimSpace(value.Username)
|
|
value.Newline = strings.ToLower(strings.TrimSpace(value.Newline))
|
|
value.ControlShortcut = normalizeControlShortcut(value.ControlShortcut, "^]")
|
|
value.SwitchShortcut = normalizeControlShortcut(value.SwitchShortcut, "^T")
|
|
value.QuitShortcut = normalizeControlShortcut(value.QuitShortcut, "^Q")
|
|
value.BreakShortcut = normalizeControlShortcut(value.BreakShortcut, "")
|
|
value.InterruptShortcut = normalizeControlShortcut(value.InterruptShortcut, "")
|
|
if !validPortOperationMode(value.Mode) {
|
|
return domain.PortOperation{}, fmt.Errorf("mode must be disabled, reverse_ssh, or reverse_telnet")
|
|
}
|
|
if value.Mode != "disabled" && (value.Port < 1 || value.Port > 65535) {
|
|
return domain.PortOperation{}, fmt.Errorf("port must be between 1 and 65535")
|
|
}
|
|
if value.KeepAliveSeconds < 0 || value.KeepAliveSeconds > 3600 {
|
|
return domain.PortOperation{}, fmt.Errorf("keep-alive is out of range")
|
|
}
|
|
value.KeepAliveSeconds = 3
|
|
value.TimeoutMilliseconds = 10000
|
|
value.InactivityTimeoutMilliseconds = 0
|
|
if value.MaxClients < 1 || value.MaxClients > 4 {
|
|
return domain.PortOperation{}, fmt.Errorf("max_clients must be between 1 and 4")
|
|
}
|
|
if value.Authentication != "local" && value.Authentication != "radius" && value.Authentication != "tacacs" {
|
|
return domain.PortOperation{}, fmt.Errorf("authentication must be local, radius, or tacacs")
|
|
}
|
|
value.Host = ""
|
|
value.Username = ""
|
|
value.PasswordStored = false
|
|
if value.Newline != "none" && value.Newline != "cr" && value.Newline != "lf" && value.Newline != "crlf" {
|
|
return domain.PortOperation{}, fmt.Errorf("newline must be none, cr, lf, or crlf")
|
|
}
|
|
if err := validateDistinctControlShortcuts(value); err != nil {
|
|
return domain.PortOperation{}, err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeControlShortcut(value, fallback string) string {
|
|
value = strings.ToUpper(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
if len(value) == 2 && value[0] == '^' && value[1] >= '@' && value[1] <= '_' {
|
|
return value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func validateDistinctControlShortcuts(value domain.PortOperation) error {
|
|
shortcuts := []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{"control_shortcut", value.ControlShortcut},
|
|
{"switch_shortcut", value.SwitchShortcut},
|
|
{"quit_shortcut", value.QuitShortcut},
|
|
{"break_shortcut", value.BreakShortcut},
|
|
{"interrupt_shortcut", value.InterruptShortcut},
|
|
}
|
|
seen := make(map[string]string, len(shortcuts))
|
|
for _, shortcut := range shortcuts {
|
|
if shortcut.value == "" {
|
|
continue
|
|
}
|
|
if len(shortcut.value) != 2 || shortcut.value[0] != '^' || shortcut.value[1] < '@' || shortcut.value[1] > '_' {
|
|
return fmt.Errorf("%s must be empty or use ^ plus one control key", shortcut.name)
|
|
}
|
|
if previous := seen[shortcut.value]; previous != "" {
|
|
return fmt.Errorf("%s conflicts with %s", shortcut.name, previous)
|
|
}
|
|
seen[shortcut.value] = shortcut.name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeRedirectChannels(values []string, sourceIndex int) ([]string, error) {
|
|
if len(values) == 0 {
|
|
return nil, nil
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]bool, len(values))
|
|
for _, value := range values {
|
|
index, err := parseChannel(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("redirect_to: %w", err)
|
|
}
|
|
if index == sourceIndex {
|
|
return nil, fmt.Errorf("redirect_to must not include the source channel")
|
|
}
|
|
channel := channelName(index)
|
|
if seen[channel] {
|
|
return nil, fmt.Errorf("redirect_to must not include duplicate channels")
|
|
}
|
|
seen[channel] = true
|
|
result = append(result, channel)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func validByteOrDisabled(value int) bool { return value >= -1 && value <= 255 }
|
|
|
|
func validDataLogMode(mode string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case serial.CaptureModeOff, serial.CaptureModeRX, serial.CaptureModeTX, serial.CaptureModeRXTX:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isSupportedBaudRate(baudRate int) bool {
|
|
switch baudRate {
|
|
case 110, 300, 600, 1200, 1800, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func makeAlarmRule(ruleID string, config alarmRuleConfig) domain.AlarmRule {
|
|
return domain.AlarmRule{
|
|
RuleID: ruleID,
|
|
Name: config.Name,
|
|
Source: config.Source,
|
|
Channel: config.Channel,
|
|
Trigger: config.Trigger,
|
|
Severity: config.Severity,
|
|
Delivery: append([]string(nil), config.Delivery...),
|
|
Enabled: config.Enabled,
|
|
}
|
|
}
|
|
|
|
func makeAlarmRules(configs map[string]alarmRuleConfig) []domain.AlarmRule {
|
|
ruleIDs := sortedAlarmRuleIDs(configs)
|
|
rules := make([]domain.AlarmRule, 0, len(ruleIDs))
|
|
for _, ruleID := range ruleIDs {
|
|
rules = append(rules, makeAlarmRule(ruleID, configs[ruleID]))
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func alarmRuleConfigFromExport(rules []domain.AlarmRule) (map[string]alarmRuleConfig, error) {
|
|
if len(rules) == 0 {
|
|
return nil, fmt.Errorf("config restore alarm rules must not be empty")
|
|
}
|
|
result := make(map[string]alarmRuleConfig, len(rules))
|
|
for _, rule := range rules {
|
|
ruleID := normalizeRuleID(rule.RuleID)
|
|
if ruleID == "" {
|
|
return nil, fmt.Errorf("config restore alarm rule id must not be empty")
|
|
}
|
|
if _, exists := result[ruleID]; exists {
|
|
return nil, fmt.Errorf("config restore alarm rules must not include duplicate rule ids")
|
|
}
|
|
trigger := strings.TrimSpace(rule.Trigger)
|
|
if trigger == "" {
|
|
return nil, fmt.Errorf("trigger must not be empty")
|
|
}
|
|
delivery, err := normalizeDelivery(rule.Delivery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[ruleID] = alarmRuleConfig{
|
|
Name: strings.TrimSpace(rule.Name),
|
|
Source: strings.TrimSpace(rule.Source),
|
|
Channel: strings.TrimSpace(rule.Channel),
|
|
Trigger: trigger,
|
|
Severity: strings.ToLower(strings.TrimSpace(rule.Severity)),
|
|
Delivery: delivery,
|
|
Enabled: rule.Enabled,
|
|
}
|
|
}
|
|
// Configuration exports created before the complete per-port alarm matrix
|
|
// existed do not contain lifecycle, DCD/DSR, or every keyword rule. Keep
|
|
// those backups restorable without silently removing the newer rule set.
|
|
mergeMissingAlarmRuleConfig(result, baseAlarmRuleConfig())
|
|
return result, nil
|
|
}
|
|
|
|
func listenerPortFor(index int, mode string) int {
|
|
switch mode {
|
|
case "disabled":
|
|
return 0
|
|
case "reverse_ssh":
|
|
return 10000 + index
|
|
case "telnet", "reverse_telnet":
|
|
return 2300 + index
|
|
default:
|
|
return 2200 + index
|
|
}
|
|
}
|
|
|
|
func defaultPortOperation(index int, mode string) domain.PortOperation {
|
|
mode = normalizeMode(mode)
|
|
port := listenerPortFor(index, mode)
|
|
if mode == "disabled" {
|
|
port = 0
|
|
}
|
|
return domain.PortOperation{
|
|
Mode: mode, Port: port, KeepAliveSeconds: 3,
|
|
TimeoutMilliseconds: 10000, InactivityTimeoutMilliseconds: 0, MaxClients: 4, Authentication: "local", Newline: "cr",
|
|
ControlShortcut: "^]", SwitchShortcut: "^T", QuitShortcut: "^Q",
|
|
}
|
|
}
|
|
|
|
// Legacy port records only carried a local listener mode and port. Reverse
|
|
// modes can be migrated without inventing any secret or remote endpoint, but a
|
|
// normal SSH/Telnet operation cannot: it requires an explicit destination.
|
|
// Leave those operations disabled until an administrator supplies a host.
|
|
func migratableOperationMode(mode string) string {
|
|
switch normalizeMode(mode) {
|
|
case "reverse_ssh", "reverse_telnet":
|
|
return normalizeMode(mode)
|
|
default:
|
|
return "disabled"
|
|
}
|
|
}
|
|
|
|
// migrateStoredPortOperation is used only while loading the trusted local
|
|
// configuration store. It converts values that were valid in an older
|
|
// release but were explicitly removed by Ver.20260507_01 into the fail-closed
|
|
// Disabled operation. API updates and imported backups continue through the
|
|
// strict validator and therefore cannot use a removed mode.
|
|
func migrateStoredPortOperation(value domain.PortOperation, index int) domain.PortOperation {
|
|
value.Mode = normalizeMode(value.Mode)
|
|
if !validPortOperationMode(value.Mode) {
|
|
return defaultPortOperation(index, "disabled")
|
|
}
|
|
defaults := defaultPortOperation(index, value.Mode)
|
|
if value.Mode == "disabled" {
|
|
value.Port = 0
|
|
value.Host = ""
|
|
value.Authentication = defaults.Authentication
|
|
value.Username = ""
|
|
value.PasswordStored = false
|
|
}
|
|
if value.Mode != "disabled" && (value.Port < 1 || value.Port > 65535) {
|
|
value.Port = defaults.Port
|
|
}
|
|
if value.MaxClients < 1 {
|
|
value.MaxClients = defaults.MaxClients
|
|
} else if value.MaxClients > 4 {
|
|
value.MaxClients = 4
|
|
}
|
|
if strings.TrimSpace(value.Authentication) == "" {
|
|
value.Authentication = defaults.Authentication
|
|
}
|
|
value.Authentication = strings.ToLower(strings.TrimSpace(value.Authentication))
|
|
if value.Authentication != "local" && value.Authentication != "radius" && value.Authentication != "tacacs" {
|
|
value.Authentication = "local"
|
|
}
|
|
if strings.TrimSpace(value.Newline) == "" {
|
|
value.Newline = defaults.Newline
|
|
}
|
|
value.ControlShortcut = normalizeControlShortcut(value.ControlShortcut, defaults.ControlShortcut)
|
|
value.SwitchShortcut = normalizeControlShortcut(value.SwitchShortcut, defaults.SwitchShortcut)
|
|
value.QuitShortcut = normalizeControlShortcut(value.QuitShortcut, defaults.QuitShortcut)
|
|
value.BreakShortcut = normalizeControlShortcut(value.BreakShortcut, "")
|
|
value.InterruptShortcut = normalizeControlShortcut(value.InterruptShortcut, "")
|
|
if validateDistinctControlShortcuts(value) != nil {
|
|
value.ControlShortcut = defaults.ControlShortcut
|
|
value.SwitchShortcut = defaults.SwitchShortcut
|
|
value.QuitShortcut = defaults.QuitShortcut
|
|
value.BreakShortcut = ""
|
|
value.InterruptShortcut = ""
|
|
}
|
|
value.TimeoutMilliseconds = 10000
|
|
value.InactivityTimeoutMilliseconds = 0
|
|
value.Host = ""
|
|
value.Username = ""
|
|
value.PasswordStored = false
|
|
return value
|
|
}
|
|
|
|
func normalizeMode(mode string) string {
|
|
value := strings.ToLower(strings.TrimSpace(mode))
|
|
value = strings.ReplaceAll(value, "-", "_")
|
|
value = strings.ReplaceAll(value, " ", "_")
|
|
return value
|
|
}
|
|
|
|
func validPortOperationMode(mode string) bool {
|
|
switch mode {
|
|
case "disabled", "reverse_ssh", "reverse_telnet":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeDirectAccess(access string) string {
|
|
value := strings.ToLower(strings.TrimSpace(access))
|
|
if value == "writer" {
|
|
return "writer"
|
|
}
|
|
return "viewer"
|
|
}
|
|
|
|
func normalizeRuleID(ruleID string) string {
|
|
return strings.ToLower(strings.TrimSpace(ruleID))
|
|
}
|
|
|
|
func normalizeTimezone(timezone string) (string, error) {
|
|
value := strings.TrimSpace(timezone)
|
|
if value == "" {
|
|
return "UTC", nil
|
|
}
|
|
if len(value) > 64 {
|
|
return "", fmt.Errorf("timezone must be 64 characters or fewer")
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '/' || char == '-' || char == '+' {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf("timezone contains invalid characters")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeTimeMode(mode string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(mode))
|
|
value = strings.ReplaceAll(value, "-", "_")
|
|
value = strings.ReplaceAll(value, " ", "_")
|
|
switch value {
|
|
case "manual":
|
|
return "manual", nil
|
|
case "ntp", "ntp_client":
|
|
return "ntp", nil
|
|
default:
|
|
return "", fmt.Errorf("time mode must be manual or ntp")
|
|
}
|
|
}
|
|
|
|
func normalizeNTPServers(servers []string) ([]string, error) {
|
|
if len(servers) == 0 {
|
|
return nil, fmt.Errorf("ntp_servers must include at least one server")
|
|
}
|
|
if len(servers) > 4 {
|
|
return nil, fmt.Errorf("ntp_servers must include at most four servers")
|
|
}
|
|
normalized := make([]string, 0, len(servers))
|
|
seen := make(map[string]bool, len(servers))
|
|
for _, server := range servers {
|
|
value := strings.ToLower(strings.TrimSpace(server))
|
|
if value == "" {
|
|
return nil, fmt.Errorf("ntp server must not be empty")
|
|
}
|
|
if len(value) > 128 {
|
|
return nil, fmt.Errorf("ntp server must be 128 characters or fewer")
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == '_' || char == ':' {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("ntp server contains invalid characters")
|
|
}
|
|
if !seen[value] {
|
|
normalized = append(normalized, value)
|
|
seen[value] = true
|
|
}
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func validateTimeSettings(settings timeSettingsConfig) error {
|
|
if _, err := normalizeTimeMode(settings.Mode); err != nil {
|
|
return err
|
|
}
|
|
if _, err := normalizeTimezone(settings.Timezone); err != nil {
|
|
return err
|
|
}
|
|
if settings.Mode == "ntp" {
|
|
if _, err := normalizeNTPServers(settings.NTPServers); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func timeConfigFromExport(settings domain.TimeSettings) (timeSettingsConfig, error) {
|
|
mode, err := normalizeTimeMode(settings.Mode)
|
|
if err != nil {
|
|
return timeSettingsConfig{}, err
|
|
}
|
|
timezone, err := normalizeTimezone(settings.Timezone)
|
|
if err != nil {
|
|
return timeSettingsConfig{}, err
|
|
}
|
|
servers := append([]string(nil), settings.NTPServers...)
|
|
if mode == "ntp" {
|
|
servers, err = normalizeNTPServers(settings.NTPServers)
|
|
if err != nil {
|
|
return timeSettingsConfig{}, err
|
|
}
|
|
}
|
|
result := timeSettingsConfig{Mode: mode, Timezone: timezone, NTPServers: servers}
|
|
return result, validateTimeSettings(result)
|
|
}
|
|
|
|
func validateRetentionDays(field string, days int) error {
|
|
if days < 1 || days > logpolicy.MaxRetentionDays {
|
|
return fmt.Errorf("%s must be between 1 and %d", field, logpolicy.MaxRetentionDays)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateLogRetention(settings logRetentionConfig) error {
|
|
if err := validateRetentionDays("system_days", settings.SystemDays); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRetentionDays("audit_days", settings.AuditDays); err != nil {
|
|
return err
|
|
}
|
|
if err := validateRetentionDays("port_days", settings.PortDays); err != nil {
|
|
return err
|
|
}
|
|
if settings.ProtectedUsageWarningPct < 50 || settings.ProtectedUsageWarningPct > 95 {
|
|
return fmt.Errorf("protected_usage_warning_pct must be between 50 and 95")
|
|
}
|
|
if !settings.PreserveOnFactoryReset {
|
|
return fmt.Errorf("preserve_on_factory_reset must remain true for protected logs")
|
|
}
|
|
if settings.KeepaliveIntervalMinutes != 3 && settings.KeepaliveIntervalMinutes != 5 && settings.KeepaliveIntervalMinutes != 10 {
|
|
return fmt.Errorf("keepalive_interval_minutes must be 3, 5, or 10")
|
|
}
|
|
if settings.AllPortStatusIntervalMinutes < 1 || settings.AllPortStatusIntervalMinutes > 1440 {
|
|
return fmt.Errorf("all_port_status_interval_minutes must be between 1 and 1440")
|
|
}
|
|
if settings.SinglePortStatusIntervalMinutes < 1 || settings.SinglePortStatusIntervalMinutes > 1440 {
|
|
return fmt.Errorf("single_port_status_interval_minutes must be between 1 and 1440")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func logRetentionConfigFromExport(settings domain.LogRetentionSettings) (logRetentionConfig, error) {
|
|
result := logRetentionConfig{
|
|
SystemDays: settings.SystemDays,
|
|
AuditDays: settings.AuditDays,
|
|
PortDays: settings.PortDays,
|
|
ProtectedUsageWarningPct: settings.ProtectedUsageWarningPct,
|
|
PreserveOnFactoryReset: settings.PreserveOnFactoryReset,
|
|
LocalSystemEnabled: settings.LocalSystemEnabled,
|
|
LocalConfigurationEnabled: settings.LocalConfigurationEnabled,
|
|
LocalOperationEnabled: settings.LocalOperationEnabled,
|
|
LocalStatusEnabled: settings.LocalStatusEnabled,
|
|
RemoteSystemEnabled: settings.RemoteSystemEnabled,
|
|
RemoteConfigurationEnabled: settings.RemoteConfigurationEnabled,
|
|
RemoteOperationEnabled: settings.RemoteOperationEnabled,
|
|
RemoteStatusEnabled: settings.RemoteStatusEnabled,
|
|
KeepaliveEnabled: settings.KeepaliveEnabled,
|
|
KeepaliveIntervalMinutes: settings.KeepaliveIntervalMinutes,
|
|
AllPortStatusEnabled: settings.AllPortStatusEnabled,
|
|
AllPortStatusIntervalMinutes: settings.AllPortStatusIntervalMinutes,
|
|
SinglePortStatusEnabled: settings.SinglePortStatusEnabled,
|
|
SinglePortStatusRealtime: settings.SinglePortStatusRealtime,
|
|
SinglePortStatusIntervalMinutes: settings.SinglePortStatusIntervalMinutes,
|
|
}
|
|
migrateLogEventDefaults(&result)
|
|
return result, validateLogRetention(result)
|
|
}
|
|
|
|
func migrateLogEventDefaults(settings *logRetentionConfig) {
|
|
if settings == nil || settings.KeepaliveIntervalMinutes != 0 || settings.AllPortStatusIntervalMinutes != 0 || settings.SinglePortStatusIntervalMinutes != 0 {
|
|
return
|
|
}
|
|
defaults := baseLogRetention()
|
|
settings.LocalSystemEnabled = defaults.LocalSystemEnabled
|
|
settings.LocalConfigurationEnabled = defaults.LocalConfigurationEnabled
|
|
settings.LocalOperationEnabled = defaults.LocalOperationEnabled
|
|
settings.LocalStatusEnabled = defaults.LocalStatusEnabled
|
|
settings.KeepaliveEnabled = defaults.KeepaliveEnabled
|
|
settings.KeepaliveIntervalMinutes = defaults.KeepaliveIntervalMinutes
|
|
settings.AllPortStatusEnabled = defaults.AllPortStatusEnabled
|
|
settings.AllPortStatusIntervalMinutes = defaults.AllPortStatusIntervalMinutes
|
|
settings.SinglePortStatusEnabled = defaults.SinglePortStatusEnabled
|
|
settings.SinglePortStatusIntervalMinutes = defaults.SinglePortStatusIntervalMinutes
|
|
}
|
|
|
|
func validateAlarmDeliveryPolicy(policy alarmDeliveryPolicyConfig) error {
|
|
if policy.RetryCount < 0 || policy.RetryCount > 10 {
|
|
return fmt.Errorf("retry_count must be between 0 and 10")
|
|
}
|
|
if policy.RetryIntervalSeconds < 10 || policy.RetryIntervalSeconds > 3600 {
|
|
return fmt.Errorf("retry_interval_seconds must be between 10 and 3600")
|
|
}
|
|
if policy.PendingQueueLimit < 10 || policy.PendingQueueLimit > 10000 {
|
|
return fmt.Errorf("pending_queue_limit must be between 10 and 10000")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func alarmDeliveryConfigFromExport(policy domain.AlarmDeliveryPolicy) (alarmDeliveryPolicyConfig, error) {
|
|
result := alarmDeliveryPolicyConfig{
|
|
RetryCount: policy.RetryCount,
|
|
RetryIntervalSeconds: policy.RetryIntervalSeconds,
|
|
PendingQueueLimit: policy.PendingQueueLimit,
|
|
NotifyOnRetryExhausted: policy.NotifyOnRetryExhausted,
|
|
}
|
|
return result, validateAlarmDeliveryPolicy(result)
|
|
}
|
|
|
|
func emailNotificationFromExport(settings domain.EmailNotificationSettings) (emailNotificationConfig, error) {
|
|
if settings.SMTPHost == "" && settings.SMTPPort == 0 && settings.From == "" && len(settings.To) == 0 && settings.CustomTitle == "" && settings.CustomContent == "" {
|
|
return baseEmailNotification(), nil
|
|
}
|
|
result := emailNotificationConfig{
|
|
Enabled: settings.Enabled,
|
|
SMTPHost: strings.TrimSpace(settings.SMTPHost),
|
|
SMTPPort: settings.SMTPPort,
|
|
STARTTLSEnabled: settings.STARTTLSEnabled,
|
|
AuthEnabled: settings.AuthEnabled,
|
|
Username: strings.TrimSpace(settings.Username),
|
|
PasswordStored: settings.PasswordStored,
|
|
From: strings.TrimSpace(settings.From),
|
|
To: normalizeEmailList(settings.To),
|
|
CC: normalizeEmailList(settings.CC),
|
|
CustomTitle: strings.TrimSpace(settings.CustomTitle),
|
|
CustomContent: strings.TrimSpace(settings.CustomContent),
|
|
}
|
|
return result, validateEmailNotification(result)
|
|
}
|
|
|
|
func validateEmailNotification(settings emailNotificationConfig) error {
|
|
if settings.SMTPPort < 1 || settings.SMTPPort > 65535 {
|
|
return fmt.Errorf("email notification smtp_port must be between 1 and 65535")
|
|
}
|
|
if strings.TrimSpace(settings.SMTPHost) == "" || len(settings.SMTPHost) > 253 {
|
|
return fmt.Errorf("email notification smtp_host must not be empty or longer than 253 characters")
|
|
}
|
|
if err := validateEmailAddress("from", settings.From); err != nil {
|
|
return err
|
|
}
|
|
if len(settings.To) > 16 || len(settings.CC) > 16 {
|
|
return fmt.Errorf("email notification recipient lists must contain at most 16 entries each")
|
|
}
|
|
for _, address := range settings.To {
|
|
if err := validateEmailAddress("to", address); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, address := range settings.CC {
|
|
if err := validateEmailAddress("cc", address); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if settings.AuthEnabled && strings.TrimSpace(settings.Username) == "" {
|
|
return fmt.Errorf("email notification auth requires username")
|
|
}
|
|
if settings.AuthEnabled && !settings.PasswordStored {
|
|
return fmt.Errorf("email notification auth requires password_stored true")
|
|
}
|
|
if settings.Enabled && len(settings.To) == 0 {
|
|
return fmt.Errorf("email notification enabled mode requires at least one to recipient")
|
|
}
|
|
if len(settings.CustomTitle) > 120 {
|
|
return fmt.Errorf("email notification custom_title must be 120 characters or fewer")
|
|
}
|
|
if len(settings.CustomContent) > 512 {
|
|
return fmt.Errorf("email notification custom_content must be 512 characters or fewer")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeEmailList(values []string) []string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func validateEmailAddress(field, address string) error {
|
|
value := strings.TrimSpace(address)
|
|
if value == "" || len(value) > 254 || strings.Count(value, "@") != 1 {
|
|
return fmt.Errorf("email notification %s address is invalid", field)
|
|
}
|
|
parts := strings.Split(value, "@")
|
|
if parts[0] == "" || parts[1] == "" || strings.ContainsAny(value, " \t\r\n") {
|
|
return fmt.Errorf("email notification %s address is invalid", field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func snmpAgentFromExport(settings domain.SNMPAgentSettings) (snmpAgentConfig, error) {
|
|
if settings.Location == "" && settings.Contact == "" && len(settings.V3Users) == 0 && !settings.Enabled && !settings.V1Enabled && !settings.V2CEnabled && !settings.V3Enabled {
|
|
return baseSNMPAgent(), nil
|
|
}
|
|
users, err := snmpV3UsersFromDomain(settings.V3Users)
|
|
if err != nil {
|
|
return snmpAgentConfig{}, err
|
|
}
|
|
result := snmpAgentConfig{
|
|
Enabled: settings.Enabled,
|
|
Location: strings.TrimSpace(settings.Location),
|
|
Contact: strings.TrimSpace(settings.Contact),
|
|
V1Enabled: settings.V1Enabled,
|
|
V2CEnabled: settings.V2CEnabled,
|
|
V3Enabled: settings.V3Enabled,
|
|
ReadOnlyCommunityStored: settings.ReadOnlyCommunityStored,
|
|
ReadWriteCommunityStored: settings.ReadWriteCommunityStored,
|
|
V3Users: users,
|
|
}
|
|
return result, validateSNMPAgent(result)
|
|
}
|
|
|
|
func snmpV3UsersFromDomain(users []domain.SNMPV3User) ([]snmpV3UserConfig, error) {
|
|
if len(users) > 8 {
|
|
return nil, fmt.Errorf("snmp agent v3 user list must contain at most 8 entries")
|
|
}
|
|
result := make([]snmpV3UserConfig, 0, len(users))
|
|
seen := make(map[string]bool, len(users))
|
|
for _, user := range users {
|
|
authProtocol, err := normalizeSNMPAuthProtocol(user.AuthProtocol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
privacyProtocol, err := normalizeSNMPPrivacyProtocol(user.PrivacyProtocol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config := snmpV3UserConfig{
|
|
Username: strings.ToLower(strings.TrimSpace(user.Username)),
|
|
Access: normalizeSNMPAccess(user.Access),
|
|
AuthProtocol: authProtocol,
|
|
PrivacyProtocol: privacyProtocol,
|
|
AuthKeyStored: user.AuthKeyStored,
|
|
PrivacyKeyStored: user.PrivacyKeyStored,
|
|
Enabled: user.Enabled,
|
|
}
|
|
if config.Username == "" {
|
|
return nil, fmt.Errorf("snmp agent v3 username must not be empty")
|
|
}
|
|
if seen[config.Username] {
|
|
return nil, fmt.Errorf("snmp agent v3 username must be unique")
|
|
}
|
|
seen[config.Username] = true
|
|
if err := validateSNMPV3User(config); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, config)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateSNMPAgent(settings snmpAgentConfig) error {
|
|
if len(settings.Location) > 128 || len(settings.Contact) > 128 {
|
|
return fmt.Errorf("snmp agent location and contact must be 128 characters or fewer")
|
|
}
|
|
for _, user := range settings.V3Users {
|
|
if err := validateSNMPV3User(user); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if (settings.V1Enabled || settings.V2CEnabled) && !settings.ReadOnlyCommunityStored {
|
|
return fmt.Errorf("snmp agent v1/v2c requires read_only_community_stored true")
|
|
}
|
|
if settings.Enabled && !settings.V1Enabled && !settings.V2CEnabled && !settings.V3Enabled {
|
|
return fmt.Errorf("snmp agent enabled mode requires at least one SNMP version")
|
|
}
|
|
if settings.Enabled && settings.V3Enabled && !hasEnabledSNMPV3User(settings.V3Users) {
|
|
return fmt.Errorf("snmp agent v3 enabled mode requires at least one enabled v3 user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSNMPV3User(user snmpV3UserConfig) error {
|
|
if strings.TrimSpace(user.Username) == "" {
|
|
return fmt.Errorf("snmp agent v3 username must not be empty")
|
|
}
|
|
authProtocol, err := normalizeSNMPAuthProtocol(user.AuthProtocol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
privacyProtocol, err := normalizeSNMPPrivacyProtocol(user.PrivacyProtocol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user.Enabled && authProtocol != "none" && !user.AuthKeyStored {
|
|
return fmt.Errorf("snmp agent enabled v3 user requires auth_key_stored true")
|
|
}
|
|
if user.Enabled && privacyProtocol != "none" && !user.PrivacyKeyStored {
|
|
return fmt.Errorf("snmp agent enabled v3 user requires privacy_key_stored true")
|
|
}
|
|
if privacyProtocol != "none" && authProtocol == "none" {
|
|
return fmt.Errorf("snmp agent v3 privacy requires auth protocol")
|
|
}
|
|
if user.Access != "" && user.Access != "read" && user.Access != "write" {
|
|
return fmt.Errorf("snmp agent v3 access must be read or write")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeSNMPAccess(access string) string {
|
|
if strings.EqualFold(strings.TrimSpace(access), "write") {
|
|
return "write"
|
|
}
|
|
return "read"
|
|
}
|
|
|
|
func normalizeSNMPAuthProtocol(protocol string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(protocol))
|
|
switch value {
|
|
case "none", "md5", "sha1", "sha256":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("snmp agent auth_protocol must be none, md5, sha1, or sha256")
|
|
}
|
|
}
|
|
|
|
func normalizeSNMPPrivacyProtocol(protocol string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(protocol))
|
|
switch value {
|
|
case "none", "des", "aes":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("snmp agent privacy_protocol must be none, des, or aes")
|
|
}
|
|
}
|
|
|
|
func hasEnabledSNMPV3User(users []snmpV3UserConfig) bool {
|
|
for _, user := range users {
|
|
if user.Enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func snmpTrapTargetsFromDomain(targets []domain.SNMPTrapTarget) ([]snmpTrapTargetConfig, error) {
|
|
if len(targets) > 8 {
|
|
return nil, fmt.Errorf("snmp trap target list must contain at most 8 entries")
|
|
}
|
|
result := make([]snmpTrapTargetConfig, 0, len(targets))
|
|
seen := make(map[string]bool, len(targets))
|
|
for _, target := range targets {
|
|
version, err := normalizeSNMPTrapVersion(target.Version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
authProtocol, err := normalizeSNMPTrapAuthProtocol(target.AuthProtocol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
privacyProtocol, err := normalizeSNMPTrapPrivacyProtocol(target.PrivacyProtocol)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
config := snmpTrapTargetConfig{
|
|
TargetID: strings.ToLower(strings.TrimSpace(target.TargetID)),
|
|
Host: strings.TrimSpace(target.Host),
|
|
Port: target.Port,
|
|
Version: version,
|
|
Username: strings.TrimSpace(target.Username),
|
|
AuthProtocol: authProtocol,
|
|
PrivacyProtocol: privacyProtocol,
|
|
EngineID: strings.TrimSpace(target.EngineID),
|
|
CredentialStored: target.CredentialStored,
|
|
PrivacyKeyStored: target.PrivacyKeyStored,
|
|
Enabled: target.Enabled,
|
|
}
|
|
if config.TargetID == "" {
|
|
return nil, fmt.Errorf("snmp trap target_id must not be empty")
|
|
}
|
|
if seen[config.TargetID] {
|
|
return nil, fmt.Errorf("snmp trap target_id must be unique")
|
|
}
|
|
seen[config.TargetID] = true
|
|
if err := validateSNMPTrapTarget(config); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, config)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func snmpTrapTargetsFromExport(settings domain.SNMPTrapTargetSettings) ([]snmpTrapTargetConfig, error) {
|
|
if len(settings.Targets) == 0 {
|
|
return cloneSNMPTrapTargets(baseSNMPTrapTargets()), nil
|
|
}
|
|
return snmpTrapTargetsFromDomain(settings.Targets)
|
|
}
|
|
|
|
func validateSNMPTrapTargets(targets []snmpTrapTargetConfig) error {
|
|
if len(targets) == 0 {
|
|
return fmt.Errorf("snmp trap target list must include at least one target")
|
|
}
|
|
if len(targets) > 8 {
|
|
return fmt.Errorf("snmp trap target list must contain at most 8 entries")
|
|
}
|
|
seen := make(map[string]bool, len(targets))
|
|
for _, target := range targets {
|
|
targetID := strings.ToLower(strings.TrimSpace(target.TargetID))
|
|
if targetID == "" {
|
|
return fmt.Errorf("snmp trap target_id must not be empty")
|
|
}
|
|
if seen[targetID] {
|
|
return fmt.Errorf("snmp trap target_id must be unique")
|
|
}
|
|
seen[targetID] = true
|
|
if err := validateSNMPTrapTarget(target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSNMPTrapTarget(target snmpTrapTargetConfig) error {
|
|
if strings.TrimSpace(target.Host) == "" || len(target.Host) > 253 {
|
|
return fmt.Errorf("snmp trap host must not be empty or longer than 253 characters")
|
|
}
|
|
if target.Port < 1 || target.Port > 65535 {
|
|
return fmt.Errorf("snmp trap port must be between 1 and 65535")
|
|
}
|
|
version, err := normalizeSNMPTrapVersion(target.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if target.Enabled && version != "v3" && !target.CredentialStored {
|
|
return fmt.Errorf("snmp trap v1/v2c enabled target requires a stored community")
|
|
}
|
|
if version == "v3" {
|
|
if target.Enabled && strings.TrimSpace(target.Username) == "" {
|
|
return fmt.Errorf("snmp trap v3 enabled target requires username")
|
|
}
|
|
if target.Enabled && strings.TrimSpace(target.EngineID) == "" {
|
|
return fmt.Errorf("snmp trap v3 enabled target requires engine_id")
|
|
}
|
|
if len(target.EngineID) > 64 || strings.ContainsAny(target.EngineID, "\r\n\x00") {
|
|
return fmt.Errorf("snmp trap v3 engine_id is invalid")
|
|
}
|
|
if target.AuthProtocol != "none" && target.Enabled && !target.CredentialStored {
|
|
return fmt.Errorf("snmp trap v3 authentication requires a stored credential")
|
|
}
|
|
if target.PrivacyProtocol != "none" && target.Enabled && !target.PrivacyKeyStored {
|
|
return fmt.Errorf("snmp trap v3 privacy requires a stored privacy key")
|
|
}
|
|
if target.PrivacyProtocol != "none" && target.AuthProtocol == "none" {
|
|
return fmt.Errorf("snmp trap v3 privacy requires authentication")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeSNMPTrapVersion(version string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(version))
|
|
switch value {
|
|
case "v1", "v2c", "v3":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("snmp trap version must be v1, v2c, or v3")
|
|
}
|
|
}
|
|
|
|
func normalizeSNMPTrapAuthProtocol(protocol string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(protocol)) {
|
|
case "", "none", "disable":
|
|
return "none", nil
|
|
case "md5":
|
|
return "md5", nil
|
|
case "sha", "sha1":
|
|
return "sha1", nil
|
|
case "sha256":
|
|
return "sha256", nil
|
|
default:
|
|
return "", fmt.Errorf("snmp trap auth_protocol must be none, md5, sha1, or sha256")
|
|
}
|
|
}
|
|
|
|
func normalizeSNMPTrapPrivacyProtocol(protocol string) (string, error) {
|
|
switch strings.ToLower(strings.TrimSpace(protocol)) {
|
|
case "", "none", "disable":
|
|
return "none", nil
|
|
case "des":
|
|
return "des", nil
|
|
case "aes":
|
|
return "aes", nil
|
|
default:
|
|
return "", fmt.Errorf("snmp trap privacy_protocol must be none, des, or aes")
|
|
}
|
|
}
|
|
|
|
func validateManagementAccess(settings managementAccessConfig) error {
|
|
if settings.IdleTimeoutMinutes < 1 || settings.IdleTimeoutMinutes > 120 {
|
|
return fmt.Errorf("idle_timeout_minutes must be between 1 and 120")
|
|
}
|
|
if settings.LoginDetectionMinutes < 0 || settings.LoginDetectionMinutes > 1440 {
|
|
return fmt.Errorf("login_detection_minutes must be between 0 and 1440")
|
|
}
|
|
if settings.LoginSecurityHitCount < 0 || settings.LoginSecurityHitCount > maximumLoginSecurityHitCount {
|
|
return fmt.Errorf("login_security_hit_count must be between 0 and 100")
|
|
}
|
|
if settings.SSHEnabled || settings.TelnetEnabled {
|
|
return fmt.Errorf("management access SSH and Telnet are excluded; use per-channel SSH or Telnet access")
|
|
}
|
|
if !settings.HTTPSEnabled && !settings.SerialConsoleEnabled {
|
|
return fmt.Errorf("management access must keep HTTPS or serial console enabled")
|
|
}
|
|
ports := []struct {
|
|
name string
|
|
port int
|
|
enabled bool
|
|
}{
|
|
{name: "http_port", port: settings.HTTPPort, enabled: settings.HTTPEnabled},
|
|
{name: "https_port", port: settings.HTTPSPort, enabled: settings.HTTPSEnabled},
|
|
}
|
|
activePorts := make(map[int]string)
|
|
for _, item := range ports {
|
|
if item.port < 1 || item.port > 65535 {
|
|
return fmt.Errorf("management access %s must be between 1 and 65535", item.name)
|
|
}
|
|
if !item.enabled {
|
|
continue
|
|
}
|
|
if existing, ok := activePorts[item.port]; ok {
|
|
return fmt.Errorf("management access services %s and %s cannot share port %d", existing, item.name, item.port)
|
|
}
|
|
activePorts[item.port] = item.name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateManagementAccessPorts(settings managementAccessConfig) managementAccessConfig {
|
|
defaults := baseManagementAccess()
|
|
// Old configuration snapshots may contain the removed management CLI
|
|
// services. Keep their port fields for backward-compatible decoding, but
|
|
// never restore or start management SSH/Telnet.
|
|
settings.SSHEnabled = false
|
|
settings.TelnetEnabled = false
|
|
if settings.HTTPPort == 0 {
|
|
settings.HTTPPort = defaults.HTTPPort
|
|
}
|
|
if settings.HTTPSPort == 0 {
|
|
settings.HTTPSPort = defaults.HTTPSPort
|
|
}
|
|
if settings.SSHPort == 0 {
|
|
settings.SSHPort = defaults.SSHPort
|
|
}
|
|
if settings.TelnetPort == 0 {
|
|
settings.TelnetPort = defaults.TelnetPort
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func migrateManagementAccessPortsPointer(settings *managementAccessConfig) {
|
|
if settings != nil {
|
|
*settings = migrateManagementAccessPorts(*settings)
|
|
}
|
|
}
|
|
|
|
func managementConfigFromExport(settings domain.ManagementAccessSettings) (managementAccessConfig, error) {
|
|
result := migrateManagementAccessPorts(managementAccessConfig{
|
|
HTTPEnabled: settings.HTTPEnabled,
|
|
HTTPPort: settings.HTTPPort,
|
|
HTTPSEnabled: settings.HTTPSEnabled,
|
|
HTTPSPort: settings.HTTPSPort,
|
|
SSHEnabled: settings.SSHEnabled,
|
|
SSHPort: settings.SSHPort,
|
|
TelnetEnabled: settings.TelnetEnabled,
|
|
TelnetPort: settings.TelnetPort,
|
|
SerialConsoleEnabled: settings.SerialConsoleEnabled,
|
|
IdleTimeoutMinutes: settings.IdleTimeoutMinutes,
|
|
CaptchaEnabled: settings.CaptchaEnabled,
|
|
LoginDetectionMinutes: settings.LoginDetectionMinutes,
|
|
LoginSecurityHitCount: settings.LoginSecurityHitCount,
|
|
})
|
|
return result, validateManagementAccess(result)
|
|
}
|
|
|
|
func normalizeSyslogProtocol(protocol string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(protocol))
|
|
switch value {
|
|
case "udp", "tcp":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("remote syslog protocol must be udp or tcp")
|
|
}
|
|
}
|
|
|
|
func normalizeSyslogFacility(facility string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(facility))
|
|
switch value {
|
|
case "local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("remote syslog facility must be local0 through local7")
|
|
}
|
|
}
|
|
|
|
func remoteSyslogConfigFromExport(settings domain.RemoteSyslogSettings) (remoteSyslogConfig, error) {
|
|
protocol, err := normalizeSyslogProtocol(settings.Protocol)
|
|
if err != nil {
|
|
return remoteSyslogConfig{}, err
|
|
}
|
|
result := remoteSyslogConfig{
|
|
Enabled: settings.Enabled,
|
|
Host: strings.TrimSpace(settings.Host),
|
|
Port: settings.Port,
|
|
Protocol: protocol,
|
|
Facility: strings.TrimSpace(settings.Facility),
|
|
}
|
|
return result, validateRemoteSyslog(result)
|
|
}
|
|
|
|
func tlsCertificateConfigFromExport(settings domain.TLSCertificateSettings) (tlsCertificateConfig, error) {
|
|
if strings.TrimSpace(settings.ActiveCertificateID) == "" {
|
|
if strings.TrimSpace(settings.Subject) == "" && strings.TrimSpace(settings.Issuer) == "" && strings.TrimSpace(settings.FingerprintSHA256) == "" && settings.NotBefore.IsZero() && settings.NotAfter.IsZero() {
|
|
return baseTLSCertificate(), nil
|
|
}
|
|
return tlsCertificateConfig{}, fmt.Errorf("tls certificate active_certificate_id must not be empty")
|
|
}
|
|
result := tlsCertificateConfig{
|
|
ActiveCertificateID: strings.ToLower(strings.TrimSpace(settings.ActiveCertificateID)),
|
|
Subject: strings.TrimSpace(settings.Subject),
|
|
Issuer: strings.TrimSpace(settings.Issuer),
|
|
NotBefore: settings.NotBefore.UTC(),
|
|
NotAfter: settings.NotAfter.UTC(),
|
|
FingerprintSHA256: strings.ToLower(strings.TrimSpace(settings.FingerprintSHA256)),
|
|
Source: strings.TrimSpace(settings.Source),
|
|
PrivateKeyStored: settings.PrivateKeyStored,
|
|
}
|
|
return result, validateTLSCertificate(result)
|
|
}
|
|
|
|
func validateTLSCertificate(settings tlsCertificateConfig) error {
|
|
if strings.TrimSpace(settings.ActiveCertificateID) == "" {
|
|
return fmt.Errorf("tls certificate active_certificate_id must not be empty")
|
|
}
|
|
if strings.TrimSpace(settings.Subject) == "" || strings.TrimSpace(settings.Issuer) == "" {
|
|
return fmt.Errorf("tls certificate subject and issuer must not be empty")
|
|
}
|
|
if settings.NotBefore.IsZero() || settings.NotAfter.IsZero() || !settings.NotAfter.After(settings.NotBefore) {
|
|
return fmt.Errorf("tls certificate validity window is invalid")
|
|
}
|
|
if len(settings.FingerprintSHA256) != 64 {
|
|
return fmt.Errorf("tls certificate fingerprint_sha256 must be 64 hex characters")
|
|
}
|
|
if _, err := hex.DecodeString(settings.FingerprintSHA256); err != nil {
|
|
return fmt.Errorf("tls certificate fingerprint_sha256 must be hex encoded")
|
|
}
|
|
if !settings.PrivateKeyStored {
|
|
return fmt.Errorf("tls certificate private key must be stored in protected storage")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func externalAuthConfigFromExport(settings domain.ExternalAuthSettings) (externalAuthConfig, error) {
|
|
if settings.PrimaryMethod == "" && len(settings.RadiusServers) == 0 && len(settings.TACACSServers) == 0 && settings.TimeoutSeconds == 0 {
|
|
return baseExternalAuth(), nil
|
|
}
|
|
method, err := normalizeExternalAuthMethod(settings.PrimaryMethod)
|
|
if err != nil {
|
|
return externalAuthConfig{}, err
|
|
}
|
|
radiusServers, err := externalAuthServersFromDomain(settings.RadiusServers, "radius")
|
|
if err != nil {
|
|
return externalAuthConfig{}, err
|
|
}
|
|
tacacsServers, err := externalAuthServersFromDomain(settings.TACACSServers, "tacacs")
|
|
if err != nil {
|
|
return externalAuthConfig{}, err
|
|
}
|
|
result := externalAuthConfig{
|
|
Enabled: settings.Enabled,
|
|
PrimaryMethod: method,
|
|
LocalFallback: settings.LocalFallback,
|
|
TimeoutSeconds: settings.TimeoutSeconds,
|
|
RadiusServers: radiusServers,
|
|
TACACSServers: tacacsServers,
|
|
DefaultRole: normalizeRole(settings.DefaultRole),
|
|
}
|
|
if result.DefaultRole == "" {
|
|
result.DefaultRole = "viewer"
|
|
}
|
|
return result, validateExternalAuth(result)
|
|
}
|
|
|
|
func externalAuthServersFromDomain(servers []domain.ExternalAuthServer, kind string) ([]externalAuthServerConfig, error) {
|
|
if len(servers) > 4 {
|
|
return nil, fmt.Errorf("external auth %s server list must contain at most 4 entries", kind)
|
|
}
|
|
result := make([]externalAuthServerConfig, 0, len(servers))
|
|
seen := make(map[string]bool, len(servers))
|
|
for _, server := range servers {
|
|
config := externalAuthServerConfig{
|
|
ServerID: strings.ToLower(strings.TrimSpace(server.ServerID)),
|
|
Host: strings.TrimSpace(server.Host),
|
|
Port: server.Port,
|
|
SecretStored: server.SecretStored,
|
|
Enabled: server.Enabled,
|
|
}
|
|
if config.ServerID == "" {
|
|
return nil, fmt.Errorf("external auth %s server_id must not be empty", kind)
|
|
}
|
|
if seen[config.ServerID] {
|
|
return nil, fmt.Errorf("external auth %s server_id must be unique", kind)
|
|
}
|
|
seen[config.ServerID] = true
|
|
if err := validateExternalAuthServer(config, kind); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, config)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) prepareExternalAuthServersLocked(servers []domain.ExternalAuthServer, current []externalAuthServerConfig, kind string) ([]externalAuthServerConfig, error) {
|
|
currentByID := make(map[string]externalAuthServerConfig, len(current))
|
|
for _, server := range current {
|
|
currentByID[server.ServerID] = server
|
|
}
|
|
changes := make(map[string]*[]byte)
|
|
for index := range servers {
|
|
serverID := strings.ToLower(strings.TrimSpace(servers[index].ServerID))
|
|
key := externalAuthSecretKey("pending", kind, serverID)
|
|
if servers[index].ClearSecret {
|
|
changes[key] = nil
|
|
servers[index].SecretStored = false
|
|
} else if servers[index].Secret != "" {
|
|
value := []byte(servers[index].Secret)
|
|
changes[key] = &value
|
|
servers[index].SecretStored = true
|
|
} else if previous, ok := currentByID[serverID]; ok {
|
|
servers[index].SecretStored = s.secrets.Has(key)
|
|
if !servers[index].SecretStored && previous.SecretStored {
|
|
for _, phase := range []string{"running", "persistent"} {
|
|
if value, exists := s.secrets.Get(externalAuthSecretKey(phase, kind, serverID)); exists {
|
|
changes[key] = &value
|
|
servers[index].SecretStored = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
servers[index].Secret = ""
|
|
servers[index].ClearSecret = false
|
|
}
|
|
if len(changes) > 0 {
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return nil, fmt.Errorf("update protected external auth secret: %w", err)
|
|
}
|
|
}
|
|
return externalAuthServersFromDomain(servers, kind)
|
|
}
|
|
|
|
func externalAuthSecretKey(phase, kind, serverID string) string {
|
|
return fmt.Sprintf("external/%s/%s/%s", phase, kind, strings.ToLower(strings.TrimSpace(serverID)))
|
|
}
|
|
|
|
func configSecretKey(phase, suffix string) string { return "config/" + phase + "/" + suffix }
|
|
|
|
func (s *Store) preparePendingSecretLocked(suffix, value string, clear, stored bool) (bool, error) {
|
|
key := configSecretKey("pending", suffix)
|
|
if clear {
|
|
if err := s.secrets.Delete(key); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
if value != "" {
|
|
if err := s.secrets.Set(key, []byte(value)); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
if s.secrets.Has(key) {
|
|
return true, nil
|
|
}
|
|
if stored {
|
|
for _, phase := range []string{"running", "persistent"} {
|
|
if secret, ok := s.secrets.Get(configSecretKey(phase, suffix)); ok {
|
|
if err := s.secrets.Set(key, secret); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func notificationSecretSuffixes(email emailNotificationConfig, agent snmpAgentConfig, traps []snmpTrapTargetConfig) []string {
|
|
result := []string{"smtp/password", "snmp/agent/ro", "snmp/agent/rw"}
|
|
for _, user := range agent.V3Users {
|
|
result = append(result, "snmp/agent/v3/"+strings.ToLower(user.Username)+"/auth", "snmp/agent/v3/"+strings.ToLower(user.Username)+"/privacy")
|
|
}
|
|
for _, target := range traps {
|
|
prefix := "snmp/trap/" + strings.ToLower(target.TargetID)
|
|
result = append(result, prefix+"/credential", prefix+"/privacy")
|
|
}
|
|
return result
|
|
}
|
|
|
|
func snmpAgentSecretSuffixes(agent snmpAgentConfig) []string {
|
|
result := []string{"snmp/agent/ro", "snmp/agent/rw"}
|
|
for _, user := range agent.V3Users {
|
|
result = append(result, "snmp/agent/v3/"+strings.ToLower(user.Username)+"/auth", "snmp/agent/v3/"+strings.ToLower(user.Username)+"/privacy")
|
|
}
|
|
return result
|
|
}
|
|
|
|
func snmpTrapSecretSuffixes(traps []snmpTrapTargetConfig) []string {
|
|
result := make([]string, 0, len(traps)*2)
|
|
for _, target := range traps {
|
|
prefix := "snmp/trap/" + strings.ToLower(target.TargetID)
|
|
result = append(result, prefix+"/credential", prefix+"/privacy")
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Store) syncConfigSecretSuffixesLocked(source, destination string, suffixes []string) error {
|
|
changes := make(map[string]*[]byte)
|
|
for _, suffix := range suffixes {
|
|
value, ok := s.secrets.Get(configSecretKey(source, suffix))
|
|
key := configSecretKey(destination, suffix)
|
|
if !ok {
|
|
changes[key] = nil
|
|
} else {
|
|
changes[key] = &value
|
|
}
|
|
}
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return fmt.Errorf("synchronize protected configuration secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) syncNotificationSecretsLocked(source, destination string, email emailNotificationConfig, agent snmpAgentConfig, traps []snmpTrapTargetConfig) error {
|
|
changes := make(map[string]*[]byte)
|
|
for _, suffix := range notificationSecretSuffixes(email, agent, traps) {
|
|
value, ok := s.secrets.Get(configSecretKey(source, suffix))
|
|
key := configSecretKey(destination, suffix)
|
|
if !ok {
|
|
changes[key] = nil
|
|
} else {
|
|
changes[key] = &value
|
|
}
|
|
}
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return fmt.Errorf("synchronize notification secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A pending policy is a complete snapshot, including providers omitted from a
|
|
// partial update. Carry their unchanged secrets forward before apply/save clears
|
|
// the pending namespace. SecretStored=false represents an explicit clear and
|
|
// must never fall back to an older running or persistent credential.
|
|
func (s *Store) stageExternalAuthSecretsLocked(config externalAuthConfig) error {
|
|
changes := make(map[string]*[]byte)
|
|
for kind, servers := range map[string][]externalAuthServerConfig{"radius": config.RadiusServers, "tacacs": config.TACACSServers} {
|
|
for _, server := range servers {
|
|
key := externalAuthSecretKey("pending", kind, server.ServerID)
|
|
if !server.SecretStored {
|
|
changes[key] = nil
|
|
continue
|
|
}
|
|
var value []byte
|
|
var found bool
|
|
for _, phase := range []string{"pending", "running", "persistent"} {
|
|
if value, found = s.secrets.Get(externalAuthSecretKey(phase, kind, server.ServerID)); found {
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("external auth %s server %s shared secret is missing; re-enter the shared secret", kind, server.ServerID)
|
|
}
|
|
changes[key] = &value
|
|
}
|
|
}
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return fmt.Errorf("stage protected external auth secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) syncExternalAuthSecretsLocked(source, destination string, config externalAuthConfig) error {
|
|
changes := make(map[string]*[]byte)
|
|
for kind, servers := range map[string][]externalAuthServerConfig{"radius": config.RadiusServers, "tacacs": config.TACACSServers} {
|
|
for _, server := range servers {
|
|
destinationKey := externalAuthSecretKey(destination, kind, server.ServerID)
|
|
if !server.SecretStored {
|
|
changes[destinationKey] = nil
|
|
continue
|
|
}
|
|
value, ok := s.secrets.Get(externalAuthSecretKey(source, kind, server.ServerID))
|
|
if !ok {
|
|
return fmt.Errorf("external auth %s server %s shared secret is missing in %s; re-enter the shared secret", kind, server.ServerID, source)
|
|
}
|
|
changes[destinationKey] = &value
|
|
}
|
|
}
|
|
if err := s.secrets.Update(changes); err != nil {
|
|
return fmt.Errorf("synchronize protected external auth secrets: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateExternalAuth(settings externalAuthConfig) error {
|
|
if _, err := normalizeExternalAuthMethod(settings.PrimaryMethod); err != nil {
|
|
return err
|
|
}
|
|
if settings.TimeoutSeconds < 1 || settings.TimeoutSeconds > 30 {
|
|
return fmt.Errorf("external auth timeout_seconds must be between 1 and 30")
|
|
}
|
|
if normalizeRole(settings.DefaultRole) == "" {
|
|
return fmt.Errorf("external auth default_role must be administrator, port_operator, or viewer")
|
|
}
|
|
for _, server := range settings.RadiusServers {
|
|
if err := validateExternalAuthServer(server, "radius"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, server := range settings.TACACSServers {
|
|
if err := validateExternalAuthServer(server, "tacacs"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if settings.Enabled {
|
|
if !settings.LocalFallback {
|
|
return fmt.Errorf("external auth requires local fallback")
|
|
}
|
|
switch settings.PrimaryMethod {
|
|
case "radius":
|
|
if !hasEnabledExternalAuthServer(settings.RadiusServers) {
|
|
return fmt.Errorf("external auth radius mode requires at least one enabled radius server")
|
|
}
|
|
case "tacacs":
|
|
if !hasEnabledExternalAuthServer(settings.TACACSServers) {
|
|
return fmt.Errorf("external auth tacacs mode requires at least one enabled tacacs server")
|
|
}
|
|
default:
|
|
return fmt.Errorf("external auth enabled mode requires primary_method radius or tacacs")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateStoredExternalAuthRole(settings *externalAuthConfig) error {
|
|
if settings == nil {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(settings.DefaultRole) == "" {
|
|
// DefaultRole was added after external-auth transaction state was first
|
|
// persisted. A missing JSON field decodes to an empty string, so use the
|
|
// least-privileged current default during store-only upgrade migration.
|
|
settings.DefaultRole = "viewer"
|
|
return nil
|
|
}
|
|
role := normalizeRole(settings.DefaultRole)
|
|
if role == "" {
|
|
return fmt.Errorf("external auth default_role must be administrator, port_operator, or viewer")
|
|
}
|
|
settings.DefaultRole = role
|
|
return nil
|
|
}
|
|
|
|
func validateExternalAuthServer(server externalAuthServerConfig, kind string) error {
|
|
if strings.TrimSpace(server.Host) == "" || len(server.Host) > 253 {
|
|
return fmt.Errorf("external auth %s host must not be empty or longer than 253 characters", kind)
|
|
}
|
|
if server.Port < 1 || server.Port > 65535 {
|
|
return fmt.Errorf("external auth %s port must be between 1 and 65535", kind)
|
|
}
|
|
if server.Enabled && !server.SecretStored {
|
|
return fmt.Errorf("external auth %s enabled server requires secret_stored true", kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeExternalAuthMethod(method string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(method))
|
|
switch value {
|
|
case "local", "radius", "tacacs":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("external auth primary_method must be local, radius, or tacacs")
|
|
}
|
|
}
|
|
|
|
func hasEnabledExternalAuthServer(servers []externalAuthServerConfig) bool {
|
|
for _, server := range servers {
|
|
if server.Enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateRemoteSyslog(settings remoteSyslogConfig) error {
|
|
if settings.Port < 1 || settings.Port > 65535 {
|
|
return fmt.Errorf("remote syslog port must be between 1 and 65535")
|
|
}
|
|
if _, err := normalizeSyslogProtocol(settings.Protocol); err != nil {
|
|
return err
|
|
}
|
|
if _, err := normalizeSyslogFacility(settings.Facility); err != nil {
|
|
return err
|
|
}
|
|
host := strings.TrimSpace(settings.Host)
|
|
if settings.Enabled && host == "" {
|
|
return fmt.Errorf("remote syslog host must not be empty when enabled")
|
|
}
|
|
if len(host) > 128 {
|
|
return fmt.Errorf("remote syslog host must be 128 characters or fewer")
|
|
}
|
|
for _, char := range host {
|
|
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == '_' || char == ':' {
|
|
continue
|
|
}
|
|
return fmt.Errorf("remote syslog host contains invalid characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeDiagnosticsKind(kind string) (string, error) {
|
|
value := strings.ToLower(strings.TrimSpace(kind))
|
|
value = strings.ReplaceAll(value, "-", "_")
|
|
switch value {
|
|
case "network_test", "support_bundle":
|
|
return value, nil
|
|
default:
|
|
return "", fmt.Errorf("diagnostic kind not found")
|
|
}
|
|
}
|
|
|
|
func normalizeDiagnosticsTarget(kind, target string) (string, error) {
|
|
value := strings.TrimSpace(target)
|
|
if kind == "support_bundle" {
|
|
return "", nil
|
|
}
|
|
if value == "" {
|
|
return "", fmt.Errorf("diagnostic target must not be empty")
|
|
}
|
|
if len(value) > 128 {
|
|
return "", fmt.Errorf("diagnostic target must be 128 characters or fewer")
|
|
}
|
|
if strings.HasPrefix(value, "-") {
|
|
return "", fmt.Errorf("diagnostic target must not begin with a command option")
|
|
}
|
|
for _, char := range value {
|
|
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '.' || char == '-' || char == '_' || char == ':' {
|
|
continue
|
|
}
|
|
return "", fmt.Errorf("diagnostic target contains invalid characters")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDiagnosticsPort(kind string, port int) (int, error) {
|
|
if kind != diagnostics.KindTCPConnectionTest {
|
|
if port != 0 {
|
|
return 0, errors.New("diagnostic port is only valid for tcp_connection_test")
|
|
}
|
|
return 0, nil
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return 0, errors.New("diagnostic port must be between 1 and 65535 for tcp_connection_test")
|
|
}
|
|
return port, nil
|
|
}
|
|
|
|
func normalizeDiagnosticsTimeout(value int) (int, error) {
|
|
if value == 0 {
|
|
return defaultDiagnosticsTimeoutSeconds, nil
|
|
}
|
|
if value < 1 || value > maxDiagnosticsTimeoutSeconds {
|
|
return 0, fmt.Errorf("diagnostic timeout_seconds must be between 1 and %d", maxDiagnosticsTimeoutSeconds)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDiagnosticsCount(kind string, value int) (int, error) {
|
|
if kind != diagnostics.KindNetworkTest {
|
|
return 0, nil
|
|
}
|
|
if value == 0 {
|
|
return 4, nil
|
|
}
|
|
if value < 1 || value > 20 {
|
|
return 0, errors.New("diagnostic count must be between 1 and 20 for network_test")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDiagnosticsMaxHops(kind string, value int) (int, error) {
|
|
if kind != diagnostics.KindTraceroute {
|
|
if value != 0 {
|
|
return 0, errors.New("diagnostic max_hops is only valid for traceroute")
|
|
}
|
|
return 0, nil
|
|
}
|
|
if value == 0 {
|
|
return diagnostics.DefaultTracerouteMaxHops, nil
|
|
}
|
|
if value < 1 || value > diagnostics.MaximumTracerouteMaxHops {
|
|
return 0, fmt.Errorf("diagnostic max_hops must be between 1 and %d for traceroute", diagnostics.MaximumTracerouteMaxHops)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDiagnosticsMaxOutput(value int) (int, error) {
|
|
if value == 0 {
|
|
return defaultDiagnosticsMaxOutputBytes, nil
|
|
}
|
|
if value < 1024 || value > maxDiagnosticsMaxOutputBytes {
|
|
return 0, fmt.Errorf("diagnostic max_output_bytes must be between 1024 and %d", maxDiagnosticsMaxOutputBytes)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func normalizeDelivery(delivery []string) ([]string, error) {
|
|
if len(delivery) == 0 {
|
|
return nil, fmt.Errorf("delivery must include at least one target")
|
|
}
|
|
if len(delivery) > 5 {
|
|
return nil, fmt.Errorf("delivery must include at most five targets")
|
|
}
|
|
normalized := make([]string, 0, len(delivery))
|
|
seen := make(map[string]bool, len(delivery))
|
|
for _, item := range delivery {
|
|
value := strings.ToLower(strings.TrimSpace(item))
|
|
value = strings.ReplaceAll(value, "-", "_")
|
|
value = strings.ReplaceAll(value, " ", "_")
|
|
switch value {
|
|
case "web", "syslog", "snmp_trap", "email":
|
|
default:
|
|
return nil, fmt.Errorf("delivery target must be web, syslog, snmp_trap, or email")
|
|
}
|
|
if !seen[value] {
|
|
normalized = append(normalized, value)
|
|
seen[value] = true
|
|
}
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func normalizeAlarmDeliveryTestMessage(message string) string {
|
|
value := strings.TrimSpace(message)
|
|
if value == "" {
|
|
return "prototype alarm delivery test completed"
|
|
}
|
|
runes := []rune(value)
|
|
if len(runes) > 256 {
|
|
value = string(runes[:256])
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (s *Store) validateAlarmDeliveryTargetsLocked(delivery []string) error {
|
|
for _, target := range delivery {
|
|
switch target {
|
|
case "web":
|
|
case "syslog":
|
|
if !s.runningSyslog.Enabled || strings.TrimSpace(s.runningSyslog.Host) == "" {
|
|
return fmt.Errorf("syslog delivery test requires enabled remote syslog with host")
|
|
}
|
|
case "email":
|
|
if !s.runningEmail.Enabled || strings.TrimSpace(s.runningEmail.SMTPHost) == "" || strings.TrimSpace(s.runningEmail.From) == "" || len(s.runningEmail.To) == 0 {
|
|
return fmt.Errorf("email delivery test requires enabled email notification with smtp_host, from, and to")
|
|
}
|
|
case "snmp_trap":
|
|
if !hasEnabledSNMPTrapTarget(s.runningSNMPTraps) {
|
|
return fmt.Errorf("snmp_trap delivery test requires at least one enabled snmp trap target")
|
|
}
|
|
default:
|
|
return fmt.Errorf("delivery target must be web, syslog, snmp_trap, or email")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) matchPortKeywordRulesLocked(channel, content string) []string {
|
|
rules := make([]logpolicy.KeywordRule, 0)
|
|
for ruleID, rule := range s.runningAlarms {
|
|
if !rule.Enabled || rule.Source != "port_log" || rule.Trigger == "" {
|
|
continue
|
|
}
|
|
if rule.Channel != "" && rule.Channel != channel {
|
|
continue
|
|
}
|
|
rules = append(rules, logpolicy.KeywordRule{RuleID: ruleID, Keyword: rule.Trigger})
|
|
}
|
|
return logpolicy.MatchCommand(content, rules)
|
|
}
|
|
|
|
func (s *Store) matchLinkRulesLocked(event domain.LinkLogEvent) []string {
|
|
matches := make([]string, 0)
|
|
for ruleID, rule := range s.runningAlarms {
|
|
if !rule.Enabled {
|
|
continue
|
|
}
|
|
if rule.Channel != "" && rule.Channel != event.Resource {
|
|
continue
|
|
}
|
|
linkDown := rule.Source == event.Kind+"_link" && event.EventType == "transition" && event.PreviousState == "up" && event.State == "down"
|
|
signalChanged := event.Kind == "serial" && event.EventType == "transition" && rule.Source == "serial_"+event.Signal
|
|
if linkDown || signalChanged || s.matchesPrimaryNetworkFailureLocked(rule, event) {
|
|
matches = append(matches, ruleID)
|
|
}
|
|
}
|
|
sort.Strings(matches)
|
|
return matches
|
|
}
|
|
|
|
func (s *Store) matchesPrimaryNetworkFailureLocked(rule alarmRuleConfig, event domain.LinkLogEvent) bool {
|
|
if rule.Source != "network" || event.Kind != "ethernet" || event.State != "down" || event.EventType != "transition" {
|
|
return false
|
|
}
|
|
global, ok := s.runningNetwork["Interface-1"]
|
|
if !ok || event.Resource != global.OOBPrimaryInterface {
|
|
return false
|
|
}
|
|
return rule.Channel == "" || rule.Channel == event.Resource
|
|
}
|
|
|
|
func (s *Store) alarmRequestsForRulesLocked(ruleIDs []string, message string) []alarmdelivery.Request {
|
|
requests := make([]alarmdelivery.Request, 0, len(ruleIDs))
|
|
for _, ruleID := range ruleIDs {
|
|
rule, ok := s.runningAlarms[ruleID]
|
|
if !ok || !rule.Enabled {
|
|
continue
|
|
}
|
|
channels, syslogTarget, emailTarget, snmpTargets := s.availableDeliveryTargetsLocked(rule.Delivery, message)
|
|
if len(channels) == 0 {
|
|
continue
|
|
}
|
|
bounded, _ := logpolicy.Sanitize(message, 1024)
|
|
requests = append(requests, alarmdelivery.Request{
|
|
RuleID: ruleID, Message: bounded, Channels: channels,
|
|
RetryCount: s.runningAlarmDelivery.RetryCount,
|
|
RetryIntervalSeconds: s.runningAlarmDelivery.RetryIntervalSeconds,
|
|
QueueLimit: min(s.runningAlarmDelivery.PendingQueueLimit, 1000), Syslog: syslogTarget,
|
|
Email: emailTarget, SNMPTraps: snmpTargets,
|
|
})
|
|
}
|
|
return requests
|
|
}
|
|
|
|
func (s *Store) availableDeliveryTargetsLocked(configured []string, message string) ([]string, *alarmdelivery.SyslogTarget, *alarmdelivery.EmailTarget, []alarmdelivery.SNMPTrapTarget) {
|
|
channels := make([]string, 0, len(configured))
|
|
var syslogTarget *alarmdelivery.SyslogTarget
|
|
var emailTarget *alarmdelivery.EmailTarget
|
|
var snmpTargets []alarmdelivery.SNMPTrapTarget
|
|
for _, channel := range configured {
|
|
switch channel {
|
|
case alarmdelivery.ChannelWeb:
|
|
channels = append(channels, channel)
|
|
case alarmdelivery.ChannelSyslog:
|
|
if s.runningSyslog.Enabled && strings.TrimSpace(s.runningSyslog.Host) != "" {
|
|
channels = append(channels, channel)
|
|
syslogTarget = &alarmdelivery.SyslogTarget{
|
|
Host: s.runningSyslog.Host, Port: s.runningSyslog.Port,
|
|
Protocol: s.runningSyslog.Protocol, Facility: s.runningSyslog.Facility,
|
|
}
|
|
}
|
|
case alarmdelivery.ChannelEmail:
|
|
if s.runningEmail.Enabled {
|
|
channels = append(channels, channel)
|
|
emailTarget = &alarmdelivery.EmailTarget{
|
|
Host: s.runningEmail.SMTPHost, Port: s.runningEmail.SMTPPort,
|
|
STARTTLS: s.runningEmail.STARTTLSEnabled, Auth: s.runningEmail.AuthEnabled,
|
|
Username: s.runningEmail.Username, PasswordKey: configSecretKey("running", "smtp/password"),
|
|
From: s.runningEmail.From, To: append([]string(nil), s.runningEmail.To...),
|
|
CC: append([]string(nil), s.runningEmail.CC...), Subject: s.runningEmail.CustomTitle,
|
|
Content: strings.ReplaceAll(s.runningEmail.CustomContent, "{{message}}", message),
|
|
}
|
|
}
|
|
case alarmdelivery.ChannelSNMPTrap:
|
|
if hasEnabledSNMPTrapTarget(s.runningSNMPTraps) {
|
|
channels = append(channels, channel)
|
|
for _, configuredTarget := range s.runningSNMPTraps {
|
|
if !configuredTarget.Enabled {
|
|
continue
|
|
}
|
|
prefix := "snmp/trap/" + strings.ToLower(configuredTarget.TargetID)
|
|
snmpTargets = append(snmpTargets, alarmdelivery.SNMPTrapTarget{
|
|
TargetID: configuredTarget.TargetID, Host: configuredTarget.Host, Port: configuredTarget.Port,
|
|
Version: configuredTarget.Version, Username: configuredTarget.Username,
|
|
AuthProtocol: configuredTarget.AuthProtocol, PrivacyProtocol: configuredTarget.PrivacyProtocol,
|
|
EngineID: configuredTarget.EngineID, CredentialKey: configSecretKey("running", prefix+"/credential"),
|
|
PrivacyKeyRef: configSecretKey("running", prefix+"/privacy"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return channels, syslogTarget, emailTarget, snmpTargets
|
|
}
|
|
|
|
func (s *Store) remoteSyslogRequestLocked(logType, message string) *alarmdelivery.Request {
|
|
if !s.runningSyslog.Enabled || strings.TrimSpace(s.runningSyslog.Host) == "" || !s.remoteLogTypeEnabledLocked(logType) {
|
|
return nil
|
|
}
|
|
bounded, _ := logpolicy.Sanitize("["+logType+"] "+message, 1024)
|
|
if bounded == "" {
|
|
bounded = "[" + logType + "] log event"
|
|
}
|
|
return &alarmdelivery.Request{
|
|
RuleID: "log_forward_" + logType, Message: bounded, Channels: []string{alarmdelivery.ChannelSyslog},
|
|
RetryCount: s.runningAlarmDelivery.RetryCount,
|
|
RetryIntervalSeconds: s.runningAlarmDelivery.RetryIntervalSeconds,
|
|
QueueLimit: min(s.runningAlarmDelivery.PendingQueueLimit, 1000),
|
|
Syslog: &alarmdelivery.SyslogTarget{
|
|
Host: s.runningSyslog.Host, Port: s.runningSyslog.Port,
|
|
Protocol: s.runningSyslog.Protocol, Facility: s.runningSyslog.Facility,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Store) remoteLogTypeEnabledLocked(logType string) bool {
|
|
switch logType {
|
|
case "system":
|
|
return s.runningRetention.RemoteSystemEnabled
|
|
case "configuration":
|
|
return s.runningRetention.RemoteConfigurationEnabled
|
|
case "operation", "audit", "port":
|
|
return s.runningRetention.RemoteOperationEnabled
|
|
case "status", "link":
|
|
return s.runningRetention.RemoteStatusEnabled
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func auditEventLogType(action string) string {
|
|
action = strings.ToLower(strings.TrimSpace(action))
|
|
if strings.HasPrefix(action, "config.") || strings.Contains(action, ".update") || strings.Contains(action, ".create") || strings.Contains(action, ".delete") || strings.Contains(action, ".restore") {
|
|
return "configuration"
|
|
}
|
|
return "operation"
|
|
}
|
|
|
|
func (s *Store) deliverBestEffort(ctx context.Context, request *alarmdelivery.Request) {
|
|
if request == nil {
|
|
return
|
|
}
|
|
_, _ = s.alarmDeliveryController.Deliver(ctx, *request)
|
|
}
|
|
|
|
func hasEnabledSNMPTrapTarget(targets []snmpTrapTargetConfig) bool {
|
|
for _, target := range targets {
|
|
if target.Enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sortedAlarmRuleIDs(rules map[string]alarmRuleConfig) []string {
|
|
ruleIDs := make([]string, 0, len(rules))
|
|
for ruleID := range rules {
|
|
ruleIDs = append(ruleIDs, ruleID)
|
|
}
|
|
sort.Strings(ruleIDs)
|
|
return ruleIDs
|
|
}
|
|
|
|
func sortedOperationIDs(operations map[string]domain.Operation) []string {
|
|
operationIDs := make([]string, 0, len(operations))
|
|
for operationID := range operations {
|
|
operationIDs = append(operationIDs, operationID)
|
|
}
|
|
sort.Strings(operationIDs)
|
|
return operationIDs
|
|
}
|
|
|
|
func directSessionID(channel string, sequence int) string {
|
|
return fmt.Sprintf("direct_%s_%06d", channel, sequence)
|
|
}
|
|
|
|
func directSession(channel, actor, access string, state directState, message, auditID string, ticket directstream.IssuedTicket) domain.DirectSession {
|
|
sessionID := directSessionID(channel, state.Sequence)
|
|
return directSessionWithID(sessionID, channel, actor, access, state, message, auditID, ticket)
|
|
}
|
|
|
|
func directSessionWithID(sessionID, channel, actor, access string, state directState, message, auditID string, ticket directstream.IssuedTicket) domain.DirectSession {
|
|
expiresAt := ticket.ExpiresAt
|
|
return domain.DirectSession{
|
|
SessionID: sessionID,
|
|
Channel: channel,
|
|
Actor: actor,
|
|
Access: access,
|
|
ViewerCount: state.ViewerCount,
|
|
MaxViewers: state.MaxViewers,
|
|
Writer: state.Writer,
|
|
State: "open",
|
|
Message: message,
|
|
AuditID: auditID,
|
|
StreamURL: fmt.Sprintf("/api/v1/direct/%s/sessions/%s/stream", channel, sessionID),
|
|
StreamProtocol: directstream.Subprotocol,
|
|
StreamTicket: ticket.Value,
|
|
TicketExpiresAt: &expiresAt,
|
|
}
|
|
}
|
|
|
|
func directLifecycleSession(sessionID, channel, actor, access string, state directState, lifecycleState, message, auditID string) domain.DirectSession {
|
|
return domain.DirectSession{
|
|
SessionID: sessionID,
|
|
Channel: channel,
|
|
Actor: actor,
|
|
Access: access,
|
|
ViewerCount: state.ViewerCount,
|
|
MaxViewers: state.MaxViewers,
|
|
Writer: state.Writer,
|
|
State: lifecycleState,
|
|
Message: message,
|
|
AuditID: auditID,
|
|
}
|
|
}
|
|
|
|
func (s *Store) firstDirectSessionLocked(channel, actor string) (directSessionRecord, bool) {
|
|
var selected directSessionRecord
|
|
found := false
|
|
for _, record := range s.directSessions {
|
|
if record.Channel != channel || record.Actor != actor {
|
|
continue
|
|
}
|
|
if !found || record.SessionID < selected.SessionID {
|
|
selected = record
|
|
found = true
|
|
}
|
|
}
|
|
return selected, found
|
|
}
|
|
|
|
func samplePortLogEvents(now time.Time) []domain.PortLogEvent {
|
|
return []domain.PortLogEvent{
|
|
{
|
|
Timestamp: now.Add(-61 * time.Second),
|
|
Channel: "CH01",
|
|
EventType: "command",
|
|
Direction: "RX",
|
|
Actor: "admin/session-82",
|
|
Command: "show LOGIN FAILED counters",
|
|
ReturnValue: "authentication failure count: 5",
|
|
Message: "command completed",
|
|
Match: "keyword",
|
|
KeywordMatches: []string{"port_ch01_keyword"},
|
|
AlarmTriggered: true,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Channel: "CH07",
|
|
EventType: "command",
|
|
Direction: "TX",
|
|
Actor: "ops/session-77",
|
|
Command: "show running-config",
|
|
ReturnValue: "configuration returned (bounded sample)",
|
|
Message: "command completed",
|
|
Match: "port",
|
|
},
|
|
{
|
|
Timestamp: now.Add(-14 * time.Minute),
|
|
Channel: "CH19",
|
|
EventType: "traffic",
|
|
Direction: "RX",
|
|
Actor: "viewer/session-71",
|
|
Message: "128 bytes received",
|
|
Match: "port",
|
|
},
|
|
}
|
|
}
|
|
|
|
func sampleLinkLogEvents(now time.Time) []domain.LinkLogEvent {
|
|
return []domain.LinkLogEvent{
|
|
{Timestamp: now.Add(-5 * time.Minute), Kind: "serial", Resource: "CH01", State: "up", Signal: "dcd", EventType: "snapshot", Message: "serial CH01 link up"},
|
|
{Timestamp: now.Add(-2 * time.Minute), Kind: "ethernet", Resource: "Interface-1", State: "down", PreviousState: "up", Signal: "carrier", EventType: "transition", AlarmTriggered: true, MatchedRuleIDs: []string{"ethernet_link_down"}, Message: "ethernet Interface-1 link down"},
|
|
}
|
|
}
|
|
|
|
func sampleSystemLogEvents(now time.Time) []domain.SystemLogEvent {
|
|
return []domain.SystemLogEvent{
|
|
{
|
|
Timestamp: now.Add(-45 * time.Second),
|
|
Category: "configuration",
|
|
Severity: "info",
|
|
Source: "tera-api",
|
|
Message: "configuration apply completed",
|
|
},
|
|
{
|
|
Timestamp: now.Add(-3 * time.Minute),
|
|
Category: "status",
|
|
Severity: "warning",
|
|
Source: "network",
|
|
Message: "primary link degraded; monitoring failover path",
|
|
},
|
|
{
|
|
Timestamp: now.Add(-12 * time.Minute),
|
|
Category: "system",
|
|
Severity: "critical",
|
|
Source: "storage",
|
|
Message: "protected log usage exceeded threshold",
|
|
},
|
|
}
|
|
}
|
|
|
|
func channelName(index int) string {
|
|
return fmt.Sprintf("CH%02d", index)
|
|
}
|
|
|
|
func parseChannel(channel string) (int, error) {
|
|
value := strings.ToUpper(strings.TrimSpace(channel))
|
|
if !strings.HasPrefix(value, "CH") || len(value) != 4 {
|
|
return 0, fmt.Errorf("channel must be CH01 through CH48")
|
|
}
|
|
index, err := strconv.Atoi(value[2:])
|
|
if err != nil || index < 1 || index > 48 {
|
|
return 0, fmt.Errorf("channel must be CH01 through CH48")
|
|
}
|
|
return index, nil
|
|
}
|
|
|