From 1fb87f5f6c04a49d1724c497a990eaed9cb86af0 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 04:31:09 +0300 Subject: [PATCH] Fix busy binary reinstall and add per-node log viewing. Co-authored-by: Cursor --- cmd/node/main.go | 41 +++++++++++++++++++++ internal/db/db.go | 9 +++-- internal/db/nodes.go | 21 ++++++++--- internal/handlers/handlers.go | 1 + internal/handlers/nodes.go | 42 ++++++++++++++++++++++ internal/models/models.go | 1 + internal/nodeclient/client.go | 34 ++++++++++++++++++ internal/nodeinstall/ssh.go | 67 +++++++++++++++++++++++++++++------ web/templates/node_view.html | 17 +++++++++ web/templates/nodes.html | 8 ++++- 10 files changed, 222 insertions(+), 19 deletions(-) diff --git a/cmd/node/main.go b/cmd/node/main.go index 483ff8a..eb11020 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -2,10 +2,12 @@ package main import ( "encoding/json" + "io" "log" "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -18,6 +20,7 @@ type Agent struct { secret string version string dataDir string + logPath string startedAt time.Time mu sync.RWMutex config map[string]any @@ -34,11 +37,19 @@ func main() { dataDir := env("DATA_DIR", "/var/lib/vpn-node") _ = os.MkdirAll(dataDir, 0o755) + logPath := filepath.Join(dataDir, "agent.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + log.SetOutput(io.MultiWriter(os.Stderr, logFile)) + defer logFile.Close() + } + a := &Agent{ name: name, secret: secret, version: version, dataDir: dataDir, + logPath: logPath, startedAt: time.Now().UTC(), config: map[string]any{}, } @@ -48,6 +59,7 @@ func main() { mux.HandleFunc("/health", a.auth(a.health)) mux.HandleFunc("/api/v1/config", a.auth(a.configHandler)) mux.HandleFunc("/api/v1/status", a.auth(a.status)) + mux.HandleFunc("/api/v1/logs", a.auth(a.logs)) addr := ":" + port log.Printf("vpn-node %s listening on %s name=%s", version, addr, name) @@ -130,6 +142,34 @@ func (a *Agent) status(w http.ResponseWriter, _ *http.Request) { }) } +func (a *Agent) logs(w http.ResponseWriter, r *http.Request) { + lines := 300 + if v := r.URL.Query().Get("lines"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 { + lines = n + } + } + content, err := tailFile(a.logPath, lines) + if err != nil { + writeJSON(w, map[string]any{"ok": true, "logs": "(no agent.log yet)"}) + return + } + writeJSON(w, map[string]any{"ok": true, "logs": content}) +} + +func tailFile(path string, maxLines int) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + text := string(b) + parts := strings.Split(text, "\n") + if len(parts) > maxLines { + parts = parts[len(parts)-maxLines:] + } + return strings.Join(parts, "\n"), nil +} + func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -146,6 +186,7 @@ func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) { a.config = cfg a.mu.Unlock() _ = a.saveConfig(filepath.Join(a.dataDir, "config.json")) + log.Printf("config updated: installed=%v enabled=%v", stringList(cfg, "protocols_installed"), stringList(cfg, "protocols_enabled")) writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/db/db.go b/internal/db/db.go index f05e924..375fd1e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS nodes ( install_mode TEXT NOT NULL DEFAULT 'auto', last_error TEXT NOT NULL DEFAULT '', install_log TEXT NOT NULL DEFAULT '', + runtime_log TEXT NOT NULL DEFAULT '', agent_version TEXT NOT NULL DEFAULT '', last_seen_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -95,8 +96,12 @@ CREATE TABLE IF NOT EXISTS node_protocols ( CREATE INDEX IF NOT EXISTS idx_node_protocols_protocol ON node_protocols(protocol_id); ` - _, err := db.Exec(schema) - return err + if _, err := db.Exec(schema); err != nil { + return err + } + // Additive migrations for existing deployments. + _, _ = db.Exec(`ALTER TABLE nodes ADD COLUMN IF NOT EXISTS runtime_log TEXT NOT NULL DEFAULT ''`) + return nil } func SeedAdmin(db *sql.DB, cfg *config.Config) error { diff --git a/internal/db/nodes.go b/internal/db/nodes.go index 356faec..1efa4be 100644 --- a/internal/db/nodes.go +++ b/internal/db/nodes.go @@ -20,7 +20,7 @@ func scanNode(scanner interface { err := scanner.Scan( &n.ID, &n.Name, &n.Host, &n.SSHPort, &n.SSHUser, &n.SSHAuthType, &n.SSHSecretEnc, &n.NodePort, &n.SecretKey, &status, &n.InstallMode, &n.LastError, &n.InstallLog, - &n.AgentVersion, &lastSeen, &n.CreatedAt, &n.UpdatedAt, + &n.RuntimeLog, &n.AgentVersion, &lastSeen, &n.CreatedAt, &n.UpdatedAt, ) if err != nil { return nil, err @@ -36,7 +36,7 @@ func scanNode(scanner interface { const nodeColumns = ` id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc, node_port, secret_key, status, install_mode, last_error, install_log, -agent_version, last_seen_at, created_at, updated_at` +runtime_log, agent_version, last_seen_at, created_at, updated_at` func CreateNode(db *sql.DB, n *models.Node) error { if n.ID == uuid.Nil { @@ -52,13 +52,13 @@ func CreateNode(db *sql.DB, n *models.Node) error { INSERT INTO nodes ( id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc, node_port, secret_key, status, install_mode, last_error, install_log, - agent_version, last_seen_at, created_at, updated_at + runtime_log, agent_version, last_seen_at, created_at, updated_at ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17 + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18 )`, n.ID, n.Name, n.Host, n.SSHPort, n.SSHUser, n.SSHAuthType, n.SSHSecretEnc, n.NodePort, n.SecretKey, string(n.Status), n.InstallMode, n.LastError, n.InstallLog, - n.AgentVersion, n.LastSeenAt, n.CreatedAt, n.UpdatedAt, + n.RuntimeLog, n.AgentVersion, n.LastSeenAt, n.CreatedAt, n.UpdatedAt, ) return err } @@ -114,6 +114,17 @@ WHERE id = $1`, id, strings.TrimRight(chunk, "\n")) return err } +func SetNodeRuntimeLog(db *sql.DB, id uuid.UUID, logText string) error { + // Cap stored log size to keep DB rows reasonable. + const max = 200_000 + if len(logText) > max { + logText = logText[len(logText)-max:] + } + _, err := db.Exec(` +UPDATE nodes SET runtime_log = $2, updated_at = NOW() WHERE id = $1`, id, logText) + return err +} + func MarkNodeSeen(db *sql.DB, id uuid.UUID, version string, status models.NodeStatus) error { _, err := db.Exec(` UPDATE nodes diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 23219c7..2d14180 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -72,6 +72,7 @@ func (s *Server) Routes() http.Handler { r.HandleFunc("/admin/nodes/{id}/install", s.Auth.RequireAuth(s.nodeInstall)).Methods(http.MethodPost) r.HandleFunc("/admin/nodes/{id}/check", s.Auth.RequireAuth(s.nodeCheck)).Methods(http.MethodPost) r.HandleFunc("/admin/nodes/{id}/sync", s.Auth.RequireAuth(s.nodeSyncProtocols)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes/{id}/logs", s.Auth.RequireAuth(s.nodeFetchLogs)).Methods(http.MethodPost) r.HandleFunc("/admin/nodes/{id}/delete", s.Auth.RequireAuth(s.nodeDelete)).Methods(http.MethodPost) r.HandleFunc("/admin/nodes/{id}/protocols/{protocolID}", s.Auth.RequireAuth(s.nodeProtocolAction)).Methods(http.MethodPost) diff --git a/internal/handlers/nodes.go b/internal/handlers/nodes.go index 7e14bcc..43d76b4 100644 --- a/internal/handlers/nodes.go +++ b/internal/handlers/nodes.go @@ -223,6 +223,48 @@ func (s *Server) nodeDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/nodes?ok=deleted", http.StatusSeeOther) } +func (s *Server) nodeFetchLogs(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + n, err := db.GetNode(s.DB, id) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + + var chunks []string + stamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC") + chunks = append(chunks, "=== fetched at "+stamp+" ===") + + if agentLogs, err := nodeclient.FetchLogs(n, 300); err == nil { + chunks = append(chunks, "---- agent API /api/v1/logs ----", agentLogs) + } else { + chunks = append(chunks, "---- agent API ----", "unavailable: "+err.Error()) + } + + secret, err := secretbox.Decrypt(s.SecretKey, n.SSHSecretEnc) + if err == nil && secret != "" { + if dockerLogs, err := nodeinstall.FetchDockerLogs(n, secret, 300); err == nil { + chunks = append(chunks, "---- docker / SSH ----", dockerLogs) + } else { + chunks = append(chunks, "---- docker / SSH ----", "unavailable: "+err.Error()) + } + } else { + chunks = append(chunks, "---- docker / SSH ----", "(no SSH credentials stored — only agent API logs)") + } + + full := strings.Join(chunks, "\n") + if err := db.SetNodeRuntimeLog(s.DB, id, full); err != nil { + log.Printf("save runtime log: %v", err) + http.Redirect(w, r, "/admin/nodes/"+id.String()+"?err=logs_save", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/nodes/"+id.String()+"#logs", http.StatusSeeOther) +} + func (s *Server) runAutoInstall(id uuid.UUID) { n, err := db.GetNode(s.DB, id) if err != nil || n == nil { diff --git a/internal/models/models.go b/internal/models/models.go index 56c3c42..0fc2cd2 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -83,6 +83,7 @@ type Node struct { InstallMode string `json:"install_mode"` // auto | manual LastError string `json:"last_error"` InstallLog string `json:"install_log"` + RuntimeLog string `json:"runtime_log"` AgentVersion string `json:"agent_version"` LastSeenAt *time.Time `json:"last_seen_at,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go index dabc1c9..b93a94f 100644 --- a/internal/nodeclient/client.go +++ b/internal/nodeclient/client.go @@ -78,3 +78,37 @@ func PushConfig(n *models.Node, cfg map[string]any) error { } return nil } + +func FetchLogs(n *models.Node, lines int) (string, error) { + if lines <= 0 { + lines = 300 + } + url := fmt.Sprintf("http://%s:%d/api/v1/logs?lines=%d", n.Host, n.NodePort, lines) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+n.SecretKey) + + client := &http.Client{Timeout: 10 * time.Second} + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %d: %s", res.StatusCode, string(body)) + } + var payload struct { + OK bool `json:"ok"` + Logs string `json:"logs"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return string(body), nil + } + if payload.Logs == "" { + return "(empty agent logs)", nil + } + return payload.Logs, nil +} diff --git a/internal/nodeinstall/ssh.go b/internal/nodeinstall/ssh.go index b2b93e6..90c4e72 100644 --- a/internal/nodeinstall/ssh.go +++ b/internal/nodeinstall/ssh.go @@ -49,6 +49,13 @@ func (c SSHConfig) dial() (*ssh.Client, error) { return ssh.Dial("tcp", addr, cfg) } +func Dial(n *models.Node, sshSecret string) (*ssh.Client, error) { + return SSHConfig{ + Host: n.Host, Port: n.SSHPort, User: n.SSHUser, + AuthType: n.SSHAuthType, Secret: sshSecret, + }.dial() +} + func run(client *ssh.Client, cmd string, log LogFunc) (string, error) { session, err := client.NewSession() if err != nil { @@ -77,10 +84,12 @@ func shellQuote(s string) string { return strconv.Quote(s) } -// uploadViaStdin writes bytes to remotePath using `cat` over SSH stdin. -// Avoids legacy `scp -t`, which fails on many modern OpenSSH servers (status 1). +// uploadViaStdin writes to a temp file then atomically replaces the target. +// Avoids "Text file busy" when the destination is a running binary / bind-mount. func uploadViaStdin(client *ssh.Client, remotePath string, data []byte, mode os.FileMode, log LogFunc) error { dir := filepath.ToSlash(filepath.Dir(remotePath)) + tmpPath := remotePath + ".tmp" + if _, err := run(client, "mkdir -p "+shellQuote(dir)+" 2>/dev/null || sudo mkdir -p "+shellQuote(dir), log); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } @@ -99,15 +108,14 @@ func uploadViaStdin(client *ssh.Client, remotePath string, data []byte, mode os. return err } - // Prefer direct write; fall back to sudo tee if permission denied. cmd := fmt.Sprintf( - `sh -c 'cat > %s && chmod %o %s' 2>/dev/null || sudo sh -c 'cat > %s && chmod %o %s'`, - shellQuote(remotePath), mode, shellQuote(remotePath), - shellQuote(remotePath), mode, shellQuote(remotePath), + `sh -c 'cat > %s && chmod %o %s && mv -f %s %s' 2>/dev/null || sudo sh -c 'cat > %s && chmod %o %s && mv -f %s %s'`, + shellQuote(tmpPath), mode, shellQuote(tmpPath), shellQuote(tmpPath), shellQuote(remotePath), + shellQuote(tmpPath), mode, shellQuote(tmpPath), shellQuote(tmpPath), shellQuote(remotePath), ) if log != nil { - log(fmt.Sprintf("upload %s (%d bytes) via ssh stdin", remotePath, len(data))) + log(fmt.Sprintf("upload %s (%d bytes) via tmp+mv", remotePath, len(data))) } if err := session.Start(cmd); err != nil { @@ -139,15 +147,46 @@ func uploadBinary(client *ssh.Client, remotePath string, data []byte, log LogFun return uploadViaStdin(client, remotePath, data, 0o755, log) } +func stopNodeContainers(client *ssh.Client, log LogFunc) { + _, _ = run(client, `cd /opt/vpn-panel-node 2>/dev/null && (docker compose down --remove-orphans || docker-compose down --remove-orphans || true)`, log) + _, _ = run(client, `docker rm -f vpn-panel-node 2>/dev/null || true`, log) + // Ensure nothing still holds the binary open. + _, _ = run(client, `sleep 1; true`, nil) +} + +// FetchDockerLogs pulls recent container / agent logs over SSH. +func FetchDockerLogs(n *models.Node, sshSecret string, lines int) (string, error) { + if lines <= 0 { + lines = 300 + } + client, err := Dial(n, sshSecret) + if err != nil { + return "", fmt.Errorf("ssh connect: %w", err) + } + defer client.Close() + + cmd := fmt.Sprintf( + `(docker logs --tail %d vpn-panel-node 2>&1 || docker compose -f /opt/vpn-panel-node/docker-compose.yml logs --tail %d 2>&1 || true); `+ + `echo "---- agent.log ----"; `+ + `(tail -n %d /opt/vpn-panel-node/data/agent.log 2>/dev/null || sudo tail -n %d /opt/vpn-panel-node/data/agent.log 2>/dev/null || echo "(no agent.log)")`, + lines, lines, lines, lines, + ) + out, err := run(client, cmd, nil) + if out == "" && err != nil { + return "", err + } + if out == "" { + out = "(empty logs)" + } + return out, nil +} + // Provision installs Docker (if needed), uploads agent binary + compose, starts the node. func Provision(n *models.Node, sshSecret string, agentBinary []byte, log LogFunc) error { if len(agentBinary) == 0 { return fmt.Errorf("agent binary is empty — build cmd/node for linux/amd64") } - client, err := SSHConfig{ - Host: n.Host, Port: n.SSHPort, User: n.SSHUser, - AuthType: n.SSHAuthType, Secret: sshSecret, - }.dial() + client, err := Dial(n, sshSecret) if err != nil { return fmt.Errorf("ssh connect: %w", err) } @@ -164,6 +203,12 @@ func Provision(n *models.Node, sshSecret string, agentBinary []byte, log LogFunc return fmt.Errorf("prepare dir: %w", err) } + // Stop running agent first — otherwise overwrite fails with "Text file busy". + if log != nil { + log("stopping existing vpn-panel-node (if any)") + } + stopNodeContainers(client, log) + if err := uploadBinary(client, "/opt/vpn-panel-node/vpn-node", agentBinary, log); err != nil { return err } diff --git a/web/templates/node_view.html b/web/templates/node_view.html index 7541b88..3144e29 100644 --- a/web/templates/node_view.html +++ b/web/templates/node_view.html @@ -49,6 +49,9 @@
+
+ +
@@ -137,6 +140,20 @@ {{end}} +
+
+

Логи сервера

+
+ +
+
+ {{if .Node.RuntimeLog}} +
{{.Node.RuntimeLog}}
+ {{else}} +

Логов ещё нет. Нажмите «Обновить логи» — панель заберёт agent API и docker logs по SSH.

+ {{end}} +
+
Агент
{{if .Node.AgentVersion}}{{.Node.AgentVersion}}{{else}}—{{end}}
Last seen
{{if .Node.LastSeenAt}}{{.Node.LastSeenAt}}{{else}}—{{end}}
diff --git a/web/templates/nodes.html b/web/templates/nodes.html index f678932..b309d35 100644 --- a/web/templates/nodes.html +++ b/web/templates/nodes.html @@ -56,7 +56,13 @@ {{.NodePort}} {{.InstallMode}} {{.StatusLabel}} - Открыть + + Открыть + Логи +
+ +
+ {{end}}