diff options
| author | SergeiEU <39683682+SergeiEU@users.noreply.github.com> | 2026-04-01 10:17:15 +0400 |
|---|---|---|
| committer | SergeiEU <39683682+SergeiEU@users.noreply.github.com> | 2026-04-01 10:17:15 +0400 |
| commit | 1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch) | |
| tree | d8c85273ede547e03a5727bf185f5d07e87b4a08 /internal/sync | |
| download | vpnem-main.tar.gz vpnem-main.tar.bz2 vpnem-main.zip | |
Diffstat (limited to 'internal/sync')
| -rw-r--r-- | internal/sync/fetcher.go | 82 | ||||
| -rw-r--r-- | internal/sync/health.go | 33 | ||||
| -rw-r--r-- | internal/sync/latency.go | 62 | ||||
| -rw-r--r-- | internal/sync/updater.go | 159 |
4 files changed, 336 insertions, 0 deletions
diff --git a/internal/sync/fetcher.go b/internal/sync/fetcher.go new file mode 100644 index 0000000..74c2bd6 --- /dev/null +++ b/internal/sync/fetcher.go @@ -0,0 +1,82 @@ +package sync + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "vpnem/internal/models" +) + +// Fetcher pulls configuration from the vpnem server API. +type Fetcher struct { + baseURL string + client *http.Client +} + +// NewFetcher creates a new Fetcher. +func NewFetcher(baseURL string) *Fetcher { + return &Fetcher{ + baseURL: baseURL, + client: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +// FetchServers retrieves the server list from the API. +func (f *Fetcher) FetchServers() (*models.ServersResponse, error) { + var resp models.ServersResponse + if err := f.getJSON("/api/v1/servers", &resp); err != nil { + return nil, fmt.Errorf("fetch servers: %w", err) + } + return &resp, nil +} + +// FetchRuleSets retrieves the rule-set manifest from the API. +func (f *Fetcher) FetchRuleSets() (*models.RuleSetManifest, error) { + var resp models.RuleSetManifest + if err := f.getJSON("/api/v1/ruleset/manifest", &resp); err != nil { + return nil, fmt.Errorf("fetch rulesets: %w", err) + } + return &resp, nil +} + +// FetchVersion retrieves the latest client version info. +func (f *Fetcher) FetchVersion() (*models.VersionResponse, error) { + var resp models.VersionResponse + if err := f.getJSON("/api/v1/version", &resp); err != nil { + return nil, fmt.Errorf("fetch version: %w", err) + } + return &resp, nil +} + +// ServerIPs extracts all unique server IPs from the server list. +func ServerIPs(servers []models.Server) []string { + seen := make(map[string]bool) + var ips []string + for _, s := range servers { + if !seen[s.Server] { + seen[s.Server] = true + ips = append(ips, s.Server) + } + } + return ips +} + +func (f *Fetcher) getJSON(path string, v any) error { + resp, err := f.client.Get(f.baseURL + path) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/internal/sync/health.go b/internal/sync/health.go new file mode 100644 index 0000000..4d6ceca --- /dev/null +++ b/internal/sync/health.go @@ -0,0 +1,33 @@ +package sync + +import ( + "fmt" + "net" + "time" + + "vpnem/internal/models" +) + +// HealthCheck tests if a server's proxy port is reachable. +func HealthCheck(server models.Server, timeout time.Duration) error { + addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return fmt.Errorf("server %s unreachable: %w", server.Tag, err) + } + conn.Close() + return nil +} + +// FindHealthyServer returns the first healthy non-RU server from the list. +func FindHealthyServer(servers []models.Server, timeout time.Duration) *models.Server { + for _, s := range servers { + if s.Region == "RU" { + continue + } + if err := HealthCheck(s, timeout); err == nil { + return &s + } + } + return nil +} diff --git a/internal/sync/latency.go b/internal/sync/latency.go new file mode 100644 index 0000000..dd3268b --- /dev/null +++ b/internal/sync/latency.go @@ -0,0 +1,62 @@ +package sync + +import ( + "fmt" + "net" + "sort" + "sync" + "time" + + "vpnem/internal/models" +) + +// LatencyResult holds a server's latency measurement. +type LatencyResult struct { + Tag string `json:"tag"` + Region string `json:"region"` + Latency int `json:"latency_ms"` // -1 means unreachable +} + +// MeasureLatency pings all servers concurrently and returns results sorted by latency. +func MeasureLatency(servers []models.Server, timeout time.Duration) []LatencyResult { + var wg sync.WaitGroup + results := make([]LatencyResult, len(servers)) + + for i, s := range servers { + wg.Add(1) + go func(idx int, srv models.Server) { + defer wg.Done() + ms := tcpPing(srv.Server, srv.ServerPort, timeout) + results[idx] = LatencyResult{ + Tag: srv.Tag, + Region: srv.Region, + Latency: ms, + } + }(i, s) + } + + wg.Wait() + + sort.Slice(results, func(i, j int) bool { + if results[i].Latency == -1 { + return false + } + if results[j].Latency == -1 { + return true + } + return results[i].Latency < results[j].Latency + }) + + return results +} + +func tcpPing(host string, port int, timeout time.Duration) int { + addr := fmt.Sprintf("%s:%d", host, port) + start := time.Now() + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return -1 + } + conn.Close() + return int(time.Since(start).Milliseconds()) +} diff --git a/internal/sync/updater.go b/internal/sync/updater.go new file mode 100644 index 0000000..23cbd19 --- /dev/null +++ b/internal/sync/updater.go @@ -0,0 +1,159 @@ +package sync + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// Updater checks for and downloads client updates. +type Updater struct { + fetcher *Fetcher + currentVer string + dataDir string +} + +// NewUpdater creates an updater. +func NewUpdater(fetcher *Fetcher, currentVersion, dataDir string) *Updater { + return &Updater{ + fetcher: fetcher, + currentVer: currentVersion, + dataDir: dataDir, + } +} + +// UpdateInfo describes an available update. +type UpdateInfo struct { + Available bool `json:"available"` + Version string `json:"version"` + Changelog string `json:"changelog"` + CurrentVer string `json:"current_version"` +} + +// Check returns info about available updates. +func (u *Updater) Check() (*UpdateInfo, error) { + ver, err := u.fetcher.FetchVersion() + if err != nil { + return nil, fmt.Errorf("check update: %w", err) + } + + return &UpdateInfo{ + Available: ver.Version != u.currentVer, + Version: ver.Version, + Changelog: ver.Changelog, + CurrentVer: u.currentVer, + }, nil +} + +// Download fetches the new binary, cleans stale configs, replaces current binary and restarts. +func (u *Updater) Download() (string, error) { + ver, err := u.fetcher.FetchVersion() + if err != nil { + return "", fmt.Errorf("fetch version: %w", err) + } + + if ver.URL == "" { + suffix := "linux-amd64" + if runtime.GOOS == "windows" { + suffix = "windows-amd64.exe" + } + ver.URL = u.fetcher.baseURL + "/releases/vpnem-" + suffix + } + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(ver.URL) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download: HTTP %d", resp.StatusCode) + } + + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + + newBin := filepath.Join(u.dataDir, "vpnem-update"+ext) + f, err := os.Create(newBin) + if err != nil { + return "", fmt.Errorf("create file: %w", err) + } + + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(newBin) + return "", fmt.Errorf("write update: %w", err) + } + f.Close() + + if runtime.GOOS != "windows" { + os.Chmod(newBin, 0o755) + } + + // Clean stale configs so new version starts fresh + os.Remove(filepath.Join(u.dataDir, "state.json")) + os.Remove(filepath.Join(u.dataDir, "config.json")) + os.Remove(filepath.Join(u.dataDir, "cache.db")) + + // Replace current binary and restart + currentBin, _ := os.Executable() + if currentBin != "" { + if runtime.GOOS == "windows" { + // Windows can't overwrite running exe — rename old, copy new, restart + oldBin := currentBin + ".old" + os.Remove(oldBin) + os.Rename(currentBin, oldBin) + copyFile(newBin, currentBin) + // Restart: launch new binary and exit current + cmd := exec.Command(currentBin) + cmd.Dir = u.dataDir + cmd.Start() + os.Exit(0) + } else { + // Linux: overwrite in place, then re-exec + copyFile(newBin, currentBin) + os.Remove(newBin) + cmd := exec.Command(currentBin) + cmd.Dir = u.dataDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Start() + os.Exit(0) + } + } + + return newBin, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + if err != nil { + return err + } + + // Preserve executable permission on Linux + if runtime.GOOS != "windows" { + os.Chmod(dst, 0o755) + } + return nil +} |
