{{.Name}}
- {{if .Enabled}}ON{{else}}OFF{{end}} + {{.EnabledLabel}}{{.Description}}
+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 @@
{{.Description}}{{.Name}}
- {{if .Enabled}}ON{{else}}OFF{{end}}
+ {{.EnabledLabel}}
| Протокол | +Порт | +Установлен | +Включён | ++ |
|---|---|---|---|---|
+ {{.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-протоколов
+Статус в панели и на каких серверах протокол установлен / включён
| Протокол | -Код | Порт | -Статус | +В панели | +Установлен на | +Включён на | |
|---|---|---|---|---|---|---|---|
|
{{.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}}
|
+ Установка на сервер — на странице ноды. «В панели» = доступен в каталоге; + «Установлен / Включён на» = фактическое состояние на серверах. +