Fix VLESS share links to Remnawave format and default inbound to VLESS.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+138
-39
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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
|
seen[key] = true
|
||||||
|
|
||||||
if h.Available && h.Address != "" {
|
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)
|
hosts = append(hosts, h)
|
||||||
}
|
}
|
||||||
return hosts, rows.Err()
|
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 {
|
func buildShareLink(uid, name string, h *models.SubHost) string {
|
||||||
name = url.QueryEscape(name)
|
name = url.QueryEscape(name)
|
||||||
q := url.Values{}
|
switch strings.ToLower(h.ProtocolCode) {
|
||||||
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 {
|
|
||||||
case "vless":
|
case "vless":
|
||||||
q.Set("encryption", "none")
|
return buildVLESSLink(uid, name, h)
|
||||||
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name)
|
|
||||||
case "vmess":
|
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":
|
case "trojan":
|
||||||
pass := uid
|
return buildTrojanLink(uid, name, h)
|
||||||
if h.Password != "" {
|
|
||||||
pass = h.Password
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, q.Encode(), name)
|
|
||||||
case "shadowsocks":
|
case "shadowsocks":
|
||||||
method := h.SSMethod
|
method := h.SSMethod
|
||||||
if method == "" {
|
if method == "" {
|
||||||
method = "aes-128-gcm"
|
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)
|
return fmt.Sprintf("ss://%s@%s:%d#%s", userInfo, h.Address, h.Port, name)
|
||||||
default:
|
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.
|
// ClientSubscriptionLinks builds share links for available hosts only.
|
||||||
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
|
func ClientSubscriptionLinks(db *sql.DB, client *models.Client) ([]string, error) {
|
||||||
hosts, err := ClientSubscriptionHosts(db, client)
|
hosts, err := ClientSubscriptionHosts(db, client)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (s *Server) profileView(w http.ResponseWriter, r *http.Request) {
|
|||||||
"UserName": s.Auth.CurrentName(r),
|
"UserName": s.Auth.CurrentName(r),
|
||||||
"Active": "profiles",
|
"Active": "profiles",
|
||||||
"Profile": p,
|
"Profile": p,
|
||||||
"Protocols": protocols,
|
"Protocols": inboundProtocols(protocols),
|
||||||
"Flash": r.URL.Query().Get("ok"),
|
"Flash": r.URL.Query().Get("ok"),
|
||||||
"Error": r.URL.Query().Get("err"),
|
"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)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
|
||||||
return
|
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)
|
in, err := parseInboundForm(r, profileID, protocolID, proto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("inbound form: %v", err)
|
log.Printf("inbound form: %v", err)
|
||||||
@@ -278,12 +284,23 @@ func (s *Server) inboundEditGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Active": "profiles",
|
"Active": "profiles",
|
||||||
"Profile": p,
|
"Profile": p,
|
||||||
"Inbound": in,
|
"Inbound": in,
|
||||||
"Protocols": protocols,
|
"Protocols": inboundProtocols(protocols),
|
||||||
"Flash": r.URL.Query().Get("ok"),
|
"Flash": r.URL.Query().Get("ok"),
|
||||||
"Error": r.URL.Query().Get("err"),
|
"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) {
|
func (s *Server) inboundUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
||||||
if err != nil {
|
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)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=protocol", http.StatusSeeOther)
|
||||||
return
|
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)
|
in, err := parseInboundForm(r, profileID, protocolID, proto)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=keys", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=keys", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
<label>Протокол
|
<label>Протокол
|
||||||
<select name="protocol_id" required>
|
<select name="protocol_id" required>
|
||||||
{{range .Protocols}}
|
{{range .Protocols}}
|
||||||
<option value="{{.ID}}">{{.Name}} ({{.Code}})</option>
|
<option value="{{.ID}}" {{if eq .Code "vless"}}selected{{end}}>{{.Name}} ({{.Code}})</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -113,14 +113,14 @@
|
|||||||
<input name="tag" placeholder="VLESS-443 (авто если пусто)">
|
<input name="tag" placeholder="VLESS-443 (авто если пусто)">
|
||||||
</label>
|
</label>
|
||||||
<label>Порт
|
<label>Порт
|
||||||
<input name="port" type="number" placeholder="443">
|
<input name="port" type="number" value="443" placeholder="443">
|
||||||
</label>
|
</label>
|
||||||
<label>Listen
|
<label>Listen
|
||||||
<input name="listen" value="0.0.0.0">
|
<input name="listen" value="0.0.0.0">
|
||||||
</label>
|
</label>
|
||||||
<label>Network
|
<label>Network
|
||||||
<select name="network">
|
<select name="network">
|
||||||
<option value="tcp">tcp</option>
|
<option value="tcp" selected>tcp</option>
|
||||||
<option value="ws">ws</option>
|
<option value="ws">ws</option>
|
||||||
<option value="grpc">grpc</option>
|
<option value="grpc">grpc</option>
|
||||||
<option value="quic">quic</option>
|
<option value="quic">quic</option>
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<input name="fingerprint" value="chrome">
|
<input name="fingerprint" value="chrome">
|
||||||
</label>
|
</label>
|
||||||
<label>Flow
|
<label>Flow
|
||||||
<input name="flow" placeholder="xtls-rprx-vision для VLESS+Reality">
|
<input name="flow" value="xtls-rprx-vision" placeholder="xtls-rprx-vision для VLESS+Reality">
|
||||||
</label>
|
</label>
|
||||||
<label>ALPN
|
<label>ALPN
|
||||||
<input name="alpn" placeholder="h2,http/1.1">
|
<input name="alpn" placeholder="h2,http/1.1">
|
||||||
@@ -156,19 +156,19 @@
|
|||||||
<input name="sort_order" type="number" value="0">
|
<input name="sort_order" type="number" value="0">
|
||||||
</label>
|
</label>
|
||||||
<label>Заметка
|
<label>Заметка
|
||||||
<input name="remark" placeholder="опционально">
|
<input name="remark" placeholder="Sweden RU (имя в подписке)">
|
||||||
</label>
|
</label>
|
||||||
<label>Reality dest
|
<label>Reality dest
|
||||||
<input name="reality_dest" placeholder="www.microsoft.com:443">
|
<input name="reality_dest" value="www.microsoft.com:443" placeholder="www.microsoft.com:443">
|
||||||
</label>
|
</label>
|
||||||
<label>Reality server names
|
<label>Reality server names
|
||||||
<input name="reality_server_names" placeholder="www.microsoft.com">
|
<input name="reality_server_names" value="www.microsoft.com" placeholder="www.microsoft.com">
|
||||||
</label>
|
</label>
|
||||||
<label>SS method
|
<label>SS method
|
||||||
<input name="ss_method" value="aes-128-gcm">
|
<input name="ss_method" value="aes-128-gcm">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Для Reality ключи и shortId генерируются автоматически. После назначения профиля ноде Xray поднимется на агенте.</p>
|
<p class="muted">Только Xray-протоколы (VLESS/VMess/Trojan/SS). Для Reality ключи и shortId генерируются автоматически.</p>
|
||||||
<button class="btn btn-primary" type="submit">Добавить inbound</button>
|
<button class="btn btn-primary" type="submit">Добавить inbound</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user