diff --git a/internal/device/carrier_db.go b/internal/device/carrier_db.go index 8bf4289..c59ab0a 100644 --- a/internal/device/carrier_db.go +++ b/internal/device/carrier_db.go @@ -3,6 +3,7 @@ package device import ( _ "embed" "encoding/json" + "sort" "strings" ) @@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) { return country, len(country) == 2 } +// MCCsByCountry returns the complete MCC grouping from the embedded carrier +// database, keyed by ISO alpha-2 country/territory code. The returned map and +// slices are new values and may be safely modified by callers. +func MCCsByCountry() map[string][]string { + result := make(map[string][]string) + for mcc, rawCountry := range globalCarrierDatabase.Countries { + country := strings.ToUpper(strings.TrimSpace(rawCountry)) + mcc = strings.TrimSpace(mcc) + if len(country) != 2 || len(mcc) != 3 { + continue + } + result[country] = append(result[country], mcc) + } + for country := range result { + sort.Strings(result[country]) + } + return result +} + var globalCarrierDatabase = func() carrierDatabase { var database carrierDatabase if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil { diff --git a/internal/device/scan_test.go b/internal/device/scan_test.go index d3142ce..4a1eb1a 100644 --- a/internal/device/scan_test.go +++ b/internal/device/scan_test.go @@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) { } } +func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) { + grouped := MCCsByCountry() + if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" { + t.Fatalf("GB MCCs = %#v", got) + } + grouped["GB"][0] = "999" + if country, ok := CountryForMCC("234"); !ok || country != "GB" { + t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok) + } +} + func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) { tests := []struct { imsi string diff --git a/internal/server/proxy_api.go b/internal/server/proxy_api.go index e202c3f..2df6953 100644 --- a/internal/server/proxy_api.go +++ b/internal/server/proxy_api.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "net/http" + "sort" "strings" "time" + "vocat/internal/device" "vocat/internal/i18n" localproxy "vocat/internal/proxy" "vocat/internal/store" @@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id } payload.ID = id s.saveAndProbeUpstream(w, r, payload) + case http.MethodPatch: + var request struct { + Enabled bool `json:"enabled"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + value, err := s.store.UpstreamProxy(r.Context(), id) + if err != nil { + s.writeStoreError(w, err) + return + } + value.Enabled = request.Enabled + value.UpdatedAt = time.Now().UTC() + if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil { + s.writeStoreError(w, err) + return + } + bindings, err := s.store.ListDeviceProxyBindings(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + reconnectRequested := false + var reconnectErrors []string + for _, binding := range bindings { + if binding.UpstreamProxyID != id { + continue + } + requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID) + reconnectRequested = reconnectRequested || requested + if reconnectErr != nil { + reconnectErrors = append(reconnectErrors, reconnectErr.Error()) + } + } + response := upstreamProxyResponse(value.Redacted()) + response["reconnect_requested"] = reconnectRequested + if len(reconnectErrors) > 0 { + response["reconnect_error"] = strings.Join(reconnectErrors, "; ") + } + writeJSON(w, http.StatusOK, map[string]any{"data": response}) case http.MethodDelete: bindings, listErr := s.store.ListDeviceProxyBindings(r.Context()) if listErr != nil { @@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) default: - w.Header().Set("Allow", "PUT, DELETE") + w.Header().Set("Allow", "PUT, PATCH, DELETE") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } @@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string { return "" } -var proxyCountries = []proxyCountry{ +var namedProxyCountries = []proxyCountry{ {Code: "CN", Name: "中国", MCCs: []string{"460", "461"}}, {Code: "HK", Name: "中国香港", MCCs: []string{"454"}}, {Code: "MO", Name: "中国澳门", MCCs: []string{"455"}}, @@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{ {Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}}, {Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}}, } + +var proxyCountries = buildProxyCountries() + +func buildProxyCountries() []proxyCountry { + byCode := make(map[string]proxyCountry) + for _, country := range namedProxyCountries { + byCode[country.Code] = country + } + for code, mccs := range device.MCCsByCountry() { + country, found := byCode[code] + if !found { + country = proxyCountry{Code: code, Name: code} + } + country.MCCs = append([]string(nil), mccs...) + byCode[code] = country + } + result := make([]proxyCountry, 0, len(byCode)) + for _, country := range byCode { + result = append(result, country) + } + sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code }) + return result +} diff --git a/internal/store/proxy.go b/internal/store/proxy.go index 0d9f3ad..5b38512 100644 --- a/internal/store/proxy.go +++ b/internal/store/proxy.go @@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) { } func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error { - value.DeviceID = strings.TrimSpace(value.DeviceID) - value.ICCID = strings.TrimSpace(value.ICCID) - value.ProfileName = strings.TrimSpace(value.ProfileName) - value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID) - if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" { - return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID") + value, err := normalizeDeviceProxyBinding(value) + if err != nil { + return err } - now := time.Now().UTC() - createdAt := value.CreatedAt - if createdAt.IsZero() { - createdAt = now - } - updatedAt := value.UpdatedAt - if updatedAt.IsZero() { - updatedAt = now - } - _, err := s.db.ExecContext(ctx, ` + _, err = s.db.ExecContext(ctx, ` INSERT INTO device_proxy_bindings ( iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) @@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB profile_name = excluded.profile_name, upstream_proxy_id = excluded.upstream_proxy_id, updated_at = excluded.updated_at - `, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix()) + `, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix()) if err != nil { return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err) } return nil } +// InsertDeviceProxyBindingIfAbsent materializes a default route without ever +// replacing an explicit (or concurrently-created) ICCID binding. +func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) { + value, err := normalizeDeviceProxyBinding(value) + if err != nil { + return false, err + } + result, err := s.db.ExecContext(ctx, ` + INSERT INTO device_proxy_bindings ( + iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(iccid) DO NOTHING + `, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix()) + if err != nil { + return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err) + } + return affected > 0, nil +} + +func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) { + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.ICCID = strings.TrimSpace(value.ICCID) + value.ProfileName = strings.TrimSpace(value.ProfileName) + value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID) + if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" { + return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID") + } + now := time.Now().UTC() + if value.CreatedAt.IsZero() { + value.CreatedAt = now + } + if value.UpdatedAt.IsZero() { + value.UpdatedAt = now + } + return value, nil +} + func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) { return deviceProxyBinding(s.db.QueryRowContext( ctx, diff --git a/internal/vowifi/integration/store.go b/internal/vowifi/integration/store.go index 45a62e0..b540be4 100644 --- a/internal/vowifi/integration/store.go +++ b/internal/vowifi/integration/store.go @@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve( return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil } var upstreamID string + matchedCountryRule := false if iccid != "" { binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid) if err == nil { @@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve( if upstreamID == "" { country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC)) if !found { - return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil + country = strings.ToUpper(strings.TrimSpace(request.CountryCode)) + if len(country) != 2 { + return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil + } } rule, ruleErr := resolver.Store.CountryRule(ctx, country) if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) { @@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve( return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr) } upstreamID = rule.UpstreamProxyID + matchedCountryRule = true } upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID) if err != nil { @@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve( ) } if !upstream.Enabled { + if matchedCountryRule { + return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil + } return vowifi.ProxyRoute{}, fmt.Errorf( "upstream proxy %q for device %s is disabled", upstream.ID, deviceID, ) } + if matchedCountryRule && iccid != "" { + created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{ + DeviceID: deviceID, + ICCID: iccid, + ProfileName: iccid, + UpstreamProxyID: upstream.ID, + }) + if bindErr != nil { + return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr) + } + if !created { + // Another request or an administrator may have created an explicit + // binding after our first lookup. The persisted ICCID route wins. + binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid) + if bindingErr != nil { + return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr) + } + if binding.UpstreamProxyID != upstream.ID { + upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID) + if err != nil { + return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err) + } + if !upstream.Enabled { + return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID) + } + } + } + } return vowifi.ProxyRoute{ Mode: vowifi.ProxyModeSOCKS5, ID: upstream.ID, diff --git a/internal/vowifi/integration/store_test.go b/internal/vowifi/integration/store_test.go index 1ecf7e8..a4bd8d5 100644 --- a/internal/vowifi/integration/store_test.go +++ b/internal/vowifi/integration/store_test.go @@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) { } } +func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) { + database := testStore(t) + ctx := context.Background() + if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{ + ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertCountryRule(ctx, store.CountryRule{ + CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true, + }); err != nil { + t.Fatal(err) + } + route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"}) + if err != nil { + t.Fatal(err) + } + if route.Mode != vowifi.ProxyModeDirect { + t.Fatalf("route = %#v, want direct for a disabled country default", route) + } +} + +func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) { + database := testStore(t) + ctx := context.Background() + if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{ + ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{ + DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled", + }); err != nil { + t.Fatal(err) + } + _, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{ + DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", + }) + if err == nil { + t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route") + } +} + +func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) { + database := testStore(t) + ctx := context.Background() + if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + for _, proxy := range []store.UpstreamProxy{ + {ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true}, + {ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true}, + } { + if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil { + t.Fatal(err) + } + } + if err := database.UpsertCountryRule(ctx, store.CountryRule{ + CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true, + }); err != nil { + t.Fatal(err) + } + request := vowifi.ProxyRequest{ + DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", + } + resolver := ProxyResolver{Store: database} + route, err := resolver.Resolve(ctx, request) + if err != nil { + t.Fatal(err) + } + if route.ID != "first" { + t.Fatalf("first route = %#v, want MCC default", route) + } + binding, err := database.DeviceProxyBinding(ctx, request.ICCID) + if err != nil { + t.Fatal(err) + } + if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" { + t.Fatalf("materialized binding = %#v", binding) + } + + if err := database.UpsertCountryRule(ctx, store.CountryRule{ + CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true, + }); err != nil { + t.Fatal(err) + } + route, err = resolver.Resolve(ctx, request) + if err != nil { + t.Fatal(err) + } + if route.ID != "first" { + t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route) + } +} + +func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) { + database := testStore(t) + ctx := context.Background() + if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + for _, proxyID := range []string{"explicit", "default"} { + if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{ + ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true, + }); err != nil { + t.Fatal(err) + } + } + iccid := "89441000400128014257" + if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{ + DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit", + }); err != nil { + t.Fatal(err) + } + created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{ + DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default", + }) + if err != nil { + t.Fatal(err) + } + if created { + t.Fatal("default binding unexpectedly replaced an explicit binding") + } + binding, err := database.DeviceProxyBinding(ctx, iccid) + if err != nil { + t.Fatal(err) + } + if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" { + t.Fatalf("binding = %#v, want explicit binding unchanged", binding) + } +} + func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) { database := testStore(t) for _, proxy := range []store.UpstreamProxy{ diff --git a/web/embed_test.go b/web/embed_test.go new file mode 100644 index 0000000..ec7e127 --- /dev/null +++ b/web/embed_test.go @@ -0,0 +1,16 @@ +package web + +import ( + "io/fs" + "testing" +) + +func TestEmbeddedDistributionContainsIndex(t *testing.T) { + index, err := fs.ReadFile(Dist, "index.html") + if err != nil { + t.Fatalf("read embedded index.html: %v", err) + } + if len(index) == 0 { + t.Fatal("embedded index.html is empty") + } +} diff --git a/web/src/components/proxy/CountryRulesDialog.tsx b/web/src/components/proxy/CountryRulesDialog.tsx new file mode 100644 index 0000000..6db7151 --- /dev/null +++ b/web/src/components/proxy/CountryRulesDialog.tsx @@ -0,0 +1,126 @@ +import { SearchRegular } from "@fluentui/react-icons"; +import { useEffect, useMemo, useState } from "react"; +import type { Country, CountryRule, UpstreamProxy } from "../../types"; +import { Button, EmptyState, Input, Modal, Select } from "../ui"; +import { useI18n } from "../../lib/i18n"; + +export interface CountryRulesDialogProps { + open: boolean; + proxies: UpstreamProxy[]; + countries: Country[]; + rules: CountryRule[]; + busy: boolean; + onSave: (assignments: Record) => void; + onClose: () => void; +} + +export function CountryRulesDialog(props: CountryRulesDialogProps) { + const { t, lang } = useI18n(); + const { open, proxies, countries, rules, busy, onSave, onClose } = props; + const [query, setQuery] = useState(""); + const [assignments, setAssignments] = useState>({}); + const regionNames = useMemo(() => { + try { + return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" }); + } catch { + return null; + } + }, [lang]); + const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode; + const proxyOptions = useMemo(() => [ + { value: "", label: t("直连") }, + ...proxies.map((proxy) => ({ + value: proxy.id, + label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}(${t("已禁用")})`, + disabled: !proxy.enabled, + })), + ], [proxies, t, lang]); + + useEffect(() => { + if (!open) { + setQuery(""); + setAssignments({}); + return; + } + setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId]))); + // Sample rules only when opening. Polling must not discard in-progress edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const filtered = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + return [...countries] + .sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en")) + .filter((country) => { + if (!needle) return true; + return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs] + .some((value) => String(value || "").toLocaleLowerCase().includes(needle)); + }); + }, [countries, query, lang, regionNames]); + + const configuredCount = Object.values(assignments).filter(Boolean).length; + + return ( + + + + + )} + > +
+
+ {t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")} +
+
+
{configuredCount} {t("个国家规则")}
+ setQuery(event.target.value)} + placeholder={t("搜索国家、地区代码或 MCC")} + prefix={} + className="w-full sm:w-72" + /> +
+
+
+ + + + + + + + + + {filtered.map((country) => ( + + + + + + ))} + +
{t("国家 / 地区")}MCC{t("规则")}
+ {countryLabel(country)} + {country.countryCode} + {country.mccs.join(", ")} +
+
+ {filtered.length === 0 ? : null} +
+
+
+ ); +} diff --git a/web/src/components/proxy/UpstreamSection.tsx b/web/src/components/proxy/UpstreamSection.tsx index f6a4886..dcb4b4d 100644 --- a/web/src/components/proxy/UpstreamSection.tsx +++ b/web/src/components/proxy/UpstreamSection.tsx @@ -1,4 +1,4 @@ -import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons"; +import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons"; import type { UpstreamProxy } from "../../types"; import { Button, Tag } from "../ui"; import type { LoadError, UpstreamRow } from "./shared"; @@ -12,9 +12,11 @@ export interface UpstreamSectionProps { onEdit: (proxy: UpstreamProxy) => void; onDelete: (proxy: UpstreamProxy) => void; onOpenBindings: (proxy: UpstreamProxy) => void; + onToggle: (proxy: UpstreamProxy) => void; + toggleBusyId?: string; } -export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) { +export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) { const { t } = useI18n(); return (
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
) : null}
- +
- - + @@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet {rows.map((row) => ( - - +
{t("名称")}{t("协议")} {t("地址")}{t("鉴权")} {t("状态")} {t("SIM / Profile 绑定")}{t("国家规则")} {t("操作")}
{row.name || row.id}SOCKS5 {row.addr}{row.username || t("无")} {row.enabled ? t("已启用") : t("已禁用")} -
- - {row.bindingCount} {t("个 SIM / Profile")} -
+ {row.bindingCount} +
+ {row.countryNames.length ? ( +
+ {row.countryNames.map((countryName) => {countryName})} +
+ ) : }
+ @@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{t("暂无上游代理")}
-
{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}
+
{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}
) : null} {loading ?
{t("加载中...")}
: null} diff --git a/web/src/components/proxy/shared.ts b/web/src/components/proxy/shared.ts index d56b9c6..a6b2871 100644 --- a/web/src/components/proxy/shared.ts +++ b/web/src/components/proxy/shared.ts @@ -29,6 +29,7 @@ export interface UpstreamProbeResult { export interface UpstreamRow extends UpstreamProxy { bindingCount: number; + countryNames: string[]; } export function ipv6Hint(): string { diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 2362f60..896e95f 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -1005,6 +1005,13 @@ export const EN_DICT: Record = { "绑定:": "Bound:", "鉴权:": "Auth:", 国家规则: "Country Rules", + "MCC 国家规则": "MCC Country Rules", + 规则: "Rule", + 代理已启用: "Proxy enabled", + 代理已禁用: "Proxy disabled", + 切换代理状态失败: "Failed to change proxy status", + "代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连": + "Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.", 新增代理: "Add Proxy", 新增实例: "Add Instance", 删除规则: "Delete Rule", @@ -1013,6 +1020,22 @@ export const EN_DICT: Record = { "UDP 中继地址:": "UDP Relay Address: ", "规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。": "Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.", + "未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。": + "A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.", + "为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。": + "Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.", + "同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。": + "Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.", + "搜索国家、地区代码或 MCC": "Search country, region code, or MCC", + "国家 / 地区": "Country / Region", + 当前规则: "Current Rule", + 当前代理: "This Proxy", + 直连: "Direct", + "没有匹配的国家或 MCC": "No matching country or MCC", + "管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定": + "Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings", + "点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。": + "Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.", "VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。": "VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ", diff --git a/web/src/pages/ProxyPage.tsx b/web/src/pages/ProxyPage.tsx index 15d591b..75d4883 100644 --- a/web/src/pages/ProxyPage.tsx +++ b/web/src/pages/ProxyPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { AddRegular } from "@fluentui/react-icons"; +import { AddRegular, GlobeRegular } from "@fluentui/react-icons"; import { api, ApiError, apiMessage } from "../api"; -import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types"; +import type { Country, CountryRule, DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types"; import { usePolling } from "../lib/usePolling"; import { Button, PageHeader, confirmDialog, message } from "../components/ui"; import { @@ -14,6 +14,7 @@ import { } from "../components/proxy/shared"; import { UpstreamDialog } from "../components/proxy/UpstreamDialog"; import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog"; +import { CountryRulesDialog } from "../components/proxy/CountryRulesDialog"; import { UpstreamSection } from "../components/proxy/UpstreamSection"; import { tf, useI18n } from "../lib/i18n"; import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions"; @@ -24,11 +25,13 @@ interface BindingMutationResult { } export default function ProxyPage() { - const { t } = useI18n(); + const { t, lang } = useI18n(); const [proxies, setProxies] = useState([]); const [devices, setDevices] = useState([]); const [bindings, setBindings] = useState([]); + const [countries, setCountries] = useState([]); + const [countryRules, setCountryRules] = useState([]); const [upstreamLoading, setUpstreamLoading] = useState(true); const [upstreamError, setUpstreamError] = useState(null); const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false); @@ -39,28 +42,46 @@ export default function ProxyPage() { const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false); const [bindingsProxy, setBindingsProxy] = useState(null); const [bindingBusy, setBindingBusy] = useState(false); + const [countryDialogOpen, setCountryDialogOpen] = useState(false); + const [countryBusy, setCountryBusy] = useState(false); + const [toggleBusyId, setToggleBusyId] = useState(""); const [plugins, setPlugins] = useState([]); - const proxyRows = useMemo( - () => proxies.map((proxy) => ({ + const regionNames = useMemo(() => { + try { + return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" }); + } catch { + return null; + } + }, [lang]); + + const proxyRows = useMemo(() => proxies.map((proxy) => { + const countryNames = countryRules + .filter((rule) => rule.enabled && rule.upstreamProxyId === proxy.id) + .map((rule) => regionNames?.of(rule.countryCode) || rule.countryName || rule.countryCode); + return { ...proxy, bindingCount: bindings.filter((binding) => binding.upstreamProxyId === proxy.id).length, - })), - [proxies, bindings], - ); + countryNames, + }; + }), [proxies, bindings, countryRules, regionNames]); const loadUpstream = useCallback(async (initial = false) => { if (initial) setUpstreamLoading(true); setUpstreamError(null); try { - const [proxyList, bindingList, deviceList] = await Promise.all([ + const [proxyList, bindingList, deviceList, countryList, ruleList] = await Promise.all([ api("/upstream-proxies"), api("/upstream-proxy-profile-bindings"), api("/devices"), + api("/upstream-proxy-countries"), + api("/upstream-proxy-country-rules"), ]); setProxies(proxyList || []); setBindings(bindingList || []); setDevices(deviceList?.devices || []); + setCountries(countryList || []); + setCountryRules(ruleList || []); } catch (error) { setUpstreamError({ message: apiMessage(error), status: error instanceof ApiError ? error.status : undefined }); } finally { @@ -165,6 +186,8 @@ export default function ProxyPage() { {tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")} +
+ {t("绑定到该代理的国家规则将自动删除,相关国家会恢复直连。")} , t("确认删除"), { confirmText: t("删除"), cancelText: t("取消"), type: "warning" }, @@ -174,6 +197,7 @@ export default function ProxyPage() { await api(`/upstream-proxies/${proxy.id}`, { method: "DELETE" }); message.success(t("上游代理已删除")); if (bindingsProxy?.id === proxy.id) setBindingsDialogOpen(false); + setCountryDialogOpen(false); await loadUpstream(false); } catch (error) { message.error(apiMessage(error) || t("删除失败")); @@ -185,6 +209,53 @@ export default function ProxyPage() { setBindingsDialogOpen(true); }, []); + const toggleUpstream = useCallback(async (proxy: UpstreamProxy) => { + const enabled = !proxy.enabled; + setToggleBusyId(proxy.id); + try { + const result = await api(`/upstream-proxies/${encodeURIComponent(proxy.id)}`, { + method: "PATCH", + body: { enabled }, + }); + if (result.reconnectError) { + message.warning(`${enabled ? t("代理已启用") : t("代理已禁用")};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`); + } else if (enabled) { + message.success(t("代理已启用")); + } else { + message.success(t("代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连")); + } + await loadUpstream(false); + } catch (error) { + message.error(apiMessage(error) || t("切换代理状态失败")); + } finally { + setToggleBusyId(""); + } + }, [loadUpstream, t]); + + const saveCountryRules = useCallback(async (assignments: Record) => { + setCountryBusy(true); + const current = new Map(countryRules.map((rule) => [rule.countryCode, rule.upstreamProxyId])); + const changed = Object.entries(assignments).filter(([code, proxyID]) => proxyID && current.get(code) !== proxyID); + const removed = countryRules.filter((rule) => !assignments[rule.countryCode]); + try { + await Promise.all(changed.map(([code, proxyID]) => api(`/upstream-proxy-country-rules/${encodeURIComponent(code)}`, { + method: "PUT", + body: { upstreamProxyId: proxyID, enabled: true }, + }))); + await Promise.all(removed.map((rule) => api(`/upstream-proxy-country-rules/${encodeURIComponent(rule.countryCode)}`, { + method: "DELETE", + }))); + message.success(t("国家规则已保存")); + await loadUpstream(false); + setCountryDialogOpen(false); + } catch (error) { + await loadUpstream(false); + message.error(apiMessage(error) || t("保存规则失败")); + } finally { + setCountryBusy(false); + } + }, [countryRules, loadUpstream, t]); + const showRouteChangeResult = useCallback((result: BindingMutationResult, successText: string) => { if (result.reconnectError) { message.warning(`${successText};${t("线路已保存,将在下次启动 VoWiFi 时应用")}`); @@ -241,8 +312,13 @@ export default function ProxyPage() {
} onClick={() => openUpstreamDialog()}>{t("新增代理")}} + subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")} + actions={( +
+ + +
+ )} /> void toggleUpstream(proxy)} + toggleBusyId={toggleBusyId} /> {plugins.filter((plugin) => plugin.enabled).flatMap((plugin) => plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => ( @@ -293,6 +371,15 @@ export default function ProxyPage() { onDelete={(iccids) => void deleteProfileBindings(iccids)} onClose={() => setBindingsDialogOpen(false)} /> + void saveCountryRules(assignments)} + onClose={() => setCountryDialogOpen(false)} + />
); }