252 lines
7.0 KiB
Go
252 lines
7.0 KiB
Go
package nodeinstall
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"github.com/orohi/vpn-panel/internal/models"
|
|
)
|
|
|
|
type LogFunc func(line string)
|
|
|
|
type SSHConfig struct {
|
|
Host string
|
|
Port int
|
|
User string
|
|
AuthType string // password | key
|
|
Secret string
|
|
}
|
|
|
|
func (c SSHConfig) dial() (*ssh.Client, error) {
|
|
var auth []ssh.AuthMethod
|
|
switch c.AuthType {
|
|
case "key":
|
|
signer, err := ssh.ParsePrivateKey([]byte(c.Secret))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ssh key: %w", err)
|
|
}
|
|
auth = append(auth, ssh.PublicKeys(signer))
|
|
default:
|
|
auth = append(auth, ssh.Password(c.Secret))
|
|
}
|
|
|
|
cfg := &ssh.ClientConfig{
|
|
User: c.User,
|
|
Auth: auth,
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // bootstrap only; harden later with known_hosts
|
|
Timeout: 20 * time.Second,
|
|
}
|
|
addr := net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port))
|
|
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 {
|
|
return "", err
|
|
}
|
|
defer session.Close()
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
session.Stdout = &stdout
|
|
session.Stderr = &stderr
|
|
if log != nil {
|
|
log("$ " + cmd)
|
|
}
|
|
err = session.Run(cmd)
|
|
out := strings.TrimSpace(stdout.String() + "\n" + stderr.String())
|
|
if log != nil && out != "" {
|
|
log(out)
|
|
}
|
|
if err != nil {
|
|
return out, fmt.Errorf("%w: %s", err, out)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func shellQuote(s string) string {
|
|
return strconv.Quote(s)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer session.Close()
|
|
|
|
var stderr bytes.Buffer
|
|
session.Stderr = &stderr
|
|
|
|
stdin, err := session.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd := fmt.Sprintf(
|
|
`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 tmp+mv", remotePath, len(data)))
|
|
}
|
|
|
|
if err := session.Start(cmd); err != nil {
|
|
return fmt.Errorf("start upload: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(stdin, bytes.NewReader(data)); err != nil {
|
|
_ = stdin.Close()
|
|
_ = session.Wait()
|
|
return fmt.Errorf("write upload: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
_ = stdin.Close()
|
|
|
|
if err := session.Wait(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return fmt.Errorf("upload %s failed: %s", remotePath, msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeRemoteFile(client *ssh.Client, remotePath, content string, mode os.FileMode, log LogFunc) error {
|
|
return uploadViaStdin(client, remotePath, []byte(content), mode, log)
|
|
}
|
|
|
|
func uploadBinary(client *ssh.Client, remotePath string, data []byte, log LogFunc) error {
|
|
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 := Dial(n, sshSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh connect: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if log != nil {
|
|
log("SSH connected")
|
|
}
|
|
|
|
if _, err := run(client, `command -v docker >/dev/null 2>&1 || (curl -fsSL https://get.docker.com | sh)`, log); err != nil {
|
|
return fmt.Errorf("install docker: %w", err)
|
|
}
|
|
if _, err := run(client, `sudo mkdir -p /opt/vpn-panel-node/data && (sudo chown -R "$(whoami):$(whoami)" /opt/vpn-panel-node || true)`, log); err != nil {
|
|
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
|
|
}
|
|
if err := writeRemoteFile(client, "/opt/vpn-panel-node/docker-compose.yml", ComposeYAML(n), 0o644, log); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := run(client, `cd /opt/vpn-panel-node && chmod +x vpn-node && (docker compose up -d --force-recreate || docker-compose up -d --force-recreate)`, log); err != nil {
|
|
return fmt.Errorf("docker compose up: %w", err)
|
|
}
|
|
|
|
if log != nil {
|
|
log(fmt.Sprintf("node agent started on :%d", n.NodePort))
|
|
log("firewall: allow NODE_PORT only from panel IP")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func FindAgentBinary() (string, error) {
|
|
candidates := []string{
|
|
"/app/bin/vpn-node",
|
|
"bin/vpn-node",
|
|
"bin/vpn-node-linux-amd64",
|
|
"vpn-node",
|
|
}
|
|
for _, c := range candidates {
|
|
if st, err := os.Stat(c); err == nil && !st.IsDir() && st.Size() > 0 {
|
|
return c, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("vpn-node binary not found (looked in %s)", strings.Join(candidates, ", "))
|
|
}
|
|
|
|
func LoadAgentBinary() ([]byte, error) {
|
|
path, err := FindAgentBinary()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.ReadFile(path)
|
|
}
|