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