package httpapi import ( "encoding/json" "io" "net/http" "sync" "time" "webui4frpc/internal/process" ) // workerLog is one frpc worker's log tail on a node. type workerLog struct { Name string `json:"name"` Remote string `json:"remote"` // worker key = remote name State string `json:"state"` Lines string `json:"lines"` } // nodeLogsResp is the body of GET /node/logs: all frpc workers handling // forwards on THIS node (cluster-owned workers that run here + local-only ones). type nodeLogsResp struct { Node string `json:"node"` Workers []workerLog `json:"workers"` } // localWorkerLogs gathers this node's frpc worker logs. Shared by /node/logs // and the /cluster/logs/export self entry so the requesting node doesn't HTTP // back to itself. func (h *Handler) localWorkerLogs() nodeLogsResp { out := nodeLogsResp{Node: h.SelfAddr, Workers: []workerLog{}} if h.Process == nil { return out } for _, name := range h.Process.ListWorkers() { st, _ := h.Process.Status(name) lines, _ := tailFile(h.Process.LogPath(name), 64*1024) // Parse the worker key to get the real remote name for display. remoteName := name if _, r, _, ok := process.ParseWorkerKey(name); ok { remoteName = r } out.Workers = append(out.Workers, workerLog{ Name: name, Remote: remoteName, State: st.State, Lines: lines, }) } return out } // handleNodeLogs returns the frpc worker logs of THIS node only. Read-level: // auditors can pull it directly on any node, and the cluster export fans it out. func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) return } writeJSON(w, http.StatusOK, h.localWorkerLogs()) } // clusterWorkerLogNode is one node's entry in the export bundle. type clusterWorkerLogNode struct { ID string `json:"id"` Addr string `json:"addr"` OK bool `json:"ok"` Error string `json:"error,omitempty"` Logs *nodeLogsResp `json:"logs,omitempty"` } // gatherClusterWorkerLogs fans out GET /node/logs to every alive ring node // (using the flag Basic creds, which resolve to admin on each peer via the // flag fast path) and aggregates the results. Unreachable nodes are marked // error but don't abort the export. The requester's own logs are gathered // locally without an HTTP round-trip. Shared by the JSON bundle and the CSV // audit export. func (h *Handler) gatherClusterWorkerLogs(r *http.Request) (string, []clusterWorkerLogNode) { snap := h.Ring.Snapshot() selfID := snap.SelfID type target struct{ id, addr string } var targets []target for _, n := range snap.Nodes { if !n.Alive || n.Addr == "" { continue } targets = append(targets, target{n.ID, n.Addr}) } results := make([]clusterWorkerLogNode, len(targets)) var wg sync.WaitGroup cli := &http.Client{Timeout: 8 * time.Second} for i, t := range targets { wg.Add(1) go func(i int, t target) { defer wg.Done() entry := clusterWorkerLogNode{ID: t.id, Addr: t.addr} // Self: gather locally, no HTTP round-trip. if t.id == selfID { logs := h.localWorkerLogs() entry.OK = true entry.Logs = &logs results[i] = entry return } req, err := http.NewRequest(http.MethodGet, "http://"+t.addr+apiPrefix+"/node/logs", nil) if err != nil { entry.Error = err.Error() results[i] = entry return } req.SetBasicAuth(h.User, h.Password) resp, err := cli.Do(req) if err != nil { entry.Error = "unreachable: " + err.Error() results[i] = entry return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) if resp.StatusCode != http.StatusOK { entry.Error = "HTTP " + resp.Status results[i] = entry return } var logs nodeLogsResp if err := json.Unmarshal(body, &logs); err != nil { entry.Error = "parse: " + err.Error() results[i] = entry return } entry.OK = true entry.Logs = &logs results[i] = entry }(i, t) } wg.Wait() return selfID, results } // handleClusterLogsExport serves the aggregated worker-log bundle as JSON. func (h *Handler) handleClusterLogsExport(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 } selfID, results := h.gatherClusterWorkerLogs(r) bundle := map[string]any{ "_type": "webui4frpc-worker-logs", "version": 1, "exportedAt": time.Now().Unix(), "requester": selfID, "nodes": results, } body, _ := json.MarshalIndent(bundle, "", " ") w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", `attachment; filename="worker-logs.json"`) _, _ = w.Write(body) }