mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 07:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688e1e8311 |
+13
-7
@@ -41,7 +41,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), 2000)
|
||||
logger := slog.New(logs)
|
||||
|
||||
args := os.Args[1:]
|
||||
@@ -206,7 +206,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
|
||||
deviceLogger := logger.With("category", "hardware")
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: deviceLogger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -227,7 +228,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}()
|
||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||
defer cancelPolling()
|
||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
||||
go pollDeviceSnapshots(pollContext, deviceLogger, database, deviceManager)
|
||||
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||
go collectCellularTraffic(pollContext, logger, database)
|
||||
go persistLogsToStore(pollContext, logger, logs, database)
|
||||
@@ -640,7 +641,7 @@ func configureVoWiFiRuntime(
|
||||
Devices: mapper,
|
||||
}
|
||||
manager := vowifiruntime.New(vowifiruntime.Options{
|
||||
Logger: logger,
|
||||
Logger: logger.With("category", "vowifi"),
|
||||
OnState: projector.Save,
|
||||
Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) {
|
||||
deviceConfig, err := database.Device(factoryContext, deviceID)
|
||||
@@ -717,7 +718,7 @@ func protectVoWiFiStartupRadioWithRetry(
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
@@ -759,14 +760,15 @@ func newVoWiFiOrchestrator(
|
||||
if apn == "" {
|
||||
apn = "ims"
|
||||
}
|
||||
vowifiLogger := logger.With("category", "vowifi", "device_id", deviceConfig.ID)
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
APN: apn, Logger: vowifiLogger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
Logger: logger,
|
||||
Logger: vowifiLogger,
|
||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||
// alternate transport only if no SIP response was observed.
|
||||
@@ -983,6 +985,10 @@ func persistLogsToStore(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if loghub.IsHTTPAccessEntry(entry) {
|
||||
continue
|
||||
}
|
||||
entry = loghub.SanitizeEntry(entry)
|
||||
var fields json.RawMessage
|
||||
if len(entry.Fields) > 0 {
|
||||
if raw, err := json.Marshal(entry.Fields); err == nil {
|
||||
|
||||
@@ -206,6 +206,11 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
|
||||
type discoveryEvent struct {
|
||||
connected bool
|
||||
candidate modem.Candidate
|
||||
}
|
||||
events := make([]discoveryEvent, 0)
|
||||
manager.mu.Lock()
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate.ID) == "" {
|
||||
@@ -218,8 +223,12 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
candidate: candidate,
|
||||
discovered: true,
|
||||
}
|
||||
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||
continue
|
||||
}
|
||||
if !state.discovered {
|
||||
events = append(events, discoveryEvent{connected: true, candidate: candidate})
|
||||
}
|
||||
if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() {
|
||||
state.resetClientOnLock = true
|
||||
}
|
||||
@@ -231,10 +240,28 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
if state.discovered {
|
||||
events = append(events, discoveryEvent{candidate: state.candidate})
|
||||
}
|
||||
state.discovered = false
|
||||
stale = append(stale, state)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if manager.logger != nil {
|
||||
for _, event := range events {
|
||||
message := "hardware disconnected"
|
||||
if event.connected {
|
||||
message = "hardware connected"
|
||||
}
|
||||
manager.logger.Info(message,
|
||||
"event", "hardware.discovery",
|
||||
"device_id", event.candidate.ID,
|
||||
"hardware_kind", event.candidate.HardwareKind,
|
||||
"vendor_id", event.candidate.VendorID,
|
||||
"product_id", event.candidate.ProductID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, state := range stale {
|
||||
state.opMu.Lock()
|
||||
@@ -382,6 +409,11 @@ func (manager *Manager) setResult(
|
||||
return
|
||||
}
|
||||
previousError := state.lastError
|
||||
var previousSnapshot *Snapshot
|
||||
if state.snapshot != nil {
|
||||
value := *state.snapshot
|
||||
previousSnapshot = &value
|
||||
}
|
||||
if snapshot != nil {
|
||||
value := *snapshot
|
||||
value.Warnings = append([]string(nil), snapshot.Warnings...)
|
||||
@@ -394,6 +426,13 @@ func (manager *Manager) setResult(
|
||||
state.lastError = ""
|
||||
}
|
||||
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
|
||||
registrationChanged := snapshot != nil && manager.logger != nil &&
|
||||
(previousSnapshot == nil ||
|
||||
previousSnapshot.RegistrationStatus != snapshot.RegistrationStatus ||
|
||||
previousSnapshot.OperatorCode != snapshot.OperatorCode ||
|
||||
previousSnapshot.AccessTech != snapshot.AccessTech ||
|
||||
previousSnapshot.PSAttached != snapshot.PSAttached ||
|
||||
previousSnapshot.SIMStatus != snapshot.SIMStatus)
|
||||
backend := state.backend
|
||||
hardwareKind := state.candidate.HardwareKind
|
||||
manager.mu.Unlock()
|
||||
@@ -406,6 +445,21 @@ func (manager *Manager) setResult(
|
||||
"error", HardwareErrorDetail(err),
|
||||
)
|
||||
}
|
||||
if registrationChanged {
|
||||
manager.logger.Info(
|
||||
"cellular registration state changed",
|
||||
"category", "network",
|
||||
"event", "network.registration",
|
||||
"device_id", id,
|
||||
"sim_status", snapshot.SIMStatus,
|
||||
"registration_status", snapshot.RegistrationStatus,
|
||||
"registration_source", snapshot.RegistrationSource,
|
||||
"operator", snapshot.OperatorName,
|
||||
"operator_code", snapshot.OperatorCode,
|
||||
"access_technology", snapshot.AccessTech,
|
||||
"packet_service_attached", snapshot.PSAttached,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
|
||||
|
||||
+23
-3
@@ -9,9 +9,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry is the stable, secret-neutral representation exposed by the log API.
|
||||
// Callers remain responsible for never adding credentials or keying material
|
||||
// to slog attributes.
|
||||
// Entry is the stable, centrally-redacted representation exposed by the log
|
||||
// API. The Hub sanitizes both the downstream handler and the captured entry so
|
||||
// diagnostic logs can be safely exported by users.
|
||||
type Entry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Level string `json:"level"`
|
||||
@@ -58,6 +58,7 @@ func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
}
|
||||
|
||||
func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
||||
record = sanitizeRecord(record)
|
||||
err := h.next.Handle(ctx, record)
|
||||
fields := make(map[string]any)
|
||||
for _, attr := range h.attrs {
|
||||
@@ -81,6 +82,7 @@ func (h *Hub) Handle(ctx context.Context, record slog.Record) error {
|
||||
}
|
||||
|
||||
func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
attrs = sanitizeAttrs(attrs)
|
||||
nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...)
|
||||
return &Hub{
|
||||
next: h.next.WithAttrs(attrs),
|
||||
@@ -180,6 +182,24 @@ func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) {
|
||||
return channel, cancel
|
||||
}
|
||||
|
||||
// Clear drops captured history and every entry currently queued for live and
|
||||
// persistence subscribers. Subscribers stay connected for future events.
|
||||
func (h *Hub) Clear() {
|
||||
h.core.mu.Lock()
|
||||
h.core.entries = h.core.entries[:0]
|
||||
for _, subscriber := range h.core.subscribers {
|
||||
for {
|
||||
select {
|
||||
case <-subscriber:
|
||||
continue
|
||||
default:
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
h.core.mu.Unlock()
|
||||
}
|
||||
|
||||
func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package loghub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +32,51 @@ func TestHubHistoryFiltersAndBounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRedactsDownstreamAndHistoryAndPreservesErrors(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
hub := New(slog.NewJSONHandler(&output, nil), 100)
|
||||
logger := slog.New(hub).With("imsi", "234159611634973")
|
||||
logger.Warn(
|
||||
"delivery to +447700900123 failed",
|
||||
"iccid", "8944101234567890123",
|
||||
"peer", "+447700900456",
|
||||
"error", errors.New("modem rejected MSISDN=447700900789 with +CMS ERROR: 305"),
|
||||
)
|
||||
|
||||
entry := hub.History(1, slog.LevelDebug, "")[0]
|
||||
if strings.Contains(entry.Message, "447700900123") {
|
||||
t.Fatalf("message was not redacted: %q", entry.Message)
|
||||
}
|
||||
for _, key := range []string{"imsi", "iccid", "peer"} {
|
||||
if value := entry.Fields[key]; !strings.Contains(value.(string), "REDACTED") {
|
||||
t.Fatalf("%s = %#v, want redacted", key, value)
|
||||
}
|
||||
}
|
||||
errorText, ok := entry.Fields["error"].(string)
|
||||
if !ok || !strings.Contains(errorText, "+CMS ERROR: 305") || strings.Contains(errorText, "447700900789") {
|
||||
t.Fatalf("error = %#v, want original modem error with identity redacted", entry.Fields["error"])
|
||||
}
|
||||
if raw := output.String(); strings.Contains(raw, "234159611634973") || strings.Contains(raw, "447700900") {
|
||||
t.Fatalf("downstream output leaked an identity: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEntryProtectsLegacyNestedFields(t *testing.T) {
|
||||
entry := SanitizeEntry(Entry{
|
||||
Message: "incoming SIP from sip:[email protected]",
|
||||
Fields: map[string]any{
|
||||
"details": map[string]any{"associated_number": "+447700900456", "status": "registered"},
|
||||
},
|
||||
})
|
||||
if strings.Contains(entry.Message, "447700900123") {
|
||||
t.Fatalf("message = %q", entry.Message)
|
||||
}
|
||||
details := entry.Fields["details"].(map[string]any)
|
||||
if strings.Contains(details["associated_number"].(string), "447700900456") {
|
||||
t.Fatalf("nested field leaked: %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubSubscription(t *testing.T) {
|
||||
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
entries, cancel := hub.Subscribe(1)
|
||||
@@ -47,3 +95,29 @@ func TestHubSubscription(t *testing.T) {
|
||||
t.Fatal("timed out waiting for log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubClearDropsHistoryAndQueuedEntries(t *testing.T) {
|
||||
hub := New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
entries, cancel := hub.Subscribe(4)
|
||||
defer cancel()
|
||||
logger := slog.New(hub)
|
||||
logger.Info("before clear")
|
||||
hub.Clear()
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("history after Clear = %#v", history)
|
||||
}
|
||||
select {
|
||||
case entry := <-entries:
|
||||
t.Fatalf("queued entry survived Clear: %#v", entry)
|
||||
default:
|
||||
}
|
||||
logger.Info("after clear")
|
||||
select {
|
||||
case entry := <-entries:
|
||||
if entry.Message != "after clear" {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber did not remain active after Clear")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package loghub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
sipIdentityPattern = regexp.MustCompile(`(?i)\b(sips?|tel):([^@;>,\s]+)(@[^;>,\s]+)?`)
|
||||
internationalPhonePattern = regexp.MustCompile(`(?:\+|00)[0-9][0-9 ()-]{5,}[0-9]`)
|
||||
longDigitsPattern = regexp.MustCompile(`\b[0-9]{7,22}\b`)
|
||||
labeledIdentityPattern = regexp.MustCompile(`(?i)\b(iccid|imsi|msisdn|imei|eid)\s*([=:])\s*([a-z0-9+_-]{7,})`)
|
||||
)
|
||||
|
||||
// IsHTTPAccessEntry identifies legacy request-traffic entries. Access traffic
|
||||
// is intentionally excluded from the user diagnostic log surface.
|
||||
func IsHTTPAccessEntry(entry Entry) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Message), "http request") {
|
||||
return true
|
||||
}
|
||||
category, _ := entry.Fields["category"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(category), "http_access")
|
||||
}
|
||||
|
||||
// SanitizeEntry also protects records that were persisted by an older build
|
||||
// before central redaction was introduced.
|
||||
func SanitizeEntry(entry Entry) Entry {
|
||||
entry.Message = RedactString(entry.Message)
|
||||
entry.Caller = RedactString(entry.Caller)
|
||||
if entry.Fields != nil {
|
||||
entry.Fields = sanitizeMap(entry.Fields)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// RedactString masks common telecom identities while retaining enough of the
|
||||
// suffix to correlate repeated events in an exported diagnostic log.
|
||||
func RedactString(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
value = labeledIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := labeledIdentityPattern.FindStringSubmatch(match)
|
||||
return parts[1] + parts[2] + maskToken(parts[3])
|
||||
})
|
||||
value = sipIdentityPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||
parts := sipIdentityPattern.FindStringSubmatch(match)
|
||||
domain := parts[3]
|
||||
return parts[1] + ":" + maskToken(parts[2]) + domain
|
||||
})
|
||||
value = internationalPhonePattern.ReplaceAllStringFunc(value, maskToken)
|
||||
return longDigitsPattern.ReplaceAllStringFunc(value, maskToken)
|
||||
}
|
||||
|
||||
func sanitizeRecord(record slog.Record) slog.Record {
|
||||
clean := slog.NewRecord(record.Time, record.Level, RedactString(record.Message), record.PC)
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
clean.AddAttrs(sanitizeAttr(attr))
|
||||
return true
|
||||
})
|
||||
return clean
|
||||
}
|
||||
|
||||
func sanitizeAttrs(attrs []slog.Attr) []slog.Attr {
|
||||
clean := make([]slog.Attr, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
clean = append(clean, sanitizeAttr(attr))
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func sanitizeAttr(attr slog.Attr) slog.Attr {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return attr
|
||||
}
|
||||
if sensitiveKey(attr.Key) {
|
||||
return slog.String(attr.Key, maskToken(valueText(attr.Value.Any())))
|
||||
}
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
children := attr.Value.Group()
|
||||
return slog.Group(attr.Key, attrsToAny(sanitizeAttrs(children))...)
|
||||
}
|
||||
switch attr.Value.Kind() {
|
||||
case slog.KindString:
|
||||
return slog.String(attr.Key, RedactString(attr.Value.String()))
|
||||
case slog.KindAny:
|
||||
return slog.Any(attr.Key, sanitizeAny(attr.Value.Any(), attr.Key))
|
||||
default:
|
||||
return attr
|
||||
}
|
||||
}
|
||||
|
||||
func attrsToAny(attrs []slog.Attr) []any {
|
||||
values := make([]any, len(attrs))
|
||||
for index := range attrs {
|
||||
values[index] = attrs[index]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func sanitizeAny(value any, key string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if sensitiveKey(key) {
|
||||
return maskToken(valueText(value))
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case error:
|
||||
return RedactString(typed.Error())
|
||||
case string:
|
||||
return RedactString(typed)
|
||||
case []byte:
|
||||
return RedactString(string(typed))
|
||||
case json.RawMessage:
|
||||
var decoded any
|
||||
if json.Unmarshal(typed, &decoded) == nil {
|
||||
return sanitizeAny(decoded, key)
|
||||
}
|
||||
return RedactString(string(typed))
|
||||
case map[string]any:
|
||||
return sanitizeMap(typed)
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for index := range typed {
|
||||
result[index] = sanitizeAny(typed[index], key)
|
||||
}
|
||||
return result
|
||||
case time.Time, time.Duration:
|
||||
return value
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(value)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct || rv.Kind() == reflect.Pointer) {
|
||||
if raw, err := json.Marshal(value); err == nil {
|
||||
var decoded any
|
||||
if json.Unmarshal(raw, &decoded) == nil {
|
||||
return sanitizeAny(decoded, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stringer, ok := value.(fmt.Stringer); ok {
|
||||
return RedactString(stringer.String())
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizeMap(source map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
result[key] = sanitizeAny(value, key)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sensitiveKey(key string) bool {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return unicode.ToLower(r)
|
||||
}
|
||||
return -1
|
||||
}, key)
|
||||
if strings.Contains(normalized, "password") || strings.Contains(normalized, "passwd") ||
|
||||
strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") ||
|
||||
strings.Contains(normalized, "cookie") || strings.Contains(normalized, "authorization") ||
|
||||
strings.Contains(normalized, "privateidentity") || strings.Contains(normalized, "publicidentity") ||
|
||||
strings.Contains(normalized, "associatednumber") || strings.Contains(normalized, "sipuri") {
|
||||
return true
|
||||
}
|
||||
switch normalized {
|
||||
case "iccid", "imsi", "imei", "eid", "supi", "suci", "msisdn", "phone", "phonenumber",
|
||||
"number", "caller", "called", "callee", "recipient", "peer", "from", "to":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func valueText(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if err, ok := value.(error); ok {
|
||||
return err.Error()
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
|
||||
func maskToken(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
runes := []rune(value)
|
||||
digits := make([]rune, 0, 4)
|
||||
for index := len(runes) - 1; index >= 0 && len(digits) < 4; index-- {
|
||||
if unicode.IsDigit(runes[index]) {
|
||||
digits = append(digits, runes[index])
|
||||
}
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
|
||||
digits[left], digits[right] = digits[right], digits[left]
|
||||
}
|
||||
return "[REDACTED:" + string(digits) + "]"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -21,6 +22,20 @@ func (s *Server) recordAudit(
|
||||
outcome string,
|
||||
remoteAddr string,
|
||||
) {
|
||||
level := slog.LevelInfo
|
||||
if !strings.EqualFold(strings.TrimSpace(outcome), "success") {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Log(ctx, level, "user operation",
|
||||
"category", auditLogCategory(action),
|
||||
"event", action,
|
||||
"actor", actor,
|
||||
"entity_type", entityType,
|
||||
"entity_id", entityID,
|
||||
"outcome", outcome,
|
||||
)
|
||||
}
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
@@ -34,7 +49,24 @@ func (s *Server) recordAudit(
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("write audit event failed", "action", action, "error", err)
|
||||
s.logger.Warn("write audit event failed", "category", "system", "action", action, "raw_error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func auditLogCategory(action string) string {
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
switch {
|
||||
case strings.Contains(action, ".sms") || strings.HasPrefix(action, "sms."):
|
||||
return "sms"
|
||||
case strings.Contains(action, ".call") || strings.HasPrefix(action, "call."):
|
||||
return "call"
|
||||
case strings.Contains(action, "vowifi") || strings.Contains(action, "ims"):
|
||||
return "vowifi"
|
||||
case strings.Contains(action, "device") || strings.Contains(action, "esim") ||
|
||||
strings.Contains(action, ".at.") || strings.Contains(action, ".ussd"):
|
||||
return "hardware"
|
||||
default:
|
||||
return "operation"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("VoWiFi call operation failed",
|
||||
"category", "call", "event", "call."+action,
|
||||
"device_id", config.ID, "number", number, "call_id", callID,
|
||||
"transport", transport, "raw_error", err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
@@ -156,6 +161,11 @@ func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config
|
||||
return true
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
||||
s.logger.Warn("cellular call operation rejected",
|
||||
"category", "call", "event", "call."+action,
|
||||
"device_id", config.ID, "number", number, "transport", transport,
|
||||
"modem_final", response.Final, "raw_response", response.Text(),
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -98,11 +98,21 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
|
||||
if notification.Time.IsZero() {
|
||||
notification.Time = time.Now().UTC()
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Info("incoming call detected",
|
||||
"category", "call",
|
||||
"event", "call.incoming",
|
||||
"device_id", notification.DeviceID,
|
||||
"caller", notification.Caller,
|
||||
"called", notification.Called,
|
||||
"transport", notification.Environment,
|
||||
)
|
||||
}
|
||||
|
||||
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||
s.logger.Debug("suppressed duplicate incoming call notification", "category", "call", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -137,7 +147,7 @@ func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCa
|
||||
}
|
||||
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||
s.logger.Warn("send incoming call notification", "category", "call", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "raw_error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1002,11 @@ func (s *Server) handleVoWiFiReconnect(
|
||||
}
|
||||
|
||||
func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
||||
s.logger.Warn("VoWiFi operation failed",
|
||||
"category", "vowifi",
|
||||
"event", "vowifi.operation_failed",
|
||||
"raw_error", err,
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, vowifiruntime.ErrNotRegistered):
|
||||
writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime")
|
||||
@@ -1012,7 +1017,6 @@ func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, vowifi.ErrNotRunning):
|
||||
writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running")
|
||||
default:
|
||||
s.logger.Warn("VoWiFi action rejected", "error", err)
|
||||
writeError(w, http.StatusBadGateway, "vowifi_error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1070,6 +1074,13 @@ func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) boo
|
||||
text += "\n"
|
||||
}
|
||||
text += commandErr.Final
|
||||
s.logger.Warn("AT command rejected by modem",
|
||||
"category", "hardware",
|
||||
"event", "hardware.at_rejected",
|
||||
"device_id", id,
|
||||
"modem_final", commandErr.Final,
|
||||
"raw_response", text,
|
||||
)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"response": text,
|
||||
@@ -1509,6 +1520,11 @@ func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool
|
||||
}
|
||||
|
||||
func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
s.logger.Warn("hardware operation failed",
|
||||
"category", "hardware",
|
||||
"event", "hardware.operation_failed",
|
||||
"raw_error", device.HardwareErrorDetail(err),
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, device.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present")
|
||||
@@ -1547,9 +1563,6 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
|
||||
default:
|
||||
// Preserve the hardware failure reason in the operator-visible log while
|
||||
// keeping AT payloads and long APDU material out of it.
|
||||
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
|
||||
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,24 @@ func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
if r.Method == http.MethodDelete {
|
||||
clearedAt := time.Now().UTC()
|
||||
if s.logs != nil {
|
||||
s.logs.Clear()
|
||||
}
|
||||
deleted, err := s.store.ClearLogEvents(r.Context(), clearedAt)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{"cleared": true, "deleted": deleted},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("lines"))
|
||||
@@ -170,7 +187,9 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
// backs the live stream).
|
||||
entries := []loghub.Entry{}
|
||||
if s.store != nil {
|
||||
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit})
|
||||
events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{
|
||||
Limit: limit, ExcludeMessage: "http request",
|
||||
})
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -179,11 +198,17 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if storedLogLevel(event.Level) < minimum {
|
||||
continue
|
||||
}
|
||||
entry := storedLogToEntry(event)
|
||||
entry := loghub.SanitizeEntry(storedLogToEntry(event))
|
||||
if loghub.IsHTTPAccessEntry(entry) {
|
||||
continue
|
||||
}
|
||||
if search != "" && !storedLogContains(entry, search) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if len(entries) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
// ListLogEvents is newest-first; present chronologically.
|
||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||
@@ -283,7 +308,8 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if logLevel(entry.Level) < minimum {
|
||||
entry = loghub.SanitizeEntry(entry)
|
||||
if loghub.IsHTTPAccessEntry(entry) || logLevel(entry.Level) < minimum {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Write([]byte("event: log\ndata: ")); err != nil {
|
||||
@@ -308,7 +334,7 @@ func logLevel(value string) slog.Level {
|
||||
return slog.LevelError
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "debug":
|
||||
case "debug", "all", "":
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleLogHistoryDeleteClearsMemoryAndDatabase(t *testing.T) {
|
||||
server := newSettingsTestServer(t)
|
||||
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
server.logs = hub
|
||||
server.logger = slog.New(hub)
|
||||
server.logger.Info("memory log")
|
||||
if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{
|
||||
Level: "info", Message: "persisted log",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil)
|
||||
server.handleLogHistory(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("memory history after clear = %#v", history)
|
||||
}
|
||||
count, err := server.store.CountLogEvents(context.Background())
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("persisted count after clear = %d, %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,17 @@ func parseLoggingConfig(config loggingConfig) (loggingConfig, error) {
|
||||
if config.Count < 1 {
|
||||
config.Count = 10000
|
||||
}
|
||||
if config.Count > store.MaxLogEvents {
|
||||
config.Count = store.MaxLogEvents
|
||||
}
|
||||
if config.Days < 1 {
|
||||
config.Days = 30
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited.
|
||||
// loadLoggingConfig reads the persisted retention policy. "unlimited" means
|
||||
// no user-selected limit below the global 10,000-row hard ceiling.
|
||||
func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig {
|
||||
config := defaultLoggingConfig()
|
||||
setting, err := s.store.AppSetting(ctx, loggingSettingKey)
|
||||
@@ -64,13 +68,17 @@ func (s *Server) applyLogRetention(ctx context.Context) error {
|
||||
switch config.Mode {
|
||||
case "days":
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour)
|
||||
_, err := s.store.PruneLogEvents(ctx, cutoff)
|
||||
if _, err := s.store.PruneLogEvents(ctx, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||
return err
|
||||
case "count":
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, config.Count)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
_, err := s.store.PruneLogEventsToCount(ctx, store.MaxLogEvents)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +124,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"count": config.Count,
|
||||
"days": config.Days,
|
||||
"stored_logs": stored,
|
||||
"max_logs": store.MaxLogEvents,
|
||||
},
|
||||
})
|
||||
case http.MethodPut:
|
||||
@@ -152,6 +161,7 @@ func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"count": config.Count,
|
||||
"days": config.Days,
|
||||
"stored_logs": stored,
|
||||
"max_logs": store.MaxLogEvents,
|
||||
},
|
||||
})
|
||||
default:
|
||||
|
||||
@@ -199,6 +199,16 @@ func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingCountIsClampedToHardLimit(t *testing.T) {
|
||||
config, err := parseLoggingConfig(loggingConfig{Mode: "count", Count: store.MaxLogEvents + 500})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Count != store.MaxLogEvents {
|
||||
t.Fatalf("count = %d, want %d", config.Count, store.MaxLogEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockoutViaHTTP(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
for i := 0; i < 4; i++ {
|
||||
|
||||
+47
-14
@@ -160,7 +160,7 @@ func New(options Options) (*Server, error) {
|
||||
mux.HandleFunc("/", server.handleSPA)
|
||||
|
||||
server.handler = server.recoverPanics(
|
||||
server.securityHeaders(server.accessControl(server.logRequests(mux))),
|
||||
server.securityHeaders(server.accessControl(server.logUserOperation(mux))),
|
||||
)
|
||||
return server, nil
|
||||
}
|
||||
@@ -557,16 +557,14 @@ func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
type operationStatusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
func (w *operationStatusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (w *statusWriter) WriteHeader(status int) {
|
||||
func (w *operationStatusWriter) WriteHeader(status int) {
|
||||
if w.status != 0 {
|
||||
return
|
||||
}
|
||||
@@ -574,25 +572,60 @@ func (w *statusWriter) WriteHeader(status int) {
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
// logUserOperation records state-changing API actions, not request traffic.
|
||||
// GET/HEAD polling, assets, health checks and the live log stream are never
|
||||
// emitted, keeping the diagnostic page focused on actions a user initiated.
|
||||
func (s *Server) logUserOperation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
writer := &statusWriter{ResponseWriter: w}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/auth/") || strings.HasPrefix(r.URL.Path, "/api/logs/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
writer := &operationStatusWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(writer, r)
|
||||
status := writer.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
s.logger.Info(
|
||||
"http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
level := slog.LevelInfo
|
||||
outcome := "success"
|
||||
message := "user operation completed"
|
||||
if status >= http.StatusBadRequest {
|
||||
level = slog.LevelWarn
|
||||
outcome = "failed"
|
||||
message = "user operation failed"
|
||||
}
|
||||
s.logger.Log(r.Context(), level, message,
|
||||
"category", operationPathCategory(r.URL.Path),
|
||||
"event", "user.operation",
|
||||
"operation", strings.TrimPrefix(r.URL.Path, "/api/"),
|
||||
"outcome", outcome,
|
||||
"status", status,
|
||||
"duration", time.Since(startedAt),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func operationPathCategory(path string) string {
|
||||
path = strings.ToLower(path)
|
||||
switch {
|
||||
case strings.Contains(path, "/sms"):
|
||||
return "sms"
|
||||
case strings.Contains(path, "/call"):
|
||||
return "call"
|
||||
case strings.Contains(path, "/vowifi") || strings.Contains(path, "/ims"):
|
||||
return "vowifi"
|
||||
case strings.Contains(path, "/network") || strings.Contains(path, "/operator"):
|
||||
return "network"
|
||||
case strings.Contains(path, "/device") || strings.Contains(path, "/esim") ||
|
||||
strings.Contains(path, "/ussd") || strings.Contains(path, "/at"):
|
||||
return "hardware"
|
||||
default:
|
||||
return "operation"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
@@ -18,9 +19,36 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestUserOperationLoggerExcludesReadTraffic(t *testing.T) {
|
||||
hub := loghub.New(slog.NewTextHandler(io.Discard, nil), 100)
|
||||
server := &Server{logger: slog.New(hub)}
|
||||
handler := server.logUserOperation(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/devices", nil))
|
||||
if history := hub.History(10, slog.LevelDebug, ""); len(history) != 0 {
|
||||
t.Fatalf("GET traffic produced diagnostic logs: %#v", history)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPatch, "/api/devices/dev1/network", nil))
|
||||
history := hub.History(10, slog.LevelDebug, "")
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("mutation log count = %d, want 1", len(history))
|
||||
}
|
||||
if history[0].Message != "user operation completed" || history[0].Fields["category"] != "network" {
|
||||
t.Fatalf("mutation log = %#v", history[0])
|
||||
}
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodDelete, "/api/logs/history", nil))
|
||||
if history = hub.History(10, slog.LevelDebug, ""); len(history) != 1 {
|
||||
t.Fatalf("log clear endpoint produced an operation log: %#v", history)
|
||||
}
|
||||
}
|
||||
|
||||
type testApplication struct {
|
||||
server *httptest.Server
|
||||
client *http.Client
|
||||
|
||||
@@ -369,16 +369,26 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
if sendErr != nil {
|
||||
data["retry_safe"] = false
|
||||
if result.PartsAccepted > 0 {
|
||||
s.logger.Warn("multipart SMS was only partially accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "parts_attempted", result.PartsAttempted,
|
||||
"parts_accepted", result.PartsAccepted, "raw_error", sendErr,
|
||||
)
|
||||
data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
return
|
||||
}
|
||||
s.logger.Warn(
|
||||
"SMS submission failed after modem interaction",
|
||||
"category", "sms",
|
||||
"event", "sms.submission",
|
||||
"device_id", request.DeviceID,
|
||||
"peer", request.Phone,
|
||||
"transport", "cellular_at",
|
||||
"parts_attempted", result.PartsAttempted,
|
||||
"parts_accepted", result.PartsAccepted,
|
||||
"error", sendErr,
|
||||
"raw_error", sendErr,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
@@ -390,6 +400,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !result.AllPartsAccepted {
|
||||
s.logger.Warn("SMS submission was not confirmed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "modem_final", result.ModemFinal,
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "sms_submission_unconfirmed",
|
||||
@@ -399,6 +415,11 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
s.logger.Info("SMS submission accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", request.DeviceID, "peer", request.Phone,
|
||||
"transport", "cellular_at", "parts", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
}
|
||||
|
||||
@@ -475,6 +496,12 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
"outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
|
||||
}
|
||||
if sendErr != nil {
|
||||
s.logger.Warn("IMS SMS submission failed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
"raw_error", sendErr,
|
||||
)
|
||||
data["retry_safe"] = false
|
||||
data["warning"] = sendErr.Error()
|
||||
if result.PartsAccepted == 0 {
|
||||
@@ -489,6 +516,11 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
}
|
||||
}
|
||||
if !result.AllPartsAccepted && result.PartsAccepted == 0 {
|
||||
s.logger.Warn("IMS SMS submission was not confirmed",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": apiError{
|
||||
Code: "ims_sms_submission_unconfirmed",
|
||||
@@ -498,6 +530,19 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
})
|
||||
return
|
||||
}
|
||||
if result.AllPartsAccepted {
|
||||
s.logger.Info("IMS SMS submission accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts", result.PartsAccepted,
|
||||
)
|
||||
} else {
|
||||
s.logger.Warn("multipart IMS SMS was only partially accepted",
|
||||
"category", "sms", "event", "sms.submission",
|
||||
"device_id", deviceID, "peer", result.To, "transport", "ims",
|
||||
"parts_attempted", result.PartsAttempted, "parts_accepted", result.PartsAccepted,
|
||||
)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
|
||||
}
|
||||
|
||||
@@ -653,7 +698,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
"delivery_status": message.DeliveryStatus,
|
||||
"data_coding_scheme": message.DataCodingScheme,
|
||||
})
|
||||
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
saved, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: messageID,
|
||||
DeviceID: config.ID,
|
||||
ModemIMEI: modemIMEI,
|
||||
@@ -670,7 +715,14 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
Extra: extra,
|
||||
})
|
||||
if saveErr != nil {
|
||||
s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
|
||||
s.logger.Warn("persist modem SMS failed", "category", "sms", "device_id", config.ID, "raw_error", saveErr)
|
||||
} else if saved.Direction == "inbound" && saved.CreatedAt.Unix() == saved.UpdatedAt.Unix() {
|
||||
s.logger.Info("cellular SMS received",
|
||||
"category", "sms", "event", "sms.received",
|
||||
"device_id", config.ID, "peer", saved.Peer,
|
||||
"transport", "cellular_at", "encoding", message.Encoding,
|
||||
"parts", saved.PartsTotal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -770,6 +822,6 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("database operation failed", "error", err)
|
||||
s.logger.Error("database operation failed", "category", "system", "event", "store.operation_failed", "raw_error", err)
|
||||
writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
|
||||
}
|
||||
|
||||
@@ -1017,6 +1017,15 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||
t.Fatalf("log filter result = %+v, %v", logs, err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(ctx, LogEvent{
|
||||
Time: recent, Level: "info", Message: " HTTP REQUEST ",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logs, err = database.ListLogEvents(ctx, LogFilter{Level: "info", ExcludeMessage: "http request"})
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "ready" {
|
||||
t.Fatalf("excluded log filter result = %+v, %v", logs, err)
|
||||
}
|
||||
auditDeleted, logDeleted, err := database.PruneEvents(
|
||||
ctx,
|
||||
old.Add(time.Minute),
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaxLogEvents is the hard storage ceiling. Every new row beyond this limit
|
||||
// replaces the oldest row regardless of the optional, stricter retention rule.
|
||||
const MaxLogEvents = 10000
|
||||
|
||||
func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) {
|
||||
value.Action = strings.TrimSpace(value.Action)
|
||||
if value.Action == "" {
|
||||
@@ -123,6 +127,8 @@ func auditEvent(row rowScanner) (AuditEvent, error) {
|
||||
}
|
||||
|
||||
func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
value.Level = strings.ToLower(strings.TrimSpace(value.Level))
|
||||
if value.Level == "" {
|
||||
return LogEvent{}, errors.New("log level is required")
|
||||
@@ -137,7 +143,18 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
||||
if value.Time.IsZero() {
|
||||
value.Time = time.Now().UTC()
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
value.Fields = fields
|
||||
if !s.logClearedAt.IsZero() && !value.Time.After(s.logClearedAt) {
|
||||
// The entry was queued before a user cleared the log. Silently discard it
|
||||
// so an in-flight persistence worker cannot resurrect cleared history.
|
||||
return value, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return LogEvent{}, fmt.Errorf("begin log append: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO log_events (event_time, level, message, caller, fields_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields))
|
||||
@@ -148,7 +165,17 @@ func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, e
|
||||
if err != nil {
|
||||
return LogEvent{}, fmt.Errorf("read log event id: %w", err)
|
||||
}
|
||||
value.Fields = fields
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM log_events
|
||||
WHERE id <= COALESCE((
|
||||
SELECT id FROM log_events ORDER BY id DESC LIMIT 1 OFFSET ?
|
||||
), 0)
|
||||
`, MaxLogEvents); err != nil {
|
||||
return LogEvent{}, fmt.Errorf("enforce log event limit: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return LogEvent{}, fmt.Errorf("commit log append: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -159,6 +186,10 @@ func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent
|
||||
clauses = append(clauses, `level = ?`)
|
||||
args = append(args, strings.ToLower(filter.Level))
|
||||
}
|
||||
if filter.ExcludeMessage != "" {
|
||||
clauses = append(clauses, `LOWER(TRIM(message)) <> ?`)
|
||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.ExcludeMessage)))
|
||||
}
|
||||
if !filter.Since.IsZero() {
|
||||
clauses = append(clauses, `event_time >= ?`)
|
||||
args = append(args, filter.Since.UTC().Unix())
|
||||
@@ -236,6 +267,29 @@ func (s *Store) CountLogEvents(ctx context.Context) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ClearLogEvents permanently removes all persisted logs. Entries timestamped
|
||||
// at or before clearedAt are also rejected if they were already queued by the
|
||||
// asynchronous persistence worker.
|
||||
func (s *Store) ClearLogEvents(ctx context.Context, clearedAt time.Time) (int64, error) {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
if clearedAt.IsZero() {
|
||||
clearedAt = time.Now().UTC()
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM log_events`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear log events: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read cleared log count: %w", err)
|
||||
}
|
||||
if clearedAt.After(s.logClearedAt) {
|
||||
s.logClearedAt = clearedAt
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the
|
||||
// rest. keep <= 0 deletes everything.
|
||||
func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendLogEventEnforcesHardLimit(t *testing.T) {
|
||||
database, err := Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if _, err := database.db.ExecContext(context.Background(), `
|
||||
WITH RECURSIVE sequence(value) AS (
|
||||
SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value <= ?
|
||||
)
|
||||
INSERT INTO log_events(event_time, level, message, caller, fields_json)
|
||||
SELECT value, 'info', 'seed-' || value, '', '{}' FROM sequence
|
||||
`, MaxLogEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "newest", Time: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := database.CountLogEvents(context.Background())
|
||||
if err != nil || count != MaxLogEvents {
|
||||
t.Fatalf("CountLogEvents = %d, %v; want %d", count, err, MaxLogEvents)
|
||||
}
|
||||
logs, err := database.ListLogEvents(context.Background(), LogFilter{Limit: 1})
|
||||
if err != nil || len(logs) != 1 || logs[0].Message != "newest" {
|
||||
t.Fatalf("newest log = %#v, %v", logs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLogEventsRejectsAlreadyQueuedEntries(t *testing.T) {
|
||||
database, err := Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
cutoff := time.Now().UTC()
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "existing", Time: cutoff.Add(-time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := database.ClearLogEvents(context.Background(), cutoff)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("ClearLogEvents = %d, %v", deleted, err)
|
||||
}
|
||||
late, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: "queued-before-clear", Time: cutoff.Add(-time.Millisecond),
|
||||
})
|
||||
if err != nil || late.ID != 0 {
|
||||
t.Fatalf("old queued append = %+v, %v", late, err)
|
||||
}
|
||||
if _, err := database.AppendLogEvent(context.Background(), LogEvent{
|
||||
Level: "info", Message: fmt.Sprintf("new-%d", MaxLogEvents), Time: cutoff.Add(time.Millisecond),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := database.CountLogEvents(context.Background())
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("CountLogEvents = %d, %v; want 1", count, err)
|
||||
}
|
||||
}
|
||||
@@ -467,11 +467,12 @@ type LogEvent struct {
|
||||
}
|
||||
|
||||
type LogFilter struct {
|
||||
Level string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
Level string
|
||||
ExcludeMessage string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
type CardPolicy struct {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -19,7 +20,9 @@ var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// Store owns the SQLite connection used by the process.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
logMu sync.Mutex
|
||||
logClearedAt time.Time
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
|
||||
@@ -146,6 +146,8 @@ func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Cal
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS call started",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"direction", "outgoing",
|
||||
"identity_source", identitySource,
|
||||
"target_scheme", strings.ToLower(strings.TrimSuffix(strings.SplitN(target, ":", 2)[0], ":")),
|
||||
@@ -228,6 +230,8 @@ func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey)
|
||||
} else {
|
||||
if ackErr := session.sendRejectedInviteACK(call, response); ackErr != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Warn("IMS rejected INVITE ACK failed",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"error", safeSIPDiagnostic(ackErr.Error()),
|
||||
@@ -368,6 +372,14 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.Logger != nil {
|
||||
session.provider.config.Logger.Info("IMS incoming call received",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"caller", call.public.Number,
|
||||
"call_id", call.public.ID,
|
||||
)
|
||||
}
|
||||
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
||||
calledNumber := identityNumber(request.value("To"))
|
||||
if calledNumber == "" {
|
||||
@@ -837,6 +849,8 @@ func (session *Session) logCallResponse(response *sipResponse, diagnostic string
|
||||
return
|
||||
}
|
||||
session.provider.config.Logger.Info("IMS call response",
|
||||
"category", "call",
|
||||
"device_id", session.request.DeviceID,
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"sip_status", response.StatusCode,
|
||||
"diagnostic", diagnostic,
|
||||
|
||||
@@ -911,7 +911,7 @@ func (session *Session) logInboundSMS(level slog.Level, message string, request
|
||||
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
|
||||
logger = session.provider.config.Logger
|
||||
}
|
||||
base := []any{"device_id", session.request.DeviceID}
|
||||
base := []any{"category", "sms", "subsystem", "ims", "device_id", session.request.DeviceID}
|
||||
if request != nil {
|
||||
base = append(base,
|
||||
"call_id", strings.TrimSpace(request.value("Call-ID")),
|
||||
@@ -1091,6 +1091,8 @@ func (session *Session) logOutboundSMS(level slog.Level, message string, attribu
|
||||
}
|
||||
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
|
||||
base := []any{
|
||||
"category", "sms",
|
||||
"subsystem", "ims",
|
||||
"device_id", session.request.DeviceID,
|
||||
"home_plmn", plmn,
|
||||
"transport", session.transport,
|
||||
|
||||
@@ -11,12 +11,13 @@ import { message } from "../ui/message";
|
||||
type RetentionMode = LoggingSettings["mode"];
|
||||
|
||||
// 运行日志保留策略:默认不限制,可按条数或天数限制,服务端据此裁剪历史日志。
|
||||
export function LogRetentionCard() {
|
||||
export function LogRetentionCard({ refreshKey = 0 }: { refreshKey?: number }) {
|
||||
const { t } = useI18n();
|
||||
const [mode, setMode] = useState<RetentionMode>("unlimited");
|
||||
const [count, setCount] = useState(10000);
|
||||
const [days, setDays] = useState(30);
|
||||
const [storedLogs, setStoredLogs] = useState(0);
|
||||
const [maxLogs, setMaxLogs] = useState(10000);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -25,6 +26,7 @@ export function LogRetentionCard() {
|
||||
setCount(data.count);
|
||||
setDays(data.days);
|
||||
setStoredLogs(data.storedLogs);
|
||||
setMaxLogs(data.maxLogs || 10000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,14 +43,14 @@ export function LogRetentionCard() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apply]);
|
||||
}, [apply, refreshKey]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = await updateLoggingSettings({
|
||||
mode,
|
||||
count: Math.max(1, Math.trunc(count) || 1),
|
||||
count: Math.min(maxLogs, Math.max(1, Math.trunc(count) || 1)),
|
||||
days: Math.max(1, Math.trunc(days) || 1),
|
||||
});
|
||||
apply(data);
|
||||
@@ -58,7 +60,7 @@ export function LogRetentionCard() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [mode, count, days, apply]);
|
||||
}, [mode, count, days, maxLogs, apply]);
|
||||
|
||||
const onNumber = (setter: (value: number) => void) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const parsed = parseInt(event.target.value, 10);
|
||||
@@ -78,7 +80,7 @@ export function LogRetentionCard() {
|
||||
className="w-32"
|
||||
disabled={loading}
|
||||
options={[
|
||||
{ value: "unlimited", label: t("不限制") },
|
||||
{ value: "unlimited", label: t("最多 10000 条") },
|
||||
{ value: "count", label: t("按条数") },
|
||||
{ value: "days", label: t("按天数") },
|
||||
]}
|
||||
@@ -88,6 +90,7 @@ export function LogRetentionCard() {
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxLogs}
|
||||
value={count === 0 ? "" : count}
|
||||
onChange={onNumber(setCount)}
|
||||
disabled={loading}
|
||||
@@ -110,8 +113,9 @@ export function LogRetentionCard() {
|
||||
</label>
|
||||
) : null}
|
||||
<span className="text-sm text-gray-400">
|
||||
{t("当前已存储")} {storedLogs} {t("条")}
|
||||
{t("当前已存储")} {storedLogs} / {maxLogs} {t("条")}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{t("达到上限后自动删除最旧日志")}</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -498,6 +498,22 @@ export const EN_DICT: Record<string, string> = {
|
||||
"连接中断,正在尝试重连…": "Connection lost, reconnecting…",
|
||||
已导出日志: "Logs exported",
|
||||
"查看系统运行日志,支持过滤和搜索": "View system runtime logs with filtering and search",
|
||||
设备与业务日志: "Device & Service Logs",
|
||||
"记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码":
|
||||
"Hardware, network registration, WiFi Calling, SMS, calls, and user operations. Sensitive identities are automatically redacted.",
|
||||
业务分类: "Category",
|
||||
全部业务: "All Services",
|
||||
硬件与模块: "Hardware & Modem",
|
||||
驻网: "Network Registration",
|
||||
通话: "Calls",
|
||||
用户操作: "User Operations",
|
||||
系统错误: "System",
|
||||
"最多 10000 条": "Up to 10,000",
|
||||
达到上限后自动删除最旧日志: "The oldest logs are automatically removed at the limit",
|
||||
"此操作会永久删除服务端保存的全部日志,无法恢复。": "This permanently deletes all logs stored on the server and cannot be undone.",
|
||||
"确认清空日志?": "Clear all logs?",
|
||||
日志已清空: "Logs cleared",
|
||||
清空日志失败: "Failed to clear logs",
|
||||
继续: "Resume",
|
||||
暂停: "Pause",
|
||||
已连接: "Connected",
|
||||
|
||||
+113
-26
@@ -16,11 +16,13 @@ import { Switch } from "../components/ui/Switch";
|
||||
import { Select } from "../components/ui/Select";
|
||||
import { Input } from "../components/ui/Input";
|
||||
import { message } from "../components/ui/message";
|
||||
import { confirmDialog } from "../components/ui/MessageBox";
|
||||
import { LogRetentionCard } from "../components/logs/LogRetentionCard";
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
type Level = "all" | "debug" | "info" | "warn" | "error";
|
||||
type Category = "all" | "hardware" | "network" | "vowifi" | "sms" | "call" | "operation" | "system";
|
||||
|
||||
const LEVEL_OPTIONS: { value: Level; label: string }[] = [
|
||||
{ value: "all", label: "全部" },
|
||||
@@ -30,6 +32,17 @@ const LEVEL_OPTIONS: { value: Level; label: string }[] = [
|
||||
{ value: "error", label: "ERROR" },
|
||||
];
|
||||
|
||||
const CATEGORY_OPTIONS: { value: Category; label: string }[] = [
|
||||
{ value: "all", label: "全部业务" },
|
||||
{ value: "hardware", label: "硬件与模块" },
|
||||
{ value: "network", label: "驻网" },
|
||||
{ value: "vowifi", label: "WiFi Calling" },
|
||||
{ value: "sms", label: "短信" },
|
||||
{ value: "call", label: "通话" },
|
||||
{ value: "operation", label: "用户操作" },
|
||||
{ value: "system", label: "系统错误" },
|
||||
];
|
||||
|
||||
function levelColor(level: string): string {
|
||||
switch (level.toLowerCase()) {
|
||||
case "debug":
|
||||
@@ -52,6 +65,42 @@ function fieldsText(fields: LogEntry["fields"]): string {
|
||||
return typeof fields === "string" ? fields : JSON.stringify(fields);
|
||||
}
|
||||
|
||||
function logFields(entry: LogEntry): Record<string, unknown> {
|
||||
return entry.fields && typeof entry.fields === "object" ? entry.fields : {};
|
||||
}
|
||||
|
||||
function logCategory(entry: LogEntry): Exclude<Category, "all"> {
|
||||
const explicit = String(logFields(entry).category ?? "").toLowerCase();
|
||||
if (CATEGORY_OPTIONS.some((item) => item.value === explicit && item.value !== "all")) {
|
||||
return explicit as Exclude<Category, "all">;
|
||||
}
|
||||
const text = `${entry.message} ${fieldsText(entry.fields)}`.toLowerCase();
|
||||
if (/\bsms\b|短信|tpdu|rp-data|rpdu/.test(text)) return "sms";
|
||||
if (/incoming call|\bcall\b|invite|来电|通话/.test(text)) return "call";
|
||||
if (/vowifi|wi-?fi calling|\bims\b|\bike\b|epdg|ipsec/.test(text)) return "vowifi";
|
||||
if (/registration|operator|network|驻网|注册网络/.test(text)) return "network";
|
||||
if (/device|modem|hardware|sim|uicc|esim|qmi|串口|模块|设备/.test(text)) return "hardware";
|
||||
if (/operation|audit|setting|操作/.test(text)) return "operation";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function categoryColor(category: Exclude<Category, "all">): string {
|
||||
switch (category) {
|
||||
case "hardware": return "bg-cyan-500/15 text-cyan-300";
|
||||
case "network": return "bg-emerald-500/15 text-emerald-300";
|
||||
case "vowifi": return "bg-sky-500/15 text-sky-300";
|
||||
case "sms": return "bg-violet-500/15 text-violet-300";
|
||||
case "call": return "bg-pink-500/15 text-pink-300";
|
||||
case "operation": return "bg-amber-500/15 text-amber-300";
|
||||
default: return "bg-gray-500/20 text-gray-300";
|
||||
}
|
||||
}
|
||||
|
||||
function isHTTPAccessLog(entry: LogEntry): boolean {
|
||||
return entry.message.trim().toLowerCase() === "http request" ||
|
||||
String(logFields(entry).category ?? "").toLowerCase() === "http_access";
|
||||
}
|
||||
|
||||
// Reference renders a fixed YYYY-MM-DD HH:mm:ss timestamp.
|
||||
function displayTime(time: string): string {
|
||||
try {
|
||||
@@ -71,8 +120,11 @@ export default function LogsPage() {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [autoTail, setAutoTail] = useState(true);
|
||||
const [level, setLevel] = useState<Level>("all");
|
||||
const [category, setCategory] = useState<Category>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [connError, setConnError] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [retentionRefreshKey, setRetentionRefreshKey] = useState(0);
|
||||
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -80,6 +132,7 @@ export default function LogsPage() {
|
||||
const levelRef = useRef<Level>("all");
|
||||
|
||||
const appendLog = useCallback((entry: LogEntry) => {
|
||||
if (isHTTPAccessLog(entry)) return;
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next;
|
||||
@@ -118,7 +171,7 @@ export default function LogsPage() {
|
||||
try {
|
||||
const res = await api<LogEntry[] | { logs?: LogEntry[] }>("/logs/history?lines=500");
|
||||
const list = Array.isArray(res) ? res : (res?.logs ?? []);
|
||||
setLogs(list.slice(-MAX_LOGS));
|
||||
setLogs(list.filter((entry) => !isHTTPAccessLog(entry)).slice(-MAX_LOGS));
|
||||
} catch {
|
||||
/* 历史回填失败不阻塞实时流 */
|
||||
} finally {
|
||||
@@ -164,13 +217,34 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [connect]);
|
||||
|
||||
const clearLogs = useCallback(() => setLogs([]), []);
|
||||
const clearLogs = useCallback(async () => {
|
||||
const confirmed = await confirmDialog(
|
||||
t("此操作会永久删除服务端保存的全部日志,无法恢复。"),
|
||||
t("确认清空日志?"),
|
||||
{ type: "warning", confirmText: t("清空"), cancelText: t("取消") },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
await api<{ cleared: boolean; deleted: number }>("/logs/history", { method: "DELETE" });
|
||||
setLogs([]);
|
||||
setRetentionRefreshKey((value) => value + 1);
|
||||
message.success(t("日志已清空"));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t("清空日志失败"));
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = logs;
|
||||
if (level !== "all") {
|
||||
list = list.filter((e) => e.level.toLowerCase() === level.toLowerCase());
|
||||
}
|
||||
if (category !== "all") {
|
||||
list = list.filter((entry) => logCategory(entry) === category);
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
@@ -181,14 +255,14 @@ export default function LogsPage() {
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [logs, level, search]);
|
||||
}, [logs, level, category, search]);
|
||||
|
||||
const exportLogs = useCallback(() => {
|
||||
const text = filtered
|
||||
.map((v) => {
|
||||
const time = new Date(v.time).toLocaleString();
|
||||
const fields = v.fields ? ` ${fieldsText(v.fields)}` : "";
|
||||
return `[${time}] ${v.level.toUpperCase().padEnd(5)} ${v.caller ?? ""} ${v.message}${fields}`;
|
||||
return `[${time}] ${v.level.toUpperCase().padEnd(5)} [${logCategory(v)}] ${v.caller ?? ""} ${v.message}${fields}`;
|
||||
})
|
||||
.join("\n");
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
@@ -204,8 +278,8 @@ export default function LogsPage() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<PageHeader
|
||||
title={t("实时日志")}
|
||||
subtitle={t("查看系统运行日志,支持过滤和搜索")}
|
||||
title={t("设备与业务日志")}
|
||||
subtitle={t("记录硬件、驻网、WiFi Calling、短信、通话和用户操作;敏感信息已自动打码")}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
@@ -216,7 +290,7 @@ export default function LogsPage() {
|
||||
>
|
||||
{paused ? t("继续") : t("暂停")}
|
||||
</Button>
|
||||
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
|
||||
<Button loading={clearing} onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
|
||||
{t("清空")}
|
||||
</Button>
|
||||
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
|
||||
@@ -255,6 +329,13 @@ export default function LogsPage() {
|
||||
className="w-full sm:w-40"
|
||||
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||
/>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(v) => setCategory(v as Category)}
|
||||
placeholder={t("业务分类")}
|
||||
className="w-full sm:w-44"
|
||||
options={CATEGORY_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -279,7 +360,7 @@ export default function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogRetentionCard />
|
||||
<LogRetentionCard refreshKey={retentionRefreshKey} />
|
||||
|
||||
<div className="ui-card overflow-hidden">
|
||||
<div
|
||||
@@ -291,24 +372,30 @@ export default function LogsPage() {
|
||||
{loading ? t("等待日志...") : connected ? t("等待日志...") : t("未连接到日志流")}
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map((entry, i) => (
|
||||
<div key={i} className="py-0.5 hover:bg-white/5 px-2 -mx-2 rounded whitespace-nowrap">
|
||||
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
|
||||
<span className={cx("font-bold ml-1.5", levelColor(entry.level))}>
|
||||
{entry.level.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className="text-indigo-400 inline-block max-w-48 truncate align-bottom ml-1.5"
|
||||
title={entry.caller ?? ""}
|
||||
>
|
||||
{entry.caller ?? ""}
|
||||
</span>
|
||||
<span className="text-gray-100 ml-1.5">{entry.message}</span>
|
||||
{entry.fields ? (
|
||||
<span className="text-amber-300/70 ml-1.5">{fieldsText(entry.fields)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{filtered.map((entry, i) => {
|
||||
const entryCategory = logCategory(entry);
|
||||
const fields = logFields(entry);
|
||||
const hasRawError = fields.raw_error !== undefined || fields.error !== undefined || fields.raw_response !== undefined;
|
||||
return (
|
||||
<div key={`${entry.time}-${i}`} className="border-b border-white/5 px-2 py-2 -mx-2 last:border-0 hover:bg-white/5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-gray-500">[{displayTime(entry.time)}]</span>
|
||||
<span className={cx("font-bold", levelColor(entry.level))}>{entry.level.toUpperCase()}</span>
|
||||
<span className={cx("rounded px-1.5 py-0.5 text-[11px]", categoryColor(entryCategory))}>
|
||||
{t(CATEGORY_OPTIONS.find((item) => item.value === entryCategory)?.label ?? "系统错误")}
|
||||
</span>
|
||||
{entry.caller ? <span className="max-w-48 truncate text-indigo-400" title={entry.caller}>{entry.caller}</span> : null}
|
||||
<span className="break-words text-gray-100">{entry.message}</span>
|
||||
</div>
|
||||
{entry.fields ? (
|
||||
<pre className={cx(
|
||||
"mt-1 whitespace-pre-wrap break-all pl-2 text-xs leading-5",
|
||||
hasRawError ? "border-l-2 border-red-500/60 text-red-200" : "text-amber-300/70",
|
||||
)}>{typeof entry.fields === "string" ? entry.fields : JSON.stringify(entry.fields, null, 2)}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -432,12 +432,13 @@ export interface SecuritySettings {
|
||||
clientAllowed: boolean;
|
||||
}
|
||||
|
||||
// 运行日志保留策略:默认不限制,可按条数或天数限制。
|
||||
// 运行日志保留策略:全局硬上限 10000 条,可配置更严格的条数或天数限制。
|
||||
export interface LoggingSettings {
|
||||
mode: "unlimited" | "count" | "days";
|
||||
count: number;
|
||||
days: number;
|
||||
storedLogs: number;
|
||||
maxLogs: number;
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
|
||||
Reference in New Issue
Block a user