platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
48
selective-vpn-api/app/egressutil/http.go
Normal file
48
selective-vpn-api/app/egressutil/http.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package egressutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HTTPGetBody(client *http.Client, rawURL string, timeout time.Duration, userAgent string, maxBytes int64) (string, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("http client is nil")
|
||||
}
|
||||
limit := maxBytes
|
||||
if limit <= 0 {
|
||||
limit = 8 * 1024
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(userAgent) != "" {
|
||||
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
return "", fmt.Errorf("%s -> %s", rawURL, msg)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
25
selective-vpn-api/app/egressutil/identity.go
Normal file
25
selective-vpn-api/app/egressutil/identity.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package egressutil
|
||||
|
||||
import "strings"
|
||||
|
||||
type IdentitySnapshot struct {
|
||||
IP string
|
||||
CountryCode string
|
||||
CountryName string
|
||||
UpdatedAt string
|
||||
Stale bool
|
||||
RefreshInProgress bool
|
||||
LastError string
|
||||
NextRetryAt string
|
||||
}
|
||||
|
||||
func IdentityChanged(prev, next IdentitySnapshot) bool {
|
||||
return strings.TrimSpace(prev.IP) != strings.TrimSpace(next.IP) ||
|
||||
strings.TrimSpace(prev.CountryCode) != strings.TrimSpace(next.CountryCode) ||
|
||||
strings.TrimSpace(prev.CountryName) != strings.TrimSpace(next.CountryName) ||
|
||||
strings.TrimSpace(prev.UpdatedAt) != strings.TrimSpace(next.UpdatedAt) ||
|
||||
prev.Stale != next.Stale ||
|
||||
prev.RefreshInProgress != next.RefreshInProgress ||
|
||||
strings.TrimSpace(prev.LastError) != strings.TrimSpace(next.LastError) ||
|
||||
strings.TrimSpace(prev.NextRetryAt) != strings.TrimSpace(next.NextRetryAt)
|
||||
}
|
||||
19
selective-vpn-api/app/egressutil/probe.go
Normal file
19
selective-vpn-api/app/egressutil/probe.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package egressutil
|
||||
|
||||
func ProbeFirstSuccess(endpoints []string, probe func(rawURL string) (string, error)) (string, []string) {
|
||||
if len(endpoints) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
errs := make([]string, 0, len(endpoints))
|
||||
for _, rawURL := range endpoints {
|
||||
if probe == nil {
|
||||
continue
|
||||
}
|
||||
val, err := probe(rawURL)
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
return "", errs
|
||||
}
|
||||
43
selective-vpn-api/app/egressutil/scope.go
Normal file
43
selective-vpn-api/app/egressutil/scope.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package egressutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ScopeTarget struct {
|
||||
Scope string
|
||||
Source string
|
||||
SourceID string
|
||||
}
|
||||
|
||||
func ParseScope(raw string, sanitizeID func(string) string) (ScopeTarget, error) {
|
||||
scope := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch {
|
||||
case scope == "adguardvpn":
|
||||
return ScopeTarget{
|
||||
Scope: "adguardvpn",
|
||||
Source: "adguardvpn",
|
||||
}, nil
|
||||
case scope == "system":
|
||||
return ScopeTarget{
|
||||
Scope: "system",
|
||||
Source: "system",
|
||||
}, nil
|
||||
case strings.HasPrefix(scope, "transport:"):
|
||||
id := strings.TrimSpace(strings.TrimPrefix(scope, "transport:"))
|
||||
if sanitizeID != nil {
|
||||
id = sanitizeID(id)
|
||||
}
|
||||
if id == "" {
|
||||
return ScopeTarget{}, fmt.Errorf("invalid transport scope id")
|
||||
}
|
||||
return ScopeTarget{
|
||||
Scope: "transport:" + id,
|
||||
Source: "transport",
|
||||
SourceID: id,
|
||||
}, nil
|
||||
default:
|
||||
return ScopeTarget{}, fmt.Errorf("invalid scope, expected adguardvpn|system|transport:<id>")
|
||||
}
|
||||
}
|
||||
399
selective-vpn-api/app/egressutil/util.go
Normal file
399
selective-vpn-api/app/egressutil/util.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package egressutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
curlPathOnce sync.Once
|
||||
curlPath string
|
||||
|
||||
wgetPathOnce sync.Once
|
||||
wgetPath string
|
||||
)
|
||||
|
||||
func ParseIPFromBody(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
|
||||
if err := json.Unmarshal([]byte(s), &obj); err == nil && obj != nil {
|
||||
keys := []string{
|
||||
"ip", "origin", "query", "your_ip", "client_ip",
|
||||
"ip_addr", "ip_address", "address",
|
||||
}
|
||||
for _, key := range keys {
|
||||
if v := strings.TrimSpace(AnyToString(obj[key])); v != "" {
|
||||
if i := strings.Index(v, ","); i >= 0 {
|
||||
v = strings.TrimSpace(v[:i])
|
||||
}
|
||||
if addr, err := netip.ParseAddr(v); err == nil {
|
||||
return addr.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||
switch r {
|
||||
case '\n', '\r', '\t', ' ', ',', ';', '[', ']', '"', '\'':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
for _, part := range parts {
|
||||
v := strings.TrimSpace(strings.TrimPrefix(part, "ip="))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(v); err == nil {
|
||||
return addr.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot parse egress ip from response: %q", s)
|
||||
}
|
||||
|
||||
func ParseGeoResponse(raw string) (string, string, error) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if v, ok := obj["success"]; ok {
|
||||
if b, ok := v.(bool); ok && !b {
|
||||
msg := strings.TrimSpace(AnyToString(obj["message"]))
|
||||
if msg == "" {
|
||||
msg = "geo lookup reported success=false"
|
||||
}
|
||||
return "", "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(AnyToString(obj["status"]))); status == "fail" {
|
||||
msg := strings.TrimSpace(AnyToString(obj["message"]))
|
||||
if msg == "" {
|
||||
msg = "geo lookup status=fail"
|
||||
}
|
||||
return "", "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
code := NormalizeCountryCode(FirstNonEmptyAny(obj, "country_code", "countryCode", "cc"))
|
||||
name := strings.TrimSpace(FirstNonEmptyAny(obj, "country_name", "country", "countryName"))
|
||||
if code == "" && name == "" {
|
||||
return "", "", fmt.Errorf("geo response does not contain country fields")
|
||||
}
|
||||
return code, name, nil
|
||||
}
|
||||
|
||||
func NormalizeCountryCode(raw string) string {
|
||||
cc := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if len(cc) != 2 {
|
||||
return ""
|
||||
}
|
||||
for _, ch := range cc {
|
||||
if ch < 'A' || ch > 'Z' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
func IPEndpoints(envRaw string) []string {
|
||||
raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ","))
|
||||
if raw == "" {
|
||||
return []string{
|
||||
"https://api64.ipify.org",
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
}
|
||||
}
|
||||
return ParseURLList(raw)
|
||||
}
|
||||
|
||||
func GeoEndpointsForIP(envRaw, ip string) []string {
|
||||
raw := strings.TrimSpace(strings.ReplaceAll(envRaw, ";", ","))
|
||||
if raw == "" {
|
||||
raw = "https://ipwho.is/%s,http://ip-api.com/json/%s?fields=status,country,countryCode,query,message"
|
||||
}
|
||||
base := ParseURLList(raw)
|
||||
out := make([]string, 0, len(base))
|
||||
for _, item := range base {
|
||||
if strings.Contains(item, "%s") {
|
||||
out = append(out, fmt.Sprintf(item, ip))
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.TrimRight(item, "/")+"/"+ip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ParseURLList(raw string) []string {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
v := strings.TrimSpace(part)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func LimitEndpoints(in []string, maxN int) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
if maxN <= 0 || maxN >= len(in) {
|
||||
out := make([]string, 0, len(in))
|
||||
out = append(out, in...)
|
||||
return out
|
||||
}
|
||||
out := make([]string, 0, maxN)
|
||||
out = append(out, in[:maxN]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func JoinErrorsCompact(errs []string) string {
|
||||
if len(errs) == 0 {
|
||||
return "probe failed"
|
||||
}
|
||||
first := strings.TrimSpace(errs[0])
|
||||
if first == "" {
|
||||
first = "probe failed"
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return first
|
||||
}
|
||||
return fmt.Sprintf("%s; +%d more", first, len(errs)-1)
|
||||
}
|
||||
|
||||
func ParseSingBoxSOCKSProxyURL(root map[string]any) string {
|
||||
if root == nil {
|
||||
return ""
|
||||
}
|
||||
rawInbounds, ok := root["inbounds"].([]any)
|
||||
if !ok || len(rawInbounds) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, raw := range rawInbounds {
|
||||
inb, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := strings.ToLower(strings.TrimSpace(AnyToString(inb["type"])))
|
||||
if typ != "socks" && typ != "mixed" {
|
||||
continue
|
||||
}
|
||||
port, ok := parseIntAny(inb["listen_port"])
|
||||
if !ok || port <= 0 || port > 65535 {
|
||||
continue
|
||||
}
|
||||
host := strings.TrimSpace(AnyToString(inb["listen"]))
|
||||
switch host {
|
||||
case "", "::", "::1", "0.0.0.0", "[::]":
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("socks5h://%s:%d", host, port)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ResolvedHostForURL(rawURL string) (string, int, string) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", 0, ""
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
if host == "" {
|
||||
return "", 0, ""
|
||||
}
|
||||
if _, err := netip.ParseAddr(host); err == nil {
|
||||
return "", 0, ""
|
||||
}
|
||||
|
||||
port := 0
|
||||
if p := strings.TrimSpace(u.Port()); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err == nil && n > 0 && n <= 65535 {
|
||||
port = n
|
||||
}
|
||||
}
|
||||
if port == 0 {
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http":
|
||||
port = 80
|
||||
default:
|
||||
port = 443
|
||||
}
|
||||
}
|
||||
|
||||
ip, err := ResolveHostIPv4(host, 2*time.Second)
|
||||
if err != nil || ip == "" {
|
||||
return "", 0, ""
|
||||
}
|
||||
return host, port, ip
|
||||
}
|
||||
|
||||
func ResolveHostIPv4(host string, timeout time.Duration) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, strings.TrimSpace(host))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ip4 := a.IP.To4(); ip4 != nil {
|
||||
return ip4.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no ipv4 address for host %q", host)
|
||||
}
|
||||
|
||||
func ResolveCurlPath() string {
|
||||
curlPathOnce.Do(func() {
|
||||
if p, err := exec.LookPath("curl"); err == nil {
|
||||
curlPath = strings.TrimSpace(p)
|
||||
return
|
||||
}
|
||||
for _, cand := range []string{"/usr/bin/curl", "/bin/curl"} {
|
||||
if _, err := exec.LookPath(cand); err == nil {
|
||||
curlPath = strings.TrimSpace(cand)
|
||||
return
|
||||
}
|
||||
}
|
||||
curlPath = ""
|
||||
})
|
||||
return curlPath
|
||||
}
|
||||
|
||||
func ResolveWgetPath() string {
|
||||
wgetPathOnce.Do(func() {
|
||||
if p, err := exec.LookPath("wget"); err == nil {
|
||||
wgetPath = strings.TrimSpace(p)
|
||||
return
|
||||
}
|
||||
for _, cand := range []string{"/usr/bin/wget", "/bin/wget"} {
|
||||
if _, err := exec.LookPath(cand); err == nil {
|
||||
wgetPath = strings.TrimSpace(cand)
|
||||
return
|
||||
}
|
||||
}
|
||||
wgetPath = ""
|
||||
})
|
||||
return wgetPath
|
||||
}
|
||||
|
||||
func TimeoutSec(timeout time.Duration) int {
|
||||
sec := int(timeout.Seconds())
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func FirstNonEmptyAny(obj map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if v := strings.TrimSpace(AnyToString(obj[key])); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func AnyToString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case fmt.Stringer:
|
||||
return x.String()
|
||||
case int:
|
||||
return strconv.Itoa(x)
|
||||
case int64:
|
||||
return strconv.FormatInt(x, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, raw := range in {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseIntAny(v any) (int, bool) {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, true
|
||||
case int8:
|
||||
return int(x), true
|
||||
case int16:
|
||||
return int(x), true
|
||||
case int32:
|
||||
return int(x), true
|
||||
case int64:
|
||||
return int(x), true
|
||||
case uint:
|
||||
return int(x), true
|
||||
case uint8:
|
||||
return int(x), true
|
||||
case uint16:
|
||||
return int(x), true
|
||||
case uint32:
|
||||
return int(x), true
|
||||
case uint64:
|
||||
return int(x), true
|
||||
case float64:
|
||||
return int(x), true
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user