Files
panel-vpn/internal/db/clients.go
T

466 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"database/sql"
"encoding/base64"
"fmt"
"net/url"
"strings"
"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()
}
// ClientSubscriptionHosts returns Remnawave-style hosts: inbound × node combinations.
func ClientSubscriptionHosts(db *sql.DB, client *models.Client) ([]models.SubHost, error) {
if client == nil {
return nil, nil
}
rows, err := db.Query(`
SELECT i.id, i.tag, i.remark, p.code, p.name, i.port, i.network, i.security,
i.path, i.host, i.sni, i.fingerprint, i.flow, i.alpn,
i.reality_public_key, i.reality_short_id, i.ss_method, i.password,
COALESCE(n.host, ''), COALESCE(n.name, ''),
COALESCE(n.status = 'online', FALSE),
COALESCE(ni.enabled AND n.status = 'online', FALSE)
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
LEFT JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.enabled = TRUE
LEFT JOIN nodes n ON n.id = ni.node_id AND n.profile_id = i.profile_id
WHERE ci.client_id = $1
ORDER BY i.sort_order ASC, i.tag ASC, n.name ASC NULLS LAST`, client.ID)
if err != nil {
return nil, err
}
defer rows.Close()
uid := client.UUID.String()
var hosts []models.SubHost
seen := map[string]bool{}
for rows.Next() {
var h models.SubHost
if err := rows.Scan(
&h.InboundID, &h.Tag, &h.Remark, &h.ProtocolCode, &h.ProtocolName,
&h.Port, &h.Network, &h.Security,
&h.Path, &h.HostHeader, &h.SNI, &h.Fingerprint, &h.Flow, &h.ALPN,
&h.PublicKey, &h.ShortID, &h.SSMethod, &h.Password,
&h.Address, &h.NodeName,
&h.NodeOnline, &h.Available,
); err != nil {
return nil, err
}
// Deduplicate: same inbound without any node → one offline card.
key := h.InboundID.String() + "|" + h.Address + "|" + h.NodeName
if h.Address == "" {
key = h.InboundID.String() + "|none"
}
if seen[key] {
continue
}
seen[key] = true
if h.Available && h.Address != "" {
h.Link = buildShareLink(uid, hostDisplayName(client.Username, &h), &h)
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
func hostDisplayName(username string, h *models.SubHost) string {
if h.Remark != "" {
return h.Remark
}
if h.NodeName != "" {
return h.NodeName
}
if h.Tag != "" {
return h.Tag
}
return username
}
// buildShareLink builds Remnawave-compatible share URIs.
// Query keys are intentionally ordered (not alphabetically).
func buildShareLink(uid, name string, h *models.SubHost) string {
name = url.QueryEscape(name)
switch strings.ToLower(h.ProtocolCode) {
case "vless":
return buildVLESSLink(uid, name, h)
case "vmess":
return buildVMessLink(uid, name, h)
case "trojan":
return buildTrojanLink(uid, name, h)
case "shadowsocks":
method := h.SSMethod
if method == "" {
method = "aes-128-gcm"
}
userInfo := base64.StdEncoding.EncodeToString([]byte(method + ":" + uid))
return fmt.Sprintf("ss://%s@%s:%d#%s", userInfo, h.Address, h.Port, name)
default:
// Non-Xray protocols are not shareable as vless-style URIs.
return ""
}
}
func buildVLESSLink(uid, name string, h *models.SubHost) string {
network := h.Network
if network == "" {
network = "tcp"
}
security := h.Security
if security == "" {
security = "none"
}
flow := h.Flow
if flow == "" && security == "reality" && network == "tcp" {
flow = "xtls-rprx-vision"
}
fp := h.Fingerprint
if fp == "" && (security == "reality" || security == "tls") {
fp = "chrome"
}
var parts []string
parts = append(parts, "encryption=none")
if flow != "" {
parts = append(parts, "flow="+url.QueryEscape(flow))
}
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if fp != "" {
parts = append(parts, "fp="+url.QueryEscape(fp))
}
if h.ALPN != "" {
parts = append(parts, "alpn="+url.QueryEscape(h.ALPN))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func buildVMessLink(uid, name string, h *models.SubHost) string {
network := orDefault(h.Network, "tcp")
security := orDefault(h.Security, "none")
var parts []string
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if h.Fingerprint != "" {
parts = append(parts, "fp="+url.QueryEscape(h.Fingerprint))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("vmess://%s@%s:%d?%s#%s", uid, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func buildTrojanLink(uid, name string, h *models.SubHost) string {
pass := uid
if h.Password != "" {
pass = h.Password
}
network := orDefault(h.Network, "tcp")
security := orDefault(h.Security, "tls")
var parts []string
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if h.Fingerprint != "" {
parts = append(parts, "fp="+url.QueryEscape(h.Fingerprint))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
// ClientSubscriptionLinks builds share links for available hosts only.
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
hosts, err := ClientSubscriptionHosts(db, client)
if err != nil {
return nil, err
}
var links []string
for _, h := range hosts {
if h.Link != "" {
links = append(links, h.Link)
}
}
return links, nil
}
func BuildSubscriptionInfo(db *sql.DB, client *models.Client) (*models.SubscriptionInfo, error) {
hosts, err := ClientSubscriptionHosts(db, client)
if err != nil {
return nil, err
}
var links []string
for _, h := range hosts {
if h.Link != "" {
links = append(links, h.Link)
}
}
info := &models.SubscriptionInfo{Client: *client, Hosts: hosts, Links: links}
if client.ExpireAt != nil {
info.ExpireUnix = client.ExpireAt.Unix()
}
return info, nil
}