4 Commits
Author SHA1 Message Date
MengMengCode 707ca3c124 feat: enhance URL validation and email address parsing; refactor related components 2026-08-11 02:02:55 +08:00
MengMengCode a09f9af646 feat: update schema version and refactor proxy binding logic
- Increment schema version from 11 to 12.
- Modify ProxyResolver to use ICCID for device proxy binding resolution.
- Update tests to reflect changes in proxy binding logic using ICCID.
- Enhance DeviceBindingsDialog to manage eSIM profile bindings instead of device bindings.
- Update UI components and translations to reflect the new profile binding terminology.
- Implement pagination for automatic task runs in AutomaticTasksPage.
- Create a new Pagination component for better navigation in lists.
2026-08-11 01:23:04 +08:00
MengMengCode 928ba7746e update 2026-08-11 00:22:54 +08:00
MengMengCode 21f210d219 update readme 2026-08-10 23:05:41 +08:00
55 changed files with 1888 additions and 410 deletions
+8
View File
@@ -305,6 +305,14 @@ cd web && npm run build
- [Linux.do](https://linux.do) — An inspiring tech community
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
## Buy me a coffee
| Network | Address |
| ------- | ------- |
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
## License
See [LICENSE](LICENSE).
+33
View File
@@ -25,8 +25,11 @@ func Enabled(ctx context.Context, database *store.Store) bool {
const (
EnabledSettingKey = "developer.enabled"
DeviceLimitSettingKey = "developer.device_limit"
SMSHourlyLimitKey = "developer.sms_hourly_limit"
DefaultDeviceLimit = 5
MaxDeviceLimit = 128
DefaultSMSHourlyLimit = 10
MaxSMSHourlyLimit = 1000
)
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
@@ -57,6 +60,33 @@ func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
}
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
// only controls whether administrators can see and modify this value.
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
if err != nil {
return DefaultSMSHourlyLimit
}
var document struct {
Limit int `json:"limit"`
}
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit {
return DefaultSMSHourlyLimit
}
return document.Limit
}
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
if limit < 1 || limit > MaxSMSHourlyLimit {
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
}
value, err := json.Marshal(map[string]int{"limit": limit})
if err != nil {
return err
}
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
}
// ResetExperimental restores every mutable developer-only setting. It is
// called both by `vocat develop off` and at startup whenever developer mode is
// disabled, so stale database values cannot silently remain active.
@@ -72,6 +102,9 @@ func ResetExperimental(ctx context.Context, database *store.Store) error {
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
}
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
}
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
}
+24
View File
@@ -22,6 +22,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
if err := SetDeviceLimit(ctx, database, 24); err != nil {
t.Fatal(err)
}
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
t.Fatal(err)
}
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
t.Fatal(err)
@@ -41,6 +44,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
}
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
}
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
if err != nil {
t.Fatal(err)
@@ -75,3 +81,21 @@ func TestSetDeviceLimitValidatesRange(t *testing.T) {
t.Fatal("out-of-range device limit was accepted")
}
}
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
t.Fatal("out-of-range SMS hourly limit was accepted")
}
if err := SetSMSHourlyLimit(ctx, database, 25); err != nil {
t.Fatal(err)
}
if got := SMSHourlyLimit(ctx, database); got != 25 {
t.Fatalf("SMS hourly limit = %d, want 25", got)
}
}
+28 -18
View File
@@ -3,14 +3,17 @@ package device
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"vocat/internal/netguard"
)
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
@@ -24,25 +27,31 @@ import (
// header.functionExecutionStatus (with statusCodeData.message holding the
// human-readable failure, e.g. "The matchingID is not found").
type es9pClient struct {
smdp string
http *http.Client
smdp string
endpoint *url.URL
http *http.Client
}
func newES9PClient(smdp string) *es9pClient {
// The eUICC — not the host — is the root of trust for RSP: during
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
// the card will accept. The host TLS layer is transport only, and a minimal
// embedded box may ship no CA bundle (this is exactly what broke on the test
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
// is safe here specifically because the card does the authoritative check.
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
smdp = strings.TrimSpace(smdp)
if smdp == "" || strings.Contains(smdp, "://") {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate, err := url.Parse("https://" + smdp)
if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
(candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate.Path = ""
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
if err != nil {
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
}
return &es9pClient{
smdp: strings.TrimSpace(smdp),
http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
}
smdp: validated.Host,
endpoint: validated,
http: netguard.NewPublicHTTPClient(90*time.Second, true),
}, nil
}
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
@@ -80,12 +89,13 @@ type es9pStatusCodeData struct {
// is decided the way lpac decides it: a non-success execution status, or a
// missing required output field, yields an es9pError carrying the SM-DP+ message.
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/" + function
body, err := json.Marshal(request)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
+24 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
t.Helper()
server := httptest.NewTLSServer(handler)
t.Cleanup(server.Close)
client := newES9PClient(strings.TrimPrefix(server.URL, "https://"))
client.http = server.Client()
return client
endpoint, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return &es9pClient{
smdp: strings.TrimPrefix(server.URL, "https://"),
endpoint: endpoint,
http: server.Client(),
}
}
func successEnvelope(fields map[string]any) map[string]any {
@@ -33,6 +40,20 @@ func successEnvelope(fields map[string]any) map[string]any {
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
for _, address := range []string{
"https://rsp.example.com",
"127.0.0.1",
"169.254.169.254",
"rsp.example.com/unexpected/path",
"user:[email protected]",
} {
if _, err := newES9PClient(context.Background(), address); err == nil {
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
}
}
}
func TestInitiateAuthenticationSuccess(t *testing.T) {
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
+4 -1
View File
@@ -74,7 +74,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
return nil, err
}
client := newES9PClient(smdp)
client, err := newES9PClient(ctx, smdp)
if err != nil {
return nil, err
}
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
init, err := client.initiateAuthentication(ctx, challenge, info1)
+27 -9
View File
@@ -8,9 +8,9 @@ import (
"hash/fnv"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"unicode"
)
func platformSupported() error { return nil }
@@ -54,14 +54,15 @@ func boundResolver(networkInterface string) *net.Resolver {
}
func exportRouteDNSServers(networkInterface string) []string {
safeName := strings.Map(func(character rune) rune {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
return character
}
return '_'
}, networkInterface)
file, err := os.Open(filepath.Join("/run/vocat", "cellular-"+safeName+".dns"))
if !validInterfaceName(networkInterface) {
return []string{"1.1.1.1", "8.8.8.8"}
}
root, err := os.OpenRoot("/run/vocat")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
defer root.Close()
file, err := root.Open("cellular-" + networkInterface + ".dns")
if err != nil {
return []string{"1.1.1.1", "8.8.8.8"}
}
@@ -78,3 +79,20 @@ func exportRouteDNSServers(networkInterface string) []string {
}
return servers
}
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
// matches kernel interface names and prevents a stored device value from ever
// becoming a filesystem path component.
func validInterfaceName(value string) bool {
if value == "" || len(value) > 15 || value == "." || value == ".." {
return false
}
for _, character := range value {
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
character == '-' || character == '_' || character == '.') {
return false
}
}
return true
}
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
package exportproxy
import "testing"
func TestValidInterfaceName(t *testing.T) {
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
if !validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = false", value)
}
}
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
if validInterfaceName(value) {
t.Errorf("validInterfaceName(%q) = true", value)
}
}
}
+10 -8
View File
@@ -26,6 +26,7 @@ import (
"time"
"vocat/internal/exportproxy"
"vocat/internal/netguard"
)
const maxPackageBytes int64 = 64 << 20
@@ -73,7 +74,7 @@ func NewManager(root string, logger *slog.Logger) (*Manager, error) {
}
manager := &Manager{
root: root, logger: logger, plugins: make(map[string]*Plugin),
client: &http.Client{Timeout: 45 * time.Second},
client: netguard.NewPublicHTTPClient(45*time.Second, true),
}
if err := manager.scan(); err != nil {
return nil, err
@@ -155,9 +156,9 @@ func (manager *Manager) List() []Plugin {
}
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL")
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
if err != nil {
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
@@ -353,12 +354,13 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
http.NotFound(w, r)
return
}
filename := filepath.Join(plugin.dir, filepath.FromSlash(name))
if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) {
root, err := os.OpenRoot(plugin.dir)
if err != nil {
http.NotFound(w, r)
return
}
file, err := os.Open(filename)
defer root.Close()
file, err := root.Open(filepath.FromSlash(name))
if err != nil {
http.NotFound(w, r)
return
@@ -369,7 +371,7 @@ func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, n
http.NotFound(w, r)
return
}
contentType := mime.TypeByExtension(filepath.Ext(filename))
contentType := mime.TypeByExtension(filepath.Ext(name))
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
+18
View File
@@ -3,12 +3,30 @@ package extensions
import (
"archive/zip"
"bytes"
"context"
"io"
"log/slog"
"strings"
"testing"
)
func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
manager, err := NewManager(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
defer manager.Close()
for _, raw := range []string{
"http://example.com/plugin.zip",
"https://127.0.0.1/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
} {
if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil {
t.Errorf("InstallURL(%q) accepted an unsafe destination", raw)
}
}
}
func TestInstallListDisableAndUninstall(t *testing.T) {
manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
+170
View File
@@ -0,0 +1,170 @@
package netguard
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently
// resolved address is publicly routable. The transport returned by
// NewPublicHTTPClient repeats the same check when it dials, which also prevents
// DNS rebinding between validation and connection establishment.
func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
return nil, errors.New("destination must be an absolute HTTP URL")
}
if parsed.User != nil {
return nil, errors.New("destination URL cannot contain user information")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errors.New("destination URL must use HTTP or HTTPS")
}
if requireHTTPS && parsed.Scheme != "https" {
return nil, errors.New("destination URL must use HTTPS")
}
if port := parsed.Port(); port != "" {
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return nil, errors.New("destination URL has an invalid port")
}
}
if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
// NewPublicHTTPClient creates a client that never uses environment proxies,
// rejects private/special-use destinations at dial time, and validates every
// redirect before following it.
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
if timeout <= 0 {
timeout = 30 * time.Second
}
transport := &http.Transport{
Proxy: nil,
DialContext: PublicDialer(timeout),
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
return &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 4 {
return errors.New("too many redirects")
}
_, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS)
return err
},
}
}
// PublicDialer resolves the original hostname and connects directly to one of
// its validated public addresses. It does not pass the hostname back through a
// second resolver, so a DNS rebinding response cannot redirect the connection.
func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("parse outbound address: %w", err)
}
addresses, err := resolvePublic(ctx, host)
if err != nil {
return nil, err
}
dialer := net.Dialer{Timeout: timeout}
var lastErr error
for _, address := range addresses {
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port))
if err == nil {
return connection, nil
}
lastErr = err
}
return nil, fmt.Errorf("connect to public destination: %w", lastErr)
}
}
func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) {
if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
literal = literal.Unmap()
if !publicAddress(literal) {
return nil, errors.New("destination resolves to a private or special-use address")
}
return []netip.Addr{literal}, nil
}
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("resolve destination: %w", err)
}
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if !publicAddress(address) {
return nil, errors.New("destination resolves to a private or special-use address")
}
result = append(result, address)
}
if len(result) == 0 {
return nil, errors.New("destination has no IP address")
}
return result, nil
}
var blockedNetworks = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.88.99.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
// Block both the well-known and local-use NAT64 prefixes. Otherwise a
// public-looking IPv6 literal could translate to a private IPv4 target.
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("2002::/16"),
}
func publicAddress(address netip.Addr) bool {
if !address.IsValid() || !address.IsGlobalUnicast() {
return false
}
for _, blocked := range blockedNetworks {
if blocked.Contains(address) {
return false
}
}
return true
}
+29
View File
@@ -0,0 +1,29 @@
package netguard
import (
"context"
"testing"
)
func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) {
tests := []string{
"http://127.0.0.1/plugin.zip",
"https://[::1]/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
"https://[64:ff9b::7f00:1]/",
"https://[2002:7f00:1::]/",
"file:///etc/passwd",
"https://user:[email protected]/plugin.zip",
}
for _, raw := range tests {
if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil {
t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw)
}
}
}
func TestValidatePublicURLCanRequireHTTPS(t *testing.T) {
if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil {
t.Fatal("HTTP destination was accepted while HTTPS was required")
}
}
@@ -266,13 +266,13 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
return err
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return err
}
var recipients []*mail.Address
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
address, err := parseMailAddress(item)
if err != nil {
return err
}
@@ -291,7 +291,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
return err
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + from.String(),
"Date: " + time.Now().UTC().Format(time.RFC1123Z), "From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients), "Subject: " + mime.QEncoding.Encode("UTF-8", subject),
"MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", text, "",
}, "\r\n")
+20 -6
View File
@@ -456,6 +456,10 @@ func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request,
s.handleAutomaticTasks(w, r)
return true
}
if len(segments) == 2 && segments[1] == "runs" {
s.handleAutomaticTaskRuns(w, r)
return true
}
id, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
@@ -481,12 +485,7 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
s.writeStoreError(w, err)
return
}
runs, err := s.store.ListAutomaticTaskRuns(r.Context(), 100)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks, "runs": runs}})
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
case http.MethodPost:
task, err := s.decodeAutomaticTask(r, 0)
if err != nil {
@@ -531,6 +530,21 @@ func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id
}
}
func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
query := r.URL.Query()
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}})
}
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
if !requireMethod(w, r, http.MethodPost) {
return
+40 -15
View File
@@ -7,37 +7,62 @@ import (
)
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
if !s.developerEnabled {
if !s.developerActive(r.Context()) {
writeError(w, http.StatusNotFound, "not_found", "resource not found")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
s.writeDeveloperSettings(w, r)
case http.MethodPut:
var request struct {
DeviceLimit int `json:"device_limit"`
DeviceLimit *int `json:"device_limit"`
SMSHourlyLimit *int `json:"sms_hourly_limit"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
if request.DeviceLimit == nil && request.SMSHourlyLimit == nil {
writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required")
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": request.DeviceLimit,
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
}})
if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) {
writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range")
return
}
if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) {
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range")
return
}
if request.DeviceLimit != nil {
if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
}
if request.SMSHourlyLimit != nil {
if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil {
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error())
return
}
s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated")
}
s.writeDeveloperSettings(w, r)
default:
w.Header().Set("Allow", "GET, PUT")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
"default_device_limit": developer.DefaultDeviceLimit,
"max_device_limit": developer.MaxDeviceLimit,
"sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store),
"default_sms_hourly_limit": developer.DefaultSMSHourlyLimit,
"max_sms_hourly_limit": developer.MaxSMSHourlyLimit,
}})
}
@@ -1,9 +1,15 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"vocat/internal/developer"
"vocat/internal/store"
)
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
@@ -20,3 +26,27 @@ func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
}
}
}
func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil {
t.Fatal(err)
}
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleDeveloperSettings(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
}
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
t.Fatalf("SMS hourly limit = %d, want 25", got)
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, clean
if !requireMethod(w, r, http.MethodPost) {
return true
}
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20))
r.Body = http.MaxBytesReader(nil, r.Body, maxPluginUploadBytes+(1<<20))
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
return true
+141 -58
View File
@@ -26,8 +26,8 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries})
case "upstream-proxy-country-rules":
s.handleCountryRules(w, r)
case "upstream-proxy-device-bindings":
s.handleDeviceProxyBindings(w, r)
case "upstream-proxy-profile-bindings":
s.handleProfileProxyBindings(w, r)
default:
segments := splitAPIPath(cleanPath)
switch {
@@ -40,8 +40,6 @@ func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath
s.handleUpstreamProbe(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules":
s.handleCountryRule(w, r, segments[1])
case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings":
s.handleDeviceProxyBinding(w, r, segments[1])
default:
return false
}
@@ -114,7 +112,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
for _, binding := range bindings {
if binding.UpstreamProxyID == id {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
@@ -124,36 +122,32 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
}
func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
type profileProxyBindingPayload struct {
DeviceID string `json:"device_id"`
ICCID string `json:"iccid"`
ProfileName string `json:"profile_name"`
// Accepted for compatibility with the first profile-picker bundle, which
// sent the read-only display state together with the writable identity.
StateText string `json:"state_text,omitempty"`
}
func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) {
deviceID = strings.TrimSpace(deviceID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
func (s *Server) handleProfileProxyBindings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
case http.MethodGet:
values, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
result := make([]map[string]any, 0, len(values))
for _, value := range values {
result = append(result, deviceProxyBindingResponse(value))
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
case http.MethodPost:
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
UpstreamProxyID string `json:"upstream_proxy_id"`
Bindings []profileProxyBindingPayload `json:"bindings"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
@@ -166,44 +160,114 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
return
}
if !upstream.Enabled {
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device")
writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a profile")
return
}
// Once bound, a device may not be silently rebinded to a different
// upstream proxy. Force the caller to DELETE first so the change is
// intentional. Re-binding the same upstream stays idempotent.
if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
if len(request.Bindings) == 0 || len(request.Bindings) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID}
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
values := make([]store.DeviceProxyBinding, 0, len(request.Bindings))
seen := make(map[string]struct{}, len(request.Bindings))
for _, item := range request.Bindings {
deviceID := strings.TrimSpace(item.DeviceID)
iccid := strings.TrimSpace(item.ICCID)
if !validDeviceID(deviceID) {
writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters")
return
}
if !validProfileICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "profile ICCID must contain 18 to 22 digits")
return
}
if _, duplicate := seen[iccid]; duplicate {
writeError(w, http.StatusBadRequest, "duplicate_iccid", "the same ICCID was selected more than once")
return
}
seen[iccid] = struct{}{}
if _, err := s.store.Device(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
return
}
if existing, err := s.store.DeviceProxyBinding(r.Context(), iccid); err == nil && existing.UpstreamProxyID != upstream.ID {
writeError(w, http.StatusConflict, "profile_already_bound", "this ICCID is already bound to another upstream proxy; delete that binding first")
return
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
name := strings.TrimSpace(item.ProfileName)
if name == "" {
name = iccid
}
values = append(values, store.DeviceProxyBinding{DeviceID: deviceID, ICCID: iccid, ProfileName: name, UpstreamProxyID: upstream.ID})
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := deviceProxyBindingResponse(value)
response["reconnect_requested"] = reconnected
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
requested := false
var reconnectErrors []string
for _, value := range values {
if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
}
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(value.DeviceID, value.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"created": len(values), "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
case http.MethodDelete:
if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil {
s.writeStoreError(w, err)
var request struct {
UpstreamProxyID string `json:"upstream_proxy_id"`
ICCIDs []string `json:"iccids"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID)
response := map[string]any{"deleted": true, "reconnect_requested": reconnected}
if reconnectErr != nil {
response["reconnect_error"] = reconnectErr.Error()
if len(request.ICCIDs) == 0 || len(request.ICCIDs) > 200 {
writeError(w, http.StatusBadRequest, "invalid_bindings", "select between 1 and 200 profiles")
return
}
requested := false
deleted := 0
var reconnectErrors []string
for _, rawICCID := range request.ICCIDs {
iccid := strings.TrimSpace(rawICCID)
binding, err := s.store.DeviceProxyBinding(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
continue
}
if err != nil {
s.writeStoreError(w, err)
return
}
if strings.TrimSpace(request.UpstreamProxyID) != "" && binding.UpstreamProxyID != strings.TrimSpace(request.UpstreamProxyID) {
writeError(w, http.StatusConflict, "binding_proxy_mismatch", "selected ICCID is not bound to this upstream proxy")
return
}
if err := s.store.DeleteDeviceProxyBinding(r.Context(), iccid); err != nil {
s.writeStoreError(w, err)
return
}
deleted++
reconnected, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
requested = requested || reconnected
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := map[string]any{"deleted": deleted, "reconnect_requested": requested}
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
default:
w.Header().Set("Allow", "PUT, DELETE")
w.Header().Set("Allow", "GET, POST, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -211,7 +275,7 @@ func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request
// A binding is already durable before this is called. Reconnect failures are
// returned as advisory information: the chosen route will still be used on
// the next VoWiFi start/reconnect.
func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
func (s *Server) requestProfileProxyRouteReconnect(deviceID, iccid string) (bool, error) {
if s.vowifi == nil {
return false, nil
}
@@ -222,6 +286,10 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
if !config.VoWiFiEnabled {
return false, nil
}
state, stateErr := s.vowifi.State(deviceID)
if stateErr != nil || strings.TrimSpace(state.ICCID) == "" || strings.TrimSpace(state.ICCID) != strings.TrimSpace(iccid) {
return false, nil
}
if _, err := s.vowifi.RequestReconnect(deviceID); err != nil {
s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err)
return false, err
@@ -229,6 +297,19 @@ func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) {
return true, nil
}
func validProfileICCID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 18 || len(value) > 22 {
return false
}
for _, digit := range value {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func (s *Server) saveAndProbeUpstream(
w http.ResponseWriter,
r *http.Request,
@@ -258,7 +339,7 @@ func (s *Server) saveAndProbeUpstream(
}
for _, binding := range bindings {
if binding.UpstreamProxyID == saved.ID {
s.requestProxyRouteReconnect(binding.DeviceID)
s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
}
}
probe, probeErr := localproxy.ProbeSOCKS5(
@@ -449,6 +530,8 @@ func countryRuleResponse(value store.CountryRule) map[string]any {
func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any {
return map[string]any{
"device_id": value.DeviceID,
"iccid": value.ICCID,
"profile_name": value.ProfileName,
"upstream_proxy_id": value.UpstreamProxyID,
}
}
+61 -95
View File
@@ -10,120 +10,86 @@ import (
"testing"
"vocat/internal/store"
"vocat/internal/vowifi"
)
func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) {
const testProfileICCID = "89441000400128014257"
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
t.Helper()
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true,
}); err != nil {
t.Fatal(err)
}
controller := &fakeVoWiFiController{}
server := &Server{
store: database,
vowifi: controller,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
}
request := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want 1", controller.reconnects)
}
request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil)
response = httptest.NewRecorder()
server.handleDeviceProxyBinding(response, request, "ec20")
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
if controller.reconnects != 2 {
t.Fatalf("reconnects = %d, want 2", controller.reconnects)
}
}
func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) {
database, err := store.Open(context.Background(), ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "ec20", Name: "EC20", VoWiFiEnabled: true,
}); err != nil {
t.Fatal(err)
}
for _, up := range []store.UpstreamProxy{
for _, upstream := range []store.UpstreamProxy{
{ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil {
if err := database.UpsertUpstreamProxy(context.Background(), upstream); err != nil {
t.Fatal(err)
}
}
server := &Server{
store: database,
vowifi: &fakeVoWiFiController{},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
maxRequestBodyBytes: 4096,
controller := &fakeVoWiFiController{state: vowifi.State{DeviceID: "ec20", ICCID: testProfileICCID, Enabled: true}}
return &Server{store: database, vowifi: controller, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), maxRequestBodyBytes: 16 << 10}, database, controller
}
func profileBindingRequest(t *testing.T, server *Server, method, body string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, "/api/upstream-proxy-profile-bindings", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleProfileProxyBindings(response, request)
return response
}
func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" || binding.ProfileName != "Vodafone UK" {
t.Fatalf("binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
}
// First bind to route-1 succeeds.
put := func(proxyID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(
http.MethodPut,
"/api/upstream-proxy-device-bindings/ec20",
bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.handleDeviceProxyBinding(rec, req, "ec20")
return rec
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
if response.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
}
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String())
if _, err := database.DeviceProxyBinding(context.Background(), testProfileICCID); err != store.ErrNotFound {
t.Fatalf("binding after delete error = %v, want ErrNotFound", err)
}
// Rebind to a different upstream must be rejected with 409.
rec := put("route-2")
if rec.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), "ec20")
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err)
}
// Re-binding the SAME upstream stays idempotent (no 409).
if rec := put("route-1"); rec.Code != http.StatusOK {
t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String())
if controller.reconnects != 2 {
t.Fatalf("reconnects after delete = %d, want 2", controller.reconnects)
}
}
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
server, database, _ := newProfileBindingTestServer(t)
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if first.Code != http.StatusOK {
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
}
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
if second.Code != http.StatusConflict {
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), testProfileICCID)
if err != nil || binding.UpstreamProxyID != "route-1" {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
+4 -1
View File
@@ -414,7 +414,10 @@ func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination
}
}
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
// MaxBytesReader's ResponseWriter parameter is deprecated and unused by Go.
// Passing nil also makes the request body and response data flows explicitly
// separate for static analysis.
r.Body = http.MaxBytesReader(nil, r.Body, s.maxRequestBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
+30 -5
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -278,7 +279,7 @@ func validateNotificationField(
}
}
if name == "from_address" && value != "" {
if _, err := mail.ParseAddress(value); err != nil {
if _, err := parseMailAddress(value); err != nil {
return fmt.Errorf("%s is not a valid email address", field)
}
}
@@ -763,13 +764,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return fmt.Errorf("parse sender address: %w", err)
}
recipients := make([]*mail.Address, 0)
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
address, err := parseMailAddress(item)
if err != nil {
return fmt.Errorf("parse recipient address: %w", err)
}
@@ -789,7 +790,7 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
}
message := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + from.String(),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: vocat notification test",
"MIME-Version: 1.0",
@@ -814,11 +815,35 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
func joinMailAddresses(values []*mail.Address) string {
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, value.String())
result = append(result, formatMailAddress(value))
}
return strings.Join(result, ", ")
}
func parseMailAddress(value string) (*mail.Address, error) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, "\r\n\x00") {
return nil, errors.New("email address contains a prohibited control character")
}
address, err := mail.ParseAddress(value)
if err != nil || address.Address == "" || strings.ContainsAny(address.Address, "\r\n\x00") {
return nil, errors.New("invalid email address")
}
for _, character := range address.Name {
if character < 0x20 || character == 0x7f {
return nil, errors.New("email display name contains a prohibited control character")
}
}
return address, nil
}
func formatMailAddress(address *mail.Address) string {
if address.Name == "" {
return address.Address
}
return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">"
}
func restrictedHTTPClient(
ctx context.Context,
timeout time.Duration,
+20
View File
@@ -583,3 +583,23 @@ func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) {
t.Fatal("unknown path was claimed by settings router")
}
}
func TestParseMailAddressRejectsHeaderInjection(t *testing.T) {
for _, value := range []string{
"[email protected]\r\nBcc: [email protected]",
"[email protected]\nX-Test: injected",
"display\x00name <[email protected]>",
} {
if _, err := parseMailAddress(value); err == nil {
t.Errorf("parseMailAddress(%q) accepted header injection", value)
}
}
address, err := parseMailAddress("Vocat Alerts <[email protected]>")
if err != nil {
t.Fatal(err)
}
header := formatMailAddress(address)
if strings.ContainsAny(header, "\r\n") {
t.Fatalf("formatted address contains a line break: %q", header)
}
}
+34
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/store"
"vocat/internal/vowifi"
@@ -224,6 +225,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "blocked_destination", reason)
return
}
// Validate the logical message before consuming a global send slot. Both
// cellular AT and VoWiFi IMS use this same encoder/validator.
if _, err := device.PrepareSMSSubmitTPDUs(request.Phone, request.Message); err != nil {
s.writeDeviceError(w, err)
return
}
config, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
s.writeStoreError(w, err)
@@ -233,6 +240,33 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
if !s.requirePhysicalDevice(w, present) {
return
}
limit := developer.SMSHourlyLimit(r.Context(), s.store)
reservation, err := s.store.ReserveSMSSend(r.Context(), request.DeviceID, limit, time.Now().UTC())
if err != nil {
s.writeStoreError(w, err)
return
}
if !reservation.Allowed {
retryAfter := time.Until(reservation.ResetAt)
if retryAfter < time.Second {
retryAfter = time.Second
}
w.Header().Set("Retry-After", strconv.FormatInt(int64((retryAfter+time.Second-1)/time.Second), 10))
writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": apiError{
Code: "sms_rate_limited",
Message: fmt.Sprintf("Global SMS limit reached: at most %d messages may be submitted in a rolling one-hour window.", reservation.Limit),
},
"data": map[string]any{
"limit": reservation.Limit,
"used": reservation.Used,
"remaining": reservation.Remaining,
"reset_at": reservation.ResetAt,
"retry_after": int64((retryAfter + time.Second - 1) / time.Second),
},
})
return
}
if config.VoWiFiEnabled && s.vowifi != nil {
state, stateErr := s.vowifi.State(request.DeviceID)
sender, canSendIMS := s.vowifi.(imsSMSController)
+52
View File
@@ -5,9 +5,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"vocat/internal/developer"
"vocat/internal/device"
"vocat/internal/store"
)
@@ -155,3 +158,52 @@ func TestBlockedSMSDestination(t *testing.T) {
})
}
}
func TestHandleSMSSendEnforcesGlobalHourlyLimit(t *testing.T) {
ctx := context.Background()
database, err := store.Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := developer.SetSMSHourlyLimit(ctx, database, 1); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if reservation, err := database.ReserveSMSSend(ctx, "another-device", 1, time.Now().UTC()); err != nil || !reservation.Allowed {
t.Fatalf("seed global SMS reservation = %+v, %v", reservation, err)
}
server := &Server{
store: database,
logger: regionTestLogger(),
maxRequestBodyBytes: 4096,
devices: fakeDeviceController{entry: device.Device{
ID: "ec20_1",
Discovered: true,
Snapshot: &device.Snapshot{DeviceID: "ec20_1"},
}},
}
request := httptest.NewRequest(
http.MethodPost,
"/api/sms/send",
strings.NewReader(`{"device_id":"ec20_1","phone":"+447700900123","message":"hello"}`),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.handleSMSSend(response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429; body=%s", response.Code, response.Body.String())
}
if response.Header().Get("Retry-After") == "" {
t.Fatal("Retry-After header is missing")
}
var envelope errorEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Error.Code != "sms_rate_limited" {
t.Fatalf("error code = %q, want sms_rate_limited", envelope.Error.Code)
}
}
+3 -3
View File
@@ -402,13 +402,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
}
}
from, err := mail.ParseAddress(configString(config, "from_address"))
from, err := parseMailAddress(configString(config, "from_address"))
if err != nil {
return fmt.Errorf("parse sender address: %w", err)
}
recipients := make([]*mail.Address, 0)
for _, item := range configStrings(config, "to_addresses") {
address, err := mail.ParseAddress(item)
address, err := parseMailAddress(item)
if err != nil {
return fmt.Errorf("parse recipient address: %w", err)
}
@@ -428,7 +428,7 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + from.String(),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
"MIME-Version: 1.0",
+38 -3
View File
@@ -179,16 +179,51 @@ func (s *Store) UpdateAutomaticTaskRun(ctx context.Context, run AutomaticTaskRun
return err
}
const automaticTaskRunSelect = `
SELECT id, task_id, device_id, scheduled_at, started_at, finished_at,
status, attempts, output, error, created_at, updated_at
FROM automatic_task_runs`
func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]AutomaticTaskRun, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, device_id, scheduled_at,
started_at, finished_at, status, attempts, output, error, created_at, updated_at
FROM automatic_task_runs ORDER BY id DESC LIMIT ?`, limit)
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
return scanAutomaticTaskRuns(rows)
}
// ListAutomaticTaskRunsPaginated returns one page of runs (newest first) plus
// the total run count, so the UI can page through the full history instead of
// a fixed recent window.
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
total := 0
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count automatic task runs: %w", err)
}
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, 0, err
}
runs, err := scanAutomaticTaskRuns(rows)
if err != nil {
return nil, 0, err
}
return runs, total, nil
}
func scanAutomaticTaskRuns(rows *sql.Rows) ([]AutomaticTaskRun, error) {
defer rows.Close()
var result []AutomaticTaskRun
for rows.Next() {
+48
View File
@@ -70,3 +70,51 @@ func TestDeletingAutomaticTaskRemovesRunHistory(t *testing.T) {
t.Fatalf("orphan runs = %+v, %v", runs, err)
}
}
func TestListAutomaticTaskRunsPaginated(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-runs-page.db"))
mustSaveDevice(t, database, "ec20", "EC20")
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
Name: "task", Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
TaskType: "call", Environment: "cellular", IntervalDays: 1,
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", Payload: []byte(`{"phone":"10086","duration_seconds":10}`),
NextRunAt: time.Now().Add(time.Hour),
})
if err != nil {
t.Fatal(err)
}
for index := 0; index < 5; index++ {
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
t.Fatal(err)
}
}
first, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 0)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(first) != 2 {
t.Fatalf("first page: total = %d, runs = %+v", total, first)
}
if first[0].ID <= first[1].ID {
t.Fatalf("runs not newest-first: %+v", first)
}
last, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 2, 4)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(last) != 1 {
t.Fatalf("last page: total = %d, runs = %+v", total, last)
}
// Out-of-range paging inputs are clamped to defaults, not errors.
all, total, err := database.ListAutomaticTaskRunsPaginated(ctx, 0, -5)
if err != nil {
t.Fatal(err)
}
if total != 5 || len(all) != 5 {
t.Fatalf("clamped page: total = %d, runs = %+v", total, all)
}
}
+47 -4
View File
@@ -59,6 +59,7 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
"device_proxy_bindings",
"notification_settings", "app_settings", "audit_events",
"log_events", "card_policies", "traffic_buckets",
"sms_send_attempts",
} {
var found string
err := database.db.QueryRowContext(ctx, `
@@ -105,6 +106,48 @@ func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
}
}
func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "profile-proxy-binding.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
for version := 1; version <= 11; version++ {
for _, statement := range migrationStatements(version) {
if _, err := raw.ExecContext(ctx, statement); err != nil {
t.Fatalf("create v%d schema: %v", version, err)
}
}
}
if _, err := raw.ExecContext(ctx, `
INSERT INTO devices (id, name, created_at, updated_at) VALUES
('known', 'Known', 100, 100), ('unknown', 'Unknown', 100, 100);
INSERT INTO upstream_proxies (id, name, addr, created_at, updated_at)
VALUES ('route', 'Route', '127.0.0.1:1080', 100, 100);
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
VALUES ('known', '89441000400128014257', 100);
PRAGMA user_version = 11;
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database := openTestStore(t, path)
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
t.Fatalf("migrated binding = %+v, %v", binding, err)
}
bindings, err := database.ListDeviceProxyBindings(ctx)
if err != nil || len(bindings) != 1 {
t.Fatalf("migrated bindings = %+v, %v; unknown ICCID binding must be dropped", bindings, err)
}
}
func TestMigration9NormalizesVoWiFiAirplanePolicy(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "rf-safe-policy.db")
@@ -603,12 +646,12 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
t.Fatalf("CountryRule() = %+v, %v", rule, err)
}
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
DeviceID: "ec20-1", UpstreamProxyID: "up-1",
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
}); err != nil {
t.Fatal(err)
}
binding, err := database.DeviceProxyBinding(ctx, "ec20-1")
if err != nil || binding.UpstreamProxyID != "up-1" {
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
}
if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil {
@@ -617,7 +660,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
}
if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) {
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
}
}
+41
View File
@@ -186,6 +186,47 @@ func migrationStatements(version int) []string {
`CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`,
}
case 11:
return []string{
`CREATE TABLE IF NOT EXISTS sms_send_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx
ON sms_send_attempts(created_at, id)`,
}
case 12:
return []string{
`ALTER TABLE device_proxy_bindings RENAME TO device_proxy_bindings_v11`,
`CREATE TABLE device_proxy_bindings (
iccid TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
profile_name TEXT NOT NULL DEFAULT '',
upstream_proxy_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE
)`,
// A legacy device-wide binding is safe to preserve only when the
// currently observed ICCID is known. It then becomes one profile binding
// instead of leaking onto every future profile used by that device.
`INSERT OR IGNORE INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
)
SELECT COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')),
b.device_id, '', b.upstream_proxy_id, b.created_at, b.updated_at
FROM device_proxy_bindings_v11 b
LEFT JOIN vowifi_runtime v ON v.device_id = b.device_id
LEFT JOIN device_runtime d ON d.device_id = b.device_id
WHERE COALESCE(NULLIF(v.iccid, ''), NULLIF(d.iccid, '')) IS NOT NULL`,
`DROP TABLE device_proxy_bindings_v11`,
`CREATE INDEX device_proxy_bindings_proxy_idx
ON device_proxy_bindings(upstream_proxy_id)`,
`CREATE INDEX device_proxy_bindings_device_idx
ON device_proxy_bindings(device_id, iccid)`,
}
default:
return nil
}
+4 -3
View File
@@ -302,11 +302,12 @@ type CountryRule struct {
UpdatedAt time.Time
}
// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole
// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then
// travel inside that tunnel.
// DeviceProxyBinding selects the SOCKS5 upstream for exactly one eSIM profile.
// ICCID is globally unique, while one proxy may serve profiles on many devices.
type DeviceProxyBinding struct {
DeviceID string
ICCID string
ProfileName string
UpstreamProxyID string
CreatedAt time.Time
UpdatedAt time.Time
+21 -17
View File
@@ -358,9 +358,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.UpstreamProxyID == "" {
return errors.New("device proxy binding requires device and upstream proxy IDs")
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
}
now := time.Now().UTC()
createdAt := value.CreatedAt
@@ -373,28 +375,30 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
device_id, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(device_id) DO UPDATE SET
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
device_id = excluded.device_id,
profile_name = excluded.profile_name,
upstream_proxy_id = excluded.upstream_proxy_id,
updated_at = excluded.updated_at
`, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
if err != nil {
return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err)
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
}
return nil
}
func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) {
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
return deviceProxyBinding(s.db.QueryRowContext(
ctx,
deviceProxyBindingSelect+` WHERE device_id = ?`,
strings.TrimSpace(deviceID),
deviceProxyBindingSelect+` WHERE iccid = ?`,
strings.TrimSpace(iccid),
))
}
func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) {
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`)
rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id, profile_name COLLATE NOCASE, iccid`)
if err != nil {
return nil, fmt.Errorf("list device proxy bindings: %w", err)
}
@@ -413,26 +417,26 @@ func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBindi
return values, nil
}
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error {
func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, iccid string) error {
result, err := s.db.ExecContext(
ctx,
`DELETE FROM device_proxy_bindings WHERE device_id = ?`,
strings.TrimSpace(deviceID),
`DELETE FROM device_proxy_bindings WHERE iccid = ?`,
strings.TrimSpace(iccid),
)
if err != nil {
return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err)
return fmt.Errorf("delete proxy binding for ICCID %q: %w", iccid, err)
}
return requireAffected(result)
}
const deviceProxyBindingSelect = `
SELECT device_id, upstream_proxy_id, created_at, updated_at
SELECT device_id, iccid, profile_name, upstream_proxy_id, created_at, updated_at
FROM device_proxy_bindings`
func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) {
var value DeviceProxyBinding
var createdAt, updatedAt int64
err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt)
err := row.Scan(&value.DeviceID, &value.ICCID, &value.ProfileName, &value.UpstreamProxyID, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return DeviceProxyBinding{}, ErrNotFound
}
+88
View File
@@ -0,0 +1,88 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
const SMSRateWindow = time.Hour
// SMSRateReservation is the durable result of claiming one global outbound
// SMS slot. The quota is shared by every device, SIM, transport, and caller.
type SMSRateReservation struct {
Allowed bool
Limit int
Used int
Remaining int
ResetAt time.Time
}
// ReserveSMSSend atomically claims one slot in the rolling one-hour window.
// It intentionally records submission attempts separately from SMS history so
// deleting a conversation cannot reset the global safety limit.
func (s *Store) ReserveSMSSend(
ctx context.Context,
deviceID string,
limit int,
now time.Time,
) (SMSRateReservation, error) {
if limit < 1 {
return SMSRateReservation{}, errors.New("SMS hourly limit must be positive")
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
cutoff := now.Add(-SMSRateWindow).Unix()
result, err := s.db.ExecContext(ctx, `
INSERT INTO sms_send_attempts (device_id, created_at)
SELECT ?, ?
WHERE (
SELECT COUNT(*) FROM sms_send_attempts WHERE created_at > ?
) < ?
`, strings.TrimSpace(deviceID), now.Unix(), cutoff, limit)
if err != nil {
return SMSRateReservation{}, fmt.Errorf("reserve global SMS send slot: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return SMSRateReservation{}, fmt.Errorf("read global SMS reservation result: %w", err)
}
status, err := s.smsRateStatus(ctx, limit, cutoff)
if err != nil {
return SMSRateReservation{}, err
}
status.Allowed = affected == 1
if status.Allowed {
// Old rows are irrelevant to enforcement. Pruning after the atomic claim
// keeps the hot index compact without creating a delete-before-insert race.
_, _ = s.db.ExecContext(ctx, `DELETE FROM sms_send_attempts WHERE created_at <= ?`, now.Add(-7*24*time.Hour).Unix())
}
return status, nil
}
func (s *Store) smsRateStatus(ctx context.Context, limit int, cutoff int64) (SMSRateReservation, error) {
var used int
var earliest *int64
if err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*), MIN(created_at)
FROM sms_send_attempts
WHERE created_at > ?
`, cutoff).Scan(&used, &earliest); err != nil {
return SMSRateReservation{}, fmt.Errorf("read global SMS rate status: %w", err)
}
remaining := limit - used
if remaining < 0 {
remaining = 0
}
status := SMSRateReservation{Limit: limit, Used: used, Remaining: remaining}
if earliest != nil {
status.ResetAt = time.Unix(*earliest, 0).UTC().Add(SMSRateWindow)
}
return status, nil
}
+58
View File
@@ -0,0 +1,58 @@
package store
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestReserveSMSSendIsGlobalAndRolling(t *testing.T) {
database := openTestStore(t, ":memory:")
now := time.Unix(1_800_000_000, 0).UTC()
first, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now)
if err != nil || !first.Allowed || first.Used != 1 || first.Remaining != 1 {
t.Fatalf("first reservation = %+v, %v", first, err)
}
second, err := database.ReserveSMSSend(context.Background(), "ec20_2", 2, now.Add(time.Second))
if err != nil || !second.Allowed || second.Used != 2 || second.Remaining != 0 {
t.Fatalf("second reservation = %+v, %v", second, err)
}
blocked, err := database.ReserveSMSSend(context.Background(), "another-device", 2, now.Add(2*time.Second))
if err != nil || blocked.Allowed || blocked.Used != 2 || !blocked.ResetAt.Equal(now.Add(SMSRateWindow)) {
t.Fatalf("blocked reservation = %+v, %v", blocked, err)
}
afterWindow, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now.Add(SMSRateWindow+time.Second))
if err != nil || !afterWindow.Allowed || afterWindow.Used != 1 {
t.Fatalf("reservation after rolling window = %+v, %v", afterWindow, err)
}
}
func TestReserveSMSSendCannotExceedLimitConcurrently(t *testing.T) {
database := openTestStore(t, ":memory:")
now := time.Unix(1_800_000_000, 0).UTC()
const limit = 10
const callers = 40
var allowed atomic.Int32
var wait sync.WaitGroup
for index := 0; index < callers; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
result, err := database.ReserveSMSSend(context.Background(), "device", limit, now)
if err != nil {
t.Errorf("reservation %d: %v", index, err)
return
}
if result.Allowed {
allowed.Add(1)
}
}(index)
}
wait.Wait()
if got := allowed.Load(); got != limit {
t.Fatalf("allowed reservations = %d, want %d", got, limit)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 10
const schemaVersion = 12
var ErrNotFound = errors.New("store: not found")
+6 -1
View File
@@ -205,8 +205,13 @@ func (relay *sessionRelay) terminalError() error {
func (relay *sessionRelay) Close() error {
relay.cancel()
// ReceiveSessionPacket implementations normally observe the canceled
// context through a short read deadline. Close the transport as an explicit
// wake-up as well: a socket implementation that is stuck in Read must not
// hold teardown (and the associated TUN interface) indefinitely.
transportErr := relay.transport.Close()
<-relay.done
return relay.terminalErrorIfFailure()
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
}
func (relay *sessionRelay) terminalErrorIfFailure() error {
+50 -6
View File
@@ -22,12 +22,13 @@ type fakeSentPacket struct {
}
type fakeSessionTransport struct {
incoming chan fakeSessionPacket
sent chan fakeSentPacket
closed chan struct{}
once sync.Once
readers atomic.Int32
maxReads atomic.Int32
incoming chan fakeSessionPacket
sent chan fakeSentPacket
closed chan struct{}
ignoreContext bool
once sync.Once
readers atomic.Int32
maxReads atomic.Int32
}
func newFakeSessionTransport() *fakeSessionTransport {
@@ -81,6 +82,18 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
}
}
defer transport.readers.Add(-1)
if transport.ignoreContext {
select {
case packet := <-transport.incoming:
if packet.err != nil {
return 0, false, packet.err
}
copy(buffer, packet.data)
return len(packet.data), packet.ike, nil
case <-transport.closed:
return 0, false, net.ErrClosed
}
}
select {
case packet := <-transport.incoming:
if packet.err != nil {
@@ -96,6 +109,37 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket(
return 0, false, net.ErrClosed
}
}
func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
transport := newFakeSessionTransport()
transport.ignoreContext = true
relay := newSessionRelay(
transport,
legacyTestSuite(),
ikeKeys{},
[8]byte{1},
[8]byte{2},
true,
time.Hour,
)
deadline := time.Now().Add(time.Second)
for transport.readers.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if transport.readers.Load() == 0 {
t.Fatal("relay did not enter the transport read")
}
done := make(chan error, 1)
go func() { done <- relay.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("close relay: %v", err)
}
case <-time.After(time.Second):
t.Fatal("relay Close did not interrupt the transport read")
}
}
func (transport *fakeSessionTransport) Close() error {
transport.once.Do(func() { close(transport.closed) })
return nil
+82 -10
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
@@ -21,6 +20,8 @@ import (
const userspaceTunnelMTU = 1380
const userspaceTunnelPollInterval = 100 * time.Millisecond
type linuxUserspaceInstaller struct {
ipCommand string
}
@@ -30,6 +31,7 @@ type linuxUserspaceHandle struct {
config ChildSAConfig
tunnel *espTunnel
tun *os.File
tunFD int
relay NATTPacketRelay
runContext context.Context
@@ -93,6 +95,7 @@ func (installer linuxUserspaceInstaller) Install(
config: cloneChildSAConfig(config),
tunnel: tunnel,
tun: tun,
tunFD: int(tun.Fd()),
relay: config.Relay,
runContext: runContext,
cancel: cancel,
@@ -128,6 +131,15 @@ func openLinuxTUN(name string) (*os.File, string, error) {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: create TUN interface: %w", err)
}
// A blocking TUN read is not guaranteed to wake when another goroutine
// closes the descriptor on Linux. Keep the descriptor non-blocking and use
// poll below so cancellation can always drain the data-plane workers before
// the interface is released. Without this, a failed session can retain the
// TUN forever and every automatic reconnect fails with EBUSY.
if err := unix.SetNonblock(descriptor, true); err != nil {
_ = unix.Close(descriptor)
return nil, "", fmt.Errorf("ike: make TUN interface cancellable: %w", err)
}
file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name())
if file == nil {
_ = unix.Close(descriptor)
@@ -475,7 +487,7 @@ func (handle *linuxUserspaceHandle) copyTUNToRelay() {
defer handle.wait.Done()
buffer := make([]byte, 65535)
for {
count, err := handle.tun.Read(buffer)
count, err := readTUNPacket(handle.runContext, handle.tunFD, buffer)
if err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: read TUN packet: %w", err))
@@ -520,7 +532,7 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
// without allowing a forged datagram to tear down the CHILD_SA.
continue
}
if err := writeFull(handle.tun, cleartext); err != nil {
if err := writeTUNPacket(handle.runContext, handle.tunFD, cleartext); err != nil {
if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) {
handle.fail(fmt.Errorf("ike: write TUN packet: %w", err))
}
@@ -529,17 +541,74 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() {
}
}
func writeFull(destination io.Writer, packet []byte) error {
count, err := destination.Write(packet)
if err != nil {
return err
func readTUNPacket(ctx context.Context, descriptor int, buffer []byte) (int, error) {
for {
if err := ctx.Err(); err != nil {
return 0, err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLIN)
if err != nil {
return 0, err
}
if !ready {
continue
}
count, err := unix.Read(descriptor, buffer)
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
return count, err
}
if count != len(packet) {
return io.ErrShortWrite
}
func writeTUNPacket(ctx context.Context, descriptor int, packet []byte) error {
for written := 0; written < len(packet); {
if err := ctx.Err(); err != nil {
return err
}
ready, err := pollTUN(ctx, descriptor, unix.POLLOUT)
if err != nil {
return err
}
if !ready {
continue
}
count, err := unix.Write(descriptor, packet[written:])
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
continue
}
if err != nil {
return err
}
if count == 0 {
return errors.New("ike: zero-length TUN write")
}
written += count
}
return nil
}
func pollTUN(ctx context.Context, descriptor int, events int16) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
poll := []unix.PollFd{{Fd: int32(descriptor), Events: events}}
count, err := unix.Poll(poll, int(userspaceTunnelPollInterval/time.Millisecond))
if errors.Is(err, unix.EINTR) {
return false, nil
}
if err != nil {
return false, err
}
if count == 0 {
return false, nil
}
if poll[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, os.ErrClosed
}
return poll[0].Revents&events != 0, nil
}
func (handle *linuxUserspaceHandle) fail(err error) {
handle.mu.Lock()
notify := false
@@ -583,9 +652,12 @@ func (handle *linuxUserspaceHandle) Close(ctx context.Context) error {
handle.mu.Unlock()
handle.cancelRun()
// Workers use a non-blocking, polled TUN descriptor and therefore leave on
// cancellation without requiring a cross-goroutine close. Wait first so no
// blocked syscall can retain the interface after Close returns.
handle.wait.Wait()
cleanupErr := handle.cleanupNetwork(ctx)
handle.closeTUN()
handle.wait.Wait()
// A terminal data-plane error is delivered exactly once through Failures.
// Close reports only teardown errors so the orchestrator does not record
// the same runtime cause again as a cleanup failure.
+22 -20
View File
@@ -24,13 +24,13 @@ type digestChallenge struct {
}
type digestCredentials struct {
Username string
Password []byte
AUTS string
URI string
Method string
CNonce string
NC uint32
Username string
AKAResponse []byte
AUTS string
URI string
Method string
CNonce string
NC uint32
}
func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) {
@@ -146,7 +146,7 @@ func parseAuthDirectives(value string) (map[string]string, error) {
}
type akaMaterial struct {
password []byte
response []byte
auts []byte
ck []byte
ik []byte
@@ -156,7 +156,7 @@ func clearAKAMaterial(material *akaMaterial) {
if material == nil {
return
}
zeroBytes(material.password)
zeroBytes(material.response)
zeroBytes(material.auts)
zeroBytes(material.ck)
zeroBytes(material.ik)
@@ -193,7 +193,7 @@ func authenticateAKA(
return akaMaterial{}, err
}
return akaMaterial{
password: res,
response: res,
ck: append([]byte(nil), result.CK...),
ik: append([]byte(nil), result.IK...),
}, nil
@@ -231,7 +231,7 @@ func extractRES(result vowifi.AKAResult) ([]byte, error) {
func newDigestCredentials(
username string,
password []byte,
akaResponse []byte,
uri string,
method string,
nc uint32,
@@ -241,12 +241,12 @@ func newDigestCredentials(
return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err)
}
return digestCredentials{
Username: username,
Password: password,
URI: uri,
Method: method,
CNonce: hex.EncodeToString(cnonceBytes),
NC: nc,
Username: username,
AKAResponse: akaResponse,
URI: uri,
Method: method,
CNonce: hex.EncodeToString(cnonceBytes),
NC: nc,
}, nil
}
@@ -255,7 +255,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
response := digestResponse(
credentials.Username,
challenge.Realm,
credentials.Password,
credentials.AKAResponse,
credentials.Method,
credentials.URI,
challenge.Nonce,
@@ -290,7 +290,7 @@ func buildDigestAuthorization(challenge digestChallenge, credentials digestCrede
func digestResponse(
username string,
realm string,
password []byte,
akaResponse []byte,
method string,
uri string,
nonce string,
@@ -300,7 +300,9 @@ func digestResponse(
) string {
ha1Hash := md5.New()
_, _ = ha1Hash.Write([]byte(username + ":" + realm + ":"))
_, _ = ha1Hash.Write(password)
// AKAv1-MD5 is mandated by the IMS server challenge (3GPP TS 33.203).
// akaResponse is the short-lived USIM RES value, not a stored password.
_, _ = ha1Hash.Write(akaResponse)
ha1 := hex.EncodeToString(ha1Hash.Sum(nil))
ha2 := md5Hex(method + ":" + uri)
if qop == "" {
+11 -11
View File
@@ -64,8 +64,8 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) {
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if !reflect.DeepEqual(material.password, []byte{0xde, 0xad, 0xbe, 0xef}) {
t.Fatalf("password = %x, want deadbeef", material.password)
if !reflect.DeepEqual(material.response, []byte{0xde, 0xad, 0xbe, 0xef}) {
t.Fatalf("response = %x, want deadbeef", material.response)
}
if len(aka.challenges) != 1 {
t.Fatalf("challenge count = %d, want 1", len(aka.challenges))
@@ -97,12 +97,12 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) {
if err != nil {
t.Fatalf("authenticateAKA() error = %v", err)
}
if !reflect.DeepEqual(material.auts, auts) || len(material.password) != 0 {
if !reflect.DeepEqual(material.auts, auts) || len(material.response) != 0 {
t.Fatalf("material = %#v", material)
}
}
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) {
func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) {
authorization := buildDigestAuthorization(
digestChallenge{
Realm: "ims.example",
@@ -111,13 +111,13 @@ func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) {
QOP: "auth",
},
digestCredentials{
Username: "[email protected]",
Password: nil,
AUTS: "AAECAwQFBgcICQoLDA0=",
URI: "sip:ims.example",
Method: "REGISTER",
CNonce: "cnonce",
NC: 1,
Username: "[email protected]",
AKAResponse: nil,
AUTS: "AAECAwQFBgcICQoLDA0=",
URI: "sip:ims.example",
Method: "REGISTER",
CNonce: "cnonce",
NC: 1,
},
)
directives, err := parseAuthDirectives(strings.TrimPrefix(authorization, "Digest "))
+12 -12
View File
@@ -405,7 +405,7 @@ func dialSIP(
type authenticationState struct {
challenge digestChallenge
password []byte
response []byte
auts string
cnonce string
nc uint32
@@ -633,13 +633,13 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
if session.auth != nil {
session.auth.nc++
credentials := digestCredentials{
Username: session.identity.private,
Password: session.auth.password,
AUTS: session.auth.auts,
URI: "sip:" + session.identity.domain,
Method: "REGISTER",
CNonce: session.auth.cnonce,
NC: session.auth.nc,
Username: session.identity.private,
AKAResponse: session.auth.response,
AUTS: session.auth.auts,
URI: "sip:" + session.identity.domain,
Method: "REGISTER",
CNonce: session.auth.cnonce,
NC: session.auth.nc,
}
authorization = buildDigestAuthorization(session.auth.challenge, credentials)
if session.auth.challenge.Proxy {
@@ -686,7 +686,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
}
credentials, err := newDigestCredentials(
session.identity.private,
material.password,
material.response,
"sip:"+session.identity.domain,
"REGISTER",
1,
@@ -698,7 +698,7 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse
auts := base64.StdEncoding.EncodeToString(material.auts)
session.auth = &authenticationState{
challenge: challenge,
password: append([]byte(nil), material.password...),
response: append([]byte(nil), material.response...),
auts: auts,
cnonce: credentials.CNonce,
}
@@ -1021,8 +1021,8 @@ func (session *Session) clearAuthentication() {
if session.auth == nil {
return
}
for index := range session.auth.password {
session.auth.password[index] = 0
for index := range session.auth.response {
session.auth.response[index] = 0
}
session.auth = nil
}
+4 -3
View File
@@ -27,15 +27,16 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{}, errors.New("vowifi proxy resolver: store is nil")
}
deviceID := strings.TrimSpace(request.DeviceID)
if deviceID == "" {
iccid := strings.TrimSpace(request.ICCID)
if deviceID == "" || iccid == "" {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
binding, err := resolver.Store.DeviceProxyBinding(ctx, deviceID)
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for device %s: %w", deviceID, err)
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
}
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
if err != nil {
+24 -2
View File
@@ -21,7 +21,7 @@ func testStore(t *testing.T) *store.Store {
return database
}
func TestProxyResolverUsesDeviceBinding(t *testing.T) {
func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
@@ -38,13 +38,15 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20",
ICCID: "89441000400128014257",
ProfileName: "Vodafone UK",
UpstreamProxyID: "clash",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(
context.Background(),
vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234", HomeMNC: "15"},
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
)
if err != nil {
t.Fatal(err)
@@ -57,6 +59,26 @@ func TestProxyResolverUsesDeviceBinding(t *testing.T) {
}
}
func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.T) {
database := testStore(t)
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct for unbound ICCID", route)
}
}
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
+1
View File
@@ -257,6 +257,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
}
proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{
DeviceID: orchestrator.options.DeviceID,
ICCID: strings.TrimSpace(identity.ICCID),
HomeMCC: strings.TrimSpace(identity.HomeMCC),
HomeMNC: strings.TrimSpace(identity.HomeMNC),
CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)),
+1
View File
@@ -203,6 +203,7 @@ type ProxyRoute struct {
type ProxyRequest struct {
DeviceID string
ICCID string
HomeMCC string
HomeMNC string
CountryCode string
+139 -54
View File
@@ -1,6 +1,8 @@
import { DesktopRegular, LinkRegular } from "@fluentui/react-icons";
import type { DeviceListItem, DeviceProxyBinding, UpstreamProxy } from "../../types";
import { Button, EmptyState, Modal, Tag } from "../ui";
import { AddRegular, DeleteRegular } from "@fluentui/react-icons";
import { useEffect, useMemo, useState } from "react";
import { api, apiMessage } from "../../api";
import type { DeviceListItem, DeviceProxyBinding, EsimOverview, ProfileProxyCandidate, UpstreamProxy } from "../../types";
import { Button, EmptyState, Modal, Tag, message } from "../ui";
import { useI18n } from "../../lib/i18n";
export interface DeviceBindingsDialogProps {
@@ -9,69 +11,152 @@ export interface DeviceBindingsDialogProps {
proxies: UpstreamProxy[];
devices: DeviceListItem[];
bindings: DeviceProxyBinding[];
busyDevice: string;
onBind: (deviceId: string) => void;
onUnbind: (deviceId: string) => void;
busy: boolean;
onAdd: (profiles: ProfileProxyCandidate[]) => void;
onDelete: (iccids: string[]) => void;
onClose: () => void;
}
function profileLabel(profile: { name?: string; serviceProviderName?: string; iccid: string }) {
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
}
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const { t } = useI18n();
const { open, proxy, proxies, devices, bindings, busyDevice, onBind, onUnbind, onClose } = props;
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
const [adding, setAdding] = useState(false);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [candidates, setCandidates] = useState<ProfileProxyCandidate[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const proxyName = proxy?.name || proxy?.id || "";
const bindingByDevice = new Map(bindings.map((item) => [item.deviceId, item]));
const proxyNameById = new Map(proxies.map((item) => [item.id, item.name || item.id]));
const deviceKey = devices.map((device) => device.id).sort().join("|");
const current = useMemo(
() => bindings.filter((item) => item.upstreamProxyId === proxy?.id),
[bindings, proxy?.id],
);
const bindingByICCID = useMemo(() => new Map(bindings.map((item) => [item.iccid, item])), [bindings]);
const proxyNameById = useMemo(() => new Map(proxies.map((item) => [item.id, item.name || item.id])), [proxies]);
useEffect(() => {
if (!open) {
setAdding(false);
setSelected([]);
setCandidates([]);
}
}, [open]);
useEffect(() => {
if (!adding || !open) return;
let active = true;
setLoadingProfiles(true);
Promise.allSettled(devices.map(async (device) => {
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
return (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({
deviceId: device.id,
iccid: String(profile.iccid || "").trim(),
profileName: profileLabel(profile),
stateText: profile.stateText,
}))).filter((profile) => profile.iccid);
})).then((results) => {
if (!active) return;
const unique = new Map<string, ProfileProxyCandidate>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const profile of result.value) if (!unique.has(profile.iccid)) unique.set(profile.iccid, profile);
}
setCandidates(Array.from(unique.values()).sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.profileName.localeCompare(b.profileName)));
}).catch((error) => {
if (active) message.error(apiMessage(error) || t("读取 eSIM Profile 失败"));
}).finally(() => {
if (active) setLoadingProfiles(false);
});
return () => { active = false; };
}, [adding, open, deviceKey, t]);
useEffect(() => {
if (!adding || selected.length === 0) return;
if (selected.every((iccid) => bindingByICCID.get(iccid)?.upstreamProxyId === proxy?.id)) {
setAdding(false);
setSelected([]);
}
}, [adding, selected, bindingByICCID, proxy?.id]);
useEffect(() => {
if (adding) return;
const available = new Set(current.map((item) => item.iccid));
setSelected((items) => items.filter((iccid) => available.has(iccid)));
}, [adding, current]);
const rows = adding ? candidates : current;
const selectable = rows.filter((row) => adding ? !bindingByICCID.has(row.iccid) : true).map((row) => row.iccid);
const allSelected = selectable.length > 0 && selectable.every((iccid) => selected.includes(iccid));
const toggle = (iccid: string) => setSelected((values) => values.includes(iccid) ? values.filter((item) => item !== iccid) : [...values, iccid]);
const toggleAll = () => setSelected(allSelected ? [] : selectable);
return (
<Modal open={open} onClose={onClose} title={`${t("设备绑定")}${proxyName}`} width="max-w-2xl">
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 Profile 绑定") : t("Profile 绑定")}${proxyName}`} width="max-w-5xl">
<div className="space-y-4 pb-2">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。")}
{t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
</div>
{devices.length === 0 ? (
<EmptyState title={t("暂无可绑定设备")} subtitle={t("请先在设备管理中添加设备。")}/>
) : (
<div className="space-y-2">
{devices.map((device) => {
const binding = bindingByDevice.get(device.id);
const boundHere = binding?.upstreamProxyId === proxy?.id;
const boundElsewhere = !!binding && !boundHere;
return (
<div key={device.id} className="ui-panel-muted flex items-center justify-between gap-3 rounded-lg p-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-sky-600 shadow-sm dark:bg-white/10 dark:text-sky-300">
<DesktopRegular className="text-[18px]" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold text-gray-900 dark:text-white">{device.name || device.id}</span>
<span className="font-mono text-xs text-gray-400">{device.id}</span>
{boundHere ? <Tag type="success">{t("已绑定")}</Tag> : null}
{!device.vowifiEnabled ? <Tag type="info">{t("VoWiFi 未启用")}</Tag> : null}
</div>
<div className="mt-0.5 text-xs text-gray-500">
{boundHere
? t("当前通过此代理通信")
: boundElsewhere
? `${t("当前绑定")}: ${proxyNameById.get(binding.upstreamProxyId) || binding.upstreamProxyId}`
: t("当前直连")}
</div>
</div>
</div>
{boundHere ? (
<Button size="small" variant="danger" loading={busyDevice === device.id} onClick={() => onUnbind(device.id)}>
{t("解绑")}
</Button>
) : (
<Button size="small" variant="primary" icon={<LinkRegular />} loading={busyDevice === device.id} onClick={() => onBind(device.id)}>
{boundElsewhere ? t("切换绑定") : t("绑定设备")}
</Button>
)}
</div>
);
})}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-gray-500">{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}</div>
<div className="flex gap-2">
{adding ? (
<Button size="small" onClick={() => { setAdding(false); setSelected([]); }}>{t("返回绑定列表")}</Button>
) : null}
{adding ? (
<Button
size="small"
variant="primary"
icon={<AddRegular />}
loading={busy}
disabled={selected.length === 0 || loadingProfiles}
onClick={() => onAdd(candidates.filter((item) => selected.includes(item.iccid)))}
>{t("添加所选")}</Button>
) : (
<>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy} disabled={selected.length === 0} onClick={() => onDelete(selected)}>{t("删除所选")}</Button>
<Button size="small" variant="primary" icon={<AddRegular />} onClick={() => { setAdding(true); setSelected([]); }}>{t("添加")}</Button>
</>
)}
</div>
)}
</div>
<div className="overflow-x-auto rounded-xl border border-gray-100 dark:border-white/10">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-gray-50/80 text-xs uppercase tracking-wide text-gray-500 dark:bg-white/[0.025]">
<tr>
<th className="w-12 px-4 py-3"><input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0 || busy} aria-label={t("全选")} /></th>
<th className="px-4 py-3">{t("设备 ID")}</th>
<th className="px-4 py-3">ICCID</th>
<th className="px-4 py-3">{t("Profile 名称")}</th>
{adding ? <th className="px-4 py-3">{t("状态")}</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{rows.map((row) => {
const existing = bindingByICCID.get(row.iccid);
const unavailable = adding && !!existing;
return (
<tr key={`${row.deviceId}:${row.iccid}`} className={unavailable ? "opacity-60" : "hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]"}>
<td className="px-4 py-3"><input type="checkbox" checked={selected.includes(row.iccid)} onChange={() => toggle(row.iccid)} disabled={unavailable || busy} aria-label={row.iccid} /></td>
<td className="px-4 py-3 font-mono text-xs">{row.deviceId}</td>
<td className="px-4 py-3 font-mono text-xs">{row.iccid}</td>
<td className="px-4 py-3 font-medium">{row.profileName || row.iccid}</td>
{adding ? (
<td className="px-4 py-3">
{existing ? <Tag type={existing.upstreamProxyId === proxy?.id ? "success" : "info"}>{existing.upstreamProxyId === proxy?.id ? t("已绑定此代理") : `${t("已绑定")}: ${proxyNameById.get(existing.upstreamProxyId) || existing.upstreamProxyId}`}</Tag> : <Tag type="primary">{("stateText" in row && row.stateText) || t("可绑定")}</Tag>}
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
{loadingProfiles ? <div className="px-6 py-12 text-center text-sm text-gray-400">{t("读取 Profile 中...")}</div> : null}
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 eSIM Profile") : t("尚未绑定 Profile")} subtitle={adding ? t("请确认设备在线且支持 eSIM Profile 列表读取。") : t("点击添加,从设备 Profile 列表中选择。")}/>: null}
</div>
</div>
</Modal>
);
+1 -1
View File
@@ -108,7 +108,7 @@ export function UpstreamDialog({ open, editing, form, testing, probe, onPatch, o
</Field>
<ToggleRow
title={t("启用代理")}
subtitle={t("禁用后,已绑定设备的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
subtitle={t("禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连")}
checked={form.enabled}
onChange={(v) => onPatch({ enabled: v })}
/>
+4 -4
View File
@@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<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("Profile 绑定")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<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("台设备")}</span>
<span>{row.bindingCount} {t("个 Profile")}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("设备绑定")}</Button>
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("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>
</div>
@@ -72,7 +72,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 上游代理,然后将需要使用它的设备直接绑定;未绑定设备默认直连。")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
@@ -0,0 +1,58 @@
import { SendRegular } from "@fluentui/react-icons";
import type { DeveloperSettings } from "../../types";
import { useI18n } from "../../lib/i18n";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { CardDecor, CardIcon, CardTitle } from "./Cards";
export function SMSRateLimitCard({
value,
limit,
loading,
saving,
onLimitChange,
onSave,
}: {
value: DeveloperSettings | null;
limit: number;
loading: boolean;
saving: boolean;
onLimitChange: (limit: number) => void;
onSave: () => void;
}) {
const { lang } = useI18n();
const zh = lang === "zh";
return (
<div className="ui-card group relative overflow-hidden p-8">
<CardDecor />
<div className="relative z-10 mb-6 flex items-center gap-3">
<CardIcon>
<SendRegular className="text-[24px]" />
</CardIcon>
<CardTitle
title={zh ? "短信发送速率限制" : "SMS send rate limit"}
subtitle={zh ? "所有设备与 SIM 卡共享的全局发送额度" : "One global quota shared by every device and SIM"}
/>
</div>
<div className="relative z-10 space-y-4">
<Input
type="number"
min={1}
max={value?.maxSmsHourlyLimit ?? 1000}
value={Number.isFinite(limit) ? limit : ""}
disabled={loading || saving}
onChange={(event) => onLimitChange(Number(event.target.value))}
suffix={zh ? "条 / 小时" : "messages / hour"}
/>
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">
{zh
? `采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。关闭开发者模式后恢复为 ${value?.defaultSmsHourlyLimit ?? 10} 条/小时。`
: `Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited. Disabling developer mode restores ${value?.defaultSmsHourlyLimit ?? 10} messages/hour.`}
</p>
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
</Button>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { cx } from "../../lib/utils";
import { useI18n } from "../../lib/i18n";
import { Button } from "./Button";
import { Select } from "./Select";
export interface PaginationProps {
/** Current page, 1-based. */
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
className?: string;
}
type PageItem = number | "ellipsis";
// Build the page-number strip: always show the first and last page, the pages
// around the current one, and collapse longer gaps into a single ellipsis
// (filling a gap of exactly one page with that page's number).
function pageWindow(current: number, pages: number): PageItem[] {
if (pages <= 7) {
return Array.from({ length: pages }, (_, index) => index + 1);
}
const left = Math.max(2, current - 1);
const right = Math.min(pages - 1, current + 1);
const kept: number[] = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || (i >= left && i <= right)) {
kept.push(i);
}
}
const items: PageItem[] = [];
let previous = 0;
for (const page of kept) {
if (previous !== 0) {
if (page - previous === 2) items.push(previous + 1);
else if (page - previous > 2) items.push("ellipsis");
}
items.push(page);
previous = page;
}
return items;
}
export function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
className,
}: PaginationProps) {
const { t } = useI18n();
const pages = Math.max(1, Math.ceil(total / pageSize));
const current = Math.min(Math.max(1, page), pages);
const items = pageWindow(current, pages);
if (total <= 0) return null;
return (
<div className={cx("flex flex-wrap items-center gap-x-3 gap-y-2", className)}>
<span className="text-xs text-gray-400">{t("共 {total} 条").replace("{total}", String(total))}</span>
<div className="flex items-center gap-1 sm:ml-auto">
{onPageSizeChange ? (
<Select
value={String(pageSize)}
onChange={(value) => onPageSizeChange(Number(value))}
options={pageSizeOptions.map((count) => ({
value: String(count),
label: t("{count} 条/页").replace("{count}", String(count)),
}))}
className="mr-1 w-24"
/>
) : null}
<Button
size="small"
icon={<ChevronLeftRegular />}
disabled={current <= 1}
onClick={() => onPageChange(current - 1)}
aria-label={t("上一页")}
/>
{items.map((item, index) =>
item === "ellipsis" ? (
<span key={`ellipsis-${index}`} className="px-1 text-xs text-gray-400">
</span>
) : (
<Button
key={item}
size="small"
variant={item === current ? "primary" : "text"}
onClick={() => onPageChange(item)}
aria-current={item === current ? "page" : undefined}
>
{item}
</Button>
),
)}
<Button
size="small"
icon={<ChevronRightRegular />}
disabled={current >= pages}
onClick={() => onPageChange(current + 1)}
aria-label={t("下一页")}
/>
</div>
</div>
);
}
+2
View File
@@ -8,6 +8,8 @@ export { Switch } from "./Switch";
export { Input, Textarea } from "./Input";
export { Select } from "./Select";
export type { SelectOption } from "./Select";
export { Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
export { Tabs } from "./Tabs";
export type { TabItem } from "./Tabs";
export { Tag } from "./Tag";
+34
View File
@@ -31,6 +31,36 @@ export const EN_DICT: Record<string, string> = {
"插件可能已被禁用、卸载或没有注册此页面。": "The plugin may be disabled, uninstalled, or may not register this page.",
// Device-bound VoWiFi upstream routing.
"设备绑定": "Device Bindings",
"Profile 绑定": "Profile Bindings",
"添加 Profile 绑定": "Add Profile Bindings",
"VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。":
"VoWiFi selects its proxy by the active ICCID. An ICCID can use only one proxy, while one proxy can serve profiles across multiple devices.",
"从设备已安装的 eSIM Profile 中选择": "Select from eSIM profiles installed on the devices",
"个 Profile": "profiles",
"返回绑定列表": "Back to bindings",
"添加所选": "Add selected",
: "Add",
: "Select all",
"删除所选": "Delete selected",
"设备 ID": "Device ID",
"Profile 名称": "Profile Name",
"已绑定此代理": "Bound to this proxy",
"可绑定": "Available",
"没有可显示的 eSIM Profile": "No eSIM profiles to display",
"尚未绑定 Profile": "No profiles bound",
"请确认设备在线且支持 eSIM Profile 列表读取。": "Make sure the device is online and supports eSIM profile listing.",
"点击添加,从设备 Profile 列表中选择。": "Click Add and select from the device profile list.",
"读取 eSIM Profile 失败": "Failed to read eSIM profiles",
"Profile 已绑定": "Profiles bound",
"所选 ICCID 已绑定其他代理,请先删除原绑定": "A selected ICCID is bound to another proxy; delete the existing binding first",
"所选 Profile 绑定已删除": "Selected profile bindings deleted",
"删除绑定失败": "Failed to delete bindings",
"绑定到该代理的 Profile 将自动解绑并恢复直连。": "Profiles bound to this proxy will be unbound and return to direct routing.",
"管理 VoWiFi 上游代理和 eSIM Profile 绑定": "Manage VoWiFi upstream proxies and eSIM profile bindings",
"禁用后,已绑定 Profile 的 VoWiFi 将停止使用该线路,不会泄漏到直连":
"When disabled, bound profiles stop using this VoWiFi route and do not leak traffic to a direct connection.",
"点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。":
"Create a SOCKS5 upstream proxy, then bind eSIM profiles by ICCID. Unbound profiles use a direct connection.",
"绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。":
"Once bound, this device uses the SOCKS5 proxy for VoWiFi setup and communications. Unbinding restores direct routing. Route changes trigger an immediate VoWiFi reconnect.",
"暂无可绑定设备": "No devices available",
@@ -99,6 +129,10 @@ export const EN_DICT: Record<string, string> = {
: "Queued",
: "Running",
: "No run history",
"共 {total} 条": "{total} total",
"{count} 条/页": "{count} / page",
: "Previous",
: "Next",
: "Edit Automatic Task",
: "Add Automatic Task",
: "Task Name",
+72 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AddRegular,
DeleteRegular,
@@ -14,6 +14,7 @@ import {
Input,
Modal,
PageHeader,
Pagination,
Select,
Switch,
Tag,
@@ -135,6 +136,9 @@ export default function AutomaticTasksPage() {
const { t } = useI18n();
const [tasks, setTasks] = useState<AutomaticTask[]>([]);
const [runs, setRuns] = useState<AutomaticTaskRun[]>([]);
const [runsTotal, setRunsTotal] = useState(0);
const [runsPage, setRunsPage] = useState(1);
const [runsPageSize, setRunsPageSize] = useState(20);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
const [loading, setLoading] = useState(true);
@@ -143,16 +147,19 @@ export default function AutomaticTasksPage() {
const [form, setForm] = useState<TaskForm>(() => emptyForm());
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState(0);
// Refs mirror the runs page/pageSize so the 5s poll reloads the page the user
// is actually looking at instead of snapping back to page 1 on every tick.
const runsPageRef = useRef(1);
const runsPageSizeRef = useRef(20);
const load = useCallback(async (initial = false) => {
if (initial) setLoading(true);
try {
const [taskData, deviceData] = await Promise.all([
api<{ tasks?: AutomaticTask[]; runs?: AutomaticTaskRun[] }>("/automatic-tasks"),
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
api<DevicesResponse>("/devices"),
]);
setTasks(taskData.tasks || []);
setRuns(taskData.runs || []);
setDevices(deviceData.devices || []);
} catch (error) {
message.error(apiMessage(error));
@@ -161,11 +168,57 @@ export default function AutomaticTasksPage() {
}
}, []);
const fetchRuns = useCallback(async (page: number, pageSize: number) => {
const request = (target: number) =>
api<{ runs?: AutomaticTaskRun[]; total?: number }>(
`/automatic-tasks/runs?limit=${pageSize}&offset=${(target - 1) * pageSize}`,
);
try {
let data = await request(page);
const total = data.total ?? 0;
const pages = Math.max(1, Math.ceil(total / pageSize));
// Clamp when the current page fell past the end (a larger page size, or
// runs removed with a deleted task) instead of showing an empty slice.
if (page > pages) {
data = await request(pages);
runsPageRef.current = pages;
setRunsPage(pages);
}
setRuns(data.runs || []);
setRunsTotal(total);
} catch (error) {
message.error(apiMessage(error));
}
}, []);
const reloadRuns = useCallback(
() => fetchRuns(runsPageRef.current, runsPageSizeRef.current),
[fetchRuns],
);
useEffect(() => {
void load(true);
const timer = window.setInterval(() => void load(), 5000);
void reloadRuns();
const timer = window.setInterval(() => {
void load();
void reloadRuns();
}, 5000);
return () => window.clearInterval(timer);
}, [load]);
}, [load, reloadRuns]);
function changeRunsPage(page: number) {
runsPageRef.current = page;
setRunsPage(page);
void fetchRuns(page, runsPageSizeRef.current);
}
function changeRunsPageSize(pageSize: number) {
runsPageSizeRef.current = pageSize;
setRunsPageSize(pageSize);
runsPageRef.current = 1;
setRunsPage(1);
void fetchRuns(1, pageSize);
}
const loadProfiles = useCallback(async (deviceId: string, keepICCID = "") => {
setProfiles([]);
@@ -299,6 +352,7 @@ export default function AutomaticTasksPage() {
await api(`/automatic-tasks/${task.id}/run`, { method: "POST" });
message.success(t("任务已加入设备队列"));
await load();
changeRunsPage(1);
} catch (error) {
message.error(apiMessage(error));
} finally {
@@ -313,6 +367,7 @@ export default function AutomaticTasksPage() {
await api(`/automatic-tasks/${task.id}`, { method: "DELETE" });
message.success(t("自动任务已删除"));
await load();
await reloadRuns();
} catch (error) {
message.error(apiMessage(error));
} finally {
@@ -393,13 +448,24 @@ export default function AutomaticTasksPage() {
<table className="w-full min-w-[800px] text-left text-sm">
<thead className="bg-gray-50/70 text-xs text-gray-500 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("结果")}</th></tr></thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{runs.slice(0, 30).map((run) => (
{runs.map((run) => (
<tr key={run.id}><td className="px-4 py-3 font-medium">{taskByID.get(run.taskId)?.name || `#${run.taskId}`}</td><td className="px-4 py-3">{deviceByID.get(run.deviceId)?.name || run.deviceId}</td><td className="px-4 py-3"><Tag type={run.status === "success" ? "success" : run.status === "failed" ? "danger" : run.status === "running" ? "warning" : "info"}>{({ queued: t("排队中"), running: t("执行中"), success: t("成功"), failed: t("失败") })[run.status]}</Tag></td><td className="px-4 py-3 text-xs">{formatDateTime(run.scheduledAt)}</td><td className="px-4 py-3">{run.attempts}</td><td className="px-4 py-3"><div className={run.error ? "max-w-md text-red-500" : "max-w-md text-gray-600 dark:text-gray-300"}>{run.error || run.output || "--"}</div></td></tr>
))}
</tbody>
</table>
</div>
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
{runsTotal > 0 ? (
<div className="border-t border-gray-100 px-5 py-3 dark:border-white/10">
<Pagination
page={runsPage}
pageSize={runsPageSize}
total={runsTotal}
onPageChange={changeRunsPage}
onPageSizeChange={changeRunsPageSize}
/>
</div>
) : null}
</div>
<Modal open={open} onClose={() => setOpen(false)} title={form.id ? t("编辑自动任务") : t("添加自动任务")} width="max-w-3xl">
+30 -25
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AddRegular } from "@fluentui/react-icons";
import { api, ApiError, apiMessage } from "../api";
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, UpstreamProxy } from "../types";
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import { usePolling } from "../lib/usePolling";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import {
@@ -38,7 +38,7 @@ export default function ProxyPage() {
const [upstreamProbe, setUpstreamProbe] = useState<UpstreamProbeResult | null>(null);
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
const [busyDevice, setBusyDevice] = useState("");
const [bindingBusy, setBindingBusy] = useState(false);
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const proxyRows = useMemo<UpstreamRow[]>(
@@ -55,7 +55,7 @@ export default function ProxyPage() {
try {
const [proxyList, bindingList, deviceList] = await Promise.all([
api<UpstreamProxy[]>("/upstream-proxies"),
api<DeviceProxyBinding[]>("/upstream-proxy-device-bindings"),
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
api<DevicesResponse>("/devices"),
]);
setProxies(proxyList || []);
@@ -164,7 +164,7 @@ export default function ProxyPage() {
<>
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
<br />
{t("绑定到该代理的设备将自动解绑并恢复直连。")}
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
</>,
t("确认删除"),
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
@@ -195,48 +195,53 @@ export default function ProxyPage() {
}
}, [t]);
const bindDevice = useCallback(async (deviceId: string) => {
if (!bindingsProxy) return;
setBusyDevice(deviceId);
const addProfileBindings = useCallback(async (profiles: ProfileProxyCandidate[]) => {
if (!bindingsProxy || profiles.length === 0) return;
setBindingBusy(true);
try {
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
method: "PUT",
body: { upstreamProxyId: bindingsProxy.id },
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
method: "POST",
body: {
upstreamProxyId: bindingsProxy.id,
bindings: profiles.map(({ deviceId, iccid, profileName }) => ({ deviceId, iccid, profileName })),
},
});
showRouteChangeResult(result, t("设备已绑定"));
showRouteChangeResult(result, t("Profile 已绑定"));
await loadUpstream(false);
} catch (error) {
const code = error instanceof ApiError ? error.code : "";
if (code === "device_already_bound") {
message.error(t("该设备已绑定其他代理,请先解绑后再切换"));
if (code === "profile_already_bound") {
message.error(t("所选 ICCID 已绑定其他代理,请先删除原绑定"));
} else {
message.error(apiMessage(error) || t("绑定失败"));
}
} finally {
setBusyDevice("");
setBindingBusy(false);
}
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
const unbindDevice = useCallback(async (deviceId: string) => {
setBusyDevice(deviceId);
const deleteProfileBindings = useCallback(async (iccids: string[]) => {
if (!bindingsProxy || iccids.length === 0) return;
setBindingBusy(true);
try {
const result = await api<BindingMutationResult>(`/upstream-proxy-device-bindings/${encodeURIComponent(deviceId)}`, {
const result = await api<BindingMutationResult>("/upstream-proxy-profile-bindings", {
method: "DELETE",
body: { upstreamProxyId: bindingsProxy.id, iccids },
});
showRouteChangeResult(result, t("设备已解绑并恢复直连"));
showRouteChangeResult(result, t("所选 Profile 绑定已删除"));
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("解绑失败"));
message.error(apiMessage(error) || t("删除绑定失败"));
} finally {
setBusyDevice("");
setBindingBusy(false);
}
}, [loadUpstream, showRouteChangeResult, t]);
}, [bindingsProxy, loadUpstream, showRouteChangeResult, t]);
return (
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理和设备绑定")}
subtitle={t("管理 VoWiFi 上游代理和 eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
/>
<UpstreamSection
@@ -283,9 +288,9 @@ export default function ProxyPage() {
proxies={proxies}
devices={devices}
bindings={bindings}
busyDevice={busyDevice}
onBind={(deviceId) => void bindDevice(deviceId)}
onUnbind={(deviceId) => void unbindDevice(deviceId)}
busy={bindingBusy}
onAdd={(profiles) => void addProfileBindings(profiles)}
onDelete={(iccids) => void deleteProfileBindings(iccids)}
onClose={() => setBindingsDialogOpen(false)}
/>
</div>
+32 -1
View File
@@ -24,6 +24,7 @@ import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
import { SMSRateLimitCard } from "../components/settings/SMSRateLimitCard";
const EMPTY_PASSWORD: PasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
@@ -38,7 +39,6 @@ const NOTIFY_TABS = [
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
const EMPTY_SECURITY: NetworkAccessForm = { mode: "internal", allowedCidrs: [], trustProxyHeaders: false };
export default function SettingsPage() {
const { refresh } = useAuth();
const { t, lang } = useI18n();
@@ -65,8 +65,10 @@ export default function SettingsPage() {
const [savingHTTPS, setSavingHTTPS] = useState(false);
const [developerSettings, setDeveloperSettings] = useState<DeveloperSettings | null>(null);
const [deviceLimit, setDeviceLimit] = useState(5);
const [smsHourlyLimit, setSMSHourlyLimit] = useState(10);
const [loadingDeveloper, setLoadingDeveloper] = useState(false);
const [savingDeveloper, setSavingDeveloper] = useState(false);
const [savingSMSLimit, setSavingSMSLimit] = useState(false);
const updateChannel = useCallback(<K extends keyof NotifyForms>(key: K, patch: Partial<NotifyForms[K]>) => {
setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
@@ -131,6 +133,7 @@ export default function SettingsPage() {
const data = await api<DeveloperSettings>("/settings/developer");
setDeveloperSettings(data);
setDeviceLimit(data.deviceLimit);
setSMSHourlyLimit(data.smsHourlyLimit);
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "设备配额配置加载失败" : "Failed to load device quota settings"));
} finally {
@@ -152,6 +155,7 @@ export default function SettingsPage() {
setHTTPSSettings(null);
setDeveloperSettings(null);
setDeviceLimit(5);
setSMSHourlyLimit(10);
}
}, [systemInfo.developer, fetchHTTPS, fetchDeveloperSettings]);
@@ -188,6 +192,25 @@ export default function SettingsPage() {
}
}, [developerSettings, deviceLimit, lang]);
const onSaveSMSHourlyLimit = useCallback(async () => {
const maximum = developerSettings?.maxSmsHourlyLimit ?? 1000;
if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) {
message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`);
return;
}
setSavingSMSLimit(true);
try {
const data = await api<DeveloperSettings>("/settings/developer", { method: "PUT", body: { smsHourlyLimit } });
setDeveloperSettings(data);
setSMSHourlyLimit(data.smsHourlyLimit);
message.success(lang === "zh" ? "短信发送速率限制已保存" : "SMS rate limit saved");
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "短信发送速率限制保存失败" : "Failed to save SMS rate limit"));
} finally {
setSavingSMSLimit(false);
}
}, [developerSettings, smsHourlyLimit, lang]);
const onSaveSecurity = useCallback(async () => {
setSavingSecurity(true);
try {
@@ -406,6 +429,14 @@ export default function SettingsPage() {
onLimitChange={setDeviceLimit}
onSave={onSaveDeviceLimit}
/>
<SMSRateLimitCard
value={developerSettings}
limit={smsHourlyLimit}
loading={loadingDeveloper}
saving={savingSMSLimit}
onLimitChange={setSMSHourlyLimit}
onSave={onSaveSMSHourlyLimit}
/>
<PluginsCard />
</>
) : null}
+12
View File
@@ -316,11 +316,20 @@ export interface CountryRule {
export interface DeviceProxyBinding {
deviceId: string;
iccid: string;
profileName: string;
upstreamProxyId: string;
reconnectRequested?: boolean;
reconnectError?: string;
}
export interface ProfileProxyCandidate {
deviceId: string;
iccid: string;
profileName: string;
stateText?: string;
}
export interface LogEntry {
time: string;
level: "debug" | "info" | "warn" | "error" | string;
@@ -403,6 +412,9 @@ export interface DeveloperSettings {
deviceLimit: number;
defaultDeviceLimit: number;
maxDeviceLimit: number;
smsHourlyLimit: number;
defaultSmsHourlyLimit: number;
maxSmsHourlyLimit: number;
}
export type Notice = {