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
}
+5 -4
View File
@@ -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">
+1
View File
@@ -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">
+59
View File
@@ -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">
+1
View File
@@ -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">
+47
View File
@@ -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>
+147
View File
@@ -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>
+62
View File
@@ -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>
+1
View File
@@ -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">