Add node mode with SSH auto-install and Remnawave-style manual deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user