package api import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "slices" "strconv" "strings" "tera-clone/services/tera-api/internal/startup" "time" "tera-clone/services/tera-api/internal/alarmdelivery" "tera-clone/services/tera-api/internal/authcaptcha" "tera-clone/services/tera-api/internal/domain" "tera-clone/services/tera-api/internal/hardware/greenled" "tera-clone/services/tera-api/internal/networkconfig" "tera-clone/services/tera-api/internal/portservice" "tera-clone/services/tera-api/internal/serial" "tera-clone/services/tera-api/internal/sessionhealth" "tera-clone/services/tera-api/internal/systemmetrics" "tera-clone/services/tera-api/internal/systemtime" "tera-clone/services/tera-api/internal/update" ) type Router struct { clockBootstrap *startup.ClockBootstrap store domain.Store mux *http.ServeMux corsAllowAll bool corsAllowlist map[string]struct{} maxRequestBytes int64 disableMockAuth bool greenLEDs greenled.Controller serialPorts serial.Controller portServices portservice.Controller sessions sessionhealth.Controller network networkconfig.Controller firmware update.Controller systemMetrics systemmetrics.Reader maxFirmwareBytes int64 firmwareTimeout time.Duration captcha authcaptcha.Service commandsReady func() bool startupStatus func() startup.Status serialRuntime serialRuntimeCache } type commandSpec struct { Action string Target string Permission string } type RouterOptions struct { ClockBootstrap *startup.ClockBootstrap CORSAllowedOrigins []string MaxRequestBytes int64 DisableMockActors bool GreenLEDController greenled.Controller SerialController serial.Controller PortServiceController portservice.Controller SessionController sessionhealth.Controller NetworkController networkconfig.Controller FirmwareController update.Controller SystemMetricsReader systemmetrics.Reader MaxFirmwareBytes int64 FirmwareTimeout time.Duration CaptchaService authcaptcha.Service CommandsReady func() bool StartupStatus func() startup.Status } const ( defaultMaxRequestBytes int64 = 1 << 20 defaultLogLimit = 100 maxLogLimit = 500 defaultMaxFirmwareBytes int64 = 1280 << 20 defaultFirmwareTimeout = 30 * time.Minute failedLoginSafetyThreshold = 5 failedLoginSafetyMinutes = 15 serialApplyLeaseRetryInterval = 25 * time.Millisecond serialApplyLeaseRetryCount = 20 ) func NewRouter(store domain.Store) http.Handler { return NewRouterWithOptions(store, RouterOptions{ MaxRequestBytes: defaultMaxRequestBytes, SerialController: serial.MockController{}, }) } func NewRouterWithOptions(store domain.Store, options RouterOptions) http.Handler { greenLEDController := options.GreenLEDController if greenLEDController == nil { greenLEDController = greenled.UnavailableController{Reason: "tera-hwd is not connected"} } serialController := options.SerialController if serialController == nil { serialController = serial.UnavailableController{Reason: "tera-seriald is not connected"} } portServiceController := options.PortServiceController if portServiceController == nil { portServiceController = portservice.NoopController{} } sessionController := options.SessionController if sessionController == nil { sessionController = sessionhealth.UnavailableController{Reason: "tera-sessiond status is not connected"} } networkController := options.NetworkController if networkController == nil { networkController = &networkconfig.MockController{} } firmwareController := options.FirmwareController if firmwareController == nil { firmwareController = update.UnavailableController{Reason: "tera-updated is not connected"} } systemMetricsReader := options.SystemMetricsReader if systemMetricsReader == nil { systemMetricsReader = systemmetrics.UnavailableReader{} } maxFirmwareBytes := options.MaxFirmwareBytes if maxFirmwareBytes <= 0 { maxFirmwareBytes = defaultMaxFirmwareBytes } firmwareTimeout := options.FirmwareTimeout if firmwareTimeout <= 0 { firmwareTimeout = defaultFirmwareTimeout } captchaService := options.CaptchaService if captchaService == nil { captchaService = authcaptcha.New(authcaptcha.Options{}) } router := &Router{ store: store, mux: http.NewServeMux(), corsAllowlist: make(map[string]struct{}), maxRequestBytes: normalizeMaxRequestBytes(options.MaxRequestBytes), disableMockAuth: options.DisableMockActors, greenLEDs: greenLEDController, serialPorts: serialController, portServices: portServiceController, sessions: sessionController, network: networkController, firmware: firmwareController, systemMetrics: systemMetricsReader, maxFirmwareBytes: maxFirmwareBytes, firmwareTimeout: firmwareTimeout, captcha: captchaService, commandsReady: options.CommandsReady, startupStatus: options.StartupStatus, clockBootstrap: options.ClockBootstrap, } for _, origin := range options.CORSAllowedOrigins { origin = strings.TrimSpace(origin) if origin == "" { continue } if origin == "*" { router.corsAllowAll = true router.corsAllowlist = nil break } router.corsAllowlist[origin] = struct{}{} } router.routes() return router } func normalizeMaxRequestBytes(value int64) int64 { if value > 0 { return value } return defaultMaxRequestBytes } func managementClientForRequest(req *http.Request) (string, string, int) { address := req.RemoteAddr if host, _, err := net.SplitHostPort(address); err == nil { address = host } protocol := "http" destinationPort := 80 if req.TLS != nil { protocol, destinationPort = "https", 443 } if _, portText, err := net.SplitHostPort(req.Host); err == nil { if port, parseErr := strconv.Atoi(portText); parseErr == nil && port >= 1 && port <= 65535 { destinationPort = port } } // The browser-facing tera-web process is the only trusted source of these // headers. Remote clients cannot spoof them because they are accepted only // when the API connection itself originates from loopback. if remoteIP := net.ParseIP(strings.Trim(address, "[]")); remoteIP != nil && remoteIP.IsLoopback() { forwardedIP := strings.TrimSpace(req.Header.Get("X-Tera-Client-IP")) if parsed := net.ParseIP(strings.Trim(forwardedIP, "[]")); parsed != nil { address = parsed.String() } forwardedProtocol := strings.ToLower(strings.TrimSpace(req.Header.Get("X-Tera-Client-Protocol"))) if forwardedProtocol == "http" || forwardedProtocol == "https" { protocol = forwardedProtocol } if forwardedPort, err := strconv.Atoi(strings.TrimSpace(req.Header.Get("X-Tera-Client-Port"))); err == nil && forwardedPort >= 1 && forwardedPort <= 65535 { destinationPort = forwardedPort } } return address, protocol, destinationPort } func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead && req.URL.Path != "/api/v1/system/factory-reset" { if gate, ok := r.store.(interface{ BeginMutation() (func(), bool) }); ok { release, accepted := gate.BeginMutation() if !accepted { writeError(w, http.StatusServiceUnavailable, "factory_reset_rebooting", "factory reset completed; device is rebooting") return } defer release() } } req = req.WithContext(domain.WithCommandMetadata(req.Context(), domain.CommandMetadata{ Surface: "web_api", Command: req.Method + " " + req.URL.Path, })) if authorizer, ok := r.store.(domain.ClientAddressAuthorizer); ok { address, protocol, destinationPort := managementClientForRequest(req) if err := authorizer.AuthorizeClient(req.Context(), address, protocol, destinationPort); err != nil { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{Actor: "anonymous", Action: "auth.address_filter", Target: address, Result: "authorization_denied", Message: err.Error()}) writeError(w, http.StatusForbidden, "address_filter_denied", err.Error()) return } } origin := req.Header.Get("Origin") corsAllowed := r.applyCORSHeaders(w, origin) if req.Method == http.MethodOptions { if origin != "" && !corsAllowed { writeError(w, http.StatusForbidden, "cors_origin_forbidden", "CORS origin is not allowed") return } w.WriteHeader(http.StatusNoContent) return } if r.commandsReady != nil && !r.commandsReady() && !commandAllowedDuringStartup(req) { w.Header().Set("Retry-After", "1") writeError(w, http.StatusServiceUnavailable, "service_initializing", "device runtime synchronization is still in progress") return } if req.Method == http.MethodPost && req.URL.Path == "/api/v1/firmware/packages" { deadline := time.Now().Add(r.firmwareTimeout) controller := http.NewResponseController(w) _ = controller.SetReadDeadline(deadline) _ = controller.SetWriteDeadline(deadline) req.Body = http.MaxBytesReader(w, req.Body, r.maxFirmwareBytes) } else { req.Body = http.MaxBytesReader(w, req.Body, r.maxRequestBytes) } r.mux.ServeHTTP(w, req) } func commandAllowedDuringStartup(req *http.Request) bool { switch req.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } if req.Method != http.MethodPost { return false } switch req.URL.Path { case "/api/v1/auth/logout", "/api/v1/auth/bootstrap-time": return true default: return false } } func decodeJSON(w http.ResponseWriter, req *http.Request, target any) bool { ok, _ := decodeJSONBody(w, req, target, false) return ok } func decodeOptionalJSON(w http.ResponseWriter, req *http.Request, target any) bool { ok, _ := decodeJSONBody(w, req, target, true) return ok } func decodeJSONBody(w http.ResponseWriter, req *http.Request, target any, allowEmpty bool) (bool, string) { decoder := json.NewDecoder(req.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { if errors.Is(err, io.EOF) { if allowEmpty { return true, "" } message := writeJSONDecodeError(w, err) return false, message } message := writeJSONDecodeError(w, err) return false, message } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { if err != nil { message := writeJSONDecodeError(w, err) return false, message } message := "request body must contain a single JSON value" writeError(w, http.StatusBadRequest, "validation_failed", message) return false, message } return true, "" } func writeJSONDecodeError(w http.ResponseWriter, err error) string { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { message := "request body exceeds configured size limit" writeError(w, http.StatusRequestEntityTooLarge, "payload_too_large", message) return message } message := "invalid JSON body" writeError(w, http.StatusBadRequest, "validation_failed", message) return message } func (r *Router) decodeCommandJSON(w http.ResponseWriter, req *http.Request, ctx context.Context, spec commandSpec, target any) bool { ok, message := decodeJSONBody(w, req, target, false) if !ok { r.recordRejectedCommand(ctx, spec, message) } return ok } func (r *Router) decodeConfigRestoreJSON(w http.ResponseWriter, req *http.Request, ctx context.Context, spec commandSpec, target any) bool { ok, message := decodeConfigRestoreJSONBody(w, req, target) if !ok { r.recordRejectedCommand(ctx, spec, message) } return ok } func (r *Router) decodeOptionalCommandJSON(w http.ResponseWriter, req *http.Request, ctx context.Context, spec commandSpec, target any) bool { ok, message := decodeJSONBody(w, req, target, true) if !ok { r.recordRejectedCommand(ctx, spec, message) } return ok } func decodeConfigRestoreJSONBody(w http.ResponseWriter, req *http.Request, target any) (bool, string) { body, err := io.ReadAll(req.Body) if err != nil { message := writeJSONDecodeError(w, err) return false, message } var raw any if err := json.Unmarshal(body, &raw); err != nil { message := writeJSONDecodeError(w, err) return false, message } if key, ok := findConfigRestoreSecretField(raw); ok { message := "config restore must not include secret material field " + strconv.Quote(key) writeError(w, http.StatusBadRequest, "validation_failed", message) return false, message } decoder := json.NewDecoder(strings.NewReader(string(body))) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { message := writeJSONDecodeError(w, err) return false, message } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { message := writeJSONDecodeError(w, err) return false, message } return true, "" } func findConfigRestoreSecretField(value any) (string, bool) { switch typed := value.(type) { case map[string]any: for key, child := range typed { if isConfigRestoreSecretField(key) { return key, true } if found, ok := findConfigRestoreSecretField(child); ok { return found, true } } case []any: for _, child := range typed { if found, ok := findConfigRestoreSecretField(child); ok { return found, true } } } return "", false } func isConfigRestoreSecretField(key string) bool { normalized := strings.ToLower(strings.TrimSpace(key)) switch normalized { case "password_stored", "secret_stored", "credential_stored", "private_key_stored", "auth_key_stored", "privacy_key_stored", "read_only_community_stored": return false case "password", "password_hash", "password_value", "smtp_password", "secret", "secret_value", "shared_secret", "credential", "credential_value", "private_key", "private_key_pem", "community_value", "auth_key", "auth_secret", "privacy_key", "privacy_secret": return true default: return strings.Contains(normalized, "password") || strings.Contains(normalized, "shared_secret") || strings.Contains(normalized, "private_key") || strings.HasSuffix(normalized, "_secret") } } func (r *Router) recordRejectedCommand(ctx context.Context, spec commandSpec, message string) { _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{ Action: spec.Action, Target: spec.Target, Result: "rejected", Message: message, }) } func (r *Router) applyCORSHeaders(w http.ResponseWriter, origin string) bool { if r.corsAllowAll { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", r.corsAllowedHeaders()) return true } if origin == "" { return true } if _, ok := r.corsAllowlist[origin]; !ok { return false } w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", r.corsAllowedHeaders()) return true } func (r *Router) corsAllowedHeaders() string { if r.disableMockAuth { return "Authorization, Content-Type, X-Tera-Filename" } return "Authorization, Content-Type, X-Mock-Actor, X-Tera-Filename" } func (r *Router) routes() { r.mux.HandleFunc("GET /api/v1/system/status", r.handleSystemStatus) r.mux.HandleFunc("GET /api/v1/system/services", r.handleServiceHealth) r.mux.HandleFunc("POST /api/v1/system/reboot", r.handleSystemReboot) r.mux.HandleFunc("POST /api/v1/system/factory-reset", r.handleFactoryReset) r.mux.HandleFunc("POST /api/v1/auth/login", r.handleAuthLogin) r.mux.HandleFunc("GET /api/v1/auth/login-policy", r.handleAuthLoginPolicy) r.mux.HandleFunc("GET /api/v1/auth/startup", r.handleStartupStatus) r.mux.HandleFunc("POST /api/v1/auth/bootstrap-time", r.handleBootstrapTime) r.mux.HandleFunc("POST /api/v1/auth/captcha", r.handleAuthCaptcha) r.mux.HandleFunc("POST /api/v1/auth/logout", r.handleAuthLogout) r.mux.HandleFunc("GET /api/v1/auth/me", r.handleAuthMe) r.mux.HandleFunc("PATCH /api/v1/auth/password", r.handleOwnPassword) r.mux.HandleFunc("GET /api/v1/auth/sessions", r.handleAuthSessions) r.mux.HandleFunc("DELETE /api/v1/auth/sessions/", r.handleAuthSessionSubtree) r.mux.HandleFunc("GET /api/v1/auth/tokens", r.handleAuthTokens) r.mux.HandleFunc("POST /api/v1/auth/tokens", r.handleCreateAuthToken) r.mux.HandleFunc("DELETE /api/v1/auth/tokens/", r.handleAuthTokenSubtree) r.mux.HandleFunc("GET /api/v1/auth/users", r.handleAuthUsers) r.mux.HandleFunc("POST /api/v1/auth/users", r.handleCreateAuthUser) r.mux.HandleFunc("PATCH /api/v1/auth/users/", r.handleAuthUserSubtree) r.mux.HandleFunc("DELETE /api/v1/auth/users/", r.handleAuthUserSubtree) r.mux.HandleFunc("GET /api/v1/auth/external", r.handleExternalAuth) r.mux.HandleFunc("PATCH /api/v1/auth/external", r.handleUpdateExternalAuth) r.mux.HandleFunc("GET /api/v1/auth/groups", r.handleAuthGroups) r.mux.HandleFunc("POST /api/v1/auth/groups", r.handleCreateAuthGroup) r.mux.HandleFunc("PATCH /api/v1/auth/groups/", r.handleAuthGroupSubtree) r.mux.HandleFunc("DELETE /api/v1/auth/groups/", r.handleAuthGroupSubtree) r.mux.HandleFunc("GET /api/v1/auth/permissions", r.handleAuthPermissions) r.mux.HandleFunc("GET /api/v1/network", r.handleNetwork) r.mux.HandleFunc("PATCH /api/v1/network/running", r.handleUpdateNetwork) r.mux.HandleFunc("POST /api/v1/network/apply", r.handleApplyNetwork) r.mux.HandleFunc("GET /api/v1/network/apply-status", r.handleNetworkApplyStatus) r.mux.HandleFunc("POST /api/v1/network/confirm", r.handleConfirmNetwork) r.mux.HandleFunc("POST /api/v1/network/rollback", r.handleRollbackNetwork) r.mux.HandleFunc("GET /api/v1/snmp/agent", r.handleSNMPAgent) r.mux.HandleFunc("PATCH /api/v1/snmp/agent", r.handleUpdateSNMPAgent) r.mux.HandleFunc("GET /api/v1/time/settings", r.handleTimeSettings) r.mux.HandleFunc("PATCH /api/v1/time/settings", r.handleUpdateTimeSettings) r.mux.HandleFunc("GET /api/v1/security/management-access", r.handleManagementAccess) r.mux.HandleFunc("PATCH /api/v1/security/management-access", r.handleUpdateManagementAccess) r.mux.HandleFunc("GET /api/v1/security/tls-certificate", r.handleTLSCertificate) r.mux.HandleFunc("PATCH /api/v1/security/tls-certificate", r.handleUpdateTLSCertificate) r.mux.HandleFunc("GET /api/v1/security/tls-certificates", r.handleTLSCertificates) r.mux.HandleFunc("POST /api/v1/security/tls-certificates", r.handleCreateTLSCertificate) r.mux.HandleFunc("POST /api/v1/security/tls-certificates/import", r.handleImportTLSCertificate) r.mux.HandleFunc("POST /api/v1/security/tls-certificates/", r.handleTLSCertificateSubtree) r.mux.HandleFunc("DELETE /api/v1/security/tls-certificates/", r.handleTLSCertificateSubtree) r.mux.HandleFunc("GET /api/v1/security/address-filters", r.handleAddressFilters) r.mux.HandleFunc("PUT /api/v1/security/address-filters", r.handleUpdateAddressFilters) r.mux.HandleFunc("GET /api/v1/ports/runtime-status", r.handlePortRuntimeStatus) r.mux.HandleFunc("GET /api/v1/ports", r.handlePorts) r.mux.HandleFunc("PATCH /api/v1/ports", r.handlePorts) r.mux.HandleFunc("POST /api/v1/ports/restart", r.handleBulkPortRestart) r.mux.HandleFunc("GET /api/v1/ports/", r.handlePortSubtree) r.mux.HandleFunc("PATCH /api/v1/ports/", r.handlePortSubtree) r.mux.HandleFunc("POST /api/v1/ports/", r.handlePortSubtree) r.mux.HandleFunc("GET /api/v1/hardware/serial-ports", r.handleSerialPorts) r.mux.HandleFunc("GET /api/v1/alarms/rules", r.handleAlarmRules) r.mux.HandleFunc("GET /api/v1/alarms/rules/", r.handleAlarmRuleSubtree) r.mux.HandleFunc("PATCH /api/v1/alarms/rules/", r.handleAlarmRuleSubtree) r.mux.HandleFunc("GET /api/v1/alarms/delivery-policy", r.handleAlarmDeliveryPolicy) r.mux.HandleFunc("PATCH /api/v1/alarms/delivery-policy", r.handleUpdateAlarmDeliveryPolicy) r.mux.HandleFunc("POST /api/v1/alarms/delivery-test", r.handleAlarmDeliveryTest) r.mux.HandleFunc("GET /api/v1/alarms/email-settings", r.handleEmailNotification) r.mux.HandleFunc("PATCH /api/v1/alarms/email-settings", r.handleUpdateEmailNotification) r.mux.HandleFunc("GET /api/v1/alarms/snmp-trap-targets", r.handleSNMPTrapTargets) r.mux.HandleFunc("PATCH /api/v1/alarms/snmp-trap-targets", r.handleUpdateSNMPTrapTargets) r.mux.HandleFunc("GET /api/v1/config/status", r.handleConfigStatus) r.mux.HandleFunc("GET /api/v1/config/export", r.handleConfigExport) r.mux.HandleFunc("POST /api/v1/config/export/encrypted", r.handleEncryptedConfigExport) r.mux.HandleFunc("POST /api/v1/config/restore", r.handleRestoreConfig) r.mux.HandleFunc("POST /api/v1/config/restore/encrypted", r.handleEncryptedConfigRestore) r.mux.HandleFunc("POST /api/v1/config/apply", r.handleApplyConfig) r.mux.HandleFunc("POST /api/v1/config/save", r.handleSaveConfig) r.mux.HandleFunc("POST /api/v1/config/save-and-apply", r.handleSaveAndApplyConfig) r.mux.HandleFunc("POST /api/v1/config/discard", r.handleDiscardConfig) r.mux.HandleFunc("POST /api/v1/config/save-and-restart", r.handleSaveAndRestart) r.mux.HandleFunc("POST /api/v1/direct/", r.handleDirectSubtree) r.mux.HandleFunc("DELETE /api/v1/direct/", r.handleDirectSubtree) r.mux.HandleFunc("POST /api/v1/time/sync-browser", r.handleSyncBrowserTime) r.mux.HandleFunc("POST /api/v1/time/sync-ntp", r.handleSyncNTPTime) r.mux.HandleFunc("GET /api/v1/diagnostics/support-bundles/", r.handleSupportBundleDownload) r.mux.HandleFunc("POST /api/v1/diagnostics/", r.handleDiagnosticsSubtree) r.mux.HandleFunc("GET /api/v1/logs/audit", r.handleAuditEvents) r.mux.HandleFunc("GET /api/v1/logs/system", r.handleSystemLogEvents) r.mux.HandleFunc("GET /api/v1/logs/port", r.handlePortLogEvents) r.mux.HandleFunc("POST /api/v1/logs/port/clear", r.handleClearPortLogs) r.mux.HandleFunc("POST /api/v1/logs/system/clear", r.handleClearSystemLogs) r.mux.HandleFunc("POST /api/v1/logs/links/clear", r.handleClearLinkLogs) r.mux.HandleFunc("GET /api/v1/logs/links", r.handleLinkLogEvents) r.mux.HandleFunc("GET /api/v1/logs/policy", r.handleLogPolicy) r.mux.HandleFunc("GET /api/v1/logs/storage", r.handleLogStorageStatus) r.mux.HandleFunc("GET /api/v1/logs/remote-syslog/status", r.handleRemoteSyslogStatus) r.mux.HandleFunc("GET /api/v1/logs/retention", r.handleLogRetention) r.mux.HandleFunc("PATCH /api/v1/logs/retention", r.handleUpdateLogRetention) r.mux.HandleFunc("GET /api/v1/logs/remote-syslog", r.handleRemoteSyslog) r.mux.HandleFunc("PATCH /api/v1/logs/remote-syslog", r.handleUpdateRemoteSyslog) r.mux.HandleFunc("POST /api/v1/logs/export", r.handleExportLogs) r.mux.HandleFunc("POST /api/v1/logs/export/download", r.handleDownloadLogs) r.mux.HandleFunc("GET /api/v1/operations", r.handleOperations) r.mux.HandleFunc("GET /api/v1/operations/", r.handleOperation) r.mux.HandleFunc("GET /api/v1/firmware/status", r.handleFirmwareStatus) r.mux.HandleFunc("POST /api/v1/firmware/packages", r.handleFirmwareUpload) r.mux.HandleFunc("POST /api/v1/firmware/apply", r.handleFirmwareApply) } func (r *Router) handleSystemStatus(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "system.read", Target: "system", Permission: "system.read"}); !ok { return } status, err := r.store.SystemStatus(req.Context()) if err == nil { networkState, _ := r.store.NetworkState(req.Context()) metrics := r.systemMetrics.Read(req.Context(), networkState.Interfaces) status.BuildNumber = metrics.BuildNumber if status.BuildNumber != "" { status.FirmwareVersion = status.BuildNumber } else { status.BuildNumber = status.FirmwareVersion } status.DeviceID = metrics.DeviceID status.SystemTime = metrics.SystemTime status.UptimeSeconds = metrics.UptimeSeconds status.TemperatureCelsius = metrics.TemperatureCelsius status.LoadAverages = metrics.LoadAverages status.CPUUsagePercent = metrics.CPUUsagePercent status.MemoryTotalBytes = metrics.MemoryTotalBytes status.MemoryUsedBytes = metrics.MemoryUsedBytes status.MemoryAvailableBytes = metrics.MemoryAvailableBytes status.MemoryUsagePercent = metrics.MemoryUsagePercent status.Storage = metrics.Storage status.NetworkInterfaces = metrics.NetworkInterfaces runtime := r.readSerialRuntime(req.Context()) status.SerialPortCount = runtime.availablePorts() if runtime.sessionErr == nil { status.ActiveSessions = runtime.session.Broker.TotalAttachments status.ActivePortCount = runtime.session.Broker.ActiveChannels status.MostUsedPort = "-" for _, channel := range runtime.session.Serial.Channels { status.TotalSerialRXBytes += channel.RXBytes status.TotalSerialTXBytes += channel.TXBytes total := channel.RXBytes + channel.TXBytes if total > status.MostUsedPortBytes || (total == status.MostUsedPortBytes && total > 0 && (status.MostUsedPort == "-" || channel.Channel < status.MostUsedPort)) { status.MostUsedPort = channel.Channel status.MostUsedPortBytes = total } } } else { status.ActiveSessions = 0 status.MostUsedPort = "-" } } writeResult(w, status, err) } func (r *Router) handleServiceHealth(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "system.service.read", Target: "services", Permission: "system.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } health, err := r.store.ServiceHealth(req.Context()) health = r.withRuntimeHealth(req.Context(), health) health = r.withFirmwareHealth(req.Context(), health) health = windowServiceHealth(health, limit, offset) writeResult(w, health, err) } func (r *Router) withFirmwareHealth(ctx context.Context, health []domain.ServiceHealth) []domain.ServiceHealth { status, statusErr := r.firmware.Status(ctx) for index := range health { if health[index].Service != "tera-updated" { continue } if statusErr != nil { health[index].Status = "degraded" health[index].Detail = "validation-only updater unavailable: " + statusErr.Error() return health } health[index].Status = "ok" pressure := status.Capacity.Pressure if pressure == "critical" || pressure == "emergency" { health[index].Status = "degraded" } if pressure == "" { pressure = "unknown" } health[index].Detail = fmt.Sprintf("validation-only updater reachable; state=%s; storage_pressure=%s", status.State, pressure) return health } return health } func (r *Router) handleSystemReboot(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "system.reboot", Target: "system", Permission: "system.reboot"}) if !ok { return } operation, err := r.store.RebootSystem(ctx) writeResult(w, operation, err) } func (r *Router) handleFactoryReset(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "system.factory_reset", Target: "system", Permission: "system.factory_reset"}) if !ok { return } operation, err := r.store.FactoryReset(ctx) writeResult(w, operation, err) } func (r *Router) handleAuthMe(w http.ResponseWriter, req *http.Request) { actor, ok := r.authenticateActor(w, req) if !ok { return } writeJSON(w, http.StatusOK, authProfile(actor)) } func (r *Router) handleAuthLogin(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store") var loginRequest domain.AuthLoginRequest if ok, message := decodeJSONBody(w, req, &loginRequest, false); !ok { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: "auth.login", Target: "auth", Result: "rejected", Message: message, }) return } settings, err := r.store.ManagementAccess(req.Context()) if err != nil { writeResult(w, nil, err) return } if settings.CaptchaEnabled && !r.captcha.Verify(requestPeerSubject(req), loginRequest.CaptchaID, loginRequest.CaptchaAnswer) { loginRequest.Password = "" loginRequest.CaptchaAnswer = "" _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: "auth.captcha.verify", Target: "auth", Result: "rejected", Message: "login automation challenge rejected", }) writeError(w, http.StatusUnauthorized, "captcha_failed", "login verification failed") return } loginRequest.CaptchaID = "" loginRequest.CaptchaAnswer = "" session, err := r.store.Login(req.Context(), loginRequest) if err != nil { writeError(w, http.StatusUnauthorized, "authentication_failed", "invalid username or password") return } writeJSON(w, http.StatusOK, session) } func (r *Router) handleAuthLoginPolicy(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store") settings, err := r.store.ManagementAccess(req.Context()) if err != nil { writeResult(w, nil, err) return } writeJSON(w, http.StatusOK, domain.AuthLoginPolicy{ CaptchaEnabled: settings.CaptchaEnabled, DetectionMinutes: settings.LoginDetectionMinutes, SecurityHitCount: settings.LoginSecurityHitCount, FixedSafetyThreshold: failedLoginSafetyThreshold, FixedSafetyMinutes: failedLoginSafetyMinutes, }) } func (r *Router) handleAuthCaptcha(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store") settings, err := r.store.ManagementAccess(req.Context()) if err != nil { writeResult(w, nil, err) return } if !settings.CaptchaEnabled { writeError(w, http.StatusConflict, "captcha_disabled", "login verification challenge is disabled") return } challenge, err := r.captcha.Create(requestPeerSubject(req)) if err != nil { writeError(w, http.StatusServiceUnavailable, "captcha_unavailable", "login verification challenge is unavailable") return } writeJSON(w, http.StatusOK, challenge) } func requestPeerSubject(req *http.Request) string { address := strings.TrimSpace(req.RemoteAddr) if host, _, err := net.SplitHostPort(address); err == nil { address = host } peerIP := net.ParseIP(address) if peerIP != nil && peerIP.IsLoopback() { forwarded := strings.TrimSpace(req.Header.Get("X-Forwarded-For")) if !strings.Contains(forwarded, ",") { if forwardedIP := net.ParseIP(forwarded); forwardedIP != nil { return forwardedIP.String() } } } if peerIP != nil { return peerIP.String() } if address == "" { return "unknown" } return address } func (r *Router) handleAuthLogout(w http.ResponseWriter, req *http.Request) { token := bearerToken(req.Header.Get("Authorization")) if token == "" { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: "auth.logout", Target: "auth", Result: "authentication_failed", Message: "authentication required", }) writeError(w, http.StatusUnauthorized, "authentication_required", "authentication required") return } if err := r.store.Logout(req.Context(), token); err != nil { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: "auth.logout", Target: "auth", Result: "authentication_failed", Message: "invalid session token", }) writeError(w, http.StatusUnauthorized, "authentication_required", "invalid session token") return } writeJSON(w, http.StatusOK, map[string]string{ "result": "logged_out", "message": "session revoked", }) } func (r *Router) handleAuthUsers(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.users.read"); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } users, err := r.store.AuthUsers(req.Context()) users = windowAuthUsers(users, limit, offset) writeResult(w, users, err) } func (r *Router) handleAuthSessions(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.sessions.read"); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } sessions, err := r.store.AuthSessions(req.Context()) sessions = windowAuthSessions(sessions, limit, offset) writeResult(w, sessions, err) } func (r *Router) handleAuthSessionSubtree(w http.ResponseWriter, req *http.Request) { sessionID := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/auth/sessions/"), "/") if sessionID == "" || req.Method != http.MethodDelete { writeError(w, http.StatusNotFound, "not_found", "auth session route not found") return } ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "auth.session.revoke", Target: sessionID, Permission: "auth.write"}) if !ok { return } result, err := r.store.RevokeAuthSession(ctx, sessionID) writeResult(w, result, err) } func (r *Router) handleAuthTokens(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.tokens.read"); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } tokens, err := r.store.AuthTokens(req.Context()) tokens = windowAuthTokens(tokens, limit, offset) writeResult(w, tokens, err) } func (r *Router) handleCreateAuthToken(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "auth.token.create", Target: "auth", Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var createRequest domain.AuthTokenCreateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &createRequest) { return } token, err := r.store.CreateAuthToken(ctx, createRequest) writeResult(w, token, err) } func (r *Router) handleAuthTokenSubtree(w http.ResponseWriter, req *http.Request) { tokenID := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/auth/tokens/"), "/") if tokenID == "" || req.Method != http.MethodDelete { writeError(w, http.StatusNotFound, "not_found", "auth token route not found") return } ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "auth.token.revoke", Target: tokenID, Permission: "auth.write"}) if !ok { return } result, err := r.store.RevokeAuthToken(ctx, tokenID) writeResult(w, result, err) } func (r *Router) handleExternalAuth(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.external.read"); !ok { return } settings, err := r.store.ExternalAuth(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateExternalAuth(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "auth.external.update", Target: "external_auth", Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.ExternalAuthUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateExternalAuth(ctx, update) writeResult(w, result, err) } func (r *Router) handleAddressFilters(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.address_filter.read"); !ok { return } settings, err := r.store.AddressFilters(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateAddressFilters(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "auth.address_filter.update", Target: "address_filters", Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var settings domain.AddressFilterSettings if !r.decodeCommandJSON(w, req, ctx, spec, &settings) { return } result, err := r.store.UpdateAddressFilters(ctx, settings) writeResult(w, result, err) } func (r *Router) handleCreateAuthUser(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "auth.user.create", Target: "auth", Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var createRequest domain.AuthUserCreateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &createRequest) { return } user, err := r.store.CreateAuthUser(ctx, createRequest) writeResult(w, user, err) } func (r *Router) handleOwnPassword(w http.ResponseWriter, req *http.Request) { actor, ok := r.authenticateActor(w, req) if !ok { return } ctx := domain.WithActor(req.Context(), actor) spec := commandSpec{Action: "auth.password.change", Target: actor.Name, Permission: ""} var update domain.AuthSelfPasswordUpdateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } user, err := r.store.ChangeOwnPassword(ctx, update) writeResult(w, user, err) } func (r *Router) handleCreateAuthGroup(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "auth.group.create", Target: "auth", Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var create domain.AuthGroupCreateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &create) { return } group, err := r.store.CreateAuthGroup(ctx, create) writeResult(w, group, err) } func (r *Router) handleAuthUserSubtree(w http.ResponseWriter, req *http.Request) { rest := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/auth/users/"), "/") parts := strings.Split(rest, "/") if len(parts) == 0 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found", "auth user route not found") return } username := parts[0] switch { case req.Method == http.MethodDelete && len(parts) == 1: ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "auth.user.delete", Target: strings.ToLower(username), Permission: "auth.write"}) if !ok { return } result, err := r.store.DeleteAuthUser(ctx, username) writeResult(w, result, err) case req.Method == http.MethodPatch && len(parts) == 1: spec := commandSpec{Action: "auth.user.update", Target: strings.ToLower(username), Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var updateRequest domain.AuthUserUpdateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &updateRequest) { return } user, err := r.store.UpdateAuthUser(ctx, username, updateRequest) writeResult(w, user, err) case req.Method == http.MethodPatch && len(parts) == 2 && parts[1] == "password": spec := commandSpec{Action: "auth.user.password.update", Target: strings.ToLower(username), Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var updateRequest domain.AuthUserPasswordUpdateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &updateRequest) { return } user, err := r.store.UpdateAuthUserPassword(ctx, username, updateRequest) writeResult(w, user, err) default: writeError(w, http.StatusNotFound, "not_found", "auth user route not found") } } func (r *Router) handleAuthGroups(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.groups.read"); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } groups, err := r.store.AuthGroups(req.Context()) groups = windowAuthGroups(groups, limit, offset) writeResult(w, groups, err) } func (r *Router) handleAuthGroupSubtree(w http.ResponseWriter, req *http.Request) { groupID := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/auth/groups/"), "/") if groupID == "" { writeError(w, http.StatusNotFound, "not_found", "auth group route not found") return } if req.Method == http.MethodDelete { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "auth.group.delete", Target: strings.ToLower(groupID), Permission: "auth.write"}) if !ok { return } result, err := r.store.DeleteAuthGroup(ctx, groupID) writeResult(w, result, err) return } if req.Method != http.MethodPatch { writeError(w, http.StatusNotFound, "not_found", "auth group route not found") return } spec := commandSpec{Action: "auth.group.update", Target: strings.ToLower(groupID), Permission: "auth.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var updateRequest domain.AuthGroupUpdateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &updateRequest) { return } group, err := r.store.UpdateAuthGroup(ctx, groupID, updateRequest) writeResult(w, group, err) } func (r *Router) handleAuthPermissions(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeAuthRead(w, req, "auth.permissions.read"); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } permissions, err := r.store.AuthPermissions(req.Context()) permissions = windowAuthPermissions(permissions, limit, offset) writeResult(w, permissions, err) } func (r *Router) handleNetwork(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "network.read", Target: "network", Permission: "network.read"}); !ok { return } network, err := r.store.NetworkState(req.Context()) if err == nil { status, statusErr := r.network.Status(req.Context()) if statusErr == nil { if reconcileErr := r.reconcileRolledBackNetwork(req.Context(), status); reconcileErr != nil { err = reconcileErr } else if strings.HasPrefix(status.State, "rolled_back") { network, err = r.store.NetworkState(req.Context()) } mergeNetworkStatus(&network, status) } else if networkconfig.IsUnavailable(statusErr) { network.Apply = &domain.NetworkApplyStatus{State: "unavailable"} } else { err = statusErr } } writeResult(w, network, err) } func (r *Router) handleUpdateNetwork(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "network.update", Target: "network", Permission: "network.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.NetworkUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateNetwork(ctx, update) writeResult(w, result, err) } func (r *Router) handleNetworkApplyStatus(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "network.apply_status.read", Target: "network", Permission: "network.read"}); !ok { return } status, err := r.network.Status(req.Context()) if err == nil { err = r.reconcileRolledBackNetwork(req.Context(), status) } writeResult(w, networkApplyStatus(status), err) } // handleApplyNetwork applies only the pending network section. Other staged // sections (ports, alarms, and so on) remain pending and are never activated by // a network form submission. func (r *Router) handleApplyNetwork(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "network.apply", Target: "network", Permission: "network.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } applyStore, ok := r.store.(networkPendingConfigApplier) if !ok { writeResult(w, nil, errors.New("network-only configuration apply is unavailable")) return } // Reconfiguring the management interface can close the HTTP connection that // initiated the operation. Keep the authenticated operation values, but do // not let that expected disconnect cancel confirmation and persistence. operationCtx, cancelOperation := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) defer cancelOperation() networkApply, err := r.applyPendingNetworkConfig(operationCtx) if err != nil { r.recordHardwareFailure(operationCtx, spec, err) writeResult(w, nil, err) return } result, err := applyStore.ApplyPendingNetwork(operationCtx) if err != nil && networkApply != nil { r.rollbackAppliedNetwork(networkApply) } if err == nil && networkApply != nil { status, confirmErr := r.network.Confirm(operationCtx, networkApply.TransactionID) if confirmErr != nil { r.rollbackAppliedNetwork(networkApply) err = confirmErr } else if confirmer, ok := r.store.(networkRuntimeConfirmer); ok { err = confirmer.ConfirmNetworkRuntime(operationCtx, networkApply.TransactionID) } if err == nil { if saver, ok := r.store.(networkRunningConfigSaver); ok { err = saver.SaveRunningNetwork(operationCtx) } } if err == nil { r.recordNetworkTransaction(operationCtx, "network.confirm", networkApply.TransactionID, "succeeded", "confirmed by immediate network apply") status.State = "confirmed" result.NetworkApply = networkApplyStatus(status) } } if err == nil && networkApply == nil { result.NetworkApply = domainNetworkApplyResult(nil) } writeResult(w, result, err) } func (r *Router) handleConfirmNetwork(w http.ResponseWriter, req *http.Request) { r.handleNetworkTransaction(w, req, "network.confirm", r.network.Confirm) } func (r *Router) handleRollbackNetwork(w http.ResponseWriter, req *http.Request) { r.handleNetworkTransaction(w, req, "network.rollback", r.network.Rollback) } func (r *Router) handleNetworkTransaction(w http.ResponseWriter, req *http.Request, action string, command func(context.Context, string) (networkconfig.Status, error)) { spec := commandSpec{Action: action, Target: "network", Permission: "network.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.NetworkTransactionRequest if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } request.TransactionID = strings.TrimSpace(request.TransactionID) if !validNetworkTransactionID(request.TransactionID) { err := errors.New("transaction_id must contain 1 to 128 letters, digits, or hyphens") r.recordNetworkTransaction(ctx, action, "", "failed", err.Error()) writeResult(w, nil, err) return } status, err := command(ctx, request.TransactionID) if err != nil { r.recordNetworkTransaction(ctx, action, request.TransactionID, "failed", err.Error()) writeResult(w, nil, err) return } if action == "network.rollback" { if err := r.reconcileRolledBackNetwork(ctx, status); err != nil { r.recordNetworkTransaction(ctx, action, request.TransactionID, "failed", err.Error()) writeResult(w, nil, err) return } } else if action == "network.confirm" { if confirmer, ok := r.store.(networkRuntimeConfirmer); ok { if err := confirmer.ConfirmNetworkRuntime(ctx, request.TransactionID); err != nil { r.recordNetworkTransaction(ctx, action, request.TransactionID, "failed", err.Error()) writeResult(w, nil, err) return } } if saver, ok := r.store.(networkRunningConfigSaver); ok { if err := saver.SaveRunningNetwork(ctx); err != nil { r.recordNetworkTransaction(ctx, action, request.TransactionID, "failed", err.Error()) writeResult(w, nil, err) return } } } auditID := r.recordNetworkTransaction(ctx, action, request.TransactionID, "succeeded", status.State) writeResult(w, domain.CommandResult{ Result: action, Message: status.State, AuditID: auditID, NetworkApply: networkApplyStatus(status), }, nil) } func validNetworkTransactionID(value string) bool { if len(value) == 0 || len(value) > 128 { return false } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' { continue } return false } return true } type networkTransactionAuditRecorder interface { RecordNetworkTransaction(context.Context, string, string, string, string) (string, error) } func (r *Router) recordNetworkTransaction(ctx context.Context, action, transactionID, result, message string) string { recorder, ok := r.store.(networkTransactionAuditRecorder) if !ok { return "" } auditID, _ := recorder.RecordNetworkTransaction(ctx, action, transactionID, result, message) return auditID } func mergeNetworkStatus(state *domain.NetworkState, status networkconfig.Status) { state.RoutingMode = status.RoutingMode state.DefaultRoute = status.DefaultRoute state.Apply = networkApplyStatus(status) state.OOB.PrimaryInterface = status.OOB.PrimaryInterface state.OOB.FallbackInterface = status.OOB.FallbackInterface state.OOB.PrimaryLink = status.OOB.PrimaryLink state.OOB.FallbackAvailable = status.OOB.FallbackAvailable state.OOB.FallbackActive = status.OOB.FallbackActive state.OOB.FailoverReason = status.OOB.FailoverReason state.OOB.LastFailoverAt = status.OOB.LastFailoverAt for index := range state.Interfaces { for _, actual := range status.Interfaces { if actual.Device != state.Interfaces[index].Device { continue } state.Interfaces[index].Link = actual.Link if actual.Speed != "" { state.Interfaces[index].Speed = actual.Speed } if len(actual.Addresses) > 0 { state.Interfaces[index].Address = actual.Addresses[0] } break } } } func networkApplyStatus(status networkconfig.Status) *domain.NetworkApplyStatus { return &domain.NetworkApplyStatus{ TransactionID: status.TransactionID, State: status.State, Deadline: status.Deadline, } } type networkRuntimeReconciler interface { ReconcileNetworkRuntime(context.Context, networkconfig.Config) error } type networkRuntimePreparer interface { PrepareNetworkRuntimeApply(context.Context, string) error } type networkRuntimeConfirmer interface { ConfirmNetworkRuntime(context.Context, string) error } type networkRuntimeRollbacker interface { RollbackNetworkRuntime(context.Context) (bool, error) } type networkPendingConfigApplier interface { ApplyPendingNetwork(context.Context) (domain.CommandResult, error) } type networkRunningConfigSaver interface { SaveRunningNetwork(context.Context) error } func (r *Router) reconcileRolledBackNetwork(ctx context.Context, status networkconfig.Status) error { if !strings.HasPrefix(status.State, "rolled_back") { return nil } if rollbacker, ok := r.store.(networkRuntimeRollbacker); ok { restored, err := rollbacker.RollbackNetworkRuntime(ctx) if err != nil || restored { return err } } if status.Current == nil { return nil } reconciler, ok := r.store.(networkRuntimeReconciler) if !ok { return nil } return reconciler.ReconcileNetworkRuntime(ctx, *status.Current) } func (r *Router) handleSNMPAgent(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "snmp.agent.read", Target: "snmp_agent", Permission: "config.read"}); !ok { return } settings, err := r.store.SNMPAgent(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateSNMPAgent(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "snmp.agent.update", Target: "snmp_agent", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.SNMPAgentUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateSNMPAgent(ctx, update) writeResult(w, result, err) } func (r *Router) handleTimeSettings(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "time.settings.read", Target: "time", Permission: "config.read"}); !ok { return } settings, err := r.store.TimeSettings(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateTimeSettings(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "time.settings.update", Target: "time", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.TimeSettingsUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateTimeSettings(ctx, update) writeResult(w, result, err) } func (r *Router) handleManagementAccess(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "security.management_access.read", Target: "management_access", Permission: "config.read"}); !ok { return } settings, err := r.store.ManagementAccess(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateManagementAccess(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "security.management_access.update", Target: "management_access", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.ManagementAccessUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateManagementAccess(ctx, update) writeResult(w, result, err) } func (r *Router) handleTLSCertificate(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "security.tls_certificate.read", Target: "tls_certificate", Permission: "config.read"}); !ok { return } settings, err := r.store.TLSCertificate(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateTLSCertificate(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "security.tls_certificate.update", Target: "tls_certificate", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.TLSCertificateUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateTLSCertificate(ctx, update) writeResult(w, result, err) } func (r *Router) handleTLSCertificates(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "security.tls_certificate.list", Target: "tls_certificate", Permission: "config.read"}); !ok { return } inventory, err := r.store.TLSCertificates(req.Context()) writeResult(w, inventory, err) } func (r *Router) handleCreateTLSCertificate(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "security.tls_certificate.create", Target: "tls_certificate", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.TLSCertificateCreateRequest if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } entry, err := r.store.CreateTLSCertificate(ctx, request) writeResult(w, entry, err) } func (r *Router) handleImportTLSCertificate(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "security.tls_certificate.import", Target: "tls_certificate", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.TLSCertificateImportRequest if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } entry, err := r.store.ImportTLSCertificate(ctx, request) request.Password, request.PrivateKeyPEM = "", "" writeResult(w, entry, err) } func (r *Router) handleTLSCertificateSubtree(w http.ResponseWriter, req *http.Request) { path := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/security/tls-certificates/"), "/") parts := strings.Split(path, "/") if len(parts) == 1 && req.Method == http.MethodDelete { spec := commandSpec{Action: "security.tls_certificate.delete", Target: parts[0], Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } result, err := r.store.DeleteTLSCertificate(ctx, parts[0]) writeResult(w, result, err) return } if len(parts) == 2 && parts[1] == "csr" && req.Method == http.MethodPost { spec := commandSpec{Action: "security.tls_certificate.csr", Target: parts[0], Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.TLSCSRRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &request) { return } artifact, err := r.store.ExportTLSCSR(ctx, parts[0], request) writeResult(w, artifact, err) return } writeError(w, http.StatusNotFound, "not_found", "TLS certificate endpoint not found") } func (r *Router) handlePorts(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPatch { spec := commandSpec{Action: "port.bulk_update", Target: "ports", Permission: "port.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.PortBulkUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } for _, channel := range update.Channels { if !r.requirePortAccess(w, ctx, strings.ToUpper(channel), "write", spec) { return } } result, err := r.store.UpdatePorts(ctx, update.Channels, update.Update) writeResult(w, result, err) return } ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "port.read", Target: "ports", Permission: "port.read"}) if !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } ports, err := r.store.Ports(req.Context()) if err == nil { if req.URL.Query().Get("runtime") != "false" { ports = r.overlayPortRuntime(ports, r.readSerialRuntime(req.Context())) } else { // Configuration reads must not wait for hardware/session telemetry. // Never expose unobserved store defaults as a live link state. for i := range ports { ports[i].Status = "unknown" ports[i].Runtime = nil ports[i].ConnectedUsers = 0 ports[i].Writer = "" } } ports = r.filterAuthorizedPorts(ctx, ports, "read") } ports = windowPorts(ports, limit, offset) writeResult(w, ports, err) } func (r *Router) handlePortRuntimeStatus(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "port.read", Target: "ports", Permission: "port.read"}) if !ok { return } ports, err := r.store.Ports(req.Context()) if err == nil { ports = r.overlayPortRuntime(ports, r.readSerialRuntime(req.Context())) ports = r.filterAuthorizedPorts(ctx, ports, "read") } writeResult(w, compactPortRuntimeStatuses(ports), err) } func (r *Router) handlePortSubtree(w http.ResponseWriter, req *http.Request) { rest := strings.TrimPrefix(req.URL.Path, "/api/v1/ports/") parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 0 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found", "port route not found") return } channel := strings.ToUpper(parts[0]) switch { case req.Method == http.MethodGet && len(parts) == 1: ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "port.read", Target: channel, Permission: "port.read"}) if !ok || !r.requirePortAccess(w, ctx, channel, "read", commandSpec{Action: "port.read", Target: channel}) { return } port, err := r.store.Port(req.Context(), channel) if err == nil { ports := r.overlayPortRuntime([]domain.Port{port}, r.readSerialRuntime(req.Context())) port = ports[0] } writeResult(w, port, err) case req.Method == http.MethodPatch && len(parts) == 1: spec := commandSpec{Action: "port.update", Target: channel, Permission: "port.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok || !r.requirePortAccess(w, ctx, channel, "write", spec) { return } var update domain.PortUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdatePort(ctx, channel, update) writeResult(w, result, err) case req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restart": spec := commandSpec{Action: "port.restart", Target: channel, Permission: "port.restart"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok || !r.requirePortAccess(w, ctx, channel, "write", spec) { return } port, err := r.store.Port(ctx, channel) if err != nil { r.recordRejectedCommand(ctx, spec, err.Error()) writeResult(w, nil, err) return } if err := r.applySerialPortConfig(ctx, port); err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } operation, err := r.store.RestartPort(ctx, channel) writeResult(w, operation, err) default: writeError(w, http.StatusNotFound, "not_found", "port route not found") } } func (r *Router) handleBulkPortRestart(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "port.bulk_restart", Target: "ports", Permission: "port.restart"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.PortBulkRestart if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } if len(request.Channels) == 0 || len(request.Channels) > serial.ChannelCount { err := errors.New("channels must include 1 through 48 unique channels") r.recordRejectedCommand(ctx, spec, err.Error()) writeResult(w, nil, err) return } ports := make([]domain.Port, 0, len(request.Channels)) seen := make(map[string]bool, len(request.Channels)) for index, requested := range request.Channels { channel := strings.ToUpper(strings.TrimSpace(requested)) if seen[channel] { err := errors.New("channels must not include duplicates") r.recordRejectedCommand(ctx, spec, err.Error()) writeResult(w, nil, err) return } seen[channel] = true request.Channels[index] = channel if !r.requirePortAccess(w, ctx, channel, "write", spec) { return } port, err := r.store.Port(ctx, channel) if err != nil { r.recordRejectedCommand(ctx, spec, err.Error()) writeResult(w, nil, err) return } ports = append(ports, port) } if err := r.applySerialPortsConfig(ctx, ports); err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } operation, err := r.store.RestartPorts(ctx, request.Channels) writeResult(w, operation, err) } func (r *Router) handleSerialPorts(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "hardware.serial.read", Target: "serial_ports", Permission: "hardware.read"}); !ok { return } states, err := r.serialPorts.States(req.Context()) if serial.IsUnavailable(err) { states, _ = serial.UnavailableController{Reason: err.Error()}.States(req.Context()) err = nil } writeResult(w, states, err) } func (r *Router) handleGreenLEDs(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "hardware.green_led.read", Target: "green_leds", Permission: "hardware.read"}); !ok { return } states, err := r.greenLEDs.States(req.Context()) if greenled.IsUnavailable(err) { states, _ = greenled.UnavailableController{Reason: err.Error()}.States(req.Context()) err = nil } writeResult(w, states, err) } type greenLEDCommandResponse struct { greenled.State AuditID string `json:"audit_id"` Message string `json:"message"` } func (r *Router) handleGreenLEDSubtree(w http.ResponseWriter, req *http.Request) { rest := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/hardware/green-leds/"), "/") parts := strings.Split(rest, "/") if len(parts) == 0 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found", "green LED route not found") return } channel := strings.ToUpper(parts[0]) spec := commandSpec{Action: "hardware.green_led.set", Target: channel, Permission: "hardware.write"} switch { case req.Method == http.MethodPut && len(parts) == 1: ctx, ok := r.authorizeCommand(w, req, spec) if !ok || !r.requirePortAccess(w, ctx, channel, "write", spec) { return } var command domain.GreenLEDForceRequest if !r.decodeCommandJSON(w, req, ctx, spec, &command) { return } if command.Force == nil { message := "force must be provided" r.recordRejectedCommand(ctx, spec, message) writeError(w, http.StatusBadRequest, "validation_failed", message) return } state, err := r.greenLEDs.SetForce(ctx, channel, *command.Force) action := "hardware.green_led.release" message := channel + " green LED released to RX activity" if *command.Force { action = "hardware.green_led.force" message = channel + " green LED forced on" } r.writeGreenLEDCommandResult(w, ctx, action, channel, message, state, err) case req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "pulse": ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var command domain.GreenLEDPulseRequest if !r.decodeCommandJSON(w, req, ctx, spec, &command) { return } state, err := r.greenLEDs.Pulse(ctx, channel, time.Duration(command.DwellMilliseconds)*time.Millisecond) message := fmt.Sprintf("%s green LED forced on for %d ms then released to RX activity", channel, command.DwellMilliseconds) r.writeGreenLEDCommandResult(w, ctx, "hardware.green_led.pulse", channel, message, state, err) default: writeError(w, http.StatusNotFound, "not_found", "green LED route not found") } } func (r *Router) writeGreenLEDCommandResult(w http.ResponseWriter, ctx context.Context, action, channel, message string, state greenled.State, commandErr error) { if commandErr != nil { _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{ Action: action, Target: channel, Result: "failed", Message: commandErr.Error(), }) writeResult(w, nil, commandErr) return } auditID, err := r.store.RecordAudit(ctx, domain.AuditEvent{ Action: action, Target: channel, Result: "succeeded", Message: message, }) if err != nil { writeResult(w, nil, err) return } writeJSON(w, http.StatusOK, greenLEDCommandResponse{ State: state, AuditID: auditID, Message: message, }) } func (r *Router) handleConfigStatus(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "config.read", Target: "config", Permission: "config.read"}); !ok { return } status, err := r.store.ConfigStatus(req.Context()) writeResult(w, status, err) } func (r *Router) handleConfigExport(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "config.export", Target: "config", Permission: "config.read"}); !ok { return } export, err := r.store.ConfigExport(req.Context()) writeResult(w, export, err) } func (r *Router) handleEncryptedConfigExport(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.export.encrypted", Target: "config", Permission: "config.read"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request encryptedConfigExportRequest if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } export, err := r.store.ConfigExport(ctx) if err != nil { request.Key = "" writeResult(w, nil, err) return } content, err := encryptConfigEnvelope(export, request.Key) request.Key = "" if err != nil { writeResult(w, nil, err) return } stamp := export.GeneratedAt.UTC().Format("20060102T150405Z") writeJSON(w, http.StatusOK, encryptedConfigExportResponse{ Filename: "tera-core-x48-pro-config-" + stamp + ".tera-config.json", Content: content, Summary: export.Summary, }) } func (r *Router) handleRestoreConfig(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.restore", Target: "config", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var snapshot domain.ConfigExport if !r.decodeConfigRestoreJSON(w, req, ctx, spec, &snapshot) { return } result, err := r.store.RestoreConfig(ctx, snapshot) writeResult(w, result, err) } func (r *Router) handleEncryptedConfigRestore(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.restore.encrypted", Target: "config", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request encryptedConfigRestoreRequest if !r.decodeCommandJSON(w, req, ctx, spec, &request) { return } snapshot, err := decryptConfigEnvelope(request.Content, request.Key) request.Key = "" request.Content = "" if err != nil { writeResult(w, nil, err) return } if !request.IncludeNetwork { current, exportErr := r.store.ConfigExport(ctx) if exportErr != nil { writeResult(w, nil, exportErr) return } // A restore that excludes networking must preserve the interfaces that // are serving the current management connection. RestoreConfig stages // only values that differ from this running state, so this also removes // any unrelated network transaction that was pending before restore. snapshot.Persistent.Network = current.Running.Network } result, err := r.store.RestoreConfig(ctx, snapshot) writeResult(w, result, err) } func (r *Router) handleAlarmRules(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "alarm.rule.read", Target: "alarms", Permission: "alarm.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } rules, err := r.store.AlarmRules(req.Context()) rules = windowAlarmRules(rules, limit, offset) writeResult(w, rules, err) } func (r *Router) handleAlarmRuleSubtree(w http.ResponseWriter, req *http.Request) { rest := strings.TrimPrefix(req.URL.Path, "/api/v1/alarms/rules/") parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 0 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found", "alarm rule route not found") return } ruleID := strings.ToLower(parts[0]) switch { case req.Method == http.MethodGet && len(parts) == 1: if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "alarm.rule.read", Target: ruleID, Permission: "alarm.read"}); !ok { return } rule, err := r.store.AlarmRule(req.Context(), ruleID) writeResult(w, rule, err) case req.Method == http.MethodPatch && len(parts) == 1: spec := commandSpec{Action: "alarm.rule.update", Target: ruleID, Permission: "alarm.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.AlarmRuleUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateAlarmRule(ctx, ruleID, update) writeResult(w, result, err) default: writeError(w, http.StatusNotFound, "not_found", "alarm rule route not found") } } func (r *Router) handleAlarmDeliveryPolicy(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "alarm.delivery_policy.read", Target: "alarm_delivery", Permission: "alarm.read"}); !ok { return } policy, err := r.store.AlarmDeliveryPolicy(req.Context()) writeResult(w, policy, err) } func (r *Router) handleUpdateAlarmDeliveryPolicy(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "alarm.delivery_policy.update", Target: "alarm_delivery", Permission: "alarm.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.AlarmDeliveryPolicyUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateAlarmDeliveryPolicy(ctx, update) writeResult(w, result, err) } func (r *Router) handleAlarmDeliveryTest(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "alarm.delivery.test", Target: "alarm_delivery", Permission: "alarm.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var request domain.AlarmDeliveryTestRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &request) { return } operation, err := r.store.TestAlarmDelivery(ctx, request) writeResult(w, operation, err) } func (r *Router) handleEmailNotification(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "alarm.email.read", Target: "email_notification", Permission: "alarm.read"}); !ok { return } settings, err := r.store.EmailNotification(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateEmailNotification(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "alarm.email.update", Target: "email_notification", Permission: "alarm.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.EmailNotificationUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateEmailNotification(ctx, update) writeResult(w, result, err) } func (r *Router) handleSNMPTrapTargets(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "alarm.snmp_trap.read", Target: "snmp_trap_targets", Permission: "alarm.read"}); !ok { return } targets, err := r.store.SNMPTrapTargets(req.Context()) writeResult(w, targets, err) } func (r *Router) handleUpdateSNMPTrapTargets(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "alarm.snmp_trap.update", Target: "snmp_trap_targets", Permission: "alarm.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.SNMPTrapTargetUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateSNMPTrapTargets(ctx, update) writeResult(w, result, err) } func (r *Router) handleAuditEvents(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "audit.read", Target: "audit", Permission: "audit.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } from, to, ok := parseLogTimeRange(w, req) if !ok { return } order := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("order"))) if order == "" { order = "asc" } if order != "asc" && order != "desc" { writeError(w, http.StatusBadRequest, "validation_failed", "order must be asc or desc") return } events, err := r.store.AuditEvents(req.Context()) events = filterAuditEvents(events, req.URL.Query().Get("actor"), req.URL.Query().Get("keyword"), from, to) if order == "desc" { reverseAuditEvents(events) } events = windowAuditEvents(events, limit, offset) writeResult(w, events, err) } func (r *Router) handleSystemLogEvents(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.read", Target: "logs", Permission: "log.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } from, to, ok := parseLogTimeRange(w, req) if !ok { return } filter := domain.SystemLogFilter{ Category: req.URL.Query().Get("category"), Severity: req.URL.Query().Get("severity"), Keyword: req.URL.Query().Get("keyword"), From: from, To: to, Limit: limit, Offset: offset, } events, err := r.store.SystemLogEvents(req.Context(), filter) writeResult(w, events, err) } func (r *Router) handlePortLogEvents(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.port.read", Target: "logs", Permission: "log.read"}) if !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } from, to, ok := parseLogTimeRange(w, req) if !ok { return } filter := domain.PortLogFilter{ Channel: req.URL.Query().Get("channel"), Keyword: req.URL.Query().Get("keyword"), EventType: req.URL.Query().Get("event_type"), Direction: req.URL.Query().Get("direction"), Actor: req.URL.Query().Get("actor"), From: from, To: to, Limit: limit, Offset: offset, } if filter.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(filter.Channel), "read", commandSpec{Action: "log.port.read", Target: filter.Channel}) { return } events, err := r.store.PortLogEvents(ctx, filter) writeResult(w, events, err) } func (r *Router) handleLinkLogEvents(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.read", Target: "logs", Permission: "log.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } from, to, ok := parseLogTimeRange(w, req) if !ok { return } events, err := r.store.LinkLogEvents(req.Context(), domain.LinkLogFilter{ Kind: req.URL.Query().Get("kind"), Resource: req.URL.Query().Get("resource"), State: req.URL.Query().Get("state"), EventType: req.URL.Query().Get("event_type"), Keyword: req.URL.Query().Get("keyword"), From: from, To: to, Limit: limit, Offset: offset, }) writeResult(w, events, err) } func (r *Router) handleLogPolicy(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.policy.read", Target: "logs", Permission: "log.read"}); !ok { return } policy, err := r.store.LogPolicy(req.Context()) writeResult(w, policy, err) } func parsePagination(w http.ResponseWriter, req *http.Request) (int, int, bool) { limit, ok := parseOptionalNonNegativeInt(w, req.URL.Query().Get("limit"), "limit") if !ok { return 0, 0, false } if limit == 0 { limit = defaultLogLimit } if limit > maxLogLimit { writeError(w, http.StatusBadRequest, "validation_failed", "limit must be at most 500") return 0, 0, false } offset, ok := parseOptionalNonNegativeInt(w, req.URL.Query().Get("offset"), "offset") if !ok { return 0, 0, false } return limit, offset, true } func parseOptionalNonNegativeInt(w http.ResponseWriter, value, field string) (int, bool) { if strings.TrimSpace(value) == "" { return 0, true } parsed, err := strconv.Atoi(value) if err != nil || parsed < 0 { writeError(w, http.StatusBadRequest, "validation_failed", field+" must be a non-negative integer") return 0, false } return parsed, true } func parseLogTimeRange(w http.ResponseWriter, req *http.Request) (time.Time, time.Time, bool) { from, ok := parseOptionalRFC3339(w, req.URL.Query().Get("from"), "from") if !ok { return time.Time{}, time.Time{}, false } to, ok := parseOptionalRFC3339(w, req.URL.Query().Get("to"), "to") if !ok { return time.Time{}, time.Time{}, false } if !from.IsZero() && !to.IsZero() && from.After(to) { writeError(w, http.StatusBadRequest, "validation_failed", "from must not be after to") return time.Time{}, time.Time{}, false } return from, to, true } func parseOptionalRFC3339(w http.ResponseWriter, value, field string) (time.Time, bool) { value = strings.TrimSpace(value) if value == "" { return time.Time{}, true } parsed, err := time.Parse(time.RFC3339, value) if err != nil { writeError(w, http.StatusBadRequest, "validation_failed", field+" must be an RFC3339 timestamp") return time.Time{}, false } return parsed, true } func filterAuditEvents(events []domain.AuditEvent, actor, keyword string, from, to time.Time) []domain.AuditEvent { actor = strings.ToLower(strings.TrimSpace(actor)) keyword = strings.ToLower(strings.TrimSpace(keyword)) filtered := make([]domain.AuditEvent, 0, len(events)) for _, event := range events { if !from.IsZero() && event.Timestamp.Before(from) || !to.IsZero() && event.Timestamp.After(to) { continue } if actor != "" && !strings.Contains(strings.ToLower(event.Actor), actor) { continue } searchable := strings.ToLower(event.AuditID + " " + event.Actor + " " + event.Action + " " + event.Target + " " + event.Command + " " + event.ReturnValue + " " + event.Message) if keyword != "" && !strings.Contains(searchable, keyword) { continue } filtered = append(filtered, event) } return filtered } func reverseAuditEvents(events []domain.AuditEvent) { for left, right := 0, len(events)-1; left < right; left, right = left+1, right-1 { events[left], events[right] = events[right], events[left] } } func windowAuditEvents(events []domain.AuditEvent, limit, offset int) []domain.AuditEvent { if offset >= len(events) { return []domain.AuditEvent{} } end := offset + limit if end > len(events) { end = len(events) } return events[offset:end] } func windowOperations(operations []domain.Operation, limit, offset int) []domain.Operation { if offset >= len(operations) { return []domain.Operation{} } end := offset + limit if end > len(operations) { end = len(operations) } return operations[offset:end] } func windowAuthSessions(sessions []domain.AuthSessionInfo, limit, offset int) []domain.AuthSessionInfo { if offset >= len(sessions) { return []domain.AuthSessionInfo{} } end := offset + limit if end > len(sessions) { end = len(sessions) } return sessions[offset:end] } func windowAuthTokens(tokens []domain.AuthTokenInfo, limit, offset int) []domain.AuthTokenInfo { if offset >= len(tokens) { return []domain.AuthTokenInfo{} } end := offset + limit if end > len(tokens) { end = len(tokens) } return tokens[offset:end] } func windowAuthUsers(users []domain.AuthUser, limit, offset int) []domain.AuthUser { if offset >= len(users) { return []domain.AuthUser{} } end := offset + limit if end > len(users) { end = len(users) } return users[offset:end] } func windowAuthGroups(groups []domain.AuthGroup, limit, offset int) []domain.AuthGroup { if offset >= len(groups) { return []domain.AuthGroup{} } end := offset + limit if end > len(groups) { end = len(groups) } return groups[offset:end] } func windowAuthPermissions(permissions []domain.AuthPermission, limit, offset int) []domain.AuthPermission { if offset >= len(permissions) { return []domain.AuthPermission{} } end := offset + limit if end > len(permissions) { end = len(permissions) } return permissions[offset:end] } func windowPorts(ports []domain.Port, limit, offset int) []domain.Port { if offset >= len(ports) { return []domain.Port{} } end := offset + limit if end > len(ports) { end = len(ports) } return ports[offset:end] } func windowAlarmRules(rules []domain.AlarmRule, limit, offset int) []domain.AlarmRule { if offset >= len(rules) { return []domain.AlarmRule{} } end := offset + limit if end > len(rules) { end = len(rules) } return rules[offset:end] } func windowServiceHealth(health []domain.ServiceHealth, limit, offset int) []domain.ServiceHealth { if offset >= len(health) { return []domain.ServiceHealth{} } end := offset + limit if end > len(health) { end = len(health) } return health[offset:end] } func (r *Router) handleLogRetention(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.retention.read", Target: "logs", Permission: "log.read"}); !ok { return } settings, err := r.store.LogRetention(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateLogRetention(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "log.retention.update", Target: "logs", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.LogRetentionUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateLogRetention(ctx, update) writeResult(w, result, err) } func (r *Router) handleRemoteSyslog(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "log.remote_syslog.read", Target: "remote_syslog", Permission: "log.read"}); !ok { return } settings, err := r.store.RemoteSyslog(req.Context()) writeResult(w, settings, err) } func (r *Router) handleUpdateRemoteSyslog(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "log.remote_syslog.update", Target: "remote_syslog", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var update domain.RemoteSyslogUpdate if !r.decodeCommandJSON(w, req, ctx, spec, &update) { return } result, err := r.store.UpdateRemoteSyslog(ctx, update) writeResult(w, result, err) } func (r *Router) handleExportLogs(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "log.export", Target: "logs", Permission: "log.export"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var exportRequest domain.LogExportRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) { return } if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) { return } operation, err := r.store.ExportLogs(ctx, exportRequest) writeResult(w, operation, err) } type streamingLogExporter interface { StreamLogExport(context.Context, domain.LogExportRequest, io.Writer) (domain.Operation, error) } type logAttachmentWriter struct { http.ResponseWriter filename string committed bool } func (writer *logAttachmentWriter) commit() { if writer.committed { return } contentType := "application/x-ndjson" if strings.HasSuffix(writer.filename, ".csv") { contentType = "text/csv; charset=utf-8" } if strings.HasSuffix(writer.filename, ".json") { contentType = "application/json" } writer.Header().Set("Content-Type", contentType) writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, writer.filename)) writer.Header().Set("X-Content-Type-Options", "nosniff") writer.WriteHeader(http.StatusOK) writer.committed = true } func (writer *logAttachmentWriter) Write(payload []byte) (int, error) { writer.commit() return writer.ResponseWriter.Write(payload) } func (r *Router) handleDownloadLogs(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "log.export", Target: "logs", Permission: "log.export"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var exportRequest domain.LogExportRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &exportRequest) { return } if exportRequest.Channel != "" && !r.requirePortAccess(w, ctx, strings.ToUpper(exportRequest.Channel), "read", spec) { return } exporter, ok := r.store.(streamingLogExporter) if !ok { writeError(w, http.StatusServiceUnavailable, "service_unavailable", "streaming log export is unavailable") return } logType := strings.ToLower(strings.TrimSpace(exportRequest.LogType)) if logType == "" { logType = "port" } format := strings.ToLower(strings.TrimSpace(exportRequest.Format)) if format == "" { format = "jsonl" } if format != "jsonl" && format != "json" && format != "csv" { writeError(w, http.StatusBadRequest, "invalid_request", "format must be csv, json, or jsonl") return } filename := fmt.Sprintf("tera-%s-logs-%s.%s", logType, time.Now().UTC().Format("20060102T150405Z"), format) attachment := &logAttachmentWriter{ResponseWriter: w, filename: filename} if _, err := exporter.StreamLogExport(ctx, exportRequest, attachment); err != nil { if !attachment.committed { writeResult(w, domain.Operation{}, err) } return } attachment.commit() } func (r *Router) handleSyncBrowserTime(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "time.sync_browser", Target: "time", Permission: "time.sync"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var syncRequest domain.BrowserTimeSyncRequest if !r.decodeCommandJSON(w, req, ctx, spec, &syncRequest) { return } operation, err := r.store.SyncBrowserTime(ctx, syncRequest) writeResult(w, operation, err) } func (r *Router) handleSyncNTPTime(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "time.sync_ntp", Target: "time", Permission: "time.sync"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } operation, err := r.store.SyncNTPTime(ctx) writeResult(w, operation, err) } func (r *Router) handleDiagnosticsSubtree(w http.ResponseWriter, req *http.Request) { kind := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/diagnostics/"), "/") if kind != "network-test" && kind != "support-bundle" { writeError(w, http.StatusNotFound, "not_found", "diagnostics route not found") return } action := "diagnostics." + strings.ReplaceAll(strings.ToLower(kind), "-", "_") spec := commandSpec{Action: action, Target: "diagnostics", Permission: "diagnostics.run"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var diagnosticsRequest domain.DiagnosticsRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &diagnosticsRequest) { return } operation, err := r.store.RunDiagnostics(ctx, kind, diagnosticsRequest) writeResult(w, operation, err) } func (r *Router) handleSupportBundleDownload(w http.ResponseWriter, req *http.Request) { bundleID := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/diagnostics/support-bundles/"), "/") if bundleID == "" || strings.Contains(bundleID, "/") { writeError(w, http.StatusNotFound, "not_found", "support bundle route not found") return } spec := commandSpec{Action: "diagnostics.support_bundle.download", Target: bundleID, Permission: "diagnostics.run"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } artifact, err := r.store.SupportBundle(ctx, bundleID) if err != nil { writeResult(w, nil, err) return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", artifact.Filename)) w.Header().Set("Content-Length", strconv.Itoa(len(artifact.Content))) w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Checksum-SHA256", artifact.SHA256) w.WriteHeader(http.StatusOK) _, _ = w.Write(artifact.Content) } func (r *Router) handleDirectSubtree(w http.ResponseWriter, req *http.Request) { rest := strings.TrimPrefix(req.URL.Path, "/api/v1/direct/") parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) < 2 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found", "direct route not found") return } channel := strings.ToUpper(parts[0]) switch { case req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "sessions": spec := commandSpec{Action: "direct.session.open", Target: channel, Permission: "direct.view"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } var sessionRequest domain.DirectSessionRequest if !r.decodeOptionalCommandJSON(w, req, ctx, spec, &sessionRequest) { return } if strings.EqualFold(sessionRequest.Access, "writer") { spec.Permission = "direct.write" ctx, ok = r.authorizeCommand(w, req, spec) if !ok { return } } if !r.requirePortAccess(w, ctx, channel, directAccessForRequest(sessionRequest), spec) { return } if strings.EqualFold(sessionRequest.Access, "writer") && !r.requireDirectPortConnected(w, ctx, channel, spec) { return } session, err := r.store.OpenDirectSession(ctx, channel, sessionRequest) writeResult(w, session, err) case req.Method == http.MethodDelete && len(parts) == 3 && parts[1] == "sessions" && parts[2] != "": sessionID := parts[2] ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "direct.session.close", Target: channel, Permission: "direct.view"}) if !ok || !r.requirePortAccess(w, ctx, channel, "read", commandSpec{Action: "direct.session.close", Target: channel}) { return } session, err := r.store.CloseDirectSession(ctx, channel, sessionID) writeResult(w, session, err) case req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "writer": ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "direct.writer.claim", Target: channel, Permission: "direct.write"}) if !ok || !r.requirePortAccess(w, ctx, channel, "write", commandSpec{Action: "direct.writer.claim", Target: channel}) { return } session, err := r.store.ClaimDirectWriter(ctx, channel) writeResult(w, session, err) case req.Method == http.MethodDelete && len(parts) == 2 && parts[1] == "writer": ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "direct.writer.release", Target: channel, Permission: "direct.write"}) if !ok || !r.requirePortAccess(w, ctx, channel, "write", commandSpec{Action: "direct.writer.release", Target: channel}) { return } session, err := r.store.ReleaseDirectWriter(ctx, channel) writeResult(w, session, err) default: writeError(w, http.StatusNotFound, "not_found", "direct route not found") } } func (r *Router) handleApplyConfig(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.apply", Target: "config", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } if err := r.applyPendingSerialConfig(ctx); err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } networkApply, err := r.applyPendingNetworkConfig(ctx) if err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } result, err := r.store.ApplyConfig(ctx) if err != nil && networkApply != nil { r.rollbackAppliedNetwork(networkApply) } if err == nil { result.NetworkApply = domainNetworkApplyResult(networkApply) } writeResult(w, result, err) } func (r *Router) handleSaveConfig(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "config.save", Target: "config", Permission: "config.write"}) if !ok { return } result, err := r.store.SaveConfig(ctx) writeResult(w, result, err) } func (r *Router) handleSaveAndApplyConfig(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.save_and_apply", Target: "config", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } if err := r.applyPendingSerialConfig(ctx); err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } networkApply, err := r.applyPendingNetworkConfig(ctx) if err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } result, err := r.store.SaveAndApplyConfig(ctx) if err != nil && networkApply != nil { r.rollbackAppliedNetwork(networkApply) } if err == nil { result.NetworkApply = domainNetworkApplyResult(networkApply) } writeResult(w, result, err) } func (r *Router) handleDiscardConfig(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "config.discard", Target: "config", Permission: "config.write"}) if !ok { return } result, err := r.store.DiscardConfig(ctx) writeResult(w, result, err) } func (r *Router) handleSaveAndRestart(w http.ResponseWriter, req *http.Request) { spec := commandSpec{Action: "config.save_and_restart", Target: "config", Permission: "config.write"} ctx, ok := r.authorizeCommand(w, req, spec) if !ok { return } if err := r.applyPendingSerialConfig(ctx); err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } networkApply, err := r.applyPendingNetworkConfig(ctx) if err != nil { r.recordHardwareFailure(ctx, spec, err) writeResult(w, nil, err) return } operation, err := r.store.SaveAndRestart(ctx) if err != nil && networkApply != nil { r.rollbackAppliedNetwork(networkApply) } if err == nil && networkApply != nil { status, confirmErr := r.network.Confirm(ctx, networkApply.TransactionID) if confirmErr != nil { writeResult(w, nil, confirmErr) return } if confirmer, ok := r.store.(networkRuntimeConfirmer); ok { if confirmErr := confirmer.ConfirmNetworkRuntime(ctx, networkApply.TransactionID); confirmErr != nil { writeResult(w, nil, confirmErr) return } } r.recordNetworkTransaction(ctx, "network.confirm", networkApply.TransactionID, "succeeded", "confirmed before save-and-restart") if operation.Result == nil { operation.Result = make(map[string]any) } operation.Result["network_apply"] = networkApplyStatus(status) } if err == nil { rebootOperation, rebootErr := r.store.RebootSystem(ctx) if rebootErr != nil { writeResult(w, nil, fmt.Errorf("configuration was saved but device reboot could not be scheduled: %w", rebootErr)) return } if operation.Result == nil { operation.Result = make(map[string]any) } operation.Result["reboot_scheduled"] = true operation.Result["reboot_operation_id"] = rebootOperation.OperationID operation.Message = strings.TrimSpace(operation.Message + "; device reboot scheduled") } writeResult(w, operation, err) } func (r *Router) rollbackAppliedNetwork(networkApply *networkconfig.ApplyResult) { if networkApply == nil || networkApply.TransactionID == "" { return } status, err := r.network.Rollback(context.Background(), networkApply.TransactionID) if err == nil { _ = r.reconcileRolledBackNetwork(context.Background(), status) } } func (r *Router) applyPendingNetworkConfig(ctx context.Context) (*networkconfig.ApplyResult, error) { config, pending, err := r.pendingNetworkConfig(ctx) if err != nil || !pending { return nil, err } if err := r.confirmReachablePendingNetworkTransaction(ctx); err != nil { return nil, err } result, err := r.network.Apply(ctx, config) if err != nil { return nil, err } if preparer, ok := r.store.(networkRuntimePreparer); ok { if err := preparer.PrepareNetworkRuntimeApply(ctx, result.TransactionID); err != nil { _, _ = r.network.Rollback(context.Background(), result.TransactionID) return nil, err } } return &result, nil } // confirmReachablePendingNetworkTransaction settles a previous guarded // network apply before starting another one. Reaching an authenticated config // command proves that the management API is still reachable through the // currently active network configuration, which is the same condition used by // the Web UI's explicit confirmation flow. func (r *Router) confirmReachablePendingNetworkTransaction(ctx context.Context) error { status, err := r.network.Status(ctx) if err != nil { return err } if status.State != "pending_confirmation" || status.TransactionID == "" { return nil } confirmed, err := r.network.Confirm(ctx, status.TransactionID) if err != nil { r.recordNetworkTransaction(ctx, "network.confirm", status.TransactionID, "failed", err.Error()) return err } r.recordNetworkTransaction(ctx, "network.confirm", status.TransactionID, "succeeded", "confirmed by reachable configuration command before replacement") if confirmed.State == "pending_confirmation" { return errors.New("network transaction remains pending after confirmation") } return nil } func (r *Router) pendingNetworkConfig(ctx context.Context) (networkconfig.Config, bool, error) { snapshot, err := r.store.ConfigExport(ctx) if err != nil { return networkconfig.Config{}, false, err } hasPendingNetwork := snapshot.Pending.Network != nil hasPendingInterfaces := len(snapshot.Pending.NetworkInterfaces) > 0 if !hasPendingNetwork && !hasPendingInterfaces { return networkconfig.Config{}, false, nil } interfaces := make(map[string]domain.NetworkInterface, len(snapshot.Running.Network.Interfaces)) for _, item := range snapshot.Running.Network.Interfaces { interfaces[item.Name] = item } for _, item := range snapshot.Pending.NetworkInterfaces { interfaces[item.Name] = item } global := snapshot.Running.Network if snapshot.Pending.Network != nil { pending := snapshot.Pending.Network if pending.HostName != "" { global.HostName = pending.HostName } if pending.OperatingMode != "" { global.OperatingMode = pending.OperatingMode } global.Advanced = pending.Advanced global.DDNS = pending.DDNS if pending.OOB.PrimaryInterface != "" { global.OOB.PrimaryInterface = pending.OOB.PrimaryInterface } if pending.OOB.FallbackInterface != "" { global.OOB.FallbackInterface = pending.OOB.FallbackInterface } if pending.OOB.HealthCheckTarget != "" { global.OOB.HealthCheckTarget = pending.OOB.HealthCheckTarget } if pending.OOB.HealthCheckIntervalSecond != 0 { global.OOB.HealthCheckIntervalSecond = pending.OOB.HealthCheckIntervalSecond } if pending.OOB.FailureThreshold != 0 { global.OOB.FailureThreshold = pending.OOB.FailureThreshold } } config := networkconfig.Config{ HostName: global.HostName, OperatingMode: global.OperatingMode, Advanced: networkconfig.AdvancedConfig{ Mode: global.Advanced.Mode, BondMode: global.Advanced.BondMode, BondMii: global.Advanced.BondMii, BridgeSTP: global.Advanced.BridgeSTP, }, DDNS: networkconfig.DDNSConfig{ Enabled: global.DDNS.Enabled, Provider: global.DDNS.Provider, IPv4Enabled: global.DDNS.IPv4Enabled, IPv6Enabled: global.DDNS.IPv6Enabled, Username: global.DDNS.Username, Password: global.DDNS.Password, IPv4Domain: global.DDNS.IPv4Domain, IPv6Domain: global.DDNS.IPv6Domain, CheckIPURL: global.DDNS.CheckIPURL, CustomProvider: global.DDNS.CustomProvider, CustomPath: global.DDNS.CustomPath, }, OOBPrimaryInterface: global.OOB.PrimaryInterface, OOBFallbackInterface: global.OOB.FallbackInterface, OOBHealthCheckTarget: global.OOB.HealthCheckTarget, OOBHealthCheckSecond: global.OOB.HealthCheckIntervalSecond, OOBFailureThreshold: global.OOB.FailureThreshold, RoutingMode: global.OperatingMode, RollbackTimeoutSecond: global.RollbackTimeoutSeconds, Interfaces: make([]networkconfig.Interface, 0, len(interfaces)), } if config.RollbackTimeoutSecond == 0 { config.RollbackTimeoutSecond = networkconfig.DefaultRollbackSecond } for _, item := range interfaces { 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: append([]string(nil), item.DNSServers...), IPv6Mode: item.IPv6Mode, IPv6Address: item.IPv6Address, IPv6Gateway: item.IPv6Gateway, IPv6DNSServers: append([]string(nil), item.IPv6DNSServers...), Weight: item.Weight, }) } normalized, err := networkconfig.Normalize(config) if err != nil { return networkconfig.Config{}, false, err } return normalized, true, nil } func domainNetworkApplyResult(result *networkconfig.ApplyResult) *domain.NetworkApplyStatus { if result == nil { return nil } deadline := result.Deadline return &domain.NetworkApplyStatus{ TransactionID: result.TransactionID, State: result.State, Deadline: &deadline, } } func (r *Router) applyPendingSerialConfig(ctx context.Context) error { snapshot, err := r.store.ConfigExport(ctx) if err != nil { return err } running := make(map[string]domain.Port, len(snapshot.Running.Ports)) for _, port := range snapshot.Running.Ports { running[strings.ToUpper(port.Channel)] = port } configs := make([]serial.ChannelConfig, 0, len(snapshot.Pending.Ports)) captures := make([]serial.CaptureConfig, 0, len(snapshot.Pending.Ports)) operationChanged := false for _, pending := range snapshot.Pending.Ports { current, ok := running[strings.ToUpper(pending.Channel)] if !ok { return fmt.Errorf("serial configuration is missing running %s", pending.Channel) } if pending.BaudRate != current.BaudRate || pending.DataBits != current.DataBits || pending.Parity != current.Parity || pending.StopBits != current.StopBits || pending.FlowControl != current.FlowControl { configs = append(configs, serial.ChannelConfig{ Channel: pending.Channel, BaudRate: pending.BaudRate, DataBits: pending.DataBits, Parity: pending.Parity, StopBits: pending.StopBits, StopBitsMode: pending.StopBitsMode, FlowControl: pending.FlowControl, RTSControl: pending.RTSControl, DTRControl: pending.DTRControl, IgnoreJammed: pending.IgnoreJammed, }) } if pending.DataLogMode != current.DataLogMode || pending.DataPackage != current.DataPackage || pending.DataLog != current.DataLog { captures = append(captures, captureConfigFromPort(pending)) } if pending.Operation1 != current.Operation1 || pending.Operation2 != current.Operation2 || !slices.Equal(pending.RedirectTo, current.RedirectTo) { operationChanged = true } } if len(configs) > 0 { if err := r.applySerialLineConfig(ctx, configs); err != nil { return err } } if len(captures) > 0 { controller, ok := r.serialPorts.(serial.CaptureController) if !ok { return &serial.UnavailableError{Reason: "serial payload capture control is unavailable"} } if err := controller.ApplyCapture(ctx, captures); err != nil { return err } } if operationChanged { servicePorts := snapshot.Pending.Ports if provider, ok := r.store.(domain.PortServiceCredentialProvider); ok { servicePorts, err = provider.ResolvePortServiceCredentials(ctx, "pending", servicePorts) if err != nil { return err } } if err := r.portServices.Apply(ctx, servicePorts); err != nil { return err } } return nil } // applySerialLineConfig absorbs the short exclusive lease held by the // automatic CR response probe. A real debug/session lease remains active and // still fails after this bounded wait, so line settings are never changed // underneath an operator's live connection. func (r *Router) applySerialLineConfig(ctx context.Context, configs []serial.ChannelConfig) error { for attempt := 0; ; attempt++ { _, err := r.serialPorts.Apply(ctx, configs) if err == nil { return nil } if !errors.Is(err, serial.ErrStreamLeaseBusy) || attempt >= serialApplyLeaseRetryCount { return err } timer := time.NewTimer(serialApplyLeaseRetryInterval) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } } func (r *Router) applySerialPortConfig(ctx context.Context, port domain.Port) error { return r.applySerialPortsConfig(ctx, []domain.Port{port}) } func (r *Router) applySerialPortsConfig(ctx context.Context, ports []domain.Port) error { if len(ports) == 0 { return errors.New("port restart requires at least one channel") } configs := make([]serial.ChannelConfig, 0, len(ports)) captures := make([]serial.CaptureConfig, 0, len(ports)) for _, port := range ports { configs = append(configs, serial.ChannelConfig{ Channel: port.Channel, BaudRate: port.BaudRate, DataBits: port.DataBits, Parity: port.Parity, StopBits: port.StopBits, StopBitsMode: port.StopBitsMode, FlowControl: port.FlowControl, RTSControl: port.RTSControl, DTRControl: port.DTRControl, IgnoreJammed: port.IgnoreJammed, }) captures = append(captures, captureConfigFromPort(port)) } if err := r.applySerialLineConfig(ctx, configs); err != nil { return err } controller, ok := r.serialPorts.(serial.CaptureController) if !ok { return &serial.UnavailableError{Reason: "serial payload capture control is unavailable"} } return controller.ApplyCapture(ctx, captures) } func captureConfigFromPort(port domain.Port) serial.CaptureConfig { return serial.CaptureConfig{ Channel: port.Channel, Mode: port.DataLogMode, DataPackage: serial.CapturePackageConfig{ Mode: port.DataPackage.Mode, Length: port.DataPackage.Length, TimeoutMillis: port.DataPackage.TimeoutMillis, DelimiterProcess: port.DataPackage.DelimiterProcess, Delimiter1: port.DataPackage.Delimiter1, Delimiter2: port.DataPackage.Delimiter2, }, DataLog: serial.CaptureLogConfig{ Read: port.DataLog.Read, Send: port.DataLog.Send, ShowTimestamp: port.DataLog.ShowTimestamp, ShowDirection: port.DataLog.ShowDirection, ShowUsername: port.DataLog.ShowUsername, ShowIPAddress: port.DataLog.ShowIPAddress, ShowPort: port.DataLog.ShowPort, Format: port.DataLog.Format, ReadTrigger: serial.CaptureTriggerConfig{ Length: port.DataLog.ReadTrigger.Length, TimeoutMillis: port.DataLog.ReadTrigger.TimeoutMillis, Process: port.DataLog.ReadTrigger.Process, Character1: port.DataLog.ReadTrigger.Character1, Character2: port.DataLog.ReadTrigger.Character2, }, SendTrigger: serial.CaptureTriggerConfig{ Length: port.DataLog.SendTrigger.Length, TimeoutMillis: port.DataLog.SendTrigger.TimeoutMillis, Process: port.DataLog.SendTrigger.Process, Character1: port.DataLog.SendTrigger.Character1, Character2: port.DataLog.SendTrigger.Character2, }, }, } } func (r *Router) recordHardwareFailure(ctx context.Context, spec commandSpec, commandErr error) { _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{ Action: spec.Action, Target: spec.Target, Result: "failed", Message: commandErr.Error(), }) } func (r *Router) handleOperations(w http.ResponseWriter, req *http.Request) { if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "operation.read", Target: "operations", Permission: "operation.read"}); !ok { return } limit, offset, ok := parsePagination(w, req) if !ok { return } operations, err := r.store.Operations(req.Context()) operations = windowOperations(operations, limit, offset) writeResult(w, operations, err) } func (r *Router) handleOperation(w http.ResponseWriter, req *http.Request) { operationID := strings.Trim(strings.TrimPrefix(req.URL.Path, "/api/v1/operations/"), "/") if operationID == "" { writeError(w, http.StatusNotFound, "not_found", "operation route not found") return } if _, ok := r.authorizeCommand(w, req, commandSpec{Action: "operation.read", Target: operationID, Permission: "operation.read"}); !ok { return } operation, err := r.store.Operation(req.Context(), operationID) writeResult(w, operation, err) } func (r *Router) handleFirmwareStatus(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "firmware.read", Target: "firmware", Permission: "firmware.read"}) if !ok { return } status, err := r.firmware.Status(ctx) writeUpdateResult(w, status, err) } func (r *Router) handleFirmwareUpload(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "firmware.upload", Target: "firmware", Permission: "firmware.upload"}) if !ok { return } filename := strings.TrimSpace(req.Header.Get("X-Tera-Filename")) if req.ContentLength <= 0 || req.ContentLength > r.maxFirmwareBytes { writeError(w, http.StatusRequestEntityTooLarge, "update_size_invalid", "firmware package content length is missing or exceeds the configured maximum") return } if mediaType := strings.TrimSpace(strings.SplitN(req.Header.Get("Content-Type"), ";", 2)[0]); mediaType != "application/octet-stream" { writeError(w, http.StatusUnsupportedMediaType, "update_media_type_invalid", "firmware upload must use application/octet-stream") return } status, err := r.firmware.Upload(ctx, update.UploadRequest{ Filename: filename, ContentLength: req.ContentLength, Actor: domain.ActorName(ctx), Body: req.Body, }) result := "succeeded" message := "signed local firmware package validated" if err != nil { result = "failed" message = err.Error() } _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{Action: "firmware.upload", Target: filename, Result: result, Message: message}) writeUpdateResult(w, status, err) } func (r *Router) handleFirmwareApply(w http.ResponseWriter, req *http.Request) { ctx, ok := r.authorizeCommand(w, req, commandSpec{Action: "firmware.apply", Target: "firmware", Permission: "firmware.apply"}) if !ok { return } status, err := r.firmware.Apply(ctx, update.ApplyRequest{Actor: domain.ActorName(ctx), OperationID: req.Header.Get("X-Tera-Operation-ID")}) result := "succeeded" message := "firmware apply accepted" if err != nil { result = "rejected" message = err.Error() } _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{Action: "firmware.apply", Target: status.OperationID, Result: result, Message: message}) writeUpdateResult(w, status, err) } func (r *Router) authorizeCommand(w http.ResponseWriter, req *http.Request, spec commandSpec) (context.Context, bool) { actor, ok := r.authenticateActor(w, req) if !ok { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: spec.Action, Target: spec.Target, Result: "authentication_failed", Message: "authentication required", }) return req.Context(), false } if actor.MustChangePassword && spec.Action != "auth.password.change" { writeError(w, http.StatusForbidden, "password_change_required", "password must be changed before using this function") return req.Context(), false } if actor.Role == "port_operator" && strings.HasPrefix(spec.Action, "log.") { writeError(w, http.StatusForbidden, "authorization_denied", "port operators cannot access logs") return req.Context(), false } if !actor.Permissions[spec.Permission] { _, _ = r.store.RecordAudit(domain.WithActor(req.Context(), actor), domain.AuditEvent{ Action: spec.Action, Target: spec.Target, Result: "authorization_denied", Message: "actor is not authorized for " + spec.Permission, }) writeError(w, http.StatusForbidden, "authorization_denied", "actor is not authorized for "+spec.Permission) return req.Context(), false } return domain.WithActor(req.Context(), actor), true } func directAccessForRequest(request domain.DirectSessionRequest) string { if strings.EqualFold(request.Access, "writer") { return "write" } return "read" } const directRecentRXWindowMilliseconds int64 = 10_000 func directPortConnected(port domain.Port) bool { runtime := port.Runtime if runtime == nil || !runtime.HardwareAvailable { return false } if runtime.SessionActive || runtime.CRProbeUp { return true } return runtime.LastRXAt != nil && runtime.RXAgeMillis >= 0 && runtime.RXAgeMillis <= directRecentRXWindowMilliseconds } func (r *Router) requireDirectPortConnected(w http.ResponseWriter, ctx context.Context, channel string, spec commandSpec) bool { port, err := r.store.Port(ctx, channel) if err != nil { writeResult(w, nil, err) return false } ports := r.overlayPortRuntime([]domain.Port{port}, r.readSerialRuntime(ctx)) if len(ports) == 1 && directPortConnected(ports[0]) { return true } message := channel + " is not connected; Direct Mode writer requires an UP port" _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{ Action: spec.Action, Target: channel, Result: "rejected", Message: message, }) writeError(w, http.StatusConflict, "port_not_connected", message) return false } func (r *Router) requirePortAccess(w http.ResponseWriter, ctx context.Context, channel, access string, spec commandSpec) bool { if !isAPIChannel(channel) { return true } authorizer, ok := r.store.(domain.PortAuthorizer) if !ok { return true } actor, ok := domain.ActorFromContext(ctx) if !ok { return false } if err := authorizer.AuthorizePort(ctx, actor, channel, access); err != nil { _, _ = r.store.RecordAudit(ctx, domain.AuditEvent{Action: spec.Action, Target: channel, Result: "authorization_denied", Message: err.Error()}) writeError(w, http.StatusForbidden, "port_authorization_denied", err.Error()) return false } return true } func isAPIChannel(channel string) bool { if len(channel) != 4 || !strings.HasPrefix(channel, "CH") { return false } index, err := strconv.Atoi(channel[2:]) return err == nil && index >= 1 && index <= 48 } func (r *Router) filterAuthorizedPorts(ctx context.Context, ports []domain.Port, access string) []domain.Port { authorizer, ok := r.store.(domain.PortAuthorizer) actor, actorOK := domain.ActorFromContext(ctx) if !ok || !actorOK { return ports } result := make([]domain.Port, 0, len(ports)) for _, port := range ports { if authorizer.AuthorizePort(ctx, actor, port.Channel, access) == nil { result = append(result, port) } } return result } func (r *Router) authorizeAuthRead(w http.ResponseWriter, req *http.Request, action string) (context.Context, bool) { actor, ok := r.authenticateActor(w, req) if !ok { _, _ = r.store.RecordAudit(req.Context(), domain.AuditEvent{ Actor: "anonymous", Action: action, Target: "auth", Result: "authentication_failed", Message: "authentication required", }) return req.Context(), false } if !actor.Permissions["auth.read"] { _, _ = r.store.RecordAudit(domain.WithActor(req.Context(), actor), domain.AuditEvent{ Action: action, Target: "auth", Result: "authorization_denied", Message: "actor is not authorized for auth.read", }) writeError(w, http.StatusForbidden, "authorization_denied", "actor is not authorized for auth.read") return req.Context(), false } return domain.WithActor(req.Context(), actor), true } func writeResult(w http.ResponseWriter, value any, err error) { if err != nil { status := http.StatusInternalServerError code := "internal_error" if errors.Is(err, errNotFound{}) || strings.Contains(err.Error(), "not found") { status = http.StatusNotFound code = "not_found" } else if systemtime.IsUnavailable(err) { status = http.StatusServiceUnavailable code = "time_unavailable" } else if networkconfig.IsUnavailable(err) { status = http.StatusServiceUnavailable code = "network_unavailable" } else if strings.Contains(err.Error(), "support bundle service is unavailable") { status = http.StatusServiceUnavailable code = "diagnostics_unavailable" } else if alarmdelivery.IsUnavailable(err) { status = http.StatusServiceUnavailable code = "alarm_delivery_unavailable" } else if greenled.IsUnavailable(err) || serial.IsUnavailable(err) { status = http.StatusServiceUnavailable code = "hardware_unavailable" } else if strings.Contains(err.Error(), "owned by another actor") { status = http.StatusForbidden code = "authorization_denied" } else if strings.Contains(err.Error(), "category") { status = http.StatusBadRequest code = "validation_failed" } else if strings.Contains(err.Error(), "alarm delivery queue is full") || strings.Contains(err.Error(), "already owned") || strings.Contains(err.Error(), "auth token limit") || strings.Contains(err.Error(), "viewer limit") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "last enabled administrator") { status = http.StatusConflict code = "conflict" } else if strings.Contains(err.Error(), "CH01 through CH48") || strings.Contains(err.Error(), "address") || strings.Contains(err.Error(), "alarm delivery") || strings.Contains(err.Error(), "auth group name") || strings.Contains(err.Error(), "auth token") || strings.Contains(err.Error(), "auth_key_stored") || strings.Contains(err.Error(), "audit_days") || strings.Contains(err.Error(), "baud_rate") || strings.Contains(err.Error(), "browser_time") || strings.Contains(err.Error(), "channels") || strings.Contains(err.Error(), "community_stored") || strings.Contains(err.Error(), "config file") || strings.Contains(err.Error(), "config restore") || strings.Contains(err.Error(), "credential_stored") || strings.Contains(err.Error(), "data_bits") || strings.Contains(err.Error(), "data_log_mode") || strings.Contains(err.Error(), "delivery") || strings.Contains(err.Error(), "diagnostic max_hops") || strings.Contains(err.Error(), "diagnostic port") || strings.Contains(err.Error(), "diagnostic target") || strings.Contains(err.Error(), "direct access") || strings.Contains(err.Error(), "direction") || strings.Contains(err.Error(), "dwell_ms") || strings.Contains(err.Error(), "email notification") || strings.Contains(err.Error(), "enabled") || strings.Contains(err.Error(), "encrypted config") || strings.Contains(err.Error(), "external auth") || strings.Contains(err.Error(), "flow_control") || strings.Contains(err.Error(), "format_version") || strings.Contains(err.Error(), "from") || strings.Contains(err.Error(), "gateway") || strings.Contains(err.Error(), "idle_timeout_minutes") || strings.Contains(err.Error(), "interface") || strings.Contains(err.Error(), "interval_minutes") || strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "log export") || strings.Contains(err.Error(), "log retention") || strings.Contains(err.Error(), "management access") || strings.Contains(err.Error(), "max_output_bytes") || strings.Contains(err.Error(), "mode must be") || strings.Contains(err.Error(), "network") || strings.Contains(err.Error(), "ntp") || strings.Contains(err.Error(), "offset") || strings.Contains(err.Error(), "operation_") || strings.Contains(err.Error(), "parity") || strings.Contains(err.Error(), "password") || strings.Contains(err.Error(), "password_stored") || strings.Contains(err.Error(), "pending_queue_limit") || strings.Contains(err.Error(), "port_days") || strings.Contains(err.Error(), "preserve_on_factory_reset") || strings.Contains(err.Error(), "primary_method") || strings.Contains(err.Error(), "privacy_key_stored") || strings.Contains(err.Error(), "protected_usage_warning_pct") || strings.Contains(err.Error(), "remote syslog") || strings.Contains(err.Error(), "retry_count") || strings.Contains(err.Error(), "retry_interval_seconds") || strings.Contains(err.Error(), "role") || strings.Contains(err.Error(), "session_id") || strings.Contains(err.Error(), "severity") || strings.Contains(err.Error(), "smtp") || strings.Contains(err.Error(), "snmp agent") || strings.Contains(err.Error(), "snmp trap") || strings.Contains(err.Error(), "stop_bits") || strings.Contains(err.Error(), "support bundle id") || strings.Contains(err.Error(), "system_days") || strings.Contains(err.Error(), "target_id") || strings.Contains(err.Error(), "time mode") || strings.Contains(err.Error(), "time settings") || strings.Contains(err.Error(), "timeout_seconds") || strings.Contains(err.Error(), "timezone") || strings.Contains(err.Error(), "tls certificate") || strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "trigger") || strings.Contains(err.Error(), "username") { status = http.StatusBadRequest code = "validation_failed" } writeError(w, status, code, err.Error()) return } writeJSON(w, http.StatusOK, value) } func writeUpdateResult(w http.ResponseWriter, status update.Status, err error) { if err == nil { writeJSON(w, http.StatusOK, status) return } code := update.Code(err) httpStatus := http.StatusInternalServerError switch code { case "update_unavailable": httpStatus = http.StatusServiceUnavailable case "update_capacity_failed": httpStatus = http.StatusInsufficientStorage case "update_conflict", "update_apply_unsupported": httpStatus = http.StatusConflict case "update_size_invalid": httpStatus = http.StatusRequestEntityTooLarge case "update_validation_failed", "update_receive_failed", "update_media_type_invalid": httpStatus = http.StatusBadRequest } if code == "" { code = "update_failed" } writeError(w, httpStatus, code, err.Error()) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, domain.ErrorResponse{ Error: domain.ErrorBody{ Code: code, Message: message, }, }) } type errNotFound struct{} func (errNotFound) Error() string { return "not found" }