You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

3279 lines
116 KiB

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" }