Add node mode with SSH auto-install and Remnawave-style manual deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package nodeinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/orohi/vpn-panel/internal/models"
|
||||
)
|
||||
|
||||
const AgentVersion = "0.1.0"
|
||||
|
||||
func ComposeYAML(n *models.Node) string {
|
||||
return fmt.Sprintf(`services:
|
||||
vpn-node:
|
||||
container_name: vpn-panel-node
|
||||
image: alpine:3.21
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
NODE_NAME: %q
|
||||
NODE_PORT: %q
|
||||
SECRET_KEY: %q
|
||||
AGENT_VERSION: %q
|
||||
volumes:
|
||||
- ./vpn-node:/usr/local/bin/vpn-node:ro
|
||||
- ./data:/var/lib/vpn-node
|
||||
command: ["/usr/local/bin/vpn-node"]
|
||||
`, n.Name, fmt.Sprintf("%d", n.NodePort), n.SecretKey, AgentVersion)
|
||||
}
|
||||
|
||||
func ManualInstallScript(n *models.Node) string {
|
||||
compose := ComposeYAML(n)
|
||||
var b strings.Builder
|
||||
b.WriteString("#!/usr/bin/env bash\n")
|
||||
b.WriteString("set -euo pipefail\n\n")
|
||||
b.WriteString("sudo curl -fsSL https://get.docker.com | sh\n")
|
||||
b.WriteString("sudo mkdir -p /opt/vpn-panel-node/data\n")
|
||||
b.WriteString("cd /opt/vpn-panel-node\n\n")
|
||||
b.WriteString("cat > docker-compose.yml <<'EOF'\n")
|
||||
b.WriteString(compose)
|
||||
b.WriteString("EOF\n\n")
|
||||
b.WriteString("# Скопируйте бинарник vpn-node (linux amd64) в /opt/vpn-panel-node/vpn-node\n")
|
||||
b.WriteString("# chmod +x /opt/vpn-panel-node/vpn-node\n")
|
||||
b.WriteString("# docker compose up -d && docker compose logs -f -t\n")
|
||||
b.WriteString("\necho \"NODE_PORT=")
|
||||
b.WriteString(fmt.Sprintf("%d", n.NodePort))
|
||||
b.WriteString(" — откройте его только для IP панели\"\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package nodeinstall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 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 writeRemoteFile(client *ssh.Client, remotePath, content string, mode os.FileMode, log LogFunc) error {
|
||||
dir := filepath.ToSlash(filepath.Dir(remotePath))
|
||||
if _, err := run(client, fmt.Sprintf("mkdir -p %q", dir), log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := filepath.Base(remotePath)
|
||||
go func() {
|
||||
defer stdin.Close()
|
||||
fmt.Fprintf(stdin, "C%04o %d %s\n", mode, len(content), base)
|
||||
_, _ = io.WriteString(stdin, content)
|
||||
fmt.Fprint(stdin, "\x00")
|
||||
}()
|
||||
|
||||
if log != nil {
|
||||
log(fmt.Sprintf("upload %s (%d bytes)", remotePath, len(content)))
|
||||
}
|
||||
cmd := fmt.Sprintf("scp -t %q", dir)
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return fmt.Errorf("scp %s: %w", remotePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadBinary(client *ssh.Client, remotePath string, data []byte, log LogFunc) error {
|
||||
dir := filepath.ToSlash(filepath.Dir(remotePath))
|
||||
if _, err := run(client, fmt.Sprintf("mkdir -p %q", dir), log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := filepath.Base(remotePath)
|
||||
go func() {
|
||||
defer stdin.Close()
|
||||
fmt.Fprintf(stdin, "C0755 %d %s\n", len(data), base)
|
||||
_, _ = stdin.Write(data)
|
||||
fmt.Fprint(stdin, "\x00")
|
||||
}()
|
||||
|
||||
if log != nil {
|
||||
log(fmt.Sprintf("upload binary %s (%d bytes)", remotePath, len(data)))
|
||||
}
|
||||
if err := session.Run(fmt.Sprintf("scp -t %q", dir)); err != nil {
|
||||
return fmt.Errorf("scp binary: %w", err)
|
||||
}
|
||||
return 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()
|
||||
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)
|
||||
}
|
||||
|
||||
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), 0644, 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)
|
||||
}
|
||||
Reference in New Issue
Block a user