diff --git a/internal/db/db.go b/internal/db/db.go index 375fd1e..80dcc6c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 } diff --git a/internal/db/node_protocols.go b/internal/db/node_protocols.go index 0194c0f..c8e9dc3 100644 --- a/internal/db/node_protocols.go +++ b/internal/db/node_protocols.go @@ -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 } diff --git a/internal/db/nodes.go b/internal/db/nodes.go index 1efa4be..ffaf846 100644 --- a/internal/db/nodes.go +++ b/internal/db/nodes.go @@ -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 { diff --git a/internal/db/profiles.go b/internal/db/profiles.go new file mode 100644 index 0000000..2083d81 --- /dev/null +++ b/internal/db/profiles.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2d14180..f23fd09 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) diff --git a/internal/handlers/nodes.go b/internal/handlers/nodes.go index 43d76b4..09c2a8c 100644 --- a/internal/handlers/nodes.go +++ b/internal/handlers/nodes.go @@ -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"), }) } diff --git a/internal/handlers/profiles.go b/internal/handlers/profiles.go new file mode 100644 index 0000000..8f172ad --- /dev/null +++ b/internal/handlers/profiles.go @@ -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) +} diff --git a/internal/models/models.go b/internal/models/models.go index 0fc2cd2..45461b2 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 } diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 35beeed..3ee593a 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -15,6 +15,7 @@ +
+ Профили + {{.Stats.ProfilesTotal}} +
Протоколов {{.Stats.ProtocolsTotal}} @@ -46,10 +51,6 @@ Включено {{.Stats.ProtocolsEnabled}}
-
- Админов - {{.Stats.AdminsTotal}} -
diff --git a/web/templates/node_new.html b/web/templates/node_new.html index 590d558..905623f 100644 --- a/web/templates/node_new.html +++ b/web/templates/node_new.html @@ -15,6 +15,7 @@