package app import ( "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) // --------------------------------------------------------------------- // trace: чтение + запись // --------------------------------------------------------------------- // EN: Trace log endpoints and helpers for GUI/operator visibility. // EN: Includes plain tail, filtered JSON views, append API, and bounded tail reader. // RU: Эндпоинты и хелперы trace-логов для GUI/оператора. // RU: Включает plain tail, фильтрованные JSON-режимы, append API и безопасный tail-reader. func handleTraceTailPlain(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } lines := tailFile(traceLogPath, defaultTraceTailMax) w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, strings.Join(lines, "\n")) } // --------------------------------------------------------------------- // trace-json // --------------------------------------------------------------------- // GET /api/v1/trace-json?mode=full|gui|events|smartdns func handleTraceJSON(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } mode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("mode"))) if mode == "" { mode = "full" } if mode == "events" { mode = "gui" } var lines []string switch mode { case "smartdns": // чисто SmartDNS-лог lines = tailFile(smartdnsLogPath, defaultTraceTailMax) case "gui": // Events: только человеко-читабельные события/ошибки/команды. full := tailFile(traceLogPath, defaultTraceTailMax) allow := []string{ "[gui]", "[info]", "[login]", "[vpn]", "[event]", "[error]", } for _, l := range full { ll := strings.ToLower(l) // берём только наши "человеческие" префиксы ok := false for _, a := range allow { if strings.Contains(ll, strings.ToLower(a)) { ok = true break } } if !ok { // если префикса нет, но это похоже на ошибку — тоже включаем if strings.Contains(ll, "error") || strings.Contains(ll, "failed") || strings.Contains(ll, "timeout") { ok = true } } if !ok { continue } // режем шум от резолвера/маршрутов/массовых вставок if strings.Contains(ll, "smartdns") || strings.Contains(ll, "resolver") || strings.Contains(ll, "dnstt") || strings.Contains(ll, "routes") || strings.Contains(ll, "nft add element") || strings.Contains(ll, "cache hit:") { continue } lines = append(lines, l) } default: // full // полный хвост trace.log без фильтрации lines = tailFile(traceLogPath, defaultTraceTailMax) } writeJSON(w, http.StatusOK, map[string]any{ "lines": lines, }) } // --------------------------------------------------------------------- // trace append // --------------------------------------------------------------------- // POST /api/v1/trace/append { "kind": "gui|smartdns|info", "line": "..." } func handleTraceAppend(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { Kind string `json:"kind"` Line string `json:"line"` } if r.Body != nil { defer r.Body.Close() _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) } kind := strings.ToLower(strings.TrimSpace(body.Kind)) line := strings.TrimRight(body.Line, "\r\n") if line == "" { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } _ = os.MkdirAll(stateDir, 0o755) switch kind { case "smartdns": appendTraceLineTo(smartdnsLogPath, "smartdns", line) case "gui": appendTraceLineTo(traceLogPath, "gui", line) default: appendTraceLineTo(traceLogPath, "info", line) } events.push("trace_append", map[string]any{ "kind": kind, }) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // --------------------------------------------------------------------- // trace write helpers // --------------------------------------------------------------------- func appendTraceLineTo(path, prefix, line string) { line = strings.TrimRight(line, "\r\n") if line == "" { return } ts := time.Now().UTC().Format(time.RFC3339) _ = os.MkdirAll(stateDir, 0o755) // простейший "ручной логротейт" const maxSize = 10 * 1024 * 1024 // 10 МБ if fi, err := os.Stat(path); err == nil && fi.Size() > maxSize { // можно просто truncate _ = os.Truncate(path, 0) // или переименовать в *.1 и начать новый // _ = os.Rename(path, path+".1") } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() _, _ = fmt.Fprintf(f, "[%s] %s %s\n", prefix, ts, line) } // --------------------------------------------------------------------- // EN: `appendTraceLine` appends or adds trace line to an existing state. // RU: `appendTraceLine` - добавляет trace line в текущее состояние. // --------------------------------------------------------------------- func appendTraceLine(prefix, line string) { appendTraceLineTo(traceLogPath, prefix, line) } // --------------------------------------------------------------------- // tail helper // --------------------------------------------------------------------- const defaultTailMaxBytes = 512 * 1024 func tailFile(path string, maxLines int) []string { if maxLines <= 0 { return nil } // читаем лимит из env, если задан maxBytes := defaultTailMaxBytes if env := os.Getenv("SVPN_TAIL_MAX_BYTES"); env != "" { if n, err := strconv.Atoi(env); err == nil && n > 0 { maxBytes = n } } f, err := os.Open(path) if err != nil { // файла нет или нет прав — просто ничего не отдаём return nil } defer f.Close() fi, err := f.Stat() if err != nil { return nil } size := fi.Size() if size <= 0 { return nil } // с какого смещения читаем хвост start := int64(0) if size > int64(maxBytes) { start = size - int64(maxBytes) } // двигаем указатель в файле if _, err := f.Seek(start, io.SeekStart); err != nil { return nil } // читаем хвост data, err := io.ReadAll(f) if err != nil { return nil } // режем по строкам lines := strings.Split(string(data), "\n") // если мы начали читать с середины файла (start > 0), // первая строка почти наверняка обрезана — выбрасываем её. if start > 0 && len(lines) > 0 { lines = lines[1:] } // убираем финальную пустую строку, если есть if n := len(lines); n > 0 && lines[n-1] == "" { lines = lines[:n-1] } // берём только последние maxLines if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] } return lines }