From 1ad5125bf4e1d0a97f40054c7322ed0a7d17fb10 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 04:18:09 +0300 Subject: [PATCH] Show protocol enable state and which nodes have each protocol installed. Co-authored-by: Cursor --- cmd/node/main.go | 50 ++++-- internal/db/db.go | 20 ++- internal/db/node_protocols.go | 290 +++++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 33 +--- internal/handlers/nodes.go | 26 ++- internal/handlers/protocols.go | 168 +++++++++++++++++++ internal/models/models.go | 32 ++++ internal/nodeclient/client.go | 46 +++++- web/static/css/app.css | 33 +++- web/templates/dashboard.html | 14 +- web/templates/node_view.html | 57 +++++++ web/templates/protocols.html | 43 ++++- 12 files changed, 745 insertions(+), 67 deletions(-) create mode 100644 internal/db/node_protocols.go create mode 100644 internal/handlers/protocols.go diff --git a/cmd/node/main.go b/cmd/node/main.go index 1716a88..483ff8a 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -17,6 +17,7 @@ type Agent struct { name string secret string version string + dataDir string startedAt time.Time mu sync.RWMutex config map[string]any @@ -30,7 +31,6 @@ func main() { } name := env("NODE_NAME", "vpn-node") version := env("AGENT_VERSION", defaultVersion) - dataDir := env("DATA_DIR", "/var/lib/vpn-node") _ = os.MkdirAll(dataDir, 0o755) @@ -38,6 +38,7 @@ func main() { name: name, secret: secret, version: version, + dataDir: dataDir, startedAt: time.Now().UTC(), config: map[string]any{}, } @@ -77,23 +78,44 @@ func (a *Agent) auth(next http.HandlerFunc) http.HandlerFunc { } } +func stringList(cfg map[string]any, key string) []string { + out := []string{} + raw, ok := cfg[key] + if !ok { + return out + } + switch v := raw.(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + case []string: + out = append(out, v...) + } + return out +} + func (a *Agent) health(w http.ResponseWriter, _ *http.Request) { a.mu.RLock() defer a.mu.RUnlock() - protocols := []string{} - if raw, ok := a.config["protocols"].([]any); ok { - for _, p := range raw { - if s, ok := p.(string); ok { - protocols = append(protocols, s) - } - } + enabled := stringList(a.config, "protocols_enabled") + if len(enabled) == 0 { + enabled = stringList(a.config, "protocols") + } + installed := stringList(a.config, "protocols_installed") + if len(installed) == 0 { + installed = append([]string{}, enabled...) } writeJSON(w, map[string]any{ - "ok": true, - "name": a.name, - "version": a.version, - "uptime_sec": int64(time.Since(a.startedAt).Seconds()), - "protocols": protocols, + "ok": true, + "name": a.name, + "version": a.version, + "uptime_sec": int64(time.Since(a.startedAt).Seconds()), + "protocols": enabled, + "protocols_installed": installed, + "protocols_enabled": enabled, }) } @@ -123,7 +145,7 @@ func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) { a.mu.Lock() a.config = cfg a.mu.Unlock() - _ = a.saveConfig("/var/lib/vpn-node/config.json") + _ = a.saveConfig(filepath.Join(a.dataDir, "config.json")) writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/db/db.go b/internal/db/db.go index 8a0d9eb..f05e924 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -82,6 +82,18 @@ CREATE TABLE IF NOT EXISTS nodes ( ); CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status); + +CREATE TABLE IF NOT EXISTS node_protocols ( + node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + protocol_id UUID NOT NULL REFERENCES protocols(id) ON DELETE CASCADE, + installed BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + port INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (node_id, protocol_id) +); + +CREATE INDEX IF NOT EXISTS idx_node_protocols_protocol ON node_protocols(protocol_id); ` _, err := db.Exec(schema) return err @@ -129,7 +141,7 @@ func SeedProtocols(db *sql.DB) error { for _, p := range defaults { _, err := db.Exec(` INSERT INTO protocols (code, name, description, port, enabled, sort_order) -VALUES ($1, $2, $3, $4, TRUE, $5) +VALUES ($1, $2, $3, $4, FALSE, $5) ON CONFLICT (code) DO NOTHING`, p.Code, p.Name, p.Description, p.Port, p.Sort, ) @@ -185,6 +197,12 @@ UPDATE protocols SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1`, i return err } +func SetProtocolEnabled(db *sql.DB, id uuid.UUID, enabled bool) error { + _, err := db.Exec(` +UPDATE protocols SET enabled = $2, updated_at = NOW() WHERE id = $1`, id, enabled) + return err +} + func GetStats(db *sql.DB) (models.DashboardStats, error) { var s models.DashboardStats err := db.QueryRow(`SELECT COUNT(*) FROM protocols`).Scan(&s.ProtocolsTotal) diff --git a/internal/db/node_protocols.go b/internal/db/node_protocols.go new file mode 100644 index 0000000..0194c0f --- /dev/null +++ b/internal/db/node_protocols.go @@ -0,0 +1,290 @@ +package db + +import ( + "database/sql" + + "github.com/google/uuid" + + "github.com/orohi/vpn-panel/internal/models" +) + +func ListProtocolsDetailed(db *sql.DB) ([]models.Protocol, error) { + list, err := ListProtocols(db) + if err != nil { + return nil, err + } + if len(list) == 0 { + return list, nil + } + + rows, err := db.Query(` +SELECT np.protocol_id, np.installed, np.enabled, + n.id, n.name, n.host, n.status +FROM node_protocols np +JOIN nodes n ON n.id = np.node_id +WHERE np.installed = TRUE OR np.enabled = TRUE +ORDER BY n.name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + byID := map[uuid.UUID]*models.Protocol{} + for i := range list { + byID[list[i].ID] = &list[i] + } + + for rows.Next() { + var protocolID, nodeID uuid.UUID + var installed, enabled bool + var name, host, status string + if err := rows.Scan(&protocolID, &installed, &enabled, &nodeID, &name, &host, &status); err != nil { + return nil, err + } + p := byID[protocolID] + if p == nil { + continue + } + ref := models.NodeProtoRef{ + NodeID: nodeID, + NodeName: name, + Host: host, + Status: models.NodeStatus(status), + } + if installed { + p.InstalledOn = append(p.InstalledOn, ref) + } + if enabled { + p.EnabledOn = append(p.EnabledOn, ref) + } + } + return list, rows.Err() +} + +func ListNodeProtocols(db *sql.DB, nodeID uuid.UUID) ([]models.NodeProtocol, error) { + rows, err := db.Query(` +SELECT p.id, p.code, p.name, COALESCE(NULLIF(np.port, 0), p.port), + COALESCE(np.installed, FALSE), COALESCE(np.enabled, FALSE), + COALESCE(np.updated_at, p.updated_at) +FROM protocols p +LEFT JOIN node_protocols np ON np.protocol_id = p.id AND np.node_id = $1 +ORDER BY p.sort_order ASC, p.name ASC`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []models.NodeProtocol + for rows.Next() { + var np models.NodeProtocol + np.NodeID = nodeID + if err := rows.Scan( + &np.ProtocolID, &np.ProtocolCode, &np.ProtocolName, &np.Port, + &np.Installed, &np.Enabled, &np.UpdatedAt, + ); err != nil { + return nil, err + } + list = append(list, np) + } + return list, rows.Err() +} + +func UpsertNodeProtocol(db *sql.DB, nodeID, protocolID uuid.UUID, installed, enabled bool, port int) error { + _, err := db.Exec(` +INSERT INTO node_protocols (node_id, protocol_id, installed, enabled, port, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW()) +ON CONFLICT (node_id, protocol_id) DO UPDATE SET + installed = EXCLUDED.installed, + enabled = EXCLUDED.enabled, + port = EXCLUDED.port, + updated_at = NOW()`, nodeID, protocolID, installed, enabled, port) + return err +} + +func SetNodeProtocolFlags(db *sql.DB, nodeID, protocolID uuid.UUID, installed, enabled bool) error { + var port int + err := db.QueryRow(` +SELECT COALESCE(NULLIF(np.port, 0), p.port) +FROM protocols p +LEFT JOIN node_protocols np ON np.protocol_id = p.id AND np.node_id = $1 +WHERE p.id = $2`, nodeID, protocolID).Scan(&port) + if err == sql.ErrNoRows { + return sql.ErrNoRows + } + if err != nil { + return err + } + return UpsertNodeProtocol(db, nodeID, protocolID, installed, enabled, port) +} + +func GetProtocol(db *sql.DB, id uuid.UUID) (*models.Protocol, error) { + p := &models.Protocol{} + err := db.QueryRow(` +SELECT id, code, name, description, port, enabled, sort_order, created_at, updated_at +FROM protocols WHERE id = $1`, id).Scan( + &p.ID, &p.Code, &p.Name, &p.Description, &p.Port, + &p.Enabled, &p.SortOrder, &p.CreatedAt, &p.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return p, nil +} + +func EnabledProtocolCodesForNode(db *sql.DB, nodeID uuid.UUID) ([]string, error) { + rows, err := db.Query(` +SELECT p.code FROM node_protocols np +JOIN protocols p ON p.id = np.protocol_id +WHERE np.node_id = $1 AND np.enabled = TRUE +ORDER BY p.sort_order`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + var codes []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + codes = append(codes, c) + } + return codes, rows.Err() +} + +func InstalledProtocolCodesForNode(db *sql.DB, nodeID uuid.UUID) ([]string, error) { + rows, err := db.Query(` +SELECT p.code FROM node_protocols np +JOIN protocols p ON p.id = np.protocol_id +WHERE np.node_id = $1 AND np.installed = TRUE +ORDER BY p.sort_order`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + var codes []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + codes = append(codes, c) + } + return codes, rows.Err() +} + +// SyncNodeProtocolsFromAgent updates install/enable flags from agent-reported codes. +func SyncNodeProtocolsFromAgent(db *sql.DB, nodeID uuid.UUID, installed, enabled []string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + installedSet := map[string]bool{} + for _, c := range installed { + installedSet[c] = true + } + enabledSet := map[string]bool{} + for _, c := range enabled { + enabledSet[c] = true + installedSet[c] = true // enabled implies installed + } + + rows, err := tx.Query(`SELECT id, code, port FROM protocols`) + if err != nil { + return err + } + defer rows.Close() + + type proto struct { + id uuid.UUID + code string + port int + } + var all []proto + for rows.Next() { + var p proto + if err := rows.Scan(&p.id, &p.code, &p.port); err != nil { + return err + } + all = append(all, p) + } + if err := rows.Err(); err != nil { + return err + } + _ = rows.Close() + + for _, p := range all { + inst := installedSet[p.code] + en := enabledSet[p.code] + if !inst && !en { + _, err = tx.Exec(` +UPDATE node_protocols SET installed = FALSE, enabled = FALSE, updated_at = NOW() +WHERE node_id = $1 AND protocol_id = $2`, nodeID, p.id) + if err != nil { + return err + } + continue + } + _, err = tx.Exec(` +INSERT INTO node_protocols (node_id, protocol_id, installed, enabled, port, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW()) +ON CONFLICT (node_id, protocol_id) DO UPDATE SET + installed = EXCLUDED.installed, + enabled = EXCLUDED.enabled, + updated_at = NOW()`, nodeID, p.id, inst, en, p.port) + if err != nil { + return err + } + } + return tx.Commit() +} + +func NodeProtocolConfigPayload(db *sql.DB, nodeID uuid.UUID) (map[string]any, error) { + rows, err := db.Query(` +SELECT p.code, np.installed, np.enabled, COALESCE(NULLIF(np.port, 0), p.port) +FROM node_protocols np +JOIN protocols p ON p.id = np.protocol_id +WHERE np.node_id = $1 AND (np.installed = TRUE OR np.enabled = TRUE) +ORDER BY p.sort_order`, nodeID) + if err != nil { + return nil, err + } + defer rows.Close() + + var installed, enabled []string + var items []map[string]any + for rows.Next() { + var code string + var inst, en bool + var port int + if err := rows.Scan(&code, &inst, &en, &port); err != nil { + return nil, err + } + if inst { + installed = append(installed, code) + } + if en { + enabled = append(enabled, code) + } + items = append(items, map[string]any{ + "code": code, "installed": inst, "enabled": en, "port": port, + }) + } + if installed == nil { + installed = []string{} + } + if enabled == nil { + enabled = []string{} + } + return map[string]any{ + "protocols": enabled, // backward-compatible health list + "protocols_installed": installed, + "protocols_enabled": enabled, + "protocol_items": items, + }, rows.Err() +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e58dcca..23219c7 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,7 +7,6 @@ import ( "net/http" "path/filepath" - "github.com/google/uuid" "github.com/gorilla/mux" "github.com/orohi/vpn-panel/internal/auth" @@ -72,7 +71,9 @@ func (s *Server) Routes() http.Handler { 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}/sync", s.Auth.RequireAuth(s.nodeSyncProtocols)).Methods(http.MethodPost) r.HandleFunc("/admin/nodes/{id}/delete", s.Auth.RequireAuth(s.nodeDelete)).Methods(http.MethodPost) + r.HandleFunc("/admin/nodes/{id}/protocols/{protocolID}", s.Auth.RequireAuth(s.nodeProtocolAction)).Methods(http.MethodPost) r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) @@ -150,7 +151,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { http.Error(w, "db error", http.StatusInternalServerError) return } - protocols, err := db.ListProtocols(s.DB) + protocols, err := db.ListProtocolsDetailed(s.DB) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return @@ -165,31 +166,3 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { "Active": "dashboard", }) } - -func (s *Server) protocols(w http.ResponseWriter, r *http.Request) { - protocols, err := db.ListProtocols(s.DB) - if err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - s.render(w, "protocols.html", pageData{ - "Title": "Протоколы", - "UserName": s.Auth.CurrentName(r), - "Protocols": protocols, - "Active": "protocols", - }) -} - -func (s *Server) toggleProtocol(w http.ResponseWriter, r *http.Request) { - idStr := mux.Vars(r)["id"] - id, err := uuid.Parse(idStr) - if err != nil { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - if err := db.ToggleProtocol(s.DB, id); err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/admin/protocols", http.StatusSeeOther) -} diff --git a/internal/handlers/nodes.go b/internal/handlers/nodes.go index 1849141..7e14bcc 100644 --- a/internal/handlers/nodes.go +++ b/internal/handlers/nodes.go @@ -148,15 +148,21 @@ func (s *Server) nodeView(w http.ResponseWriter, r *http.Request) { 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, - "Compose": nodeinstall.ComposeYAML(n), - "ManualScript": nodeinstall.ManualInstallScript(n), - "Flash": r.URL.Query().Get("ok"), - "Error": r.URL.Query().Get("err"), + "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"), }) } @@ -197,6 +203,10 @@ func (s *Server) nodeCheck(w http.ResponseWriter, r *http.Request) { 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) } diff --git a/internal/handlers/protocols.go b/internal/handlers/protocols.go new file mode 100644 index 0000000..98b456d --- /dev/null +++ b/internal/handlers/protocols.go @@ -0,0 +1,168 @@ +package handlers + +import ( + "log" + "net/http" + + "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" +) + +func (s *Server) syncNodeConfig(n *models.Node) error { + cfg, err := db.NodeProtocolConfigPayload(s.DB, n.ID) + if err != nil { + return err + } + return nodeclient.PushConfig(n, cfg) +} + +func (s *Server) protocols(w http.ResponseWriter, r *http.Request) { + protocols, err := db.ListProtocolsDetailed(s.DB) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + nodes, _ := db.ListNodes(s.DB) + s.render(w, "protocols.html", pageData{ + "Title": "Протоколы", + "UserName": s.Auth.CurrentName(r), + "Protocols": protocols, + "Nodes": nodes, + "Active": "protocols", + "Flash": r.URL.Query().Get("ok"), + "Error": r.URL.Query().Get("err"), + }) +} + +func (s *Server) toggleProtocol(w http.ResponseWriter, r *http.Request) { + idStr := mux.Vars(r)["id"] + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := db.ToggleProtocol(s.DB, id); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/protocols", http.StatusSeeOther) +} + +func (s *Server) nodeProtocolAction(w http.ResponseWriter, r *http.Request) { + nodeID, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid node id", http.StatusBadRequest) + return + } + protocolID, err := uuid.Parse(mux.Vars(r)["protocolID"]) + if err != nil { + http.Error(w, "invalid protocol id", http.StatusBadRequest) + return + } + action := r.FormValue("action") + if action == "" { + _ = r.ParseForm() + action = r.FormValue("action") + } + + n, err := db.GetNode(s.DB, nodeID) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + p, err := db.GetProtocol(s.DB, protocolID) + if err != nil || p == nil { + http.NotFound(w, r) + return + } + + list, err := db.ListNodeProtocols(s.DB, nodeID) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + var current models.NodeProtocol + for _, item := range list { + if item.ProtocolID == protocolID { + current = item + break + } + } + + installed, enabled := current.Installed, current.Enabled + switch action { + case "install": + installed, enabled = true, true + case "uninstall": + installed, enabled = false, false + case "enable": + installed, enabled = true, true + case "disable": + enabled = false + if !installed { + installed = false + } + default: + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=bad_action", http.StatusSeeOther) + return + } + + if err := db.SetNodeProtocolFlags(s.DB, nodeID, protocolID, installed, enabled); err != nil { + log.Printf("set node protocol: %v", err) + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=proto_save", http.StatusSeeOther) + return + } + + // If panel catalog was off and we install/enable — turn protocol on in catalog. + if (action == "install" || action == "enable") && !p.Enabled { + _ = db.SetProtocolEnabled(s.DB, protocolID, true) + } + if action == "uninstall" { + // If nowhere else has it installed, turn catalog off. + detailed, _ := db.ListProtocolsDetailed(s.DB) + for _, item := range detailed { + if item.ID == protocolID && len(item.InstalledOn) == 0 { + _ = db.SetProtocolEnabled(s.DB, protocolID, false) + } + } + } + + if n.Status == models.NodeStatusOnline { + if err := s.syncNodeConfig(n); err != nil { + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=sync_failed", http.StatusSeeOther) + return + } + } + + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?ok=protocol_"+action, http.StatusSeeOther) +} + +func (s *Server) nodeSyncProtocols(w http.ResponseWriter, r *http.Request) { + nodeID, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + n, err := db.GetNode(s.DB, nodeID) + if err != nil || n == nil { + http.NotFound(w, r) + return + } + hr, err := nodeclient.Health(n) + if err != nil { + _ = db.UpdateNodeStatus(s.DB, nodeID, models.NodeStatusOffline, err.Error()) + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=offline", http.StatusSeeOther) + return + } + _ = db.MarkNodeSeen(s.DB, nodeID, hr.Version, models.NodeStatusOnline) + // Push panel → node (desired state). Do not wipe local DB from empty agent. + if err := s.syncNodeConfig(n); err != nil { + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=sync_failed", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?ok=synced", http.StatusSeeOther) +} diff --git a/internal/models/models.go b/internal/models/models.go index e7fe510..56c3c42 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -25,8 +25,40 @@ type Protocol struct { SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + InstalledOn []NodeProtoRef `json:"installed_on,omitempty"` + EnabledOn []NodeProtoRef `json:"enabled_on,omitempty"` } +type NodeProtoRef struct { + NodeID uuid.UUID `json:"node_id"` + NodeName string `json:"node_name"` + Host string `json:"host"` + Status NodeStatus `json:"status"` +} + +type NodeProtocol struct { + NodeID uuid.UUID `json:"node_id"` + ProtocolID uuid.UUID `json:"protocol_id"` + ProtocolCode string `json:"protocol_code"` + ProtocolName string `json:"protocol_name"` + Port int `json:"port"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (p Protocol) EnabledLabel() string { + if p.Enabled { + return "Включён" + } + return "Выключен" +} + +func (p Protocol) InstallCount() int { return len(p.InstalledOn) } +func (p Protocol) ActiveCount() int { return len(p.EnabledOn) } + + type NodeStatus string const ( diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go index c28a3ae..dabc1c9 100644 --- a/internal/nodeclient/client.go +++ b/internal/nodeclient/client.go @@ -1,6 +1,7 @@ package nodeclient import ( + "bytes" "encoding/json" "fmt" "io" @@ -11,11 +12,13 @@ import ( ) type HealthResponse struct { - OK bool `json:"ok"` - Name string `json:"name"` - Version string `json:"version"` - Uptime int64 `json:"uptime_sec"` - Protocols []string `json:"protocols"` + OK bool `json:"ok"` + Name string `json:"name"` + Version string `json:"version"` + Uptime int64 `json:"uptime_sec"` + Protocols []string `json:"protocols"` + ProtocolsInstalled []string `json:"protocols_installed"` + ProtocolsEnabled []string `json:"protocols_enabled"` } func Health(n *models.Node) (*HealthResponse, error) { @@ -40,5 +43,38 @@ func Health(n *models.Node) (*HealthResponse, error) { if err := json.Unmarshal(body, &hr); err != nil { return nil, err } + // Fallback: older agents only expose protocols as enabled list. + if len(hr.ProtocolsEnabled) == 0 && len(hr.Protocols) > 0 { + hr.ProtocolsEnabled = hr.Protocols + } + if len(hr.ProtocolsInstalled) == 0 && len(hr.ProtocolsEnabled) > 0 { + hr.ProtocolsInstalled = append([]string{}, hr.ProtocolsEnabled...) + } return &hr, nil } + +func PushConfig(n *models.Node, cfg map[string]any) error { + payload, err := json.Marshal(cfg) + if err != nil { + return err + } + url := fmt.Sprintf("http://%s:%d/api/v1/config", n.Host, n.NodePort) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+n.SecretKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 12 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if res.StatusCode >= 300 { + return fmt.Errorf("status %d: %s", res.StatusCode, string(body)) + } + return nil +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 4b8aa43..bccbc6e 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -274,8 +274,37 @@ input:focus { padding: 0.25rem 0.55rem; border-radius: 999px; } -.badge.on { background: rgba(61,214,198,0.15); color: var(--ok); } -.badge.off { background: rgba(122,135,156,0.18); color: var(--off); } +.chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.chip { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + background: rgba(122,135,156,0.16); + color: var(--muted); + border: 1px solid var(--line); +} +.chip.on { background: rgba(61,214,198,0.15); color: var(--ok); border-color: rgba(61,214,198,0.3); } +.chip.warn { background: rgba(255,196,87,0.14); color: #ffc457; } +.chip.err { background: rgba(255,107,122,0.14); color: #ff8a96; } +.chip.off { opacity: 0.85; } + +.actions-multi { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: flex-end; +} +.row-off { opacity: 0.78; } +.proto-servers { margin-top: 0.75rem; } +.proto-servers .stat-label { margin-bottom: 0.35rem; } + .badge.warn { background: rgba(255, 196, 87, 0.16); color: #ffc457; } .badge.err { background: rgba(255,107,122,0.15); color: #ff8a96; } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 7fb4fc5..35beeed 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -88,13 +88,25 @@

{{.Name}}

- {{if .Enabled}}ON{{else}}OFF{{end}} + {{.EnabledLabel}}

{{.Description}}

{{.Code}} порт {{.Port}}
+
+ {{if .InstalledOn}} + Серверы +
+ {{range .InstalledOn}} + {{.NodeName}} + {{end}} +
+ {{else}} + Не установлен ни на одной ноде + {{end}} +
{{end}} diff --git a/web/templates/node_view.html b/web/templates/node_view.html index 1dc4118..7541b88 100644 --- a/web/templates/node_view.html +++ b/web/templates/node_view.html @@ -46,12 +46,69 @@
+
+ +
К списку +
+

Протоколы на этом сервере

+
+ + + + + + + + + + + + {{range .NodeProtocols}} + + + + + + + + {{end}} + +
ПротоколПортУстановленВключён
+ {{.ProtocolName}} +
{{.ProtocolCode}}
+
{{.Port}}{{if .Installed}}Да{{else}}Нет{{end}}{{if .Enabled}}ON{{else}}OFF{{end}} + {{if not .Installed}} +
+ + +
+ {{else}} + {{if .Enabled}} +
+ + +
+ {{else}} +
+ + +
+ {{end}} +
+ + +
+ {{end}} +
+
+
+

Важно

Откройте порт {{.Node.NodePort}} на ноде только для IP панели (как NODE_PORT в Remnawave).

diff --git a/web/templates/protocols.html b/web/templates/protocols.html index 943dcac..d8d97b2 100644 --- a/web/templates/protocols.html +++ b/web/templates/protocols.html @@ -26,31 +26,57 @@

Протоколы

-

Включение и отключение VPN-протоколов

+

Статус в панели и на каких серверах протокол установлен / включён

+ {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} +
- - + + + {{range .Protocols}} - + - + +
ПротоколКод ПортСтатусВ панелиУстановлен наВключён на
{{.Name}}
{{.Description}}
+ {{.Code}}
{{.Code}} {{.Port}} - {{if .Enabled}}Включён{{else}}Выключен{{end}} + {{.EnabledLabel}} + + {{if .InstalledOn}} +
+ {{range .InstalledOn}} + {{.NodeName}} + {{end}} +
+ {{else}} + нигде + {{end}} +
+ {{if .EnabledOn}} +
+ {{range .EnabledOn}} + {{.NodeName}} + {{end}} +
+ {{else}} + нигде + {{end}}
@@ -64,6 +90,11 @@
+ +

+ Установка на сервер — на странице ноды. «В панели» = доступен в каталоге; + «Установлен / Включён на» = фактическое состояние на серверах. +