Add VPN client users with inbound access and subscription links.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user