From c41bae9852eb80351513461755064e570e284cab Mon Sep 17 00:00:00 2001 From: orohi Date: Wed, 29 Jul 2026 04:56:45 +0300 Subject: [PATCH] Show subscription inbounds as Remnawave-style host cards. Co-authored-by: Cursor --- internal/db/clients.go | 118 +++++++++++------ internal/handlers/clients.go | 48 +++---- internal/handlers/handlers.go | 1 + internal/handlers/subscription.go | 202 ++++++++++++++++++++++++++++++ internal/models/models.go | 34 +++++ web/static/css/app.css | 31 ++++- web/templates/client_view.html | 32 ++++- web/templates/subscription.html | 99 +++++++++++++++ 8 files changed, 493 insertions(+), 72 deletions(-) create mode 100644 internal/handlers/subscription.go create mode 100644 web/templates/subscription.html diff --git a/internal/db/clients.go b/internal/db/clients.go index 1c9821a..b7fd3c7 100644 --- a/internal/db/clients.go +++ b/internal/db/clients.go @@ -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 } diff --git a/internal/handlers/clients.go b/internal/handlers/clients.go index 96d1be4..6b0356f 100644 --- a/internal/handlers/clients.go +++ b/internal/handlers/clients.go @@ -132,7 +132,7 @@ func (s *Server) clientView(w http.ResponseWriter, r *http.Request) { return } inbounds, _ := db.ListAllInbounds(s.DB) - links, _ := db.ClientSubscriptionLinks(s.DB, c) + info, _ := db.BuildSubscriptionInfo(s.DB, c) scheme := "https" if r.TLS == nil { if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { @@ -142,16 +142,23 @@ func (s *Server) clientView(w http.ResponseWriter, r *http.Request) { } } subURL := scheme + "://" + r.Host + "/sub/" + c.SubToken + var links []string + var hosts []models.SubHost + if info != nil { + links = info.Links + hosts = info.Hosts + } s.render(w, "client_view.html", pageData{ - "Title": c.Username, - "UserName": s.Auth.CurrentName(r), - "Active": "clients", - "Client": c, - "Inbounds": inbounds, - "Links": links, - "SubURL": subURL, - "Flash": r.URL.Query().Get("ok"), - "Error": r.URL.Query().Get("err"), + "Title": c.Username, + "UserName": s.Auth.CurrentName(r), + "Active": "clients", + "Client": c, + "Inbounds": inbounds, + "Links": links, + "Hosts": hosts, + "SubURL": subURL, + "Flash": r.URL.Query().Get("ok"), + "Error": r.URL.Query().Get("err"), }) } @@ -216,24 +223,3 @@ func (s *Server) clientDelete(w http.ResponseWriter, r *http.Request) { _ = db.DeleteClient(s.DB, id) http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther) } - -func (s *Server) subscriptionGet(w http.ResponseWriter, r *http.Request) { - token := mux.Vars(r)["token"] - c, err := db.GetClientBySubToken(s.DB, token) - if err != nil || c == nil || !c.IsActive() { - http.Error(w, "not found", http.StatusNotFound) - return - } - links, err := db.ClientSubscriptionLinks(s.DB, c) - if err != nil { - http.Error(w, "error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Profile-Title", c.Username) - if len(links) == 0 { - _, _ = w.Write([]byte("# no active inbounds / online nodes\n")) - return - } - _, _ = w.Write([]byte(strings.Join(links, "\n") + "\n")) -} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8e1150c..3b62cdc 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -113,6 +113,7 @@ func (s *Server) Routes() http.Handler { r.HandleFunc("/admin/nodes/{id}/protocols/{protocolID}", s.Auth.RequireAuth(s.nodeProtocolAction)).Methods(http.MethodPost) r.HandleFunc("/sub/{token}", s.subscriptionGet).Methods(http.MethodGet) + r.HandleFunc("/sub/{token}/info", s.subscriptionInfoGet).Methods(http.MethodGet) r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/handlers/subscription.go b/internal/handlers/subscription.go new file mode 100644 index 0000000..eb856e4 --- /dev/null +++ b/internal/handlers/subscription.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/mux" + + "github.com/orohi/vpn-panel/internal/db" + "github.com/orohi/vpn-panel/internal/models" +) + +func isBrowserRequest(r *http.Request) bool { + if r.URL.Query().Get("format") == "raw" || r.URL.Query().Get("format") == "base64" { + return false + } + if r.URL.Query().Get("format") == "html" { + return true + } + ua := strings.ToLower(r.UserAgent()) + browsers := []string{"mozilla", "chrome", "safari", "firefox", "edge", "opera"} + for _, b := range browsers { + if strings.Contains(ua, b) { + // Exclude common VPN clients that include mozilla in UA sometimes + clients := []string{"clash", "v2ray", "sing-box", "singbox", "shadowrocket", "stash", "quantumult", "surge", "hiddify", "nekobox", "streisand"} + for _, c := range clients { + if strings.Contains(ua, c) { + return false + } + } + return true + } + } + return false +} + +func writeSubHeaders(w http.ResponseWriter, username string, used, total int64, expireUnix int64) { + w.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(username))) + w.Header().Set("profile-update-interval", "12") + w.Header().Set("subscription-userinfo", fmt.Sprintf( + "upload=0; download=%d; total=%d; expire=%d", + used, total, expireUnix, + )) + w.Header().Set("announce", "base64:"+base64.StdEncoding.EncodeToString([]byte("VPN Panel"))) +} + +func (s *Server) subscriptionGet(w http.ResponseWriter, r *http.Request) { + token := mux.Vars(r)["token"] + c, err := db.GetClientBySubToken(s.DB, token) + if err != nil || c == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + info, err := db.BuildSubscriptionInfo(s.DB, c) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + expireUnix := int64(0) + if c.ExpireAt != nil { + expireUnix = c.ExpireAt.Unix() + } + writeSubHeaders(w, c.Username, c.TrafficUsedBytes, c.TrafficLimitBytes, expireUnix) + + format := r.URL.Query().Get("format") + if format == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(info) + return + } + + if isBrowserRequest(r) { + if !c.IsActive() { + s.render(w, "subscription.html", pageData{ + "Title": c.Username, + "Info": info, + "SubURL": publicSubURL(r, token), + "Expired": true, + }) + return + } + s.render(w, "subscription.html", pageData{ + "Title": c.Username, + "Info": info, + "SubURL": publicSubURL(r, token), + "Now": time.Now(), + }) + return + } + + if !c.IsActive() { + http.Error(w, "subscription inactive", http.StatusForbidden) + return + } + + body := strings.Join(info.Links, "\n") + if body != "" { + body += "\n" + } + if format == "plain" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(body)) + return + } + // Default for VPN apps: base64 (common subscription format) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(base64.StdEncoding.EncodeToString([]byte(body)))) +} + +func (s *Server) subscriptionInfoGet(w http.ResponseWriter, r *http.Request) { + token := mux.Vars(r)["token"] + c, err := db.GetClientBySubToken(s.DB, token) + if err != nil || c == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + info, err := db.BuildSubscriptionInfo(s.DB, c) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "username": c.Username, + "status": c.Status, + "active": c.IsActive(), + "uuid": c.UUID, + "expire": info.ExpireUnix, + "traffic": map[string]any{ + "used": c.TrafficUsedBytes, + "limit": c.TrafficLimitBytes, + }, + "hosts": info.Hosts, + "inbounds": groupHostsByInbound(info.Hosts), + "sub_url": publicSubURL(r, token), + }) +} + +func groupHostsByInbound(hosts []models.SubHost) []map[string]any { + type agg struct { + Tag, Protocol, Network, Security string + Port int + Servers []map[string]any + } + order := []uuid.UUID{} + byID := map[uuid.UUID]*agg{} + for _, h := range hosts { + a, ok := byID[h.InboundID] + if !ok { + a = &agg{ + Tag: h.Tag, Protocol: h.ProtocolName, Network: h.Network, + Security: h.Security, Port: h.Port, + } + byID[h.InboundID] = a + order = append(order, h.InboundID) + } + server := map[string]any{ + "node": h.NodeName, + "address": h.Address, + "online": h.NodeOnline, + "available": h.Available, + "link": h.Link, + "title": h.Title(), + } + if h.Address != "" || h.NodeName != "" { + a.Servers = append(a.Servers, server) + } + } + var out []map[string]any + for _, id := range order { + a := byID[id] + out = append(out, map[string]any{ + "id": id.String(), + "tag": a.Tag, + "protocol": a.Protocol, + "port": a.Port, + "network": a.Network, + "security": a.Security, + "servers": a.Servers, + }) + } + return out +} + +func publicSubURL(r *http.Request, token string) string { + scheme := "https" + if r.TLS == nil { + if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { + scheme = xf + } else { + scheme = "http" + } + } + return scheme + "://" + r.Host + "/sub/" + token +} diff --git a/internal/models/models.go b/internal/models/models.go index 10f803f..ec508d9 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -225,3 +225,37 @@ func (c Client) HasInbound(id uuid.UUID) bool { } return false } + +// SubHost is one subscription endpoint (inbound on a node), Remnawave-style host. +type SubHost struct { + InboundID uuid.UUID `json:"inbound_id"` + Tag string `json:"tag"` + Remark string `json:"remark"` + ProtocolCode string `json:"protocol"` + ProtocolName string `json:"protocol_name"` + Port int `json:"port"` + Network string `json:"network"` + Security string `json:"security"` + Address string `json:"address"` + NodeName string `json:"node_name"` + NodeOnline bool `json:"node_online"` + Available bool `json:"available"` + Link string `json:"link"` +} + +func (h SubHost) Title() string { + if h.Remark != "" { + return h.Remark + } + if h.NodeName != "" { + return h.Tag + " · " + h.NodeName + } + return h.Tag +} + +type SubscriptionInfo struct { + Client Client `json:"client"` + Hosts []SubHost `json:"hosts"` + Links []string `json:"links"` + ExpireUnix int64 `json:"expire_unix"` +} diff --git a/web/static/css/app.css b/web/static/css/app.css index bccbc6e..a16d3f0 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -77,7 +77,36 @@ code { .btn-sm { padding: 0.45rem 0.9rem; font-size: 0.9rem; } .btn-block { width: 100%; } -.page-home { min-height: 100vh; } +.page-sub { min-height: 100vh; } +.sub-wrap { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.25rem 3rem; + animation: rise .55s ease both; +} +.sub-header { margin-bottom: 1.5rem; } +.sub-brand { + font-size: clamp(1.8rem, 5vw, 2.6rem) !important; + margin-bottom: 0.35rem !important; +} +.sub-header h1 { margin: 0 0 0.35rem; font-size: 1.5rem; } +.sub-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + margin-bottom: 1.5rem; +} +.sub-host details.sub-link { margin-top: 0.75rem; } +.sub-host summary { + cursor: pointer; + color: var(--accent); + font-size: 0.9rem; + font-weight: 600; +} +@media (max-width: 700px) { + .sub-meta { grid-template-columns: 1fr; } +} + .home-top { display: flex; justify-content: space-between; diff --git a/web/templates/client_view.html b/web/templates/client_view.html index 748ca47..2881c7d 100644 --- a/web/templates/client_view.html +++ b/web/templates/client_view.html @@ -49,14 +49,38 @@

Subscription

-

Ссылка для клиента (нужны online-ноды с назначенным профилем и включёнными inbound):

+

Ссылка подписки (в браузере — страница с инбаундами, в клиенте — base64):

{{.SubURL}}
+ + +

Инбаунды в подписке

+ {{if .Hosts}} +
+ {{range .Hosts}} +
+
+

{{.Title}}

+ {{if .Available}}ON{{else}}OFF{{end}} +
+

{{.Tag}} · {{.ProtocolName}} · {{.Network}}/{{.Security}} :{{.Port}}

+
+ {{if .Address}}{{.NodeName}}{{.Address}}{{else}}offline / нет ноды{{end}} +
+
+ {{end}} +
+ {{else}} +

Нет инбаундов — отметьте их ниже в форме.

+ {{end}} + {{if .Links}} -

Сгенерированные ссылки

+

Ссылки

{{range .Links}}{{.}}
 {{end}}
- {{else}} -

Пока нет ссылок — назначьте inbound пользователю, профиль ноде, и дождитесь Online.

{{end}}
diff --git a/web/templates/subscription.html b/web/templates/subscription.html new file mode 100644 index 0000000..6f079ad --- /dev/null +++ b/web/templates/subscription.html @@ -0,0 +1,99 @@ + + + + + + {{.Title}} · Subscription + + + + + + + +
+
+

VPN Panel

+

{{.Info.Client.Username}}

+

Подписка · инбаунды / хосты

+ {{if .Expired}} +
Подписка неактивна или истекла
+ {{else}} + {{.Info.Client.StatusLabel}} + {{end}} +
+ +
+
+ Трафик + + {{if gt .Info.Client.TrafficLimitBytes 0}} + использовано / лимит задан + {{else}} + ∞ без лимита + {{end}} + +
+
+ Истекает + + {{if .Info.Client.ExpireAt}}{{.Info.Client.ExpireAt.Format "02.01.2006 15:04"}}{{else}}бессрочно{{end}} + +
+
+ Хостов + {{len .Info.Hosts}} +
+
+ +
+

Subscription URL

+
{{.SubURL}}
+

В клиенте добавьте эту ссылку как подписку. В браузере открывается эта страница с инбаундами.

+ +
+ +
+

Инбаунды / Hosts

+ {{if .Info.Hosts}} +
+ {{range .Info.Hosts}} +
+
+

{{.Title}}

+ {{if .Available}}ON{{else}}OFF{{end}} +
+

+ {{.Tag}} · {{.ProtocolName}}
+ {{.Network}} / {{.Security}} · порт {{.Port}} +

+
+ {{if .Address}} + {{.NodeName}} + {{.Address}}:{{.Port}} + {{else}} + Нет online-ноды с этим inbound + {{end}} +
+ {{if .Link}} + + {{end}} +
+ {{end}} +
+ {{else}} +
+

Инбаунды не назначены. Админ должен выбрать inbound у пользователя.

+
+ {{end}} +
+
+ +