Show protocol enable state and which nodes have each protocol installed.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-14
@@ -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)
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+31
-2
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user