| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- package server
- import (
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
- "vocat/internal/device"
- )
- func esimUnavailable(w http.ResponseWriter) {
- writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.")
- }
- // handleESIM routes every /devices/{id}/esim* path.
- func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool {
- if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- s.writeEsimOverview(w, r, physicalID, physicalPresent)
- return true
- }
- switch rest[0] {
- case "profiles":
- if len(rest) == 1 {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- s.writeEsimGroups(w, r, physicalID, physicalPresent)
- return true
- }
- if len(rest) == 2 && r.Method == http.MethodDelete {
- s.handleEsimDelete(w, r, physicalID, physicalPresent, rest[1])
- return true
- }
- if len(rest) == 2 && r.Method == http.MethodPatch {
- s.handleEsimRename(w, r, physicalID, physicalPresent, rest[1])
- return true
- }
- esimUnavailable(w)
- return true
- case "notifications":
- if len(rest) == 1 {
- 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{}}})
- return true
- }
- // notifications/{id}/actions/retry
- esimUnavailable(w)
- return true
- case "actions":
- if len(rest) == 2 && rest[1] == "switch" {
- if !requireMethod(w, r, http.MethodPost) {
- return true
- }
- s.handleEsimSwitch(w, r, physicalID, physicalPresent)
- return true
- }
- if len(rest) == 2 && rest[1] == "disable" {
- if !requireMethod(w, r, http.MethodPost) {
- return true
- }
- s.handleEsimDisable(w, r, physicalID, physicalPresent)
- return true
- }
- if len(rest) == 2 && rest[1] == "download" {
- if !requireMethod(w, r, http.MethodGet) {
- return true
- }
- s.handleEsimDownload(w, r, physicalID, physicalPresent)
- return true
- }
- // Any other provisioning action is not implemented.
- esimUnavailable(w)
- return true
- default:
- return false
- }
- }
- // 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).
- func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) (string, []device.EsimInventoryEntry) {
- if s.devices == nil || !physicalPresent {
- return "empty", nil
- }
- info, err := s.devices.ESIMInventory(r.Context(), physicalID)
- if err != nil {
- if errors.Is(err, device.ErrNoEUICC) {
- return "empty", nil
- }
- s.writeDeviceError(w, err)
- return "error", nil
- }
- return "ok", info
- }
- // writeEsimOverview returns { chipInfo, profiles } for the eSIM tab.
- func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
- status, info := s.esimInfo(w, r, physicalID, physicalPresent)
- switch status {
- case "error":
- return
- case "empty":
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"chipInfo": nil, "profiles": []any{}}})
- return
- }
- chipInfo := esimInventoryChipInfo(info)
- groups := esimInventoryGroups(info)
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "chipInfo": chipInfo,
- "profiles": groups,
- },
- })
- }
- func esimInventoryChipInfo(entries []device.EsimInventoryEntry) map[string]any {
- eids := make([]any, 0, len(entries))
- firmware := ""
- for _, entry := range entries {
- chip := entry.Chip
- eid := map[string]any{"eid": chip.EID, "aid": chip.AID}
- if chip.HasFreeNvram {
- eid["freeNvramBytes"] = chip.FreeNvramBytes
- eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024)
- }
- if chip.Manufacturer != "" {
- eid["manufacturer"] = chip.Manufacturer
- }
- if len(chip.Certificates) > 0 {
- eid["certificates"] = chip.Certificates
- }
- if len(chip.TrustedCIs) > 0 {
- eid["trustedCiKeyIds"] = chip.TrustedCIs
- }
- if chip.DefaultSmdpAddress != "" {
- eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress
- }
- if chip.RootDsAddress != "" {
- eid["rootDsAddress"] = chip.RootDsAddress
- }
- if chip.SAS != "" {
- eid["sasAccreditationNumber"] = chip.SAS
- }
- eids = append(eids, eid)
- if firmware == "" {
- firmware = chip.FirmwareVer
- }
- }
- result := map[string]any{"eids": eids}
- if firmware != "" {
- result["firmware"] = firmware
- }
- return result
- }
- func esimInventoryGroups(entries []device.EsimInventoryEntry) []map[string]any {
- groups := make([]map[string]any, 0, len(entries))
- for _, entry := range entries {
- groups = append(groups, esimGroups(entry.Info)...)
- }
- return groups
- }
- // esimChipInfo reads the eUICC chip header (EID, firmware, free NVRAM,
- // manufacturer, CI certificates, SM-DP+/Root SM-DS addresses, SAS, info source)
- // for the eSIM tab. On any read failure it returns a sparse object so the
- // profile list still renders.
- func (s *Server) esimChipInfo(r *http.Request, physicalID string) map[string]any {
- chip, err := s.devices.ESIMChipInfo(r.Context(), physicalID)
- if err != nil || chip == nil {
- return map[string]any{}
- }
- eid := map[string]any{
- "eid": chip.EID,
- "aid": chip.AID,
- }
- if chip.HasFreeNvram {
- eid["freeNvramBytes"] = chip.FreeNvramBytes
- eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024)
- }
- if chip.Manufacturer != "" {
- eid["manufacturer"] = chip.Manufacturer
- }
- if len(chip.Certificates) > 0 {
- eid["certificates"] = chip.Certificates
- }
- if len(chip.TrustedCIs) > 0 {
- eid["trustedCiKeyIds"] = chip.TrustedCIs
- }
- if chip.DefaultSmdpAddress != "" {
- eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress
- }
- if chip.RootDsAddress != "" {
- eid["rootDsAddress"] = chip.RootDsAddress
- }
- if chip.SAS != "" {
- eid["sasAccreditationNumber"] = chip.SAS
- }
- chipMap := map[string]any{
- "eids": []any{eid},
- }
- if chip.FirmwareVer != "" {
- chipMap["firmware"] = chip.FirmwareVer
- }
- return chipMap
- }
- // writeEsimGroups returns just the profile groups for the /esim/profiles call.
- func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
- status, info := s.esimInfo(w, r, physicalID, physicalPresent)
- switch status {
- case "error":
- return
- case "empty":
- writeJSON(w, http.StatusOK, map[string]any{"data": []any{}})
- return
- }
- groups := esimInventoryGroups(info)
- writeJSON(w, http.StatusOK, map[string]any{"data": groups})
- }
- // GetProfilesInfo does not include an EID on every eUICC implementation. The
- // EC20 hosts one physical eUICC, so associate the separately-read chip identity
- // with that sole profile group. Without this, the SPA cannot match the group to
- // its manufacturer/certificate/production metadata even though it was read.
- func attachSingleEUICCIdentity(groups []map[string]any, chipInfo map[string]any) {
- if len(groups) != 1 {
- return
- }
- eids, ok := chipInfo["eids"].([]any)
- if !ok || len(eids) != 1 {
- return
- }
- identity, ok := eids[0].(map[string]any)
- if !ok {
- return
- }
- groupEID, _ := groups[0]["eid"].(string)
- chipEID, _ := identity["eid"].(string)
- if strings.TrimSpace(groupEID) == "" && strings.TrimSpace(chipEID) != "" {
- groups[0]["eid"] = strings.TrimSpace(chipEID)
- }
- groupAID, _ := groups[0]["aidHex"].(string)
- chipAID, _ := identity["aid"].(string)
- if strings.TrimSpace(groupAID) == "" && strings.TrimSpace(chipAID) != "" {
- groups[0]["aidHex"] = strings.TrimSpace(chipAID)
- }
- }
- // esimGroups flattens the eUICC profile list into the SPA's per-eUICC groups
- // (the EC20 hosts a single eUICC, so this is normally one group).
- func esimGroups(info device.EsimInfo) []map[string]any {
- profiles := make([]map[string]any, 0, len(info.Profiles))
- for _, p := range info.Profiles {
- profiles = append(profiles, map[string]any{
- "iccid": p.ICCID,
- "name": firstNonEmpty(p.Nickname, p.Name),
- "serviceProviderName": p.ServiceProvider,
- "state": p.State,
- "stateText": p.StateText,
- "classText": p.Class,
- })
- }
- return []map[string]any{
- {
- "eid": info.EID,
- "aidHex": info.AID,
- "profiles": profiles,
- },
- }
- }
- func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) {
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- if !physicalPresent {
- writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
- return
- }
- iccid = strings.TrimSpace(iccid)
- if iccid == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
- return
- }
- var request struct {
- Name string `json:"name"`
- AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- nickname := strings.TrimSpace(request.Name)
- if nickname == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required")
- return
- }
- if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil {
- s.writeDeviceError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "renamed", "iccid": iccid, "name": nickname}})
- }
- // handleEsimSwitch enables one already-installed profile by ICCID (切卡). The
- // eUICC EnableProfile command needs no authentication key.
- func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- if !physicalPresent {
- writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
- return
- }
- var request struct {
- ICCID string `json:"iccid"`
- AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- iccid := strings.TrimSpace(request.ICCID)
- if iccid == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
- return
- }
- // A confirmed profile switch includes the EC20 reset and a live ICCID read,
- // which normally takes longer than the server's ordinary response deadline.
- controller := http.NewResponseController(w)
- _ = controller.SetWriteDeadline(time.Time{})
- if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
- s.writeDeviceError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
- }
- func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- if !physicalPresent {
- writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
- return
- }
- var request struct {
- ICCID string `json:"iccid"`
- AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- iccid := strings.TrimSpace(request.ICCID)
- if iccid == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
- return
- }
- if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
- s.writeDeviceError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "disabled", "iccid": iccid, "recovering": true}})
- }
- func (s *Server) handleEsimDelete(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) {
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- if !physicalPresent {
- writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
- return
- }
- iccid = strings.TrimSpace(iccid)
- if iccid == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
- return
- }
- result, err := s.devices.ESIMDeleteProfile(r.Context(), physicalID, iccid, r.URL.Query().Get("aid_hex"))
- if err != nil {
- s.writeDeviceError(w, err)
- return
- }
- data := map[string]any{
- "status": "deleted",
- "iccid": iccid,
- "spaceDelta": map[string]any{"direction": "reclaimed", "bytes": result.SpaceDelta},
- }
- if result.Warning != "" {
- data["warning"] = result.Warning
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": data})
- }
- // handleEsimDownload streams one eSIM profile download (写卡) as Server-Sent
- // Events. The SPA drives it with GET + query params (smdp/matching_id/
- // confirmation_code/aid_hex/imei) and reads `data: {step,msg,pct,...}` lines.
- // The event field names (step/msg/pct/code/space_delta/warning) match the
- // reference contract byte-for-byte, so the frontend needs no changes.
- func (s *Server) handleEsimDownload(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- if !physicalPresent {
- writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
- return
- }
- query := r.URL.Query()
- params := device.EsimDownloadParams{
- SMDP: query.Get("smdp"),
- MatchingID: query.Get("matching_id"),
- ConfirmationCode: query.Get("confirmation_code"),
- AIDHex: query.Get("aid_hex"),
- IMEI: query.Get("imei"),
- }
- if strings.TrimSpace(params.SMDP) == "" {
- writeError(w, http.StatusBadRequest, "invalid_request", "smdp 为必填项")
- return
- }
- controller := beginSSE(w)
- emit := func(payload map[string]any) {
- // A failed write means the client went away; r.Context() is then already
- // cancelled, so the device layer stops the download on its own.
- _ = writeSSEEvent(w, controller, "progress", payload)
- }
- result, err := s.devices.ESIMDownloadProfile(r.Context(), physicalID, params, func(p device.EsimProgress) {
- emit(map[string]any{"step": p.Step, "msg": p.Msg, "pct": p.Pct})
- })
- if err != nil {
- emit(map[string]any{
- "step": "error",
- "msg": "下载失败: " + err.Error(),
- "pct": -1,
- "code": device.ESIMDownloadErrorCode(err),
- })
- return
- }
- done := map[string]any{
- "step": "done",
- "msg": "Profile 下载完成",
- "pct": 100,
- "space_delta": map[string]any{"direction": "consumed", "bytes": result.SpaceDelta},
- }
- if result.Warning != "" {
- done["warning"] = result.Warning
- }
- emit(done)
- }
|