package api import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "slices" "strconv" "strings" "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 { 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 serialRuntime serialRuntimeCache } type commandSpec struct { Action string Target string Permission string } type RouterOptions struct { 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 } 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, } 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) { 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/login", "/api/v1/auth/logout", "/api/v1/auth/captcha": 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("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("GET /api/v1/logs/links", r.handleLinkLogEvents) r.mux.HandleFunc("GET /api/v1/logs/policy", r.handleLogPolicy) 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 { ports = r.overlayPortRuntime(ports, r.readSerialRuntime(req.Context())) 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) { 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.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, } events, err := r.store.PortLogEvents(req.Context(), 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 } 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 } writer.Header().Set("Content-Type", "application/x-ndjson") 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 } 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" } filename := fmt.Sprintf("tera-%s-logs-%s.jsonl", logType, time.Now().UTC().Format("20060102T150405Z")) 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.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" }