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
+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")
}