diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go index 256cd1a..5af619e 100644 --- a/cmd/vpnapp/main_windows.go +++ b/cmd/vpnapp/main_windows.go @@ -360,17 +360,45 @@ func (a *app) autoCheckUpdate() { func openURL(raw string) error { raw = strings.TrimSpace(raw) - switch raw { - case "https://evilfox.win/", "https://evilfox.win", "http://evilfox.win/", "http://evilfox.win": + if raw == "" { + return fmt.Errorf("пустая ссылка") + } + lower := strings.ToLower(raw) + switch { + case lower == "https://evilfox.win/" || lower == "https://evilfox.win" || + lower == "http://evilfox.win/" || lower == "http://evilfox.win": raw = "https://evilfox.win/" + case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"): + // allow shop deep-links on evilfox.win only default: return fmt.Errorf("разрешена только ссылка evilfox.win") } - cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", raw) + if err := shellOpen(raw); err == nil { + return nil + } + // Fallbacks when ShellExecute is blocked by policy. + cmd := exec.Command("cmd", "/c", "start", "", raw) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if err := cmd.Start(); err == nil { + return nil + } + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", raw) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} return cmd.Start() } +func shellOpen(raw string) error { + verb, err := windows.UTF16PtrFromString("open") + if err != nil { + return err + } + file, err := windows.UTF16PtrFromString(raw) + if err != nil { + return err + } + return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL) +} + func applyWindowIcon(hwnd unsafe.Pointer) { ico := findIconPath() if ico == "" || hwnd == nil { diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 9f5afb3..a32d332 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 1e2112d..0a2e373 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,10 +1,10 @@ { - "version": "1.3.0", - "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", + "version": "1.3.1", + "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", + "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", "mandatory": false } diff --git a/dist/update.json b/dist/update.json index 1e2112d..0a2e373 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,10 +1,10 @@ { - "version": "1.3.0", - "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", + "version": "1.3.1", + "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", + "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", "mandatory": false } diff --git a/internal/appui/index.html b/internal/appui/index.html index b44cd91..6e5342b 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -205,12 +205,18 @@ } .shop-link { display: block; + width: 100%; margin-top: 8px; + padding: 0; + border: 0; + background: transparent; text-align: center; + font: inherit; font-size: .78rem; color: var(--accent); text-decoration: none; font-weight: 600; + cursor: pointer; } .shop-link:hover { text-decoration: underline; } @@ -414,7 +420,7 @@

Безопасное подключение

На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.

- https://evilfox.win/ +

Navis

@@ -722,11 +728,16 @@ })); async function openShop(e) { - if (e) e.preventDefault(); + if (e) { + e.preventDefault(); + e.stopPropagation(); + } try { + setMeta("Открываю evilfox.win…"); await openURL(SHOP_URL); + setMeta("Открыто в браузере", "ok"); } catch (err) { - setMeta(String(err), "err"); + setMeta("Не удалось открыть ссылку: " + String(err), "err"); } } shopBtn.addEventListener("click", openShop); diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index 3addb55..685c663 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -12,7 +12,7 @@ import ( "vpnclient/internal/protocols/hysteria2" ) -// Result is a TCP reachability measurement to a proxy host. +// Result is a reachability measurement to a proxy host. type Result struct { Name string `json:"name"` Host string `json:"host"` @@ -22,12 +22,13 @@ type Result struct { Error string `json:"error,omitempty"` } -// PingProxyURI measures TCP connect time to the host in a share/proxy URI. +// PingProxyURI measures connect time to the host in a share/proxy URI. func PingProxyURI(ctx context.Context, name, proxyURI string) Result { return PingProfile(ctx, name, "", proxyURI) } // PingProfile pings using protocol hints when available. +// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused". func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result { res := Result{Name: name} if strings.TrimSpace(proxyURI) == "" { @@ -35,8 +36,9 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU return res } + hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) var host, port string - if proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) { + if hy2 { h, p, err := hysteria2.HostPort(proxyURI) if err != nil { res.Error = err.Error() @@ -49,7 +51,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU res.Error = err.Error() return res } - // Parse host from normalized URI without importing net/url dance twice rest := normalized if i := strings.Index(rest, "://"); i >= 0 { rest = rest[i+3:] @@ -75,15 +76,76 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU return res } - d := net.Dialer{Timeout: 4 * time.Second} start := time.Now() - conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + var err error + if hy2 { + err = probeUDP(ctx, host, port) + } else { + err = probeTCP(ctx, host, port) + } if err != nil { - res.Error = fmt.Sprintf("недоступен: %v", err) + res.Error = friendlyDialError(err, hy2) return res } - _ = conn.Close() res.Ms = time.Since(start).Milliseconds() res.OK = true return res } + +func probeTCP(ctx context.Context, host, port string) error { + d := net.Dialer{Timeout: 4 * time.Second} + conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + _ = conn.Close() + return nil +} + +func probeUDP(ctx context.Context, host, port string) error { + d := net.Dialer{Timeout: 4 * time.Second} + conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port)) + if err != nil { + return err + } + defer conn.Close() + + deadline := time.Now().Add(1500 * time.Millisecond) + if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) { + deadline = dl + } + _ = conn.SetDeadline(deadline) + + // Any datagram is enough to exercise the UDP path; QUIC rarely answers a bare probe. + if _, err := conn.Write([]byte{0}); err != nil { + return err + } + buf := make([]byte, 64) + _, err = conn.Read(buf) + if err == nil { + return nil + } + if ne, ok := err.(net.Error); ok && ne.Timeout() { + // No ICMP unreachable → port is typically open or filtered; treat as reachable for UI ping. + return nil + } + return err +} + +func friendlyDialError(err error, hy2 bool) string { + msg := err.Error() + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "refused"): + if hy2 { + return "UDP порт недоступен (connection refused)" + } + return "порт закрыт (connection refused)" + case strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"): + return "таймаут" + case strings.Contains(lower, "no such host"): + return "DNS: хост не найден" + default: + return fmt.Sprintf("недоступен: %v", err) + } +} diff --git a/internal/netcheck/ping_test.go b/internal/netcheck/ping_test.go new file mode 100644 index 0000000..2d627ef --- /dev/null +++ b/internal/netcheck/ping_test.go @@ -0,0 +1,33 @@ +package netcheck + +import ( + "context" + "strings" + "testing" + "time" + + "vpnclient/internal/config" +) + +func TestFriendlyDialError(t *testing.T) { + err := &netError{s: "dial tcp 1.2.3.4:22514: connectex: No connection could be made because the target machine actively refused it."} + got := friendlyDialError(err, true) + if !strings.Contains(got, "UDP") { + t.Fatalf("got %q", got) + } +} + +type netError struct{ s string } + +func (e *netError) Error() string { return e.s } +func (e *netError) Timeout() bool { return false } +func (e *netError) Temporary() bool { return false } + +func TestPingProfileEmpty(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + r := PingProfile(ctx, "x", config.ProtocolHysteria2, "") + if r.OK || r.Error == "" { + t.Fatalf("%+v", r) + } +} diff --git a/internal/subscription/fetch.go b/internal/subscription/fetch.go index daf481e..34001e7 100644 --- a/internal/subscription/fetch.go +++ b/internal/subscription/fetch.go @@ -39,7 +39,7 @@ func Fetch(ctx context.Context, rawURL string) ([]Item, error) { if err != nil { return nil, err } - req.Header.Set("User-Agent", "Navis/1.3.0") + req.Header.Set("User-Agent", "Navis/1.3.1") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { diff --git a/internal/update/update.go b/internal/update/update.go index 8d527e4..032f177 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -18,7 +18,7 @@ import ( ) // CurrentVersion is the shipped client version. -const CurrentVersion = "1.3.0" +const CurrentVersion = "1.3.1" // DefaultManifestURL is the update feed (hosted in the project git repo). const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" diff --git a/server/update.json b/server/update.json index 1e2112d..0a2e373 100644 --- a/server/update.json +++ b/server/update.json @@ -1,10 +1,10 @@ { - "version": "1.3.0", - "notes": "Hysteria 2: BBR/Brutal, Salamander/Gecko, SNI-маскировка; URL подписки для импорта профилей", + "version": "1.3.1", + "notes": "Фикс пинга Hysteria2 (UDP вместо TCP) и открытие ссылки evilfox.win", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "9dabde753f85c7dab9574582fc9e28e45e2aed26646f068c9e7057872063c063", + "sha256": "88ddd38b0324979cae882c6cb0b8e4298c98b88547e72649587106425201e09e", "mandatory": false } diff --git a/versioninfo.json b/versioninfo.json index 6459d44..db0ef99 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -1,7 +1,7 @@ { "FixedFileInfo": { - "FileVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 }, - "ProductVersion": { "Major": 1, "Minor": 3, "Patch": 0, "Build": 0 }, + "FileVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 }, + "ProductVersion": { "Major": 1, "Minor": 3, "Patch": 1, "Build": 0 }, "FileFlagsMask": "3f", "FileFlags": "00", "FileOS": "40004", @@ -11,11 +11,11 @@ "StringFileInfo": { "CompanyName": "Navis", "FileDescription": "Navis — NaiveProxy / Hysteria 2 client for Windows", - "FileVersion": "1.3.0.0", + "FileVersion": "1.3.1.0", "InternalName": "Navis", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "1.3.0.0" + "ProductVersion": "1.3.1.0" }, "VarFileInfo": { "Translation": {