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 { // StartupTiming observes synchronous startup stages; callbacks must return promptly. StartupTiming func(stage string, elapsed time.Duration, err error) 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 FactoryResetCleanup func() error } 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 { lastSyslogResult alarmdelivery.Result lastSyslogError string browserTimeSynced bool // Runtime only: first authorized browser login after service start. factoryResetCleanup func() error resetGate sync.RWMutex resetComplete bool 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 linkClearRevision uint64 systemClearRevision uint64 journalLocalSequences map[string]map[uint64]struct{} storagePressureActive bool portKeywordCursor uint64 portKeywordStarted bool portKeywordTails map[string]portKeywordTail 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, startupErr error) { started := time.Now() defer func() { if options.StartupTiming != nil { options.StartupTiming("store.total", time.Since(started), startupErr) } }() measure := func(name string, work func() error) error { start := time.Now() err := work() if options.StartupTiming != nil { options.StartupTiming(name, time.Since(start), err) } return err } passwordKDF := storePasswordKDF(options.PasswordKDF) passwordKDFs := storePasswordKDFs(options.PasswordKDFs, options.PasswordKDF) 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, factoryResetCleanup: options.FactoryResetCleanup, 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, // Bootstrap accounts are generated only if the auth store has no inventory. authUsers: nil, 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 := measure("config.load", store.loadConfigStore); err != nil { return nil, err } if err := measure("config.persist", store.persistConfigStoreLocked); err != nil { return nil, err } } if options.OperationPath != "" { if err := measure("operations.load", store.loadOperationStore); err != nil { return nil, err } if err := measure("operations.persist", store.persistOperationStoreLocked); err != nil { return nil, err } } if options.AuthPath != "" { if err := measure("auth.load", store.loadAuthStore); err != nil { return nil, err } } // nil means missing/empty storage; a persisted empty inventory stays empty. if store.authUsers == nil { if err := measure("auth.bootstrap", func() error { var err error if options.RemoveLegacyAuthUsers { store.authUsers, err = factoryAuthUsers(passwordKDF) } else { store.authUsers, err = defaultAuthUsers(passwordKDF) } return err }); err != nil { return nil, err } } if options.SessionPath != "" { if err := measure("sessions.load", store.loadSessionStore); err != nil { return nil, err } } if options.LoginFailurePath != "" { if err := measure("login_failures.load", store.loadLoginFailureStore); err != nil { return nil, err } } if options.RemoveLegacyAuthUsers { store.removeLegacyAuthUsersLocked() } // Persist once, after migration and legacy-account cleanup. if err := measure("auth.persist", store.persistAuthStoreLocked); err != nil { return nil, err } if err := measure("sessions.persist", store.persistSessionStoreLocked); err != nil { return nil, err } if err := measure("login_failures.persist", store.persistLoginFailureStoreLocked); err != nil { return nil, err } if options.LogJournal != nil { if err := measure("logs.retention", func() error { return store.applyJournalRetentionLocked(store.runningRetention) }); err != nil { return nil, err } if err := measure("logs.journal.load", store.loadJournalLocked); err != nil { return nil, err } } else if options.AuditPath != "" { if err := measure("logs.audit.load", store.loadAuditLog); err != nil { return nil, err } if err := measure("logs.audit.persist", store.persistAuditLogLocked); err != nil { return nil, err } } if options.SystemLogPath != "" { if err := measure("logs.system.load", store.loadSystemLogStore); err != nil { return nil, err } } if options.PortLogPath != "" { if err := measure("logs.port.load", store.loadPortLogStore); err != nil { return nil, err } } if options.LinkLogPath != "" { if err := measure("logs.link.load", 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 := measure("operations.persist", 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 !s.browserTimeSynced && !request.BrowserTime.IsZero() && actor.Permissions["time.sync"] { before := s.clock().UTC() result, syncErr := s.timeController.Sync(ctx, systemtime.Request{BrowserTime: request.BrowserTime, Timezone: s.runningTime.Timezone}) outcome, message := "failed", "automatic PC time synchronization failed; retry on next administrator login" if syncErr == nil { s.browserTimeSynced = true now = s.clock().UTC() // Mint the new session after changing the clock. outcome = "succeeded" message = fmt.Sprintf("automatic PC time synchronization: %s -> %s", before.Format(time.RFC3339), result.AppliedTime.UTC().Format(time.RFC3339)) } s.recordAuditEventLocked(enrichAuditEvent(ctx, domain.AuditEvent{Actor: actor.Name, Action: "time.sync_browser", Target: "time", Result: outcome, Message: message})) } 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) } if err := s.evaluateReceivedPortKeywords(ctx, snapshot); err != nil { return 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 = false 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 !logPortVisible(ctx, event.Channel) { continue } 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 + " " + string(event.Payload)) 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 { if *update.PreserveOnFactoryReset { return domain.CommandResult{}, s.rejectCommandLocked(ctx, "log.retention.update", "logs", errors.New("preserve_on_factory_reset must be false: factory reset erases all logs")) } next.PreserveOnFactoryReset = false } 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) { if _, err := logExportFormat(request.Format); err != nil { return normalizedLogExportRequest{}, err } 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(ctx, 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 } if err := writeLogExport(writer, request.Format, rows); err != nil { return operation, err } return operation, nil } func (s *Store) exportLogRows(ctx context.Context, 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 !logPortVisible(ctx, event.Channel) { continue } 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+" "+string(event.Payload)) } 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 syslogTarget != nil { s.rememberSyslog(deliveryResult, err) } 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) 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() metadata, err := s.tlsCertificates.Activate(certificateID) if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if s.runningTLS.ActiveCertificateID == certificateID { updated := tlsCertificateConfigFromMetadata(metadata) if !equalTLSCertificate(s.runningTLS, updated) { s.runningTLS = updated if s.persistentTLS.ActiveCertificateID == certificateID { s.persistentTLS = updated } if err := s.persistConfigStoreLocked(); err != nil { return err } } } return nil } 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 { if err := s.refreshSystemClearLocked(); err != nil { return err } 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) refreshSystemClearLocked() error { if cleaner, ok := s.logJournal.(logservice.SystemLogCleaner); ok { revision, err := cleaner.SystemClearRevision() if err != nil { return err } if revision != s.systemClearRevision { s.auditEvents = nil s.journalSequence[logstorage.ClassAudit] = 0 delete(s.journalLocalSequences, logstorage.ClassAudit) delete(s.journalLast, logstorage.ClassAudit) s.systemLogEvents = nil s.journalSequence[logstorage.ClassSystem] = 0 delete(s.journalLocalSequences, logstorage.ClassSystem) delete(s.journalLast, logstorage.ClassSystem) s.systemClearRevision = revision } } return nil } func (s *Store) refreshSystemJournalLocked() error { if err := s.refreshSystemClearLocked(); err != nil { return err } 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 { if cleaner, ok := s.logJournal.(logservice.LinkLogCleaner); ok { revision, err := cleaner.LinkClearRevision() if err != nil { return err } if revision != s.linkClearRevision { s.linkLogEvents = nil s.journalSequence[logstorage.ClassLink] = 0 delete(s.journalLocalSequences, logstorage.ClassLink) delete(s.journalLast, logstorage.ClassLink) s.linkClearRevision = revision } } 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") } // A persisted account inventory replaces bootstrap users, never merges them. s.authUsers = make(map[string]authUserRecord) s.authGroupOverrides = make(map[string]authGroupRecord) s.authTokens = make(map[string]authTokenRecord) 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: "Erase device data, restore terauser, and reboot", 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: 9600, 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: false, 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: true, 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: false, 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.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: false, 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 } result, err := s.alarmDeliveryController.Deliver(ctx, *request) if request.Syslog != nil { s.rememberSyslog(result, err) } } 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 }