Add config profiles with inbound assignment for nodes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
@@ -38,6 +39,10 @@
|
||||
<span class="stat-label">Online</span>
|
||||
<span class="stat-value accent">{{.Stats.NodesOnline}}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Профили</span>
|
||||
<span class="stat-value">{{.Stats.ProfilesTotal}}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Протоколов</span>
|
||||
<span class="stat-value">{{.Stats.ProtocolsTotal}}</span>
|
||||
@@ -46,10 +51,6 @@
|
||||
<span class="stat-label">Включено</span>
|
||||
<span class="stat-value accent">{{.Stats.ProtocolsEnabled}}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Админов</span>
|
||||
<span class="stat-value">{{.Stats.AdminsTotal}}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
@@ -58,6 +59,64 @@
|
||||
<a class="btn btn-ghost" href="/admin/nodes">К списку</a>
|
||||
</div>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Config Profile</h2>
|
||||
<form class="form-card" method="POST" action="/admin/nodes/{{.Node.ID}}/profile">
|
||||
<label>Профиль для этой ноды
|
||||
<select name="profile_id">
|
||||
<option value="none">— без профиля —</option>
|
||||
{{range .Profiles}}
|
||||
<option value="{{.ID}}" {{if eq $.Node.ProfileIDString (printf "%s" .ID)}}selected{{end}}>{{.Name}} ({{.InboundCount}} inbound)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">При назначении все inbound профиля включаются на ноде. Потом можно выключить отдельные.</p>
|
||||
<button class="btn btn-primary" type="submit">Назначить профиль</button>
|
||||
</form>
|
||||
{{if .Node.ProfileName}}
|
||||
<p class="muted" style="margin-top:0.75rem">Текущий: <strong>{{.Node.ProfileName}}</strong> · <a href="/admin/profiles/{{.Node.ProfileIDString}}">открыть профиль</a></p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .NodeInbounds}}
|
||||
<section class="panel-section">
|
||||
<h2>Инбаунды на ноде</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Протокол</th>
|
||||
<th>Порт</th>
|
||||
<th>Network / Security</th>
|
||||
<th>На ноде</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .NodeInbounds}}
|
||||
<tr>
|
||||
<td><code>{{.Tag}}</code></td>
|
||||
<td>{{.ProtocolName}}</td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Network}} / {{.Security}}</td>
|
||||
<td><span class="badge {{if .NodeEnabled}}on{{else}}off{{end}}">{{if .NodeEnabled}}ON{{else}}OFF{{end}}</span></td>
|
||||
<td class="actions">
|
||||
<form method="POST" action="/admin/nodes/{{$.Node.ID}}/inbounds/{{.ID}}">
|
||||
<input type="hidden" name="action" value="{{if .NodeEnabled}}disable{{else}}enable{{end}}">
|
||||
<button class="btn btn-sm {{if .NodeEnabled}}btn-ghost{{else}}btn-primary{{end}}" type="submit">
|
||||
{{if .NodeEnabled}}Выключить{{else}}Включить{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Протоколы на этом сервере</h2>
|
||||
<div class="table-wrap">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · VPN Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="page-admin">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/admin">VPN Panel</a>
|
||||
<nav>
|
||||
<a href="/admin">Обзор</a>
|
||||
<a href="/admin/nodes">Ноды</a>
|
||||
<a class="active" href="/admin/profiles">Профили</a>
|
||||
<a href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<span class="user-chip">{{.UserName}}</span>
|
||||
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<header class="admin-header">
|
||||
<h1>Новый профиль</h1>
|
||||
<p class="muted">Шаблон конфигурации ноды с набором инбаундов</p>
|
||||
</header>
|
||||
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
|
||||
<form class="form-card" method="POST" action="/admin/profiles/new">
|
||||
<label>Имя
|
||||
<input name="name" required value="{{.Form.Name}}" placeholder="Sample / EU-Reality">
|
||||
</label>
|
||||
<label>Описание
|
||||
<textarea name="description" rows="3" placeholder="Кратко для чего профиль">{{.Form.Description}}</textarea>
|
||||
</label>
|
||||
<div class="cta-row">
|
||||
<button class="btn btn-primary" type="submit">Создать</button>
|
||||
<a class="btn btn-ghost" href="/admin/profiles">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · VPN Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="page-admin">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/admin">VPN Panel</a>
|
||||
<nav>
|
||||
<a href="/admin">Обзор</a>
|
||||
<a href="/admin/nodes">Ноды</a>
|
||||
<a class="active" href="/admin/profiles">Профили</a>
|
||||
<a href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<span class="user-chip">{{.UserName}}</span>
|
||||
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<header class="admin-header row-head">
|
||||
<div>
|
||||
<h1>{{.Profile.Name}}</h1>
|
||||
<p class="muted">{{.Profile.InboundCount}} inbound(s) · {{.Profile.NodeCount}} node(s)</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" href="/admin/profiles">К списку</a>
|
||||
</header>
|
||||
|
||||
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Профиль</h2>
|
||||
<form class="form-card" method="POST" action="/admin/profiles/{{.Profile.ID}}">
|
||||
<div class="form-grid">
|
||||
<label>Имя
|
||||
<input name="name" required value="{{.Profile.Name}}">
|
||||
</label>
|
||||
<label>Описание
|
||||
<input name="description" value="{{.Profile.Description}}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||
<button class="btn btn-ghost" type="submit" form="delete-profile" onclick="return confirm('Удалить профиль и все инбаунды?');">Удалить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form id="delete-profile" method="POST" action="/admin/profiles/{{.Profile.ID}}/delete"></form>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Инбаунды</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Протокол</th>
|
||||
<th>Порт</th>
|
||||
<th>Network</th>
|
||||
<th>Security</th>
|
||||
<th>В профиле</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Profile.Inbounds}}
|
||||
<tr>
|
||||
<td><code>{{.Tag}}</code>{{if .Remark}}<div class="cell-desc">{{.Remark}}</div>{{end}}</td>
|
||||
<td>{{.ProtocolName}} <code>{{.ProtocolCode}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Network}}</td>
|
||||
<td>{{.Security}}</td>
|
||||
<td><span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span></td>
|
||||
<td class="actions actions-multi">
|
||||
<form method="POST" action="/admin/profiles/{{$.Profile.ID}}/inbounds/{{.ID}}/toggle">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">{{if .Enabled}}Выкл{{else}}Вкл{{end}}</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/profiles/{{$.Profile.ID}}/inbounds/{{.ID}}/delete" onsubmit="return confirm('Удалить inbound?');">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7"><span class="muted">Инбаундов пока нет — добавьте ниже</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h2>Добавить inbound</h2>
|
||||
<form class="form-card" method="POST" action="/admin/profiles/{{.Profile.ID}}/inbounds">
|
||||
<div class="form-grid">
|
||||
<label>Протокол
|
||||
<select name="protocol_id" required>
|
||||
{{range .Protocols}}
|
||||
<option value="{{.ID}}">{{.Name}} ({{.Code}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tag
|
||||
<input name="tag" placeholder="VLESS-443 (авто если пусто)">
|
||||
</label>
|
||||
<label>Порт
|
||||
<input name="port" type="number" placeholder="443">
|
||||
</label>
|
||||
<label>Listen
|
||||
<input name="listen" value="0.0.0.0">
|
||||
</label>
|
||||
<label>Network
|
||||
<select name="network">
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="ws">ws</option>
|
||||
<option value="grpc">grpc</option>
|
||||
<option value="quic">quic</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Security
|
||||
<select name="security">
|
||||
<option value="none">none</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="reality">reality</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Порядок
|
||||
<input name="sort_order" type="number" value="0">
|
||||
</label>
|
||||
<label>Заметка
|
||||
<input name="remark" placeholder="опционально">
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Добавить inbound</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · VPN Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="page-admin">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/admin">VPN Panel</a>
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<span class="user-chip">{{.UserName}}</span>
|
||||
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<header class="admin-header row-head">
|
||||
<div>
|
||||
<h1>Config Profiles</h1>
|
||||
<p class="muted">Профили конфигурации и инбаунды для нод (как Remnawave)</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/admin/profiles/new">+ Создать профиль</a>
|
||||
</header>
|
||||
|
||||
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Profiles}}
|
||||
<div class="proto-grid">
|
||||
{{range .Profiles}}
|
||||
<article class="proto-card">
|
||||
<div class="proto-top">
|
||||
<h3><a href="/admin/profiles/{{.ID}}">{{.Name}}</a></h3>
|
||||
</div>
|
||||
<p>{{if .Description}}{{.Description}}{{else}}Без описания{{end}}</p>
|
||||
<div class="proto-meta">
|
||||
<span>{{.InboundCount}} inbound(s)</span>
|
||||
<span>{{.NodeCount}} node(s)</span>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>Профилей ещё нет. Создайте профиль и добавьте инбаунды (VLESS, Trojan…).</p>
|
||||
<a class="btn btn-primary" href="/admin/profiles/new">Создать профиль</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav>
|
||||
<a class="{{if eq .Active "dashboard"}}active{{end}}" href="/admin">Обзор</a>
|
||||
<a class="{{if eq .Active "nodes"}}active{{end}}" href="/admin/nodes">Ноды</a>
|
||||
<a class="{{if eq .Active "profiles"}}active{{end}}" href="/admin/profiles">Профили</a>
|
||||
<a class="{{if eq .Active "protocols"}}active{{end}}" href="/admin/protocols">Протоколы</a>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
|
||||
Reference in New Issue
Block a user