diff --git a/internal/db/clients.go b/internal/db/clients.go index e879356..5956b3a 100644 --- a/internal/db/clients.go +++ b/internal/db/clients.go @@ -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) diff --git a/internal/handlers/profiles.go b/internal/handlers/profiles.go index 3c49faf..b6b0033 100644 --- a/internal/handlers/profiles.go +++ b/internal/handlers/profiles.go @@ -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) diff --git a/web/templates/profile_view.html b/web/templates/profile_view.html index 2c4a346..0d12c33 100644 --- a/web/templates/profile_view.html +++ b/web/templates/profile_view.html @@ -105,7 +105,7 @@ @@ -113,14 +113,14 @@ -

Для Reality ключи и shortId генерируются автоматически. После назначения профиля ноде Xray поднимется на агенте.

+

Только Xray-протоколы (VLESS/VMess/Trojan/SS). Для Reality ключи и shortId генерируются автоматически.