2026-04-19 19:24:24 +00:00
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-19 21:55:48 +00:00
|
|
|
"context"
|
2026-04-19 19:24:24 +00:00
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-04-19 21:55:48 +00:00
|
|
|
"net"
|
2026-04-19 19:24:24 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/config"
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database"
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
customGeoTypeGeosite = "geosite"
|
|
|
|
|
customGeoTypeGeoip = "geoip"
|
|
|
|
|
minDatBytes = 64
|
|
|
|
|
customGeoProbeTimeout = 12 * time.Second
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
|
|
|
|
reservedCustomAliases = map[string]struct{}{
|
|
|
|
|
"geoip": {}, "geosite": {},
|
|
|
|
|
"geoip_ir": {}, "geosite_ir": {},
|
|
|
|
|
"geoip_ru": {}, "geosite_ru": {},
|
|
|
|
|
}
|
|
|
|
|
ErrCustomGeoInvalidType = errors.New("custom_geo_invalid_type")
|
|
|
|
|
ErrCustomGeoAliasRequired = errors.New("custom_geo_alias_required")
|
|
|
|
|
ErrCustomGeoAliasPattern = errors.New("custom_geo_alias_pattern")
|
|
|
|
|
ErrCustomGeoAliasReserved = errors.New("custom_geo_alias_reserved")
|
|
|
|
|
ErrCustomGeoURLRequired = errors.New("custom_geo_url_required")
|
|
|
|
|
ErrCustomGeoInvalidURL = errors.New("custom_geo_invalid_url")
|
|
|
|
|
ErrCustomGeoURLScheme = errors.New("custom_geo_url_scheme")
|
|
|
|
|
ErrCustomGeoURLHost = errors.New("custom_geo_url_host")
|
|
|
|
|
ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
|
|
|
|
|
ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
|
|
|
|
|
ErrCustomGeoDownload = errors.New("custom_geo_download")
|
2026-04-19 21:55:48 +00:00
|
|
|
ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked")
|
|
|
|
|
ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal")
|
2026-04-19 19:24:24 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type CustomGeoUpdateAllItem struct {
|
|
|
|
|
Id int `json:"id"`
|
|
|
|
|
Alias string `json:"alias"`
|
|
|
|
|
FileName string `json:"fileName"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomGeoUpdateAllFailure struct {
|
|
|
|
|
Id int `json:"id"`
|
|
|
|
|
Alias string `json:"alias"`
|
|
|
|
|
FileName string `json:"fileName"`
|
|
|
|
|
Err string `json:"error"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomGeoUpdateAllResult struct {
|
|
|
|
|
Succeeded []CustomGeoUpdateAllItem `json:"succeeded"`
|
|
|
|
|
Failed []CustomGeoUpdateAllFailure `json:"failed"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomGeoService struct {
|
|
|
|
|
serverService *ServerService
|
|
|
|
|
updateAllGetAll func() ([]model.CustomGeoResource, error)
|
|
|
|
|
updateAllApply func(id int, onStartup bool) (string, error)
|
|
|
|
|
updateAllRestart func() error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewCustomGeoService() *CustomGeoService {
|
|
|
|
|
s := &CustomGeoService{
|
|
|
|
|
serverService: &ServerService{},
|
|
|
|
|
}
|
|
|
|
|
s.updateAllGetAll = s.GetAll
|
|
|
|
|
s.updateAllApply = s.applyDownloadAndPersist
|
|
|
|
|
s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NormalizeAliasKey(alias string) string {
|
|
|
|
|
return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) fileNameFor(typ, alias string) string {
|
|
|
|
|
if typ == customGeoTypeGeoip {
|
|
|
|
|
return fmt.Sprintf("geoip_%s.dat", alias)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("geosite_%s.dat", alias)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) validateType(typ string) error {
|
|
|
|
|
if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
|
|
|
|
|
return ErrCustomGeoInvalidType
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) validateAlias(alias string) error {
|
|
|
|
|
if alias == "" {
|
|
|
|
|
return ErrCustomGeoAliasRequired
|
|
|
|
|
}
|
|
|
|
|
if !customGeoAliasPattern.MatchString(alias) {
|
|
|
|
|
return ErrCustomGeoAliasPattern
|
|
|
|
|
}
|
|
|
|
|
if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
|
|
|
|
|
return ErrCustomGeoAliasReserved
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
|
2026-04-19 19:24:24 +00:00
|
|
|
if raw == "" {
|
2026-04-19 21:55:48 +00:00
|
|
|
return "", ErrCustomGeoURLRequired
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
u, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
2026-04-19 21:55:48 +00:00
|
|
|
return "", ErrCustomGeoInvalidURL
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
2026-04-19 21:55:48 +00:00
|
|
|
return "", ErrCustomGeoURLScheme
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
if u.Host == "" {
|
2026-04-19 21:55:48 +00:00
|
|
|
return "", ErrCustomGeoURLHost
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
// Reconstruct URL from parsed components to break taint propagation.
|
|
|
|
|
clean := &url.URL{
|
|
|
|
|
Scheme: u.Scheme,
|
|
|
|
|
Host: u.Host,
|
|
|
|
|
Path: u.Path,
|
|
|
|
|
RawPath: u.RawPath,
|
|
|
|
|
RawQuery: u.RawQuery,
|
|
|
|
|
Fragment: u.Fragment,
|
|
|
|
|
}
|
|
|
|
|
return clean.String(), nil
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func localDatFileNeedsRepair(path string) bool {
|
2026-04-19 21:55:48 +00:00
|
|
|
safePath, err := sanitizeDestPath(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
fi, err := os.Stat(safePath)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if fi.IsDir() {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return fi.Size() < int64(minDatBytes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CustomGeoLocalFileNeedsRepair(path string) bool {
|
|
|
|
|
return localDatFileNeedsRepair(path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
func isBlockedIP(ip net.IP) bool {
|
|
|
|
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
|
|
|
|
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
|
|
|
|
|
// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
|
|
|
|
|
func checkSSRFDefault(ctx context.Context, hostname string) error {
|
|
|
|
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
|
|
|
|
|
}
|
|
|
|
|
for _, ipAddr := range ips {
|
|
|
|
|
if isBlockedIP(ipAddr.IP) {
|
|
|
|
|
return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
|
|
|
|
|
var checkSSRF = checkSSRFDefault
|
|
|
|
|
|
|
|
|
|
func ssrfSafeTransport() http.RoundTripper {
|
|
|
|
|
base, ok := http.DefaultTransport.(*http.Transport)
|
|
|
|
|
if !ok {
|
|
|
|
|
base = &http.Transport{}
|
|
|
|
|
}
|
|
|
|
|
cloned := base.Clone()
|
|
|
|
|
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
|
|
|
host, _, err := net.SplitHostPort(addr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
|
|
|
|
|
}
|
|
|
|
|
if err := checkSSRF(ctx, host); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var dialer net.Dialer
|
|
|
|
|
return dialer.DialContext(ctx, network, addr)
|
|
|
|
|
}
|
|
|
|
|
return cloned
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:24:24 +00:00
|
|
|
func probeCustomGeoURLWithGET(rawURL string) error {
|
2026-04-19 21:55:48 +00:00
|
|
|
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
|
|
|
|
req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Range", "bytes=0-0")
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
|
|
|
|
|
switch resp.StatusCode {
|
|
|
|
|
case http.StatusOK, http.StatusPartialContent:
|
|
|
|
|
return nil
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("get range status %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func probeCustomGeoURL(rawURL string) error {
|
2026-04-19 21:55:48 +00:00
|
|
|
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
|
|
|
|
req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
_ = resp.Body.Close()
|
|
|
|
|
sc := resp.StatusCode
|
|
|
|
|
if sc >= 200 && sc < 300 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
|
|
|
|
|
return probeCustomGeoURLWithGET(rawURL)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("head status %d", sc)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) EnsureOnStartup() {
|
|
|
|
|
list, err := s.GetAll()
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warning("custom geo startup: load list:", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n := len(list)
|
|
|
|
|
if n == 0 {
|
|
|
|
|
logger.Info("custom geo startup: no custom geofiles configured")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
|
|
|
|
|
for i := range list {
|
|
|
|
|
r := &list[i]
|
2026-04-19 21:55:48 +00:00
|
|
|
sanitizedURL, err := s.sanitizeURL(r.Url)
|
|
|
|
|
if err != nil {
|
2026-04-19 19:24:24 +00:00
|
|
|
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
r.Url = sanitizedURL
|
2026-04-19 19:24:24 +00:00
|
|
|
s.syncLocalPath(r)
|
|
|
|
|
localPath := r.LocalPath
|
|
|
|
|
if !localDatFileNeedsRepair(localPath) {
|
|
|
|
|
logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
|
|
|
|
|
if err := probeCustomGeoURL(r.Url); err != nil {
|
|
|
|
|
logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
|
|
|
|
|
}
|
|
|
|
|
_, _ = s.applyDownloadAndPersist(r.Id, true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
|
2026-04-19 21:55:48 +00:00
|
|
|
safeDestPath, err := sanitizeDestPath(destPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return false, "", err
|
|
|
|
|
}
|
|
|
|
|
if skipped {
|
2026-04-19 21:55:48 +00:00
|
|
|
if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
2026-04-19 19:24:24 +00:00
|
|
|
return true, lm, nil
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
return false, lm, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
|
|
|
|
|
// It resolves symlinks to prevent symlink-based escapes.
|
|
|
|
|
// Returns the cleaned absolute path that is safe to use in file operations.
|
|
|
|
|
func sanitizeDestPath(destPath string) (string, error) {
|
|
|
|
|
baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
|
|
|
|
}
|
|
|
|
|
// Resolve symlinks in base directory to get the real path.
|
|
|
|
|
if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
|
|
|
|
|
baseDirAbs = resolved
|
|
|
|
|
}
|
|
|
|
|
destPathAbs, err := filepath.Abs(destPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
|
|
|
|
}
|
|
|
|
|
// Resolve symlinks for the parent directory of the destination path.
|
|
|
|
|
destDir := filepath.Dir(destPathAbs)
|
|
|
|
|
if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
|
|
|
|
|
destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
|
|
|
|
|
}
|
|
|
|
|
// Verify the resolved path is within the safe base directory using prefix check.
|
|
|
|
|
safeDirPrefix := baseDirAbs + string(filepath.Separator)
|
|
|
|
|
if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
|
|
|
|
|
return "", ErrCustomGeoPathTraversal
|
|
|
|
|
}
|
|
|
|
|
return destPathAbs, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:24:24 +00:00
|
|
|
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
|
2026-04-19 21:55:48 +00:00
|
|
|
safeDestPath, err := sanitizeDestPath(destPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
sanitizedURL, err := s.sanitizeURL(resourceURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:24:24 +00:00
|
|
|
var req *http.Request
|
2026-04-19 21:55:48 +00:00
|
|
|
req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !forceFull {
|
2026-04-19 21:55:48 +00:00
|
|
|
if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
2026-04-19 19:24:24 +00:00
|
|
|
if !fi.ModTime().IsZero() {
|
|
|
|
|
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
|
|
|
|
|
} else if lastModifiedHeader != "" {
|
|
|
|
|
if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
|
|
|
|
|
req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
|
|
|
|
|
// lgtm[go/request-forgery]
|
2026-04-19 19:24:24 +00:00
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
var serverModTime time.Time
|
|
|
|
|
if lm := resp.Header.Get("Last-Modified"); lm != "" {
|
|
|
|
|
if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
|
|
|
|
|
serverModTime = parsed
|
|
|
|
|
newLastModified = lm
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateModTime := func() {
|
|
|
|
|
if !serverModTime.IsZero() {
|
2026-04-19 21:55:48 +00:00
|
|
|
_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode == http.StatusNotModified {
|
|
|
|
|
if forceFull {
|
|
|
|
|
return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
|
|
|
|
|
}
|
|
|
|
|
updateModTime()
|
|
|
|
|
return true, newLastModified, nil
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
binDir := filepath.Dir(safeDestPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err = os.MkdirAll(binDir, 0o755); err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
out, err := os.Create(safeTmpPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
n, err := io.Copy(out, resp.Body)
|
|
|
|
|
closeErr := out.Close()
|
|
|
|
|
if err != nil {
|
2026-04-19 21:55:48 +00:00
|
|
|
_ = os.Remove(safeTmpPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
if closeErr != nil {
|
2026-04-19 21:55:48 +00:00
|
|
|
_ = os.Remove(safeTmpPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
|
|
|
|
|
}
|
|
|
|
|
if n < minDatBytes {
|
2026-04-19 21:55:48 +00:00
|
|
|
_ = os.Remove(safeTmpPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
|
|
|
|
|
_ = os.Remove(safeTmpPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateModTime()
|
|
|
|
|
if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
|
|
|
|
|
newLastModified = resp.Header.Get("Last-Modified")
|
|
|
|
|
}
|
|
|
|
|
return false, newLastModified, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
|
|
|
|
|
if r.LocalPath != "" {
|
|
|
|
|
return r.LocalPath
|
|
|
|
|
}
|
|
|
|
|
return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
|
|
|
|
|
p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
|
|
|
|
r.LocalPath = p
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 21:55:48 +00:00
|
|
|
func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
|
|
|
|
|
s.syncLocalPath(r)
|
|
|
|
|
safePath, err := sanitizeDestPath(r.LocalPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
r.LocalPath = safePath
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func removeSafePathIfExists(path string) error {
|
|
|
|
|
safePath, err := sanitizeDestPath(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(safePath); err == nil {
|
|
|
|
|
if err := os.Remove(safePath); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 19:24:24 +00:00
|
|
|
func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
|
|
|
|
|
if err := s.validateType(r.Type); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := s.validateAlias(r.Alias); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
sanitizedURL, err := s.sanitizeURL(r.Url)
|
|
|
|
|
if err != nil {
|
2026-04-19 19:24:24 +00:00
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
r.Url = sanitizedURL
|
2026-04-19 19:24:24 +00:00
|
|
|
var existing int64
|
|
|
|
|
database.GetDB().Model(&model.CustomGeoResource{}).
|
|
|
|
|
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
|
|
|
|
|
if existing > 0 {
|
|
|
|
|
return ErrCustomGeoDuplicateAlias
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 19:24:24 +00:00
|
|
|
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
now := time.Now().Unix()
|
|
|
|
|
r.LastUpdatedAt = now
|
|
|
|
|
r.LastModified = lm
|
|
|
|
|
if err = database.GetDB().Create(r).Error; err != nil {
|
2026-04-19 21:55:48 +00:00
|
|
|
_ = removeSafePathIfExists(r.LocalPath)
|
2026-04-19 19:24:24 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
|
|
|
|
|
if err = s.serverService.RestartXrayService(); err != nil {
|
|
|
|
|
logger.Warning("custom geo create: restart xray:", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
|
|
|
|
|
var cur model.CustomGeoResource
|
|
|
|
|
if err := database.GetDB().First(&cur, id).Error; err != nil {
|
|
|
|
|
if database.IsNotFound(err) {
|
|
|
|
|
return ErrCustomGeoNotFound
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := s.validateType(r.Type); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := s.validateAlias(r.Alias); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
sanitizedURL, err := s.sanitizeURL(r.Url)
|
|
|
|
|
if err != nil {
|
2026-04-19 19:24:24 +00:00
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 21:55:48 +00:00
|
|
|
r.Url = sanitizedURL
|
2026-04-19 19:24:24 +00:00
|
|
|
if cur.Type != r.Type || cur.Alias != r.Alias {
|
|
|
|
|
var cnt int64
|
|
|
|
|
database.GetDB().Model(&model.CustomGeoResource{}).
|
|
|
|
|
Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
|
|
|
|
|
Count(&cnt)
|
|
|
|
|
if cnt > 0 {
|
|
|
|
|
return ErrCustomGeoDuplicateAlias
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
oldPath := s.resolveDestPath(&cur)
|
|
|
|
|
r.Id = id
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-04-19 19:24:24 +00:00
|
|
|
if oldPath != r.LocalPath && oldPath != "" {
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
|
|
|
|
|
logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
r.LastUpdatedAt = time.Now().Unix()
|
|
|
|
|
r.LastModified = lm
|
|
|
|
|
err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
|
|
|
|
|
"geo_type": r.Type,
|
|
|
|
|
"alias": r.Alias,
|
|
|
|
|
"url": r.Url,
|
|
|
|
|
"local_path": r.LocalPath,
|
|
|
|
|
"last_updated_at": r.LastUpdatedAt,
|
|
|
|
|
"last_modified": r.LastModified,
|
|
|
|
|
}).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
logger.Infof("custom geo updated id=%d", id)
|
|
|
|
|
if err = s.serverService.RestartXrayService(); err != nil {
|
|
|
|
|
logger.Warning("custom geo update: restart xray:", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
|
|
|
|
|
var r model.CustomGeoResource
|
|
|
|
|
if err := database.GetDB().First(&r, id).Error; err != nil {
|
|
|
|
|
if database.IsNotFound(err) {
|
|
|
|
|
return "", ErrCustomGeoNotFound
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
displayName = s.fileNameFor(r.Type, r.Alias)
|
|
|
|
|
p := s.resolveDestPath(&r)
|
2026-04-19 21:55:48 +00:00
|
|
|
if _, err := sanitizeDestPath(p); err != nil {
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
2026-04-19 19:24:24 +00:00
|
|
|
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
|
|
|
|
if p != "" {
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := removeSafePathIfExists(p); err != nil {
|
|
|
|
|
logger.Warningf("custom geo delete file %s: %v", p, err)
|
2026-04-19 19:24:24 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
logger.Infof("custom geo deleted id=%d", id)
|
|
|
|
|
if err := s.serverService.RestartXrayService(); err != nil {
|
|
|
|
|
logger.Warning("custom geo delete: restart xray:", err)
|
|
|
|
|
}
|
|
|
|
|
return displayName, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
|
|
|
|
|
var list []model.CustomGeoResource
|
|
|
|
|
err := database.GetDB().Order("id asc").Find(&list).Error
|
|
|
|
|
return list, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
|
|
|
|
|
var r model.CustomGeoResource
|
|
|
|
|
if err := database.GetDB().First(&r, id).Error; err != nil {
|
|
|
|
|
if database.IsNotFound(err) {
|
|
|
|
|
return "", ErrCustomGeoNotFound
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
displayName = s.fileNameFor(r.Type, r.Alias)
|
2026-04-19 21:55:48 +00:00
|
|
|
if err := s.syncAndSanitizeLocalPath(&r); err != nil {
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
|
|
|
|
sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
|
|
|
|
|
if sanitizeErr != nil {
|
|
|
|
|
return displayName, sanitizeErr
|
|
|
|
|
}
|
|
|
|
|
skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
|
2026-04-19 19:24:24 +00:00
|
|
|
if err != nil {
|
|
|
|
|
if onStartup {
|
|
|
|
|
logger.Warningf("custom geo startup download id=%d: %v", id, err)
|
|
|
|
|
} else {
|
|
|
|
|
logger.Warningf("custom geo manual update id=%d: %v", id, err)
|
|
|
|
|
}
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
|
|
|
|
now := time.Now().Unix()
|
|
|
|
|
updates := map[string]any{
|
|
|
|
|
"last_modified": lm,
|
|
|
|
|
"local_path": r.LocalPath,
|
|
|
|
|
"last_updated_at": now,
|
|
|
|
|
}
|
|
|
|
|
if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
|
|
|
|
if onStartup {
|
|
|
|
|
logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
|
|
|
|
|
} else {
|
|
|
|
|
logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
|
|
|
|
|
}
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
|
|
|
|
if skipped {
|
|
|
|
|
if onStartup {
|
|
|
|
|
logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
|
|
|
|
|
} else {
|
|
|
|
|
logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if onStartup {
|
|
|
|
|
logger.Infof("custom geo startup download ok id=%d", id)
|
|
|
|
|
} else {
|
|
|
|
|
logger.Infof("custom geo manual update ok id=%d", id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return displayName, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
|
|
|
|
|
displayName, err := s.applyDownloadAndPersist(id, false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return displayName, err
|
|
|
|
|
}
|
|
|
|
|
if err = s.serverService.RestartXrayService(); err != nil {
|
|
|
|
|
logger.Warning("custom geo manual update: restart xray:", err)
|
|
|
|
|
}
|
|
|
|
|
return displayName, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
|
|
|
|
|
var list []model.CustomGeoResource
|
|
|
|
|
var err error
|
|
|
|
|
if s.updateAllGetAll != nil {
|
|
|
|
|
list, err = s.updateAllGetAll()
|
|
|
|
|
} else {
|
|
|
|
|
list, err = s.GetAll()
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
res := &CustomGeoUpdateAllResult{}
|
|
|
|
|
if len(list) == 0 {
|
|
|
|
|
return res, nil
|
|
|
|
|
}
|
|
|
|
|
for _, r := range list {
|
|
|
|
|
var name string
|
|
|
|
|
var applyErr error
|
|
|
|
|
if s.updateAllApply != nil {
|
|
|
|
|
name, applyErr = s.updateAllApply(r.Id, false)
|
|
|
|
|
} else {
|
|
|
|
|
name, applyErr = s.applyDownloadAndPersist(r.Id, false)
|
|
|
|
|
}
|
|
|
|
|
if applyErr != nil {
|
|
|
|
|
res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
|
|
|
|
|
Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
|
|
|
|
|
Id: r.Id, Alias: r.Alias, FileName: name,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if len(res.Succeeded) > 0 {
|
|
|
|
|
var restartErr error
|
|
|
|
|
if s.updateAllRestart != nil {
|
|
|
|
|
restartErr = s.updateAllRestart()
|
|
|
|
|
} else {
|
|
|
|
|
restartErr = s.serverService.RestartXrayService()
|
|
|
|
|
}
|
|
|
|
|
if restartErr != nil {
|
|
|
|
|
logger.Warning("custom geo update all: restart xray:", restartErr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return res, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomGeoAliasItem struct {
|
|
|
|
|
Alias string `json:"alias"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
FileName string `json:"fileName"`
|
|
|
|
|
ExtExample string `json:"extExample"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomGeoAliasesResponse struct {
|
|
|
|
|
Geosite []CustomGeoAliasItem `json:"geosite"`
|
|
|
|
|
Geoip []CustomGeoAliasItem `json:"geoip"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
|
|
|
|
|
list, err := s.GetAll()
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warning("custom geo GetAliasesForUI:", err)
|
|
|
|
|
return CustomGeoAliasesResponse{}, err
|
|
|
|
|
}
|
|
|
|
|
var out CustomGeoAliasesResponse
|
|
|
|
|
for _, r := range list {
|
|
|
|
|
fn := s.fileNameFor(r.Type, r.Alias)
|
|
|
|
|
ex := fmt.Sprintf("ext:%s:tag", fn)
|
|
|
|
|
item := CustomGeoAliasItem{
|
|
|
|
|
Alias: r.Alias,
|
|
|
|
|
Type: r.Type,
|
|
|
|
|
FileName: fn,
|
|
|
|
|
ExtExample: ex,
|
|
|
|
|
}
|
|
|
|
|
if r.Type == customGeoTypeGeoip {
|
|
|
|
|
out.Geoip = append(out.Geoip, item)
|
|
|
|
|
} else {
|
|
|
|
|
out.Geosite = append(out.Geosite, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|