This commit is contained in:
MengMengCode
2026-08-16 15:16:45 +08:00
parent 505ee1eac0
commit 60f6232e57
12 changed files with 605 additions and 43 deletions
+20
View File
@@ -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 {
+11
View File
@@ -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
+69 -2
View File
@@ -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
}
+46 -17
View File
@@ -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,
+37 -1
View File
@@ -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,
+135
View File
@@ -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{
+16
View File
@@ -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")
}
}
@@ -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<string, string>) => 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<Record<string, string>>({});
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 (
<Modal
open={open}
onClose={onClose}
title={t("MCC 国家规则")}
width="max-w-5xl"
footer={(
<>
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
</>
)}
>
<div className="space-y-4 pb-1">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("搜索国家、地区代码或 MCC")}
prefix={<SearchRegular />}
className="w-full sm:w-72"
/>
</div>
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
<div className="max-h-[55vh] overflow-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
<tr>
<th className="px-4 py-3">{t("国家 / 地区")}</th>
<th className="px-4 py-3">MCC</th>
<th className="w-72 px-4 py-3">{t("规则")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{filtered.map((country) => (
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3">
<span className="font-medium">{countryLabel(country)}</span>
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
<td className="px-4 py-2">
<Select
value={assignments[country.countryCode] || ""}
options={proxyOptions}
disabled={busy}
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
</div>
</div>
</Modal>
);
}
+23 -12
View File
@@ -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 (
<div className="ui-card overflow-hidden">
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("名称")}</th>
<th className="px-4 py-3">{t("协议")}</th>
<th className="px-4 py-3">{t("地址")}</th>
<th className="px-4 py-3">{t("鉴权")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
<th className="px-4 py-3">{t("国家规则")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{rows.map((row) => (
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
<td className="px-4 py-3">{row.username || t("无")}</td>
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
</div>
{row.bindingCount}
</td>
<td className="px-4 py-3">
{row.countryNames.length ? (
<div className="flex max-w-sm flex-wrap gap-1">
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
</div>
) : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="small"
variant={row.enabled ? "warning" : "success"}
plain
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
loading={toggleBusyId === row.id}
onClick={() => onToggle(row)}
>{row.enabled ? t("禁用") : t("启用")}</Button>
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无上游代理")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
+1
View File
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
export interface UpstreamRow extends UpstreamProxy {
bindingCount: number;
countryNames: string[];
}
export function ipv6Hint(): string {
+23
View File
@@ -1005,6 +1005,13 @@ export const EN_DICT: Record<string, string> = {
"绑定:": "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<string, string> = {
"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. ",
+98 -11
View File
@@ -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<UpstreamProxy[]>([]);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [bindings, setBindings] = useState<DeviceProxyBinding[]>([]);
const [countries, setCountries] = useState<Country[]>([]);
const [countryRules, setCountryRules] = useState<CountryRule[]>([]);
const [upstreamLoading, setUpstreamLoading] = useState(true);
const [upstreamError, setUpstreamError] = useState<LoadError | null>(null);
const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false);
@@ -39,28 +42,46 @@ export default function ProxyPage() {
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
const [bindingBusy, setBindingBusy] = useState(false);
const [countryDialogOpen, setCountryDialogOpen] = useState(false);
const [countryBusy, setCountryBusy] = useState(false);
const [toggleBusyId, setToggleBusyId] = useState("");
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const proxyRows = useMemo<UpstreamRow[]>(
() => 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<UpstreamRow[]>(() => 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<UpstreamProxy[]>("/upstream-proxies"),
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
api<DevicesResponse>("/devices"),
api<Country[]>("/upstream-proxy-countries"),
api<CountryRule[]>("/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 })}
<br />
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
<br />
{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<BindingMutationResult>(`/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<string, string>) => {
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() {
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")}
actions={(
<div className="flex gap-2">
<Button icon={<GlobeRegular />} onClick={() => setCountryDialogOpen(true)}>{t("MCC 国家规则")}</Button>
<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>
</div>
)}
/>
<UpstreamSection
rows={proxyRows}
@@ -252,6 +328,8 @@ export default function ProxyPage() {
onEdit={openUpstreamDialog}
onDelete={removeUpstream}
onOpenBindings={openBindingsDialog}
onToggle={(proxy) => 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)}
/>
<CountryRulesDialog
open={countryDialogOpen}
proxies={proxies}
countries={countries}
rules={countryRules}
busy={countryBusy}
onSave={(assignments) => void saveCountryRules(assignments)}
onClose={() => setCountryDialogOpen(false)}
/>
</div>
);
}