Add node mode with SSH auto-install and Remnawave-style manual deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:03:33 +03:00
co-authored by Cursor
parent 93bbbf8ce8
commit 7b77d84f68
20 changed files with 1470 additions and 8 deletions
+30
View File
@@ -60,6 +60,28 @@ CREATE TABLE IF NOT EXISTS protocols (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
host TEXT NOT NULL,
ssh_port INT NOT NULL DEFAULT 22,
ssh_user TEXT NOT NULL DEFAULT 'root',
ssh_auth_type TEXT NOT NULL DEFAULT 'password',
ssh_secret_enc TEXT NOT NULL DEFAULT '',
node_port INT NOT NULL DEFAULT 2222,
secret_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
install_mode TEXT NOT NULL DEFAULT 'auto',
last_error TEXT NOT NULL DEFAULT '',
install_log TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status);
`
_, err := db.Exec(schema)
return err
@@ -174,5 +196,13 @@ func GetStats(db *sql.DB) (models.DashboardStats, error) {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&s.AdminsTotal)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&s.NodesTotal)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE status = 'online'`).Scan(&s.NodesOnline)
return s, err
}
+138
View File
@@ -0,0 +1,138 @@
package db
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/orohi/vpn-panel/internal/models"
)
func scanNode(scanner interface {
Scan(dest ...any) error
}) (*models.Node, error) {
n := &models.Node{}
var lastSeen sql.NullTime
var status string
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,
)
if err != nil {
return nil, err
}
n.Status = models.NodeStatus(status)
if lastSeen.Valid {
t := lastSeen.Time
n.LastSeenAt = &t
}
return n, nil
}
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`
func CreateNode(db *sql.DB, n *models.Node) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
now := time.Now().UTC()
n.CreatedAt = now
n.UpdatedAt = now
if n.Status == "" {
n.Status = models.NodeStatusPending
}
_, err := db.Exec(`
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
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17
)`,
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,
)
return err
}
func GetNode(db *sql.DB, id uuid.UUID) (*models.Node, error) {
row := db.QueryRow(`SELECT `+nodeColumns+` FROM nodes WHERE id = $1`, id)
n, err := scanNode(row)
if err == sql.ErrNoRows {
return nil, nil
}
return n, err
}
func ListNodes(db *sql.DB) ([]models.Node, error) {
rows, err := db.Query(`SELECT ` + nodeColumns + ` FROM nodes ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Node
for rows.Next() {
n, err := scanNode(rows)
if err != nil {
return nil, err
}
list = append(list, *n)
}
return list, rows.Err()
}
func UpdateNodeStatus(db *sql.DB, id uuid.UUID, status models.NodeStatus, lastError string) error {
_, err := db.Exec(`
UPDATE nodes
SET status = $2, last_error = $3, updated_at = NOW()
WHERE id = $1`, id, string(status), lastError)
return err
}
func ClearNodeInstallLog(db *sql.DB, id uuid.UUID) error {
_, err := db.Exec(`UPDATE nodes SET install_log = '', updated_at = NOW() WHERE id = $1`, id)
return err
}
func AppendNodeInstallLog(db *sql.DB, id uuid.UUID, chunk string) error {
_, err := db.Exec(`
UPDATE nodes
SET install_log = CASE
WHEN install_log = '' OR install_log IS NULL THEN $2
ELSE install_log || E'\n' || $2
END, updated_at = NOW()
WHERE id = $1`, id, strings.TrimRight(chunk, "\n"))
return err
}
func MarkNodeSeen(db *sql.DB, id uuid.UUID, version string, status models.NodeStatus) error {
_, err := db.Exec(`
UPDATE nodes
SET last_seen_at = NOW(), agent_version = $2, status = $3, last_error = '', updated_at = NOW()
WHERE id = $1`, id, version, string(status))
return err
}
func DeleteNode(db *sql.DB, id uuid.UUID) error {
res, err := db.Exec(`DELETE FROM nodes WHERE id = $1`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("node not found")
}
return nil
}
+35 -3
View File
@@ -12,23 +12,45 @@ import (
"github.com/orohi/vpn-panel/internal/auth"
"github.com/orohi/vpn-panel/internal/db"
"github.com/orohi/vpn-panel/internal/secretbox"
)
type Server struct {
DB *sql.DB
Auth *auth.Manager
Templates *template.Template
SecretKey []byte
}
type pageData map[string]any
func New(database *sql.DB, authMgr *auth.Manager, templatesDir string) (*Server, error) {
func New(database *sql.DB, authMgr *auth.Manager, appSecret, templatesDir string) (*Server, error) {
pattern := filepath.Join(templatesDir, "*.html")
tmpl, err := template.ParseGlob(pattern)
tmpl, err := template.New("").Funcs(template.FuncMap{
"statusClass": func(s string) string {
switch s {
case "online":
return "on"
case "installing":
return "warn"
case "error":
return "err"
case "offline":
return "off"
default:
return "off"
}
},
}).ParseGlob(pattern)
if err != nil {
return nil, err
}
return &Server{DB: database, Auth: authMgr, Templates: tmpl}, nil
return &Server{
DB: database,
Auth: authMgr,
Templates: tmpl,
SecretKey: secretbox.DeriveKey(appSecret),
}, nil
}
func (s *Server) Routes() http.Handler {
@@ -44,6 +66,14 @@ func (s *Server) Routes() http.Handler {
r.HandleFunc("/admin/protocols", s.Auth.RequireAuth(s.protocols)).Methods(http.MethodGet)
r.HandleFunc("/admin/protocols/{id}/toggle", s.Auth.RequireAuth(s.toggleProtocol)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes", s.Auth.RequireAuth(s.nodesList)).Methods(http.MethodGet)
r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesNewGet)).Methods(http.MethodGet)
r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesCreate)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes/{id}", s.Auth.RequireAuth(s.nodeView)).Methods(http.MethodGet)
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}/delete", s.Auth.RequireAuth(s.nodeDelete)).Methods(http.MethodPost)
r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
@@ -125,11 +155,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
nodes, _ := db.ListNodes(s.DB)
s.render(w, "dashboard.html", pageData{
"Title": "Админка",
"UserName": s.Auth.CurrentName(r),
"Stats": stats,
"Protocols": protocols,
"Nodes": nodes,
"Active": "dashboard",
})
}
+260
View File
@@ -0,0 +1,260 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/orohi/vpn-panel/internal/db"
"github.com/orohi/vpn-panel/internal/models"
"github.com/orohi/vpn-panel/internal/nodeclient"
"github.com/orohi/vpn-panel/internal/nodeinstall"
"github.com/orohi/vpn-panel/internal/secretbox"
)
func (s *Server) nodesList(w http.ResponseWriter, r *http.Request) {
nodes, err := db.ListNodes(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
s.render(w, "nodes.html", pageData{
"Title": "Ноды",
"UserName": s.Auth.CurrentName(r),
"Nodes": nodes,
"Active": "nodes",
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) nodesNewGet(w http.ResponseWriter, r *http.Request) {
s.render(w, "node_new.html", pageData{
"Title": "Добавить ноду",
"UserName": s.Auth.CurrentName(r),
"Active": "nodes",
"Form": map[string]any{
"SSHPort": 22,
"NodePort": 2222,
"SSHUser": "root",
"SSHAuthType": "password",
"InstallMode": "auto",
},
})
}
func (s *Server) nodesCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
host := strings.TrimSpace(r.FormValue("host"))
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
sshAuth := r.FormValue("ssh_auth_type")
sshSecret := r.FormValue("ssh_secret")
installMode := r.FormValue("install_mode")
if installMode != "manual" {
installMode = "auto"
}
if sshAuth != "key" {
sshAuth = "password"
}
sshPort, _ := strconv.Atoi(r.FormValue("ssh_port"))
if sshPort <= 0 {
sshPort = 22
}
nodePort, _ := strconv.Atoi(r.FormValue("node_port"))
if nodePort <= 0 {
nodePort = 2222
}
formErr := func(msg string) {
s.render(w, "node_new.html", pageData{
"Title": "Добавить ноду",
"UserName": s.Auth.CurrentName(r),
"Active": "nodes",
"Error": msg,
"Form": map[string]any{
"Name": name, "Host": host, "SSHUser": sshUser,
"SSHPort": sshPort, "NodePort": nodePort,
"SSHAuthType": sshAuth, "InstallMode": installMode,
},
})
}
if name == "" || host == "" {
formErr("Укажите имя и хост ноды")
return
}
if installMode == "auto" && strings.TrimSpace(sshSecret) == "" {
formErr("Для автоустановки нужен SSH пароль или ключ")
return
}
token, err := secretbox.RandomToken(32)
if err != nil {
http.Error(w, "token error", http.StatusInternalServerError)
return
}
enc, err := secretbox.Encrypt(s.SecretKey, sshSecret)
if err != nil {
http.Error(w, "encrypt error", http.StatusInternalServerError)
return
}
n := &models.Node{
ID: uuid.New(),
Name: name,
Host: host,
SSHPort: sshPort,
SSHUser: sshUser,
SSHAuthType: sshAuth,
SSHSecretEnc: enc,
NodePort: nodePort,
SecretKey: token,
Status: models.NodeStatusPending,
InstallMode: installMode,
}
if err := db.CreateNode(s.DB, n); err != nil {
log.Printf("create node: %v", err)
formErr("Не удалось сохранить ноду")
return
}
if installMode == "auto" {
go s.runAutoInstall(n.ID)
http.Redirect(w, r, "/admin/nodes/"+n.ID.String()+"?ok=install_started", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/nodes/"+n.ID.String()+"?ok=created", http.StatusSeeOther)
}
func (s *Server) nodeView(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
}
s.render(w, "node_view.html", pageData{
"Title": n.Name,
"UserName": s.Auth.CurrentName(r),
"Active": "nodes",
"Node": n,
"Compose": nodeinstall.ComposeYAML(n),
"ManualScript": nodeinstall.ManualInstallScript(n),
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) nodeInstall(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
}
if n.Status == models.NodeStatusInstalling {
http.Redirect(w, r, "/admin/nodes/"+id.String()+"?err=already_installing", http.StatusSeeOther)
return
}
go s.runAutoInstall(id)
http.Redirect(w, r, "/admin/nodes/"+id.String()+"?ok=install_started", http.StatusSeeOther)
}
func (s *Server) nodeCheck(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
}
hr, err := nodeclient.Health(n)
if err != nil {
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusOffline, err.Error())
http.Redirect(w, r, "/admin/nodes/"+id.String()+"?err=offline", http.StatusSeeOther)
return
}
_ = db.MarkNodeSeen(s.DB, id, hr.Version, models.NodeStatusOnline)
http.Redirect(w, r, "/admin/nodes/"+id.String()+"?ok=online", http.StatusSeeOther)
}
func (s *Server) nodeDelete(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
}
if err := db.DeleteNode(s.DB, id); err != nil {
http.Redirect(w, r, "/admin/nodes?err=delete", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/nodes?ok=deleted", http.StatusSeeOther)
}
func (s *Server) runAutoInstall(id uuid.UUID) {
n, err := db.GetNode(s.DB, id)
if err != nil || n == nil {
return
}
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusInstalling, "")
_ = db.ClearNodeInstallLog(s.DB, id)
logFn := func(line string) {
_ = db.AppendNodeInstallLog(s.DB, id, time.Now().UTC().Format("15:04:05")+" "+line)
}
secret, err := secretbox.Decrypt(s.SecretKey, n.SSHSecretEnc)
if err != nil {
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, "decrypt ssh secret: "+err.Error())
return
}
if secret == "" {
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, "SSH credentials empty")
return
}
bin, err := nodeinstall.LoadAgentBinary()
if err != nil {
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, err.Error())
logFn(err.Error())
return
}
if err := nodeinstall.Provision(n, secret, bin, logFn); err != nil {
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusError, err.Error())
logFn("ERROR: " + err.Error())
return
}
// wait briefly then probe health
time.Sleep(3 * time.Second)
if hr, err := nodeclient.Health(n); err == nil {
_ = db.MarkNodeSeen(s.DB, id, hr.Version, models.NodeStatusOnline)
logFn("health OK, node online")
return
}
_ = db.UpdateNodeStatus(s.DB, id, models.NodeStatusOffline, "agent started but health check failed (check firewall NODE_PORT)")
logFn("agent up, waiting for health on NODE_PORT — open firewall from panel IP")
}
+47
View File
@@ -27,8 +27,55 @@ type Protocol struct {
UpdatedAt time.Time `json:"updated_at"`
}
type NodeStatus string
const (
NodeStatusPending NodeStatus = "pending"
NodeStatusInstalling NodeStatus = "installing"
NodeStatusOnline NodeStatus = "online"
NodeStatusOffline NodeStatus = "offline"
NodeStatusError NodeStatus = "error"
)
type Node struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
SSHPort int `json:"ssh_port"`
SSHUser string `json:"ssh_user"`
SSHAuthType string `json:"ssh_auth_type"` // password | key
SSHSecretEnc string `json:"-"`
NodePort int `json:"node_port"`
SecretKey string `json:"-"`
Status NodeStatus `json:"status"`
InstallMode string `json:"install_mode"` // auto | manual
LastError string `json:"last_error"`
InstallLog string `json:"install_log"`
AgentVersion string `json:"agent_version"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (n Node) StatusLabel() string {
switch n.Status {
case NodeStatusOnline:
return "Online"
case NodeStatusInstalling:
return "Установка"
case NodeStatusOffline:
return "Offline"
case NodeStatusError:
return "Ошибка"
default:
return "Ожидание"
}
}
type DashboardStats struct {
ProtocolsTotal int
ProtocolsEnabled int
AdminsTotal int
NodesTotal int
NodesOnline int
}
+44
View File
@@ -0,0 +1,44 @@
package nodeclient
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/orohi/vpn-panel/internal/models"
)
type HealthResponse struct {
OK bool `json:"ok"`
Name string `json:"name"`
Version string `json:"version"`
Uptime int64 `json:"uptime_sec"`
Protocols []string `json:"protocols"`
}
func Health(n *models.Node) (*HealthResponse, error) {
url := fmt.Sprintf("http://%s:%d/health", n.Host, n.NodePort)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+n.SecretKey)
client := &http.Client{Timeout: 8 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body))
}
var hr HealthResponse
if err := json.Unmarshal(body, &hr); err != nil {
return nil, err
}
return &hr, nil
}
+49
View File
@@ -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()
}
+208
View File
@@ -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)
}
+71
View File
@@ -0,0 +1,71 @@
package secretbox
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
)
func DeriveKey(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
func Encrypt(key []byte, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
out := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(out), nil
}
func Decrypt(key []byte, encoded string) (string, error) {
if encoded == "" {
return "", nil
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func RandomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}