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,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")
|
||||
}
|
||||
Reference in New Issue
Block a user