Fix VLESS share links to Remnawave format and default inbound to VLESS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 05:26:00 +03:00
co-authored by Cursor
parent 20a20168b6
commit b48b936c27
3 changed files with 171 additions and 49 deletions
+138 -39
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
@@ -272,66 +273,164 @@ ORDER BY i.sort_order ASC, i.tag ASC, n.name ASC NULLS LAST`, client.ID)
seen[key] = true
if h.Available && h.Address != "" {
h.Link = buildShareLink(uid, client.Username+"-"+h.Tag, &h)
h.Link = buildShareLink(uid, hostDisplayName(client.Username, &h), &h)
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
func hostDisplayName(username string, h *models.SubHost) string {
if h.Remark != "" {
return h.Remark
}
if h.NodeName != "" {
return h.NodeName
}
if h.Tag != "" {
return h.Tag
}
return username
}
// buildShareLink builds Remnawave-compatible share URIs.
// Query keys are intentionally ordered (not alphabetically).
func buildShareLink(uid, name string, h *models.SubHost) string {
name = url.QueryEscape(name)
q := url.Values{}
q.Set("type", h.Network)
q.Set("security", h.Security)
if h.Path != "" {
q.Set("path", h.Path)
}
if h.HostHeader != "" {
q.Set("host", h.HostHeader)
}
if h.SNI != "" {
q.Set("sni", h.SNI)
}
if h.Fingerprint != "" {
q.Set("fp", h.Fingerprint)
}
if h.Flow != "" {
q.Set("flow", h.Flow)
}
if h.ALPN != "" {
q.Set("alpn", h.ALPN)
}
if h.PublicKey != "" {
q.Set("pbk", h.PublicKey)
}
if h.ShortID != "" {
q.Set("sid", h.ShortID)
}
switch h.ProtocolCode {
switch strings.ToLower(h.ProtocolCode) {
case "vless":
q.Set("encryption", "none")
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name)
return buildVLESSLink(uid, name, h)
case "vmess":
return fmt.Sprintf("vmess://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name)
return buildVMessLink(uid, name, h)
case "trojan":
pass := uid
if h.Password != "" {
pass = h.Password
}
return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, q.Encode(), name)
return buildTrojanLink(uid, name, h)
case "shadowsocks":
method := h.SSMethod
if method == "" {
method = "aes-128-gcm"
}
userInfo := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + uid))
userInfo := base64.StdEncoding.EncodeToString([]byte(method + ":" + uid))
return fmt.Sprintf("ss://%s@%s:%d#%s", userInfo, h.Address, h.Port, name)
default:
return fmt.Sprintf("%s://%s@%s:%d?%s#%s", h.ProtocolCode, uid, h.Address, h.Port, q.Encode(), name)
// Non-Xray protocols are not shareable as vless-style URIs.
return ""
}
}
func buildVLESSLink(uid, name string, h *models.SubHost) string {
network := h.Network
if network == "" {
network = "tcp"
}
security := h.Security
if security == "" {
security = "none"
}
flow := h.Flow
if flow == "" && security == "reality" && network == "tcp" {
flow = "xtls-rprx-vision"
}
fp := h.Fingerprint
if fp == "" && (security == "reality" || security == "tls") {
fp = "chrome"
}
var parts []string
parts = append(parts, "encryption=none")
if flow != "" {
parts = append(parts, "flow="+url.QueryEscape(flow))
}
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if fp != "" {
parts = append(parts, "fp="+url.QueryEscape(fp))
}
if h.ALPN != "" {
parts = append(parts, "alpn="+url.QueryEscape(h.ALPN))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func buildVMessLink(uid, name string, h *models.SubHost) string {
network := orDefault(h.Network, "tcp")
security := orDefault(h.Security, "none")
var parts []string
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if h.Fingerprint != "" {
parts = append(parts, "fp="+url.QueryEscape(h.Fingerprint))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("vmess://%s@%s:%d?%s#%s", uid, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func buildTrojanLink(uid, name string, h *models.SubHost) string {
pass := uid
if h.Password != "" {
pass = h.Password
}
network := orDefault(h.Network, "tcp")
security := orDefault(h.Security, "tls")
var parts []string
parts = append(parts, "type="+url.QueryEscape(network))
parts = append(parts, "security="+url.QueryEscape(security))
if h.Path != "" {
parts = append(parts, "path="+url.QueryEscape(h.Path))
}
if h.HostHeader != "" {
parts = append(parts, "host="+url.QueryEscape(h.HostHeader))
}
if h.SNI != "" {
parts = append(parts, "sni="+url.QueryEscape(h.SNI))
}
if h.Fingerprint != "" {
parts = append(parts, "fp="+url.QueryEscape(h.Fingerprint))
}
if h.PublicKey != "" {
parts = append(parts, "pbk="+url.QueryEscape(h.PublicKey))
}
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, strings.Join(parts, "&"), name)
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
// ClientSubscriptionLinks builds share links for available hosts only.
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
hosts, err := ClientSubscriptionHosts(db, client)
+25 -2
View File
@@ -78,7 +78,7 @@ func (s *Server) profileView(w http.ResponseWriter, r *http.Request) {
"UserName": s.Auth.CurrentName(r),
"Active": "profiles",
"Profile": p,
"Protocols": protocols,
"Protocols": inboundProtocols(protocols),
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
@@ -226,6 +226,12 @@ func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
return
}
switch proto.Code {
case "vless", "vmess", "trojan", "shadowsocks":
default:
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
return
}
in, err := parseInboundForm(r, profileID, protocolID, proto)
if err != nil {
log.Printf("inbound form: %v", err)
@@ -278,12 +284,23 @@ func (s *Server) inboundEditGet(w http.ResponseWriter, r *http.Request) {
"Active": "profiles",
"Profile": p,
"Inbound": in,
"Protocols": protocols,
"Protocols": inboundProtocols(protocols),
"Flash": r.URL.Query().Get("ok"),
"Error": r.URL.Query().Get("err"),
})
}
func inboundProtocols(all []models.Protocol) []models.Protocol {
var out []models.Protocol
for _, p := range all {
switch p.Code {
case "vless", "vmess", "trojan", "shadowsocks":
out = append(out, p)
}
}
return out
}
func (s *Server) inboundUpdate(w http.ResponseWriter, r *http.Request) {
profileID, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
@@ -311,6 +328,12 @@ func (s *Server) inboundUpdate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=protocol", http.StatusSeeOther)
return
}
switch proto.Code {
case "vless", "vmess", "trojan", "shadowsocks":
default:
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=protocol", http.StatusSeeOther)
return
}
in, err := parseInboundForm(r, profileID, protocolID, proto)
if err != nil {
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=keys", http.StatusSeeOther)