Add VPN client users with inbound access and subscription links.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:49:32 +03:00
co-authored by Cursor
parent 4991b313d7
commit c0e78ce949
16 changed files with 930 additions and 11 deletions
+277
View File
@@ -0,0 +1,277 @@
package db
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/orohi/vpn-panel/internal/models"
"github.com/orohi/vpn-panel/internal/secretbox"
)
func scanClient(scanner interface {
Scan(dest ...any) error
}) (*models.Client, error) {
c := &models.Client{}
var expire sql.NullTime
var status string
err := scanner.Scan(
&c.ID, &c.Username, &c.Email, &c.UUID, &status,
&c.TrafficLimitBytes, &c.TrafficUsedBytes, &expire, &c.SubToken, &c.Note,
&c.CreatedAt, &c.UpdatedAt, &c.InboundCount,
)
if err != nil {
return nil, err
}
c.Status = models.ClientStatus(status)
if expire.Valid {
t := expire.Time
c.ExpireAt = &t
}
return c, nil
}
const clientColumns = `
c.id, c.username, c.email, c.uuid, c.status,
c.traffic_limit_bytes, c.traffic_used_bytes, c.expire_at, c.sub_token, c.note,
c.created_at, c.updated_at,
(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)`
func CreateClient(db *sql.DB, c *models.Client, inboundIDs []uuid.UUID) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
if c.UUID == uuid.Nil {
c.UUID = uuid.New()
}
if c.SubToken == "" {
tok, err := secretbox.RandomToken(24)
if err != nil {
return err
}
c.SubToken = tok
}
if c.Status == "" {
c.Status = models.ClientStatusActive
}
now := time.Now().UTC()
c.CreatedAt = now
c.UpdatedAt = now
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`
INSERT INTO clients (
id, username, email, uuid, status, traffic_limit_bytes, traffic_used_bytes,
expire_at, sub_token, note, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
c.ID, c.Username, c.Email, c.UUID, string(c.Status), c.TrafficLimitBytes, c.TrafficUsedBytes,
c.ExpireAt, c.SubToken, c.Note, c.CreatedAt, c.UpdatedAt,
)
if err != nil {
return err
}
for _, iid := range inboundIDs {
if _, err := tx.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, c.ID, iid); err != nil {
return err
}
}
return tx.Commit()
}
func UpdateClient(db *sql.DB, c *models.Client, inboundIDs []uuid.UUID) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(`
UPDATE clients SET
username = $2, email = $3, status = $4, traffic_limit_bytes = $5,
expire_at = $6, note = $7, updated_at = NOW()
WHERE id = $1`,
c.ID, c.Username, c.Email, string(c.Status), c.TrafficLimitBytes, c.ExpireAt, c.Note,
)
if err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM client_inbounds WHERE client_id = $1`, c.ID); err != nil {
return err
}
for _, iid := range inboundIDs {
if _, err := tx.Exec(`INSERT INTO client_inbounds (client_id, inbound_id) VALUES ($1, $2)`, c.ID, iid); err != nil {
return err
}
}
return tx.Commit()
}
func GetClient(db *sql.DB, id uuid.UUID) (*models.Client, error) {
row := db.QueryRow(`SELECT `+clientColumns+` FROM clients c WHERE c.id = $1`, id)
c, err := scanClient(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ids, err := ListClientInboundIDs(db, id)
if err != nil {
return nil, err
}
c.InboundIDs = ids
return c, nil
}
func GetClientBySubToken(db *sql.DB, token string) (*models.Client, error) {
row := db.QueryRow(`SELECT `+clientColumns+` FROM clients c WHERE c.sub_token = $1`, token)
c, err := scanClient(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ids, err := ListClientInboundIDs(db, c.ID)
if err != nil {
return nil, err
}
c.InboundIDs = ids
return c, nil
}
func ListClients(db *sql.DB) ([]models.Client, error) {
rows, err := db.Query(`SELECT ` + clientColumns + ` FROM clients c ORDER BY c.created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []models.Client
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
list = append(list, *c)
}
return list, rows.Err()
}
func ListClientInboundIDs(db *sql.DB, clientID uuid.UUID) ([]uuid.UUID, error) {
rows, err := db.Query(`SELECT inbound_id FROM client_inbounds WHERE client_id = $1`, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func SetClientStatus(db *sql.DB, id uuid.UUID, status models.ClientStatus) error {
_, err := db.Exec(`UPDATE clients SET status = $2, updated_at = NOW() WHERE id = $1`, id, string(status))
return err
}
func DeleteClient(db *sql.DB, id uuid.UUID) error {
res, err := db.Exec(`DELETE FROM clients WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("client not found")
}
return nil
}
func ListAllInbounds(db *sql.DB) ([]models.Inbound, error) {
rows, err := db.Query(`
SELECT ` + inboundColumns + `
FROM inbounds i
JOIN protocols p ON p.id = i.protocol_id
WHERE i.enabled = TRUE
ORDER BY i.sort_order ASC, i.tag ASC`)
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()
}
// ClientSubscriptionLinks builds basic share links using online nodes that have the inbound enabled.
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
if client == nil || len(client.InboundIDs) == 0 {
return nil, nil
}
rows, err := db.Query(`
SELECT DISTINCT i.tag, p.code, i.port, i.network, i.security, n.host, n.name
FROM client_inbounds ci
JOIN inbounds i ON i.id = ci.inbound_id AND i.enabled = TRUE
JOIN protocols p ON p.id = i.protocol_id
JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.enabled = TRUE
JOIN nodes n ON n.id = ni.node_id AND n.status = 'online' AND n.profile_id = i.profile_id
WHERE ci.client_id = $1
ORDER BY n.name, i.tag`, client.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []string
uid := client.UUID.String()
for rows.Next() {
var tag, code, network, security, host, nodeName string
var port int
if err := rows.Scan(&tag, &code, &port, &network, &security, &host, &nodeName); err != nil {
return nil, err
}
name := client.Username + "-" + tag + "@" + nodeName
switch code {
case "vless":
links = append(links, fmt.Sprintf(
"vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s",
uid, host, port, network, security, name,
))
case "vmess":
links = append(links, fmt.Sprintf(
"vmess://%s@%s:%d?type=%s&security=%s#%s",
uid, host, port, network, security, name,
))
case "trojan":
links = append(links, fmt.Sprintf(
"trojan://%s@%s:%d?type=%s&security=%s#%s",
uid, host, port, network, security, name,
))
case "shadowsocks":
links = append(links, fmt.Sprintf(
"ss://%s@%s:%d#%s",
uid, host, port, name,
))
default:
links = append(links, fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, name))
}
}
return links, rows.Err()
}
+32
View File
@@ -129,6 +129,30 @@ CREATE TABLE IF NOT EXISTS node_inbounds (
enabled BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (node_id, inbound_id)
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
uuid UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
status TEXT NOT NULL DEFAULT 'active',
traffic_limit_bytes BIGINT NOT NULL DEFAULT 0,
traffic_used_bytes BIGINT NOT NULL DEFAULT 0,
expire_at TIMESTAMPTZ,
sub_token TEXT NOT NULL UNIQUE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_clients_status ON clients(status);
CREATE INDEX IF NOT EXISTS idx_clients_sub_token ON clients(sub_token);
CREATE TABLE IF NOT EXISTS client_inbounds (
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
inbound_id UUID NOT NULL REFERENCES inbounds(id) ON DELETE CASCADE,
PRIMARY KEY (client_id, inbound_id)
);
`
if _, err := db.Exec(schema); err != nil {
return err
@@ -266,5 +290,13 @@ func GetStats(db *sql.DB) (models.DashboardStats, error) {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM config_profiles`).Scan(&s.ProfilesTotal)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM clients`).Scan(&s.ClientsTotal)
if err != nil {
return s, err
}
err = db.QueryRow(`SELECT COUNT(*) FROM clients WHERE status = 'active'`).Scan(&s.ClientsActive)
return s, err
}
+239
View File
@@ -0,0 +1,239 @@
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"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) clientsList(w http.ResponseWriter, r *http.Request) {
clients, err := db.ListClients(s.DB)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
s.render(w, "clients.html", pageData{
"Title": "Пользователи",
"UserName": s.Auth.CurrentName(r),
"Clients": clients,
"Active": "clients",
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) clientsNewGet(w http.ResponseWriter, r *http.Request) {
inbounds, _ := db.ListAllInbounds(s.DB)
s.render(w, "client_new.html", pageData{
"Title": "Новый пользователь",
"UserName": s.Auth.CurrentName(r),
"Active": "clients",
"Inbounds": inbounds,
"Form": map[string]any{
"Status": string(models.ClientStatusActive),
},
})
}
func parseInboundIDs(r *http.Request) []uuid.UUID {
vals := r.Form["inbound_ids"]
var ids []uuid.UUID
for _, v := range vals {
id, err := uuid.Parse(v)
if err == nil {
ids = append(ids, id)
}
}
return ids
}
func parseExpireAt(raw string) *time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
// datetime-local: 2006-01-02T15:04
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02", time.RFC3339} {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
u := t.UTC()
return &u
}
}
return nil
}
func (s *Server) clientsCreate(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
username := strings.TrimSpace(r.FormValue("username"))
email := strings.TrimSpace(r.FormValue("email"))
note := strings.TrimSpace(r.FormValue("note"))
status := models.ClientStatus(r.FormValue("status"))
if status == "" {
status = models.ClientStatusActive
}
gb, _ := strconv.ParseFloat(r.FormValue("traffic_limit_gb"), 64)
var limitBytes int64
if gb > 0 {
limitBytes = int64(gb * 1024 * 1024 * 1024)
}
expire := parseExpireAt(r.FormValue("expire_at"))
inboundIDs := parseInboundIDs(r)
inbounds, _ := db.ListAllInbounds(s.DB)
formErr := func(msg string) {
s.render(w, "client_new.html", pageData{
"Title": "Новый пользователь", "UserName": s.Auth.CurrentName(r), "Active": "clients",
"Error": msg, "Inbounds": inbounds,
"Form": map[string]any{
"Username": username, "Email": email, "Note": note, "Status": string(status),
"TrafficLimitGB": r.FormValue("traffic_limit_gb"), "ExpireAt": r.FormValue("expire_at"),
},
"SelectedInbounds": inboundIDs,
})
}
if username == "" {
formErr("Укажите username")
return
}
c := &models.Client{
Username: username,
Email: email,
Status: status,
TrafficLimitBytes: limitBytes,
ExpireAt: expire,
Note: note,
}
if err := db.CreateClient(s.DB, c, inboundIDs); err != nil {
log.Printf("create client: %v", err)
formErr("Не удалось создать (возможно username занят)")
return
}
http.Redirect(w, r, "/admin/clients/"+c.ID.String()+"?ok=created", http.StatusSeeOther)
}
func (s *Server) clientView(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
}
c, err := db.GetClient(s.DB, id)
if err != nil || c == nil {
http.NotFound(w, r)
return
}
inbounds, _ := db.ListAllInbounds(s.DB)
links, _ := db.ClientSubscriptionLinks(s.DB, c)
scheme := "https"
if r.TLS == nil {
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
scheme = xf
} else {
scheme = "http"
}
}
subURL := scheme + "://" + r.Host + "/sub/" + c.SubToken
s.render(w, "client_view.html", pageData{
"Title": c.Username,
"UserName": s.Auth.CurrentName(r),
"Active": "clients",
"Client": c,
"Inbounds": inbounds,
"Links": links,
"SubURL": subURL,
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func (s *Server) clientUpdate(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()
gb, _ := strconv.ParseFloat(r.FormValue("traffic_limit_gb"), 64)
var limitBytes int64
if gb > 0 {
limitBytes = int64(gb * 1024 * 1024 * 1024)
}
c := &models.Client{
ID: id,
Username: strings.TrimSpace(r.FormValue("username")),
Email: strings.TrimSpace(r.FormValue("email")),
Status: models.ClientStatus(r.FormValue("status")),
TrafficLimitBytes: limitBytes,
ExpireAt: parseExpireAt(r.FormValue("expire_at")),
Note: strings.TrimSpace(r.FormValue("note")),
}
if c.Username == "" {
http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=username", http.StatusSeeOther)
return
}
if err := db.UpdateClient(s.DB, c, parseInboundIDs(r)); err != nil {
log.Printf("update client: %v", err)
http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=save", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/clients/"+id.String()+"?ok=saved", http.StatusSeeOther)
}
func (s *Server) clientToggle(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
}
c, err := db.GetClient(s.DB, id)
if err != nil || c == nil {
http.NotFound(w, r)
return
}
next := models.ClientStatusDisabled
if c.Status != models.ClientStatusActive {
next = models.ClientStatusActive
}
_ = db.SetClientStatus(s.DB, id, next)
http.Redirect(w, r, "/admin/clients/"+id.String()+"?ok=status", http.StatusSeeOther)
}
func (s *Server) clientDelete(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
}
_ = db.DeleteClient(s.DB, id)
http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther)
}
func (s *Server) subscriptionGet(w http.ResponseWriter, r *http.Request) {
token := mux.Vars(r)["token"]
c, err := db.GetClientBySubToken(s.DB, token)
if err != nil || c == nil || !c.IsActive() {
http.Error(w, "not found", http.StatusNotFound)
return
}
links, err := db.ClientSubscriptionLinks(s.DB, c)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Profile-Title", c.Username)
if len(links) == 0 {
_, _ = w.Write([]byte("# no active inbounds / online nodes\n"))
return
}
_, _ = w.Write([]byte(strings.Join(links, "\n") + "\n"))
}
+29 -3
View File
@@ -6,7 +6,9 @@ import (
"log"
"net/http"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/orohi/vpn-panel/internal/auth"
@@ -28,18 +30,32 @@ func New(database *sql.DB, authMgr *auth.Manager, appSecret, templatesDir string
tmpl, err := template.New("").Funcs(template.FuncMap{
"statusClass": func(s string) string {
switch s {
case "online":
case "online", "active", "on":
return "on"
case "installing":
return "warn"
case "error":
case "error", "expired":
return "err"
case "offline":
case "offline", "disabled", "off":
return "off"
default:
return "off"
}
},
"hasInbound": func(ids []uuid.UUID, id uuid.UUID) bool {
for _, x := range ids {
if x == id {
return true
}
}
return false
},
"formatTime": func(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
},
}).ParseGlob(pattern)
if err != nil {
return nil, err
@@ -75,6 +91,14 @@ func (s *Server) Routes() http.Handler {
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/clients", s.Auth.RequireAuth(s.clientsList)).Methods(http.MethodGet)
r.HandleFunc("/admin/clients/new", s.Auth.RequireAuth(s.clientsNewGet)).Methods(http.MethodGet)
r.HandleFunc("/admin/clients/new", s.Auth.RequireAuth(s.clientsCreate)).Methods(http.MethodPost)
r.HandleFunc("/admin/clients/{id}", s.Auth.RequireAuth(s.clientView)).Methods(http.MethodGet)
r.HandleFunc("/admin/clients/{id}", s.Auth.RequireAuth(s.clientUpdate)).Methods(http.MethodPost)
r.HandleFunc("/admin/clients/{id}/toggle", s.Auth.RequireAuth(s.clientToggle)).Methods(http.MethodPost)
r.HandleFunc("/admin/clients/{id}/delete", s.Auth.RequireAuth(s.clientDelete)).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)
@@ -88,6 +112,8 @@ func (s *Server) Routes() http.Handler {
r.HandleFunc("/admin/nodes/{id}/delete", s.Auth.RequireAuth(s.nodeDelete)).Methods(http.MethodPost)
r.HandleFunc("/admin/nodes/{id}/protocols/{protocolID}", s.Auth.RequireAuth(s.nodeProtocolAction)).Methods(http.MethodPost)
r.HandleFunc("/sub/{token}", s.subscriptionGet).Methods(http.MethodGet)
r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
+70
View File
@@ -154,4 +154,74 @@ type DashboardStats struct {
NodesTotal int
NodesOnline int
ProfilesTotal int
ClientsTotal int
ClientsActive int
}
type ClientStatus string
const (
ClientStatusActive ClientStatus = "active"
ClientStatusDisabled ClientStatus = "disabled"
ClientStatusExpired ClientStatus = "expired"
)
type Client struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
UUID uuid.UUID `json:"uuid"` // VLESS/VMess user id
Status ClientStatus `json:"status"`
TrafficLimitBytes int64 `json:"traffic_limit_bytes"` // 0 = unlimited
TrafficUsedBytes int64 `json:"traffic_used_bytes"`
ExpireAt *time.Time `json:"expire_at,omitempty"`
SubToken string `json:"sub_token"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
InboundIDs []uuid.UUID `json:"inbound_ids,omitempty"`
InboundCount int `json:"inbound_count"`
}
func (c Client) StatusLabel() string {
switch c.Status {
case ClientStatusActive:
return "Активен"
case ClientStatusDisabled:
return "Отключён"
case ClientStatusExpired:
return "Истёк"
default:
return string(c.Status)
}
}
func (c Client) IsActive() bool {
if c.Status != ClientStatusActive {
return false
}
if c.ExpireAt != nil && time.Now().After(*c.ExpireAt) {
return false
}
if c.TrafficLimitBytes > 0 && c.TrafficUsedBytes >= c.TrafficLimitBytes {
return false
}
return true
}
func (c Client) TrafficLimitGB() float64 {
if c.TrafficLimitBytes <= 0 {
return 0
}
return float64(c.TrafficLimitBytes) / (1024 * 1024 * 1024)
}
func (c Client) HasInbound(id uuid.UUID) bool {
for _, x := range c.InboundIDs {
if x == id {
return true
}
}
return false
}
+81
View File
@@ -0,0 +1,81 @@
<!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 class="active" href="/admin/clients">Пользователи</a>
<a href="/admin/nodes">Ноды</a>
<a 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">Создайте VPN-клиента и назначьте инбаунды</p>
</header>
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<form class="form-card" method="POST" action="/admin/clients/new">
<div class="form-grid">
<label>Username *
<input name="username" required value="{{.Form.Username}}" placeholder="user1" autofocus>
</label>
<label>Email
<input name="email" type="email" value="{{.Form.Email}}" placeholder="optional@mail.com">
</label>
<label>Статус
<select name="status">
<option value="active" selected>Активен</option>
<option value="disabled">Отключён</option>
</select>
</label>
<label>Лимит трафика (GB, 0 = ∞)
<input name="traffic_limit_gb" type="number" step="0.1" min="0" value="{{.Form.TrafficLimitGB}}" placeholder="0">
</label>
<label>Истекает
<input name="expire_at" type="datetime-local" value="{{.Form.ExpireAt}}">
</label>
<label>Заметка
<input name="note" value="{{.Form.Note}}" placeholder="опционально">
</label>
</div>
<fieldset class="mode-pick">
<legend>Инбаунды</legend>
{{if .Inbounds}}
{{range .Inbounds}}
<label class="radio">
<input type="checkbox" name="inbound_ids" value="{{.ID}}">
<span><code>{{.Tag}}</code> · {{.ProtocolName}} :{{.Port}} ({{.Network}}/{{.Security}})</span>
</label>
{{end}}
{{else}}
<p class="muted">Сначала создайте профиль и добавьте inbound.</p>
{{end}}
</fieldset>
<div class="cta-row">
<button class="btn btn-primary" type="submit">Создать</button>
<a class="btn btn-ghost" href="/admin/clients">Отмена</a>
</div>
</form>
</main>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!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 class="active" href="/admin/clients">Пользователи</a>
<a href="/admin/nodes">Ноды</a>
<a 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>{{.Client.Username}}</h1>
<p class="muted"><code>{{.Client.UUID}}</code></p>
</div>
<span class="badge {{statusClass (printf "%s" .Client.Status)}}">{{.Client.StatusLabel}}</span>
</header>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
<div class="cta-row" style="margin-bottom:1.25rem">
<form method="POST" action="/admin/clients/{{.Client.ID}}/toggle">
<button class="btn btn-ghost" type="submit">{{if eq (printf "%s" .Client.Status) "active"}}Отключить{{else}}Включить{{end}}</button>
</form>
<form method="POST" action="/admin/clients/{{.Client.ID}}/delete" onsubmit="return confirm('Удалить пользователя?');">
<button class="btn btn-ghost" type="submit">Удалить</button>
</form>
<a class="btn btn-ghost" href="/admin/clients">К списку</a>
</div>
<section class="panel-section">
<h2>Subscription</h2>
<p class="muted">Ссылка для клиента (нужны online-ноды с назначенным профилем и включёнными inbound):</p>
<pre class="code-block">{{.SubURL}}</pre>
{{if .Links}}
<h3 style="margin-top:1rem;font-size:1rem">Сгенерированные ссылки</h3>
<pre class="code-block log">{{range .Links}}{{.}}
{{end}}</pre>
{{else}}
<p class="hint">Пока нет ссылок — назначьте inbound пользователю, профиль ноде, и дождитесь Online.</p>
{{end}}
</section>
<section class="panel-section">
<h2>Редактирование</h2>
<form class="form-card" method="POST" action="/admin/clients/{{.Client.ID}}">
<div class="form-grid">
<label>Username
<input name="username" required value="{{.Client.Username}}">
</label>
<label>Email
<input name="email" type="email" value="{{.Client.Email}}">
</label>
<label>Статус
<select name="status">
<option value="active" {{if eq (printf "%s" .Client.Status) "active"}}selected{{end}}>Активен</option>
<option value="disabled" {{if eq (printf "%s" .Client.Status) "disabled"}}selected{{end}}>Отключён</option>
<option value="expired" {{if eq (printf "%s" .Client.Status) "expired"}}selected{{end}}>Истёк</option>
</select>
</label>
<label>Лимит трафика (GB, 0 = ∞)
<input name="traffic_limit_gb" type="number" step="0.1" min="0" value="{{printf "%.2f" .Client.TrafficLimitGB}}">
</label>
<label>Истекает
<input name="expire_at" type="datetime-local" value="{{formatTime .Client.ExpireAt}}">
</label>
<label>Заметка
<input name="note" value="{{.Client.Note}}">
</label>
</div>
<fieldset class="mode-pick">
<legend>Инбаунды</legend>
{{range .Inbounds}}
<label class="radio">
<input type="checkbox" name="inbound_ids" value="{{.ID}}" {{if $.Client.HasInbound .ID}}checked{{end}}>
<span><code>{{.Tag}}</code> · {{.ProtocolName}} :{{.Port}}</span>
</label>
{{else}}
<p class="muted">Нет доступных inbound — создайте в профиле.</p>
{{end}}
</fieldset>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</section>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>Пользователи</h1>
<p class="muted">VPN-клиенты: доступ к инбаундам и subscription-ссылка</p>
</div>
<a class="btn btn-primary" href="/admin/clients/new">+ Создать пользователя</a>
</header>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
{{if .Clients}}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Статус</th>
<th>Инбаунды</th>
<th>Лимит</th>
<th>Истекает</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Clients}}
<tr>
<td>
<strong><a href="/admin/clients/{{.ID}}">{{.Username}}</a></strong>
{{if .Email}}<div class="cell-desc">{{.Email}}</div>{{end}}
</td>
<td><span class="badge {{statusClass (printf "%s" .Status)}}">{{.StatusLabel}}</span></td>
<td>{{.InboundCount}}</td>
<td>{{if gt .TrafficLimitBytes 0}}{{printf "%.1f" .TrafficLimitGB}} GB{{else}}∞{{end}}</td>
<td>{{if .ExpireAt}}{{.ExpireAt.Format "02.01.2006 15:04"}}{{else}}—{{end}}</td>
<td class="actions"><a class="btn btn-sm btn-ghost" href="/admin/clients/{{.ID}}">Открыть</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty-state">
<p>Пользователей пока нет.</p>
<a class="btn btn-primary" href="/admin/clients/new">Создать первого</a>
</div>
{{end}}
</main>
</body>
</html>
+9 -8
View File
@@ -14,6 +14,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>
@@ -31,6 +32,14 @@
</header>
<section class="stats stats-5">
<div class="stat">
<span class="stat-label">Пользователи</span>
<span class="stat-value">{{.Stats.ClientsTotal}}</span>
</div>
<div class="stat">
<span class="stat-label">Активные</span>
<span class="stat-value accent">{{.Stats.ClientsActive}}</span>
</div>
<div class="stat">
<span class="stat-label">Ноды</span>
<span class="stat-value">{{.Stats.NodesTotal}}</span>
@@ -43,14 +52,6 @@
<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>
</div>
<div class="stat">
<span class="stat-label">Включено</span>
<span class="stat-value accent">{{.Stats.ProtocolsEnabled}}</span>
</div>
</section>
<section class="panel-section">
+1
View File
@@ -14,6 +14,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>
+1
View File
@@ -17,6 +17,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>
+1
View File
@@ -14,6 +14,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a href="/admin">Обзор</a>
<a href="/admin/clients">Пользователи</a>
<a href="/admin/nodes">Ноды</a>
<a class="active" href="/admin/profiles">Профили</a>
<a href="/admin/protocols">Протоколы</a>
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="/admin">VPN Panel</a>
<nav>
<a href="/admin">Обзор</a>
<a href="/admin/clients">Пользователи</a>
<a href="/admin/nodes">Ноды</a>
<a class="active" href="/admin/profiles">Профили</a>
<a href="/admin/protocols">Протоколы</a>
+1
View File
@@ -14,6 +14,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>
+1
View File
@@ -14,6 +14,7 @@
<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 "clients"}}active{{end}}" href="/admin/clients">Пользователи</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>