Show subscription inbounds as Remnawave-style host cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user