arblant
firefox-vpn-client / core / internal / routing / update.go
update.go182 lines · 5.9 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
package routing

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/securefile"
)

// Default list sources, both from Chocolate4U/Iran-v2ray-rules; together
// they cover every region (RegionRules). The domain list has tags such as
// ir, category-ir, cn, geolocation-cn, category-ru, tld-ru, and private;
// the network list has ir, cn, ru, af, tr, id, br, and private.
const (
	DefaultGeoSiteURL = "https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat"
	DefaultGeoIPURL   = "https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geoip.dat"
)

// Source is a set of the two lists (the ones v2rayNG offers).
type Source struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	GeoSiteURL string `json:"geosite_url"`
	GeoIPURL   string `json:"geoip_url"`
}

// Sources are the list sets the program offers; both cover every country.
// Checked on 2026-10-15: both network lists have all countries (260 and
// 278 tags); only Chocolate4U's domain list has ir (Loyalsoldier's has
// category-ir), and both have cn, geolocation-cn, category-ru, and tld-ru.
// runetfreedom/russia-v2ray-rules-dat is not offered: 88% of its 74 MB
// domain list are two Russian blocklists that no preset uses.
var Sources = []Source{
	{"chocolate4u", "Chocolate4U", DefaultGeoSiteURL, DefaultGeoIPURL},
	{"loyalsoldier", "Loyalsoldier",
		"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat",
		"https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat"},
}

// RegionSource is the usual list set of region: Loyalsoldier's for China,
// Chocolate4U's otherwise (Iran's rules need its geosite:ir).
func RegionSource(region string) Source {
	id := "chocolate4u"
	if region == RegionChina {
		id = "loyalsoldier"
	}
	for _, s := range Sources {
		if s.ID == id {
			return s
		}
	}
	return Sources[0]
}

// SourceOf finds the offered list set with these URLs.
func SourceOf(geositeURL, geoipURL string) (Source, bool) {
	for _, s := range Sources {
		if s.GeoSiteURL == geositeURL && s.GeoIPURL == geoipURL {
			return s, true
		}
	}
	return Source{}, false
}

// sourceFile records where the two lists in the folder came from, so that
// other URLs download them again although they are not old yet.
const sourceFile = "lists-source.txt"

// SourceChanged reports whether the lists in dir came from other URLs. A
// folder from before the record holds the default lists.
func SourceChanged(dir, geositeURL, geoipURL string) bool {
	data, err := os.ReadFile(filepath.Join(dir, sourceFile))
	if err != nil {
		return geositeURL != DefaultGeoSiteURL || geoipURL != DefaultGeoIPURL
	}
	return strings.TrimSpace(string(data)) != geositeURL+"\n"+geoipURL
}

// maxListSize bounds a downloaded list (the offered ones are 8 to 17 MB).
const maxListSize = 64 << 20

// FileInfo describes a list file in the routing folder.
type FileInfo struct {
	Name      string    `json:"name"`
	Size      int64     `json:"size"`
	UpdatedAt time.Time `json:"updated_at"`
}

// Files describes the list files present in dir, the extra ones too.
func Files(dir string, extras []ExtraFile) []FileInfo {
	var out []FileInfo
	names := []string{GeoSiteFile, GeoIPFile}
	for _, e := range extras {
		names = append(names, e.Name)
	}
	for _, name := range names {
		if info, err := os.Stat(filepath.Join(dir, name)); err == nil {
			out = append(out, FileInfo{Name: name, Size: info.Size(), UpdatedAt: info.ModTime()})
		}
	}
	return out
}

// Due reports whether a list in dir, an extra one too, is missing or older
// than maxAge.
func Due(dir string, maxAge time.Duration, extras []ExtraFile) bool {
	names := []string{GeoSiteFile, GeoIPFile}
	for _, e := range extras {
		names = append(names, e.Name)
	}
	for _, name := range names {
		info, err := os.Stat(filepath.Join(dir, name))
		if err != nil || time.Since(info.ModTime()) >= maxAge {
			return true
		}
	}
	return false
}

// Download fetches both lists with client and replaces the files in dir;
// a file is replaced only when its download is complete and readable as a
// geo list, so a failed update keeps the old one.
func Download(ctx context.Context, client *http.Client, dir, geositeURL, geoipURL string, extras []ExtraFile) error {
	if err := securefile.EnsurePrivateDir(dir); err != nil {
		return err
	}
	var errs []error
	files := []struct{ name, url string }{{GeoSiteFile, geositeURL}, {GeoIPFile, geoipURL}}
	for _, e := range extras {
		files = append(files, struct{ name, url string }{e.Name, e.URL})
	}
	mainFailed := false
	for i, f := range files {
		if err := downloadOne(ctx, client, filepath.Join(dir, f.name), f.url); err != nil {
			errs = append(errs, fmt.Errorf("%s: %w", f.name, err))
			mainFailed = mainFailed || i < 2
		}
	}
	// The record says where both lists came from; without it, or with
	// other URLs, the next check downloads them again.
	record := filepath.Join(dir, sourceFile)
	if mainFailed {
		_ = os.Remove(record)
	} else {
		_ = securefile.WritePrivate(record, []byte(geositeURL+"\n"+geoipURL+"\n"))
	}
	return errors.Join(errs...)
}

func downloadOne(ctx context.Context, client *http.Client, path, url string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
	}
	data, err := io.ReadAll(io.LimitReader(resp.Body, maxListSize+1))
	if err != nil {
		return err
	}
	if len(data) > maxListSize {
		return fmt.Errorf("larger than %d MiB", maxListSize>>20)
	}
	var tags int
	if err := entries(data, func(string) bool { return true }, func(string, []byte) error { tags++; return nil }); err != nil || tags == 0 {
		return fmt.Errorf("not a v2ray geo list (%d tags, %v)", tags, err)
	}
	return securefile.WritePrivate(path, data)
}