Fix busy binary reinstall and add per-node log viewing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:31:09 +03:00
co-authored by Cursor
parent 2d8b1d4438
commit 1fb87f5f6c
10 changed files with 222 additions and 19 deletions
+41
View File
@@ -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)
+7 -2
View File
@@ -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 {
+16 -5
View File
@@ -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
+1
View File
@@ -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)
+42
View File
@@ -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 {
+1
View File
@@ -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"`
+34
View File
@@ -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
}
+56 -11
View File
@@ -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
}
+17
View File
@@ -49,6 +49,9 @@
<form method="POST" action="/admin/nodes/{{.Node.ID}}/sync">
<button class="btn btn-ghost" type="submit">Синхронизировать протоколы</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/logs">
<button class="btn btn-ghost" type="submit">Обновить логи</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/delete" onsubmit="return confirm('Удалить ноду из панели?');">
<button class="btn btn-ghost" type="submit">Удалить</button>
</form>
@@ -137,6 +140,20 @@
</section>
{{end}}
<section class="panel-section" id="logs">
<div class="section-head">
<h2>Логи сервера</h2>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/logs">
<button class="btn btn-sm btn-ghost" type="submit">Обновить</button>
</form>
</div>
{{if .Node.RuntimeLog}}
<pre class="code-block log">{{.Node.RuntimeLog}}</pre>
{{else}}
<p class="muted">Логов ещё нет. Нажмите «Обновить логи» — панель заберёт agent API и docker logs по SSH.</p>
{{end}}
</section>
<section class="panel-section meta-grid">
<div><span class="stat-label">Агент</span><div>{{if .Node.AgentVersion}}{{.Node.AgentVersion}}{{else}}—{{end}}</div></div>
<div><span class="stat-label">Last seen</span><div>{{if .Node.LastSeenAt}}{{.Node.LastSeenAt}}{{else}}—{{end}}</div></div>
+7 -1
View File
@@ -56,7 +56,13 @@
<td>{{.NodePort}}</td>
<td>{{.InstallMode}}</td>
<td><span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span></td>
<td class="actions"><a class="btn btn-sm btn-ghost" href="/admin/nodes/{{.ID}}">Открыть</a></td>
<td class="actions actions-multi">
<a class="btn btn-sm btn-ghost" href="/admin/nodes/{{.ID}}">Открыть</a>
<a class="btn btn-sm btn-ghost" href="/admin/nodes/{{.ID}}#logs">Логи</a>
<form method="POST" action="/admin/nodes/{{.ID}}/logs">
<button class="btn btn-sm btn-ghost" type="submit">Обновить логи</button>
</form>
</td>
</tr>
{{end}}
</tbody>