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.spider_x, 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.SpiderX, &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)) } spx := h.SpiderX if spx == "" && h.Security == "reality" { spx = "/" } if spx != "" { parts = append(parts, "spx="+url.QueryEscape(spx)) } 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 }