docs(api): inventory all SimAdmin operations

This commit is contained in:
chick
2026-07-16 05:33:50 +08:00
parent c789ec62e1
commit 9dffadbec2
100 changed files with 33233 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
# SimAdmin upstream API static-contract evidence matrix — `58e2204`
- **Audited commit:** `58e220411d6599609f0eeda01eb7016e9212f970`.
- **Coverage:** 100 paths / 117 operations (GET 50, POST 62, DELETE 5); IA domain/owner mapping remains frozen.
- **Independent snapshot:** repository fixture `packages/operation-registry/test/fixtures/upstream-58e2204/` contains whole upstream source and all 82 Bruno `.bru` files. `manifest.json` records commit and SHA-256 for every whole file; tests re-hash files and exact handler slices without `/tmp` or registry-derived content.
- **Exact handlers:** each `handlerRange` starts at the async function signature and ends at its brace-matched closing brace. Helper evidence, when needed, belongs in `supplementalRanges`, never in the handler digest.
- **Response evidence:** explicit Rust returns, `ApiResponse<T>` payload construction, or dynamic JSON construction are implementation evidence, not real response fixtures or an exhaustive schema. `opaque-into-response` count: **0**.
- **Bruno evidence:** 67/117 operations reference parsed Bruno evidence from 82 files. This does **not** claim that 82 files cover all 117 operations; operations without Bruno are marked route+handler(+model).
- **Sensitive fields:** every item carries snapshot-rebuildable evidence (file/range/SHA-256/symbol/model field), with explicit container model chains for array paths and exact handler slices for dynamic keys.
- **Boundary / semanticBindingReview:** structural checks prove source/model/field relationships, not semantic payload intent. Each registry operation is marked `semanticBindingReview: 'reviewed-phase-0.2'`; manual review fixes the method/path/handler/payload-symbol tuple, especially high-value response bindings.
- **Policy:** every R2 requires at least explicit confirmation; every R3 requires strong confirmation. Auth/session-sensitive operations use dedicated flow/replay protections.
- **Phase 0.3 仅负责采集真实响应样本** (`responseFixtureStatus=pending-phase-0.3`); static evidence is not a runtime fixture.
## Whole-file snapshot SHA-256
See `upstream-58e2204/manifest.json`; it is machine-verified on every test run and is the authoritative complete whole-file SHA list.
## All operations
| Method | Path | operationId | Domain | uiOwner | Handler | Evidence level | Content-Type | Idempotency | Risk / policy | Request contract | Response contract | Evidence digest |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| `GET` | `/api/device` | `getDevice` | device-system | overview | `get_device_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1076-1090 | ApiResponse<DeviceInfoResponse> success payload/error envelope; success/error payload evidenced by ApiResponse<DeviceInfoResponse>; exact handler backend/src/handlers.rs:1076-1090 | `7b4d3ba9a85015e5` |
| `GET` | `/api/sim` | `getSim` | sim | overview | `get_sim_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1126-1145 | success/error payload evidenced by ApiResponse<SimInfoResponse>; exact handler backend/src/handlers.rs:1126-1145 | `db5280e4e6d37c43` |
| `POST` | `/api/sim/details/refresh` | `postSimDetailsRefresh` | sim | overview | `refresh_sim_details_handler` | route+handler | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1148-1164 | dynamic-json construction in exact handler backend/src/handlers.rs:1148-1164; field shape is implementation evidence only | `ab04a42ae36c3495` |
| `POST` | `/api/sim/cache` | `postSimCache` | sim | overview | `update_sim_cache_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor UpdateSimCacheRequest fields [phone_number:Option<String>, sms_center:Option<String>] (models.rs:1036); exact handler backend/src/handlers.rs:1167-1213 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1167-1213 | `b2b5f366ff8b133d` |
| `GET` | `/api/network` | `getNetwork` | cellular | cellular | `get_network_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1218-1232 | ApiResponse<NetworkInfoResponse> success payload/error envelope; success/error payload evidenced by ApiResponse<NetworkInfoResponse>; exact handler backend/src/handlers.rs:1218-1232 | `5d02208a69b79024` |
| `GET` | `/api/cells` | `getCells` | cellular | cellular | `get_cells` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1235-1249 | success/error payload evidenced by ApiResponse<CellsResponse>; exact handler backend/src/handlers.rs:1235-1249 | `2a0eb9db873a461a` |
| `POST` | `/api/cell-monitor/start` | `postCellMonitorStart` | cellular | cellular | `start_cell_monitor_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1252-1282 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1252-1282 | `c70d5916a800de22` |
| `POST` | `/api/cell-monitor/stop` | `postCellMonitorStop` | cellular | cellular | `stop_cell_monitor_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1285-1312 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1285-1312 | `81303fb59efb78b8` |
| `GET` | `/api/radio-mode` | `getRadioMode` | radio-lock | cellular | `get_radio_mode_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1315-1329 | success/error payload evidenced by ApiResponse<RadioModeResponse>; exact handler backend/src/handlers.rs:1315-1329 | `caba610280c2850f` |
| `POST` | `/api/radio-mode` | `postRadioMode` | radio-lock | cellular | `set_radio_mode_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor RadioModeRequest fields [mode:RadioMode] (models.rs:351); exact handler backend/src/handlers.rs:1332-1352 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1332-1352 | `c41c6134fe1f28b0` |
| `GET` | `/api/band-lock` | `getBandLock` | radio-lock | cellular | `get_band_lock_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1355-1369 | success/error payload evidenced by ApiResponse<BandLockStatus>; exact handler backend/src/handlers.rs:1355-1369 | `6130c68b619423b3` |
| `POST` | `/api/band-lock` | `postBandLock` | radio-lock | cellular | `set_band_lock_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor BandLockRequest fields [lte_fdd_bands:Vec<u32>, lte_tdd_bands:Vec<u32>, nr_fdd_bands:Vec<u32>, nr_tdd_bands:Vec<u32>] (models.rs:377); exact handler backend/src/handlers.rs:1372-1392 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1372-1392 | `04504f8b9b2d528e` |
| `GET` | `/api/network/interfaces` | `getNetworkInterfaces` | device-network | device-network | `get_network_interfaces_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1619-1644 | success/error payload evidenced by ApiResponse<NetworkInterfacesResponse>; exact handler backend/src/handlers.rs:1619-1644 | `a1ac88c365ed70bf` |
| `GET` | `/api/network/connection-addresses` | `getNetworkConnectionAddresses` | device-network | device-network | `get_network_connection_addresses` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1647-1666 | success/error payload evidenced by ApiResponse<ConnectionAddressesResponse>; exact handler backend/src/handlers.rs:1647-1666 | `a5f28ff95121c097` |
| `GET` | `/api/device-network/ddns/config` | `getDeviceNetworkDdnsConfig` | device-network | device-network | `get_device_ddns_config_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1669-1679 | dynamic-json construction in exact handler backend/src/handlers.rs:1669-1679; field shape is implementation evidence only | `4ec8e855d61695c9` |
| `POST` | `/api/device-network/ddns/config` | `postDeviceNetworkDdnsConfig` | device-network | device-network | `set_device_ddns_config_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::DdnsConfig fields [enabled:bool, provider:String, access_id:String, access_secret:String, interval_seconds:u64, ttl:u32, ipv4:DdnsIpConfig, ipv6:DdnsIpConfig] (config.rs:634); exact handler backend/src/handlers.rs:1682-1721 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1682-1721 | `10137722721a3ef9` |
| `GET` | `/api/device-network/ddns/status` | `getDeviceNetworkDdnsStatus` | device-network | device-network | `get_device_ddns_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1750-1757 | dynamic-json construction in exact handler backend/src/handlers.rs:1750-1757; field shape is implementation evidence only | `02494bf7928a9a23` |
| `POST` | `/api/device-network/ddns/sync` | `postDeviceNetworkDdnsSync` | device-network | device-network | `sync_device_ddns_handler` | route+handler+bruno | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1760-1781 | success/error payload evidenced by ApiResponse<DdnsSyncResponse>; exact handler backend/src/handlers.rs:1760-1781 | `43cf9573e2b43aaf` |
| `GET` | `/api/device-network/ddns/logs` | `getDeviceNetworkDdnsLogs` | device-network | device-network | `get_device_ddns_logs_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1784-1790 | dynamic-json construction in exact handler backend/src/handlers.rs:1784-1790; field shape is implementation evidence only | `ac80f3ada641ca13` |
| `POST` | `/api/device-network/ddns/logs/clear` | `postDeviceNetworkDdnsLogsClear` | device-network | device-network | `clear_device_ddns_logs_handler` | route+handler+bruno | none | conditional | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1793-1802 | dynamic-json construction in exact handler backend/src/handlers.rs:1793-1802; field shape is implementation evidence only | `8ebb7d7cfe772a4e` |
| `GET` | `/api/device-network/wlan/status` | `getDeviceNetworkWlanStatus` | device-network | device-network | `get_device_wlan_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1805-1819 | success/error payload evidenced by ApiResponse<WlanStatusResponse>; exact handler backend/src/handlers.rs:1805-1819 | `66741c07471de1f7` |
| `POST` | `/api/device-network/wlan/enabled` | `postDeviceNetworkWlanEnabled` | device-network | device-network | `set_device_wlan_enabled_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor WlanEnabledRequest fields [enabled:bool] (models.rs:606); exact handler backend/src/handlers.rs:1822-1841 | success/error payload evidenced by ApiResponse<WlanStatusResponse>; exact handler backend/src/handlers.rs:1822-1841 | `b6cc1238ca609208` |
| `POST` | `/api/device-network/wlan/scan` | `postDeviceNetworkWlanScan` | device-network | device-network | `scan_device_wlan_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1844-1858 | success/error payload evidenced by ApiResponse<WlanScanResponse>; exact handler backend/src/handlers.rs:1844-1858 | `de90691d7c57a4e9` |
| `GET` | `/api/device-network/wlan/profiles` | `getDeviceNetworkWlanProfiles` | device-network | device-network | `get_device_wlan_profiles_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1861-1875 | success/error payload evidenced by ApiResponse<WlanProfilesResponse>; exact handler backend/src/handlers.rs:1861-1875 | `caadf1fc1c436c63` |
| `POST` | `/api/device-network/wlan/forget` | `postDeviceNetworkWlanForget` | device-network | device-network | `forget_device_wlan_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor WlanForgetRequest fields [uuid:String, connection_id:String] (models.rs:635); exact handler backend/src/handlers.rs:1878-1897 | success/error payload evidenced by ApiResponse<WlanProfilesResponse>; exact handler backend/src/handlers.rs:1878-1897 | `f5142f819f6da442` |
| `POST` | `/api/device-network/wlan/connect` | `postDeviceNetworkWlanConnect` | device-network | device-network | `connect_device_wlan_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor WlanConnectRequest fields [ssid:String, password:String, auto_join:bool] (models.rs:611); exact handler backend/src/handlers.rs:1900-1955 | success/error payload evidenced by ApiResponse<WlanStatusResponse>; exact handler backend/src/handlers.rs:1900-1955 | `9c422666a3db49cd` |
| `POST` | `/api/device-network/wlan/disconnect` | `postDeviceNetworkWlanDisconnect` | device-network | device-network | `disconnect_device_wlan_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1958-1991 | success/error payload evidenced by ApiResponse<WlanStatusResponse>; exact handler backend/src/handlers.rs:1958-1991 | `719a709dc8509a35` |
| `POST` | `/api/device-network/wlan/profile` | `postDeviceNetworkWlanProfile` | device-network | device-network | `save_device_wlan_profile_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor WlanProfileRequest fields [connection_id:String, auto_join:Option<bool>, ipv4_mode:Option<String>, ipv4_address:Option<String>, ipv4_prefix:Option<u8>, ipv4_gateway:Option<String>] (models.rs:620); exact handler backend/src/handlers.rs:1994-2013 | success/error payload evidenced by ApiResponse<WlanStatusResponse>; exact handler backend/src/handlers.rs:1994-2013 | `b3180f55af718c04` |
| `GET` | `/api/network/signal-strength` | `getNetworkSignalStrength` | cellular | cellular | `get_signal_strength_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2016-2030 | success/error payload evidenced by ApiResponse<SignalStrengthResponse>; exact handler backend/src/handlers.rs:2016-2030 | `2c04c31da8b4160b` |
| `GET` | `/api/location/cell-info` | `getLocationCellInfo` | cellular | cellular | `get_cell_location_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1395-1409 | success/error payload evidenced by ApiResponse<CellLocationResponse>; exact handler backend/src/handlers.rs:1395-1409 | `fc748f05e03bf684` |
| `GET` | `/api/network/operators` | `getNetworkOperators` | cellular | cellular | `get_network_operators` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1412-1426 | success/error payload evidenced by ApiResponse<OperatorListResponse>; exact handler backend/src/handlers.rs:1412-1426 | `d3b30db8a501b92a` |
| `GET` | `/api/network/operators/scan` | `getNetworkOperatorsScan` | cellular | cellular | `scan_network_operators` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1429-1443 | success/error payload evidenced by ApiResponse<OperatorListResponse>; exact handler backend/src/handlers.rs:1429-1443 | `4a8bfbd38a943df8` |
| `POST` | `/api/network/register-manual` | `postNetworkRegisterManual` | cellular | cellular | `register_network_manual` | route+handler+model+bruno | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor ManualRegisterRequest fields [mccmnc:String] (models.rs:771); exact handler backend/src/handlers.rs:1446-1466 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1446-1466 | `8b78cc782499ff83` |
| `POST` | `/api/network/register-auto` | `postNetworkRegisterAuto` | cellular | cellular | `register_network_auto` | route+handler+bruno | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1469-1486 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1469-1486 | `431aabcc9852603b` |
| `GET` | `/api/apn` | `getApn` | data-connection | cellular | `get_apn_list_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1489-1504 | success/error payload evidenced by ApiResponse<ApnListResponse>; exact handler backend/src/handlers.rs:1489-1504 | `798953ca7e3857d0` |
| `POST` | `/api/apn` | `postApn` | data-connection | cellular | `set_apn_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetApnRequest fields [context_path:String, apn:Option<String>, protocol:Option<String>, username:Option<String>, password:Option<String>, auth_method:Option<String>] (models.rs:795); exact handler backend/src/handlers.rs:1507-1571 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:1507-1571 | `6edced468c5f3bcb` |
| `GET` | `/api/cell-lock` | `getCellLock` | radio-lock | cellular | `get_cell_lock_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1574-1582 | dynamic-json construction in exact handler backend/src/handlers.rs:1574-1582; field shape is implementation evidence only | `fe4b4aaedc6e5ccf` |
| `POST` | `/api/cell-lock` | `postCellLock` | radio-lock | cellular | `set_cell_lock_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor CellLockRequest fields [rat:u8, enable:bool, lock_type:u8, pci:Option<u16>, arfcn:Option<u32>] (models.rs:405); exact handler backend/src/handlers.rs:1585-1603 | success/error payload evidenced by ApiResponse<CellLockResult>; exact handler backend/src/handlers.rs:1585-1603 | `29ff2739b7d0b584` |
| `POST` | `/api/cell-lock/unlock-all` | `postCellLockUnlockAll` | radio-lock | cellular | `unlock_all_cells_handler` | route+handler+bruno | none | conditional | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:1606-1616 | dynamic-json construction in exact handler backend/src/handlers.rs:1606-1616; field shape is implementation evidence only | `390162e5ce89c095` |
| `GET` | `/api/data` | `getData` | data-connection | cellular | `get_data_status` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2035-2062 | success/error payload evidenced by ApiResponse<DataConnectionResponse>; exact handler backend/src/handlers.rs:2035-2062 | `43f00a519bd67c3a` |
| `POST` | `/api/data` | `postData` | data-connection | cellular | `set_data_status` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor DataConnectionRequest fields [active:bool] (models.rs:234); exact handler backend/src/handlers.rs:2065-2131 | success/error payload evidenced by ApiResponse<DataConnectionResponse>; exact handler backend/src/handlers.rs:2065-2131 | `f1653cfe4442172c` |
| `GET` | `/api/roaming` | `getRoaming` | data-connection | cellular | `get_roaming_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2172-2193 | success/error payload evidenced by ApiResponse<RoamingResponse>; exact handler backend/src/handlers.rs:2172-2193 | `687ff0cdb729645a` |
| `POST` | `/api/roaming` | `postRoaming` | data-connection | cellular | `set_roaming_status_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor RoamingRequest fields [allowed:bool] (models.rs:244); exact handler backend/src/handlers.rs:2196-2247 | success/error payload evidenced by ApiResponse<RoamingResponse>; exact handler backend/src/handlers.rs:2196-2247 | `b18b5c87eb24ccc9` |
| `GET` | `/api/airplane-mode` | `getAirplaneMode` | data-connection | cellular | `get_airplane_mode_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2315-2329 | success/error payload evidenced by ApiResponse<AirplaneModeResponse>; exact handler backend/src/handlers.rs:2315-2329 | `243c2a63fd99d45c` |
| `POST` | `/api/airplane-mode` | `postAirplaneMode` | data-connection | cellular | `set_airplane_mode_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor AirplaneModeRequest fields [enabled:bool] (models.rs:255); exact handler backend/src/handlers.rs:2250-2312 | success/error payload evidenced by ApiResponse<AirplaneModeResponse>; exact handler backend/src/handlers.rs:2250-2312 | `5efb8a55c495ea0b` |
| `POST` | `/api/baseband/restart` | `postBasebandRestart` | data-connection | cellular | `restart_baseband_handler` | route+handler | none | non-idempotent | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2133-2159 | success/error payload evidenced by ApiResponse<BasebandRestartResponse>; exact handler backend/src/handlers.rs:2133-2159 | `ef18782a4b21d5b4` |
| `GET` | `/api/baseband/restart/status` | `getBasebandRestartStatus` | data-connection | cellular | `get_baseband_restart_status_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2161-2169 | dynamic-json construction in exact handler backend/src/handlers.rs:2161-2169; field shape is implementation evidence only | `736cc766f31d5d71` |
| `GET` | `/api/work-mode` | `getWorkMode` | workmode-esim | esim | `get_work_mode_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:352-365 | dynamic-json construction in exact handler backend/src/handlers.rs:352-365; field shape is implementation evidence only | `f2d3dca3f43326f8` |
| `POST` | `/api/work-mode` | `postWorkMode` | workmode-esim | esim | `set_work_mode_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor WorkModeRequest fields [mode:WorkMode, confirm:bool] (models.rs:57); exact handler backend/src/handlers.rs:368-405 | success/error payload evidenced by ApiResponse<WorkModeResponse>; exact handler backend/src/handlers.rs:368-405 | `cf17a54e0df74a9d` |
| `GET` | `/api/esim/config` | `getEsimConfig` | workmode-esim | esim | `get_esim_config_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:456-462 | dynamic-json construction in exact handler backend/src/handlers.rs:456-462; field shape is implementation evidence only | `1be6b526e1a78451` |
| `POST` | `/api/esim/config` | `postEsimConfig` | workmode-esim | esim | `set_esim_config_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::EsimConfig fields [lpac_path:String, custom_memory_total_kb:Option<u32>] (config.rs:1660); exact handler backend/src/handlers.rs:465-482 | dynamic-json construction in exact handler backend/src/handlers.rs:465-482; field shape is implementation evidence only | `b497f06687977d9f` |
| `GET` | `/api/esim/lpac/status` | `getEsimLpacStatus` | workmode-esim | esim | `get_esim_lpac_status_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:408-416 | dynamic-json construction in exact handler backend/src/handlers.rs:408-416; field shape is implementation evidence only | `a4d5143d1f83089b` |
| `POST` | `/api/esim/lpac/repair` | `postEsimLpacRepair` | workmode-esim | esim | `repair_esim_lpac_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor EsimLpacRepairRequest fields [proxy_prefix:Option<String>, asset_url:Option<String>] (models.rs:153); exact handler backend/src/handlers.rs:419-453 | dynamic-json construction in exact handler backend/src/handlers.rs:419-453; field shape is implementation evidence only | `4d88af6126732e94` |
| `GET` | `/api/esim/euicc` | `getEsimEuicc` | workmode-esim | esim | `get_esim_euicc_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | query extractor std::collections::HashMap<String, String>; exact exact handler backend/src/handlers.rs:485-521 | dynamic-json construction in exact handler backend/src/handlers.rs:485-521; field shape is implementation evidence only | `7bb950b7fe21268d` |
| `GET` | `/api/esim/profiles` | `getEsimProfiles` | workmode-esim | esim | `get_esim_profiles_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | query extractor std::collections::HashMap<String, String>; exact exact handler backend/src/handlers.rs:524-598 | success/error payload evidenced by ApiResponse<EsimProfilesResponse>; exact handler backend/src/handlers.rs:524-598 | `07f60eddaa9d20ff` |
| `POST` | `/api/esim/profiles` | `postEsimProfiles` | workmode-esim | esim | `download_esim_profile_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor EsimDownloadRequest fields [smdp:String, matching_id:String, confirmation_code:Option<String>, imei:Option<String>] (models.rs:174); exact handler backend/src/handlers.rs:803-1071 | success/error payload evidenced by ApiResponse<EsimCommandResponse>; exact handler backend/src/handlers.rs:803-1071 | `fe41273a15be55b3` |
| `POST` | `/api/esim/profiles/{iccid}/enable` | `postEsimProfilesIccidEnable` | workmode-esim | esim | `enable_esim_profile_handler` | route+handler | none | conditional | R1 / command / confirm:none / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:601-726 | dynamic-json construction in exact handler backend/src/handlers.rs:601-726; field shape is implementation evidence only | `d54705546ca3bf2a` |
| `POST` | `/api/esim/profiles/{iccid}/rename` | `postEsimProfilesIccidRename` | workmode-esim | esim | `rename_esim_profile_handler` | route+handler+model | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor EsimRenameRequest fields [name:String] (models.rs:169); path extractor String; exact handler backend/src/handlers.rs:729-750 | success/error payload evidenced by ApiResponse<EsimCommandResponse>; exact handler backend/src/handlers.rs:729-750 | `0d5cb68158e1e6c2` |
| `DELETE` | `/api/esim/profiles/{iccid}` | `deleteEsimProfilesIccid` | workmode-esim | esim | `delete_esim_profile_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:753-780 | dynamic-json construction in exact handler backend/src/handlers.rs:753-780; field shape is implementation evidence only | `8ad25b4826f75d27` |
| `GET` | `/api/calls` | `getCalls` | calls | calls | `get_calls_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2634-2655 | success/error payload evidenced by ApiResponse<CallListResponse>; exact handler backend/src/handlers.rs:2634-2655 | `21e77021b749876b` |
| `POST` | `/api/call/dial` | `postCallDial` | calls | calls | `dial_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor MakeCallRequest fields [phone_number:String] (models.rs:810); exact handler backend/src/handlers.rs:2657-2689 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2657-2689 | `c440bb90bb4373b3` |
| `POST` | `/api/call/hangup` | `postCallHangup` | calls | calls | `hangup_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2691-2725 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2691-2725 | `cffdc6b2f949af4d` |
| `POST` | `/api/call/hangup-all` | `postCallHangupAll` | calls | calls | `hangup_all_calls_handler` | route+handler+bruno | none | non-idempotent | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2727-2760 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2727-2760 | `3af8fab7bb4c0437` |
| `POST` | `/api/call/answer` | `postCallAnswer` | calls | calls | `answer_call_handler` | route+handler+model+bruno | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor HangupCallRequest fields [path:String] (models.rs:830); exact handler backend/src/handlers.rs:2762-2789 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2762-2789 | `cfcda6600d9ba27c` |
| `GET` | `/api/call/volume` | `getCallVolume` | calls | calls | `get_call_volume_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2899-2906 | success/error payload evidenced by ApiResponse<CallVolumeResponse>; exact handler backend/src/handlers.rs:2899-2906 | `4f96c358e70ba5b3` |
| `POST` | `/api/call/volume` | `postCallVolume` | calls | calls | `set_call_volume_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetCallVolumeRequest fields [speaker_volume:Option<u8>, microphone_volume:Option<u8>, muted:Option<bool>] (models.rs:856); exact handler backend/src/handlers.rs:2908-2922 | success/error payload evidenced by ApiResponse<CallVolumeResponse>; exact handler backend/src/handlers.rs:2908-2922 | `9f2e624358e53aaf` |
| `GET` | `/api/call/forwarding` | `getCallForwarding` | calls | calls | `get_call_forwarding_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2924-2931 | success/error payload evidenced by ApiResponse<CallForwardingResponse>; exact handler backend/src/handlers.rs:2924-2931 | `f7916eb4c5c484e5` |
| `POST` | `/api/call/forwarding` | `postCallForwarding` | calls | calls | `set_call_forwarding_handler` | route+handler+model+bruno | application/json | conditional | R1 / command / confirm:none / registeredOperation | body extractor SetCallForwardingRequest fields [forward_type:String, number:String, timeout:Option<u16>] (models.rs:891); exact handler backend/src/handlers.rs:2933-2943 | success/error payload evidenced by ApiResponse<CallForwardingResponse>; exact handler backend/src/handlers.rs:2933-2943 | `ddc8b848bccd96aa` |
| `GET` | `/api/call/settings` | `getCallSettings` | calls | calls | `get_call_settings_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2852-2866 | success/error payload evidenced by ApiResponse<CallSettingsResponse>; exact handler backend/src/handlers.rs:2852-2866 | `893ccecb6c077f7e` |
| `POST` | `/api/call/settings` | `postCallSettings` | calls | calls | `set_call_settings_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor SetCallSettingRequest fields [property:String, value:String] (models.rs:875); exact handler backend/src/handlers.rs:2868-2897 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2868-2897 | `55ab650b3c53b78a` |
| `GET` | `/api/call/history` | `getCallHistory` | calls | calls | `get_call_history_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor CallHistoryRequest fields [limit:i64, offset:i64] (models.rs:835); exact handler backend/src/handlers.rs:2791-2813 | Rust return (StatusCode, Json<ApiResponse<CallHistoryResponse>>); exact handler backend/src/handlers.rs:2791-2813 | `1def94db4c3d848e` |
| `DELETE` | `/api/call/history/{id}` | `deleteCallHistoryId` | calls | calls | `delete_call_history_handler` | route+handler+bruno | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/handlers.rs:2815-2832 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2815-2832 | `defa60d157769310` |
| `POST` | `/api/call/history/clear` | `postCallHistoryClear` | calls | calls | `clear_call_history_handler` | route+handler+bruno | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2834-2850 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2834-2850 | `b0e0002012256b5d` |
| `GET` | `/api/ims/status` | `getImsStatus` | calls | calls | `get_ims_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2945-2952 | success/error payload evidenced by ApiResponse<ImsStatusResponse>; exact handler backend/src/handlers.rs:2945-2952 | `e533ac5c9e7bc694` |
| `GET` | `/api/voicemail/status` | `getVoicemailStatus` | calls | calls | `get_voicemail_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2954-2961 | success/error payload evidenced by ApiResponse<VoicemailStatusResponse>; exact handler backend/src/handlers.rs:2954-2961 | `837b8fbc971099b9` |
| `POST` | `/api/sms/send` | `postSmsSend` | messages | messages | `send_sms_handler` | route+handler+model+bruno | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor SendSmsRequest fields [phone_number:String, content:String] (models.rs:912); exact handler backend/src/handlers.rs:2373-2404 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:2373-2404 | `6cd7eaa5e1a1f939` |
| `GET` | `/api/sms/list` | `getSmsList` | messages | messages | `get_sms_list_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor SmsListRequest fields [limit:i64, offset:i64, direction:Option<String>] (models.rs:918); exact handler backend/src/handlers.rs:2407-2434 | Rust return (StatusCode, Json<ApiResponse<SmsListResponse>>); exact handler backend/src/handlers.rs:2407-2434 | `abbc14adc02b2f5f` |
| `GET` | `/api/sms/conversation` | `getSmsConversation` | messages | messages | `get_sms_conversation_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor SmsConversationRequest fields [phone_number:String, limit:i64] (models.rs:927); exact handler backend/src/handlers.rs:2437-2458 | Rust return (StatusCode, Json<ApiResponse<SmsListResponse>>); exact handler backend/src/handlers.rs:2437-2458 | `e0747d062889fe8c` |
| `GET` | `/api/sms/stats` | `getSmsStats` | messages | messages | `get_sms_stats_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2461-2477 | Rust return (StatusCode, Json<ApiResponse<SmsStatsResponse>>); exact handler backend/src/handlers.rs:2461-2477 | `bd62f399815cb0d1` |
| `POST` | `/api/sms/batch-delete` | `postSmsBatchDelete` | messages | messages | `delete_sms_batch_handler` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / registeredOperation | body extractor SmsBatchDeleteRequest fields [ids:Vec<i64>, phone_numbers:Vec<String>] (models.rs:934); exact handler backend/src/handlers.rs:2551-2578 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2551-2578 | `94c5c83487401203` |
| `DELETE` | `/api/sms/conversation/{phone_number}` | `deleteSmsConversationPhoneNumber` | messages | messages | `delete_sms_conversation_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:2528-2548 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2528-2548 | `8abd673088da8720` |
| `DELETE` | `/api/sms/message/{id}` | `deleteSmsMessageId` | messages | messages | `delete_sms_message_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/handlers.rs:2508-2525 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2508-2525 | `8edc9f89854b5c8d` |
| `POST` | `/api/sms/clear` | `postSmsClear` | messages | messages | `clear_sms_handler` | route+handler+bruno | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:2480-2505 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:2480-2505 | `8c536214c43b65fc` |
| `GET` | `/api/stats` | `getStats` | device-system | overview | `get_system_stats` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3272-3288 | dynamic-json construction in exact handler backend/src/handlers.rs:3272-3288; field shape is implementation evidence only | `94834f33d10be4b6` |
| `GET` | `/api/stats/cpu` | `getStatsCpu` | device-system | overview | `get_cpu_info` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3291-3302 | success/error payload evidenced by ApiResponse<CpuInfo>; exact handler backend/src/handlers.rs:3291-3302 | `9f24be45c5c8df7a` |
| `GET` | `/api/connectivity` | `getConnectivity` | device-system | overview | `get_connectivity_check` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3305-3322 | Rust return (StatusCode, Json<ApiResponse<ConnectivityCheckResponse>>); exact handler backend/src/handlers.rs:3305-3322 | `42ba5f81f052b992` |
| `POST` | `/api/system/reboot` | `postSystemReboot` | device-system | overview | `system_reboot` | route+handler+model+bruno | application/json | non-idempotent | R3 / job / confirm:strong / registeredOperation | body extractor SystemRebootRequest fields [delay_seconds:u32] (models.rs:422); exact handler backend/src/handlers.rs:3381-3406 | dynamic-json construction in exact handler backend/src/handlers.rs:3381-3406; field shape is implementation evidence only | `362737633fd01332` |
| `POST` | `/api/service/restart` | `postServiceRestart` | device-system | overview | `restart_service_handler` | route+handler | none | non-idempotent | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3578-3601 | dynamic-json construction in exact handler backend/src/handlers.rs:3578-3601; field shape is implementation evidence only | `712a81c6cca94e0c` |
| `GET` | `/api/notifications/config` | `getNotificationsConfig` | notifications | notifications | `get_notification_config_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3641-3652 | Rust return ( StatusCode, Json<ApiResponse<crate::config::NotificationConfig>>, ); exact handler backend/src/handlers.rs:3641-3652 | `96344b8b2d1b8818` |
| `POST` | `/api/notifications/config` | `postNotificationsConfig` | notifications | notifications | `set_notification_config_handler` | route+handler+model+bruno | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::NotificationConfig fields [version:u8, channels:Vec<NotificationChannelInstance>, rules:Vec<NotificationRule>, log_cleanup:NotificationLogCleanupConfig] (config.rs:548); exact handler backend/src/handlers.rs:3655-3672 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:3655-3672 | `5fc4fbfff68050e0` |
| `POST` | `/api/notifications/test/{channel}` | `postNotificationsTestChannel` | notifications | notifications | `test_notification_channel_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:3675-3704 | Rust return ( StatusCode, Json<ApiResponse<crate::models::WebhookTestResponse>>, ); exact handler backend/src/handlers.rs:3675-3704 | `df28427afab1724a` |
| `GET` | `/api/notifications/logs` | `getNotificationsLogs` | notifications | notifications | `get_notification_logs_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor NotificationLogQuery fields [event_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3607); exact handler backend/src/handlers.rs:3709-3734 | Rust return ( StatusCode, Json<ApiResponse<crate::db::NotificationLogsResponse>>, ); exact handler backend/src/handlers.rs:3709-3734 | `da933f5e679cb9e1` |
| `POST` | `/api/notifications/logs/clear` | `postNotificationsLogsClear` | notifications | notifications | `clear_notification_logs_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3737-3760 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:3737-3760 | `04de36a4268a3ab6` |
| `GET` | `/api/notifications/queue` | `getNotificationsQueue` | notifications | notifications | `get_notification_queue_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor NotificationQueueQuery fields [limit:i64] (notification_queue.rs:14); exact handler backend/src/notification_queue.rs:24-41 | Rust return ( StatusCode, Json<ApiResponse<crate::db::NotificationQueueResponse>>, ); exact handler backend/src/notification_queue.rs:24-41 | `babc88061bcb3386` |
| `POST` | `/api/notifications/queue/retry-all` | `postNotificationsQueueRetryAll` | notifications | notifications | `retry_all_notification_queue_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:84-100 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/notification_queue.rs:84-100 | `f58e1be1cbf6a022` |
| `POST` | `/api/notifications/queue/clear` | `postNotificationsQueueClear` | notifications | notifications | `clear_notification_queue_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/notification_queue.rs:103-119 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/notification_queue.rs:103-119 | `2501068f6741e02e` |
| `DELETE` | `/api/notifications/queue/{id}` | `deleteNotificationsQueueId` | notifications | notifications | `delete_notification_queue_item_handler` | route+handler | none | idempotent | R3 / job / confirm:strong / registeredOperation | path extractor i64; exact handler backend/src/notification_queue.rs:64-81 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/notification_queue.rs:64-81 | `6b845ad2da8f319d` |
| `POST` | `/api/notifications/queue/{id}/retry` | `postNotificationsQueueIdRetry` | notifications | notifications | `retry_notification_queue_item_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor i64; exact handler backend/src/notification_queue.rs:44-61 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/notification_queue.rs:44-61 | `068be5777b9d8f78` |
| `GET` | `/api/automation/config` | `getAutomationConfig` | automation | automation | `get_automation_config_handler` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3968-3979 | Rust return ( StatusCode, Json<ApiResponse<crate::config::AutomationConfig>>, ); exact handler backend/src/handlers.rs:3968-3979 | `eed75ffb9545cf5e` |
| `POST` | `/api/automation/config` | `postAutomationConfig` | automation | automation | `set_automation_config_handler` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / registeredOperation | body extractor crate::config::AutomationConfig fields [enabled:bool, tasks:Vec<AutomationTask>] (config.rs:1394); exact handler backend/src/handlers.rs:3982-3999 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:3982-3999 | `7f324660f7624a52` |
| `GET` | `/api/automation/logs` | `getAutomationLogs` | automation | automation | `get_automation_logs_handler` | route+handler+model | none | safe | R0 / query / confirm:none / registeredOperation | query extractor AutomationLogQuery fields [task_type:String, status:String, q:String, start_date:String, end_date:String, limit:i64, offset:i64] (handlers.rs:3938); exact handler backend/src/handlers.rs:4002-4027 | Rust return ( StatusCode, Json<ApiResponse<crate::db::AutomationLogsResponse>>, ); exact handler backend/src/handlers.rs:4002-4027 | `b6cacc95a374e31a` |
| `POST` | `/api/automation/logs/clear` | `postAutomationLogsClear` | automation | automation | `clear_automation_logs_handler` | route+handler | none | conditional | R3 / job / confirm:strong / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:4030-4053 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:4030-4053 | `0912300e74c831ac` |
| `POST` | `/api/automation/test/{task_id}` | `postAutomationTestTaskId` | automation | automation | `test_automation_task_handler` | route+handler | none | non-idempotent | R2 / job / confirm:explicit / registeredOperation | path extractor String; exact handler backend/src/handlers.rs:4056-4146 | Rust return (StatusCode, Json<ApiResponse<serde_json::Value>>); exact handler backend/src/handlers.rs:4056-4146 | `091db790a51ab185` |
| `GET` | `/api/ota/status` | `getOtaStatus` | ota | ota | `get_ota_status_handler` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3763-3769 | dynamic-json construction in exact handler backend/src/handlers.rs:3763-3769; field shape is implementation evidence only | `89d8afa94cbad60c` |
| `POST` | `/api/ota/upload` | `postOtaUpload` | ota | ota | `upload_ota_handler` | route+handler | application/octet-stream | non-idempotent | R3 / job / confirm:strong / registeredOperation | bytes extractor axum::body::Bytes; exact exact handler backend/src/handlers.rs:3772-3792 | ApiResponse<OtaUploadResponse> success payload/error envelope; success/error payload evidenced by ApiResponse<OtaUploadResponse>; exact handler backend/src/handlers.rs:3772-3792 | `becfb7dc7e9c1355` |
| `POST` | `/api/ota/latest-release` | `postOtaLatestRelease` | ota | ota | `get_latest_ota_release_handler` | route+handler+model | application/json | non-idempotent | R1 / command / confirm:none / registeredOperation | body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option<String>] (models.rs:986); exact handler backend/src/handlers.rs:3795-3825 | success/error payload evidenced by ApiResponse<crate::models::OtaLatestReleaseResponse>; exact handler backend/src/handlers.rs:3795-3825 | `61083109860620e5` |
| `POST` | `/api/ota/online-prepare` | `postOtaOnlinePrepare` | ota | ota | `prepare_online_ota_handler` | route+handler+model | application/json | non-idempotent | R2 / job / confirm:explicit / registeredOperation | body extractor crate::models::OtaOnlinePrepareRequest fields [proxy_prefix:Option<String>] (models.rs:986); exact handler backend/src/handlers.rs:3828-3889 | success/error payload evidenced by ApiResponse<crate::models::OtaUploadResponse>; exact handler backend/src/handlers.rs:3828-3889 | `6d70f80714b7f004` |
| `POST` | `/api/ota/apply` | `postOtaApply` | ota | ota | `apply_ota_handler` | route+handler+model+bruno | application/json | non-idempotent | R3 / job / confirm:strong / registeredOperation | body extractor crate::models::OtaApplyRequest fields [restart_now:bool] (models.rs:1030); exact handler backend/src/handlers.rs:3892-3911 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:3892-3911 | `46c24f90fdb71e52` |
| `POST` | `/api/ota/cancel` | `postOtaCancel` | ota | ota | `cancel_ota_handler` | route+handler+bruno | none | conditional | R1 / command / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:3914-3931 | success/error payload evidenced by ApiResponse<serde_json::Value>; exact handler backend/src/handlers.rs:3914-3931 | `9085b81dc3f85886` |
| `POST` | `/api/auth/password` | `postAuthPassword` | instances-auth | settings-instance | `auth::change_password` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / dedicatedFlow | body extractor ChangePasswordRequest fields [new_password:String] (auth.rs:59); exact handler backend/src/auth.rs:491-553 | success/error payload evidenced by ApiResponse<Value>; exact handler backend/src/auth.rs:491-553 | `ec7372792586fd2a` |
| `GET` | `/api/auth/settings` | `getAuthSettings` | instances-auth | settings-instance | `auth::get_settings` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/auth.rs:555-570 | Rust return (StatusCode, Json<ApiResponse<AuthSettingsResponse>>); exact handler backend/src/auth.rs:555-570 | `035bdb5afbefdcb8` |
| `POST` | `/api/auth/settings` | `postAuthSettings` | instances-auth | settings-instance | `auth::set_settings` | route+handler+model | application/json | conditional | R2 / job / confirm:explicit / dedicatedFlow | body extractor SecurityConfig fields [password_protection_enabled:bool, password_min_length:u8, password_require_letters:bool, password_require_digits:bool, password_require_symbols:bool, session_ttl_seconds:i64, idle_timeout_seconds:i64] (config.rs:615); exact handler backend/src/auth.rs:572-640 | success/error payload evidenced by ApiResponse<Value>; exact handler backend/src/auth.rs:572-640 | `10b564c9437defd5` |
| `GET` | `/api/health` | `getHealth` | instances-auth | instances-new | `health_check` | route+handler+bruno | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/handlers.rs:65-75 | dynamic-json construction in exact handler backend/src/handlers.rs:65-75; field shape is implementation evidence only | `e169391fd5f376b8` |
| `GET` | `/api/auth/status` | `getAuthStatus` | instances-auth | instances-new | `auth::status` | route+handler | none | safe | R0 / query / confirm:none / registeredOperation | none; no body/query/path extractor; exact handler backend/src/auth.rs:345-364 | Rust return (StatusCode, Json<ApiResponse<AuthStatusResponse>>); exact handler backend/src/auth.rs:345-364 | `0ee683548f9f8036` |
| `POST` | `/api/auth/setup` | `postAuthSetup` | instances-auth | instances-new | `auth::setup` | route+handler+model | application/json | conditional | R3 / job / confirm:strong / dedicatedFlow | body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:366-427 | success/error payload evidenced by ApiResponse<Value>; exact handler backend/src/auth.rs:366-427 | `1c0c5eb2cc77326d` |
| `POST` | `/api/auth/login` | `postAuthLogin` | instances-auth | instances-new | `auth::login` | route+handler+model | application/json | conditional | R1 / command / confirm:none / dedicatedFlow | body extractor LoginRequest fields [password:String] (auth.rs:54); exact handler backend/src/auth.rs:429-489 | success/error payload evidenced by ApiResponse<Value>; exact handler backend/src/auth.rs:429-489 | `defd9444410e5b12` |
| `POST` | `/api/auth/logout` | `postAuthLogout` | instances-auth | settings-instance | `auth::logout` | route+handler | none | conditional | R1 / command / confirm:none / dedicatedFlow | none body; HeaderMap carries simadmin_session cookie; exact handler backend/src/auth.rs:642-655; cookie name backend/src/auth.rs:43-43 | dynamic-json construction in exact handler backend/src/auth.rs:642-655; field shape is implementation evidence only | `1ccf5c295251b2a2` |
## Sensitive-field audit ledger
This table is generated from the registry. Each row is an exact directional redaction contract; evidence details remain machine-readable in the registry.
| Method | Path | Direction | Sensitive path | Mode | Reason |
|---|---|---|---|---|---|
| GET | `/api/sim` | response | `$.response.data.iccid` | mask | SIM/subscriber identifier |
| GET | `/api/sim` | response | `$.response.data.imsi` | mask | SIM/subscriber identifier |
| GET | `/api/sim` | response | `$.response.data.phone_numbers` | mask | SIM/subscriber identifier |
| GET | `/api/sim` | response | `$.response.data.sms_center` | mask | SIM/subscriber identifier |
| POST | `/api/sim/cache` | request | `$.body.phone_number` | mask | subscriber contact value |
| POST | `/api/sim/cache` | request | `$.body.sms_center` | mask | subscriber contact value |
| GET | `/api/device-network/ddns/config` | response | `$.response.data.access_id` | mask | identifier or address |
| GET | `/api/device-network/ddns/config` | response | `$.response.data.access_secret` | drop | secret or message content |
| POST | `/api/device-network/ddns/config` | request | `$.body.access_id` | mask | identifier or address |
| POST | `/api/device-network/ddns/config` | request | `$.body.access_secret` | drop | secret or message content |
| GET | `/api/device-network/wlan/status` | response | `$.response.data.ssid` | mask | network identifier |
| GET | `/api/device-network/wlan/status` | response | `$.response.data.ipv4_addresses` | mask | network identifier |
| GET | `/api/device-network/wlan/status` | response | `$.response.data.ipv6_addresses` | mask | network identifier |
| POST | `/api/device-network/wlan/scan` | response | `$.response.data.networks[*].ssid` | mask | network identifier |
| POST | `/api/device-network/wlan/scan` | response | `$.response.data.networks[*].bssid` | mask | network identifier |
| GET | `/api/device-network/wlan/profiles` | response | `$.response.data.profiles[*].ssid` | mask | network identifier |
| POST | `/api/device-network/wlan/connect` | request | `$.body.ssid` | mask | network identifier |
| POST | `/api/device-network/wlan/connect` | request | `$.body.password` | drop | WLAN credential |
| GET | `/api/apn` | response | `$.response.data.contexts[*].username` | drop | APN credential |
| GET | `/api/apn` | response | `$.response.data.contexts[*].password` | drop | APN credential |
| POST | `/api/apn` | request | `$.body.username` | mask | identifier or address |
| POST | `/api/apn` | request | `$.body.password` | drop | secret or message content |
| GET | `/api/esim/euicc` | response | `$.response.data.eid` | mask | eUICC identifier |
| GET | `/api/esim/profiles` | response | `$.response.data.profiles[*].iccid` | mask | eSIM identifier |
| POST | `/api/esim/profiles` | request | `$.body.matching_id` | mask | identifier or address |
| POST | `/api/esim/profiles` | request | `$.body.confirmation_code` | drop | secret or message content |
| POST | `/api/esim/profiles` | request | `$.body.imei` | mask | identifier or address |
| GET | `/api/calls` | response | `$.response.data.calls[*].phone_number` | mask | call party |
| POST | `/api/call/dial` | request | `$.body.phone_number` | mask | identifier or address |
| GET | `/api/call/forwarding` | response | `$.response.data.voice_unconditional` | mask | forwarding destination |
| GET | `/api/call/forwarding` | response | `$.response.data.voice_busy` | mask | forwarding destination |
| GET | `/api/call/forwarding` | response | `$.response.data.voice_no_reply` | mask | forwarding destination |
| GET | `/api/call/forwarding` | response | `$.response.data.voice_not_reachable` | mask | forwarding destination |
| POST | `/api/call/forwarding` | request | `$.body.number` | mask | identifier or address |
| GET | `/api/call/history` | response | `$.response.data.records[*].phone_number` | mask | call party |
| GET | `/api/voicemail/status` | response | `$.response.data.mailbox_number` | mask | voicemail number |
| POST | `/api/sms/send` | request | `$.body.phone_number` | mask | identifier or address |
| POST | `/api/sms/send` | request | `$.body.content` | drop | secret or message content |
| GET | `/api/sms/list` | response | `$.response.data.messages[*].phone_number` | mask | message correspondent |
| GET | `/api/sms/list` | response | `$.response.data.messages[*].content` | drop | message body |
| GET | `/api/sms/conversation` | query | `$.query.phone_number` | mask | message correspondent |
| GET | `/api/sms/conversation` | response | `$.response.data.messages[*].phone_number` | mask | message correspondent |
| GET | `/api/sms/conversation` | response | `$.response.data.messages[*].content` | drop | message body |
| POST | `/api/sms/batch-delete` | request | `$.body.phone_numbers[*]` | mask | identifier or address |
| DELETE | `/api/sms/conversation/{phone_number}` | path | `$.path.phone_number` | mask | identifier or address |
| GET | `/api/notifications/config` | response | `$.response.data.channels[*].config` | mask | identifier or address |
| POST | `/api/notifications/config` | request | `$.body.channels[*].config` | mask | identifier or address |
| GET | `/api/automation/config` | response | `$.response.data.tasks[*].config` | mask | identifier or address |
| POST | `/api/automation/config` | request | `$.body.tasks[*].config` | mask | identifier or address |
| POST | `/api/auth/password` | request | `$.body.new_password` | drop | secret or message content |
| POST | `/api/auth/password` | session | `$.session.set-cookie` | drop | secret or message content |
| GET | `/api/auth/settings` | session | `$.session.cookie` | drop | secret or message content |
| POST | `/api/auth/settings` | session | `$.session.cookie` | drop | secret or message content |
| GET | `/api/auth/status` | session | `$.session.cookie` | drop | secret or message content |
| POST | `/api/auth/setup` | request | `$.body.password` | drop | secret or message content |
| POST | `/api/auth/setup` | session | `$.session.set-cookie` | drop | secret or message content |
| POST | `/api/auth/login` | request | `$.body.password` | drop | secret or message content |
| POST | `/api/auth/login` | session | `$.session.set-cookie` | drop | secret or message content |
| POST | `/api/auth/logout` | session | `$.session.cookie` | drop | secret or message content |
| POST | `/api/auth/logout` | session | `$.session.set-cookie` | drop | secret or message content |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,709 @@
{
"upstreamCommit": "58e220411d6599609f0eeda01eb7016e9212f970",
"sourceRange": "backend/src/main.rs:503-940",
"extractionRule": "Axum .route declarations in source range; get/post/delete only; OPTIONS excluded",
"routes": [
{
"method": "GET",
"path": "/api/device",
"handler": "get_device_info",
"source": "backend/src/main.rs:506-506"
},
{
"method": "GET",
"path": "/api/sim",
"handler": "get_sim_info",
"source": "backend/src/main.rs:508-508"
},
{
"method": "POST",
"path": "/api/sim/details/refresh",
"handler": "refresh_sim_details_handler",
"source": "backend/src/main.rs:510-511"
},
{
"method": "POST",
"path": "/api/sim/cache",
"handler": "update_sim_cache_handler",
"source": "backend/src/main.rs:514-515"
},
{
"method": "GET",
"path": "/api/network",
"handler": "get_network_info",
"source": "backend/src/main.rs:519-520"
},
{
"method": "GET",
"path": "/api/cells",
"handler": "get_cells",
"source": "backend/src/main.rs:522-522"
},
{
"method": "POST",
"path": "/api/cell-monitor/start",
"handler": "start_cell_monitor_handler",
"source": "backend/src/main.rs:524-525"
},
{
"method": "POST",
"path": "/api/cell-monitor/stop",
"handler": "stop_cell_monitor_handler",
"source": "backend/src/main.rs:528-529"
},
{
"method": "GET",
"path": "/api/radio-mode",
"handler": "get_radio_mode_handler",
"source": "backend/src/main.rs:532-533"
},
{
"method": "POST",
"path": "/api/radio-mode",
"handler": "set_radio_mode_handler",
"source": "backend/src/main.rs:532-534"
},
{
"method": "GET",
"path": "/api/band-lock",
"handler": "get_band_lock_handler",
"source": "backend/src/main.rs:538-539"
},
{
"method": "POST",
"path": "/api/band-lock",
"handler": "set_band_lock_handler",
"source": "backend/src/main.rs:538-540"
},
{
"method": "GET",
"path": "/api/network/interfaces",
"handler": "get_network_interfaces_info",
"source": "backend/src/main.rs:544-545"
},
{
"method": "GET",
"path": "/api/network/connection-addresses",
"handler": "get_network_connection_addresses",
"source": "backend/src/main.rs:548-549"
},
{
"method": "GET",
"path": "/api/device-network/ddns/config",
"handler": "get_device_ddns_config_handler",
"source": "backend/src/main.rs:552-553"
},
{
"method": "POST",
"path": "/api/device-network/ddns/config",
"handler": "set_device_ddns_config_handler",
"source": "backend/src/main.rs:552-554"
},
{
"method": "GET",
"path": "/api/device-network/ddns/status",
"handler": "get_device_ddns_status_handler",
"source": "backend/src/main.rs:558-559"
},
{
"method": "POST",
"path": "/api/device-network/ddns/sync",
"handler": "sync_device_ddns_handler",
"source": "backend/src/main.rs:562-563"
},
{
"method": "GET",
"path": "/api/device-network/ddns/logs",
"handler": "get_device_ddns_logs_handler",
"source": "backend/src/main.rs:566-567"
},
{
"method": "POST",
"path": "/api/device-network/ddns/logs/clear",
"handler": "clear_device_ddns_logs_handler",
"source": "backend/src/main.rs:570-571"
},
{
"method": "GET",
"path": "/api/device-network/wlan/status",
"handler": "get_device_wlan_status_handler",
"source": "backend/src/main.rs:574-575"
},
{
"method": "POST",
"path": "/api/device-network/wlan/enabled",
"handler": "set_device_wlan_enabled_handler",
"source": "backend/src/main.rs:578-579"
},
{
"method": "POST",
"path": "/api/device-network/wlan/scan",
"handler": "scan_device_wlan_handler",
"source": "backend/src/main.rs:582-583"
},
{
"method": "GET",
"path": "/api/device-network/wlan/profiles",
"handler": "get_device_wlan_profiles_handler",
"source": "backend/src/main.rs:586-587"
},
{
"method": "POST",
"path": "/api/device-network/wlan/forget",
"handler": "forget_device_wlan_handler",
"source": "backend/src/main.rs:590-591"
},
{
"method": "POST",
"path": "/api/device-network/wlan/connect",
"handler": "connect_device_wlan_handler",
"source": "backend/src/main.rs:594-595"
},
{
"method": "POST",
"path": "/api/device-network/wlan/disconnect",
"handler": "disconnect_device_wlan_handler",
"source": "backend/src/main.rs:598-599"
},
{
"method": "POST",
"path": "/api/device-network/wlan/profile",
"handler": "save_device_wlan_profile_handler",
"source": "backend/src/main.rs:602-603"
},
{
"method": "GET",
"path": "/api/network/signal-strength",
"handler": "get_signal_strength_handler",
"source": "backend/src/main.rs:606-607"
},
{
"method": "GET",
"path": "/api/location/cell-info",
"handler": "get_cell_location_handler",
"source": "backend/src/main.rs:610-611"
},
{
"method": "GET",
"path": "/api/network/operators",
"handler": "get_network_operators",
"source": "backend/src/main.rs:614-615"
},
{
"method": "GET",
"path": "/api/network/operators/scan",
"handler": "scan_network_operators",
"source": "backend/src/main.rs:618-619"
},
{
"method": "POST",
"path": "/api/network/register-manual",
"handler": "register_network_manual",
"source": "backend/src/main.rs:622-623"
},
{
"method": "POST",
"path": "/api/network/register-auto",
"handler": "register_network_auto",
"source": "backend/src/main.rs:626-627"
},
{
"method": "GET",
"path": "/api/apn",
"handler": "get_apn_list_handler",
"source": "backend/src/main.rs:630-631"
},
{
"method": "POST",
"path": "/api/apn",
"handler": "set_apn_handler",
"source": "backend/src/main.rs:630-632"
},
{
"method": "GET",
"path": "/api/cell-lock",
"handler": "get_cell_lock_status_handler",
"source": "backend/src/main.rs:636-637"
},
{
"method": "POST",
"path": "/api/cell-lock",
"handler": "set_cell_lock_handler",
"source": "backend/src/main.rs:636-638"
},
{
"method": "POST",
"path": "/api/cell-lock/unlock-all",
"handler": "unlock_all_cells_handler",
"source": "backend/src/main.rs:642-643"
},
{
"method": "GET",
"path": "/api/data",
"handler": "get_data_status",
"source": "backend/src/main.rs:647-648"
},
{
"method": "POST",
"path": "/api/data",
"handler": "set_data_status",
"source": "backend/src/main.rs:647-649"
},
{
"method": "GET",
"path": "/api/roaming",
"handler": "get_roaming_status_handler",
"source": "backend/src/main.rs:653-654"
},
{
"method": "POST",
"path": "/api/roaming",
"handler": "set_roaming_status_handler",
"source": "backend/src/main.rs:653-655"
},
{
"method": "GET",
"path": "/api/airplane-mode",
"handler": "get_airplane_mode_handler",
"source": "backend/src/main.rs:659-660"
},
{
"method": "POST",
"path": "/api/airplane-mode",
"handler": "set_airplane_mode_handler",
"source": "backend/src/main.rs:659-661"
},
{
"method": "POST",
"path": "/api/baseband/restart",
"handler": "restart_baseband_handler",
"source": "backend/src/main.rs:665-666"
},
{
"method": "GET",
"path": "/api/baseband/restart/status",
"handler": "get_baseband_restart_status_handler",
"source": "backend/src/main.rs:669-670"
},
{
"method": "GET",
"path": "/api/work-mode",
"handler": "get_work_mode_handler",
"source": "backend/src/main.rs:674-675"
},
{
"method": "POST",
"path": "/api/work-mode",
"handler": "set_work_mode_handler",
"source": "backend/src/main.rs:674-676"
},
{
"method": "GET",
"path": "/api/esim/config",
"handler": "get_esim_config_handler",
"source": "backend/src/main.rs:680-681"
},
{
"method": "POST",
"path": "/api/esim/config",
"handler": "set_esim_config_handler",
"source": "backend/src/main.rs:680-682"
},
{
"method": "GET",
"path": "/api/esim/lpac/status",
"handler": "get_esim_lpac_status_handler",
"source": "backend/src/main.rs:686-687"
},
{
"method": "POST",
"path": "/api/esim/lpac/repair",
"handler": "repair_esim_lpac_handler",
"source": "backend/src/main.rs:690-691"
},
{
"method": "GET",
"path": "/api/esim/euicc",
"handler": "get_esim_euicc_handler",
"source": "backend/src/main.rs:694-695"
},
{
"method": "GET",
"path": "/api/esim/profiles",
"handler": "get_esim_profiles_handler",
"source": "backend/src/main.rs:698-699"
},
{
"method": "POST",
"path": "/api/esim/profiles",
"handler": "download_esim_profile_handler",
"source": "backend/src/main.rs:698-700"
},
{
"method": "POST",
"path": "/api/esim/profiles/{iccid}/enable",
"handler": "enable_esim_profile_handler",
"source": "backend/src/main.rs:704-705"
},
{
"method": "POST",
"path": "/api/esim/profiles/{iccid}/rename",
"handler": "rename_esim_profile_handler",
"source": "backend/src/main.rs:708-709"
},
{
"method": "DELETE",
"path": "/api/esim/profiles/{iccid}",
"handler": "delete_esim_profile_handler",
"source": "backend/src/main.rs:712-713"
},
{
"method": "GET",
"path": "/api/calls",
"handler": "get_calls_handler",
"source": "backend/src/main.rs:717-718"
},
{
"method": "POST",
"path": "/api/call/dial",
"handler": "dial_call_handler",
"source": "backend/src/main.rs:721-722"
},
{
"method": "POST",
"path": "/api/call/hangup",
"handler": "hangup_call_handler",
"source": "backend/src/main.rs:725-726"
},
{
"method": "POST",
"path": "/api/call/hangup-all",
"handler": "hangup_all_calls_handler",
"source": "backend/src/main.rs:729-730"
},
{
"method": "POST",
"path": "/api/call/answer",
"handler": "answer_call_handler",
"source": "backend/src/main.rs:733-734"
},
{
"method": "GET",
"path": "/api/call/volume",
"handler": "get_call_volume_handler",
"source": "backend/src/main.rs:737-738"
},
{
"method": "POST",
"path": "/api/call/volume",
"handler": "set_call_volume_handler",
"source": "backend/src/main.rs:737-739"
},
{
"method": "GET",
"path": "/api/call/forwarding",
"handler": "get_call_forwarding_handler",
"source": "backend/src/main.rs:743-744"
},
{
"method": "POST",
"path": "/api/call/forwarding",
"handler": "set_call_forwarding_handler",
"source": "backend/src/main.rs:743-745"
},
{
"method": "GET",
"path": "/api/call/settings",
"handler": "get_call_settings_handler",
"source": "backend/src/main.rs:749-750"
},
{
"method": "POST",
"path": "/api/call/settings",
"handler": "set_call_settings_handler",
"source": "backend/src/main.rs:749-751"
},
{
"method": "GET",
"path": "/api/call/history",
"handler": "get_call_history_handler",
"source": "backend/src/main.rs:755-756"
},
{
"method": "DELETE",
"path": "/api/call/history/{id}",
"handler": "delete_call_history_handler",
"source": "backend/src/main.rs:759-760"
},
{
"method": "POST",
"path": "/api/call/history/clear",
"handler": "clear_call_history_handler",
"source": "backend/src/main.rs:763-764"
},
{
"method": "GET",
"path": "/api/ims/status",
"handler": "get_ims_status_handler",
"source": "backend/src/main.rs:767-768"
},
{
"method": "GET",
"path": "/api/voicemail/status",
"handler": "get_voicemail_status_handler",
"source": "backend/src/main.rs:771-772"
},
{
"method": "POST",
"path": "/api/sms/send",
"handler": "send_sms_handler",
"source": "backend/src/main.rs:776-777"
},
{
"method": "GET",
"path": "/api/sms/list",
"handler": "get_sms_list_handler",
"source": "backend/src/main.rs:780-781"
},
{
"method": "GET",
"path": "/api/sms/conversation",
"handler": "get_sms_conversation_handler",
"source": "backend/src/main.rs:784-785"
},
{
"method": "GET",
"path": "/api/sms/stats",
"handler": "get_sms_stats_handler",
"source": "backend/src/main.rs:788-789"
},
{
"method": "POST",
"path": "/api/sms/batch-delete",
"handler": "delete_sms_batch_handler",
"source": "backend/src/main.rs:792-793"
},
{
"method": "DELETE",
"path": "/api/sms/conversation/{phone_number}",
"handler": "delete_sms_conversation_handler",
"source": "backend/src/main.rs:796-797"
},
{
"method": "DELETE",
"path": "/api/sms/message/{id}",
"handler": "delete_sms_message_handler",
"source": "backend/src/main.rs:800-801"
},
{
"method": "POST",
"path": "/api/sms/clear",
"handler": "clear_sms_handler",
"source": "backend/src/main.rs:804-805"
},
{
"method": "GET",
"path": "/api/stats",
"handler": "get_system_stats",
"source": "backend/src/main.rs:808-808"
},
{
"method": "GET",
"path": "/api/stats/cpu",
"handler": "get_cpu_info",
"source": "backend/src/main.rs:809-809"
},
{
"method": "GET",
"path": "/api/connectivity",
"handler": "get_connectivity_check",
"source": "backend/src/main.rs:811-812"
},
{
"method": "POST",
"path": "/api/system/reboot",
"handler": "system_reboot",
"source": "backend/src/main.rs:815-816"
},
{
"method": "POST",
"path": "/api/service/restart",
"handler": "restart_service_handler",
"source": "backend/src/main.rs:819-820"
},
{
"method": "GET",
"path": "/api/notifications/config",
"handler": "get_notification_config_handler",
"source": "backend/src/main.rs:824-825"
},
{
"method": "POST",
"path": "/api/notifications/config",
"handler": "set_notification_config_handler",
"source": "backend/src/main.rs:824-826"
},
{
"method": "POST",
"path": "/api/notifications/test/{channel}",
"handler": "test_notification_channel_handler",
"source": "backend/src/main.rs:830-831"
},
{
"method": "GET",
"path": "/api/notifications/logs",
"handler": "get_notification_logs_handler",
"source": "backend/src/main.rs:835-836"
},
{
"method": "POST",
"path": "/api/notifications/logs/clear",
"handler": "clear_notification_logs_handler",
"source": "backend/src/main.rs:839-840"
},
{
"method": "GET",
"path": "/api/notifications/queue",
"handler": "get_notification_queue_handler",
"source": "backend/src/main.rs:843-844"
},
{
"method": "POST",
"path": "/api/notifications/queue/retry-all",
"handler": "retry_all_notification_queue_handler",
"source": "backend/src/main.rs:847-848"
},
{
"method": "POST",
"path": "/api/notifications/queue/clear",
"handler": "clear_notification_queue_handler",
"source": "backend/src/main.rs:851-852"
},
{
"method": "DELETE",
"path": "/api/notifications/queue/{id}",
"handler": "delete_notification_queue_item_handler",
"source": "backend/src/main.rs:855-856"
},
{
"method": "POST",
"path": "/api/notifications/queue/{id}/retry",
"handler": "retry_notification_queue_item_handler",
"source": "backend/src/main.rs:859-860"
},
{
"method": "GET",
"path": "/api/automation/config",
"handler": "get_automation_config_handler",
"source": "backend/src/main.rs:864-865"
},
{
"method": "POST",
"path": "/api/automation/config",
"handler": "set_automation_config_handler",
"source": "backend/src/main.rs:864-866"
},
{
"method": "GET",
"path": "/api/automation/logs",
"handler": "get_automation_logs_handler",
"source": "backend/src/main.rs:870-871"
},
{
"method": "POST",
"path": "/api/automation/logs/clear",
"handler": "clear_automation_logs_handler",
"source": "backend/src/main.rs:874-875"
},
{
"method": "POST",
"path": "/api/automation/test/{task_id}",
"handler": "test_automation_task_handler",
"source": "backend/src/main.rs:878-879"
},
{
"method": "GET",
"path": "/api/ota/status",
"handler": "get_ota_status_handler",
"source": "backend/src/main.rs:882-883"
},
{
"method": "POST",
"path": "/api/ota/upload",
"handler": "upload_ota_handler",
"source": "backend/src/main.rs:886-887"
},
{
"method": "POST",
"path": "/api/ota/latest-release",
"handler": "get_latest_ota_release_handler",
"source": "backend/src/main.rs:892-893"
},
{
"method": "POST",
"path": "/api/ota/online-prepare",
"handler": "prepare_online_ota_handler",
"source": "backend/src/main.rs:896-897"
},
{
"method": "POST",
"path": "/api/ota/apply",
"handler": "apply_ota_handler",
"source": "backend/src/main.rs:900-901"
},
{
"method": "POST",
"path": "/api/ota/cancel",
"handler": "cancel_ota_handler",
"source": "backend/src/main.rs:904-905"
},
{
"method": "POST",
"path": "/api/auth/password",
"handler": "auth::change_password",
"source": "backend/src/main.rs:908-909"
},
{
"method": "GET",
"path": "/api/auth/settings",
"handler": "auth::get_settings",
"source": "backend/src/main.rs:912-913"
},
{
"method": "POST",
"path": "/api/auth/settings",
"handler": "auth::set_settings",
"source": "backend/src/main.rs:912-914"
},
{
"method": "GET",
"path": "/api/health",
"handler": "health_check",
"source": "backend/src/main.rs:923-923"
},
{
"method": "GET",
"path": "/api/auth/status",
"handler": "auth::status",
"source": "backend/src/main.rs:925-926"
},
{
"method": "POST",
"path": "/api/auth/setup",
"handler": "auth::setup",
"source": "backend/src/main.rs:929-930"
},
{
"method": "POST",
"path": "/api/auth/login",
"handler": "auth::login",
"source": "backend/src/main.rs:933-934"
},
{
"method": "POST",
"path": "/api/auth/logout",
"handler": "auth::logout",
"source": "backend/src/main.rs:937-938"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,699 @@
//! Single-admin authentication for the SimAdmin web console.
use std::io::{self, Write};
use std::num::NonZeroU32;
use anyhow::{bail, Result};
use axum::{
extract::State,
http::{header, HeaderMap, HeaderValue, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ring::{
digest, pbkdf2,
rand::{SecureRandom, SystemRandom},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
config::SecurityConfig,
db::Database,
models::ApiResponse,
state::AppState,
system_event::{
codes as system_event_codes, severity as system_event_severity,
status as system_event_status,
},
};
const PASSWORD_KEY: &str = "admin_password_hash";
const PASSWORD_ALGORITHM: &str = "pbkdf2_sha256";
const PBKDF2_ITERATIONS: u32 = 210_000;
const PASSWORD_SALT_LEN: usize = 16;
const PASSWORD_HASH_LEN: usize = 32;
const PASSWORD_MAX_LENGTH: usize = 64;
const PASSWORD_MIN_LENGTH_MIN: u8 = 1;
const PASSWORD_MIN_LENGTH_MAX: u8 = PASSWORD_MAX_LENGTH as u8;
const SESSION_TOKEN_LEN: usize = 32;
const SESSION_TTL_NEVER_SECONDS: i64 = 100 * 365 * 24 * 60 * 60;
const SESSION_COOKIE: &str = "simadmin_session";
const SESSION_TTL_OPTIONS: [i64; 5] = [
24 * 60 * 60,
7 * 24 * 60 * 60,
14 * 24 * 60 * 60,
30 * 24 * 60 * 60,
-1,
];
const IDLE_TIMEOUT_OPTIONS: [i64; 6] = [30 * 60, 60 * 60, 2 * 60 * 60, 3 * 60 * 60, 6 * 60 * 60, 0];
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct ChangePasswordRequest {
pub new_password: String,
}
#[derive(Debug, Serialize)]
pub struct AuthStatusResponse {
pub configured: bool,
pub authenticated: bool,
pub settings: SecurityConfig,
}
#[derive(Debug, Serialize)]
pub struct AuthSettingsResponse {
pub configured: bool,
pub settings: SecurityConfig,
}
#[derive(Debug)]
struct SessionToken {
token: String,
hash: String,
}
fn normalize_security_settings(mut settings: SecurityConfig) -> SecurityConfig {
if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length)
{
settings.password_min_length = SecurityConfig::default().password_min_length;
}
if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) {
settings.session_ttl_seconds = SecurityConfig::default().session_ttl_seconds;
}
if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) {
settings.idle_timeout_seconds = SecurityConfig::default().idle_timeout_seconds;
}
if !settings.password_require_letters
&& !settings.password_require_digits
&& !settings.password_require_symbols
{
settings.password_require_letters = true;
}
settings
}
fn validate_security_settings(settings: &SecurityConfig) -> Result<()> {
if !(PASSWORD_MIN_LENGTH_MIN..=PASSWORD_MIN_LENGTH_MAX).contains(&settings.password_min_length)
{
bail!("密码最小长度需为 1-64 之间的整数");
}
if !settings.password_require_letters
&& !settings.password_require_digits
&& !settings.password_require_symbols
{
bail!("字符类型要求至少需要选择一项");
}
if !SESSION_TTL_OPTIONS.contains(&settings.session_ttl_seconds) {
bail!("会话有效期只能选择 1 天、7 天、14 天、30 天或永不过期");
}
if !IDLE_TIMEOUT_OPTIONS.contains(&settings.idle_timeout_seconds) {
bail!("空闲超时只能选择 30 分钟、1 小时、2 小时、3 小时、6 小时或关闭");
}
Ok(())
}
fn configured_session_ttl_seconds(settings: &SecurityConfig) -> i64 {
if settings.session_ttl_seconds < 0 {
SESSION_TTL_NEVER_SECONDS
} else {
settings.session_ttl_seconds
}
}
fn enabled_password_types_text(settings: &SecurityConfig) -> &'static str {
match (
settings.password_require_letters,
settings.password_require_digits,
settings.password_require_symbols,
) {
(true, true, true) => "英文字母、数字和符号",
(true, true, false) => "英文字母和数字",
(true, false, true) => "英文字母和符号",
(false, true, true) => "数字和符号",
(true, false, false) => "英文字母",
(false, true, false) => "数字",
(false, false, true) => "符号",
(false, false, false) => "英文字母、数字和符号",
}
}
fn password_byte_allowed(byte: u8, settings: &SecurityConfig) -> bool {
byte.is_ascii_graphic()
&& ((settings.password_require_letters && byte.is_ascii_alphabetic())
|| (settings.password_require_digits && byte.is_ascii_digit())
|| (settings.password_require_symbols
&& byte.is_ascii_graphic()
&& !byte.is_ascii_alphanumeric()))
}
pub fn validate_admin_password(password: &str, settings: &SecurityConfig) -> Result<()> {
let settings = normalize_security_settings(settings.clone());
if !password
.bytes()
.all(|byte| password_byte_allowed(byte, &settings))
{
bail!(
"密码只能包含{},不能包含空格、中文或未启用的字符类型",
enabled_password_types_text(&settings)
);
}
if !((settings.password_min_length as usize)..=PASSWORD_MAX_LENGTH).contains(&password.len()) {
bail!(
"密码长度需为 {}-{} 个字符",
settings.password_min_length,
PASSWORD_MAX_LENGTH
);
}
if settings.password_require_letters && !password.bytes().any(|byte| byte.is_ascii_alphabetic())
{
bail!("密码需包含英文字母");
}
if settings.password_require_digits && !password.bytes().any(|byte| byte.is_ascii_digit()) {
bail!("密码需包含数字");
}
if settings.password_require_symbols
&& !password
.bytes()
.any(|byte| byte.is_ascii_graphic() && !byte.is_ascii_alphanumeric())
{
bail!("密码需包含符号");
}
Ok(())
}
pub fn hash_password(password: &str, settings: &SecurityConfig) -> Result<String> {
validate_admin_password(password, settings)?;
let rng = SystemRandom::new();
let mut salt = [0u8; PASSWORD_SALT_LEN];
rng.fill(&mut salt)
.map_err(|_| anyhow::anyhow!("Failed to generate password salt"))?;
let mut output = [0u8; PASSWORD_HASH_LEN];
let iterations = NonZeroU32::new(PBKDF2_ITERATIONS).expect("non-zero iterations");
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA256,
iterations,
&salt,
password.as_bytes(),
&mut output,
);
Ok(format!(
"{}${}${}${}",
PASSWORD_ALGORITHM,
PBKDF2_ITERATIONS,
URL_SAFE_NO_PAD.encode(salt),
URL_SAFE_NO_PAD.encode(output)
))
}
fn verify_password(password: &str, encoded_hash: &str) -> Result<bool> {
let parts: Vec<&str> = encoded_hash.split('$').collect();
if parts.len() != 4 || parts[0] != PASSWORD_ALGORITHM {
bail!("Unsupported password hash format");
}
let iterations = parts[1].parse::<u32>()?;
let iterations = NonZeroU32::new(iterations).ok_or_else(|| anyhow::anyhow!("Invalid hash"))?;
let salt = URL_SAFE_NO_PAD.decode(parts[2])?;
let expected = URL_SAFE_NO_PAD.decode(parts[3])?;
Ok(pbkdf2::verify(
pbkdf2::PBKDF2_HMAC_SHA256,
iterations,
&salt,
password.as_bytes(),
&expected,
)
.is_ok())
}
fn generate_session_token() -> Result<SessionToken> {
let rng = SystemRandom::new();
let mut raw = [0u8; SESSION_TOKEN_LEN];
rng.fill(&mut raw)
.map_err(|_| anyhow::anyhow!("Failed to generate session token"))?;
let token = URL_SAFE_NO_PAD.encode(raw);
let hash = hash_session_token(&token);
Ok(SessionToken { token, hash })
}
fn hash_session_token(token: &str) -> String {
URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, token.as_bytes()).as_ref())
}
fn session_cookie(token: &str, settings: &SecurityConfig) -> String {
let max_age = configured_session_ttl_seconds(settings);
format!("{SESSION_COOKIE}={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}")
}
fn expired_session_cookie() -> String {
format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0")
}
fn cookie_token(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
cookie.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == SESSION_COOKIE).then(|| value.to_string())
})
}
fn wants_login_redirect(headers: &HeaderMap) -> bool {
let accepts_html = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
.map(|value| value.contains("text/html"))
.unwrap_or(false);
let is_navigation = headers
.get("sec-fetch-mode")
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("navigate"))
.unwrap_or(false);
accepts_html || is_navigation
}
fn unauthorized_response(headers: &HeaderMap, message: impl Into<String>) -> Response {
if wants_login_redirect(headers) {
return (StatusCode::SEE_OTHER, [(header::LOCATION, "/login")]).into_response();
}
(
StatusCode::UNAUTHORIZED,
Json(ApiResponse::<Value>::error(message.into())),
)
.into_response()
}
fn response_with_session<T: Serialize>(
payload: ApiResponse<T>,
token: &str,
settings: &SecurityConfig,
) -> Response {
let mut response = Json(payload).into_response();
let cookie = session_cookie(token, settings);
if let Ok(value) = HeaderValue::from_str(&cookie) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
response
}
fn is_authenticated(database: &Database, headers: &HeaderMap) -> bool {
let Some(token) = cookie_token(headers) else {
return false;
};
database
.auth_session_valid(&hash_session_token(&token))
.unwrap_or(false)
}
pub async fn auth_middleware(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::extract::Request,
next: Next,
) -> Response {
if request.method() == Method::OPTIONS {
return next.run(request).await;
}
let settings = normalize_security_settings(state.config_manager.get_security());
if !settings.password_protection_enabled {
return next.run(request).await;
}
if !state.database.auth_is_configured().unwrap_or(false) {
return unauthorized_response(&headers, "管理员密码尚未设置");
}
if !is_authenticated(&state.database, &headers) {
return unauthorized_response(&headers, "请先登录");
}
next.run(request).await
}
pub async fn status(
State(state): State<AppState>,
headers: HeaderMap,
) -> (StatusCode, Json<ApiResponse<AuthStatusResponse>>) {
let settings = normalize_security_settings(state.config_manager.get_security());
let configured = state.database.auth_is_configured().unwrap_or(false);
let authenticated = !settings.password_protection_enabled
|| (configured && is_authenticated(&state.database, &headers));
(
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Success",
AuthStatusResponse {
configured,
authenticated,
settings,
},
)),
)
}
pub async fn setup(State(state): State<AppState>, Json(payload): Json<LoginRequest>) -> Response {
let settings = normalize_security_settings(state.config_manager.get_security());
if state.database.auth_is_configured().unwrap_or(false) {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::<Value>::error("管理员密码已设置")),
)
.into_response();
}
let password_hash = match hash_password(&payload.password, &settings) {
Ok(hash) => hash,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response()
}
};
if let Err(err) = state
.database
.set_auth_config_value(PASSWORD_KEY, &password_hash)
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!(
"保存管理员密码失败: {err}"
))),
)
.into_response();
}
let session = match generate_session_token() {
Ok(session) => session,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response()
}
};
if let Err(err) = state
.database
.insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings))
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!("创建会话失败: {err}"))),
)
.into_response();
}
response_with_session(
ApiResponse::success_with_message("Admin password configured", Value::Null),
&session.token,
&settings,
)
}
pub async fn login(State(state): State<AppState>, Json(payload): Json<LoginRequest>) -> Response {
let settings = normalize_security_settings(state.config_manager.get_security());
let Some(password_hash) = state
.database
.get_auth_config_value(PASSWORD_KEY)
.unwrap_or(None)
else {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::<Value>::error("管理员密码尚未设置")),
)
.into_response();
};
match verify_password(&payload.password, &password_hash) {
Ok(true) => {}
Ok(false) => {
state.system_event_emitter.record_login_failure().await;
return (
StatusCode::UNAUTHORIZED,
Json(ApiResponse::<Value>::error("管理员密码不正确")),
)
.into_response();
}
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!("验证密码失败: {err}"))),
)
.into_response()
}
}
let session = match generate_session_token() {
Ok(session) => session,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response()
}
};
if let Err(err) = state
.database
.insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings))
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!("创建会话失败: {err}"))),
)
.into_response();
}
response_with_session(
ApiResponse::success_with_message("Logged in", Value::Null),
&session.token,
&settings,
)
}
pub async fn change_password(
State(state): State<AppState>,
Json(payload): Json<ChangePasswordRequest>,
) -> Response {
let settings = normalize_security_settings(state.config_manager.get_security());
let new_hash = match hash_password(&payload.new_password, &settings) {
Ok(hash) => hash,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response()
}
};
if let Err(err) = state.database.replace_admin_password_hash(&new_hash) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!("更新密码失败: {err}"))),
)
.into_response();
}
let session = match generate_session_token() {
Ok(session) => session,
Err(err) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response()
}
};
if let Err(err) = state
.database
.insert_auth_session(&session.hash, configured_session_ttl_seconds(&settings))
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!("创建会话失败: {err}"))),
)
.into_response();
}
state
.system_event_emitter
.emit_code(
system_event_codes::SECURITY_PASSWORD_CHANGED,
system_event_severity::WARNING,
system_event_status::CHANGED,
"admin",
"管理员密码已修改",
)
.await;
response_with_session(
ApiResponse::success_with_message("Password updated", Value::Null),
&session.token,
&settings,
)
}
pub async fn get_settings(
State(state): State<AppState>,
) -> (StatusCode, Json<ApiResponse<AuthSettingsResponse>>) {
let settings = normalize_security_settings(state.config_manager.get_security());
let configured = state.database.auth_is_configured().unwrap_or(false);
(
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Success",
AuthSettingsResponse {
configured,
settings,
},
)),
)
}
pub async fn set_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SecurityConfig>,
) -> Response {
let previous_settings = normalize_security_settings(state.config_manager.get_security());
if let Err(err) = validate_security_settings(&payload) {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse::<Value>::error(err.to_string())),
)
.into_response();
}
let settings = normalize_security_settings(payload);
if let Err(err) = state.config_manager.set_security(settings.clone()) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse::<Value>::error(format!(
"保存安全设置失败: {err}"
))),
)
.into_response();
}
if previous_settings.password_protection_enabled && !settings.password_protection_enabled {
state
.system_event_emitter
.emit_code(
system_event_codes::SECURITY_PASSWORD_PROTECTION_DISABLED,
system_event_severity::CRITICAL,
system_event_status::CHANGED,
"security",
"密码保护已关闭",
)
.await;
} else if previous_settings != settings {
state
.system_event_emitter
.emit_code(
system_event_codes::SECURITY_POLICY_CHANGED,
system_event_severity::WARNING,
system_event_status::CHANGED,
"security",
"安全策略已变更",
)
.await;
}
let mut response = (
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Security settings saved",
settings.clone(),
)),
)
.into_response();
if let Some(token) = cookie_token(&headers) {
let _ = state.database.refresh_auth_session(
&hash_session_token(&token),
configured_session_ttl_seconds(&settings),
);
if let Ok(value) = HeaderValue::from_str(&session_cookie(&token, &settings)) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
}
response
}
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) = cookie_token(&headers) {
let _ = state
.database
.delete_auth_session(&hash_session_token(&token));
}
let mut response =
Json(ApiResponse::success_with_message("Logged out", Value::Null)).into_response();
if let Ok(value) = HeaderValue::from_str(&expired_session_cookie()) {
response.headers_mut().insert(header::SET_COOKIE, value);
}
response
}
pub fn reset_admin_password_interactive(
database: &Database,
settings: &SecurityConfig,
) -> Result<()> {
let password = read_password_line("New admin password: ")?;
let confirm = read_password_line("Confirm admin password: ")?;
if password != confirm {
bail!("Passwords do not match");
}
let hash = hash_password(&password, settings)?;
database.replace_admin_password_hash(&hash)?;
println!("Admin password updated and all web sessions were cleared.");
Ok(())
}
pub fn clear_admin_auth(database: &Database) -> Result<()> {
database.clear_admin_auth()?;
println!("Admin password and all web sessions were cleared.");
println!("Open the web UI to set a new admin password.");
Ok(())
}
#[cfg(unix)]
fn read_password_line(prompt: &str) -> Result<String> {
print!("{prompt}");
io::stdout().flush()?;
let _ = std::process::Command::new("stty").arg("-echo").status();
let mut value = String::new();
let result = io::stdin().read_line(&mut value);
let _ = std::process::Command::new("stty").arg("echo").status();
println!();
result?;
Ok(value.trim_end_matches(['\r', '\n']).to_string())
}
#[cfg(not(unix))]
fn read_password_line(prompt: &str) -> Result<String> {
print!("{prompt}");
io::stdout().flush()?;
let mut value = String::new();
io::stdin().read_line(&mut value)?;
Ok(value.trim_end_matches(['\r', '\n']).to_string())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use crate::db::Database;
use crate::models::ApiResponse;
#[derive(Debug, Default, Deserialize)]
pub struct NotificationQueueQuery {
#[serde(default = "default_notification_queue_limit")]
pub limit: i64,
}
fn default_notification_queue_limit() -> i64 {
100
}
/// GET /api/notifications/queue
pub async fn get_notification_queue_handler(
Query(query): Query<NotificationQueueQuery>,
State(database): State<Arc<Database>>,
) -> (
StatusCode,
Json<ApiResponse<crate::db::NotificationQueueResponse>>,
) {
match database.get_notification_queue(query.limit) {
Ok(queue) => (
StatusCode::OK,
Json(ApiResponse::success_with_message("Success", queue)),
),
Err(err) => (
StatusCode::OK,
Json(ApiResponse::error(format!("Failed: {}", err))),
),
}
}
/// POST /api/notifications/queue/{id}/retry
pub async fn retry_notification_queue_item_handler(
Path(id): Path<i64>,
State(database): State<Arc<Database>>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match database.retry_notification_queue_item(id) {
Ok(updated) => (
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Notification queue item scheduled for retry",
json!({ "updated": updated }),
)),
),
Err(err) => (
StatusCode::OK,
Json(ApiResponse::error(format!("Failed: {}", err))),
),
}
}
/// DELETE /api/notifications/queue/{id}
pub async fn delete_notification_queue_item_handler(
Path(id): Path<i64>,
State(database): State<Arc<Database>>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match database.delete_notification_queue_item(id) {
Ok(updated) => (
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Notification queue item cancelled",
json!({ "updated": updated }),
)),
),
Err(err) => (
StatusCode::OK,
Json(ApiResponse::error(format!("Failed: {}", err))),
),
}
}
/// POST /api/notifications/queue/retry-all
pub async fn retry_all_notification_queue_handler(
State(database): State<Arc<Database>>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match database.retry_all_notification_queue_items() {
Ok(updated) => (
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Notification queue items scheduled for retry",
json!({ "updated": updated }),
)),
),
Err(err) => (
StatusCode::OK,
Json(ApiResponse::error(format!("Failed: {}", err))),
),
}
}
/// POST /api/notifications/queue/clear
pub async fn clear_notification_queue_handler(
State(database): State<Arc<Database>>,
) -> (StatusCode, Json<ApiResponse<serde_json::Value>>) {
match database.clear_active_notification_queue() {
Ok(updated) => (
StatusCode::OK,
Json(ApiResponse::success_with_message(
"Notification queue cleared",
json!({ "updated": updated }),
)),
),
Err(err) => (
StatusCode::OK,
Json(ApiResponse::error(format!("Failed: {}", err))),
),
}
}
@@ -0,0 +1,839 @@
//! OTA 更新模块
//!
//! 处理 OTA 更新包的上传、验证和应用
use crate::config::ConfigManager;
use crate::models::{
OtaLatestReleaseResponse, OtaMeta, OtaReleaseAsset, OtaStatusResponse, OtaUploadResponse,
OtaValidation, VersionUpdateEvent,
};
use crate::notification::NotificationSender;
use chrono::{DateTime, FixedOffset, NaiveTime, TimeZone, Utc};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
/// OTA 相关路径
const OTA_STAGING_DIR: &str = "/tmp/ota_staging";
const OTA_BINARY_PATH: &str = "/opt/simadmin/simadmin";
const OTA_WWW_PATH: &str = "/opt/simadmin/www";
const OTA_META_PATH: &str = "/opt/simadmin/meta.json";
const OTA_SERVICE_NAME: &str = "simadmin.service";
const NM_CONF_DIR: &str = "/etc/NetworkManager/conf.d";
const NM_CONF_PATH: &str = "/etc/NetworkManager/conf.d/99-simadmin-unmanaged-modem.conf";
const NM_UNMANAGED_WWAN_CONFIG: &str = "[keyfile]\nunmanaged-devices=interface-name:wwan*\n";
const LATEST_RELEASE_API: &str = "https://api.github.com/repos/3899/SimAdmin/releases/latest";
const BEIJING_UTC_OFFSET_SECONDS: i32 = 8 * 60 * 60;
const UPDATE_CHECK_HOURS: [u32; 2] = [9, 18];
const OTA_HTTP_TIMEOUT_SECS: u64 = 30;
const BUILTIN_PROXY_PREFIXES: [&str; 3] = [
"https://gh-proxy.com/",
"https://ghproxy.net/",
"https://githubproxy.cc/",
];
pub const MAX_OTA_BYTES: u64 = 50 * 1024 * 1024;
/// 当前版本信息(编译时注入)
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 获取当前 commit(从环境变量或默认值)
pub fn get_current_commit() -> String {
option_env!("GIT_COMMIT").unwrap_or("unknown").to_string()
}
/// 获取 OTA 更新状态
pub fn get_ota_status() -> OtaStatusResponse {
let pending_meta = read_pending_meta();
OtaStatusResponse {
current_version: CURRENT_VERSION.to_string(),
current_commit: get_current_commit(),
pending_update: pending_meta.is_some(),
pending_meta,
}
}
pub fn duration_until_next_update_check() -> Duration {
duration_until_next_update_check_from(Utc::now())
}
fn duration_until_next_update_check_from(now_utc: DateTime<Utc>) -> Duration {
let beijing = beijing_offset();
let now = now_utc.with_timezone(&beijing);
let next_check = UPDATE_CHECK_HOURS
.iter()
.filter_map(|hour| {
let check_time = NaiveTime::from_hms_opt(*hour, 0, 0)?;
beijing
.from_local_datetime(&now.date_naive().and_time(check_time))
.single()
})
.find(|check| now <= *check)
.unwrap_or_else(|| {
let check_time = NaiveTime::from_hms_opt(UPDATE_CHECK_HOURS[0], 0, 0)
.expect("valid update check time");
beijing
.from_local_datetime(
&(now.date_naive() + chrono::Duration::days(1)).and_time(check_time),
)
.single()
.expect("fixed offset has a single local time")
});
(next_check - now)
.to_std()
.unwrap_or_else(|_| Duration::from_secs(0))
}
pub fn normalize_proxy_prefix(prefix: Option<String>) -> String {
let Some(prefix) = prefix else {
return String::new();
};
let prefix = prefix.trim();
if prefix.is_empty() {
return String::new();
}
if prefix.ends_with('/') {
prefix.to_string()
} else {
format!("{}/", prefix)
}
}
pub fn build_ota_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("SimAdmin OTA updater")
.timeout(Duration::from_secs(OTA_HTTP_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
fn push_proxy_url(urls: &mut Vec<String>, proxy_prefix: &str, url: &str) {
let proxy_prefix = normalize_proxy_prefix(Some(proxy_prefix.to_string()));
if proxy_prefix.is_empty() {
return;
}
let proxied_url = format!("{}{}", proxy_prefix, url);
if !urls.iter().any(|existing| existing == &proxied_url) {
urls.push(proxied_url);
}
}
pub fn ota_request_urls(
url: &str,
proxy_prefix: &str,
include_builtin_proxies: bool,
) -> Vec<String> {
let mut urls = Vec::new();
push_proxy_url(&mut urls, proxy_prefix, url);
if include_builtin_proxies {
for builtin_proxy in BUILTIN_PROXY_PREFIXES {
push_proxy_url(&mut urls, builtin_proxy, url);
}
}
urls.push(url.to_string());
urls
}
pub fn is_supported_ota_asset(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".zip")
}
pub fn supported_release_asset(release: &OtaLatestReleaseResponse) -> Option<&OtaReleaseAsset> {
release
.assets
.iter()
.find(|asset| is_supported_ota_asset(&asset.name))
}
pub async fn fetch_latest_github_release(
client: &reqwest::Client,
proxy_prefix: &str,
include_builtin_proxies: bool,
) -> Result<OtaLatestReleaseResponse, String> {
let mut last_error = String::new();
for url in ota_request_urls(LATEST_RELEASE_API, proxy_prefix, include_builtin_proxies) {
match client
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
{
Ok(response) => {
let status = response.status();
if !status.is_success() {
last_error = format!("GitHub Releases request failed: HTTP {}", status);
continue;
}
match response.json::<OtaLatestReleaseResponse>().await {
Ok(release) => return Ok(release),
Err(e) => {
last_error = format!("Failed to parse latest release: {}", e);
continue;
}
}
}
Err(e) => {
last_error = format!("Failed to request latest release: {}", e);
}
}
}
if last_error.is_empty() {
Err("GitHub Releases request failed".to_string())
} else {
Err(last_error)
}
}
pub async fn check_and_notify_version_update(
config_manager: Arc<ConfigManager>,
notification_sender: Arc<NotificationSender>,
) -> Result<(), String> {
let update_config = config_manager.get_version_update_notifications();
if !update_config.enabled || !notification_sender.has_version_update_targets() {
return Ok(());
}
let proxy_prefix = normalize_proxy_prefix(Some(update_config.proxy_prefix));
let client = build_ota_http_client()?;
let release = fetch_latest_github_release(&client, &proxy_prefix, true).await?;
if !compare_versions(&release.tag_name, CURRENT_VERSION) {
return Ok(());
}
let notify_version = normalize_version(&release.tag_name);
if update_config
.last_notified_version
.as_deref()
.map(normalize_version)
.as_deref()
== Some(notify_version.as_str())
{
return Ok(());
}
let asset = supported_release_asset(&release)
.ok_or_else(|| "No supported OTA asset found in latest release".to_string())?;
let own_number = notification_sender.get_own_number().await;
let current_time = chrono::Utc::now().to_rfc3339();
let event = VersionUpdateEvent {
asset_name: asset.name.clone(),
version: notify_version.clone(),
build_time: current_time.clone(),
release_url: release.html_url.clone().unwrap_or_default(),
timestamp: current_time,
own_number,
};
let result = notification_sender
.forward_version_update_event(&event)
.await?;
if result.delivered {
config_manager.set_last_notified_update_version(notify_version)?;
}
if !result.errors.is_empty() {
tracing::warn!(
errors = %result.errors.join("; "),
"Version update notification partially failed"
);
}
Ok(())
}
pub async fn download_ota_asset_bytes(
client: &reqwest::Client,
proxy_prefix: &str,
include_builtin_proxies: bool,
asset: &OtaReleaseAsset,
) -> Result<Vec<u8>, String> {
if asset.size > MAX_OTA_BYTES {
return Err(format!(
"OTA asset is too large: {} bytes exceeds {} bytes",
asset.size, MAX_OTA_BYTES
));
}
let mut last_error = String::new();
for download_url in ota_request_urls(
&asset.browser_download_url,
proxy_prefix,
include_builtin_proxies,
) {
let response = match client.get(&download_url).send().await {
Ok(response) => response,
Err(e) => {
last_error = format!("Failed to download OTA asset: {}", e);
continue;
}
};
let status = response.status();
if !status.is_success() {
last_error = format!("OTA asset download failed: HTTP {}", status);
continue;
}
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(e) => {
last_error = format!("Failed to read OTA asset: {}", e);
continue;
}
};
if bytes.len() as u64 > MAX_OTA_BYTES {
last_error = format!(
"OTA asset is too large: {} bytes exceeds {} bytes",
bytes.len(),
MAX_OTA_BYTES
);
continue;
}
return Ok(bytes.to_vec());
}
if last_error.is_empty() {
Err("OTA asset download failed".to_string())
} else {
Err(last_error)
}
}
/// 读取待安装的更新元数据
fn read_pending_meta() -> Option<OtaMeta> {
let meta_path = format!("{}/meta.json", OTA_STAGING_DIR);
if let Ok(content) = fs::read_to_string(&meta_path) {
serde_json::from_str(&content).ok()
} else {
None
}
}
/// 处理上传的 OTA 包(支持 tar.gz 和 zip 格式)
pub fn handle_ota_upload(data: &[u8]) -> Result<OtaUploadResponse, String> {
// 清理并创建临时目录
let _ = fs::remove_dir_all(OTA_STAGING_DIR);
fs::create_dir_all(OTA_STAGING_DIR)
.map_err(|e| format!("Failed to create staging dir: {}", e))?;
// 自动检测文件格式
let is_zip = detect_zip_format(data);
if is_zip {
// ZIP 格式处理
let zip_path = format!("{}/update.zip", OTA_STAGING_DIR);
let mut file =
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
file.write_all(data)
.map_err(|e| format!("Failed to write zip file: {}", e))?;
// 解压 ZIP(使用 unzip 命令)
let output = Command::new("unzip")
.args(["-o", &zip_path, "-d", OTA_STAGING_DIR])
.output()
.map_err(|e| {
format!(
"Failed to extract zip: {}. Make sure 'unzip' is installed.",
e
)
})?;
if !output.status.success() {
return Err(format!(
"Failed to extract zip: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// 删除原始 zip 文件
let _ = fs::remove_file(&zip_path);
// ZIP 格式不保留 Unix 权限,需要手动设置
fix_file_permissions()?;
} else {
// TAR.GZ 格式处理(默认,保留 Unix 权限)
let tar_path = format!("{}/update.tar.gz", OTA_STAGING_DIR);
let mut file =
fs::File::create(&tar_path).map_err(|e| format!("Failed to create tar file: {}", e))?;
file.write_all(data)
.map_err(|e| format!("Failed to write tar file: {}", e))?;
// 解压 TAR.GZ
let output = Command::new("tar")
.args(["-xzf", &tar_path, "-C", OTA_STAGING_DIR])
.output()
.map_err(|e| format!("Failed to extract tar: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to extract tar: {}",
String::from_utf8_lossy(&output.stderr)
));
}
// 删除原始 tar 文件
let _ = fs::remove_file(&tar_path);
}
// 读取 meta.json
let meta_path = format!("{}/meta.json", OTA_STAGING_DIR);
let meta_content = fs::read_to_string(&meta_path)
.map_err(|_| "meta.json not found in OTA package".to_string())?;
let meta: OtaMeta =
serde_json::from_str(&meta_content).map_err(|e| format!("Invalid meta.json: {}", e))?;
// 验证
let validation = validate_ota_package(&meta)?;
Ok(OtaUploadResponse { meta, validation })
}
/// 验证 OTA 包
fn validate_ota_package(meta: &OtaMeta) -> Result<OtaValidation, String> {
let binary_path = format!("{}/simadmin", OTA_STAGING_DIR);
let www_path = format!("{}/www", OTA_STAGING_DIR);
// 检查文件存在
if !Path::new(&binary_path).exists() {
return Ok(OtaValidation {
valid: false,
is_newer: false,
binary_md5_match: false,
frontend_md5_match: false,
arch_match: false,
error: Some("Binary file not found in package".to_string()),
});
}
if !Path::new(&www_path).exists() {
return Ok(OtaValidation {
valid: false,
is_newer: false,
binary_md5_match: false,
frontend_md5_match: false,
arch_match: false,
error: Some("Frontend directory not found in package".to_string()),
});
}
// 计算二进制 MD5(严格验证)
let binary_md5 = calculate_file_md5(&binary_path)?;
let binary_md5_match = binary_md5 == meta.binary_md5;
// 前端目录存在即可(MD5 跨平台难以保持一致)
let frontend_md5_match = true; // 跳过前端 MD5 验证
// 检查架构(只接受 musl
let arch_match = meta.arch == "aarch64-unknown-linux-musl";
// 比较版本
let is_newer = compare_versions(&meta.version, CURRENT_VERSION);
// 只验证二进制 MD5 和架构
let valid = binary_md5_match && arch_match;
// 生成详细的错误信息
let error = if !valid {
let mut errors = Vec::new();
if !binary_md5_match {
errors.push(format!(
"Binary MD5 mismatch: expected={}, actual={}",
meta.binary_md5, binary_md5
));
}
if !arch_match {
errors.push(format!(
"Arch mismatch: expected=aarch64-unknown-linux-musl, actual={}",
meta.arch
));
}
Some(errors.join("; "))
} else {
None
};
Ok(OtaValidation {
valid,
is_newer,
binary_md5_match,
frontend_md5_match,
arch_match,
error,
})
}
/// 计算文件 MD5
fn calculate_file_md5(path: &str) -> Result<String, String> {
let mut file = fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Failed to read file: {}", e))?;
Ok(format!("{:x}", md5::compute(&contents)))
}
/// 比较版本号(返回 v1 > v2)
pub fn compare_versions(v1: &str, v2: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
normalize_version(v)
.split(['.', '-'])
.filter_map(|s| s.parse().ok())
.collect()
};
let v1_parts = parse(v1);
let v2_parts = parse(v2);
for i in 0..std::cmp::max(v1_parts.len(), v2_parts.len()) {
let p1 = v1_parts.get(i).unwrap_or(&0);
let p2 = v2_parts.get(i).unwrap_or(&0);
if p1 > p2 {
return true;
} else if p1 < p2 {
return false;
}
}
false
}
fn normalize_version(version: &str) -> String {
version.trim().trim_start_matches(['v', 'V']).to_string()
}
fn beijing_offset() -> FixedOffset {
FixedOffset::east_opt(BEIJING_UTC_OFFSET_SECONDS).expect("valid Beijing UTC offset")
}
/// 应用 OTA 更新
pub fn apply_ota_update(restart_now: bool) -> Result<String, String> {
let meta = read_pending_meta().ok_or_else(|| "No pending update".to_string())?;
let staging_binary = format!("{}/simadmin", OTA_STAGING_DIR);
let staging_www = format!("{}/www", OTA_STAGING_DIR);
install_binary_atomic(&staging_binary, OTA_BINARY_PATH)?;
// 复制前端文件(删除旧目录,复制新目录)
let _ = fs::remove_dir_all(OTA_WWW_PATH);
copy_dir_recursive(&staging_www, OTA_WWW_PATH)?;
chmod_www_tree(OTA_WWW_PATH)?;
install_meta_file()?;
let nm_result = configure_networkmanager_modem_unmanaged(restart_now);
// 清理暂存目录
let _ = fs::remove_dir_all(OTA_STAGING_DIR);
let message = format!(
"Update to version {} applied successfully; {}",
meta.version, nm_result
);
if restart_now {
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(1));
restart_service_no_block();
});
}
Ok(message)
}
fn configure_networkmanager_modem_unmanaged(restart_now: bool) -> String {
if !Path::new("/etc/NetworkManager").exists() {
return "NetworkManager not installed, unmanaged modem config skipped".to_string();
}
if let Ok(content) = fs::read_to_string(NM_CONF_PATH) {
if content == NM_UNMANAGED_WWAN_CONFIG {
return "NetworkManager already ignores wwan*".to_string();
}
}
if let Err(err) = fs::create_dir_all(NM_CONF_DIR) {
return format!("Failed to create NetworkManager conf.d: {err}");
}
if let Err(err) = fs::write(NM_CONF_PATH, NM_UNMANAGED_WWAN_CONFIG) {
return format!("Failed to write NetworkManager unmanaged modem config: {err}");
}
if !restart_now {
return "NetworkManager config written; service restart deferred".to_string();
}
match Command::new("systemctl")
.args(["is-active", "--quiet", "NetworkManager.service"])
.status()
{
Ok(status) if status.success() => {
match Command::new("systemctl")
.args(["restart", "NetworkManager.service"])
.output()
{
Ok(output) if output.status.success() => {
"NetworkManager configured to ignore wwan*, service restarted".to_string()
}
Ok(output) => format!(
"NetworkManager config written, restart failed: {}",
String::from_utf8_lossy(&output.stderr)
),
Err(err) => format!("NetworkManager config written, restart failed: {err}"),
}
}
_ => "NetworkManager configured to ignore wwan*, service not active".to_string(),
}
}
fn install_binary_atomic(src: &str, dst: &str) -> Result<(), String> {
let dst_path = Path::new(dst);
let dst_dir = dst_path
.parent()
.ok_or_else(|| format!("Invalid binary path: {}", dst))?;
fs::create_dir_all(dst_dir).map_err(|e| format!("Failed to create binary dir: {}", e))?;
let tmp_path = ota_temp_binary_path(dst_path);
let _ = fs::remove_file(&tmp_path);
fs::copy(src, &tmp_path).map_err(|e| format!("Failed to copy binary: {}", e))?;
let chmod_output = Command::new("chmod")
.arg("755")
.arg(&tmp_path)
.output()
.map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to chmod: {}", e)
})?;
if !chmod_output.status.success() {
let _ = fs::remove_file(&tmp_path);
return Err(format!(
"Failed to chmod: {}",
String::from_utf8_lossy(&chmod_output.stderr)
));
}
fs::rename(&tmp_path, dst).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
format!("Failed to replace binary: {}", e)
})?;
Ok(())
}
fn ota_temp_binary_path(dst: &Path) -> PathBuf {
let file_name = dst
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("simadmin");
dst.with_file_name(format!(".{}.ota-new", file_name))
}
fn chmod_www_tree(path: &str) -> Result<(), String> {
let output = Command::new("chmod")
.args(["-R", "a+rX", path])
.output()
.map_err(|e| format!("Failed to chmod www: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to chmod www: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
fn install_meta_file() -> Result<(), String> {
let staging_meta = format!("{}/meta.json", OTA_STAGING_DIR);
if !Path::new(&staging_meta).exists() {
return Ok(());
}
fs::copy(&staging_meta, OTA_META_PATH)
.map_err(|e| format!("Failed to copy meta.json: {}", e))?;
let output = Command::new("chmod")
.args(["644", OTA_META_PATH])
.output()
.map_err(|e| format!("Failed to chmod meta.json: {}", e))?;
if !output.status.success() {
return Err(format!(
"Failed to chmod meta.json: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
fn restart_service_no_block() {
let _ = Command::new("systemctl")
.args(["--no-block", "restart", OTA_SERVICE_NAME])
.output();
}
/// 递归复制目录
fn copy_dir_recursive(src: &str, dst: &str) -> Result<(), String> {
fs::create_dir_all(dst).map_err(|e| format!("Failed to create dir: {}", e))?;
let entries = fs::read_dir(src).map_err(|e| format!("Failed to read src dir: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let src_path = entry.path();
let dst_path = Path::new(dst).join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(
src_path.to_str().unwrap_or(""),
dst_path.to_str().unwrap_or(""),
)?;
} else {
fs::copy(&src_path, &dst_path).map_err(|e| format!("Failed to copy file: {}", e))?;
}
}
Ok(())
}
/// 取消待安装的更新
pub fn cancel_pending_update() -> Result<(), String> {
if Path::new(OTA_STAGING_DIR).exists() {
fs::remove_dir_all(OTA_STAGING_DIR)
.map_err(|e| format!("Failed to remove staging dir: {}", e))?;
}
Ok(())
}
/// 检测文件是否为 ZIP 格式(通过魔术字节)
fn detect_zip_format(data: &[u8]) -> bool {
// ZIP 文件魔术字节: PK\x03\x04 (0x504B0304)
// TAR.GZ 文件魔术字节: \x1f\x8b (gzip header)
if data.len() < 4 {
return false;
}
// 检查是否是 ZIP 格式
data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compares_release_tag_versions() {
assert!(compare_versions("v1.0.4", "1.0.3"));
assert!(!compare_versions("v1.0.3", "1.0.3"));
assert!(!compare_versions("v1.0.2", "1.0.3"));
}
#[test]
fn schedules_next_update_check_at_nine_and_eighteen_beijing_time() {
let before_nine = Utc.with_ymd_and_hms(2026, 5, 15, 0, 59, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(before_nine),
Duration::from_secs(60)
);
let before_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 9, 59, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(before_eighteen),
Duration::from_secs(60)
);
let after_eighteen = Utc.with_ymd_and_hms(2026, 5, 15, 10, 1, 0).unwrap();
assert_eq!(
duration_until_next_update_check_from(after_eighteen),
Duration::from_secs(14 * 60 * 60 + 59 * 60)
);
}
#[test]
fn ota_request_urls_prefer_configured_proxy_then_builtin_then_direct() {
assert_eq!(
ota_request_urls("https://example.com/release", "https://proxy.local/", true),
vec![
"https://proxy.local/https://example.com/release".to_string(),
"https://gh-proxy.com/https://example.com/release".to_string(),
"https://ghproxy.net/https://example.com/release".to_string(),
"https://githubproxy.cc/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
assert_eq!(
ota_request_urls("https://example.com/release", "https://gh-proxy.com", true),
vec![
"https://gh-proxy.com/https://example.com/release".to_string(),
"https://ghproxy.net/https://example.com/release".to_string(),
"https://githubproxy.cc/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
}
#[test]
fn ota_request_urls_can_skip_builtin_proxies() {
assert_eq!(
ota_request_urls("https://example.com/release", "", false),
vec!["https://example.com/release".to_string()]
);
assert_eq!(
ota_request_urls("https://example.com/release", "https://proxy.local", false),
vec![
"https://proxy.local/https://example.com/release".to_string(),
"https://example.com/release".to_string()
]
);
}
}
/// 修复文件权限(用于 ZIP 解压后)
fn fix_file_permissions() -> Result<(), String> {
let binary_path = format!("{}/simadmin", OTA_STAGING_DIR);
let www_path = format!("{}/www", OTA_STAGING_DIR);
// 设置二进制文件权限为 755(可执行)
if Path::new(&binary_path).exists() {
Command::new("chmod")
.args(["755", &binary_path])
.output()
.map_err(|e| format!("Failed to chmod binary: {}", e))?;
}
// 设置前端文件权限:目录 755,文件 644
if Path::new(&www_path).exists() {
// 所有目录设置为 755
let _ = Command::new("find")
.args([&www_path, "-type", "d", "-exec", "chmod", "755", "{}", "+"])
.output();
// 所有文件设置为 644
let _ = Command::new("find")
.args([&www_path, "-type", "f", "-exec", "chmod", "644", "{}", "+"])
.output();
}
Ok(())
}
@@ -0,0 +1,16 @@
meta {
name: clear_call_history
type: http
seq: 3
}
post {
url: http://192.168.68.1:3000/api/call/history/clear
body: none
auth: none
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: delete_call_history
type: http
seq: 2
}
delete {
url: http://192.168.68.1:3000/api/call/history/1
body: none
auth: none
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_airplane_mode
type: http
seq: 17
}
get {
url: http://192.168.68.1:3000/api/airplane-mode
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get APN List
type: http
seq: 80
}
get {
url: http://192.168.68.1:3000/api/apn
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_band_lock
type: http
seq: 28
}
get {
url: http://192.168.68.1:3000/api/band-lock
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Call Forwarding
type: http
seq: 211
}
get {
url: http://192.168.68.1:3000/api/call/forwarding
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_call_history
type: http
seq: 1
}
get {
url: http://192.168.68.1:3000/api/call/history?limit=50&offset=0
body: none
auth: none
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Call Settings
type: http
seq: 213
}
get {
url: http://192.168.68.1:3000/api/call/settings
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Call Volume
type: http
seq: 204
}
get {
url: http://192.168.68.1:3000/api/call/volume
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Calls
type: http
seq: 100
}
get {
url: http://192.168.68.1:3000/api/calls
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_cell_location_info
type: http
seq: 25
}
get {
url: http://192.168.68.1:3000/api/location/cell-info
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,20 @@
meta {
name: 获取小区锁定状态
type: http
seq: 70
}
get {
url: http://192.168.68.1:3000/api/cell-lock
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_cells_info
type: http
seq: 1
}
get {
url: http://192.168.68.1:3000/api/cells
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,41 @@
meta {
name: get_connectivity
type: http
seq: 1
}
get {
url: {{base_url}}/api/connectivity
body: none
auth: none
}
docs {
# 联网检测
通过 ping 检测 IPv4 和 IPv6 连通性。
## 响应示例
```json
{
"status": "ok",
"message": "Connectivity check completed",
"data": {
"ipv4": {
"success": true,
"latency_ms": 12.5,
"target": "223.5.5.5",
"error": null
},
"ipv6": {
"success": true,
"latency_ms": 15.2,
"target": "2400:3200::1",
"error": null
}
}
}
```
}
@@ -0,0 +1,17 @@
meta {
name: get_cpu_info
type: http
seq: 23
}
get {
url: http://192.168.68.1:3000/api/stats/cpu
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_data_status
type: http
seq: 8
}
get {
url: http://192.168.68.1:3000/api/data
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_device_ddns_config
type: http
seq: 80
}
get {
url: http://192.168.68.1:3000/api/device-network/ddns/config
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_device_ddns_logs
type: http
seq: 84
}
get {
url: http://192.168.68.1:3000/api/device-network/ddns/logs
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_device_ddns_status
type: http
seq: 82
}
get {
url: http://192.168.68.1:3000/api/device-network/ddns/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_device_info
type: http
seq: 3
}
get {
url: http://192.168.68.1:3000/api/device
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_device_wlan_profiles
type: http
seq: 92
}
get {
url: http://192.168.68.1:3000/api/device-network/wlan/profiles
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_device_wlan_status
type: http
seq: 85
}
get {
url: http://192.168.68.1:3000/api/device-network/wlan/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_health
type: http
seq: 16
}
get {
url: http://192.168.68.1:3000/api/health
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get IMS Status
type: http
seq: 203
}
get {
url: http://192.168.68.1:3000/api/ims/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_network_info
type: http
seq: 5
}
get {
url: http://192.168.68.1:3000/api/network
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_network_interfaces
type: http
seq: 26
}
get {
url: http://192.168.68.1:3000/api/network/interfaces
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,15 @@
meta {
name: get_notification_config
type: http
seq: 1
}
get {
url: http://192.168.68.1:3000/api/notifications/config
body: none
auth: none
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Operators
type: http
seq: 207
}
get {
url: http://192.168.68.1:3000/api/network/operators
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,26 @@
meta {
name: Get OTA Status
type: http
seq: 200
}
get {
url: http://192.168.68.1:3000/api/ota/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}
docs {
获取 OTA 更新状态
返回信息:
- current_version: 当前运行版本
- current_commit: 当前 Git commit
- pending_update: 是否有待安装的更新
- pending_meta: 待安装更新的元数据(如果有)
}
@@ -0,0 +1,17 @@
meta {
name: get_radio_mode
type: http
seq: 24
}
get {
url: http://192.168.68.1:3000/api/radio-mode
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_roaming_status
type: http
seq: 10
}
get {
url: http://192.168.68.1:3000/api/roaming
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Signal Strength
type: http
seq: 201
}
get {
url: http://192.168.68.1:3000/api/network/signal-strength
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: get_sim_info
type: http
seq: 4
}
get {
url: http://192.168.68.1:3000/api/sim
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get SMS List
type: http
seq: 111
}
get {
url: http://192.168.68.1:3000/api/sms/list?limit=50&offset=0
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get SMS Stats
type: http
seq: 112
}
get {
url: http://192.168.68.1:3000/api/sms/stats
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,17 @@
meta {
name: get_stats
type: http
seq: 24
}
get {
url: http://192.168.68.1:3000/api/stats
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Get Voicemail Status
type: http
seq: 206
}
get {
url: http://192.168.68.1:3000/api/voicemail/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: Answer Call
type: http
seq: 104
}
post {
url: http://192.168.68.1:3000/api/call/answer
body: json
auth: inherit
}
body:json {
{
"path": "/ril_0/voicecall01"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: Dial Call
type: http
seq: 101
}
post {
url: http://192.168.68.1:3000/api/call/dial
body: json
auth: inherit
}
body:json {
{
"phone_number": "10086"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: Hangup Call
type: http
seq: 102
}
post {
url: http://192.168.68.1:3000/api/call/hangup
body: json
auth: inherit
}
body:json {
{
"path": "/ril_0/voicecall01"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Hangup All Calls
type: http
seq: 103
}
post {
url: http://192.168.68.1:3000/api/call/hangup-all
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,19 @@
meta {
name: post_device_ddns_logs_clear
type: http
seq: 91
}
post {
url: http://192.168.68.1:3000/api/device-network/ddns/logs/clear
body: json
auth: inherit
}
body:json {
{}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,19 @@
meta {
name: post_device_ddns_sync
type: http
seq: 83
}
post {
url: http://192.168.68.1:3000/api/device-network/ddns/sync
body: json
auth: inherit
}
body:json {
{}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: post_device_wlan_connect
type: http
seq: 87
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/connect
body: json
auth: inherit
}
body:json {
{
"ssid": "Your-WiFi",
"password": "your-password",
"auto_join": true
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,19 @@
meta {
name: post_device_wlan_disconnect
type: http
seq: 89
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/disconnect
body: json
auth: inherit
}
body:json {
{}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: post_device_wlan_forget
type: http
seq: 93
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/forget
body: json
auth: inherit
}
body:json {
{
"uuid": "saved-wifi-connection-uuid",
"connection_id": "Saved WiFi"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: post_device_wlan_profile
type: http
seq: 90
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/profile
body: json
auth: inherit
}
body:json {
{
"connection_id": "Your-WiFi",
"auto_join": true,
"ipv4_mode": "dhcp"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,19 @@
meta {
name: post_device_wlan_scan
type: http
seq: 86
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/scan
body: json
auth: inherit
}
body:json {
{}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,34 @@
meta {
name: Apply OTA Update
type: http
seq: 202
}
post {
url: http://192.168.68.1:3000/api/ota/apply
body: json
auth: inherit
}
body:json {
{
"restart_now": false
}
}
settings {
encodeUrl: true
}
docs {
应用已上传的 OTA 更新
参数:
- restart_now: 是否立即重启服务(true/false)
注意:
- 更新前必须先上传 OTA 包(/api/ota/upload
- restart_now=true 时,服务会在 1 秒后自动重启
- restart_now=false 时,需要手动重启服务才能生效
}
@@ -0,0 +1,34 @@
meta {
name: Apply OTA Update (with restart)
type: http
seq: 203
}
post {
url: http://192.168.68.1:3000/api/ota/apply
body: json
auth: inherit
}
body:json {
{
"restart_now": true
}
}
settings {
encodeUrl: true
}
docs {
应用已上传的 OTA 更新并立即重启服务
参数:
- restart_now: true(立即重启)
警告:
- 执行此操作后,服务会在 1 秒后自动重启
- 请确保已上传有效的 OTA 包
- 重启期间所有连接会断开
}
@@ -0,0 +1,29 @@
meta {
name: Cancel Pending OTA Update
type: http
seq: 204
}
post {
url: http://192.168.68.1:3000/api/ota/cancel
body: none
auth: inherit
}
settings {
encodeUrl: true
}
docs {
取消待安装的 OTA 更新
操作:
- 清除暂存目录中的 OTA 包
- 删除待安装的元数据
使用场景:
- 上传了错误的 OTA 包
- 验证失败后需要重新上传
- 决定不进行更新
}
@@ -0,0 +1,16 @@
meta {
name: Clear All SMS
type: http
seq: 113
}
post {
url: http://192.168.68.1:3000/api/sms/clear
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: Send SMS
type: http
seq: 110
}
post {
url: http://192.168.68.1:3000/api/sms/send
body: json
auth: inherit
}
body:json {
{
"phone_number": "10086",
"content": "测试短信"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: post_system_reboot
type: http
seq: 32
}
post {
url: http://192.168.68.1:3000/api/system/reboot
body: json
auth: inherit
}
body:json {
{
"delay_seconds": 3
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Register Operator Auto
type: http
seq: 210
}
post {
url: http://192.168.68.1:3000/api/network/register-auto
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: Register Operator Manual
type: http
seq: 209
}
post {
url: http://192.168.68.1:3000/api/network/register-manual
body: json
auth: inherit
}
body:json {
{
"mccmnc": "46001"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,16 @@
meta {
name: Scan Operators (Slow)
type: http
seq: 208
}
get {
url: http://192.168.68.1:3000/api/network/operators/scan
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_airplane_mode_disable
type: http
seq: 19
}
post {
url: http://192.168.68.1:3000/api/airplane-mode
body: json
auth: inherit
}
body:json {
{
"enabled": false
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_airplane_mode_enable
type: http
seq: 18
}
post {
url: http://192.168.68.1:3000/api/airplane-mode
body: json
auth: inherit
}
body:json {
{
"enabled": true
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,27 @@
meta {
name: Set APN Configuration
type: http
seq: 81
}
post {
url: http://192.168.68.1:3000/api/apn
body: json
auth: inherit
}
body:json {
{
"context_path": "/ril_0/context2",
"apn": "cbnet",
"protocol": "dual",
"username": "",
"password": "",
"auth_method": "chap"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,26 @@
meta {
name: set_band_lock_lte_b1_b3
type: http
seq: 29
}
post {
url: http://192.168.68.1:3000/api/band-lock
body: json
auth: inherit
}
body:json {
{
"lte_fdd_bands": [1, 3],
"lte_tdd_bands": [],
"nr_fdd_bands": [],
"nr_tdd_bands": []
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,26 @@
meta {
name: set_band_lock_lte_nr_mix
type: http
seq: 31
}
post {
url: http://192.168.68.1:3000/api/band-lock
body: json
auth: inherit
}
body:json {
{
"lte_fdd_bands": [1, 3, 8],
"lte_tdd_bands": [38, 40, 41],
"nr_fdd_bands": [1, 28],
"nr_tdd_bands": [41, 77, 78, 79]
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,26 @@
meta {
name: set_band_lock_nr_n78
type: http
seq: 30
}
post {
url: http://192.168.68.1:3000/api/band-lock
body: json
auth: inherit
}
body:json {
{
"lte_fdd_bands": [],
"lte_tdd_bands": [],
"nr_fdd_bands": [],
"nr_tdd_bands": [78]
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: Set Call Forwarding
type: http
seq: 212
}
post {
url: http://192.168.68.1:3000/api/call/forwarding
body: json
auth: inherit
}
body:json {
{
"forward_type": "unconditional",
"number": "10086"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: Set Call Settings
type: http
seq: 214
}
post {
url: http://192.168.68.1:3000/api/call/settings
body: json
auth: inherit
}
body:json {
{
"property": "HideCallerId",
"value": "default"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,24 @@
meta {
name: Set Call Volume
type: http
seq: 205
}
post {
url: http://192.168.68.1:3000/api/call/volume
body: json
auth: inherit
}
body:json {
{
"speaker_volume": 50,
"microphone_volume": 50,
"muted": false
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,29 @@
meta {
name: 锁定LTE小区
type: http
seq: 72
}
post {
url: http://192.168.68.1:3000/api/cell-lock
body: json
auth: inherit
}
body:json {
{
"rat": 12,
"enable": true,
"arfcn": 100,
"pci": 100
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,29 @@
meta {
name: 锁定NR小区
type: http
seq: 71
}
post {
url: http://192.168.68.1:3000/api/cell-lock
body: json
auth: inherit
}
body:json {
{
"rat": 16,
"enable": true,
"arfcn": 633984,
"pci": 599
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: set_data_status_disable
type: http
seq: 10
}
post {
url: http://192.168.68.1:3000/api/data
body: json
auth: inherit
}
body:json {
{
"active": false
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,22 @@
meta {
name: set_data_status_enable
type: http
seq: 9
}
post {
url: http://192.168.68.1:3000/api/data
body: json
auth: inherit
}
body:json {
{
"active": true
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,54 @@
meta {
name: set_device_ddns_config
type: http
seq: 81
}
post {
url: http://192.168.68.1:3000/api/device-network/ddns/config
body: json
auth: inherit
}
body:json {
{
"enabled": true,
"provider": "cloudflare",
"access_id": "your-cloudflare-zone-id",
"access_secret": "your-cloudflare-api-token",
"interval_seconds": 300,
"ttl": 600,
"ipv4": {
"enabled": true,
"get_type": "interface",
"interface_name": "eth0",
"urls": [
"https://api.ipify.org",
"https://ip.3322.net",
"https://4.ident.me",
"https://ddns.oray.com/checkip",
"https://4.ipw.cn"
],
"domains": [
"home.example.com"
]
},
"ipv6": {
"enabled": false,
"get_type": "interface",
"interface_name": "wlan0",
"urls": [
"https://api6.ipify.org",
"https://speed.neu6.edu.cn/getIP.php",
"https://v6.ident.me",
"https://myip6.ipip.net",
"https://6.ipw.cn"
],
"domains": []
}
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,21 @@
meta {
name: set_device_wlan_enabled
type: http
seq: 88
}
post {
url: http://192.168.68.1:3000/api/device-network/wlan/enabled
body: json
auth: inherit
}
body:json {
{
"enabled": true
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,44 @@
meta {
name: set_notification_config
type: http
seq: 2
}
post {
url: http://192.168.68.1:3000/api/notifications/config
body: json
auth: none
}
body:json {
{
"webhook": {
"enabled": true,
"url": "https://example.com/webhook",
"forward_sms": true,
"forward_calls": true,
"forward_ddns": true,
"headers": {
"Authorization": "Bearer your-token"
},
"secret": "your-secret-key"
},
"bark": {
"enabled": false,
"forward_sms": true,
"forward_calls": true,
"forward_ddns": true,
"server_url": "https://api.day.app",
"device_key": "",
"group": "",
"sound": "",
"level": "",
"auto_copy": true,
"save_history": true
}
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_radio_mode_auto
type: http
seq: 25
}
post {
url: http://192.168.68.1:3000/api/radio-mode
body: json
auth: inherit
}
body:json {
{
"mode": "auto"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_radio_mode_lte
type: http
seq: 26
}
post {
url: http://192.168.68.1:3000/api/radio-mode
body: json
auth: inherit
}
body:json {
{
"mode": "lte"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_radio_mode_nr
type: http
seq: 27
}
post {
url: http://192.168.68.1:3000/api/radio-mode
body: json
auth: inherit
}
body:json {
{
"mode": "nr"
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_roaming_disable
type: http
seq: 12
}
post {
url: http://192.168.68.1:3000/api/roaming
body: json
auth: inherit
}
body:json {
{
"allowed": false
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,23 @@
meta {
name: set_roaming_enable
type: http
seq: 11
}
post {
url: http://192.168.68.1:3000/api/roaming
body: json
auth: inherit
}
body:json {
{
"allowed": true
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,35 @@
meta {
name: test_notification_channel
type: http
seq: 3
}
post {
url: http://192.168.68.1:3000/api/notifications/test/{{channel}}
body: none
auth: none
}
vars:pre-request {
channel: webhook
}
docs {
# 测试指定通知渠道
`channel` 是通知渠道参数,不是独立接口名称。
可用值:
- `webhook`
- `bark`
- `wecom_app`
- `wecom_robot`
- `dingtalk_robot`
- `dingtalk_app`
- `feishu_robot`
- `telegram`
}
settings {
encodeUrl: true
}
@@ -0,0 +1,26 @@
meta {
name: unlock_all_bands
type: http
seq: 32
}
post {
url: http://192.168.68.1:3000/api/band-lock
body: json
auth: inherit
}
body:json {
{
"lte_fdd_bands": [],
"lte_tdd_bands": [],
"nr_fdd_bands": [],
"nr_tdd_bands": []
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,24 @@
meta {
name: 解锁所有小区
type: http
seq: 74
}
post {
url: http://192.168.68.1:3000/api/cell-lock/unlock-all
body: json
auth: inherit
}
body:json {
{}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,27 @@
meta {
name: 解锁NR小区
type: http
seq: 73
}
post {
url: http://192.168.68.1:3000/api/cell-lock
body: json
auth: inherit
}
body:json {
{
"rat": 16,
"enable": false
}
}
settings {
encodeUrl: true
}
@@ -0,0 +1,99 @@
{
"upstreamCommit": "58e220411d6599609f0eeda01eb7016e9212f970",
"snapshotFormat": 1,
"brunoFileCount": 82,
"files": {
"backend/src/auth.rs": "c4960d75b4682c2ed8a0e4d860a2e87cd646cd77503ec2066995a04366418c6d",
"backend/src/config.rs": "fc998fa3ef24176d24125e7b4f5bd2aca2a9762d3cd16aa1f7db85608fe9979f",
"backend/src/device_network.rs": "3958c02be887b4f1c3414058299c32a31ed2f225c3020b6eb5c8a807fcd2c418",
"backend/src/esim.rs": "5f11e1a571c122e9809ae096dec27cb01ea62606facae9ae982f6a68af0a9776",
"backend/src/handlers.rs": "de42e03e41ab8da09aa1085d48f2337ebf5647db9c609e80dd12b32f34510376",
"backend/src/main.rs": "7eea98f36212a6174e94e316e6e319bff0fbf090d040983b73aa7aa9eb863c8e",
"backend/src/models.rs": "3b251f7416a7287afaf90776fa8cc7d0893d9b8ed6a2dfd6375f9c05da140090",
"backend/src/notification_queue.rs": "887ce67f4d5fb6edb6c5a404552048eaf2885fa7bc8be6d7a07c03f73c4431e1",
"backend/src/ota.rs": "f6f6b08f4bfad36f5262d72a7fba314517af0b76a1ade30006499c0c550f8f58",
"bruno-api/clear_call_history.bru": "f98e6c4af63aa72d5e2e0319df679d79f675cdfab8d08ea4fb894eac59d309a6",
"bruno-api/delete_call_history.bru": "763cac4ca664907a01e05f1d555360d5305b96ef8c5031ceca1958bde9933e32",
"bruno-api/get_airplane_mode.bru": "c435d579ae66e3249903803f42d81260fbb618d6255734c96e977fa1041de5c0",
"bruno-api/get_apn_list.bru": "34db7076861a5ad7fa5867fd042b0840e4800d4ed08f6b9276f254061a0c156d",
"bruno-api/get_band_lock.bru": "30327d7b32227339c204ac18d442ee02badfd19862f2215d8c2310045c2403ae",
"bruno-api/get_call_forwarding.bru": "f7da262de38036743e533799fbfa967c839b3f963339797be5f61ab613365f0b",
"bruno-api/get_call_history.bru": "36f7914da5a334d9aed020c2ccb8a586f580fe4389d1e5c456e3a9b58124c804",
"bruno-api/get_call_settings.bru": "9004fd78efcb144b1970da5634790031aecb9ddbb22d66476898f57b45ae26eb",
"bruno-api/get_call_volume.bru": "cbd14454ba165decbedcd1276b06126f7393838b189d338a6a4c80e395001ada",
"bruno-api/get_calls.bru": "2ad92484d7d3d4fa3ba1cfbfd715508a14ba9f841fe269175a2d1d7b9d0c1a48",
"bruno-api/get_cell_location_info.bru": "c17ffd72a6a7fc522be75f281d9986eb6685955d12af2c04c4a2372fd44d99ce",
"bruno-api/get_cell_lock.bru": "f2ad3efc9a9ac7e71a6acdb61c436a9bea47579dc876c434bad5ef03472083d1",
"bruno-api/get_cells_info.bru": "f87ee9c5b94005ea41c016204224f98763301670e4eb84b086929b9b48ab4387",
"bruno-api/get_connectivity.bru": "190b0e79b3402fcf9717de03d06da2adf57f47dae90c94cbb160bc4ae095594b",
"bruno-api/get_cpu_info.bru": "fa2d2e59dd04960de571c168e51248dc07a9fa7eabb05375a96788a496e52341",
"bruno-api/get_data_status.bru": "5197a60ce25149c9f8bfff6e5586f43c2d45ebcd13c96147c910bb927aeb50fb",
"bruno-api/get_device_ddns_config.bru": "b502ec49b25e4072050af240fde192f18e7f8f611ac3e39aaabdfa526b4502a1",
"bruno-api/get_device_ddns_logs.bru": "611ca74ebfd45545fec0248d08676a299eacc1e1a3f22ba260e3264b55509d0f",
"bruno-api/get_device_ddns_status.bru": "035f8633fe531fc4aff7c658730825bffc359a3b4d59e40d6517c83bf733d27f",
"bruno-api/get_device_info.bru": "49f4be82ef91cb1e479e204f5cab09a5aaab9f66c6866c5f202727f52a9d9d90",
"bruno-api/get_device_wlan_profiles.bru": "eb273643295a3cd200d262261862c0292cb856aed7164d7eb9ecd4d1931a7b3a",
"bruno-api/get_device_wlan_status.bru": "2559811eac9e308978417b9ba87ea195b299e4cd5b97dcd5bf50e2e38804c374",
"bruno-api/get_health.bru": "33c5816397b169f3e39cb12a216449fc73a0e88885641d94b643d1097a2973a7",
"bruno-api/get_ims_status.bru": "79f3e333ca6640a2a105eb47c0f7bbd06c73138afb7477d205192504d398c5e8",
"bruno-api/get_network_info.bru": "73e436031af6c79fe5a64d91d3b3853856a3d07d253cbb6ecee333e37ddcf8d9",
"bruno-api/get_network_interfaces.bru": "bbcb3f63fc23079cbbf31e3a02177027aa54d8a82e29da454ffa597be8056c06",
"bruno-api/get_notification_config.bru": "30c9ab47a63c4771f2cdb6efa9c4233ccaae3188b66c514ce69c5cf989944699",
"bruno-api/get_operators.bru": "64919ee811f5145a97748432cf388a837ad59a3d17156344175a0cf636f9bb7d",
"bruno-api/get_ota_status.bru": "6d63ec016942d191f3968c0614f5aff9d6ed88646e1a4ec53916d78161effd9a",
"bruno-api/get_radio_mode.bru": "eedd238adbde3989dbaf38b47fba3c76238528bb949ed62e5f70f2cac1b483b8",
"bruno-api/get_roaming_status.bru": "3f7bf6a0e4460a57f202a542b50cc3f8c634f89ffe8d7fb847fb6c0761e612f7",
"bruno-api/get_signal_strength.bru": "1720aaeb6f522af0bb4cbc0bf06b97b5ce6e06c935e827393ef13be8bc0d74ed",
"bruno-api/get_sim_info.bru": "94055040ad6be660960a06bebf38c1939545d95efe6415f036c149593ca238a2",
"bruno-api/get_sms_list.bru": "9c32941a92378832fbff323700a6538406f957adda0c59f92644015d0d5af2c9",
"bruno-api/get_sms_stats.bru": "b77d0ae2f716c95a7181525fa9629096aa3cda54fca2a1c924aeaa85edafaa51",
"bruno-api/get_stats.bru": "79647d3e56ae9246b02d18e1d421187b18098f14167a73e7145ccb8a70565b1d",
"bruno-api/get_voicemail_status.bru": "9d39b6ed21f99a36332260f6d9d175c8f691a66769687742497932f17281b4df",
"bruno-api/post_call_answer.bru": "dfe0376c35f04ba3f02a606e0e8c4d022118ef16602ca9c9d535b65443806bda",
"bruno-api/post_call_dial.bru": "3353df9e6937b3e52b897c29186ac26a653eab45b7a534a3dbbd66fc1bc06c4c",
"bruno-api/post_call_hangup.bru": "8370cccedb7640bf2e85b37fdbb43cc3409e4ae36c62bfa570fc9891b1a10203",
"bruno-api/post_call_hangup_all.bru": "97c3f186878394b900ec301d7be5133d30a2b98f3dc6cd611812bbba34a173b5",
"bruno-api/post_device_ddns_logs_clear.bru": "8ed8893ce7057bc9743e0ff4a77d40a3febebe63edd6d153007caba61f61f500",
"bruno-api/post_device_ddns_sync.bru": "affa36b0a3065e9900f1bfb24cb4c0a9d4c2fecefcd3d6d247df97ded9c9b192",
"bruno-api/post_device_wlan_connect.bru": "034295206a93396cfd9d4fc3eaaef8487aff3239ed0e78f2ab1eb397aa25bcc8",
"bruno-api/post_device_wlan_disconnect.bru": "cbcd0cea2dac83e31015f80ba433284b5776925ca69c7a9157e665b1abea9cdd",
"bruno-api/post_device_wlan_forget.bru": "76bc0db3cdacc3b86aa9c3b10439bac82fe6f58508b0ca3ce1d47e532569a5f7",
"bruno-api/post_device_wlan_profile.bru": "23874c871624f4997c48901b0ef1235f4fddeb0f5c1a51d718f1d216899a24d7",
"bruno-api/post_device_wlan_scan.bru": "0b12b8facb3991ddb90dcb2b4fef2972dcc23b7c8964c8b2c0038b0bd87b7000",
"bruno-api/post_ota_apply.bru": "f33f0bb193b8b6c0c5ae4cb12699f9392b60cc87ddd2f8ef90e2f5792d7a5194",
"bruno-api/post_ota_apply_restart.bru": "b45db76c1e1ada242011cc59f1bd1b616f1222043bc965087fe113def7214822",
"bruno-api/post_ota_cancel.bru": "b841db31a94e82f02204913760e5c1d0f7f6d6d9b2e0fff611cd5a405881ebaf",
"bruno-api/post_sms_clear.bru": "63b3d8f1d820e07897022cc1ec1b1b0f9c7312f81bd7cf33e593bbf3a38b359f",
"bruno-api/post_sms_send.bru": "50e94481363de8bbe35059730598c7fba5bea7649934a768dedce32637e1bd59",
"bruno-api/post_system_reboot.bru": "573dd9a8576abc7b9f60186f3e99f50ead8c180296ca3613f0fc1040174a4060",
"bruno-api/register_operator_auto.bru": "cd001ae45bd4d76a7733ec389a9aa15c27ca422f6f77c831509ce199f1f734f2",
"bruno-api/register_operator_manual.bru": "304e2cad15cea6a4004929ffcfc2f68d7953c7728a795fe51ef9b725db1318bd",
"bruno-api/scan_operators.bru": "bb337e21438c510697408fe35beb3a81d50c756c1b47ac4cdf7e9af50cdbdbfd",
"bruno-api/set_airplane_mode_disable.bru": "355f52ec18659e67cb71c5a471684fd6c588f903bd05e371e4c01dbcb4ceba3c",
"bruno-api/set_airplane_mode_enable.bru": "c3348f90354b5d624e4fd6ebf57b03462fc733ab80a2e6b8d62821711c314d57",
"bruno-api/set_apn.bru": "77c83e57b7ac9768dc85f68a0f984e7f09f6f626309437d1c496d0fab3fb1522",
"bruno-api/set_band_lock_lte_b1_b3.bru": "ce537a191a3e282953de864b8586bc15977a924eca267551aff2ef28ca032ab9",
"bruno-api/set_band_lock_lte_nr_mix.bru": "948a6253cfe3de0007ffdf5fdc25a7a0bab107059246c203ff81a3cf6b122b7d",
"bruno-api/set_band_lock_nr_n78.bru": "36b3be9a90f501b464954926517371b81af90bdc87f7db1ce8b25bfeee7fe9f2",
"bruno-api/set_call_forwarding.bru": "e8dd8f308e9e6be64469c2c4658a905c23cf0d12b681f5e1a48793dcc26a40e5",
"bruno-api/set_call_settings.bru": "3ca1aea68756c0ad016193332d0bfb2635fe356cb75e41441f37bde9b2cc6d94",
"bruno-api/set_call_volume.bru": "4d79026273eb76b7ccbaa0abb14d49faeb9112c32a3768b1baf78f425308e508",
"bruno-api/set_cell_lock_lte.bru": "bf15743756113ab74897795e1c0682eeea6c2bbca81c3557e0e53c5f4611a6a2",
"bruno-api/set_cell_lock_nr.bru": "3e910d6f96f42947fc9b668d082d4c052920dcdff93d7baa9e60202f0018af8f",
"bruno-api/set_data_status_disable.bru": "8688916e2ce401b949602f4a672cc11c8d2b44c97e0236e880dbd29519c123b5",
"bruno-api/set_data_status_enable.bru": "5a12f41e64e451979502b60b058375358a28b1b165d067995a4ccd6cb8907238",
"bruno-api/set_device_ddns_config.bru": "c13ea5e86986cf6a03fd06e95c50e5d492af0ac7232f5198aaa1c0138f4dd64f",
"bruno-api/set_device_wlan_enabled.bru": "d771ac8b6709b9dd62f38dc1ade1958e538ab3c01305baa2e9f2b87c927a2d2a",
"bruno-api/set_notification_config.bru": "026d7bab925384faad0d947ffd1dad89630a66bad78adb72d237d17f25692ace",
"bruno-api/set_radio_mode_auto.bru": "dceeccc49c336c82a8ff0fabefa9b2bb44ec7dbc3274c43d39cd9dc782032976",
"bruno-api/set_radio_mode_lte.bru": "cf821c094a8b60a8b5339ba889d85f4938033fca2bede6279fd9fb2ef5308d1e",
"bruno-api/set_radio_mode_nr.bru": "41d55ce5d18aa0e14e7729598fce1503040f2cb06a2933e997958aa0c9f7a7d7",
"bruno-api/set_roaming_disable.bru": "8991a96840470380bf50d8fedc08ccbd86dbadf95cbe6d9ee7fec9733c309d2b",
"bruno-api/set_roaming_enable.bru": "3a4ef29e7f646e0555545316ddb5afc14cf966984e91dea6979a7432ba453182",
"bruno-api/test_notification_channel.bru": "0f5ba9361bb55410e5d5cc4644ab9e6cf53ee7ea31525530db3b15aee9a83848",
"bruno-api/unlock_all_bands.bru": "3c82d066e73a51483c8e976c0469317881b20f823a6bc4c30a663eee51dc7449",
"bruno-api/unlock_all_cells.bru": "0206880d1c17e667a95cd9b8f12126a48c525106be2eb4e31eb9bb94e185e0d6",
"bruno-api/unlock_cell_nr.bru": "0e0d4504d6e9cee4e465d8a23e589cd21256d81c87c415cd8a97bd669cf09c19",
"backend/src/db.rs": "0608dd302c76574bf6bd4d33dd3d3077673d8bfc6fb55191859d7b4d275c8ce9"
}
}
@@ -0,0 +1,328 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { upstream58e2204Operations } from '../src/upstream-58e2204.ts';
const fixturePath = fileURLToPath(new URL('./fixtures/main-routes-58e2204.json', import.meta.url));
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
const key = (operation: { method: string; path?: string; pathTemplate?: string }) =>
`${operation.method} ${operation.pathTemplate ?? operation.path}`;
const evidenceFixturePath = fileURLToPath(new URL('./fixtures/source-evidence-58e2204.json', import.meta.url));
const evidenceFixture = JSON.parse(await readFile(evidenceFixturePath, 'utf8'));
const snapshotRoot = fileURLToPath(new URL('./fixtures/upstream-58e2204/', import.meta.url));
const snapshotManifest = JSON.parse(await readFile(`${snapshotRoot}/manifest.json`, 'utf8'));
const requiredFields = [
'operationId', 'upstreamDomain', 'method', 'pathTemplate', 'handler', 'capability', 'riskLevel',
'requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'timeoutMs',
'idempotency', 'confirmationPolicy', 'batchable', 'syncSafe', 'sensitiveFields',
'auditedAtCommit', 'compatibilityAdapter', 'uiOwner', 'sourceEvidence', 'responseFixtureStatus',
] as const;
const expectedDomains = new Set([
'instances-auth', 'device-system', 'sim', 'cellular', 'radio-lock', 'data-connection',
'device-network', 'workmode-esim', 'messages', 'calls', 'notifications', 'automation', 'ota',
]);
const expectedOwners = new Set([
'instances-new', 'settings-instance', 'fleet', 'overview', 'cellular', 'device-network',
'messages', 'calls', 'esim', 'notifications', 'automation', 'ota',
]);
const operationKey = (operation: { method: string; pathTemplate: string; handler: string }) =>
`${operation.method} ${operation.pathTemplate} ${operation.handler}`;
test('sensitive fields are independently snapshot-rebuildable model-chain anchors', async () => {
const directionClaim = (operation: any, direction: string) =>
['request', 'path', 'query'].includes(direction) ? operation.requestEvidence : operation.responseEvidence;
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
const evidence = field.evidence;
assert.ok(evidence, `${operation.operationId}: ${field.path} missing evidence`);
assert.match(evidence.sourceSha256, /^[a-f0-9]{64}$/);
assert.ok(evidence.sourceFile && evidence.symbol && (evidence.fieldPath || evidence.modelField || evidence.dynamicKey));
const source = await readFile(`${snapshotRoot}/${evidence.sourceFile}`, 'utf8');
const slice = source.split('\n').slice(evidence.startLine - 1, evidence.endLine).join('\n');
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sourceSha256, `${operation.operationId}: ${field.path}`);
assert.ok(directionClaim(operation, field.direction), `${operation.operationId}: direction`);
if (evidence.handlerToken) assert.ok(slice.includes(evidence.handlerToken));
else if (evidence.dynamicKey) assert.match(slice, new RegExp(`["']${evidence.dynamicKey}["']`));
else {
assert.match(slice, new RegExp(`(?:struct|enum)\\s+${evidence.symbol}\\b`));
assert.match(slice, new RegExp(`\\b${evidence.modelField}\\s*:`));
assert.equal(evidence.modelField, field.path.replace(/\[\*\]/g, '').split('.').at(-1));
for (const link of evidence.containerPath ?? []) {
const linkSource = await readFile(`${snapshotRoot}/${link.sourceFile}`, 'utf8');
const linkSlice = linkSource.split('\n').slice(link.startLine - 1, link.endLine).join('\n');
assert.equal(createHash('sha256').update(linkSlice).digest('hex'), link.sourceSha256);
assert.match(linkSlice, new RegExp(`struct\\s+${link.symbol}\\b`));
assert.match(linkSlice, new RegExp(`\\b${link.field}\\s*:\\s*(?:Vec<)?${link.targetSymbol}`));
}
}
}
const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path);
assert.ok(!get('GET','/api/sim').sensitiveFields.some((f: any) => f.path.endsWith('.imei')));
assert.deepEqual(get('GET','/api/device-network/wlan/status').sensitiveFields.map((f: any) => f.path), [
'$.response.data.ssid', '$.response.data.ipv4_addresses', '$.response.data.ipv6_addresses']);
assert.deepEqual(get('GET','/api/device-network/wlan/profiles').sensitiveFields.map((f: any) => f.path), ['$.response.data.profiles[*].ssid']);
});
test('Phase 0.2 uses structured, snapshot-rebuildable evidence and directional redaction contracts', () => {
for (const operation of upstream58e2204Operations as any[]) {
for (const name of ['requestEvidence', 'responseEvidence']) {
const claim = operation[name];
assert.ok(claim && typeof claim === 'object', `${operation.operationId}: ${name}`);
assert.match(claim.kind, /^(none|path|query|json|multipart|bytes|explicit-return|api-response|dynamic-json|opaque)$/);
assert.match(claim.sourceSha256, /^[a-f0-9]{64}$/);
assert.ok(claim.sourceFile && Number.isInteger(claim.startLine) && Number.isInteger(claim.endLine));
assert.ok(Array.isArray(claim.symbols));
assert.ok(Array.isArray(claim.modelEvidence));
}
for (const field of operation.sensitiveFields) {
assert.match(field.direction, /^(request|response|path|query|session)$/);
assert.match(field.path, /^\$\.(?:body|response|path|query|session)/);
assert.ok(field.reason);
assert.match(field.redactionMode, /^(drop|mask)$/);
assert.ok(field.sourceAnchor?.symbol || field.sourceAnchor?.token);
}
}
const get = (method: string, path: string) => (upstream58e2204Operations as any[]).find(o => o.method === method && o.pathTemplate === path);
assert.ok(get('GET', '/api/sms/list').responseEvidence.symbols.some((s: any) => s.symbol === 'SmsListResponse'));
assert.ok(get('GET', '/api/device').responseEvidence.symbols.some((s: any) => s.symbol === 'DeviceInfoResponse'));
assert.ok(get('GET', '/api/network').responseEvidence.symbols.some((s: any) => s.symbol === 'NetworkInfoResponse'));
assert.ok(get('POST', '/api/ota/upload').responseEvidence.symbols.some((s: any) => s.symbol === 'OtaUploadResponse'));
});
test('structured claims are rebuilt from the independent snapshot, with exact braces and symbols', async () => {
for (const operation of upstream58e2204Operations as any[]) for (const claim of [operation.requestEvidence, operation.responseEvidence]) {
const source = await readFile(`${snapshotRoot}/${claim.sourceFile}`, 'utf8');
const slice = source.split('\n').slice(claim.startLine - 1, claim.endLine).join('\n');
assert.equal(createHash('sha256').update(slice).digest('hex'), claim.sourceSha256, operation.operationId);
const fn = operation.handler.split('::').at(-1); const signature = new RegExp(`(?:pub\\s+)?async\\s+fn\\s+${fn}\\b`).exec(source);
assert.ok(signature); const open = source.indexOf('{', signature!.index); let depth = 0, close = -1;
for (let i = open; i < source.length; i++) { if (source[i] === '{') depth++; else if (source[i] === '}' && --depth === 0) { close = i; break; } }
assert.equal(source.slice(0, close + 1).split('\n').length, claim.endLine, `${operation.operationId}: exact end`);
for (const symbol of claim.symbols) assert.ok(slice.includes(symbol.symbol) || claim.modelEvidence.some((m: any) => m.symbol === symbol.symbol), `${operation.operationId}: ${symbol.symbol}`);
for (const model of claim.modelEvidence) {
const modelSource = await readFile(`${snapshotRoot}/${model.sourceFile}`, 'utf8');
const modelSlice = modelSource.split('\n').slice(model.startLine - 1, model.endLine).join('\n');
assert.equal(createHash('sha256').update(modelSlice).digest('hex'), model.sourceSha256, model.symbol);
assert.match(modelSlice, new RegExp(`(?:struct|enum)\\s+${model.symbol}\\b`));
for (const field of model.fields) assert.match(modelSlice, new RegExp(`\\b${field}\\s*:`));
}
if (claim.kind === 'dynamic-json') assert.match(slice, /json!|Json\s*\(|ApiResponse::/);
}
});
test('Axum route chains are independently parsed from frozen main.rs', async () => {
const source = await readFile(`${snapshotRoot}/backend/src/main.rs`, 'utf8'); const parsed: string[] = [];
for (let from = 0; ;) {
const start = source.indexOf('.route(', from); if (start < 0) break; let depth = 0, quote = false, end = -1;
for (let i = start + 6; i < source.length; i++) { const c = source[i]; if (c === '"' && source[i - 1] !== '\\') quote = !quote; if (!quote && c === '(') depth++; else if (!quote && c === ')' && --depth === 0) { end = i; break; } }
const chain = source.slice(start, end + 1); from = end + 1; const path = /\.route\(\s*"([^"]+)"/.exec(chain)?.[1]; if (!path) continue;
for (const match of chain.matchAll(/\.(get|post|delete)\(([\w:]+)\)|\b(get|post|delete)\(([\w:]+)\)/g)) { const method = (match[1] ?? match[3]).toUpperCase(); const handler = match[2] ?? match[4]; if (handler !== 'options_handler') parsed.push(`${method} ${path} ${handler}`); }
}
const expected = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort();
assert.deepEqual(parsed.sort(), expected); assert.deepEqual(parsed.sort(), (upstream58e2204Operations as any[]).map(operationKey).sort());
});
test('Bruno files are independently parsed and agree with structured request claims', async () => {
for (const evidence of evidenceFixture.operations) for (const bruno of evidence.brunoEvidence ?? []) {
const text = await readFile(`${snapshotRoot}/${bruno.file}`, 'utf8'); const method = /\b(get|post|delete)\s*\{/.exec(text)?.[1].toUpperCase();
const url = /url:\s*(?:\{\{baseUrl\}\}|https?:\/\/[^/\s]+)(\/[^\s]+)/.exec(text)?.[1]; assert.equal(method, bruno.method); assert.equal(url, bruno.path);
const bodyMode = /body:(json|multipart|text)/.exec(text)?.[1] ?? 'none'; assert.equal(bodyMode, bruno.bodyMode, bruno.file);
const operation: any = (upstream58e2204Operations as any[]).find(o => o.method === method && (o.pathTemplate === url || new RegExp(`^${o.pathTemplate.replace(/\{[^}]+\}/g, '[^/]+')}$`).test(url!))); assert.ok(operation, bruno.file);
if (bodyMode === 'json' && bruno.bodyFields.length) assert.equal(operation.requestEvidence.kind, 'json', bruno.file);
if (bodyMode === 'json' && !bruno.bodyFields.length) assert.ok(['none','path','query','json'].includes(operation.requestEvidence.kind), `${bruno.file}: empty Bruno JSON has no contract fields`);
for (const field of bruno.bodyFields) assert.match(text, new RegExp(`\\b${field}\\b`));
}
});
test('frozen fixture has the audited upstream route totals', () => {
assert.equal(fixture.upstreamCommit, '58e220411d6599609f0eeda01eb7016e9212f970');
assert.equal(fixture.routes.length, 117);
assert.equal(new Set(fixture.routes.map(key)).size, 117);
assert.equal(new Set(fixture.routes.map((route: { path: string }) => route.path)).size, 100);
assert.deepEqual(
Object.fromEntries(['GET', 'POST', 'DELETE'].map(method => [method, fixture.routes.filter((r: { method: string }) => r.method === method).length])),
{ GET: 50, POST: 62, DELETE: 5 },
);
});
test('registry is exactly route-parity complete with independent fixture', () => {
assert.equal(upstream58e2204Operations.length, 117);
const fixtureTriples = fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort();
assert.deepEqual(upstream58e2204Operations.map(operationKey).sort(), fixtureTriples);
assert.equal(new Set(upstream58e2204Operations.map(operation => operation.pathTemplate)).size, 100);
assert.equal(new Set(upstream58e2204Operations.map(operation => operation.operationId)).size, 117);
for (const operation of upstream58e2204Operations) {
const route = fixture.routes.find((r: any) => `${r.method} ${r.path} ${r.handler}` === operationKey(operation));
assert.ok(route, operationKey(operation));
assert.ok(operation.sourceEvidence.includes(route.source), `${operation.operationId}: exact main.rs evidence`);
}
});
test('frozen static-contract evidence is complete and bound to routes', () => {
assert.equal(evidenceFixture.upstreamCommit, fixture.upstreamCommit);
assert.equal(evidenceFixture.operations.length, 117);
assert.deepEqual(evidenceFixture.operations.map((e: any) => `${e.method} ${e.path} ${e.handler}`).sort(),
fixture.routes.map((r: any) => `${r.method} ${r.path} ${r.handler}`).sort());
for (const evidence of evidenceFixture.operations) {
assert.match(evidence.handlerEvidence, /^backend\/src\/(?:handlers|auth|notification_queue)\.rs:\d+-\d+$/);
assert.match(evidence.sha256, /^[a-f0-9]{64}$/);
assert.ok(evidence.requestSummary && evidence.responseSummary);
}
});
test('source evidence is independently reproducible from frozen whole-file snapshots', async () => {
assert.equal(snapshotManifest.upstreamCommit, fixture.upstreamCommit);
assert.equal(snapshotManifest.brunoFileCount, 82);
for (const [file, expected] of Object.entries(snapshotManifest.files)) {
const bytes = await readFile(`${snapshotRoot}/${file}`);
assert.equal(createHash('sha256').update(bytes).digest('hex'), expected, file);
}
for (const evidence of evidenceFixture.operations) {
const range = evidence.handlerRange;
assert.ok(range && Number.isInteger(range.startLine) && Number.isInteger(range.endLine), evidence.handler);
const source = await readFile(`${snapshotRoot}/${range.file}`, 'utf8');
const slice = source.split('\n').slice(range.startLine - 1, range.endLine).join('\n');
assert.equal(createHash('sha256').update(slice).digest('hex'), evidence.sha256, evidence.handler);
assert.match(slice, new RegExp(`async\\s+fn\\s+${evidence.handler.split('::').at(-1)}\\b`));
assert.equal((slice.match(/{/g) ?? []).length, (slice.match(/}/g) ?? []).length, evidence.handler);
assert.ok(evidence.extractorTokens.every((token: string) => slice.replace(/\\s+/g, '').includes(token.replace(/\\s+/g, ''))), evidence.handler);
assert.ok(evidence.responseKind && evidence.responseTypeEvidence !== 'Rust return impl IntoResponse', evidence.handler);
}
});
test('high-value response, confirmation, sensitive-field and Bruno contracts are fixed', () => {
const operations: any[] = upstream58e2204Operations as any;
const byPath = (method: string, path: string) => operations.find(o => o.method === method && o.pathTemplate === path)!;
assert.match(byPath('GET', '/api/device').responseSchemaEvidence, /ApiResponse<DeviceInfoResponse>/);
assert.match(byPath('GET', '/api/network').responseSchemaEvidence, /ApiResponse<NetworkInfoResponse>/);
assert.match(byPath('POST', '/api/ota/upload').responseSchemaEvidence, /ApiResponse<OtaUploadResponse>/);
for (const operation of operations) {
if (operation.riskLevel === 'R2') assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy), operation.operationId);
if (operation.riskLevel === 'R3') assert.equal(operation.confirmationPolicy, 'strong', operation.operationId);
}
const expectedSensitive: Record<string, string[]> = {
'POST /api/esim/profiles': ['$.body.matching_id', '$.body.confirmation_code', '$.body.imei'],
'POST /api/sms/send': ['$.body.phone_number', '$.body.content'],
'GET /api/sms/conversation': ['$.query.phone_number'],
'POST /api/call/dial': ['$.body.phone_number'],
'POST /api/auth/setup': ['$.body.password'],
'POST /api/auth/login': ['$.body.password'],
'POST /api/auth/password': ['$.body.new_password'],
'POST /api/device-network/wlan/connect': ['$.body.ssid', '$.body.password'],
};
for (const [route, fields] of Object.entries(expectedSensitive)) {
const [method, path] = route.split(' ');
for (const field of fields) assert.ok(byPath(method, path).sensitiveFields.some((claim: any) => claim.path === field), `${route}: ${field}`);
}
for (const evidence of evidenceFixture.operations) {
assert.match(evidence.evidenceLevel, /^route\+handler(?:\+model)?(?:\+bruno)?$/);
if (evidence.brunoEvidence) assert.ok(evidence.brunoEvidence.length > 0);
}
});
test('every operation carries auditable orchestration metadata', () => {
for (const operation of upstream58e2204Operations) {
for (const field of requiredFields) assert.ok(field in operation, `${operation.operationId}: missing ${field}`);
assert.ok(expectedDomains.has(operation.upstreamDomain), `${operation.operationId}: unknown domain`);
assert.ok(expectedOwners.has(operation.uiOwner), `${operation.operationId}: unknown owner`);
assert.match(operation.operationId, /^[a-z][A-Za-z0-9]*$/);
assert.ok(['R0', 'R1', 'R2', 'R3'].includes(operation.riskLevel));
assert.ok(Number.isInteger(operation.timeoutMs) && operation.timeoutMs > 0);
assert.ok(Array.isArray(operation.sensitiveFields));
assert.ok(Array.isArray(operation.sourceEvidence) && operation.sourceEvidence.length > 0);
assert.ok(operation.sourceEvidence.some(evidence => /^backend\/src\/main\.rs:\d+-\d+$/.test(evidence)));
assert.ok(operation.handler && operation.requestSchemaEvidence && operation.responseSchemaEvidence);
for (const field of ['requestSchemaEvidence', 'responseSchemaEvidence', 'requestContentType', 'idempotency'] as const) {
assert.doesNotMatch(operation[field], /TODO_EVIDENCE|not frozen/i, `${operation.operationId}: ${field}`);
}
assert.ok(['safe', 'idempotent', 'non-idempotent', 'conditional'].includes(operation.idempotency));
assert.equal(operation.auditedAtCommit, fixture.upstreamCommit);
assert.equal(operation.compatibilityAdapter, 'none-pinned-commit');
if (operation.riskLevel === 'R2') assert.equal(operation.syncSafe, false);
if (operation.riskLevel === 'R3') {
assert.equal(operation.capability, 'job');
assert.equal(operation.syncSafe, false);
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
}
if (operation.batchable && operation.riskLevel === 'R2') assert.equal(operation.capability, 'job');
}
});
test('dangerous authentication endpoints use dedicated flows, never generic proxy', () => {
const dangerous = upstream58e2204Operations.filter(operation =>
['/api/auth/setup', '/api/auth/password'].includes(operation.pathTemplate));
assert.equal(dangerous.length, 2);
for (const operation of dangerous) {
assert.equal(operation.riskLevel, 'R3');
assert.equal(operation.executionPolicy, 'dedicatedFlow');
}
const loginLogout = upstream58e2204Operations.filter(operation =>
['/api/auth/login', '/api/auth/logout'].includes(operation.pathTemplate));
assert.equal(loginLogout.length, 2);
assert.ok(loginLogout.every(operation => operation.riskLevel === 'R1' && operation.sessionSensitive));
const authSettingsPost = upstream58e2204Operations.find(operation => key(operation) === 'POST /api/auth/settings');
assert.ok(authSettingsPost && ['R2', 'R3'].includes(authSettingsPost.riskLevel));
assert.equal(authSettingsPost?.syncSafe, false);
});
test('product risk floors and auth replay policy are enforced', () => {
const byPath = (method: string, path: string) => upstream58e2204Operations.find(o => o.method === method && o.pathTemplate === path)!;
const r3 = [
['DELETE','/api/esim/profiles/{iccid}'], ['DELETE','/api/call/history/{id}'], ['POST','/api/call/history/clear'],
['POST','/api/sms/batch-delete'], ['DELETE','/api/sms/conversation/{phone_number}'], ['DELETE','/api/sms/message/{id}'], ['POST','/api/sms/clear'],
['POST','/api/notifications/logs/clear'], ['POST','/api/notifications/queue/clear'], ['DELETE','/api/notifications/queue/{id}'],
['POST','/api/automation/logs/clear'],
];
for (const [method, path] of r3) {
const operation = byPath(method, path);
assert.equal(operation.riskLevel, 'R3', `${method} ${path}`);
assert.equal(operation.capability, 'job');
assert.ok(['explicit', 'strong'].includes(operation.confirmationPolicy));
}
const r2 = [
['POST','/api/sms/send'], ['POST','/api/notifications/test/{channel}'],
['POST','/api/notifications/queue/retry-all'], ['POST','/api/notifications/queue/{id}/retry'],
['POST','/api/automation/test/{task_id}'],
];
for (const [method, path] of r2) {
const operation = byPath(method, path);
assert.ok(['R2', 'R3'].includes(operation.riskLevel), `${method} ${path}`);
assert.equal(operation.capability, 'job');
assert.equal(operation.syncSafe, false);
}
for (const path of ['/api/auth/login', '/api/auth/logout']) {
const operation = byPath('POST', path);
assert.equal(operation.executionPolicy, 'dedicatedFlow');
assert.equal(operation.sessionSensitive, true);
assert.equal(operation.automaticReplay, 'forbidden');
}
assert.equal(byPath('POST', '/api/auth/settings').executionPolicy, 'dedicatedFlow');
});
test('evidence matrix lists every registry operation', async () => {
const documentPath = fileURLToPath(new URL('../../../docs/api/simadmin-upstream-58e2204.md', import.meta.url));
const document = await readFile(documentPath, 'utf8');
const rows = document.split('\n').filter(line => /^\| `(?:GET|POST|DELETE)` \|/.test(line));
assert.equal(rows.length, 117);
for (const operation of upstream58e2204Operations) {
const row = rows.find(row => row.includes(`\`${operation.method}\``) && row.includes(`\`${operation.pathTemplate}\``) && row.includes(`\`${operation.operationId}\``));
assert.ok(row, key(operation));
const evidence = evidenceFixture.operations.find((e: any) => `${e.method} ${e.path} ${e.handler}` === operationKey(operation));
assert.ok(evidence, operation.operationId);
for (const value of [operation.upstreamDomain, operation.uiOwner, operation.handler, operation.requestContentType,
operation.idempotency, operation.riskLevel, operation.capability, evidence.sha256.slice(0, 16)]) {
assert.ok(row.includes(value), `${operation.operationId}: document drift for ${value}`);
}
}
assert.doesNotMatch(document, /TODO_EVIDENCE|not frozen/i);
assert.match(document, /Phase 0\.3[^\n]*真实响应样本/);
assert.match(document, /dynamic-json/);
assert.match(document, /58e220411d6599609f0eeda01eb7016e9212f970/);
for (const operation of upstream58e2204Operations as any[]) for (const field of operation.sensitiveFields) {
const exactRow = `| ${operation.method} | \`${operation.pathTemplate}\` | ${field.direction} | \`${field.path}\` | ${field.redactionMode} | ${field.reason} |`;
assert.ok(document.includes(exactRow), `${operation.operationId}: sensitive ledger drift ${field.path}`);
}
});