466 lines
12 KiB
Go
466 lines
12 KiB
Go
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
|
||
}
|