From c0e78ce94947af5f6e8d838bc8629987317d64f1 Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 04:49:32 +0300 Subject: [PATCH] Add VPN client users with inbound access and subscription links. Co-authored-by: Cursor --- internal/db/clients.go | 277 ++++++++++++++++++++++++++++++++ internal/db/db.go | 32 ++++ internal/handlers/clients.go | 239 +++++++++++++++++++++++++++ internal/handlers/handlers.go | 32 +++- internal/models/models.go | 70 ++++++++ web/templates/client_new.html | 81 ++++++++++ web/templates/client_view.html | 108 +++++++++++++ web/templates/clients.html | 78 +++++++++ web/templates/dashboard.html | 17 +- web/templates/node_new.html | 1 + web/templates/node_view.html | 1 + web/templates/nodes.html | 1 + web/templates/profile_new.html | 1 + web/templates/profile_view.html | 1 + web/templates/profiles.html | 1 + web/templates/protocols.html | 1 + 16 files changed, 930 insertions(+), 11 deletions(-) create mode 100644 internal/db/clients.go create mode 100644 internal/handlers/clients.go create mode 100644 web/templates/client_new.html create mode 100644 web/templates/client_view.html create mode 100644 web/templates/clients.html diff --git a/internal/db/clients.go b/internal/db/clients.go new file mode 100644 index 0000000..1c9821a --- /dev/null +++ b/internal/db/clients.go @@ -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() +} diff --git a/internal/db/db.go b/internal/db/db.go index 80dcc6c..fcd1597 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 } diff --git a/internal/handlers/clients.go b/internal/handlers/clients.go new file mode 100644 index 0000000..96d1be4 --- /dev/null +++ b/internal/handlers/clients.go @@ -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")) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f23fd09..8e1150c 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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")) diff --git a/internal/models/models.go b/internal/models/models.go index 45461b2..10f803f 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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 } diff --git a/web/templates/client_new.html b/web/templates/client_new.html new file mode 100644 index 0000000..c5c7d91 --- /dev/null +++ b/web/templates/client_new.html @@ -0,0 +1,81 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+

Новый пользователь

+

Создайте VPN-клиента и назначьте инбаунды

+
+ {{if .Error}}
{{.Error}}
{{end}} + +
+
+ + + + + + +
+ +
+ Инбаунды + {{if .Inbounds}} + {{range .Inbounds}} + + {{end}} + {{else}} +

Сначала создайте профиль и добавьте inbound.

+ {{end}} +
+ +
+ + Отмена +
+
+
+ + diff --git a/web/templates/client_view.html b/web/templates/client_view.html new file mode 100644 index 0000000..748ca47 --- /dev/null +++ b/web/templates/client_view.html @@ -0,0 +1,108 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+
+

{{.Client.Username}}

+

{{.Client.UUID}}

+
+ {{.Client.StatusLabel}} +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+
+ +
+
+ +
+ К списку +
+ +
+

Subscription

+

Ссылка для клиента (нужны online-ноды с назначенным профилем и включёнными inbound):

+
{{.SubURL}}
+ {{if .Links}} +

Сгенерированные ссылки

+
{{range .Links}}{{.}}
+{{end}}
+ {{else}} +

Пока нет ссылок — назначьте inbound пользователю, профиль ноде, и дождитесь Online.

+ {{end}} +
+ +
+

Редактирование

+
+
+ + + + + + +
+ +
+ Инбаунды + {{range .Inbounds}} + + {{else}} +

Нет доступных inbound — создайте в профиле.

+ {{end}} +
+ + +
+
+
+ + diff --git a/web/templates/clients.html b/web/templates/clients.html new file mode 100644 index 0000000..8e62f31 --- /dev/null +++ b/web/templates/clients.html @@ -0,0 +1,78 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+
+

Пользователи

+

VPN-клиенты: доступ к инбаундам и subscription-ссылка

+
+ + Создать пользователя +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + + {{if .Clients}} +
+ + + + + + + + + + + + + {{range .Clients}} + + + + + + + + + {{end}} + +
UsernameСтатусИнбаундыЛимитИстекает
+ {{.Username}} + {{if .Email}}
{{.Email}}
{{end}} +
{{.StatusLabel}}{{.InboundCount}}{{if gt .TrafficLimitBytes 0}}{{printf "%.1f" .TrafficLimitGB}} GB{{else}}∞{{end}}{{if .ExpireAt}}{{.ExpireAt.Format "02.01.2006 15:04"}}{{else}}—{{end}}Открыть
+
+ {{else}} +
+

Пользователей пока нет.

+ Создать первого +
+ {{end}} +
+ + diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 3ee593a..2b184e8 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -14,6 +14,7 @@ VPN Panel