Show subscription inbounds as Remnawave-style host cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 04:56:45 +03:00
co-authored by Cursor
parent c0e78ce949
commit c41bae9852
8 changed files with 493 additions and 72 deletions
+82 -36
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"fmt"
"net/url"
"time"
"github.com/google/uuid"
@@ -220,58 +221,103 @@ ORDER BY i.sort_order ASC, i.tag ASC`)
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 {
// 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 DISTINCT i.tag, p.code, i.port, i.network, i.security, n.host, n.name
SELECT i.id, i.tag, i.remark, p.code, p.name, i.port, i.network, i.security,
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
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
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 n.name, i.tag`, client.ID)
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()
var links []string
uid := client.UUID.String()
var hosts []models.SubHost
seen := map[string]bool{}
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 {
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.Address, &h.NodeName,
&h.NodeOnline, &h.Available,
); 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))
// 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(h.ProtocolCode, uid, h.Address, h.Port, h.Network, h.Security, client.Username+"-"+h.Tag)
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
func buildShareLink(code, uid, host string, port int, network, security, name string) string {
name = url.QueryEscape(name)
switch code {
case "vless":
return fmt.Sprintf("vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s", uid, host, port, network, security, name)
case "vmess":
return fmt.Sprintf("vmess://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name)
case "trojan":
return fmt.Sprintf("trojan://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name)
case "shadowsocks":
return fmt.Sprintf("ss://%s@%s:%d#%s", uid, host, port, name)
default:
return fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, 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, rows.Err()
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
}