diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 6866770..4b59ae3 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -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 diff --git a/internal/device/data.go b/internal/device/data.go index 4d8c961..9bd16bc 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -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) diff --git a/internal/device/data_linux.go b/internal/device/data_linux.go index 2aaf335..34930ad 100644 --- a/internal/device/data_linux.go +++ b/internal/device/data_linux.go @@ -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. diff --git a/internal/device/data_other.go b/internal/device/data_other.go index 9373b90..243e5ca 100644 --- a/internal/device/data_other.go +++ b/internal/device/data_other.go @@ -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) } diff --git a/internal/device/data_test.go b/internal/device/data_test.go index 47a0a24..cd33868 100644 --- a/internal/device/data_test.go +++ b/internal/device/data_test.go @@ -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) diff --git a/internal/device/es9p.go b/internal/device/es9p.go index b9acaf9..ec0d878 100644 --- a/internal/device/es9p.go +++ b/internal/device/es9p.go @@ -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 diff --git a/internal/device/es9p_test.go b/internal/device/es9p_test.go index a41616c..9999cfa 100644 --- a/internal/device/es9p_test.go +++ b/internal/device/es9p_test.go @@ -47,6 +47,7 @@ func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) { "169.254.169.254", "rsp.example.com/unexpected/path", "user:password@rsp.example.com", + "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) + } +} diff --git a/internal/device/esim_delete.go b/internal/device/esim_delete.go index 8da72a2..f31fdb1 100644 --- a/internal/device/esim_delete.go +++ b/internal/device/esim_delete.go @@ -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 } diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go index 7210741..22b759c 100644 --- a/internal/device/esim_download.go +++ b/internal/device/esim_download.go @@ -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 diff --git a/internal/device/esim_notifications.go b/internal/device/esim_notifications.go new file mode 100644 index 0000000..2c95440 --- /dev/null +++ b/internal/device/esim_notifications.go @@ -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, ¬ification.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) +} diff --git a/internal/device/esim_notifications_test.go b/internal/device/esim_notifications_test.go new file mode 100644 index 0000000..779496d --- /dev/null +++ b/internal/device/esim_notifications_test.go @@ -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") + } +} diff --git a/internal/device/types.go b/internal/device/types.go index fe148ca..2148272 100644 --- a/internal/device/types.go +++ b/internal/device/types.go @@ -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 { diff --git a/internal/extensions/manager.go b/internal/extensions/manager.go index be1e2e7..958dd2a 100644 --- a/internal/extensions/manager.go +++ b/internal/extensions/manager.go @@ -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) diff --git a/internal/extensions/manager_test.go b/internal/extensions/manager_test.go index b6f1a82..dc357b0 100644 --- a/internal/extensions/manager_test.go +++ b/internal/extensions/manager_test.go @@ -18,6 +18,8 @@ func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) { defer manager.Close() for _, raw := range []string{ "http://example.com/plugin.zip", + "https://user@example.com/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/", } { diff --git a/internal/server/automatic_task_notifications.go b/internal/server/automatic_task_notifications.go index 5fc371b..9556ed1 100644 --- a/internal/server/automatic_task_notifications.go +++ b/internal/server/automatic_task_notifications.go @@ -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 } diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go index d721f68..e38de9a 100644 --- a/internal/server/automatic_tasks.go +++ b/internal/server/automatic_tasks.go @@ -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 { diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 1b392ff..2b91206 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -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, diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index f907c76..2925ef1 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -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(), diff --git a/internal/server/email_message.go b/internal/server/email_message.go new file mode 100644 index 0000000..3ededf7 --- /dev/null +++ b/internal/server/email_message.go @@ -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") +} diff --git a/internal/server/email_message_test.go b/internal/server/email_message_test.go new file mode 100644 index 0000000..4fb1958 --- /dev/null +++ b/internal/server/email_message_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "bytes" + "encoding/base64" + "net/mail" + "strings" + "testing" +) + +func TestWritePlainTextMailEncodesUntrustedContent(t *testing.T) { + from, err := parseMailAddress("VoCat Alerts ") + if err != nil { + t.Fatal(err) + } + recipient, err := parseMailAddress("Admin ") + if err != nil { + t.Fatal(err) + } + body := "message\r\nBcc: injected@example.com\r\n" + 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: injected@example.com") { + 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: "alerts@example.com"} + recipients := []*mail.Address{{Address: "admin@example.com"}} + if err := writePlainTextMail(&bytes.Buffer{}, from, recipients, "hello\r\nBcc: x@example.com", "body"); err == nil { + t.Fatal("injected subject was accepted") + } +} diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go index 3c0a811..b4b2156 100644 --- a/internal/server/esim_api.go +++ b/internal/server/esim_api.go @@ -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) { diff --git a/internal/server/server.go b/internal/server/server.go index 0600582..d1a55f4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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() diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index b68597d..074afce 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -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 diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index cb9e51a..0903e1e 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -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) diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go index 2c2c9b2..dac6ef1 100644 --- a/internal/server/sms_notifications.go +++ b/internal/server/sms_notifications.go @@ -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) } diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index b54e6b2..11393be 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -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 diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 9a6a35d..12fcf7f 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -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 } diff --git a/internal/store/models.go b/internal/store/models.go index 0e1c3f7..4e9a772 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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 diff --git a/internal/store/settings.go b/internal/store/settings.go index 3e9bb26..c569121 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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) } diff --git a/internal/store/store.go b/internal/store/store.go index 95f2295..320689a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/web/src/components/devices/CardPolicyAPN.tsx b/web/src/components/devices/CardPolicyAPN.tsx new file mode 100644 index 0000000..6560515 --- /dev/null +++ b/web/src/components/devices/CardPolicyAPN.tsx @@ -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([]); + const [customProfiles, setCustomProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [editorOpen, setEditorOpen] = useState(false); + const [editingProfile, setEditingProfile] = useState(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(() => { + 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 ( +
+
+
+
{t("蜂窝 APN")}
+
+ {t("APN 列表和启用状态跟随当前 ICCID/Profile 保存")} +
+
+ +
+ + {loading ? ( +
+ {t("正在读取 APN 列表...")} +
+ ) : ( +
+ + + + + + + + + + + + + + + {rows.map((row) => { + const active = isActive(row); + return ( + + + + + + + + + + + ); + })} + +
APN{t("账号 / 认证")}MCC / MNC{t("协议 / 漫游")}Proxy{t("来源")}{t("状态")}{t("操作")}
+ {row.source === "automatic" ? t("运营商自动配置") : row.apn} + {row.cid ? CID {row.cid} : null} + +
{row.username || "—"}{row.hasPassword ? {t("已设密码")} : null}
+ {row.authType && row.authType !== "NONE" ?
{row.authType.replace("PAP_OR_CHAP", "PAP / CHAP")}
: null} +
{row.mcc || "—"} / {row.mnc || "—"}
{protocolLabel(row.ipVersion)}
{t("漫游")}:{protocolLabel(row.roamingIPVersion)}
{row.proxy || "—"}{sourceLabel(row)}{active ? {t("使用中")} : } + + {row.source === "custom" ? ( + <> + + + + ) : null} +
+
+ )} + {!deviceOnline ?
{t("设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取")}
: null} + setEditorOpen(false)} + title={editingProfile ? t("修改 APN 配置") : t("新增 APN 配置")} + width="max-w-4xl" + closeOnOverlay={!adding} + footer={ + <> + + + + } + > +
+ + + + + + + +