summaryrefslogtreecommitdiff
path: root/internal/sync
diff options
context:
space:
mode:
authorSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
committerSergeiEU <39683682+SergeiEU@users.noreply.github.com>2026-04-01 10:17:15 +0400
commit1bd203c5555046b7ee4fbfe2f822eb3d03571ad7 (patch)
treed8c85273ede547e03a5727bf185f5d07e87b4a08 /internal/sync
downloadvpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.gz
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.tar.bz2
vpnem-1bd203c5555046b7ee4fbfe2f822eb3d03571ad7.zip
Initial importHEADmain
Diffstat (limited to 'internal/sync')
-rw-r--r--internal/sync/fetcher.go82
-rw-r--r--internal/sync/health.go33
-rw-r--r--internal/sync/latency.go62
-rw-r--r--internal/sync/updater.go159
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
+}