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.

3345 lines
119 KiB

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