{{.Title}}
+ {{if .Available}}ON{{else}}OFF{{end}} +{{.Tag}} · {{.ProtocolName}} · {{.Network}}/{{.Security}} :{{.Port}}
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 @@
Ссылка для клиента (нужны online-ноды с назначенным профилем и включёнными inbound): Ссылка подписки (в браузере — страница с инбаундами, в клиенте — base64): Нет инбаундов — отметьте их ниже в форме. Пока нет ссылок — назначьте inbound пользователю, профиль ноде, и дождитесь Online.Subscription
- {{.SubURL}}
+
+
+ Инбаунды в подписке
+ {{if .Hosts}}
+ {{.Title}}
+ {{if .Available}}ON{{else}}OFF{{end}}
+ {{.Tag}} · {{.ProtocolName}} · {{.Network}}/{{.Security}} :{{.Port}}Сгенерированные ссылки
+ Ссылки
{{range .Links}}{{.}}
{{end}}
- {{else}}
-
VPN Panel
+Подписка · инбаунды / хосты
+ {{if .Expired}} +{{.SubURL}}
+ В клиенте добавьте эту ссылку как подписку. В браузере открывается эта страница с инбаундами.
+ +
+ {{.Tag}} · {{.ProtocolName}}
+ {{.Network}} / {{.Security}} · порт {{.Port}}
+
{{.Link}}
+ Инбаунды не назначены. Админ должен выбрать inbound у пользователя.
+