package db import ( "database/sql" "encoding/base64" "fmt" "net/url" "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, client.Username+"-"+h.Tag, &h) } hosts = append(hosts, h) } return hosts, rows.Err() } func buildShareLink(uid, name string, h *models.SubHost) string { name = url.QueryEscape(name) q := url.Values{} q.Set("type", h.Network) q.Set("security", h.Security) if h.Path != "" { q.Set("path", h.Path) } if h.HostHeader != "" { q.Set("host", h.HostHeader) } if h.SNI != "" { q.Set("sni", h.SNI) } if h.Fingerprint != "" { q.Set("fp", h.Fingerprint) } if h.Flow != "" { q.Set("flow", h.Flow) } if h.ALPN != "" { q.Set("alpn", h.ALPN) } if h.PublicKey != "" { q.Set("pbk", h.PublicKey) } if h.ShortID != "" { q.Set("sid", h.ShortID) } switch h.ProtocolCode { case "vless": q.Set("encryption", "none") return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name) case "vmess": return fmt.Sprintf("vmess://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name) case "trojan": pass := uid if h.Password != "" { pass = h.Password } return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, q.Encode(), name) case "shadowsocks": method := h.SSMethod if method == "" { method = "aes-128-gcm" } userInfo := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + uid)) return fmt.Sprintf("ss://%s@%s:%d#%s", userInfo, h.Address, h.Port, name) default: return fmt.Sprintf("%s://%s@%s:%d?%s#%s", h.ProtocolCode, uid, h.Address, h.Port, q.Encode(), name) } } // 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 }