Show protocol enable state and which nodes have each protocol installed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:18:09 +03:00
co-authored by Cursor
parent 6510de0214
commit 1ad5125bf4
12 changed files with 745 additions and 67 deletions
+31 -9
View File
@@ -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,
"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)
+19 -1
View File
@@ -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)
+290
View File
@@ -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()
}
+3 -30
View File
@@ -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)
}
+10
View File
@@ -148,11 +148,17 @@ 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,
"NodeProtocols": nodeProtocols,
"Compose": nodeinstall.ComposeYAML(n),
"ManualScript": nodeinstall.ManualInstallScript(n),
"Flash": r.URL.Query().Get("ok"),
@@ -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)
}
+168
View File
@@ -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)
}
+32
View File
@@ -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 (
+36
View File
@@ -1,6 +1,7 @@
package nodeclient
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -16,6 +17,8 @@ type HealthResponse struct {
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
}
+31 -2
View File
@@ -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; }
+13 -1
View File
@@ -88,13 +88,25 @@
<article class="proto-card {{if .Enabled}}is-on{{else}}is-off{{end}}">
<div class="proto-top">
<h3>{{.Name}}</h3>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{.EnabledLabel}}</span>
</div>
<p>{{.Description}}</p>
<div class="proto-meta">
<code>{{.Code}}</code>
<span>порт {{.Port}}</span>
</div>
<div class="proto-servers">
{{if .InstalledOn}}
<span class="stat-label">Серверы</span>
<div class="chip-list">
{{range .InstalledOn}}
<a class="chip {{statusClass (printf "%s" .Status)}}" href="/admin/nodes/{{.NodeID}}">{{.NodeName}}</a>
{{end}}
</div>
{{else}}
<span class="muted">Не установлен ни на одной ноде</span>
{{end}}
</div>
</article>
{{end}}
</div>
+57
View File
@@ -46,12 +46,69 @@
<form method="POST" action="/admin/nodes/{{.Node.ID}}/check">
<button class="btn btn-ghost" type="submit">Проверить online</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/sync">
<button class="btn btn-ghost" type="submit">Синхронизировать протоколы</button>
</form>
<form method="POST" action="/admin/nodes/{{.Node.ID}}/delete" onsubmit="return confirm('Удалить ноду из панели?');">
<button class="btn btn-ghost" type="submit">Удалить</button>
</form>
<a class="btn btn-ghost" href="/admin/nodes">К списку</a>
</div>
<section class="panel-section">
<h2>Протоколы на этом сервере</h2>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Протокол</th>
<th>Порт</th>
<th>Установлен</th>
<th>Включён</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .NodeProtocols}}
<tr>
<td>
<strong>{{.ProtocolName}}</strong>
<div><code>{{.ProtocolCode}}</code></div>
</td>
<td>{{.Port}}</td>
<td><span class="badge {{if .Installed}}on{{else}}off{{end}}">{{if .Installed}}Да{{else}}Нет{{end}}</span></td>
<td><span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span></td>
<td class="actions actions-multi">
{{if not .Installed}}
<form method="POST" action="/admin/nodes/{{$.Node.ID}}/protocols/{{.ProtocolID}}">
<input type="hidden" name="action" value="install">
<button class="btn btn-sm btn-primary" type="submit">Установить</button>
</form>
{{else}}
{{if .Enabled}}
<form method="POST" action="/admin/nodes/{{$.Node.ID}}/protocols/{{.ProtocolID}}">
<input type="hidden" name="action" value="disable">
<button class="btn btn-sm btn-ghost" type="submit">Выключить</button>
</form>
{{else}}
<form method="POST" action="/admin/nodes/{{$.Node.ID}}/protocols/{{.ProtocolID}}">
<input type="hidden" name="action" value="enable">
<button class="btn btn-sm btn-primary" type="submit">Включить</button>
</form>
{{end}}
<form method="POST" action="/admin/nodes/{{$.Node.ID}}/protocols/{{.ProtocolID}}">
<input type="hidden" name="action" value="uninstall">
<button class="btn btn-sm btn-ghost" type="submit">Снять</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="panel-section">
<h2>Важно</h2>
<p class="muted">Откройте порт <strong>{{.Node.NodePort}}</strong> на ноде <em>только</em> для IP панели (как NODE_PORT в Remnawave).</p>
+37 -6
View File
@@ -26,31 +26,57 @@
<main class="admin-main">
<header class="admin-header">
<h1>Протоколы</h1>
<p class="muted">Включение и отключение VPN-протоколов</p>
<p class="muted">Статус в панели и на каких серверах протокол установлен / включён</p>
</header>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Протокол</th>
<th>Код</th>
<th>Порт</th>
<th>Статус</th>
<th>В панели</th>
<th>Установлен на</th>
<th>Включён на</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Protocols}}
<tr>
<tr class="{{if .Enabled}}row-on{{else}}row-off{{end}}">
<td>
<strong>{{.Name}}</strong>
<div class="cell-desc">{{.Description}}</div>
<code>{{.Code}}</code>
</td>
<td><code>{{.Code}}</code></td>
<td>{{.Port}}</td>
<td>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}Включён{{else}}Выключен{{end}}</span>
<span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{.EnabledLabel}}</span>
</td>
<td>
{{if .InstalledOn}}
<div class="chip-list">
{{range .InstalledOn}}
<a class="chip {{statusClass (printf "%s" .Status)}}" href="/admin/nodes/{{.NodeID}}" title="{{.Host}}">{{.NodeName}}</a>
{{end}}
</div>
{{else}}
<span class="muted">нигде</span>
{{end}}
</td>
<td>
{{if .EnabledOn}}
<div class="chip-list">
{{range .EnabledOn}}
<a class="chip on" href="/admin/nodes/{{.NodeID}}" title="{{.Host}}">{{.NodeName}}</a>
{{end}}
</div>
{{else}}
<span class="muted">нигде</span>
{{end}}
</td>
<td class="actions">
<form method="POST" action="/admin/protocols/{{.ID}}/toggle">
@@ -64,6 +90,11 @@
</tbody>
</table>
</div>
<p class="hint" style="margin-top:1rem">
Установка на сервер — на странице ноды. «В панели» = доступен в каталоге;
«Установлен / Включён на» = фактическое состояние на серверах.
</p>
</main>
</body>
</html>