Add config profiles with inbound assignment for nodes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:41:41 +03:00
co-authored by Cursor
parent 1fb87f5f6c
commit 4991b313d7
16 changed files with 1122 additions and 25 deletions
+39
View File
@@ -95,12 +95,47 @@ CREATE TABLE IF NOT EXISTS node_protocols (
);
CREATE INDEX IF NOT EXISTS idx_node_protocols_protocol ON node_protocols(protocol_id);
CREATE TABLE IF NOT EXISTS config_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS inbounds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES config_profiles(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
protocol_id UUID NOT NULL REFERENCES protocols(id) ON DELETE RESTRICT,
port INT NOT NULL DEFAULT 443,
network TEXT NOT NULL DEFAULT 'tcp',
security TEXT NOT NULL DEFAULT 'none',
listen TEXT NOT NULL DEFAULT '0.0.0.0',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
remark TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (profile_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_inbounds_profile ON inbounds(profile_id);
CREATE TABLE IF NOT EXISTS node_inbounds (
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
inbound_id UUID NOT NULL REFERENCES inbounds(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (node_id, inbound_id)
);
`
if _, err := db.Exec(schema); err != nil {
return err
}
// Additive migrations for existing deployments.
_, _ = db.Exec(`ALTER TABLE nodes ADD COLUMN IF NOT EXISTS runtime_log TEXT NOT NULL DEFAULT ''`)
_, _ = db.Exec(`ALTER TABLE nodes ADD COLUMN IF NOT EXISTS profile_id UUID REFERENCES config_profiles(id) ON DELETE SET NULL`)
return nil
}
@@ -227,5 +262,9 @@ func GetStats(db *sql.DB) (models.DashboardStats, error) {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE status = 'online'`).Scan(&s.NodesOnline)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM config_profiles`).Scan(&s.ProfilesTotal)
return s, err
}
+44 -3
View File
@@ -275,16 +275,57 @@ ORDER BY p.sort_order`, nodeID)
"code": code, "installed": inst, "enabled": en, "port": port,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
if installed == nil {
installed = []string{}
}
if enabled == nil {
enabled = []string{}
}
return map[string]any{
"protocols": enabled, // backward-compatible health list
payload := map[string]any{
"protocols": enabled,
"protocols_installed": installed,
"protocols_enabled": enabled,
"protocol_items": items,
}, rows.Err()
}
n, err := GetNode(db, nodeID)
if err != nil {
return nil, err
}
if n != nil && n.ProfileID != nil {
payload["profile"] = map[string]any{
"id": n.ProfileID.String(),
"name": n.ProfileName,
}
inbounds, err := ListNodeInbounds(db, nodeID)
if err != nil {
return nil, err
}
var inList []map[string]any
for _, in := range inbounds {
if !in.NodeEnabled || !in.Enabled {
continue
}
inList = append(inList, map[string]any{
"id": in.ID.String(),
"tag": in.Tag,
"protocol": in.ProtocolCode,
"port": in.Port,
"network": in.Network,
"security": in.Security,
"listen": in.Listen,
"remark": in.Remark,
})
}
if inList == nil {
inList = []map[string]any{}
}
payload["inbounds"] = inList
}
return payload, nil
}
+28 -9
View File
@@ -17,10 +17,13 @@ func scanNode(scanner interface {
n := &models.Node{}
var lastSeen sql.NullTime
var status string
var profileID uuid.NullUUID
var profileName sql.NullString
err := scanner.Scan(
&n.ID, &n.Name, &n.Host, &n.SSHPort, &n.SSHUser, &n.SSHAuthType, &n.SSHSecretEnc,
&n.NodePort, &n.SecretKey, &status, &n.InstallMode, &n.LastError, &n.InstallLog,
&n.RuntimeLog, &n.AgentVersion, &lastSeen, &n.CreatedAt, &n.UpdatedAt,
&profileID, &profileName,
)
if err != nil {
return nil, err
@@ -30,13 +33,25 @@ func scanNode(scanner interface {
t := lastSeen.Time
n.LastSeenAt = &t
}
if profileID.Valid {
id := profileID.UUID
n.ProfileID = &id
}
if profileName.Valid {
n.ProfileName = profileName.String
}
return n, nil
}
const nodeColumns = `
id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc,
node_port, secret_key, status, install_mode, last_error, install_log,
runtime_log, agent_version, last_seen_at, created_at, updated_at`
n.id, n.name, n.host, n.ssh_port, n.ssh_user, n.ssh_auth_type, n.ssh_secret_enc,
n.node_port, n.secret_key, n.status, n.install_mode, n.last_error, n.install_log,
n.runtime_log, n.agent_version, n.last_seen_at, n.created_at, n.updated_at,
n.profile_id, COALESCE(cp.name, '')`
const nodeFrom = `
FROM nodes n
LEFT JOIN config_profiles cp ON cp.id = n.profile_id`
func CreateNode(db *sql.DB, n *models.Node) error {
if n.ID == uuid.Nil {
@@ -52,19 +67,19 @@ func CreateNode(db *sql.DB, n *models.Node) error {
INSERT INTO nodes (
id, name, host, ssh_port, ssh_user, ssh_auth_type, ssh_secret_enc,
node_port, secret_key, status, install_mode, last_error, install_log,
runtime_log, agent_version, last_seen_at, created_at, updated_at
runtime_log, agent_version, last_seen_at, created_at, updated_at, profile_id
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19
)`,
n.ID, n.Name, n.Host, n.SSHPort, n.SSHUser, n.SSHAuthType, n.SSHSecretEnc,
n.NodePort, n.SecretKey, string(n.Status), n.InstallMode, n.LastError, n.InstallLog,
n.RuntimeLog, n.AgentVersion, n.LastSeenAt, n.CreatedAt, n.UpdatedAt,
n.RuntimeLog, n.AgentVersion, n.LastSeenAt, n.CreatedAt, n.UpdatedAt, n.ProfileID,
)
return err
}
func GetNode(db *sql.DB, id uuid.UUID) (*models.Node, error) {
row := db.QueryRow(`SELECT `+nodeColumns+` FROM nodes WHERE id = $1`, id)
row := db.QueryRow(`SELECT `+nodeColumns+nodeFrom+` WHERE n.id = $1`, id)
n, err := scanNode(row)
if err == sql.ErrNoRows {
return nil, nil
@@ -73,7 +88,7 @@ func GetNode(db *sql.DB, id uuid.UUID) (*models.Node, error) {
}
func ListNodes(db *sql.DB) ([]models.Node, error) {
rows, err := db.Query(`SELECT ` + nodeColumns + ` FROM nodes ORDER BY created_at DESC`)
rows, err := db.Query(`SELECT ` + nodeColumns + nodeFrom + ` ORDER BY n.created_at DESC`)
if err != nil {
return nil, err
}
@@ -115,7 +130,6 @@ WHERE id = $1`, id, strings.TrimRight(chunk, "\n"))
}
func SetNodeRuntimeLog(db *sql.DB, id uuid.UUID, logText string) error {
// Cap stored log size to keep DB rows reasonable.
const max = 200_000
if len(logText) > max {
logText = logText[len(logText)-max:]
@@ -133,6 +147,11 @@ WHERE id = $1`, id, version, string(status))
return err
}
func SetNodeProfile(db *sql.DB, nodeID uuid.UUID, profileID *uuid.UUID) error {
_, err := db.Exec(`UPDATE nodes SET profile_id = $2, updated_at = NOW() WHERE id = $1`, nodeID, profileID)
return err
}
func DeleteNode(db *sql.DB, id uuid.UUID) error {
res, err := db.Exec(`DELETE FROM nodes WHERE id = $1`, id)
if err != nil {
+340
View File
@@ -0,0 +1,340 @@
package db
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/orohi/vpn-panel/internal/models"
)
func CreateProfile(db *sql.DB, p *models.ConfigProfile) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
now := time.Now().UTC()
p.CreatedAt = now
p.UpdatedAt = now
_, err := db.Exec(`
INSERT INTO config_profiles (id, name, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)`, p.ID, p.Name, p.Description, p.CreatedAt, p.UpdatedAt)
return err
}
func UpdateProfile(db *sql.DB, p *models.ConfigProfile) error {
_, err := db.Exec(`
UPDATE config_profiles SET name = $2, description = $3, updated_at = NOW() WHERE id = $1`,
p.ID, p.Name, p.Description)
return err
}
func DeleteProfile(db *sql.DB, id uuid.UUID) error {
res, err := db.Exec(`DELETE FROM config_profiles WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("profile not found")
}
return nil
}
func GetProfile(db *sql.DB, id uuid.UUID) (*models.ConfigProfile, error) {
p := &models.ConfigProfile{}
err := db.QueryRow(`
SELECT id, name, description, created_at, updated_at,
(SELECT COUNT(*) FROM inbounds i WHERE i.profile_id = config_profiles.id),
(SELECT COUNT(*) FROM nodes n WHERE n.profile_id = config_profiles.id)
FROM config_profiles WHERE id = $1`, id).Scan(
&p.ID, &p.Name, &p.Description, &p.CreatedAt, &p.UpdatedAt,
&p.InboundCount, &p.NodeCount,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
inbounds, err := ListInboundsByProfile(db, id)
if err != nil {
return nil, err
}
p.Inbounds = inbounds
return p, nil
}
func ListProfiles(db *sql.DB) ([]models.ConfigProfile, error) {
rows, err := db.Query(`
SELECT id, name, description, created_at, updated_at,
(SELECT COUNT(*) FROM inbounds i WHERE i.profile_id = config_profiles.id),
(SELECT COUNT(*) FROM nodes n WHERE n.profile_id = config_profiles.id)
FROM config_profiles
ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.ConfigProfile
for rows.Next() {
var p models.ConfigProfile
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.CreatedAt, &p.UpdatedAt,
&p.InboundCount, &p.NodeCount,
); err != nil {
return nil, err
}
list = append(list, p)
}
return list, rows.Err()
}
func scanInbound(scanner interface {
Scan(dest ...any) error
}) (*models.Inbound, error) {
in := &models.Inbound{}
err := scanner.Scan(
&in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName,
&in.Port, &in.Network, &in.Security, &in.Listen, &in.Enabled, &in.SortOrder, &in.Remark,
&in.CreatedAt, &in.UpdatedAt,
)
if err != nil {
return nil, err
}
return in, nil
}
const inboundColumns = `
i.id, i.profile_id, i.tag, i.protocol_id, p.code, p.name,
i.port, i.network, i.security, i.listen, i.enabled, i.sort_order, i.remark,
i.created_at, i.updated_at`
func ListInboundsByProfile(db *sql.DB, profileID uuid.UUID) ([]models.Inbound, error) {
rows, err := db.Query(`
SELECT `+inboundColumns+`
FROM inbounds i
JOIN protocols p ON p.id = i.protocol_id
WHERE i.profile_id = $1
ORDER BY i.sort_order ASC, i.tag ASC`, profileID)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Inbound
for rows.Next() {
in, err := scanInbound(rows)
if err != nil {
return nil, err
}
list = append(list, *in)
}
return list, rows.Err()
}
func GetInbound(db *sql.DB, id uuid.UUID) (*models.Inbound, error) {
row := db.QueryRow(`
SELECT `+inboundColumns+`
FROM inbounds i
JOIN protocols p ON p.id = i.protocol_id
WHERE i.id = $1`, id)
in, err := scanInbound(row)
if err == sql.ErrNoRows {
return nil, nil
}
return in, err
}
func CreateInbound(db *sql.DB, in *models.Inbound) error {
if in.ID == uuid.Nil {
in.ID = uuid.New()
}
if in.Tag == "" {
in.Tag = strings.ToUpper(in.ProtocolCode) + "-" + fmt.Sprintf("%d", in.Port)
}
if in.Network == "" {
in.Network = "tcp"
}
if in.Security == "" {
in.Security = "none"
}
if in.Listen == "" {
in.Listen = "0.0.0.0"
}
now := time.Now().UTC()
in.CreatedAt = now
in.UpdatedAt = now
_, err := db.Exec(`
INSERT INTO inbounds (
id, profile_id, tag, protocol_id, port, network, security, listen, enabled, sort_order, remark, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
in.ID, in.ProfileID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security, in.Listen,
in.Enabled, in.SortOrder, in.Remark, in.CreatedAt, in.UpdatedAt,
)
return err
}
func UpdateInbound(db *sql.DB, in *models.Inbound) error {
_, err := db.Exec(`
UPDATE inbounds SET
tag = $2, protocol_id = $3, port = $4, network = $5, security = $6,
listen = $7, enabled = $8, sort_order = $9, remark = $10, updated_at = NOW()
WHERE id = $1`,
in.ID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security,
in.Listen, in.Enabled, in.SortOrder, in.Remark,
)
return err
}
func DeleteInbound(db *sql.DB, id uuid.UUID) error {
_, err := db.Exec(`DELETE FROM inbounds WHERE id = $1`, id)
return err
}
func ToggleInbound(db *sql.DB, id uuid.UUID) error {
_, err := db.Exec(`UPDATE inbounds SET enabled = NOT enabled, updated_at = NOW() WHERE id = $1`, id)
return err
}
// AssignProfileToNode sets profile and enables all profile inbounds on the node.
func AssignProfileToNode(db *sql.DB, nodeID uuid.UUID, profileID uuid.UUID) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`UPDATE nodes SET profile_id = $2, updated_at = NOW() WHERE id = $1`, nodeID, profileID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM node_inbounds WHERE node_id = $1`, nodeID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO node_inbounds (node_id, inbound_id, enabled)
SELECT $1, i.id, TRUE FROM inbounds i WHERE i.profile_id = $2 AND i.enabled = TRUE`, nodeID, profileID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return ApplyProfileInboundsToNodeProtocols(db, nodeID)
}
func ClearNodeProfile(db *sql.DB, nodeID uuid.UUID) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`UPDATE nodes SET profile_id = NULL, updated_at = NOW() WHERE id = $1`, nodeID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM node_inbounds WHERE node_id = $1`, nodeID); err != nil {
return err
}
return tx.Commit()
}
func SetNodeInboundEnabled(db *sql.DB, nodeID, inboundID uuid.UUID, enabled bool) error {
_, err := db.Exec(`
INSERT INTO node_inbounds (node_id, inbound_id, enabled) VALUES ($1, $2, $3)
ON CONFLICT (node_id, inbound_id) DO UPDATE SET enabled = EXCLUDED.enabled`, nodeID, inboundID, enabled)
if err != nil {
return err
}
return ApplyProfileInboundsToNodeProtocols(db, nodeID)
}
func ListNodeInbounds(db *sql.DB, nodeID uuid.UUID) ([]models.Inbound, error) {
n, err := GetNode(db, nodeID)
if err != nil || n == nil || n.ProfileID == nil {
return nil, err
}
rows, err := db.Query(`
SELECT `+inboundColumns+`, COALESCE(ni.enabled, FALSE)
FROM inbounds i
JOIN protocols p ON p.id = i.protocol_id
LEFT JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.node_id = $1
WHERE i.profile_id = $2
ORDER BY i.sort_order ASC, i.tag ASC`, nodeID, *n.ProfileID)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Inbound
for rows.Next() {
in := models.Inbound{}
if err := rows.Scan(
&in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName,
&in.Port, &in.Network, &in.Security, &in.Listen, &in.Enabled, &in.SortOrder, &in.Remark,
&in.CreatedAt, &in.UpdatedAt, &in.NodeEnabled,
); err != nil {
return nil, err
}
list = append(list, in)
}
return list, rows.Err()
}
// ApplyProfileInboundsToNodeProtocols mirrors enabled node inbounds into node_protocols.
func ApplyProfileInboundsToNodeProtocols(db *sql.DB, nodeID uuid.UUID) error {
rows, err := db.Query(`
SELECT i.protocol_id, i.port, BOOL_OR(ni.enabled AND i.enabled) AS active
FROM inbounds i
JOIN nodes n ON n.profile_id = i.profile_id AND n.id = $1
JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.node_id = $1
GROUP BY i.protocol_id, i.port`, nodeID)
if err != nil {
return err
}
defer rows.Close()
type row struct {
protocolID uuid.UUID
port int
active bool
}
var active []row
seen := map[uuid.UUID]row{}
for rows.Next() {
var r row
if err := rows.Scan(&r.protocolID, &r.port, &r.active); err != nil {
return err
}
if prev, ok := seen[r.protocolID]; ok {
if r.active {
prev.active = true
prev.port = r.port
seen[r.protocolID] = prev
}
continue
}
seen[r.protocolID] = r
}
if err := rows.Err(); err != nil {
return err
}
for _, r := range seen {
active = append(active, r)
}
// Reset then apply.
if _, err := db.Exec(`
UPDATE node_protocols SET installed = FALSE, enabled = FALSE, updated_at = NOW()
WHERE node_id = $1`, nodeID); err != nil {
return err
}
for _, r := range active {
if !r.active {
continue
}
if err := UpsertNodeProtocol(db, nodeID, r.protocolID, true, true, r.port); err != nil {
return err
}
_ = SetProtocolEnabled(db, r.protocolID, true)
}
return nil
}
+12
View File
@@ -65,6 +65,16 @@ func (s *Server) Routes() http.Handler {
r.HandleFunc("/admin/protocols", s.Auth.RequireAuth(s.protocols)).Methods(http.MethodGet)
r.HandleFunc("/admin/protocols/{id}/toggle", s.Auth.RequireAuth(s.toggleProtocol)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles", s.Auth.RequireAuth(s.profilesList)).Methods(http.MethodGet)
r.HandleFunc("/admin/profiles/new", s.Auth.RequireAuth(s.profilesNewGet)).Methods(http.MethodGet)
r.HandleFunc("/admin/profiles/new", s.Auth.RequireAuth(s.profilesCreate)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles/{id}", s.Auth.RequireAuth(s.profileView)).Methods(http.MethodGet)
r.HandleFunc("/admin/profiles/{id}", s.Auth.RequireAuth(s.profileUpdate)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles/{id}/delete", s.Auth.RequireAuth(s.profileDelete)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles/{id}/inbounds", s.Auth.RequireAuth(s.inboundCreate)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}/toggle", s.Auth.RequireAuth(s.inboundToggle)).Methods(http.MethodPost)
r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}/delete", s.Auth.RequireAuth(s.inboundDelete)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes", s.Auth.RequireAuth(s.nodesList)).Methods(http.MethodGet)
r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesNewGet)).Methods(http.MethodGet)
r.HandleFunc("/admin/nodes/new", s.Auth.RequireAuth(s.nodesCreate)).Methods(http.MethodPost)
@@ -73,6 +83,8 @@ func (s *Server) Routes() http.Handler {
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}/logs", s.Auth.RequireAuth(s.nodeFetchLogs)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes/{id}/profile", s.Auth.RequireAuth(s.nodeAssignProfile)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes/{id}/inbounds/{inboundID}", s.Auth.RequireAuth(s.nodeInboundToggle)).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)
+13 -9
View File
@@ -153,16 +153,20 @@ func (s *Server) nodeView(w http.ResponseWriter, r *http.Request) {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
profiles, _ := db.ListProfiles(s.DB)
nodeInbounds, _ := db.ListNodeInbounds(s.DB, id)
s.render(w, "node_view.html", pageData{
"Title": n.Name,
"UserName": s.Auth.CurrentName(r),
"Active": "nodes",
"Node": n,
"NodeProtocols": nodeProtocols,
"Compose": nodeinstall.ComposeYAML(n),
"ManualScript": nodeinstall.ManualInstallScript(n),
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
"Title": n.Name,
"UserName": s.Auth.CurrentName(r),
"Active": "nodes",
"Node": n,
"NodeProtocols": nodeProtocols,
"NodeInbounds": nodeInbounds,
"Profiles": profiles,
"Compose": nodeinstall.ComposeYAML(n),
"ManualScript": nodeinstall.ManualInstallScript(n),
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
+280
View File
@@ -0,0 +1,280 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/orohi/vpn-panel/internal/db"
"github.com/orohi/vpn-panel/internal/models"
)
func (s *Server) profilesList(w http.ResponseWriter, r *http.Request) {
profiles, err := db.ListProfiles(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
s.render(w, "profiles.html", pageData{
"Title": "Профили",
"UserName": s.Auth.CurrentName(r),
"Profiles": profiles,
"Active": "profiles",
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) profilesNewGet(w http.ResponseWriter, r *http.Request) {
s.render(w, "profile_new.html", pageData{
"Title": "Новый профиль",
"UserName": s.Auth.CurrentName(r),
"Active": "profiles",
})
}
func (s *Server) profilesCreate(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
desc := strings.TrimSpace(r.FormValue("description"))
if name == "" {
s.render(w, "profile_new.html", pageData{
"Title": "Новый профиль", "UserName": s.Auth.CurrentName(r), "Active": "profiles",
"Error": "Укажите имя профиля", "Form": map[string]any{"Name": name, "Description": desc},
})
return
}
p := &models.ConfigProfile{Name: name, Description: desc}
if err := db.CreateProfile(s.DB, p); err != nil {
log.Printf("create profile: %v", err)
s.render(w, "profile_new.html", pageData{
"Title": "Новый профиль", "UserName": s.Auth.CurrentName(r), "Active": "profiles",
"Error": "Не удалось создать (возможно имя занято)", "Form": map[string]any{"Name": name, "Description": desc},
})
return
}
http.Redirect(w, r, "/admin/profiles/"+p.ID.String()+"?ok=created", http.StatusSeeOther)
}
func (s *Server) profileView(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
p, err := db.GetProfile(s.DB, id)
if err != nil || p == nil {
http.NotFound(w, r)
return
}
protocols, _ := db.ListProtocols(s.DB)
s.render(w, "profile_view.html", pageData{
"Title": p.Name,
"UserName": s.Auth.CurrentName(r),
"Active": "profiles",
"Profile": p,
"Protocols": protocols,
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) profileUpdate(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
_ = r.ParseForm()
p := &models.ConfigProfile{
ID: id,
Name: strings.TrimSpace(r.FormValue("name")),
Description: strings.TrimSpace(r.FormValue("description")),
}
if p.Name == "" {
http.Redirect(w, r, "/admin/profiles/"+id.String()+"?err=name", http.StatusSeeOther)
return
}
if err := db.UpdateProfile(s.DB, p); err != nil {
http.Redirect(w, r, "/admin/profiles/"+id.String()+"?err=save", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profiles/"+id.String()+"?ok=saved", http.StatusSeeOther)
}
func (s *Server) profileDelete(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := db.DeleteProfile(s.DB, id); err != nil {
http.Redirect(w, r, "/admin/profiles?err=delete", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/profiles?ok=deleted", http.StatusSeeOther)
}
func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) {
profileID, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
_ = r.ParseForm()
protocolID, err := uuid.Parse(r.FormValue("protocol_id"))
if err != nil {
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
return
}
proto, err := db.GetProtocol(s.DB, protocolID)
if err != nil || proto == nil {
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
return
}
port, _ := strconv.Atoi(r.FormValue("port"))
if port <= 0 {
port = proto.Port
if port <= 0 {
port = 443
}
}
tag := strings.TrimSpace(r.FormValue("tag"))
if tag == "" {
tag = strings.ToUpper(proto.Code) + "-" + strconv.Itoa(port)
}
sortOrder, _ := strconv.Atoi(r.FormValue("sort_order"))
in := &models.Inbound{
ProfileID: profileID,
Tag: tag,
ProtocolID: protocolID,
ProtocolCode: proto.Code,
Port: port,
Network: r.FormValue("network"),
Security: r.FormValue("security"),
Listen: strings.TrimSpace(r.FormValue("listen")),
Enabled: true,
SortOrder: sortOrder,
Remark: strings.TrimSpace(r.FormValue("remark")),
}
if err := db.CreateInbound(s.DB, in); err != nil {
log.Printf("create inbound: %v", err)
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther)
return
}
// Enable on all nodes already using this profile.
nodes, _ := db.ListNodes(s.DB)
for _, n := range nodes {
if n.ProfileID != nil && *n.ProfileID == profileID {
_ = db.SetNodeInboundEnabled(s.DB, n.ID, in.ID, true)
if n.Status == models.NodeStatusOnline {
nn := n
_ = s.syncNodeConfig(&nn)
}
}
}
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_added", http.StatusSeeOther)
}
func (s *Server) inboundDelete(w http.ResponseWriter, r *http.Request) {
profileID, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
inboundID, err := uuid.Parse(mux.Vars(r)["inboundID"])
if err != nil {
http.Error(w, "invalid inbound", http.StatusBadRequest)
return
}
_ = db.DeleteInbound(s.DB, inboundID)
nodes, _ := db.ListNodes(s.DB)
for _, n := range nodes {
if n.ProfileID != nil && *n.ProfileID == profileID {
_ = db.ApplyProfileInboundsToNodeProtocols(s.DB, n.ID)
if n.Status == models.NodeStatusOnline {
nn := n
_ = s.syncNodeConfig(&nn)
}
}
}
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_deleted", http.StatusSeeOther)
}
func (s *Server) inboundToggle(w http.ResponseWriter, r *http.Request) {
profileID, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
inboundID, err := uuid.Parse(mux.Vars(r)["inboundID"])
if err != nil {
http.Error(w, "invalid inbound", http.StatusBadRequest)
return
}
_ = db.ToggleInbound(s.DB, inboundID)
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_toggled", http.StatusSeeOther)
}
func (s *Server) nodeAssignProfile(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
}
_ = r.ParseForm()
raw := strings.TrimSpace(r.FormValue("profile_id"))
n, err := db.GetNode(s.DB, nodeID)
if err != nil || n == nil {
http.NotFound(w, r)
return
}
if raw == "" || raw == "none" {
_ = db.ClearNodeProfile(s.DB, nodeID)
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?ok=profile_cleared", http.StatusSeeOther)
return
}
profileID, err := uuid.Parse(raw)
if err != nil {
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=profile", http.StatusSeeOther)
return
}
if err := db.AssignProfileToNode(s.DB, nodeID, profileID); err != nil {
log.Printf("assign profile: %v", err)
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=profile_assign", http.StatusSeeOther)
return
}
n2, _ := db.GetNode(s.DB, nodeID)
if n2 != nil && n2.Status == models.NodeStatusOnline {
_ = s.syncNodeConfig(n2)
}
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?ok=profile_assigned", http.StatusSeeOther)
}
func (s *Server) nodeInboundToggle(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
}
inboundID, err := uuid.Parse(mux.Vars(r)["inboundID"])
if err != nil {
http.Error(w, "invalid inbound", http.StatusBadRequest)
return
}
_ = r.ParseForm()
enabled := r.FormValue("action") == "enable"
if err := db.SetNodeInboundEnabled(s.DB, nodeID, inboundID, enabled); err != nil {
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?err=inbound", http.StatusSeeOther)
return
}
n, _ := db.GetNode(s.DB, nodeID)
if n != nil && n.Status == models.NodeStatusOnline {
_ = s.syncNodeConfig(n)
}
http.Redirect(w, r, "/admin/nodes/"+nodeID.String()+"?ok=inbound_updated", http.StatusSeeOther)
}
+43
View File
@@ -79,6 +79,8 @@ type Node struct {
SSHSecretEnc string `json:"-"`
NodePort int `json:"node_port"`
SecretKey string `json:"-"`
ProfileID *uuid.UUID `json:"profile_id,omitempty"`
ProfileName string `json:"profile_name,omitempty"`
Status NodeStatus `json:"status"`
InstallMode string `json:"install_mode"` // auto | manual
LastError string `json:"last_error"`
@@ -105,10 +107,51 @@ func (n Node) StatusLabel() string {
}
}
func (n Node) ProfileIDString() string {
if n.ProfileID == nil {
return ""
}
return n.ProfileID.String()
}
type ConfigProfile struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
InboundCount int `json:"inbound_count"`
NodeCount int `json:"node_count"`
Inbounds []Inbound `json:"inbounds,omitempty"`
}
type Inbound struct {
ID uuid.UUID `json:"id"`
ProfileID uuid.UUID `json:"profile_id"`
Tag string `json:"tag"`
ProtocolID uuid.UUID `json:"protocol_id"`
ProtocolCode string `json:"protocol_code"`
ProtocolName string `json:"protocol_name"`
Port int `json:"port"`
Network string `json:"network"` // tcp, ws, grpc, quic, udp
Security string `json:"security"` // none, tls, reality
Listen string `json:"listen"`
Enabled bool `json:"enabled"` // definition enabled in profile
SortOrder int `json:"sort_order"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-node activation when listed under a node
NodeEnabled bool `json:"node_enabled,omitempty"`
}
type DashboardStats struct {
ProtocolsTotal int
ProtocolsEnabled int
AdminsTotal int
NodesTotal int
NodesOnline int
ProfilesTotal int
}