feat: implement eSIM notification handling and email message construction

- Add eSIM notification management in `esim_notifications.go` with functions to retrieve, list, and remove notifications.
- Implement parsing logic for pending notifications and notification metadata.
- Create tests for eSIM notification parsing and request handling in `esim_notifications_test.go`.
- Introduce email message construction in `email_message.go` to securely format and send emails.
- Add tests for email message encoding and validation in `email_message_test.go`.
- Enhance the CardPolicyAPN component to manage APN configurations, including adding, editing, and deleting custom APNs.
- Implement UI for displaying and managing APN settings with appropriate validation and user feedback.
This commit is contained in:
MengMengCode
2026-08-11 18:58:32 +08:00
parent 48fc4c5ab5
commit 3cc73f1885
37 changed files with 2214 additions and 182 deletions
+30 -4
View File
@@ -438,10 +438,28 @@ func restoreConfiguredCellularData(
if err != nil {
continue
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, device.NetworkRequest{
networkRequest := device.NetworkRequest{
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
})
}
if entry.Snapshot != nil {
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
networkRequest.APN = policy.APN
if policy.IPVersion != "" {
networkRequest.IPVersion = policy.IPVersion
}
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
networkRequest.Username = profile.Username
networkRequest.Password = profile.Password
networkRequest.Authentication = profile.AuthType
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
networkRequest.IPVersion = profile.RoamingIPVersion
}
}
}
}
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
cancel()
if err != nil {
logger.Warn("startup cellular data recovery failed", "device_id", config.ID, "error", err)
@@ -866,7 +884,7 @@ func enforceDefaultSafeCardPolicy(
return
}
iccid := strings.TrimSpace(snapshot.ICCID)
if _, err := database.CardPolicy(ctx, iccid); err == nil && !snapshot.SIMChanged {
if _, err := database.CardPolicy(ctx, iccid); err == nil {
return
} else if !errors.Is(err, store.ErrNotFound) {
logger.Warn("default card policy: read policy", "iccid", iccid, "error", err)
@@ -953,11 +971,19 @@ func reconcileCardPolicies(
continue
}
}
deviceChanged := false
if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) {
config.VoWiFiEnabled = policy.VoWiFiEnabled
if policy.VoWiFiEnabled {
config.NetworkEnabled = false
}
deviceChanged = true
}
if config.APN != strings.TrimSpace(policy.APN) {
config.APN = strings.TrimSpace(policy.APN)
deviceChanged = true
}
if deviceChanged {
if err := database.UpsertDevice(ctx, config); err != nil {
logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err)
continue
+48 -4
View File
@@ -13,6 +13,40 @@ import (
var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`)
// ValidAPN reports whether value can safely be used as a modem PDP-context APN.
// An empty value is valid and means that the modem/operator default should be used.
func ValidAPN(value string) bool {
value = strings.TrimSpace(value)
return value == "" || apnPattern.MatchString(value)
}
func validNetworkCredential(value string) bool {
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func normalizeNetworkAuthentication(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "NONE":
return "NONE"
case "PAP":
return "PAP"
case "CHAP":
return "CHAP"
case "PAP_OR_CHAP":
return "PAP_OR_CHAP"
default:
return ""
}
}
func (manager *Manager) SetNetwork(
ctx context.Context,
id string,
@@ -23,9 +57,16 @@ func (manager *Manager) SetNetwork(
return NetworkResult{}, err
}
apn := strings.TrimSpace(request.APN)
if request.Enabled && apn != "" && !apnPattern.MatchString(apn) {
if request.Enabled && !ValidAPN(apn) {
return NetworkResult{}, ErrInvalidNetworkAPN
}
if !validNetworkCredential(request.Username) || !validNetworkCredential(request.Password) {
return NetworkResult{}, errors.New("APN username or password contains unsupported characters")
}
authentication := normalizeNetworkAuthentication(request.Authentication)
if authentication == "" {
return NetworkResult{}, errors.New("authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
}
ipVersion := normalizeIPVersion(request.IPVersion)
if ipVersion == "" {
return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6")
@@ -58,7 +99,7 @@ func (manager *Manager) SetNetwork(
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
}
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
}
client, err := manager.clientLocked(ctx, state, candidate)
@@ -69,9 +110,12 @@ func (manager *Manager) SetNetwork(
if request.Enabled {
commands := []string{
fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn),
"AT+CGATT=1",
"AT+CGACT=1,1",
}
if authentication != "NONE" {
authCode := map[string]int{"PAP": 1, "CHAP": 2, "PAP_OR_CHAP": 3}[authentication]
commands = append(commands, fmt.Sprintf(`AT+CGAUTH=1,%d,"%s","%s"`, authCode, request.Username, request.Password))
}
commands = append(commands, "AT+CGATT=1", "AT+CGACT=1,1")
for _, command := range commands {
if _, err := manager.command(ctx, client, command); err != nil {
manager.setResult(id, state, nil, err)
+16
View File
@@ -23,6 +23,9 @@ func setQMINetwork(
enabled bool,
apn string,
ipVersion string,
username string,
password string,
authentication string,
) (NetworkResult, error) {
qmiNetwork, err := exec.LookPath("qmi-network")
if err != nil {
@@ -39,6 +42,15 @@ func setQMINetwork(
if apn != "" {
profileText = "APN=" + apn + "\n" + profileText
}
if username != "" {
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
}
if password != "" {
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
}
if authentication != "" && authentication != "NONE" {
profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
}
if _, err := fmt.Fprint(profile, profileText); err != nil {
_ = profile.Close()
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
@@ -110,6 +122,10 @@ func setQMINetwork(
}, nil
}
func shellProfileValue(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
// Linux socket mark. Unmarked host traffic never sees the cellular default
// route; only plugin sockets carrying this mark are policy-routed to it.
+3
View File
@@ -15,6 +15,9 @@ func setQMINetwork(
bool,
string,
string,
string,
string,
string,
) (NetworkResult, error) {
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
}
+17
View File
@@ -35,6 +35,23 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
client.assertDone(t)
}
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
{command: "AT+CGATT=1", response: okResponse()},
{command: "AT+CGACT=1,1", response: okResponse()},
}}
manager, id := newStartedTestManager(t, client)
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
Username: "gg", Password: "p", Authentication: "PAP",
}); err != nil {
t.Fatalf("enable authenticated network: %v", err)
}
client.assertDone(t)
}
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
client := &transcriptClient{}
manager, id := newStartedTestManager(t, client)
+59 -32
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -32,9 +33,11 @@ type es9pClient struct {
http *http.Client
}
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
smdp = strings.TrimSpace(smdp)
if smdp == "" || strings.Contains(smdp, "://") {
if !smdpAddressPattern.MatchString(smdp) {
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
}
candidate, err := url.Parse("https://" + smdp)
@@ -159,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
// mirrors lpac's euicc/es9p_errors.c.
var es9pErrorTable = map[[2]string]string{
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
{"8.1.2", "6.3"}: "EUM Certificate has expired",
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
{"8.2", "1.2"}: "Profile has not yet been released",
{"8.2", "3.7"}: "BPP is not available for a new binding",
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
{"8.2.7", "2.2"}: "Confirmation Code is missing",
{"8.2.7", "3.8"}: "Confirmation Code is refused",
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
{"8.8.5", "4.1"}: "The Download order has expired",
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
}
func es9pErrorMessage(subjectCode, reasonCode string) string {
@@ -264,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
// authorization needed for PrepareDownload.
type es9pAuthenticateResult struct {
TransactionID string
TransactionID string
ProfileMetadata []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpSigned2 []byte
SmdpSignature2 []byte
SmdpCertificate []byte
}
@@ -297,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
"transactionId": transactionID,
"transactionId": transactionID,
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
}, "boundProfilePackage")
if err != nil {
@@ -310,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
// for the download case). It is best-effort: the profile is already installed, so
// a notification failure is reported by the caller as a warning, not a failure.
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
_, err := c.call(ctx, "handleNotification", map[string]string{
endpoint := *c.endpoint
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
body, err := json.Marshal(map[string]string{
"pendingNotification": es9pBase64Encode(pendingNotification),
})
return err
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "gsma-rsp-lpad")
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("es9p handleNotification: %w", err)
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
// SGP.22 defines HandleNotification as a notification-handler function:
// success is an empty HTTP 204 response, not the JSON envelope returned by
// ordinary ES9+ request-response functions.
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
}
return nil
}
// cancelSession aborts an in-flight download so the SM-DP+ releases the
+32
View File
@@ -47,6 +47,7 @@ func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
"169.254.169.254",
"rsp.example.com/unexpected/path",
"user:[email protected]",
"rsp.example.com\r\nX-Injected: yes",
} {
if _, err := newES9PClient(context.Background(), address); err == nil {
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
@@ -162,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
t.Fatalf("bpp = %X, want %X", got, pkg)
}
}
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
pending := []byte{0xBF, 0x37, 0x00}
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
if err != nil || !bytes.Equal(decoded, pending) {
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
}
w.WriteHeader(http.StatusNoContent)
})
if err := client.handleNotification(context.Background(), pending); err != nil {
t.Fatalf("handleNotification: %v", err)
}
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
})
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
t.Fatalf("HTTP 200 error = %v", err)
}
}
+13 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
)
var (
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
}
deleted := &EsimDeleteResult{}
var warnings []string
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
deleted.SpaceDelta = int64(freeAfter - freeBefore)
}
} else {
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read"
warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
}
// DeleteProfile creates a signed notification only when the Profile metadata
// configured a receiver. Flush all retained notifications so earlier events
// for the same receiver cannot be overtaken by this delete event.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notifyErr := channel.deliverPendingNotifications(notifyContext)
cancelNotify()
if notifyErr != nil {
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
}
deleted.Warning = strings.Join(warnings, "")
manager.removeCachedProfile(id, strings.TrimSpace(iccid))
return deleted, nil
}
+17 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
)
// EsimDownloadParams are the SPA download form fields, mapped from the
@@ -130,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
if err != nil {
return nil, err
}
iccid, err := installationResult(installResponse)
if err != nil {
return nil, err
}
report("notify", "正在向运营商发送下载通知...", 90)
iccid, installErr := installationResult(installResponse)
warning := ""
if err := client.handleNotification(ctx, installResponse); err != nil {
warning = "Profile 已安装,但下载通知发送失败"
notification, notificationErr := parsePendingNotification(installResponse)
if notificationErr == nil {
// Loading the final BPP segment is the commit point. Finish the operator
// acknowledgement even if the browser closes its SSE connection now.
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
notificationErr = channel.deliverNotification(notifyContext, notification)
cancelNotify()
}
if notificationErr != nil {
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
}
// Error installation results must be reported too. Return the card-side
// installation failure only after making that best-effort ES9+ attempt.
if installErr != nil {
return nil, installErr
}
freeAfter := freeBefore
+354
View File
@@ -0,0 +1,354 @@
package device
import (
"context"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
)
// EsimNotification is one notification retained by an eUICC until its receiver
// acknowledges it through ES9+.HandleNotification.
type EsimNotification struct {
SequenceNumber uint64 `json:"sequenceNumber"`
Event string `json:"event,omitempty"`
ICCID string `json:"iccid,omitempty"`
Address string `json:"address,omitempty"`
AIDHex string `json:"aidHex,omitempty"`
CanRetry bool `json:"canRetry"`
raw []byte
}
func encodePositiveInteger(value uint64) []byte {
if value == 0 {
return []byte{0}
}
encoded := make([]byte, 8)
for index := len(encoded) - 1; index >= 0; index-- {
encoded[index] = byte(value)
value >>= 8
}
for len(encoded) > 1 && encoded[0] == 0 {
encoded = encoded[1:]
}
if encoded[0]&0x80 != 0 {
encoded = append([]byte{0}, encoded...)
}
return encoded
}
func decodePositiveInteger(encoded []byte) (uint64, bool) {
if len(encoded) == 0 || len(encoded) > 9 || encoded[0]&0x80 != 0 {
return 0, false
}
if len(encoded) == 9 {
if encoded[0] != 0 {
return 0, false
}
encoded = encoded[1:]
}
var value uint64
for _, octet := range encoded {
value = value<<8 | uint64(octet)
}
return value, true
}
func buildRetrieveNotificationsRequest(sequenceNumber *uint64) []byte {
if sequenceNumber == nil {
return derConstruct(0xBF2B)
}
return derConstruct(0xBF2B, derEncode(0x80, encodePositiveInteger(*sequenceNumber)))
}
func buildListNotificationsRequest() []byte {
return derConstruct(0xBF28)
}
func buildRemoveNotificationRequest(sequenceNumber uint64) []byte {
return derConstruct(0xBF30, derEncode(0x80, encodePositiveInteger(sequenceNumber)))
}
func notificationEventName(bitString []byte) string {
if len(bitString) < 2 || bitString[0] > 7 {
return ""
}
bitCount := (len(bitString)-1)*8 - int(bitString[0])
for bit := 0; bit < bitCount; bit++ {
if bitString[1+bit/8]&(0x80>>uint(bit%8)) == 0 {
continue
}
switch bit {
case 0:
return "install"
case 1, 4:
return "enable"
case 2, 5:
return "disable"
case 3, 6:
return "delete"
case 7:
return "rpm"
default:
return fmt.Sprintf("event-%d", bit)
}
}
return ""
}
func notificationFromMetadata(metadata *derNode) (EsimNotification, error) {
sequenceNumber, ok := decodePositiveInteger(derValue(metadata.children, 0x80))
if !ok {
return EsimNotification{}, errors.New("esim: pending notification has an invalid sequence number")
}
address := strings.TrimSpace(string(derValue(metadata.children, 0x0C)))
if address == "" {
return EsimNotification{}, errors.New("esim: pending notification has no receiver address")
}
return EsimNotification{
SequenceNumber: sequenceNumber,
Event: notificationEventName(derValue(metadata.children, 0x81)),
ICCID: decodeICCID(derValue(metadata.children, 0x5A)),
Address: address,
CanRetry: true,
}, nil
}
func parsePendingNotification(raw []byte) (EsimNotification, error) {
metadataNodes := derFindAll(derParse(raw), 0xBF2F)
if len(metadataNodes) == 0 {
return EsimNotification{}, errors.New("esim: pending notification has no metadata")
}
notification, err := notificationFromMetadata(metadataNodes[0])
if err != nil {
return EsimNotification{}, err
}
notification.raw = append([]byte(nil), raw...)
return notification, nil
}
func parseNotificationMetadataList(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF28 || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected ListNotification response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
return nil, fmt.Errorf("esim: eUICC could not list notifications (result %X)", responseNodes[0].value)
}
metadataNodes := derFindAll(responseNodes, 0xBF2F)
notifications := make([]EsimNotification, 0, len(metadataNodes))
for _, metadata := range metadataNodes {
notification, parseErr := notificationFromMetadata(metadata)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func parsePendingNotifications(payload []byte) ([]EsimNotification, error) {
tag, headerLength, totalLength, err := derElementAt(payload, 0)
if err != nil || tag != 0xBF2B || totalLength != len(payload) {
return nil, fmt.Errorf("esim: unexpected RetrieveNotificationsList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
value := payload[headerLength:totalLength]
responseNodes := derParse(value)
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
errorCode := responseNodes[0].value
return nil, fmt.Errorf("esim: eUICC could not retrieve notifications (result %X)", errorCode)
}
// The notificationList CHOICE alternative is encoded as context tag A0 by
// AUTOMATIC TAGS on newer eUICCs. Older cards are also seen returning the
// SEQUENCE OF contents directly. Accept both without including the list
// wrapper in the PendingNotification sent to ES9+.
if len(responseNodes) == 1 && responseNodes[0].tag == 0xA0 {
value = responseNodes[0].value
} else if len(responseNodes) == 1 && responseNodes[0].tag == 0x30 && firstChild(responseNodes[0].children, 0xBF2F) == nil {
value = responseNodes[0].value
}
var notifications []EsimNotification
for offset := 0; offset < len(value); {
_, _, elementLength, elementErr := derElementAt(value, offset)
if elementErr != nil {
return nil, elementErr
}
raw := value[offset : offset+elementLength]
notification, parseErr := parsePendingNotification(raw)
if parseErr != nil {
return nil, parseErr
}
notifications = append(notifications, notification)
offset += elementLength
}
sort.SliceStable(notifications, func(left, right int) bool {
if notifications[left].Address == notifications[right].Address {
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
}
return notifications[left].Address < notifications[right].Address
})
return notifications, nil
}
func removeNotificationResult(payload []byte) error {
roots := derParse(payload)
if len(roots) != 1 || roots[0].tag != 0xBF30 {
return fmt.Errorf("esim: unexpected RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
result := derValue(roots[0].children, 0x80)
if len(result) == 0 {
result = derValue(roots[0].children, 0x02)
}
if len(result) != 1 {
return fmt.Errorf("esim: malformed RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
}
switch result[0] {
case 0, 1: // ok, or already removed after an earlier acknowledged retry
return nil
default:
return fmt.Errorf("esim: eUICC could not remove notification (result %d)", result[0])
}
}
func (channel *euiccChannel) retrieveNotifications(ctx context.Context, sequenceNumber *uint64) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildRetrieveNotificationsRequest(sequenceNumber))
if err != nil {
return nil, err
}
return parsePendingNotifications(payload)
}
func (channel *euiccChannel) listNotifications(ctx context.Context) ([]EsimNotification, error) {
payload, err := channel.es10(ctx, buildListNotificationsRequest())
if err != nil {
return nil, err
}
return parseNotificationMetadataList(payload)
}
func (channel *euiccChannel) removeNotification(ctx context.Context, sequenceNumber uint64) error {
payload, err := channel.es10(ctx, buildRemoveNotificationRequest(sequenceNumber))
if err != nil {
return err
}
return removeNotificationResult(payload)
}
func (channel *euiccChannel) deliverNotification(ctx context.Context, notification EsimNotification) error {
client, err := newES9PClient(ctx, notification.Address)
if err != nil {
return err
}
if err := client.handleNotification(ctx, notification.raw); err != nil {
return err
}
if err := channel.removeNotification(ctx, notification.SequenceNumber); err != nil {
return fmt.Errorf("notification acknowledged but could not be removed from eUICC: %w", err)
}
return nil
}
// deliverPendingNotifications sends each receiver's notifications oldest first.
// A failed item stops only that receiver's group so a later sequence number can
// never overtake it and make the older notification stale.
func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) error {
notifications, err := channel.listNotifications(ctx)
if err != nil {
return err
}
blockedAddresses := make(map[string]bool)
var failures []error
for _, notification := range notifications {
if blockedAddresses[notification.Address] {
continue
}
pending, retrieveErr := channel.retrieveNotifications(ctx, &notification.SequenceNumber)
if retrieveErr == nil {
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
for _, candidate := range pending {
if candidate.SequenceNumber == notification.SequenceNumber {
retrieveErr = channel.deliverNotification(ctx, candidate)
break
}
}
}
if retrieveErr != nil {
blockedAddresses[notification.Address] = true
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
}
}
return errors.Join(failures...)
}
// ESIMNotifications returns the notifications retained across every eUICC
// storage exposed by the physical card.
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return nil, err
}
var all []EsimNotification
var lastErr error
succeeded := false
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
channel, err := manager.openEuiccAID(ctx, id, aid)
if err != nil {
lastErr = err
continue
}
notifications, retrieveErr := channel.listNotifications(ctx)
channel.close(context.Background())
if retrieveErr != nil {
lastErr = retrieveErr
continue
}
succeeded = true
for index := range notifications {
notifications[index].AIDHex = aid
}
all = append(all, notifications...)
}
if !succeeded && lastErr != nil {
return nil, lastErr
}
return all, nil
}
// ESIMRetryNotification sends one retained notification and removes it from the
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
manager.esimMu.Lock()
defer manager.esimMu.Unlock()
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
return err
}
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
if err != nil {
return err
}
defer channel.close(context.Background())
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
if err != nil {
return err
}
for _, notification := range notifications {
if notification.SequenceNumber == sequenceNumber {
return channel.deliverNotification(ctx, notification)
}
}
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
}
@@ -0,0 +1,86 @@
package device
import (
"bytes"
"testing"
)
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
t.Helper()
iccidBCD, err := encodeICCID(iccid)
if err != nil {
t.Fatal(err)
}
return derConstruct(0xBF2F,
derEncode(0x80, []byte{sequence}),
derEncode(0x81, event),
derEncode(0x0C, []byte(address)),
derEncode(0x5A, iccidBCD),
)
}
func TestParsePendingNotifications(t *testing.T) {
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
if err != nil {
t.Fatalf("parsePendingNotifications: %v", err)
}
if len(notifications) != 2 {
t.Fatalf("notifications = %#v", notifications)
}
// Results are grouped by receiver, then sorted by sequence number.
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
}
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
}
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
if err != nil || len(metadata) != 2 {
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
}
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
t.Fatalf("listed metadata = %#v", metadata[0])
}
}
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
t.Fatalf("list request = %X", got)
}
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
t.Fatalf("retrieve all request = %X", got)
}
sequenceNumber := uint64(128)
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
}
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
t.Fatalf("remove request = %X, want %X", got, wantRemove)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
t.Fatalf("removeNotificationResult(ok): %v", err)
}
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
t.Fatal("undefinedError response was accepted")
}
}
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
derEncode(0x80, []byte{1}),
derEncode(0x81, []byte{4, 0x10}),
))
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
t.Fatal("notification without receiver address was accepted")
}
}
+7 -4
View File
@@ -24,10 +24,13 @@ var (
)
type NetworkRequest struct {
Enabled bool `json:"enabled"`
APN string `json:"apn"`
IPVersion string `json:"ipVersion"`
Backend string `json:"backend,omitempty"`
Enabled bool `json:"enabled"`
APN string `json:"apn"`
IPVersion string `json:"ipVersion"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Authentication string `json:"authentication,omitempty"`
Backend string `json:"backend,omitempty"`
}
type NetworkResult struct {
+9
View File
@@ -19,6 +19,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -31,6 +32,10 @@ import (
const maxPackageBytes int64 = 64 << 20
// This syntactic guard gives the request boundary an explicit allowlist. The
// resolved addresses are still checked again by netguard before dialing.
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
type Plugin struct {
Manifest
Enabled bool `json:"enabled"`
@@ -156,6 +161,10 @@ func (manager *Manager) List() []Plugin {
}
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
rawURL = strings.TrimSpace(rawURL)
if !publicHTTPSURLPattern.MatchString(rawURL) {
return Plugin{}, errors.New("plugin URL must be a public absolute 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)
+2
View File
@@ -18,6 +18,8 @@ func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
defer manager.Close()
for _, raw := range []string{
"http://example.com/plugin.zip",
"https://[email protected]/plugin.zip",
"https://example.com/plugin.zip\r\nX-Injected: yes",
"https://127.0.0.1/plugin.zip",
"https://169.254.169.254/latest/meta-data/",
} {
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -290,12 +289,7 @@ func sendEmailTextNotification(ctx context.Context, config map[string]any, subje
if err != nil {
return err
}
email := strings.Join([]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")
if _, err := io.WriteString(writer, email); err != nil {
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
_ = writer.Close()
return err
}
+51 -5
View File
@@ -258,11 +258,22 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, NetworkEnabled: config.NetworkEnabled, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policy.APN = config.APN
} else if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = config.NetworkEnabled
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = false
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if task.TaskType != "public_ip" {
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, false)); err != nil {
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID, "error", err)
}
}
@@ -282,7 +293,7 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if !s.developerActive(ctx) {
return errors.New("roaming public IP tasks require developer mode")
}
if _, err := s.devices.SetNetwork(ctx, physicalID, device.NetworkRequest{Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
s.rollbackAutomaticNetwork(config.ID, physicalID, iccid, *config)
return fmt.Errorf("start roaming data: %w", err)
}
@@ -425,18 +436,53 @@ func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Devi
func (s *Server) rollbackAutomaticNetwork(deviceID, physicalID, iccid string, config store.Device) {
cleanupContext, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, device.NetworkRequest{Enabled: false, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend}); err != nil {
policy, policyErr := s.store.CardPolicy(cleanupContext, iccid)
if policyErr != nil {
policy = store.CardPolicy{ICCID: iccid, APN: config.APN, IPVersion: "IPV4V6"}
}
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
s.logger.Warn("stop one-shot automatic roaming data", "device_id", deviceID, "error", err)
}
config.NetworkEnabled = false
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
s.logger.Warn("restore automatic roaming data setting", "device_id", deviceID, "error", err)
}
if err := s.store.UpsertCardPolicy(cleanupContext, store.CardPolicy{ICCID: iccid, NetworkEnabled: false, VoWiFiEnabled: false, AirplaneEnabled: false, APN: config.APN, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy.NetworkEnabled = false
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = false
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(cleanupContext, policy); err != nil {
s.logger.Warn("restore automatic roaming card policy", "device_id", deviceID, "error", err)
}
}
func (s *Server) cardNetworkRequest(
ctx context.Context,
physicalID string,
config store.Device,
policy store.CardPolicy,
enabled bool,
) device.NetworkRequest {
request := device.NetworkRequest{
Enabled: enabled, APN: policy.APN, IPVersion: policy.IPVersion, Backend: config.DeviceBackend,
}
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
profile, err := s.store.CardAPNProfileByAPN(ctx, policy.ICCID, policy.APN, policy.IPVersion)
if err != nil {
return request
}
request.Username = profile.Username
request.Password = profile.Password
request.Authentication = profile.AuthType
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil &&
entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
request.IPVersion = profile.RoamingIPVersion
}
return request
}
func compactAutomaticResponse(body []byte) string {
var payload map[string]any
if json.Unmarshal(body, &payload) == nil {
+118 -10
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
@@ -552,6 +553,11 @@ func (s *Server) handleDevicePath(
return true
}
return s.handleCellularData(w, r, config, physicalID)
case "network/apns":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
}
return s.handleAPNProfiles(w, r, physicalID)
case "network/public-ip":
if !s.requirePhysicalDevice(w, physicalPresent) {
return true
@@ -1127,6 +1133,65 @@ func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, config
return true
}
type modemAPNProfile struct {
CID int `json:"cid"`
APN string `json:"apn"`
IPVersion string `json:"ip_version"`
}
func parseModemAPNProfiles(lines []string) []modemAPNProfile {
profiles := make([]modemAPNProfile, 0)
seen := make(map[string]bool)
for _, line := range lines {
line = strings.TrimSpace(line)
prefix := strings.Index(strings.ToUpper(line), "+CGDCONT:")
if prefix < 0 {
continue
}
record, err := csv.NewReader(strings.NewReader(strings.TrimSpace(line[prefix+len("+CGDCONT:"):]))).Read()
if err != nil || len(record) < 3 {
continue
}
cid, err := strconv.Atoi(strings.TrimSpace(record[0]))
if err != nil || cid < 1 {
continue
}
ipVersion := strings.ToUpper(strings.TrimSpace(record[1]))
if ipVersion == "IPV4" {
ipVersion = "IP"
}
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
continue
}
apn := strings.TrimSpace(record[2])
if apn == "" || !device.ValidAPN(apn) {
continue
}
key := strings.ToLower(apn) + "\x00" + ipVersion
if seen[key] {
continue
}
seen[key] = true
profiles = append(profiles, modemAPNProfile{CID: cid, APN: apn, IPVersion: ipVersion})
}
return profiles
}
func (s *Server) handleAPNProfiles(w http.ResponseWriter, r *http.Request, physicalID string) bool {
if !requireMethod(w, r, http.MethodGet) {
return true
}
response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CGDCONT?")
if err != nil {
s.writeDeviceError(w, err)
return true
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"items": parseModemAPNProfiles(response.Lines),
}})
return true
}
func (s *Server) handleCellularData(
w http.ResponseWriter,
r *http.Request,
@@ -1165,32 +1230,75 @@ func (s *Server) handleCellularData(
}
}
apn := strings.TrimSpace(request.APN)
if apn == "" {
policyIPVersion := "IPV4V6"
activeICCID := ""
isRoaming := false
var activePolicy store.CardPolicy
var activeAPNProfile store.CardAPNProfile
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil {
activeICCID = strings.TrimSpace(entry.Snapshot.ICCID)
isRoaming = entry.Snapshot.RegistrationStatus == 5
if stored, policyErr := s.store.CardPolicy(r.Context(), activeICCID); policyErr == nil {
activePolicy = stored
if apn == "" {
apn = strings.TrimSpace(stored.APN)
}
if stored.IPVersion != "" {
policyIPVersion = stored.IPVersion
}
}
}
if apn == "" && activePolicy.ICCID == "" {
apn = strings.TrimSpace(config.APN)
}
if !device.ValidAPN(apn) {
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
return true
}
if profile, profileErr := s.store.CardAPNProfileByAPN(r.Context(), activeICCID, apn, policyIPVersion); profileErr == nil {
activeAPNProfile = profile
}
effectiveIPVersion := policyIPVersion
if isRoaming && activeAPNProfile.RoamingIPVersion != "" {
effectiveIPVersion = activeAPNProfile.RoamingIPVersion
}
networkRequest := device.NetworkRequest{
Enabled: request.Enabled, APN: apn, IPVersion: effectiveIPVersion,
Username: activeAPNProfile.Username, Password: activeAPNProfile.Password,
Authentication: activeAPNProfile.AuthType, Backend: config.DeviceBackend,
}
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
result, err := s.devices.SetNetwork(r.Context(), physicalID, device.NetworkRequest{
Enabled: request.Enabled, APN: apn, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
})
result, err := s.devices.SetNetwork(r.Context(), physicalID, networkRequest)
if err != nil {
s.writeDeviceError(w, err)
return true
}
previous := config.NetworkEnabled
config.NetworkEnabled = request.Enabled
if apn != "" {
config.APN = apn
}
config.APN = apn
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
rollbackContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, device.NetworkRequest{
Enabled: previous, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
})
networkRequest.Enabled = previous
networkRequest.APN = config.APN
_, _ = s.devices.SetNetwork(rollbackContext, physicalID, networkRequest)
cancel()
s.writeStoreError(w, err)
return true
}
if validICCID(activeICCID) {
if activePolicy.ICCID == "" {
activePolicy = defaultCardPolicy(activeICCID)
}
activePolicy.APN = apn
activePolicy.IPVersion = policyIPVersion
if strings.TrimSpace(request.APN) != "" {
activePolicy.Source = "manual"
}
if err := s.store.UpsertCardPolicy(r.Context(), activePolicy); err != nil {
s.logger.Warn("cellular APN active but card policy could not be updated", "device_id", config.ID, "iccid", activeICCID, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"enabled": result.Enabled, "interface": result.Interface,
"backend": result.Backend, "export_proxy_only": true,
@@ -31,6 +31,24 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
return envelope.Data
}
func TestParseModemAPNProfiles(t *testing.T) {
profiles := parseModemAPNProfiles([]string{
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
`+CGDCONT: 2,"IP","ims","0.0.0.0",0,0`,
`+CGDCONT: 3,"IPV4V6","internet","0.0.0.0",0,0`,
`+CGDCONT: 4,"IP","","0.0.0.0",0,0`,
})
if len(profiles) != 2 {
t.Fatalf("profiles = %#v", profiles)
}
if profiles[0].CID != 1 || profiles[0].APN != "internet" || profiles[0].IPVersion != "IPV4V6" {
t.Fatalf("first profile = %#v", profiles[0])
}
if profiles[1].CID != 2 || profiles[1].APN != "ims" || profiles[1].IPVersion != "IP" {
t.Fatalf("second profile = %#v", profiles[1])
}
}
type esimAIDCaptureController struct {
fakeDeviceController
switchAID string
@@ -286,6 +304,13 @@ func TestHandleESIMShapes(t *testing.T) {
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "dev1"}); err != nil {
t.Fatal(err)
}
const switchedICCID = "8900000000000000001"
if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{
ICCID: switchedICCID, VoWiFiEnabled: false, AirplaneEnabled: false,
APN: "profile.apn", IPVersion: "IP", Source: "manual",
}); err != nil {
t.Fatal(err)
}
controller := &esimAIDCaptureController{}
present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller}
swOK := httptest.NewRecorder()
@@ -301,6 +326,14 @@ func TestHandleESIMShapes(t *testing.T) {
if controller.switchAID != "A0000005591010FFFFFFFF8900000177" {
t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID)
}
storedPolicy, err := database.CardPolicy(context.Background(), switchedICCID)
if err != nil || storedPolicy.VoWiFiEnabled || storedPolicy.AirplaneEnabled || storedPolicy.APN != "profile.apn" || storedPolicy.IPVersion != "IP" {
t.Fatalf("switch overwrote saved policy: %+v, %v", storedPolicy, err)
}
storedDevice, err := database.Device(context.Background(), "dev1")
if err != nil || storedDevice.VoWiFiEnabled || storedDevice.APN != "profile.apn" {
t.Fatalf("switch did not restore device policy: %+v, %v", storedDevice, err)
}
// Disable happy path routes the active profile to ES10c DisableProfile.
disableOK := httptest.NewRecorder()
@@ -340,6 +373,64 @@ func TestHandleESIMShapes(t *testing.T) {
}
}
type fakeEsimNotificationController struct {
fakeDeviceController
items []device.EsimNotification
listErr error
retryErr error
retryDeviceID string
retryAID string
retrySequence uint64
}
func (f *fakeEsimNotificationController) ESIMNotifications(context.Context, string) ([]device.EsimNotification, error) {
return f.items, f.listErr
}
func (f *fakeEsimNotificationController) ESIMRetryNotification(_ context.Context, deviceID, aidHex string, sequenceNumber uint64) error {
f.retryDeviceID = deviceID
f.retryAID = aidHex
f.retrySequence = sequenceNumber
return f.retryErr
}
func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
SequenceNumber: 12,
Event: "delete",
ICCID: "89441000400128014257",
Address: "rsp.example.com",
AIDHex: "A0000005591010FFFFFFFF8900000100",
CanRetry: true,
}}}
server := &Server{logger: regionTestLogger(), devices: controller}
list := httptest.NewRecorder()
server.handleESIM(list, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", true)
if list.Code != http.StatusOK {
t.Fatalf("list status = %d, body=%s", list.Code, list.Body.String())
}
data := decodeData(t, list)
items, ok := data["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("items = %#v", data["items"])
}
item := items[0].(map[string]any)
if item["sequenceNumber"] != float64(12) || item["event"] != "delete" || item["address"] != "rsp.example.com" {
t.Fatalf("item = %#v", item)
}
retry := httptest.NewRecorder()
retryRequest := httptest.NewRequest(http.MethodPost, "/esim/notifications/12/actions/retry?aid_hex=A000", nil)
server.handleESIM(retry, retryRequest, []string{"notifications", "12", "actions", "retry"}, "dev1", true)
if retry.Code != http.StatusOK {
t.Fatalf("retry status = %d, body=%s", retry.Code, retry.Body.String())
}
if controller.retryDeviceID != "dev1" || controller.retryAID != "A000" || controller.retrySequence != 12 {
t.Fatalf("retry args = (%q, %q, %d)", controller.retryDeviceID, controller.retryAID, controller.retrySequence)
}
}
func TestHandleFixUSBNet(t *testing.T) {
server := &Server{
logger: regionTestLogger(),
+67
View File
@@ -0,0 +1,67 @@
package server
import (
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"net/mail"
"strings"
"time"
)
// writePlainTextMail constructs one RFC 5322 message without allowing values
// supplied by notification configuration or device messages to create new
// headers or MIME parts. Mailbox values have already passed net/mail parsing,
// the subject is encoded as one encoded-word, and the body is base64 encoded.
func writePlainTextMail(
writer io.Writer,
from *mail.Address,
recipients []*mail.Address,
subject string,
body string,
) error {
if from == nil || len(recipients) == 0 {
return errors.New("email sender and recipient are required")
}
if strings.ContainsAny(subject, "\r\n\x00") {
return errors.New("email subject contains a prohibited control character")
}
encodedBody := wrapMIMEBase64(base64.StdEncoding.EncodeToString([]byte(body)))
message := strings.Join([]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: base64",
"",
encodedBody,
"",
}, "\r\n")
// The only values reaching this sink have been parsed as RFC mailboxes or
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
// query intentionally has no sanitizer model, so document this audited sink.
// codeql[go/email-injection]
if _, err := io.WriteString(writer, message); err != nil {
return fmt.Errorf("write email message: %w", err)
}
return nil
}
func wrapMIMEBase64(value string) string {
if value == "" {
return ""
}
const lineLength = 76
lines := make([]string, 0, (len(value)+lineLength-1)/lineLength)
for len(value) > lineLength {
lines = append(lines, value[:lineLength])
value = value[lineLength:]
}
lines = append(lines, value)
return strings.Join(lines, "\r\n")
}
+44
View File
@@ -0,0 +1,44 @@
package server
import (
"bytes"
"encoding/base64"
"net/mail"
"strings"
"testing"
)
func TestWritePlainTextMailEncodesUntrustedContent(t *testing.T) {
from, err := parseMailAddress("VoCat Alerts <[email protected]>")
if err != nil {
t.Fatal(err)
}
recipient, err := parseMailAddress("Admin <[email protected]>")
if err != nil {
t.Fatal(err)
}
body := "message\r\nBcc: [email protected]\r\n<script>alert(1)</script>"
var output bytes.Buffer
if err := writePlainTextMail(&output, from, []*mail.Address{recipient}, "new SMS", body); err != nil {
t.Fatal(err)
}
message := output.String()
if strings.Contains(message, body) || strings.Contains(message, "\r\nBcc: [email protected]") {
t.Fatalf("unencoded body reached message: %q", message)
}
if !strings.Contains(message, "Content-Transfer-Encoding: base64") {
t.Fatalf("base64 transfer encoding missing: %q", message)
}
encoded := base64.StdEncoding.EncodeToString([]byte(body))
if !strings.Contains(strings.ReplaceAll(message, "\r\n", ""), encoded) {
t.Fatalf("encoded body missing: %q", message)
}
}
func TestWritePlainTextMailRejectsInjectedSubject(t *testing.T) {
from := &mail.Address{Address: "[email protected]"}
recipients := []*mail.Address{{Address: "[email protected]"}}
if err := writePlainTextMail(&bytes.Buffer{}, from, recipients, "hello\r\nBcc: [email protected]", "body"); err == nil {
t.Fatal("injected subject was accepted")
}
}
+100 -15
View File
@@ -1,9 +1,11 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -15,6 +17,11 @@ func esimUnavailable(w http.ResponseWriter) {
writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.")
}
type esimNotificationController interface {
ESIMNotifications(context.Context, string) ([]device.EsimNotification, error)
ESIMRetryNotification(context.Context, string, string, uint64) error
}
// handleESIM routes every /devices/{id}/esim* path.
func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool, configuredIDs ...string) bool {
configuredID := physicalID
@@ -53,11 +60,16 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
if !requireMethod(w, r, http.MethodGet) {
return true
}
// No LPA download backend, so there are never pending notifications.
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
s.writeEsimNotifications(w, r, physicalID, physicalPresent)
return true
}
if len(rest) == 4 && rest[2] == "actions" && rest[3] == "retry" {
if !requireMethod(w, r, http.MethodPost) {
return true
}
s.handleEsimNotificationRetry(w, r, physicalID, physicalPresent, rest[1])
return true
}
// notifications/{id}/actions/retry
esimUnavailable(w)
return true
case "actions":
@@ -90,6 +102,48 @@ func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []strin
}
}
func (s *Server) writeEsimNotifications(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
controller, ok := s.devices.(esimNotificationController)
if !ok || !physicalPresent {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
return
}
items, err := controller.ESIMNotifications(r.Context(), physicalID)
if err != nil {
s.writeDeviceError(w, err)
return
}
if items == nil {
items = []device.EsimNotification{}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
}
func (s *Server) handleEsimNotificationRetry(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, rawSequenceNumber string) {
controller, ok := s.devices.(esimNotificationController)
if !ok {
esimUnavailable(w)
return
}
if !physicalPresent {
writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
return
}
sequenceNumber, err := strconv.ParseUint(strings.TrimSpace(rawSequenceNumber), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "notification sequence number is invalid")
return
}
if err := controller.ESIMRetryNotification(r.Context(), physicalID, r.URL.Query().Get("aid_hex"), sequenceNumber); err != nil {
s.writeDeviceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"status": "sent",
"message": "通知已上报运营商并从 eUICC 待处理列表移除",
}})
}
// esimInfo loads the eUICC profile list. The string result is "ok" (use info),
// "empty" (no usable eUICC — render the empty state), or "error" (an error
// response has already been written).
@@ -366,37 +420,68 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
s.writeDeviceError(w, err)
return
}
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil {
s.writeStoreError(w, err)
return
}
// Never replace a returning profile's policy with defaults. VoWiFi still
// implies airplane mode, but every user-selected value and APN belongs to
// this ICCID and is restored when the profile becomes active again.
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
}
config, err := s.store.Device(r.Context(), configuredID)
if err != nil {
s.writeStoreError(w, err)
return
}
config.VoWiFiEnabled = true
config.VoWiFiEnabled = policy.VoWiFiEnabled
config.NetworkEnabled = false
config.APN = policy.APN
if err := s.store.UpsertDevice(r.Context(), config); err != nil {
s.writeStoreError(w, err)
return
}
canRestoreFlightImmediately := s.vowifi == nil
if s.vowifi != nil {
state, stateErr := s.vowifi.State(configuredID)
switch {
case stateErr == nil && state.Enabled:
_, err = s.vowifi.RequestReconnect(configuredID)
default:
_, err = s.vowifi.RequestEnabled(configuredID, true)
if policy.VoWiFiEnabled {
switch {
case stateErr == nil && state.Enabled:
_, err = s.vowifi.RequestReconnect(configuredID)
default:
_, err = s.vowifi.RequestEnabled(configuredID, true)
}
} else if stateErr == nil && state.Enabled {
_, err = s.vowifi.RequestEnabled(configuredID, false)
} else {
canRestoreFlightImmediately = true
}
if err != nil {
s.logger.Warn("profile switched in safe airplane mode but VoWiFi start was not queued", "device_id", configuredID, "iccid", iccid, "error", err)
s.logger.Warn("profile switched but saved VoWiFi state was not queued", "device_id", configuredID, "iccid", iccid, "enabled", policy.VoWiFiEnabled, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
if !policy.VoWiFiEnabled && canRestoreFlightImmediately && !policy.AirplaneEnabled {
if _, err := s.devices.SetFlight(r.Context(), physicalID, false); err != nil {
s.logger.Warn("profile switched but saved airplane state will require reconciliation", "device_id", configuredID, "iccid", iccid, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"status": "switched", "iccid": iccid, "verified": true,
"card_policy": cardPolicyResponse(policy),
}})
}
func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
-7
View File
@@ -562,13 +562,6 @@ func (w *statusWriter) WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Write(data []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
+317 -54
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -25,6 +24,7 @@ import (
"sync"
"time"
"vocat/internal/device"
"vocat/internal/store"
)
@@ -100,6 +100,14 @@ func (s *Server) routeSettingsAPI(
s.handleCardPolicy(w, r, segments[1])
return true
}
if len(segments) == 3 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], "")
return true
}
if len(segments) == 4 && segments[0] == "cards" && segments[2] == "apns" {
s.handleCardAPNProfiles(w, r, segments[1], segments[3])
return true
}
return false
}
@@ -788,18 +796,13 @@ func sendEmailNotificationTest(ctx context.Context, config map[string]any) error
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
message := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: vocat notification test",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
if err := writePlainTextMail(
writer,
from,
recipients,
"vocat notification test",
"This is a vocat notification test.",
"",
}, "\r\n")
if _, err := io.WriteString(writer, message); err != nil {
); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP test message: %w", err)
}
@@ -841,7 +844,7 @@ func formatMailAddress(address *mail.Address) string {
if address.Name == "" {
return address.Address
}
return mime.QEncoding.Encode("UTF-8", address.Name) + " <" + address.Address + ">"
return (&mail.Address{Name: address.Name, Address: address.Address}).String()
}
func restrictedHTTPClient(
@@ -1217,13 +1220,7 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
case http.MethodGet:
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = store.CardPolicy{
ICCID: iccid,
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
@@ -1238,65 +1235,78 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut:
var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN string `json:"apn"`
IPVersion string `json:"ip_version"`
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil ||
request.AirplaneEnabled == nil {
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"all card policy switches are required",
"at least one card policy field is required",
)
return
}
request.APN = strings.TrimSpace(request.APN)
if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
policy, err := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(err, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
} else if err != nil {
s.writeStoreError(w, err)
return
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
if request.APN != nil {
apn := strings.TrimSpace(*request.APN)
if !device.ValidAPN(apn) {
writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN must contain only letters, digits, dots, underscores, or hyphens")
return
}
policy.APN = apn
}
if request.IPVersion != "IP" &&
request.IPVersion != "IPV6" &&
request.IPVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
if request.IPVersion != nil {
ipVersion := strings.ToUpper(strings.TrimSpace(*request.IPVersion))
if ipVersion == "" {
ipVersion = "IPV4V6"
}
if ipVersion != "IP" && ipVersion != "IPV6" && ipVersion != "IPV4V6" {
writeError(
w,
http.StatusBadRequest,
"invalid_card_policy",
"IP version must be IP, IPV6, or IPV4V6",
)
return
}
policy.IPVersion = ipVersion
}
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
if request.AirplaneEnabled != nil {
policy.AirplaneEnabled = *request.AirplaneEnabled
}
// VoWiFi always owns an RF-off modem. Store airplane=true even when an
// older client omits that implication, so disabling VoWiFi cannot expose a
// brief cellular attach window.
if *request.VoWiFiEnabled {
*request.AirplaneEnabled = true
if policy.VoWiFiEnabled {
policy.AirplaneEnabled = true
policy.NetworkEnabled = false
}
policy := store.CardPolicy{
ICCID: iccid,
NetworkEnabled: false,
VoWiFiEnabled: *request.VoWiFiEnabled,
AirplaneEnabled: *request.AirplaneEnabled,
APN: request.APN,
IPVersion: request.IPVersion,
Source: "manual",
if policy.IPVersion == "" {
policy.IPVersion = "IPV4V6"
}
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
policy, err = s.store.CardPolicy(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
@@ -1308,6 +1318,259 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
}
func defaultCardPolicy(iccid string) store.CardPolicy {
return store.CardPolicy{
ICCID: strings.TrimSpace(iccid),
VoWiFiEnabled: true,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: "default",
}
}
type cardAPNProfilePayload struct {
APN string `json:"apn"`
Username string `json:"username"`
Password *string `json:"password"`
ClearPassword bool `json:"clear_password"`
Proxy string `json:"proxy"`
MCC string `json:"mcc"`
MNC string `json:"mnc"`
IPVersion string `json:"ip_version"`
RoamingIPVersion string `json:"roaming_ip_version"`
AuthType string `json:"auth_type"`
}
func (s *Server) decodeCardAPNProfilePayload(w http.ResponseWriter, r *http.Request) (cardAPNProfilePayload, bool) {
var request cardAPNProfilePayload
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return request, false
}
request.APN = strings.TrimSpace(request.APN)
if request.APN == "" || !device.ValidAPN(request.APN) {
writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens")
return request, false
}
request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
if request.IPVersion == "" {
request.IPVersion = "IPV4V6"
}
if request.IPVersion != "IP" && request.IPVersion != "IPV6" && request.IPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_ip_version", "IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(request.RoamingIPVersion))
if request.RoamingIPVersion == "" {
request.RoamingIPVersion = "IP"
}
if request.RoamingIPVersion != "IP" && request.RoamingIPVersion != "IPV6" && request.RoamingIPVersion != "IPV4V6" {
writeError(w, http.StatusBadRequest, "invalid_roaming_ip_version", "roaming IP version must be IP, IPV6, or IPV4V6")
return request, false
}
request.AuthType = strings.ToUpper(strings.TrimSpace(request.AuthType))
if request.AuthType == "" {
request.AuthType = "NONE"
}
if request.AuthType != "NONE" && request.AuthType != "PAP" && request.AuthType != "CHAP" && request.AuthType != "PAP_OR_CHAP" {
writeError(w, http.StatusBadRequest, "invalid_auth_type", "authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
return request, false
}
request.Username = strings.TrimSpace(request.Username)
request.Proxy = strings.TrimSpace(request.Proxy)
request.MCC = strings.TrimSpace(request.MCC)
request.MNC = strings.TrimSpace(request.MNC)
password := ""
if request.Password != nil {
password = *request.Password
}
if !validAPNText(request.Username, 128) || !validAPNText(password, 128) || !validAPNText(request.Proxy, 255) {
writeError(w, http.StatusBadRequest, "invalid_apn_credentials", "APN username, password, or proxy contains unsupported characters")
return request, false
}
if request.MCC != "" && !decimalLength(request.MCC, 3, 3) {
writeError(w, http.StatusBadRequest, "invalid_mcc", "MCC must contain exactly 3 digits")
return request, false
}
if request.MNC != "" && !decimalLength(request.MNC, 2, 3) {
writeError(w, http.StatusBadRequest, "invalid_mnc", "MNC must contain 2 or 3 digits")
return request, false
}
return request, true
}
func (s *Server) handleCardAPNProfiles(w http.ResponseWriter, r *http.Request, iccid, profileID string) {
iccid = strings.TrimSpace(iccid)
if !validICCID(iccid) {
writeError(w, http.StatusBadRequest, "invalid_iccid", "ICCID must contain between 10 and 32 decimal digits")
return
}
if profileID != "" {
id, err := strconv.ParseInt(profileID, 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid_apn_profile", "APN profile ID is invalid")
return
}
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
var existing store.CardAPNProfile
for _, profile := range profiles {
if profile.ID == id {
existing = profile
break
}
}
if existing.ID == 0 {
writeError(w, http.StatusNotFound, "apn_profile_not_found", "APN profile was not found")
return
}
switch r.Method {
case http.MethodDelete:
if err := s.store.DeleteCardAPNProfile(r.Context(), iccid, id); err != nil {
s.writeStoreError(w, err)
return
}
policy, err := s.store.CardPolicy(r.Context(), iccid)
if err == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = ""
policy.IPVersion = "IPV4V6"
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil && !errors.Is(err, store.ErrNotFound) {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "id": id}})
case http.MethodPatch, http.MethodPut:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
password := existing.Password
if request.ClearPassword {
password = ""
} else if request.Password != nil && *request.Password != "" {
password = *request.Password
}
updated, err := s.store.UpdateCardAPNProfile(r.Context(), store.CardAPNProfile{
ID: id, ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
policy, policyErr := s.store.CardPolicy(r.Context(), iccid)
if policyErr == nil && strings.EqualFold(policy.APN, existing.APN) && strings.EqualFold(policy.IPVersion, existing.IPVersion) {
policy.APN = updated.APN
policy.IPVersion = updated.IPVersion
policy.Source = "manual"
if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
s.writeStoreError(w, err)
return
}
} else if policyErr != nil && !errors.Is(policyErr, store.ErrNotFound) {
s.writeStoreError(w, policyErr)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": cardAPNProfileResponse(updated)})
default:
w.Header().Set("Allow", "PATCH, PUT, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
return
}
switch r.Method {
case http.MethodGet:
profiles, err := s.store.ListCardAPNProfiles(r.Context(), iccid)
if err != nil {
s.writeStoreError(w, err)
return
}
items := make([]map[string]any, 0, len(profiles))
for _, profile := range profiles {
items = append(items, cardAPNProfileResponse(profile))
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}})
case http.MethodPost:
request, ok := s.decodeCardAPNProfilePayload(w, r)
if !ok {
return
}
if _, err := s.store.CardPolicy(r.Context(), iccid); errors.Is(err, store.ErrNotFound) {
if err := s.store.UpsertCardPolicy(r.Context(), defaultCardPolicy(iccid)); err != nil {
s.writeStoreError(w, err)
return
}
} else if err != nil {
s.writeStoreError(w, err)
return
}
password := ""
if request.Password != nil {
password = *request.Password
}
profile, err := s.store.UpsertCardAPNProfile(r.Context(), store.CardAPNProfile{
ICCID: iccid, APN: request.APN, Username: request.Username,
Password: password, Proxy: request.Proxy, MCC: request.MCC, MNC: request.MNC,
IPVersion: request.IPVersion, RoamingIPVersion: request.RoamingIPVersion,
AuthType: request.AuthType,
})
if err != nil {
s.writeStoreError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": cardAPNProfileResponse(profile)})
default:
w.Header().Set("Allow", "GET, POST")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
func cardAPNProfileResponse(profile store.CardAPNProfile) map[string]any {
return map[string]any{
"id": profile.ID, "iccid": profile.ICCID, "apn": profile.APN,
"username": profile.Username, "has_password": profile.Password != "",
"proxy": profile.Proxy, "mcc": profile.MCC, "mnc": profile.MNC,
"ip_version": profile.IPVersion, "roaming_ip_version": profile.RoamingIPVersion,
"auth_type": profile.AuthType, "created_at": profile.CreatedAt,
"updated_at": profile.UpdatedAt,
}
}
func validAPNText(value string, maxLength int) bool {
if len(value) > maxLength || strings.ContainsAny(value, "\r\n\x00\"") {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func decimalLength(value string, minimum, maximum int) bool {
if len(value) < minimum || len(value) > maximum {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validICCID(value string) bool {
if len(value) < 10 || len(value) > 32 {
return false
+96
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"sync/atomic"
"testing"
@@ -459,6 +460,101 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
// Updating only the switches must preserve the ICCID-specific APN.
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"vowifi_enabled":false,"airplane_enabled":false}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" {
t.Fatalf("partially updated policy = %+v, %v", stored, err)
}
// APN-only updates are accepted without changing either switch.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("APN-only policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "mobile.example" || stored.IPVersion != "IP" {
t.Fatalf("APN-only updated policy = %+v, %v", stored, err)
}
// A profile can keep multiple custom APNs independently of the active APN.
recorder = test.request(t, http.MethodPost, "/api/cards/"+iccid+"/apns", `{
"apn":"custom.table","username":"gg","password":"p","proxy":"",
"mcc":"234","mnc":"10","ip_version":"IPV4V6",
"roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusCreated {
t.Fatalf("create custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
custom := response["data"].(map[string]any)
customID := int64(custom["id"].(float64))
recorder = test.request(t, http.MethodGet, "/api/cards/"+iccid+"/apns", "")
if recorder.Code != http.StatusOK {
t.Fatalf("list custom APNs status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
items := response["data"].(map[string]any)["items"].([]any)
if len(items) != 1 {
t.Fatalf("custom APNs = %#v", items)
}
listed := items[0].(map[string]any)
if listed["apn"] != "custom.table" || listed["username"] != "gg" ||
listed["has_password"] != true || listed["mcc"] != "234" || listed["mnc"] != "10" ||
listed["roaming_ip_version"] != "IP" || listed["auth_type"] != "PAP" {
t.Fatalf("custom APNs = %#v", items)
}
if _, exposed := listed["password"]; exposed {
t.Fatalf("custom APN API exposed stored password: %#v", listed)
}
storedAPN, err := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.table", "IPV4V6")
if err != nil || storedAPN.Username != "gg" || storedAPN.Password != "p" || storedAPN.AuthType != "PAP" {
t.Fatalf("stored custom APN = %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.edited","username":"gg2","proxy":"","mcc":"234","mnc":"10",
"ip_version":"IPV4V6","roaming_ip_version":"IP","auth_type":"PAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
storedAPN, err = test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.edited", "IPV4V6")
if err != nil || storedAPN.Username != "gg2" || storedAPN.Password != "p" {
t.Fatalf("editing custom APN did not preserve password: %#v, %v", storedAPN, err)
}
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"custom.edited","ip_version":"IPV4V6"}`)
if recorder.Code != http.StatusOK {
t.Fatalf("activate custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(t, http.MethodPatch, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), `{
"apn":"custom.final","username":"gg2","clear_password":true,"proxy":"",
"mcc":"234","mnc":"10","ip_version":"IP","roaming_ip_version":"IPV4V6","auth_type":"CHAP"
}`)
if recorder.Code != http.StatusOK {
t.Fatalf("edit active custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
storedAPN, profileErr := test.database.CardAPNProfileByAPN(context.Background(), iccid, "custom.final", "IP")
if err != nil || profileErr != nil || stored.APN != "custom.final" || stored.IPVersion != "IP" || storedAPN.Password != "" {
t.Fatalf("active APN edit was not synchronized: policy=%#v profile=%#v errors=%v/%v", stored, storedAPN, err, profileErr)
}
recorder = test.request(t, http.MethodDelete, "/api/cards/"+iccid+"/apns/"+strconv.FormatInt(customID, 10), "")
if recorder.Code != http.StatusOK {
t.Fatalf("delete custom APN status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.APN != "" || stored.IPVersion != "IPV4V6" {
t.Fatalf("deleting active custom APN did not restore automatic mode: %+v, %v", stored, err)
}
recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid ICCID status = %d", recorder.Code)
+6 -13
View File
@@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/mail"
@@ -426,19 +425,13 @@ func sendEmailSMSNotification(ctx context.Context, config map[string]any, messag
if err != nil {
return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
}
email := strings.Join([]string{
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
"From: " + formatMailAddress(from),
"To: " + joinMailAddresses(recipients),
"Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
if err := writePlainTextMail(
writer,
from,
recipients,
"收到新短信 - "+message.DeviceLabel,
message.Text(),
"",
}, "\r\n")
if _, err := io.WriteString(writer, email); err != nil {
); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP notification: %w", err)
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
"local_proxy_config", "upstream_proxies", "country_rules",
"device_proxy_bindings",
"notification_settings", "app_settings", "audit_events",
"log_events", "card_policies", "traffic_buckets",
"log_events", "card_policies", "card_apn_profiles", "traffic_buckets",
"sms_send_attempts",
} {
var found string
+28
View File
@@ -227,6 +227,34 @@ func migrationStatements(version int) []string {
`CREATE INDEX device_proxy_bindings_device_idx
ON device_proxy_bindings(device_id, iccid)`,
}
case 13:
return []string{
`CREATE TABLE IF NOT EXISTS card_apn_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
iccid TEXT NOT NULL,
apn TEXT NOT NULL,
ip_version TEXT NOT NULL DEFAULT 'IPV4V6'
CHECK (ip_version IN ('IP', 'IPV6', 'IPV4V6')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (iccid, apn, ip_version),
FOREIGN KEY (iccid) REFERENCES card_policies(iccid) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS card_apn_profiles_iccid_idx
ON card_apn_profiles(iccid, id)`,
}
case 14:
return []string{
`ALTER TABLE card_apn_profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN password TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN proxy TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mcc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN mnc TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE card_apn_profiles ADD COLUMN roaming_ip_version TEXT NOT NULL DEFAULT 'IP'
CHECK (roaming_ip_version IN ('IP', 'IPV6', 'IPV4V6'))`,
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
}
default:
return nil
}
+16
View File
@@ -488,6 +488,22 @@ type CardPolicy struct {
UpdatedAt time.Time
}
type CardAPNProfile struct {
ID int64
ICCID string
APN string
Username string
Password string
Proxy string
MCC string
MNC string
IPVersion string
RoamingIPVersion string
AuthType string
CreatedAt time.Time
UpdatedAt time.Time
}
type TrafficBucket struct {
DeviceID string
Bucket string
+155
View File
@@ -465,6 +465,161 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
return value, nil
}
func (s *Store) UpsertCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
value.ICCID = strings.TrimSpace(value.ICCID)
value.APN = strings.TrimSpace(value.APN)
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
if value.ICCID == "" || value.APN == "" {
return CardAPNProfile{}, errors.New("card APN profile ICCID and APN are required")
}
if value.IPVersion == "" {
value.IPVersion = "IPV4V6"
}
switch value.IPVersion {
case "IP", "IPV6", "IPV4V6":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN profile IP version %q", value.IPVersion)
}
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
if value.RoamingIPVersion == "" {
value.RoamingIPVersion = "IP"
}
switch value.RoamingIPVersion {
case "IP", "IPV6", "IPV4V6":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN roaming IP version %q", value.RoamingIPVersion)
}
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
if value.AuthType == "" {
value.AuthType = "NONE"
}
switch value.AuthType {
case "NONE", "PAP", "CHAP", "PAP_OR_CHAP":
default:
return CardAPNProfile{}, fmt.Errorf("unsupported card APN authentication type %q", value.AuthType)
}
value.Username = strings.TrimSpace(value.Username)
value.Proxy = strings.TrimSpace(value.Proxy)
value.MCC = strings.TrimSpace(value.MCC)
value.MNC = strings.TrimSpace(value.MNC)
now := time.Now().UTC().Unix()
var createdAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `
INSERT INTO card_apn_profiles (
iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid, apn, ip_version) DO UPDATE SET
username = excluded.username, password = excluded.password,
proxy = excluded.proxy, mcc = excluded.mcc, mnc = excluded.mnc,
roaming_ip_version = excluded.roaming_ip_version,
auth_type = excluded.auth_type, updated_at = excluded.updated_at
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
`, value.ICCID, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, now).Scan(
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
)
if err != nil {
return CardAPNProfile{}, fmt.Errorf("upsert card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
}
func (s *Store) ListCardAPNProfiles(ctx context.Context, iccid string) ([]CardAPNProfile, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
FROM card_apn_profiles WHERE iccid = ? ORDER BY id
`, strings.TrimSpace(iccid))
if err != nil {
return nil, fmt.Errorf("list card APN profiles: %w", err)
}
defer rows.Close()
values := make([]CardAPNProfile, 0)
for rows.Next() {
var value CardAPNProfile
var createdAt, updatedAt int64
if err := rows.Scan(&value.ID, &value.ICCID, &value.APN, &value.Username,
&value.Password, &value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate card APN profiles: %w", err)
}
return values, nil
}
func (s *Store) CardAPNProfileByAPN(ctx context.Context, iccid, apn, ipVersion string) (CardAPNProfile, error) {
profiles, err := s.ListCardAPNProfiles(ctx, iccid)
if err != nil {
return CardAPNProfile{}, err
}
for _, profile := range profiles {
if strings.EqualFold(profile.APN, strings.TrimSpace(apn)) &&
strings.EqualFold(profile.IPVersion, strings.TrimSpace(ipVersion)) {
return profile, nil
}
}
return CardAPNProfile{}, ErrNotFound
}
func (s *Store) UpdateCardAPNProfile(ctx context.Context, value CardAPNProfile) (CardAPNProfile, error) {
value.ICCID = strings.TrimSpace(value.ICCID)
value.APN = strings.TrimSpace(value.APN)
value.Username = strings.TrimSpace(value.Username)
value.Proxy = strings.TrimSpace(value.Proxy)
value.MCC = strings.TrimSpace(value.MCC)
value.MNC = strings.TrimSpace(value.MNC)
value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion))
value.RoamingIPVersion = strings.ToUpper(strings.TrimSpace(value.RoamingIPVersion))
value.AuthType = strings.ToUpper(strings.TrimSpace(value.AuthType))
if value.ID < 1 || value.ICCID == "" || value.APN == "" {
return CardAPNProfile{}, errors.New("card APN profile ID, ICCID, and APN are required")
}
now := time.Now().UTC().Unix()
var createdAt, updatedAt int64
err := s.db.QueryRowContext(ctx, `
UPDATE card_apn_profiles SET
apn = ?, username = ?, password = ?, proxy = ?, mcc = ?, mnc = ?,
ip_version = ?, roaming_ip_version = ?, auth_type = ?, updated_at = ?
WHERE id = ? AND iccid = ?
RETURNING id, iccid, apn, username, password, proxy, mcc, mnc,
ip_version, roaming_ip_version, auth_type, created_at, updated_at
`, value.APN, value.Username, value.Password, value.Proxy, value.MCC, value.MNC,
value.IPVersion, value.RoamingIPVersion, value.AuthType, now, value.ID, value.ICCID).Scan(
&value.ID, &value.ICCID, &value.APN, &value.Username, &value.Password,
&value.Proxy, &value.MCC, &value.MNC, &value.IPVersion,
&value.RoamingIPVersion, &value.AuthType, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return CardAPNProfile{}, ErrNotFound
}
if err != nil {
return CardAPNProfile{}, fmt.Errorf("update card APN profile: %w", err)
}
value.CreatedAt = time.Unix(createdAt, 0).UTC()
value.UpdatedAt = time.Unix(updatedAt, 0).UTC()
return value, nil
}
func (s *Store) DeleteCardAPNProfile(ctx context.Context, iccid string, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM card_apn_profiles WHERE iccid = ? AND id = ?`, strings.TrimSpace(iccid), id)
if err != nil {
return fmt.Errorf("delete card APN profile: %w", err)
}
return requireAffected(result)
}
func (s *Store) UpsertTrafficBucket(ctx context.Context, value TrafficBucket) error {
return s.writeTrafficBucket(ctx, value, false)
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 12
const schemaVersion = 14
var ErrNotFound = errors.New("store: not found")
@@ -121,7 +121,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
// already contain an additive column. Remaining statements in the
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type"))
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
@@ -0,0 +1,334 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiMessage } from "../../api";
import type { CardPolicy } from "../../types";
import { Button, Input, Modal, Select, Spinner, Switch, Tag, confirmDialog, message } from "../ui";
import { useI18n } from "../../lib/i18n";
import {
createCardAPN,
deleteCardAPN,
getCardAPNs,
getDeviceAPNs,
updateCardAPN,
updateCardPolicy,
type CardAPNProfile,
type ModemAPNProfile,
} from "./deviceActions";
interface CardPolicyAPNProps {
deviceId: string;
iccid: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onSaved: (policy: CardPolicy) => void;
}
interface APNRow {
key: string;
apn: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
source: "automatic" | "modem" | "custom";
cid?: number;
customID?: number;
username?: string;
hasPassword?: boolean;
proxy?: string;
mcc?: string;
mnc?: string;
roamingIPVersion?: "IP" | "IPV6" | "IPV4V6";
authType?: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
}
const APN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
export function CardPolicyAPN({ deviceId, iccid, policy, deviceOnline, onSaved }: CardPolicyAPNProps) {
const { t } = useI18n();
const [modemProfiles, setModemProfiles] = useState<ModemAPNProfile[]>([]);
const [customProfiles, setCustomProfiles] = useState<CardAPNProfile[]>([]);
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<CardAPNProfile | null>(null);
const [newAPN, setNewAPN] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newProxy, setNewProxy] = useState("");
const [newMCC, setNewMCC] = useState("");
const [newMNC, setNewMNC] = useState("");
const [newIPVersion, setNewIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IPV4V6");
const [newRoamingIPVersion, setNewRoamingIPVersion] = useState<"IP" | "IPV6" | "IPV4V6">("IP");
const [newAuthType, setNewAuthType] = useState<"NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP">("NONE");
const [clearPassword, setClearPassword] = useState(false);
const [adding, setAdding] = useState(false);
const [pendingKey, setPendingKey] = useState("");
const load = useCallback(async () => {
setLoading(true);
const [customResult, modemResult] = await Promise.allSettled([
getCardAPNs(iccid),
deviceOnline ? getDeviceAPNs(deviceId) : Promise.resolve({ items: [] as ModemAPNProfile[] }),
]);
setCustomProfiles(customResult.status === "fulfilled" ? customResult.value.items || [] : []);
setModemProfiles(modemResult.status === "fulfilled" ? modemResult.value.items || [] : []);
setLoading(false);
}, [deviceId, deviceOnline, iccid]);
useEffect(() => {
load();
}, [load]);
const rows = useMemo<APNRow[]>(() => {
const result: APNRow[] = [
{ key: "automatic", apn: "", ipVersion: "IPV4V6", source: "automatic" },
];
for (const item of modemProfiles) {
result.push({
key: `modem:${item.cid}:${item.apn}:${item.ipVersion}`,
apn: item.apn,
ipVersion: item.ipVersion,
source: "modem",
cid: item.cid,
});
}
for (const item of customProfiles) {
result.push({
key: `custom:${item.id}`,
apn: item.apn,
ipVersion: item.ipVersion,
source: "custom",
customID: item.id,
username: item.username,
hasPassword: item.hasPassword,
proxy: item.proxy,
mcc: item.mcc,
mnc: item.mnc,
roamingIPVersion: item.roamingIpVersion,
authType: item.authType,
});
}
return result;
}, [customProfiles, modemProfiles]);
function isActive(row: APNRow) {
const activeAPN = policy?.apn || "";
const activeIP = policy?.ipVersion || "IPV4V6";
if (row.source === "automatic") return activeAPN === "";
if (row.apn !== activeAPN || row.ipVersion !== activeIP) return false;
const activeCustom = customProfiles.some((item) => item.apn === activeAPN && item.ipVersion === activeIP);
return row.source === "custom" || !activeCustom;
}
async function enable(row: APNRow) {
setPendingKey(row.key);
try {
const saved = await updateCardPolicy(iccid, { apn: row.apn, ipVersion: row.ipVersion });
onSaved(saved);
message.success(row.source === "automatic" ? t("已使用运营商自动 APN 配置") : t("APN 已启用"));
} catch (error) {
message.error(apiMessage(error) || t("启用 APN 失败"));
} finally {
setPendingKey("");
}
}
function openEditor(profile?: CardAPNProfile) {
setEditingProfile(profile || null);
setNewAPN(profile?.apn || "");
setNewUsername(profile?.username || "");
setNewPassword("");
setNewProxy(profile?.proxy || "");
setNewMCC(profile?.mcc || "");
setNewMNC(profile?.mnc || "");
setNewIPVersion(profile?.ipVersion || "IPV4V6");
setNewRoamingIPVersion(profile?.roamingIpVersion || "IP");
setNewAuthType(profile?.authType || "NONE");
setClearPassword(false);
setEditorOpen(true);
}
async function saveEditor() {
const cleanAPN = newAPN.trim();
if (!cleanAPN || !APN_PATTERN.test(cleanAPN)) {
message.warning(t("APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符"));
return;
}
if (newMCC && !/^\d{3}$/.test(newMCC)) {
message.warning(t("MCC 必须是 3 位数字"));
return;
}
if (newMNC && !/^\d{2,3}$/.test(newMNC)) {
message.warning(t("MNC 必须是 2 或 3 位数字"));
return;
}
setAdding(true);
try {
const payload = {
apn: cleanAPN,
username: newUsername.trim(),
proxy: newProxy.trim(),
mcc: newMCC.trim(),
mnc: newMNC.trim(),
ipVersion: newIPVersion,
roamingIpVersion: newRoamingIPVersion,
authType: newAuthType,
};
if (editingProfile) {
await updateCardAPN(iccid, editingProfile.id, {
...payload,
...(newPassword ? { password: newPassword } : {}),
clearPassword,
});
} else {
await createCardAPN(iccid, { ...payload, password: newPassword });
}
setEditorOpen(false);
await load();
message.success(editingProfile ? t("自定义 APN 已修改") : t("自定义 APN 已添加,请点击启用后使用"));
} catch (error) {
message.error(apiMessage(error) || (editingProfile ? t("修改 APN 失败") : t("添加 APN 失败")));
} finally {
setAdding(false);
}
}
async function remove(row: APNRow) {
if (!row.customID) return;
const confirmed = await confirmDialog(
t("确定删除这个自定义 APN 配置吗?"),
t("删除 APN"),
{ type: "danger", confirmVariant: "danger", confirmText: t("删除") },
);
if (!confirmed) return;
setPendingKey(row.key);
try {
const wasActive = isActive(row);
await deleteCardAPN(iccid, row.customID);
if (wasActive && policy) {
onSaved({ ...policy, apn: "", ipVersion: "IPV4V6" });
}
await load();
message.success(wasActive ? t("APN 已删除,并恢复运营商自动配置") : t("自定义 APN 已删除"));
} catch (error) {
message.error(apiMessage(error) || t("删除 APN 失败"));
} finally {
setPendingKey("");
}
}
function edit(row: APNRow) {
const profile = customProfiles.find((item) => item.id === row.customID);
if (profile) openEditor(profile);
}
const sourceLabel = (row: APNRow) => {
if (row.source === "automatic") return t("默认");
if (row.source === "modem") return t("模组已有");
return t("自定义");
};
const protocolLabel = (value?: "IP" | "IPV6" | "IPV4V6") => value === "IP" ? "IPv4" : value === "IPV6" ? "IPv6" : value === "IPV4V6" ? "IPv4 / IPv6" : "—";
return (
<div className="rounded-lg border border-gray-200/70 bg-white/60 p-3 dark:border-white/10 dark:bg-black/10">
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100">{t("蜂窝 APN")}</div>
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{t("APN 列表和启用状态跟随当前 ICCID/Profile 保存")}
</div>
</div>
<Button size="small" variant="primary" plain onClick={() => openEditor()}>
{t("新增 APN")}
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-gray-400">
<Spinner className="h-4 w-4 animate-spin" /> {t("正在读取 APN 列表...")}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200/70 dark:border-white/10">
<table className="w-full min-w-[1050px] text-left text-xs">
<thead className="bg-gray-50 text-gray-500 dark:bg-white/5 dark:text-gray-400">
<tr>
<th className="px-3 py-2 font-semibold">APN</th>
<th className="px-3 py-2 font-semibold">{t("账号 / 认证")}</th>
<th className="px-3 py-2 font-semibold">MCC / MNC</th>
<th className="px-3 py-2 font-semibold">{t("协议 / 漫游")}</th>
<th className="px-3 py-2 font-semibold">Proxy</th>
<th className="px-3 py-2 font-semibold">{t("来源")}</th>
<th className="px-3 py-2 font-semibold">{t("状态")}</th>
<th className="px-3 py-2 text-right font-semibold">{t("操作")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{rows.map((row) => {
const active = isActive(row);
return (
<tr key={row.key} className={active ? "bg-sky-50/60 dark:bg-sky-500/5" : "bg-white/40 dark:bg-transparent"}>
<td className="px-3 py-2.5 font-mono text-gray-800 dark:text-gray-100">
{row.source === "automatic" ? t("运营商自动配置") : row.apn}
{row.cid ? <span className="ml-2 text-[10px] text-gray-400">CID {row.cid}</span> : null}
</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">
<div>{row.username || "—"}{row.hasPassword ? <span className="ml-1 text-[10px] text-gray-400">{t("已设密码")}</span> : null}</div>
{row.authType && row.authType !== "NONE" ? <div className="mt-0.5 text-[10px] text-gray-400">{row.authType.replace("PAP_OR_CHAP", "PAP / CHAP")}</div> : null}
</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.mcc || "—"} / {row.mnc || "—"}</td>
<td className="px-3 py-2.5 text-gray-600 dark:text-gray-300"><div>{protocolLabel(row.ipVersion)}</div><div className="mt-0.5 text-[10px] text-gray-400">{t("漫游")}{protocolLabel(row.roamingIPVersion)}</div></td>
<td className="max-w-[150px] truncate px-3 py-2.5 text-gray-600 dark:text-gray-300">{row.proxy || "—"}</td>
<td className="px-3 py-2.5"><Tag type={row.source === "custom" ? "primary" : "info"}>{sourceLabel(row)}</Tag></td>
<td className="px-3 py-2.5">{active ? <Tag type="success">{t("使用中")}</Tag> : <span className="text-gray-400"></span>}</td>
<td className="whitespace-nowrap px-3 py-2.5 text-right">
<Button size="small" variant={active ? "default" : "primary"} plain={!active} disabled={active} loading={pendingKey === row.key} onClick={() => enable(row)}>
{active ? t("已启用") : t("启用")}
</Button>
{row.source === "custom" ? (
<>
<Button className="ml-1" size="small" onClick={() => edit(row)}>{t("修改")}</Button>
<Button className="ml-1" size="small" variant="danger" plain loading={pendingKey === row.key} onClick={() => remove(row)}>{t("删除")}</Button>
</>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{!deviceOnline ? <div className="mt-2 text-[11px] text-amber-600 dark:text-amber-400">{t("设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取")}</div> : null}
<Modal
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingProfile ? t("修改 APN 配置") : t("新增 APN 配置")}
width="max-w-4xl"
closeOnOverlay={!adding}
footer={
<>
<Button disabled={adding} onClick={() => setEditorOpen(false)}>{t("取消")}</Button>
<Button variant="primary" loading={adding} onClick={saveEditor}>{editingProfile ? t("保存修改") : t("添加到列表")}</Button>
</>
}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">APN *</span><Input value={newAPN} maxLength={100} onChange={(event) => setNewAPN(event.target.value)} placeholder="giffgaff.com" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("用户名")}</span><Input value={newUsername} maxLength={128} onChange={(event) => setNewUsername(event.target.value)} placeholder="gg" /></label>
<label className="space-y-1">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("密码")}</span>
<Input type="password" value={newPassword} disabled={clearPassword} maxLength={128} onChange={(event) => setNewPassword(event.target.value)} placeholder={editingProfile?.hasPassword ? t("留空表示保持原密码") : "p"} />
</label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">Proxy</span><Input value={newProxy} maxLength={255} onChange={(event) => setNewProxy(event.target.value)} placeholder={t("留空")} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MCC</span><Input inputMode="numeric" value={newMCC} maxLength={3} onChange={(event) => setNewMCC(event.target.value.replace(/\D/g, ""))} placeholder="234" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">MNC</span><Input inputMode="numeric" value={newMNC} maxLength={3} onChange={(event) => setNewMNC(event.target.value.replace(/\D/g, ""))} placeholder="10" /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 协议")}</span><Select value={newIPVersion} onChange={(value) => setNewIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IP", label: "IPv4" }, { value: "IPV6", label: "IPv6" }]} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("APN 漫游协议")}</span><Select value={newRoamingIPVersion} onChange={(value) => setNewRoamingIPVersion(value as "IP" | "IPV6" | "IPV4V6")} options={[{ value: "IP", label: "IPv4" }, { value: "IPV4V6", label: "IPv4 / IPv6" }, { value: "IPV6", label: "IPv6" }]} /></label>
<label className="space-y-1"><span className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t("认证类型")}</span><Select value={newAuthType} onChange={(value) => setNewAuthType(value as "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP")} options={[{ value: "NONE", label: t("无") }, { value: "PAP", label: "PAP" }, { value: "CHAP", label: "CHAP" }, { value: "PAP_OR_CHAP", label: "PAP / CHAP" }]} /></label>
</div>
{editingProfile?.hasPassword ? (
<div className="mt-4 flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div><div className="text-xs font-semibold text-gray-700 dark:text-gray-200">{t("清除已保存密码")}</div><div className="text-[11px] text-gray-400">{t("关闭时,密码输入框留空会保持原密码")}</div></div>
<Switch checked={clearPassword} onChange={(value) => { setClearPassword(value); if (value) setNewPassword(""); }} />
</div>
) : null}
</Modal>
</div>
);
}
+11 -2
View File
@@ -1,6 +1,7 @@
import { CardUiRegular } from "@fluentui/react-icons";
import { Tag } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types";
@@ -27,7 +28,8 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onChanged: onPolicyChanged,
});
const sourceLabel = policy ? (policy.source === "user" ? t("手动设置") : t("自动默认")) : "";
const isManual = policy?.source === "user" || policy?.source === "manual";
const sourceLabel = policy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const { local } = toggles;
return (
@@ -56,7 +58,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
</div>
{sourceLabel ? <Tag type={policy?.source === "user" ? "primary" : "info"}>{sourceLabel}</Tag> : null}
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
@@ -80,6 +82,13 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onToggle={toggles.onAirplaneToggle}
/>
</div>
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
</div>
) : null}
</div>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Button, Spinner } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { getCardPolicy, putCardPolicy, enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import type { CardPolicy } from "../../types";
@@ -87,6 +88,16 @@ export function EsimCardPolicyInline({ deviceId, iccid, isActiveCard, deviceOnli
onToggle={toggles.onAirplaneToggle}
/>
</div>
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
deviceOnline={deviceOnline}
onSaved={(saved) => {
setPolicy(saved);
onPolicyChanged();
}}
/>
</>
)}
</div>
@@ -1,4 +1,5 @@
import { cx } from "../../lib/utils";
import { CountryFlag } from "../CountryFlag";
import { EsimProfileRow } from "./EsimProfileRow";
import type { EsimChipInfo, EsimEid, EsimProfileGroup } from "./types";
import { useI18n } from "../../lib/i18n";
@@ -35,11 +36,12 @@ function normAid(aid?: string): string {
return (aid || "").trim().toUpperCase();
}
function manufacturerFlag(manufacturer?: string): string {
function manufacturerCountryCode(manufacturer?: string): string {
const value = (manufacturer || "").toLowerCase();
if (value.includes("eastcompeace") || value.includes("watchdata")) return "🇨🇳";
if (value.includes("giesecke") || value.includes("g+d")) return "🇩🇪";
if (value.includes("thales") || value.includes("idemia")) return "🇫🇷";
if (value.includes("eastcompeace") || value.includes("watchdata") || value.includes("hutopt")) return "CN";
if (value.includes("giesecke") || value.includes("g+d")) return "DE";
if (value.includes("thales") || value.includes("idemia")) return "FR";
if (value.includes("gemalto")) return "CH";
return "";
}
@@ -56,7 +58,9 @@ function PkiInfo({ eid }: { eid: EsimEid }) {
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400 dark:text-gray-500">
{eid.manufacturer ? (
<span className="inline-flex items-center gap-1">
<span className="text-[10px]">{t("生产商:")}</span> {eid.manufacturer} {manufacturerFlag(eid.manufacturer)}
<span className="text-[10px]">{t("生产商:")}</span>
<span>{eid.manufacturer}</span>
<CountryFlag countryCode={manufacturerCountryCode(eid.manufacturer)} />
</span>
) : null}
{eid.certificates && eid.certificates.length ? (
@@ -50,7 +50,7 @@ export function EsimNotificationsModal({ open, loading, items, retryingSeq, onCl
<div className="max-h-[420px] space-y-2 overflow-auto pr-1">
{items.map((item) => (
<div
key={item.sequenceNumber}
key={`${item.aidHex || "default"}:${item.sequenceNumber}`}
className="flex flex-col gap-3 rounded-xl border border-gray-200 p-3 dark:border-white/10 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1 space-y-1">
+58 -1
View File
@@ -22,6 +22,63 @@ export function setFlightMode(deviceId: string, enabled: boolean) {
export function getCardPolicy(iccid: string) {
return api<CardPolicy>(`/cards/${iccid}/policy`);
}
export interface CardPolicyUpdate {
vowifiEnabled?: boolean;
airplaneEnabled?: boolean;
apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6";
}
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
}
export function putCardPolicy(iccid: string, body: { vowifiEnabled: boolean; airplaneEnabled: boolean }) {
return ok(api(`/cards/${iccid}/policy`, { method: "PUT", body }));
return ok(updateCardPolicy(iccid, body));
}
export interface ModemAPNProfile {
cid: number;
apn: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
}
export function getDeviceAPNs(deviceId: string) {
return api<{ items: ModemAPNProfile[] }>(`/devices/${deviceId}/network/apns`);
}
export interface CardAPNProfile {
id: number;
iccid: string;
apn: string;
username: string;
hasPassword: boolean;
proxy: string;
mcc: string;
mnc: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
createdAt?: string;
updatedAt?: string;
}
export function getCardAPNs(iccid: string) {
return api<{ items: CardAPNProfile[] }>(`/cards/${iccid}/apns`);
}
export interface CardAPNCreate {
apn: string;
username: string;
password: string;
proxy: string;
mcc: string;
mnc: string;
ipVersion: "IP" | "IPV6" | "IPV4V6";
roamingIpVersion: "IP" | "IPV6" | "IPV4V6";
authType: "NONE" | "PAP" | "CHAP" | "PAP_OR_CHAP";
}
export function createCardAPN(iccid: string, body: CardAPNCreate) {
return api<CardAPNProfile>(`/cards/${iccid}/apns`, { method: "POST", body });
}
export function updateCardAPN(iccid: string, id: number, body: Omit<CardAPNCreate, "password"> & { password?: string; clearPassword?: boolean }) {
return api<CardAPNProfile>(`/cards/${iccid}/apns/${id}`, { method: "PATCH", body });
}
export function deleteCardAPN(iccid: string, id: number) {
return api<{ deleted: boolean; id: number }>(`/cards/${iccid}/apns/${id}`, { method: "DELETE" });
}
+3 -7
View File
@@ -69,13 +69,9 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
api("/settings/preferences", { method: "PUT", body: { language: next } }).catch(() => {});
}, []);
const t = useCallback(
(text: string) => {
if (!text || lang === "zh") return text;
return EN_DICT[text] ?? text;
},
[lang],
);
// 委托给模块级 tl(读取实时 activeLang),使 t 的函数身份稳定:
// 否则 useCallback 闭包会捕获到旧语言的 t,切换语言后出现标题/正文语言不一致。
const t = useCallback((text: string) => tl(text), []);
const value = useMemo(() => ({ lang, setLanguage, t }), [lang, setLanguage, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;