Release 1.3.0: Hysteria2 BBR/Salamander/masquerade options and subscription URL import.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
ep, err := ParseEndpoint(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var insecureOverride *bool
|
||||
if profile.Hy2Insecure {
|
||||
v := true
|
||||
insecureOverride = &v
|
||||
}
|
||||
ep.ApplyOverrides(profile.Hy2SNI, profile.Hy2Obfs, profile.Hy2ObfsPassword, profile.Hy2PinSHA256, insecureOverride)
|
||||
|
||||
socks := "127.0.0.1:1080"
|
||||
httpListen := "127.0.0.1:1081"
|
||||
if hp, ok := profile.SOCKSListenHostPort(); ok {
|
||||
socks = hp
|
||||
}
|
||||
if hp, ok := profile.HTTPListenHostPort(); ok {
|
||||
httpListen = hp
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# generated by Navis — do not edit\n")
|
||||
|
||||
// Expanded form so congestion/obfs/tls can coexist with auth.
|
||||
b.WriteString("server: " + yamlQuote(ep.Server) + "\n")
|
||||
if ep.Auth != "" {
|
||||
b.WriteString("auth: " + yamlQuote(ep.Auth) + "\n")
|
||||
}
|
||||
|
||||
if ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" {
|
||||
b.WriteString("tls:\n")
|
||||
if ep.SNI != "" {
|
||||
b.WriteString(" sni: " + yamlQuote(ep.SNI) + "\n")
|
||||
}
|
||||
if ep.Insecure {
|
||||
b.WriteString(" insecure: true\n")
|
||||
}
|
||||
if ep.PinSHA256 != "" {
|
||||
b.WriteString(" pinSHA256: " + yamlQuote(ep.PinSHA256) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
obfs := strings.ToLower(strings.TrimSpace(ep.Obfs))
|
||||
if obfs == "" {
|
||||
obfs = strings.ToLower(strings.TrimSpace(profile.Hy2Obfs))
|
||||
}
|
||||
obfsPass := ep.ObfsPassword
|
||||
if obfsPass == "" {
|
||||
obfsPass = profile.Hy2ObfsPassword
|
||||
}
|
||||
switch obfs {
|
||||
case "salamander":
|
||||
if obfsPass == "" {
|
||||
return fmt.Errorf("hysteria2: salamander требует пароль (obfs-password)")
|
||||
}
|
||||
b.WriteString("obfs:\n")
|
||||
b.WriteString(" type: salamander\n")
|
||||
b.WriteString(" salamander:\n")
|
||||
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
|
||||
case "gecko":
|
||||
if obfsPass == "" {
|
||||
return fmt.Errorf("hysteria2: gecko требует пароль (obfs-password)")
|
||||
}
|
||||
b.WriteString("obfs:\n")
|
||||
b.WriteString(" type: gecko\n")
|
||||
b.WriteString(" gecko:\n")
|
||||
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
|
||||
}
|
||||
|
||||
// Congestion: BBR (default) or reno. Bandwidth enables Brutal instead.
|
||||
up := ParseBandwidthMbps(profile.Hy2BandwidthUp)
|
||||
down := ParseBandwidthMbps(profile.Hy2BandwidthDown)
|
||||
cong := strings.ToLower(strings.TrimSpace(profile.Hy2Congestion))
|
||||
if cong == "" {
|
||||
cong = "bbr"
|
||||
}
|
||||
bbrProfile := strings.ToLower(strings.TrimSpace(profile.Hy2BBRProfile))
|
||||
if bbrProfile == "" {
|
||||
bbrProfile = "standard"
|
||||
}
|
||||
|
||||
if up != "" || down != "" {
|
||||
b.WriteString("bandwidth:\n")
|
||||
if up != "" {
|
||||
b.WriteString(" up: " + yamlQuote(up) + "\n")
|
||||
}
|
||||
if down != "" {
|
||||
b.WriteString(" down: " + yamlQuote(down) + "\n")
|
||||
}
|
||||
} else {
|
||||
// No Brutal → use BBR/reno
|
||||
b.WriteString("congestion:\n")
|
||||
b.WriteString(" type: " + yamlQuote(cong) + "\n")
|
||||
if cong == "bbr" {
|
||||
b.WriteString(" bbrProfile: " + yamlQuote(bbrProfile) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if hop := strings.TrimSpace(profile.Hy2HopInterval); hop != "" {
|
||||
b.WriteString("transport:\n")
|
||||
b.WriteString(" type: udp\n")
|
||||
b.WriteString(" udp:\n")
|
||||
b.WriteString(" hopInterval: " + yamlQuote(hop) + "\n")
|
||||
}
|
||||
|
||||
if profile.Hy2FastOpen {
|
||||
b.WriteString("fastOpen: true\n")
|
||||
}
|
||||
if profile.Hy2Lazy {
|
||||
b.WriteString("lazy: true\n")
|
||||
}
|
||||
|
||||
// "Masquerade extras" on client = look like normal HTTPS via SNI already above;
|
||||
// optional ALPN hint kept for compatibility with some panels.
|
||||
if alpn := strings.TrimSpace(profile.Hy2ALPN); alpn != "" {
|
||||
// Official client uses TLS defaults; keep as comment for operators.
|
||||
b.WriteString("# alpn-hint: " + yamlQuote(alpn) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("socks5:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
|
||||
b.WriteString("http:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
|
||||
|
||||
return writeFile(path, b.String())
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package hysteria2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Endpoint is a parsed Hysteria 2 share link plus optional overrides.
|
||||
type Endpoint struct {
|
||||
Server string // host:port or multi-port host
|
||||
Auth string
|
||||
SNI string
|
||||
Insecure bool
|
||||
PinSHA256 string
|
||||
Obfs string // salamander | gecko
|
||||
ObfsPassword string
|
||||
Remark string
|
||||
RawURI string
|
||||
}
|
||||
|
||||
// ParseEndpoint parses hysteria2:// / hy2:// into discrete fields.
|
||||
func ParseEndpoint(raw string) (Endpoint, error) {
|
||||
normalized, remark, err := NormalizeShareLink(raw)
|
||||
if err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
u, err := url.Parse(normalized)
|
||||
if err != nil {
|
||||
return Endpoint{}, err
|
||||
}
|
||||
ep := Endpoint{Remark: remark, RawURI: normalized}
|
||||
if u.User != nil {
|
||||
user := u.User.Username()
|
||||
if pass, ok := u.User.Password(); ok {
|
||||
ep.Auth = user + ":" + pass
|
||||
} else {
|
||||
ep.Auth, _ = url.QueryUnescape(user)
|
||||
}
|
||||
}
|
||||
ep.Server = u.Host
|
||||
if ep.Server == "" {
|
||||
return Endpoint{}, fmt.Errorf("hysteria2 URI missing host")
|
||||
}
|
||||
q := u.Query()
|
||||
ep.SNI = first(q, "sni", "peer")
|
||||
ep.PinSHA256 = first(q, "pinSHA256", "pinsha256")
|
||||
ep.Obfs = first(q, "obfs")
|
||||
ep.ObfsPassword = first(q, "obfs-password", "obfs_password")
|
||||
if v := first(q, "insecure"); v == "1" || strings.EqualFold(v, "true") {
|
||||
ep.Insecure = true
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
|
||||
func first(q url.Values, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := strings.TrimSpace(q.Get(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ApplyOverrides merges non-empty profile overrides into the endpoint.
|
||||
func (ep *Endpoint) ApplyOverrides(sni, obfs, obfsPass, pin string, insecure *bool) {
|
||||
if sni != "" {
|
||||
ep.SNI = sni
|
||||
}
|
||||
if obfs != "" {
|
||||
ep.Obfs = obfs
|
||||
}
|
||||
if obfsPass != "" {
|
||||
ep.ObfsPassword = obfsPass
|
||||
}
|
||||
if pin != "" {
|
||||
ep.PinSHA256 = pin
|
||||
}
|
||||
if insecure != nil {
|
||||
ep.Insecure = *insecure
|
||||
}
|
||||
}
|
||||
|
||||
// BoolPtr helper for optional bool overrides.
|
||||
func BoolPtr(v bool) *bool { return &v }
|
||||
|
||||
// HasTLSExtras reports whether discrete TLS/obfs fields should be written.
|
||||
func (ep Endpoint) HasTLSExtras() bool {
|
||||
return ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" || ep.Obfs != ""
|
||||
}
|
||||
|
||||
// PortHint returns a single port for ping.
|
||||
func (ep Endpoint) PortHint() string {
|
||||
_, port, err := HostPort(ep.RawURI)
|
||||
if err != nil || port == "" {
|
||||
return "443"
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
// ParseBandwidthMbps tries to normalize user input like "100" or "100 mbps".
|
||||
func ParseBandwidthMbps(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" || raw == "0" || raw == "auto" || raw == "bbr" {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(raw, "bkmgt") {
|
||||
return raw
|
||||
}
|
||||
if _, err := strconv.Atoi(raw); err == nil {
|
||||
return raw + " mbps"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -180,32 +180,7 @@ func (e *Engine) LocalSOCKSProxy() (string, bool) {
|
||||
return e.profile.SOCKSListenHostPort()
|
||||
}
|
||||
|
||||
func writeRuntimeConfig(path string, profile config.Profile) error {
|
||||
server, _, err := NormalizeShareLink(profile.Proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socks := "127.0.0.1:1080"
|
||||
httpListen := "127.0.0.1:1081"
|
||||
if hp, ok := profile.SOCKSListenHostPort(); ok {
|
||||
socks = hp
|
||||
}
|
||||
if hp, ok := profile.HTTPListenHostPort(); ok {
|
||||
httpListen = hp
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# generated by Navis — do not edit\n")
|
||||
b.WriteString("server: " + yamlQuote(server) + "\n")
|
||||
b.WriteString("socks5:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
|
||||
b.WriteString("http:\n")
|
||||
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
|
||||
return os.WriteFile(path, []byte(b.String()), 0o600)
|
||||
}
|
||||
|
||||
func yamlQuote(s string) string {
|
||||
// Always quote — URIs and host:port can confuse YAML.
|
||||
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
return `"` + escaped + `"`
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package hysteria2
|
||||
|
||||
import "vpnclient/internal/config"
|
||||
|
||||
// EnrichProfile fills empty hy2 option fields from the share URI.
|
||||
func EnrichProfile(p *config.Profile) {
|
||||
if p == nil || p.Protocol != config.ProtocolHysteria2 || p.Proxy == "" {
|
||||
return
|
||||
}
|
||||
ep, err := ParseEndpoint(p.Proxy)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if p.Hy2SNI == "" {
|
||||
p.Hy2SNI = ep.SNI
|
||||
}
|
||||
if p.Hy2Obfs == "" {
|
||||
p.Hy2Obfs = ep.Obfs
|
||||
}
|
||||
if p.Hy2ObfsPassword == "" {
|
||||
p.Hy2ObfsPassword = ep.ObfsPassword
|
||||
}
|
||||
if p.Hy2PinSHA256 == "" {
|
||||
p.Hy2PinSHA256 = ep.PinSHA256
|
||||
}
|
||||
if ep.Insecure {
|
||||
p.Hy2Insecure = true
|
||||
}
|
||||
if p.Hy2Congestion == "" {
|
||||
p.Hy2Congestion = "bbr"
|
||||
}
|
||||
if p.Hy2BBRProfile == "" {
|
||||
p.Hy2BBRProfile = "standard"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user