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 } nodeProtocols, err := db.ListNodeProtocols(s.DB, id) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } s.render(w, "node_view.html", pageData{ "Title": n.Name, "UserName": s.Auth.CurrentName(r), "Active": "nodes", "Node": n, "NodeProtocols": nodeProtocols, "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) // Panel is source of truth — push desired protocols to the agent. if err := s.syncNodeConfig(n); err != nil { log.Printf("push config to node %s: %v", id, err) } 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") }