Show subscription inbounds as Remnawave-style host cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+76
-30
@@ -3,6 +3,7 @@ package db
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -220,58 +221,103 @@ ORDER BY i.sort_order ASC, i.tag ASC`)
|
|||||||
return list, rows.Err()
|
return list, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientSubscriptionLinks builds basic share links using online nodes that have the inbound enabled.
|
// ClientSubscriptionHosts returns Remnawave-style hosts: inbound × node combinations.
|
||||||
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
|
func ClientSubscriptionHosts(db *sql.DB, client *models.Client) ([]models.SubHost, error) {
|
||||||
if client == nil || len(client.InboundIDs) == 0 {
|
if client == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows, err := db.Query(`
|
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
|
FROM client_inbounds ci
|
||||||
JOIN inbounds i ON i.id = ci.inbound_id AND i.enabled = TRUE
|
JOIN inbounds i ON i.id = ci.inbound_id AND i.enabled = TRUE
|
||||||
JOIN protocols p ON p.id = i.protocol_id
|
JOIN protocols p ON p.id = i.protocol_id
|
||||||
JOIN node_inbounds ni ON ni.inbound_id = i.id AND ni.enabled = TRUE
|
LEFT 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 nodes n ON n.id = ni.node_id AND n.profile_id = i.profile_id
|
||||||
WHERE ci.client_id = $1
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var links []string
|
|
||||||
uid := client.UUID.String()
|
uid := client.UUID.String()
|
||||||
|
var hosts []models.SubHost
|
||||||
|
seen := map[string]bool{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var tag, code, network, security, host, nodeName string
|
var h models.SubHost
|
||||||
var port int
|
if err := rows.Scan(
|
||||||
if err := rows.Scan(&tag, &code, &port, &network, &security, &host, &nodeName); err != nil {
|
&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
|
return nil, err
|
||||||
}
|
}
|
||||||
name := client.Username + "-" + tag + "@" + nodeName
|
// 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 {
|
switch code {
|
||||||
case "vless":
|
case "vless":
|
||||||
links = append(links, fmt.Sprintf(
|
return fmt.Sprintf("vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s", uid, host, port, network, security, name)
|
||||||
"vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s",
|
|
||||||
uid, host, port, network, security, name,
|
|
||||||
))
|
|
||||||
case "vmess":
|
case "vmess":
|
||||||
links = append(links, fmt.Sprintf(
|
return fmt.Sprintf("vmess://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name)
|
||||||
"vmess://%s@%s:%d?type=%s&security=%s#%s",
|
|
||||||
uid, host, port, network, security, name,
|
|
||||||
))
|
|
||||||
case "trojan":
|
case "trojan":
|
||||||
links = append(links, fmt.Sprintf(
|
return fmt.Sprintf("trojan://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name)
|
||||||
"trojan://%s@%s:%d?type=%s&security=%s#%s",
|
|
||||||
uid, host, port, network, security, name,
|
|
||||||
))
|
|
||||||
case "shadowsocks":
|
case "shadowsocks":
|
||||||
links = append(links, fmt.Sprintf(
|
return fmt.Sprintf("ss://%s@%s:%d#%s", uid, host, port, name)
|
||||||
"ss://%s@%s:%d#%s",
|
|
||||||
uid, host, port, name,
|
|
||||||
))
|
|
||||||
default:
|
default:
|
||||||
links = append(links, fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, name))
|
return fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return links, rows.Err()
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (s *Server) clientView(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
inbounds, _ := db.ListAllInbounds(s.DB)
|
inbounds, _ := db.ListAllInbounds(s.DB)
|
||||||
links, _ := db.ClientSubscriptionLinks(s.DB, c)
|
info, _ := db.BuildSubscriptionInfo(s.DB, c)
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if r.TLS == nil {
|
if r.TLS == nil {
|
||||||
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
|
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
|
||||||
@@ -142,6 +142,12 @@ func (s *Server) clientView(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
subURL := scheme + "://" + r.Host + "/sub/" + c.SubToken
|
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{
|
s.render(w, "client_view.html", pageData{
|
||||||
"Title": c.Username,
|
"Title": c.Username,
|
||||||
"UserName": s.Auth.CurrentName(r),
|
"UserName": s.Auth.CurrentName(r),
|
||||||
@@ -149,6 +155,7 @@ func (s *Server) clientView(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Client": c,
|
"Client": c,
|
||||||
"Inbounds": inbounds,
|
"Inbounds": inbounds,
|
||||||
"Links": links,
|
"Links": links,
|
||||||
|
"Hosts": hosts,
|
||||||
"SubURL": subURL,
|
"SubURL": subURL,
|
||||||
"Flash": r.URL.Query().Get("ok"),
|
"Flash": r.URL.Query().Get("ok"),
|
||||||
"Error": r.URL.Query().Get("err"),
|
"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)
|
_ = db.DeleteClient(s.DB, id)
|
||||||
http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther)
|
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"))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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("/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}", s.subscriptionGet).Methods(http.MethodGet)
|
||||||
|
r.HandleFunc("/sub/{token}/info", s.subscriptionInfoGet).Methods(http.MethodGet)
|
||||||
|
|
||||||
r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
r.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -225,3 +225,37 @@ func (c Client) HasInbound(id uuid.UUID) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
+30
-1
@@ -77,7 +77,36 @@ code {
|
|||||||
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.9rem; }
|
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.9rem; }
|
||||||
.btn-block { width: 100%; }
|
.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 {
|
.home-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -49,14 +49,38 @@
|
|||||||
|
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<h2>Subscription</h2>
|
<h2>Subscription</h2>
|
||||||
<p class="muted">Ссылка для клиента (нужны online-ноды с назначенным профилем и включёнными inbound):</p>
|
<p class="muted">Ссылка подписки (в браузере — страница с инбаундами, в клиенте — base64):</p>
|
||||||
<pre class="code-block">{{.SubURL}}</pre>
|
<pre class="code-block">{{.SubURL}}</pre>
|
||||||
|
<div class="cta-row" style="margin:0.75rem 0 1rem">
|
||||||
|
<a class="btn btn-sm btn-primary" href="{{.SubURL}}" target="_blank" rel="noopener">Открыть страницу</a>
|
||||||
|
<a class="btn btn-sm btn-ghost" href="{{.SubURL}}?format=plain" target="_blank" rel="noopener">Plain</a>
|
||||||
|
<a class="btn btn-sm btn-ghost" href="{{.SubURL}}/info" target="_blank" rel="noopener">JSON</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:0 0 0.75rem;font-size:1.05rem">Инбаунды в подписке</h3>
|
||||||
|
{{if .Hosts}}
|
||||||
|
<div class="proto-grid">
|
||||||
|
{{range .Hosts}}
|
||||||
|
<article class="proto-card {{if .Available}}is-on{{else}}is-off{{end}}">
|
||||||
|
<div class="proto-top">
|
||||||
|
<h3>{{.Title}}</h3>
|
||||||
|
<span class="badge {{if .Available}}on{{else}}off{{end}}">{{if .Available}}ON{{else}}OFF{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<p><code>{{.Tag}}</code> · {{.ProtocolName}} · {{.Network}}/{{.Security}} :{{.Port}}</p>
|
||||||
|
<div class="proto-meta">
|
||||||
|
{{if .Address}}<span>{{.NodeName}}</span><code>{{.Address}}</code>{{else}}<span class="muted">offline / нет ноды</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="hint">Нет инбаундов — отметьте их ниже в форме.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Links}}
|
{{if .Links}}
|
||||||
<h3 style="margin-top:1rem;font-size:1rem">Сгенерированные ссылки</h3>
|
<h3 style="margin-top:1.25rem;font-size:1rem">Ссылки</h3>
|
||||||
<pre class="code-block log">{{range .Links}}{{.}}
|
<pre class="code-block log">{{range .Links}}{{.}}
|
||||||
{{end}}</pre>
|
{{end}}</pre>
|
||||||
{{else}}
|
|
||||||
<p class="hint">Пока нет ссылок — назначьте inbound пользователю, профиль ноде, и дождитесь Online.</p>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} · Subscription</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-sub">
|
||||||
|
<div class="home-bg" aria-hidden="true"></div>
|
||||||
|
<main class="sub-wrap">
|
||||||
|
<header class="sub-header">
|
||||||
|
<p class="brand-mark sub-brand">VPN Panel</p>
|
||||||
|
<h1>{{.Info.Client.Username}}</h1>
|
||||||
|
<p class="muted">Подписка · инбаунды / хосты</p>
|
||||||
|
{{if .Expired}}
|
||||||
|
<div class="alert">Подписка неактивна или истекла</div>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge {{statusClass (printf "%s" .Info.Client.Status)}}">{{.Info.Client.StatusLabel}}</span>
|
||||||
|
{{end}}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="sub-meta">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Трафик</span>
|
||||||
|
<span class="stat-value" style="font-size:1.2rem">
|
||||||
|
{{if gt .Info.Client.TrafficLimitBytes 0}}
|
||||||
|
использовано / лимит задан
|
||||||
|
{{else}}
|
||||||
|
∞ без лимита
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Истекает</span>
|
||||||
|
<span class="stat-value" style="font-size:1.2rem">
|
||||||
|
{{if .Info.Client.ExpireAt}}{{.Info.Client.ExpireAt.Format "02.01.2006 15:04"}}{{else}}бессрочно{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Хостов</span>
|
||||||
|
<span class="stat-value accent" style="font-size:1.2rem">{{len .Info.Hosts}}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Subscription URL</h2>
|
||||||
|
<pre class="code-block">{{.SubURL}}</pre>
|
||||||
|
<p class="hint">В клиенте добавьте эту ссылку как подписку. В браузере открывается эта страница с инбаундами.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{{.SubURL}}?format=base64">Raw base64</a>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{{.SubURL}}?format=plain">Plain links</a>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="{{.SubURL}}/info">JSON info</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<h2>Инбаунды / Hosts</h2>
|
||||||
|
{{if .Info.Hosts}}
|
||||||
|
<div class="proto-grid">
|
||||||
|
{{range .Info.Hosts}}
|
||||||
|
<article class="proto-card sub-host {{if .Available}}is-on{{else}}is-off{{end}}">
|
||||||
|
<div class="proto-top">
|
||||||
|
<h3>{{.Title}}</h3>
|
||||||
|
<span class="badge {{if .Available}}on{{else}}off{{end}}">{{if .Available}}ON{{else}}OFF{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<code>{{.Tag}}</code> · {{.ProtocolName}}<br>
|
||||||
|
{{.Network}} / {{.Security}} · порт {{.Port}}
|
||||||
|
</p>
|
||||||
|
<div class="proto-meta">
|
||||||
|
{{if .Address}}
|
||||||
|
<span>{{.NodeName}}</span>
|
||||||
|
<code>{{.Address}}:{{.Port}}</code>
|
||||||
|
{{else}}
|
||||||
|
<span class="muted">Нет online-ноды с этим inbound</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Link}}
|
||||||
|
<details class="sub-link">
|
||||||
|
<summary>Показать ссылку</summary>
|
||||||
|
<pre class="code-block log">{{.Link}}</pre>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Инбаунды не назначены. Админ должен выбрать inbound у пользователя.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user