Fix busy binary reinstall and add per-node log viewing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user