esim_api.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. package server
  2. import (
  3. "errors"
  4. "fmt"
  5. "net/http"
  6. "strings"
  7. "time"
  8. "vocat/internal/device"
  9. )
  10. func esimUnavailable(w http.ResponseWriter) {
  11. writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.")
  12. }
  13. // handleESIM routes every /devices/{id}/esim* path.
  14. func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool {
  15. if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") {
  16. if !requireMethod(w, r, http.MethodGet) {
  17. return true
  18. }
  19. s.writeEsimOverview(w, r, physicalID, physicalPresent)
  20. return true
  21. }
  22. switch rest[0] {
  23. case "profiles":
  24. if len(rest) == 1 {
  25. if !requireMethod(w, r, http.MethodGet) {
  26. return true
  27. }
  28. s.writeEsimGroups(w, r, physicalID, physicalPresent)
  29. return true
  30. }
  31. if len(rest) == 2 && r.Method == http.MethodDelete {
  32. s.handleEsimDelete(w, r, physicalID, physicalPresent, rest[1])
  33. return true
  34. }
  35. if len(rest) == 2 && r.Method == http.MethodPatch {
  36. s.handleEsimRename(w, r, physicalID, physicalPresent, rest[1])
  37. return true
  38. }
  39. esimUnavailable(w)
  40. return true
  41. case "notifications":
  42. if len(rest) == 1 {
  43. if !requireMethod(w, r, http.MethodGet) {
  44. return true
  45. }
  46. // No LPA download backend, so there are never pending notifications.
  47. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}})
  48. return true
  49. }
  50. // notifications/{id}/actions/retry
  51. esimUnavailable(w)
  52. return true
  53. case "actions":
  54. if len(rest) == 2 && rest[1] == "switch" {
  55. if !requireMethod(w, r, http.MethodPost) {
  56. return true
  57. }
  58. s.handleEsimSwitch(w, r, physicalID, physicalPresent)
  59. return true
  60. }
  61. if len(rest) == 2 && rest[1] == "disable" {
  62. if !requireMethod(w, r, http.MethodPost) {
  63. return true
  64. }
  65. s.handleEsimDisable(w, r, physicalID, physicalPresent)
  66. return true
  67. }
  68. if len(rest) == 2 && rest[1] == "download" {
  69. if !requireMethod(w, r, http.MethodGet) {
  70. return true
  71. }
  72. s.handleEsimDownload(w, r, physicalID, physicalPresent)
  73. return true
  74. }
  75. // Any other provisioning action is not implemented.
  76. esimUnavailable(w)
  77. return true
  78. default:
  79. return false
  80. }
  81. }
  82. // esimInfo loads the eUICC profile list. The string result is "ok" (use info),
  83. // "empty" (no usable eUICC — render the empty state), or "error" (an error
  84. // response has already been written).
  85. func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) (string, []device.EsimInventoryEntry) {
  86. if s.devices == nil || !physicalPresent {
  87. return "empty", nil
  88. }
  89. info, err := s.devices.ESIMInventory(r.Context(), physicalID)
  90. if err != nil {
  91. if errors.Is(err, device.ErrNoEUICC) {
  92. return "empty", nil
  93. }
  94. s.writeDeviceError(w, err)
  95. return "error", nil
  96. }
  97. return "ok", info
  98. }
  99. // writeEsimOverview returns { chipInfo, profiles } for the eSIM tab.
  100. func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
  101. status, info := s.esimInfo(w, r, physicalID, physicalPresent)
  102. switch status {
  103. case "error":
  104. return
  105. case "empty":
  106. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"chipInfo": nil, "profiles": []any{}}})
  107. return
  108. }
  109. chipInfo := esimInventoryChipInfo(info)
  110. groups := esimInventoryGroups(info)
  111. writeJSON(w, http.StatusOK, map[string]any{
  112. "data": map[string]any{
  113. "chipInfo": chipInfo,
  114. "profiles": groups,
  115. },
  116. })
  117. }
  118. func esimInventoryChipInfo(entries []device.EsimInventoryEntry) map[string]any {
  119. eids := make([]any, 0, len(entries))
  120. firmware := ""
  121. for _, entry := range entries {
  122. chip := entry.Chip
  123. eid := map[string]any{"eid": chip.EID, "aid": chip.AID}
  124. if chip.HasFreeNvram {
  125. eid["freeNvramBytes"] = chip.FreeNvramBytes
  126. eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024)
  127. }
  128. if chip.Manufacturer != "" {
  129. eid["manufacturer"] = chip.Manufacturer
  130. }
  131. if len(chip.Certificates) > 0 {
  132. eid["certificates"] = chip.Certificates
  133. }
  134. if len(chip.TrustedCIs) > 0 {
  135. eid["trustedCiKeyIds"] = chip.TrustedCIs
  136. }
  137. if chip.DefaultSmdpAddress != "" {
  138. eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress
  139. }
  140. if chip.RootDsAddress != "" {
  141. eid["rootDsAddress"] = chip.RootDsAddress
  142. }
  143. if chip.SAS != "" {
  144. eid["sasAccreditationNumber"] = chip.SAS
  145. }
  146. eids = append(eids, eid)
  147. if firmware == "" {
  148. firmware = chip.FirmwareVer
  149. }
  150. }
  151. result := map[string]any{"eids": eids}
  152. if firmware != "" {
  153. result["firmware"] = firmware
  154. }
  155. return result
  156. }
  157. func esimInventoryGroups(entries []device.EsimInventoryEntry) []map[string]any {
  158. groups := make([]map[string]any, 0, len(entries))
  159. for _, entry := range entries {
  160. groups = append(groups, esimGroups(entry.Info)...)
  161. }
  162. return groups
  163. }
  164. // esimChipInfo reads the eUICC chip header (EID, firmware, free NVRAM,
  165. // manufacturer, CI certificates, SM-DP+/Root SM-DS addresses, SAS, info source)
  166. // for the eSIM tab. On any read failure it returns a sparse object so the
  167. // profile list still renders.
  168. func (s *Server) esimChipInfo(r *http.Request, physicalID string) map[string]any {
  169. chip, err := s.devices.ESIMChipInfo(r.Context(), physicalID)
  170. if err != nil || chip == nil {
  171. return map[string]any{}
  172. }
  173. eid := map[string]any{
  174. "eid": chip.EID,
  175. "aid": chip.AID,
  176. }
  177. if chip.HasFreeNvram {
  178. eid["freeNvramBytes"] = chip.FreeNvramBytes
  179. eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024)
  180. }
  181. if chip.Manufacturer != "" {
  182. eid["manufacturer"] = chip.Manufacturer
  183. }
  184. if len(chip.Certificates) > 0 {
  185. eid["certificates"] = chip.Certificates
  186. }
  187. if len(chip.TrustedCIs) > 0 {
  188. eid["trustedCiKeyIds"] = chip.TrustedCIs
  189. }
  190. if chip.DefaultSmdpAddress != "" {
  191. eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress
  192. }
  193. if chip.RootDsAddress != "" {
  194. eid["rootDsAddress"] = chip.RootDsAddress
  195. }
  196. if chip.SAS != "" {
  197. eid["sasAccreditationNumber"] = chip.SAS
  198. }
  199. chipMap := map[string]any{
  200. "eids": []any{eid},
  201. }
  202. if chip.FirmwareVer != "" {
  203. chipMap["firmware"] = chip.FirmwareVer
  204. }
  205. return chipMap
  206. }
  207. // writeEsimGroups returns just the profile groups for the /esim/profiles call.
  208. func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
  209. status, info := s.esimInfo(w, r, physicalID, physicalPresent)
  210. switch status {
  211. case "error":
  212. return
  213. case "empty":
  214. writeJSON(w, http.StatusOK, map[string]any{"data": []any{}})
  215. return
  216. }
  217. groups := esimInventoryGroups(info)
  218. writeJSON(w, http.StatusOK, map[string]any{"data": groups})
  219. }
  220. // GetProfilesInfo does not include an EID on every eUICC implementation. The
  221. // EC20 hosts one physical eUICC, so associate the separately-read chip identity
  222. // with that sole profile group. Without this, the SPA cannot match the group to
  223. // its manufacturer/certificate/production metadata even though it was read.
  224. func attachSingleEUICCIdentity(groups []map[string]any, chipInfo map[string]any) {
  225. if len(groups) != 1 {
  226. return
  227. }
  228. eids, ok := chipInfo["eids"].([]any)
  229. if !ok || len(eids) != 1 {
  230. return
  231. }
  232. identity, ok := eids[0].(map[string]any)
  233. if !ok {
  234. return
  235. }
  236. groupEID, _ := groups[0]["eid"].(string)
  237. chipEID, _ := identity["eid"].(string)
  238. if strings.TrimSpace(groupEID) == "" && strings.TrimSpace(chipEID) != "" {
  239. groups[0]["eid"] = strings.TrimSpace(chipEID)
  240. }
  241. groupAID, _ := groups[0]["aidHex"].(string)
  242. chipAID, _ := identity["aid"].(string)
  243. if strings.TrimSpace(groupAID) == "" && strings.TrimSpace(chipAID) != "" {
  244. groups[0]["aidHex"] = strings.TrimSpace(chipAID)
  245. }
  246. }
  247. // esimGroups flattens the eUICC profile list into the SPA's per-eUICC groups
  248. // (the EC20 hosts a single eUICC, so this is normally one group).
  249. func esimGroups(info device.EsimInfo) []map[string]any {
  250. profiles := make([]map[string]any, 0, len(info.Profiles))
  251. for _, p := range info.Profiles {
  252. profiles = append(profiles, map[string]any{
  253. "iccid": p.ICCID,
  254. "name": firstNonEmpty(p.Nickname, p.Name),
  255. "serviceProviderName": p.ServiceProvider,
  256. "state": p.State,
  257. "stateText": p.StateText,
  258. "classText": p.Class,
  259. })
  260. }
  261. return []map[string]any{
  262. {
  263. "eid": info.EID,
  264. "aidHex": info.AID,
  265. "profiles": profiles,
  266. },
  267. }
  268. }
  269. func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) {
  270. if s.devices == nil {
  271. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  272. return
  273. }
  274. if !physicalPresent {
  275. writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
  276. return
  277. }
  278. iccid = strings.TrimSpace(iccid)
  279. if iccid == "" {
  280. writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
  281. return
  282. }
  283. var request struct {
  284. Name string `json:"name"`
  285. AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile
  286. }
  287. if err := s.decodeJSON(w, r, &request); err != nil {
  288. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  289. return
  290. }
  291. nickname := strings.TrimSpace(request.Name)
  292. if nickname == "" {
  293. writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required")
  294. return
  295. }
  296. if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil {
  297. s.writeDeviceError(w, err)
  298. return
  299. }
  300. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "renamed", "iccid": iccid, "name": nickname}})
  301. }
  302. // handleEsimSwitch enables one already-installed profile by ICCID (切卡). The
  303. // eUICC EnableProfile command needs no authentication key.
  304. func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
  305. if s.devices == nil {
  306. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  307. return
  308. }
  309. if !physicalPresent {
  310. writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
  311. return
  312. }
  313. var request struct {
  314. ICCID string `json:"iccid"`
  315. AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid
  316. }
  317. if err := s.decodeJSON(w, r, &request); err != nil {
  318. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  319. return
  320. }
  321. iccid := strings.TrimSpace(request.ICCID)
  322. if iccid == "" {
  323. writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
  324. return
  325. }
  326. // A confirmed profile switch includes the EC20 reset and a live ICCID read,
  327. // which normally takes longer than the server's ordinary response deadline.
  328. controller := http.NewResponseController(w)
  329. _ = controller.SetWriteDeadline(time.Time{})
  330. if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
  331. s.writeDeviceError(w, err)
  332. return
  333. }
  334. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}})
  335. }
  336. func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
  337. if s.devices == nil {
  338. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  339. return
  340. }
  341. if !physicalPresent {
  342. writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
  343. return
  344. }
  345. var request struct {
  346. ICCID string `json:"iccid"`
  347. AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID
  348. }
  349. if err := s.decodeJSON(w, r, &request); err != nil {
  350. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  351. return
  352. }
  353. iccid := strings.TrimSpace(request.ICCID)
  354. if iccid == "" {
  355. writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
  356. return
  357. }
  358. if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil {
  359. s.writeDeviceError(w, err)
  360. return
  361. }
  362. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "disabled", "iccid": iccid, "recovering": true}})
  363. }
  364. func (s *Server) handleEsimDelete(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) {
  365. if s.devices == nil {
  366. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  367. return
  368. }
  369. if !physicalPresent {
  370. writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
  371. return
  372. }
  373. iccid = strings.TrimSpace(iccid)
  374. if iccid == "" {
  375. writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required")
  376. return
  377. }
  378. result, err := s.devices.ESIMDeleteProfile(r.Context(), physicalID, iccid, r.URL.Query().Get("aid_hex"))
  379. if err != nil {
  380. s.writeDeviceError(w, err)
  381. return
  382. }
  383. data := map[string]any{
  384. "status": "deleted",
  385. "iccid": iccid,
  386. "spaceDelta": map[string]any{"direction": "reclaimed", "bytes": result.SpaceDelta},
  387. }
  388. if result.Warning != "" {
  389. data["warning"] = result.Warning
  390. }
  391. writeJSON(w, http.StatusOK, map[string]any{"data": data})
  392. }
  393. // handleEsimDownload streams one eSIM profile download (写卡) as Server-Sent
  394. // Events. The SPA drives it with GET + query params (smdp/matching_id/
  395. // confirmation_code/aid_hex/imei) and reads `data: {step,msg,pct,...}` lines.
  396. // The event field names (step/msg/pct/code/space_delta/warning) match the
  397. // reference contract byte-for-byte, so the frontend needs no changes.
  398. func (s *Server) handleEsimDownload(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) {
  399. if s.devices == nil {
  400. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  401. return
  402. }
  403. if !physicalPresent {
  404. writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host")
  405. return
  406. }
  407. query := r.URL.Query()
  408. params := device.EsimDownloadParams{
  409. SMDP: query.Get("smdp"),
  410. MatchingID: query.Get("matching_id"),
  411. ConfirmationCode: query.Get("confirmation_code"),
  412. AIDHex: query.Get("aid_hex"),
  413. IMEI: query.Get("imei"),
  414. }
  415. if strings.TrimSpace(params.SMDP) == "" {
  416. writeError(w, http.StatusBadRequest, "invalid_request", "smdp 为必填项")
  417. return
  418. }
  419. controller := beginSSE(w)
  420. emit := func(payload map[string]any) {
  421. // A failed write means the client went away; r.Context() is then already
  422. // cancelled, so the device layer stops the download on its own.
  423. _ = writeSSEEvent(w, controller, "progress", payload)
  424. }
  425. result, err := s.devices.ESIMDownloadProfile(r.Context(), physicalID, params, func(p device.EsimProgress) {
  426. emit(map[string]any{"step": p.Step, "msg": p.Msg, "pct": p.Pct})
  427. })
  428. if err != nil {
  429. emit(map[string]any{
  430. "step": "error",
  431. "msg": "下载失败: " + err.Error(),
  432. "pct": -1,
  433. "code": device.ESIMDownloadErrorCode(err),
  434. })
  435. return
  436. }
  437. done := map[string]any{
  438. "step": "done",
  439. "msg": "Profile 下载完成",
  440. "pct": 100,
  441. "space_delta": map[string]any{"direction": "consumed", "bytes": result.SpaceDelta},
  442. }
  443. if result.Warning != "" {
  444. done["warning"] = result.Warning
  445. }
  446. emit(done)
  447. }