Files
elmprodvpn/selective-vpn-api/app/trace_handlers.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

262 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}