mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
- handleAuditWorkerLogsCsv: 全环 fan-out 收集每节点 frpc 日志, 逐行展开为 CSV (node/worker_key/remote/worker_state/log_line), Excel 可直接过滤/pivot 审计 - gatherClusterWorkerLogs: 提取 JSON 与 CSV 共用的日志聚合逻辑 - 路由: GET /audit/worker-logs.csv (read 级) - ClusterView: 导出 worker 日志改下拉菜单 (JSON/CSV 两格式) - api.ts: downloadAuditCsv 支持 worker-logs 类型 - gofmt: handlers_audit.go 格式对齐
229 lines
7.0 KiB
Go
229 lines
7.0 KiB
Go
// Audit exports: CSV downloads of the evidence tables an auditor needs —
|
|
// user accounts (with last-login), API keys (with last-use), and the ring
|
|
// operation log. All read-level: exporting is exactly what a viewer/auditor
|
|
// role exists for. CSV cells are RFC4180-escaped; timestamps are ISO8601 UTC
|
|
// so spreadsheets sort them correctly.
|
|
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"webui4frpc/internal/cluster"
|
|
)
|
|
|
|
// csvEscape quotes a cell per RFC4180: wrap in double quotes when the value
|
|
// contains quote/comma/newline, doubling embedded quotes. Prefixing a leading
|
|
// '=' '+' '@' '\t' with an apostrophe defuses spreadsheet formula injection
|
|
// (CSV cells are data, not formulas — auditors open these in Excel).
|
|
func csvEscape(v string) string {
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
if strings.ContainsAny(v, ",\"\n\r") {
|
|
v = `"` + strings.ReplaceAll(v, `"`, `""`) + `"`
|
|
}
|
|
if len(v) > 0 && (v[0] == '=' || v[0] == '+' || v[0] == '@' || v[0] == '\t' || v[0] == '-') {
|
|
return "'" + v
|
|
}
|
|
return v
|
|
}
|
|
|
|
// isoTime renders unix seconds as ISO8601 UTC ("2026-08-24T12:00:00Z"); 0 →
|
|
// empty (never logged / never used).
|
|
func isoTime(unix int64) string {
|
|
if unix <= 0 {
|
|
return ""
|
|
}
|
|
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
// writeCSV sets attachment headers and streams the header row + rows.
|
|
func writeCSV(w http.ResponseWriter, filename string, header []string, rows [][]string) {
|
|
var b strings.Builder
|
|
writeRow := func(cells []string) {
|
|
for i, c := range cells {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteString(csvEscape(c))
|
|
}
|
|
b.WriteString("\r\n")
|
|
}
|
|
writeRow(header)
|
|
for _, r := range rows {
|
|
writeRow(r)
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
|
_, _ = w.Write([]byte(b.String()))
|
|
}
|
|
|
|
// handleAuditUsersCsv serves GET /audit/users.csv: the account inventory with
|
|
// last-login timestamps. Admin-only? No — read-level: auditors (viewer role)
|
|
// are precisely the people who need this; password hashes were never part of
|
|
// the User JSON shape and are not included here either.
|
|
func (h *Handler) handleAuditUsersCsv(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
users, err := h.Store.ListUsers()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
rows := make([][]string, 0, len(users))
|
|
for _, u := range users {
|
|
rows = append(rows, []string{
|
|
strconv.FormatInt(u.ID, 10),
|
|
u.Username,
|
|
u.Role,
|
|
strconv.FormatBool(u.Enabled),
|
|
strconv.FormatBool(u.System),
|
|
isoTime(u.CreatedAt),
|
|
isoTime(u.LastLoginAt),
|
|
})
|
|
}
|
|
writeCSV(w,
|
|
fmt.Sprintf("audit-users-%s.csv", time.Now().UTC().Format("20060102-150405")),
|
|
[]string{"id", "username", "role", "enabled", "system", "created_at", "last_login_at"},
|
|
rows)
|
|
}
|
|
|
|
// handleAuditApiKeysCsv serves GET /audit/apikeys.csv: key inventory with
|
|
// scope, last-use and expiry. The plaintext key is unrecoverable by design;
|
|
// only the display prefix is exported.
|
|
func (h *Handler) handleAuditApiKeysCsv(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
keys, err := h.Store.ListApiKeys()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Resolve owning username for readability.
|
|
nameOf := map[int64]string{}
|
|
if users, err := h.Store.ListUsers(); err == nil {
|
|
for _, u := range users {
|
|
nameOf[u.ID] = u.Username
|
|
}
|
|
}
|
|
rows := make([][]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
expires := ""
|
|
if k.ExpiresAt != 0 {
|
|
expires = isoTime(k.ExpiresAt)
|
|
}
|
|
rows = append(rows, []string{
|
|
strconv.FormatInt(k.ID, 10),
|
|
k.Prefix + "…",
|
|
nameOf[k.UserID],
|
|
k.Label,
|
|
k.Scope,
|
|
isoTime(k.CreatedAt),
|
|
isoTime(k.LastUsedAt),
|
|
expires,
|
|
})
|
|
}
|
|
writeCSV(w,
|
|
fmt.Sprintf("audit-apikeys-%s.csv", time.Now().UTC().Format("20060102-150405")),
|
|
[]string{"id", "key_prefix", "owner", "label", "scope", "created_at", "last_used_at", "expires_at"},
|
|
rows)
|
|
}
|
|
|
|
// handleAuditClusterLogCsv serves GET /audit/cluster-log.csv: this node's ring
|
|
// operation log (forward add/remove, join/leave, leader changes, claims) as
|
|
// one flat CSV sorted by seq — the "who did what to the cluster" timeline.
|
|
// Data payloads are flattened into a human-readable detail column plus raw
|
|
// JSON for lossless reprocessing.
|
|
func (h *Handler) handleAuditClusterLogCsv(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
if h.Ring == nil {
|
|
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
|
return
|
|
}
|
|
snap := h.Ring.Snapshot()
|
|
entries := snap.Log
|
|
rows := make([][]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
rows = append(rows, []string{
|
|
strconv.FormatInt(e.Seq, 10),
|
|
isoTime(e.At),
|
|
e.Node,
|
|
e.Kind,
|
|
cluster.DetailOf(e),
|
|
rawJSON(e.Data),
|
|
})
|
|
}
|
|
writeCSV(w,
|
|
fmt.Sprintf("audit-cluster-log-%s.csv", time.Now().UTC().Format("20060102-150405")),
|
|
[]string{"seq", "time_utc", "node", "kind", "detail", "data_json"},
|
|
rows)
|
|
}
|
|
|
|
// handleAuditWorkerLogsCsv serves GET /audit/worker-logs.csv: every alive
|
|
// node's frpc worker logs, fanned out over the ring and flattened to one row
|
|
// per LOG LINE (node/worker/state columns repeated) so auditors can filter
|
|
// and pivot in a spreadsheet. Unreachable nodes contribute an error row.
|
|
func (h *Handler) handleAuditWorkerLogsCsv(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
if h.Ring == nil {
|
|
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
|
return
|
|
}
|
|
_, results := h.gatherClusterWorkerLogs(r)
|
|
rows := make([][]string, 0, 64)
|
|
for _, n := range results {
|
|
if !n.OK || n.Logs == nil {
|
|
rows = append(rows, []string{n.ID, "", "ERROR", n.Error, ""})
|
|
continue
|
|
}
|
|
for _, wk := range n.Logs.Workers {
|
|
// One row per line keeps the CSV rectangular; the tail's embedded
|
|
// newlines would otherwise need multi-line cells (RFC4180 allows
|
|
// them but Excel's filtering/pivot works better on flat rows).
|
|
for _, line := range strings.Split(strings.TrimRight(wk.Lines, "\n"), "\n") {
|
|
if ts, rest, found := strings.Cut(line, "."); found && len(ts) > 10 {
|
|
line = rest // strip the ANSI-prefixed timestamp prefix noise
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
rows = append(rows, []string{n.ID, wk.Name, wk.Remote, wk.State, line})
|
|
}
|
|
}
|
|
}
|
|
writeCSV(w,
|
|
fmt.Sprintf("audit-worker-logs-%s.csv", time.Now().UTC().Format("20060102-150405")),
|
|
[]string{"node", "worker_key", "remote", "worker_state", "log_line"},
|
|
rows)
|
|
}
|
|
|
|
// rawJSON renders the log entry's payload as compact JSON (empty when absent)
|
|
// for the lossless audit column.
|
|
func rawJSON(data json.RawMessage) string {
|
|
if len(data) == 0 {
|
|
return ""
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := json.Compact(&buf, data); err != nil {
|
|
return string(data)
|
|
}
|
|
return buf.String()
|
|
}
|