docs(api): inventory all SimAdmin operations
This commit is contained in:
@@ -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
+699
@@ -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())
|
||||
}
|
||||
+2067
File diff suppressed because it is too large
Load Diff
+2399
File diff suppressed because it is too large
Load Diff
+1605
File diff suppressed because it is too large
Load Diff
+1537
File diff suppressed because it is too large
Load Diff
+4252
File diff suppressed because it is too large
Load Diff
+1042
File diff suppressed because it is too large
Load Diff
+1039
File diff suppressed because it is too large
Load Diff
Vendored
+119
@@ -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))),
|
||||
),
|
||||
}
|
||||
}
|
||||
+839
@@ -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(())
|
||||
}
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+26
@@ -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: 待安装更新的元数据(如果有)
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_ddns_logs_clear.bru
Vendored
+19
@@ -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
|
||||
}
|
||||
Vendored
+19
@@ -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
|
||||
}
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
packages/operation-registry/test/fixtures/upstream-58e2204/bruno-api/post_device_wlan_disconnect.bru
Vendored
+19
@@ -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
|
||||
}
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
Vendored
+19
@@ -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
|
||||
}
|
||||
+34
@@ -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 时,需要手动重启服务才能生效
|
||||
}
|
||||
|
||||
Vendored
+34
@@ -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 包
|
||||
- 重启期间所有连接会断开
|
||||
}
|
||||
|
||||
+29
@@ -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 包
|
||||
- 验证失败后需要重新上传
|
||||
- 决定不进行更新
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
|
||||
+24
@@ -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
|
||||
}
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+54
@@ -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
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
}
|
||||
Vendored
+44
@@ -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
|
||||
}
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+35
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+27
@@ -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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user