diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8db063..f67ea6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Install Chromium for browser regression tests run: pnpm --dir web exec playwright install --with-deps chromium - name: Run desktop and mobile browser regressions - run: make web-e2e + run: make web-e2e && make web-e2e-simulator - name: Audit reachable vulnerabilities run: make security - name: Build Web and host binaries diff --git a/Makefile b/Makefile index 424bfd8..3069467 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) COMMANDS := simplusd simplus-agent simplus-netd simplusctl VOWIFI_HIL_COMMANDS := simplus-vowifi-hil-prepare simplus-vowifi-hil-vici simplus-vowifi-hil-pcscf simplus-vowifi-hil-ims simplus-vowifi-hil-redact simplus-vowifi-hil-stun GO_PACKAGES := ./cmd/... ./internal/... -GENERATED_PATHS := internal/api/openapi/generated.go internal/storage/sqlite/generated/core/db.go internal/storage/sqlite/generated/core/models.go internal/storage/sqlite/generated/core/state.sql.go web/src/api/generated +GENERATED_PATHS := internal/api/openapi/generated.go internal/storage/sqlite/generated/core web/src/api/generated SQLC_VERSION := v1.31.1 GOVULNCHECK_VERSION := v1.6.0 ACTIONLINT_VERSION := v1.7.12 @@ -46,7 +46,7 @@ endif export GOTOOLCHAIN GOFLAGS VERSION COMMIT PNPM_HOME -.PHONY: doctor bootstrap-dev generate verify-generated verify-modules check-docs check-container-files format check-format lint test web-e2e test-worktree-manifest test-dev-sim security build build-go build-linux build-vowifi-hil build-strongswan-plugins-deb test-strongswan-plugins-package container-build container-config container-release-bundle dev-sim dev-sim-lan dev-hardware dev-hardware-lan dev-hardware-probe dev-agent-deploy dev-toolchain clean +.PHONY: doctor bootstrap-dev generate verify-generated verify-modules check-docs check-container-files format check-format lint test web-e2e web-e2e-simulator test-worktree-manifest test-dev-sim security build build-go build-linux build-vowifi-hil build-strongswan-plugins-deb test-strongswan-plugins-package container-build container-config container-release-bundle dev-sim dev-sim-lan dev-hardware dev-hardware-lan dev-hardware-probe dev-agent-deploy dev-toolchain clean doctor: @set -eu; \ @@ -127,6 +127,9 @@ test: web-e2e: $(PNPM) --dir web e2e +web-e2e-simulator: + $(PNPM) --dir web e2e:simulator + test-worktree-manifest: @scripts/dev/worktree-manifest-test.sh diff --git a/README.md b/README.md index 08415e6..d1c430c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Simplus 是一个运行在 Linux 主机上的可信局域网通信控制后台 当前部署运行形态已从宿主机原生进程迁移到 Docker Compose。仓库通过 `control`、 `agent` 和 `netd` 三个镜像划分管理面、硬件访问与网络权限;Compose 中的 `app` 服务 -运行 control 镜像,并由 `data-init` 和 `bootstrap` 完成数据目录初始化及首次管理员创建。 +运行 control 镜像,并由 `data-init` 和 `provision` 完成数据目录初始化及首次管理员创建。 容器部署已经在 Debian 13/amd64 开发宿主完成镜像与隔离 smoke,并以 `v0.1.1` Release 部署包和 GHCR 镜像完成有数据切换、回滚演练及最终健康、摘要、挂载验收。本次切换未执行 @@ -47,7 +47,9 @@ AT/QMI、设备路径或通用硬件写入口;真实副作用必须经过独 remap 及其他发行版尚未验证。Web 与 controller 只应开放给受信任局域网,不能直接暴露 到公网。 -当前可安装候选是 `v0.1.1`;不要使用 `v0.1.0`、`latest` 或生产机本地构建的镜像。 +当前源码的单库、provision 与新通知重构尚未发布。`v0.1.1` 属于旧布局,不能使用 +本文的新布局步骤;请等待包含 [ADR 0028](docs/decisions/0028-control-state-and-durable-notifications.md) +的新版本,将下文 `vX.Y.Z` 替换为该明确版本。不要使用 `v0.1.0`、`latest` 或生产机本地构建的镜像。 生产安装不需要克隆源码,也不需要逐个下载 Release 中的源码包或其他发布材料。安装者 实际只需下载一个 `simplus-compose-*.tar.gz` 部署归档和它的一个 `.sha256` 校验文件; 三个 GHCR 镜像稍后由 `docker compose pull` 自动拉取。先确认宿主符合支持边界并取得 @@ -65,7 +67,7 @@ stat -c '%g' /dev/ttyUSB0 持久目录 `/opt/simplus`,不要长期从 Downloads、`/tmp` 或其他临时解包目录运行: ```bash -version=v0.1.1 +version=vX.Y.Z base="https://github.com/leonfox28/simplus/releases/download/$version" curl -fLO "$base/simplus-compose-$version-linux-amd64.tar.gz" && curl -fLO "$base/simplus-compose-$version-linux-amd64.tar.gz.sha256" && @@ -90,7 +92,7 @@ curl -fLO "$base/simplus-compose-$version-linux-amd64.tar.gz" && 的 `dialout` 通常为 `20`,但应以实际设备为准。生产镜像版本由部署包固定,不要增加 镜像 tag 变量。 -持久数据将写入 `/opt/simplus/data/core` 和 `/opt/simplus/data/agent`。这些目录包含 +持久数据将写入 `/opt/simplus/data/control-v2` 和 `/opt/simplus/data/agent`。这些目录包含 数据库、管理员状态和运行数据;请在升级或恢复前停止写入并完整备份 `/opt/simplus/data`。 确认数据位置后,审阅并执行宿主准备脚本,再渲染、拉取和启动: @@ -100,12 +102,12 @@ sudo bash prepare-container-host.sh && docker compose config --quiet && docker compose pull && docker compose up -d && - docker compose wait bootstrap && + docker compose wait provision && docker compose ps && - docker compose logs bootstrap + docker compose logs provision ``` -`bootstrap` 只在全新实例首次启动时输出 `simplus_admin` 的一次性随机初始密码,请立即 +`provision` 只在全新实例首次启动时输出 `simplus_admin` 的一次性随机初始密码,请立即 保存并在首次登录后修改。已有实例重建时不会覆盖密码,也不会再次输出原密码。管理后台 默认位于 `http://:8080`。 diff --git a/api/openapi.yaml b/api/openapi.yaml index dff3355..1c0197d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1183,570 +1183,6 @@ paths: "503": description: Equipment identity is temporarily unavailable content: { application/json: { schema: { $ref: "#/components/schemas/ApiError" } } } - /api/v1/setup/status: - get: - operationId: getSetupStatus - summary: Read the first-run boundary without opening normal business APIs - tags: [setup] - responses: - "200": - description: Current first-run boundary - content: - application/json: - schema: - $ref: "#/components/schemas/SetupStatusResponse" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The installation state could not be read - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/bootstrap: - post: - operationId: consumeSetupBootstrap - summary: Exchange a one-time root-generated bootstrap code for a restricted setup session - tags: [setup] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ConsumeBootstrapRequest" - responses: - "200": - description: Authorized setup session; the opaque session token is stored only in an HttpOnly cookie - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: The request body is invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: The bootstrap code is invalid, expired, or already consumed - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: First-run setup is not available in the current installation state - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The bootstrap exchange could not be completed - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/session: - get: - operationId: getSetupSession - summary: Resume the restricted first-run session from its HttpOnly cookie - tags: [setup] - responses: - "200": - description: Current restricted setup session - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: First-run setup is not available in the current installation state - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The setup session could not be read - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/administrator: - put: - operationId: putSetupAdministrator - summary: Create or replace the sole administrator while first-run setup remains uninitialized - tags: [setup] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ConfigureSetupAdministratorRequest" - responses: - "200": - description: Initial administrator configuration persisted; password material is never returned - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: Username, password confirmation, password policy, locale, or body is invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: First-run setup is not available in the current installation state - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The administrator configuration could not be persisted - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/storage: - put: - operationId: putSetupStorage - summary: Validate and persist the data and recording roots for a new instance - tags: [setup] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ConfigureSetupStorageRequest" - responses: - "200": - description: Storage roots were identity-checked, write-tested, and persisted - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: The recording root is invalid or conflicts with the live data root - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: An earlier setup prerequisite is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: A storage root failed validation or persistence - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/https: - put: - operationId: putSetupHTTPS - summary: Configure loopback management or generate a local-CA HTTPS candidate - tags: [setup] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ConfigureSetupHTTPSRequest" - responses: - "200": - description: Management endpoint configuration persisted - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: Listener, mode, port, or SAN configuration is invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: Storage is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: Certificate generation, key protection, or persistence failed - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/https/confirm: - post: - operationId: confirmSetupHTTPS - summary: Confirm the displayed SHA-256 local root fingerprint - tags: [setup] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ConfirmSetupHTTPSRequest" - responses: - "200": - description: The HTTPS candidate was confirmed - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: The fingerprint does not match the current candidate - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: Setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: Confirmation persistence failed - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/https/root-certificate: - get: - operationId: getSetupHTTPSRootCertificate - summary: Read the public local root certificate for explicit trust installation - tags: [setup] - responses: - "200": - description: Public root certificate and independently displayed fingerprint - content: - application/json: - schema: - $ref: "#/components/schemas/SetupRootCertificateResponse" - "400": - description: The current setup mode has no local root certificate - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The public root certificate could not be read - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/inventory: - get: - operationId: getSetupInventory - summary: Read the restricted HIL-0 inventory while the instance remains RF Off - tags: [setup] - responses: - "200": - description: Current read-only device and line snapshot - content: - application/json: - schema: - $ref: "#/components/schemas/InventoryResponse" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: HTTPS setup is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: Inventory could not be read - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/hardware/topology: - get: - operationId: getSetupHardwareTopology - summary: Read the RF-Off normalized hardware topology during first-run setup - tags: [setup, inventory] - responses: - "200": - description: Current normalized RF-Off hardware topology - content: - application/json: - schema: - $ref: "#/components/schemas/HardwareTopologyResponse" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: HTTPS confirmation is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The setup request authority is invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: The hardware topology could not be produced or failed validation - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/hardware/confirm: - post: - operationId: confirmSetupHardware - summary: Confirm the current read-only hardware inventory - tags: [setup] - responses: - "200": - description: Current hardware inventory digest was persisted - content: - application/json: - schema: - $ref: "#/components/schemas/SetupSessionResponse" - "400": - description: The current hardware topology is invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: HTTPS setup is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: Hardware review could not be persisted - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - /api/v1/setup/complete: - post: - operationId: completeSetup - summary: Revalidate every setup prerequisite and atomically enter ready - tags: [setup] - responses: - "200": - description: The instance is ready and requires normal administrator login - content: - application/json: - schema: - $ref: "#/components/schemas/SetupCompletionResponse" - "400": - description: Current hardware no longer matches the reviewed RF-Off snapshot - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "401": - description: No live setup session is present - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "409": - description: A prerequisite is incomplete or setup is unavailable - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "421": - description: The HTTP authority is not a permitted loopback host - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "500": - description: Final storage, TLS, or atomic state transition failed - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" - "504": - description: The request exceeded the bounded control-plane deadline - content: - application/json: - schema: - $ref: "#/components/schemas/ApiError" /api/v1/system/health: get: operationId: getSystemHealth @@ -1790,205 +1226,15 @@ components: description: Versioned opaque cursor from the previous page schema: { type: string, minLength: 1, maxLength: 256, pattern: "^[A-Za-z0-9_-]+$" } schemas: - SetupCompletionResponse: - type: object - additionalProperties: false - required: [installationState, managementUrl, loginRequired] - properties: - installationState: - type: string - enum: [ready] - managementUrl: - type: string - minLength: 1 - maxLength: 2048 - loginRequired: - type: boolean - enum: [true] HealthStatus: type: string enum: [ok, degraded] InstallationState: type: string enum: [uninitialized, ready, maintenance] - SetupPhase: - type: string - enum: [bootstrap-required, complete, maintenance] - SetupFlow: - type: string - enum: [create-new] - ConsumeBootstrapRequest: - type: object - additionalProperties: false - required: [bootstrapCode] - properties: - bootstrapCode: - type: string - pattern: "^[A-Za-z0-9_-]{43}$" - ConfigureSetupAdministratorRequest: - type: object - additionalProperties: false - required: [username, password, passwordConfirmation, instanceDefaultLocale] - properties: - username: - type: string - minLength: 3 - maxLength: 32 - pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{2,31}$" - password: - type: string - format: password - minLength: 12 - maxLength: 128 - passwordConfirmation: - type: string - format: password - minLength: 12 - maxLength: 128 - instanceDefaultLocale: - type: string - enum: [zh-CN, en-US] - ConfigureSetupStorageRequest: - type: object - additionalProperties: false - required: [recordingsRoot] - properties: - recordingsRoot: - type: string - minLength: 1 - maxLength: 4096 - pattern: "^/" - SetupHTTPSMode: - type: string - enum: [loopback-only, local-ca] - ConfigureSetupHTTPSRequest: - type: object - additionalProperties: false - required: [mode, listenHost, listenPort, subjectAlternativeNames] - properties: - mode: - $ref: "#/components/schemas/SetupHTTPSMode" - listenHost: - type: string - minLength: 1 - maxLength: 255 - listenPort: - type: integer - minimum: 1 - maximum: 65535 - subjectAlternativeNames: - type: array - maxItems: 16 - uniqueItems: true - items: - type: string - minLength: 1 - maxLength: 253 - ConfirmSetupHTTPSRequest: - type: object - additionalProperties: false - required: [rootFingerprintSha256] - properties: - rootFingerprintSha256: - type: string - pattern: "^[0-9A-F]{2}(:[0-9A-F]{2}){31}$" - SetupRootCertificateResponse: - type: object - additionalProperties: false - required: [pem, rootFingerprintSha256] - properties: - pem: - type: string - minLength: 1 - rootFingerprintSha256: - type: string - pattern: "^[0-9A-F]{2}(:[0-9A-F]{2}){31}$" - SetupSessionResponse: - type: object - additionalProperties: false - required: - - authorized - - expiresAt - - selectedFlow - - supportedFlows - - administratorConfigured - - administratorUsername - - instanceDefaultLocale - - storageConfigured - - dataRoot - - recordingsRoot - - httpsConfigured - - httpsConfirmed - - httpsMode - - httpsListenUrl - - httpsRootFingerprint - - httpsLeafNotAfter - - hardwareReviewed - - hardwareDeviceCount - - hardwareLineCount - - hardwareInventoryDigest - properties: - authorized: - type: boolean - enum: [true] - expiresAt: - type: string - format: date-time - selectedFlow: - $ref: "#/components/schemas/SetupFlow" - supportedFlows: - type: array - minItems: 1 - maxItems: 1 - uniqueItems: true - items: - $ref: "#/components/schemas/SetupFlow" - administratorConfigured: - type: boolean - administratorUsername: - type: string - maxLength: 32 - instanceDefaultLocale: - type: string - enum: [zh-CN, en-US] - storageConfigured: - type: boolean - dataRoot: - type: string - maxLength: 4096 - recordingsRoot: - type: string - maxLength: 4096 - httpsConfigured: - type: boolean - httpsConfirmed: - type: boolean - httpsMode: - type: string - enum: ['', loopback-only, local-ca, imported] - httpsListenUrl: - type: string - maxLength: 2048 - httpsRootFingerprint: - type: string - maxLength: 95 - httpsLeafNotAfter: - type: string - maxLength: 64 - hardwareReviewed: - type: boolean - hardwareDeviceCount: - type: integer - minimum: 0 - hardwareLineCount: - type: integer - minimum: 0 - hardwareInventoryDigest: - type: string - maxLength: 64 BackendKind: type: string - enum: [simulator, hardware, replay] + enum: [simulator, hardware] DeviceTransport: type: string enum: [simulated, usb, uart] @@ -2760,33 +2006,6 @@ components: type: string minLength: 1 maxLength: 1600 - SetupStatusResponse: - type: object - additionalProperties: false - required: - - installationState - - phase - - setupRequired - - businessApiAvailable - - bootstrapGenerationAvailable - - supportedFlows - properties: - installationState: - $ref: "#/components/schemas/InstallationState" - phase: - $ref: "#/components/schemas/SetupPhase" - setupRequired: - type: boolean - businessApiAvailable: - type: boolean - bootstrapGenerationAvailable: - type: boolean - supportedFlows: - type: array - maxItems: 1 - uniqueItems: true - items: - $ref: "#/components/schemas/SetupFlow" HealthResponse: type: object additionalProperties: false @@ -2998,12 +2217,14 @@ components: secret: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" } NotificationEventKind: type: string - enum: [sms.received, sms.failed, call.incoming, call.missed, system.degraded] + enum: [sms.received, sms.failed, call.incoming, call.missed, system.degraded, vowifi.connected, vowifi.disconnected, cellular.connected, cellular.disconnected] NotificationChannel: type: object additionalProperties: false - required: [id, provider, deliveryMode, targetType, displayName, webhookHint, signingSecretConfigured, enabled, eventKinds, lastDeliveryAt, lastDeliveryStatus, lastErrorCode] + required: [id, provider, deliveryMode, targetType, displayName, webhookHint, signingSecretConfigured, enabled, eventKinds, lastDeliveryAt, lastDeliveryStatus, lastErrorCode, pendingCount, failedCount] properties: + pendingCount: { type: integer, format: int64, minimum: 0 } + failedCount: { type: integer, format: int64, minimum: 0 } id: { type: string, pattern: "^channel_[A-Za-z0-9_-]{22}$" } provider: { type: string, enum: [wecom, feishu] } deliveryMode: { type: string, enum: [webhook, feishu_app] } @@ -3012,7 +2233,7 @@ components: webhookHint: { type: string, enum: [qyapi.weixin.qq.com, open.feishu.cn] } signingSecretConfigured: { type: boolean } enabled: { type: boolean } - eventKinds: { type: array, minItems: 1, maxItems: 5, uniqueItems: true, items: { $ref: "#/components/schemas/NotificationEventKind" } } + eventKinds: { type: array, minItems: 1, maxItems: 9, uniqueItems: true, items: { $ref: "#/components/schemas/NotificationEventKind" } } lastDeliveryAt: { type: string, maxLength: 64 } lastDeliveryStatus: { type: string, enum: [never, success, failed] } lastErrorCode: { type: string, maxLength: 64 } @@ -3026,7 +2247,7 @@ components: webhookUrl: { type: string, maxLength: 4096, description: Required on create; empty on update preserves the credential } signingSecret: { type: string, maxLength: 512, description: Optional Feishu signature secret; empty preserves the existing secret } enabled: { type: boolean } - eventKinds: { type: array, minItems: 1, maxItems: 5, uniqueItems: true, items: { $ref: "#/components/schemas/NotificationEventKind" } } + eventKinds: { type: array, minItems: 1, maxItems: 9, uniqueItems: true, items: { $ref: "#/components/schemas/NotificationEventKind" } } NotificationChannelList: type: object additionalProperties: false diff --git a/cmd/simplus-agent/main.go b/cmd/simplus-agent/main.go index 641fcd5..622caae 100644 --- a/cmd/simplus-agent/main.go +++ b/cmd/simplus-agent/main.go @@ -18,6 +18,8 @@ import ( "syscall" "time" + "github.com/leonfox28/simplus/internal/lifecycle" + "github.com/leonfox28/simplus/internal/agentapi" "github.com/leonfox28/simplus/internal/buildinfo" "github.com/leonfox28/simplus/internal/hardwareprobe" @@ -203,8 +205,10 @@ func run(args []string, stdout, stderr io.Writer) int { logger.Error("QDC507 SMS backend initialization failed") return 1 } + var requests lifecycle.Requests server := &http.Server{ - Handler: agentapi.NewManagedHardwareHandler(monitor, rfService, equipmentIdentityService, logger, smsBackend), ReadHeaderTimeout: 3 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + Handler: requests.Handler(agentapi.NewManagedHardwareHandler(monitor, rfService, equipmentIdentityService, logger, smsBackend)), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: agentapi.SMSRequestTimeout + 10*time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10, } var simAKAServer *http.Server @@ -221,13 +225,15 @@ func run(args []string, stdout, stderr io.Writer) int { return 1 } simAKAServer = &http.Server{ - Handler: agentapi.NewSIMAKAHILHandler(agentapi.NewSIMAKAService(monitor, scanner), logger), + BaseContext: func(net.Listener) context.Context { return ctx }, + Handler: requests.Handler(agentapi.NewSIMAKAHILHandler(agentapi.NewSIMAKAService(monitor, scanner), logger)), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 15 * time.Second, MaxHeaderBytes: 8 << 10, } } monitorErrors := make(chan error, 1) - go func() { monitorErrors <- monitor.Run(ctx, *scanInterval) }() + monitorDone := make(chan struct{}) + go func() { defer close(monitorDone); monitorErrors <- monitor.Run(ctx, *scanInterval) }() type serverFailure struct { name string err error @@ -256,6 +262,8 @@ func run(args []string, stdout, stderr io.Writer) int { exitCode = 1 } } + requests.StopAdmission() + stop() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := server.Shutdown(shutdownCtx); err != nil { logger.Error("agent shutdown failed", "error", err) @@ -281,6 +289,12 @@ func run(args []string, stdout, stderr io.Writer) int { if exitCode == 0 { logger.Info("hardware agent stopped") } + _ = server.Close() + if simAKAServer != nil { + _ = simAKAServer.Close() + } + requests.Wait() + <-monitorDone if !closeState() { exitCode = 1 } diff --git a/cmd/simplus-netd/main.go b/cmd/simplus-netd/main.go index a1d9b7b..b785f16 100644 --- a/cmd/simplus-netd/main.go +++ b/cmd/simplus-netd/main.go @@ -17,6 +17,8 @@ import ( "syscall" "time" + "github.com/leonfox28/simplus/internal/lifecycle" + "github.com/leonfox28/simplus/internal/agentapi" "github.com/leonfox28/simplus/internal/buildinfo" "github.com/leonfox28/simplus/internal/mihomosupervisor" @@ -90,9 +92,10 @@ func run(args []string, stdout, stderr io.Writer) int { mux := http.NewServeMux() mux.Handle("/v1/vowifi/", http.StripPrefix("/v1/vowifi", vowifisupervisor.NewHandler(vowifiLocal, logger))) mux.Handle("/", mihomosupervisor.NewHandler(local, logger)) - server := &http.Server{Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: agentapi.SMSRequestTimeout, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + var requests lifecycle.Requests + server := &http.Server{BaseContext: func(net.Listener) context.Context { return ctx }, Handler: requests.Handler(mux), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: agentapi.SMSRequestTimeout, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} serverErrors := make(chan error, 1) go func() { serverErrors <- server.Serve(listener) }() logger.Info("Mihomo supervisor listening", "socket", *socketPath) @@ -105,15 +108,25 @@ func run(args []string, stdout, stderr io.Writer) int { exitCode = 1 } } + requests.StopAdmission() + stop() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - if err := vowifiLocal.Close(shutdownCtx); err != nil { - logger.Error("Host VoWiFi supervisor shutdown failed", "error", err) - exitCode = 1 - } if err := server.Shutdown(shutdownCtx); err != nil { logger.Error("Mihomo supervisor shutdown failed", "error", err) exitCode = 1 } + _ = server.Close() + requests.Wait() + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := vowifiLocal.Close(cleanupCtx); err != nil { + logger.Error("Host VoWiFi cleanup failed", "error", err) + exitCode = 1 + } + if err := local.Close(cleanupCtx); err != nil && !errors.Is(err, mihomosupervisor.ErrNotRunning) { + logger.Error("Mihomo cleanup failed", "error", err) + exitCode = 1 + } + cleanupCancel() cancel() if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { logger.Error("Mihomo supervisor socket cleanup failed", "error", err) diff --git a/cmd/simplus-vowifi-hil-ims/main.go b/cmd/simplus-vowifi-hil-ims/main.go index 6d37249..1abe8bf 100644 --- a/cmd/simplus-vowifi-hil-ims/main.go +++ b/cmd/simplus-vowifi-hil-ims/main.go @@ -15,6 +15,8 @@ import ( "time" "github.com/leonfox28/simplus/internal/vowifihil" + + "github.com/leonfox28/simplus/internal/ims" ) type result struct { @@ -74,7 +76,7 @@ func run(source, pcscf netip.Addr) (result, bool) { return output, false } - unprotected, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(source.AsSlice()), Port: vowifihil.IMSSIPPort}) + unprotected, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(source.AsSlice()), Port: ims.IMSSIPPort}) if err != nil { output.Stage = "unprotected-port" return output, false @@ -124,7 +126,7 @@ func run(source, pcscf netip.Addr) (result, bool) { return output, false } unprotectedPort := uint16(unprotected.LocalAddr().(*net.UDPAddr).Port) - input := vowifihil.IMSInitialRegisterInput{ + input := ims.IMSInitialRegisterInput{ Source: source, UnprotectedPort: unprotectedPort, ProtectedClientPort: uint16(protectedClient.LocalAddr().(*net.UDPAddr).Port), ProtectedServerPort: uint16(protectedServer.LocalAddr().(*net.UDPAddr).Port), @@ -135,7 +137,7 @@ func run(source, pcscf netip.Addr) (result, bool) { Branch: branch, FromTag: fromTag, CallID: callToken + "@" + source.String(), ContactUser: contactUser, WLANNodeID: wlanNodeID, } - packet, securityClient, err := vowifihil.BuildIMSInitialRegister(input) + packet, securityClient, err := ims.BuildIMSInitialRegister(input) if err != nil { output.Stage = "register-build" return output, false @@ -152,7 +154,7 @@ func run(source, pcscf netip.Addr) (result, bool) { } defer zero(response) output.ResponseReceived = true - summary, parseErr := vowifihil.ParseIMSInitialResponse(response, input.CallID, input.HomeDomain) + summary, parseErr := ims.ParseIMSInitialResponse(response, input.CallID, input.HomeDomain) output.Status = summary.Status output.MinExpires = summary.MinExpires output.AKAAlgorithm = summary.AKAAlgorithm @@ -163,7 +165,7 @@ func run(source, pcscf netip.Addr) (result, bool) { return output, false } - challenge, err := vowifihil.ExtractIMSRegistrationChallenge(response, input.CallID, input.HomeDomain) + challenge, err := ims.ExtractIMSRegistrationChallenge(response, input.CallID, input.HomeDomain) if err != nil { output.Stage = "challenge-parse" return output, false @@ -171,29 +173,29 @@ func run(source, pcscf netip.Addr) (result, bool) { defer zero(challenge.RAND[:]) defer zero(challenge.AUTN[:]) authContext, cancelAuth := context.WithTimeout(context.Background(), 15*time.Second) - material, akaState, err := vowifihil.AuthenticateIMSChallenge(authContext, inspection.Target, challenge) + material, akaState, err := ims.AuthenticateIMSChallenge(authContext, inspection.Target, challenge) cancelAuth() output.AKAState = akaState if err != nil { - vowifihil.DiscardIMSAKASynchronizationFailure(err) + ims.DiscardIMSAKASynchronizationFailure(err) output.Stage = "ims-aka" return output, false } defer material.Destroy() tunnelContext, cancelTunnel := context.WithTimeout(context.Background(), 3*time.Second) - tunnel, err := vowifihil.DiscoverEPDGTunnel(tunnelContext, source) + tunnel, err := ims.DiscoverEPDGTunnel(tunnelContext, source) cancelTunnel() if err != nil { output.Stage = "epdg-policy" return output, false } - clientSecurity := vowifihil.IMSClientIPSecParameters{ + clientSecurity := ims.IMSClientIPSecParameters{ ClientSPI: input.ClientSPI, ServerSPI: input.ServerSPI, ProtectedClientPort: input.ProtectedClientPort, ProtectedServerPort: input.ProtectedServerPort, } installContext, cancelInstall := context.WithTimeout(context.Background(), 4*time.Second) - installation, err := vowifihil.InstallIMSXFRM(installContext, source, pcscf, tunnel, + installation, err := ims.InstallIMSXFRM(installContext, source, pcscf, tunnel, clientSecurity, challenge.SecurityServer, &material) cancelInstall() if err != nil { @@ -213,7 +215,7 @@ func run(source, pcscf netip.Addr) (result, bool) { output.Stage = "random" return output, false } - protectedPacket, err := vowifihil.BuildIMSAuthenticatedRegister(input, challenge, material.RES, + protectedPacket, err := ims.BuildIMSAuthenticatedRegister(input, challenge, material.RES, securityClient, protectedBranch, cnonce) if err != nil { output.Stage = "authenticated-register-build" @@ -242,7 +244,7 @@ func run(source, pcscf netip.Addr) (result, bool) { } func exchangeInitialRegister(connection *net.UDPConn, pcscf netip.Addr, packet []byte) ([]byte, bool, string) { - target := &net.UDPAddr{IP: net.IP(pcscf.AsSlice()), Port: vowifihil.IMSSIPPort} + target := &net.UDPAddr{IP: net.IP(pcscf.AsSlice()), Port: ims.IMSSIPPort} buffer := make([]byte, 64<<10) deadline := time.Now().Add(10 * time.Second) backoff := 500 * time.Millisecond @@ -277,7 +279,7 @@ func exchangeInitialRegister(connection *net.UDPConn, pcscf netip.Addr, packet [ } func exchangeAuthenticatedRegister(client, server *net.UDPConn, pcscf netip.Addr, - security vowifihil.IMSIPSecParameters, packet []byte, callID string) (int, bool, string, string) { + security ims.IMSIPSecParameters, packet []byte, callID string) (int, bool, string, string) { target := &net.UDPAddr{IP: net.IP(pcscf.AsSlice()), Port: int(security.ProtectedServerPort)} buffer := make([]byte, 64<<10) defer zero(buffer) @@ -326,7 +328,7 @@ func exchangeAuthenticatedRegister(client, server *net.UDPConn, pcscf netip.Addr zero(buffer[:count]) continue } - status, parseErr := vowifihil.ParseIMSAuthenticatedResponse(buffer[:count], callID) + status, parseErr := ims.ParseIMSAuthenticatedResponse(buffer[:count], callID) zero(buffer[:count]) if parseErr != nil || status < 200 { continue diff --git a/cmd/simplus-vowifi-hil-prepare/main.go b/cmd/simplus-vowifi-hil-prepare/main.go index 2f4c041..e21c7d3 100644 --- a/cmd/simplus-vowifi-hil-prepare/main.go +++ b/cmd/simplus-vowifi-hil-prepare/main.go @@ -10,6 +10,8 @@ import ( "time" "github.com/leonfox28/simplus/internal/vowifihil" + + "github.com/leonfox28/simplus/internal/ims" ) func main() { @@ -27,7 +29,7 @@ func main() { if err != nil { fatal("the ML307A/VOXI RF-off preflight failed") } - config, err := vowifihil.Build(vowifihil.Input{Target: inspection.Target, IMSI: inspection.IMSI}) + config, err := vowifihil.Build(ims.Input{Target: inspection.Target, IMSI: inspection.IMSI}) if err != nil { fatal("the ML307A/VOXI identity does not satisfy the HIL profile") } @@ -63,7 +65,7 @@ func main() { IMPUCount int `json:"imsPublicIdentityCount"` IMSDiscovery string `json:"imsApplicationDiscovery"` IMSCandidates int `json:"imsApplicationCandidates"` - }{true, "VOXI / Vodafone UK", "off", "ready", vowifihil.IMSAPN, vowifihil.EPDGFQDN, + }{true, "VOXI / Vodafone UK", "off", "ready", ims.IMSAPN, ims.EPDGFQDN, inspection.IMSIdentity.IdentitySource, len(inspection.IMSIdentity.PublicIdentities), inspection.IMSIdentity.ApplicationDiscovery, inspection.IMSIdentity.ApplicationCandidates} if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { diff --git a/cmd/simplus-vowifi-hil-vici/main.go b/cmd/simplus-vowifi-hil-vici/main.go index adac1f4..d5c13c1 100644 --- a/cmd/simplus-vowifi-hil-vici/main.go +++ b/cmd/simplus-vowifi-hil-vici/main.go @@ -10,6 +10,8 @@ import ( "time" "github.com/leonfox28/simplus/internal/vowifihil" + + "github.com/leonfox28/simplus/internal/ims" "github.com/strongswan/govici/vici" ) @@ -43,7 +45,7 @@ func main() { fail("plugins", state) } state.RequiredPlugins = true - connection, err := vowifihil.ConnectionMessage(input) + connection, err := ims.ConnectionMessage(input) if err != nil { fail("connection-encode", state) } @@ -51,13 +53,13 @@ func main() { fail("connection-load", state) } known, err := session.Call(ctx, "get-conns", nil) - if err != nil || !hasString(known.Get("conns"), vowifihil.ConnectionName) { + if err != nil || !hasString(known.Get("conns"), ims.ConnectionName) { fail("connection-verify", state) } state.ConnectionLoaded = true initiate := vici.NewMessage() - if initiate.Set("ike", vowifihil.ConnectionName) != nil || initiate.Set("child", "ims") != nil || + if initiate.Set("ike", ims.ConnectionName) != nil || initiate.Set("child", "ims") != nil || initiate.Set("timeout", 35000) != nil || initiate.Set("loglevel", 0) != nil { fail("initiate-encode", state) } @@ -72,26 +74,26 @@ func main() { writeResult(state) } -func readInput() (vowifihil.ConnectionInput, error) { +func readInput() (ims.ConnectionInput, error) { info, err := os.Lstat(vowifihil.VICIConfig) if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { - return vowifihil.ConnectionInput{}, errors.New("invalid VICI input file") + return ims.ConnectionInput{}, errors.New("invalid VICI input file") } stat, ok := info.Sys().(*syscall.Stat_t) if !ok || stat.Uid != 0 { - return vowifihil.ConnectionInput{}, errors.New("invalid VICI input owner") + return ims.ConnectionInput{}, errors.New("invalid VICI input owner") } file, err := os.Open(vowifihil.VICIConfig) if err != nil { - return vowifihil.ConnectionInput{}, err + return ims.ConnectionInput{}, err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, 4097)) if err != nil || len(data) == 0 || len(data) > 4096 { - return vowifihil.ConnectionInput{}, errors.New("invalid VICI input size") + return ims.ConnectionInput{}, errors.New("invalid VICI input size") } defer zero(data) - return vowifihil.ParseConnectionInput(data) + return ims.ParseConnectionInput(data) } func hasRequiredPlugins(message *vici.Message) bool { @@ -102,7 +104,7 @@ func hasRequiredPlugins(message *vici.Message) bool { if !ok { return false } - for _, required := range vowifihil.RequiredPlugins() { + for _, required := range ims.RequiredPlugins() { found := false for _, plugin := range plugins { if plugin == required { diff --git a/cmd/simplusctl/main.go b/cmd/simplusctl/main.go index c88e96b..3676eed 100644 --- a/cmd/simplusctl/main.go +++ b/cmd/simplusctl/main.go @@ -26,7 +26,6 @@ import ( "github.com/leonfox28/simplus/internal/mihomosupervisor" ) -type bootstrapGenerator func(context.Context, string) (control.BootstrapResponse, error) type administratorProvisioner func(context.Context, string, control.ProvisionAdministratorRequest) (control.ProvisionAdministratorResponse, error) type hardwareProber func(context.Context, string) (hardwareProbeOutput, error) type serviceHealthChecker func(context.Context, string) error @@ -38,13 +37,12 @@ type hardwareProbeOutput struct { } type dependencies struct { - effectiveUID func() int - generateBootstrap bootstrapGenerator - provisionAdmin administratorProvisioner - probeHardware hardwareProber - checkAgentHealth serviceHealthChecker - checkNetdHealth serviceHealthChecker - checkAppHealth serviceHealthChecker + effectiveUID func() int + provisionAdmin administratorProvisioner + probeHardware hardwareProber + checkAgentHealth serviceHealthChecker + checkNetdHealth serviceHealthChecker + checkAppHealth serviceHealthChecker } func main() { @@ -53,13 +51,12 @@ func main() { func run(args []string) int { return runWithDependencies(args, os.Stdout, os.Stderr, dependencies{ - effectiveUID: os.Geteuid, - generateBootstrap: control.GenerateBootstrap, - provisionAdmin: control.ProvisionAdministrator, - probeHardware: probeHardwareAgent, - checkAgentHealth: checkAgentHealth, - checkNetdHealth: checkNetdHealth, - checkAppHealth: checkAppHealth, + effectiveUID: os.Geteuid, + provisionAdmin: control.ProvisionAdministrator, + probeHardware: probeHardwareAgent, + checkAgentHealth: checkAgentHealth, + checkNetdHealth: checkNetdHealth, + checkAppHealth: checkAppHealth, }) } @@ -86,9 +83,6 @@ func runWithDependencies(args []string, stdout, stderr io.Writer, deps dependenc } return 0 } - if len(args) >= 1 && args[0] == "bootstrap-url" { - return runBootstrapURL(args[1:], stdout, stderr, deps) - } if len(args) >= 1 && args[0] == "provision-admin" { return runProvisionAdministrator(args[1:], stdout, stderr, deps) } @@ -272,73 +266,6 @@ func runProvisionAdministrator(args []string, stdout, stderr io.Writer, deps dep return 0 } -func runBootstrapURL(args []string, stdout, stderr io.Writer, deps dependencies) int { - flags := flag.NewFlagSet("bootstrap-url", flag.ContinueOnError) - flags.SetOutput(stderr) - configPath := flags.String("config", os.Getenv("SIMPLUS_CONFIG"), "optional Simplus YAML configuration path") - socketPath := flags.String("socket", os.Getenv("SIMPLUS_CONTROL_SOCKET"), "simplusd root control socket") - baseURL := flags.String("base-url", envOrDefault("SIMPLUS_BOOTSTRAP_BASE_URL", "http://127.0.0.1:5173"), "browser origin used in the one-time URL") - jsonOutput := flags.Bool("json", false, "emit a locale-neutral JSON result") - if err := flags.Parse(args); err != nil { - return 2 - } - if flags.NArg() != 0 { - fmt.Fprintln(stderr, "bootstrap-url accepts no positional arguments") - return 2 - } - if deps.effectiveUID == nil || deps.effectiveUID() != 0 { - fmt.Fprintln(stderr, "bootstrap-url must be run as root") - return 1 - } - - resolvedSocket := strings.TrimSpace(*socketPath) - if resolvedSocket == "" { - cfg, err := config.Load(*configPath) - if err != nil { - fmt.Fprintf(stderr, "resolve control socket: %v\n", err) - return 1 - } - resolvedSocket = control.SocketPath(cfg.Storage.DataRoot) - } - if !filepath.IsAbs(resolvedSocket) { - fmt.Fprintln(stderr, "control socket path must be absolute") - return 2 - } - browserURL, err := bootstrapBrowserURL(*baseURL, "placeholder") - if err != nil { - fmt.Fprintf(stderr, "invalid --base-url: %v\n", err) - return 2 - } - _ = browserURL - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - response, err := deps.generateBootstrap(ctx, filepath.Clean(resolvedSocket)) - if err != nil { - fmt.Fprintf(stderr, "generate bootstrap URL: %v\n", err) - return 1 - } - browserURL, err = bootstrapBrowserURL(*baseURL, response.Code) - if err != nil { - fmt.Fprintf(stderr, "build bootstrap URL: %v\n", err) - return 1 - } - if *jsonOutput { - result := struct { - URL string `json:"url"` - ExpiresAt time.Time `json:"expiresAt"` - }{URL: browserURL, ExpiresAt: response.ExpiresAt} - if err := json.NewEncoder(stdout).Encode(result); err != nil { - fmt.Fprintf(stderr, "write bootstrap result: %v\n", err) - return 1 - } - return 0 - } - fmt.Fprintln(stdout, browserURL) - fmt.Fprintf(stderr, "expires at %s\n", response.ExpiresAt.UTC().Format(time.RFC3339)) - return 0 -} - func runHardwareProbe(args []string, stdout, stderr io.Writer, deps dependencies) int { flags := flag.NewFlagSet("hardware probe", flag.ContinueOnError) flags.SetOutput(stderr) @@ -420,23 +347,6 @@ func probeHardwareAgent(ctx context.Context, socketPath string) (hardwareProbeOu return hardwareProbeOutput{Hello: hello, Snapshot: snapshot, Probe: probe}, nil } -func bootstrapBrowserURL(baseURL, code string) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(baseURL)) - if err != nil { - return "", err - } - if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { - return "", fmt.Errorf("must be an absolute HTTP(S) origin") - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { - return "", fmt.Errorf("must not contain credentials, a path, query, or fragment") - } - parsed.Path = "/setup" - parsed.RawPath = "" - parsed.Fragment = url.Values{"bootstrap": []string{code}}.Encode() - return parsed.String(), nil -} - func envOrDefault(name, fallback string) string { if value := os.Getenv(name); value != "" { return value diff --git a/cmd/simplusctl/main_test.go b/cmd/simplusctl/main_test.go index a9f1bdf..f9723c8 100644 --- a/cmd/simplusctl/main_test.go +++ b/cmd/simplusctl/main_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "strings" "testing" - "time" "github.com/leonfox28/simplus/internal/agentapi" "github.com/leonfox28/simplus/internal/control" @@ -24,70 +23,6 @@ func TestDoctorSupportStatusIsKnown(t *testing.T) { } } -func TestBootstrapURLRequiresRootBeforeContactingDaemon(t *testing.T) { - called := false - var stdout, stderr bytes.Buffer - exitCode := runWithDependencies( - []string{"bootstrap-url", "--socket", "/tmp/simplus.sock"}, - &stdout, - &stderr, - dependencies{ - effectiveUID: func() int { return 501 }, - generateBootstrap: func(context.Context, string) (control.BootstrapResponse, error) { - called = true - return control.BootstrapResponse{}, nil - }, - }, - ) - if exitCode != 1 || called { - t.Fatalf("exit = %d, daemon called = %v", exitCode, called) - } - if !strings.Contains(stderr.String(), "must be run as root") || stdout.Len() != 0 { - t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) - } -} - -func TestBootstrapURLUsesFragmentAndSupportsJSON(t *testing.T) { - const code = "ERERERERERERERERERERERERERERERERERERERERERE" - expires := time.Date(2026, 8, 2, 12, 10, 0, 0, time.UTC) - var stdout, stderr bytes.Buffer - exitCode := runWithDependencies( - []string{ - "bootstrap-url", - "--socket", "/run/simplus/control.sock", - "--base-url", "https://simplus.example", - "--json", - }, - &stdout, - &stderr, - dependencies{ - effectiveUID: func() int { return 0 }, - generateBootstrap: func(_ context.Context, socket string) (control.BootstrapResponse, error) { - if socket != "/run/simplus/control.sock" { - t.Fatalf("socket = %q", socket) - } - return control.BootstrapResponse{Code: code, ExpiresAt: expires}, nil - }, - }, - ) - if exitCode != 0 || stderr.Len() != 0 { - t.Fatalf("exit=%d stderr=%q", exitCode, stderr.String()) - } - var result struct { - URL string `json:"url"` - ExpiresAt time.Time `json:"expiresAt"` - } - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - t.Fatal(err) - } - if result.URL != "https://simplus.example/setup#bootstrap="+code { - t.Fatalf("URL = %q", result.URL) - } - if !result.ExpiresAt.Equal(expires) { - t.Fatalf("expiry = %s", result.ExpiresAt) - } -} - func TestProvisionAdministratorPrintsGeneratedCredentialOnlyWhenCreated(t *testing.T) { var stdout, stderr bytes.Buffer exitCode := runWithDependencies([]string{"provision-admin", "--socket", "/run/simplus/control.sock", "--json"}, &stdout, &stderr, dependencies{ @@ -184,30 +119,6 @@ func TestHardwareProbeJSONUsesTypedAgentReport(t *testing.T) { } } -func TestBootstrapURLRejectsNonOriginBeforeGenerating(t *testing.T) { - called := false - var stdout, stderr bytes.Buffer - exitCode := runWithDependencies( - []string{ - "bootstrap-url", - "--socket", "/run/simplus/control.sock", - "--base-url", "https://simplus.example/prefix?secret=value", - }, - &stdout, - &stderr, - dependencies{ - effectiveUID: func() int { return 0 }, - generateBootstrap: func(context.Context, string) (control.BootstrapResponse, error) { - called = true - return control.BootstrapResponse{}, nil - }, - }, - ) - if exitCode != 2 || called { - t.Fatalf("exit=%d daemon called=%v", exitCode, called) - } -} - func TestHealthSubcommandsUseTypedCheckers(t *testing.T) { tests := []struct { kind, flagName, target string diff --git a/cmd/simplusd/main.go b/cmd/simplusd/main.go index 5909357..fe07ac7 100644 --- a/cmd/simplusd/main.go +++ b/cmd/simplusd/main.go @@ -11,13 +11,16 @@ import ( "os" "os/signal" "path/filepath" + "sync" "syscall" "time" "github.com/leonfox28/simplus/internal/agentapi" + "github.com/leonfox28/simplus/internal/agentinventory" "github.com/leonfox28/simplus/internal/api/httpapi" "github.com/leonfox28/simplus/internal/application/auth" "github.com/leonfox28/simplus/internal/application/calls" + "github.com/leonfox28/simplus/internal/application/connectivity" "github.com/leonfox28/simplus/internal/application/contacts" "github.com/leonfox28/simplus/internal/application/euicc" "github.com/leonfox28/simplus/internal/application/health" @@ -32,11 +35,20 @@ import ( vowifiapp "github.com/leonfox28/simplus/internal/application/vowifi" "github.com/leonfox28/simplus/internal/buildinfo" "github.com/leonfox28/simplus/internal/config" + "github.com/leonfox28/simplus/internal/connectivityagent" "github.com/leonfox28/simplus/internal/control" + vowifidomain "github.com/leonfox28/simplus/internal/domain/vowifi" + "github.com/leonfox28/simplus/internal/feishu" + "github.com/leonfox28/simplus/internal/lifecycle" + mihomoassets "github.com/leonfox28/simplus/internal/mihomoassets" + "github.com/leonfox28/simplus/internal/modemagent" "github.com/leonfox28/simplus/internal/notificationwebhook" "github.com/leonfox28/simplus/internal/security/password" "github.com/leonfox28/simplus/internal/security/secretbox" + "github.com/leonfox28/simplus/internal/simulator" + "github.com/leonfox28/simplus/internal/smstransport" sqlitestore "github.com/leonfox28/simplus/internal/storage/sqlite" + "github.com/leonfox28/simplus/internal/subscriptionhttp" "github.com/leonfox28/simplus/internal/vowifisupervisor" ) @@ -65,46 +77,59 @@ func run() int { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - databaseRoot := filepath.Join(cfg.Storage.DataRoot, "db") + if _, err := os.Lstat(filepath.Join(cfg.Storage.DataRoot, "db")); err == nil || !errors.Is(err, os.ErrNotExist) { + logger.Error("legacy data layout detected; use a new data root and preserve the old data") + return 2 + } + databaseRoot := filepath.Join(cfg.Storage.DataRoot, "state") stores, err := sqlitestore.OpenSet(ctx, databaseRoot) if err != nil { logger.Error("database initialization failed", "error", err) return 1 } + defer stores.Close() + var workers sync.WaitGroup + defer func() { stop(); workers.Wait() }() + instanceSecretKeyPath := filepath.Join(databaseRoot, ".simplus-secrets-key-v1") - setupService, err := newSetupService(stores, instanceSecretKeyPath) + setupService, err := newSetupService(stores) if err != nil { logger.Error("Setup dependency configuration failed", "error", err) - _ = stores.Close() return 1 } authService := auth.NewService(stores, stores, password.NewDefaultHasher()) secretKeyring, err := secretbox.Open(instanceSecretKeyPath) if err != nil { logger.Error("instance secret key initialization failed", "error", err) - _ = stores.Close() return 1 } var inventoryService *inventory.Service var hardwareAgentClient *agentapi.Client var messageTransports []messaging.SMSTransport mihomoSupervisorSocket := os.Getenv("SIMPLUS_MIHOMO_SUPERVISOR_SOCKET") - var voWiFiSupervisor *vowifisupervisor.Client + var voWiFiSupervisor interface { + vowifidomain.API + connectivity.VoWiFiSource + } + var cellularSource connectivity.CellularSource + modemOptions := modemapp.Options{RF: modemapp.DisabledHardware{}, Runtime: modemapp.DisabledHardware{}, Identity: modemapp.DisabledIdentity{}} switch cfg.Runtime.Backend { case config.BackendSimulator: inventoryService = inventory.NewMultiSimulator() + simulatedRadio := simulator.NewCellular(inventoryService) + cellularSource = simulatedRadio + modemOptions.RF, modemOptions.Runtime = simulatedRadio, simulatedRadio + voWiFiSupervisor = simulator.NewVoWiFi() const simulatorAgentInstanceID = "01234567-89ab-cdef-0123-456789abcdef" simulatorClient, clientErr := agentapi.NewLocalSMSClient(simulatorAgentInstanceID, agentapi.NewDefaultSimulatorSMSBackend()) if clientErr != nil { logger.Error("simulator SMS client configuration rejected", "error", clientErr) - _ = stores.Close() return 2 } - simulatorGateway, gatewayErr := messaging.NewAgentSMSGateway(simulatorClient, simulatorAgentInstanceID) + simulatorGateway, gatewayErr := smstransport.NewAgentSMSGateway(simulatorClient) if gatewayErr != nil { logger.Error("simulator SMS gateway configuration rejected", "error", gatewayErr) - _ = stores.Close() return 2 } simulatorTransport := messaging.AgentNativeSMSTransport(simulatorGateway, simulatorGateway) @@ -113,7 +138,6 @@ func run() int { agentClient, clientErr := agentapi.NewClient(cfg.Runtime.AgentSocket) if clientErr != nil { logger.Error("hardware agent configuration rejected", "error", clientErr) - _ = stores.Close() return 2 } helloCtx, cancelHello := context.WithTimeout(ctx, 5*time.Second) @@ -121,83 +145,61 @@ func run() int { cancelHello() if helloErr != nil { logger.Error("hardware agent unavailable", "socket", cfg.Runtime.AgentSocket, "error", helloErr) - _ = stores.Close() return 1 } if policyErr := requireTypedHardwareAgent(hello); policyErr != nil { logger.Error("hardware Agent does not satisfy the typed capability policy", "error", policyErr) - _ = stores.Close() return 1 } - inventoryService = inventory.New(inventory.NewAgentSource(agentClient)) + inventoryService = inventory.New(agentinventory.NewAgentSource(agentClient)) hardwareAgentClient = agentClient - agentSMSGateway, gatewayErr := messaging.NewAgentSMSGateway(agentClient, hello.AgentInstanceID) + agentSMSGateway, gatewayErr := smstransport.NewAgentSMSGateway(agentClient) if gatewayErr != nil { logger.Error("hardware Agent SMS gateway configuration rejected", "error", gatewayErr) - _ = stores.Close() return 2 } messageTransports = append(messageTransports, messaging.AgentNativeSMSTransport(agentSMSGateway, agentSMSGateway)) if mihomoSupervisorSocket != "" { - voWiFiSupervisor, clientErr = vowifisupervisor.NewClient(mihomoSupervisorSocket) + voWiFiClient, clientErr := vowifisupervisor.NewClient(mihomoSupervisorSocket) + voWiFiSupervisor = voWiFiClient if clientErr != nil { logger.Error("Host VoWiFi supervisor client configuration failed", "error", clientErr) - _ = stores.Close() return 2 } - voWiFiGateway, gatewayErr := messaging.NewVoWiFiSMSGateway(voWiFiSupervisor) + voWiFiGateway, gatewayErr := smstransport.NewVoWiFiSMSGateway(voWiFiClient) if gatewayErr != nil { logger.Error("Host VoWiFi SMS gateway configuration failed", "error", gatewayErr) - _ = stores.Close() return 2 } messageTransports = append(messageTransports, messaging.HostVoWiFiSMSTransport(nil, voWiFiGateway, voWiFiGateway)) } - case config.BackendReplay: - logger.Error("replay backend is not implemented", "backend", cfg.Runtime.Backend) - _ = stores.Close() - return 2 default: logger.Error("unsupported backend", "backend", cfg.Runtime.Backend) - _ = stores.Close() return 2 } - managedModemService, err := modemapp.New(stores, inventoryService) - if err != nil { - logger.Error("managed modem initialization failed", "error", err) - _ = stores.Close() - return 1 - } if hardwareAgentClient != nil { - managedModemService.UseRFController(modemapp.NewAgentRFController(hardwareAgentClient)) - managedModemService.UseEquipmentIdentityReader(modemapp.NewAgentEquipmentIdentityReader(hardwareAgentClient)) + cellularSource = connectivityagent.Source{Client: hardwareAgentClient} + controller := modemagent.NewAgentRFController(hardwareAgentClient) + modemOptions.RF, modemOptions.Runtime = controller, controller + modemOptions.Identity = modemagent.NewAgentEquipmentIdentityReader(hardwareAgentClient) } - managedLineService, err := lineapp.New(stores, inventoryService) + managedModemService, err := modemapp.New(stores, inventoryService, modemOptions) if err != nil { - logger.Error("managed line initialization failed", "error", err) - _ = stores.Close() + logger.Error("managed modem initialization failed", "error", err) return 1 } + var phoneNumbers lineapp.PhoneNumberSource = lineapp.DisabledPhoneNumbers{} if voWiFiSupervisor != nil { - managedLineService.UsePhoneNumberSource(lineapp.NewVoWiFiPhoneNumberSource(voWiFiSupervisor)) + phoneNumbers = lineapp.NewVoWiFiPhoneNumberSource(voWiFiSupervisor) } - messageService, err := messaging.NewService(ctx, stores, managedLineService) + managedLineService, err := lineapp.New(stores, inventoryService, phoneNumbers) if err != nil { - logger.Error("messaging initialization failed", "error", err) - _ = stores.Close() + logger.Error("managed line initialization failed", "error", err) return 1 } - if cfg.Runtime.Backend == config.BackendSimulator { - if err := messageService.UseTransports(messageTransports...); err != nil { - logger.Error("Simulator SMS transport configuration failed", "error", err) - _ = stores.Close() - return 2 - } - } contactService, err := contacts.New(stores) if err != nil { logger.Error("contacts initialization failed", "error", err) - _ = stores.Close() return 1 } var callService *calls.Service @@ -206,161 +208,190 @@ func run() int { callService, err = calls.New(ctx, stores, managedLineService) if err != nil { logger.Error("calls initialization failed", "error", err) - _ = stores.Close() return 1 } euiccService, err = euicc.New(stores) if err != nil { logger.Error("eUICC initialization failed", "error", err) - _ = stores.Close() return 1 } } realtimeHub := realtime.NewHub() webhookClient := notificationwebhook.NewClient() + feishuClient := feishu.NewFeishuClient() notificationService, err := notificationapp.New(notificationapp.Dependencies{ Store: stores, Secrets: secretKeyring, Webhooks: webhookClient, + ProcessContext: ctx, Registrar: feishuClient, Messenger: feishuClient, OnChange: func() { realtimeHub.Publish([]realtime.Topic{realtime.TopicNotifications}, "") }, }) if err != nil { logger.Error("notification dependency configuration failed", "error", err) - _ = stores.Close() return 1 } - feishuClient := notificationapp.NewFeishuClient() - notificationService.ConfigureFeishuBinding(ctx, feishuClient, feishuClient, func() { - realtimeHub.Publish([]realtime.Topic{realtime.TopicNotifications}, "") + defer notificationService.Close() + + workers.Go(func() { + notificationService.Run(ctx, func() { realtimeHub.Publish([]realtime.Topic{realtime.TopicNotifications}, "") }, func(err error) { logger.Warn("notification worker failed", "error", err) }) }) mihomoRoot := filepath.Join(cfg.Storage.DataRoot, "mihomo") - mihomoCoreManager := mihomoapp.NewCoreManager(mihomoRoot) - mihomoConfigManager := mihomoapp.NewConfigManager(mihomoRoot, stores, mihomoCoreManager) + mihomoCoreManager := mihomoassets.NewCoreManager(mihomoRoot) + mihomoConfigManager := mihomoassets.NewConfigManager(mihomoRoot, stores, mihomoCoreManager) mihomoController, controllerErr := mihomoControllerAddress(cfg.Server.Listen) if controllerErr != nil { logger.Error("Mihomo controller address derivation failed", "error", controllerErr) - _ = stores.Close() return 1 } - mihomoDashboardManager := mihomoapp.NewDashboardManager(mihomoRoot, mihomoController) + mihomoDashboardManager := mihomoassets.NewDashboardManager(mihomoRoot, mihomoController) mihomoDashboardStatus, dashboardErr := mihomoDashboardManager.Ensure() if dashboardErr != nil { logger.Error("Mihomo dashboard initialization failed", "error", dashboardErr) - _ = stores.Close() return 1 } mihomoConfigManager.ConfigureDashboard(mihomoDashboardStatus) mihomoSupervisor, supervisorErr := newMihomoSupervisor(mihomoRoot, mihomoSupervisorSocket) if supervisorErr != nil { logger.Error("Mihomo supervisor configuration failed", "error", supervisorErr) - _ = stores.Close() return 2 } + if owned, ok := mihomoSupervisor.(interface{ Close(context.Context) error }); ok { + defer func() { + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := owned.Close(shutdown); err != nil { + logger.Warn("owned Mihomo cleanup failed", "error", err) + } + }() + } mihomoRuntimeManager, runtimeManagerErr := mihomoapp.NewRuntimeManager(mihomoRoot, stores, mihomoConfigManager, mihomoCoreManager, mihomoSupervisor) if runtimeManagerErr != nil { logger.Error("Mihomo runtime manager dependency configuration failed", "error", runtimeManagerErr) - _ = stores.Close() return 1 } - mihomoSubscriptionService := mihomoapp.NewSubscriptionService(stores, secretKeyring, mihomoConfigManager) + mihomoSubscriptionService, err := mihomoapp.NewSubscriptionService(stores, secretKeyring, subscriptionhttp.New(), mihomoConfigManager) + if err != nil { + logger.Error("Mihomo subscription dependencies invalid", "error", err) + return 2 + } lineEgressService := lineegressapp.New(stores, managedLineService, mihomoRuntimeManager) var voWiFiService *vowifiapp.Service - if cfg.Runtime.Backend == config.BackendHardware && voWiFiSupervisor != nil { + if voWiFiSupervisor != nil { voWiFiService, err = vowifiapp.New(stores, managedLineService, lineEgressService, mihomoRuntimeManager, voWiFiSupervisor) if err != nil { logger.Error("Host VoWiFi service configuration failed", "error", err) - _ = stores.Close() return 2 } - go voWiFiService.Run(ctx, 10*time.Second, func(reconcileErr error) { - if reconcileErr != nil { - logger.Warn("Host VoWiFi desired-state reconciliation failed", "error", reconcileErr) - return - } - realtimeHub.Publish([]realtime.Topic{realtime.TopicVoWiFi}, "") + workers.Go(func() { + voWiFiService.Run(ctx, 10*time.Second, func(reconcileErr error) { + if reconcileErr != nil { + logger.Warn("Host VoWiFi desired-state reconciliation failed", "error", reconcileErr) + return + } + realtimeHub.Publish([]realtime.Topic{realtime.TopicVoWiFi}, "") + }) }) for index := range messageTransports { messageTransports[index] = messageTransports[index].UseHostVoWiFiAvailability(voWiFiService) } - if err := messageService.UseTransports(messageTransports...); err != nil { - logger.Error("Host VoWiFi SMS transport configuration failed", "error", err) - _ = stores.Close() - return 2 - } } - if cfg.Runtime.Backend == config.BackendHardware && voWiFiService == nil { - if err := messageService.UseTransports(messageTransports...); err != nil { - logger.Error("hardware Agent SMS transport configuration failed", "error", err) - _ = stores.Close() - return 2 - } + messageService, err := messaging.NewService(ctx, stores, managedLineService, messageTransports...) + if err != nil { + logger.Error("messaging initialization failed", "error", err) + return 1 } - smsSyncCoordinator, err := messaging.NewSyncCoordinator(messageService, notificationService, realtimeHub) + smsSyncCoordinator, err := messaging.NewSyncCoordinator(messageService, realtimeHub) if err != nil { logger.Error("SMS synchronization coordinator configuration failed", "error", err) - _ = stores.Close() return 1 } - var agentChangeCoordinator *inventory.AgentChangeCoordinator + var agentChangeCoordinator *agentinventory.AgentChangeCoordinator if hardwareAgentClient != nil { - agentChangeCoordinator, err = inventory.NewAgentChangeCoordinator(hardwareAgentClient, realtimeHub) + agentChangeCoordinator, err = agentinventory.NewAgentChangeCoordinator(hardwareAgentClient, realtimeHub) if err != nil { logger.Error("hardware Agent change coordinator configuration failed", "error", err) - _ = stores.Close() return 1 } } - go smsSyncCoordinator.Run(ctx, 2*time.Second, func(report messaging.SyncReport) { - if report.SyncError != nil { - logger.Warn("SMS synchronization failed", "error", report.SyncError) - } - if report.DurableChange { - logger.Info("SMS synchronization completed", - "inbound_persisted", report.Result.Persisted, "inbound_already_known", report.Result.AlreadyKnown, - "inbound_acknowledged", report.Result.Acknowledged, "outbound_sent", report.Result.OutboundSent, - "outbound_failed", report.Result.OutboundFailed, "outbound_unconfirmed", report.Result.OutboundUnconfirmed, - "outbound_reports_acknowledged", report.Result.OutboundReportsAcknowledged) - } - if report.NotificationError != nil { - logger.Warn("inbound SMS notification failed", "error", report.NotificationError) - } + workers.Go(func() { + smsSyncCoordinator.Run(ctx, 2*time.Second, func(report messaging.SyncReport) { + if report.SyncError != nil { + logger.Warn("SMS synchronization failed", "error", report.SyncError) + } + if report.DurableChange { + logger.Info("SMS synchronization completed", + "inbound_persisted", report.Result.Persisted, "inbound_already_known", report.Result.AlreadyKnown, + "inbound_acknowledged", report.Result.Acknowledged, "outbound_sent", report.Result.OutboundSent, + "outbound_failed", report.Result.OutboundFailed, "outbound_unconfirmed", report.Result.OutboundUnconfirmed, + "outbound_reports_acknowledged", report.Result.OutboundReportsAcknowledged) + } + }) }) if agentChangeCoordinator != nil { - go agentChangeCoordinator.Run(ctx, func(report inventory.AgentChangeReport) { - switch report.Operation { - case inventory.AgentChangeSnapshot: - logger.Warn("hardware Agent snapshot watch initialization failed", "error", report.Error) - case inventory.AgentChangeWatch: - logger.Warn("hardware Agent change watch failed", "error", report.Error) - } + workers.Go(func() { + agentChangeCoordinator.Run(ctx, func(report agentinventory.AgentChangeReport) { + switch report.Operation { + case agentinventory.AgentChangeSnapshot: + logger.Warn("hardware Agent snapshot watch initialization failed", "error", report.Error) + case agentinventory.AgentChangeWatch: + logger.Warn("hardware Agent change watch failed", "error", report.Error) + } + }) }) } - apiServer := httpapi.New(health.New(stores, cfg.Runtime.Backend), setupService, inventoryService, logger, authService, messageService, contactService) - apiServer = httpapi.WithManagedModems(apiServer, managedModemService) - apiServer = httpapi.WithManagedLines(apiServer, managedLineService) + if cellularSource != nil { + monitor, monitorErr := connectivity.NewCellularMonitor(stores, stores, cellularSource) + if monitorErr != nil { + logger.Error("cellular monitor configuration failed", "error", monitorErr) + return 2 + } + workers.Go(func() { + monitor.Run(ctx, func(err error) { logger.Warn("cellular monitoring unavailable", "error", err) }) + }) + } + if voWiFiSupervisor != nil { + monitor, monitorErr := connectivity.NewVoWiFiMonitor(stores, stores, voWiFiSupervisor) + if monitorErr != nil { + logger.Error("VoWiFi monitor configuration failed", "error", monitorErr) + return 2 + } + workers.Go(func() { + monitor.Run(ctx, func(err error) { + if err != nil { + logger.Warn("VoWiFi monitoring unavailable", "error", err) + } + }) + }) + } + dependencies := httpapi.Dependencies{ + Health: health.New(stores, cfg.Runtime.Backend), Setup: setupService, Inventory: inventoryService, + Auth: authService, Messages: messageService, Contacts: contactService, Logger: logger, + Modems: managedModemService, Lines: managedLineService, LineEgress: lineEgressService, + MihomoCore: mihomoCoreManager, MihomoSubscriptions: mihomoSubscriptionService, + MihomoConfig: mihomoConfigManager, MihomoRuntime: mihomoRuntimeManager, MihomoDashboard: mihomoDashboardManager, + Notifications: notificationService, Realtime: realtimeHub, + } if callService != nil { - apiServer = httpapi.WithCalls(apiServer, callService) + dependencies.Calls = callService } if euiccService != nil { - apiServer = httpapi.WithEUICC(apiServer, euiccService) + dependencies.Euicc = euiccService } - apiServer = httpapi.WithMihomoCore(apiServer, mihomoCoreManager) - apiServer = httpapi.WithMihomoSubscriptions(apiServer, mihomoSubscriptionService) - apiServer = httpapi.WithLineEgress(apiServer, lineEgressService) if voWiFiService != nil { - apiServer = httpapi.WithVoWiFi(apiServer, voWiFiService) + dependencies.Vowifi = voWiFiService + } + apiServer, err := httpapi.NewServer(dependencies) + if err != nil { + logger.Error("HTTP dependencies invalid", "error", err) + return 2 } - apiServer = httpapi.WithMihomoConfig(apiServer, mihomoConfigManager) - apiServer = httpapi.WithMihomoRuntime(apiServer, mihomoRuntimeManager) - apiServer = httpapi.WithMihomoDashboard(apiServer, mihomoDashboardManager) - apiServer = httpapi.WithNotifications(apiServer, notificationService) - apiServer = httpapi.WithRealtime(apiServer, realtimeHub) handler, err := applicationHandler(httpapi.Router(apiServer), os.Getenv("SIMPLUS_WEB_ROOT")) if err != nil { logger.Error("Web root configuration failed", "error", err) - _ = stores.Close() return 2 } + var requests lifecycle.Requests server := &http.Server{ Addr: cfg.Server.Listen, - Handler: handler, + Handler: requests.Handler(handler), + BaseContext: func(net.Listener) context.Context { return ctx }, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -373,13 +404,12 @@ func run() int { controlListener, err := control.ListenRootOnly(controlPath, 0) if err != nil { logger.Error("root control socket bind failed", "path", controlPath, "error", err) - if closeErr := stores.Close(); closeErr != nil { - logger.Error("database close failed after control socket error", "error", closeErr) - } + return 1 } controlServer := &http.Server{ - Handler: control.NewBootstrapHandler(setupService, logger), + Handler: requests.Handler(control.NewProvisionHandler(setupService, logger)), + BaseContext: func(net.Listener) context.Context { return ctx }, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -391,9 +421,7 @@ func run() int { if err != nil { logger.Error("control plane bind failed", "address", cfg.Server.Listen, "error", err) _ = controlListener.Close() - if closeErr := stores.Close(); closeErr != nil { - logger.Error("database close failed after bind error", "error", closeErr) - } + return 1 } logger.Info("control plane listening", @@ -425,19 +453,23 @@ func run() int { } } + requests.StopAdmission() + stop() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) if err := errors.Join(server.Shutdown(shutdownCtx), controlServer.Shutdown(shutdownCtx)); err != nil { logger.Error("graceful shutdown failed", "error", err) exitCode = 1 } + _ = server.Close() + _ = controlServer.Close() cancel() + requests.Wait() + apiServer.Wait() + workers.Wait() + notificationService.Close() if exitCode == 0 { logger.Info("control plane stopped") } - if err := stores.Close(); err != nil { - logger.Error("database close failed", "error", err) - exitCode = 1 - } return exitCode } @@ -469,7 +501,7 @@ func requireTypedHardwareAgent(hello agentapi.Hello) error { equipmentIdentity = true case agentapi.FeatureSMS: sms = true - case agentapi.CommandRadioEnsureOff, "durable-command-outcomes": + case "radio.ensure-off", "durable-command-outcomes": return fmt.Errorf("Agent advertises forbidden mutation feature %q", feature) } } diff --git a/cmd/simplusd/main_test.go b/cmd/simplusd/main_test.go index d12072a..7e19610 100644 --- a/cmd/simplusd/main_test.go +++ b/cmd/simplusd/main_test.go @@ -42,7 +42,7 @@ func TestHardwareRuntimeRequiresTypedRFControlAndRejectsUnapprovedMutationFeatur {agentapi.FeatureRFControl, agentapi.FeatureEquipmentIdentityRead}, {agentapi.FeatureRFControl, agentapi.FeatureSMS}, {agentapi.FeatureEquipmentIdentityRead, agentapi.FeatureSMS}, - append(append([]string(nil), required...), agentapi.CommandRadioEnsureOff), + append(append([]string(nil), required...), "radio.ensure-off"), append(append([]string(nil), required...), "durable-command-outcomes"), } { if err := requireTypedHardwareAgent(agentapi.Hello{Features: features}); err == nil { diff --git a/cmd/simplusd/setup.go b/cmd/simplusd/setup.go index 3f3777c..6c45212 100644 --- a/cmd/simplusd/setup.go +++ b/cmd/simplusd/setup.go @@ -1,54 +1,11 @@ package main import ( - "time" - "github.com/leonfox28/simplus/internal/application/setup" - "github.com/leonfox28/simplus/internal/security/managementcert" "github.com/leonfox28/simplus/internal/security/password" - "github.com/leonfox28/simplus/internal/security/secretbox" - storagefs "github.com/leonfox28/simplus/internal/storage/filesystem" sqlitestore "github.com/leonfox28/simplus/internal/storage/sqlite" ) -func newSetupService(stores *sqlitestore.Set, instanceSecretKeyPath string) (*setup.Service, error) { - return setup.New(setup.Dependencies{ - StateStore: stores, - AuthorizationStore: stores, - AdministratorStore: stores, - PasswordHasher: password.NewDefaultHasher(), - StorageStore: stores, - DirectoryPreparer: func(path string) (setup.DirectoryIdentity, error) { - identity, err := storagefs.PreparePrivateDirectory(path) - if err != nil { - return setup.DirectoryIdentity{}, err - } - return setup.DirectoryIdentity{ - Path: identity.Path, - Device: identity.Device, - Inode: identity.Inode, - }, nil - }, - ManagementTLSStore: stores, - SecretProtectorOpener: func() (setup.SecretProtector, error) { - return secretbox.Open(instanceSecretKeyPath) - }, - LocalCAGenerator: func(now time.Time, sans []string) (setup.LocalCABundle, error) { - bundle, err := managementcert.GenerateLocalCA(now, sans) - if err != nil { - return setup.LocalCABundle{}, err - } - return setup.LocalCABundle{ - CACertificatePEM: bundle.CACertificatePEM, - CAPrivateKeyPEM: bundle.CAPrivateKeyPEM, - LeafCertificatePEM: bundle.LeafCertificatePEM, - LeafPrivateKeyPEM: bundle.LeafPrivateKeyPEM, - RootFingerprint: bundle.RootFingerprint, - LeafNotAfter: bundle.LeafNotAfter, - SANs: bundle.SANs, - }, nil - }, - HardwareReviewStore: stores, - CompletionStore: stores, - }) +func newSetupService(stores *sqlitestore.Set) (*setup.Service, error) { + return setup.New(setup.Dependencies{StateStore: stores, AdministratorStore: stores, PasswordHasher: password.NewDefaultHasher()}) } diff --git a/cmd/simplusd/setup_test.go b/cmd/simplusd/setup_test.go index 0119476..99684af 100644 --- a/cmd/simplusd/setup_test.go +++ b/cmd/simplusd/setup_test.go @@ -18,7 +18,7 @@ func TestNewSetupServiceAcceptsCompleteProductionDependencies(t *testing.T) { t.Cleanup(func() { _ = stores.Close() }) instanceSecretKeyPath := filepath.Join(databaseRoot, ".simplus-secrets-key-v1") - service, err := newSetupService(stores, instanceSecretKeyPath) + service, err := newSetupService(stores) if err != nil { t.Fatal(err) } diff --git a/components/strongswan-simplus-simaka/simplus_simaka_agent.c b/components/strongswan-simplus-simaka/simplus_simaka_agent.c index 679515e..92575ce 100644 --- a/components/strongswan-simplus-simaka/simplus_simaka_agent.c +++ b/components/strongswan-simplus-simaka/simplus_simaka_agent.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -214,43 +215,98 @@ static bool write_all(int descriptor, const char *data, size_t length) return true; } -static bool json_string(const char *body, const char *key, char *output, size_t output_size) +typedef struct { + const char *key; + char *value; + size_t capacity; + bool seen; +} json_field_t; + +static void json_space(const char **cursor) { - char pattern[96]; - const char *start, *end; - size_t length, index; + while (**cursor == ' ' || **cursor == '\t' || **cursor == '\r' || **cursor == '\n') { (*cursor)++; } +} - if (snprintf(pattern, sizeof(pattern), "\"%s\":\"", key) < 0) - { - return false; - } - start = strstr(body, pattern); - if (!start) - { - return false; - } - start += strlen(pattern); - end = strchr(start, '"'); - if (!end) - { - return false; - } - length = (size_t)(end - start); - if (length == 0 || length >= output_size) - { - return false; - } - for (index = 0; index < length; index++) - { - unsigned char byte = (unsigned char)start[index]; - if (byte < 0x20 || byte == '\\') - { - return false; - } - } - memcpy(output, start, length); - output[length] = '\0'; - return true; +/* The typed protocol contains only canonical ASCII tokens, not arbitrary JSON + * strings. Escapes, nested values, unknown fields and duplicate keys fail closed. */ +static bool json_token(const char **cursor, char *output, size_t capacity) +{ + size_t length = 0; + json_space(cursor); + if (*(*cursor)++ != '"') { return false; } + while (**cursor && **cursor != '"') { + unsigned char byte = (unsigned char)**cursor; + if (byte < 0x20 || byte > 0x7e || byte == '\\' || length + 1 >= capacity) { return false; } + output[length++] = *(*cursor)++; + } + if (**cursor != '"' || length == 0) { return false; } + (*cursor)++; output[length] = '\0'; return true; +} + +static bool json_object(const char **cursor, json_field_t *fields, size_t count, + json_field_t *result_fields, size_t result_count) +{ + char key[40]; + bool first = true, version_seen = false, result_seen = false; + size_t index; + json_space(cursor); + if (**cursor != '{') { return false; } + (*cursor)++; + for (;;) { + json_space(cursor); + if (**cursor == '}') { (*cursor)++; break; } + if (!first) { if (**cursor != ',') { return false; } (*cursor)++; } + first = false; + if (!json_token(cursor,key,sizeof(key))) { return false; } + json_space(cursor); if (**cursor != ':') { return false; } (*cursor)++; json_space(cursor); + if (result_fields && strcmp(key,"protocolVersion") == 0) { + if (version_seen || **cursor != '1') { return false; } + version_seen = true; (*cursor)++; + if (**cursor != ',' && **cursor != '}' && **cursor != ' ' && **cursor != '\t' && **cursor != '\r' && **cursor != '\n') { return false; } + continue; + } + if (result_fields && strcmp(key,"result") == 0) { + if (result_seen || !json_object(cursor,result_fields,result_count,NULL,0)) { return false; } + result_seen = true; continue; + } + for (index = 0; index < count; index++) { if (strcmp(key,fields[index].key) == 0) { break; } } + if (index == count || fields[index].seen || !json_token(cursor,fields[index].value,fields[index].capacity)) { return false; } + fields[index].seen = true; + } + return !result_fields || (version_seen && result_seen); +} + +static bool http_body(char *response, char **body) +{ + char *line, *end, *separator; + bool length_seen = false; + size_t expected = 0; + separator = strstr(response,"\r\n\r\n"); + if (!separator) { return false; } + line = strstr(response,"\r\n"); if (!line) { return false; } line += 2; + while (line < separator) { + char *colon; + end = strstr(line,"\r\n"); + if (!end || end > separator) { return false; } + colon = memchr(line,':',(size_t)(end-line)); + if (!colon) { return false; } + if ((size_t)(colon-line) == 17 && strncasecmp(line,"Transfer-Encoding",17) == 0) { return false; } + if ((size_t)(colon-line) == 14 && strncasecmp(line,"Content-Length",14) == 0) { + char *number = colon+1; + if (length_seen) { return false; } length_seen = true; + while (number < end && (*number == ' ' || *number == '\t')) { number++; } + if (number == end || *number < '0' || *number > '9') { return false; } + while (number < end && *number >= '0' && *number <= '9') { + expected = expected*10 + (size_t)(*number++ - '0'); + if (expected >= HTTP_BUFFER_SIZE) { return false; } + } + while (number < end && (*number == ' ' || *number == '\t')) { number++; } + if (number != end) { return false; } + } + line = end+2; + } + *body = separator+4; + return !length_seen || expected == strlen(*body); } static bool parse_response( @@ -280,30 +336,29 @@ static bool parse_response( { goto done; } - body = strstr(response, "\r\n\r\n"); - if (!body) - { - goto done; - } - body += 4; - if (!strstr(body, "\"protocolVersion\":1") || - !json_string(body, "agentInstanceId", agent, sizeof(agent)) || - !json_string(body, "deviceId", device, sizeof(device)) || - !json_string(body, "exchangeId", exchange, sizeof(exchange)) || - !json_string(body, "state", state, sizeof(state)) || - strcmp(agent, target->agent_instance_id) != 0 || - strcmp(device, target->device_id) != 0 || - strcmp(exchange, exchange_id) != 0) - { - goto done; - } + if (!http_body(response,&body)) { goto done; } + json_field_t envelope[] = { + {"agentInstanceId", agent, sizeof(agent), false}, + {"deviceId", device, sizeof(device), false}, + {"exchangeId", exchange, sizeof(exchange), false}, + }; + json_field_t values[] = { + {"state", state, sizeof(state), false}, {"res", res, sizeof(res), false}, + {"ck", ck, sizeof(ck), false}, {"ik", ik, sizeof(ik), false}, + {"auts", auts, sizeof(auts), false}, + }; + const char *cursor = body; + if (!json_object(&cursor,envelope,3,values,5)) { goto done; } + json_space(&cursor); + if (*cursor != '\0' || !envelope[0].seen || !envelope[1].seen || !envelope[2].seen || !values[0].seen || + strcmp(agent,target->agent_instance_id) != 0 || strcmp(device,target->device_id) != 0 || strcmp(exchange,exchange_id) != 0) { goto done; } if (strcmp(state, "success") == 0) { size_t res_bytes; - if (!json_string(body, "res", res, sizeof(res)) || strlen(res) < 8 || + if (!values[1].seen || values[4].seen || strlen(res) < 8 || strlen(res) > SIMPLUS_AKA_RES_MAX * 2 || strlen(res) % 2 != 0 || - !json_string(body, "ck", ck, sizeof(ck)) || - !json_string(body, "ik", ik, sizeof(ik))) + !values[2].seen || + !values[3].seen) { goto done; } @@ -320,7 +375,7 @@ static bool parse_response( } else if (strcmp(state, "synchronization-failure") == 0) { - if (!json_string(body, "auts", auts, sizeof(auts)) || + if (!values[4].seen || values[1].seen || values[2].seen || values[3].seen || !decode_hex(auts, SIMPLUS_AKA_AUTS_LEN, result->auts)) { goto done; @@ -482,7 +537,7 @@ bool simplus_simaka_agent_authenticate( } response_length += (size_t)count; } - if (response_length == 0 || response_length >= sizeof(response) - 1) + if (response_length == 0 || response_length >= sizeof(response) - 1 || memchr(response, '\0', response_length) != NULL) { if (stage) { diff --git a/components/strongswan-simplus-simaka/tests/agent_test.c b/components/strongswan-simplus-simaka/tests/agent_test.c index 5a8c825..6fc224d 100644 --- a/components/strongswan-simplus-simaka/tests/agent_test.c +++ b/components/strongswan-simplus-simaka/tests/agent_test.c @@ -64,12 +64,48 @@ static void test_rejects_mismatch_and_noncanonical_hex(void) assert(result.state == SIMPLUS_SIMAKA_RESULT_FAILED); } +static void test_strict_protocol_and_truncation(void) +{ + const char *invalid[] = { + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":10,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1.0,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"},\"unknown\":1}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}{}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"other\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"auts\":\"000102030405060708090a0b0c0d\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1F\"}}", + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\nContent-Length: 273\r\nContent-Length: 273\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}", + }; + char buffer[2048]; + simplus_simaka_result_t result; + size_t index, byte; + for (index = 0; index < sizeof(invalid)/sizeof(invalid[0]); index++) { + strcpy(buffer,invalid[index]); memset(&result,0xa5,sizeof(result)); + assert(!simplus_simaka_parse_response_for_test(&target,"00112233445566778899aabbccddeeff",buffer,&result)); + for (byte = 0; byte < sizeof(result); byte++) { assert(((unsigned char*)&result)[byte] == 0); } + } + const char *valid = "HTTP/1.1 200 OK\r\n\r\n{\"protocolVersion\":1,\"agentInstanceId\":\"01234567-89ab-cdef-0123-456789abcdef\",\"deviceId\":\"usb-1-3\",\"exchangeId\":\"00112233445566778899aabbccddeeff\",\"result\":{\"state\":\"success\",\"res\":\"01020304\",\"ck\":\"000102030405060708090a0b0c0d0e0f\",\"ik\":\"101112131415161718191a1b1c1d1e1f\"}}"; + for (index = 0; index < strlen(valid); index++) { + memcpy(buffer,valid,index); buffer[index] = '\0'; + assert(!simplus_simaka_parse_response_for_test(&target,"00112233445566778899aabbccddeeff",buffer,&result)); + } + strcpy(buffer,"HTTP/1.1 200 OK\r\n\r\n{\n \"protocolVersion\": 1,\n \"agentInstanceId\": \"01234567-89ab-cdef-0123-456789abcdef\",\n \"deviceId\": \"usb-1-3\",\n \"exchangeId\": \"00112233445566778899aabbccddeeff\",\n \"result\": {\n \"state\": \"success\",\n \"res\": \"01020304\",\n \"ck\": \"000102030405060708090a0b0c0d0e0f\",\n \"ik\": \"101112131415161718191a1b1c1d1e1f\"\n }\n}"); + assert(simplus_simaka_parse_response_for_test(&target,"00112233445566778899aabbccddeeff",buffer,&result)); + simplus_simaka_result_clear(&result); +} + int main(void) { assert(strcmp(simplus_simaka_exchange_stage_name( SIMPLUS_SIMAKA_EXCHANGE_CONNECT), "connect") == 0); assert(strcmp(simplus_simaka_exchange_stage_name( (simplus_simaka_exchange_stage_t)99), "not-started") == 0); + test_strict_protocol_and_truncation(); test_success(); test_sync_failure(); test_rejects_mismatch_and_noncanonical_hex(); diff --git a/compose.yaml b/compose.yaml index 3d56adc..590b87d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -101,8 +101,8 @@ services: - /run/netns:rw,noexec,nosuid,nodev,mode=0755,size=4m volumes: - type: bind - source: ./data/core - target: /var/lib/simplus + source: ./data/control-v2/mihomo + target: /var/lib/simplus/mihomo - type: volume source: agent-runtime target: /run/simplus-agent @@ -124,7 +124,7 @@ services: retries: 6 start_period: 15s restart: unless-stopped - stop_grace_period: 20s + stop_grace_period: 45s pids_limit: 512 networks: [runtime] @@ -147,7 +147,7 @@ services: - /tmp:rw,noexec,nosuid,nodev,mode=0700,size=16m volumes: - type: bind - source: ./data/core + source: ./data/control-v2 target: /var/lib/simplus - type: volume source: agent-runtime @@ -173,11 +173,11 @@ services: retries: 6 start_period: 15s restart: unless-stopped - stop_grace_period: 20s + stop_grace_period: 45s pids_limit: 256 networks: [runtime] - bootstrap: + provision: image: ghcr.io/leonfox28/simplus-control:${SIMPLUS_IMAGE_TAG:?set SIMPLUS_IMAGE_TAG for source-tree development validation} user: "0:0" entrypoint: ["/usr/local/bin/simplusctl"] @@ -197,7 +197,7 @@ services: read_only: true volumes: - type: bind - source: ./data/core + source: ./data/control-v2 target: /var/lib/simplus depends_on: app: diff --git a/containers/data-init.sh b/containers/data-init.sh index e292f52..3cc5217 100644 --- a/containers/data-init.sh +++ b/containers/data-init.sh @@ -10,6 +10,10 @@ fail() { root=/data [ ! -L "$root" ] && [ -d "$root" ] || fail '/data must be a real bind-mounted directory' +# Refuse the previous layout before chmod/chown, seeding or directory creation. +[ ! -e "$root/core" ] && [ ! -L "$root/core" ] || fail 'legacy data layout detected; preserve it and select a new installation directory' +[ ! -e "$root/control-v2/db" ] && [ ! -L "$root/control-v2/db" ] || fail 'legacy control database detected; refusing to modify existing data' + prepare_directory() { path=$1 owner=$2 @@ -24,15 +28,15 @@ prepare_directory() { chmod "$mode" "$path" } -prepare_directory "$root/core" 10001 10001 0700 +prepare_directory "$root/control-v2" 10001 10001 0700 prepare_directory "$root/agent" 10002 10002 0700 -prepare_directory "$root/core/mihomo" 10001 10001 0700 -prepare_directory "$root/core/mihomo/runtime" 10001 10001 0700 -prepare_directory "$root/core/mihomo/runtime/ui" 10001 10001 0755 -prepare_directory "$root/core/mihomo/versions" 10001 10001 0700 +prepare_directory "$root/control-v2/mihomo" 10001 10001 0700 +prepare_directory "$root/control-v2/mihomo/runtime" 10001 10001 0700 +prepare_directory "$root/control-v2/mihomo/runtime/ui" 10001 10001 0755 +prepare_directory "$root/control-v2/mihomo/versions" 10001 10001 0700 -ui=$root/core/mihomo/runtime/ui -[ "$ui" = '/data/core/mihomo/runtime/ui' ] || fail 'refusing an unexpected Zashboard target' +ui=$root/control-v2/mihomo/runtime/ui +[ "$ui" = '/data/control-v2/mihomo/runtime/ui' ] || fail 'refusing an unexpected Zashboard target' source_ui=/usr/share/simplus/zashboard source_version=$(cat "$source_ui/VERSION") installed_version= @@ -44,7 +48,7 @@ if [ "$installed_version" != "$source_version" ] || [ ! -f "$ui/index.html" ]; t cp -a "$source_ui/." "$ui/" fi -mihomo_root=$root/core/mihomo +mihomo_root=$root/control-v2/mihomo mihomo_seed=/usr/share/simplus/mihomo mihomo_version=$(cat "$mihomo_seed/VERSION") mihomo_archive_sha256=$(cat "$mihomo_seed/ARCHIVE_SHA256") @@ -91,9 +95,9 @@ else mv "$manifest_tmp" "$core_manifest" fi -chown -R 10001:10001 "$root/core" +chown -R 10001:10001 "$root/control-v2" chown -R 10002:10002 "$root/agent" -chmod 0700 "$root/core" "$root/agent" "$root/core/mihomo" "$root/core/mihomo/runtime" "$root/core/mihomo/versions" +chmod 0700 "$root/control-v2" "$root/agent" "$root/control-v2/mihomo" "$root/control-v2/mihomo/runtime" "$root/control-v2/mihomo/versions" find "$ui" -type d -exec chmod 0755 {} + find "$ui" -type f -exec chmod 0644 {} + diff --git a/docs/README.md b/docs/README.md index 30fdd4b..bc33dff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,8 @@ | [`privacy-and-publication.md`](privacy-and-publication.md) | 公开/私有记录边界和发布前检查 | 规范性 | | [`decisions/`](decisions/) | 影响产品范围或架构的重要决策 | 决策记录 | +当前重构的存储、安装和通知决策见 [0028](decisions/0028-control-state-and-durable-notifications.md)。 + ## 按任务阅读 - 改产品范围:先读 `product.md`,并新增或更新 decision; diff --git a/docs/architecture.md b/docs/architecture.md index f5e167c..1a7393d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ LAN Browser Vite dev server or control container Vite-built React SPA │ /api ▼ -control container: simplusd ───── ./data/core/SQLite +control container: simplusd ───── ./data/control-v2/SQLite ├── shared Unix socket ──► agent container (network none) │ └── bounded host /sys + /dev ──► modem(s) └── shared Unix socket ──► netd container (Docker bridge) @@ -23,7 +23,7 @@ control container: simplusd ───── ./data/core/SQLite production 以 Docker Compose 保留三个进程/镜像的权限分离;它不是 privileged 单体, 也不使用 host network。固定 UID 10001/10002 和共享 runtime volume 让现有 Unix -`SO_PEERCRED` 鉴权跨容器成立。持久数据只通过 `./data/core` 与 `./data/agent` 映射; +`SO_PEERCRED` 鉴权跨容器成立。持久数据只通过 `./data/control-v2` 与 `./data/agent` 映射; 首次容器部署是新实例,不猜测迁移原生 `/var/lib`。宿主只持有 Docker、内核与开机 加载的 USB serial `option` 模块,边界见 [`0021`](decisions/0021-container-production-deployment.md)。 当前开发 VM 的真实容器 HIL 已覆盖 Mihomo、Host VoWiFi 和单段自号码短信回环; @@ -137,9 +137,9 @@ Web/API -> application/Line -> typed service port -> Agent capability -> model a - 单一前端栈是 React 19、Vite、React Router Declarative Mode、直接使用的 Ant Design 6 与 TanStack Query;Umi Max、Ant Design Pro Components、ProLayout 和 Umi runtime 已原子移除,不保留第二套路由、UI 或服务端状态栈; -- `AppProviders` 显式装配 Ant Design 与 QueryClient,`BootstrapGate` 统一处理 setup、 +- `AppProviders` 显式装配 Ant Design 与 QueryClient,`SessionGate` 统一处理安装就绪、 管理员 session 和 401 恢复,`AppRouter` 与响应式 `AppShell` 拥有公共/受保护路由、 - 桌面 Sider 和手机 Drawer;Login/Setup 继续使用独立页面壳; + 桌面 Sider 和手机 Drawer;Login 使用独立页面壳; - `api/openapi.yaml` 由 `@hey-api/openapi-ts` 同时生成 Fetch SDK、TypeScript 类型、 Zod 结构 schema 和 TanStack Query keys/options。生成目录是公共 operation/payload/query identity 的唯一浏览器 owner;手写 runtime 只负责同源 @@ -148,12 +148,12 @@ Web/API -> application/Line -> typed service port -> Agent capability -> model a - 服务端和 SQLite 是业务状态唯一权威。浏览器通过 HTTP 读取快照和提交 mutation, TanStack Query 只保存可丢弃的页面快照;query 只有在网络或服务端明确标记可重试时 有界重试,mutation 默认不自动重试,敏感或业务数据不写入浏览器持久存储; -- 同源 `GET /api/v1/events` 经过 setup、管理员 session 与可信 LAN gate,只发送有界 +- 同源 `GET /api/v1/events` 经过安装就绪、管理员 session 与可信 LAN gate,只发送有界 资源失效、重连 resync 与新短信/来电 attention hint。事件不携带正文、号码、身份、 路径、拓扑、命令或诊断材料,也不构成业务真相源;客户端只失效当前活跃 query, 再经 HTTP 取得权威快照,慢连接和丢失 hint 不得阻塞 mutation、后台同步或造成 无限队列; -- Messages 使用首次成功持久化时由 messages SQLite 分配的 `recordSequence` 稳定倒序, +- Messages 使用首次成功持久化时由 control SQLite 分配的 `recordSequence` 稳定倒序, Calls 继续使用 `(createdAt, stable ID)`;两者使用互相隔离的 opaque keyset cursor。 短信产品会话只以 exact remote address 标识并跨 Line 合并;全局、remote-only 以及兼容 的 Line + remote 查询都由匹配 sequence 索引支持,Line-only 继续拒绝。每条消息仍保存 @@ -161,7 +161,7 @@ Web/API -> application/Line -> typed service port -> Agent capability -> model a - 短信会话摘要由后端分页返回最后消息、持久未读数和最近出站 Line。浏览器只有在会话 detail 实际可见、页面前台且 remote-only 最新页成功渲染后,才原样提交该 HTTP snapshot 的 opaque read-through token;联系人只在 Web 以 exact 号码关联名称,不拥有会话身份; -- 登录、基础初始化以及导航中的模组、线路、短信、语音、Mihomo、通知和系统设置页面 +- 登录以及导航中的模组、线路、短信、语音、Mihomo、通知和系统设置页面 使用同一套直接 Ant Design 视觉语言;桌面以 Table/Sider 为主,手机以 Card/List 与 Drawer 为主,宽表只在自身容器滚动,加载、空、错误、部分失败和不可用原因明确可见; - 模组页只展示管理员已添加的模组,主表固定为型号、当前模块序列号(不可用时回退 USB Serial)、默认隐藏且按需实时读取的 IMEI、在线状态、SIM 插入状态与射频开关;“添加模组”对话框以单选表格展示未添加候选的相对 USB 地址、VID:PID、型号、脱敏 USB 序列标识、支持状态、类型化不可添加原因和能力; @@ -255,7 +255,9 @@ request -> validate -> enqueue -> execute -> observe -> persist result -> notify 写入权威状态,浏览器再通过 HTTP 重新读取。SSE 不能承载 mutation 结果或建立第二套 事件状态。 -旧的 ResourceGroup lease 应用编排器已移除;已发布的 runtime migration 00005 和 SQLite lease repository 只作为数据与迁移兼容的 dormant fixture 保留,不是 production capability,也不是新 application 类型的来源。独立的 `radio.ensure-off` outcome/fencing ledger 仍保留,但 production Agent 不注册该命令。新的 RF 路径只接受目标开关状态,在 Agent 内串行执行固定命令并立即读回;新的 SMS/Call 纵切也不应扩展通用分布式命令平台。 +通用 ResourceGroup lease 仓储、旧 `radio.ensure-off` 命令账本及占位 replay backend +已删除。保留实际设备/SIM 身份校验、Agent 共享设备互斥及短信专用恢复机制。 +RF 只接受布尔目标并读回确认;生产能力不扩展到任意命令。 ## 5. 关键数据流 @@ -274,7 +276,7 @@ Simulator 继续使用进程内 Local Agent client。Host VoWiFi Line 使用独 ```text modem/IMS worker -> bounded typed read -> simplusd - -> transactionally persist raw/decoded message + unread marker + -> transactionally persist raw/decoded message + unread marker + notification tasks -> confirm persistence -> delete/ack modem copy when applicable -> bounded messages invalidation/attention @@ -296,29 +298,39 @@ Web -> emergency/number validation -> modem worker ## 6. 存储 -当前代码使用五个 SQLite 数据库和独立录音目录。这不是 MVP 必须维持的领域边界,但立即合库会延迟核心功能,因此: - -- 当前 migration 和数据库继续工作; -- messages v8 以 `record_sequence INTEGER PRIMARY KEY AUTOINCREMENT` 记录每条短信首次成功 - 持久化的全局顺序,并用 remote/Line + remote sequence 索引支持历史与摘要。v7 历史按 - 入站 `updatedAt`、出站 `createdAt` 回填,再以原业务时间与 message ID 确定性打破平局; - `sms_message_unread` 继续以独立 `AUTOINCREMENT` 到达序号记录首次入站,计数从 marker - 派生,message 删除级联 marker,旧 v6 历史升级时 ledger 为空; -- core 数据库保存 Host VoWiFi `desired_active`,但不保存网络运行事实或鉴权材料; -- core v23 以独立 `feishu_app_notification_channels` 表保存飞书应用私聊渠道;App ID、 - App Secret 与授权用户 `open_id` 使用字段独立的实例密钥标签加密。旧 Webhook 渠道 - 继续留在 v12 表,两个变体通过应用层 `deliveryMode` 合并读取; -- 新表放到语义最接近的现有库; -- 不新增 dataset identity、备份协议或跨库事务框架; -- MVP 后再评估是否合并为一个 SQLite 数据库; -- 目录与数据库保持普通 `0700/0600` 权限即可,不继续扩展 inode/mount 身份策略。 +control 使用单一 `state/control.sqlite3`,覆盖账户、会话、模组、线路、消息、未读、 +联系人、通话、Mihomo、通知与消费检查点。Agent 的硬件短信恢复数据库保持独立。 +新布局使用 `simplus-control-state-v2` 标记;发现旧布局立即拒绝,绝不自动迁移或覆盖。 +安装时管理员与 `ready` 状态同事务提交,Web 不再承接 Bootstrap、硬件审查、录音目录 +或本地 CA 初始化。决策见 [0028](decisions/0028-control-state-and-durable-notifications.md)。 + +- 消息首次落库获得单调 `record_sequence`,未读 marker 与入站通知任务同事务; +- 已配置的 VoWiFi 激活意图持久化,网络运行事实来自 supervisor; +- 飞书应用凭据按字段标签加密,Webhook secret 和实例密钥不进入公共响应; +- sqlc schema 统一来自 `migrations/control`,新表和约束只在 control 中演进; +- 数据目录/文件为 `0700/0600`,netd 只访问 Mihomo 子目录与共享运行目录。 + +### 持久通知与连接观测 + +通知任务由独立 worker 投递。暂时失败按 15 秒到 5 分钟退避,明确永久拒绝保存安全 +原因码;进程重启回收未完成租期。相同渠道、对象、连接类型严格按顺序领取,其他 +对象独立推进。停用、取消订阅、删除渠道或可删除的业务记录会取消对应待投递任务。 +消息平台不提供事务边界,响应丢失可能造成重复投递。 + +VoWiFi 从 supervisor 的实际在线状态迁移生成事件,类型化接口包含实例标识、序号、 +时间和安全原因,缓存 1024 条。control 用带游标的快照启动,用变化接口续读;来源 +重启或游标溢出时重新校准并累计监测缺口,不猜测缺口中的状态。蜂窝按模组独立每 +5 秒探测,拓扑变化提前调度,复用 Agent 身份校验与设备互斥。未知/超时保留上次 +确认状态。首次在线通知,首次离线仅建基线;之后每次确认变化立即通知,不加等待。 +本地/漫游互换、信号和制式变化不触发连接事件。检查点、游标和通知任务同事务。 +新订阅不回放历史,连接通知仅包含显示名称、连接类型、状态、时间和确认原因。 ### 飞书通知绑定 ```text Web POST -> 内存中的单实例绑定状态 -> 固定 accounts.feishu.cn 设备授权轮询 -> 授权结果校验 -> 固定 open.feishu.cn 私聊测试 - -> 三字段独立加密 -> core v23 应用渠道行 -> notifications 失效提示 + -> 三字段独立加密 -> control 应用渠道行 -> notifications 失效提示 ``` 验证 URL、device code 和等待状态不进入 SQLite、SSE 或日志;普通渠道列表只返回 @@ -348,16 +360,28 @@ Web POST -> 内存中的单实例绑定状态 -> 固定 accounts.feishu.cn 设 `(createdAt, stable ID)` keyset。两者都不能退化为 offset 或互相接受对方的 cursor。 17. 短信会话只按 exact remote address 跨 Line 合并;Line 仍是每条消息的事实与发送时的 显式身份,最近 Line 不可用时不得静默切换。 -18. 未读只能由首次入站持久化在 messages 数据集内原子创建 marker;已读只能使用成功 +18. 未读只能由首次入站持久化在 control 事务内原子创建 marker;已读只能使用成功 显示的 HTTP snapshot opaque 水位清除不晚于它的 marker,SSE 不承载未读真相。 这些规则应优先由类型、测试和小型检查器强制执行,而不是在多份文档中重复描述。 ## 8. 当前技术债处理原则 -- 已完成但超出新 MVP 的 setup/auth/topology 代码先保持可用;ResourceGroup lease - 应用编排器已删除,只有已发布 migration 与 SQLite fixture 为兼容保留; +- 旧 setup、通用租约、命令占位和多库布局已按 ADR 0028 删除;新数据目录明确隔离旧实例; - 不为删代码而中断短信纵切; - 当旧抽象实际阻碍一个纵切时,用小型执行计划删除或折叠; - 每次只保留一个业务真相源,避免在 daemon、Agent 和 Web 分别维护同一状态; - 新设计优先选普通 Go、SQLite、Unix socket 和明确 JSON schema。 + +## 9. 实现与生命周期边界 + +领域值与规则位于 `internal/domain`;应用用例通过小接口编排,不导入 Unix 客户端、 +外部 HTTP、文件或进程实现。`agentinventory`、`modemagent`、`smstransport`、`feishu`、 +`subscriptionhttp` 和 `mihomoassets` 是外层适配器;生产 SIP/IMS/strongSwan 由 +`internal/ims` 拥有,`internal/vowifihil` 只保留显式验证入口。 +`internal/architecture` 的导入检查与各边界行为测试共同约束这一方向。 + +入口在构造时装配依赖,禁用功能使用明确实现。HTTP 按认证、设备、通信、网络和通知 +拆分并共享安全 gate、错误映射与超时。退出先停止接入,取消并等待请求、SSE、绑定、 +通知及各线路后台任务,再回收子进程和网络,最后关闭数据库。同模组操作仍串行, +各线路独立超时与退避;慢操作不持有全局状态锁。 diff --git a/docs/decisions/0028-control-state-and-durable-notifications.md b/docs/decisions/0028-control-state-and-durable-notifications.md new file mode 100644 index 0000000..b365565 --- /dev/null +++ b/docs/decisions/0028-control-state-and-durable-notifications.md @@ -0,0 +1,28 @@ +# 0028:统一 control 状态、安装初始化与持久通知 + +- 状态:Accepted +- 日期:2026-09-30 + +保留 Go、React、SQLite 和 control/agent/netd 三进程。此次重构允许破坏旧 API 和 +数据库兼容性;使用独立的新数据布局,发现旧布局拒绝启动,不迁移、删除或覆盖旧数据。 +control 的业务状态收敛到一个 SQLite 数据库,Agent 的硬件短信恢复数据库保持独立。 +管理员创建与实例就绪在安装事务中完成,登录直接进入管理界面,删除 Bootstrap、本地 +CA、首次登录硬件检查及录音目录流程。此决定取代此前这些流程与多库兼容性的要求。 + +通知使用持久待投递记录。入站短信、未读标记和投递任务同事务,提交后才确认硬件消息。 +worker 独立投递,暂时失败从 15 秒指数退避至 5 分钟,永久失败保留安全原因。重启恢复 +未完成任务;外部响应丢失允许重复投递,不承诺外部恰好一次。停用、取消订阅或删除 +渠道/业务对象会取消相关未投递任务,不补发订阅前的事件。 + +增加 `vowifi.connected`、`vowifi.disconnected`、`cellular.connected`、 +`cellular.disconnected`。首次确认在线通知,首次离线建立基线;每次确认变化立即通知, +不增加断线等待或合并短暂变化。未知和通信失败保留上次确认状态。蜂窝连接仅指本地/ +漫游注册;5 秒只读探测无法保证捕捉两次采样之间已恢复的变化。 + +VoWiFi supervisor 提供携带实例、序号、观测时间和安全原因码的有序变更及带游标快照, +保留最近 1024 条。来源重启或游标过期重新校准并记录监测缺口,不猜测中间状态。 +监测检查点、游标与通知任务原子提交;同对象、连接类型和渠道依次投递,其他对象独立。 +连接消息只包含显示名称、连接类型、状态、时间和可确认的原因,不包含设备/SIM 身份、 +手机号、私有网络或原始协议错误。新渠道默认订阅全部九类事件。 + +验证使用临时 SQLite、Simulator、协议 fixture 和浏览器集成;不包含部署或真实硬件操作。 diff --git a/docs/development.md b/docs/development.md index 3e5481f..220f672 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,7 +47,9 @@ corepack pnpm --dir web e2e ``` `build` 输出 `web/dist`,由 production `simplusd` 与匹配的 API 一起承载;`e2e` -使用本地脱敏 fixture,不是硬件或真实通信测试。OpenAPI 或生成 client 发生变化时还要 +使用本地脱敏 fixture,不是硬件或真实通信测试。`corepack pnpm --dir web e2e:simulator` +另外构建真实 control 后端和静态 Web,在临时数据目录初始化管理员并验证管理、短信、 +电话及连接通知。该测试仅用 Simulator,所有通知请求经过本地拒绝代理,不联系通知平台。OpenAPI 或生成 client 发生变化时还要 运行 `make verify-generated`。旧 Umi/Pro 输入已经删除;不要重新引入第二套路由、UI 或服务端状态栈。 @@ -62,7 +64,7 @@ make dev-sim ```text Web: http://127.0.0.1:5173 API: http://127.0.0.1:8080 -Data: /.dev/data +Data: /.dev/data (新布局:state/control.sqlite3) ``` 从同一受信任 LAN 的另一台设备访问时显式运行: @@ -75,7 +77,8 @@ make dev-sim-lan 继续使用 loopback,并由开发服务器代理同源 `/api`,包括有界 SSE 失效流。该普通 HTTP 入口只用于可信开发网络。 -全新开发数据库需要先由 root control plane 创建唯一管理员。在运行服务的主机终端 +旧的 `db/` 布局会明确拒绝启动,保留旧数据并通过 `SIMPLUS_DATA_ROOT` 选择独立新目录; +不会自动迁移或删除。全新开发数据库需要先由 root control plane 创建唯一管理员。在运行服务的主机终端 执行;`` 必须与启动 Simulator 时使用的绝对数据目录一致: ```bash @@ -87,8 +90,8 @@ sudo "$PWD/.dev/bin/simplusctl-dev" provision-admin \ ``` 命令只在首次创建时显示一次随机密码。浏览器随后打开 -`http://:5173/login`;管理员首次登录会取得受限 setup session 并进入 -`/setup`,完成录音目录后再进入后台,不需要把 root Bootstrap URL 复制到浏览器。 +`http://:5173/login`;管理员登录后直接进入 +管理界面;管理员创建与实例就绪已在安装事务中完成。 不要把管理员密码、Cookie 或开发数据库写进问题、文档或测试 fixture。 ## 3. 真实硬件 HIL-0 diff --git a/docs/engineering/backend/api-contracts.md b/docs/engineering/backend/api-contracts.md index 3c777d9..26177e4 100644 --- a/docs/engineering/backend/api-contracts.md +++ b/docs/engineering/backend/api-contracts.md @@ -190,14 +190,14 @@ channel is durable, credentialless at the public boundary, and outbound-only. transient attempt. - `POST /api/v1/notification-channel-bindings/feishu` starts one attempt; `DELETE` cancels a waiting attempt. -- `Service.ConfigureFeishuBinding(processCtx, registrar, messenger, onChange)` - injects process lifetime and the two fixed-purpose provider ports. +- `notification.New(Dependencies)` receives process lifetime, registrar, messenger + and the invalidation callback before any binding can start. - `Service.StartFeishuBinding(context.Context)`, `Service.FeishuBindingStatus()`, and `Service.CancelFeishuBinding()` own the state machine. HTTP handlers do not poll providers or persist credentials. - `feishu_app_notification_channels` owns encrypted `app_id`, `app_secret`, and recipient `open_id` ciphertexts separately from the legacy Webhook - table. Its schema version is core v23. + table within the unified control database. ### 3. Contracts @@ -237,7 +237,7 @@ channel is durable, credentialless at the public boundary, and outbound-only. encryption and persistence. - App ID, App Secret, and recipient `open_id` use independent, versioned AEAD labels containing the channel ID. A successful row defaults to display name - `飞书私聊`, enabled, all five notification event kinds, and successful last + `飞书私聊`, enabled, all nine notification event kinds, and successful last delivery status. - Public `NotificationChannel` discriminates `deliveryMode` as `webhook` or `feishu_app` and `targetType` as `webhook` or `authorized_user`. App-channel @@ -381,3 +381,15 @@ commands, or device paths (`cmd/simplus-netd/main.go` and timeout that conflicts with the underlying operation budget. - Treating the Unix socket's filesystem mode as sufficient without preserving peer-credential and fixed-UID checks. + +## Durable notification and connection contracts + +`NotificationEventKind` has nine values; channel create/update accepts at most nine +unique selections. New Web channels and Feishu binding default to all nine. +Channel snapshots expose `pendingCount` (pending or delivering) and `failedCount` +(permanently failed). Provider credentials and raw failures remain private. + +Netd's typed connection snapshot/changes interface is separate from browser SSE. +It carries instance, sequence, observation time and bounded safe reason codes, +retains 1024 transitions, and requires reset on instance changes or expired +cursors. The HTTP browser API remains the source of displayed state. diff --git a/docs/engineering/backend/application-boundaries.md b/docs/engineering/backend/application-boundaries.md index 3fd469e..14c415d 100644 --- a/docs/engineering/backend/application-boundaries.md +++ b/docs/engineering/backend/application-boundaries.md @@ -1,1068 +1,89 @@ # Application and Hardware Boundaries -## Consumer-Owned Typed Ports - -Application services define only the dependencies required by their use case. -For example, `internal/application/messaging/service.go` defines `Repository`, -`LineSource`, `Sender`, `Inbox`, and `SubmitReportInbox`; its -`SendSMSCommand` carries business IDs, destination, body, and encoded segments -but deliberately carries no AT/QMI text or device path. Follow this shape when -adding a transport or repository capability: - -- accept `context.Context` on I/O and long-running work; -- use typed commands/results and stable domain IDs; -- validate mandatory dependencies in constructors (`messaging.NewService`, - `line.New`, and `modem.New` are current examples); -- add the smallest method needed by the current vertical slice. - -The HTTP layer follows the same rule. Interfaces such as `Messenger`, -`ManagedLineManager`, and `VoWiFiManager` in -`internal/api/httpapi/server.go` express handler needs and keep concrete -assembly in `cmd/simplusd/main.go`. - -## Scenario: Retired ResourceGroup lease application contract - -### 1. Scope / Trigger - -Apply this contract when considering a ResourceGroup lease feature, changing -the retained runtime lease tables/repository, or introducing an application -repository whose methods use persistence-owned values. An interface named -`Repository` is not a layer boundary when its parameters, results, or enum -branches are owned by `internal/storage/sqlite`. - -The former `internal/application/resourcelease` package was never assembled by -a production binary and has been removed. Do not recreate it merely to expose -the historical SQLite fixture. - -### 2. Signatures - -These current signatures belong only to the dormant storage fixture in -`internal/storage/sqlite/resource_leases.go`: - -```go -func (*Set) AcquireResourceGroupLease( - context.Context, - ResourceLeaseAcquire, -) (ResourceLease, bool, error) - -func (*Set) RenewResourceGroupLease( - context.Context, - string, - uint64, - time.Time, - time.Time, -) (ResourceLease, error) - -func (*Set) ReleaseResourceGroupLease(context.Context, string, uint64) error -func (*Set) ActiveResourceGroupLeases( - context.Context, - string, - time.Time, -) ([]ResourceLease, error) -``` - -`ResourceLeaseAcquire`, `ResourceLease`, `ResourceLeaseOperation`, and -`ResourceLeaseCall` are SQLite-package vocabulary. No supported application -signature accepts, returns, aliases, or re-exports them. - -### 3. Contracts - -- `internal/application/resourcelease` must remain absent unless a newly - approved current product feature establishes a real application use case. -- A future use case defines its request/result/kind vocabulary in the - application or protocol-neutral domain owner first. A concrete SQLite - adapter maps those values at the persistence boundary. -- Application validation must not branch on SQLite constants or treat the - retained storage record as the business source of truth. -- Runtime migration `00005_resource_group_leases.sql`, the SQLite repository, - and its focused test are historical compatibility/fixture infrastructure, - not evidence of a production capability. -- Removing dead application code does not authorize a schema change, deletion - of stored rows, a new command/API, or activation of the separate Agent - `radio.ensure-off` outcome/fencing ledger. - -### 4. Validation & Error Matrix - -| Condition | Required result | -| --- | --- | -| Application port accepts/returns `sqlite.ResourceLease*` | reject the design; define application/domain values and an explicit mapping | -| Application code aliases a SQLite lease type or branches on its kind constants | reject as persistence vocabulary leaking upward | -| No production caller and the old application policy is obsolete | remove the complete application package; do not preserve an unused facade | -| Dead application package is removed | retain the released migration and existing rows unchanged | -| A future product feature genuinely needs leasing | approve a new task, define the current business contract, then implement and test the mapping | -| A future change wants to drop lease tables or data | require a separately approved migration/data-compatibility decision and a new migration; never rewrite runtime v5 | - -### 5. Good / Base / Bad Cases - -- Good: a future vertical slice defines application-owned lease intent and a - narrow repository, while a SQLite adapter explicitly converts to its storage - representation and focused tests prove the round trip. -- Base: the dormant SQLite repository, real-SQLite tests, and embedded runtime - migration remain isolated with no application import or production caller. -- Bad: recreate `application/resourcelease`, alias `sqlite.ResourceLease`, or - call the SQLite fixture from an executable because the old tables already - exist. - -### 6. Tests Required - -- Focused scans must find no `internal/application/resourcelease` package and - no application use of `sqlite.ResourceLease*` or the SQLite lease-kind - constants. -- `internal/storage/sqlite/resource_leases_test.go` remains the focused - real-SQLite evidence for replay, fencing, expiry/reopen, conflict, and - concurrent acquisition behavior while the fixture is retained. -- Run `go test -count=1 ./internal/storage/sqlite`, then the supported - `./cmd/... ./internal/...` test/vet/lint scope to catch hidden imports or - production wiring. -- A task that does not change OpenAPI, generated sources, or migrations must - still run generated-drift and protected-file diff checks. No service, - private runtime database, hardware, or HIL validation is required. - -### 7. Wrong vs Correct - -```go -// Wrong: the interface name hides a concrete persistence contract. -type Repository interface { - Acquire(context.Context, sqlite.ResourceLeaseAcquire) ( - sqlite.ResourceLease, bool, error, - ) -} - -// Correct current shape: no application ResourceGroup lease contract exists. -// The retained methods stay inside internal/storage/sqlite and are called only -// by the storage-focused compatibility tests. - -// If a separately approved feature needs leasing, that task defines its -// business vocabulary first and adds an explicit persistence mapping; this -// retired contract does not prescribe the future API. -``` - -## Scenario: HTTP-owned adjacent application ports - -### 1. Scope / Trigger - -Apply this contract when changing `httpapi.Server`, `httpapi.New`, Setup or -inventory handlers, system health, authenticated realtime streaming, or -`WithRealtime`. HTTP owns the operations it consumes; `cmd/simplusd` owns the -concrete application implementations supplied at runtime. - -### 2. Signatures - -The HTTP boundary owns these exact roles: - -```go -type HealthReader interface { - Snapshot(context.Context) (health.Snapshot, error) -} - -type SetupManager interface { - Status(context.Context) (setup.Status, error) - ConsumeBootstrap(context.Context, string) (setup.SessionGrant, error) - ReadSession(context.Context, string) (setup.Session, error) - ConfigureAdministrator(context.Context, string, setup.AdministratorInput) (setup.Session, error) - ConfigureStorage(context.Context, string, setup.StorageInput) (setup.Session, error) - ConfigureHTTPS(context.Context, string, setup.HTTPSInput) (setup.Session, error) - ConfirmHTTPS(context.Context, string, string) (setup.Session, error) - ReadRootCertificate(context.Context, string) ([]byte, string, error) - ConfirmHardwareReview(context.Context, string, setup.HardwareReviewInput) (setup.Session, error) - Complete(context.Context, string, setup.HardwareReviewInput) (setup.Completion, error) - BeginAdministratorSetup(context.Context) (setup.SessionGrant, error) -} - -type InventoryReader interface { - Snapshot(context.Context) (inventory.Snapshot, error) - Topology(context.Context) (inventory.Topology, error) -} - -type RealtimeManager interface { - Subscribe() *realtime.Subscription - Publish([]realtime.Topic, realtime.Attention) -} -``` - -`Server` stores those four interfaces. The first three `New` parameters use -the corresponding roles, and `WithRealtime(*Server, RealtimeManager)` supplies -the fourth. Argument order, logger defaults, variadic contacts and the -`*Server` return shape remain unchanged. - -### 3. Contracts - -- Every method above has a live HTTP call. Setup deliberately excludes - `GenerateBootstrap` and `ProvisionAdministrator`, which belong to other - composition/control paths. -- Application-owned typed inputs and results may appear in port signatures; - concrete application implementation pointers may not appear in HTTP fields - or construction parameters. -- `cmd/simplusd` injects the existing `*health.Service`, `*setup.Service`, - `*inventory.Service`, and `*realtime.Hub` by structural satisfaction. Do not - add an adapter or move concrete selection into HTTP. -- Interface values are stored unchanged. `httpDependencyMissing` is used only - by `StreamEvents`, `realtimeSessionValid`, `publish`, and `Login` so those - optional availability decisions treat typed nil as absent. -- Do not normalize typed-nil Health/Setup/Inventory values to a true nil - interface: their concrete nil-receiver methods return the existing bounded - configuration errors used by HTTP mappings. -- This boundary refactor changes no OpenAPI schema/route, cookie, status/error - code, Setup transition, health/inventory payload, realtime topic/attention, - heartbeat/session behavior, generated source or application implementation. - -### 4. Validation & Error Matrix - -| Condition | Required behavior | -| --- | --- | -| Production concrete services and Hub | structurally satisfy the four ports; no wrapper or caller behavior change | -| Independent fake implementations | `New`/`WithRealtime` accept them without a concrete service value | -| raw or typed-nil Setup in Login/realtime validation | treat as absent; skip optional Login Setup work and fail realtime validation closed | -| raw or typed-nil Realtime manager | stream returns `EVENT_STREAM_UNAVAILABLE`; publication is a no-op | -| typed-nil concrete Health/Setup/Inventory called by a handler | dispatch its nil-receiver method and retain the pre-refactor bounded error path | -| extra method added to a port | focused exact-method-set test fails until a live HTTP need is demonstrated | -| `WithRealtime(nil, manager)` | return nil without dereferencing the server | - -### 5. Good / Base / Bad Cases - -- Good: production supplies the existing services, while an HTTP test supplies - four recording fakes and observes the same bounded mapping/publication flow. -- Base: realtime is not configured; authenticated stream setup returns the - existing stable unavailable error and ordinary publish calls do nothing. -- Bad: store `*setup.Service` directly, add every exported Setup method to the - interface, normalize typed nil to true nil, or introduce a general service - registry/event bus. - -### 6. Tests Required - -- Compile-time assertions for all four production implementations and four - independent fake implementations. -- A handwritten exact method-name assertion for each interface, including the - negative absence of Setup's control-only methods. -- Fake-only construction plus observable `Subscribe` and `Publish` calls. -- Raw/typed-nil optional availability cases and separate typed-nil concrete - Health/Setup/Inventory receiver-error cases. -- Existing `httptest` coverage for Setup gates, Health/Inventory mapping, - authenticated SSE, heartbeat/session expiry and publication remains green. -- Run focused HTTP and command tests under `-race`, then the supported - `./cmd/... ./internal/...` tests/vet/lint and generated-drift checks. - -### 7. Wrong vs Correct - -```go -// Wrong: concrete coupling plus an interface conversion that loses nil state. -type Server struct { setup *setup.Service } -if server.setupPort != nil { // typed-nil pointer inside the interface is non-nil - _, _ = server.setupPort.Status(ctx) -} - -// Correct: consumer-owned port and typed-nil-aware optional availability. -type Server struct { setup SetupManager } -if !httpDependencyMissing(server.setup) { - _, _ = server.setup.Status(ctx) -} -``` - -## Scenario: Background coordination and realtime invalidation - -### 1. Scope / Trigger - -Apply this contract whenever an executable starts a polling or long-polling -loop whose result determines notification intent, realtime invalidation, -attention metadata, retry policy, or other business meaning. The executable -owns construction, configured intervals, goroutine lifetime, shutdown and -operational log rendering. The owning application package owns result -interpretation, event/topic selection, side-effect ordering and retry state. - -### 2. Signatures - -The current SMS coordinator in `internal/application/messaging` consumes: - -```go -type InboundSyncer interface { - SyncInbound(context.Context) (InboundSyncResult, error) -} -type NotificationSender interface { - NotifyReceivedSMS(context.Context, string, string) error - NotifyReceivedSMSSummary(context.Context, int) error -} -type RealtimePublisher interface { - Publish([]realtime.Topic, realtime.Attention) -} - -func NewSyncCoordinator(InboundSyncer, NotificationSender, RealtimePublisher) (*SyncCoordinator, error) -func (*SyncCoordinator) Run(context.Context, time.Duration, func(SyncReport)) -``` - -The Agent change coordinator in `internal/application/inventory` consumes a -separate smallest source instead of widening the Snapshot/Probe `AgentClient`: - -```go -type AgentChangeSource interface { - Snapshot(context.Context, bool) (agentapi.Snapshot, error) - Changes(context.Context, string, uint64, int) (agentapi.ChangeResponse, error) -} -type AgentChangePublisher interface { - Publish([]realtime.Topic, realtime.Attention) -} - -func NewAgentChangeCoordinator(AgentChangeSource, AgentChangePublisher) (*AgentChangeCoordinator, error) -func (*AgentChangeCoordinator) Run(context.Context, func(AgentChangeReport)) -``` - -`InboundSyncResult` keeps counters as its public reporting surface and carries -an unexported, narrow `{Sender, Body}` value for every newly persisted inbound -SMS. `SyncReport` carries the typed result, synchronization error, joined -notification error and durable-change decision. `AgentChangeReport` carries -operation `snapshot | changes` plus the source error. These reports let -`cmd/simplusd` preserve operational logs without importing `slog` into the -application coordinators or exposing SMS bodies to the executable. - -### 3. Contracts - -- SMS runs once immediately. Each `SyncInbound` call is bounded to 20 seconds. - `Persisted`, `AlreadyKnown`, `OutboundSent`, `OutboundFailed` or - `OutboundUnconfirmed` publishes `messages`; acknowledgement-only counters do - not. Only `Persisted > 0` attaches `sms.received` attention. -- Each successful first persistence appends exactly one private `{Sender, - Body}` notification value in discovery order. A repository replay appends - none. If persistence succeeds and acknowledgement fails, the partial result - retains the value, so that cycle notifies once and the later replay does not - notify again. Pass this narrow value instead of a domain `sms.Message`. -- Single-part inbound, outbound and fully assembled multipart bodies share the - same validation: non-blank valid UTF-8, at most 1600 runes and at most 6400 - bytes. Revalidate after multipart assembly before persistence; validating - fragments alone is insufficient. -- For every private value, the coordinator calls `NotifyReceivedSMS` in - discovery order under a fresh, detached 15-second context. Calls are - sequential and independent: collect an indexed, content-free error and - continue after a failure or timeout. After all per-SMS calls, invoke - `NotifyReceivedSMSSummary` once under its own detached 15-second context. -- The notification service renders each Feishu-only message as - `[Simplus] 新短信\n发件人:\n内容:\n` and preserves the complete - body, including Unicode and line breaks. The count summary remains once per - synchronization cycle for enabled non-Feishu channels only; SMS content must - never be sent to those channels. Existing generic notification behavior - remains unchanged. -- The coordinator publishes `notifications` after all notification attempts, - including when any delivery fails. Channel delivery status reflects the - latest individual attempt. Only the synchronization error selects retry; - notification failures are joined for reporting and never trigger a sync - retry or a persistent notification outbox. -- SMS intervals below one second normalize to two seconds. Synchronization - failure begins at `max(15s, interval*4)`, doubles to five minutes and resets - after a successful synchronization. Partial durable results are still - published/notified before the error selects retry. -- Agent watching starts with `Snapshot(ctx, false)` and calls `Changes` with - the current instance ID, generation and 25-second wait. Explicit changes and - instance/generation differences after reconnect publish exactly - `inventory`, `modems`, and `lines`, without attention. -- Agent retry starts at one second, doubles to 30 seconds and resets after a - successful change response. Cancellation stops pending retry waits. The - coordinator publishes no snapshot/device payload through realtime. -- Each coordinator owns its consumer interfaces even though the concrete - `realtime.Hub` implements both structurally. Do not replace them with a - generic background framework, catch-all event bus or concrete Hub field. - -### 4. Validation & Error Matrix - -| Condition | Required result | -| --- | --- | -| Any required SMS coordinator dependency is nil | `ErrSyncCoordinatorConfiguration`; do not start the loop | -| Any required Agent coordinator dependency is nil | `ErrAgentChangeCoordinatorConfiguration`; do not start the loop | -| SMS result contains N newly persisted values | publish `messages`, attempt N ordered Feishu content notifications with fresh deadlines, attempt one non-Feishu count summary, then publish `notifications` | -| One per-SMS Feishu attempt fails or times out | join an indexed error without sender/body, continue with every later SMS and the non-Feishu summary; a successful sync keeps the normal interval | -| Persistence succeeds but acknowledgement fails | retain and notify the private value in the partial result, report the sync error, then use retry delay; the replay must not notify again | -| Repository reports the SMS as already known | increment `AlreadyKnown` but append no private notification value and send no SMS-content notification | -| Assembled multipart body exceeds the shared SMS limit | return `ErrInboundSync` before persistence; do not expose a partial assembled message | -| SMS result contains only acknowledgement counters | no realtime publication or notification | -| Agent initial snapshot fails | report operation `snapshot`, wait with bounded retry, retain prior successful snapshot | -| Agent long-poll fails | report operation `changes`, reconnect with bounded retry | -| Reconnected instance or generation differs | publish Inventory/Modems/Lines once before resuming long-poll | -| Parent context is cancelled | stop pending interval/retry waits promptly and cancel in-flight sync/Agent source contexts; each notification already started or subsequently required by the completed result remains detached and individually bounded by its own 15-second deadline | - -### 5. Good / Base / Bad Cases - -- Good: One sync discovers two new SMS messages. The first Feishu attempt - fails, the second is still attempted with its own deadline, non-Feishu - channels receive one count summary, notification invalidation is published, - and the joined error contains only the ordinal and delivery error. -- Base: A replayed SMS increments `AlreadyKnown` without another content - notification. Independently, an unchanged Agent long-poll updates the - current snapshot, resets retry and continues without publishing. -- Bad: pass a complete `sms.Message` through the coordinator, reuse one timeout - across the batch, stop after the first Feishu failure, send body text to - WeCom, omit post-assembly body validation, or construct notification policy - in `cmd/simplusd`. - -### 6. Tests Required - -- `messaging/service_test.go`, `messaging/vowifi_sms_test.go` and inbound tests: - first-persistence-only private values, exact discovery order, persisted - sender/body, replay/restart suppression, persistence-plus-ACK-error partial - results, complete multipart body, and rejection after oversized assembly. -- `messaging/sync_coordinator_test.go`: required dependencies, exact - sync→Messages→ordered per-SMS attempts→summary→Notifications order, a fresh - detached/bounded context per attempt, continuation after failure, content-free - joined errors, acknowledgement-only behavior, retry isolation/reset/cap and - cancellation. -- `notification/service_test.go`: exact Unicode and multiline Feishu text in - webhook and app modes, Feishu/non-Feishu provider filtering, latest-attempt - status, validation and delivery errors, and no body leakage to other channel - types. -- `inventory/change_coordinator_test.go`: non-probing snapshot and exact - long-poll arguments, explicit change, instance restart, generation change, - unchanged reconnect, typed failure reporting, retry reset/cap and - cancellation. -- `cmd/simplusd` tests retain executable-only helpers; focused ownership scans - must find no command-local SMS body, notification policy, SMS/Agent topic - mapping or backoff functions; operational logs remain counter/error-only. -- Run the coordinator packages and `cmd/simplusd` under `go test -race`, then - run the supported `./cmd/... ./internal/...` test/vet scope. - -### 7. Wrong vs Correct - -```go -// Wrong: collapse multiple SMS messages into one Feishu summary. -if result.Persisted > 0 { - notifications.Notify(ctx, "sms.received", - fmt.Sprintf("[Simplus] 收到 %d 条新短信", result.Persisted)) -} - -// Correct: the application coordinator attempts each SMS independently. -for index, received := range result.receivedSMS { - deliveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) - if err := notifications.NotifyReceivedSMS(deliveryCtx, received.Sender, received.Body); err != nil { - notificationErr = errors.Join(notificationErr, - fmt.Errorf("received SMS %d: %w", index+1, err)) - } - cancel() -} -summaryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) -if err := notifications.NotifyReceivedSMSSummary(summaryCtx, result.Persisted); err != nil { - notificationErr = errors.Join(notificationErr, - fmt.Errorf("received SMS summary: %w", err)) -} -cancel() -``` - -## Scenario: Explicit Setup dependencies and concrete adapter assembly - -### 1. Scope / Trigger - -Apply this contract when changing `internal/application/setup`, any Setup -constructor caller, the instance secret-key path, private-directory validation, -password hashing, or Local CA generation. Setup owns its state machine, -validation and side-effect ordering. The `cmd/simplusd` composition root owns -which concrete SQLite, password, filesystem, secretbox and certificate -implementations satisfy those needs. - -Setup is an active OpenAPI/HTTP/Web flow. Do not delete or simplify it as part -of a dependency repair, and do not put concrete defaults back into the -application package for caller convenience. - -### 2. Signatures - -The application constructor accepts named dependencies and returns a stable -configuration error: - -```go -type Dependencies struct { - StateStore StateStore - AuthorizationStore AuthorizationStore - AdministratorStore AdministratorStore - PasswordHasher PasswordHasher - StorageStore StorageStore - DirectoryPreparer DirectoryPreparer - ManagementTLSStore ManagementTLSStore - SecretProtectorOpener SecretProtectorOpener - LocalCAGenerator LocalCAGenerator - HardwareReviewStore HardwareReviewStore - CompletionStore CompletionStore - Random io.Reader - Now func() time.Time -} - -func New(Dependencies) (*Service, error) -``` - -Filesystem and certificate adapters translate into application-owned values: - -```go -type DirectoryIdentity struct { - Path string - Device, Inode uint64 -} -type DirectoryPreparer func(string) (DirectoryIdentity, error) - -type LocalCABundle struct { - CACertificatePEM, CAPrivateKeyPEM []byte - LeafCertificatePEM, LeafPrivateKeyPEM []byte - RootFingerprint string - LeafNotAfter time.Time - SANs []string -} -type LocalCAGenerator func(time.Time, []string) (LocalCABundle, error) -``` - -`cmd/simplusd/setup.go:newSetupService(*sqlite.Set, instanceSecretKeyPath)` is -the production assembly seam. `main.go` derives the path argument from its -configured database root; no separate API, environment variable or request -field selects the Setup key path. - -### 3. Contracts - -- `StateStore` is mandatory. Other nil fields explicitly mean that capability - is unavailable and existing method-level configuration errors remain - authoritative. A StateStore-only service is a valid narrow fixture for - Setup/ready-state gates. -- AdministratorStore/PasswordHasher, StorageStore/DirectoryPreparer, and - SecretProtectorOpener/LocalCAGenerator are all-or-none pairs. The Local CA - pair additionally requires ManagementTLSStore. `New` wraps - `ErrDependenciesInvalid` for every invalid shape; it never discovers roles - by asserting another dependency's dynamic type. -- Nil `Random` and `Now` select `crypto/rand.Reader` and `time.Now`. Bootstrap - and session lifetimes remain ten and thirty minutes. Tests inject these - seams through `Dependencies`, not by assigning private Service fields. -- Production assigns the same SQLite Set separately to every persistence role, - selects `password.NewDefaultHasher`, translates all three directory identity - fields, and translates all seven Local CA bundle fields. -- `databaseRoot/.simplus-secrets-key-v1` is derived once in `cmd/simplusd` and - used by both the lazy Setup SecretProtector opener and the existing instance - keyring. `setup.Dependencies` carries no key-path field, and production - exposes no independent key-path selector. -- Local CA mode retains the exact encryption labels - `management-tls-ca-private-key-v1` and - `management-tls-leaf-private-key-v1`. After both encryptions succeed, Setup - clears both plaintext key buffers before calling `ConfigureManagementTLS` - with the same `managementtls.Configuration` fields. Directory device/inode - range and completion preflight checks are unchanged. -- This constructor refactor changes no OpenAPI, HTTP error, cookie, generated - source, SQLite schema/data, cryptographic format, certificate lifetime, - setup transition or Web behavior. - -### 4. Validation & Error Matrix - -| Condition | Required result | -| --- | --- | -| StateStore missing | constructor returns `ErrDependenciesInvalid` | -| Exactly one of AdministratorStore / PasswordHasher present | constructor returns `ErrDependenciesInvalid` | -| Exactly one of StorageStore / DirectoryPreparer present | constructor returns `ErrDependenciesInvalid` | -| Exactly one of SecretProtectorOpener / LocalCAGenerator present | constructor returns `ErrDependenciesInvalid` | -| Local CA pair present without ManagementTLSStore | constructor returns `ErrDependenciesInvalid` | -| Optional capability absent and its operation is called | existing bounded “not configured” error; never panic or infer a fallback | -| StateStore-only dependency set | valid Service; Status/gates work, unrelated mutations remain unavailable | -| Production dependency set incomplete | `cmd/simplusd` logs Setup dependency configuration failure, closes stores and exits non-zero | -| LocalCAGenerator returns an error | existing `ErrHTTPSRequestInvalid` mapping; no key/certificate persistence | - -### 5. Good / Base / Bad Cases - -- Good: `cmd/simplusd` passes explicit roles and adapters, Setup receives only - application values, and Local CA private keys are encrypted/cleared before - the unchanged configuration is persisted. -- Base: an HTTP test needs only ready-state gating and constructs - `setup.New(setup.Dependencies{StateStore: fixedState})`; no SQLite concrete - capabilities appear implicitly. -- Bad: `setup.New(store, store)` type-asserts the first store into hidden roles, - imports secretbox/filesystem/managementcert, or assembles a key path inside - the application package. - -### 6. Tests Required - -- `internal/application/setup/service_test.go`: missing StateStore, each incomplete - pair, Local CA without TLS, valid State-only and full dependency shapes; - deterministic clock/random injection and adapter fakes with no private-field - assignment. -- Setup behavior tests: exact directory identity persistence/range checks, - Local CA bundle-to-configuration mapping, exact SecretProtector labels and - ciphertext mapping, plaintext private-key clearing after both encryptions - succeed, session lifetimes, hardware review and completion preflight. -- `cmd/simplusd/setup_test.go`: a temporary SQLite Set is accepted by the full - production assembly and constructing the Service does not eagerly open the - lazy Setup secret protector. -- Control and HTTP tests: explicit exercised/full dependencies preserve - bootstrap, session, Setup endpoint and ready-state-gate behavior. -- Focused scans find no concrete security/storage import, StateStore role - assertion or private Service-field injection in application Setup; run the - affected packages under `go test -race`, then the supported - `./cmd/... ./internal/...` test/vet scope. - -### 7. Wrong vs Correct - -```go -// Wrong: application construction selects hidden implementations by dynamic type. -setupService := setup.New(stores, stores) - -// Correct: the executable composition root supplies every production role. -instanceSecretKeyPath := filepath.Join(databaseRoot, ".simplus-secrets-key-v1") -setupService, err := newSetupService(stores, instanceSecretKeyPath) -if err != nil { - logger.Error("Setup dependency configuration failed", "error", err) - _ = stores.Close() - return 1 -} -``` - -## Scenario: Explicit Mihomo supervisor composition - -### 1. Scope / Trigger - -Apply this contract when changing `RuntimeManager` in -`internal/application/mihomo`, its constructor, -`SIMPLUS_MIHOMO_SUPERVISOR_SOCKET`, or local/socket Mihomo supervisor -selection. The application owns selected-subscription intent, artifact -readiness, persisted running state and restart/rollback semantics. The -`cmd/simplusd` composition root owns which concrete supervisor implementation -satisfies the typed runtime capability. - -### 2. Signatures - -The application constructor has one explicit, error-returning form: - -```go -var ErrRuntimeManagerConfiguration = errors.New( - "Mihomo runtime manager dependencies are invalid", -) - -func NewRuntimeManager( - root string, - store RuntimeStore, - artifacts ArtifactResolver, - core CoreStatusReader, - supervisor mihomosupervisor.API, -) (*RuntimeManager, error) -``` - -The executable owns the concrete selection seam: - -```go -func newMihomoSupervisor( - root string, - socketPath string, -) (mihomosupervisor.API, error) -``` - -### 3. Contracts - -- `root` is the absolute `filepath.Join(cfg.Storage.DataRoot, "mihomo")`. - `NewRuntimeManager` requires an absolute root and cleans it before storing it. -- `RuntimeStore`, `ArtifactResolver`, `CoreStatusReader` and - `mihomosupervisor.API` are all mandatory. Nil and typed-nil dependencies wrap - `ErrRuntimeManagerConfiguration`; construction never returns a manager that - can fail later because one of these dependencies is absent. -- Empty `SIMPLUS_MIHOMO_SUPERVISOR_SOCKET` explicitly selects - `mihomosupervisor.NewLocal(root)` for the existing development/Simulator - mode. A non-empty value must be an absolute Unix-socket path and selects - `mihomosupervisor.NewClient(socketPath)`. -- Both concrete constructors only validate/normalize paths and allocate - in-memory state: selecting local mode does not start Mihomo, and selecting - socket mode does not dial netd. Supervisor filesystem/process/socket I/O - starts only when the application invokes `Status`, `Start` or `Stop` on the - injected typed API. -- `newMihomoSupervisor` translates either concrete-constructor failure into a - true nil API. `main.go` logs the bounded configuration failure, closes the - stores and exits with code 2. Application dependency configuration failure - also closes the stores and exits non-zero (currently code 1). -- Compose production supplies the absolute netd socket and remains - socket-backed. Empty-socket local mode is not a production fallback. -- Fixed artifact readiness, generated-config checks and restart rollback stay - in `internal/application/mihomo`; supervisor request-path/process validation - and concrete process lifecycle stay in `internal/mihomosupervisor`. Do not - move either behavior into `cmd/simplusd` while fixing composition. - -### 4. Validation & Error Matrix - -| Condition | Required result | -| --- | --- | -| Empty or relative application root | `NewRuntimeManager` returns nil and wraps `ErrRuntimeManagerConfiguration` | -| Any required dependency is nil or typed nil | constructor returns nil and wraps `ErrRuntimeManagerConfiguration` | -| Empty socket plus absolute root | select `*mihomosupervisor.Local`; do not execute a process | -| Empty socket plus relative root | return a true nil API and the local-constructor error; startup stops | -| Absolute socket path | select `*mihomosupervisor.Client`; do not contact the socket during construction | -| Relative socket path | return a true nil API and the client-constructor error; startup stops | -| Valid supervisor but invalid application dependencies | close stores and exit non-zero before HTTP/background assembly | -| Runtime `Start`/`Stop`/`Status`/`Restart` fails | preserve existing typed application/supervisor error mapping and rollback semantics; do not select another implementation | - -### 5. Good / Base / Bad Cases - -- Good: Compose passes an absolute netd socket; `cmd/simplusd` constructs the - Unix client, injects it once, and the application knows only the typed API. -- Base: local development leaves the socket empty; the command constructs a - local supervisor but no child process starts until `RuntimeManager.Start` or - `RuntimeManager.Restart` issues a typed `Start` request. -- Bad: an application convenience constructor calls `NewLocal`, discards its - error, infers local mode from a missing dependency, or silently falls back - from a failed socket client to a local process. - -### 6. Tests Required - -- `internal/application/mihomo/runtime_test.go` injects a recording fake, - asserts the selected subscription, binary path, absolute generated-config - path, pending-restart state and one stop call, and covers empty/relative - root plus nil and typed-nil mandatory dependencies with `errors.Is`. -- `cmd/simplusd/mihomo_test.go` asserts concrete local/socket selection and - true-nil error returns for invalid paths. A deliberately missing absolute - socket proves construction does not dial it. -- `internal/mihomosupervisor` retains the concrete process/client behavior - tests. Application runtime tests may create synthetic artifact files needed - by readiness checks, but must not launch a process. -- Focused scans must find one `NewRuntimeManager` constructor, no - `NewRuntimeManagerWithSupervisor`, no application call to `NewLocal`, and no - discarded concrete-constructor error. Run focused packages under - `go test -race`, then the supported `./cmd/... ./internal/...` test/vet scope. - -### 7. Wrong vs Correct - -```go -// Wrong: the application hides a concrete default and its failure. -func NewRuntimeManager(root string, store RuntimeStore, artifacts ArtifactResolver, core CoreStatusReader) *RuntimeManager { - local, _ := mihomosupervisor.NewLocal(root) - return &RuntimeManager{Supervisor: local} -} - -// Correct: cmd owns implementation choice and the application requires it. -supervisor, err := newMihomoSupervisor(mihomoRoot, mihomoSupervisorSocket) -if err != nil { - logger.Error("Mihomo supervisor configuration failed", "error", err) - _ = stores.Close() - return 2 -} -runtime, err := mihomoapp.NewRuntimeManager( - mihomoRoot, stores, configManager, coreManager, supervisor, -) -if err != nil { - logger.Error("Mihomo runtime manager dependency configuration failed", "error", err) - _ = stores.Close() - return 1 -} -``` - -## Scenario: Mihomo subscription refresh compatibility and bounded failures - -### 1. Scope / Trigger - -Apply this contract when changing Mihomo subscription download headers, -`SubscriptionService.Refresh`, subscription artifact generation, refresh-state -persistence, `httpapi.writeMihomoSubscriptionError`, or the Web message for a -refresh failure. Subscription URLs can contain credentials, while provider -responses and transport errors can contain private topology or account detail; -none of those values may cross the application boundary in an error or log. - -### 2. Signatures - -The application and HTTP boundary retain these shapes: - -```go -const subscriptionUserAgent = "clash.meta" - -type SubscriptionRefreshError struct { - Code string -} - -func (*SubscriptionService) Refresh( - context.Context, - string, -) (SubscriptionView, []domain.Node, error) - -func (*SubscriptionRefreshError) Error() string -``` - -The authenticated mutation remains -`POST /api/v1/mihomo/subscriptions/{subscriptionID}/refresh`. A recognized -`*SubscriptionRefreshError` maps to HTTP 502 with -`MIHOMO_SUBSCRIPTION_REFRESH_FAILED` and `retryable: true`; no OpenAPI shape or -generated source changes for the stage-specific application code. - -### 3. Contracts - -- Every subscription fetch sends exactly `User-Agent: clash.meta` plus the - fixed existing `Accept` value. This identifier is also an output-dialect - negotiation token: a plain `mihomo` identifier can yield a Base64 URI list - that produces node summaries but cannot preserve the complete proxy maps - required by `ConfigManager`. Do not add a hostname branch, version spoofing, - fallback User-Agent, credential rewrite, or automatic retry. -- The default client remains proxy-free and HTTPS-only, rejects unsafe literal - or resolved addresses, revalidates every redirect, follows at most three - redirects, and retains its 10-second TLS handshake, 20-second response-header, - 30-second total and 5 MiB response limits. -- Fetch/request/status/read/size, parse, missing artifact manager, artifact - generation and artifact validation failures call the common failure path - with only their existing stable `SUBSCRIPTION_*` code. The typed error does - not wrap or retain the original URL, transport error, provider body, parsed - node, command output or filesystem path. -- The failure path persists `failed`, the stable code and refresh timestamp - before returning `SubscriptionRefreshError`. If that write fails, return the - storage error instead; do not report that failure state was recorded. -- Artifact construction remains before node replacement. A rejected download - or failed parse/build must not replace the stored nodes or last-known-good - immutable artifact. -- HTTP identifies refresh failures with `errors.As`, logs only `error_code`, - and emits the existing bounded 502 response. Other storage failures retain - HTTP 500 / `MIHOMO_SUBSCRIPTION_PERSIST_FAILED` and the internal error log. -- Web maps `MIHOMO_SUBSCRIPTION_REFRESH_FAILED` to bounded guidance about - subscription-source access or validity. It never renders an upstream status, - URL, credential, hostname or response body; the existing mutation - `onSettled` path clears its row-scoped busy state. - -### 4. Validation & Error Matrix - -| Condition | Required result | -| --- | --- | -| Invalid subscription ID/input | existing `ErrSubscriptionInvalid`; HTTP 400 | -| Missing subscription | existing `ErrSubscriptionNotFound`; HTTP 404 | -| Request construction, transport, timeout, non-200, read or size failure | persist `SUBSCRIPTION_FETCH_FAILED`; return bounded typed error; HTTP 502 | -| YAML/base64 content cannot produce valid nodes | persist `SUBSCRIPTION_PARSE_FAILED`; return bounded typed error; HTTP 502 | -| Artifact manager unavailable | persist `SUBSCRIPTION_CONFIG_UNAVAILABLE`; return bounded typed error; HTTP 502 | -| Artifact generation fails | persist `SUBSCRIPTION_CONFIG_GENERATION_FAILED`; return bounded typed error; HTTP 502 | -| Artifact validation fails with `ErrConfigValidationFailed` | persist `SUBSCRIPTION_CONFIG_VALIDATION_FAILED`; return bounded typed error; HTTP 502 | -| Failure-state persistence fails | return the storage error; HTTP 500 `MIHOMO_SUBSCRIPTION_PERSIST_FAILED` | -| Valid response and artifact | replace summarized nodes with `success / ""`; return the refreshed view and nodes | - -### 5. Good / Base / Bad Cases - -- Good: a provider-neutral synthetic HTTPS request carries `clash.meta`, - receives a complete Mihomo YAML document, passes the real artifact-generation - boundary and replaces the node snapshot with successful status. -- Base: the provider rejects the request. The application persists only - `SUBSCRIPTION_FETCH_FAILED`, HTTP returns the existing 502 code, and Web - gives actionable source-validity guidance while retaining prior data. -- Bad: treat HTTP 200 or successful node-summary parsing as proof that the - response can generate a runnable configuration; retry with several - browser/client identifiers; special-case a private provider; use - `strings.Contains(err.Error(), ...)` for HTTP mapping; or log a `%w`-wrapped - `url.Error` containing the credential-bearing URL. - -### 6. Tests Required - -- Application tests inject a synthetic `RoundTripper` that returns a parseable - Base64 URI list for other identifiers and complete YAML for `clash.meta`; - assert exact User-Agent/Accept headers and pass the response through the real - `ConfigManager` generation path before asserting successful node/status and - immutable-artifact publication. Continue to assert each stable failure code - and zero node replacement on failure. -- Privacy assertions compare complete typed error text and reject markers from - the synthetic URL, transport error, provider body and artifact error. -- HTTP tests pass a typed refresh error and an ordinary storage error; assert - 502 versus 500 mappings and that the typed-error log has `error_code` but no - raw `error` attribute. -- Web tests construct `ApiClientError` with the public refresh code and assert - the dedicated Chinese message; existing page behavior keeps URL hints only - and clears row busy state through `onSettled`. -- Run application and HTTP packages under `go test -race`, full Web tests, - typecheck/build, generated-drift checks, and the supported repository - test/vet/lint targets. Use only synthetic providers and data; no production - refresh, hardware or HIL action is required. - -### 7. Wrong vs Correct - -```go -// Wrong: HTTP 200 can still contain a summary-only URI list that cannot be -// turned back into complete proxy mappings. -const subscriptionUserAgent = "mihomo" - -// Correct: negotiate the complete Mihomo YAML dialect required by artifacts. -const subscriptionUserAgent = "clash.meta" - -// Wrong: raw transport detail can contain the complete subscription URL, and -// the HTTP layer depends on mutable prose. -return fmt.Errorf("fetch Mihomo subscription: %w", err) -if strings.Contains(err.Error(), "fetch Mihomo subscription") { /* 502 */ } - -// Correct: persist one bounded stage code and classify by application type. -return service.refreshFailed(ctx, item, "SUBSCRIPTION_FETCH_FAILED") -var refreshError *mihomo.SubscriptionRefreshError -if errors.As(err, &refreshError) { /* bounded 502 + error_code log */ } -``` - -## Scenario: Typed legacy Webhook delivery port - -### 1. Scope / Trigger - -Apply this contract when changing `internal/application/notification`, the -legacy enterprise WeChat/Feishu bot Webhook protocol, notification Service -construction, delivery outcome persistence, or the concrete client assembled -in `cmd/simplusd`. The application owns channel/event/secret/state policy. The -`internal/notificationwebhook` adapter owns fixed target and wire behavior. - -Feishu application registration/private-message delivery remains the separate -`FeishuRegistrar`/`FeishuMessenger` path. Do not merge it into the legacy -Webhook adapter or use this seam to create a generic HTTP provider. - -### 2. Signatures - -The application owns the bounded port vocabulary: - -```go -type WebhookProvider string - -const ( - WebhookProviderWeCom WebhookProvider = "wecom" - WebhookProviderFeishu WebhookProvider = "feishu" -) - -type WebhookTarget struct { URL, Hint string } - -type WebhookDeliveryRequest struct { - Provider WebhookProvider - URL string - SigningSecret string - Message string - Timestamp int64 -} - -type WebhookDeliveryOutcome string - -const ( - WebhookDelivered WebhookDeliveryOutcome = "delivered" - WebhookNetworkFailed WebhookDeliveryOutcome = "network_failed" - WebhookRejected WebhookDeliveryOutcome = "rejected" -) - -type WebhookDeliveryResult struct { Outcome WebhookDeliveryOutcome } - -type WebhookPort interface { - ValidateTarget(WebhookProvider, string) (WebhookTarget, error) - Deliver(context.Context, WebhookDeliveryRequest) (WebhookDeliveryResult, error) -} - -type Dependencies struct { - Store Store - Secrets SecretCipher - Webhooks WebhookPort -} - -func New(Dependencies) (*Service, error) -``` - -`ErrDependenciesInvalid` is the stable constructor error; -`ErrWebhookResultInvalid` marks a contradictory/unknown adapter result. The -concrete `notificationwebhook.Client` structurally implements `WebhookPort`, -is constructed by `NewClient() *Client`, and owns the exact stable sentinels -`ErrTargetInvalid`, `ErrRequestInvalid`, `ErrNetworkFailed`, and -`ErrProviderRejected`. - -### 3. Contracts - -- Store, Secrets and Webhooks are mandatory. Nil and typed-nil dependencies - wrap `ErrDependenciesInvalid`; no hidden HTTP client/default is constructed. -- The port carries only a supported provider, one URL, optional signing - secret, bounded text and Unix timestamp. It exposes no method, header, raw - body, redirect, retry, proxy or general HTTP option. The normalized URL is at - most 4096 bytes, public hint at most 255 bytes, signing secret at most 512 - bytes and message at most 4000 runes. -- Create and non-empty URL Update call `ValidateTarget`, accept only a nonempty - bounded normalized URL plus hostname-only hint, then encrypt the URL and - persist its ciphertext with the plaintext hostname-only hint. An empty - Update URL preserves the prior URL ciphertext and hint; an empty signing - secret preserves the prior signing-secret ciphertext. -- The Service owns the unchanged labels - `notification-channel:v1::webhook` and - `notification-channel:v1::signing`, decrypts only for the - current call, and never logs or returns plaintext credentials. -- Before every delivery, the adapter validates again and executes the returned - normalized `WebhookTarget.URL`—never the original unchecked string. -- Target validation trims surrounding whitespace and preserves the - `url.Parse`/`URL.String` normalization used by legacy rows: HTTPS, no - userinfo or fragment; `url.Hostname()` equals the supported official - hostname; WeCom uses exact `/cgi-bin/webhook/send` with nonempty `key` and - preserves extra query values; Feishu uses a nonempty - `/open-apis/bot/v2/hook/` suffix and no query. Existing explicit ports remain - accepted. -- The concrete client has a 15-second timeout, refuses redirects, POSTs - `application/json` with `User-Agent: Simplus`, emits the existing WeCom - `msgtype=text`/`text.content` and Feishu - `msg_type=text`/`content.text` shapes, reads at most 64 KiB plus one byte, and - requires HTTP 2xx plus explicit numeric `errcode=0`/`code=0`. Optional - Feishu signing uses the decimal Unix timestamp and - `base64(HMAC-SHA256(key=timestamp+"\n"+secret, message=""))` formula. -- Target/request/network/rejection failures return exact stable adapter - sentinels. Raw parse/request/transport/redirect/read/status/provider errors, - URLs, keys, signing secrets and response bodies are never wrapped or returned - across the port. -- `cmd/simplusd` constructs `notificationwebhook.NewClient`, injects it through - `notification.Dependencies`, and on configuration failure logs a bounded - error, closes stores and exits non-zero before later assembly. -- This introduces no provider feature and changes no OpenAPI, SQLite schema, - ciphertext format/labels, public error mapping, event kind or Feishu - application-channel behavior. - -### 4. Validation & Error Matrix - -| Condition | Application result and state | -| --- | --- | -| missing/typed-nil Store, Secrets or Webhooks | constructor returns nil and wraps `ErrDependenciesInvalid` | -| invalid provider/target during Create or replacement Update | `ErrChannelInvalid`; no persisted row/ciphertext change | -| empty Update target | preserve existing URL ciphertext and hostname hint | -| empty Update signing secret | preserve existing signing-secret ciphertext | -| adapter preflight returns zero outcome plus error | return the bounded error; no delivery record | -| `delivered` plus nil | persist `success / ""`; persistence failure remains visible | -| `network_failed` plus error | best-effort persist `failed / DELIVERY_NETWORK_FAILED`; return primary error | -| `rejected` plus error | best-effort persist `failed / DELIVERY_REJECTED`; return primary error | -| delivered plus error, failure outcome plus nil, zero plus nil, or unknown outcome | `ErrWebhookResultInvalid`; do not invent a status | -| Webhook/signing decryption fails | no adapter call and no delivery record | -| redirect, transport or context failure returned by `http.Client.Do` | credential-safe `network_failed`; never expose the attempted URL | -| request construction fails before `http.Client.Do` | zero outcome plus credential-safe `ErrRequestInvalid`; no delivery record | -| non-2xx, read/size/JSON/missing/nonzero provider code | credential-safe `rejected`; never expose response content | - -Failure-record persistence remains secondary and is deliberately ignored after -an external failure. A successful external delivery followed by success-state -persistence failure returns the store error and is not automatically retried. - -### 5. Good / Base / Bad Cases - -- Good: the Service decrypts one stored Feishu bot target/signing secret, - passes a bounded request, receives `delivered`, persists success and exposes - only the hostname hint. -- Base: a network failure returns one exact stable adapter error; the Service - records the existing network-failure code while no credential appears in - logs, errors or views. -- Bad: the Service builds provider JSON, sets HTTP headers, calls - `http.Client.Do`, branches on raw response fields, or accepts a port carrying - caller-selected method/headers/body. - -### 6. Tests Required - -- `internal/application/notification/service_test.go` uses a recording fake - port for missing/typed-nil construction; normalized target persistence; - decrypted provider/URL/signing/message/timestamp handoff; event filtering; - success/network/rejected/preflight/contradictory/unknown outcomes; ignored - failure-state write errors; and visible success-state write errors. -- `internal/notificationwebhook/client_test.go` uses synthetic transports only - and proves both payload/signature shapes, exact headers, URL normalization, - explicit-port/extra-query compatibility, representative rejected - target/input classes, exact bounds, per-delivery revalidation, - redirects/transport/read/size/status/JSON/missing/nonzero code handling and - no external request for preflight errors. -- Credential privacy tests compare exact returned sentinels/error strings, not - only `errors.Is`: a `%w` wrapper may satisfy `errors.Is` while leaking a URL, - key, secret, redirect target or provider body in `Error()`. -- Existing notification SQLite integration, Feishu binding, HTTP and storage - tests remain green. Run notification/adapter/cmd under `go test -race`, then - the supported `./cmd/... ./internal/...` test/vet/lint and generated-drift - scope. -- Focused scans find no `net/http`, provider payload/signing functions or - official target/path literals in `notification/service.go`, and no - storage/event/state policy import in `internal/notificationwebhook`. - -### 7. Wrong vs Correct - -```go -// Wrong: application owns concrete Webhook protocol and can leak URL errors. -response, err := service.Client.Do(request) -if err != nil { - return fmt.Errorf("deliver webhook: %w", err) -} - -// Correct: application owns outcome policy through a bounded port. -result, err := service.Webhooks.Deliver(ctx, WebhookDeliveryRequest{ - Provider: provider, URL: decryptedURL, SigningSecret: decryptedSecret, - Message: message, Timestamp: service.Now().Unix(), -}) -switch result.Outcome { -case WebhookNetworkFailed: - _ = service.Store.RecordNotificationDelivery( - ctx, channelID, "failed", "DELIVERY_NETWORK_FAILED", service.Now().UTC(), - ) - return err -case WebhookRejected: - // Persist DELIVERY_REJECTED with the same best-effort rule. -} -``` +## Dependency direction + +Domain packages own facts and rules. Application packages orchestrate narrow, +consumer-owned ports. Adapters own SQLite, Unix protocols, external HTTP, +filesystem and subprocess behavior. Entry points only configure, assemble and +manage lifecycle. `internal/architecture/boundaries_test.go` enforces imports; +behavior tests cover replay, cancellation, identities and failure ordering. + +The concrete adapters are `agentinventory`, `modemagent`, `smstransport`, +`subscriptionhttp`, `mihomoassets`, `notificationwebhook` and `feishu`. +Production SIP/IMS/strongSwan live in `internal/ims`; HIL packages provide only +explicit validation entry points and cannot be production dependencies. +Application runtime ports use domain values rather than Unix wire structs. + +## Construction and HTTP + +Validate required dependencies once in constructors. Pass explicit disabled +capabilities for unsupported features; do not use reflection to detect typed +nil interfaces or install dependencies after starting workers. +`httpapi.NewServer(Dependencies)` assembles authentication, device, +communication, network and notification handlers. Shared code owns trusted +LAN/Origin, CSRF, session, initialization, bounded decode and timeout rules. +Tests should assert behavior through ports rather than public setter names. + +Administrator provisioning is an installation use case: credentials and ready +state commit atomically. There are no Web setup, Bootstrap, local CA or recording +directory workflows. The root Unix control plane only provisions the initial +administrator. Existing credentials are never replaced by repeated provisioning. + +## Background work and shutdown + +SMS synchronization, VoWiFi reconciliation, cellular observation and notification +workers run with process-owned contexts regardless of browser activity. Each line +or modem has its own deadline and retry clock. Agent device gates still serialize +operations on the same modem. Do not hold a global state lock across I/O. +Inventory probes run independently and retain successful results when another +modem times out. Unknown observations never become invented connectivity loss. + +Stop admission, cancel request/background contexts, wait for handlers (including +timeout handlers), SSE and workers, reap subprocesses/network objects, then close +SQLite. Cancellation must also wait for bounded final persistence. Streaming +worker output is unbounded over its lifetime but bounded per message. + +SSE only invalidates HTTP queries or signals attention. Never put authoritative +snapshots, message bodies, identities, protocol logs or secrets into browser +realtime events. Background progress must not depend on a connected browser. + +## SMS submission and recovery + +Look up a persistent operation result before checking current hardware. An +identical replay returns that result even if the Agent restarted or device left. +A different payload under the same operation ID is a conflict. Resolve each new +operation against the current instance, device generation and SIM identity. +Transport mapping distinguishes definitely not dispatched, explicit rejection, +accepted submission and unknown outcome. Disconnect/timeout/malformed response +after dispatch persists as `unconfirmed`; never automatically send again. + +Inbound payload, unread marker and delivery tasks commit in one transaction +before hardware ACK/deletion. Multipart fragments persist before ACK and assemble +only after an unambiguous complete group. Agent-specific hardware SMS recovery +remains separate from the single control business database. Generic command +ledgers, ResourceGroup leases and placeholder replay backends are removed. + +## Notifications and external HTTP + +Business events enqueue durable tasks; independent workers deliver with 15-second +to five-minute exponential retry. Unknown provider errors remain retryable; +confirmed permanent rejection retains a safe reason. Do not persist raw provider +responses. External response loss can duplicate delivery. SQL orders each +channel/object/connection stream while allowing other streams to advance. + +Connection checkpoints and cursors commit with delivery tasks. VoWiFi consumes +actual online transitions using a 1024-event cursor stream and baseline snapshot; +source restart or overflow records a gap. Cellular probes every five seconds, +with topology-triggered early probing. First online notifies; first offline only +establishes a baseline. Every confirmed later transition notifies immediately. +Failures/unknown readings retain the last confirmed state. Stop/unsubscribe/delete +cancels related pending tasks, and new subscriptions do not replay history. + +Webhook validation allows only the established official provider endpoints, +refuses redirects and bounds requests/replies. Feishu registration and sending +are adapter-owned. Mihomo subscription HTTP fetch is similarly isolated with +bounded size, redirect and target checks; artifact validation and subprocess +execution stay outside application services. ## Stable Business Identity and Runtime Resolution @@ -1192,7 +213,7 @@ application intent -> Agent typed capability -> model adapter -> protocol I/O orchestrates probing. It does not manufacture model commands. - `internal/attransport/transport.go` owns tty session mechanics. Its `Query` is a compiled-in adapter seam, never a Web/API input. -- `internal/application/inventory/agent_source.go` maps only `observed` +- `internal/agentinventory/agent_source.go` maps only `observed` evidence to business capabilities; descriptors or documented/unverified features do not become operational support. diff --git a/docs/engineering/backend/directory-structure.md b/docs/engineering/backend/directory-structure.md index f2a83ec..704a626 100644 --- a/docs/engineering/backend/directory-structure.md +++ b/docs/engineering/backend/directory-structure.md @@ -7,50 +7,30 @@ runtime or domain that owns it: | Path | Current owner and evidence | | --- | --- | -| `cmd/` | Per-binary flag parsing, concrete dependency construction, lifecycle, operational log rendering, and executable-only helpers. `cmd/simplusd/main.go` wires stores, services, transports, HTTP and application-owned background coordinators, constructs the fixed legacy-Webhook adapter, and injects it into Notification; `cmd/simplusd/setup.go` translates concrete password/filesystem/secret/certificate adapters into Setup-owned ports and values, while `cmd/simplusd/mihomo.go` selects the empty-socket development/Simulator local supervisor or the configured Unix-socket client; `cmd/simplus-agent/main.go` wires the typed hardware server; `cmd/simplus-netd/main.go` wires the privileged supervisors. | -| `internal/application/` | Domain use-case services, background business coordinators and consumer-owned ports/values. Setup owns explicit persistence/security ports and directory/Local-CA values but no concrete adapter defaults; the Mihomo runtime manager requires one injected typed supervisor and never selects its local/client implementation; Notification owns Webhook port values, secret/state policy and outcome persistence but no raw legacy-Webhook HTTP/provider protocol; Messaging and Inventory own their SMS/Agent-change coordination policy; `internal/application/realtime/` owns bounded topic publication/subscriptions. There is no supported `application/resourcelease` package: the unassembled SQLite-typed orchestrator was retired. | -| `internal/domain/` | Business records, enums, validation, and domain errors without transport assembly. `internal/domain/hardware/` defines normalized topology; `internal/domain/pagination/` owns shared opaque cursor validation/encoding. | -| `internal/api/httpapi/` | Public HTTP authentication, middleware, request/response mapping, generated OpenAPI server implementation, and consumer-owned ports for adjacent Health, Setup, Inventory and Realtime application behavior (`internal/api/httpapi/server.go`). | -| `api/` and `internal/api/openapi/` | `api/openapi.yaml` is the public API source; `internal/api/openapi/generate.go` and `api/oapi-codegen.yaml` define Go generation; `internal/api/openapi/generated.go` is output. | -| `internal/agentapi/` | Bounded Unix-socket hardware protocol, peer validation, typed clients/servers, and protocol tests. | -| `internal/modemadapter/`, `internal/hardwareprobe/`, `internal/attransport/` | Model facts and commands, inventory orchestration, and generic bounded tty I/O respectively. | -| `internal/storage/sqlite/` | SQLite opening, migrations, repositories, generated sqlc package, and persistence tests. `resource_leases.go`, its focused test, and runtime migration 00005 are dormant historical compatibility fixtures, not application APIs or evidence of a production lease capability. | -| `internal/notificationwebhook/` | Concrete legacy enterprise WeChat/Feishu bot Webhook target validation, payload/signature generation, bounded HTTP and explicit provider-response parsing. It implements the Notification-owned port, returns typed outcomes plus credential-safe stable errors, and owns no store or event/state policy. | -| `internal/*supervisor/` | Typed clients and local implementations for privileged Mihomo/Host VoWiFi runtime ownership. | +| `cmd/` | Configuration, concrete dependency construction and process lifecycle. | +| `internal/domain/` | Business facts, safe values, rules and neutral runtime capabilities. | +| `internal/application/` | Use-case orchestration through narrow consumer-owned interfaces. | +| `internal/api/httpapi/` | Auth, devices, communications, network and notification handlers; shared gates/timeouts/errors. | +| `internal/storage/sqlite/` | One control database, transactions, migrations and repositories. | +| `internal/agentinventory`, `modemagent`, `smstransport` | Agent/IMS protocol mapping outside business services. | +| `internal/feishu`, `notificationwebhook`, `subscriptionhttp`, `mihomoassets` | External HTTP, credentials, bounded downloads, filesystem and process adapters. | +| `internal/ims/` | Production SIP/IMS/SMS/strongSwan protocols. | +| `internal/vowifihil/` | Explicit validation entry points only. | +| `internal/lifecycle/`, `architecture/` | Request draining and dependency-direction checks. | +| `internal/simulator/` | In-memory RF/cellular/VoWiFi capabilities without hardware or network processes. | ## Placement Rules -- Keep `cmd/**` as the composition root. New business decisions belong in an - application or domain package, even when only one binary currently calls - them. `cmd/simplusd/main.go` demonstrates the intended assembly: it selects - the backend, builds narrow services/coordinators, starts their lifecycle, - renders operational reports, and injects services into `httpapi.Server`. -- Put an interface next to its consumer when it represents that consumer's - needs. The small interfaces at the top of - `internal/api/httpapi/server.go` and - `internal/application/messaging/service.go` avoid a shared catch-all service - package. HTTP's `HealthReader`, `SetupManager`, `InventoryReader`, and - `RealtimeManager` contain only live handler operations; `cmd/simplusd` - injects the existing concrete implementations structurally. -- Keep protocol-neutral business records in `internal/domain/**`. Wire and - persistence representations may convert at their boundaries; they must not - become the domain source of truth. -- An abstract repository does not make storage-owned parameters/results - application-safe. If a future feature needs the dormant ResourceGroup lease - mechanism, define application/domain values first and map them in the - concrete persistence owner; do not revive the retired package around the - SQLite record types. -- Keep Linux-specific implementations behind portable files or interfaces. - Examples include `internal/attransport/session_linux.go` plus - `internal/attransport/session_other.go`, and - `internal/agentapi/peercred_linux.go` plus platform fallbacks. -- Keep tests beside the package they prove. Representative files are - `internal/application/messaging/service_test.go`, - `internal/api/httpapi/server_test.go`, - `internal/modemadapter/registry_test.go`, and - `internal/storage/sqlite/store_test.go`; cross-package integration tests are - still located with the owning package, such as - `internal/application/notification/integration_test.go`. +Keep application interfaces owned by consumers, use domain values across layers, +and keep SQL, paths, protocol commands and external HTTP in adapters. Construct +validated dependencies once before serving or starting workers. Use explicit +unavailable implementations for disabled features rather than reflection or +post-construction setters. + +Keep tests beside their owner; cross-package acceptance may use an external test +package and real temporary SQLite. Use behavioral assertions for replay, failure, +ordering, privacy and cancellation. Static import checks supplement those tests. +Keep Linux-specific endpoint/peer-credential code behind platform files. ## Generated and Embedded Files @@ -61,7 +41,7 @@ Never hand-edit files that declare themselves generated: - `web/src/api/generated/` comes from the same OpenAPI source through `web/openapi-ts.config.ts` and the root `api:generate` script. - `internal/storage/sqlite/generated/core/*.go` comes from `sqlc.yaml`, - `internal/storage/sqlite/migrations/core/`, and + `internal/storage/sqlite/migrations/control/`, and `internal/storage/sqlite/queries/core/`. `internal/storage/sqlite/store.go` embeds @@ -83,11 +63,8 @@ and verification contract together instead of inventing a manual refresh. before searching the owning packages for an existing narrow pattern. - Do not import a concrete SQLite store or Agent implementation into an application service when a consumer-owned port is sufficient. -- Do not alias, re-export, accept, return, or branch on the retained - `sqlite.ResourceLease*` types/constants from an application package. Their - exported Go names support only the dormant storage fixture and its tests. - Do not type-assert one catch-all store to discover optional application - capabilities or construct password/filesystem/secret/certificate defaults or + capabilities or construct password/filesystem/secret defaults or local/client supervisor/Webhook HTTP implementations in an application constructor. Name the ports and assemble concrete adapters in `cmd/**`. - Do not create a second public API type tree beside `api/openapi.yaml`, or a diff --git a/docs/engineering/backend/index.md b/docs/engineering/backend/index.md index d62b837..e855a54 100644 --- a/docs/engineering/backend/index.md +++ b/docs/engineering/backend/index.md @@ -37,9 +37,9 @@ data-flow map is `docs/architecture.md`; concrete assembly is in | Guide | Project-specific contract | | --- | --- | | [Directory Structure](./directory-structure.md) | Root Go package ownership, executable assembly, generated boundaries, and test placement | -| [Application Boundaries](./application-boundaries.md) | Typed ports/values, retired storage-typed ResourceGroup lease orchestration, HTTP-owned adjacent application ports, explicit Setup/Mihomo/Webhook adapter composition, background coordination/realtime policy, stable business identity, hardware adapters, fail-closed behavior, and side-effect ordering | +| [Application Boundaries](./application-boundaries.md) | Typed ports/values, HTTP-owned adjacent application ports, installation/Mihomo/notification adapter composition, background coordination/realtime policy, stable business identity, hardware adapters, fail-closed behavior, and side-effect ordering | | [API Contracts](./api-contracts.md) | OpenAPI-first public HTTP, cursor pagination, authenticated realtime invalidation, bounded Unix protocols, timeouts, and stable errors | -| [Storage and Migrations](./storage-and-migrations.md) | Five-dataset SQLite reality, dormant ResourceGroup lease compatibility, keyset indexes, Goose migrations, sqlc ownership, transactions, and sensitive persistence | +| [Storage and Migrations](./storage-and-migrations.md) | Single control SQLite, legacy-layout refusal, durable notification transactions, keyset indexes, Goose migrations, sqlc ownership, transactions, and sensitive persistence | | [Quality and Testing](./quality-and-testing.md) | Trusted test patterns and targeted-to-broad Go validation | ## Architecture Rules at a Glance @@ -65,7 +65,7 @@ internal/api/httpapi -> application domain service -> narrow ports to compiled-in adapters. - Unsupported, unverified, unavailable, and ambiguous hardware states remain explicit and fail closed. `internal/modemadapter/registry.go` rejects - overlapping matches, while `internal/application/inventory/agent_source.go` + overlapping matches, while `internal/agentinventory/agent_source.go` advertises only observed capabilities. - `api/openapi.yaml` and embedded SQLite migrations are sources; generated Go, TypeScript, and sqlc outputs are not hand-edited. diff --git a/docs/engineering/backend/storage-and-migrations.md b/docs/engineering/backend/storage-and-migrations.md index 540c908..b35e633 100644 --- a/docs/engineering/backend/storage-and-migrations.md +++ b/docs/engineering/backend/storage-and-migrations.md @@ -1,140 +1,50 @@ # Storage and Migrations -## Current Storage Shape - -`internal/storage/sqlite/store.go` opens five SQLite datasets: `core`, -`contacts`, `messages`, `calls`, and `runtime`. This is current compatibility, -not a requirement to create a new database for each feature. As -`docs/architecture.md` states, new data belongs in the semantically closest -existing dataset; cross-database transactions and a new backup/dataset -framework are not current product goals. - -`OpenSet` is the only normal opening path. It validates an absolute non-root -storage directory, ownership and non-symlink identities, private modes, a -storage marker, dataset identity, schema manifest, integrity, foreign keys, -and WAL artifacts. Each database uses one open connection, `busy_timeout`, -foreign keys, `synchronous=FULL`, `trusted_schema=OFF`, and WAL. Preserve those -checks rather than opening the same files ad hoc from a domain store. - -### Dormant runtime ResourceGroup lease compatibility - -Runtime migration `00005_resource_group_leases.sql` and -`internal/storage/sqlite/resource_leases.go` are legacy storage artifacts -present since the initial public source release. The application orchestrator -that exposed these SQLite-owned values was never production-wired and has been -removed. The migration, repository, and `resource_leases_test.go` are currently -retained unchanged as historical storage compatibility/fixture infrastructure; -they do not establish a supported application or production capability. - -Do not import, alias, or re-export `ResourceLeaseAcquire`, `ResourceLease`, -`ResourceLeaseOperation`, or `ResourceLeaseCall` into application contracts. -A future feature must define application/domain values and an explicit SQLite -mapping. A future request to remove the tables or their data requires a -separately approved migration/data decision and a new migration; deleting dead -application code never authorizes editing the released runtime v5 file. Keep -the focused real-SQLite replay, fencing, expiry/reopen, conflict, and -concurrency tests while this fixture remains. - -## Store Methods - -- Put persistence behavior in a domain-named file under - `internal/storage/sqlite/`, such as - `internal/storage/sqlite/messages.go`, - `internal/storage/sqlite/managed_lines.go`, or - `internal/storage/sqlite/vowifi.go`. -- Accept domain/application types, validate required invariants before SQL, - use `Context` methods, and wrap operational errors with the affected action. -- Check `RowsAffected` for single-record mutations and return a stable domain - not-found/conflict error. `UpdateManagedLine` and the outbound-message state - transitions are representative. -- Encode idempotency in database constraints plus comparison, not only in - process memory. `CreateOutboundSMS` uses a unique operation ID and rejects a - replay whose Line, destination, body, or direction differs. -- Use a transaction when several reads/writes form one invariant. The inbound - multipart spool in `internal/storage/sqlite/messages.go` uses `BeginTx`, - deferred rollback, conflict checks, and a final commit; setup/auth/eUICC - stores use the same rollback pattern. - -Do not broaden a transaction across multiple dataset files: SQLite cannot make -the current five independent databases one implicit atomic unit. If a feature -requires a new cross-dataset guarantee, design it explicitly rather than -assuming the `Set` provides one. - -## Migration Ownership - -Migrations are embedded from `internal/storage/sqlite/migrations/*/*.sql` and -applied by Goose in `internal/storage/sqlite/store.go`. For each change: - -- append the next zero-padded migration in the owning dataset directory; -- include `-- +goose Up` and a meaningful `-- +goose Down` path; -- update the singleton `dataset_metadata.schema_version` to the migration - version in both directions; -- encode invariants with SQLite `CHECK`, uniqueness, and foreign keys when the - database can enforce them; -- preserve existing user intent/data explicitly during table rebuilds; -- use `-- +goose NO TRANSACTION` only when the migration owns its explicit - `BEGIN/COMMIT` sequence, as - `internal/storage/sqlite/migrations/core/00022_line_identity_path_decoupling.sql` - does while temporarily disabling foreign keys. - -Migration tests must exercise an actual old schema and reopen through -`OpenSet`. `internal/storage/sqlite/store_test.go` downgrades with Goose, seeds -old rows, reopens, and verifies the message-v4/v5 and Line-v21/v22 upgrades; -it also checks schema tampering, swapped datasets, symlinks, hard links, -permissions, and foreign-key integrity. - -Calls v3 adds a keyset-pagination index ordered by -`(created_at_unix_ms DESC, call_id DESC)`. Calls store methods must use the -identical tuple and strict `<` boundary, query `limit+1`, and construct the next -cursor from the final returned record. Do not substitute offsets or -timestamp-only cursors; equal call timestamps require the stable-ID tiebreaker. - -Messages v8 replaces the older created-time page indexes with -`record_sequence INTEGER PRIMARY KEY AUTOINCREMENT`, plus -`(remote_address, record_sequence DESC)` and -`(line_id, remote_address, record_sequence DESC)`. Global, recipient, paired -Line/recipient, conversation-latest, conversation order, and last outbound -Line reads all use sequence. New inserts let SQLite allocate the sequence; -replay, status changes, and deletion never update or reuse it. Pages use strict -`record_sequence < ?`, query `limit+1`, and return the last included sequence -in an SMS v2 cursor, so deleting the boundary row does not break continuation. - -Messages v7 adds `(remote_address, created_at_unix_ms DESC, message_id DESC)` -for recipient history and summaries plus `sms_message_unread`. Each newly -inserted inbound message creates one unread row in the same transaction; -duplicate/replayed inbound sources do not. `unread_id INTEGER PRIMARY KEY -AUTOINCREMENT` is a separate arrival order because millisecond message time and -random IDs cannot safely watermark concurrent arrivals. Counts are derived, -message deletion cascades markers, and read-state deletion is bounded by exact -remote address plus an opaque token's unread ID/message boundary. The migration -creates an empty ledger so v6 history starts read; Down removes read state and -the remote index without rebuilding or deleting `sms_messages`. - -The v8 rebuild preserves that unread table and its explicit AUTOINCREMENT IDs -while changing `message_id` from the table primary key to a UNIQUE business -key referenced by the existing foreign key. v7 history is assigned explicit -sequence values in ascending recoverable persistence order: -`CASE direction WHEN 'inbound' THEN updated_at_unix_ms ELSE created_at_unix_ms END`, -then `created_at_unix_ms, message_id`. Up and Down run with foreign keys disabled -only around their explicit transaction, restore them afterward, preserve all -messages and unread markers, and must pass `foreign_key_check` plus Down/re-Up -tests. Never sort future SMS records by this backfill expression; it exists only -to recover v7 history. - -Never edit an already released migration to make a new checkout pass. Add a -new migration and a regression from the prior version. - -## sqlc and Generated Ownership - -`sqlc.yaml` currently generates only the small `core` state query package from -`internal/storage/sqlite/queries/core/` and the core migrations. Most domain -stores are hand-written SQL today. Do not claim or assume full sqlc coverage. - -Files under `internal/storage/sqlite/generated/core/` contain `DO NOT EDIT` -headers. Change their query/schema sources and run `make generate`; do not -patch generated methods. If sqlc is expanded to another dataset, update -`sqlc.yaml`, generated-path verification in `Makefile`, and tests in the same -change. +## Storage shape + +`internal/storage/sqlite/store.go` opens one control database at +`/state/control.sqlite3`, with WAL and `0700/0600` directory/file +permissions. `Set.DB` is the shared transaction boundary. Agent hardware SMS +recovery remains an independent private database owned by the Agent. + +ADR 0028 intentionally breaks the old five-database layout. Its migration files, +setup grants, recording/local-CA tables and dormant resource leases are removed. +The new root uses `simplus-control-state-v2`; control and the container initializer +reject old layouts before modifying them. There is no automatic import or cleanup. +Future migrations evolve this new layout without changing released migrations. + +## Repository and transaction rules + +Keep domain-named repositories under `internal/storage/sqlite`. Accept domain or +application values, use context-aware queries and return stable conflicts or +not-found errors. Use constraints and persistent comparisons for idempotency. + +- Administrator creation and installation ready state commit together. +- Incoming SMS, unread marker and subscribed notification tasks commit together, + before acknowledging or deleting a hardware message. +- Per-event subscription start times prevent buffered connection history from + being sent after a new subscription or re-enable. Unchanged subscriptions keep + their original start time when the channel is renamed. +- Connection cursor, confirmed checkpoint and its tasks commit together. Failed + commits do not advance a cursor; replay cannot enqueue duplicate events. +- Outbound operation IDs remain unique and immutable across payload comparisons. +- Notification cancellation is in the channel update/delete or message delete + transaction. A late worker completion cannot revive a cancelled task. +- Claims have bounded leases and attempt fencing. SQL blocks later events in the + same channel/object/connection stream, while other streams remain claimable. + +## Migrations and generated queries + +Migrations live in `migrations/control`, are embedded and applied by Goose. +Append a numbered migration with Up/Down and update `dataset_metadata` version. +Enforce invariants with CHECK, uniqueness and foreign keys. Exercise transaction +failure, restart, replay, cancellation and integrity against temporary SQLite. +`store_test.go` also verifies rejection of unsafe roots and unmodified old files. + +`sqlc.yaml` consumes this one schema with queries under `queries/core`; generated +Go stays under `generated/core`. Change schema/query sources then use `make generate` +and `make verify-generated`; never manually patch generated outputs. Existing +repository transactions may combine generated queries and context-aware SQL. ## Sensitive Persistence @@ -158,16 +68,12 @@ Persist only the business data the feature contract allows: ciphertexts, and empty URL input also preserves the hint. Delivery keeps plaintext only for the current call and passes it through one bounded in-memory adapter request, while `internal/notificationwebhook` owns no Store - or ciphertext. This boundary refactor requires no migration or re-encryption. + or ciphertext. The new layout retains these domain-separated encryption labels. Administrator passwords are stored only as Argon2id hashes produced by - `internal/security/password/argon2id.go`. Setup Local CA private keys are - encrypted with fixed domain-separated labels through a Setup-owned - `SecretProtector`; `cmd/simplusd` injects the concrete instance secretbox, - and SQLite persists the resulting ciphertext fields in the core - `management_tls` configuration. + `internal/security/password/argon2id.go`. The Local CA/setup secret flow has been removed. Mihomo subscription URLs are a current exception: `internal/application/mihomo/subscriptions.go` writes `url_plaintext` in the - private core database and migrates the older encrypted field to plaintext; + private control database; `internal/application/mihomo/subscriptions_integration_test.go` explicitly protects that behavior. Do not claim universal at-rest encryption, log or publish any of these values, or change their storage contract without an @@ -178,13 +84,6 @@ Persist only the business data the feature contract allows: ## Verification -Use the narrow store test first, then generation/full checks when applicable: - -```bash -go test ./internal/storage/sqlite -make verify-generated -make test -``` - -Inspect both upgrade and fresh-database behavior. A migration that passes only -on an empty database is incomplete. +Run affected repositories and use-case tests, then `make verify-generated` and +`make check-docs`. Race/lifecycle tests must prove no database access survives +shutdown. See the active plan for the cross-layer acceptance record. diff --git a/docs/engineering/frontend/directory-structure.md b/docs/engineering/frontend/directory-structure.md index e05d720..9cd762a 100644 --- a/docs/engineering/frontend/directory-structure.md +++ b/docs/engineering/frontend/directory-structure.md @@ -9,6 +9,7 @@ state; `api/openapi.yaml` owns the public contract. ```text web/ +├── e2e-simulator/ # real Go backend + disposable Simulator ├── e2e/ # synthetic Playwright desktop/mobile flows ├── openapi-ts.config.ts # Hey API generator definition ├── playwright.config.ts @@ -17,19 +18,20 @@ web/ │ ├── api/ │ │ ├── generated/ # generated SDK/types/Zod/Query helpers │ │ ├── runtime.ts # same-origin fetch, CSRF, time budgets -│ │ ├── setupClient.ts # error normalization/session expiry +│ │ ├── configureClient.ts # error normalization/session expiry │ │ ├── queryClient.ts # shared Query defaults │ │ ├── events.ts # realtime validation/topic mapping │ │ └── hardwareSchema.ts # topology cross-reference checks │ ├── app/ │ │ ├── AppProviders.tsx # Ant Design, Query, BrowserRouter -│ │ ├── BootstrapGate.tsx # setup/session bootstrap and guards +│ │ ├── SessionGate.tsx # installation/session guards │ │ ├── AppRouter.tsx # explicit lazy route table │ │ ├── AppShell.tsx # responsive navigation and Outlet │ │ └── RealtimeBridge.tsx # authenticated SSE lifecycle │ ├── components/Page.tsx # shared page/state/responsive primitives │ ├── pages/ # routed screens -│ ├── calls|messages|mihomo/ # reusable non-visual feature logic +│ ├── features/ # messages, lines, notifications with hooks/presentation/tests +│ ├── calls|mihomo/ # reusable feature logic │ ├── test/ # shared deterministic fixtures/helpers │ ├── global.css │ └── main.tsx @@ -41,9 +43,10 @@ web/ - Add routes explicitly to `src/app/AppRouter.tsx`; add authenticated menu entries to `src/app/navigation.tsx` when the route belongs in navigation. -- Keep app-wide providers, auth/setup guards, shell layout, and realtime +- Keep app-wide providers, session guards, shell layout, and realtime connection ownership under `src/app/`. -- Put routed screens in `src/pages/`. Keep a one-page helper beside its page; +- Keep feature screens, queries, forms, presentation and tests together in + `src/features/`; simpler existing screens remain in `src/pages/`. Keep a one-page helper beside its page; extract shared presentation primitives only after there is a real second consumer. - Put reusable non-visual domain transforms under their feature directory with @@ -58,13 +61,13 @@ web/ ## Route Contract -`AppRouter` uses `Routes`/`Route` from `react-router`. `/login` and `/setup` -render outside `AppShell`; authenticated pages are children of the shell and +`AppRouter` uses `Routes`/`Route` from `react-router`. `/login` +renders outside `AppShell`; authenticated pages are children of the shell and render through `Outlet`. Route modules are lazy-loaded, and unknown routes replace-navigate to `/dashboard`. Do not add a framework router, filesystem routing, or route-loader state layer. -Setup/session decisions stay in `BootstrapGate`, which can clear the query +Installation/session decisions stay in `SessionGate`, which can clear the query cache and redirect after a 401. ## Generated Ownership diff --git a/docs/engineering/frontend/index.md b/docs/engineering/frontend/index.md index 8cd1caf..71dd139 100644 --- a/docs/engineering/frontend/index.md +++ b/docs/engineering/frontend/index.md @@ -43,10 +43,10 @@ part of the current stack. ```text web/src/main.tsx -> AppProviders (Ant Design + QueryClient + BrowserRouter) - -> BootstrapGate (setup/session routing) + -> SessionGate (installation/session routing) -> AppRouter (explicit lazy routes) -> AppShell (desktop Sider / mobile Drawer + Outlet + RealtimeBridge) - -> pages/*.tsx + -> features/*/*.tsx and pages/*.tsx -> generated TanStack Query options/mutations -> generated SDK -> configured Fetch runtime -> /api/v1 HTTP @@ -62,8 +62,8 @@ web/src/main.tsx - Import routing APIs from `react-router`, UI primitives from `antd`, and icons from `@ant-design/icons`; do not introduce Umi or Pro Components. - Define routes explicitly in `web/src/app/AppRouter.tsx` and navigation in - `web/src/app/navigation.tsx`. Authentication/setup redirects belong in - `BootstrapGate`, not in individual business pages. + `web/src/app/navigation.tsx`. Authentication and installation guards belong in + `SessionGate`, not in individual business pages. - Use generated `*Options`, `*InfiniteOptions`, and `*Mutation` helpers. Build query invalidation from generated keys/tags rather than handwritten endpoint strings. diff --git a/docs/engineering/frontend/quality-guidelines.md b/docs/engineering/frontend/quality-guidelines.md index a6f5ad8..3e82a8d 100644 --- a/docs/engineering/frontend/quality-guidelines.md +++ b/docs/engineering/frontend/quality-guidelines.md @@ -29,7 +29,7 @@ co-located with source and use Testing Library for visible behavior. generated validation, error normalization, and session expiry. - Realtime: exact event validation, topic mapping, active-only invalidation, lifecycle/reconnect/visibility, attention de-dup, and 401 behavior. -- App shell/bootstrap: setup/auth redirects, cache clearing, selected +- App shell/session gate: readiness/auth redirects, cache clearing, selected navigation, desktop Sider, mobile Drawer, and logout failure/success. - Pages: loading/error/unavailable/success states, form/action behavior, candidate freshness, mutation invalidation, and responsive rendering. diff --git a/docs/engineering/frontend/state-management.md b/docs/engineering/frontend/state-management.md index dde9d88..9efd175 100644 --- a/docs/engineering/frontend/state-management.md +++ b/docs/engineering/frontend/state-management.md @@ -1,237 +1,47 @@ -# State Management - -> HTTP authority, query cache, local interaction state, and realtime hints. - -## State Ownership - -- The Go API and SQLite are authoritative for persistent Modem, Line, Message, - Call, notification, eUICC, VoWiFi, and Mihomo state. -- SMS conversation identity, summaries, and unread markers are backend-owned. - The browser keeps only the selected exact remote address and submits a - server-issued snapshot token after visible detail renders; it never derives - unread counts by grouping cached messages. -- TanStack Query owns replaceable HTTP snapshots, loading/error metadata, and - generated query keys. It is not an offline database. -- `BootstrapGate` owns setup/session routing; only a protected administrator - session 401 cancels and clears the query cache before redirecting to - `/login`. Setup authorization and rejected-login 401s are separate outcomes. -- Page components own form drafts, dialog visibility, selections, reveal state, - and per-operation progress/errors. -- There is no Redux, Zustand, Umi model, or browser-persistent business store. - -## Query and Local State Rules - -Use generated Query options for server data and local React state for transient -interaction: - -```tsx -const modems = useQuery(listManagedModemsOptions()) -const [selectedCandidate, setSelectedCandidate] = useState('') -const [operationError, setOperationError] = useState() -``` - -Do not copy query results into `useState` merely to render them. Derive joins, -labels, and flattened page lists with ordinary constants or `useMemo`. Keep a -row-specific busy key when one action should not block unrelated records. - -After a mutation, invalidate the affected generated query key and render the -server-confirmed result. A returned complete resource may update the exact -query cache only when the contract makes that replacement unambiguous. Never -optimistically claim success for SMS, call, RF, VoWiFi, eUICC, or another -side-effecting operation. - -## Scenario: Separate Setup and Administrator Authorization - -### 1. Scope / Trigger - -Use this scenario when changing `BootstrapGate`, the generated-client error -interceptor, Login/Setup pages, setup cookies, or the Vite `/api` development -proxy. These surfaces jointly decide whether an anonymous browser enters setup, -stays on login, or recovers from an expired administrator session. - -### 2. Signatures - -- `GET /api/v1/setup/status` is public and decides whether setup is required. -- `GET /api/v1/setup/session` uses the restricted - `simplus_setup_session` HttpOnly cookie; 401 means setup authorization is - absent or expired. -- `POST /api/v1/auth/login` is public; 401 means the submitted credentials were - rejected. -- `GET /api/v1/auth/session` and protected business operations use the - administrator session; their 401 responses trigger session-expiry recovery. -- `VITE_API_PROXY_TARGET` selects the loopback API target for Vite. The `/api` - proxy keeps `changeOrigin: false`. - -### 3. Contracts - -- The client error interceptor classifies a 401 using the originating request - pathname, not status alone. Paths below `/api/v1/setup/` and exact path - `/api/v1/auth/login` return ordinary `ApiClientError` values without calling - `notifySessionExpired()`. -- A protected endpoint 401 still notifies session expiry. `BootstrapGate` then - cancels requests, clears private Query snapshots, and replace-navigates to - `/login`. -- When `setupRequired` is true, both `/login` and direct `/setup` are public. - An ordinary root/protected visit settles on `/login`; successful login uses - the already-fetched setup status and replace-navigates to `/setup`. Direct - `/setup#bootstrap=...` and setup-session reloads remain reachable. -- In LAN development, Vite is the browser-facing same-origin server while the - API remains loopback-only. The proxy preserves the browser Host so the - backend's trusted-LAN validation and setup-completion `managementUrl` refer - to the address the remote browser can actually reach. -- Production continues to serve Web and API directly from `simplusd`; it does - not rely on forwarded-host headers or the Vite proxy. - -### 4. Validation & Error Matrix - -| Response source | Status | Required browser behavior | -| --- | --- | --- | -| `/api/v1/setup/session` | 401 | Stay on `/setup`; render the setup-authorization error; do not clear Query state or navigate to login | -| `/api/v1/auth/login` | 401 | Stay on `/login`; show rejected credentials; do not emit session expiry | -| Successful login while setup is required | 200 | Cache the administrator session and replace-navigate to `/setup`; the backend has issued the restricted setup cookie | -| `/api/v1/auth/session` | 401 | Clear private Query snapshots and replace-navigate to `/login` | -| Protected business operation | 401 | Use the same administrator session-expiry recovery | -| Protected business operation | 403 | Keep the current route and surface the operation error | -| Setup completion through Vite on a valid private/LAN Host | 200 | Return a `managementUrl` using the browser-facing Host and Vite port | -| Any API request with an untrusted Host | 421 | Fail closed with `TRUSTED_LAN_HOST_REQUIRED`; never trust arbitrary forwarding headers | - -### 5. Good / Base / Bad Cases - -- Good: an expired administrator cookie on `/dashboard` produces one protected - 401, clears private cache, and ends on `/login`. -- Base: an anonymous browser opens an uninitialized instance, settles on - `/login`, authenticates with the root-provisioned administrator, and then - reaches `/setup` with the restricted setup cookie issued by the login API. -- Base: a root-generated bootstrap URL or an authorized setup reload opens - `/setup` directly without requiring an administrator-session probe. -- Bad: broadcasting session expiry for every 401 sends `/setup` to `/login`, - while the setup-required gate sends `/login` back to `/setup`, creating an - unbounded redirect loop. -- Bad: rewriting the Vite proxy Host to its loopback target makes setup - completion redirect a remote browser to that browser's own `127.0.0.1`. - -### 6. Tests Required - -- Runtime unit tests: setup-session 401 and rejected-login 401 do not notify - administrator expiry; auth-session 401 still does; 403 never does. -- Component integration: an ordinary uninitialized root/protected visit settles - on `/login`; `/login` and direct `/setup` stay reachable without an - auth-session probe; an unauthorized direct Setup page makes one setup-session - request and does not loop. -- Login page: use cached setup status after a successful login and assert the - destination is `/setup` when incomplete and `/dashboard` when ready. -- Vite config: assert `/api` has `changeOrigin: false`. -- Proxy smoke when changing dev wiring: run a real Vite-to-upstream request and - assert the upstream receives the original private Host. -- Browser regression: observe the final path and a bounded navigation count; - checking only the final path can miss a fast `/setup` ↔ `/login` loop. - -### 7. Wrong vs Correct - -```ts -// Wrong: unrelated authorization domains collapse into one global reaction. -if (response.status === 401) notifySessionExpired() - -// Correct: only protected administrator requests expire that session. -const path = request ? new URL(request.url).pathname : '' -const expectedUnauthorized = - path.startsWith('/api/v1/setup/') || path === '/api/v1/auth/login' -if (response.status === 401 && !expectedUnauthorized) notifySessionExpired() -``` - -## Scenario: Authoritative HTTP with Advisory SSE - -### 1. Scope / Trigger - -Use this scenario whenever an API resource can change outside the current -browser action (incoming SMS/call, background synchronization, Agent changes, -VoWiFi reconcile, Mihomo or inventory changes). - -### 2. Signatures - -- `GET /api/v1/events` -> authenticated `text/event-stream`. -- SSE named events: `update` and `resync`; each `data` is `RealtimeEvent`. -- `RealtimeEvent = { topics: RealtimeTopic[]; attention?: RealtimeAttention }`. -- `RealtimeAttention = 'sms.received' | 'call.incoming'`. -- Resource reads remain ordinary generated HTTP queries such as - `GET /api/v1/messages`, `GET /api/v1/calls`, and `GET /api/v1/vowifi/lines`. - -### 3. Contracts - -- `topics` contains 1–11 unique values from the OpenAPI enum. -- SSE payloads contain no resource bodies, phone numbers, message content, - credentials, hardware identities, or private topology. -- On a valid event, map topics to generated query tags and invalidate matching - active queries with `refetchType: 'active'`. -- `sms.received` and `call.incoming` may show generic attention. VoWiFi and all - other topic-only changes remain silent. -- On reconnect, tab visibility recovery, or `resync`, the browser obtains truth - through HTTP. It never reconstructs missed state from event history. -- The stream uses the same administrator cookie session and trusted-LAN - authority boundary as business APIs. - -### 4. Validation & Error Matrix - -| Condition | Required behavior | -| --- | --- | -| Unknown/extra field, invalid topic, duplicate topic, malformed JSON | Ignore event; do not mutate cache or show attention | -| Stream drops while session remains valid | Close source and reconnect with 3–30 second bounded backoff | -| Session probe returns 401 | Normal session-expiry path clears cache and redirects to login | -| Tab becomes hidden | Close source; do not reconnect while hidden | -| Tab becomes visible | Invalidate active queries, then reconnect | -| Repeated attention event ID | Invalidate as needed but suppress duplicate toast | -| SSE unavailable | Page remains usable through HTTP/manual refetch; mounted VoWiFi/Mihomo runtime queries also have a 10-second foreground-only fallback | - -### 5. Good / Base / Bad Cases - -- Good: an inbound SMS publishes `{topics:['messages'], - attention:'sms.received'}`; the active message query refetches and a generic - notice appears. -- Base: a VoWiFi state change publishes `{topics:['vowifi','lines']}`; matching - visible snapshots refetch silently. -- Bad: putting an SMS body in SSE and appending it directly to the cache creates - a second contract, leaks data, and can diverge after disconnects. - -### 6. Tests Required - -- Unit: exact event validation, unique topics, topic-to-tag invalidation, active - query scope, and resync. -- Component: one EventSource owner, named event handling, attention de-dup, - visibility close/resync, bounded reconnect, and 401 behavior. -- E2E: synthetic SSE causes the visible list to refetch on desktop/mobile; no - hardware or real SMS/call action. -- Backend: auth/session revalidation, heartbeat/write deadlines, slow-client - backpressure, bounded/privacy-safe payloads, and mutation/background - publication. - -### 7. Wrong vs Correct - -```ts -// Wrong: event data becomes authoritative browser state -source.onmessage = (event) => queryClient.setQueryData(['messages'], JSON.parse(event.data)) - -// Correct: event is a validated hint; HTTP remains authoritative -source.addEventListener('update', (event) => { - const hint = decodeRealtimeEvent((event as MessageEvent).data) - if (hint) void invalidateRealtimeTopics(queryClient, hint.topics) -}) -``` - -## Sensitive State - -- Authentication and CSRF are cookie-based; do not copy tokens into - `localStorage` or `sessionStorage`. -- IMEI reveal data stays page-local, is not persisted, and is cleared on hide - and reload. -- Candidate/discovery snapshots are ephemeral evidence. Only an explicit API - mutation creates a managed Modem or Line. -- Do not cache secrets, raw hardware identifiers, device paths, or event - payloads for later replay. - -## Avoid - -- Manual polling for resources covered by Query plus SSE invalidation. -- A global client store that duplicates server-owned entities. -- Mutating arrays obtained from Query in place. -- Cross-coupling Line identity, RF, egress, and VoWiFi state into one browser - flag or optimistic operation. +# Browser State and Session Management + +HTTP snapshots and backend persistence are authoritative. TanStack Query caches +replaceable snapshots; SSE only invalidates topics or signals attention. Pages +must not reconstruct server state from event payloads or persist credentials, +messages, SIM identities or pending submissions in browser storage. + +## Installation and authentication + +`SessionGate` first reads public system health. A new instance asks the operator +to finish administrator provisioning on the server; it has no Web setup flow. +Provisioning creates the administrator and ready state atomically. Ready instances +probe the administrator session, show `/login` when absent, and enter `/dashboard` +after successful authentication. `/setup` and its APIs have been removed. + +Login credential rejection stays an operation error. A protected endpoint 401 +advances the in-memory session generation, cancels queries, clears private caches +and replaces the route with `/login`. Repeated 401s do not create a clear/refetch +loop. Login and logout advance the same generation. Responses started under an +older session are discarded before they can repopulate a new user's cache. +A 403 remains an operation error; it must not trigger a login loop. + +## Async forms and SMS uncertainty + +Mutations never retry automatically. Each SMS submission owns an operation ID +and immutable payload until a definitive result. `queued`, `unconfirmed`, lost +response and timeout preserve that request. Querying it again uses the same ID +even if the current device is unavailable. The backend checks its durable result +before resolving hardware and never resends an uncertain operation. + +An asynchronous completion clears only the draft and conversation it submitted. +Switching conversations or sessions must not clear a newer draft, attach an old +result to a different user or silently select a replacement Line. Pending IDs are +local component state; the UI asks users to keep the page open until confirmation. +Authoritative SMS records remain persisted and can be inspected after navigation. + +Unread markers are created by first inbound persistence. Clear them only with +the opaque read-through token from a successfully rendered latest HTTP snapshot, +while that conversation is visible and the document is in the foreground. + +## Verification + +Use `SessionGate.test.tsx`, API runtime tests, message feature tests, fixture +Playwright and `e2e:simulator`. Cover wrong credentials, expiry, logout/login with +in-flight responses, unknown SMS results, immutable request replay and conversation +switches during submission. Simulator browser tests use the real Go backend and +a local rejecting notification proxy, with disposable state and no hardware. diff --git a/docs/engineering/frontend/type-safety.md b/docs/engineering/frontend/type-safety.md index 6af7ca2..7be7611 100644 --- a/docs/engineering/frontend/type-safety.md +++ b/docs/engineering/frontend/type-safety.md @@ -43,7 +43,7 @@ generated TypeScript response contract is `number`. - same-origin credentials and base URL; - `Accept: application/json`; -- CSRF header on mutating business APIs, excluding login/setup; +- CSRF header on mutating business APIs, excluding login; - endpoint-aware bounded deadlines; - caller abort propagation; - stable `ApiClientError` kinds/codes for timeout, abort, network, HTTP, diff --git a/docs/engineering/infra/build-and-generated-files.md b/docs/engineering/infra/build-and-generated-files.md index 5164877..0436919 100644 --- a/docs/engineering/infra/build-and-generated-files.md +++ b/docs/engineering/infra/build-and-generated-files.md @@ -48,6 +48,11 @@ Prefer these targets over reconstructing tool invocations in a new script. When a new repeatable operation is needed, add one clear Make target and reuse it from CI/docs instead of creating divergent command sequences. +The pnpm store is fixed in `pnpm-workspace.yaml`; do not switch store locations +between generation/build invocations. Build does not regenerate API files. +Vitest uses two workers; Playwright Simulator tests use one. Vite separates the +React runtime and generated Zod validation chunks while keeping route imports lazy. + ## Generated Sources and Outputs The `generate` target has three owners: @@ -55,7 +60,7 @@ The `generate` target has three owners: | Source | Generator definition | Output | | --- | --- | --- | | `api/openapi.yaml` | `internal/api/openapi/generate.go` + `api/oapi-codegen.yaml` | `internal/api/openapi/generated.go` | -| `internal/storage/sqlite/migrations/core/**` + `internal/storage/sqlite/queries/core/**` | `sqlc.yaml` | `internal/storage/sqlite/generated/core/*.go` | +| `internal/storage/sqlite/migrations/control/**` + `internal/storage/sqlite/queries/core/**` | `sqlc.yaml` | `internal/storage/sqlite/generated/core/*.go` | | `api/openapi.yaml` | `web/openapi-ts.config.ts` via root `api:generate` -> Web `generate:api` | `web/src/api/generated/` Fetch SDK, TypeScript, Zod, and TanStack Query output | `Makefile` `GENERATED_PATHS` is the drift-check registry. `verify-generated` @@ -78,14 +83,13 @@ trusted-LAN preview. The Web remains same-origin: browser `/api` and SSE traffic flows through Vite to the loopback API. The proxy must use `changeOrigin: false`. `simplusd` validates the request Host -as loopback/private trusted-LAN authority, and setup completion derives its -browser-facing `managementUrl` from that validated authority. Rewriting Host to -the API target can redirect a remote browser to its own loopback interface. +as loopback/private trusted-LAN authority; preserve the browser-facing authority +for Origin and same-origin checks. Rewriting Host changes that trust boundary. Do not replace this contract with unvalidated `X-Forwarded-Host` handling. Keep `web/src/viteConfig.test.ts` and a real proxy smoke when changing this wiring. Authentication-context and redirect assertions are specified in -[`frontend/state-management.md`](../frontend/state-management.md#scenario-separate-setup-and-administrator-authorization). +[`frontend/state-management.md`](../frontend/state-management.md#installation-and-authentication). ## Third-Party and Packaging Inputs @@ -191,7 +195,7 @@ assets, or production installation/upgrade instructions. checksum, fresh-root creation, extraction, host validation, pull, and start steps fail closed. Fresh installation changes ownership only on the newly created root, never recursively on an existing deployment tree. Fresh start - and upgrade wait for the one-shot `bootstrap` container and propagate its + and upgrade wait for the one-shot `provision` container and propagate its exit status before showing credentials or declaring the command successful. - Those instructions describe the ordinary operator input as exactly one versioned deployment archive plus its checksum. They distinguish the two @@ -217,7 +221,7 @@ assets, or production installation/upgrade instructions. | a code change is needed after tagging | do not move/reuse the tag; publish the next patch version | | the recommended fresh-install root already exists | stop without extracting or changing ownership; direct the administrator to the lifecycle upgrade procedure | | checksum, host check, Compose render, or image pull fails | do not execute later install/start steps in the copy-paste command chain | -| `bootstrap` has not exited or exits non-zero | wait or fail the command chain; do not show an empty log as successful initialization | +| `provision` has not exited or exits non-zero | wait or fail the command chain; do not show an empty log as successful initialization | ### 5. Good / Base / Bad Cases @@ -247,7 +251,7 @@ assets, or production installation/upgrade instructions. - Review `README.md`, `docs/installation.md`, and `packaging/container/README.md` as one installation interface: durable root, three editable keys, existing-root refusal, non-recursive ownership, and - stop-on-error command ordering plus successful `bootstrap` wait must remain + stop-on-error command ordering plus successful `provision` wait must remain aligned. Each must also make the one-archive-plus-checksum operator input clear without presenting Release audit/source assets as additional installation downloads. diff --git a/docs/engineering/infra/containers-and-privileges.md b/docs/engineering/infra/containers-and-privileges.md index f51e7ba..9820bcf 100644 --- a/docs/engineering/infra/containers-and-privileges.md +++ b/docs/engineering/infra/containers-and-privileges.md @@ -17,8 +17,8 @@ there is no native production bundle, installer, or uninstaller. `simplus-netd`, kept root because it owns per-Line network objects. `compose.yaml` orchestrates those images through five services: -`data-init`, `agent`, `netd`, `app`, and one-shot `bootstrap`. This does not -collapse the runtime into five business processes: data-init/bootstrap are +`data-init`, `agent`, `netd`, `app`, and one-shot `provision`. This does not +collapse the runtime into five business processes: data-init/provision are bounded lifecycle steps around the three responsibility boundaries described in `docs/architecture.md` and `docs/decisions/0021-container-production-deployment.md`. @@ -58,7 +58,7 @@ access, or an arbitrary command/path input. `containers/netd-preflight.sh` before starting the supervisor. The preflight creates disposable netns, veth, nft TPROXY, and XFRM objects inside the netd container namespace and then removes them. Failure keeps netd unhealthy, so -app/bootstrap do not proceed. +app/provision do not proceed. `cmd/simplus-netd/main.go` exposes fixed supervisor operations over an authenticated Unix socket. Per-Line workers receive validated stable Line ID, @@ -71,7 +71,12 @@ canonical architecture/install docs together. ## Data and Initialization -Compose uses bind-mounted `./data/core` and `./data/agent`. `data-init` fixes +Netd mounts only the Mihomo subtree and runtime volume; it cannot read the control +database or key directory. The initializer rejects `data/core` and legacy `db/` +layouts before modification. Administrator creation and instance readiness are +one transaction; subsequent login enters management directly. + +Compose uses bind-mounted `./data/control-v2` and `./data/agent`. `data-init` fixes ownership/modes, installs the checked Zashboard tree, and seeds the pinned Mihomo core only for unambiguous new state. It refuses symlinks and refuses to guess an active version when existing version data lacks a current manifest @@ -83,8 +88,8 @@ backups. Database files, credentials, subscriptions, logs, and Compose data remain private and excluded by `.gitignore`/`.dockerignore` as described in `docs/installation.md` and `docs/privacy-and-publication.md`. -The one-shot bootstrap waits for typed app health, then idempotently provisions -the sole administrator. Initial credentials appear only on first bootstrap; +The one-shot provision waits for typed app health, then idempotently provisions +the sole administrator. Initial credentials appear only on first provisioning; never copy them into docs, fixtures, commands, or issue output. ## Host and Lifecycle Boundary diff --git a/docs/handoff.zh-CN.md b/docs/handoff.zh-CN.md index 24d2800..85435f6 100644 --- a/docs/handoff.zh-CN.md +++ b/docs/handoff.zh-CN.md @@ -1,24 +1,42 @@ # Simplus 当前开发交接 -> 更新:2026-08-21 +> 更新:2026-09-30 > > 状态:V1 管理面、Vite/React Query 前端、持久模组/线路层、Host VoWiFi/SMS over IMS 与 QDC507 原生蜂窝短信纵切已完成;`v0.1.1` Pre-release 已提供匿名可拉取的三镜像与版本化部署包,clean-VM 生命周期验收尚待完成。 本文只记录可以公开的代码状态、验证等级和下一步;现场与个人环境材料遵循 [`privacy-and-publication.md`](privacy-and-publication.md) 留在仓库外。产品范围以 [`product.md`](product.md) 为准,进程与安全不变量以 [`architecture.md`](architecture.md) 为准,任务状态以 [`plans/active/mvp.md`](plans/active/mvp.md) 为准。 +本次重构尚未发布;下述新布局与原子安装行为不属于已发布的 `v0.1.1`。 + +## 2026-09-30 重构状态 + +控制面现已使用独立 `data/control-v2/state/control.sqlite3`,安装事务创建管理员并就绪, +登录直接进入管理界面。旧多库/设置流程、租约和命令占位已删除;旧布局拒绝启动, +不会迁移或改写。netd 只挂载 Mihomo 子目录与运行目录。 + +应用层通过小接口依赖适配器;生产 IMS/SIP/strongSwan 与 HIL 入口分离。短信按当前 +Agent 实例解析身份,持久结果优先,提交后未知结果不重发。进程退出等待请求与后台任务。 + +通知持久化、重试和取消已接通,入站短信/未读/通知同事务。新增四类连接通知, +首次在线和每次确认变化立即通知;未知状态保留确认值。VoWiFi 使用带游标的 1024 条 +有序变化,蜂窝按模组独立五秒采样。Simulator 也使用这套监测路径。 + +验证进度与最终证据集中在[唯一活跃计划](plans/active/mvp.md);此改动不增加任何真实 +硬件兼容性证据,既有 clean-VM/部署验收状态保持待完成。 + ## 已落地能力 ### 管理与安装 -- 单管理员 setup、登录、CSRF、会话撤销和修改密码; +- 安装时单管理员初始化、登录、CSRF、会话撤销和修改密码; - React、Vite、React Router、直接 Ant Design 与 TanStack Query 后台; - OpenAPI 生成 Fetch/TypeScript/Zod/Query 契约,鉴权 HTTP 承载权威读写,同源 SSE 只做有界失效和新短信/来电提示; - 概览、模组、线路、短信、语音、Mihomo、通知和系统设置页面; - 通知页保留企业微信/飞书手工 Webhook,并已增加飞书中国版最小权限应用的一键绑定: 短期 URL 等待授权,只向授权用户私聊,测试成功后才加密持久化;解绑只删除本地绑定; -- production Dockerfile 保持 `simplus-control`、`simplus-agent`、`simplus-netd` 三个镜像,Compose 以 `data-init / agent / netd / app / bootstrap` 编排全新实例,并且是唯一受支持的 production 部署方式;clean-VM 生命周期验收仍待完成; +- production Dockerfile 保持 `simplus-control`、`simplus-agent`、`simplus-netd` 三个镜像,Compose 以 `data-init / agent / netd / app / provision` 编排全新实例,并且是唯一受支持的 production 部署方式;clean-VM 生命周期验收仍待完成; - Host VoWiFi 所需的两个 strongSwan 插件由锁定 Debian 输入独立构建为 `simplus-strongswan-plugins` 包,并随对应源码、摘要和 manifest 发布;netd 镜像安装该包和 Debian runtime,不要求用户或普通开发者提供 strongSwan 源码树; -- 全新容器实例由 bootstrap 生成随机管理员密码,升级和重建不覆盖凭据。 +- 全新容器实例由 provision 生成随机管理员密码,升级和重建不覆盖凭据。 ### 容器部署候选 @@ -34,8 +52,8 @@ - app 固定 UID 10001 且无 capability;netd 使用普通 Docker bridge 和现有受限网络 capability,临时 netns/veth/nft/XFRM probe 失败即不健康,不使用 privileged 或 host network; -- bind-mounted 数据固定为 `./data/core` 与 `./data/agent`。data-init 固定所有权并 - 安装 Zashboard 和固定摘要的 Mihomo core,已有 core 不覆盖;bootstrap 首次创建 +- bind-mounted 数据固定为 `./data/control-v2` 与 `./data/agent`。data-init 固定所有权并 + 安装 Zashboard 和固定摘要的 Mihomo core,已有 core 不覆盖;provision 首次创建 `simplus_admin`,密码只写首次容器日志;tag release 同时附带 Mihomo GPL 源码; - app/Agent/netd 的 typed health、Compose YAML 权限 contract、Shell 语法、workflow actionlint、目标 Go 测试和 Compose `config --quiet` 已通过;当前 Debian 13/amd64 diff --git a/docs/installation.md b/docs/installation.md index 8c1914c..4ad9345 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,6 +2,11 @@ ## 当前状态 +本文的新布局、provision 与安装就绪事务对应尚未发布的源码重构。历史 `v0.1.1` +的部署包不包含这些行为,不能混用;以下命令中的 `vX.Y.Z` 必须替换为包含 +[ADR 0028](decisions/0028-control-state-and-durable-notifications.md) 的明确新版本。 +下面保留的既有发布和 HIL 记录不代表新布局已部署验收。 + Docker Compose 是 Simplus 唯一受支持的生产部署方式,并保持 `simplusd`、`simplus-agent`、 `simplus-netd` 三个权限边界。生产安装接口是 GitHub Pre-release 中的版本化部署包和 GHCR 镜像,不要求源码 checkout、Git、Go、Node、pnpm、Make 或本地 `docker build`。 @@ -47,14 +52,13 @@ Docker Engine 的安装按 [Docker 官方 Debian 指南](https://docs.docker.com ## 下载部署包 -每个严格 `vX.Y.Z` tag 对应一个 `linux/amd64` 部署包。下面以替代部署候选 `v0.1.1` -为例;普通安装只需下载一个 `.tar.gz` 部署归档和它的一个 `.sha256` 校验文件。Release +每个严格 `vX.Y.Z` tag 对应一个 `linux/amd64` 部署包。版本示例使用待替换的 `vX.Y.Z`;普通安装只需下载一个 `.tar.gz` 部署归档和它的一个 `.sha256` 校验文件。Release 页面中的 strongSwan/Mihomo 对应源码、包与镜像 digest manifest 用于发布审计、许可和 可追溯性,不是需要逐个下载或解压的安装包;三个运行镜像由后续 `docker compose pull` 直接从 GHCR 拉取。版本变量必须同时决定下载目录和文件名,不要改为滚动的 `latest`: ```bash -version=v0.1.1 +version=vX.Y.Z base="https://github.com/leonfox28/simplus/releases/download/$version" curl -fLO "$base/simplus-compose-$version-linux-amd64.tar.gz" && curl -fLO "$base/simplus-compose-$version-linux-amd64.tar.gz.sha256" && @@ -120,9 +124,9 @@ Agent,避免它们在当前或下次启动时与 Compose 争用模组、端口 docker compose config --quiet && docker compose pull && docker compose up -d && - docker compose wait bootstrap && + docker compose wait provision && docker compose ps && - docker compose logs bootstrap + docker compose logs provision ``` `SIMPLUS_DEVICE_GID` 必须等于宿主 ttyUSB 节点所属组的数字 GID。用 @@ -134,14 +138,20 @@ docker compose config --quiet && `data-init` 创建并固定: ```text -/opt/simplus/data/core -> /var/lib/simplus +/opt/simplus/data/control-v2 -> /var/lib/simplus /opt/simplus/data/agent -> /var/lib/simplus-agent ``` -Compose 实际使用相对于其文件的 `./data/core` 和 `./data/agent`;上面的绝对路径来自本文 +Compose 实际使用相对于其文件的 `./data/control-v2` 和 `./data/agent`;上面的绝对路径来自本文 推荐的部署根。首次 `compose up` 前应确认该位置位于持久存储,并确定实例外、访问受限的 备份位置。 +control 的业务数据库为 `./data/control-v2/state/control.sqlite3`,实例密钥位于同一私有 +state 目录。netd 仅挂载 `./data/control-v2/mihomo` 和共享运行目录,不再访问 control +数据库或密钥。发现旧 `./data/core` 或新根中的旧 `db/` 布局会在初始化前拒绝启动, +不自动改写、搬迁或删除;先离线备份并将旧目录保留在此次新布局之外。 +本版本不兼容旧数据库,不是原地升级路径。已有实例必须明确选择新目录重新初始化。 + Agent 在这个私有目录下固定使用 `qdc507-sms/` 保存 QDC507 v2 SMS recovery ledger;container entrypoint 通过 `--state-root /var/lib/simplus-agent/qdc507-sms` 传入。缺失/不安全目录、 schema 不兼容或 store/adapter 构造失败会阻止 Agent 启动,不能退化为不带恢复状态的 @@ -152,7 +162,7 @@ schema 不兼容或 store/adapter 构造失败会阻止 Agent 启动,不能退 许可证位于 `/usr/share/doc/simplus/mihomo-LICENSE`,相同 Simplus tag 的 GitHub Release 附带校验过的对应 Mihomo 源码归档。 -`bootstrap` 在 app 健康后创建唯一管理员。首次日志显示用户名 `simplus_admin` 和随机 +`provision` 在 app 健康后原子创建唯一管理员并将实例置为就绪,登录直接进入管理界面。首次日志显示用户名 `simplus_admin` 和随机 密码;后续重建只显示凭据未变化,不会覆盖或重新输出密码。保存密码并在首次登录后 修改。管理后台通过 `http://:8080` 访问;Mihomo 运行后 Zashboard 使用 同一主机的 `19090` controller 和实例独立 secret。 @@ -188,7 +198,7 @@ new_bundle="/path/to/verified/simplus-compose-vX.Y.Z-linux-amd64" docker compose config --quiet docker compose pull docker compose up -d - docker compose wait bootstrap + docker compose wait provision ) ``` @@ -219,7 +229,7 @@ docker compose down - app、Agent 与 netd 继续通过共享 Unix socket、固定 UID 和 `SO_PEERCRED` 鉴权。 netd 启动时创建临时 namespace/veth/nft/XFRM 探针并立即清理。探针失败会阻止 netd -健康,app 和 bootstrap 也不会继续启动。该 preflight 不执行 RF、SIM AKA、VoWiFi、 +健康,app 和 provision 也不会继续启动。该 preflight 不执行 RF、SIM AKA、VoWiFi、 短信或电话操作。 ## 常见启动失败 @@ -230,7 +240,7 @@ netd 启动时创建临时 namespace/veth/nft/XFRM 探针并立即清理。探 - Agent 不健康:检查 ttyUSB 的 GID、device cgroup、ModemManager 占用和 USB 枚举; - netd preflight 失败:检查 rootful Docker、AppArmor 设置和宿主内核的 veth、nft TPROXY、XFRM 支持;不要改为 privileged/host network 掩盖问题; -- bootstrap 没有密码:先看 `docker compose ps`,只有 app 健康且数据库中尚无管理员时 +- provision 没有密码:先看 `docker compose ps`,只有 app 健康且数据库中尚无管理员时 才会生成; - Mihomo/VoWiFi 不可用:先确认预装或后台升级的 core 有效,并选择有效订阅/出口,再按 [`troubleshooting.md`](troubleshooting.md) 的稳定状态检查。 diff --git a/docs/plans/active/mvp.md b/docs/plans/active/mvp.md index b23b436..130189d 100644 --- a/docs/plans/active/mvp.md +++ b/docs/plans/active/mvp.md @@ -7,6 +7,26 @@ ## 目标 +### 已实现:全仓重构与连接状态通知 + +已确认决策见 [`0028`](../../decisions/0028-control-state-and-durable-notifications.md)。 +保留技术栈和三进程;允许破坏旧 API/数据库兼容性,旧数据只能拒绝启动,不自动改写。 +此次不部署、不执行真实通信、RF 或 HIL。按以下阶段保持实现可运行、可验证: + +- [x] 修复短信当前实例/设备/SIM 目标解析、持久结果优先及未知结果不重发;统一退出等待和独立线路调度。 +- [x] 收敛应用小接口、协议/外部 HTTP/文件/子进程适配器,拆分 HTTP,构造时校验依赖。 +- [x] 新布局单一 control SQLite、安装事务就绪,删除旧设置、租约和命令占位;建立事务通知队列。 +- [x] 有序 VoWiFi 变更、独立蜂窝探测、持久检查点、首次在线及每次确认变化通知;独立重试、取消与投递顺序。 +- [x] 分离生产 IMS/SIP/strongSwan 与 HIL,收紧 worker/C 协议及 netd 挂载。 +- [x] 按功能整理前端,修复会话/异步/未知结果处理,同步 OpenAPI、生成物和构建配置。 +- [x] 完成 Go/race/Web/C/文档/生成物/容器及真实后端 Simulator 浏览器验证,记录证据。 + +验收覆盖 Agent 重启、端口/SIM 变化、响应丢失、事务失败/崩溃恢复、取消订阅、 +连接首次基线/快速变化/未知状态、游标重放/溢出/来源重启,以及慢对象隔离和退出等待。 + +进度:实现与各阶段验证已完成;最终检查结果和限制见[验收记录](../completed/2026-09-30-control-refactor.md)。 +未执行发布、部署或真实硬件操作;clean-VM 生命周期验收仍属于后续工作。 + 在 Debian Linux 主机上提供单管理员 Web 后台,以统一类型化接口管理 4G/5G 模组、线路、短信、电话、可拔插 eUICC 已安装 Profile,以及 Host VoWiFi 的专用 Mihomo 出口。 完整业务交互先在 Simulator 验证。真实硬件默认不开放业务写入;单独决策的 ML307A 运行时 RF 开关和 Host VoWiFi 使用各自的窄化类型化边界,不扩大为通用硬件写平台。现有 Host VoWiFi 真实证据均在 RF Off 下取得。 diff --git a/docs/plans/completed/2026-09-30-control-refactor.md b/docs/plans/completed/2026-09-30-control-refactor.md new file mode 100644 index 0000000..c1cd275 --- /dev/null +++ b/docs/plans/completed/2026-09-30-control-refactor.md @@ -0,0 +1,77 @@ +# 全仓重构与连接状态通知验收记录 + +- 状态:Completed +- 决策:[0028:统一 control 状态、安装初始化与持久通知](../../decisions/0028-control-state-and-durable-notifications.md) +- 产品后续工作:[唯一活跃计划](../active/mvp.md) + +## 交付范围 + +保留 Go、React、SQLite 和 control/agent/netd 三进程。control 使用新布局中的 +`state/control.sqlite3`;Agent 的短信恢复数据库保持独立。安装事务同时创建管理员和 +标记实例就绪,登录后直接进入管理界面。旧数据库布局拒绝启动,不自动迁移或删除。 +旧设置接口、本地 CA、首次登录审查/录音目录、资源租约、旧命令账本和占位回放已移除。 + +短信操作按当前 Agent 实例、设备代际和设备/SIM 身份解析目标。重复请求先查持久结果; +发送后响应丢失、超时或损坏保留 `unconfirmed`,不自动重发。前端保留原操作标识与 +原始内容,可在设备离线时查询已有结果;会话切换后丢弃旧异步响应。 + +应用服务通过小接口使用 Agent/netd、飞书、订阅下载、Mihomo 文件和进程适配器。 +HTTP 按认证、设备、通信、网络和通知拆分,生产 IMS/SIP/短信/strongSwan 实现移入 +`internal/ims`,HIL 保留显式验证入口。依赖检查约束应用层不引入具体协议和 I/O 适配器。 +退出时停止接入、取消并等待请求及后台任务、回收所拥有的子进程和网络资源,再关闭数据库。 + +入站短信、未读状态和通知任务在同一事务提交后才确认硬件消息。通知 worker 独立投递, +暂时失败按 15 秒至 5 分钟退避,永久失败保留原因,重启恢复中断任务。停用渠道、取消 +订阅、删除渠道或短信会取消相关未投递任务;订阅起点阻止补发此前缓存的连接事件。 + +新增四类连接通知,使默认订阅扩展到九类。VoWiFi 使用带实例和序号的 1024 条有序事件 +缓存;蜂窝使用独立的五秒只读探测。同模组不重叠,慢对象不阻塞其他对象。首次确认在线 +通知,首次离线建立基线,此后每次确认变化立即入队;未知、通信失败、漫游切换和 IMS +刷新不产生虚假变化。检查点、游标和任务原子提交;同对象、连接类型和渠道保持投递顺序。 + +前端按短信、线路、通知功能拆分,HTTP 快照继续作为权威状态;通知页显示待投递和失败 +数量。OpenAPI、Go/TypeScript/sqlc 生成物同步更新。构建与生成分离,固定 pnpm 存储, +限制测试并发;拆分共享包后最大 JavaScript chunk 从约 640 kB 降至约 365 kB。 +netd 仅挂载所需 Mihomo 数据与运行目录,不能访问 control 数据库或密钥目录。 + +## 问题与回归证据 + +| 审查问题 | 修复及行为证据 | +| --- | --- | +| Agent 重启后目标过期、设备换端口或换 SIM | `smstransport`、`modemagent`、`agentinventory` 与 messaging 测试覆盖当前实例、代际、身份和持久结果优先 | +| 提交后响应丢失导致误判可重发 | `agentapi.TestSendTreatsLostOrDamagedReplyAsUnknown`、netd 客户端和短信服务测试覆盖未知结果及请求重放 | +| 一条慢线路/模组拖慢其他对象 | messaging worker、VoWiFi service、CellularMonitor 和 AgentSource 测试覆盖独立截止时间、退避和设备互斥 | +| 短信、未读与通知不一致 | `TestInboundUnreadAndNotificationAreAtomic` 覆盖事务失败;入站同步测试验证持久化后 ACK | +| 通知重启丢失、重复入队或乱序 | `TestConnectionCheckpointAndOutboxCommitTogetherAndReplayAfterRestart` 与通知 worker 集成覆盖提交失败、重开数据库、租约、响应丢失重试和流间独立性 | +| 停用/退订/删除后仍投递 | `TestDeliveryRetryLeaseIndependentStreamsAndCancellation` 覆盖取消与迟到完成;订阅起点测试覆盖新订阅、退订重订和缓存历史 | +| 首次在线、快速断开恢复、未知状态与游标缺口 | connectivity monitor、connectivityagent 和 supervisor change 测试覆盖基线、重放、缓存溢出、来源重启、漫游、RF/SIM/拔出及 IMS 刷新 | +| worker 累计输出超过 1 MiB 停止监测 | `TestWorkerOutputContinuesPastOneMiBAndIgnoresRegistrationRefresh`;仍保留单条消息长度限制 | +| C 桥接接受畸形响应 | C fixture 覆盖协议版本、字段、重复字段、截断、尾随内容、长度和敏感缓冲清理;地址及未定义行为检查通过 | +| 退出后访问数据库或遗留子进程 | lifecycle drain、通知 worker shutdown、Mihomo 丢失 manifest 仍回收子进程及开发 supervisor 集成测试 | +| 会话切换、未知短信结果和登录过期 | SessionGate、API session 和短信表单测试;桌面/手机浏览器 fixture 流程 | +| 只有前端 fixture,缺少真实后端贯通 | `web/e2e-simulator/control.spec.ts` 启动真实 `simplusd`、临时数据库与 Simulator,覆盖初始化、登录、模组/线路、短信、电话、四类连接通知和渠道取消 | + +## 验证结果 + +以下均已通过;测试使用合成身份、临时数据库、局部 HTTP fixture 和 Simulator。 + +- `make test`:Go 包测试、20 个 Web 测试文件中的 65 项测试、TypeScript、工作树清单及开发 supervisor 测试。 +- `go test -race -p 4 ./cmd/... ./internal/...`;最终短信通知对象关联、五秒探测调度和构造依赖调整另经受影响包的竞态测试。 +- `make lint`、`make check-format`、`make check-docs`、`make check-container-files` 和容器合同测试。 +- `make verify-generated`:生成前后完整工作树清单一致。 +- `go mod tidy -diff`、`go mod verify`:在仅含 Go 源码和模块清单的临时副本检查,避免遍历本地运行数据。 +- `make build`、`make web-e2e`(桌面与手机)和 `make web-e2e-simulator`(真实后端)。 +- `scripts/dev/test-simplus-simaka-c.sh` 以及同组 C fixture 的 AddressSanitizer/UndefinedBehaviorSanitizer 检查。 +- `make security`:Go 无可达漏洞,pnpm 无已知漏洞;Go 依赖模块另有四项未被当前代码调用的漏洞提示。 + +## 证据边界 + +本记录对应源码实现及自动化验证,尚未发布包含这些变更的新版本。旧版发布包的数据库 +布局和初始化流程不适用于新代码;安装说明要求使用包含决策 0028 的新发布包及独立目录。 + +未执行部署、真实短信/电话、RF 写入、HIL 或 clean-VM 容器生命周期验收;既有硬件 +证据不能自动视为本次重构的实机回归。生产镜像安装与 clean-VM 生命周期仍在活跃计划中。 + +外部通知平台响应丢失时允许重复投递,不承诺外部恰好一次;未知平台错误按暂时失败重试。 +蜂窝采样无法保证捕捉两次探测之间已恢复的瞬时变化。VoWiFi 来源重启或游标过期会记录 +监测缺口并重新建立快照基线,不推断缺失区间内的状态。 diff --git a/docs/product.md b/docs/product.md index 8f8938c..625bd9b 100644 --- a/docs/product.md +++ b/docs/product.md @@ -14,7 +14,7 @@ Simplus 是运行在 Linux 主机上的局域网 Web 工具,用来控制一台 - 通过 USB 或 UART 连接的少量 4G/5G 模组; - 浏览器从同一局域网访问; -全新 Debian 实例由部署 bootstrap 创建唯一管理员 `simplus_admin` 和每实例随机强密码;管理员首次登录只完成必要的本机初始化。HTTPS、模组和线路配置在后台管理页内分别维护,不再依赖 root Bootstrap URL。 +全新实例由安装时的 provision 创建唯一管理员 `simplus_admin` 和每实例随机强密码,并在同一事务中进入就绪状态。管理员登录后直接进入管理界面,再分别添加模组与线路;不再提供首次登录设置、录音目录或本地 CA 流程。 - 不直接暴露到公网,不考虑互不信任的多租户。 如果实际网络不可信,部署者应使用现成的 VPN 或反向代理提供 HTTPS;Simplus MVP 不自建 CA、证书轮换或公网访问体系。浏览器通话音频需要 secure context 时,也使用外部 HTTPS 终止。 @@ -141,3 +141,16 @@ Simplus 是运行在 Linux 主机上的局域网 Web 工具,用来控制一台 12. 安装和运行步骤可以由仓库文档在目标机器上复现。 真实硬件默认禁止修改上电策略、建立蜂窝数据流量,以及未经独立验收的短信、电话、eUICC 切换等会改变模组、SIM、网络或外部通信状态的操作。ML307A 运行时 RF 开关按 [`0017`](decisions/0017-managed-modems-and-capability-adapters.md) 作为窄化例外:只接受明确的开关目标,使用型号固定命令并读回确认;它不等于已验证上电策略,也不允许 HIL 在未授权时实际切换。ML307A Host VoWiFi 注册仅按 [`0011`](decisions/0011-ml307a-host-vowifi-hil.md) 的明确授权例外执行;真实 SMS over IMS 还必须遵循 [`0016`](decisions/0016-vowifi-sms-over-ims.md) 的单独授权。QDC507 原生蜂窝短信按 [`0026`](decisions/0026-qdc507-native-cellular-sms.md) 的 typed production 纵切开放;其余边界仍见 [`0003`](decisions/0003-v1-read-only-hardware.md)。 + +## 连接通知 + +渠道支持企业微信、飞书 Webhook 和飞书应用。九类事件包括原有短信、电话、系统事件, +以及 VoWiFi 连接/断开、蜂窝注册/丢失。新建渠道和飞书绑定默认全选九类。 +首次确认在线即通知,首次离线建立基线;以后每次确认的断开和恢复各自通知,主动停用 +也包括在内。通信失败或未知状态不等于断开。蜂窝连接指本地或漫游网络注册,不代表 +数据上网;信号、制式和本地/漫游切换不产生独立连接通知。 + +任务持久化并重试,页面显示待投递和永久失败数量。取消订阅或停用、删除渠道会取消 +尚未投递的任务;不补发订阅建立前的历史,外部响应丢失可能导致重复投递。蜂窝每五秒 +采样,不能保证发现两次采样之间发生并恢复的短暂断线。详见 +[持久通知决策](decisions/0028-control-state-and-durable-notifications.md)。 diff --git a/go.mod b/go.mod index dcf4380..6692375 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/getkin/kin-openapi v0.145.0 github.com/go-chi/chi/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/oapi-codegen/runtime v1.6.0 github.com/pressly/goose/v3 v3.27.3 github.com/strongswan/govici v0.8.2 @@ -19,7 +20,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/mfridman/interpolate v0.0.2 // indirect diff --git a/internal/agentapi/client.go b/internal/agentapi/client.go index 71ace53..e9c5c93 100644 --- a/internal/agentapi/client.go +++ b/internal/agentapi/client.go @@ -9,12 +9,16 @@ import ( "io" "net" "net/http" + "net/http/httptrace" "net/url" "path/filepath" "strconv" + "sync/atomic" "time" ) +var ErrSMSNotDispatched = errors.New("SMS request was not dispatched") + type Client struct { http *http.Client socketPath string @@ -96,17 +100,6 @@ func (client *Client) Probe(ctx context.Context, request ProbeRequest) (ProbeRes return response, nil } -func (client *Client) EnsureRadioOff(ctx context.Context, request RadioEnsureOffRequest) (RadioEnsureOffResponse, error) { - var response RadioEnsureOffResponse - if err := client.request(ctx, http.MethodPost, "/v1/commands/radio/ensure-off", request, &response); err != nil { - return RadioEnsureOffResponse{}, err - } - if err := validateRadioEnsureOffResponse(response, request.OperationID); err != nil { - return RadioEnsureOffResponse{}, fmt.Errorf("invalid radio.ensure-off response: %w", err) - } - return response, nil -} - func (client *Client) SetRFState(ctx context.Context, request RFSetRequest) (RFSetResponse, error) { var response RFSetResponse if err := client.request(ctx, http.MethodPost, "/v1/radio/state", request, &response); err != nil { @@ -166,12 +159,23 @@ func (client *Client) SendSMS(ctx context.Context, request SMSSendRequest) (SMSS if err := validateSMSSendRequest(request); err != nil { return SMSSendResponse{}, err } + var connected atomic.Bool + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(httptrace.GotConnInfo) { connected.Store(true) }}) var response SMSSendResponse if err := client.request(ctx, http.MethodPost, "/v1/sms/send", request, &response); err != nil { - return SMSSendResponse{}, err + var remote *ErrorResponse + if errors.As(err, &remote) { + switch remote.Code { + case "REQUEST_INVALID", "AGENT_INSTANCE_STALE", "SMS_DEVICE_NOT_FOUND", "SMS_UNSUPPORTED", "OPERATION_REPLAY_CONFLICT", "SMS_DEVICE_STALE", "SMS_EQUIPMENT_IDENTITY_CHANGED", "SMS_SIM_NOT_READY", "SMS_SIM_IDENTITY_CHANGED", "SMS_RF_OFF", "SMS_REGISTRATION_DENIED", "SMS_NOT_REGISTERED", "SMS_STATUS_UNAVAILABLE": + return SMSSendResponse{}, err + } + } else if !connected.Load() { + return SMSSendResponse{}, ErrSMSNotDispatched + } + return SMSSendResponse{}, ErrSMSOutcomeUnknown } if err := validateSMSSendResponse(response, request); err != nil { - return SMSSendResponse{}, fmt.Errorf("invalid SMS send response: %w", err) + return SMSSendResponse{}, ErrSMSOutcomeUnknown } return response, nil } @@ -224,8 +228,12 @@ func (client *Client) request(ctx context.Context, method, path string, body, ou return &apiError } decoder := json.NewDecoder(limited) + decoder.DisallowUnknownFields() if err := decoder.Decode(output); err != nil { return fmt.Errorf("decode agent response: %w", err) } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("invalid trailing Agent response") + } return nil } diff --git a/internal/agentapi/client_server_test.go b/internal/agentapi/client_server_test.go index b7d9679..93e8afe 100644 --- a/internal/agentapi/client_server_test.go +++ b/internal/agentapi/client_server_test.go @@ -27,7 +27,7 @@ func TestHelloOmitsMutatingFeaturesWhenCommandServiceIsDisabled(t *testing.T) { monitor := newMonitor(&monitorScanner{}, "01234567-89ab-cdef-0123-456789abcdef", 1) request := httptest.NewRequest(http.MethodGet, "/v1/hello", nil) response := httptest.NewRecorder() - NewHandler(monitor, nil, nil).ServeHTTP(response, request) + NewHandler(monitor, nil).ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d", response.Code) } @@ -35,12 +35,12 @@ func TestHelloOmitsMutatingFeaturesWhenCommandServiceIsDisabled(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &hello); err != nil { t.Fatal(err) } - if containsString(hello.Features, CommandRadioEnsureOff) || containsString(hello.Features, "durable-command-outcomes") || containsString(hello.Features, FeatureSMS) { + if containsString(hello.Features, "radio.ensure-off") || containsString(hello.Features, "durable-command-outcomes") || containsString(hello.Features, FeatureSMS) { t.Fatalf("disabled command features = %#v", hello.Features) } smsRequest := httptest.NewRequest(http.MethodPost, "/v1/sms/list", strings.NewReader(`{"agentInstanceId":"01234567-89ab-cdef-0123-456789abcdef","deviceId":"usb-1-1"}`)) smsResponse := httptest.NewRecorder() - NewHandler(monitor, nil, nil).ServeHTTP(smsResponse, smsRequest) + NewHandler(monitor, nil).ServeHTTP(smsResponse, smsRequest) if smsResponse.Code != http.StatusNotFound { t.Fatalf("disabled SMS route status = %d", smsResponse.Code) } @@ -150,13 +150,6 @@ func TestUnixClientServerProtocolRoundTrip(t *testing.T) { if _, err := monitor.Refresh(context.Background()); err != nil { t.Fatal(err) } - outcomes := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 8, 2) - executor := &fakeRadioExecutor{execution: RadioEnsureOffExecution{ - Observation: RadioEnsureOffObservation{ - RF: RFObservation{State: RFStateOff, Mode: intPointerForAgentTest(4)}, ActiveCallCount: intPointerForAgentTest(0), - }, - }} - commands := NewCommandService(monitor, executor, outcomes) smsBackend := NewSimulatorSMSBackend(SMSStoredMessage{ MessageID: "agent-inbound-1", DeviceID: "usb-1-1", Sender: "10086", Body: "Agent simulator inbound", ReceivedAt: time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC), @@ -174,7 +167,7 @@ func TestUnixClientServerProtocolRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - server := &http.Server{Handler: NewHandler(monitor, commands, nil, smsBackend)} + server := &http.Server{Handler: NewHandler(monitor, nil, smsBackend)} go server.Serve(listener) t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -195,7 +188,7 @@ func TestUnixClientServerProtocolRoundTrip(t *testing.T) { if hello.Protocol != ProtocolName || hello.ProtocolVersion != ProtocolVersion || hello.AgentInstanceID != monitor.InstanceID() { t.Fatalf("hello = %#v", hello) } - if !containsString(hello.Features, CommandRadioEnsureOff) || !containsString(hello.Features, "durable-command-outcomes") || !containsString(hello.Features, FeatureSMS) { + if containsString(hello.Features, "radio.ensure-off") || containsString(hello.Features, "durable-command-outcomes") || !containsString(hello.Features, FeatureSMS) { t.Fatalf("hello features = %#v", hello.Features) } snapshot, err := client.Snapshot(ctx, true) @@ -212,22 +205,6 @@ func TestUnixClientServerProtocolRoundTrip(t *testing.T) { if probe.AgentInstanceID != hello.AgentInstanceID || len(probe.Devices) != 1 || probe.Devices[0].State != ProbeStateComplete { t.Fatalf("probe = %#v", probe) } - commandRequest := commandRequestForSnapshot(snapshot) - command, err := client.EnsureRadioOff(ctx, commandRequest) - if err != nil { - t.Fatal(err) - } - if command.Outcome.State != CommandOutcomeSucceeded || command.Outcome.Observation.RF.State != RFStateOff || executor.calls != 1 { - t.Fatalf("command = %#v calls=%d", command, executor.calls) - } - conflict := commandRequest - conflict.FencingToken++ - _, err = client.EnsureRadioOff(ctx, conflict) - var apiError *ErrorResponse - if !errors.As(err, &apiError) || apiError.Code != "OPERATION_REPLAY_CONFLICT" || apiError.Retryable { - t.Fatalf("replay conflict error = %#v", err) - } - fenceEquipment, fenceSIM := strings.Repeat("a", 64), strings.Repeat("b", 64) listRequest := SMSListRequest{AgentInstanceID: hello.AgentInstanceID, DeviceID: "usb-1-1", DeviceGeneration: 1, ExpectedEquipmentFingerprint: fenceEquipment, ExpectedSubscriptionFingerprint: fenceSIM} @@ -267,6 +244,7 @@ func TestUnixClientServerProtocolRoundTrip(t *testing.T) { conflictingSend := sendRequest conflictingSend.Body = "different body" _, err = client.SendSMS(ctx, conflictingSend) + var apiError *ErrorResponse if !errors.As(err, &apiError) || apiError.Code != "OPERATION_REPLAY_CONFLICT" { t.Fatalf("SMS send conflict error = %#v", err) } diff --git a/internal/agentapi/command_service.go b/internal/agentapi/command_service.go deleted file mode 100644 index 663a898..0000000 --- a/internal/agentapi/command_service.go +++ /dev/null @@ -1,304 +0,0 @@ -package agentapi - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "regexp" - "sync" - "time" -) - -var ( - ErrCommandRequestInvalid = errors.New("Agent command request is invalid") - ErrCommandAgentStale = errors.New("Agent instance changed") - ErrCommandSnapshotStale = errors.New("Agent snapshot changed") - ErrCommandDeviceStale = errors.New("Agent device generation changed") - ErrCommandUnsupported = errors.New("Agent command is unsupported for the device") - ErrCommandPersistence = errors.New("Agent command outcome could not be persisted") -) - -var commandIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) - -type RadioEnsureOffExecution struct { - Dispatched bool - Uncertain bool - Observation RadioEnsureOffObservation - Error *ProbeError -} - -type RadioEnsureOffExecutor interface { - EnsureRadioOff(context.Context, Snapshot, string) (RadioEnsureOffExecution, error) -} - -type CommandService struct { - monitor *Monitor - executor RadioEnsureOffExecutor - store *OutcomeStore - now func() time.Time - - mu sync.Mutex -} - -func NewCommandService(monitor *Monitor, executor RadioEnsureOffExecutor, store *OutcomeStore) *CommandService { - return &CommandService{monitor: monitor, executor: executor, store: store, now: time.Now} -} - -func (service *CommandService) EnsureRadioOff(ctx context.Context, request RadioEnsureOffRequest) (RadioEnsureOffResponse, error) { - if service == nil || service.monitor == nil || service.executor == nil || service.store == nil { - return RadioEnsureOffResponse{}, errors.New("Agent command service is unavailable") - } - if err := validateRadioEnsureOffShape(request); err != nil { - return RadioEnsureOffResponse{}, err - } - digest, err := radioEnsureOffDigest(request) - if err != nil { - return RadioEnsureOffResponse{}, ErrCommandRequestInvalid - } - service.mu.Lock() - defer service.mu.Unlock() - - if existing, found, err := service.store.Find(ctx, request.OperationID); err != nil { - return RadioEnsureOffResponse{}, fmt.Errorf("%w: %w", ErrCommandPersistence, err) - } else if found { - if existing.RequestDigest != digest { - return RadioEnsureOffResponse{}, ErrOutcomeReplayConflict - } - if existing.Outcome.State == CommandOutcomeAccepted { - existing, err = service.reconcile(ctx, existing) - if err != nil { - return RadioEnsureOffResponse{}, err - } - } - return service.response(existing.Outcome), nil - } - - snapshot, err := service.currentSnapshot(ctx) - if err != nil { - return RadioEnsureOffResponse{}, err - } - if err := validateRadioEnsureOffRequest(request, snapshot); err != nil { - return RadioEnsureOffResponse{}, err - } - record, _, err := service.store.Accept(ctx, request, digest, service.currentTime()) - if err != nil { - return RadioEnsureOffResponse{}, err - } - - current := service.monitor.Snapshot() - if !sameSnapshotFence(snapshot, current) { - outcome := terminalCommandOutcome(record, service.currentTime(), CommandOutcomeFailed, ErrorHardwareChanged, ErrorLayerDevice, true, false, - RadioEnsureOffObservation{RF: RFObservation{State: RFStateUnknown}}) - if err := service.persist(outcome); err != nil { - return RadioEnsureOffResponse{}, err - } - return service.response(outcome), nil - } - - execution, executionErr := service.executor.EnsureRadioOff(ctx, snapshot, request.DeviceID) - outcome := service.outcomeFromExecution(record, execution, executionErr) - after := service.monitor.Snapshot() - if !sameSnapshotFence(snapshot, after) { - state := CommandOutcomeFailed - retryable := true - if execution.Dispatched { - state = CommandOutcomeUncertain - retryable = false - } - outcome = terminalCommandOutcome(record, service.currentTime(), state, ErrorHardwareChanged, ErrorLayerDevice, retryable, false, execution.Observation) - } - if err := service.persist(outcome); err != nil { - return RadioEnsureOffResponse{}, err - } - return service.response(outcome), nil -} - -func (service *CommandService) ReconcilePending(ctx context.Context) (int, error) { - if service == nil || service.monitor == nil || service.store == nil { - return 0, errors.New("Agent command service is unavailable") - } - service.mu.Lock() - defer service.mu.Unlock() - records, err := service.store.Pending(ctx) - if err != nil { - return 0, fmt.Errorf("%w: %w", ErrCommandPersistence, err) - } - for _, record := range records { - if _, err := service.reconcile(ctx, record); err != nil { - return 0, err - } - } - return len(records), nil -} - -func (service *CommandService) reconcile(ctx context.Context, record outcomeRecord) (outcomeRecord, error) { - observation := RadioEnsureOffObservation{RF: RFObservation{State: RFStateUnknown}} - state := CommandOutcomeUncertain - code := ErrorRadioOffOutcomeUncertain - layer := ErrorLayerRadio - - snapshot, snapshotErr := service.currentSnapshot(ctx) - if snapshotErr == nil && snapshot.Revision == record.Request.SnapshotRevision && snapshotHasQDC507(snapshot, record.Request.DeviceID) { - probe, probeErr := service.monitor.Probe(ctx, []string{record.Request.DeviceID}) - if probeErr == nil && len(probe.Devices) == 1 { - device := probe.Devices[0] - observation = RadioEnsureOffObservation{RF: device.RF, ActiveCallCount: device.ActiveCallCount} - if device.State == ProbeStateComplete && device.RF.State == RFStateOff && device.ActiveCallCount != nil && *device.ActiveCallCount == 0 { - state = CommandOutcomeSucceeded - code = OutcomeCodeRadioOffConfirmed - layer = "" - } - } - } else if snapshotErr == nil { - code = ErrorHardwareChanged - layer = ErrorLayerDevice - } - outcome := terminalCommandOutcome(record, service.currentTime(), state, code, layer, false, true, observation) - if err := service.persist(outcome); err != nil { - return outcomeRecord{}, err - } - record.Outcome = outcome - return record, nil -} - -func (service *CommandService) outcomeFromExecution(record outcomeRecord, execution RadioEnsureOffExecution, executionErr error) CommandOutcome { - observation := normalizeRadioEnsureOffObservation(execution.Observation) - switch { - case executionErr != nil: - return terminalCommandOutcome(record, service.currentTime(), CommandOutcomeFailed, ErrorHardwareChanged, ErrorLayerDevice, true, false, observation) - case execution.Error == nil && observation.RF.State == RFStateOff && observation.ActiveCallCount != nil && *observation.ActiveCallCount == 0: - return terminalCommandOutcome(record, service.currentTime(), CommandOutcomeSucceeded, OutcomeCodeRadioOffConfirmed, "", false, false, observation) - case execution.Error == nil: - return terminalCommandOutcome(record, service.currentTime(), CommandOutcomeFailed, ErrorRadioOffNotConfirmed, ErrorLayerRadio, true, false, observation) - case execution.Uncertain: - return terminalCommandOutcome(record, service.currentTime(), CommandOutcomeUncertain, execution.Error.Code, execution.Error.Layer, false, false, observation) - default: - return terminalCommandOutcome(record, service.currentTime(), CommandOutcomeFailed, execution.Error.Code, execution.Error.Layer, execution.Error.Retryable, false, observation) - } -} - -func (service *CommandService) persist(outcome CommandOutcome) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := service.store.Complete(ctx, outcome); err != nil { - return fmt.Errorf("%w: %w", ErrCommandPersistence, err) - } - return nil -} - -func (service *CommandService) currentSnapshot(ctx context.Context) (Snapshot, error) { - snapshot := service.monitor.Snapshot() - if snapshot.Generation != 0 { - return snapshot, nil - } - refreshed, err := service.monitor.Refresh(ctx) - if err != nil { - return Snapshot{}, fmt.Errorf("read current Agent snapshot: %w", err) - } - return refreshed, nil -} - -func (service *CommandService) currentTime() time.Time { - if service.now == nil { - return time.Now().UTC() - } - return service.now().UTC() -} - -func (service *CommandService) response(outcome CommandOutcome) RadioEnsureOffResponse { - return RadioEnsureOffResponse{ - ProtocolVersion: ProtocolVersion, - AgentInstanceID: service.monitor.InstanceID(), - Outcome: outcome, - } -} - -func validateRadioEnsureOffRequest(request RadioEnsureOffRequest, snapshot Snapshot) error { - if err := validateRadioEnsureOffShape(request); err != nil { - return err - } - if request.AgentInstanceID != snapshot.AgentInstanceID { - return ErrCommandAgentStale - } - if request.SnapshotGeneration != snapshot.Generation || request.SnapshotRevision != snapshot.Revision { - return ErrCommandSnapshotStale - } - for _, device := range snapshot.Devices { - if device.ID != request.DeviceID { - continue - } - if device.Generation != request.DeviceGeneration { - return ErrCommandDeviceStale - } - if device.Profile != ProfileQDC507 { - return ErrCommandUnsupported - } - return nil - } - return ErrCommandDeviceStale -} - -func validateRadioEnsureOffShape(request RadioEnsureOffRequest) error { - if !commandIDPattern.MatchString(request.OperationID) || !commandIDPattern.MatchString(request.DeviceID) || - !commandIDPattern.MatchString(request.ResourceGroupID) || !IsValidAgentInstanceID(request.AgentInstanceID) || - !isSHA256Hex(request.SnapshotRevision) || request.SnapshotGeneration == 0 || request.DeviceGeneration == 0 || - request.ResourceGroupGeneration == 0 || request.FencingToken == 0 || - request.SnapshotGeneration > math.MaxInt64 || request.DeviceGeneration > math.MaxInt64 || - request.ResourceGroupGeneration > math.MaxInt64 || request.FencingToken > math.MaxInt64 { - return ErrCommandRequestInvalid - } - return nil -} - -func radioEnsureOffDigest(request RadioEnsureOffRequest) (string, error) { - encoded, err := json.Marshal(request) - if err != nil { - return "", err - } - sum := sha256.Sum256(encoded) - return hex.EncodeToString(sum[:]), nil -} - -func terminalCommandOutcome(record outcomeRecord, completedAt time.Time, state, code, layer string, retryable, reconciled bool, observation RadioEnsureOffObservation) CommandOutcome { - completedAt = completedAt.UTC() - if completedAt.Before(record.Outcome.AcceptedAt) { - completedAt = record.Outcome.AcceptedAt - } - observation = normalizeRadioEnsureOffObservation(observation) - return CommandOutcome{ - OperationID: record.Request.OperationID, - Command: CommandRadioEnsureOff, - State: state, - Code: code, - ErrorLayer: layer, - Retryable: retryable, - Reconciled: reconciled, - Observation: observation, - AcceptedAt: record.Outcome.AcceptedAt, - CompletedAt: &completedAt, - } -} - -func normalizeRadioEnsureOffObservation(observation RadioEnsureOffObservation) RadioEnsureOffObservation { - if observation.RF.State == "" { - observation.RF.State = RFStateUnknown - } - return observation -} - -func sameSnapshotFence(left, right Snapshot) bool { - return left.AgentInstanceID == right.AgentInstanceID && left.Generation == right.Generation && left.Revision == right.Revision -} - -func snapshotHasQDC507(snapshot Snapshot, deviceID string) bool { - for _, device := range snapshot.Devices { - if device.ID == deviceID { - return device.Profile == ProfileQDC507 - } - } - return false -} diff --git a/internal/agentapi/command_service_test.go b/internal/agentapi/command_service_test.go deleted file mode 100644 index 19b92b3..0000000 --- a/internal/agentapi/command_service_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package agentapi - -import ( - "context" - "errors" - "path/filepath" - "testing" - "time" -) - -type fakeRadioExecutor struct { - calls int - execution RadioEnsureOffExecution - err error -} - -func (executor *fakeRadioExecutor) EnsureRadioOff(context.Context, Snapshot, string) (RadioEnsureOffExecution, error) { - executor.calls++ - return executor.execution, executor.err -} - -func commandRequestForSnapshot(snapshot Snapshot) RadioEnsureOffRequest { - return RadioEnsureOffRequest{ - OperationID: "radio-off-command-1", - AgentInstanceID: snapshot.AgentInstanceID, - SnapshotGeneration: snapshot.Generation, - SnapshotRevision: snapshot.Revision, - DeviceID: snapshot.Devices[0].ID, - DeviceGeneration: snapshot.Devices[0].Generation, - ResourceGroupID: "agent-usb-1-1-resources", - ResourceGroupGeneration: snapshot.Devices[0].Generation, - FencingToken: 1, - } -} - -func commandTestMonitor(t *testing.T, scanner *monitorScanner, instanceID string, generation uint64) (*Monitor, Snapshot) { - t.Helper() - monitor := newMonitor(scanner, instanceID, generation) - snapshot, err := monitor.Refresh(t.Context()) - if err != nil { - t.Fatal(err) - } - return monitor, snapshot -} - -func TestCommandServicePersistsSuccessReplayConflictAndFence(t *testing.T) { - scanner := &monitorScanner{devices: []DeviceReport{{ - ID: "usb-1-1", DisplayName: "QDC507", PhysicalPath: "1-1", Profile: ProfileQDC507, - }}} - monitor, snapshot := commandTestMonitor(t, scanner, "01234567-89ab-cdef-0123-456789abcdef", 10) - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 16, 4) - executor := &fakeRadioExecutor{execution: RadioEnsureOffExecution{ - Observation: RadioEnsureOffObservation{RF: RFObservation{State: RFStateOff, Mode: intPointerForAgentTest(4)}, ActiveCallCount: intPointerForAgentTest(0)}, - }} - service := NewCommandService(monitor, executor, store) - service.now = func() time.Time { return time.Date(2026, 8, 3, 13, 0, 0, 0, time.UTC) } - request := commandRequestForSnapshot(snapshot) - response, err := service.EnsureRadioOff(t.Context(), request) - if err != nil || response.Outcome.State != CommandOutcomeSucceeded || response.Outcome.Code != OutcomeCodeRadioOffConfirmed || executor.calls != 1 { - t.Fatalf("response=%#v calls=%d err=%v", response, executor.calls, err) - } - replayed, err := service.EnsureRadioOff(t.Context(), request) - if err != nil || replayed.Outcome.State != CommandOutcomeSucceeded || executor.calls != 1 { - t.Fatalf("replayed=%#v calls=%d err=%v", replayed, executor.calls, err) - } - conflict := request - conflict.FencingToken++ - if _, err := service.EnsureRadioOff(t.Context(), conflict); !errors.Is(err, ErrOutcomeReplayConflict) { - t.Fatalf("replay conflict error = %v", err) - } - second := request - second.OperationID = "radio-off-command-2" - second.FencingToken = 2 - if _, err := service.EnsureRadioOff(t.Context(), second); err != nil || executor.calls != 2 { - t.Fatalf("second command calls=%d err=%v", executor.calls, err) - } - stale := request - stale.OperationID = "radio-off-command-stale" - stale.FencingToken = 2 - if _, err := service.EnsureRadioOff(t.Context(), stale); !errors.Is(err, ErrOutcomeFenceStale) { - t.Fatalf("stale fence error = %v", err) - } - staleAgent := request - staleAgent.OperationID = "radio-off-command-stale-agent" - staleAgent.FencingToken = 3 - staleAgent.AgentInstanceID = "fedcba98-7654-3210-fedc-ba9876543210" - if _, err := service.EnsureRadioOff(t.Context(), staleAgent); !errors.Is(err, ErrCommandAgentStale) { - t.Fatalf("stale Agent error = %v", err) - } - staleSnapshot := request - staleSnapshot.OperationID = "radio-off-command-stale-snapshot" - staleSnapshot.FencingToken = 3 - staleSnapshot.SnapshotGeneration++ - if _, err := service.EnsureRadioOff(t.Context(), staleSnapshot); !errors.Is(err, ErrCommandSnapshotStale) { - t.Fatalf("stale snapshot error = %v", err) - } - staleDevice := request - staleDevice.OperationID = "radio-off-command-stale-device" - staleDevice.FencingToken = 3 - staleDevice.DeviceGeneration++ - if _, err := service.EnsureRadioOff(t.Context(), staleDevice); !errors.Is(err, ErrCommandDeviceStale) { - t.Fatalf("stale device error = %v", err) - } - invalid := request - invalid.OperationID = "" - if _, err := service.EnsureRadioOff(t.Context(), invalid); !errors.Is(err, ErrCommandRequestInvalid) { - t.Fatalf("invalid request error = %v", err) - } -} - -func TestCommandServicePersistsFailedAndUncertainWithoutRedispatch(t *testing.T) { - tests := []struct { - name string - execution RadioEnsureOffExecution - state string - code string - }{ - { - name: "unconfirmed adapter result", - execution: RadioEnsureOffExecution{ - Observation: RadioEnsureOffObservation{RF: RFObservation{State: RFStateOn}, ActiveCallCount: intPointerForAgentTest(0)}, - }, - state: CommandOutcomeFailed, code: ErrorRadioOffNotConfirmed, - }, - { - name: "active call", - execution: RadioEnsureOffExecution{ - Observation: RadioEnsureOffObservation{RF: RFObservation{State: RFStateOn}, ActiveCallCount: intPointerForAgentTest(1)}, - Error: &ProbeError{Layer: ErrorLayerCall, Code: ErrorActiveCallPresent, Retryable: true}, - }, - state: CommandOutcomeFailed, code: ErrorActiveCallPresent, - }, - { - name: "dispatch uncertain", - execution: RadioEnsureOffExecution{ - Dispatched: true, Uncertain: true, - Observation: RadioEnsureOffObservation{RF: RFObservation{State: RFStateUnknown}, ActiveCallCount: intPointerForAgentTest(0)}, - Error: &ProbeError{Layer: ErrorLayerRadio, Code: ErrorRadioOffOutcomeUncertain}, - }, - state: CommandOutcomeUncertain, code: ErrorRadioOffOutcomeUncertain, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - scanner := &monitorScanner{devices: []DeviceReport{{ - ID: "usb-1-1", DisplayName: "QDC507", PhysicalPath: "1-1", Profile: ProfileQDC507, - }}} - monitor, snapshot := commandTestMonitor(t, scanner, "01234567-89ab-cdef-0123-456789abcdef", 20) - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 8, 2) - executor := &fakeRadioExecutor{execution: test.execution} - service := NewCommandService(monitor, executor, store) - request := commandRequestForSnapshot(snapshot) - first, err := service.EnsureRadioOff(t.Context(), request) - if err != nil || first.Outcome.State != test.state || first.Outcome.Code != test.code || executor.calls != 1 { - t.Fatalf("first=%#v calls=%d err=%v", first, executor.calls, err) - } - second, err := service.EnsureRadioOff(t.Context(), request) - if err != nil || second.Outcome.State != test.state || executor.calls != 1 { - t.Fatalf("replay=%#v calls=%d err=%v", second, executor.calls, err) - } - }) - } -} - -func TestCommandServiceReconcilesPendingAfterAgentRestartWithoutRedispatch(t *testing.T) { - firstScanner := &monitorScanner{devices: []DeviceReport{{ - ID: "usb-1-1", DisplayName: "QDC507", PhysicalPath: "1-1", Profile: ProfileQDC507, - }}} - _, firstSnapshot := commandTestMonitor(t, firstScanner, "01234567-89ab-cdef-0123-456789abcdef", 30) - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 8, 2) - request := commandRequestForSnapshot(firstSnapshot) - digest := mustRadioEnsureOffDigest(t, request) - if _, _, err := store.Accept(t.Context(), request, digest, time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)); err != nil { - t.Fatal(err) - } - - secondScanner := &monitorScanner{ - devices: []DeviceReport{{ID: "usb-1-1", DisplayName: "QDC507", PhysicalPath: "1-1", Profile: ProfileQDC507}}, - probes: []DeviceProbe{validCompleteProbeFixture( - "usb-1-1", SIMObservation{State: SIMStatePresent, PrimaryLockState: PrimaryLockReady}, - )}, - } - secondMonitor, secondSnapshot := commandTestMonitor(t, secondScanner, "fedcba98-7654-3210-fedc-ba9876543210", 300) - if secondSnapshot.Revision != firstSnapshot.Revision || secondSnapshot.Generation == firstSnapshot.Generation { - t.Fatalf("first=%#v second=%#v", firstSnapshot, secondSnapshot) - } - executor := &fakeRadioExecutor{} - service := NewCommandService(secondMonitor, executor, store) - service.now = func() time.Time { return time.Date(2026, 8, 3, 14, 0, 1, 0, time.UTC) } - reconciled, err := service.ReconcilePending(t.Context()) - if err != nil || reconciled != 1 || executor.calls != 0 { - t.Fatalf("reconciled=%d calls=%d err=%v", reconciled, executor.calls, err) - } - record, found, err := store.Find(t.Context(), request.OperationID) - if err != nil || !found || record.Outcome.State != CommandOutcomeSucceeded || !record.Outcome.Reconciled { - t.Fatalf("record=%#v found=%v err=%v", record, found, err) - } - replayed, err := service.EnsureRadioOff(t.Context(), request) - if err != nil || replayed.Outcome.State != CommandOutcomeSucceeded || replayed.AgentInstanceID != secondSnapshot.AgentInstanceID || executor.calls != 0 { - t.Fatalf("replayed=%#v calls=%d err=%v", replayed, executor.calls, err) - } -} - -func TestCommandServiceMarksPendingOutcomeUncertainWhenHardwareChanged(t *testing.T) { - firstScanner := &monitorScanner{devices: []DeviceReport{{ - ID: "usb-1-1", DisplayName: "QDC507", PhysicalPath: "1-1", Profile: ProfileQDC507, - }}} - _, firstSnapshot := commandTestMonitor(t, firstScanner, "01234567-89ab-cdef-0123-456789abcdef", 40) - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 8, 2) - request := commandRequestForSnapshot(firstSnapshot) - digest := mustRadioEnsureOffDigest(t, request) - if _, _, err := store.Accept(t.Context(), request, digest, time.Date(2026, 8, 3, 15, 0, 0, 0, time.UTC)); err != nil { - t.Fatal(err) - } - changedScanner := &monitorScanner{devices: []DeviceReport{{ - ID: "usb-1-1", DisplayName: "QDC507 changed", PhysicalPath: "1-1", Profile: ProfileQDC507, - }}} - changedMonitor, _ := commandTestMonitor(t, changedScanner, "fedcba98-7654-3210-fedc-ba9876543210", 400) - executor := &fakeRadioExecutor{} - service := NewCommandService(changedMonitor, executor, store) - service.now = func() time.Time { return time.Date(2026, 8, 3, 15, 0, 1, 0, time.UTC) } - if reconciled, err := service.ReconcilePending(t.Context()); err != nil || reconciled != 1 { - t.Fatalf("reconciled=%d err=%v", reconciled, err) - } - record, found, err := store.Find(t.Context(), request.OperationID) - if err != nil || !found || record.Outcome.State != CommandOutcomeUncertain || record.Outcome.Code != ErrorHardwareChanged || - !record.Outcome.Reconciled || record.Outcome.Retryable || executor.calls != 0 { - t.Fatalf("record=%#v found=%v calls=%d err=%v", record, found, executor.calls, err) - } -} diff --git a/internal/agentapi/outcome_store.go b/internal/agentapi/outcome_store.go deleted file mode 100644 index 67f628f..0000000 --- a/internal/agentapi/outcome_store.go +++ /dev/null @@ -1,403 +0,0 @@ -package agentapi - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "path/filepath" - "time" - - _ "modernc.org/sqlite" -) - -const ( - outcomeSchemaVersion = 1 - DefaultCommandOutcomeLimit = 4096 - DefaultCommandResourceGroupLimit = 256 -) - -var ( - ErrOutcomeReplayConflict = errors.New("operation id was already used for a different command request") - ErrOutcomeFenceStale = errors.New("resource group generation or fencing token is stale") - ErrOutcomeLedgerFull = errors.New("command outcome ledger is full") - ErrOutcomePending = errors.New("a prior command outcome still requires reconciliation") - ErrOutcomeNotPending = errors.New("command outcome is already terminal") -) - -type OutcomeStoreOptions struct { - Directory string - MaxOutcomes int - MaxResourceGroups int -} - -type OutcomeStore struct { - db *sql.DB - maxOutcomes int - maxResourceGroups int -} - -type outcomeRecord struct { - Request RadioEnsureOffRequest - RequestDigest string - Outcome CommandOutcome -} - -func OpenOutcomeStore(ctx context.Context, options OutcomeStoreOptions) (*OutcomeStore, error) { - if !filepath.IsAbs(options.Directory) || filepath.Clean(options.Directory) == string(filepath.Separator) { - return nil, errors.New("Agent state directory must be an absolute non-root path") - } - if options.MaxOutcomes < 1 || options.MaxOutcomes > 1_000_000 || options.MaxResourceGroups < 1 || options.MaxResourceGroups > 65_536 { - return nil, errors.New("invalid Agent outcome ledger limits") - } - directory := filepath.Clean(options.Directory) - if err := os.MkdirAll(directory, 0o700); err != nil { - return nil, fmt.Errorf("create Agent state directory: %w", err) - } - if err := os.Chmod(directory, 0o700); err != nil { - return nil, fmt.Errorf("set Agent state directory permissions: %w", err) - } - path := filepath.Join(directory, "outcomes.sqlite3") - dsn := (&url.URL{Scheme: "file", Path: path}).String() - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("open Agent outcome ledger: %w", err) - } - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - closeOnError := func(err error) (*OutcomeStore, error) { - _ = db.Close() - return nil, err - } - if _, err := db.ExecContext(ctx, ` -PRAGMA busy_timeout = 5000; -PRAGMA journal_mode = WAL; -PRAGMA synchronous = FULL; -PRAGMA foreign_keys = ON; -`); err != nil { - return closeOnError(fmt.Errorf("configure Agent outcome ledger: %w", err)) - } - var version int - if err := db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil { - return closeOnError(fmt.Errorf("read Agent outcome schema version: %w", err)) - } - if version == 0 { - if _, err := db.ExecContext(ctx, outcomeSchema); err != nil { - return closeOnError(fmt.Errorf("initialize Agent outcome schema: %w", err)) - } - version = outcomeSchemaVersion - } - if version != outcomeSchemaVersion { - return closeOnError(fmt.Errorf("unsupported Agent outcome schema version %d", version)) - } - if err := os.Chmod(path, 0o600); err != nil { - return closeOnError(fmt.Errorf("set Agent outcome ledger permissions: %w", err)) - } - return &OutcomeStore{db: db, maxOutcomes: options.MaxOutcomes, maxResourceGroups: options.MaxResourceGroups}, nil -} - -func (store *OutcomeStore) Close() error { - if store == nil || store.db == nil { - return nil - } - var joined error - if _, err := store.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { - joined = errors.Join(joined, err) - } - joined = errors.Join(joined, store.db.Close()) - return joined -} - -func (store *OutcomeStore) Find(ctx context.Context, operationID string) (outcomeRecord, bool, error) { - if store == nil || store.db == nil || operationID == "" { - return outcomeRecord{}, false, errors.New("Agent outcome store is unavailable") - } - record, err := scanOutcomeRecord(store.db.QueryRowContext(ctx, outcomeSelect+` WHERE operation_id = ?`, operationID)) - if errors.Is(err, sql.ErrNoRows) { - return outcomeRecord{}, false, nil - } - if err != nil { - return outcomeRecord{}, false, fmt.Errorf("read Agent command outcome: %w", err) - } - return record, true, nil -} - -func (store *OutcomeStore) Pending(ctx context.Context) ([]outcomeRecord, error) { - if store == nil || store.db == nil { - return nil, errors.New("Agent outcome store is unavailable") - } - rows, err := store.db.QueryContext(ctx, outcomeSelect+` WHERE state = ? ORDER BY sequence`, CommandOutcomeAccepted) - if err != nil { - return nil, fmt.Errorf("query pending Agent outcomes: %w", err) - } - defer rows.Close() - var records []outcomeRecord - for rows.Next() { - record, err := scanOutcomeRecord(rows) - if err != nil { - return nil, fmt.Errorf("scan pending Agent outcome: %w", err) - } - records = append(records, record) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate pending Agent outcomes: %w", err) - } - return records, nil -} - -func (store *OutcomeStore) Accept(ctx context.Context, request RadioEnsureOffRequest, requestDigest string, now time.Time) (outcomeRecord, bool, error) { - if store == nil || store.db == nil { - return outcomeRecord{}, false, errors.New("Agent outcome store is unavailable") - } - requestJSON, err := json.Marshal(request) - if err != nil { - return outcomeRecord{}, false, fmt.Errorf("encode Agent command request: %w", err) - } - tx, err := store.db.BeginTx(ctx, nil) - if err != nil { - return outcomeRecord{}, false, fmt.Errorf("begin Agent outcome transaction: %w", err) - } - defer tx.Rollback() - if existing, found, err := findOutcomeTx(ctx, tx, request.OperationID); err != nil { - return outcomeRecord{}, false, err - } else if found { - if existing.RequestDigest != requestDigest { - return outcomeRecord{}, false, ErrOutcomeReplayConflict - } - if err := tx.Commit(); err != nil { - return outcomeRecord{}, false, fmt.Errorf("commit replayed Agent outcome: %w", err) - } - return existing, true, nil - } - var pending int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM command_outcomes WHERE state = ?`, CommandOutcomeAccepted).Scan(&pending); err != nil { - return outcomeRecord{}, false, fmt.Errorf("count pending Agent outcomes: %w", err) - } - if pending != 0 { - return outcomeRecord{}, false, ErrOutcomePending - } - if err := store.advanceFence(ctx, tx, request); err != nil { - return outcomeRecord{}, false, err - } - if err := store.makeOutcomeCapacity(ctx, tx); err != nil { - return outcomeRecord{}, false, err - } - acceptedAt := now.UTC() - if _, err := tx.ExecContext(ctx, ` -INSERT INTO command_outcomes ( - operation_id, request_digest, request_json, command, state, code, - error_layer, retryable, reconciled, rf_state, accepted_at_unix_ms -) VALUES (?, ?, ?, ?, ?, ?, '', 0, 0, ?, ?) -`, request.OperationID, requestDigest, requestJSON, CommandRadioEnsureOff, CommandOutcomeAccepted, - OutcomeCodeAccepted, RFStateUnknown, acceptedAt.UnixMilli()); err != nil { - return outcomeRecord{}, false, fmt.Errorf("insert Agent command outcome: %w", err) - } - if err := tx.Commit(); err != nil { - return outcomeRecord{}, false, fmt.Errorf("commit accepted Agent outcome: %w", err) - } - return outcomeRecord{ - Request: request, RequestDigest: requestDigest, - Outcome: CommandOutcome{ - OperationID: request.OperationID, Command: CommandRadioEnsureOff, State: CommandOutcomeAccepted, - Code: OutcomeCodeAccepted, Observation: RadioEnsureOffObservation{RF: RFObservation{State: RFStateUnknown}}, - AcceptedAt: acceptedAt, - }, - }, false, nil -} - -func (store *OutcomeStore) Complete(ctx context.Context, outcome CommandOutcome) error { - if store == nil || store.db == nil || outcome.OperationID == "" || outcome.CompletedAt == nil || - (outcome.State != CommandOutcomeSucceeded && outcome.State != CommandOutcomeFailed && outcome.State != CommandOutcomeUncertain) { - return errors.New("invalid terminal Agent command outcome") - } - var rfMode, callCount any - if outcome.Observation.RF.Mode != nil { - rfMode = *outcome.Observation.RF.Mode - } - if outcome.Observation.ActiveCallCount != nil { - callCount = *outcome.Observation.ActiveCallCount - } - result, err := store.db.ExecContext(ctx, ` -UPDATE command_outcomes -SET state = ?, code = ?, error_layer = ?, retryable = ?, reconciled = ?, - rf_state = ?, rf_mode = ?, active_call_count = ?, completed_at_unix_ms = ? -WHERE operation_id = ? AND state = ? -`, outcome.State, outcome.Code, outcome.ErrorLayer, outcome.Retryable, outcome.Reconciled, - outcome.Observation.RF.State, rfMode, callCount, outcome.CompletedAt.UTC().UnixMilli(), - outcome.OperationID, CommandOutcomeAccepted) - if err != nil { - return fmt.Errorf("complete Agent command outcome: %w", err) - } - changed, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("read completed Agent outcome count: %w", err) - } - if changed != 1 { - return ErrOutcomeNotPending - } - return nil -} - -func (store *OutcomeStore) advanceFence(ctx context.Context, tx *sql.Tx, request RadioEnsureOffRequest) error { - var generation, token int64 - err := tx.QueryRowContext(ctx, ` -SELECT resource_group_generation, fencing_token -FROM resource_group_fences WHERE resource_group_id = ? -`, request.ResourceGroupID).Scan(&generation, &token) - switch { - case errors.Is(err, sql.ErrNoRows): - var count int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM resource_group_fences`).Scan(&count); err != nil { - return fmt.Errorf("count Agent resource fences: %w", err) - } - if count >= store.maxResourceGroups { - return ErrOutcomeLedgerFull - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO resource_group_fences (resource_group_id, resource_group_generation, fencing_token, operation_id) -VALUES (?, ?, ?, ?) -`, request.ResourceGroupID, request.ResourceGroupGeneration, request.FencingToken, request.OperationID); err != nil { - return fmt.Errorf("insert Agent resource fence: %w", err) - } - return nil - case err != nil: - return fmt.Errorf("read Agent resource fence: %w", err) - case request.ResourceGroupGeneration < uint64(generation) || request.FencingToken <= uint64(token): - return ErrOutcomeFenceStale - default: - if _, err := tx.ExecContext(ctx, ` -UPDATE resource_group_fences -SET resource_group_generation = ?, fencing_token = ?, operation_id = ? -WHERE resource_group_id = ? -`, request.ResourceGroupGeneration, request.FencingToken, request.OperationID, request.ResourceGroupID); err != nil { - return fmt.Errorf("advance Agent resource fence: %w", err) - } - return nil - } -} - -func (store *OutcomeStore) makeOutcomeCapacity(ctx context.Context, tx *sql.Tx) error { - var count int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM command_outcomes`).Scan(&count); err != nil { - return fmt.Errorf("count Agent outcomes: %w", err) - } - remove := count - store.maxOutcomes + 1 - if remove <= 0 { - return nil - } - result, err := tx.ExecContext(ctx, ` -DELETE FROM command_outcomes -WHERE sequence IN ( - SELECT sequence FROM command_outcomes - WHERE state != ? - ORDER BY sequence - LIMIT ? -) -`, CommandOutcomeAccepted, remove) - if err != nil { - return fmt.Errorf("prune terminal Agent outcomes: %w", err) - } - changed, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("read pruned Agent outcome count: %w", err) - } - if changed != int64(remove) { - return ErrOutcomeLedgerFull - } - return nil -} - -func findOutcomeTx(ctx context.Context, tx *sql.Tx, operationID string) (outcomeRecord, bool, error) { - record, err := scanOutcomeRecord(tx.QueryRowContext(ctx, outcomeSelect+` WHERE operation_id = ?`, operationID)) - if errors.Is(err, sql.ErrNoRows) { - return outcomeRecord{}, false, nil - } - if err != nil { - return outcomeRecord{}, false, fmt.Errorf("read Agent command outcome: %w", err) - } - return record, true, nil -} - -type outcomeScanner interface { - Scan(...any) error -} - -func scanOutcomeRecord(scanner outcomeScanner) (outcomeRecord, error) { - var record outcomeRecord - var requestJSON []byte - var acceptedAt int64 - var completedAt, rfMode, callCount sql.NullInt64 - if err := scanner.Scan( - &record.RequestDigest, &requestJSON, &record.Outcome.OperationID, &record.Outcome.Command, - &record.Outcome.State, &record.Outcome.Code, &record.Outcome.ErrorLayer, &record.Outcome.Retryable, - &record.Outcome.Reconciled, &record.Outcome.Observation.RF.State, &rfMode, &callCount, - &acceptedAt, &completedAt, - ); err != nil { - return outcomeRecord{}, err - } - if err := json.Unmarshal(requestJSON, &record.Request); err != nil { - return outcomeRecord{}, fmt.Errorf("decode stored Agent command request: %w", err) - } - record.Outcome.AcceptedAt = time.UnixMilli(acceptedAt).UTC() - if completedAt.Valid { - value := time.UnixMilli(completedAt.Int64).UTC() - record.Outcome.CompletedAt = &value - } - if rfMode.Valid { - value := int(rfMode.Int64) - record.Outcome.Observation.RF.Mode = &value - } - if callCount.Valid { - value := int(callCount.Int64) - record.Outcome.Observation.ActiveCallCount = &value - } - return record, nil -} - -const outcomeSelect = ` -SELECT request_digest, request_json, operation_id, command, state, code, - error_layer, retryable, reconciled, rf_state, rf_mode, active_call_count, - accepted_at_unix_ms, completed_at_unix_ms -FROM command_outcomes` - -const outcomeSchema = ` -BEGIN IMMEDIATE; - -CREATE TABLE command_outcomes ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - operation_id TEXT NOT NULL UNIQUE, - request_digest TEXT NOT NULL, - request_json BLOB NOT NULL, - command TEXT NOT NULL CHECK (command = 'radio.ensure-off'), - state TEXT NOT NULL CHECK (state IN ('accepted', 'succeeded', 'failed', 'uncertain')), - code TEXT NOT NULL, - error_layer TEXT NOT NULL DEFAULT '', - retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), - reconciled INTEGER NOT NULL CHECK (reconciled IN (0, 1)), - rf_state TEXT NOT NULL, - rf_mode INTEGER, - active_call_count INTEGER CHECK (active_call_count IS NULL OR active_call_count >= 0), - accepted_at_unix_ms INTEGER NOT NULL CHECK (accepted_at_unix_ms > 0), - completed_at_unix_ms INTEGER CHECK (completed_at_unix_ms IS NULL OR completed_at_unix_ms >= accepted_at_unix_ms), - CHECK (length(operation_id) BETWEEN 1 AND 128), - CHECK (length(request_digest) = 64) -); - -CREATE INDEX command_outcomes_state_idx ON command_outcomes(state, sequence); - -CREATE TABLE resource_group_fences ( - resource_group_id TEXT PRIMARY KEY, - resource_group_generation INTEGER NOT NULL CHECK (resource_group_generation > 0), - fencing_token INTEGER NOT NULL CHECK (fencing_token > 0), - operation_id TEXT NOT NULL, - CHECK (length(resource_group_id) BETWEEN 1 AND 128), - CHECK (length(operation_id) BETWEEN 1 AND 128) -) WITHOUT ROWID; - -PRAGMA user_version = 1; - -COMMIT; -` diff --git a/internal/agentapi/outcome_store_test.go b/internal/agentapi/outcome_store_test.go deleted file mode 100644 index fbd259e..0000000 --- a/internal/agentapi/outcome_store_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package agentapi - -import ( - "errors" - "path/filepath" - "strings" - "testing" - "time" -) - -func radioEnsureOffRequestFixture() RadioEnsureOffRequest { - return RadioEnsureOffRequest{ - OperationID: "radio-off-1", - AgentInstanceID: "01234567-89ab-cdef-0123-456789abcdef", - SnapshotGeneration: 10, - SnapshotRevision: strings.Repeat("a", 64), - DeviceID: "usb-1-1", - DeviceGeneration: 10, - ResourceGroupID: "agent-usb-1-1-resources", - ResourceGroupGeneration: 10, - FencingToken: 1, - } -} - -func openTestOutcomeStore(t *testing.T, directory string, maxOutcomes, maxGroups int) *OutcomeStore { - t.Helper() - store, err := OpenOutcomeStore(t.Context(), OutcomeStoreOptions{ - Directory: directory, MaxOutcomes: maxOutcomes, MaxResourceGroups: maxGroups, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - return store -} - -func TestOutcomeStorePersistsReplayAndTerminalOutcome(t *testing.T) { - directory := filepath.Join(t.TempDir(), "agent-state") - store := openTestOutcomeStore(t, directory, 8, 4) - request := radioEnsureOffRequestFixture() - digest := mustRadioEnsureOffDigest(t, request) - now := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC) - record, replayed, err := store.Accept(t.Context(), request, digest, now) - if err != nil || replayed || record.Outcome.State != CommandOutcomeAccepted { - t.Fatalf("accepted=%#v replayed=%v err=%v", record, replayed, err) - } - count := 0 - outcome := terminalCommandOutcome(record, now.Add(time.Second), CommandOutcomeSucceeded, OutcomeCodeRadioOffConfirmed, "", false, false, - RadioEnsureOffObservation{RF: RFObservation{State: RFStateOff, Mode: intPointerForAgentTest(4)}, ActiveCallCount: &count}) - if err := store.Complete(t.Context(), outcome); err != nil { - t.Fatal(err) - } - terminal, found, err := store.Find(t.Context(), request.OperationID) - if err != nil || !found || terminal.Outcome.State != CommandOutcomeSucceeded || terminal.Outcome.Observation.RF.Mode == nil || *terminal.Outcome.Observation.RF.Mode != 4 { - t.Fatalf("terminal=%#v found=%v err=%v", terminal, found, err) - } - replayedRecord, replayed, err := store.Accept(t.Context(), request, digest, now.Add(2*time.Second)) - if err != nil || !replayed || replayedRecord.Outcome.State != CommandOutcomeSucceeded { - t.Fatalf("replay=%#v replayed=%v err=%v", replayedRecord, replayed, err) - } - conflict := request - conflict.FencingToken++ - conflictDigest := mustRadioEnsureOffDigest(t, conflict) - if _, _, err := store.Accept(t.Context(), conflict, conflictDigest, now.Add(3*time.Second)); !errors.Is(err, ErrOutcomeReplayConflict) { - t.Fatalf("replay conflict error = %v", err) - } - if err := store.Close(); err != nil { - t.Fatal(err) - } - reopened := openTestOutcomeStore(t, directory, 8, 4) - persisted, found, err := reopened.Find(t.Context(), request.OperationID) - if err != nil || !found || persisted.Outcome.State != CommandOutcomeSucceeded || persisted.RequestDigest != digest { - t.Fatalf("persisted=%#v found=%v err=%v", persisted, found, err) - } -} - -func TestOutcomeStoreBlocksPendingAndStaleFences(t *testing.T) { - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 8, 2) - now := time.Date(2026, 8, 3, 11, 0, 0, 0, time.UTC) - first := radioEnsureOffRequestFixture() - firstDigest := mustRadioEnsureOffDigest(t, first) - record, _, err := store.Accept(t.Context(), first, firstDigest, now) - if err != nil { - t.Fatal(err) - } - second := first - second.OperationID = "radio-off-2" - second.FencingToken = 2 - secondDigest := mustRadioEnsureOffDigest(t, second) - if _, _, err := store.Accept(t.Context(), second, secondDigest, now.Add(time.Second)); !errors.Is(err, ErrOutcomePending) { - t.Fatalf("pending barrier error = %v", err) - } - failed := terminalCommandOutcome(record, now.Add(2*time.Second), CommandOutcomeFailed, ErrorActiveCallPresent, ErrorLayerCall, true, false, - RadioEnsureOffObservation{RF: RFObservation{State: RFStateOn}, ActiveCallCount: intPointerForAgentTest(1)}) - if err := store.Complete(t.Context(), failed); err != nil { - t.Fatal(err) - } - secondRecord, _, err := store.Accept(t.Context(), second, secondDigest, now.Add(3*time.Second)) - if err != nil { - t.Fatal(err) - } - secondOutcome := terminalCommandOutcome(secondRecord, now.Add(4*time.Second), CommandOutcomeSucceeded, OutcomeCodeRadioOffConfirmed, "", false, false, - RadioEnsureOffObservation{RF: RFObservation{State: RFStateOff}, ActiveCallCount: intPointerForAgentTest(0)}) - if err := store.Complete(t.Context(), secondOutcome); err != nil { - t.Fatal(err) - } - stale := first - stale.OperationID = "radio-off-stale" - staleDigest := mustRadioEnsureOffDigest(t, stale) - if _, _, err := store.Accept(t.Context(), stale, staleDigest, now.Add(5*time.Second)); !errors.Is(err, ErrOutcomeFenceStale) { - t.Fatalf("stale fence error = %v", err) - } -} - -func TestOutcomeStorePrunesTerminalRowsButRetainsFence(t *testing.T) { - store := openTestOutcomeStore(t, filepath.Join(t.TempDir(), "agent-state"), 2, 1) - now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) - requests := make([]RadioEnsureOffRequest, 3) - for index := range requests { - request := radioEnsureOffRequestFixture() - request.OperationID = "radio-off-" + string(rune('1'+index)) - request.FencingToken = uint64(index + 1) - requests[index] = request - digest := mustRadioEnsureOffDigest(t, request) - record, _, err := store.Accept(t.Context(), request, digest, now.Add(time.Duration(index*2)*time.Second)) - if err != nil { - t.Fatal(err) - } - outcome := terminalCommandOutcome(record, now.Add(time.Duration(index*2+1)*time.Second), CommandOutcomeSucceeded, - OutcomeCodeRadioOffConfirmed, "", false, false, - RadioEnsureOffObservation{RF: RFObservation{State: RFStateOff}, ActiveCallCount: intPointerForAgentTest(0)}) - if err := store.Complete(t.Context(), outcome); err != nil { - t.Fatal(err) - } - } - if _, found, err := store.Find(t.Context(), requests[0].OperationID); err != nil || found { - t.Fatalf("oldest outcome found=%v err=%v", found, err) - } - oldDigest := mustRadioEnsureOffDigest(t, requests[0]) - if _, _, err := store.Accept(t.Context(), requests[0], oldDigest, now.Add(10*time.Second)); !errors.Is(err, ErrOutcomeFenceStale) { - t.Fatalf("pruned replay fence error = %v", err) - } - newGroup := radioEnsureOffRequestFixture() - newGroup.OperationID = "radio-off-new-group" - newGroup.ResourceGroupID = "agent-usb-2-1-resources" - newGroup.FencingToken = 1 - newGroupDigest := mustRadioEnsureOffDigest(t, newGroup) - if _, _, err := store.Accept(t.Context(), newGroup, newGroupDigest, now.Add(11*time.Second)); !errors.Is(err, ErrOutcomeLedgerFull) { - t.Fatalf("resource-group capacity error = %v", err) - } -} - -func intPointerForAgentTest(value int) *int { return &value } - -func mustRadioEnsureOffDigest(t *testing.T, request RadioEnsureOffRequest) string { - t.Helper() - digest, err := radioEnsureOffDigest(request) - if err != nil { - t.Fatal(err) - } - return digest -} diff --git a/internal/agentapi/probe_validation.go b/internal/agentapi/probe_validation.go index cec56b8..97f033d 100644 --- a/internal/agentapi/probe_validation.go +++ b/internal/agentapi/probe_validation.go @@ -220,40 +220,3 @@ func isPLMN(value string) bool { } return true } - -func validateRadioEnsureOffResponse(response RadioEnsureOffResponse, operationID string) error { - if response.ProtocolVersion != ProtocolVersion || !IsValidAgentInstanceID(response.AgentInstanceID) { - return errors.New("invalid command response envelope") - } - outcome := response.Outcome - if outcome.OperationID == "" || outcome.OperationID != operationID || outcome.Command != CommandRadioEnsureOff || - !oneOf(outcome.State, CommandOutcomeSucceeded, CommandOutcomeFailed, CommandOutcomeUncertain) || - outcome.Code == "" || outcome.AcceptedAt.IsZero() || outcome.CompletedAt == nil || outcome.CompletedAt.Before(outcome.AcceptedAt) { - return errors.New("invalid command outcome") - } - if !oneOf(outcome.Observation.RF.State, RFStateOff, RFStateMinimum, RFStateOn, RFStateUnknown) { - return errors.New("invalid command RF observation") - } - if outcome.Observation.ActiveCallCount != nil && *outcome.Observation.ActiveCallCount < 0 { - return errors.New("invalid command active-call count") - } - if outcome.State == CommandOutcomeSucceeded && - (outcome.Code != OutcomeCodeRadioOffConfirmed || outcome.ErrorLayer != "" || outcome.Observation.RF.State != RFStateOff || - outcome.Observation.ActiveCallCount == nil || *outcome.Observation.ActiveCallCount != 0) { - return errors.New("successful command outcome is not confirmed RF-off") - } - if outcome.State != CommandOutcomeSucceeded && !oneOf(outcome.ErrorLayer, ErrorLayerPlatform, ErrorLayerDevice, ErrorLayerTransport, ErrorLayerRadio, ErrorLayerSIM, ErrorLayerCall) { - return errors.New("failed command outcome requires an error layer") - } - if outcome.State != CommandOutcomeSucceeded && !oneOf(outcome.Code, - ErrorPlatformUnsupported, ErrorControlEndpointBusy, ErrorControlEndpointMissing, ErrorControlPermissionDenied, - ErrorControlEndpointOpen, ErrorControlEndpointConfigure, ErrorModemNoResponse, ErrorProbeCancelled, - ErrorRFStateUnavailable, ErrorCallStateUnknown, ErrorActiveCallPresent, ErrorRadioOffCommandRejected, - ErrorRadioOffNotConfirmed, ErrorRadioOffOutcomeUncertain, ErrorHardwareChanged) { - return errors.New("failed command outcome has an unknown code") - } - if outcome.State == CommandOutcomeUncertain && outcome.Retryable { - return errors.New("uncertain command outcome must not request blind retry") - } - return nil -} diff --git a/internal/agentapi/protocol.go b/internal/agentapi/protocol.go index 587702f..f270817 100644 --- a/internal/agentapi/protocol.go +++ b/internal/agentapi/protocol.go @@ -152,22 +152,6 @@ const ( ErrorHardwareChanged = "HARDWARE_CHANGED" ) -const ( - CommandRadioEnsureOff = "radio.ensure-off" -) - -const ( - CommandOutcomeAccepted = "accepted" - CommandOutcomeSucceeded = "succeeded" - CommandOutcomeFailed = "failed" - CommandOutcomeUncertain = "uncertain" -) - -const ( - OutcomeCodeAccepted = "COMMAND_ACCEPTED" - OutcomeCodeRadioOffConfirmed = "RADIO_OFF_CONFIRMED" -) - type Hello struct { Protocol string `json:"protocol"` ProtocolVersion int `json:"protocolVersion"` @@ -321,39 +305,3 @@ type ProbeResponse struct { ObservedAt time.Time `json:"observedAt"` Devices []DeviceProbe `json:"devices"` } - -type RadioEnsureOffRequest struct { - OperationID string `json:"operationId"` - AgentInstanceID string `json:"agentInstanceId"` - SnapshotGeneration uint64 `json:"snapshotGeneration"` - SnapshotRevision string `json:"snapshotRevision"` - DeviceID string `json:"deviceId"` - DeviceGeneration uint64 `json:"deviceGeneration"` - ResourceGroupID string `json:"resourceGroupId"` - ResourceGroupGeneration uint64 `json:"resourceGroupGeneration"` - FencingToken uint64 `json:"fencingToken"` -} - -type RadioEnsureOffObservation struct { - RF RFObservation `json:"rf"` - ActiveCallCount *int `json:"activeCallCount,omitempty"` -} - -type CommandOutcome struct { - OperationID string `json:"operationId"` - Command string `json:"command"` - State string `json:"state"` - Code string `json:"code"` - ErrorLayer string `json:"errorLayer,omitempty"` - Retryable bool `json:"retryable"` - Reconciled bool `json:"reconciled"` - Observation RadioEnsureOffObservation `json:"observation"` - AcceptedAt time.Time `json:"acceptedAt"` - CompletedAt *time.Time `json:"completedAt,omitempty"` -} - -type RadioEnsureOffResponse struct { - ProtocolVersion int `json:"protocolVersion"` - AgentInstanceID string `json:"agentInstanceId"` - Outcome CommandOutcome `json:"outcome"` -} diff --git a/internal/agentapi/server.go b/internal/agentapi/server.go index 3194028..d14128f 100644 --- a/internal/agentapi/server.go +++ b/internal/agentapi/server.go @@ -43,24 +43,24 @@ func (response *ErrorResponse) Is(target error) bool { return mapping[response.Code] == target } -func NewHandler(monitor *Monitor, commands *CommandService, logger *slog.Logger, smsBackends ...SMSBackend) http.Handler { - return newHandler(monitor, commands, nil, nil, logger, false, smsBackends...) +func NewHandler(monitor *Monitor, logger *slog.Logger, smsBackends ...SMSBackend) http.Handler { + return newHandler(monitor, nil, nil, logger, false, smsBackends...) } // NewReadOnlyHardwareHandler is the production V1 hardware boundary. Its // signature deliberately provides no way to inject command or SMS backends. func NewReadOnlyHardwareHandler(monitor *Monitor, logger *slog.Logger) http.Handler { - return newHandler(monitor, nil, nil, nil, logger, true) + return newHandler(monitor, nil, nil, logger, true) } // NewManagedHardwareHandler exposes read-only discovery plus the narrowly // typed RF, equipment-identity, and optional SMS services. It still has no // route for arbitrary commands, paths, calls, or eUICC mutations. func NewManagedHardwareHandler(monitor *Monitor, rf *RFService, identity *EquipmentIdentityService, logger *slog.Logger, smsBackends ...SMSBackend) http.Handler { - return newHandler(monitor, nil, rf, identity, logger, false, smsBackends...) + return newHandler(monitor, rf, identity, logger, false, smsBackends...) } -func newHandler(monitor *Monitor, commands *CommandService, rf *RFService, identity *EquipmentIdentityService, logger *slog.Logger, hardwareReadOnly bool, smsBackends ...SMSBackend) http.Handler { +func newHandler(monitor *Monitor, rf *RFService, identity *EquipmentIdentityService, logger *slog.Logger, hardwareReadOnly bool, smsBackends ...SMSBackend) http.Handler { if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } @@ -74,9 +74,6 @@ func newHandler(monitor *Monitor, commands *CommandService, rf *RFService, ident if hardwareReadOnly { features = append(features, FeatureHardwareReadOnly) } - if commands != nil { - features = append(features, "durable-command-outcomes", CommandRadioEnsureOff) - } if rf != nil { features = append(features, FeatureRFControl) } @@ -175,30 +172,6 @@ func newHandler(monitor *Monitor, commands *CommandService, rf *RFService, ident } writeJSON(w, http.StatusOK, response) }) - if commands != nil { - mux.HandleFunc("POST /v1/commands/radio/ensure-off", func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)) - decoder.DisallowUnknownFields() - var request RadioEnsureOffRequest - if err := decoder.Decode(&request); err != nil { - writeJSON(w, http.StatusBadRequest, ErrorResponse{Code: "REQUEST_INVALID", Detail: "invalid radio.ensure-off request"}) - return - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - writeJSON(w, http.StatusBadRequest, ErrorResponse{Code: "REQUEST_INVALID", Detail: "request must contain one JSON object"}) - return - } - response, err := commands.EnsureRadioOff(r.Context(), request) - if err != nil { - status, apiError := classifyCommandError(err) - logger.Warn("radio.ensure-off rejected", "code", apiError.Code, "error", err) - writeJSON(w, status, apiError) - return - } - writeJSON(w, http.StatusOK, response) - }) - } if rf != nil { mux.HandleFunc("POST /v1/radio/state", func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() @@ -307,41 +280,6 @@ func classifyRFError(err error) (int, ErrorResponse) { return status, response } -func classifyCommandError(err error) (int, ErrorResponse) { - status := http.StatusServiceUnavailable - response := ErrorResponse{Code: "COMMAND_UNAVAILABLE", Detail: "hardware command is unavailable", Retryable: true} - switch { - case errors.Is(err, ErrCommandRequestInvalid): - status = http.StatusBadRequest - response = ErrorResponse{Code: "REQUEST_INVALID", Detail: "invalid radio.ensure-off request"} - case errors.Is(err, ErrCommandAgentStale): - status = http.StatusConflict - response = ErrorResponse{Code: "AGENT_INSTANCE_STALE", Detail: "Agent instance changed; refresh before retrying", Retryable: true} - case errors.Is(err, ErrCommandSnapshotStale): - status = http.StatusConflict - response = ErrorResponse{Code: "SNAPSHOT_STALE", Detail: "hardware snapshot changed; refresh before retrying", Retryable: true} - case errors.Is(err, ErrCommandDeviceStale): - status = http.StatusConflict - response = ErrorResponse{Code: "DEVICE_GENERATION_STALE", Detail: "device generation changed; refresh before retrying", Retryable: true} - case errors.Is(err, ErrOutcomeFenceStale): - status = http.StatusConflict - response = ErrorResponse{Code: "RESOURCE_FENCE_STALE", Detail: "resource fence is stale"} - case errors.Is(err, ErrOutcomeReplayConflict): - status = http.StatusConflict - response = ErrorResponse{Code: "OPERATION_REPLAY_CONFLICT", Detail: "operation id was already used for different parameters"} - case errors.Is(err, ErrCommandUnsupported): - status = http.StatusUnprocessableEntity - response = ErrorResponse{Code: "COMMAND_UNSUPPORTED", Detail: "radio.ensure-off is unsupported for this device"} - case errors.Is(err, ErrOutcomePending): - response = ErrorResponse{Code: "OUTCOME_RECONCILIATION_PENDING", Detail: "a prior outcome must be reconciled", Retryable: true} - case errors.Is(err, ErrOutcomeLedgerFull): - response = ErrorResponse{Code: "OUTCOME_LEDGER_FULL", Detail: "Agent outcome ledger cannot accept another command"} - case errors.Is(err, ErrCommandPersistence): - response = ErrorResponse{Code: "OUTCOME_PERSIST_FAILED", Detail: "Agent could not persist the command outcome", Retryable: true} - } - return status, response -} - func contextWithTimeout(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(r.Context(), timeout) } diff --git a/internal/agentapi/sms_outcome_test.go b/internal/agentapi/sms_outcome_test.go new file mode 100644 index 0000000..ad4e09f --- /dev/null +++ b/internal/agentapi/sms_outcome_test.go @@ -0,0 +1,40 @@ +package agentapi + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestSendTreatsLostOrDamagedReplyAsUnknown(t *testing.T) { + for _, reply := range []string{"", "{", `{"protocolVersion":999}`, `{"code":"SMS_BACKEND_INVALID"}`} { + t.Run(reply, func(t *testing.T) { + var dispatched atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dispatched.Add(1) + if reply == "" { + c, _, _ := w.(http.Hijacker).Hijack() + c.Close() + return + } + if strings.Contains(reply, "SMS_BACKEND_INVALID") { + w.WriteHeader(503) + } + w.Write([]byte(reply)) + })) + defer server.Close() + client := &Client{http: &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "tcp", server.Listener.Addr().String()) + }}}} + _, err := client.SendSMS(t.Context(), SMSSendRequest{OperationID: "operation-synthetic-1", AgentInstanceID: "01234567-89ab-cdef-0123-456789abcdef", DeviceID: "usb-1-1", DeviceGeneration: 1, ExpectedEquipmentFingerprint: strings.Repeat("a", 64), ExpectedSubscriptionFingerprint: strings.Repeat("b", 64), Destination: "10086", Body: "synthetic"}) + if !errors.Is(err, ErrSMSOutcomeUnknown) || dispatched.Load() != 1 { + t.Fatalf("dispatches=%d err=%v", dispatched.Load(), err) + } + }) + } +} diff --git a/internal/agentapi/test_helpers_test.go b/internal/agentapi/test_helpers_test.go new file mode 100644 index 0000000..9921e52 --- /dev/null +++ b/internal/agentapi/test_helpers_test.go @@ -0,0 +1,3 @@ +package agentapi + +func intPointerForAgentTest(value int) *int { return &value } diff --git a/internal/application/inventory/agent_source.go b/internal/agentinventory/agent_source.go similarity index 81% rename from internal/application/inventory/agent_source.go rename to internal/agentinventory/agent_source.go index 8631283..5f56f43 100644 --- a/internal/application/inventory/agent_source.go +++ b/internal/agentinventory/agent_source.go @@ -1,4 +1,4 @@ -package inventory +package agentinventory import ( "context" @@ -49,7 +49,7 @@ func (source *AgentSource) Snapshot(ctx context.Context) (hardware.Snapshot, err probeByDevice[device.DeviceID] = device } - snapshot := hardware.Snapshot{Generation: report.Generation, ObservedAt: report.ObservedAt.UTC()} + snapshot := hardware.Snapshot{AgentInstanceID: report.AgentInstanceID, Generation: report.Generation, ObservedAt: report.ObservedAt.UTC()} for _, device := range report.Devices { if device.ID == "" || device.Generation == 0 || device.Generation > report.Generation { return hardware.Snapshot{}, errors.New("hardware agent returned an invalid device generation") @@ -172,18 +172,37 @@ func (source *AgentSource) Snapshot(ctx context.Context) (hardware.Snapshot, err func (source *AgentSource) cachedProbe(ctx context.Context, snapshot agentapi.Snapshot) agentapi.ProbeResponse { source.mu.Lock() - defer source.mu.Unlock() - if source.probeGeneration == snapshot.Generation && source.probeRevision == snapshot.Revision && source.now().Sub(source.probeAt) < 5*time.Second { - return source.probe + if source.probe.AgentInstanceID == snapshot.AgentInstanceID && source.probeGeneration == snapshot.Generation && source.probeRevision == snapshot.Revision && source.now().Sub(source.probeAt) < 5*time.Second { + probe := source.probe + source.mu.Unlock() + return probe } - probe, err := source.client.Probe(ctx, agentapi.ProbeRequest{}) - if err != nil || probe.ProtocolVersion != agentapi.ProtocolVersion || probe.AgentInstanceID != snapshot.AgentInstanceID || probe.SnapshotGeneration != snapshot.Generation || probe.SnapshotRevision != snapshot.Revision { - return agentapi.ProbeResponse{} + source.mu.Unlock() + // Each modem has its own deadline. A timed-out modem contributes no identity + // and cannot discard the confirmed identities returned by other modems. + results := make(chan agentapi.DeviceProbe, len(snapshot.Devices)) + var work sync.WaitGroup + for _, device := range snapshot.Devices { + work.Go(func() { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + probe, err := source.client.Probe(probeCtx, agentapi.ProbeRequest{DeviceIDs: []string{device.ID}}) + if err != nil || probe.ProtocolVersion != agentapi.ProtocolVersion || probe.AgentInstanceID != snapshot.AgentInstanceID || probe.SnapshotGeneration != snapshot.Generation || probe.SnapshotRevision != snapshot.Revision || len(probe.Devices) != 1 || probe.Devices[0].DeviceID != device.ID { + return + } + results <- probe.Devices[0] + }) } - source.probeGeneration = snapshot.Generation - source.probeRevision = snapshot.Revision - source.probeAt = source.now() - source.probe = probe + work.Wait() + close(results) + probe := agentapi.ProbeResponse{ProtocolVersion: agentapi.ProtocolVersion, AgentInstanceID: snapshot.AgentInstanceID, SnapshotGeneration: snapshot.Generation, SnapshotRevision: snapshot.Revision, ObservedAt: source.now().UTC(), Devices: []agentapi.DeviceProbe{}} + for result := range results { + probe.Devices = append(probe.Devices, result) + } + sort.Slice(probe.Devices, func(i, j int) bool { return probe.Devices[i].DeviceID < probe.Devices[j].DeviceID }) + source.mu.Lock() + source.probeGeneration, source.probeRevision, source.probeAt, source.probe = snapshot.Generation, snapshot.Revision, source.now(), probe + source.mu.Unlock() return probe } diff --git a/internal/application/inventory/agent_source_test.go b/internal/agentinventory/agent_source_test.go similarity index 83% rename from internal/application/inventory/agent_source_test.go rename to internal/agentinventory/agent_source_test.go index a72f32f..40c6549 100644 --- a/internal/application/inventory/agent_source_test.go +++ b/internal/agentinventory/agent_source_test.go @@ -1,9 +1,10 @@ -package inventory +package agentinventory import ( "context" "strings" "testing" + "testing/synctest" "time" "github.com/leonfox28/simplus/internal/agentapi" @@ -243,3 +244,44 @@ func TestAgentSourceKeepsControlOnlyDeviceWithoutInventingSIMGraph(t *testing.T) t.Fatalf("control-only device gained a SIM graph: %#v", topology) } } + +func TestProbeCacheIsScopedToAgentInstance(t *testing.T) { + client := &fakeAgentClient{probe: agentapi.ProbeResponse{ProtocolVersion: agentapi.ProtocolVersion, AgentInstanceID: "01234567-89ab-cdef-0123-456789abcdef", SnapshotGeneration: 1, SnapshotRevision: strings.Repeat("a", 64)}} + source := NewAgentSource(client) + snapshot := agentapi.Snapshot{Devices: []agentapi.DeviceReport{{ID: "usb-1-3"}}, AgentInstanceID: client.probe.AgentInstanceID, Generation: 1, Revision: client.probe.SnapshotRevision} + _ = source.cachedProbe(t.Context(), snapshot) + _ = source.cachedProbe(t.Context(), snapshot) + if client.probes != 1 { + t.Fatalf("cached probes=%d", client.probes) + } + snapshot.AgentInstanceID = "fedcba98-7654-3210-fedc-ba9876543210" + client.probe.AgentInstanceID = snapshot.AgentInstanceID + probe := source.cachedProbe(t.Context(), snapshot) + if probe.AgentInstanceID != snapshot.AgentInstanceID || client.probes != 2 { + t.Fatal("reused previous Agent observation") + } +} + +type slowModemClient struct{ snapshot agentapi.Snapshot } + +func (c slowModemClient) Snapshot(context.Context, bool) (agentapi.Snapshot, error) { + return c.snapshot, nil +} +func (c slowModemClient) Probe(ctx context.Context, r agentapi.ProbeRequest) (agentapi.ProbeResponse, error) { + if r.DeviceIDs[0] == "slow" { + <-ctx.Done() + return agentapi.ProbeResponse{}, ctx.Err() + } + return agentapi.ProbeResponse{ProtocolVersion: agentapi.ProtocolVersion, AgentInstanceID: c.snapshot.AgentInstanceID, SnapshotGeneration: c.snapshot.Generation, SnapshotRevision: c.snapshot.Revision, Devices: []agentapi.DeviceProbe{{DeviceID: r.DeviceIDs[0]}}}, nil +} +func TestSlowModemDoesNotDiscardOtherInventoryObservations(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + snapshot := agentapi.Snapshot{ProtocolVersion: agentapi.ProtocolVersion, AgentInstanceID: "01234567-89ab-cdef-0123-456789abcdef", Generation: 1, Revision: strings.Repeat("a", 64), Devices: []agentapi.DeviceReport{{ID: "slow"}, {ID: "fast"}}} + source := NewAgentSource(slowModemClient{snapshot}) + start := time.Now() + got := source.cachedProbe(t.Context(), snapshot) + if time.Since(start) != 3*time.Second || len(got.Devices) != 1 || got.Devices[0].DeviceID != "fast" { + t.Fatalf("observations=%+v duration=%s", got, time.Since(start)) + } + }) +} diff --git a/internal/application/inventory/change_coordinator.go b/internal/agentinventory/change_coordinator.go similarity index 99% rename from internal/application/inventory/change_coordinator.go rename to internal/agentinventory/change_coordinator.go index a62a146..6a30894 100644 --- a/internal/application/inventory/change_coordinator.go +++ b/internal/agentinventory/change_coordinator.go @@ -1,4 +1,4 @@ -package inventory +package agentinventory import ( "context" diff --git a/internal/application/inventory/change_coordinator_test.go b/internal/agentinventory/change_coordinator_test.go similarity index 99% rename from internal/application/inventory/change_coordinator_test.go rename to internal/agentinventory/change_coordinator_test.go index 1a26cd4..f8f95a8 100644 --- a/internal/application/inventory/change_coordinator_test.go +++ b/internal/agentinventory/change_coordinator_test.go @@ -1,4 +1,4 @@ -package inventory +package agentinventory import ( "context" diff --git a/internal/api/httpapi/assembly_helpers_test.go b/internal/api/httpapi/assembly_helpers_test.go new file mode 100644 index 0000000..bec4c41 --- /dev/null +++ b/internal/api/httpapi/assembly_helpers_test.go @@ -0,0 +1,115 @@ +package httpapi + +import ( + "log/slog" + "time" +) + +func WithMihomoCore(server *Server, manager MihomoCoreManager) *Server { + if server != nil { + server.mihomoCore = manager + } + return server +} + +func WithMihomoSubscriptions(server *Server, manager MihomoSubscriptionManager) *Server { + if server != nil { + server.mihomoSubscriptions = manager + } + return server +} + +func WithLineEgress(server *Server, manager LineEgressManager) *Server { + if server != nil { + server.lineEgress = manager + } + return server +} + +func WithVoWiFi(server *Server, manager VoWiFiManager) *Server { + if server != nil { + server.vowifi = manager + } + return server +} + +func WithManagedModems(server *Server, manager ManagedModemManager) *Server { + if server != nil { + server.modems = manager + } + return server +} + +func WithManagedLines(server *Server, manager ManagedLineManager) *Server { + if server != nil { + server.lines = manager + } + return server +} + +func WithMihomoConfig(server *Server, manager MihomoConfigManager) *Server { + if server != nil { + server.mihomoConfig = manager + } + return server +} +func WithMihomoRuntime(server *Server, manager MihomoRuntimeManager) *Server { + if server != nil { + server.mihomoRuntime = manager + } + return server +} +func WithMihomoDashboard(server *Server, manager MihomoDashboardManager) *Server { + if server != nil { + server.mihomoDashboard = manager + } + return server +} +func WithNotifications(server *Server, manager NotificationManager) *Server { + if server != nil { + server.notifications = manager + } + return server +} + +func WithRealtime(server *Server, manager RealtimeManager) *Server { + if server != nil { + server.realtime = manager + } + return server +} + +func WithCalls(server *Server, calls CallManager) *Server { + if server != nil { + server.calls = calls + } + return server +} + +func WithEUICC(server *Server, manager EUICCManager) *Server { + if server != nil { + server.euicc = manager + } + return server +} +func New( + healthService HealthReader, + setupService SetupManager, + inventoryService InventoryReader, + logger *slog.Logger, + authentication Authenticator, + messages Messenger, + contactManagers ...ContactManager, +) *Server { + if logger == nil { + logger = slog.Default() + } + server := &Server{ + health: healthService, setup: setupService, inventory: inventoryService, + auth: authentication, messages: messages, logger: logger, realtimeHeartbeat: 15 * time.Second, + } + if len(contactManagers) != 0 { + server.contacts = contactManagers[0] + } + return server +} diff --git a/internal/api/httpapi/auth.go b/internal/api/httpapi/auth.go new file mode 100644 index 0000000..7ea2054 --- /dev/null +++ b/internal/api/httpapi/auth.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/leonfox28/simplus/internal/api/openapi" + authapp "github.com/leonfox28/simplus/internal/application/auth" +) + +func (server *Server) requireAdministrator(w http.ResponseWriter, r *http.Request, requireCSRF bool) (authapp.Session, bool) { + if server.auth == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + return authapp.Session{}, false + } + token, csrfToken, ok := administratorTokens(r, requireCSRF) + if !ok { + if sessionCookie, err := r.Cookie(adminSessionCookieName); requireCSRF && err == nil && sessionCookie.Value != "" { + writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) + } else { + clearAdministratorCookies(w, r) + writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) + } + return authapp.Session{}, false + } + session, err := server.auth.Authenticate(r.Context(), token, csrfToken, requireCSRF) + if err != nil { + server.writeAuthenticationError(w, err, "administrator session validation failed") + return authapp.Session{}, false + } + return session, true +} + +func (server *Server) writeAuthenticationError(w http.ResponseWriter, err error, message string) { + switch { + case errors.Is(err, authapp.ErrInvalidCredentials): + writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "LOGIN_INVALID", Retryable: false}) + case errors.Is(err, authapp.ErrUnauthorized): + writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) + case errors.Is(err, authapp.ErrCSRFInvalid): + writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) + case errors.Is(err, authapp.ErrLoginRateLimited): + writeJSON(w, http.StatusTooManyRequests, openapi.ApiError{Code: "LOGIN_RATE_LIMITED", Retryable: true}) + case errors.Is(err, authapp.ErrInstanceNotReady): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "INSTANCE_NOT_READY", Retryable: false}) + case errors.Is(err, authapp.ErrPasswordRequestInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PASSWORD_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, authapp.ErrCurrentPasswordInvalid): + writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "CURRENT_PASSWORD_INVALID", Retryable: false}) + default: + server.logger.Error(message, "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + } +} + +func (server *Server) Login(w http.ResponseWriter, r *http.Request) { + if server.auth == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.LoginRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LOGIN_REQUEST_INVALID", Retryable: false}) + return + } + result, err := server.auth.Login(r.Context(), request.Username, request.Password) + if err != nil { + server.writeAuthenticationError(w, err, "administrator login failed") + return + } + setAdministratorCookies(w, r, result.SessionToken, result.CSRFToken, result.ExpiresAt) + + writeJSON(w, http.StatusOK, authSessionResponse(result.User, result.ExpiresAt)) +} + +func (server *Server) GetAuthSession(w http.ResponseWriter, r *http.Request) { + session, ok := server.requireAdministrator(w, r, false) + if !ok { + return + } + writeJSON(w, http.StatusOK, authSessionResponse(session.User, session.ExpiresAt)) +} + +func (server *Server) Logout(w http.ResponseWriter, r *http.Request) { + if server.auth == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + return + } + token, csrfToken, ok := administratorTokens(r, true) + if !ok { + clearAdministratorCookies(w, r) + writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) + return + } + if err := server.auth.Logout(r.Context(), token, csrfToken); err != nil { + server.writeAuthenticationError(w, err, "administrator logout failed") + return + } + clearAdministratorCookies(w, r) + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) ChangeAdministratorPassword(w http.ResponseWriter, r *http.Request) { + if server.auth == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + return + } + token, csrfToken, ok := administratorTokens(r, true) + if !ok { + writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) + return + } + var request openapi.ChangeAdministratorPasswordRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PASSWORD_REQUEST_INVALID", Retryable: false}) + return + } + passwordAuth, ok := server.auth.(PasswordAuthenticator) + if !ok { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) + return + } + if err := passwordAuth.ChangePassword(r.Context(), token, csrfToken, request.CurrentPassword, request.NewPassword, request.NewPasswordConfirmation); err != nil { + server.writeAuthenticationError(w, err, "administrator password replacement failed") + return + } + clearAdministratorCookies(w, r) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/httpapi/communications.go b/internal/api/httpapi/communications.go new file mode 100644 index 0000000..ccf360c --- /dev/null +++ b/internal/api/httpapi/communications.go @@ -0,0 +1,544 @@ +package httpapi + +import ( + "errors" + "fmt" + "net/http" + + "github.com/leonfox28/simplus/internal/api/openapi" + callapp "github.com/leonfox28/simplus/internal/application/calls" + contactapp "github.com/leonfox28/simplus/internal/application/contacts" + messageapp "github.com/leonfox28/simplus/internal/application/messaging" + "github.com/leonfox28/simplus/internal/application/realtime" + "github.com/leonfox28/simplus/internal/domain/call" + "github.com/leonfox28/simplus/internal/domain/contact" + "github.com/leonfox28/simplus/internal/domain/pagination" + "github.com/leonfox28/simplus/internal/domain/sms" +) + +func (server *Server) ListMessages(w http.ResponseWriter, r *http.Request, params openapi.ListMessagesParams) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.messages == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) + return + } + request := messageapp.PageRequest{} + if params.Limit != nil { + if *params.Limit == 0 { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + return + } + request.Limit = *params.Limit + } + if params.Cursor != nil { + if *params.Cursor == "" { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + request.Cursor = *params.Cursor + } else if _, present := r.URL.Query()["cursor"]; present { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + linePresent := false + if params.LineId != nil { + request.LineID = *params.LineId + linePresent = true + } else if _, present := r.URL.Query()["lineId"]; present { + linePresent = true + } + remotePresent := false + if params.RemoteAddress != nil { + request.RemoteAddress = *params.RemoteAddress + remotePresent = true + } else if _, present := r.URL.Query()["remoteAddress"]; present { + remotePresent = true + } + if (linePresent && !remotePresent) || (linePresent && request.LineID == "") || (remotePresent && request.RemoteAddress == "") { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_FILTER_INVALID", Retryable: false}) + return + } + page, err := server.messages.ListPage(r.Context(), request) + if err != nil { + if errors.Is(err, pagination.ErrCursorInvalid) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + if errors.Is(err, pagination.ErrLimitInvalid) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + return + } + if errors.Is(err, messageapp.ErrRequestInvalid) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_FILTER_INVALID", Retryable: false}) + return + } + server.logger.ErrorContext(r.Context(), "message history read failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) + return + } + response := make([]openapi.SMSMessage, 0, len(page.Messages)) + for _, message := range page.Messages { + response = append(response, smsMessageResponse(message)) + } + stats, err := server.messages.Stats(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "message history stats failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) + return + } + result := openapi.SMSMessageListResponse{ + Messages: response, TotalCount: stats.TotalCount, Capacity: stats.Capacity, NearCapacity: stats.NearCapacity, + } + if page.NextCursor != "" { + result.NextCursor = &page.NextCursor + } + if page.ReadThroughToken != "" { + result.ReadThroughToken = &page.ReadThroughToken + } + writeJSON(w, http.StatusOK, result) +} + +func (server *Server) ListMessageConversations(w http.ResponseWriter, r *http.Request, params openapi.ListMessageConversationsParams) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.messages == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) + return + } + limit := 0 + if params.Limit != nil { + if *params.Limit == 0 { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + return + } + limit = *params.Limit + } + cursor := "" + if params.Cursor != nil { + if *params.Cursor == "" { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + cursor = *params.Cursor + } else if _, present := r.URL.Query()["cursor"]; present { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + page, err := server.messages.ListConversationPage(r.Context(), limit, cursor) + if err != nil { + switch { + case errors.Is(err, pagination.ErrCursorInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + case errors.Is(err, pagination.ErrLimitInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "message conversation read failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) + } + return + } + stats, err := server.messages.Stats(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "message conversation stats failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) + return + } + conversations := make([]openapi.SMSConversationSummary, 0, len(page.Conversations)) + for _, item := range page.Conversations { + response := openapi.SMSConversationSummary{ + RemoteAddress: item.RemoteAddress, + LastMessage: smsMessageResponse(item.LastMessage), + UnreadCount: item.UnreadCount, + } + if item.LastOutboundLineID != "" { + response.LastOutboundLineId = &item.LastOutboundLineID + } + conversations = append(conversations, response) + } + result := openapi.SMSConversationListResponse{ + Conversations: conversations, ConversationTotalCount: page.TotalCount, + MessageTotalCount: stats.TotalCount, Capacity: stats.Capacity, NearCapacity: stats.NearCapacity, + } + if page.NextCursor != "" { + result.NextCursor = &page.NextCursor + } + writeJSON(w, http.StatusOK, result) +} + +func (server *Server) MarkMessageConversationRead(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.messages == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.MarkSMSConversationReadRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_READ_STATE_INVALID", Retryable: false}) + return + } + changed, err := server.messages.MarkConversationRead(r.Context(), request.RemoteAddress, request.ReadThroughToken) + if err != nil { + switch { + case errors.Is(err, messageapp.ErrRequestInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_READ_STATE_INVALID", Retryable: false}) + case errors.Is(err, sms.ErrMessageNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_READ_BOUNDARY_NOT_FOUND", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "message read state update failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) + } + return + } + if changed { + server.publish([]realtime.Topic{realtime.TopicMessages}, "") + } + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) DeleteMessage(w http.ResponseWriter, r *http.Request, messageID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.messages == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) + return + } + if err := server.messages.Delete(r.Context(), messageID); err != nil { + switch { + case errors.Is(err, messageapp.ErrRequestInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, sms.ErrMessageNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_NOT_FOUND", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "message deletion failed", "message_id", messageID, "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) + } + return + } + server.publish([]realtime.Topic{realtime.TopicMessages}, "") + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) SendMessage(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.messages == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.SendSMSRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) + return + } + result, err := server.messages.Send(r.Context(), messageapp.SendRequest{ + OperationID: request.OperationId, + LineID: request.LineId, + Destination: request.Destination, + Body: request.Body, + }) + if err != nil { + server.writeMessageError(w, r, err, request.OperationId, request.LineId) + return + } + status := http.StatusCreated + if result.Replayed { + status = http.StatusOK + } + if !result.Replayed && result.Message.Status == sms.StatusFailed { + server.enqueueNotice(r.Context(), "sms.failed", result.Message.ID, fmt.Sprintf("[Simplus] 短信发送失败 · 线路 %s · %s", result.Message.LineID, result.Message.ErrorCode)) + } + server.publish([]realtime.Topic{realtime.TopicMessages}, "") + writeJSON(w, status, smsMessageResponse(result.Message)) +} + +func (server *Server) ListCalls(w http.ResponseWriter, r *http.Request, params openapi.ListCallsParams) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.calls) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) + return + } + limit := 0 + cursor := "" + if params.Limit != nil { + if *params.Limit == 0 { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + return + } + limit = *params.Limit + } + if params.Cursor != nil { + if *params.Cursor == "" { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + cursor = *params.Cursor + } else if _, present := r.URL.Query()["cursor"]; present { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + page, err := server.calls.List(r.Context(), limit, cursor) + if err != nil { + if errors.Is(err, pagination.ErrCursorInvalid) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) + return + } + if errors.Is(err, pagination.ErrLimitInvalid) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) + return + } + server.writeCallError(w, r, err) + return + } + response := make([]openapi.Call, 0, len(page.Calls)) + for _, value := range page.Calls { + response = append(response, callResponse(value)) + } + result := openapi.CallListResponse{Calls: response} + if page.NextCursor != "" { + result.NextCursor = &page.NextCursor + } + writeJSON(w, http.StatusOK, result) +} + +func (server *Server) DialCall(w http.ResponseWriter, r *http.Request) { server.startCall(w, r, false) } + +func (server *Server) SimulateIncomingCall(w http.ResponseWriter, r *http.Request) { + server.startCall(w, r, true) +} + +func (server *Server) startCall(w http.ResponseWriter, r *http.Request, incoming bool) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.calls) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) + return + } + var request openapi.CallStartRequest + if decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) + return + } + var value call.Record + var replayed bool + var err error + if incoming { + value, replayed, err = server.calls.Incoming(r.Context(), request.OperationId, request.LineId, request.RemoteAddress) + } else { + value, replayed, err = server.calls.Dial(r.Context(), request.OperationId, request.LineId, request.RemoteAddress) + } + if err != nil { + server.writeCallError(w, r, err) + return + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + } + if incoming && !replayed { + server.enqueueNotice(r.Context(), "call.incoming", value.ID, fmt.Sprintf("[Simplus] 新来电 · 线路 %s", value.LineID)) + } + attention := realtime.Attention("") + if incoming && !replayed { + attention = realtime.AttentionCallIncoming + } + server.publish([]realtime.Topic{realtime.TopicCalls}, attention) + writeJSON(w, status, callResponse(value)) +} + +func (server *Server) ControlCall(w http.ResponseWriter, r *http.Request, callID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.calls) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) + return + } + var request openapi.CallActionRequest + if decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) + return + } + var value call.Record + var err error + switch request.Action { + case openapi.Answer: + value, err = server.calls.Answer(r.Context(), callID) + case openapi.Reject: + value, err = server.calls.Reject(r.Context(), callID) + case openapi.Hangup: + value, err = server.calls.Hangup(r.Context(), callID) + case openapi.Dtmf: + if request.Digits == nil { + err = callapp.ErrInvalid + } else { + value, err = server.calls.DTMF(r.Context(), callID, *request.Digits) + } + default: + err = callapp.ErrInvalid + } + if err != nil { + server.writeCallError(w, r, err) + return + } + if request.Action == openapi.Reject && value.Direction == call.DirectionInbound { + server.enqueueNotice(r.Context(), "call.missed", value.ID, fmt.Sprintf("[Simplus] 未接来电 · 线路 %s", value.LineID)) + } + server.publish([]realtime.Topic{realtime.TopicCalls}, "") + writeJSON(w, http.StatusOK, callResponse(value)) +} + +func (server *Server) writeCallError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, callapp.ErrInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, callapp.ErrUnsafeNumber): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_NUMBER_FORBIDDEN", Retryable: false}) + case errors.Is(err, callapp.ErrLineUnavailable): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_LINE_UNAVAILABLE", Retryable: true}) + case errors.Is(err, callapp.ErrLineBusy): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_LINE_BUSY", Retryable: true}) + case errors.Is(err, call.ErrNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "CALL_NOT_FOUND", Retryable: false}) + case errors.Is(err, call.ErrStateConflict): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_STATE_CONFLICT", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "call operation failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CALL_PERSIST_FAILED", Retryable: true}) + } +} + +func (server *Server) ListContacts(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.contacts == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CONTACTS_UNAVAILABLE", Retryable: true}) + return + } + values, err := server.contacts.List(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "contacts read failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CONTACTS_READ_FAILED", Retryable: true}) + return + } + response := make([]openapi.Contact, 0, len(values)) + for _, value := range values { + response = append(response, contactResponse(value)) + } + writeJSON(w, http.StatusOK, openapi.ContactListResponse{Contacts: response}) +} + +func (server *Server) CreateContact(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + var request openapi.ContactMutationRequest + if server.contacts == nil || decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) + return + } + value, err := server.contacts.Create(r.Context(), request.DisplayName, request.PhoneNumber) + if err != nil { + server.writeContactError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicContacts}, "") + writeJSON(w, http.StatusCreated, contactResponse(value)) +} + +func (server *Server) UpdateContact(w http.ResponseWriter, r *http.Request, contactID string) { + if !server.requireBusinessAPI(w, r) { + return + } + var request openapi.ContactMutationRequest + if server.contacts == nil || decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) + return + } + value, err := server.contacts.Update(r.Context(), contactID, request.DisplayName, request.PhoneNumber) + if err != nil { + server.writeContactError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicContacts}, "") + writeJSON(w, http.StatusOK, contactResponse(value)) +} + +func (server *Server) DeleteContact(w http.ResponseWriter, r *http.Request, contactID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if server.contacts == nil { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CONTACTS_UNAVAILABLE", Retryable: true}) + return + } + if err := server.contacts.Delete(r.Context(), contactID); err != nil { + server.writeContactError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicContacts}, "") + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) writeContactError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, contactapp.ErrInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, contact.ErrNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "CONTACT_NOT_FOUND", Retryable: false}) + case errors.Is(err, contact.ErrPhoneConflict): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CONTACT_PHONE_CONFLICT", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "contact mutation failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CONTACT_PERSIST_FAILED", Retryable: true}) + } +} + +func (server *Server) writeMessageError(w http.ResponseWriter, r *http.Request, err error, operationID, lineID string) { + switch { + case errors.Is(err, messageapp.ErrRequestInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, sms.ErrOperationConflict): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_OPERATION_CONFLICT", Retryable: false}) + case errors.Is(err, messageapp.ErrLineNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_LINE_NOT_FOUND", Retryable: false}) + case errors.Is(err, messageapp.ErrLineUnavailable): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_LINE_UNAVAILABLE", Retryable: true}) + case errors.Is(err, messageapp.ErrLineUnsupported): + writeJSON(w, http.StatusUnprocessableEntity, openapi.ApiError{Code: "MESSAGE_LINE_UNSUPPORTED", Retryable: false}) + case errors.Is(err, messageapp.ErrTransportUnavailable): + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGE_TRANSPORT_UNAVAILABLE", Retryable: true}) + case errors.Is(err, messageapp.ErrTransportAmbiguous): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_TRANSPORT_AMBIGUOUS", Retryable: false}) + case errors.Is(err, messageapp.ErrInventoryUnavailable): + server.logger.ErrorContext(r.Context(), "message inventory unavailable", "operation_id", operationID, "line_id", lineID, "error", err) + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGE_INVENTORY_UNAVAILABLE", Retryable: true}) + default: + server.logger.ErrorContext(r.Context(), "message operation failed", "operation_id", operationID, "line_id", lineID, "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) + } +} + +func smsMessageResponse(message sms.Message) openapi.SMSMessage { + response := openapi.SMSMessage{ + Id: message.ID, OperationId: message.OperationID, Direction: openapi.SMSDirection(message.Direction), + LineId: message.LineID, RemoteAddress: message.RemoteAddress, Body: message.Body, + Status: openapi.SMSStatus(message.Status), ProviderMessageId: message.ProviderMessageID, ErrorCode: message.ErrorCode, + CreatedAt: message.CreatedAt.UTC(), UpdatedAt: message.UpdatedAt.UTC(), + } + if message.SentAt != nil { + sentAt := message.SentAt.UTC() + response.SentAt = &sentAt + } + return response +} diff --git a/internal/api/httpapi/dependencies.go b/internal/api/httpapi/dependencies.go new file mode 100644 index 0000000..ec5019d --- /dev/null +++ b/internal/api/httpapi/dependencies.go @@ -0,0 +1,116 @@ +package httpapi + +import ( + "errors" + "log/slog" + "time" + + "github.com/leonfox28/simplus/internal/application/realtime" +) + +type Dependencies struct { + Health HealthReader + Setup SetupManager + Inventory InventoryReader + Auth Authenticator + Messages Messenger + Contacts ContactManager + Calls CallManager + Euicc EUICCManager + LineEgress LineEgressManager + Vowifi VoWiFiManager + Modems ManagedModemManager + Lines ManagedLineManager + MihomoCore MihomoCoreManager + MihomoSubscriptions MihomoSubscriptionManager + MihomoConfig MihomoConfigManager + MihomoRuntime MihomoRuntimeManager + MihomoDashboard MihomoDashboardManager + Notifications NotificationManager + Realtime RealtimeManager + Logger *slog.Logger +} + +func NewServer(d Dependencies) (*Server, error) { + if d.Health == nil { + return nil, errors.New("HTTP dependency health is required") + } + if d.Setup == nil { + return nil, errors.New("HTTP dependency setup is required") + } + if d.Inventory == nil { + return nil, errors.New("HTTP dependency inventory is required") + } + if d.Auth == nil { + return nil, errors.New("HTTP dependency auth is required") + } + if d.Messages == nil { + return nil, errors.New("HTTP dependency messages is required") + } + if d.Contacts == nil { + return nil, errors.New("HTTP dependency contacts is required") + } + if d.Logger == nil { + d.Logger = slog.Default() + } + if d.Realtime == nil { + d.Realtime = realtime.NewHub() + } + if d.Calls == nil { + d.Calls = disabledCallManager{} + } + if d.Euicc == nil { + d.Euicc = disabledEUICCManager{} + } + if d.LineEgress == nil { + d.LineEgress = disabledLineEgressManager{} + } + if d.Vowifi == nil { + d.Vowifi = disabledVoWiFiManager{} + } + if d.Modems == nil { + d.Modems = disabledManagedModemManager{} + } + if d.Lines == nil { + d.Lines = disabledManagedLineManager{} + } + if d.MihomoCore == nil { + d.MihomoCore = disabledMihomoCoreManager{} + } + if d.MihomoSubscriptions == nil { + d.MihomoSubscriptions = disabledMihomoSubscriptionManager{} + } + if d.MihomoConfig == nil { + d.MihomoConfig = disabledMihomoConfigManager{} + } + if d.MihomoRuntime == nil { + d.MihomoRuntime = disabledMihomoRuntimeManager{} + } + if d.MihomoDashboard == nil { + d.MihomoDashboard = disabledMihomoDashboardManager{} + } + if d.Notifications == nil { + d.Notifications = disabledNotificationManager{} + } + return &Server{ + health: d.Health, + setup: d.Setup, + inventory: d.Inventory, + auth: d.Auth, + messages: d.Messages, + contacts: d.Contacts, + calls: d.Calls, + euicc: d.Euicc, + lineEgress: d.LineEgress, + vowifi: d.Vowifi, + modems: d.Modems, + lines: d.Lines, + mihomoCore: d.MihomoCore, + mihomoSubscriptions: d.MihomoSubscriptions, + mihomoConfig: d.MihomoConfig, + mihomoRuntime: d.MihomoRuntime, + mihomoDashboard: d.MihomoDashboard, + notifications: d.Notifications, + realtime: d.Realtime, + logger: d.Logger, realtimeHeartbeat: 15 * time.Second}, nil +} diff --git a/internal/api/httpapi/devices.go b/internal/api/httpapi/devices.go new file mode 100644 index 0000000..a56c6e6 --- /dev/null +++ b/internal/api/httpapi/devices.go @@ -0,0 +1,375 @@ +package httpapi + +import ( + "errors" + "net/http" + "time" + + "github.com/leonfox28/simplus/internal/api/openapi" + euiccapp "github.com/leonfox28/simplus/internal/application/euicc" + lineapp "github.com/leonfox28/simplus/internal/application/line" + modemapp "github.com/leonfox28/simplus/internal/application/modem" + "github.com/leonfox28/simplus/internal/application/realtime" + linedomain "github.com/leonfox28/simplus/internal/domain/line" + modemdomain "github.com/leonfox28/simplus/internal/domain/modem" +) + +func (server *Server) GetInventory(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + snapshot, err := server.inventory.Snapshot(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "inventory snapshot failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{ + Code: "INVENTORY_SNAPSHOT_UNAVAILABLE", + Retryable: true, + }) + return + } + writeJSON(w, http.StatusOK, inventoryResponse(snapshot)) +} + +func (server *Server) GetEUICCState(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.euicc) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "EUICC_UNAVAILABLE", Retryable: false}) + return + } + state, err := server.euicc.State(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "eUICC state failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "EUICC_READ_FAILED", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, euiccResponse(state)) +} + +func (server *Server) ActivateEUICCProfile(w http.ResponseWriter, r *http.Request, profileID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.euicc) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "EUICC_UNAVAILABLE", Retryable: false}) + return + } + state, err := server.euicc.Switch(r.Context(), profileID) + if err != nil { + switch { + case errors.Is(err, euiccapp.ErrInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "EUICC_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, euiccapp.ErrNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "EUICC_PROFILE_NOT_FOUND", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "eUICC switch failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "EUICC_SWITCH_FAILED", Retryable: true}) + } + return + } + server.publish([]realtime.Topic{realtime.TopicEUICC}, "") + writeJSON(w, http.StatusOK, euiccResponse(state)) +} + +func (server *Server) ListManagedModems(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.modems) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.modems.List(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "managed modem list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_LIST_UNAVAILABLE", Retryable: true}) + return + } + modems := make([]openapi.ManagedModem, 0, len(items)) + for _, item := range items { + modems = append(modems, managedModemResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.ManagedModemList{Modems: modems}) +} + +func (server *Server) ListManagedLines(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.lines) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.lines.List(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "managed line list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_LIST_UNAVAILABLE", Retryable: true}) + return + } + lines := make([]openapi.ManagedLine, 0, len(items)) + for _, item := range items { + lines = append(lines, managedLineResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.ManagedLineList{Lines: lines}) +} + +func (server *Server) ListLineCandidates(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.lines) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.lines.Candidates(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "managed line candidate resolution failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_CANDIDATES_UNAVAILABLE", Retryable: true}) + return + } + candidates := make([]openapi.LineCandidate, 0, len(items)) + for _, item := range items { + candidates = append(candidates, openapi.LineCandidate{ + CandidateId: item.CandidateID, ManagedModemId: item.ManagedModemID, + ManagedModemDisplayName: item.ManagedModemDisplayName, + ManagedModemModel: item.ManagedModemModel, ManagedModemSerialNumber: item.ManagedModemSerialNumber, + SubscriptionDisplayHint: item.SubscriptionDisplayHint, + HomeOperatorName: item.HomeOperatorName, HomeOperatorCode: item.HomeOperatorCode, + SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), + Capabilities: hardwareCapabilitiesResponse(item.Capabilities), Addable: item.Addable, + ReadinessReason: openapi.LineCandidateReadinessReason(item.Readiness), + }) + } + writeJSON(w, http.StatusOK, openapi.LineCandidateList{Candidates: candidates}) +} + +func (server *Server) AddManagedLine(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.lines) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.AddManagedLineRequest + if decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_ADD_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.lines.Add(r.Context(), request.CandidateId, request.DisplayName) + if err != nil { + server.writeManagedLineError(w, r, err, true) + return + } + server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicLines}, "") + w.Header().Set("Location", "/api/v1/lines/"+item.ID) + writeJSON(w, http.StatusCreated, managedLineResponse(item)) +} + +func (server *Server) UpdateManagedLine(w http.ResponseWriter, r *http.Request, lineID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.lines) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.UpdateManagedLineRequest + if decodeJSON(w, r, &request) != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_UPDATE_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.lines.Update(r.Context(), lineID, request.DisplayName) + if err != nil { + server.writeManagedLineError(w, r, err, false) + return + } + server.publish([]realtime.Topic{realtime.TopicLines}, "") + writeJSON(w, http.StatusOK, managedLineResponse(item)) +} + +func (server *Server) writeManagedLineError(w http.ResponseWriter, r *http.Request, err error, adding bool) { + switch { + case errors.Is(err, lineapp.ErrRequestInvalid): + code := "LINE_UPDATE_REQUEST_INVALID" + if adding { + code = "LINE_ADD_REQUEST_INVALID" + } + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: code, Retryable: false}) + case errors.Is(err, lineapp.ErrCandidateNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_CANDIDATE_NOT_FOUND", Retryable: true}) + case errors.Is(err, linedomain.ErrNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_NOT_FOUND", Retryable: false}) + case errors.Is(err, lineapp.ErrCandidateInvalid): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_CANDIDATE_NOT_READY", Retryable: true}) + case errors.Is(err, lineapp.ErrAlreadyManaged): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_ALREADY_ADDED", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "managed line mutation failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_PERSIST_FAILED", Retryable: true}) + } +} + +func (server *Server) ListModemCandidates(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.modems) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.modems.Candidates(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "modem candidate scan failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_SCAN_FAILED", Retryable: true}) + return + } + candidates := make([]openapi.ModemCandidate, 0, len(items)) + for _, item := range items { + candidates = append(candidates, openapi.ModemCandidate{ + CandidateId: item.CandidateID, UsbAddress: item.USBAddress, + VendorId: item.USBVendorID, ProductId: item.USBProductID, UsbSerialHint: item.USBSerialHint, + Model: item.Model, Transport: openapi.DeviceTransport(item.Transport), + SupportStatus: openapi.ModemSupportStatus(item.Support), Addable: item.Addable, + ReadinessReason: openapi.ModemCandidateReadinessReason(item.Readiness), + Capabilities: hardwareCapabilitiesResponse(item.Capabilities), SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), + }) + } + writeJSON(w, http.StatusOK, openapi.ModemCandidateList{Candidates: candidates}) +} + +func (server *Server) AddManagedModem(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.modems) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.AddManagedModemRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_ADD_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.modems.Add(r.Context(), request.CandidateId) + if err != nil { + switch { + case errors.Is(err, modemapp.ErrCandidateInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_ADD_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, modemapp.ErrCandidateNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_CANDIDATE_NOT_FOUND", Retryable: true}) + case errors.Is(err, modemapp.ErrCandidateNotReady): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_CANDIDATE_NOT_READY", Retryable: true}) + case errors.Is(err, modemapp.ErrAlreadyManaged): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_ALREADY_ADDED", Retryable: false}) + case errors.Is(err, modemapp.ErrIdentityConflict): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_CONFLICT", Retryable: false}) + default: + server.logger.ErrorContext(r.Context(), "managed modem add failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_ADD_FAILED", Retryable: true}) + } + return + } + server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicModems, realtime.TopicLines}, "") + w.Header().Set("Location", "/api/v1/modems/"+item.ID) + writeJSON(w, http.StatusCreated, managedModemResponse(item)) +} + +func (server *Server) SetManagedModemRFState(w http.ResponseWriter, r *http.Request, modemID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.modems) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.SetManagedModemRFStateRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_RF_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.modems.SetRFState(r.Context(), modemID, request.Enabled) + if err != nil { + switch { + case errors.Is(err, modemapp.ErrModemNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_NOT_FOUND", Retryable: false}) + case errors.Is(err, modemapp.ErrRFUnavailable): + writeJSON(w, http.StatusUnprocessableEntity, openapi.ApiError{Code: "MODEM_RF_UNAVAILABLE", Retryable: false}) + default: + server.logger.WarnContext(r.Context(), "managed modem RF change failed", "modem_id", modemID, "error", err) + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_RF_CHANGE_FAILED", Retryable: true}) + } + return + } + server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicModems, realtime.TopicLines}, "") + writeJSON(w, http.StatusOK, managedModemResponse(item)) +} + +func (server *Server) ReadManagedModemEquipmentIdentity(w http.ResponseWriter, r *http.Request, modemID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.modems) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) + return + } + imei, err := server.modems.ReadEquipmentIdentity(r.Context(), modemID) + if err != nil { + switch { + case errors.Is(err, modemapp.ErrModemNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_NOT_FOUND", Retryable: false}) + case errors.Is(err, modemapp.ErrIdentityConflict): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_CONFLICT", Retryable: true}) + case errors.Is(err, modemapp.ErrEquipmentIdentityUnavailable): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_UNAVAILABLE", Retryable: true}) + default: + server.logger.WarnContext(r.Context(), "managed modem identity read failed", "modem_id", modemID, "error", err) + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_IDENTITY_UNAVAILABLE", Retryable: true}) + } + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + writeJSON(w, http.StatusOK, openapi.ManagedModemEquipmentIdentity{Imei: imei}) +} + +func managedModemResponse(item modemdomain.View) openapi.ManagedModem { + if item.Cellular.State == "" { + item.Cellular = modemdomain.UnavailableCellularStatus() + } + registrations := make([]openapi.CellularRegistration, 0, len(item.Cellular.Registrations)) + for _, registration := range item.Cellular.Registrations { + registrations = append(registrations, openapi.CellularRegistration{ + Domain: openapi.CellularRegistrationDomain(registration.Domain), State: openapi.CellularRegistrationState(registration.State), + }) + } + observedAt := "" + if !item.Cellular.ObservedAt.IsZero() { + observedAt = item.Cellular.ObservedAt.UTC().Format(time.RFC3339) + } + return openapi.ManagedModem{ + Id: item.ID, DisplayName: item.DisplayName, Model: item.Model, SerialNumber: item.SerialNumber, + Transport: openapi.DeviceTransport(item.Transport), State: openapi.ManagedModemState(item.State), + Capabilities: hardwareCapabilitiesResponse(item.Capabilities), RfState: openapi.ManagedModemRFState(item.RFState), + SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), Cellular: openapi.ManagedModemCellularStatus{ + State: openapi.CellularState(item.Cellular.State), ErrorCode: openapi.CellularErrorCode(item.Cellular.ErrorCode), + Registrations: registrations, OperatorName: item.Cellular.OperatorName, OperatorCode: item.Cellular.OperatorCode, + Rat: item.Cellular.RAT, SignalState: openapi.CellularSignalState(item.Cellular.SignalState), + SignalRssiDbm: item.Cellular.SignalRSSIDBm, ObservedAt: observedAt, + }, AddedAt: item.AddedAt, + } +} + +func (server *Server) GetHardwareTopology(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + topology, err := server.inventory.Topology(r.Context()) + if err != nil { + server.logger.ErrorContext(r.Context(), "hardware topology failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "HARDWARE_TOPOLOGY_UNAVAILABLE", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, hardwareTopologyResponse(topology)) +} diff --git a/internal/api/httpapi/disabled.go b/internal/api/httpapi/disabled.go new file mode 100644 index 0000000..6f3fa9c --- /dev/null +++ b/internal/api/httpapi/disabled.go @@ -0,0 +1,273 @@ +package httpapi + +import ( + "context" + "errors" + + notificationdomain "github.com/leonfox28/simplus/internal/domain/notification" + + callapp "github.com/leonfox28/simplus/internal/application/calls" + lineegressapp "github.com/leonfox28/simplus/internal/application/lineegress" + mihomoapp "github.com/leonfox28/simplus/internal/application/mihomo" + notificationapp "github.com/leonfox28/simplus/internal/application/notification" + "github.com/leonfox28/simplus/internal/domain/call" + domaineuicc "github.com/leonfox28/simplus/internal/domain/euicc" + linedomain "github.com/leonfox28/simplus/internal/domain/line" + mihomodomain "github.com/leonfox28/simplus/internal/domain/mihomo" + modemdomain "github.com/leonfox28/simplus/internal/domain/modem" + vowifidomain "github.com/leonfox28/simplus/internal/domain/vowifi" +) + +var ErrFeatureDisabled = errors.New("feature is disabled for this backend") + +func featureDisabled(value any) bool { + if value == nil { + return true + } + _, ok := value.(interface{ disabledFeature() }) + return ok +} + +type disabledCallManager struct{} + +func (disabledCallManager) disabledFeature() {} +func (disabledCallManager) List(context.Context, int, string) (callapp.PageResult, error) { + var r0 callapp.PageResult + return r0, ErrFeatureDisabled +} +func (disabledCallManager) Dial(context.Context, string, string, string) (call.Record, bool, error) { + var r0 call.Record + var r1 bool + return r0, r1, ErrFeatureDisabled +} +func (disabledCallManager) Incoming(context.Context, string, string, string) (call.Record, bool, error) { + var r0 call.Record + var r1 bool + return r0, r1, ErrFeatureDisabled +} +func (disabledCallManager) Answer(context.Context, string) (call.Record, error) { + var r0 call.Record + return r0, ErrFeatureDisabled +} +func (disabledCallManager) Reject(context.Context, string) (call.Record, error) { + var r0 call.Record + return r0, ErrFeatureDisabled +} +func (disabledCallManager) Hangup(context.Context, string) (call.Record, error) { + var r0 call.Record + return r0, ErrFeatureDisabled +} +func (disabledCallManager) DTMF(context.Context, string, string) (call.Record, error) { + var r0 call.Record + return r0, ErrFeatureDisabled +} + +type disabledEUICCManager struct{} + +func (disabledEUICCManager) disabledFeature() {} +func (disabledEUICCManager) State(context.Context) (domaineuicc.State, error) { + var r0 domaineuicc.State + return r0, ErrFeatureDisabled +} +func (disabledEUICCManager) Switch(context.Context, string) (domaineuicc.State, error) { + var r0 domaineuicc.State + return r0, ErrFeatureDisabled +} + +type disabledLineEgressManager struct{} + +func (disabledLineEgressManager) disabledFeature() {} +func (disabledLineEgressManager) List(context.Context) ([]lineegressapp.View, error) { + var r0 []lineegressapp.View + return r0, ErrFeatureDisabled +} +func (disabledLineEgressManager) Put(context.Context, string, string, string) (lineegressapp.View, error) { + var r0 lineegressapp.View + return r0, ErrFeatureDisabled +} + +type disabledVoWiFiManager struct{} + +func (disabledVoWiFiManager) disabledFeature() {} +func (disabledVoWiFiManager) List(context.Context) ([]vowifidomain.State, error) { + var r0 []vowifidomain.State + return r0, ErrFeatureDisabled +} +func (disabledVoWiFiManager) Activate(context.Context, string) (vowifidomain.State, error) { + var r0 vowifidomain.State + return r0, ErrFeatureDisabled +} +func (disabledVoWiFiManager) Deactivate(context.Context, string) (vowifidomain.State, error) { + var r0 vowifidomain.State + return r0, ErrFeatureDisabled +} + +type disabledManagedModemManager struct{} + +func (disabledManagedModemManager) disabledFeature() {} +func (disabledManagedModemManager) List(context.Context) ([]modemdomain.View, error) { + var r0 []modemdomain.View + return r0, ErrFeatureDisabled +} +func (disabledManagedModemManager) Candidates(context.Context) ([]modemdomain.Candidate, error) { + var r0 []modemdomain.Candidate + return r0, ErrFeatureDisabled +} +func (disabledManagedModemManager) Add(context.Context, string) (modemdomain.View, error) { + var r0 modemdomain.View + return r0, ErrFeatureDisabled +} +func (disabledManagedModemManager) SetRFState(context.Context, string, bool) (modemdomain.View, error) { + var r0 modemdomain.View + return r0, ErrFeatureDisabled +} +func (disabledManagedModemManager) ReadEquipmentIdentity(context.Context, string) (string, error) { + var r0 string + return r0, ErrFeatureDisabled +} + +type disabledManagedLineManager struct{} + +func (disabledManagedLineManager) disabledFeature() {} +func (disabledManagedLineManager) List(context.Context) ([]linedomain.View, error) { + var r0 []linedomain.View + return r0, ErrFeatureDisabled +} +func (disabledManagedLineManager) Candidates(context.Context) ([]linedomain.Candidate, error) { + var r0 []linedomain.Candidate + return r0, ErrFeatureDisabled +} +func (disabledManagedLineManager) Add(context.Context, string, string) (linedomain.View, error) { + var r0 linedomain.View + return r0, ErrFeatureDisabled +} +func (disabledManagedLineManager) Update(context.Context, string, string) (linedomain.View, error) { + var r0 linedomain.View + return r0, ErrFeatureDisabled +} + +type disabledMihomoCoreManager struct{} + +func (disabledMihomoCoreManager) disabledFeature() {} +func (disabledMihomoCoreManager) Status() (mihomoapp.CoreStatus, error) { + var r0 mihomoapp.CoreStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoCoreManager) CheckLatest(context.Context) (mihomoapp.Candidate, error) { + var r0 mihomoapp.Candidate + return r0, ErrFeatureDisabled +} +func (disabledMihomoCoreManager) InstallLatest(context.Context) (mihomoapp.CoreStatus, error) { + var r0 mihomoapp.CoreStatus + return r0, ErrFeatureDisabled +} + +type disabledMihomoSubscriptionManager struct{} + +func (disabledMihomoSubscriptionManager) disabledFeature() {} +func (disabledMihomoSubscriptionManager) List(context.Context) ([]mihomoapp.SubscriptionView, error) { + var r0 []mihomoapp.SubscriptionView + return r0, ErrFeatureDisabled +} +func (disabledMihomoSubscriptionManager) Create(context.Context, string, string, bool) (mihomoapp.SubscriptionView, error) { + var r0 mihomoapp.SubscriptionView + return r0, ErrFeatureDisabled +} +func (disabledMihomoSubscriptionManager) Update(context.Context, string, string, string, bool) (mihomoapp.SubscriptionView, error) { + var r0 mihomoapp.SubscriptionView + return r0, ErrFeatureDisabled +} +func (disabledMihomoSubscriptionManager) Delete(context.Context, string) error { + return ErrFeatureDisabled +} +func (disabledMihomoSubscriptionManager) Refresh(context.Context, string) (mihomoapp.SubscriptionView, []mihomodomain.Node, error) { + var r0 mihomoapp.SubscriptionView + var r1 []mihomodomain.Node + return r0, r1, ErrFeatureDisabled +} +func (disabledMihomoSubscriptionManager) Nodes(context.Context, string) ([]mihomodomain.Node, error) { + var r0 []mihomodomain.Node + return r0, ErrFeatureDisabled +} + +type disabledMihomoConfigManager struct{} + +func (disabledMihomoConfigManager) disabledFeature() {} +func (disabledMihomoConfigManager) Status(context.Context) (mihomoapp.ConfigStatus, error) { + var r0 mihomoapp.ConfigStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoConfigManager) GenerateAndPublish(context.Context) (mihomoapp.ConfigStatus, error) { + var r0 mihomoapp.ConfigStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoConfigManager) Select(context.Context, string) (mihomoapp.ConfigStatus, error) { + var r0 mihomoapp.ConfigStatus + return r0, ErrFeatureDisabled +} + +type disabledMihomoRuntimeManager struct{} + +func (disabledMihomoRuntimeManager) disabledFeature() {} +func (disabledMihomoRuntimeManager) Status(context.Context) (mihomoapp.RuntimeStatus, error) { + var r0 mihomoapp.RuntimeStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoRuntimeManager) Start(context.Context) (mihomoapp.RuntimeStatus, error) { + var r0 mihomoapp.RuntimeStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoRuntimeManager) Restart(context.Context) (mihomoapp.RuntimeStatus, error) { + var r0 mihomoapp.RuntimeStatus + return r0, ErrFeatureDisabled +} +func (disabledMihomoRuntimeManager) Stop(context.Context) (mihomoapp.RuntimeStatus, error) { + var r0 mihomoapp.RuntimeStatus + return r0, ErrFeatureDisabled +} + +type disabledMihomoDashboardManager struct{} + +func (disabledMihomoDashboardManager) disabledFeature() {} +func (disabledMihomoDashboardManager) Ensure() (mihomoapp.DashboardStatus, error) { + var r0 mihomoapp.DashboardStatus + return r0, ErrFeatureDisabled +} + +type disabledNotificationManager struct{} + +func (disabledNotificationManager) disabledFeature() {} +func (disabledNotificationManager) List(context.Context) ([]notificationapp.ChannelView, error) { + var r0 []notificationapp.ChannelView + return r0, ErrFeatureDisabled +} +func (disabledNotificationManager) Create(context.Context, string, string, string, string, bool, []string) (notificationapp.ChannelView, error) { + var r0 notificationapp.ChannelView + return r0, ErrFeatureDisabled +} +func (disabledNotificationManager) Update(context.Context, string, string, string, string, string, bool, []string) (notificationapp.ChannelView, error) { + var r0 notificationapp.ChannelView + return r0, ErrFeatureDisabled +} +func (disabledNotificationManager) Delete(context.Context, string) error { + return ErrFeatureDisabled +} +func (disabledNotificationManager) Test(context.Context, string) (notificationapp.ChannelView, error) { + var r0 notificationapp.ChannelView + return r0, ErrFeatureDisabled +} +func (disabledNotificationManager) Enqueue(context.Context, notificationdomain.Event) error { + return ErrFeatureDisabled +} +func (disabledNotificationManager) FeishuBindingStatus() notificationapp.BindingView { + var r0 notificationapp.BindingView + return r0 +} +func (disabledNotificationManager) StartFeishuBinding(context.Context) (notificationapp.BindingView, error) { + var r0 notificationapp.BindingView + return r0, ErrFeatureDisabled +} +func (disabledNotificationManager) CancelFeishuBinding() (notificationapp.BindingView, error) { + var r0 notificationapp.BindingView + return r0, ErrFeatureDisabled +} diff --git a/internal/api/httpapi/network.go b/internal/api/httpapi/network.go new file mode 100644 index 0000000..7cc1553 --- /dev/null +++ b/internal/api/httpapi/network.go @@ -0,0 +1,480 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/leonfox28/simplus/internal/api/openapi" + lineegressapp "github.com/leonfox28/simplus/internal/application/lineegress" + mihomoapp "github.com/leonfox28/simplus/internal/application/mihomo" + "github.com/leonfox28/simplus/internal/application/realtime" + vowifiapp "github.com/leonfox28/simplus/internal/application/vowifi" +) + +func (server *Server) GetMihomoCoreStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoCore) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoCore.Status() + if err != nil { + server.logger.ErrorContext(r.Context(), "read Mihomo core status failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CORE_STATUS_FAILED", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, mihomoCoreStatusResponse(status)) +} + +func (server *Server) GetMihomoDashboardStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoDashboard) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_DASHBOARD_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoDashboard.Ensure() + if err != nil { + server.logger.ErrorContext(r.Context(), "read Mihomo dashboard status failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_DASHBOARD_STATUS_FAILED", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, openapi.MihomoDashboardStatus{Available: status.Available, Version: status.Version, ControllerAddress: status.ControllerAddress, Url: status.URL, Secret: status.Secret}) +} + +func (server *Server) GetLatestMihomoCore(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoCore) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) + return + } + candidate, err := server.mihomoCore.CheckLatest(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "check official Mihomo release failed", "error", err) + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_RELEASE_CHECK_FAILED", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, openapi.MihomoCoreCandidate{Version: candidate.Version, AssetName: candidate.AssetName, Sha256: candidate.SHA256, Size: candidate.Size, Architecture: openapi.MihomoCoreCandidateArchitecture(candidate.Architecture)}) +} + +func (server *Server) InstallLatestMihomoCore(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoCore) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoCore.InstallLatest(r.Context()) + if errors.Is(err, mihomoapp.ErrVersionAlreadyInstalled) { + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_VERSION_ALREADY_INSTALLED", Retryable: false}) + return + } + if err != nil { + server.logger.WarnContext(r.Context(), "install official Mihomo core failed", "error", err) + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_CORE_INSTALL_FAILED", Retryable: true}) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + writeJSON(w, http.StatusOK, mihomoCoreStatusResponse(status)) +} + +func (server *Server) ListMihomoSubscriptions(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.mihomoSubscriptions.List(r.Context()) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + response := make([]openapi.MihomoSubscription, 0, len(items)) + for _, item := range items { + response = append(response, mihomoSubscriptionResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.MihomoSubscriptionList{Subscriptions: response}) +} + +func (server *Server) CreateMihomoSubscription(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.MihomoSubscriptionCreateRequest + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.mihomoSubscriptions.Create(r.Context(), "", request.Url, true) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + item, _, err = server.mihomoSubscriptions.Refresh(r.Context(), item.ID) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + writeJSON(w, http.StatusCreated, mihomoSubscriptionResponse(item)) +} + +func (server *Server) UpdateMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.MihomoSubscriptionMutation + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.mihomoSubscriptions.Update(r.Context(), subscriptionID, request.DisplayName, request.Url, request.Enabled) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + if strings.TrimSpace(request.Url) != "" { + item, _, err = server.mihomoSubscriptions.Refresh(r.Context(), subscriptionID) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + writeJSON(w, http.StatusOK, mihomoSubscriptionResponse(item)) +} + +func (server *Server) DeleteMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + if err := server.mihomoSubscriptions.Delete(r.Context(), subscriptionID); err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) RefreshMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + item, nodes, err := server.mihomoSubscriptions.Refresh(r.Context(), subscriptionID) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + writeJSON(w, http.StatusOK, openapi.MihomoSubscriptionRefresh{Subscription: mihomoSubscriptionResponse(item), Nodes: mihomoNodeResponses(nodes)}) +} + +func (server *Server) ListMihomoSubscriptionNodes(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoSubscriptions) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) + return + } + nodes, err := server.mihomoSubscriptions.Nodes(r.Context(), subscriptionID) + if err != nil { + server.writeMihomoSubscriptionError(w, r, err) + return + } + writeJSON(w, http.StatusOK, openapi.MihomoNodeList{Nodes: mihomoNodeResponses(nodes)}) +} + +func (server *Server) writeMihomoSubscriptionError(w http.ResponseWriter, r *http.Request, err error) { + var refreshError *mihomoapp.SubscriptionRefreshError + switch { + case errors.Is(err, mihomoapp.ErrSubscriptionInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, mihomoapp.ErrSubscriptionNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_NOT_FOUND", Retryable: false}) + case errors.As(err, &refreshError): + server.logger.WarnContext(r.Context(), "Mihomo subscription refresh failed", "error_code", refreshError.Code) + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REFRESH_FAILED", Retryable: true}) + default: + server.logger.WarnContext(r.Context(), "Mihomo subscription operation failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_PERSIST_FAILED", Retryable: true}) + } +} + +func (server *Server) ListLineEgressBindings(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.lineEgress) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_EGRESS_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.lineEgress.List(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "line egress list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_EGRESS_READ_FAILED", Retryable: true}) + return + } + bindings := make([]openapi.LineEgressBinding, 0, len(items)) + for _, item := range items { + bindings = append(bindings, lineEgressResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.LineEgressBindingList{Bindings: bindings}) +} + +func (server *Server) PutLineEgressBinding(w http.ResponseWriter, r *http.Request, lineID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.lineEgress) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_EGRESS_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.LineEgressBindingMutation + if err := decodeJSON(w, r, &request); err != nil || !request.Mode.Valid() { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_EGRESS_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.lineEgress.Put(r.Context(), lineID, string(request.Mode), request.CountryCode) + if err != nil { + switch { + case errors.Is(err, lineegressapp.ErrInvalidBinding): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_EGRESS_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, lineegressapp.ErrLineNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_NOT_FOUND", Retryable: false}) + case errors.Is(err, lineegressapp.ErrLineUnsupported): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_VOWIFI_UNSUPPORTED", Retryable: false}) + default: + server.logger.WarnContext(r.Context(), "line egress update failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_EGRESS_PERSIST_FAILED", Retryable: true}) + } + return + } + server.publish([]realtime.Topic{realtime.TopicLines, realtime.TopicMihomo, realtime.TopicVoWiFi}, "") + writeJSON(w, http.StatusOK, lineEgressResponse(item)) +} + +func lineEgressResponse(item lineegressapp.View) openapi.LineEgressBinding { + return openapi.LineEgressBinding{ + LineId: item.LineID, Mode: openapi.LineEgressBindingMode(item.Mode), CountryCode: item.CountryCode, + CountryName: item.CountryName, ListenerPort: item.ListenerPort, Ready: item.Ready, + ReadinessReason: openapi.LineEgressBindingReadinessReason(item.ReadinessReason), + } +} + +func (server *Server) ListVoWiFiLines(w http.ResponseWriter, r *http.Request) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.vowifi) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.vowifi.List(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "Host VoWiFi state list failed", "error", err) + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_STATUS_UNAVAILABLE", Retryable: true}) + return + } + lines := make([]openapi.VoWiFiLineState, 0, len(items)) + for _, item := range items { + lines = append(lines, voWiFiStateResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.VoWiFiLineStateList{Lines: lines}) +} + +func (server *Server) ActivateVoWiFiLine(w http.ResponseWriter, r *http.Request, lineID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.vowifi) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) + return + } + state, err := server.vowifi.Activate(r.Context(), lineID) + if err != nil { + server.writeVoWiFiError(w, r, err, lineID, true) + return + } + server.publish([]realtime.Topic{realtime.TopicVoWiFi}, "") + writeJSON(w, http.StatusAccepted, voWiFiStateResponse(state)) +} + +func (server *Server) DeactivateVoWiFiLine(w http.ResponseWriter, r *http.Request, lineID string) { + if !server.requireBusinessAPI(w, r) { + return + } + if featureDisabled(server.vowifi) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) + return + } + state, err := server.vowifi.Deactivate(r.Context(), lineID) + if err != nil { + server.writeVoWiFiError(w, r, err, lineID, false) + return + } + server.publish([]realtime.Topic{realtime.TopicVoWiFi}, "") + writeJSON(w, http.StatusOK, voWiFiStateResponse(state)) +} + +func (server *Server) writeVoWiFiError(w http.ResponseWriter, r *http.Request, err error, lineID string, activating bool) { + switch { + case errors.Is(err, vowifiapp.ErrLineNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "VOWIFI_LINE_NOT_FOUND", Retryable: false}) + case errors.Is(err, vowifiapp.ErrLineNotReady): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "VOWIFI_LINE_NOT_READY", Retryable: true}) + default: + action := "deactivation" + if activating { + action = "activation" + } + server.logger.WarnContext(r.Context(), "Host VoWiFi "+action+" failed", "line_id", lineID, "error", err) + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_RUNTIME_FAILED", Retryable: true}) + } +} + +func (server *Server) GetMihomoConfigStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.mihomoConfig) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoConfig.Status(r.Context()) + if err != nil { + server.logger.WarnContext(r.Context(), "Mihomo config status failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CONFIG_STATUS_FAILED", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) +} + +func (server *Server) PublishMihomoConfig(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoConfig) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoConfig.GenerateAndPublish(r.Context()) + if errors.Is(err, mihomoapp.ErrConfigNotReady) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_CONFIG_NOT_READY", Retryable: false}) + return + } + if errors.Is(err, mihomoapp.ErrConfigValidationFailed) { + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_CONFIG_VALIDATION_FAILED", Retryable: false}) + return + } + if err != nil { + server.logger.WarnContext(r.Context(), "Mihomo config publish failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CONFIG_PUBLISH_FAILED", Retryable: true}) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo}, "") + writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) +} + +func (server *Server) SelectMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.mihomoConfig) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) + return + } + status, err := server.mihomoConfig.Select(r.Context(), subscriptionID) + if errors.Is(err, mihomoapp.ErrConfigNotReady) { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_ARTIFACT_NOT_READY", Retryable: false}) + return + } + if errors.Is(err, mihomoapp.ErrConfigValidationFailed) { + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_CONFIG_VALIDATION_FAILED", Retryable: false}) + return + } + if err != nil { + server.logger.WarnContext(r.Context(), "Mihomo subscription selection failed", "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_SELECT_FAILED", Retryable: true}) + return + } + server.publish([]realtime.Topic{realtime.TopicMihomo, realtime.TopicVoWiFi}, "") + writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) +} + +func (server *Server) GetMihomoRuntimeStatus(w http.ResponseWriter, r *http.Request) { + server.handleMihomoRuntime(w, r, "status") +} + +func (server *Server) StartMihomo(w http.ResponseWriter, r *http.Request) { + server.handleMihomoRuntime(w, r, "start") +} + +func (server *Server) RestartMihomo(w http.ResponseWriter, r *http.Request) { + server.handleMihomoRuntime(w, r, "restart") +} + +func (server *Server) StopMihomo(w http.ResponseWriter, r *http.Request) { + server.handleMihomoRuntime(w, r, "stop") +} + +func (server *Server) handleMihomoRuntime(w http.ResponseWriter, r *http.Request, action string) { + mutation := action != "status" + if _, ok := server.requireAdministrator(w, r, mutation); !ok { + return + } + if featureDisabled(server.mihomoRuntime) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_RUNTIME_UNAVAILABLE", Retryable: true}) + return + } + var status mihomoapp.RuntimeStatus + var err error + switch action { + case "status": + status, err = server.mihomoRuntime.Status(r.Context()) + case "start": + status, err = server.mihomoRuntime.Start(r.Context()) + case "restart": + status, err = server.mihomoRuntime.Restart(r.Context()) + case "stop": + status, err = server.mihomoRuntime.Stop(r.Context()) + } + if errors.Is(err, mihomoapp.ErrConfigNotReady) || errors.Is(err, mihomoapp.ErrConfigValidationFailed) || errors.Is(err, mihomoapp.ErrRuntimeAlreadyRunning) || errors.Is(err, mihomoapp.ErrRuntimeNotRunning) || errors.Is(err, mihomoapp.ErrRuntimeStartupFailed) { + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_RUNTIME_STATE_CONFLICT", Retryable: false}) + return + } + if err != nil { + server.logger.WarnContext(r.Context(), "Mihomo runtime operation failed", "action", action, "error", err) + writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_RUNTIME_OPERATION_FAILED", Retryable: true}) + return + } + if mutation { + server.publish([]realtime.Topic{realtime.TopicMihomo, realtime.TopicVoWiFi}, "") + } + writeJSON(w, http.StatusOK, mihomoRuntimeResponse(status)) +} diff --git a/internal/api/httpapi/notifications.go b/internal/api/httpapi/notifications.go new file mode 100644 index 0000000..d3838cc --- /dev/null +++ b/internal/api/httpapi/notifications.go @@ -0,0 +1,197 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "time" + + notificationdomain "github.com/leonfox28/simplus/internal/domain/notification" + + "github.com/leonfox28/simplus/internal/api/openapi" + notificationapp "github.com/leonfox28/simplus/internal/application/notification" + "github.com/leonfox28/simplus/internal/application/realtime" +) + +func (server *Server) ListNotificationChannels(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) + return + } + items, err := server.notifications.List(r.Context()) + if err != nil { + server.writeNotificationError(w, r, err) + return + } + response := make([]openapi.NotificationChannel, 0, len(items)) + for _, item := range items { + response = append(response, notificationChannelResponse(item)) + } + writeJSON(w, http.StatusOK, openapi.NotificationChannelList{Channels: response}) +} + +func (server *Server) CreateNotificationChannel(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.NotificationChannelMutation + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.notifications.Create(r.Context(), string(request.Provider), request.DisplayName, request.WebhookUrl, request.SigningSecret, request.Enabled, notificationEventStrings(request.EventKinds)) + if err != nil { + server.writeNotificationError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicNotifications}, "") + writeJSON(w, http.StatusCreated, notificationChannelResponse(item)) +} + +func (server *Server) GetFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, false); !ok { + return + } + w.Header().Set("Cache-Control", "no-store") + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) + return + } + writeJSON(w, http.StatusOK, feishuBindingResponse(server.notifications.FeishuBindingStatus())) +} + +func (server *Server) StartFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + w.Header().Set("Cache-Control", "no-store") + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) + return + } + state, err := server.notifications.StartFeishuBinding(r.Context()) + if err != nil { + server.writeFeishuBindingError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, feishuBindingResponse(state)) +} + +func (server *Server) CancelFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + w.Header().Set("Cache-Control", "no-store") + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) + return + } + state, err := server.notifications.CancelFeishuBinding() + if err != nil { + server.writeFeishuBindingError(w, r, err) + return + } + writeJSON(w, http.StatusOK, feishuBindingResponse(state)) +} + +func (server *Server) writeFeishuBindingError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, notificationapp.ErrBindingActive): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "FEISHU_BINDING_ACTIVE", Retryable: false}) + case errors.Is(err, notificationapp.ErrBindingNotCancelable): + writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "FEISHU_BINDING_NOT_CANCELLABLE", Retryable: false}) + case errors.Is(err, notificationapp.ErrBindingUnavailable): + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) + case errors.Is(err, notificationapp.ErrFeishuProviderResultInvalid): + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: notificationapp.BindingErrorResultInvalid, Retryable: false}) + default: + server.logger.WarnContext(r.Context(), "Feishu binding start failed") + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: notificationapp.BindingErrorProviderFailed, Retryable: true}) + } +} + +func (server *Server) UpdateNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) + return + } + var request openapi.NotificationChannelMutation + if err := decodeJSON(w, r, &request); err != nil { + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) + return + } + item, err := server.notifications.Update(r.Context(), channelID, string(request.Provider), request.DisplayName, request.WebhookUrl, request.SigningSecret, request.Enabled, notificationEventStrings(request.EventKinds)) + if err != nil { + server.writeNotificationError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicNotifications}, "") + writeJSON(w, http.StatusOK, notificationChannelResponse(item)) +} + +func (server *Server) DeleteNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) + return + } + if err := server.notifications.Delete(r.Context(), channelID); err != nil { + server.writeNotificationError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicNotifications}, "") + w.WriteHeader(http.StatusNoContent) +} + +func (server *Server) TestNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { + if _, ok := server.requireAdministrator(w, r, true); !ok { + return + } + if featureDisabled(server.notifications) { + writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) + return + } + item, err := server.notifications.Test(r.Context(), channelID) + if err != nil { + server.writeNotificationError(w, r, err) + return + } + server.publish([]realtime.Topic{realtime.TopicNotifications}, "") + writeJSON(w, http.StatusOK, notificationChannelResponse(item)) +} + +func (server *Server) writeNotificationError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, notificationapp.ErrChannelInvalid): + writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) + case errors.Is(err, notificationapp.ErrChannelNotFound): + writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_NOT_FOUND", Retryable: false}) + default: + server.logger.WarnContext(r.Context(), "notification channel operation failed", "error", err) + writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "NOTIFICATION_DELIVERY_FAILED", Retryable: true}) + } +} + +func (server *Server) enqueueNotice(ctx context.Context, event, objectID, message string) { + if server == nil || featureDisabled(server.notifications) { + return + } + enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := server.notifications.Enqueue(enqueueCtx, notificationdomain.Event{Key: event + ":" + objectID, Kind: event, ObjectID: objectID, Message: message, ObservedAt: time.Now().UTC()}); err != nil { + server.logger.Warn("notification enqueue failed", "event", event, "error", err) + } + server.publish([]realtime.Topic{realtime.TopicNotifications}, "") +} diff --git a/internal/api/httpapi/server.go b/internal/api/httpapi/server.go index c45eab2..45f3b32 100644 --- a/internal/api/httpapi/server.go +++ b/internal/api/httpapi/server.go @@ -10,31 +10,27 @@ import ( "log/slog" "net" "net/http" - "reflect" "runtime/debug" "strconv" "strings" "sync" "time" + notificationdomain "github.com/leonfox28/simplus/internal/domain/notification" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/leonfox28/simplus/internal/api/openapi" authapp "github.com/leonfox28/simplus/internal/application/auth" callapp "github.com/leonfox28/simplus/internal/application/calls" - contactapp "github.com/leonfox28/simplus/internal/application/contacts" - euiccapp "github.com/leonfox28/simplus/internal/application/euicc" "github.com/leonfox28/simplus/internal/application/health" "github.com/leonfox28/simplus/internal/application/inventory" - lineapp "github.com/leonfox28/simplus/internal/application/line" lineegressapp "github.com/leonfox28/simplus/internal/application/lineegress" messageapp "github.com/leonfox28/simplus/internal/application/messaging" mihomoapp "github.com/leonfox28/simplus/internal/application/mihomo" - modemapp "github.com/leonfox28/simplus/internal/application/modem" notificationapp "github.com/leonfox28/simplus/internal/application/notification" "github.com/leonfox28/simplus/internal/application/realtime" setupapp "github.com/leonfox28/simplus/internal/application/setup" - vowifiapp "github.com/leonfox28/simplus/internal/application/vowifi" "github.com/leonfox28/simplus/internal/domain/call" "github.com/leonfox28/simplus/internal/domain/contact" domaineuicc "github.com/leonfox28/simplus/internal/domain/euicc" @@ -42,13 +38,10 @@ import ( linedomain "github.com/leonfox28/simplus/internal/domain/line" mihomodomain "github.com/leonfox28/simplus/internal/domain/mihomo" modemdomain "github.com/leonfox28/simplus/internal/domain/modem" - "github.com/leonfox28/simplus/internal/domain/pagination" - "github.com/leonfox28/simplus/internal/domain/sms" vowifidomain "github.com/leonfox28/simplus/internal/domain/vowifi" ) const ( - setupSessionCookieName = "simplus_setup_session" adminSessionCookieName = "simplus_admin_session" csrfCookieName = "simplus_csrf" csrfHeaderName = "X-Simplus-CSRF" @@ -101,7 +94,7 @@ type NotificationManager interface { Update(context.Context, string, string, string, string, string, bool, []string) (notificationapp.ChannelView, error) Delete(context.Context, string) error Test(context.Context, string) (notificationapp.ChannelView, error) - Notify(context.Context, string, string) error + Enqueue(context.Context, notificationdomain.Event) error FeishuBindingStatus() notificationapp.BindingView StartFeishuBinding(context.Context) (notificationapp.BindingView, error) CancelFeishuBinding() (notificationapp.BindingView, error) @@ -167,16 +160,6 @@ type HealthReader interface { type SetupManager interface { Status(context.Context) (setupapp.Status, error) - ConsumeBootstrap(context.Context, string) (setupapp.SessionGrant, error) - ReadSession(context.Context, string) (setupapp.Session, error) - ConfigureAdministrator(context.Context, string, setupapp.AdministratorInput) (setupapp.Session, error) - ConfigureStorage(context.Context, string, setupapp.StorageInput) (setupapp.Session, error) - ConfigureHTTPS(context.Context, string, setupapp.HTTPSInput) (setupapp.Session, error) - ConfirmHTTPS(context.Context, string, string) (setupapp.Session, error) - ReadRootCertificate(context.Context, string) ([]byte, string, error) - ConfirmHardwareReview(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Session, error) - Complete(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Completion, error) - BeginAdministratorSetup(context.Context) (setupapp.SessionGrant, error) } type InventoryReader interface { @@ -190,6 +173,7 @@ type RealtimeManager interface { } type Server struct { + work sync.WaitGroup health HealthReader setup SetupManager inventory InventoryReader @@ -213,129 +197,8 @@ type Server struct { logger *slog.Logger } -func WithMihomoCore(server *Server, manager MihomoCoreManager) *Server { - if server != nil { - server.mihomoCore = manager - } - return server -} - -func WithMihomoSubscriptions(server *Server, manager MihomoSubscriptionManager) *Server { - if server != nil { - server.mihomoSubscriptions = manager - } - return server -} - -func WithLineEgress(server *Server, manager LineEgressManager) *Server { - if server != nil { - server.lineEgress = manager - } - return server -} - -func WithVoWiFi(server *Server, manager VoWiFiManager) *Server { - if server != nil { - server.vowifi = manager - } - return server -} - -func WithManagedModems(server *Server, manager ManagedModemManager) *Server { - if server != nil { - server.modems = manager - } - return server -} - -func WithManagedLines(server *Server, manager ManagedLineManager) *Server { - if server != nil { - server.lines = manager - } - return server -} - -func WithMihomoConfig(server *Server, manager MihomoConfigManager) *Server { - if server != nil { - server.mihomoConfig = manager - } - return server -} -func WithMihomoRuntime(server *Server, manager MihomoRuntimeManager) *Server { - if server != nil { - server.mihomoRuntime = manager - } - return server -} -func WithMihomoDashboard(server *Server, manager MihomoDashboardManager) *Server { - if server != nil { - server.mihomoDashboard = manager - } - return server -} -func WithNotifications(server *Server, manager NotificationManager) *Server { - if server != nil { - server.notifications = manager - } - return server -} - -func WithRealtime(server *Server, manager RealtimeManager) *Server { - if server != nil { - server.realtime = manager - } - return server -} - -func WithCalls(server *Server, calls CallManager) *Server { - if server != nil { - server.calls = calls - } - return server -} - -func WithEUICC(server *Server, manager EUICCManager) *Server { - if server != nil { - server.euicc = manager - } - return server -} -func New( - healthService HealthReader, - setupService SetupManager, - inventoryService InventoryReader, - logger *slog.Logger, - authentication Authenticator, - messages Messenger, - contactManagers ...ContactManager, -) *Server { - if logger == nil { - logger = slog.Default() - } - server := &Server{ - health: healthService, setup: setupService, inventory: inventoryService, - auth: authentication, messages: messages, logger: logger, realtimeHeartbeat: 15 * time.Second, - } - if len(contactManagers) != 0 { - server.contacts = contactManagers[0] - } - return server -} - // httpDependencyMissing lets required ports retain their nil-receiver error // behavior while optional Setup/realtime checks treat typed nil as absent. -func httpDependencyMissing(dependency any) bool { - if dependency == nil { - return true - } - value := reflect.ValueOf(dependency) - switch value.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return value.IsNil() - default: - return false - } -} func Router(server *Server) http.Handler { router := chi.NewRouter() @@ -343,6 +206,13 @@ func Router(server *Server) http.Handler { router.Use(trustedLANHostOnly) router.Use(middleware.RequestID) router.Use(recoverJSON(server.logger)) + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.work.Add(1) + defer server.work.Done() + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestWorkKey{}, &server.work))) + }) + }) router.Use(apiTimeout) router.NotFound(func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "API_ROUTE_NOT_FOUND", Retryable: false}) @@ -417,7 +287,7 @@ func (server *Server) StreamEvents(w http.ResponseWriter, r *http.Request) { if !authorized { return } - if httpDependencyMissing(server.realtime) { + if featureDisabled(server.realtime) { writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "EVENT_STREAM_UNAVAILABLE", Retryable: true}) return } @@ -472,7 +342,7 @@ func (server *Server) StreamEvents(w http.ResponseWriter, r *http.Request) { } func (server *Server) realtimeSessionValid(ctx context.Context, token string) bool { - if server == nil || httpDependencyMissing(server.setup) || server.auth == nil { + if server == nil || featureDisabled(server.setup) || server.auth == nil { return false } checkCtx, cancel := context.WithTimeout(ctx, realtimeAuthTimeout) @@ -526,7 +396,7 @@ func writeRealtimeEvent(w io.Writer, event realtime.Event) error { } func (server *Server) publish(topics []realtime.Topic, attention realtime.Attention) { - if server != nil && !httpDependencyMissing(server.realtime) { + if server != nil && !featureDisabled(server.realtime) { server.realtime.Publish(topics, attention) } } @@ -594,6 +464,11 @@ func (response *bufferedResponse) commit(target http.ResponseWriter) { _, _ = target.Write(response.body.Bytes()) } +type requestWorkKey struct{} + +// Wait is called after admission stops; it also waits for timed-out handlers. +func (server *Server) Wait() { server.work.Wait() } + func timeoutJSON(timeout time.Duration) func(http.Handler) http.Handler { type completion struct { panicValue any @@ -605,7 +480,14 @@ func timeoutJSON(timeout time.Duration) func(http.Handler) http.Handler { defer cancel() buffer := newBufferedResponse() completed := make(chan completion, 1) + work, _ := r.Context().Value(requestWorkKey{}).(*sync.WaitGroup) + if work != nil { + work.Add(1) + } go func() { + if work != nil { + defer work.Done() + } result := completion{} defer func() { result.panicValue = recover() @@ -709,355 +591,6 @@ func securityHeaders(next http.Handler) http.Handler { }) } -func (server *Server) GetSetupStatus(w http.ResponseWriter, r *http.Request) { - status, err := server.setup.Status(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "setup status failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{ - Code: "SETUP_STATUS_UNAVAILABLE", - Retryable: true, - }) - return - } - flows := make([]openapi.SetupFlow, 0, len(status.SupportedFlows)) - for _, flow := range status.SupportedFlows { - flows = append(flows, openapi.SetupFlow(flow)) - } - writeJSON(w, http.StatusOK, openapi.SetupStatusResponse{ - InstallationState: openapi.InstallationState(status.InstallationState), - Phase: openapi.SetupPhase(status.Phase), - SetupRequired: status.SetupRequired, - BusinessApiAvailable: status.BusinessAPIAvailable, - BootstrapGenerationAvailable: status.BootstrapGenerationAvailable, - SupportedFlows: flows, - }) -} - -func (server *Server) ConsumeSetupBootstrap(w http.ResponseWriter, r *http.Request) { - var request openapi.ConsumeBootstrapRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "BOOTSTRAP_REQUEST_INVALID", Retryable: false}) - return - } - grant, err := server.setup.ConsumeBootstrap(r.Context(), request.BootstrapCode) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "bootstrap exchange failed") - return - } - http.SetCookie(w, &http.Cookie{ - Name: setupSessionCookieName, - Value: grant.Token, - Path: "/api/v1/setup", - Expires: grant.ExpiresAt, - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteStrictMode, - }) - writeJSON(w, http.StatusOK, setupSessionResponse(grant.Session)) -} - -func (server *Server) GetSetupSession(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - session, err := server.setup.ReadSession(r.Context(), token) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup session read failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) PutSetupAdministrator(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - var request openapi.ConfigureSetupAdministratorRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_ADMINISTRATOR_REQUEST_INVALID", Retryable: false}) - return - } - session, err := server.setup.ConfigureAdministrator(r.Context(), token, setupapp.AdministratorInput{ - Username: request.Username, - Password: request.Password, - PasswordConfirmation: request.PasswordConfirmation, - InstanceDefaultLocale: string(request.InstanceDefaultLocale), - }) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup administrator configuration failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) PutSetupStorage(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - var request openapi.ConfigureSetupStorageRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_STORAGE_REQUEST_INVALID", Retryable: false}) - return - } - session, err := server.setup.ConfigureStorage(r.Context(), token, setupapp.StorageInput{RecordingsRoot: request.RecordingsRoot}) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup storage configuration failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) PutSetupHTTPS(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - var request openapi.ConfigureSetupHTTPSRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_HTTPS_REQUEST_INVALID", Retryable: false}) - return - } - session, err := server.setup.ConfigureHTTPS(r.Context(), token, setupapp.HTTPSInput{ - Mode: string(request.Mode), - ListenHost: request.ListenHost, - ListenPort: request.ListenPort, - SubjectAlternativeNames: request.SubjectAlternativeNames, - }) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup HTTPS configuration failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) ConfirmSetupHTTPS(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - var request openapi.ConfirmSetupHTTPSRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_HTTPS_CONFIRMATION_INVALID", Retryable: false}) - return - } - session, err := server.setup.ConfirmHTTPS(r.Context(), token, request.RootFingerprintSha256) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup HTTPS confirmation failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) GetSetupHTTPSRootCertificate(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - certificate, fingerprint, err := server.setup.ReadRootCertificate(r.Context(), token) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup root certificate read failed") - return - } - writeJSON(w, http.StatusOK, openapi.SetupRootCertificateResponse{ - Pem: string(certificate), - RootFingerprintSha256: fingerprint, - }) -} - -func (server *Server) GetSetupInventory(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireSetupHardwareStage(w, r); !ok { - return - } - snapshot, err := server.inventory.Snapshot(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "setup inventory snapshot failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_INVENTORY_UNAVAILABLE", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, inventoryResponse(snapshot)) -} - -func (server *Server) GetSetupHardwareTopology(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireSetupHardwareStage(w, r); !ok { - return - } - topology, err := server.inventory.Topology(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "setup hardware topology failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_INVENTORY_UNAVAILABLE", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, hardwareTopologyResponse(topology)) -} - -func (server *Server) ConfirmSetupHardware(w http.ResponseWriter, r *http.Request) { - token, ok := server.requireSetupHardwareStage(w, r) - if !ok { - return - } - topology, err := server.inventory.Topology(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "setup hardware confirmation topology failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_INVENTORY_UNAVAILABLE", Retryable: true}) - return - } - hardwareInput, err := setupHardwareReviewInput(topology) - if err != nil { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "SETUP_HARDWARE_REVIEW_INVALID", Retryable: false}) - return - } - session, err := server.setup.ConfirmHardwareReview(r.Context(), token, hardwareInput) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup hardware confirmation failed") - return - } - writeJSON(w, http.StatusOK, setupSessionResponse(session)) -} - -func (server *Server) CompleteSetup(w http.ResponseWriter, r *http.Request) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return - } - _, err := server.setup.Complete(r.Context(), token, setupapp.HardwareReviewInput{}) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup completion failed") - return - } - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusOK, openapi.SetupCompletionResponse{ - InstallationState: openapi.SetupCompletionResponseInstallationStateReady, - ManagementUrl: requestManagementURL(r), - LoginRequired: openapi.SetupCompletionResponseLoginRequired(true), - }) -} - -func requestManagementURL(r *http.Request) string { - scheme := "http" - if r != nil && r.TLS != nil { - scheme = "https" - } - if r == nil { - return scheme + "://127.0.0.1" - } - return scheme + "://" + r.Host -} - -func (server *Server) requireSetupHardwareStage(w http.ResponseWriter, r *http.Request) (string, bool) { - token, ok := setupSessionToken(r) - if !ok { - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - return "", false - } - session, err := server.setup.ReadSession(r.Context(), token) - if err != nil { - server.writeSetupAuthorizationError(w, r, err, "setup hardware authorization failed") - return "", false - } - if !session.HTTPSConfigured || !session.HTTPSConfirmed { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "SETUP_PREREQUISITE_INCOMPLETE", Retryable: false}) - return "", false - } - return token, true -} - -func (server *Server) writeSetupAuthorizationError(w http.ResponseWriter, r *http.Request, err error, logMessage string) { - switch { - case errors.Is(err, setupapp.ErrBootstrapInvalidOrExpired): - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "BOOTSTRAP_INVALID_OR_EXPIRED", Retryable: false}) - case errors.Is(err, setupapp.ErrSetupSessionUnauthorized): - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "SETUP_SESSION_UNAUTHORIZED", Retryable: false}) - case errors.Is(err, setupapp.ErrSetupUnavailable): - clearSetupSessionCookie(w, r) - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "SETUP_UNAVAILABLE", Retryable: false}) - case errors.Is(err, setupapp.ErrAdministratorRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_ADMINISTRATOR_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, setupapp.ErrStorageRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_STORAGE_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, setupapp.ErrSetupPrerequisiteMissing): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "SETUP_PREREQUISITE_INCOMPLETE", Retryable: false}) - case errors.Is(err, setupapp.ErrHTTPSRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_HTTPS_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, setupapp.ErrHTTPSConfirmationInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_HTTPS_CONFIRMATION_INVALID", Retryable: false}) - case errors.Is(err, setupapp.ErrHardwareReviewInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_HARDWARE_REVIEW_INVALID", Retryable: false}) - case errors.Is(err, setupapp.ErrSetupPreflightFailed): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "SETUP_PREFLIGHT_FAILED", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), logMessage, "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_AUTHORIZATION_FAILED", Retryable: true}) - } -} - -func setupSessionResponse(session setupapp.Session) openapi.SetupSessionResponse { - leafNotAfter := "" - if !session.HTTPSLeafNotAfter.IsZero() { - leafNotAfter = session.HTTPSLeafNotAfter.UTC().Format(time.RFC3339) - } - return openapi.SetupSessionResponse{ - Authorized: openapi.SetupSessionResponseAuthorized(true), - ExpiresAt: session.ExpiresAt, - SelectedFlow: openapi.CreateNew, - SupportedFlows: []openapi.SetupFlow{openapi.CreateNew}, - AdministratorConfigured: session.AdministratorConfigured, - AdministratorUsername: session.AdministratorUsername, - InstanceDefaultLocale: openapi.SetupSessionResponseInstanceDefaultLocale(session.InstanceDefaultLocale), - StorageConfigured: session.StorageConfigured, - DataRoot: session.DataRoot, - RecordingsRoot: session.RecordingsRoot, - HttpsConfigured: session.HTTPSConfigured, - HttpsConfirmed: session.HTTPSConfirmed, - HttpsMode: openapi.SetupSessionResponseHttpsMode(session.HTTPSMode), - HttpsListenUrl: session.HTTPSListenURL, - HttpsRootFingerprint: session.HTTPSRootFingerprint, - HttpsLeafNotAfter: leafNotAfter, - HardwareReviewed: session.HardwareReviewed, - HardwareDeviceCount: session.HardwareDeviceCount, - HardwareLineCount: session.HardwareLineCount, - HardwareInventoryDigest: session.HardwareInventoryDigest, - } -} - -func setupSessionToken(r *http.Request) (string, bool) { - cookie, err := r.Cookie(setupSessionCookieName) - if err != nil || cookie.Value == "" { - return "", false - } - return cookie.Value, true -} - -func clearSetupSessionCookie(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: setupSessionCookieName, - Path: "/api/v1/setup", - MaxAge: -1, - Expires: time.Unix(1, 0).UTC(), - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteStrictMode, - }) -} - func (server *Server) requireBusinessAPI(w http.ResponseWriter, r *http.Request) bool { status, err := server.setup.Status(r.Context()) if err != nil { @@ -1124,1201 +657,88 @@ func authSessionResponse(user authapp.User, expiresAt time.Time) openapi.AuthSes } } -func (server *Server) requireAdministrator(w http.ResponseWriter, r *http.Request, requireCSRF bool) (authapp.Session, bool) { - if server.auth == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - return authapp.Session{}, false +func mihomoCoreStatusResponse(status mihomoapp.CoreStatus) openapi.MihomoCoreStatus { + installedAt := "" + if !status.InstalledAt.IsZero() { + installedAt = status.InstalledAt.UTC().Format(time.RFC3339) } - token, csrfToken, ok := administratorTokens(r, requireCSRF) - if !ok { - if sessionCookie, err := r.Cookie(adminSessionCookieName); requireCSRF && err == nil && sessionCookie.Value != "" { - writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) - } else { - clearAdministratorCookies(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) - } - return authapp.Session{}, false + return openapi.MihomoCoreStatus{Installed: status.Installed, Version: status.Version, Architecture: openapi.MihomoCoreStatusArchitecture(status.Architecture), Sha256: status.SHA256, InstalledAt: installedAt} +} + +func mihomoSubscriptionResponse(item mihomoapp.SubscriptionView) openapi.MihomoSubscription { + refreshAt := "" + if !item.LastRefreshAt.IsZero() { + refreshAt = item.LastRefreshAt.UTC().Format(time.RFC3339) } - session, err := server.auth.Authenticate(r.Context(), token, csrfToken, requireCSRF) - if err != nil { - server.writeAuthenticationError(w, err, "administrator session validation failed") - return authapp.Session{}, false + return openapi.MihomoSubscription{Id: item.ID, DisplayName: item.DisplayName, Url: item.URL, UrlHint: item.URLHint, Enabled: item.Enabled, Selected: item.Selected, ArtifactReady: item.ArtifactReady, LastRefreshAt: refreshAt, LastRefreshStatus: openapi.MihomoSubscriptionLastRefreshStatus(item.LastRefreshStatus), NodeCount: item.NodeCount, LastErrorCode: item.LastErrorCode} +} +func mihomoNodeResponses(nodes []mihomodomain.Node) []openapi.MihomoNode { + result := make([]openapi.MihomoNode, 0, len(nodes)) + for _, node := range nodes { + result = append(result, openapi.MihomoNode{Id: node.ID, DisplayName: node.DisplayName, Kind: node.Kind, CountryCode: node.CountryCode, CountryName: node.CountryName}) } - return session, true + return result } -func (server *Server) writeAuthenticationError(w http.ResponseWriter, err error, message string) { - switch { - case errors.Is(err, authapp.ErrInvalidCredentials): - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "LOGIN_INVALID", Retryable: false}) - case errors.Is(err, authapp.ErrUnauthorized): - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) - case errors.Is(err, authapp.ErrCSRFInvalid): - writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) - case errors.Is(err, authapp.ErrLoginRateLimited): - writeJSON(w, http.StatusTooManyRequests, openapi.ApiError{Code: "LOGIN_RATE_LIMITED", Retryable: true}) - case errors.Is(err, authapp.ErrInstanceNotReady): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "INSTANCE_NOT_READY", Retryable: false}) - case errors.Is(err, authapp.ErrPasswordRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PASSWORD_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, authapp.ErrCurrentPasswordInvalid): - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "CURRENT_PASSWORD_INVALID", Retryable: false}) - default: - server.logger.Error(message, "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - } -} - -func (server *Server) Login(w http.ResponseWriter, r *http.Request) { - if server.auth == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - return +func voWiFiStateResponse(item vowifidomain.State) openapi.VoWiFiLineState { + registeredAt, nextRefresh := "", "" + if !item.RegisteredAt.IsZero() { + registeredAt = item.RegisteredAt.UTC().Format(time.RFC3339) } - var request openapi.LoginRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LOGIN_REQUEST_INVALID", Retryable: false}) - return + if !item.NextRefreshAt.IsZero() { + nextRefresh = item.NextRefreshAt.UTC().Format(time.RFC3339) } - result, err := server.auth.Login(r.Context(), request.Username, request.Password) - if err != nil { - server.writeAuthenticationError(w, err, "administrator login failed") - return + return openapi.VoWiFiLineState{ + LineId: item.LineID, DesiredActive: item.DesiredActive, Eligible: item.Eligible, + ReadinessCode: openapi.VoWiFiLineStateReadinessCode(item.ReadinessCode), + State: openapi.VoWiFiLineStateState(item.State), Stage: item.Stage, Online: item.Online, + EgressMode: openapi.VoWiFiLineStateEgressMode(item.EgressMode), CountryCode: item.CountryCode, + CountryName: item.CountryName, RegisteredAt: registeredAt, NextRefreshAt: nextRefresh, + Attempt: item.Attempt, LastErrorCode: item.LastErrorCode, } - setAdministratorCookies(w, r, result.SessionToken, result.CSRFToken, result.ExpiresAt) - if !httpDependencyMissing(server.setup) { - status, statusErr := server.setup.Status(r.Context()) - if statusErr != nil { - server.logger.ErrorContext(r.Context(), "read setup status after login failed", "error", statusErr) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_STATUS_UNAVAILABLE", Retryable: true}) - return - } - if status.SetupRequired { - grant, grantErr := server.setup.BeginAdministratorSetup(r.Context()) - if grantErr != nil { - server.logger.ErrorContext(r.Context(), "create administrator setup session failed", "error", grantErr) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "SETUP_SESSION_UNAVAILABLE", Retryable: true}) - return - } - http.SetCookie(w, &http.Cookie{ - Name: setupSessionCookieName, Value: grant.Token, Path: "/api/v1/setup", HttpOnly: true, - Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, Expires: grant.ExpiresAt, - }) - } +} + +func mihomoConfigResponse(status mihomoapp.ConfigStatus) openapi.MihomoConfigStatus { + generatedAt := "" + if !status.GeneratedAt.IsZero() { + generatedAt = status.GeneratedAt.UTC().Format(time.RFC3339) } - writeJSON(w, http.StatusOK, authSessionResponse(result.User, result.ExpiresAt)) + return openapi.MihomoConfigStatus{Published: status.Published, Launchable: status.Launchable, Sha256: status.SHA256, GeneratedAt: generatedAt, ErrorCode: status.ErrorCode, SelectedSubscriptionId: status.SelectedSubscriptionID, RunningSubscriptionId: status.RunningSubscriptionID} } -func (server *Server) GetAuthSession(w http.ResponseWriter, r *http.Request) { - session, ok := server.requireAdministrator(w, r, false) - if !ok { - return +func mihomoRuntimeResponse(status mihomoapp.RuntimeStatus) openapi.MihomoRuntimeStatus { + startedAt := "" + if !status.StartedAt.IsZero() { + startedAt = status.StartedAt.UTC().Format(time.RFC3339) } - writeJSON(w, http.StatusOK, authSessionResponse(session.User, session.ExpiresAt)) + return openapi.MihomoRuntimeStatus{State: openapi.MihomoRuntimeStatusState(status.State), Pid: status.PID, SelectedSubscriptionId: status.SelectedSubscriptionID, RunningSubscriptionId: status.RunningSubscriptionID, PendingRestart: status.PendingRestart, StartedAt: startedAt, LastErrorCode: status.LastErrorCode} } -func (server *Server) Logout(w http.ResponseWriter, r *http.Request) { - if server.auth == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - return +func notificationEventStrings(events []openapi.NotificationEventKind) []string { + result := make([]string, 0, len(events)) + for _, event := range events { + result = append(result, string(event)) } - token, csrfToken, ok := administratorTokens(r, true) - if !ok { - clearAdministratorCookies(w, r) - writeJSON(w, http.StatusUnauthorized, openapi.ApiError{Code: "AUTH_SESSION_UNAUTHORIZED", Retryable: false}) - return + return result +} +func notificationChannelResponse(item notificationapp.ChannelView) openapi.NotificationChannel { + lastAt := "" + if !item.LastDeliveryAt.IsZero() { + lastAt = item.LastDeliveryAt.UTC().Format(time.RFC3339) } - if err := server.auth.Logout(r.Context(), token, csrfToken); err != nil { - server.writeAuthenticationError(w, err, "administrator logout failed") - return + events := make([]openapi.NotificationEventKind, 0, len(item.EventKinds)) + for _, event := range item.EventKinds { + events = append(events, openapi.NotificationEventKind(event)) } - clearAdministratorCookies(w, r) - w.WriteHeader(http.StatusNoContent) + return openapi.NotificationChannel{PendingCount: item.PendingCount, FailedCount: item.FailedCount, Id: item.ID, Provider: openapi.NotificationChannelProvider(item.Provider), DeliveryMode: openapi.NotificationChannelDeliveryMode(item.DeliveryMode), TargetType: openapi.NotificationChannelTargetType(item.TargetType), DisplayName: item.DisplayName, WebhookHint: openapi.NotificationChannelWebhookHint(item.WebhookHint), SigningSecretConfigured: item.SigningSecretConfigured, Enabled: item.Enabled, EventKinds: events, LastDeliveryAt: lastAt, LastDeliveryStatus: openapi.NotificationChannelLastDeliveryStatus(item.LastDeliveryStatus), LastErrorCode: item.LastErrorCode} } -func (server *Server) ChangeAdministratorPassword(w http.ResponseWriter, r *http.Request) { - if server.auth == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - return - } - token, csrfToken, ok := administratorTokens(r, true) - if !ok { - writeJSON(w, http.StatusForbidden, openapi.ApiError{Code: "CSRF_INVALID", Retryable: false}) - return - } - var request openapi.ChangeAdministratorPasswordRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PASSWORD_REQUEST_INVALID", Retryable: false}) - return - } - passwordAuth, ok := server.auth.(PasswordAuthenticator) - if !ok { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "AUTH_UNAVAILABLE", Retryable: true}) - return - } - if err := passwordAuth.ChangePassword(r.Context(), token, csrfToken, request.CurrentPassword, request.NewPassword, request.NewPasswordConfirmation); err != nil { - server.writeAuthenticationError(w, err, "administrator password replacement failed") - return - } - clearAdministratorCookies(w, r) - w.WriteHeader(http.StatusNoContent) -} - -func (server *Server) GetInventory(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - snapshot, err := server.inventory.Snapshot(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "inventory snapshot failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{ - Code: "INVENTORY_SNAPSHOT_UNAVAILABLE", - Retryable: true, - }) - return - } - writeJSON(w, http.StatusOK, inventoryResponse(snapshot)) -} - -func (server *Server) GetMihomoCoreStatus(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoCore == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoCore.Status() - if err != nil { - server.logger.ErrorContext(r.Context(), "read Mihomo core status failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CORE_STATUS_FAILED", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, mihomoCoreStatusResponse(status)) -} - -func (server *Server) GetMihomoDashboardStatus(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoDashboard == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_DASHBOARD_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoDashboard.Ensure() - if err != nil { - server.logger.ErrorContext(r.Context(), "read Mihomo dashboard status failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_DASHBOARD_STATUS_FAILED", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, openapi.MihomoDashboardStatus{Available: status.Available, Version: status.Version, ControllerAddress: status.ControllerAddress, Url: status.URL, Secret: status.Secret}) -} - -func (server *Server) GetLatestMihomoCore(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoCore == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) - return - } - candidate, err := server.mihomoCore.CheckLatest(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "check official Mihomo release failed", "error", err) - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_RELEASE_CHECK_FAILED", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, openapi.MihomoCoreCandidate{Version: candidate.Version, AssetName: candidate.AssetName, Sha256: candidate.SHA256, Size: candidate.Size, Architecture: openapi.MihomoCoreCandidateArchitecture(candidate.Architecture)}) -} - -func (server *Server) InstallLatestMihomoCore(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoCore == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CORE_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoCore.InstallLatest(r.Context()) - if errors.Is(err, mihomoapp.ErrVersionAlreadyInstalled) { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_VERSION_ALREADY_INSTALLED", Retryable: false}) - return - } - if err != nil { - server.logger.WarnContext(r.Context(), "install official Mihomo core failed", "error", err) - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_CORE_INSTALL_FAILED", Retryable: true}) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - writeJSON(w, http.StatusOK, mihomoCoreStatusResponse(status)) -} - -func mihomoCoreStatusResponse(status mihomoapp.CoreStatus) openapi.MihomoCoreStatus { - installedAt := "" - if !status.InstalledAt.IsZero() { - installedAt = status.InstalledAt.UTC().Format(time.RFC3339) - } - return openapi.MihomoCoreStatus{Installed: status.Installed, Version: status.Version, Architecture: openapi.MihomoCoreStatusArchitecture(status.Architecture), Sha256: status.SHA256, InstalledAt: installedAt} -} - -func (server *Server) ListMihomoSubscriptions(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.mihomoSubscriptions.List(r.Context()) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - response := make([]openapi.MihomoSubscription, 0, len(items)) - for _, item := range items { - response = append(response, mihomoSubscriptionResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.MihomoSubscriptionList{Subscriptions: response}) -} - -func (server *Server) CreateMihomoSubscription(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.MihomoSubscriptionCreateRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.mihomoSubscriptions.Create(r.Context(), "", request.Url, true) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - item, _, err = server.mihomoSubscriptions.Refresh(r.Context(), item.ID) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - writeJSON(w, http.StatusCreated, mihomoSubscriptionResponse(item)) -} - -func (server *Server) UpdateMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.MihomoSubscriptionMutation - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.mihomoSubscriptions.Update(r.Context(), subscriptionID, request.DisplayName, request.Url, request.Enabled) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - if strings.TrimSpace(request.Url) != "" { - item, _, err = server.mihomoSubscriptions.Refresh(r.Context(), subscriptionID) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - writeJSON(w, http.StatusOK, mihomoSubscriptionResponse(item)) -} - -func (server *Server) DeleteMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - if err := server.mihomoSubscriptions.Delete(r.Context(), subscriptionID); err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - w.WriteHeader(http.StatusNoContent) -} - -func (server *Server) RefreshMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - item, nodes, err := server.mihomoSubscriptions.Refresh(r.Context(), subscriptionID) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - writeJSON(w, http.StatusOK, openapi.MihomoSubscriptionRefresh{Subscription: mihomoSubscriptionResponse(item), Nodes: mihomoNodeResponses(nodes)}) -} - -func (server *Server) ListMihomoSubscriptionNodes(w http.ResponseWriter, r *http.Request, subscriptionID string) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoSubscriptions == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTIONS_UNAVAILABLE", Retryable: true}) - return - } - nodes, err := server.mihomoSubscriptions.Nodes(r.Context(), subscriptionID) - if err != nil { - server.writeMihomoSubscriptionError(w, r, err) - return - } - writeJSON(w, http.StatusOK, openapi.MihomoNodeList{Nodes: mihomoNodeResponses(nodes)}) -} - -func (server *Server) writeMihomoSubscriptionError(w http.ResponseWriter, r *http.Request, err error) { - var refreshError *mihomoapp.SubscriptionRefreshError - switch { - case errors.Is(err, mihomoapp.ErrSubscriptionInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, mihomoapp.ErrSubscriptionNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_NOT_FOUND", Retryable: false}) - case errors.As(err, &refreshError): - server.logger.WarnContext(r.Context(), "Mihomo subscription refresh failed", "error_code", refreshError.Code) - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_REFRESH_FAILED", Retryable: true}) - default: - server.logger.WarnContext(r.Context(), "Mihomo subscription operation failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_PERSIST_FAILED", Retryable: true}) - } -} - -func mihomoSubscriptionResponse(item mihomoapp.SubscriptionView) openapi.MihomoSubscription { - refreshAt := "" - if !item.LastRefreshAt.IsZero() { - refreshAt = item.LastRefreshAt.UTC().Format(time.RFC3339) - } - return openapi.MihomoSubscription{Id: item.ID, DisplayName: item.DisplayName, Url: item.URL, UrlHint: item.URLHint, Enabled: item.Enabled, Selected: item.Selected, ArtifactReady: item.ArtifactReady, LastRefreshAt: refreshAt, LastRefreshStatus: openapi.MihomoSubscriptionLastRefreshStatus(item.LastRefreshStatus), NodeCount: item.NodeCount, LastErrorCode: item.LastErrorCode} -} -func mihomoNodeResponses(nodes []mihomodomain.Node) []openapi.MihomoNode { - result := make([]openapi.MihomoNode, 0, len(nodes)) - for _, node := range nodes { - result = append(result, openapi.MihomoNode{Id: node.ID, DisplayName: node.DisplayName, Kind: node.Kind, CountryCode: node.CountryCode, CountryName: node.CountryName}) - } - return result -} - -func (server *Server) ListLineEgressBindings(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.lineEgress == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_EGRESS_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.lineEgress.List(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "line egress list failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_EGRESS_READ_FAILED", Retryable: true}) - return - } - bindings := make([]openapi.LineEgressBinding, 0, len(items)) - for _, item := range items { - bindings = append(bindings, lineEgressResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.LineEgressBindingList{Bindings: bindings}) -} - -func (server *Server) PutLineEgressBinding(w http.ResponseWriter, r *http.Request, lineID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.lineEgress == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_EGRESS_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.LineEgressBindingMutation - if err := decodeJSON(w, r, &request); err != nil || !request.Mode.Valid() { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_EGRESS_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.lineEgress.Put(r.Context(), lineID, string(request.Mode), request.CountryCode) - if err != nil { - switch { - case errors.Is(err, lineegressapp.ErrInvalidBinding): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_EGRESS_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, lineegressapp.ErrLineNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_NOT_FOUND", Retryable: false}) - case errors.Is(err, lineegressapp.ErrLineUnsupported): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_VOWIFI_UNSUPPORTED", Retryable: false}) - default: - server.logger.WarnContext(r.Context(), "line egress update failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_EGRESS_PERSIST_FAILED", Retryable: true}) - } - return - } - server.publish([]realtime.Topic{realtime.TopicLines, realtime.TopicMihomo, realtime.TopicVoWiFi}, "") - writeJSON(w, http.StatusOK, lineEgressResponse(item)) -} - -func lineEgressResponse(item lineegressapp.View) openapi.LineEgressBinding { - return openapi.LineEgressBinding{ - LineId: item.LineID, Mode: openapi.LineEgressBindingMode(item.Mode), CountryCode: item.CountryCode, - CountryName: item.CountryName, ListenerPort: item.ListenerPort, Ready: item.Ready, - ReadinessReason: openapi.LineEgressBindingReadinessReason(item.ReadinessReason), - } -} - -func (server *Server) ListVoWiFiLines(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.vowifi == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.vowifi.List(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "Host VoWiFi state list failed", "error", err) - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_STATUS_UNAVAILABLE", Retryable: true}) - return - } - lines := make([]openapi.VoWiFiLineState, 0, len(items)) - for _, item := range items { - lines = append(lines, voWiFiStateResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.VoWiFiLineStateList{Lines: lines}) -} - -func (server *Server) ActivateVoWiFiLine(w http.ResponseWriter, r *http.Request, lineID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.vowifi == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) - return - } - state, err := server.vowifi.Activate(r.Context(), lineID) - if err != nil { - server.writeVoWiFiError(w, r, err, lineID, true) - return - } - server.publish([]realtime.Topic{realtime.TopicVoWiFi}, "") - writeJSON(w, http.StatusAccepted, voWiFiStateResponse(state)) -} - -func (server *Server) DeactivateVoWiFiLine(w http.ResponseWriter, r *http.Request, lineID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.vowifi == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_UNAVAILABLE", Retryable: true}) - return - } - state, err := server.vowifi.Deactivate(r.Context(), lineID) - if err != nil { - server.writeVoWiFiError(w, r, err, lineID, false) - return - } - server.publish([]realtime.Topic{realtime.TopicVoWiFi}, "") - writeJSON(w, http.StatusOK, voWiFiStateResponse(state)) -} - -func (server *Server) writeVoWiFiError(w http.ResponseWriter, r *http.Request, err error, lineID string, activating bool) { - switch { - case errors.Is(err, vowifiapp.ErrLineNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "VOWIFI_LINE_NOT_FOUND", Retryable: false}) - case errors.Is(err, vowifiapp.ErrLineNotReady): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "VOWIFI_LINE_NOT_READY", Retryable: true}) - default: - action := "deactivation" - if activating { - action = "activation" - } - server.logger.WarnContext(r.Context(), "Host VoWiFi "+action+" failed", "line_id", lineID, "error", err) - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "VOWIFI_RUNTIME_FAILED", Retryable: true}) - } -} - -func voWiFiStateResponse(item vowifidomain.State) openapi.VoWiFiLineState { - registeredAt, nextRefresh := "", "" - if !item.RegisteredAt.IsZero() { - registeredAt = item.RegisteredAt.UTC().Format(time.RFC3339) - } - if !item.NextRefreshAt.IsZero() { - nextRefresh = item.NextRefreshAt.UTC().Format(time.RFC3339) - } - return openapi.VoWiFiLineState{ - LineId: item.LineID, DesiredActive: item.DesiredActive, Eligible: item.Eligible, - ReadinessCode: openapi.VoWiFiLineStateReadinessCode(item.ReadinessCode), - State: openapi.VoWiFiLineStateState(item.State), Stage: item.Stage, Online: item.Online, - EgressMode: openapi.VoWiFiLineStateEgressMode(item.EgressMode), CountryCode: item.CountryCode, - CountryName: item.CountryName, RegisteredAt: registeredAt, NextRefreshAt: nextRefresh, - Attempt: item.Attempt, LastErrorCode: item.LastErrorCode, - } -} - -func (server *Server) GetMihomoConfigStatus(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.mihomoConfig == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoConfig.Status(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "Mihomo config status failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CONFIG_STATUS_FAILED", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) -} - -func (server *Server) PublishMihomoConfig(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoConfig == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoConfig.GenerateAndPublish(r.Context()) - if errors.Is(err, mihomoapp.ErrConfigNotReady) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_CONFIG_NOT_READY", Retryable: false}) - return - } - if errors.Is(err, mihomoapp.ErrConfigValidationFailed) { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_CONFIG_VALIDATION_FAILED", Retryable: false}) - return - } - if err != nil { - server.logger.WarnContext(r.Context(), "Mihomo config publish failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_CONFIG_PUBLISH_FAILED", Retryable: true}) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo}, "") - writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) -} - -func (server *Server) SelectMihomoSubscription(w http.ResponseWriter, r *http.Request, subscriptionID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.mihomoConfig == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_CONFIG_UNAVAILABLE", Retryable: true}) - return - } - status, err := server.mihomoConfig.Select(r.Context(), subscriptionID) - if errors.Is(err, mihomoapp.ErrConfigNotReady) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_ARTIFACT_NOT_READY", Retryable: false}) - return - } - if errors.Is(err, mihomoapp.ErrConfigValidationFailed) { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_CONFIG_VALIDATION_FAILED", Retryable: false}) - return - } - if err != nil { - server.logger.WarnContext(r.Context(), "Mihomo subscription selection failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_SUBSCRIPTION_SELECT_FAILED", Retryable: true}) - return - } - server.publish([]realtime.Topic{realtime.TopicMihomo, realtime.TopicVoWiFi}, "") - writeJSON(w, http.StatusOK, mihomoConfigResponse(status)) -} - -func mihomoConfigResponse(status mihomoapp.ConfigStatus) openapi.MihomoConfigStatus { - generatedAt := "" - if !status.GeneratedAt.IsZero() { - generatedAt = status.GeneratedAt.UTC().Format(time.RFC3339) - } - return openapi.MihomoConfigStatus{Published: status.Published, Launchable: status.Launchable, Sha256: status.SHA256, GeneratedAt: generatedAt, ErrorCode: status.ErrorCode, SelectedSubscriptionId: status.SelectedSubscriptionID, RunningSubscriptionId: status.RunningSubscriptionID} -} - -func (server *Server) GetMihomoRuntimeStatus(w http.ResponseWriter, r *http.Request) { - server.handleMihomoRuntime(w, r, "status") -} -func (server *Server) StartMihomo(w http.ResponseWriter, r *http.Request) { - server.handleMihomoRuntime(w, r, "start") -} -func (server *Server) RestartMihomo(w http.ResponseWriter, r *http.Request) { - server.handleMihomoRuntime(w, r, "restart") -} -func (server *Server) StopMihomo(w http.ResponseWriter, r *http.Request) { - server.handleMihomoRuntime(w, r, "stop") -} -func (server *Server) handleMihomoRuntime(w http.ResponseWriter, r *http.Request, action string) { - mutation := action != "status" - if _, ok := server.requireAdministrator(w, r, mutation); !ok { - return - } - if server.mihomoRuntime == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MIHOMO_RUNTIME_UNAVAILABLE", Retryable: true}) - return - } - var status mihomoapp.RuntimeStatus - var err error - switch action { - case "status": - status, err = server.mihomoRuntime.Status(r.Context()) - case "start": - status, err = server.mihomoRuntime.Start(r.Context()) - case "restart": - status, err = server.mihomoRuntime.Restart(r.Context()) - case "stop": - status, err = server.mihomoRuntime.Stop(r.Context()) - } - if errors.Is(err, mihomoapp.ErrConfigNotReady) || errors.Is(err, mihomoapp.ErrConfigValidationFailed) || errors.Is(err, mihomoapp.ErrRuntimeAlreadyRunning) || errors.Is(err, mihomoapp.ErrRuntimeNotRunning) || errors.Is(err, mihomoapp.ErrRuntimeStartupFailed) { - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MIHOMO_RUNTIME_STATE_CONFLICT", Retryable: false}) - return - } - if err != nil { - server.logger.WarnContext(r.Context(), "Mihomo runtime operation failed", "action", action, "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MIHOMO_RUNTIME_OPERATION_FAILED", Retryable: true}) - return - } - if mutation { - server.publish([]realtime.Topic{realtime.TopicMihomo, realtime.TopicVoWiFi}, "") - } - writeJSON(w, http.StatusOK, mihomoRuntimeResponse(status)) -} -func mihomoRuntimeResponse(status mihomoapp.RuntimeStatus) openapi.MihomoRuntimeStatus { - startedAt := "" - if !status.StartedAt.IsZero() { - startedAt = status.StartedAt.UTC().Format(time.RFC3339) - } - return openapi.MihomoRuntimeStatus{State: openapi.MihomoRuntimeStatusState(status.State), Pid: status.PID, SelectedSubscriptionId: status.SelectedSubscriptionID, RunningSubscriptionId: status.RunningSubscriptionID, PendingRestart: status.PendingRestart, StartedAt: startedAt, LastErrorCode: status.LastErrorCode} -} - -func (server *Server) ListNotificationChannels(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.notifications.List(r.Context()) - if err != nil { - server.writeNotificationError(w, r, err) - return - } - response := make([]openapi.NotificationChannel, 0, len(items)) - for _, item := range items { - response = append(response, notificationChannelResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.NotificationChannelList{Channels: response}) -} -func (server *Server) CreateNotificationChannel(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.NotificationChannelMutation - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.notifications.Create(r.Context(), string(request.Provider), request.DisplayName, request.WebhookUrl, request.SigningSecret, request.Enabled, notificationEventStrings(request.EventKinds)) - if err != nil { - server.writeNotificationError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicNotifications}, "") - writeJSON(w, http.StatusCreated, notificationChannelResponse(item)) -} -func (server *Server) GetFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, false); !ok { - return - } - w.Header().Set("Cache-Control", "no-store") - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, feishuBindingResponse(server.notifications.FeishuBindingStatus())) -} -func (server *Server) StartFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - w.Header().Set("Cache-Control", "no-store") - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) - return - } - state, err := server.notifications.StartFeishuBinding(r.Context()) - if err != nil { - server.writeFeishuBindingError(w, r, err) - return - } - writeJSON(w, http.StatusCreated, feishuBindingResponse(state)) -} -func (server *Server) CancelFeishuNotificationBinding(w http.ResponseWriter, r *http.Request) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - w.Header().Set("Cache-Control", "no-store") - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) - return - } - state, err := server.notifications.CancelFeishuBinding() - if err != nil { - server.writeFeishuBindingError(w, r, err) - return - } - writeJSON(w, http.StatusOK, feishuBindingResponse(state)) -} -func (server *Server) writeFeishuBindingError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, notificationapp.ErrBindingActive): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "FEISHU_BINDING_ACTIVE", Retryable: false}) - case errors.Is(err, notificationapp.ErrBindingNotCancelable): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "FEISHU_BINDING_NOT_CANCELLABLE", Retryable: false}) - case errors.Is(err, notificationapp.ErrBindingUnavailable): - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "FEISHU_BINDING_UNAVAILABLE", Retryable: true}) - case errors.Is(err, notificationapp.ErrFeishuProviderResultInvalid): - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: notificationapp.BindingErrorResultInvalid, Retryable: false}) - default: - server.logger.WarnContext(r.Context(), "Feishu binding start failed") - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: notificationapp.BindingErrorProviderFailed, Retryable: true}) - } -} -func (server *Server) UpdateNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.NotificationChannelMutation - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.notifications.Update(r.Context(), channelID, string(request.Provider), request.DisplayName, request.WebhookUrl, request.SigningSecret, request.Enabled, notificationEventStrings(request.EventKinds)) - if err != nil { - server.writeNotificationError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicNotifications}, "") - writeJSON(w, http.StatusOK, notificationChannelResponse(item)) -} -func (server *Server) DeleteNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) - return - } - if err := server.notifications.Delete(r.Context(), channelID); err != nil { - server.writeNotificationError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicNotifications}, "") - w.WriteHeader(http.StatusNoContent) -} -func (server *Server) TestNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { - if _, ok := server.requireAdministrator(w, r, true); !ok { - return - } - if server.notifications == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "NOTIFICATIONS_UNAVAILABLE", Retryable: true}) - return - } - item, err := server.notifications.Test(r.Context(), channelID) - if err != nil { - server.writeNotificationError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicNotifications}, "") - writeJSON(w, http.StatusOK, notificationChannelResponse(item)) -} -func (server *Server) writeNotificationError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, notificationapp.ErrChannelInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, notificationapp.ErrChannelNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "NOTIFICATION_CHANNEL_NOT_FOUND", Retryable: false}) - default: - server.logger.WarnContext(r.Context(), "notification channel operation failed", "error", err) - writeJSON(w, http.StatusBadGateway, openapi.ApiError{Code: "NOTIFICATION_DELIVERY_FAILED", Retryable: true}) - } -} -func notificationEventStrings(events []openapi.NotificationEventKind) []string { - result := make([]string, 0, len(events)) - for _, event := range events { - result = append(result, string(event)) - } - return result -} -func notificationChannelResponse(item notificationapp.ChannelView) openapi.NotificationChannel { - lastAt := "" - if !item.LastDeliveryAt.IsZero() { - lastAt = item.LastDeliveryAt.UTC().Format(time.RFC3339) - } - events := make([]openapi.NotificationEventKind, 0, len(item.EventKinds)) - for _, event := range item.EventKinds { - events = append(events, openapi.NotificationEventKind(event)) - } - return openapi.NotificationChannel{Id: item.ID, Provider: openapi.NotificationChannelProvider(item.Provider), DeliveryMode: openapi.NotificationChannelDeliveryMode(item.DeliveryMode), TargetType: openapi.NotificationChannelTargetType(item.TargetType), DisplayName: item.DisplayName, WebhookHint: openapi.NotificationChannelWebhookHint(item.WebhookHint), SigningSecretConfigured: item.SigningSecretConfigured, Enabled: item.Enabled, EventKinds: events, LastDeliveryAt: lastAt, LastDeliveryStatus: openapi.NotificationChannelLastDeliveryStatus(item.LastDeliveryStatus), LastErrorCode: item.LastErrorCode} -} - -func feishuBindingResponse(state notificationapp.BindingView) openapi.FeishuNotificationBinding { - expiresAt := "" - if !state.ExpiresAt.IsZero() { - expiresAt = state.ExpiresAt.UTC().Format(time.RFC3339) - } - return openapi.FeishuNotificationBinding{State: openapi.FeishuNotificationBindingState(state.State), VerificationUrl: state.VerificationURL, ExpiresAt: expiresAt, ChannelId: state.ChannelID, ErrorCode: state.ErrorCode} -} - -func (server *Server) ListMessages(w http.ResponseWriter, r *http.Request, params openapi.ListMessagesParams) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.messages == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) - return - } - request := messageapp.PageRequest{} - if params.Limit != nil { - if *params.Limit == 0 { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - return - } - request.Limit = *params.Limit - } - if params.Cursor != nil { - if *params.Cursor == "" { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - request.Cursor = *params.Cursor - } else if _, present := r.URL.Query()["cursor"]; present { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - linePresent := false - if params.LineId != nil { - request.LineID = *params.LineId - linePresent = true - } else if _, present := r.URL.Query()["lineId"]; present { - linePresent = true - } - remotePresent := false - if params.RemoteAddress != nil { - request.RemoteAddress = *params.RemoteAddress - remotePresent = true - } else if _, present := r.URL.Query()["remoteAddress"]; present { - remotePresent = true - } - if (linePresent && !remotePresent) || (linePresent && request.LineID == "") || (remotePresent && request.RemoteAddress == "") { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_FILTER_INVALID", Retryable: false}) - return - } - page, err := server.messages.ListPage(r.Context(), request) - if err != nil { - if errors.Is(err, pagination.ErrCursorInvalid) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - if errors.Is(err, pagination.ErrLimitInvalid) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - return - } - if errors.Is(err, messageapp.ErrRequestInvalid) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_FILTER_INVALID", Retryable: false}) - return - } - server.logger.ErrorContext(r.Context(), "message history read failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) - return - } - response := make([]openapi.SMSMessage, 0, len(page.Messages)) - for _, message := range page.Messages { - response = append(response, smsMessageResponse(message)) - } - stats, err := server.messages.Stats(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "message history stats failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) - return - } - result := openapi.SMSMessageListResponse{ - Messages: response, TotalCount: stats.TotalCount, Capacity: stats.Capacity, NearCapacity: stats.NearCapacity, - } - if page.NextCursor != "" { - result.NextCursor = &page.NextCursor - } - if page.ReadThroughToken != "" { - result.ReadThroughToken = &page.ReadThroughToken - } - writeJSON(w, http.StatusOK, result) -} - -func (server *Server) ListMessageConversations(w http.ResponseWriter, r *http.Request, params openapi.ListMessageConversationsParams) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.messages == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) - return - } - limit := 0 - if params.Limit != nil { - if *params.Limit == 0 { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - return - } - limit = *params.Limit - } - cursor := "" - if params.Cursor != nil { - if *params.Cursor == "" { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - cursor = *params.Cursor - } else if _, present := r.URL.Query()["cursor"]; present { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - page, err := server.messages.ListConversationPage(r.Context(), limit, cursor) - if err != nil { - switch { - case errors.Is(err, pagination.ErrCursorInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - case errors.Is(err, pagination.ErrLimitInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "message conversation read failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) - } - return - } - stats, err := server.messages.Stats(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "message conversation stats failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_HISTORY_UNAVAILABLE", Retryable: true}) - return - } - conversations := make([]openapi.SMSConversationSummary, 0, len(page.Conversations)) - for _, item := range page.Conversations { - response := openapi.SMSConversationSummary{ - RemoteAddress: item.RemoteAddress, - LastMessage: smsMessageResponse(item.LastMessage), - UnreadCount: item.UnreadCount, - } - if item.LastOutboundLineID != "" { - response.LastOutboundLineId = &item.LastOutboundLineID - } - conversations = append(conversations, response) - } - result := openapi.SMSConversationListResponse{ - Conversations: conversations, ConversationTotalCount: page.TotalCount, - MessageTotalCount: stats.TotalCount, Capacity: stats.Capacity, NearCapacity: stats.NearCapacity, - } - if page.NextCursor != "" { - result.NextCursor = &page.NextCursor - } - writeJSON(w, http.StatusOK, result) -} - -func (server *Server) MarkMessageConversationRead(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.messages == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.MarkSMSConversationReadRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_READ_STATE_INVALID", Retryable: false}) - return - } - changed, err := server.messages.MarkConversationRead(r.Context(), request.RemoteAddress, request.ReadThroughToken) - if err != nil { - switch { - case errors.Is(err, messageapp.ErrRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_READ_STATE_INVALID", Retryable: false}) - case errors.Is(err, sms.ErrMessageNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_READ_BOUNDARY_NOT_FOUND", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "message read state update failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) - } - return - } - if changed { - server.publish([]realtime.Topic{realtime.TopicMessages}, "") - } - w.WriteHeader(http.StatusNoContent) -} - -func (server *Server) DeleteMessage(w http.ResponseWriter, r *http.Request, messageID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.messages == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) - return - } - if err := server.messages.Delete(r.Context(), messageID); err != nil { - switch { - case errors.Is(err, messageapp.ErrRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, sms.ErrMessageNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_NOT_FOUND", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "message deletion failed", "message_id", messageID, "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) - } - return - } - server.publish([]realtime.Topic{realtime.TopicMessages}, "") - w.WriteHeader(http.StatusNoContent) -} - -func (server *Server) SendMessage(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.messages == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGING_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.SendSMSRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) - return - } - result, err := server.messages.Send(r.Context(), messageapp.SendRequest{ - OperationID: request.OperationId, - LineID: request.LineId, - Destination: request.Destination, - Body: request.Body, - }) - if err != nil { - server.writeMessageError(w, r, err, request.OperationId, request.LineId) - return - } - status := http.StatusCreated - if result.Replayed { - status = http.StatusOK - } - if !result.Replayed && result.Message.Status == sms.StatusFailed { - server.notifyAsync("sms.failed", fmt.Sprintf("[Simplus] 短信发送失败 · 线路 %s · %s", result.Message.LineID, result.Message.ErrorCode)) - } - server.publish([]realtime.Topic{realtime.TopicMessages}, "") - writeJSON(w, status, smsMessageResponse(result.Message)) -} - -func (server *Server) ListCalls(w http.ResponseWriter, r *http.Request, params openapi.ListCallsParams) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.calls == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) - return - } - limit := 0 - cursor := "" - if params.Limit != nil { - if *params.Limit == 0 { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - return - } - limit = *params.Limit - } - if params.Cursor != nil { - if *params.Cursor == "" { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - cursor = *params.Cursor - } else if _, present := r.URL.Query()["cursor"]; present { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - page, err := server.calls.List(r.Context(), limit, cursor) - if err != nil { - if errors.Is(err, pagination.ErrCursorInvalid) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_CURSOR_INVALID", Retryable: false}) - return - } - if errors.Is(err, pagination.ErrLimitInvalid) { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "PAGE_LIMIT_INVALID", Retryable: false}) - return - } - server.writeCallError(w, r, err) - return - } - response := make([]openapi.Call, 0, len(page.Calls)) - for _, value := range page.Calls { - response = append(response, callResponse(value)) - } - result := openapi.CallListResponse{Calls: response} - if page.NextCursor != "" { - result.NextCursor = &page.NextCursor - } - writeJSON(w, http.StatusOK, result) -} - -func (server *Server) GetEUICCState(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.euicc == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "EUICC_UNAVAILABLE", Retryable: false}) - return - } - state, err := server.euicc.State(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "eUICC state failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "EUICC_READ_FAILED", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, euiccResponse(state)) -} - -func (server *Server) ActivateEUICCProfile(w http.ResponseWriter, r *http.Request, profileID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.euicc == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "EUICC_UNAVAILABLE", Retryable: false}) - return - } - state, err := server.euicc.Switch(r.Context(), profileID) - if err != nil { - switch { - case errors.Is(err, euiccapp.ErrInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "EUICC_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, euiccapp.ErrNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "EUICC_PROFILE_NOT_FOUND", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "eUICC switch failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "EUICC_SWITCH_FAILED", Retryable: true}) - } - return +func feishuBindingResponse(state notificationapp.BindingView) openapi.FeishuNotificationBinding { + expiresAt := "" + if !state.ExpiresAt.IsZero() { + expiresAt = state.ExpiresAt.UTC().Format(time.RFC3339) } - server.publish([]realtime.Topic{realtime.TopicEUICC}, "") - writeJSON(w, http.StatusOK, euiccResponse(state)) + return openapi.FeishuNotificationBinding{State: openapi.FeishuNotificationBindingState(state.State), VerificationUrl: state.VerificationURL, ExpiresAt: expiresAt, ChannelId: state.ChannelID, ErrorCode: state.ErrorCode} } func euiccResponse(state domaineuicc.State) openapi.EUICCState { @@ -2329,217 +749,10 @@ func euiccResponse(state domaineuicc.State) openapi.EUICCState { return openapi.EUICCState{EidHint: state.EIDHint, Profiles: profiles} } -func (server *Server) DialCall(w http.ResponseWriter, r *http.Request) { server.startCall(w, r, false) } -func (server *Server) SimulateIncomingCall(w http.ResponseWriter, r *http.Request) { - server.startCall(w, r, true) -} -func (server *Server) startCall(w http.ResponseWriter, r *http.Request, incoming bool) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.calls == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) - return - } - var request openapi.CallStartRequest - if decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) - return - } - var value call.Record - var replayed bool - var err error - if incoming { - value, replayed, err = server.calls.Incoming(r.Context(), request.OperationId, request.LineId, request.RemoteAddress) - } else { - value, replayed, err = server.calls.Dial(r.Context(), request.OperationId, request.LineId, request.RemoteAddress) - } - if err != nil { - server.writeCallError(w, r, err) - return - } - status := http.StatusCreated - if replayed { - status = http.StatusOK - } - if incoming && !replayed { - server.notifyAsync("call.incoming", fmt.Sprintf("[Simplus] 新来电 · 线路 %s", value.LineID)) - } - attention := realtime.Attention("") - if incoming && !replayed { - attention = realtime.AttentionCallIncoming - } - server.publish([]realtime.Topic{realtime.TopicCalls}, attention) - writeJSON(w, status, callResponse(value)) -} - -func (server *Server) ControlCall(w http.ResponseWriter, r *http.Request, callID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.calls == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CALLS_UNAVAILABLE", Retryable: false}) - return - } - var request openapi.CallActionRequest - if decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) - return - } - var value call.Record - var err error - switch request.Action { - case openapi.Answer: - value, err = server.calls.Answer(r.Context(), callID) - case openapi.Reject: - value, err = server.calls.Reject(r.Context(), callID) - case openapi.Hangup: - value, err = server.calls.Hangup(r.Context(), callID) - case openapi.Dtmf: - if request.Digits == nil { - err = callapp.ErrInvalid - } else { - value, err = server.calls.DTMF(r.Context(), callID, *request.Digits) - } - default: - err = callapp.ErrInvalid - } - if err != nil { - server.writeCallError(w, r, err) - return - } - if request.Action == openapi.Reject && value.Direction == call.DirectionInbound { - server.notifyAsync("call.missed", fmt.Sprintf("[Simplus] 未接来电 · 线路 %s", value.LineID)) - } - server.publish([]realtime.Topic{realtime.TopicCalls}, "") - writeJSON(w, http.StatusOK, callResponse(value)) -} - -func (server *Server) notifyAsync(event, message string) { - if server == nil || server.notifications == nil { - return - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := server.notifications.Notify(ctx, event, message); err != nil { - server.logger.Warn("notification delivery failed", "event", event, "error", err) - } - server.publish([]realtime.Topic{realtime.TopicNotifications}, "") - }() -} - -func (server *Server) writeCallError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, callapp.ErrInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, callapp.ErrUnsafeNumber): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CALL_NUMBER_FORBIDDEN", Retryable: false}) - case errors.Is(err, callapp.ErrLineUnavailable): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_LINE_UNAVAILABLE", Retryable: true}) - case errors.Is(err, callapp.ErrLineBusy): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_LINE_BUSY", Retryable: true}) - case errors.Is(err, call.ErrNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "CALL_NOT_FOUND", Retryable: false}) - case errors.Is(err, call.ErrStateConflict): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CALL_STATE_CONFLICT", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "call operation failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CALL_PERSIST_FAILED", Retryable: true}) - } -} - func callResponse(value call.Record) openapi.Call { return openapi.Call{Id: value.ID, OperationId: value.OperationID, LineId: value.LineID, RemoteAddress: value.RemoteAddress, Direction: openapi.CallDirection(value.Direction), State: openapi.CallState(value.State), EndReason: value.EndReason, CreatedAt: value.CreatedAt.UTC(), UpdatedAt: value.UpdatedAt.UTC(), AnsweredAt: value.AnsweredAt, EndedAt: value.EndedAt} } -func (server *Server) ListContacts(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.contacts == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CONTACTS_UNAVAILABLE", Retryable: true}) - return - } - values, err := server.contacts.List(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "contacts read failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CONTACTS_READ_FAILED", Retryable: true}) - return - } - response := make([]openapi.Contact, 0, len(values)) - for _, value := range values { - response = append(response, contactResponse(value)) - } - writeJSON(w, http.StatusOK, openapi.ContactListResponse{Contacts: response}) -} - -func (server *Server) CreateContact(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - var request openapi.ContactMutationRequest - if server.contacts == nil || decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) - return - } - value, err := server.contacts.Create(r.Context(), request.DisplayName, request.PhoneNumber) - if err != nil { - server.writeContactError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicContacts}, "") - writeJSON(w, http.StatusCreated, contactResponse(value)) -} - -func (server *Server) UpdateContact(w http.ResponseWriter, r *http.Request, contactID string) { - if !server.requireBusinessAPI(w, r) { - return - } - var request openapi.ContactMutationRequest - if server.contacts == nil || decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) - return - } - value, err := server.contacts.Update(r.Context(), contactID, request.DisplayName, request.PhoneNumber) - if err != nil { - server.writeContactError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicContacts}, "") - writeJSON(w, http.StatusOK, contactResponse(value)) -} - -func (server *Server) DeleteContact(w http.ResponseWriter, r *http.Request, contactID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.contacts == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "CONTACTS_UNAVAILABLE", Retryable: true}) - return - } - if err := server.contacts.Delete(r.Context(), contactID); err != nil { - server.writeContactError(w, r, err) - return - } - server.publish([]realtime.Topic{realtime.TopicContacts}, "") - w.WriteHeader(http.StatusNoContent) -} - -func (server *Server) writeContactError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, contactapp.ErrInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "CONTACT_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, contact.ErrNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "CONTACT_NOT_FOUND", Retryable: false}) - case errors.Is(err, contact.ErrPhoneConflict): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "CONTACT_PHONE_CONFLICT", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "contact mutation failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "CONTACT_PERSIST_FAILED", Retryable: true}) - } -} - func contactResponse(value contact.Contact) openapi.Contact { return openapi.Contact{ Id: value.ID, DisplayName: value.DisplayName, PhoneNumber: value.PhoneNumber, @@ -2547,170 +760,6 @@ func contactResponse(value contact.Contact) openapi.Contact { } } -func (server *Server) writeMessageError(w http.ResponseWriter, r *http.Request, err error, operationID, lineID string) { - switch { - case errors.Is(err, messageapp.ErrRequestInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MESSAGE_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, sms.ErrOperationConflict): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_OPERATION_CONFLICT", Retryable: false}) - case errors.Is(err, messageapp.ErrLineNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MESSAGE_LINE_NOT_FOUND", Retryable: false}) - case errors.Is(err, messageapp.ErrLineUnavailable): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_LINE_UNAVAILABLE", Retryable: true}) - case errors.Is(err, messageapp.ErrLineUnsupported): - writeJSON(w, http.StatusUnprocessableEntity, openapi.ApiError{Code: "MESSAGE_LINE_UNSUPPORTED", Retryable: false}) - case errors.Is(err, messageapp.ErrTransportUnavailable): - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGE_TRANSPORT_UNAVAILABLE", Retryable: true}) - case errors.Is(err, messageapp.ErrTransportAmbiguous): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MESSAGE_TRANSPORT_AMBIGUOUS", Retryable: false}) - case errors.Is(err, messageapp.ErrInventoryUnavailable): - server.logger.ErrorContext(r.Context(), "message inventory unavailable", "operation_id", operationID, "line_id", lineID, "error", err) - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MESSAGE_INVENTORY_UNAVAILABLE", Retryable: true}) - default: - server.logger.ErrorContext(r.Context(), "message operation failed", "operation_id", operationID, "line_id", lineID, "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MESSAGE_PERSIST_FAILED", Retryable: true}) - } -} - -func (server *Server) ListManagedModems(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.modems == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.modems.List(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "managed modem list failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_LIST_UNAVAILABLE", Retryable: true}) - return - } - modems := make([]openapi.ManagedModem, 0, len(items)) - for _, item := range items { - modems = append(modems, managedModemResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.ManagedModemList{Modems: modems}) -} - -func (server *Server) ListManagedLines(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.lines == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.lines.List(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "managed line list failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_LIST_UNAVAILABLE", Retryable: true}) - return - } - lines := make([]openapi.ManagedLine, 0, len(items)) - for _, item := range items { - lines = append(lines, managedLineResponse(item)) - } - writeJSON(w, http.StatusOK, openapi.ManagedLineList{Lines: lines}) -} - -func (server *Server) ListLineCandidates(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.lines == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.lines.Candidates(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "managed line candidate resolution failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_CANDIDATES_UNAVAILABLE", Retryable: true}) - return - } - candidates := make([]openapi.LineCandidate, 0, len(items)) - for _, item := range items { - candidates = append(candidates, openapi.LineCandidate{ - CandidateId: item.CandidateID, ManagedModemId: item.ManagedModemID, - ManagedModemDisplayName: item.ManagedModemDisplayName, - ManagedModemModel: item.ManagedModemModel, ManagedModemSerialNumber: item.ManagedModemSerialNumber, - SubscriptionDisplayHint: item.SubscriptionDisplayHint, - HomeOperatorName: item.HomeOperatorName, HomeOperatorCode: item.HomeOperatorCode, - SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), - Capabilities: hardwareCapabilitiesResponse(item.Capabilities), Addable: item.Addable, - ReadinessReason: openapi.LineCandidateReadinessReason(item.Readiness), - }) - } - writeJSON(w, http.StatusOK, openapi.LineCandidateList{Candidates: candidates}) -} - -func (server *Server) AddManagedLine(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.lines == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.AddManagedLineRequest - if decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_ADD_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.lines.Add(r.Context(), request.CandidateId, request.DisplayName) - if err != nil { - server.writeManagedLineError(w, r, err, true) - return - } - server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicLines}, "") - w.Header().Set("Location", "/api/v1/lines/"+item.ID) - writeJSON(w, http.StatusCreated, managedLineResponse(item)) -} - -func (server *Server) UpdateManagedLine(w http.ResponseWriter, r *http.Request, lineID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.lines == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "LINE_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.UpdateManagedLineRequest - if decodeJSON(w, r, &request) != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "LINE_UPDATE_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.lines.Update(r.Context(), lineID, request.DisplayName) - if err != nil { - server.writeManagedLineError(w, r, err, false) - return - } - server.publish([]realtime.Topic{realtime.TopicLines}, "") - writeJSON(w, http.StatusOK, managedLineResponse(item)) -} - -func (server *Server) writeManagedLineError(w http.ResponseWriter, r *http.Request, err error, adding bool) { - switch { - case errors.Is(err, lineapp.ErrRequestInvalid): - code := "LINE_UPDATE_REQUEST_INVALID" - if adding { - code = "LINE_ADD_REQUEST_INVALID" - } - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: code, Retryable: false}) - case errors.Is(err, lineapp.ErrCandidateNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_CANDIDATE_NOT_FOUND", Retryable: true}) - case errors.Is(err, linedomain.ErrNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "LINE_NOT_FOUND", Retryable: false}) - case errors.Is(err, lineapp.ErrCandidateInvalid): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_CANDIDATE_NOT_READY", Retryable: true}) - case errors.Is(err, lineapp.ErrAlreadyManaged): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "LINE_ALREADY_ADDED", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "managed line mutation failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "LINE_PERSIST_FAILED", Retryable: true}) - } -} - func managedLineResponse(item linedomain.View) openapi.ManagedLine { phoneNumbers := make([]openapi.PhoneNumberObservation, 0, len(item.PhoneNumbers)) for _, observation := range item.PhoneNumbers { @@ -2731,216 +780,6 @@ func managedLineResponse(item linedomain.View) openapi.ManagedLine { } } -func (server *Server) ListModemCandidates(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.modems == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - items, err := server.modems.Candidates(r.Context()) - if err != nil { - server.logger.WarnContext(r.Context(), "modem candidate scan failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_SCAN_FAILED", Retryable: true}) - return - } - candidates := make([]openapi.ModemCandidate, 0, len(items)) - for _, item := range items { - candidates = append(candidates, openapi.ModemCandidate{ - CandidateId: item.CandidateID, UsbAddress: item.USBAddress, - VendorId: item.USBVendorID, ProductId: item.USBProductID, UsbSerialHint: item.USBSerialHint, - Model: item.Model, Transport: openapi.DeviceTransport(item.Transport), - SupportStatus: openapi.ModemSupportStatus(item.Support), Addable: item.Addable, - ReadinessReason: openapi.ModemCandidateReadinessReason(item.Readiness), - Capabilities: hardwareCapabilitiesResponse(item.Capabilities), SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), - }) - } - writeJSON(w, http.StatusOK, openapi.ModemCandidateList{Candidates: candidates}) -} - -func (server *Server) AddManagedModem(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.modems == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.AddManagedModemRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_ADD_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.modems.Add(r.Context(), request.CandidateId) - if err != nil { - switch { - case errors.Is(err, modemapp.ErrCandidateInvalid): - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_ADD_REQUEST_INVALID", Retryable: false}) - case errors.Is(err, modemapp.ErrCandidateNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_CANDIDATE_NOT_FOUND", Retryable: true}) - case errors.Is(err, modemapp.ErrCandidateNotReady): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_CANDIDATE_NOT_READY", Retryable: true}) - case errors.Is(err, modemapp.ErrAlreadyManaged): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_ALREADY_ADDED", Retryable: false}) - case errors.Is(err, modemapp.ErrIdentityConflict): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_CONFLICT", Retryable: false}) - default: - server.logger.ErrorContext(r.Context(), "managed modem add failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "MODEM_ADD_FAILED", Retryable: true}) - } - return - } - server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicModems, realtime.TopicLines}, "") - w.Header().Set("Location", "/api/v1/modems/"+item.ID) - writeJSON(w, http.StatusCreated, managedModemResponse(item)) -} - -func (server *Server) SetManagedModemRFState(w http.ResponseWriter, r *http.Request, modemID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.modems == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - var request openapi.SetManagedModemRFStateRequest - if err := decodeJSON(w, r, &request); err != nil { - writeJSON(w, http.StatusBadRequest, openapi.ApiError{Code: "MODEM_RF_REQUEST_INVALID", Retryable: false}) - return - } - item, err := server.modems.SetRFState(r.Context(), modemID, request.Enabled) - if err != nil { - switch { - case errors.Is(err, modemapp.ErrModemNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_NOT_FOUND", Retryable: false}) - case errors.Is(err, modemapp.ErrRFUnavailable): - writeJSON(w, http.StatusUnprocessableEntity, openapi.ApiError{Code: "MODEM_RF_UNAVAILABLE", Retryable: false}) - default: - server.logger.WarnContext(r.Context(), "managed modem RF change failed", "modem_id", modemID, "error", err) - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_RF_CHANGE_FAILED", Retryable: true}) - } - return - } - server.publish([]realtime.Topic{realtime.TopicInventory, realtime.TopicModems, realtime.TopicLines}, "") - writeJSON(w, http.StatusOK, managedModemResponse(item)) -} - -func (server *Server) ReadManagedModemEquipmentIdentity(w http.ResponseWriter, r *http.Request, modemID string) { - if !server.requireBusinessAPI(w, r) { - return - } - if server.modems == nil { - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_MANAGEMENT_UNAVAILABLE", Retryable: true}) - return - } - imei, err := server.modems.ReadEquipmentIdentity(r.Context(), modemID) - if err != nil { - switch { - case errors.Is(err, modemapp.ErrModemNotFound): - writeJSON(w, http.StatusNotFound, openapi.ApiError{Code: "MODEM_NOT_FOUND", Retryable: false}) - case errors.Is(err, modemapp.ErrIdentityConflict): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_CONFLICT", Retryable: true}) - case errors.Is(err, modemapp.ErrEquipmentIdentityUnavailable): - writeJSON(w, http.StatusConflict, openapi.ApiError{Code: "MODEM_IDENTITY_UNAVAILABLE", Retryable: true}) - default: - server.logger.WarnContext(r.Context(), "managed modem identity read failed", "modem_id", modemID, "error", err) - writeJSON(w, http.StatusServiceUnavailable, openapi.ApiError{Code: "MODEM_IDENTITY_UNAVAILABLE", Retryable: true}) - } - return - } - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Pragma", "no-cache") - writeJSON(w, http.StatusOK, openapi.ManagedModemEquipmentIdentity{Imei: imei}) -} - -func managedModemResponse(item modemdomain.View) openapi.ManagedModem { - if item.Cellular.State == "" { - item.Cellular = modemdomain.UnavailableCellularStatus() - } - registrations := make([]openapi.CellularRegistration, 0, len(item.Cellular.Registrations)) - for _, registration := range item.Cellular.Registrations { - registrations = append(registrations, openapi.CellularRegistration{ - Domain: openapi.CellularRegistrationDomain(registration.Domain), State: openapi.CellularRegistrationState(registration.State), - }) - } - observedAt := "" - if !item.Cellular.ObservedAt.IsZero() { - observedAt = item.Cellular.ObservedAt.UTC().Format(time.RFC3339) - } - return openapi.ManagedModem{ - Id: item.ID, DisplayName: item.DisplayName, Model: item.Model, SerialNumber: item.SerialNumber, - Transport: openapi.DeviceTransport(item.Transport), State: openapi.ManagedModemState(item.State), - Capabilities: hardwareCapabilitiesResponse(item.Capabilities), RfState: openapi.ManagedModemRFState(item.RFState), - SimPresence: openapi.ManagedModemSIMPresence(item.SIMPresence), Cellular: openapi.ManagedModemCellularStatus{ - State: openapi.CellularState(item.Cellular.State), ErrorCode: openapi.CellularErrorCode(item.Cellular.ErrorCode), - Registrations: registrations, OperatorName: item.Cellular.OperatorName, OperatorCode: item.Cellular.OperatorCode, - Rat: item.Cellular.RAT, SignalState: openapi.CellularSignalState(item.Cellular.SignalState), - SignalRssiDbm: item.Cellular.SignalRSSIDBm, ObservedAt: observedAt, - }, AddedAt: item.AddedAt, - } -} - -func (server *Server) GetHardwareTopology(w http.ResponseWriter, r *http.Request) { - if !server.requireBusinessAPI(w, r) { - return - } - topology, err := server.inventory.Topology(r.Context()) - if err != nil { - server.logger.ErrorContext(r.Context(), "hardware topology failed", "error", err) - writeJSON(w, http.StatusInternalServerError, openapi.ApiError{Code: "HARDWARE_TOPOLOGY_UNAVAILABLE", Retryable: true}) - return - } - writeJSON(w, http.StatusOK, hardwareTopologyResponse(topology)) -} - -func setupHardwareReviewInput(topology inventory.Topology) (setupapp.HardwareReviewInput, error) { - topologyDigest, err := inventory.SetupDigest(topology) - if err != nil { - return setupapp.HardwareReviewInput{}, err - } - summaries := make(map[string]inventory.PhysicalDevice, len(topology.Devices)) - for _, device := range topology.Devices { - summaries[device.ID] = inventory.PhysicalDevice{ID: device.ID, DisplayName: device.DisplayName, Transport: device.Transport, State: device.State, Generation: device.Generation} - } - for _, function := range topology.ModemFunctions { - summary := summaries[function.PhysicalDeviceID] - summary.ModemFunctionCount++ - summaries[function.PhysicalDeviceID] = summary - } - for _, slot := range topology.SIMSlots { - summary := summaries[slot.PhysicalDeviceID] - summary.SIMSlotCount++ - summaries[slot.PhysicalDeviceID] = summary - } - for _, group := range topology.ResourceGroups { - summary := summaries[group.PhysicalDeviceID] - summary.ResourceGroupCount++ - summaries[group.PhysicalDeviceID] = summary - } - devices := make([]setupapp.HardwareDevice, 0, len(topology.Devices)) - for _, physical := range topology.Devices { - device := summaries[physical.ID] - devices = append(devices, setupapp.HardwareDevice{ - ID: device.ID, - Transport: device.Transport, - State: device.State, - ModemFunctionCount: device.ModemFunctionCount, - SIMSlotCount: device.SIMSlotCount, - ResourceGroupCount: device.ResourceGroupCount, - }) - } - lines := make([]setupapp.HardwareLine, 0, len(topology.Lines)) - for _, line := range topology.Lines { - lines = append(lines, setupapp.HardwareLine{ - ID: line.ID, - PhysicalDeviceID: line.PhysicalDeviceID, - SubscriptionProfileID: line.SubscriptionProfileID, - }) - } - return setupapp.HardwareReviewInput{TopologyDigest: topologyDigest, Devices: devices, Lines: lines}, nil -} - func inventoryResponse(snapshot inventory.Snapshot) openapi.InventoryResponse { devices := make([]openapi.PhysicalDeviceSummary, 0, len(snapshot.Devices)) for _, device := range snapshot.Devices { @@ -2969,20 +808,6 @@ func inventoryResponse(snapshot inventory.Snapshot) openapi.InventoryResponse { return openapi.InventoryResponse{Generation: int64(snapshot.Generation), Revision: snapshot.Revision, ObservedAt: snapshot.ObservedAt, Devices: devices, Lines: lines} } -func smsMessageResponse(message sms.Message) openapi.SMSMessage { - response := openapi.SMSMessage{ - Id: message.ID, OperationId: message.OperationID, Direction: openapi.SMSDirection(message.Direction), - LineId: message.LineID, RemoteAddress: message.RemoteAddress, Body: message.Body, - Status: openapi.SMSStatus(message.Status), ProviderMessageId: message.ProviderMessageID, ErrorCode: message.ErrorCode, - CreatedAt: message.CreatedAt.UTC(), UpdatedAt: message.UpdatedAt.UTC(), - } - if message.SentAt != nil { - sentAt := message.SentAt.UTC() - response.SentAt = &sentAt - } - return response -} - func hardwareTopologyResponse(topology inventory.Topology) openapi.HardwareTopologyResponse { devices := make([]openapi.PhysicalDeviceDetail, 0, len(topology.Devices)) for _, device := range topology.Devices { diff --git a/internal/api/httpapi/server_ports_test.go b/internal/api/httpapi/server_ports_test.go index d370c35..5de52c0 100644 --- a/internal/api/httpapi/server_ports_test.go +++ b/internal/api/httpapi/server_ports_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "reflect" - "sort" "strings" "testing" @@ -40,46 +39,6 @@ func (manager *fakeSetupManager) Status(context.Context) (setupapp.Status, error return manager.status, nil } -func (*fakeSetupManager) ConsumeBootstrap(context.Context, string) (setupapp.SessionGrant, error) { - return setupapp.SessionGrant{}, nil -} - -func (*fakeSetupManager) ReadSession(context.Context, string) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) ConfigureAdministrator(context.Context, string, setupapp.AdministratorInput) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) ConfigureStorage(context.Context, string, setupapp.StorageInput) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) ConfigureHTTPS(context.Context, string, setupapp.HTTPSInput) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) ConfirmHTTPS(context.Context, string, string) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) ReadRootCertificate(context.Context, string) ([]byte, string, error) { - return nil, "", nil -} - -func (*fakeSetupManager) ConfirmHardwareReview(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Session, error) { - return setupapp.Session{}, nil -} - -func (*fakeSetupManager) Complete(context.Context, string, setupapp.HardwareReviewInput) (setupapp.Completion, error) { - return setupapp.Completion{}, nil -} - -func (*fakeSetupManager) BeginAdministratorSetup(context.Context) (setupapp.SessionGrant, error) { - return setupapp.SessionGrant{}, nil -} - type fakeInventoryReader struct{} func (*fakeInventoryReader) Snapshot(context.Context) (inventory.Snapshot, error) { @@ -109,37 +68,6 @@ func (manager *fakeRealtimeManager) Publish(topics []realtime.Topic, attention r manager.attention = attention } -func TestHTTPApplicationPortMethodSetsRemainNarrow(t *testing.T) { - tests := []struct { - name string - port reflect.Type - expected []string - }{ - {name: "health", port: reflect.TypeOf((*HealthReader)(nil)).Elem(), expected: []string{"Snapshot"}}, - {name: "setup", port: reflect.TypeOf((*SetupManager)(nil)).Elem(), expected: []string{ - "Status", "ConsumeBootstrap", "ReadSession", "ConfigureAdministrator", "ConfigureStorage", - "ConfigureHTTPS", "ConfirmHTTPS", "ReadRootCertificate", "ConfirmHardwareReview", "Complete", - "BeginAdministratorSetup", - }}, - {name: "inventory", port: reflect.TypeOf((*InventoryReader)(nil)).Elem(), expected: []string{"Snapshot", "Topology"}}, - {name: "realtime", port: reflect.TypeOf((*RealtimeManager)(nil)).Elem(), expected: []string{"Subscribe", "Publish"}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - actual := make([]string, 0, test.port.NumMethod()) - for index := 0; index < test.port.NumMethod(); index++ { - actual = append(actual, test.port.Method(index).Name) - } - sort.Strings(actual) - sort.Strings(test.expected) - if !reflect.DeepEqual(actual, test.expected) { - t.Fatalf("methods = %v, want %v", actual, test.expected) - } - }) - } -} - func TestServerAcceptsIndependentApplicationPortFakes(t *testing.T) { events := make(chan realtime.Event) close(events) @@ -181,20 +109,13 @@ func TestServerTreatsNilApplicationPortsAsUnavailable(t *testing.T) { realtime RealtimeManager }{ {name: "raw nil"}, - { - name: "typed nil", - health: (*fakeHealthReader)(nil), - setup: (*fakeSetupManager)(nil), - inventory: (*fakeInventoryReader)(nil), - realtime: (*fakeRealtimeManager)(nil), - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := WithRealtime(New(test.health, test.setup, test.inventory, nil, acceptingAuthenticator{}, nil), test.realtime) - if !httpDependencyMissing(server.health) || !httpDependencyMissing(server.setup) || - !httpDependencyMissing(server.inventory) || !httpDependencyMissing(server.realtime) { + if !featureDisabled(server.health) || !featureDisabled(server.setup) || + !featureDisabled(server.inventory) || !featureDisabled(server.realtime) { t.Fatalf("dependencies were not absent = health:%v setup:%v inventory:%v realtime:%v", server.health, server.setup, server.inventory, server.realtime) } if server.realtimeSessionValid(context.Background(), "synthetic") { diff --git a/internal/api/httpapi/server_test.go b/internal/api/httpapi/server_test.go index 4b5e3d8..2f3286b 100644 --- a/internal/api/httpapi/server_test.go +++ b/internal/api/httpapi/server_test.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "crypto/tls" "encoding/json" "errors" "io" @@ -18,6 +17,10 @@ import ( "testing" "time" + notificationdomain "github.com/leonfox28/simplus/internal/domain/notification" + + "github.com/leonfox28/simplus/internal/smstransport" + "github.com/leonfox28/simplus/internal/agentapi" "github.com/leonfox28/simplus/internal/api/openapi" authapp "github.com/leonfox28/simplus/internal/application/auth" @@ -36,7 +39,6 @@ import ( modemdomain "github.com/leonfox28/simplus/internal/domain/modem" vowifidomain "github.com/leonfox28/simplus/internal/domain/vowifi" "github.com/leonfox28/simplus/internal/security/password" - storagefs "github.com/leonfox28/simplus/internal/storage/filesystem" sqlitestore "github.com/leonfox28/simplus/internal/storage/sqlite" ) @@ -71,7 +73,7 @@ func (*testNotificationManager) Delete(context.Context, string) error { return n func (*testNotificationManager) Test(context.Context, string) (notificationapp.ChannelView, error) { return notificationapp.ChannelView{}, nil } -func (*testNotificationManager) Notify(context.Context, string, string) error { return nil } +func (*testNotificationManager) Enqueue(context.Context, notificationdomain.Event) error { return nil } func (manager *testNotificationManager) FeishuBindingStatus() notificationapp.BindingView { return manager.state } @@ -274,54 +276,6 @@ func (testSetupSecretProtector) Encrypt(label string, plaintext []byte) ([]byte, return append([]byte(label+":"), plaintext...), nil } -func newStatusSetupService(t *testing.T, store setupapp.StateStore) *setupapp.Service { - t.Helper() - service, err := setupapp.New(setupapp.Dependencies{StateStore: store}) - if err != nil { - t.Fatal(err) - } - return service -} - -func newFullSetupService(t *testing.T, stores *sqlitestore.Set) *setupapp.Service { - t.Helper() - service, err := setupapp.New(setupapp.Dependencies{ - StateStore: stores, - AuthorizationStore: stores, - AdministratorStore: stores, - PasswordHasher: password.NewDefaultHasher(), - StorageStore: stores, - DirectoryPreparer: func(path string) (setupapp.DirectoryIdentity, error) { - identity, err := storagefs.PreparePrivateDirectory(path) - if err != nil { - return setupapp.DirectoryIdentity{}, err - } - return setupapp.DirectoryIdentity{Path: identity.Path, Device: identity.Device, Inode: identity.Inode}, nil - }, - ManagementTLSStore: stores, - SecretProtectorOpener: func() (setupapp.SecretProtector, error) { - return testSetupSecretProtector{}, nil - }, - LocalCAGenerator: func(now time.Time, sans []string) (setupapp.LocalCABundle, error) { - return setupapp.LocalCABundle{ - CACertificatePEM: []byte("test-ca-certificate"), - CAPrivateKeyPEM: []byte("test-ca-private-key"), - LeafCertificatePEM: []byte("test-leaf-certificate"), - LeafPrivateKeyPEM: []byte("test-leaf-private-key"), - RootFingerprint: strings.Repeat("a", 64), - LeafNotAfter: now.Add(90 * 24 * time.Hour), - SANs: append([]string(nil), sans...), - }, nil - }, - HardwareReviewStore: stores, - CompletionStore: stores, - }) - if err != nil { - t.Fatal(err) - } - return service -} - const testBusinessLineID = "line_AQEBAQEBAQEBAQEBAQEBAQ" type testBusinessLineSource struct{ source *inventory.Service } @@ -638,260 +592,11 @@ func TestSystemHealthStartsFailClosedAndUninitialized(t *testing.T) { if body.InstallationState != openapi.InstallationState("uninitialized") { t.Fatalf("installationState = %q", body.InstallationState) } - if body.DatabaseCount != 5 { + if body.DatabaseCount != 1 { t.Fatalf("databaseCount = %d", body.DatabaseCount) } } -func TestSetupStatusExposesBootstrapBoundary(t *testing.T) { - stores, err := sqlitestore.OpenSet(context.Background(), filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer stores.Close() - handler := newAuthorizedTestHandler(t, stores) - request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) - request.Host = "127.0.0.1:8080" - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - - if response.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) - } - var body openapi.SetupStatusResponse - if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if body.InstallationState != openapi.InstallationStateUninitialized || body.Phase != openapi.SetupPhaseBootstrapRequired { - t.Fatalf("setup status = %#v", body) - } - if !body.SetupRequired || body.BusinessApiAvailable || body.BootstrapGenerationAvailable { - t.Fatalf("unexpected setup boundary = %#v", body) - } - if len(body.SupportedFlows) != 1 || body.SupportedFlows[0] != openapi.CreateNew { - t.Fatalf("supported flows = %#v", body.SupportedFlows) - } -} - -func TestBootstrapExchangeCreatesOneTimePersistentSetupSession(t *testing.T) { - stores, err := sqlitestore.OpenSet(context.Background(), filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer stores.Close() - setupService := newFullSetupService(t, stores) - grant, err := setupService.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - handler := newAuthorizedTestHandler(t, stores) - - consume := func() *httptest.ResponseRecorder { - request := httptest.NewRequest(http.MethodPost, "/api/v1/setup/bootstrap", strings.NewReader(`{"bootstrapCode":"`+grant.Code+`"}`)) - request.Host = "127.0.0.1:8080" - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - return response - } - response := consume() - if response.Code != http.StatusOK { - t.Fatalf("consume status = %d, body = %s", response.Code, response.Body.String()) - } - cookies := response.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("cookies = %#v", cookies) - } - cookie := cookies[0] - if cookie.Name != setupSessionCookieName || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/api/v1/setup" { - t.Fatalf("setup cookie = %#v", cookie) - } - if strings.Contains(response.Body.String(), cookie.Value) || strings.Contains(response.Body.String(), grant.Code) { - t.Fatal("setup response exposed an opaque credential in its body") - } - var session openapi.SetupSessionResponse - if err := json.Unmarshal(response.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if !bool(session.Authorized) || session.SelectedFlow != openapi.CreateNew { - t.Fatalf("initial session = %#v", session) - } - - if replay := consume(); replay.Code != http.StatusUnauthorized { - t.Fatalf("bootstrap replay status = %d, body = %s", replay.Code, replay.Body.String()) - } - - resumedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/setup/session", nil) - resumedRequest.Host = "127.0.0.1:8080" - resumedRequest.AddCookie(cookie) - resumedResponse := httptest.NewRecorder() - newAuthorizedTestHandler(t, stores).ServeHTTP(resumedResponse, resumedRequest) - if resumedResponse.Code != http.StatusOK { - t.Fatalf("resume status = %d, body = %s", resumedResponse.Code, resumedResponse.Body.String()) - } - if err := json.Unmarshal(resumedResponse.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if session.SelectedFlow != openapi.CreateNew { - t.Fatalf("resumed flow response = %#v", session) - } - - if _, err := setupService.GenerateBootstrap(context.Background()); err != nil { - t.Fatal(err) - } - revokedResponse := httptest.NewRecorder() - handler.ServeHTTP(revokedResponse, resumedRequest) - if revokedResponse.Code != http.StatusUnauthorized { - t.Fatalf("revoked session status = %d, body = %s", revokedResponse.Code, revokedResponse.Body.String()) - } -} - -func TestSetupAdministratorRequiresSessionAndPersistsArgon2idCredential(t *testing.T) { - stores, err := sqlitestore.OpenSet(context.Background(), filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer stores.Close() - setupService := newFullSetupService(t, stores) - grant, err := setupService.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - handler := newAuthorizedTestHandler(t, stores) - bootstrapRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/bootstrap", strings.NewReader(`{"bootstrapCode":"`+grant.Code+`"}`)) - bootstrapRequest.Host = "127.0.0.1:8080" - bootstrapRequest.Header.Set("Content-Type", "application/json") - bootstrapResponse := httptest.NewRecorder() - handler.ServeHTTP(bootstrapResponse, bootstrapRequest) - if bootstrapResponse.Code != http.StatusOK { - t.Fatalf("bootstrap status = %d, body = %s", bootstrapResponse.Code, bootstrapResponse.Body.String()) - } - cookie := bootstrapResponse.Result().Cookies()[0] - - const requestBody = `{"username":"Leon","password":"correct horse battery staple","passwordConfirmation":"correct horse battery staple","instanceDefaultLocale":"zh-CN"}` - unauthorizedRequest := httptest.NewRequest(http.MethodPut, "/api/v1/setup/administrator", strings.NewReader(requestBody)) - unauthorizedRequest.Host = "127.0.0.1:8080" - unauthorizedRequest.Header.Set("Content-Type", "application/json") - unauthorizedResponse := httptest.NewRecorder() - handler.ServeHTTP(unauthorizedResponse, unauthorizedRequest) - if unauthorizedResponse.Code != http.StatusUnauthorized { - t.Fatalf("unauthorized status = %d, body = %s", unauthorizedResponse.Code, unauthorizedResponse.Body.String()) - } - - request := httptest.NewRequest(http.MethodPut, "/api/v1/setup/administrator", strings.NewReader(requestBody)) - request.Host = "127.0.0.1:8080" - request.Header.Set("Content-Type", "application/json") - request.AddCookie(cookie) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) - } - if strings.Contains(response.Body.String(), "correct horse") || strings.Contains(response.Body.String(), "$argon2id$") { - t.Fatal("administrator response exposed password material") - } - var session openapi.SetupSessionResponse - if err := json.Unmarshal(response.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if !session.AdministratorConfigured || session.AdministratorUsername != "leon" || session.InstanceDefaultLocale != "zh-CN" { - t.Fatalf("administrator session = %#v", session) - } - credential, err := stores.ReadAdministratorCredential(context.Background(), "leon") - if err != nil { - t.Fatal(err) - } - if !credential.Found || !strings.HasPrefix(credential.PasswordHash, "$argon2id$v1$") || strings.Contains(credential.PasswordHash, "correct horse") { - t.Fatalf("stored credential = %#v", credential) - } - - storageBody, err := json.Marshal(openapi.ConfigureSetupStorageRequest{RecordingsRoot: filepath.Join(stores.Root, "recordings")}) - if err != nil { - t.Fatal(err) - } - storageRequest := httptest.NewRequest(http.MethodPut, "/api/v1/setup/storage", bytes.NewReader(storageBody)) - storageRequest.Host = "127.0.0.1:8080" - storageRequest.Header.Set("Content-Type", "application/json") - storageRequest.AddCookie(cookie) - storageResponse := httptest.NewRecorder() - handler.ServeHTTP(storageResponse, storageRequest) - if storageResponse.Code != http.StatusOK { - t.Fatalf("storage status = %d, body = %s", storageResponse.Code, storageResponse.Body.String()) - } - if err := json.Unmarshal(storageResponse.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if !session.StorageConfigured || session.DataRoot != stores.Root || session.RecordingsRoot != filepath.Join(stores.Root, "recordings") { - t.Fatalf("storage session = %#v", session) - } - - httpsRequest := httptest.NewRequest(http.MethodPut, "/api/v1/setup/https", strings.NewReader(`{"mode":"loopback-only","listenHost":"127.0.0.1","listenPort":8080,"subjectAlternativeNames":[]}`)) - httpsRequest.Host = "127.0.0.1:8080" - httpsRequest.Header.Set("Content-Type", "application/json") - httpsRequest.AddCookie(cookie) - httpsResponse := httptest.NewRecorder() - handler.ServeHTTP(httpsResponse, httpsRequest) - if httpsResponse.Code != http.StatusOK { - t.Fatalf("HTTPS status = %d, body = %s", httpsResponse.Code, httpsResponse.Body.String()) - } - if err := json.Unmarshal(httpsResponse.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if !session.HttpsConfigured || !session.HttpsConfirmed || session.HttpsMode != "loopback-only" || session.HttpsListenUrl != "http://127.0.0.1:8080" { - t.Fatalf("HTTPS session = %#v", session) - } - - setupTopologyRequest := httptest.NewRequest(http.MethodGet, "/api/v1/setup/hardware/topology", nil) - setupTopologyRequest.Host = "127.0.0.1:8080" - setupTopologyRequest.AddCookie(cookie) - setupTopologyResponse := httptest.NewRecorder() - handler.ServeHTTP(setupTopologyResponse, setupTopologyRequest) - if setupTopologyResponse.Code != http.StatusOK { - t.Fatalf("setup topology status = %d, body = %s", setupTopologyResponse.Code, setupTopologyResponse.Body.String()) - } - var setupTopology openapi.HardwareTopologyResponse - if err := json.Unmarshal(setupTopologyResponse.Body.Bytes(), &setupTopology); err != nil { - t.Fatal(err) - } - if len(setupTopology.SubscriptionProfiles) != 1 || setupTopology.SubscriptionProfiles[0].Id != "simulator-profile-1" { - t.Fatalf("setup topology = %#v", setupTopology) - } - - hardwareRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/hardware/confirm", nil) - hardwareRequest.Host = "127.0.0.1:8080" - hardwareRequest.AddCookie(cookie) - hardwareResponse := httptest.NewRecorder() - handler.ServeHTTP(hardwareResponse, hardwareRequest) - if hardwareResponse.Code != http.StatusOK { - t.Fatalf("hardware confirmation status = %d, body = %s", hardwareResponse.Code, hardwareResponse.Body.String()) - } - if err := json.Unmarshal(hardwareResponse.Body.Bytes(), &session); err != nil { - t.Fatal(err) - } - if !session.HardwareReviewed || session.HardwareDeviceCount != 1 || session.HardwareLineCount != 1 || len(session.HardwareInventoryDigest) != 64 { - t.Fatalf("hardware session = %#v", session) - } - completionRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/complete", nil) - completionRequest.Host = "127.0.0.1:8080" - completionRequest.AddCookie(cookie) - completionResponse := httptest.NewRecorder() - handler.ServeHTTP(completionResponse, completionRequest) - if completionResponse.Code != http.StatusOK { - t.Fatalf("setup completion status = %d, body = %s", completionResponse.Code, completionResponse.Body.String()) - } - var completion openapi.SetupCompletionResponse - if err := json.Unmarshal(completionResponse.Body.Bytes(), &completion); err != nil { - t.Fatal(err) - } - if completion.InstallationState != openapi.SetupCompletionResponseInstallationStateReady || !bool(completion.LoginRequired) { - t.Fatalf("completion = %#v", completion) - } - status, err := stores.InstallationState(context.Background()) - if err != nil || status != setupapp.InstallationReady { - t.Fatalf("installation state after completion = %q/%v", status, err) - } -} - func TestBusinessAPIsStayLockedUntilSetupCompletes(t *testing.T) { handler := newTestHandler(t, fixedStateStore(setupapp.InstallationUninitialized), inventory.NewSimulator()) for _, path := range []string{"/api/v1/inventory", "/api/v1/lines"} { @@ -913,25 +618,6 @@ func TestBusinessAPIsStayLockedUntilSetupCompletes(t *testing.T) { } } -func TestSetupStatusReturnsStableErrorCode(t *testing.T) { - handler := newTestHandler(t, failingStateStore{}, newTestInventory()) - request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) - request.Host = "127.0.0.1:8080" - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - - if response.Code != http.StatusInternalServerError { - t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) - } - var body openapi.ApiError - if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if body.Code != "SETUP_STATUS_UNAVAILABLE" || !body.Retryable { - t.Fatalf("error body = %#v", body) - } -} - func TestAdministratorLoginSessionCSRFAndLogout(t *testing.T) { ctx := context.Background() stores, err := sqlitestore.OpenSet(ctx, filepath.Join(t.TempDir(), "db")) @@ -946,7 +632,7 @@ func TestAdministratorLoginSessionCSRFAndLogout(t *testing.T) { if err := stores.ConfigureInitialAdministrator(ctx, "admin", hash, "zh-CN", time.Now()); err != nil { t.Fatal(err) } - if _, err := stores.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { + if _, err := stores.DB.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) @@ -1051,7 +737,7 @@ func TestMessageSendAndHistoryUseBusinessAuthCSRFAndIdempotency(t *testing.T) { t.Fatal(err) } defer stores.Close() - if _, err := stores.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { + if _, err := stores.DB.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { t.Fatal(err) } inventoryService := inventory.NewSimulator() @@ -1060,7 +746,7 @@ func TestMessageSendAndHistoryUseBusinessAuthCSRFAndIdempotency(t *testing.T) { if err != nil { t.Fatal(err) } - simulatorGateway, err := messageapp.NewAgentSMSGateway(simulatorClient, simulatorAgentInstanceID) + simulatorGateway, err := smstransport.NewAgentSMSGateway(simulatorClient) if err != nil { t.Fatal(err) } @@ -1309,7 +995,7 @@ func TestContactsCRUDUsesBusinessAuthCSRFAndDurableStore(t *testing.T) { t.Fatal(err) } defer stores.Close() - if _, err := stores.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { + if _, err := stores.DB.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { t.Fatal(err) } contacts, err := contactapp.New(stores) @@ -1370,7 +1056,7 @@ func TestSimulatorCallHTTPFlowRejectsEmergencyAndPersistsHistory(t *testing.T) { t.Fatal(err) } defer stores.Close() - if _, err := stores.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { + if _, err := stores.DB.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { t.Fatal(err) } lines := inventory.NewSimulator() @@ -1802,18 +1488,6 @@ func TestRouterRejectsUntrustedHostHeader(t *testing.T) { } } -func TestRequestManagementURLUsesValidatedRequestAuthority(t *testing.T) { - request := httptest.NewRequest(http.MethodPost, "http://192.168.50.10:8080/api/v1/setup/complete", nil) - request.Host = "192.168.50.10:8080" - if actual := requestManagementURL(request); actual != "http://192.168.50.10:8080" { - t.Fatalf("management URL = %q", actual) - } - request.TLS = &tls.ConnectionState{} - if actual := requestManagementURL(request); actual != "https://192.168.50.10:8080" { - t.Fatalf("TLS management URL = %q", actual) - } -} - func TestFeishuBindingRouterRequiresAuthAndCSRFUsesNoStoreAndHidesCredentials(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) manager := &testNotificationManager{state: notificationapp.BindingView{ @@ -1892,3 +1566,23 @@ func TestFeishuBindingRouterRequiresAuthAndCSRFUsesNoStoreAndHidesCredentials(t t.Fatalf("cancel conflict status=%d body=%s", cancelConflictResponse.Code, cancelConflictResponse.Body.String()) } } + +func newFullSetupService(t *testing.T, stores *sqlitestore.Set) *setupapp.Service { + t.Helper() + service, err := setupapp.New(setupapp.Dependencies{StateStore: stores, AdministratorStore: stores, PasswordHasher: password.NewDefaultHasher()}) + if err != nil { + t.Fatal(err) + } + return service +} + +type testInstallationStatus struct{ store setupapp.StateStore } + +func (s testInstallationStatus) Status(ctx context.Context) (setupapp.Status, error) { + state, err := s.store.InstallationState(ctx) + return setupapp.Status{InstallationState: state, BusinessAPIAvailable: state == "ready", SetupRequired: state == "uninitialized"}, err +} +func newStatusSetupService(t *testing.T, store setupapp.StateStore) SetupManager { + t.Helper() + return testInstallationStatus{store} +} diff --git a/internal/api/openapi/generated.go b/internal/api/openapi/generated.go index ed5534b..5b98fbe 100644 --- a/internal/api/openapi/generated.go +++ b/internal/api/openapi/generated.go @@ -22,16 +22,16 @@ import ( // Defines values for AuthSessionResponseLocale. const ( - AuthSessionResponseLocaleEnUS AuthSessionResponseLocale = "en-US" - AuthSessionResponseLocaleZhCN AuthSessionResponseLocale = "zh-CN" + EnUS AuthSessionResponseLocale = "en-US" + ZhCN AuthSessionResponseLocale = "zh-CN" ) // Valid indicates whether the value is a known member of the AuthSessionResponseLocale enum. func (e AuthSessionResponseLocale) Valid() bool { switch e { - case AuthSessionResponseLocaleEnUS: + case EnUS: return true - case AuthSessionResponseLocaleZhCN: + case ZhCN: return true default: return false @@ -41,7 +41,6 @@ func (e AuthSessionResponseLocale) Valid() bool { // Defines values for BackendKind. const ( Hardware BackendKind = "hardware" - Replay BackendKind = "replay" Simulator BackendKind = "simulator" ) @@ -50,8 +49,6 @@ func (e BackendKind) Valid() bool { switch e { case Hardware: return true - case Replay: - return true case Simulator: return true default: @@ -284,24 +281,6 @@ func (e CellularState) Valid() bool { } } -// Defines values for ConfigureSetupAdministratorRequestInstanceDefaultLocale. -const ( - ConfigureSetupAdministratorRequestInstanceDefaultLocaleEnUS ConfigureSetupAdministratorRequestInstanceDefaultLocale = "en-US" - ConfigureSetupAdministratorRequestInstanceDefaultLocaleZhCN ConfigureSetupAdministratorRequestInstanceDefaultLocale = "zh-CN" -) - -// Valid indicates whether the value is a known member of the ConfigureSetupAdministratorRequestInstanceDefaultLocale enum. -func (e ConfigureSetupAdministratorRequestInstanceDefaultLocale) Valid() bool { - switch e { - case ConfigureSetupAdministratorRequestInstanceDefaultLocaleEnUS: - return true - case ConfigureSetupAdministratorRequestInstanceDefaultLocaleZhCN: - return true - default: - return false - } -} - // Defines values for DeviceTransport. const ( DeviceTransportSimulated DeviceTransport = "simulated" @@ -880,11 +859,15 @@ func (e NotificationChannelMutationProvider) Valid() bool { // Defines values for NotificationEventKind. const ( - NotificationEventKindCallIncoming NotificationEventKind = "call.incoming" - NotificationEventKindCallMissed NotificationEventKind = "call.missed" - NotificationEventKindSmsFailed NotificationEventKind = "sms.failed" - NotificationEventKindSmsReceived NotificationEventKind = "sms.received" - NotificationEventKindSystemDegraded NotificationEventKind = "system.degraded" + NotificationEventKindCallIncoming NotificationEventKind = "call.incoming" + NotificationEventKindCallMissed NotificationEventKind = "call.missed" + NotificationEventKindCellularConnected NotificationEventKind = "cellular.connected" + NotificationEventKindCellularDisconnected NotificationEventKind = "cellular.disconnected" + NotificationEventKindSmsFailed NotificationEventKind = "sms.failed" + NotificationEventKindSmsReceived NotificationEventKind = "sms.received" + NotificationEventKindSystemDegraded NotificationEventKind = "system.degraded" + NotificationEventKindVowifiConnected NotificationEventKind = "vowifi.connected" + NotificationEventKindVowifiDisconnected NotificationEventKind = "vowifi.disconnected" ) // Valid indicates whether the value is a known member of the NotificationEventKind enum. @@ -894,12 +877,20 @@ func (e NotificationEventKind) Valid() bool { return true case NotificationEventKindCallMissed: return true + case NotificationEventKindCellularConnected: + return true + case NotificationEventKindCellularDisconnected: + return true case NotificationEventKindSmsFailed: return true case NotificationEventKindSmsReceived: return true case NotificationEventKindSystemDegraded: return true + case NotificationEventKindVowifiConnected: + return true + case NotificationEventKindVowifiDisconnected: + return true default: return false } @@ -1145,147 +1136,6 @@ func (e SMSStatus) Valid() bool { } } -// Defines values for SetupCompletionResponseInstallationState. -const ( - SetupCompletionResponseInstallationStateReady SetupCompletionResponseInstallationState = "ready" -) - -// Valid indicates whether the value is a known member of the SetupCompletionResponseInstallationState enum. -func (e SetupCompletionResponseInstallationState) Valid() bool { - switch e { - case SetupCompletionResponseInstallationStateReady: - return true - default: - return false - } -} - -// Defines values for SetupCompletionResponseLoginRequired. -const ( - SetupCompletionResponseLoginRequiredTrue SetupCompletionResponseLoginRequired = true -) - -// Valid indicates whether the value is a known member of the SetupCompletionResponseLoginRequired enum. -func (e SetupCompletionResponseLoginRequired) Valid() bool { - switch e { - case SetupCompletionResponseLoginRequiredTrue: - return true - default: - return false - } -} - -// Defines values for SetupFlow. -const ( - CreateNew SetupFlow = "create-new" -) - -// Valid indicates whether the value is a known member of the SetupFlow enum. -func (e SetupFlow) Valid() bool { - switch e { - case CreateNew: - return true - default: - return false - } -} - -// Defines values for SetupHTTPSMode. -const ( - SetupHTTPSModeLocalCa SetupHTTPSMode = "local-ca" - SetupHTTPSModeLoopbackOnly SetupHTTPSMode = "loopback-only" -) - -// Valid indicates whether the value is a known member of the SetupHTTPSMode enum. -func (e SetupHTTPSMode) Valid() bool { - switch e { - case SetupHTTPSModeLocalCa: - return true - case SetupHTTPSModeLoopbackOnly: - return true - default: - return false - } -} - -// Defines values for SetupPhase. -const ( - SetupPhaseBootstrapRequired SetupPhase = "bootstrap-required" - SetupPhaseComplete SetupPhase = "complete" - SetupPhaseMaintenance SetupPhase = "maintenance" -) - -// Valid indicates whether the value is a known member of the SetupPhase enum. -func (e SetupPhase) Valid() bool { - switch e { - case SetupPhaseBootstrapRequired: - return true - case SetupPhaseComplete: - return true - case SetupPhaseMaintenance: - return true - default: - return false - } -} - -// Defines values for SetupSessionResponseAuthorized. -const ( - SetupSessionResponseAuthorizedTrue SetupSessionResponseAuthorized = true -) - -// Valid indicates whether the value is a known member of the SetupSessionResponseAuthorized enum. -func (e SetupSessionResponseAuthorized) Valid() bool { - switch e { - case SetupSessionResponseAuthorizedTrue: - return true - default: - return false - } -} - -// Defines values for SetupSessionResponseHttpsMode. -const ( - SetupSessionResponseHttpsModeEmpty SetupSessionResponseHttpsMode = "" - SetupSessionResponseHttpsModeImported SetupSessionResponseHttpsMode = "imported" - SetupSessionResponseHttpsModeLocalCa SetupSessionResponseHttpsMode = "local-ca" - SetupSessionResponseHttpsModeLoopbackOnly SetupSessionResponseHttpsMode = "loopback-only" -) - -// Valid indicates whether the value is a known member of the SetupSessionResponseHttpsMode enum. -func (e SetupSessionResponseHttpsMode) Valid() bool { - switch e { - case SetupSessionResponseHttpsModeEmpty: - return true - case SetupSessionResponseHttpsModeImported: - return true - case SetupSessionResponseHttpsModeLocalCa: - return true - case SetupSessionResponseHttpsModeLoopbackOnly: - return true - default: - return false - } -} - -// Defines values for SetupSessionResponseInstanceDefaultLocale. -const ( - SetupSessionResponseInstanceDefaultLocaleEnUS SetupSessionResponseInstanceDefaultLocale = "en-US" - SetupSessionResponseInstanceDefaultLocaleZhCN SetupSessionResponseInstanceDefaultLocale = "zh-CN" -) - -// Valid indicates whether the value is a known member of the SetupSessionResponseInstanceDefaultLocale enum. -func (e SetupSessionResponseInstanceDefaultLocale) Valid() bool { - switch e { - case SetupSessionResponseInstanceDefaultLocaleEnUS: - return true - case SetupSessionResponseInstanceDefaultLocaleZhCN: - return true - default: - return false - } -} - // Defines values for SubscriptionProfileState. const ( SubscriptionProfileStateActive SubscriptionProfileState = "active" @@ -1502,40 +1352,6 @@ type ChangeAdministratorPasswordRequest struct { NewPasswordConfirmation string `json:"newPasswordConfirmation"` } -// ConfigureSetupAdministratorRequest defines model for ConfigureSetupAdministratorRequest. -type ConfigureSetupAdministratorRequest struct { - InstanceDefaultLocale ConfigureSetupAdministratorRequestInstanceDefaultLocale `json:"instanceDefaultLocale"` - Password string `json:"password"` - PasswordConfirmation string `json:"passwordConfirmation"` - Username string `json:"username"` -} - -// ConfigureSetupAdministratorRequestInstanceDefaultLocale defines model for ConfigureSetupAdministratorRequest.InstanceDefaultLocale. -type ConfigureSetupAdministratorRequestInstanceDefaultLocale string - -// ConfigureSetupHTTPSRequest defines model for ConfigureSetupHTTPSRequest. -type ConfigureSetupHTTPSRequest struct { - ListenHost string `json:"listenHost"` - ListenPort int `json:"listenPort"` - Mode SetupHTTPSMode `json:"mode"` - SubjectAlternativeNames []string `json:"subjectAlternativeNames"` -} - -// ConfigureSetupStorageRequest defines model for ConfigureSetupStorageRequest. -type ConfigureSetupStorageRequest struct { - RecordingsRoot string `json:"recordingsRoot"` -} - -// ConfirmSetupHTTPSRequest defines model for ConfirmSetupHTTPSRequest. -type ConfirmSetupHTTPSRequest struct { - RootFingerprintSha256 string `json:"rootFingerprintSha256"` -} - -// ConsumeBootstrapRequest defines model for ConsumeBootstrapRequest. -type ConsumeBootstrapRequest struct { - BootstrapCode string `json:"bootstrapCode"` -} - // Contact defines model for Contact. type Contact struct { CreatedAt time.Time `json:"createdAt"` @@ -1994,10 +1810,12 @@ type NotificationChannel struct { DisplayName string `json:"displayName"` Enabled bool `json:"enabled"` EventKinds []NotificationEventKind `json:"eventKinds"` + FailedCount int64 `json:"failedCount"` Id string `json:"id"` LastDeliveryAt string `json:"lastDeliveryAt"` LastDeliveryStatus NotificationChannelLastDeliveryStatus `json:"lastDeliveryStatus"` LastErrorCode string `json:"lastErrorCode"` + PendingCount int64 `json:"pendingCount"` Provider NotificationChannelProvider `json:"provider"` SigningSecretConfigured bool `json:"signingSecretConfigured"` TargetType NotificationChannelTargetType `json:"targetType"` @@ -2197,77 +2015,6 @@ type SetManagedModemRFStateRequest struct { Enabled bool `json:"enabled"` } -// SetupCompletionResponse defines model for SetupCompletionResponse. -type SetupCompletionResponse struct { - InstallationState SetupCompletionResponseInstallationState `json:"installationState"` - LoginRequired SetupCompletionResponseLoginRequired `json:"loginRequired"` - ManagementUrl string `json:"managementUrl"` -} - -// SetupCompletionResponseInstallationState defines model for SetupCompletionResponse.InstallationState. -type SetupCompletionResponseInstallationState string - -// SetupCompletionResponseLoginRequired defines model for SetupCompletionResponse.LoginRequired. -type SetupCompletionResponseLoginRequired bool - -// SetupFlow defines model for SetupFlow. -type SetupFlow string - -// SetupHTTPSMode defines model for SetupHTTPSMode. -type SetupHTTPSMode string - -// SetupPhase defines model for SetupPhase. -type SetupPhase string - -// SetupRootCertificateResponse defines model for SetupRootCertificateResponse. -type SetupRootCertificateResponse struct { - Pem string `json:"pem"` - RootFingerprintSha256 string `json:"rootFingerprintSha256"` -} - -// SetupSessionResponse defines model for SetupSessionResponse. -type SetupSessionResponse struct { - AdministratorConfigured bool `json:"administratorConfigured"` - AdministratorUsername string `json:"administratorUsername"` - Authorized SetupSessionResponseAuthorized `json:"authorized"` - DataRoot string `json:"dataRoot"` - ExpiresAt time.Time `json:"expiresAt"` - HardwareDeviceCount int `json:"hardwareDeviceCount"` - HardwareInventoryDigest string `json:"hardwareInventoryDigest"` - HardwareLineCount int `json:"hardwareLineCount"` - HardwareReviewed bool `json:"hardwareReviewed"` - HttpsConfigured bool `json:"httpsConfigured"` - HttpsConfirmed bool `json:"httpsConfirmed"` - HttpsLeafNotAfter string `json:"httpsLeafNotAfter"` - HttpsListenUrl string `json:"httpsListenUrl"` - HttpsMode SetupSessionResponseHttpsMode `json:"httpsMode"` - HttpsRootFingerprint string `json:"httpsRootFingerprint"` - InstanceDefaultLocale SetupSessionResponseInstanceDefaultLocale `json:"instanceDefaultLocale"` - RecordingsRoot string `json:"recordingsRoot"` - SelectedFlow SetupFlow `json:"selectedFlow"` - StorageConfigured bool `json:"storageConfigured"` - SupportedFlows []SetupFlow `json:"supportedFlows"` -} - -// SetupSessionResponseAuthorized defines model for SetupSessionResponse.Authorized. -type SetupSessionResponseAuthorized bool - -// SetupSessionResponseHttpsMode defines model for SetupSessionResponse.HttpsMode. -type SetupSessionResponseHttpsMode string - -// SetupSessionResponseInstanceDefaultLocale defines model for SetupSessionResponse.InstanceDefaultLocale. -type SetupSessionResponseInstanceDefaultLocale string - -// SetupStatusResponse defines model for SetupStatusResponse. -type SetupStatusResponse struct { - BootstrapGenerationAvailable bool `json:"bootstrapGenerationAvailable"` - BusinessApiAvailable bool `json:"businessApiAvailable"` - InstallationState InstallationState `json:"installationState"` - Phase SetupPhase `json:"phase"` - SetupRequired bool `json:"setupRequired"` - SupportedFlows []SetupFlow `json:"supportedFlows"` -} - // SubscriptionProfileDetail defines model for SubscriptionProfileDetail. type SubscriptionProfileDetail struct { DisplayIdentityHint string `json:"displayIdentityHint"` @@ -2414,21 +2161,6 @@ type CreateNotificationChannelJSONRequestBody = NotificationChannelMutation // UpdateNotificationChannelJSONRequestBody defines body for UpdateNotificationChannel for application/json ContentType. type UpdateNotificationChannelJSONRequestBody = NotificationChannelMutation -// PutSetupAdministratorJSONRequestBody defines body for PutSetupAdministrator for application/json ContentType. -type PutSetupAdministratorJSONRequestBody = ConfigureSetupAdministratorRequest - -// ConsumeSetupBootstrapJSONRequestBody defines body for ConsumeSetupBootstrap for application/json ContentType. -type ConsumeSetupBootstrapJSONRequestBody = ConsumeBootstrapRequest - -// PutSetupHTTPSJSONRequestBody defines body for PutSetupHTTPS for application/json ContentType. -type PutSetupHTTPSJSONRequestBody = ConfigureSetupHTTPSRequest - -// ConfirmSetupHTTPSJSONRequestBody defines body for ConfirmSetupHTTPS for application/json ContentType. -type ConfirmSetupHTTPSJSONRequestBody = ConfirmSetupHTTPSRequest - -// PutSetupStorageJSONRequestBody defines body for PutSetupStorage for application/json ContentType. -type PutSetupStorageJSONRequestBody = ConfigureSetupStorageRequest - // ServerInterface represents all server handlers. type ServerInterface interface { // Login Create an administrator browser session @@ -2605,42 +2337,6 @@ type ServerInterface interface { // TestNotificationChannel Send one explicit test message // (POST /api/v1/notification-channels/{channelId}/test) TestNotificationChannel(w http.ResponseWriter, r *http.Request, channelId string) - // PutSetupAdministrator Create or replace the sole administrator while first-run setup remains uninitialized - // (PUT /api/v1/setup/administrator) - PutSetupAdministrator(w http.ResponseWriter, r *http.Request) - // ConsumeSetupBootstrap Exchange a one-time root-generated bootstrap code for a restricted setup session - // (POST /api/v1/setup/bootstrap) - ConsumeSetupBootstrap(w http.ResponseWriter, r *http.Request) - // CompleteSetup Revalidate every setup prerequisite and atomically enter ready - // (POST /api/v1/setup/complete) - CompleteSetup(w http.ResponseWriter, r *http.Request) - // ConfirmSetupHardware Confirm the current read-only hardware inventory - // (POST /api/v1/setup/hardware/confirm) - ConfirmSetupHardware(w http.ResponseWriter, r *http.Request) - // GetSetupHardwareTopology Read the RF-Off normalized hardware topology during first-run setup - // (GET /api/v1/setup/hardware/topology) - GetSetupHardwareTopology(w http.ResponseWriter, r *http.Request) - // PutSetupHTTPS Configure loopback management or generate a local-CA HTTPS candidate - // (PUT /api/v1/setup/https) - PutSetupHTTPS(w http.ResponseWriter, r *http.Request) - // ConfirmSetupHTTPS Confirm the displayed SHA-256 local root fingerprint - // (POST /api/v1/setup/https/confirm) - ConfirmSetupHTTPS(w http.ResponseWriter, r *http.Request) - // GetSetupHTTPSRootCertificate Read the public local root certificate for explicit trust installation - // (GET /api/v1/setup/https/root-certificate) - GetSetupHTTPSRootCertificate(w http.ResponseWriter, r *http.Request) - // GetSetupInventory Read the restricted HIL-0 inventory while the instance remains RF Off - // (GET /api/v1/setup/inventory) - GetSetupInventory(w http.ResponseWriter, r *http.Request) - // GetSetupSession Resume the restricted first-run session from its HttpOnly cookie - // (GET /api/v1/setup/session) - GetSetupSession(w http.ResponseWriter, r *http.Request) - // GetSetupStatus Read the first-run boundary without opening normal business APIs - // (GET /api/v1/setup/status) - GetSetupStatus(w http.ResponseWriter, r *http.Request) - // PutSetupStorage Validate and persist the data and recording roots for a new instance - // (PUT /api/v1/setup/storage) - PutSetupStorage(w http.ResponseWriter, r *http.Request) // GetSystemHealth Read the local control-plane health snapshot // (GET /api/v1/system/health) GetSystemHealth(w http.ResponseWriter, r *http.Request) @@ -3005,78 +2701,6 @@ func (_ Unimplemented) TestNotificationChannel(w http.ResponseWriter, r *http.Re w.WriteHeader(http.StatusNotImplemented) } -// PutSetupAdministrator Create or replace the sole administrator while first-run setup remains uninitialized -// (PUT /api/v1/setup/administrator) -func (_ Unimplemented) PutSetupAdministrator(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// ConsumeSetupBootstrap Exchange a one-time root-generated bootstrap code for a restricted setup session -// (POST /api/v1/setup/bootstrap) -func (_ Unimplemented) ConsumeSetupBootstrap(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// CompleteSetup Revalidate every setup prerequisite and atomically enter ready -// (POST /api/v1/setup/complete) -func (_ Unimplemented) CompleteSetup(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// ConfirmSetupHardware Confirm the current read-only hardware inventory -// (POST /api/v1/setup/hardware/confirm) -func (_ Unimplemented) ConfirmSetupHardware(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetSetupHardwareTopology Read the RF-Off normalized hardware topology during first-run setup -// (GET /api/v1/setup/hardware/topology) -func (_ Unimplemented) GetSetupHardwareTopology(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// PutSetupHTTPS Configure loopback management or generate a local-CA HTTPS candidate -// (PUT /api/v1/setup/https) -func (_ Unimplemented) PutSetupHTTPS(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// ConfirmSetupHTTPS Confirm the displayed SHA-256 local root fingerprint -// (POST /api/v1/setup/https/confirm) -func (_ Unimplemented) ConfirmSetupHTTPS(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetSetupHTTPSRootCertificate Read the public local root certificate for explicit trust installation -// (GET /api/v1/setup/https/root-certificate) -func (_ Unimplemented) GetSetupHTTPSRootCertificate(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetSetupInventory Read the restricted HIL-0 inventory while the instance remains RF Off -// (GET /api/v1/setup/inventory) -func (_ Unimplemented) GetSetupInventory(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetSetupSession Resume the restricted first-run session from its HttpOnly cookie -// (GET /api/v1/setup/session) -func (_ Unimplemented) GetSetupSession(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// GetSetupStatus Read the first-run boundary without opening normal business APIs -// (GET /api/v1/setup/status) -func (_ Unimplemented) GetSetupStatus(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// PutSetupStorage Validate and persist the data and recording roots for a new instance -// (PUT /api/v1/setup/storage) -func (_ Unimplemented) PutSetupStorage(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - // GetSystemHealth Read the local control-plane health snapshot // (GET /api/v1/system/health) func (_ Unimplemented) GetSystemHealth(w http.ResponseWriter, r *http.Request) { @@ -4248,174 +3872,6 @@ func (siw *ServerInterfaceWrapper) TestNotificationChannel(w http.ResponseWriter handler.ServeHTTP(w, r) } -// PutSetupAdministrator operation middleware -func (siw *ServerInterfaceWrapper) PutSetupAdministrator(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PutSetupAdministrator(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ConsumeSetupBootstrap operation middleware -func (siw *ServerInterfaceWrapper) ConsumeSetupBootstrap(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ConsumeSetupBootstrap(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// CompleteSetup operation middleware -func (siw *ServerInterfaceWrapper) CompleteSetup(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CompleteSetup(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ConfirmSetupHardware operation middleware -func (siw *ServerInterfaceWrapper) ConfirmSetupHardware(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ConfirmSetupHardware(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetSetupHardwareTopology operation middleware -func (siw *ServerInterfaceWrapper) GetSetupHardwareTopology(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetSetupHardwareTopology(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// PutSetupHTTPS operation middleware -func (siw *ServerInterfaceWrapper) PutSetupHTTPS(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PutSetupHTTPS(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// ConfirmSetupHTTPS operation middleware -func (siw *ServerInterfaceWrapper) ConfirmSetupHTTPS(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ConfirmSetupHTTPS(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetSetupHTTPSRootCertificate operation middleware -func (siw *ServerInterfaceWrapper) GetSetupHTTPSRootCertificate(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetSetupHTTPSRootCertificate(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetSetupInventory operation middleware -func (siw *ServerInterfaceWrapper) GetSetupInventory(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetSetupInventory(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetSetupSession operation middleware -func (siw *ServerInterfaceWrapper) GetSetupSession(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetSetupSession(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// GetSetupStatus operation middleware -func (siw *ServerInterfaceWrapper) GetSetupStatus(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetSetupStatus(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// PutSetupStorage operation middleware -func (siw *ServerInterfaceWrapper) PutSetupStorage(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PutSetupStorage(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // GetSystemHealth operation middleware func (siw *ServerInterfaceWrapper) GetSystemHealth(w http.ResponseWriter, r *http.Request) { @@ -4792,42 +4248,6 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/modems/{modemId}/equipment-identity", wrapper.ReadManagedModemEquipmentIdentity) }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/setup/status", wrapper.GetSetupStatus) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/setup/bootstrap", wrapper.ConsumeSetupBootstrap) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/setup/session", wrapper.GetSetupSession) - }) - r.Group(func(r chi.Router) { - r.Put(options.BaseURL+"/api/v1/setup/administrator", wrapper.PutSetupAdministrator) - }) - r.Group(func(r chi.Router) { - r.Put(options.BaseURL+"/api/v1/setup/storage", wrapper.PutSetupStorage) - }) - r.Group(func(r chi.Router) { - r.Put(options.BaseURL+"/api/v1/setup/https", wrapper.PutSetupHTTPS) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/setup/https/confirm", wrapper.ConfirmSetupHTTPS) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/setup/https/root-certificate", wrapper.GetSetupHTTPSRootCertificate) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/setup/inventory", wrapper.GetSetupInventory) - }) - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/api/v1/setup/hardware/topology", wrapper.GetSetupHardwareTopology) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/setup/hardware/confirm", wrapper.ConfirmSetupHardware) - }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/v1/setup/complete", wrapper.CompleteSetup) - }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/system/health", wrapper.GetSystemHealth) }) @@ -4840,233 +4260,199 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "7H15c9s4lvhXQWmnamd2pPhIOtud/DGllu2Oam3HK9ndv92ebAomIQsbkmAA0I47m+/+K1wkQIKXbB3u", - "+I/uyBKJ41147+EdXwcBiVOSoISzwZuvgxRSGCOOqPzrAt6gSUYZoeKvELGA4pRjkgzeDH5FlGGSoBCQ", - "FH7OEAjkg2BBSQz4EoGUoltMMgZSeIMGwwEWb33OEL0fDAcJjNHgzUC9MxgOWLBEMRSzxPDLKUpu+HLw", - "5vCH18NBjBPz98FwkELOERUj/c/v49F/w9Ef+6OfPo4+/P0vg+GA36diUMYpTm4G374N5QZOcYx5df1n", - "8AuOsxhQFBAaMkARz2iCwrcgRAuYRZwBTsDhfs3KIzmqvXD92uDN4f5Q7EKMPnjzw77cgvrjIF8jTji6", - "QXTwTaxSjSEhPg7DM5jAGxSe4gTN0OcMMbl4GIZYrBxGF5SkiHKM2ODNAkYMDQep9dXXQQCTEIeQo2mo", - "UJqDLMIJGuU/j37fH/0ER4sPX18efvPAbzgIMUsjeH8ut+yg5uBwv4yaKvQp+pxhisLBm9+dRbkDf8hf", - "Jdf/iwIuJi7AcEZCFD82HH5XdPNB/zv68HV/+PrlNz8N1e3Cu+4UH1Oq2KXPSkmIykscj/5bkLaXssWi", - "FoiiJJCveX7l9B5eR/av14RECCbVLYm57Ve8+8r4co6YYPgZYilJGOq5RfQlxRSxsUTigtAY8sGbgSRD", - "jmPk22NEAqi2gBLBPb8P/liOJueD4QAlo6u5tdDinYwhmlTJ9fWrftSaD5OvYmjtwAehn2HwCSXhf+Ak", - "tFfMcJxFkEsZt4Q0vINUQVuQv3cHExhFPWELE3aHKAr7ADegCPJ+r4SYokBJz2KDOLkmWSJ4mmRcffTt", - "CiXhDEGm3i0hxvdwv5XhMoMHMIo+OgfE14PXw4PDfb+cE3KxKiTc14evX/lfFpiAAixtI4gF/Pithp1j", - "wtE4DClirDTKP//5938ISf3h68th3QYYhxy5aAlILH4UaIOR+gQDjm8lKQsAD4aDBcQR8iMsS8N+9FHi", - "ICxJwgJNDuXybm3CMjuxCcamVXtdPi4UzDOWQ612ZMAKfSvekouWkwg2Tm6yVCybxwsv7EJ8g3kZjQKF", - "//Yv49GRoCX/gVsCoV5M3T5PMeMrCmPBHvID5iiWH/5C0WLwZvAve4U6uKfVkj0pkb5JnWaqnhdKjV4T", - "pBTei18T9IUXuuKjanHuCSyWXgeTOYeUr4Z6rwwQX5bkyOHhroqBEqQ6MZ8XkCiKsghSqclMtGpi+GEw", - "HEyOT0+vTsezj/PL8eXV/OPV+fjX8fR0/PPpsfPr9Ozj+fvLj7Pj8dF/2T/MTj6+Pzlxvjn+ZTq/nI0v", - "p+/PPx4dn0+Pj+yf1SjikePZ8ZH/1NRrnqEbzLjad08CCEkMcdLKDJ6JjtSbthzuO8RcvlhGoV6SGbcJ", - "WZ71WFgLhJhNhYoiJBhKWWcgzssnS0L4iMoHhMoh1oYgDZb6rEEJRorKzBOjJYmR+w0lUJ9N1pcsZp5H", - "xbfF4yhG9AYlwf2IJNF9dZpRwBbXI7HCVCrItLwWPZT/uSz5lJC7pBE0c3yTwKgClBhBlplR4C3EkVSl", - "O45ZHq0j8LyAr6CHLkZksRCP43ikfoXhfb+FiiMPjUNhx0q6IPQCMnZHaLiiZZZRihJuBinblvuHbeq6", - "OG7ual6vnDeHza9PSLLAQr/BFe20w1Dl46m0M3eh9fN6WVs8cJNRNEc8Sx3wrwZ2nDAOkwAdKU/F6Qr2", - "VWrBPNcK02J/jovgx3Y8pDVIeIyxa2zBl4fOqy9r1JEPxccX8twfvjzocOpalqO1dO82hzUIaaeFd5eX", - "F/NV1RzGUfKOqBcdWv+hlenUyxeEmpeVW+v1Dz+8/KHZzTUcxFqRaDoZi82diafFgZpJEIwjgSAozJdz", - "GCNXd3U28bJ1E4Uae/C6pMYKMYg/Z0j/zmmGyviNlbvEAqMDlvoVt2N1zgmFNys6/ZQbEyc3bEZIGbev", - "9n9q0sH3Wqm6NHrtXmj8UPqkhPATnNwgmlKc8PkSHv7w2mNKjUcnH74efvvrG+uPv33txKL+KWr2xLIY", - "/UwIF4I3XW1L1+b1id/NV9gGr7r4IN3hapbNYdD7UF7FI1TnH/5xv5UPq04btewefpt0SRJ0nsXXiK7g", - "NHkUF4cNAndBvfwWausPMenVCD2sek0krkTc36+Y9hV/sZ6oYR9nGYerO2EeRFUPIYmy9VWLWt/Wj9At", - "DtAlhQlL9QFZcgIr84Bdi/9Dyr361fHVdDK5oGSBI7SC7+rW6/HPqXQaooRjfv8OJ7w/aB+X23PP+Ahl", - "OAhGqdr06Hd4/eEvq/Ceb4+5z9OHMQnr3Pjqc5eBw9UgqPfYnUkdcqjoLjFOzF8tTGtWbC3BB5EThNky", - "OyccL3AgOfhnnIhjv68wWsIkQVHFE/aX//sf/VNHpxqyXVClawN33Jb7Muf6qe3+oepND6WNfAcxV+Y2", - "R0x/YlkQIOS4081s4lMg1Puozsl+i2gO6SsaVS+pLyhiKOGAJNE9uFviCAG9iLcgQbeIghRRJtTPkoGk", - "NL5mHjKu9vIqbGgNLVza6PBRzzt9v6WvwuokoPL2jyAvPn+OcfFHfI3jgbIX4lheAouzNA1Y4IWhmXUC", - "U3iNI5yLwx7kqh0xvxIc1ArQG8xhJJ84QyGG/sekKLuwuLz6yJIw/iv5DS/wOBOY8j0TwySD0Tnid4R+", - "mqOouHarPpvopwJY80CKk4NfBYrva36nOIb0fo7jUxJ8yuWh58Hs08FVch2R4JP/AboQ5z8lkf9nhuNx", - "EGjPtv/nNMxqfoxr3srY9RUMOtxzF7Or0YYltPtwnA9frK6CQHvbLjZq8eiHuYMpF9plwmrivl9QgmjV", - "j4IT/vrVoM1IN4Oc4gQdIQ5x1PtmyeXCpsPNy7mNeobnKqkiD26c/XeZ34KYT1HpHiyihdZJlgT+i6Ae", - "I6XLe4YDGCm18kFDUcRIRgP0CyVZ+qCROl1vCNrR1xnSG5KfZ5p8Hy8aRyqBFUBVsVC3jCpoyjqlRUxD", - "l7ab7mQMYV2SlETk5n5Fky6UO+quLF44kNDs+616U/tQDolw0mNVHpHiWZODsu6Dn9mv1Y9Orhmit/18", - "Gw5pdF/RzH6tfkUU3WLWAQMFCenn1UGZqyCd1jSfnskX6pfDcDyPSA/vwXx6Jl5oGLHKcT1Gr75cN1NJ", - "JDgMmwPZoYBhzlgVsrMAYYG5ZjMVEjGc4RUJCEZ8uaIggCnW0ba2Tn174NWHrwvtuwnEdryaOHMhh9eQ", - "oQnJtGFr9IR9n54gLy6iyL0hbppuWnlBHydZuwCRoJurZ5XRxFxtuMG+ydigeGNoQ9K3iQJ6ZYjU43Se", - "b8IghnySNHZDYVhj90194DOvZwlOMMcwwn/o62t1XxtDgYBEGJQ1g96ihBO6neNmnsVCo93+eSPVj/rF", - "rHYUrCqsHyad6sWJ2OTExCL3lSZhWBcaPCzHTLveCOncxNIfoUL+1brlhgAnn1ACKLqFkRwgBJAD5QEH", - "GrAPiER/DLtiSWL0XgZFFT4ld4PvSIzA2WQyOjufgBBRfItCgBOGQyRTGsY3YvN3mC9JxgH6khKGkxsw", - "PZtPBxWHlHI1fxupD4fDGp3aXpUxedxV/UyyJEQhOD75OL84BwmMEVgQKlek3JtAe/XeAhSn/B7cLVEC", - "3PiONp9XbIXbHz0g7N8dqaLrywO3o/PPHkj8L6os5seWt+aIYhgVtwEuXCcqVgNca/jGJMwiBJh8ScHQ", - "MOYQEL5E9A4zRQhX85+BGYxQ/Uo36PtXLYS84HZWREmb88DEz529Pzo++/j+5OR0en48GA7m07OP45/n", - "x+eX+g83Dm98Kl/8OD46ktF0P0/Pj6bnv3ycvD8/OZ1OLr1nCMOxcjkGrSe6nZ8xn57lr5V0P01LHn+5", - "hwwbc0ZKdFVPsj7KaaCL+vV6mNMjRVyoVcxEI26rOG4V7Kd49ZyXfgdmcZaUrgMPX7VdBxYT1u3n+IYi", - "xla8SRDaF7333Jxrn/+Hr3Unhnqz5raqWwpAj/BfFQOCaGtwzH5TcEyhAwYmNKRwmUvv3ZLEZKT35mXh", - "DpLk+JfZ8Xwuw2qFNJj+cjWTIkIIlo+/vv9tejL9eHU+v7q4eD+7lL/Mr36eT2bTCxmgK96bH58eT9Rv", - "k/dX55ez/5Jfn7y/OhffnU3fvT97rwJ3r87Pp+e/FF/OjueX49nlx9nxf15N6+J5ldLb7tXNw5p1XI5N", - "Ly4NlFBU6NVd2dIh4xVY81q92Y8xXd7py5z5lJ32ZG7tN8aiZarvTub+oCx7JXU79gTbVsNhfRRp2xWP", - "Fc/QRZ26eYg/H++IC/qJOI7r3MSuU9jcm1qY8VIbucErRsCkDwiMfvwMyHw1vl1a2cpbuCp61Ji1LtyI", - "H6AiPBUTq9lKGl8KQwlFgCKWRbzO6AEwCYGGNQoBZEAIWrCAOMooAtf30o76DV2Dq2kXA+lpmnVW0Fgf", - "x1r+0vvCweIe/ocej3sXGWuxq1fUNptrfQSIJ0JrGxacg4FCdpeMtEKOtMi4FfS+fu5LW572Vffq/YUV", - "tHsUICk3RmSxEMPofKE2pch2AvR3Q/bMWH+M80KHfPRxbthpWhlbwxHSS2B39oPRxbyHQFBlNk4KmfBk", - "ROwjuaz6wiqHFLcjfpveLgcIdxGYWvIxV9oVU9YKNIP9inPKMMAwZ8AGeeGj/56hsnYAZ5ek2CLpuHJZ", - "0+bCJiXfvsVif/3HG48//m//aMjkLrzxbfNSyD0pZh6XRpFV2yNM35fY7BwML+1A4Je+u/2bBEYzxvDR", - "dey4puzSRKPDfa9nirkJr12WaufI9s2J9udB50UhcuIog7OEtRIxKBy5mykDxqG2Np44/pzhNEYJN9Hm", - "fTMxY4Q9SU0fvh780MV8FW+3LXEFTUUeRL1VFXX4OyT5uk1T0TO17cEcSPblurwvlQnNTSnLdaLeGilV", - "sdVCEl7rD51HrK5KK01GffKPQD/Nz+YTktwiynSCDAxXzfeD4eWSkuxmeUk+oeRxi240l6L46z/elJJp", - "/k+PaCXNApk1uz88+Onb3zrk5JVKwlS25yUV6SZUSZQPP53axLx293Q9jSKYJcHS3LS7+tNY3doucIRA", - "ChlDTOpGOiAFhSAgFAF9k45J8hbwJWYgJIiBhHAQI5gACFJKAsQYwAzQLEl0aoInbvs6wmyJwpqobfWq", - "HXM1DTulSciAZhSu8KYvq1PfmcswAH+tpRLNFPtyoJ0P76LMPT1q1l4HjSbqo6tHYtBgiTkKeEYdaQLj", - "UPp2IY1fv/KKEkEz3Gd/dEhCYnUptQ2QF4flH8jvgDbaxMHLV4cH//7vOjO/wSltBXBZK7iV4uTv//zn", - "i9KHdkKw4rtyuFhUIJc+dKHdjNCVhEkdNsXUrQjNWd/Po/nPHROYVuMvBzXN6mxZI8mX70Tb2RCxEGLv", - "ph4RR5Atrwmk4WrYyG1If5yTytiIELWOuHaBF1DEV8jgHg4yGvlO6CYEPA5v2LZ0gZnq9tUS8z3Wo+Vc", - "H5e7eIn/yI6ZhIRdffufdN3H+jojqzhM5ahNl9rNaFrBBBBb7mEBFBRRSSNvyyNXM9VvYJYlHMerieII", - "Mn7cR7dLkbwBnyHGofLk+PLowvbA6G0oU2LJnU+GsuXCOElTZGk+Mo81i3j7rTvL09bC/hpVBeb2RoYl", - "DNZTiT1q7/Oa4wUM+MyEmpR0dJBrj8A8qpT1inoO0C1KAF5IJT6CHDEOKFpQxJYgzwmuTcpfLZ8eJUKo", - "16kLlWR7C0xdA5r68pB4YaZ23dVCyl+oBtHL7GaTXy3Pp4ZipUKWFJkLuUKqpFAzwxqyrRLAXP+Sx9om", - "6AsHkkgBoYAqun0LMAcxvAchXiyQVf5ck70x0rwUUNUKfJV6fMqE54KuvYBT+4GjdAAzfkFlFpyGJcYp", - "I96HVxtDq/H2RF7QreYqybyZ9ZFMp/jCgSxWBK5mp29VuLpgaBzHKMSQo+gehOQuiQgMmbzSNuHtymK3", - "+cqXgN8r4oJGHYGxwrluL7Tv+e7I2PI533LKu/N2296KcWjrE6ctBCRJx2wakMRkPQhyUfdaJAGq/g+Q", - "Tkd6ixiQ3QN6VmzwMatZeTfYar7cHa3QvbtfhSAbKE4LnhrCk5dbG0iiWTFm7jHuu7tfF6eUhFnAKyrn", - "q2GdD+FVXbZ5Jf64NYM3x8Ks9PIjpiSkKaF83injUA3gvPGgi15ZRqLez+DJG/q7uqX8u/S0a1v/b//m", - "9yywaxUM41EJvPeQtygJCX0oohtTNaztWvPZRFZed3HX7VxuOzhoyqWoZudbZNPO/ZvJtChJnH5XZi2J", - "Fs185EkCmLw/v5y9Py1lDcmkosnkeF4u6338n1fTi7Pj88uP06Pj88vp5X+VHsi/bkww8iXs9wyj75bf", - "XC5G9EgC9QnXJnm0cO6OwdSuslIkVtfX1Kil63lZeufeC/WDVflamCQ+wrOLmk1USave+dERvkX0/qyU", - "tXCHrpdE5nwvZPm0jzBNa9pCrEtBRbco4f+Bk7C7LLLhcWxeL3V5aKow5yuV66vp2avcm7AMjzSYu/sQ", - "zBsPdCL0dxpScotDFR9XEENA4pwUanIsb6Q/TDraJ0WalxezHNIbxC/l9z6SgxlfEor/QOHHjDmVKYsJ", - "9dNGPzCDfL6HKX5xh/AXnLz4/PmFWjhJUfJCrf5FkLQ7AbE+1xUkhi6XOOsvSwR7WfVQsX0QFplXaMVL", - "Cl18DR7BsIo2oN5cjf+MPHIjvFsLsZopO+5q58zqnZFaj8HIVQfB+1RBGKiqmkCGm/GMIqDu2EzQa+ES", - "4EsE0BcsS0vqh1wfwQ/eOveakbwFJGcVt8TbBp+EWEBAkYxpg1F//4QtB3y8rqpLunCrYfA2oi6wb6sD", - "MXtBUYDwrfJZxuxF7n8PYBS9sNpiyb9jzJh69J5xFL9orNpSk5fR05WSh1TbJvnrYalS8e8HQhXTkaoH", - "dTEZsvrQSoklc/lqJaekH+eUb/TybAy9Lh8Oq2uw29ToKNARkyVAccxqEOEpdPZYUm3n9fhO8bSl4jyb", - "CFb3RaW3pEj6VmnHQTn9aZozUfzViL6PRF2nilnHCl5O7bKO7+jiaF2ffkJ06oFgabteiLUS+AzBiOMY", - "jTkXZ6rriSmdVu4B5aNxM5o8+/o6rO0VNBcwLC9Z4IOkOOhTBFGNcSleK10CHDzwlNFLaYK2mtaGtDzd", - "ZeSZrpFmcF6U2RoObskdXmDxC2IM3qjEQNlucFh0PTDFCZTDIVdHmKnRW4O4alHI7+a8iuEXFe88MV0n", - "i9oor1oLo8AvE5Lorlrv04bXa5sOWZVgXQpedT99rYvHr+Tbvxxpbif1XLyWgVsB3Up+xgJEHuw7+6lQ", - "pofaOgh4C752Ui0MMRkFeV1whuMRNE6oW4IDNIpNcdGYjZhq/WQeLAqNj5RQGkFValyXFx8xq5a4eMOu", - "Ej7ytHgoUDCfnpmcpYq+pZJfmtNgSrVkV5JiDW1IXr/abWGGy7BrKZLrwtoKFO1Sr9cwbU60j+qyL0bN", - "A03d3dW1VGlhCWf1dk0rcTqqxKJbocaP6s9Lt7rwKm1w5Aq6xU9ul56SEH1xDjUTxtVwKD7iiZJ2vFrX", - "GCmu1DsKZ7U/a55hCUFdyMmZefUMvlLu3YP6Z6cw0FmfPQ29wFrBJeEwyq2p+oH22wbqUVnbhYFVKLe5", - "u7dWiR+04gRBOrEg5+tlspke4g7oanHi2/WwwH1pQ17a9UO7fzD7mVpIB+yaJ/WF1vuMyyINp77yhqVG", - "e8PXr75tJRdUsC1FMGwgrB4xvS2ZpTY43YlrMHgkq9KVbHei4SpFnPpUI3Is1PXqGali0h0n8X67E2ql", - "gljW/lqoq4BFS3Ou9uSa7l3xo4fQ7mO05TfXGhqVFbWirqTvurlGnHl9MN2t3v78bF4EvT1Kj0wbBza9", - "DYvynWUulfQ/LEr4V1Hg5vN2b7VZsOR2FIDcudTjyLaEevMxvb0jdrj2igR8dd2jXH2i8O/xBxzt1XiT", - "zxnKVJdPVb+Xxjp3Qg6fX0XmTl/vkYGScH42X7XV8GrHRij7GeZGUM9Gug+tnfxwGV1CsCtwciFj71KL", - "GC9qEfcUIVkNIQ0BEOUWnQ1x/LKp9oTEaYRUEP9KQgs3dR2pj5yLTB1TrKOU9CucZujD0N+9EN6gGCX8", - "qpr1vP/qx55ZSp4uLe4M5SXWQvAkInfOdbM8NEYJuqvhRKcbvfViREh6DYNPI5JEMgOKBDAaBbB+mIsl", - "ZM4QeRfvUb5ZYYhIFKMO3V7kqDNC+EQgWF5DoBXpIlW1+1pExAYas6fyjqZ7e3bVNR8xtjpPwFCcG7KO", - "lNAlmoPxnIev/HV2vSH3RZReF/4JIYe1TfybO9t20wOX2o+l/DQdr3PNS3mboSN8o+Vhm+6/tFrA9Zxt", - "hm4xuqtDyJLzlLVhrXhIHsf1z5wiuDgnfLzglQAd/7bkS7KofJ2g879TFidSgtRIlOEAxzrE+UPdeDOX", - "Y0or+emHunojSYCOkEz2PhXTOWv6YzmanMvgrNHVvKZEf0CoLC/fnVhNFqmRxI16by6ypekiL0jacJ3H", - "g4v3eqjY9lTWXfUDb6wstnd7KjtgqKx6WCuV6kRQHT59cLPkSwWHVY6qsI9NwhUWqKFHH395WNwvmHzi", - "o14a1R8TUllf8ZTID+vC8z9uri9znTGZfzNOccuTj9NKLzX6RSuZK01E8qLQICy9bgPs1PvO16P8qa2W", - "118D8mEz7ipb9NJPbTfKLdx+PuXARd1Qc/2dJDwY8xcWNRehxW2Uv9XDahehdauwQxzlZZgU4PnHiASf", - "ao77SofBnlXcrqRrzKoGvppV+5BAyYaUdh8QfyW/4RPsFC7vGfUWp56yIO2FQTZXtAoxAQwViVKTKSHb", - "9Jw9XpMoFOEbXHsk9S/98kAHUIK+9Csdo+ut+utZmtzXSQleq7e/kr+8G8+OfhvPjlVXKz3WFhpj3Qh1", - "i/Yo+HSDHlgZSha60VkTJElQoP8wa1F/5TVwhVJpPSXHMYWlarLv6rp6ubxh0W0ZzYWoVhu2lmMxT3N/", - "MAe0ZaIc5rKkS1JZSWqtvWtEWUo+VueIb1JBXZBqYpGyMEYJyjiFEdCRbqM0gglSf8GAy9JJcxynUcZe", - "CMbHXMicgf4KjC+mVlnCN4P9Fwcv9rVHOIEpHrwZvHyx/+Klaq66lGDYgyneuz3YE8bVnnT8SblDFIQd", - "V7JqwTRQW0WM/6y942J5JoI6TSMdwrv3vzpTXgG1tVuV3d7pmwtQHTxJtbUh1324v/9oc48zviz7veQS", - "SiXMMr4UCkugipjZpiNg6nUB7FePubIUS87wLWeayOJJQCINUAM6sYCDjSxgkqe2MQApAlgtSC3hp40s", - "4dKUeU4CBLCq5ixd75JVFDmL5RxuaDmEgBgm97pMnUaNlnTyAviHDRGHZCcQkCwKJVCuhRRR3vBQiitm", - "gmcGE1NeqUTS15TcMVSQ9nDA4Q0zjpjBBzFKWXqQjDeKD/F7hZFf+YrFyTkBRbdEKO8bpOpzAiJ8i/z8", - "LWjMhMnJNb3cDKfNZyd2gUR98G+WnkjGuxHUTCJNZb/qzjYPpCy7gV+aeUhrsoTJDRrbs1yYd9ZzXjXM", - "2OsU8xC/GQdQlEYwQOFbgG4Rva8hyTvZDM7ik81QhLVKuV3BGs4JsBl2NZKC0JzYDLVUVvS9MGuOGodd", - "DTVVuFV+reovkqgs+HJgwiTUZAZgFBnqY+3Ma7j8zdfBDfLw7i+IWwrYYPt6nmnHBev1vV09kDZFYYbr", - "pJVYJjNYJTEYPtJxEJikNy8pCcPQJB+lkMIYcdkp8nf/TotH9i5kAFuMBRQ7PayDvr59WCO9iq04UXUe", - "TMxQICAq4ALU9RPb7Clwo0OABHIZoUIQRwKQWzoPzkmTZfbThiwzbZHk5ogyQg42ZhO9u7y8APq+lN8b", - "ywiCFNEYcyHOOM0YR+HodHwOloRtVHpIFnUaE8rJX25wcmGvCpAULl9dl1o1/5GhHnJRrzaGM6NHoS8B", - "QiFSEtO0hHQdQiGCoXTHuXJWyAowx3EWScp3ZEIhVJUIrUrVvRDDqN56O8IwErBblz4No2jOIeVb8gHJ", - "rfl4OURxSsT4SoGSrHz4iMKsbmLxva4xFG7DrUQoyBIGFwjoQjTfmRA/xQlyOnDLIvWCAqTQiHCwcZkp", - "xDeT0SEBys0KRwIIJgU4kaKjJAiUqhbDYKn8+K3iIK+dUSsS9Axoqp98Fg+bEw8G5gq5W5QTW3A876J4", - "2D1ZME3+FwVC6SsEAbaJposM+Cr+mYbf9mBQJDM4dhUWC0qhKqcgr+gH6p1BmT2H1s7tkqYwKtczlYkJ", - "3uQIYW75ZdFEKUhrFkHjQCUM7JQMUlEooULqFiQAzFMYd0MAvNocEwoTYiHTVTcoehQR5loIA3eYL61D", - "fvdE0Thhd4gOAUVCJA3BEiY3IEuHQKIuCcHR5dmJ0FtyUdUinEz5qEZHUFFjan0sqeZoc9KYpQAmYyPB", - "9T3QgVpAysydvd7a8i3uRinZ4KjNmymt7DCj0iiwCpnl9Gq++vCt9rCS6pqecV3HlRrdlCnudWYdPPYq", - "GgC+Td01MKv7vvnvKFOjIpAuSWIMfiAVRp8nc8Ms6XKkOWzqwhhypHo50nOI7H3Vn6bhN92YAKnQvZID", - "TH5vM23bRa5ZvxpxmxQO8A7HULzaKDFtRWXbpRPOZaecNl0/kvy2jZmGXYxBw1ud7UH1Qj+T0BcTosyi", - "3T1k9zd5yOoiHs+H7Pcpf/4UJ7xi6B4nvKqI2BB3cnw1nUxMItza2NOaxRdKkXvnkHiuMOGfb8w3dSWs", - "AF9UwXCufeRtin0rXA1tKVBoVeOUY+qESxSCvIpsQbS6YGeFYvfMs3tf9Sfjgb3VOSXtx27+Ytdjl5k9", - "jJyqt6Pf4fWHXo7YsV6npHqdp7dF7rrDPFgiFb6WFxGymWyzh6EG6/fmLNV0sJ3D78kKF0W8QGWyGtJx", - "3KQ6KhOG4BoGnwyBm5JMTYLmVmzAOhvdRR/DYAnm82MQQg7BAqNIRtMK1cFEpDjdE14AYeEwThGMxYMw", - "vMWM0Pu3IIiwmAlQxO6TYElJgv9AgKvCZnmIEpQ7lFFLYjcyu6l01yzHPlbLbhUnHH3hao8jtajuuHS7", - "QnhjH+ktoiOhPwI5hd73YDhYIhhKofx1MIHBEo30rZQ7e95Oj8hC5b6CRHLa79kl9RxC17zCY4vwXO0Z", - "MQ6vI8yWJuZ8M2LOyYSwQ6c1DTlX5a6UU5two61N3wETQqrGEdIu77wCljhxHO9apjlyzhQ22eM61b/J", - "HDBFGkxZgHWqLeW5ugSkJ4TGMMJ/oBCYfYF8X893SIUtu4RMR3fqDCUgq5tIe7ecJGjqnIDxxZTthpDJ", - "RcwmJYpYToWqSoa57DeOZGygznAsmPPphcvmaQkWX4X3CYxxkENiCObTsyHQ+usQOK2HhlIgyUigGwrT", - "pSWLis5Ijjgqvm4QQ1Orq9La5E8+SRfBky8bsASmbEmeWfsR9AdTpG7jyoMCaBmlfl6v45kQs4DcIip4", - "RpYXU4ezNFVGgtmA3RyshS3E8yOrI3+dWZI35AfTIxXDL7vPCQsDkKKHKHsBDFUz5SzESS4aTOBFjDiU", - "1o30DFh904dA9rkWjA9MBxewKOqvsaEx1yx5KfsysyFg92zBgKyuIKNbNGwSEiKPUXOKGZdl2Iqtr5Hl", - "nZlkDY2m5LcwlBaqgI5tsUppZ6HqWe1oKEBQEUAb86trLJawVY5tYSS6Rd74lqBmAEEOwlZVXge1ObFT", - "T4LyCIaC32TXMEsE6NooFfZXdWVG1ziRBRQbQ7vEqo7l8z+bx9fMN85sdbwjgaX2AfJ9eICLvoilYA5U", - "jakhOHNKTA1V7oWVmqTHVCH8CyvV+VT3WmwBbjM0rfpla4WjNU8dBPUjiuwizPizfNlJ+WLjqWPEnCsb", - "dNCXHkBKlSXCNJdJ+RFr6lCVKby4fijnw2qTRPdfLxSEXIrJ6fQlIwNY/BfHGZfHm2ZbIOsp2TGab0FI", - "kFY9ZekHMDsxjDsE7wjjQFWNGoL52VyysAxcrR764zC0OGFNgQnuJFsK/rO3WScutxD9V2iRhErcbi9V", - "efeiFHLYSGEDIiL03m1IPmclMFJCjyK9EhVEDYGire0Ivl5xiSSJxHoZL7TowrigJJZ3K+ZYV7dF4Ujq", - "TsIQ2TMXaLkQ63Do731Vhf++dbsytpoHdbgv7lyOsiU+a/2isLZI64ajtLpIwy2EaZ0/C8BaBr/Tzqb8", - "ynr7YianEH9olHzB1ltkchDJhNZodBvjtegjQvaUCbKbkuQiq1qE6ypTWZ7HhH5uWppUN9yQGegxTLcR", - "/CKOM2XbFv4kU1Z5gywuobGVSBTFncaO0LXybeOhxNYXSqcAMCn7DAQYXa+Bg+RgSXBg8/4dxVzwvoxR", - "MwWI9QjNckB3dxtVGuTW+xPUG5NSW9g/QUGmppbHvkiRszlwwAZwAhJ0hxgfLTBlAoshokKTRTBYgkhY", - "rRxoiP8rA+ohlslO94ssArKPjZ1v+b3XenrqDpvniJZc5VFUX2IYj2tpczEtaklCUGL2J60WZfJYq6JK", - "CKUvMOBA9ZIFMG8maw6LvO9n+3mxJ1A3ymvie7W4M0g/eY6OmUD6epQ5MWNJpIvZHlq/VYZCmktViRJI", - "JbvJQE1CpVlReDTgRs09RTs2RsWKSAo/Z0hHklor3nmhvu2Srq82yPI5YjSLWf459AUzzp7PuSdwzikG", - "hKGvdqrlQ3w+5R7nlBMyXvpWbzHDvpOuENVQSeiuJ1wnI2iDhs+wfA69T1VzFK/T+S1wesSr40kddeBu", - "iRKgnDi2wB0qX8/nDMmYnrKz54HOnfrlKzXEwVmKEH2bm7dqCUNAEc9oou79xBNgiZkMcYIBJYzlt+W+", - "bZQ75nt389d/vCk18P4/vbcPxR7Bi4+y5drBT9/+VuPFWqeB6uvGX2Ob6tqg9VapupPdcSt0qGxQdeVq", - "E8kCRxzRZ9v0z3Fmby1a0himRpg8m6TrNUnFWzFhgjRlaXPbRK1WM7aO6LqM1DlKQo3ENRmSYob52XxL", - "F4uF0Pcm5iTizHeLlAKyUD5lzJRXOLWakj5mBEjz0gTRCKzm00vL2IQG6dRGmb2LOcsvxw09bDyHVyy3", - "WCoOh1LxGIIQCSjKb+UhdE3CZ+N514xn0yMcqNh0nWiznTPYLMG6BxtKsec9nCVN8RLtFdUfn8BJ/erw", - "cEt4rty1zc/mmzb27fPLY/MzeLtZe99IXRkZKYEiy8CYdBDs6czwlHSI4s40VLVFhfEvT5kEQJc8Olv6", - "e1/1p04l4mxdo81XbJRLpdhss1KccSo+hwCd5e7V77VSXHEX17VSnGEyTccyolGwrrU+WUviGjJUq723", - "BhblXNg1tshTRO5Hf2iRzfUyKmJPWVZNiZoqfGIin5tzyLP1pk9UZ/NX1JL2oPZt6uc2x9BjN/M/x5In", - "jVD3+0dhbsTai869e/XRKgpRDZbfRSYrIdiQ2z6GftX5CeVtp5lVtmFj3jRdakpV4hZno7z8Ne2Risob", - "G4/6rlgM8kQ3EZkWsBzC+kXT1NC8j4a6agSJcQCj6N68WoJ+D2rziQqKuggKijYnJvK5GtIDxbp3SUZs", - "sIGhkjH1VUnyRGfp7I7urVJuCsIO8DpTyZ4epr6pzlQ9cCpjwQpkbptkpvn2d49odsGTsSntEDOgG/zb", - "eTE5cSoe2oy5fUTukoiokJZbRPHC4Mbfmko/PVTP3g9BSsl1VTzrrSjeUwGRZLHAAS4uMgPiBK+3sZwa", - "pUk+b4fZ8rPOK6CUwwKFxe4pihBkdg7QrsjszdDb+zIg8hIOpTZtuePVTcRaouCTcqSZgc4Qh5PsGv0/", - "Q1l65G7EFUK2vCZQtSVvPvqPzKObOv/LE3rA+d/mke9TAzgqbb+LLlDoAAXwkAy+TwlOlM8p1w6krypC", - "FAQUyaALGHVUFMz9XStZzdSDmyIqdzpfM2IraTtjPihq6KSUBEVFA+WqU445sflE6N8su86HBqG8qApK", - "dS3bQbinkx3q1a2ZeiDX9rcOQrWezaoVc+MVFbbPQvaLUGZWBLMkWHpZwthXhUs1f1nd3Ml9GIRzIl1B", - "9wDzfghsQd98p5A33zzqzh0sidMvjyxWjFSptmhhRTYOsxqzR/cWNlW//9xZ4fCjQXVfXJK0CZUk3SFM", - "kjTdLCY1TozPtwZ7JFUBEjpB1JWn3dBhY7IliFG+MHeeXztu7Onq6sS4S9o596aMY3HADHASRJlMv1VB", - "dWkEccLRFw6uZqesj1NTFRSowmpdSRKVidQCtlVTpLpvn6WuY0iY89zmL9Wq83/3zpKyPKMIQIsb3l1e", - "Xszdw+Zqdmp6WqdU9hbwXiV1FHh7X+0/u13o+nmt7W73yL7M3czVpb1G+/7S39yJlZ+maIEoSgJdXSUx", - "6cRp3sbBJ6Var+1cgHfuSGG99SiVRrYnMbdVJKCbsDRVAnZPWO4Q0+gKGw7LOCVEiU5XiO5VkGWAZNQi", - "QwFF8pB/DFm1JwuJ9tTZzuU7ayczMU2dviZ+AwqY2BQN3SHkSn0tIclIYytxl7vrYq83FVG0oIgtu1Vz", - "2aT0rnHRyNV2OYXXKTr1MvxOG/lTxUpOQlX5dxcIfnN++hPEgyUgFKSQspqO4OoZ6fhTbPaHiiFCSUDv", - "08pp9BiiU3k2ngjNK4/cNki+LXwl9xUGtZFG+5sn9LLjcvOBK3ZIhepzr6NXZY+fErjqvamFL1zl8uQb", - "W+go8ERYScq3WhTzycNXpF9Iexm6eoVIiGJ/9XWPdiEe3ky5cneqtnrlWWKVm34uUP5UCpTnFfTripSz", - "ACZJ1W8QQHW8Rlax8uje7kogCYEBvoRillukB0QJkITS3pRADdClcvaZenL9tSPlRG21sxUHPBfP3vni", - "2ZpCV6iebYk6u3i2yZBTrTDyNpd+Sq/t35lXjj7TlfvXW59azrLdAtVqoz5MSVZS8mIrBarzkNznCq1P", - "pUS1vpkblhL4lpCpztUya09oaILKU9kR1NTk2JoQ6lbJehyGdpVqz3Gbq6GyRL5da7ZRFFUO3b2v8l9Z", - "d9ZAaZRDqSGWAzpS5di8OzWvdkoyUVN3Nfrk452tvY1oCNV9N2hg07PjqSpIpCqLRvdF9k7ejUFu8q1u", - "jFTQx/fR8fTVFhiyUnl6Y1EIcnrMAFksZAotoapFhilWr7pfhCDMqLEz8xp2G8rzPa5ITkXAMnWV5YGz", - "eT0M1YJogym2ngViBjiKU0IhxdF9W7ChELMkkfCPbbr4V6b4lVgFgV0i1sraCpKWLloKNM6RY/DMTuZa", - "pm9dpq6joodvr9vtHFCrps5OdCBl0QncaXe/Sd11dpLncD+rrDsm23M6CWAiawflitw1yuW6qqJlEc6G", - "hPrsxMRMd5HjGyPmouiGP4AFcSmsy1LatK/KQW463ouzVDXQgyFMOaLtkjohPM+tGQk0JSjK++TtLRBm", - "y6wpiGUCkwBFJ/K5c2ssu1PCmiRX/aQNGqnpU2FMhm46pl+f3JyjSZxeccptU5AjVV5JdRhNtHkVSHRU", - "SUmhCUBwB1WtfgW8HBx6AotebMJQKfR16QLfI/I3pez9bFaMqOz12iGPxDjqiuZ0Gtc6vm1kinH0wX1D", - "gHwP9B9sB/2/aZoXst49uXWpoT+gvv7cfWkwTghfIpojT0gE2Rd5s5fwkqAoulGQlPUynYsVk6vgSQ8g", - "eWO+kexbFiHI+EgQJ47QDTLUahf36E6pLSdb842LTU4T88IaZZhnvtqbSLMcj/ueZFwWMQL2joHZceE2", - "82aKdWV1FfTsWfGafPiemfoFOh6scylNYeGBeWTzQY751L446MSKf/kNTUgstHHNbHfoeknIpwcw1t5X", - "/alT0HMdHe1g1LNeXim8r4F9Wv0VOaC6eiz0C48Rp/yEGHh/0wxsQpUdBt5FIuvDi3umPMEu0KX3ZLlE", - "3pN3sDsEEaIIyxb0dhza1ghjc2reBSW3OES0CDmT/huj51EUIHyLVCds5bl0vRe6PmLuTLYbkHU7ahji", - "Wbrn6Oy1HuSLjM/F42Pn6fUIN1MIDVVn3FadZrGQuXJENpXmnyZYqIAlO6hULMzcwb0FKWTsjtAQxJAj", - "Kl7ETN/UqYYIm3UAXzFEhbQaFgvT/kRdITn/OiURDu6HqsIS2pXSyZKetxy4dIIp4yOaJXo1pshuXuRE", - "p8MZP4KuS1FE4qLn+v2uc66BlZo68Dylwrem0TjN849k9DGJytu/W+IIqa4eFo1RFEOcMJAlWMkf/IcT", - "JSmf8on+a0K4GLohtX9CEpbFShL/nD++Nrkv5sqn2XFhP9buLRS6kuetLvkt+7IZacTJJyRlko4pl84Z", - "nAjD8R3n6XvxZ0DIJ4w231JOke8WBbhqkaaxDgIS2m3Nh0LFEZgf2iUyAkUr4bNsf8KyvcA5+qIuMV2R", - "LobMXRJPS6Qfmw1BoaWP5I0iJYSPitK5JYqX/nOZFUKxNAccodJFnBtwNUlz9YQUcYN1y1A9W4sYLYd6", - "K/5WNWykyBdES+OKVh2RG7zRZOdK4kMRwRpDHiwR0yFVtxjdoRDMTkbvF4u8Sd33qhGPxaQSlQxzLdgN", - "paraxVqWlnoXPMtJfeioPoCEwhs0BJenc3UOyrKbJkJBXkaWCps+LXlp5dEh5Q2SZOGQTqncKEq4NJRh", - "eN9FOhq23dM2baPOKx6QMuydfmuwAwpnRQAVrT9CfCPgfweZawdtUpEMysvjJCURuXl2C7wZ6No4RtQ9", - "S8A+sDP0pE7WP4/Vr8SMYzLIZuvSNqwyeS8hZ3ivqRKpI+AuzQtrFHTluboIO6X7SStbK1QV+fJ9CxXb", - "RbubssV4qRTDOELGOhY2aXhWjyhXqFASZoG6kdEdLIpyaE9Rt9LBa5qBLJaqAkInh5R8jBXZM6wLedVy", - "iPOUtV4mSQLeyCWSnGnH/Ykq1FzmnqAkVGWpa66ONqnancpO0YgOZYT0EKSEqo7N8/F5aX3Pet5c2WnP", - "Ol5f3wqi+poameZS8sbxE7oX0pirVGpJdlbz8idr7ebCqYB4XHA/oXmDLQDVHetoMtb1NYsWEl3UQSGG", - "exq865bJ9jw7LpEN01hQV32Ot5GeJRazwMkNoikVR0PeIFU6Hx0rYittRnZKDD9L20Zpa5sMfxp5qk3p", - "ELM0gvcoBPN349HhD6+VBJW3LzYDdZef8t4mKE6odptayjZCuHWsrd2BWJqvSa7J5oqBgoi1L+lgxUmI", - "UpSEeZkIDUwbcltyLCrJIrRQWZNDXr/kmLXxs2tS71nqmOWlNZTnqaT0ZK1svUc/bcpr3iJmk2bMjRPo", - "IpUKy7tNEE0t3+HapE8+SRd3XuHgDJHMuVOl4BL03d+TPl8RPChFR1+E/ZkEiRUL8m56Otq37vtUICC3", - "QydMFODsBLxfLLrIERNa0iZF5nkIys5cgdaGyTyHHT+Hpj3enYVB6p9CqrAsRmW5Yrv51VZlBTHMWSUs", - "tYtAURk8rfJk7U0PrWm6SJMCChKkAmDP3FCJz3PExZ/qoK3iP8/rJimSPS11CKJbebYTS8gLgdarMH1x", - "sJHLMD3XjjtfzVWKsKAYuEMU5SXZRsESBZ9QOAR3FHM04ohx8ZewJbYW/kRRQKgspCBtvuIuzNRlinDA", - "WdG4UeoOsgeNeP67jQ5NAII0woj6Iv6e7aC+sbbM4ppq7ELp/uzpiexf7WYLeifK62uaOblsyHRYfYLu", - "ckupRWzfM47ivSWCEV82qjLywXfquXUGTckZOsWFyiddR8ozXyi+CDMq7SCWXSsM/6kUGOVrdN8pU4NF", - "9RIALtnfkju8wCMxSXMZn1/Jb/gEn8rn1kj1xTSyfGdd5Z53hHGgngWn0ovITeuQTRURs5dgagfWFxKT", - "NYVCxISupZrPmYr/9jhKxRaiyxjsBuAGhwpRtSjc+yr+mYbf9mT9KlMbtrVKhXqra4kK8fQD61OM9fIK", - "fFep6nBdVOUVFWpBsjNREKB0wwVxJBFvpcSomHkI5tOzvQvVMxQEMIXXOMJc1cHPby10a9Fyv6bdZLgL", - "rSTAAq9YLlD1a5JF05QN+KXEhHrsVZguRDvOdkf5ApsYb3+TjKfFt9W8fVsct9uEnNNWiZQpiomuFyO3", - "1EC+Ylxx6GiCzGg0eDPYG3z78O3/BwAA//8=", + "7H1pc9s4mvBfQWmnao+R4iPHTicfphzZ7lat7Xglu/vd6cmmIBKyMCEJBgDluLP572/hIgESvGTryCQf", + "uiNLJI7nxoPn+DIISJySBCWcDV5/GaSQwhhxROVf1/AOjTPKCBV/hYgFFKcck2TwevArogyTBIWApPBT", + "hkAgHwQLSmLAlwikFK0wyRhI4R0aDAdYvPUpQ/RhMBwkMEaD1wP1zmA4YMESxVDMEsPPFyi548vB6+OX", + "r4aDGCfm76PhIIWcIypG+t/fT0Z/g6M/Dkc/fRi9//OfBsMBf0jFoIxTnNwNvn4dyg1c4Bjz6vov4Wcc", + "ZzGgKCA0ZIAintEEhW9AiBYwizgDnIDjw5qVR3JUe+H6tcHr48Oh2IUYffD65aHcgvrjKF8jTji6Q3Tw", + "VaxSjSEhfhKGlzCBdyi8wAmaok8ZYnLxMAyxWDmMrilJEeUYscHrBYwYGg5S66svgwAmIQ4hR5NQoTQH", + "WYQTNMp/Hv1+OPoJjhbvvzw//uqB33AQYpZG8OFKbtlBzdHxYRk1VehT9CnDFIWD1787i3IHfp+/Sub/", + "QAEXExdguCQhip8aDr8runmv/x29/3I4fPX8q5+G6nbhXXeKzyhV7NJnpSRE5SWejP4mSNtL2WJRC0RR", + "EsjXPL9y+gDnkf3rnJAIwaS6JTG3/Yp3XxlfzhATDD9FLCUJQz23iD6nmCJ2IpG4IDSGfPB6IMmQ4xj5", + "9hiRAKotoERwz++DP5aj8dVgOEDJ6HZmLbR4J2OIJlVyffWiH7Xmw+SrGFo78EHoLQw+oiT8L5yE9ooZ", + "jrMIcinjlpCG95Ai78LHMIp6ghQm7B5RFPaBaUAR5P1eCTFFgRKaxb5wMidZIliZZFx99O0KJeEUQabe", + "LeHD93C/leEyXwcwij44euHL0avh0fGhX7wJcViVDe7rw1cv/C8LTEABlrYRxAL+8rWGi2PC0UkYUsRY", + "aZS///3PfxUC+v2X58O6DTAOOXLREpBY/CjQBiP1CQYcryQFCwAPhoMFxBHyIyxLw370UWIcLEnCAk0O", + "5fJubcIyO7EJxqZVe10+5hPMcyKHWk9TwAp9K96Si5aTCO5N7rJULJvHCy/sQnyHeRmNAoX/8S8no1NB", + "S349WwKhXkzdPi8w42vKYMEe8gPmKJYf/kTRYvB68C8HhRV4oK2RAymRvkpTZqKeF7aMXhOkFD6IXxP0", + "mRcm4pMab67iFUuvg8mMQ8rXQ71XBogvS3Lk+HhfxUAJUp2YzwtIFEVZBKk0YMbaIjH8MBgOxmcXF7cX", + "J9MPs5uTm9vZh9urk19PJhcnby/OnF8nlx+u3t18mJ6dnP6P/cP0/MO783Pnm7OfJ7Ob6cnN5N3Vh9Oz", + "q8nZqf2zGkU8cjY9O/VrTb3mKbrDjKt99ySAkMQQJ63M4JnoVL1py+G+Q8zki2UU6iWZcZuQ5VmPhbVA", + "iNlUWCZCgqGUdQbirKxZEsJHVD4gTA6xNgRpsNS6BiUYKSozT4yWJEbuN5RArZusL1nMPI+Kb4vHUYzo", + "HUqChxFJoofqNKOALeYjscJU2sW0vBY9lP+5LPmYkPukETQzfJfAqAKUGEGWmVHgCuJIWtAdxyyP1hF4", + "XsBX0EMXI7JYiMdxPFK/wvCh30KFykMnoTi+Srog9Boydk9ouOaBLKMUJdwMUj5SHh63WelC3dzXvF7R", + "N8fNr49JssDCvsEV67TDUGX1VNqZu9D6eb2sTRIOg96wXcewrzvd/+WwFRFV21stu4f5nS5Jgq6yeI7o", + "Grbvk1iqNgjcBfUyP9XWH2OZqRF6GGeaSBz77OjwsGKhVU77eqKGfVxmHK5vSz+Kqh5DEmUlWota39ZP", + "0QoH6IbChKWEcs8RXkl5Nhf/h5R7JebZ7WQ8vqZkgSO0xhFk5fXX5FQ6CVHCMX/4BSe8P2iflttzv8YI", + "ZTgIRqna9Oh3OH//p3V4z7fH/Ojqw5iEda5D+3iicLgeBPUeuzOpQw4upyoFY/5qYVqzYmsJPoicI8yW", + "2RXheIEDycFvcRKKxfcURkuYJCiqHGj+9H//q3/qeDZC9kmi5P1xx23xdjrOwzY3UtUpEkpT5x5irqwm", + "jpj+xLIgQMjxipjZxKcAJgGK6nwlK0RzSN/SqHrFcE0RQwkHwmoF90scIaAX8QYkaIUoSBFlwmYTk1n7", + "enH406s2HjIek/IqbGgNLVza6PBRzy/aO6kdmXUSUDltRpAXnz/FuPgjnuNYbIaEKI6lC1/o0jRggReG", + "ZtYxTOEcRzgXhz3IVdvTvxIc1ArQO8xhJJ+4RCGG/sekKLu2uLz6yJIw/iv5DS/wSSYw5XsmhkkGoyvE", + "7wn9OENR4T2tPpvopwJY80CKk6NfBYofan6nOIb0YYbjCxJ8zOWh58Hs49FtMo9I8NH/AF0I/U9J5P+Z", + "4fgkCLSDwv9zGmY1P8Y1b2VsfguDDrcUxexqtGEJ7T4c58MXq6sg0N62i41aPPph7mDKhXaZsJq472eU", + "oMKNkZu1OOGvXgyabxKLQS5wgk4Rhzjq7SB0ubBJuXk5t9HO8HgEK/Lgztl/l/ktiPkMle5XfVponWdJ", + "4Pfn9RgpXT4wHMBImZWPGooiRjIaoJ8pydJHjdTJSyVoR3ulhHqc5/pMk+/T3aVKI7ACqCoW6pZRBU3Z", + "prSIaejSdpNrzRDWDUlJRO4e1jzShXJH3Y3FawcSmn2/Vh3uj+WQCCc9VuURKZ41OSjrPvil/Vr96GTO", + "EF318204pNF9RVP7tfoVUbTCrAMGChLSzytFmZsgndY0m1zKF+qXw3A8i0gP78FscileaBixynE9Rq++", + "XDdTSSQ4DJsD2aGAYc5YFbKzAGGBuWYzFRIxnOEVCQhGfLmmIIAp1rFStk29OvLaw/PC+m4CsR1tIHQu", + "5HAOGRqTTB9sjZ1w6LMTcMI4jCLX0d803aTyglYnWbsAkaCbqWfVoYm51nDD+SZjg+KNoQ1J3yYK6JUh", + "Uo/TWb4JgxjyUdLYHYVhzblv4gOfeT1LcII5hhH+Q99CKLd7DAUCEnGgrBl0hRJO6G7UzSyLhUW7e30j", + "zY/6xaynCtYV1o+TTvXiRGxybCLJ+kqTMKwL7BqWI95cb4R0bmLpj1ABm2rdckOAk48oARStYCQHCAHk", + "QHnAgQbsI+IIn+JcsSQxeifvtgufkrvBX0iMwOV4PLq8GoMQUbxCIcAJwyGSAaknd2Lz95gvScYB+pwS", + "hpM7MLmcTQYVh5RyNX8dqQ/Hwxqb2l6VOfK4q3pLsiREITg7/zC7vgIJjBFYECpXpNybQHv13gAUp/wB", + "3C9RAtxrujafV2wFS54+ImjTHali60uF29H5Zw8k/hdVFvOXlrdmiGIYFbcBLlzH6soNzDV8YxJmEQJM", + "vqRgaBhzCAhfInqPmSKE29lbYAYjVL/SDfr+VQshL7idFcFuRh+YMIjLd6dnlx/enZ9fTK7OBsPBbHL5", + "4eTt7OzqRv/hhlOcXMgXP5ycnsqgiLeTq9PJ1c8fxu+uzi8m4xuvDmE4Vi7HoFWj29G1s8ll/lrJ9tO0", + "5PGXe8iwMeK3RFf1JOujnAa6qF+vhzk9UsSFWuWYaMRtFcetgv0Crx+x3E9hFrqkdB14/KLtOrCYsG4/", + "Z3cUMbbmTYKwvujDuBrerH3+77/UaQz1Zs1tVbdIzh5RXBFmXGj3a333l4fvv3r58vnLYYs9HZeCpbIk", + "IMkC3+nQEOUZl967JYnJSO/Ny8IdJMnZz9Oz2UxGRwlpMPn5dipFhBAsH35999vkfPLh9mp2e339bnoj", + "f5ndvp2Np5NrGWcl3pudXZyN1W/jd7dXN9P/kV+fv7u9Et9dTn55d/lOxV/dXl1Nrn4uvpyezW5Opjcf", + "pmf/fTupC8tSRm+7VzePTosVK9r04tJACUWFXd2VLR0yXoM15+rNfozp8k5f5syn7LQnc2u/NRYtU313", + "Mi/t04P8uh17YqaqUU0+irTPFU8Vz9DFnLp7jD8f74kL+htxHNe5iV2nsLk3tTDjpTZyh9eMgEkfEd/2", + "9Pkr+Wp8u7RyzXZwVfSkMWtduBE/wkT4Vo5YzaekkxtxUEIRoIhlEa879ACYhEDDGoUAMiAELVhAHGUU", + "gfmDPEf9hubgdtLlgPRtHuusoLE+jrX8pXeFg8VV/scej3sXGWuxq1fUNh/X+ggQT4TWLk5wDgYK2V06", + "pBVypEXGrWH39XNf2vK0r7lX7y+soN1jAEm5MSKLhRhGh323GUW2E6C/G7Jn4uFT6Asd8tHHuWFH22ds", + "Ayqkl8Du7Aeji1kPgaCSpM8LmfDNiNgncln1hVUOKW5H/Da9XQ4Q7iIwteRjrrQrpqwVaAb7FeeUYYBh", + "zoAN8sJH/z1DZe0Azi65TUXuWOWyps2FTUq+fYvF/u2vrz3++H//a0NCXuGNb5uXwvLynh97XRpFclSP", + "MH1ffpqjGJ7bgcDPfXf7dwmMpozh03nsuKbswhKj40OvZ4q5eUtdlmqnOvVNbfOns+W5vTlxlMFZwlqJ", + "GBSO3M2UAeNQWxtPnH3KcBqjhJto855sgWOEPWm+778cvexyfBVvty1xDUtFKqLepopS/g5JvmqzVPRM", + "bXswCsm+XJf3pTIvrSnzrE7UWyOlKrZaSMK5/tB5xOqqtNFkzCf/CPTj7HI2JskKUaYTZOCamXDCartZ", + "UpLdLW/IR5Q8be50c0bxv/31dSmZ5v/0iO+LkcGzD9Ihc/TT139vJ+lyZn9le15SkW5CmQ5393jt1Cbm", + "tbunqzaKYJYES3PT7tpPJ+rWdoEjBFLIGGLSNtIBKSgEAaEI6Jt0TJI3gC8xAyFBDCSEgxjBBECQUhIg", + "xgBmgGZJolMTPHHb8wizJQprorbVq3bM1STslCYhA5pRuMabS3j88pXHW2zCAPwlM0o0U+zLgXY+vIsy", + "V3vUrL0OGk3UR9ePxKDBEnMU8Iw60gTGofTtQhq/euEVJYJmuO/80SEJyQv8FsgLZfkH8jugjTVx9PzF", + "8dF//qcw0pud0lYAl7WClRQnf/7735+VPrQTghXflcPFogK59KEL7WaEriVM6rAppm5FaM76fh7Nf+6Y", + "wLQefzmoaTZnyxZJvnwn2s6GiIUQezf1iDiFbDknkIbrYSM/Q/rjnFTGRoSopeLaBV5AEW8s0vGi5gIk", + "o5FPQzch4Gl4wz5LF5ipbl8tMd9jPVqutLrcx0v8J3bMJCTs6tv/qKt2lbjmkQ5TOWrTpXYzmtY4Aogt", + "9zgBFBRRSSNvyyNXM9VvYJolHMfrieIIMn7Wx7ZLkbwBnyLGofLk+PLowvbA6F0YU2LJnTVD+eTCOElT", + "ZFk+Mo81i3j7rTvL09bC/hZVBeb2RoYlDNZTiT1qb33N8QIGfGpCTUo2OsitR2AeVcZ6xTwHaIUSgBfS", + "iI8gR4wDihYUsSXIc4Jrk/LXy6dHiRDqdeZCJdneAlPXgKa+PCRemKpddz0h5S9Ug+hldrPJr5b6qaHm", + "nJAlReZCbpAqKdTMsIZsqwQw07/ksbYJ+syBJFJAKKCKbt8AzEEMH0CIFwtkFa/VZG8OaV4KqFoFKm+7", + "7YqfRp4LuuOXLx+tcJQNYMYvqMyC07DEOGXE+/BqY2g93h7LC7r1XCWZN7M+kukUnzn45ebmegZupxdv", + "VLi6YGgcxyjEkKPoAYTkPokIDJm80jbh7erEbvOVLwG/V8QFjToCYw29bi+0r353ZGxZz7doeXfebttb", + "Mw5tc+K0hYAk6ZhNA5KYrAdBLupeiyRA1f8B0ulIV4gBWfu5Z8UGH7OalXeDrebL/bEK3bv7dQiygeK0", + "4KkhPHm5tYUkmjVj5p7ivrv7dXFKSZgFvGJyvhjW+RBe1GWbV+KPWzN4cyxMSy8/YUpCmhLKZ50yDtUA", + "zhuPuuiVZSTq/QyevKE/q1vKP0tPuz7r//t/+D0LbK6CYTwmgfcecoWSkNDHIroxVcParjWfTWTldRd3", + "3c7ltoODplyKana+RTbt3L+dTIuSxOl3ZdaSaNHMR54kgPG7q5vpu4tS1pBMKhqPz2bl6qxn/307ub48", + "u7r5MDk9u7qZ3PxP6YH868YEI1/Cfs8w+m75zeViRE8kUL/h2iRPFs7dMZjaNVaKxOr6mhq1dD0rS+/c", + "e6F+sAqYiiOJj/DsomZjVdKqd350hFeIPlyWshbu0XxJZM73QpZP+wDTtKa696YMVLRCCf8vnITdZZEN", + "jzPzuiuSfmqqMDccZAn+lCH9M6cZ+mrO6PlRvD5b4bBTtkK/YnHiXHmqkdTdA2HeeKQLYl2X47qwSilZ", + "4VAF5xWUGJA4p8OaBM876YyTXv5xkWPmJSsO6R3iN/J7H73DjC8JxX+g8EPGnLKYxYT6aWOcmEE+PcAU", + "P7tH+DNOnn369EwtnKQoeaZW/yxI2j2QWBsVChJDl0Wd9ZfFkb2seqjYDhCLxyqk5qWkMk2UUO4yi0/0", + "eUTWOnaKenM9yWAkpRt73loi1kzZcVd7d+DfG3n6FFxedV28SxWEgar3CWQgHM8oAur2z4TjFs4KvkQA", + "fcay6KV+yPVevPQWy9Zc5i1tOa04TN40eEvEAgKKZLQdjPp7Tmwh4RMEqu6lC7ca7m8j6gL7tqESs2cU", + "BQivlDc1Zs/ym4EARtEzq++K/DvGjKlHHxhH8bO8nsxwsCL3eIGfBSRJjEtWfxViZn9rgn2feb90HvZR", + "UU0eSk/XUR5CbrsgXg1LlZl/PxKmp47MPaqLQZHVltZKpJnJVys5NP34sXyDmWef6HX5KKO6Bru7gsbF", + "iMmSpzhmNYjwFHZ7Klm59+eWTvHDpWJE2wjO90Xht6SE+lZpx305bRWaM2/81Ze+j8Rkp2pbx4plTq22", + "ju/oYnBdn/6G6NQDwdJ2vRBrJfApghHHMTrhXGhq1/NU0oGu2vPRuBlNatS+Dnp7Bc0FG8tLFvggKQ76", + "FH1UY9yI10qXHkeP1DJ6KU3QVtPakJY2g4y00zXhDM6LsmLGaBC/IMbgnUqElF2yhkWXB1OMQTlYciOH", + "mZrENYirFsH8bvRVDD+r+O6xaZZW1IJ50VoIBn4ek0Q3g3mXNrx+VFdHxqp861Lwuvvpe2Z5+srF/cuv", + "5qevnovXMnAnoFvLr1qAyIN9Zz8VyvRQWwcBb8HXTiKGISajIK+DznA8gsZttiI4QKPYFFON2YhxQlVP", + "Z/lgUVh9pITSCKrS6rqc+ohZtdPFG3ZV9JGnpUWBgtnk0uRoVewtlezTnPZTqp27lhRraLvy6sV+CzNc", + "hl1LUWAX1lZgbJf6xIZpc6J90iuKYtQ8sNbdXV0LmRaWcFZv1/AS2lElUq2EGT+q15duNeV12v7IFXSL", + "F90tPSUh+uwoNRO21uTrfjqNknYMJdAYKUIIOgpntT9rnmEJQV3IyZl5/YzFUq7ho9q+pjDQWa49D3qB", + "tYIbwmG07oWHPVCPSuIuDKzCwM1NabVJ/KgVJwjSsQU5X++W7bS+dUBXixPfrocF7ksb8tKuH9r9g/cv", + "1UI6YNc8qa/g3ule4hePacy96dxXwbYUwaZ70h4xzC2ZtDY43YlrMHjq69ae92gf5o3ba0SOhbo+UR1E", + "xeA7TuLDdifUY7vRt1BXAYuWZmTtyUTdmznvvKm8uSzRqKyYFXUljDfNNULn9cF0t/4Cs8tZEeS3ge71", + "dpP62k72kv6HRcuCKgrc/OXurUULltyNAZA7l3qobEuot/WO35WKHW68AgNf3/YoV9so/Hv8Eaq9GiHz", + "KUOZ7l0dqKbEOlfEim+QxK6dvl6VgZJwdjlbL7VjXbURyv6N+SGoZ+Pg3Xf879ax396lFjFe1CLuKbqy", + "HkIawirKLUkb8hbq+//swP/yLV+d6hZGm6/d68GYv5STccUU52F/cd31XDF1q7AvWVVDYGHH5h8jEnys", + "EU+Vni4962bcSuVs1V986p7Y7Ve1DUlEPiD+Sn7D59gpFdnz3i1OPYmY7amY2ysTgJgAxklDw2wkC6Nf", + "Pl1ZfhThO1ybMdQ/2faRKkiYRb2SdXWFK38FIZNtMC7Ba/2GA/KXX06mp7+dTM9UHwE91g5aEdxhxhHt", + "kWJ/hx6Ziy9Ti3U0mIrRUn+Ytai/8qpjFDlPyXFMKn9NxHJdHwWXNyy6LaO5ENVqw9ZyLOZp7sjggLZM", + "lMNclnRJGS5JrY3X6S1Lyaeq1ftVOskXpBoweUECGKFRgjJOYQT0XdsojWCC1F8w4DJZfYbjNMrYM8H4", + "mAuZM9BfgZPriVUI5vXg8NnRs0NtkyYwxYPXg+fPDp89V+2slhIMBzDFB6ujA5jx5UFE7rBSgkRB2DFm", + "VdH7gdoqYvytts/F8kwMR5pGOojg4B86N0kBtbU/gF1Q/6sLUH19S/XxVq77+PDwyeY+yfhyhhiTKbT6", + "CC2XUCoakfGlMFgCVTYiFApPFqmUpW6Z6eD54ilXlmLJGb7lTBKZrg4k0gA1oBMLONrKAsZ5yC4DkCKA", + "1YLUEn7ayhJuTGG9JEAAq/p5Mk1IsooiZ7Gc4y0thxAQw+RBFwbRqNGSTrqgXm6JOCQ7gYBkUSiBMhdS", + "JE4jxFEoxRUz7vvB2CS0l0h6Tsk9QwVpDwcc3jFpZWd8OXgvRilLD5LxRvEhfq8w8gtfeQ45J6BoRYTx", + "vkWqviIgwivk529BY+aiTq7p+XY4bTY9t0vSaMW/XXoiGe9GUFOJNBXVr2uJP5Ky7JYpaeYhrfESJnfo", + "xJ7l2ryzGX3VMGMvLeYhfjMOoCiNYIDCNwCtEH2oIcl72X7D4pPtUIS1SrldwRqOBtgOuxpJQWhObIZa", + "Kiv6Xpg1R43DroaaKtwqv1YVb0hUFnw5MGESajIDMIoM9bF25jVc/vrL4A55ePdnxC0DbLB7O880QID1", + "9t6+KqRtUZjhOnlKLJMZrJIYDJ9IHQQm7NZLSuJgaMIfU0hhjLjszfO7f6fFIwfX8gotxgKKnR7W105f", + "32+QXsVWnHs9DyamKBAQFXABFAWEhmy7WuBOX0II5DJChSCOBCB3pA+uSNPJ7Kctncz0iSQ/jqhDyNHW", + "zkS/3NxcA53BzR/MyQiCFNEYcyHOOM0YR+Ho4uQKLAnbqvSQLOq0gpGTP9/i5OK8KkBSuHx1JUBVbl2G", + "A8hFvdgazowdhT4HCIVISUzThMd1CIUIhtId58pZISvADMdZJCnfkQmFUFUitCpVD0IMo/rT2ymGkYDd", + "puxpGEUzDinfkQ9Ibs3HyyGKUyLGVwaUZOXjJxRmdROL73XudLgLtxKhIEsYXCCgU2G/MyF+gRPk9DyU", + "ZUEFBUihEeFg6zJTiG8m+xwHKD9WOBJAMCnAiRQdJUGgTLUYBkvlx28VB3n2Xq1I0DOgiX7yh3jYnngw", + "MFfI3aGc2IHjeR/Fw/7JgknyDxQIo68QBNgmmi4y4Iv4ZxJ+PYBBEU7lnKuwWFAKVUKXvKIfqHcGZfYc", + "Wju3y0DBqFwDSoZGecOzxHHLL4vGykDasAg6CVSd1b2SQSoKJVRI3YEEgHkQ9X4IgBfbY0JxhFjIgPkt", + "ih5FhLkVwsA95ktLye+fKDpJ2D2iQ0CREElDsITJHcjSIZCoS0JwenN5LuyWXFS1CCeTwN7oCCqy3DfH", + "kmqONieNWQpgsp4hmD+Y7tlAysy9vd7a8S3uVinZ4KjNmylP2WFG5aHAKqWQ06v56v3XWmUlzTU946bU", + "lRrdlF/rpbOOnnoVDQDfpe0amNV93/x3mqlREZAN1fWBH0iD0efJ3DJLuhxplE1dGEOOVC9HepTIwRf9", + "aRJ+1aVgkQrdKznA5Pc207Zd5Jr1qxF3SeEA73EMxYutEtNOTLZ90nAuO+W06fqR5LdtzDTschg0vNX5", + "PKhe6Hck9MWEqGPR/irZw20qWZ1G+EPJfp/y559CwyuG7qHhVU2WhriTs9vJeGzqw2yMPa1ZfKEUuXcO", + "ieeKI/yPG/NtXQkrwMcyjStGCXeufeRtin0rXA1tKVBo1QOSYxZNv/M6VgXR6pJBFYo9MM8efNGfjAd2", + "pXNK2tVu/mJXtcvMHkZO3a3R73D+vpcj9kSvU1K9ztPbIXfdYx4skQpfy9OYbSbbrjLUYP3enKWaDnaj", + "/L5Z4aKIF6hMVkM6jptUR2XCEMxh8NEQuEkKbxI0K7EBSze6iz6DwRLMZmcghByCBUaRjKYVpoOJSHHq", + "tz4D4oTDOEUwFg/CcIUZoQ9vQBBhMROgiD0kwZKSBP+BAFelFfIQJSh3KKOWxG5kdlPprlmOfaaW3SpO", + "OPrM1R5HalHdcenWpfXGPtIVoiNhP8resFzvezAcLBEMpVD+MhjDYIlG+lbKnT1vQUJkqURfCWg57ffs", + "kvoRQte8wjOL8FzrGTEO5xFmSxNzvh0x52RC2KHTmoacq3JXyqlNuNHWpvKpCSFV4whpl9d+BkucOI53", + "LdMcObfUdRcOuE71bzoOmCINpizAJs2W8lxdAtITQmMY4T9QCMy+QL6vH3dIxVl2CZmO7tQZSoAhnqXy", + "vFtOEpxnTOY9g5PrCdsPIZOLmG1KFLGcClWVDuaywyOSsYE6w7Fgzm8vXDZPS7D4KnxIYIyDHBJDMJtc", + "DoG2X4fAKX4+lAJJRgLdUZguLVlU1GZ3xFHxdYMYmlh13Tcmf/JJugiefNmAJTBlS/KDtZ/AfogISaXZ", + "vm3jQQG0jFI/r9fxjGwwtEJU8IyskKuUszyqjASzAbs9QQtbiOdHbs9V77Ekb4EKJqcqhl/2vxAnDECK", + "LkbsGTBUzZSzECe5aDCBFzHiUJ5upGfA6lQ5BLI3oGB8YGpIgwVO7hBNqTA5hua4ZslL2YyODQF7YAsG", + "ZHUFGd2iYSMbVVcPNReYcSE+xsXWN8jyzkyyhkZT8pvqxiuhY59YpbSzUPXD7GgoQFARQFvzq2sslrBV", + "jm1hJFohb3xLUDOAIAdxVlVeB7U5sVNPgvIIhoLfZN8CSwTo2igV9ld1ZUZzLNsoNod2iVWdyeffmsc3", + "zDfObHW8I4Gl9gHyfXiAiz6LpWAOVI2pIbh0SkwNVe6FlZqkx1Qh/Asr1flCd3tpAW4zNK36ZRuFozVP", + "HQT1I4rsIsz4D/myl/LFxlPHiDlXNuigLz2AlCpLhGkuk3IVa+pQlSm8uH4o58PqI4nuK1kYCLkUk9Pp", + "S0YGsPgvjjMu1ZtmWyDrKdkxmm9ASJA2PWXpBzA9N4w7BL8QxoGqGjUEs8uZZGEZuFpV+idhaHHChgIT", + "3El2FPxnb7NOXO4g+q+wIgmVuN1dqvL+RSnksJHCBkRE2L27kHzOSmCkhB5FeiUqiBoCRVu7EXy94hJJ", + "Eon1Ml5Y0cXhgpJY3q0Yta5ui8KRtJ3EQeTAXKDlQqyD0j/4ogr/fe12ZWyVL+9wX9y5HGVLfNbmRWFt", + "kdYtR2l1kYY7CNO6+iEAaxn8Xjub8ivr3YuZnEL8oVHyBdtukclBJBNWo7FtjNeijwg5UEeQ/ZQk11n1", + "RLipMpXlefL29luWJtUNN2QGeg6muwh+EepMnW0Lf5Ipq7xFFpfQ2EkkiuJOc45gWZoSyu3DQ4mtr5VN", + "AWBS9hkIMLpeAwfJwZLgwOb9e4ple38Zo2YKEOsRmuWA7i8xqrToqvcnqDfGpcZU/wQFmZqarvkiRS5n", + "wAEbwAlI0D1ifLTAlAkshogKSxbBYAkicWrlQEP8XxlQD7FM9tpcZBGISACdfMvvvdbTt+6w+RHRkps8", + "iupLDONxLW0vpkUtSQhKzP5Jq0WZPNaqqBJC6TMMOFDdrADM21kZZZF3HmrXFwcCdaO8Jr7XiruE9KNH", + "dUwF0jdjzIkZSyJdzPbY+q0yFNJcqkqUQCrZTQZqEiqPFYVHA271uKdox8aoWBFJ4acM6UhSa8V7L9R3", + "XdL1xRZZPkeMZjHLP4c+Y8bZDz33Deg5xYAw9NVOtXyIP7Tc02g5IeOlb3WFGfZpukJUQyWhu2q4Toeg", + "LR58hmU99C5VzVG8Tuc3wOlSqdSTUnXgfokSoJw4tsAdKl/PpwzJmJ6ys+eRzp365SszxMFZihB9kx9v", + "1RKGgCKe0UTd+4knwBIzGeIEA0oYy2/Lfdso9+z07uYpOp1u+oDq6wdaczbVtUHrT6XqTnbPT6FDdQZV", + "V642kSxwxBH9cTb959DZO4uWNAdTI0x+HEk3eyQVb8WECdKUpc3tI2q1mrGlousyUmcoCYsm6Zs4SJba", + "3G75KsDuqexJzEmEzneLlAKyUD5lzJRXOLWakj5lBEjz0gTRCKzm08uTsQkN0qmNMnsXc5Zfjht62HoO", + "r1husVQcDqXhMQRWJ16phOYk/HF43rfDM0MRCqQekVawTrTZjQ42S7DuwYZS7HmVs6QpXqK9ovrjN6Cp", + "Xxwf7wjPlbu22eVs24d9W395zvwMrrZ73jdSV0ZGSqDIMjAmHQR7OjN8SzZEcWcaqtqi4vAvtUwCoEse", + "nU/6B1/0p04l4mxbo81XbIxLZdjsslKccSr+CAG6zN2r32uluOIurmulOMNkmo5lRKNgXWt9spbEHDJU", + "a723BhblXNg1tshTRO4v/tAim+tlVMSBOlk1JWqq8ImxfG7GIc82mz5Rnc1fUUueB7VvUz+3PYY+cTP/", + "cyx50gh1v38U5odYe9G5d68+WkUhquHkd53JSgg25HaPoV91fkJ522lmlW3YmjdNl5pSlbiFbpSXv6Y9", + "UlF5Y+tR35UTg9ToJiLTApZDWD9rmhqa99FQV40gMQ5gFD2YV0vQ70FtPlFBURdBQdH2xEQ+V0N6oFj3", + "PsmILTYwVDKmvipJnugsnd3Rg1XKTUHYAV5nKjnQw9Q31ZmoBy5kLFiBzF2TzCTf/v4RzT54MrZlHWIG", + "dIN/Oy8mJ07FQ9s5bp+S+yQiKqRlhSheGNz4W1Ppp4fq2YchSCmZV8Wz3oriPRUQSRYLHODiIjMgTvB6", + "G8upUZrk826YLdd1XgGlHBYoLHZPUYQgs3OA9kVmb4fe3pUBkZdwKLVpyx2vbiLWEgUflSPNDHSJOBxn", + "c/T/DGXpkbsRVwjZck6gakverPpPzaPb0v/lCT3g/Jt55Pu0AE5L2+9iCxQ2QAE8JIPvU4IT5XPKrQPp", + "q4oQBQFFMugCRh0NBXN/10pWU/XgtojKnc7XjNhK2s6YD4oaOiklQVHRQLnqlGNObD4R9jfL5vnQIJQX", + "VUGprmU7CA90skO9uTVVD+TW/s5BqNazXbNiZryi4uyzkP0i1DErglkSLL0sYc5XhUs1f1nd3Ml9GIRz", + "Il1BDwDzfghsQd9sr5A32z7qrhwsCe2XRxYrRqpUW7SwIhuHWY3ZowcLm6rff+6scPjRoLovLknahEqS", + "7hEmSZpuF5MaJ8bnW4M9kqoACZ0g6srTbuiwMdkSxChfmDnPbxw39nR1dWLcJe2de1PGsThgBjgJokym", + "36qgujSCOOHoMwe30wvWx6mpCgpUYbWpJInKRGoBu6opUt2376SuY0iY89z2L9Wq83/3zpKyPKMIQIsb", + "frm5uZ65yuZ2emF6WqdU9hbwXiV1FHgHX+w/u13o+nmt7W731L7M3c7Vpb1G+/7S39yJlZ+maIEoSgJd", + "XSUx6cRp3sbBJ6Var+1cgHfuSGG99SSVRnYnMXdVJKCbsDRVAvZPWO4R0+gKGw7LOCVEiU5XiB5UkGWA", + "ZNQiQwFFUsk/haw6kIVEe9psV/KdjZOZmKbOXhO/AQVMbIqG7hFypb2WkGSksZW4y913sdebiihaUMSW", + "3aq5bFN617ho5Gq7aOFNik69DL/TRv5UOSUnoar8uw8Evz0//TniwRIQClJIWU1HcPWMdPwpNvtDxRCh", + "JKAPaUUbPYXoVJ6Nb4TmlUduFyTfFr6S+wqD2kijw+0Tetlxuf3AFTukQvW519GrssdPCVz13tTCF65y", + "efKNLXQUeCJOScq3WhTzycNXpF9Iexm6eoVIiGJ/9XWPdSEe3k65cneqtnrlWWKVm/5RoPxbKVCeV9Cv", + "K1LOApgkVb9BAJV6jaxi5dGD3ZVAEgIDfAnFLCukB0QJkITS3pRADdClcvalenLztSPlRG21sxUH/Cie", + "vffFszWFrlE92xJ1dvFskyGnWmHkbS79lF7bvzOvHH2pK/dvtj61nGW3BarVRn2Ykqyk5MVOClTnIbk/", + "KrR+KyWq9c3csJTAt4RMda6WWXvCQhNUnsqOoKYmx86EULdK1idhaFep9qjb3AyVJfLtWrONoqiidA++", + "yH9l3VkDpVEOpYZYDuhIlTPz7sS82inJRE3d9dAnH+982tuKhVDdd4MFNrk8m6iCRKqyaPRQZO/k3Rjk", + "Jt/oxkgFfXwfHU9f7IAhK5WntxaFIKfHDJDFQqbQEqpaZJhi9ar7RQjCjJpzZl7Dbkt5vmcVyakIWKau", + "sjxwNq+HoVoQbTHF1rNAzABHcUoopDh6aAs2FGKWJBL+sU0X/8oUvxKrILBLxNpYW0PS0kVLgcYZcg48", + "0/OZluk7l6mbqOjh2+tuOwfUmqnTcx1IWXQCd9rdb9N2nZ7nOdw/TNY9k+05nQQwkbWDckNujnK5rqpo", + "WYSzJaE+PTcx013k+NaIuSi64Q9gQVwK67KUNu2rcpCbjvdCl6oGejCEKUe0XVInhOe5NSOBpgRFeZ+8", + "gwXCbJk1BbGMYRKg6Fw+d2WNZXdK2JDkqp+0wSI1fSrMkaGbjem3J7fnaBLaK065fRTkSJVXUh1GE328", + "CiQ6qqSk0AQguIeqVr8CXg4OPYFFLzZhqBT6unSB7xH52zL23poVIyp7vXbIIzGOuqI5nca1jm8bmWIc", + "fXDfECDfA/1Hu0H/b5rmhax3NbcuNfQH1Nef+y8NThLCl4jmyBMSQfZF3u4lvCQoiu4UJGW9TOdixeQq", + "eNIDSN6YbyT7lkUIMj4SxIkjdIcMtdrFPbpTaotma75xsclpbF7YoAzzzFd7E2mW43Hfk4zLIkbA3jEw", + "Oy7cZt5Msa6sroKePSvekA/fM1O/QMejTS6lKSw8MI9sP8gxn9oXB51Y8S+/oTGJhTWume0ezZeEfHwE", + "Yx180Z86BT3X0dEeRj3r5ZXC+xrYp9VfkQOqq8dCv/AUccrfEAMfbpuBTaiyw8D7SGR9ePHAlCfYB7r0", + "apYb5NW8g/0hiBBFWLagt+PQdkYY2zPzrilZ4RDRIuRM+m+MnUdRgPAKqU7YynPpei90fcTcmWw3IOum", + "atgD4yg+WCIY8WVTyvpMPviLem6DlKNmaKp9nwcfySfzBhA/ap4XR5iiynI2Vxiuq3v+LdUGdYs6ld4p", + "U0NB/QoALtmvyD1e4JFqmNh0YlGNHU3riY1RfTGNvKmoO6RYvSZVPxDpZ2HbvByzl2DcpPU+E9WlDDHZ", + "mETm2ZjgJnsc5WNdkCIGygC8obOljcKi0a08qptrsJ22uvWHZ+nlFfiuUtXxpqjKKyrUgmQQdhCgdMu2", + "/26buQ5LzdlTOMcR5irkJ1eqOouyWs11Hxkur59c4BXLBarQdOkfEiJ0gT+XmNCUrVmD6UK052x3mi+w", + "ifEOt8l4WnxbdSp2xXH7Tcg5bZVImaKYaNNYbqmBfMW4QulogsxoNHg9OBh8ff/1/wcAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/application/auth/service.go b/internal/application/auth/service.go index bec8de2..0e95efe 100644 --- a/internal/application/auth/service.go +++ b/internal/application/auth/service.go @@ -101,7 +101,7 @@ func (service *Service) Login(ctx context.Context, username, secret string) (Log if err != nil { return LoginResult{}, fmt.Errorf("read installation state for login: %w", err) } - if state != "ready" && state != "uninitialized" { + if state != "ready" { return LoginResult{}, ErrInstanceNotReady } now := service.now().UTC() diff --git a/internal/application/auth/service_test.go b/internal/application/auth/service_test.go index 2a4d1bf..6b46227 100644 --- a/internal/application/auth/service_test.go +++ b/internal/application/auth/service_test.go @@ -117,7 +117,7 @@ func TestLoginAuthenticateCSRFAndLogout(t *testing.T) { } func TestLoginGuardsStateCredentialsAndRate(t *testing.T) { - if _, err := NewService(authTestState{state: "uninitialized"}, &authTestStore{}, authTestVerifier{}).Login(context.Background(), "admin", "correct"); err != nil { + if _, err := NewService(authTestState{state: "uninitialized"}, &authTestStore{}, authTestVerifier{}).Login(context.Background(), "admin", "correct"); !errors.Is(err, ErrInstanceNotReady) { t.Fatalf("uninitialized administrator login error = %v", err) } service := NewService(authTestState{state: "ready"}, &authTestStore{}, authTestVerifier{}) diff --git a/internal/application/connectivity/cellular.go b/internal/application/connectivity/cellular.go new file mode 100644 index 0000000..a9180b6 --- /dev/null +++ b/internal/application/connectivity/cellular.go @@ -0,0 +1,187 @@ +package connectivity + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + modem "github.com/leonfox28/simplus/internal/domain/modem" + domain "github.com/leonfox28/simplus/internal/domain/notification" +) + +// ProbeTarget is an opaque current locator, never a public API or persisted key. +type ProbeTarget struct { + Instance, ID, Revision string + Generation, DeviceGeneration uint64 +} + +func (t ProbeTarget) Key() string { + return fmt.Sprintf("%s:%s:%d", t.Instance, t.ID, t.DeviceGeneration) +} + +type CellularSample struct { + Fingerprint string + Known, Connected bool + Reason string + ObservedAt time.Time +} +type CellularSource interface { + Targets(context.Context) ([]ProbeTarget, error) + Probe(context.Context, ProbeTarget) (CellularSample, error) +} +type Modems interface { + ListManagedModems(context.Context) ([]modem.Record, error) +} +type CellularMonitor struct { + store Repository + modems Modems + source CellularSource +} + +func NewCellularMonitor(store Repository, modems Modems, source CellularSource) (*CellularMonitor, error) { + if store == nil || modems == nil || source == nil { + return nil, errors.New("cellular monitor dependencies are incomplete") + } + return &CellularMonitor{store, modems, source}, nil +} +func (m *CellularMonitor) Run(ctx context.Context, report func(error)) { + type result struct { + target ProbeTarget + sample CellularSample + err error + } + results := make(chan result, 256) + var work sync.WaitGroup + defer work.Wait() + active := map[string]bool{} + next := map[string]time.Time{} + samples := map[string]CellularSample{} + present := map[string]ProbeTarget{} + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + publish := func(sample CellularSample) { + if !sample.Known { + return + } + records, err := m.modems.ListManagedModems(ctx) + if err != nil { + if report != nil { + report(err) + } + return + } + matches := []modem.Record{} + for _, r := range records { + if r.EquipmentIdentityFingerprint == sample.Fingerprint { + matches = append(matches, r) + } + } + if len(matches) != 1 { + return + } + r := matches[0] + err = m.store.CommitConnectionObservations(ctx, domain.Cursor{}, domain.Cursor{}, []domain.Observation{{ObjectID: r.ID, DisplayName: r.DisplayName, Kind: "cellular", Connected: sample.Connected, Reason: sample.Reason, ObservedAt: sample.ObservedAt}}) + if err != nil && report != nil { + report(err) + } + } + // Removal is confirmed only after a successful topology read and identity + // reconciliation. A source restart, changed port, or failed probe is not loss. + reconcileMissing := func() { + fingerprints := map[string]bool{} + for key := range present { + sample, ok := samples[key] + if !ok || sample.Fingerprint == "" { + return + } + fingerprints[sample.Fingerprint] = true + } + records, err := m.modems.ListManagedModems(ctx) + if err != nil { + if report != nil { + report(err) + } + return + } + for _, record := range records { + if record.EquipmentIdentityFingerprint != "" && !fingerprints[record.EquipmentIdentityFingerprint] { + publish(CellularSample{Fingerprint: record.EquipmentIdentityFingerprint, Known: true, Reason: "device_removed", ObservedAt: time.Now().UTC()}) + } + } + + } + schedule := func() { + snapshotCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + targets, err := m.source.Targets(snapshotCtx) + cancel() + if err != nil { + if report != nil { + report(err) + } + return + } + current := map[string]ProbeTarget{} + for _, target := range targets { + current[target.Key()] = target + } + for key := range samples { + if _, ok := current[key]; !ok { + delete(samples, key) + delete(next, key) + } + } + present = current + reconcileMissing() + for _, target := range targets { + key := target.Key() + if active[target.ID] || time.Now().Before(next[key]) { + continue + } + active[target.ID] = true + next[key] = time.Now().Add(5 * time.Second) + work.Go(func() { + probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + sample, err := m.source.Probe(probeCtx, target) + select { + case results <- result{target, sample, err}: + case <-ctx.Done(): + } + }) + } + } + schedule() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + schedule() + case r := <-results: + key := r.target.Key() + delete(active, r.target.ID) + if r.err != nil { + if report != nil { + report(r.err) + } + continue + } + if _, ok := present[key]; !ok { + continue + } + samples[key] = r.sample + duplicate := false + for other, sample := range samples { + if other != key && sample.Fingerprint != "" && sample.Fingerprint == r.sample.Fingerprint { + duplicate = true + } + } + if !duplicate { + publish(r.sample) + } + reconcileMissing() + } + } +} diff --git a/internal/application/connectivity/monitor_test.go b/internal/application/connectivity/monitor_test.go new file mode 100644 index 0000000..eb82fdc --- /dev/null +++ b/internal/application/connectivity/monitor_test.go @@ -0,0 +1,232 @@ +package connectivity + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "testing/synctest" + "time" + + stream "github.com/leonfox28/simplus/internal/domain/connectivity" + line "github.com/leonfox28/simplus/internal/domain/line" + modem "github.com/leonfox28/simplus/internal/domain/modem" + domain "github.com/leonfox28/simplus/internal/domain/notification" + "github.com/leonfox28/simplus/internal/storage/sqlite" +) + +type testLines []line.Record + +func (l testLines) ListManagedLines(context.Context) ([]line.Record, error) { return l, nil } + +type testVoWiFi struct { + snapshot stream.Snapshot + changes stream.Changes + err error +} + +func (s *testVoWiFi) ConnectionSnapshot(context.Context) (stream.Snapshot, error) { + return s.snapshot, s.err +} +func (s *testVoWiFi) ConnectionChanges(context.Context, stream.Cursor) (stream.Changes, error) { + return s.changes, s.err +} + +func TestVoWiFiMonitorReplaysAtomicallyAndRecalibratesGaps(t *testing.T) { + s, err := sqlite.OpenSet(t.Context(), filepath.Join(t.TempDir(), "state")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + at := time.Now().UTC() + c := domain.Channel{ID: "channel_AAAAAAAAAAAAAAAAAAAAAA", Provider: "wecom", DeliveryMode: domain.DeliveryModeWebhook, DisplayName: "fixture", WebhookCiphertext: make([]byte, 40), WebhookHint: "qyapi.weixin.qq.com", Enabled: true, EventKinds: domain.EventKinds, CreatedAt: at, UpdatedAt: at, LastDeliveryStatus: "never"} + if err := s.UpsertNotificationChannel(t.Context(), c); err != nil { + t.Fatal(err) + } + source := &testVoWiFi{snapshot: stream.Snapshot{Version: 1, Cursor: stream.Cursor{Instance: "netd-1", Sequence: 1}, States: []stream.State{{ObjectID: "line-A", Connected: true, ObservedAt: at}}}} + m, _ := NewVoWiFiMonitor(s, testLines{{ID: "line-A", DisplayName: "线路 A"}, {ID: "line-B", DisplayName: "线路 B"}}, source) + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + counts, _ := s.NotificationDeliveryCounts(t.Context(), c.ID) + if counts.Pending != 1 { + t.Fatalf("initial=%+v", counts) + } + source.changes = stream.Changes{Version: 1, Cursor: stream.Cursor{Instance: "netd-1", Sequence: 3}, Events: []stream.Change{ + {Sequence: 2, State: stream.State{ObjectID: "line-A", Connected: false, ObservedAt: at, Reason: "stopped"}}, + {Sequence: 3, State: stream.State{ObjectID: "line-A", Connected: true, ObservedAt: at, Reason: "registered"}}, + }} + if _, err := s.DB.Exec(`CREATE TRIGGER fail_cursor BEFORE UPDATE ON connection_cursors BEGIN SELECT RAISE(ABORT,'test'); END`); err != nil { + t.Fatal(err) + } + if err := m.Poll(t.Context()); err == nil { + t.Fatal("failed cursor transaction committed") + } + counts, _ = s.NotificationDeliveryCounts(t.Context(), c.ID) + if counts.Pending != 1 { + t.Fatalf("partial outbox commit=%+v", counts) + } + if _, err := s.DB.Exec(`DROP TRIGGER fail_cursor`); err != nil { + t.Fatal(err) + } + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + source.err = errors.New("source unavailable") + if err := m.Poll(t.Context()); err == nil { + t.Fatal("source error lost") + } + source.err = nil + source.changes = stream.Changes{Reset: true} + source.snapshot.Cursor = stream.Cursor{Instance: "netd-2"} + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + cursor, _ := s.ReadConnectionCursor(t.Context(), "vowifi") + counts, _ = s.NotificationDeliveryCounts(t.Context(), c.ID) + if cursor.Instance != "netd-2" || cursor.Gaps != 1 || counts.Pending != 3 { + t.Fatalf("cursor=%+v counts=%+v", cursor, counts) + } +} + +type memoryObservations struct { + mu sync.Mutex + items []domain.Observation +} + +func (m *memoryObservations) ReadConnectionCursor(context.Context, string) (domain.Cursor, error) { + return domain.Cursor{}, nil +} +func (m *memoryObservations) CommitConnectionObservations(_ context.Context, _, _ domain.Cursor, o []domain.Observation) error { + m.mu.Lock() + defer m.mu.Unlock() + m.items = append(m.items, o...) + return nil +} +func (m *memoryObservations) observations() []domain.Observation { + m.mu.Lock() + defer m.mu.Unlock() + return append([]domain.Observation(nil), m.items...) +} + +type testModems []modem.Record + +func (m testModems) ListManagedModems(context.Context) ([]modem.Record, error) { return m, nil } + +type cellularFixture struct { + mu sync.Mutex + targets []ProbeTarget + err error + unknown bool + calls map[string]int +} + +func (f *cellularFixture) Targets(context.Context) ([]ProbeTarget, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]ProbeTarget(nil), f.targets...), f.err +} +func (f *cellularFixture) Probe(ctx context.Context, t ProbeTarget) (CellularSample, error) { + f.mu.Lock() + f.calls[t.ID]++ + unknown := f.unknown + f.mu.Unlock() + if t.ID == "slow" { + <-ctx.Done() + return CellularSample{}, ctx.Err() + } + return CellularSample{Fingerprint: "fingerprint-A", Known: !unknown, Connected: true, Reason: "registered", ObservedAt: time.Now()}, nil +} + +func TestCellularIndependentProbesUnknownAndSourceRestart(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + repo := &memoryObservations{} + source := &cellularFixture{targets: []ProbeTarget{{Instance: "first", ID: "fast", DeviceGeneration: 1}, {Instance: "first", ID: "slow", DeviceGeneration: 1}}, calls: map[string]int{}} + m, _ := NewCellularMonitor(repo, testModems{{ID: "modem-A", EquipmentIdentityFingerprint: "fingerprint-A", DisplayName: "模组 A"}}, source) + done := make(chan struct{}) + go func() { defer close(done); m.Run(ctx, nil) }() + synctest.Wait() + if got := repo.observations(); len(got) != 1 || !got[0].Connected { + t.Fatalf("slow modem blocked initial observation: %+v", got) + } + time.Sleep(7 * time.Second) + synctest.Wait() + source.mu.Lock() + fast, slow := source.calls["fast"], source.calls["slow"] + source.targets = []ProbeTarget{{Instance: "restarted", ID: "fast", DeviceGeneration: 1}} + source.unknown = true + source.mu.Unlock() + if fast < 2 || slow != 1 { + t.Fatalf("independent calls fast=%d slow=%d", fast, slow) + } + time.Sleep(7 * time.Second) + synctest.Wait() + for _, o := range repo.observations() { + if !o.Connected { + t.Fatalf("restart/unknown invented loss: %+v", o) + } + } + source.mu.Lock() + source.err = errors.New("agent unavailable") + source.targets = nil + source.mu.Unlock() + time.Sleep(2 * time.Second) + synctest.Wait() + for _, o := range repo.observations() { + if !o.Connected { + t.Fatal("transport failure invented loss") + } + } + source.mu.Lock() + source.err = nil + source.mu.Unlock() + time.Sleep(2 * time.Second) + synctest.Wait() + got := repo.observations() + if len(got) == 0 || got[len(got)-1].Connected || got[len(got)-1].Reason != "device_removed" { + t.Fatalf("confirmed removal missing: %+v", got) + } + cancel() + <-done + }) +} + +func (m *memoryObservations) HasConnectionCheckpoint(context.Context, string, string) (bool, error) { + return false, nil +} + +func TestNewManagedLineGetsBaselineWithoutReplayingHistoricalChanges(t *testing.T) { + s, err := sqlite.OpenSet(t.Context(), filepath.Join(t.TempDir(), "state")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + at := time.Now().UTC() + source := &testVoWiFi{snapshot: stream.Snapshot{Version: 1, Cursor: stream.Cursor{Instance: "first", Sequence: 1}, States: []stream.State{{ObjectID: "line-A", Connected: true, ObservedAt: at}}}} + m, _ := NewVoWiFiMonitor(s, testLines{}, source) + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + m.lines = testLines{{ID: "line-A", DisplayName: "A"}, {ID: "line-B", DisplayName: "B"}} + source.changes = stream.Changes{Version: 1, Cursor: source.snapshot.Cursor} + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + for _, id := range []string{"line-A", "line-B"} { + known, err := s.HasConnectionCheckpoint(t.Context(), id, "vowifi") + if err != nil || !known { + t.Fatalf("missing baseline %s: %v", id, err) + } + } + source.snapshot.States = nil // A stale snapshot is now irrelevant: the cursor owns future changes. + if err := m.Poll(t.Context()); err != nil { + t.Fatal(err) + } + var connected bool + if err := s.DB.QueryRow(`SELECT connected FROM connection_checkpoints WHERE object_id='line-A'`).Scan(&connected); err != nil || !connected { + t.Fatalf("repeated snapshot changed known state: %v %v", connected, err) + } +} diff --git a/internal/application/connectivity/vowifi.go b/internal/application/connectivity/vowifi.go new file mode 100644 index 0000000..08dda5d --- /dev/null +++ b/internal/application/connectivity/vowifi.go @@ -0,0 +1,145 @@ +package connectivity + +import ( + "context" + "errors" + "time" + + stream "github.com/leonfox28/simplus/internal/domain/connectivity" + line "github.com/leonfox28/simplus/internal/domain/line" + domain "github.com/leonfox28/simplus/internal/domain/notification" +) + +type Repository interface { + HasConnectionCheckpoint(context.Context, string, string) (bool, error) + ReadConnectionCursor(context.Context, string) (domain.Cursor, error) + CommitConnectionObservations(context.Context, domain.Cursor, domain.Cursor, []domain.Observation) error +} +type Lines interface { + ListManagedLines(context.Context) ([]line.Record, error) +} +type VoWiFiSource interface { + ConnectionSnapshot(context.Context) (stream.Snapshot, error) + ConnectionChanges(context.Context, stream.Cursor) (stream.Changes, error) +} +type VoWiFiMonitor struct { + store Repository + lines Lines + source VoWiFiSource +} + +func NewVoWiFiMonitor(store Repository, lines Lines, source VoWiFiSource) (*VoWiFiMonitor, error) { + if store == nil || lines == nil || source == nil { + return nil, errors.New("connection monitor dependencies are incomplete") + } + return &VoWiFiMonitor{store, lines, source}, nil +} +func (m *VoWiFiMonitor) Poll(ctx context.Context) error { + previous, err := m.store.ReadConnectionCursor(ctx, "vowifi") + if err != nil { + return err + } + lines, err := m.lines.ListManagedLines(ctx) + if err != nil { + return err + } + names := map[string]string{} + for _, line := range lines { + names[line.ID] = line.DisplayName + } + missing := map[string]bool{} + for id := range names { + known, err := m.store.HasConnectionCheckpoint(ctx, id, "vowifi") + if err != nil { + return err + } + if !known { + missing[id] = true + } + } + next := previous + states := []stream.State{} + reset := previous.Instance == "" + if !reset { + var baseline *stream.Snapshot + if len(missing) > 0 { + snapshot, err := m.source.ConnectionSnapshot(ctx) + if err != nil { + return err + } + baseline = &snapshot + } + changes, err := m.source.ConnectionChanges(ctx, stream.Cursor{Instance: previous.Instance, Sequence: previous.Sequence}) + if err != nil { + return err + } + reset = changes.Reset + if !reset { + next.Instance, next.Sequence = changes.Cursor.Instance, changes.Cursor.Sequence + if baseline != nil && baseline.Cursor.Instance == changes.Cursor.Instance { + seen := map[string]bool{} + for _, state := range baseline.States { + seen[state.ObjectID] = true + if missing[state.ObjectID] { + states = append(states, state) + } + } + for id := range missing { + if !seen[id] { + states = append(states, stream.State{ObjectID: id, ObservedAt: time.Now().UTC(), Reason: "stopped"}) + } + } + } + for _, event := range changes.Events { + if baseline != nil && baseline.Cursor.Instance == changes.Cursor.Instance && missing[event.State.ObjectID] && event.Sequence <= baseline.Cursor.Sequence { + continue + } + states = append(states, event.State) + } + } + } + if reset { + snapshot, err := m.source.ConnectionSnapshot(ctx) + if err != nil { + return err + } + next.Instance, next.Sequence = snapshot.Cursor.Instance, snapshot.Cursor.Sequence + if previous.Instance != "" { + next.Gaps++ + } + states = snapshot.States + seen := map[string]bool{} + for _, state := range states { + seen[state.ObjectID] = true + } + for id := range names { + if !seen[id] { + states = append(states, stream.State{ObjectID: id, ObservedAt: time.Now().UTC(), Reason: "stopped"}) + } + } + } + observations := make([]domain.Observation, 0, len(states)) + for _, state := range states { + if name, ok := names[state.ObjectID]; ok { + observations = append(observations, domain.Observation{ObjectID: state.ObjectID, DisplayName: name, Kind: "vowifi", Connected: state.Connected, ObservedAt: state.ObservedAt, Reason: state.Reason}) + } + } + return m.store.CommitConnectionObservations(ctx, previous, next, observations) +} +func (m *VoWiFiMonitor) Run(ctx context.Context, report func(error)) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + cycle, cancel := context.WithTimeout(ctx, 5*time.Second) + err := m.Poll(cycle) + cancel() + if report != nil { + report(err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/internal/application/health/service.go b/internal/application/health/service.go index e723a12..d2dcf71 100644 --- a/internal/application/health/service.go +++ b/internal/application/health/service.go @@ -45,6 +45,6 @@ func (service *Service) Snapshot(ctx context.Context) (Snapshot, error) { APIVersion: APIVersion, InstallationState: state, Backend: service.backend, - DatabaseCount: 5, + DatabaseCount: 1, }, nil } diff --git a/internal/application/inventory/service.go b/internal/application/inventory/service.go index d25e571..22004aa 100644 --- a/internal/application/inventory/service.go +++ b/internal/application/inventory/service.go @@ -56,6 +56,7 @@ type Snapshot struct { } type Topology struct { + AgentInstanceID string Generation uint64 Revision string ObservedAt time.Time @@ -140,7 +141,8 @@ func (service *Service) Topology(ctx context.Context) (Topology, error) { } topology := Topology{ - Generation: normalized.Generation, ObservedAt: normalized.ObservedAt, + AgentInstanceID: normalized.AgentInstanceID, + Generation: normalized.Generation, ObservedAt: normalized.ObservedAt, Devices: append([]hardware.PhysicalDevice(nil), normalized.Devices...), ModemFunctions: append([]hardware.ModemFunction(nil), normalized.ModemFunctions...), SIMSlots: append([]hardware.SIMSlot(nil), normalized.SIMSlots...), @@ -194,7 +196,7 @@ func (simulator simulatorSource) Snapshot(ctx context.Context) (hardware.Snapsho RFControl: true, NetworkScan: true, ManualNetworkSelection: true, PrimarySIMLockState: true, PIN1Verify: true, PUK1Unblock: true, } - snapshot := hardware.Snapshot{Generation: 1, ObservedAt: time.Now().UTC()} + snapshot := hardware.Snapshot{AgentInstanceID: "01234567-89ab-cdef-0123-456789abcdef", Generation: 1, ObservedAt: time.Now().UTC()} for index := 1; index <= lineCount; index++ { suffix := fmt.Sprintf("%d", index) deviceID := "simulator-device-" + suffix diff --git a/internal/application/line/assembly_helpers_test.go b/internal/application/line/assembly_helpers_test.go new file mode 100644 index 0000000..51161a3 --- /dev/null +++ b/internal/application/line/assembly_helpers_test.go @@ -0,0 +1,3 @@ +package line + +func (s *Service) UsePhoneNumberSource(source PhoneNumberSource) { s.phoneNumbers = source } diff --git a/internal/application/line/service.go b/internal/application/line/service.go index 2580eee..8bc247f 100644 --- a/internal/application/line/service.go +++ b/internal/application/line/service.go @@ -65,17 +65,21 @@ type Service struct { mu sync.Mutex } -func (service *Service) UsePhoneNumberSource(source PhoneNumberSource) { - if service != nil { - service.phoneNumbers = source - } -} - -func New(repository Repository, inventoryService Inventory) (*Service, error) { +func New(repository Repository, inventoryService Inventory, sources ...PhoneNumberSource) (*Service, error) { if repository == nil || inventoryService == nil { return nil, errors.New("managed line service is not configured") } - return &Service{repository: repository, inventory: inventoryService, random: rand.Reader, now: time.Now}, nil + var source PhoneNumberSource = DisabledPhoneNumbers{} + if len(sources) > 1 { + return nil, errors.New("multiple phone number sources") + } + if len(sources) == 1 { + source = sources[0] + } + if source == nil { + return nil, errors.New("phone number source is required") + } + return &Service{repository: repository, inventory: inventoryService, random: rand.Reader, now: time.Now, phoneNumbers: source}, nil } func (service *Service) List(ctx context.Context) ([]domain.View, error) { @@ -536,3 +540,9 @@ func (service *Service) newID() (string, error) { } return "line_" + base64.RawURLEncoding.EncodeToString(value), nil } + +type DisabledPhoneNumbers struct{} + +func (DisabledPhoneNumbers) CurrentPhoneNumbers(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} diff --git a/internal/application/line/vowifi_phone_numbers.go b/internal/application/line/vowifi_phone_numbers.go index 967dae0..0b045dd 100644 --- a/internal/application/line/vowifi_phone_numbers.go +++ b/internal/application/line/vowifi_phone_numbers.go @@ -4,13 +4,13 @@ import ( "context" "regexp" - "github.com/leonfox28/simplus/internal/vowifisupervisor" + runtime "github.com/leonfox28/simplus/internal/domain/vowifi" ) var imsPhoneNumberPattern = regexp.MustCompile(`^\+[1-9][0-9]{2,14}$`) type voWiFiStatusSource interface { - List(context.Context) ([]vowifisupervisor.Status, error) + List(context.Context) ([]runtime.Status, error) } type voWiFiPhoneNumberSource struct { @@ -32,7 +32,7 @@ func (source voWiFiPhoneNumberSource) CurrentPhoneNumbers(ctx context.Context) ( result := make(map[string]string) duplicates := make(map[string]struct{}) for _, status := range statuses { - if status.State != vowifisupervisor.StateOnline || !status.Online || !imsPhoneNumberPattern.MatchString(status.PhoneNumber) { + if status.State != runtime.StateOnline || !status.Online || !imsPhoneNumberPattern.MatchString(status.PhoneNumber) { continue } if _, exists := result[status.LineID]; exists { diff --git a/internal/application/messaging/inbound.go b/internal/application/messaging/inbound.go index a682dff..73eb1de 100644 --- a/internal/application/messaging/inbound.go +++ b/internal/application/messaging/inbound.go @@ -21,12 +21,6 @@ type InboundSyncResult struct { OutboundFailed int OutboundUnconfirmed int OutboundReportsAcknowledged int - receivedSMS []receivedSMSNotification -} - -type receivedSMSNotification struct { - Sender string - Body string } func (service *Service) SyncInbound(ctx context.Context) (InboundSyncResult, error) { @@ -67,7 +61,6 @@ func (service *Service) SyncInbound(ctx context.Context) (InboundSyncResult, err result.OutboundFailed += lineResult.OutboundFailed result.OutboundUnconfirmed += lineResult.OutboundUnconfirmed result.OutboundReportsAcknowledged += lineResult.OutboundReportsAcknowledged - result.receivedSMS = append(result.receivedSMS, lineResult.receivedSMS...) if err != nil { syncErrors = errors.Join(syncErrors, err) } @@ -115,7 +108,6 @@ func (service *Service) syncInboundLine(ctx context.Context, line inventory.Line result.Persisted += messageResult.Persisted result.AlreadyKnown += messageResult.AlreadyKnown result.Acknowledged += messageResult.Acknowledged - result.receivedSMS = append(result.receivedSMS, messageResult.receivedSMS...) if err != nil { return result, err } @@ -254,7 +246,7 @@ func (service *Service) persistAndAcknowledgeInbound(ctx context.Context, target providerMessageID, acknowledgeMessageID, sender, body string, receivedAt time.Time, inbox Inbox) (InboundSyncResult, error) { messageID := inboundSourceID("msg_in_", lineID, providerMessageID) operationID := inboundSourceID("in_", lineID, providerMessageID) - persisted, replayed, err := service.repository.CreateInboundSMS(ctx, sms.Message{ + _, replayed, err := service.repository.CreateInboundSMS(ctx, sms.Message{ ID: messageID, OperationID: operationID, Direction: sms.DirectionInbound, LineID: lineID, RemoteAddress: sender, Body: body, Status: sms.StatusReceived, ProviderMessageID: providerMessageID, CreatedAt: receivedAt, UpdatedAt: service.currentTime(), @@ -267,10 +259,6 @@ func (service *Service) persistAndAcknowledgeInbound(ctx context.Context, target result.AlreadyKnown = 1 } else { result.Persisted = 1 - result.receivedSMS = []receivedSMSNotification{{ - Sender: persisted.RemoteAddress, - Body: persisted.Body, - }} } if err := service.acknowledgeInbound(ctx, target, lineID, acknowledgeMessageID, inbox); err != nil { return result, err diff --git a/internal/application/messaging/ports.go b/internal/application/messaging/ports.go new file mode 100644 index 0000000..3c9f578 --- /dev/null +++ b/internal/application/messaging/ports.go @@ -0,0 +1,14 @@ +package messaging + +import "github.com/leonfox28/simplus/internal/domain/sms" + +type SendSMSCommand = sms.SendSMSCommand +type SendSMSResult = sms.SendSMSResult +type Sender = sms.Sender +type InboxMessageReference = sms.InboxMessageReference +type InboxMessage = sms.InboxMessage +type InboxTarget = sms.InboxTarget +type Inbox = sms.Inbox +type SubmitReport = sms.SubmitReport +type SubmitReportInbox = sms.SubmitReportInbox +type TransportError = sms.TransportError diff --git a/internal/application/messaging/runtime_line.go b/internal/application/messaging/runtime_line.go index 552907d..1d3406b 100644 --- a/internal/application/messaging/runtime_line.go +++ b/internal/application/messaging/runtime_line.go @@ -11,6 +11,7 @@ import ( var runtimeFingerprintPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) type runtimeLine struct { + agentInstanceID string line inventory.Line transportDeviceID string equipmentFingerprint string @@ -46,12 +47,13 @@ func resolveRuntimeLine(topology inventory.Topology, line inventory.Line) (runti return runtimeLine{}, errors.New("SMS runtime device identity is unavailable") } return runtimeLine{ - line: line, transportDeviceID: transportDeviceID, + agentInstanceID: topology.AgentInstanceID, line: line, transportDeviceID: transportDeviceID, equipmentFingerprint: equipment, subscriptionFingerprint: subscription, }, nil } func (target runtimeLine) sendCommand(command SendSMSCommand) SendSMSCommand { + command.AgentInstanceID = target.agentInstanceID command.PhysicalDeviceID = target.transportDeviceID command.DeviceGeneration = target.line.Generation command.ExpectedEquipmentFingerprint = target.equipmentFingerprint @@ -64,6 +66,6 @@ func (target runtimeLine) inboxTarget() InboxTarget { if deviceID == "" { deviceID = target.line.PhysicalDeviceID } - return InboxTarget{LineID: target.line.ID, PhysicalDeviceID: deviceID, DeviceGeneration: target.line.Generation, + return InboxTarget{AgentInstanceID: target.agentInstanceID, LineID: target.line.ID, PhysicalDeviceID: deviceID, DeviceGeneration: target.line.Generation, ExpectedEquipmentFingerprint: target.equipmentFingerprint, ExpectedSubscriptionFingerprint: target.subscriptionFingerprint} } diff --git a/internal/application/messaging/service.go b/internal/application/messaging/service.go index 75bf937..9691f44 100644 --- a/internal/application/messaging/service.go +++ b/internal/application/messaging/service.go @@ -21,19 +21,19 @@ import ( ) const ( - ErrorOutcomeUnknownAfterRestart = "SEND_OUTCOME_UNKNOWN_AFTER_RESTART" - ErrorSendOutcomeUnknown = "SMS_SEND_OUTCOME_UNKNOWN" - ErrorAcceptedAwaitingReport = "IMS_SMS_ACCEPTED_AWAITING_REPORT" - ErrorCancelledBeforeDispatch = "SEND_CANCELLED_BEFORE_DISPATCH" - ErrorTransportFailed = "SMS_TRANSPORT_FAILED" - ErrorSIMNotReady = "SMS_SIM_NOT_READY" - ErrorSIMIdentityChanged = "SMS_SIM_IDENTITY_CHANGED" - ErrorEquipmentIdentityChanged = "SMS_EQUIPMENT_IDENTITY_CHANGED" - ErrorRFOff = "SMS_RF_OFF" - ErrorRegistrationDenied = "SMS_REGISTRATION_DENIED" - ErrorNotRegistered = "SMS_NOT_REGISTERED" - ErrorStatusUnavailable = "SMS_STATUS_UNAVAILABLE" - ErrorDeviceStale = "SMS_DEVICE_STALE" + ErrorOutcomeUnknownAfterRestart = sms.ErrorOutcomeUnknownAfterRestart + ErrorSendOutcomeUnknown = sms.ErrorSendOutcomeUnknown + ErrorAcceptedAwaitingReport = sms.ErrorAcceptedAwaitingReport + ErrorCancelledBeforeDispatch = sms.ErrorCancelledBeforeDispatch + ErrorTransportFailed = sms.ErrorTransportFailed + ErrorSIMNotReady = sms.ErrorSIMNotReady + ErrorSIMIdentityChanged = sms.ErrorSIMIdentityChanged + ErrorEquipmentIdentityChanged = sms.ErrorEquipmentIdentityChanged + ErrorRFOff = sms.ErrorRFOff + ErrorRegistrationDenied = sms.ErrorRegistrationDenied + ErrorNotRegistered = sms.ErrorNotRegistered + ErrorStatusUnavailable = sms.ErrorStatusUnavailable + ErrorDeviceStale = sms.ErrorDeviceStale HistoryCapacity = 10000 InboundFragmentRetention = 7 * 24 * time.Hour maximumSMSBodyRunes = 1600 @@ -64,6 +64,7 @@ var ( ) type Repository interface { + SMSByOperationID(context.Context, string) (sms.Message, bool, error) CreateOutboundSMS(context.Context, sms.Message) (sms.Message, bool, error) CreateInboundSMS(context.Context, sms.Message) (sms.Message, bool, error) StoreInboundSMSFragment(context.Context, sms.InboundFragment) ([]sms.InboundFragment, bool, error) @@ -127,88 +128,14 @@ func (transport SMSTransport) UseHostVoWiFiAvailability(availability TransportAv // SendSMSCommand is the narrow typed boundary between the application and a // modem-specific transport. It deliberately contains no AT/QMI text or device // paths. -type SendSMSCommand struct { - OperationID string - MessageID string - LineID string - PhysicalDeviceID string - ModemFunctionID string - Destination string - Body string - Segments []smscodec.Segment - DeviceGeneration uint64 - ExpectedEquipmentFingerprint string - ExpectedSubscriptionFingerprint string -} - -type SendSMSResult struct { - ProviderMessageID string - State string - ErrorCode string -} const ( - SendStateAccepted = "accepted" - SendStateSent = "sent" - SendStateFailed = "failed" - SendStateUnconfirmed = "unconfirmed" + SendStateAccepted = sms.SendStateAccepted + SendStateSent = sms.SendStateSent + SendStateFailed = sms.SendStateFailed + SendStateUnconfirmed = sms.SendStateUnconfirmed ) -type Sender interface { - SendSMS(context.Context, SendSMSCommand) (SendSMSResult, error) -} - -type InboxMessageReference struct { - SourceMessageID string - ReceivedAt time.Time -} - -type InboxMessage struct { - SourceMessageID string - Sender string - Body string - ReceivedAt time.Time - Segment *smscodec.Segment -} - -type InboxTarget struct { - LineID string - PhysicalDeviceID string - DeviceGeneration uint64 - ExpectedEquipmentFingerprint string - ExpectedSubscriptionFingerprint string -} - -type Inbox interface { - ListSMS(context.Context, InboxTarget) ([]InboxMessageReference, error) - ReadSMS(context.Context, InboxTarget, string) (InboxMessage, error) - AcknowledgeSMS(context.Context, InboxTarget, string, string) error -} - -type SubmitReport struct { - MessageID string - ProviderMessageID string - State string - ErrorCode string - CompletedAt time.Time -} - -type SubmitReportInbox interface { - ListSMSSubmitReports(context.Context, InboxTarget) ([]SubmitReport, error) - AcknowledgeSMSSubmitReport(context.Context, InboxTarget, string, string) error -} - -type TransportError struct { - Code string -} - -func (err *TransportError) Error() string { - if err == nil || err.Code == "" { - return "SMS transport failed" - } - return "SMS transport failed: " + err.Code -} - type SendRequest struct { OperationID string LineID string @@ -261,7 +188,7 @@ type Service struct { gates map[string]*serialGate } -func (service *Service) UseTransports(transports ...SMSTransport) error { +func (service *Service) configureTransports(transports ...SMSTransport) error { if service == nil { return errors.New("messaging service is unavailable") } @@ -288,7 +215,7 @@ func NewService(ctx context.Context, repository Repository, lines LineSource, tr gates: make(map[string]*serialGate), } if len(transports) != 0 { - if err := service.UseTransports(transports...); err != nil { + if err := service.configureTransports(transports...); err != nil { return nil, err } } @@ -311,6 +238,16 @@ func (service *Service) Send(ctx context.Context, request SendRequest) (SendResu if service == nil || service.repository == nil || service.lines == nil { return SendResult{}, ErrTransportUnavailable } + stored, found, err := service.repository.SMSByOperationID(ctx, request.OperationID) + if err != nil { + return SendResult{}, fmt.Errorf("%w: read outbound operation", ErrPersistence) + } + if found { + if stored.Direction != sms.DirectionOutbound || stored.LineID != request.LineID || stored.RemoteAddress != request.Destination || stored.Body != request.Body { + return SendResult{}, sms.ErrOperationConflict + } + return SendResult{Message: stored, Replayed: true}, nil + } line, runtimeTarget, transport, err := service.sendLine(ctx, request.LineID) if err != nil { return SendResult{}, err @@ -373,7 +310,7 @@ func (service *Service) Send(ctx context.Context, request SendRequest) (SendResu return SendResult{Message: failed}, nil } if strings.TrimSpace(result.ProviderMessageID) == "" || len(result.ProviderMessageID) > 128 { - failed, persistenceErr := service.markFailed(message.ID, "", ErrorTransportFailed) + failed, persistenceErr := service.markUnconfirmed(message.ID, "", ErrorSendOutcomeUnknown) if persistenceErr != nil { return SendResult{}, persistenceErr } diff --git a/internal/application/messaging/service_test.go b/internal/application/messaging/service_test.go index 2207849..b7ef02e 100644 --- a/internal/application/messaging/service_test.go +++ b/internal/application/messaging/service_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/leonfox28/simplus/internal/smstransport" + "github.com/leonfox28/simplus/internal/agentapi" "github.com/leonfox28/simplus/internal/application/inventory" lineapp "github.com/leonfox28/simplus/internal/application/line" @@ -122,7 +124,7 @@ func (inbox *failOnceInbox) AcknowledgeSMS(ctx context.Context, target InboxTarg return inbox.Inbox.AcknowledgeSMS(ctx, target, messageID, operationID) } -func newAgentGatewayForTest(t *testing.T, messages ...agentapi.SMSStoredMessage) (*AgentSMSGateway, *agentapi.SimulatorSMSBackend) { +func newAgentGatewayForTest(t *testing.T, messages ...agentapi.SMSStoredMessage) (*smstransport.AgentSMSGateway, *agentapi.SimulatorSMSBackend) { t.Helper() const instanceID = "01234567-89ab-cdef-0123-456789abcdef" backend := agentapi.NewSimulatorSMSBackend(messages...) @@ -130,7 +132,7 @@ func newAgentGatewayForTest(t *testing.T, messages ...agentapi.SMSStoredMessage) if err != nil { t.Fatal(err) } - gateway, err := NewAgentSMSGateway(client, instanceID) + gateway, err := smstransport.NewAgentSMSGateway(client) if err != nil { t.Fatal(err) } @@ -498,7 +500,7 @@ func TestHostVoWiFiSMSRequiresOnlineTransport(t *testing.T) { return SendSMSResult{ProviderMessageID: "provider-" + command.OperationID}, nil })) request := SendRequest{OperationID: "operation-vowifi-sms-001", LineID: testManagedLineID1, Destination: "13800138000", Body: "VoWiFi"} - if err := service.UseTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(false), service.transports[0].Sender, noopInbox{})); err != nil { + if err := service.configureTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(false), service.transports[0].Sender, noopInbox{})); err != nil { t.Fatal(err) } if _, err := service.Send(context.Background(), request); !errors.Is(err, ErrTransportUnavailable) { @@ -507,7 +509,7 @@ func TestHostVoWiFiSMSRequiresOnlineTransport(t *testing.T) { if calls != 0 { t.Fatalf("offline transport calls=%d", calls) } - if err := service.UseTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), service.transports[0].Sender, noopInbox{})); err != nil { + if err := service.configureTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), service.transports[0].Sender, noopInbox{})); err != nil { t.Fatal(err) } request.OperationID = "operation-vowifi-sms-002" @@ -548,7 +550,7 @@ func TestPerLineTransportResolverRequiresOneEligibleTransportAndNeverFallsBack(t vowifiCalls++ return SendSMSResult{ProviderMessageID: "vowifi"}, nil }), noopInbox{}) - if err := service.UseTransports(native, vowifi); err != nil { + if err := service.configureTransports(native, vowifi); err != nil { t.Fatal(err) } request := SendRequest{OperationID: "operation-resolver-0001", LineID: testManagedLineID1, Destination: "10086", Body: "test"} @@ -559,7 +561,7 @@ func TestPerLineTransportResolverRequiresOneEligibleTransportAndNeverFallsBack(t t.Fatalf("ambiguous calls native=%d vowifi=%d", nativeCalls, vowifiCalls) } - if err := service.UseTransports(native); err != nil { + if err := service.configureTransports(native); err != nil { t.Fatal(err) } request.OperationID = "operation-resolver-0002" @@ -570,7 +572,7 @@ func TestPerLineTransportResolverRequiresOneEligibleTransportAndNeverFallsBack(t t.Fatalf("fallback calls native=%d vowifi=%d", nativeCalls, vowifiCalls) } - if err := service.UseTransports(SMSTransport{Name: "none", Eligible: func(inventory.Line) bool { return false }, Sender: vowifi.Sender, Inbox: noopInbox{}}); err != nil { + if err := service.configureTransports(SMSTransport{Name: "none", Eligible: func(inventory.Line) bool { return false }, Sender: vowifi.Sender, Inbox: noopInbox{}}); err != nil { t.Fatal(err) } request.OperationID = "operation-resolver-0003" @@ -608,7 +610,7 @@ func TestInboundTransportFailureDoesNotStarveAnotherLine(t *testing.T) { t.Fatal(err) } transport := AgentNativeSMSTransport(senderFunc(func(context.Context, SendSMSCommand) (SendSMSResult, error) { return SendSMSResult{}, nil }), inbox) - if err := service.UseTransports(transport); err != nil { + if err := service.configureTransports(transport); err != nil { t.Fatal(err) } result, err := service.SyncInbound(ctx) @@ -698,7 +700,7 @@ func TestAgentGatewayPreservesUnknownSendOutcomeCode(t *testing.T) { agentapi.ErrSMSOutcomeUnknown, &agentapi.ErrorResponse{Code: "SMS_SEND_OUTCOME_UNKNOWN", Detail: "do not resend"}, } { - gateway, err := NewAgentSMSGateway(sendErrorSMSClient{err: transportErr}, instanceID) + gateway, err := smstransport.NewAgentSMSGateway(sendErrorSMSClient{err: transportErr}) if err != nil { t.Fatal(err) } @@ -719,9 +721,9 @@ func TestAgentGatewayPreservesUnknownSendOutcomeCode(t *testing.T) { func TestAgentGatewayMapsMissingDeviceToStableStaleCode(t *testing.T) { const instanceID = "01234567-89ab-cdef-0123-456789abcdef" - gateway, err := NewAgentSMSGateway(sendErrorSMSClient{err: &agentapi.ErrorResponse{ + gateway, err := smstransport.NewAgentSMSGateway(sendErrorSMSClient{err: &agentapi.ErrorResponse{ Code: "SMS_DEVICE_NOT_FOUND", Detail: "device unavailable", - }}, instanceID) + }}) if err != nil { t.Fatal(err) } @@ -844,9 +846,6 @@ func TestInboundSyncPersistsBeforeAcknowledgeAndDeduplicatesRestart(t *testing.T if !errors.Is(err, ErrInboundSync) || first.Persisted != 1 || first.Acknowledged != 0 { t.Fatalf("first sync = %#v, error = %v", first, err) } - if len(first.receivedSMS) != 1 || first.receivedSMS[0].Sender != inbound.Sender || first.receivedSMS[0].Body != inbound.Body { - t.Fatalf("first received SMS notifications = %#v", first.receivedSMS) - } messages, err := stores.ListSMS(ctx, 10) if err != nil { t.Fatal(err) @@ -861,9 +860,6 @@ func TestInboundSyncPersistsBeforeAcknowledgeAndDeduplicatesRestart(t *testing.T if second.AlreadyKnown != 1 || second.Acknowledged != 1 { t.Fatalf("second sync = %#v", second) } - if len(second.receivedSMS) != 0 { - t.Fatalf("replay returned received SMS notification = %#v", second.receivedSMS) - } remaining, err := backend.ListSMS(ctx, agentapi.SMSListRequest{DeviceID: inbound.DeviceID}) if err != nil { t.Fatal(err) @@ -884,9 +880,6 @@ func TestInboundSyncPersistsBeforeAcknowledgeAndDeduplicatesRestart(t *testing.T if restartResult.Persisted != 0 || restartResult.AlreadyKnown != 1 || restartResult.Acknowledged != 1 { t.Fatalf("restart sync = %#v", restartResult) } - if len(restartResult.receivedSMS) != 0 { - t.Fatalf("restart replay returned received SMS notification = %#v", restartResult.receivedSMS) - } remaining, err = restartedBackend.ListSMS(ctx, agentapi.SMSListRequest{DeviceID: inbound.DeviceID}) if err != nil { t.Fatal(err) @@ -925,11 +918,6 @@ func TestInboundSyncReturnsOrderedNarrowReceivedSMSValues(t *testing.T) { if err != nil || result.Persisted != 2 || result.Acknowledged != 2 { t.Fatalf("sync result = %#v, error = %v", result, err) } - if len(result.receivedSMS) != 2 || - result.receivedSMS[0] != (receivedSMSNotification{Sender: inbound[0].Sender, Body: inbound[0].Body}) || - result.receivedSMS[1] != (receivedSMSNotification{Sender: inbound[1].Sender, Body: inbound[1].Body}) { - t.Fatalf("received SMS notifications = %#v", result.receivedSMS) - } } func TestHistoryReadDoesNotCompeteWithBackgroundInboundSync(t *testing.T) { @@ -985,3 +973,31 @@ func TestInboundSyncDoesNotAcknowledgePersistenceFailure(t *testing.T) { t.Fatalf("Agent inbox was acknowledged after persistence failure: %#v", remaining) } } + +func TestReplaySurvivesUnavailableInventoryAndMalformedSubmission(t *testing.T) { + calls := 0 + service, _ := newTestService(t, senderFunc(func(context.Context, SendSMSCommand) (SendSMSResult, error) { + calls++ + return SendSMSResult{}, nil // transport submitted but lost the receipt + })) + request := SendRequest{OperationID: "operation-lost-receipt", LineID: testManagedLineID1, Destination: "10086", Body: "synthetic"} + result, err := service.Send(t.Context(), request) + if err != nil || result.Message.Status != sms.StatusUnconfirmed { + t.Fatalf("result=%+v error=%v", result, err) + } + service.lines = unavailableLines{} + replay, err := service.Send(t.Context(), request) + if err != nil || !replay.Replayed || replay.Message.ID != result.Message.ID || calls != 1 { + t.Fatalf("replay=%+v calls=%d error=%v", replay, calls, err) + } + request.Body = "changed" + if _, err := service.Send(t.Context(), request); !errors.Is(err, sms.ErrOperationConflict) { + t.Fatalf("conflict=%v", err) + } +} + +type unavailableLines struct{} + +func (unavailableLines) Topology(context.Context) (inventory.Topology, error) { + return inventory.Topology{}, errors.New("Agent unavailable") +} diff --git a/internal/application/messaging/sync_coordinator.go b/internal/application/messaging/sync_coordinator.go index a25700e..e9992c8 100644 --- a/internal/application/messaging/sync_coordinator.go +++ b/internal/application/messaging/sync_coordinator.go @@ -3,7 +3,6 @@ package messaging import ( "context" "errors" - "fmt" "time" "github.com/leonfox28/simplus/internal/application/realtime" @@ -11,7 +10,6 @@ import ( const ( syncTimeout = 20 * time.Second - notificationTimeout = 15 * time.Second minimumSyncInterval = 2 * time.Second minimumRetryDelay = 15 * time.Second maximumRetryDelay = 5 * time.Minute @@ -23,42 +21,45 @@ type InboundSyncer interface { SyncInbound(context.Context) (InboundSyncResult, error) } -type NotificationSender interface { - NotifyReceivedSMS(context.Context, string, string) error - NotifyReceivedSMSSummary(context.Context, int) error -} - type RealtimePublisher interface { Publish([]realtime.Topic, realtime.Attention) } type SyncReport struct { - Result InboundSyncResult - SyncError error - NotificationError error - DurableChange bool + Result InboundSyncResult + SyncError error + DurableChange bool } type SyncCoordinator struct { - syncer InboundSyncer - notifications NotificationSender - publisher RealtimePublisher - wait func(context.Context, time.Duration) bool + syncer InboundSyncer + publisher RealtimePublisher + wait func(context.Context, time.Duration) bool } -func NewSyncCoordinator(syncer InboundSyncer, notifications NotificationSender, publisher RealtimePublisher) (*SyncCoordinator, error) { - if syncer == nil || notifications == nil || publisher == nil { +func NewSyncCoordinator(syncer InboundSyncer, publisher RealtimePublisher) (*SyncCoordinator, error) { + if syncer == nil || publisher == nil { return nil, ErrSyncCoordinatorConfiguration } return &SyncCoordinator{ - syncer: syncer, - notifications: notifications, - publisher: publisher, - wait: waitForSyncContext, + syncer: syncer, + publisher: publisher, + wait: waitForSyncContext, }, nil } func (coordinator *SyncCoordinator) Run(ctx context.Context, interval time.Duration, report func(SyncReport)) { + if worker, ok := coordinator.syncer.(interface { + RunInbound(context.Context, time.Duration, func(InboundSyncResult, error)) + }); ok { + worker.RunInbound(ctx, interval, func(result InboundSyncResult, err error) { + value := coordinator.publishResult(result, err) + if report != nil { + report(value) + } + }) + return + } interval = normalizedSyncInterval(interval) retryDelay := time.Duration(0) for { @@ -86,6 +87,10 @@ func (coordinator *SyncCoordinator) runCycle(ctx context.Context) SyncReport { result, syncErr := coordinator.syncer.SyncInbound(syncCtx) cancelSync() + return coordinator.publishResult(result, syncErr) +} + +func (coordinator *SyncCoordinator) publishResult(result InboundSyncResult, syncErr error) SyncReport { report := SyncReport{Result: result, SyncError: syncErr, DurableChange: hasDurableMessageChange(result)} if report.DurableChange { attention := realtime.Attention("") @@ -94,26 +99,9 @@ func (coordinator *SyncCoordinator) runCycle(ctx context.Context) SyncReport { } coordinator.publisher.Publish([]realtime.Topic{realtime.TopicMessages}, attention) } - if result.Persisted == 0 { - return report - } - - for index, received := range result.receivedSMS { - deliveryCtx, cancelDelivery := context.WithTimeout(context.WithoutCancel(ctx), notificationTimeout) - if err := coordinator.notifications.NotifyReceivedSMS(deliveryCtx, received.Sender, received.Body); err != nil { - report.NotificationError = errors.Join( - report.NotificationError, - fmt.Errorf("received SMS %d: %w", index+1, err), - ) - } - cancelDelivery() - } - summaryCtx, cancelSummary := context.WithTimeout(context.WithoutCancel(ctx), notificationTimeout) - if err := coordinator.notifications.NotifyReceivedSMSSummary(summaryCtx, result.Persisted); err != nil { - report.NotificationError = errors.Join(report.NotificationError, fmt.Errorf("received SMS summary: %w", err)) + if result.Persisted > 0 { + coordinator.publisher.Publish([]realtime.Topic{realtime.TopicNotifications}, "") } - cancelSummary() - coordinator.publisher.Publish([]realtime.Topic{realtime.TopicNotifications}, "") return report } diff --git a/internal/application/messaging/sync_coordinator_test.go b/internal/application/messaging/sync_coordinator_test.go index d15248d..d79a280 100644 --- a/internal/application/messaging/sync_coordinator_test.go +++ b/internal/application/messaging/sync_coordinator_test.go @@ -3,325 +3,52 @@ package messaging import ( "context" "errors" - "reflect" - "strings" "testing" "time" "github.com/leonfox28/simplus/internal/application/realtime" ) -type syncTestOutcome struct { - result InboundSyncResult - err error -} - -type fakeInboundSyncer struct { - outcomes []syncTestOutcome - calls int - events *[]string - inspect func(context.Context) -} - -func (fake *fakeInboundSyncer) SyncInbound(ctx context.Context) (InboundSyncResult, error) { - if fake.events != nil { - *fake.events = append(*fake.events, "sync") - } - if fake.inspect != nil { - fake.inspect(ctx) - } - if fake.calls >= len(fake.outcomes) { - return InboundSyncResult{}, errors.New("unexpected synchronization call") - } - outcome := fake.outcomes[fake.calls] - fake.calls++ - return outcome.result, outcome.err -} +type syncerFunc func(context.Context) (InboundSyncResult, error) -type fakeNotificationSender struct { - receivedErrors []error - summaryError error - receivedCalls []receivedSMSNotification - summaryCalls []int - contexts []context.Context - events *[]string - inspect func(context.Context) -} +func (f syncerFunc) SyncInbound(ctx context.Context) (InboundSyncResult, error) { return f(ctx) } -func (fake *fakeNotificationSender) NotifyReceivedSMS(ctx context.Context, sender, body string) error { - callIndex := len(fake.receivedCalls) - fake.receivedCalls = append(fake.receivedCalls, receivedSMSNotification{Sender: sender, Body: body}) - fake.contexts = append(fake.contexts, ctx) - if fake.events != nil { - *fake.events = append(*fake.events, "notify:sms") - } - if fake.inspect != nil { - fake.inspect(ctx) - } - if callIndex < len(fake.receivedErrors) { - return fake.receivedErrors[callIndex] - } - return nil -} - -func (fake *fakeNotificationSender) NotifyReceivedSMSSummary(ctx context.Context, count int) error { - fake.summaryCalls = append(fake.summaryCalls, count) - fake.contexts = append(fake.contexts, ctx) - if fake.events != nil { - *fake.events = append(*fake.events, "notify:summary") - } - if fake.inspect != nil { - fake.inspect(ctx) - } - return fake.summaryError -} - -type syncTestPublication struct { +type fakeSyncPublisher struct { topics []realtime.Topic attention realtime.Attention } -type fakeSyncPublisher struct { - publications []syncTestPublication - events *[]string -} - -func (fake *fakeSyncPublisher) Publish(topics []realtime.Topic, attention realtime.Attention) { - copyOfTopics := append([]realtime.Topic(nil), topics...) - fake.publications = append(fake.publications, syncTestPublication{topics: copyOfTopics, attention: attention}) - if fake.events != nil { - *fake.events = append(*fake.events, "publish:"+string(topics[0])) - } -} - -func TestNewSyncCoordinatorRequiresDependencies(t *testing.T) { - syncer := &fakeInboundSyncer{} - notifications := &fakeNotificationSender{} - publisher := &fakeSyncPublisher{} - for _, test := range []struct { - name string - syncer InboundSyncer - notifications NotificationSender - publisher RealtimePublisher - }{ - {name: "missing syncer", notifications: notifications, publisher: publisher}, - {name: "missing notifications", syncer: syncer, publisher: publisher}, - {name: "missing publisher", syncer: syncer, notifications: notifications}, - } { - t.Run(test.name, func(t *testing.T) { - if _, err := NewSyncCoordinator(test.syncer, test.notifications, test.publisher); !errors.Is(err, ErrSyncCoordinatorConfiguration) { - t.Fatalf("error = %v", err) - } - }) +func (p *fakeSyncPublisher) Publish(topics []realtime.Topic, a realtime.Attention) { + p.topics = append(p.topics, topics...) + if a != "" { + p.attention = a } } - -func TestSyncCoordinatorCyclePublishesAndNotifiesInOrder(t *testing.T) { - var events []string - parent, cancelParent := context.WithCancel(context.Background()) - cancelParent() - syncer := &fakeInboundSyncer{ - outcomes: []syncTestOutcome{{result: InboundSyncResult{ - Persisted: 2, Acknowledged: 1, - receivedSMS: []receivedSMSNotification{ - {Sender: "10086", Body: "first"}, - {Sender: "Service", Body: "second\nline"}, - }, - }}}, - events: &events, - inspect: func(ctx context.Context) { - if ctx.Err() == nil { - t.Fatal("synchronization context did not inherit parent cancellation") - } - assertDeadlineNear(t, ctx, syncTimeout) - }, - } - notifications := &fakeNotificationSender{ - events: &events, - inspect: func(ctx context.Context) { - if err := ctx.Err(); err != nil { - t.Fatalf("notification context inherited cancellation: %v", err) - } - assertDeadlineNear(t, ctx, notificationTimeout) - }, - } - publisher := &fakeSyncPublisher{events: &events} - coordinator, err := NewSyncCoordinator(syncer, notifications, publisher) +func TestCoordinatorPublishesCommittedProgressEvenWhenAcknowledgementFails(t *testing.T) { + p := &fakeSyncPublisher{} + c, err := NewSyncCoordinator(syncerFunc(func(context.Context) (InboundSyncResult, error) { + return InboundSyncResult{Persisted: 1}, errors.New("ack failed") + }), p) if err != nil { t.Fatal(err) } - - report := coordinator.runCycle(parent) - if report.SyncError != nil || report.NotificationError != nil || !report.DurableChange { - t.Fatalf("report = %#v", report) - } - wantReceived := []receivedSMSNotification{{Sender: "10086", Body: "first"}, {Sender: "Service", Body: "second\nline"}} - if !reflect.DeepEqual(notifications.receivedCalls, wantReceived) || !reflect.DeepEqual(notifications.summaryCalls, []int{2}) { - t.Fatalf("received calls = %#v, summary calls = %#v", notifications.receivedCalls, notifications.summaryCalls) - } - if len(notifications.contexts) != 3 || notifications.contexts[0] == notifications.contexts[1] || notifications.contexts[1] == notifications.contexts[2] { - t.Fatalf("notification contexts are not independent: %#v", notifications.contexts) - } - wantEvents := []string{"sync", "publish:messages", "notify:sms", "notify:sms", "notify:summary", "publish:notifications"} - if !reflect.DeepEqual(events, wantEvents) { - t.Fatalf("events = %#v, want %#v", events, wantEvents) - } - if len(publisher.publications) != 2 || - !reflect.DeepEqual(publisher.publications[0].topics, []realtime.Topic{realtime.TopicMessages}) || - publisher.publications[0].attention != realtime.AttentionSMSReceived || - !reflect.DeepEqual(publisher.publications[1].topics, []realtime.Topic{realtime.TopicNotifications}) || - publisher.publications[1].attention != "" { - t.Fatalf("publications = %#v", publisher.publications) + r := c.runCycle(t.Context()) + if !r.DurableChange || r.SyncError == nil || p.attention != realtime.AttentionSMSReceived || len(p.topics) != 2 { + t.Fatalf("report=%+v publication=%+v", r, p) } } - -func TestSyncCoordinatorCycleKeepsPartialProgressAndNotificationFailure(t *testing.T) { - syncErr := errors.New("partial synchronization") - notificationErr := errors.New("delivery unavailable") - syncer := &fakeInboundSyncer{outcomes: []syncTestOutcome{{ - result: InboundSyncResult{ - Persisted: 1, OutboundFailed: 2, - receivedSMS: []receivedSMSNotification{{Sender: "private-sender", Body: "private body"}}, - }, - err: syncErr, - }}} - notifications := &fakeNotificationSender{receivedErrors: []error{notificationErr}} - publisher := &fakeSyncPublisher{} - coordinator, err := NewSyncCoordinator(syncer, notifications, publisher) - if err != nil { - t.Fatal(err) - } - - report := coordinator.runCycle(context.Background()) - if !errors.Is(report.SyncError, syncErr) || !errors.Is(report.NotificationError, notificationErr) || !report.DurableChange { - t.Fatalf("report = %#v", report) - } - if len(notifications.receivedCalls) != 1 || len(notifications.summaryCalls) != 1 || len(publisher.publications) != 2 || publisher.publications[0].attention != realtime.AttentionSMSReceived { - t.Fatalf("received calls = %d, summary calls = %d, publications = %#v", len(notifications.receivedCalls), len(notifications.summaryCalls), publisher.publications) - } - if strings.Contains(report.NotificationError.Error(), "private-sender") || strings.Contains(report.NotificationError.Error(), "private body") { - t.Fatalf("notification error leaked SMS content: %v", report.NotificationError) - } -} - -func TestSyncCoordinatorCycleContinuesAfterIndependentSMSFailure(t *testing.T) { - firstError := errors.New("first delivery timed out") - syncer := &fakeInboundSyncer{outcomes: []syncTestOutcome{{result: InboundSyncResult{ - Persisted: 2, - receivedSMS: []receivedSMSNotification{ - {Sender: "10010", Body: "first"}, - {Sender: "10086", Body: "second"}, - }, - }}}} - notifications := &fakeNotificationSender{receivedErrors: []error{firstError}} - publisher := &fakeSyncPublisher{} - coordinator, err := NewSyncCoordinator(syncer, notifications, publisher) - if err != nil { - t.Fatal(err) - } - - report := coordinator.runCycle(context.Background()) - if !errors.Is(report.NotificationError, firstError) { - t.Fatalf("notification error = %v", report.NotificationError) - } - want := []receivedSMSNotification{{Sender: "10010", Body: "first"}, {Sender: "10086", Body: "second"}} - if !reflect.DeepEqual(notifications.receivedCalls, want) || !reflect.DeepEqual(notifications.summaryCalls, []int{2}) { - t.Fatalf("received calls = %#v, summary calls = %#v", notifications.receivedCalls, notifications.summaryCalls) - } - if len(publisher.publications) != 2 || publisher.publications[1].topics[0] != realtime.TopicNotifications { - t.Fatalf("publications = %#v", publisher.publications) - } -} - -func TestSyncCoordinatorCycleClassifiesDurableMessageChanges(t *testing.T) { - tests := []struct { - name string - result InboundSyncResult - changed bool - }{ - {name: "empty"}, - {name: "acknowledgement only", result: InboundSyncResult{Acknowledged: 1}}, - {name: "outbound report acknowledgement only", result: InboundSyncResult{OutboundReportsAcknowledged: 1}}, - {name: "already known", result: InboundSyncResult{AlreadyKnown: 1}, changed: true}, - {name: "outbound sent", result: InboundSyncResult{OutboundSent: 1}, changed: true}, - {name: "outbound failed", result: InboundSyncResult{OutboundFailed: 1}, changed: true}, - {name: "outbound unconfirmed", result: InboundSyncResult{OutboundUnconfirmed: 1}, changed: true}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - syncer := &fakeInboundSyncer{outcomes: []syncTestOutcome{{result: test.result}}} - notifications := &fakeNotificationSender{} - publisher := &fakeSyncPublisher{} - coordinator, err := NewSyncCoordinator(syncer, notifications, publisher) - if err != nil { - t.Fatal(err) - } - report := coordinator.runCycle(context.Background()) - if report.DurableChange != test.changed { - t.Fatalf("durable change = %t", report.DurableChange) - } - if got := len(publisher.publications); got != boolCount(test.changed) { - t.Fatalf("publication count = %d", got) - } - if test.changed && publisher.publications[0].attention != "" { - t.Fatalf("attention = %q", publisher.publications[0].attention) - } - if len(notifications.receivedCalls) != 0 || len(notifications.summaryCalls) != 0 { - t.Fatalf("notification calls = %#v / %#v", notifications.receivedCalls, notifications.summaryCalls) - } - }) - } -} - -func TestSyncCoordinatorRunUsesSyncErrorOnlyForRetryAndResetsAfterSuccess(t *testing.T) { - syncErr := errors.New("synchronization failed") - syncer := &fakeInboundSyncer{outcomes: []syncTestOutcome{ - {err: syncErr}, - {err: syncErr}, - {}, - }} - notifications := &fakeNotificationSender{ - receivedErrors: []error{errors.New("must not affect scheduling")}, - summaryError: errors.New("must not affect scheduling"), - } - publisher := &fakeSyncPublisher{} - coordinator, err := NewSyncCoordinator(syncer, notifications, publisher) - if err != nil { - t.Fatal(err) - } - var delays []time.Duration - coordinator.wait = func(_ context.Context, delay time.Duration) bool { - delays = append(delays, delay) - return len(delays) < 3 - } - var reports []SyncReport - coordinator.Run(context.Background(), 0, func(report SyncReport) { - reports = append(reports, report) - }) - if want := []time.Duration{15 * time.Second, 30 * time.Second, 2 * time.Second}; !reflect.DeepEqual(delays, want) { - t.Fatalf("delays = %v, want %v", delays, want) - } - if syncer.calls != 3 || len(reports) != 3 { - t.Fatalf("sync calls = %d, reports = %d", syncer.calls, len(reports)) - } - - // A notification error is observable but a successful synchronization still uses the normal interval. - syncer = &fakeInboundSyncer{outcomes: []syncTestOutcome{{result: InboundSyncResult{ - Persisted: 1, receivedSMS: []receivedSMSNotification{{Sender: "10086", Body: "bounded"}}, - }}}} - coordinator, err = NewSyncCoordinator(syncer, notifications, publisher) - if err != nil { - t.Fatal(err) - } - coordinator.wait = func(_ context.Context, delay time.Duration) bool { - if delay != 3*time.Second { - t.Fatalf("delay after notification error = %s", delay) - } - return false +func TestCoordinatorStopsOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + p := &fakeSyncPublisher{} + c, _ := NewSyncCoordinator(syncerFunc(func(context.Context) (InboundSyncResult, error) { cancel(); return InboundSyncResult{}, nil }), p) + done := make(chan struct{}) + go func() { defer close(done); c.Run(ctx, time.Hour, nil) }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("coordinator did not exit") } - coordinator.Run(context.Background(), 3*time.Second, nil) } - func TestSyncRetryDelayBacksOffAndCaps(t *testing.T) { delay := time.Duration(0) want := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute, 2 * time.Minute, 4 * time.Minute, 5 * time.Minute, 5 * time.Minute} @@ -338,46 +65,3 @@ func TestSyncRetryDelayBacksOffAndCaps(t *testing.T) { t.Fatalf("capped initial retry = %s", got) } } - -func TestSyncCoordinatorRunStopsOnCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - syncer := &fakeInboundSyncer{ - outcomes: []syncTestOutcome{{}}, - inspect: func(context.Context) { - cancel() - }, - } - coordinator, err := NewSyncCoordinator(syncer, &fakeNotificationSender{}, &fakeSyncPublisher{}) - if err != nil { - t.Fatal(err) - } - done := make(chan struct{}) - go func() { - coordinator.Run(ctx, time.Hour, nil) - close(done) - }() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("coordinator did not stop after cancellation") - } -} - -func assertDeadlineNear(t *testing.T, ctx context.Context, expected time.Duration) { - t.Helper() - deadline, ok := ctx.Deadline() - if !ok { - t.Fatal("context has no deadline") - } - remaining := time.Until(deadline) - if remaining <= expected-time.Second || remaining > expected { - t.Fatalf("deadline remaining = %s, expected near %s", remaining, expected) - } -} - -func boolCount(value bool) int { - if value { - return 1 - } - return 0 -} diff --git a/internal/application/messaging/vowifi_sms_test.go b/internal/application/messaging/vowifi_sms_test.go index c2e09b4..8348110 100644 --- a/internal/application/messaging/vowifi_sms_test.go +++ b/internal/application/messaging/vowifi_sms_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/leonfox28/simplus/internal/smstransport" + "github.com/leonfox28/simplus/internal/application/inventory" "github.com/leonfox28/simplus/internal/domain/hardware" "github.com/leonfox28/simplus/internal/domain/sms" @@ -40,7 +42,7 @@ func TestVoWiFiAcceptedSubmissionIsFinalizedByLaterRPACK(t *testing.T) { fake := &fakeVoWiFiSMSAPI{sendResponse: vowifisupervisor.SMSSendResponse{ ProviderMessageID: "ims_provider_0123456789", State: vowifisupervisor.SMSSubmitAccepted, }} - gateway, err := NewVoWiFiSMSGateway(fake) + gateway, err := smstransport.NewVoWiFiSMSGateway(fake) if err != nil { t.Fatal(err) } @@ -51,7 +53,7 @@ func TestVoWiFiAcceptedSubmissionIsFinalizedByLaterRPACK(t *testing.T) { Capabilities: hardware.Capabilities{HostVoWiFiAuth: true}, } service.lines = fixedLineSource{topology: inventory.Topology{Lines: []inventory.Line{line}}} - if err := service.UseTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { + if err := service.configureTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { t.Fatal(err) } sent, err := service.Send(context.Background(), SendRequest{ @@ -161,7 +163,7 @@ func TestVoWiFiGatewayAllowsHostLineWithoutCellularSMSCapability(t *testing.T) { Part: segment.Part, Total: segment.Total, UnitCount: segment.UnitCount, UserData: segment.UserData, ReceivedAt: time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC), }}} - gateway, err := NewVoWiFiSMSGateway(fake) + gateway, err := smstransport.NewVoWiFiSMSGateway(fake) if err != nil { t.Fatal(err) } @@ -172,7 +174,7 @@ func TestVoWiFiGatewayAllowsHostLineWithoutCellularSMSCapability(t *testing.T) { Capabilities: hardware.Capabilities{SMS: false, HostVoWiFiAuth: true}, } service.lines = fixedLineSource{topology: inventory.Topology{Lines: []inventory.Line{line}}} - if err := service.UseTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { + if err := service.configureTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { t.Fatal(err) } result, err := service.Send(context.Background(), SendRequest{ @@ -193,7 +195,7 @@ func TestVoWiFiGatewayAllowsHostLineWithoutCellularSMSCapability(t *testing.T) { func TestVoWiFiGatewayRequiresHostVoWiFiCapability(t *testing.T) { fake := &fakeVoWiFiSMSAPI{} - gateway, err := NewVoWiFiSMSGateway(fake) + gateway, err := smstransport.NewVoWiFiSMSGateway(fake) if err != nil { t.Fatal(err) } @@ -204,7 +206,7 @@ func TestVoWiFiGatewayRequiresHostVoWiFiCapability(t *testing.T) { Capabilities: hardware.Capabilities{SMS: true}, } service.lines = fixedLineSource{topology: inventory.Topology{Lines: []inventory.Line{line}}} - if err := service.UseTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { + if err := service.configureTransports(HostVoWiFiSMSTransport(messagingTransportAvailability(true), gateway, gateway)); err != nil { t.Fatal(err) } _, err = service.Send(context.Background(), SendRequest{ @@ -238,7 +240,7 @@ func TestVoWiFiMultipartInboundSurvivesControlPlaneRestart(t *testing.T) { first := &fakeVoWiFiSMSAPI{messages: []vowifisupervisor.SMSMessage{ voWiFiSMSMessage("imsin_first_part_012345", "+447700900123", receivedAt, segments[0]), }} - firstGateway, err := NewVoWiFiSMSGateway(first) + firstGateway, err := smstransport.NewVoWiFiSMSGateway(first) if err != nil { t.Fatal(err) } @@ -267,7 +269,7 @@ func TestVoWiFiMultipartInboundSurvivesControlPlaneRestart(t *testing.T) { second := &fakeVoWiFiSMSAPI{messages: []vowifisupervisor.SMSMessage{ voWiFiSMSMessage(secondMessageID, "+447700900123", receivedAt.Add(2*time.Minute), segments[1]), }} - secondGateway, err := NewVoWiFiSMSGateway(second) + secondGateway, err := smstransport.NewVoWiFiSMSGateway(second) if err != nil { t.Fatal(err) } @@ -280,9 +282,6 @@ func TestVoWiFiMultipartInboundSurvivesControlPlaneRestart(t *testing.T) { if err != nil || result.Persisted != 1 || result.Acknowledged != 1 || len(second.ackRequests) != 1 { t.Fatalf("second sync=%#v acks=%#v error=%v", result, second.ackRequests, err) } - if len(result.receivedSMS) != 1 || result.receivedSMS[0].Sender != "+447700900123" || result.receivedSMS[0].Body != body { - t.Fatalf("assembled received SMS notification = %#v", result.receivedSMS) - } messages, err := stores.ListSMS(ctx, 10) if err != nil || len(messages) != 1 || messages[0].Body != body || messages[0].ProviderMessageID == secondMessageID { t.Fatalf("assembled messages=%#v error=%v", messages, err) @@ -320,7 +319,7 @@ func TestVoWiFiMultipartInboundRejectsAssembledBodyBeyondSMSLimit(t *testing.T) )) } fake := &fakeVoWiFiSMSAPI{messages: messages} - gateway, err := NewVoWiFiSMSGateway(fake) + gateway, err := smstransport.NewVoWiFiSMSGateway(fake) if err != nil { t.Fatal(err) } @@ -331,7 +330,7 @@ func TestVoWiFiMultipartInboundRejectsAssembledBodyBeyondSMSLimit(t *testing.T) } result, err := service.SyncInbound(ctx) - if !errors.Is(err, ErrInboundSync) || result.Persisted != 0 || len(result.receivedSMS) != 0 || + if !errors.Is(err, ErrInboundSync) || result.Persisted != 0 || result.Acknowledged != len(segments)-1 || len(fake.ackRequests) != len(segments)-1 { t.Fatalf("sync=%#v acks=%d error=%v", result, len(fake.ackRequests), err) } diff --git a/internal/application/messaging/worker.go b/internal/application/messaging/worker.go new file mode 100644 index 0000000..31bd546 --- /dev/null +++ b/internal/application/messaging/worker.go @@ -0,0 +1,91 @@ +package messaging + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/leonfox28/simplus/internal/application/inventory" +) + +// RunInbound keeps a separate deadline and retry clock for every line. A +// completed line publishes its committed progress immediately; other lines +// may still be probing or waiting for the modem's serial operation gate. +func (service *Service) RunInbound(ctx context.Context, interval time.Duration, report func(InboundSyncResult, error)) { + if report == nil { + report = func(InboundSyncResult, error) {} + } + type completion struct { + id string + result InboundSyncResult + err error + } + done := make(chan completion) + var work sync.WaitGroup + defer work.Wait() + active := map[string]bool{} + next := map[string]time.Time{} + retries := map[string]time.Duration{} + ticker := time.NewTicker(normalizedSyncInterval(interval)) + defer ticker.Stop() + schedule := func() { + cycle, cancel := context.WithTimeout(ctx, 5*time.Second) + topology, err := service.lines.Topology(cycle) + cancel() + if err != nil { + report(InboundSyncResult{}, err) + return + } + for _, line := range topology.Lines { + if line.State != inventory.LineReady || active[line.ID] || time.Now().Before(next[line.ID]) { + continue + } + active[line.ID] = true + work.Go(func() { + cycle, cancel := context.WithTimeout(ctx, syncTimeout) + defer cancel() + result, err := service.syncResolvedLine(cycle, topology, line) + select { + case done <- completion{line.ID, result, err}: + case <-ctx.Done(): + } + }) + } + } + schedule() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + schedule() + case result := <-done: + delete(active, result.id) + delay := normalizedSyncInterval(interval) + if result.err != nil { + delay = nextSyncRetryDelay(retries[result.id], interval) + retries[result.id] = delay + } else { + delete(retries, result.id) + } + next[result.id] = time.Now().Add(delay) + report(result.result, result.err) + } + } +} + +func (service *Service) syncResolvedLine(ctx context.Context, topology inventory.Topology, line inventory.Line) (InboundSyncResult, error) { + transport, err := service.resolveTransport(ctx, line) + if err != nil { + return InboundSyncResult{}, errors.Join(ErrInboundSync, err) + } + target := runtimeLine{line: line} + if transport.resolveLine != nil { + target, err = transport.resolveLine(topology, line) + if err != nil { + return InboundSyncResult{}, errors.Join(ErrInboundSync, err) + } + } + return service.syncInboundLine(ctx, line, target, transport) +} diff --git a/internal/application/messaging/worker_test.go b/internal/application/messaging/worker_test.go new file mode 100644 index 0000000..697bab2 --- /dev/null +++ b/internal/application/messaging/worker_test.go @@ -0,0 +1,68 @@ +package messaging + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/leonfox28/simplus/internal/application/inventory" +) + +type independentInbox struct { + noopInbox + mu sync.Mutex + slowStarted chan struct{} + fastRead chan struct{} + once sync.Once +} + +func (i *independentInbox) ListSMS(ctx context.Context, target InboxTarget) ([]InboxMessageReference, error) { + if target.LineID == testManagedLineID1 { + i.once.Do(func() { close(i.slowStarted) }) + <-ctx.Done() + return nil, ctx.Err() + } + select { + case i.fastRead <- struct{}{}: + default: + } + return nil, nil +} +func TestInboundWorkerProgressAndShutdownDoNotWaitForSlowLine(t *testing.T) { + service, _ := newTestService(t, nil) + service.lines = managedTestLines(inventory.NewMultiSimulator()) + inbox := &independentInbox{slowStarted: make(chan struct{}), fastRead: make(chan struct{}, 2)} + sender := senderFunc(func(context.Context, SendSMSCommand) (SendSMSResult, error) { return SendSMSResult{}, nil }) + if err := service.configureTransports(AgentNativeSMSTransport(sender, inbox)); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + progress := make(chan struct{}, 2) + go func() { + defer close(done) + service.RunInbound(ctx, time.Millisecond, func(_ InboundSyncResult, err error) { + if err == nil { + select { + case progress <- struct{}{}: + default: + } + } + }) + }() + for _, channel := range []<-chan struct{}{inbox.slowStarted, inbox.fastRead, progress} { + select { + case <-channel: + case <-time.After(time.Second): + t.Fatal("one slow line blocked another's progress") + } + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("worker did not drain on cancellation") + } +} diff --git a/internal/application/mihomo/config_ports.go b/internal/application/mihomo/config_ports.go new file mode 100644 index 0000000..ea99c66 --- /dev/null +++ b/internal/application/mihomo/config_ports.go @@ -0,0 +1,47 @@ +package mihomo + +import ( + "errors" + "strings" + "time" + + "github.com/leonfox28/simplus/internal/domain/lineegress" +) + +type CountrySummary struct { + Code, Name string + NodeCount int +} + +type ArtifactMetadata struct { + SubscriptionID string `json:"subscriptionId"` + Version string `json:"version"` + RawSHA256 string `json:"rawSha256"` + ConfigSHA256 string `json:"configSha256"` + CoreVersion string `json:"coreVersion"` + GeneratedAt time.Time `json:"generatedAt"` + Countries []CountrySummary `json:"countries"` +} + +type ConfigStatus struct { + Published bool `json:"published"` + Launchable bool `json:"launchable"` + SHA256 string `json:"sha256"` + GeneratedAt time.Time `json:"generatedAt"` + ErrorCode string `json:"errorCode"` + SelectedSubscriptionID string `json:"selectedSubscriptionId"` + RunningSubscriptionID string `json:"runningSubscriptionId"` +} + +func countryPort(code string) int { + return lineegress.CountryListenerPort(strings.ToUpper(code)) +} + +func CountryListenerPort(code string) int { + return lineegress.CountryListenerPort(code) +} + +var ( + ErrConfigNotReady = errors.New("Mihomo configuration is not ready") + ErrConfigValidationFailed = errors.New("Mihomo configuration validation failed") +) diff --git a/internal/application/mihomo/core_ports.go b/internal/application/mihomo/core_ports.go new file mode 100644 index 0000000..fd48cea --- /dev/null +++ b/internal/application/mihomo/core_ports.go @@ -0,0 +1,21 @@ +package mihomo + +import ( + "errors" + "time" +) + +var ErrVersionAlreadyInstalled = errors.New("Mihomo version is already installed") + +type CoreStatus struct { + Installed bool `json:"installed"` + Version string `json:"version"` + Architecture string `json:"architecture"` + SHA256 string `json:"sha256"` + BinaryPath string `json:"-"` + InstalledAt time.Time `json:"installedAt"` +} + +type CoreStatusReader interface { + Status() (CoreStatus, error) +} diff --git a/internal/application/mihomo/dashboard_port.go b/internal/application/mihomo/dashboard_port.go new file mode 100644 index 0000000..f3f880a --- /dev/null +++ b/internal/application/mihomo/dashboard_port.go @@ -0,0 +1,11 @@ +package mihomo + +const ZashboardVersion = "v3.6.0" + +type DashboardStatus struct { + Available bool + Version string + ControllerAddress string + URL string + Secret string +} diff --git a/internal/application/mihomo/release_port.go b/internal/application/mihomo/release_port.go new file mode 100644 index 0000000..b7ac7a8 --- /dev/null +++ b/internal/application/mihomo/release_port.go @@ -0,0 +1,10 @@ +package mihomo + +type Candidate struct { + Version string `json:"version"` + AssetName string `json:"assetName"` + DownloadURL string `json:"downloadUrl"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Architecture string `json:"architecture"` +} diff --git a/internal/application/mihomo/runtime.go b/internal/application/mihomo/runtime.go index c3540e2..1a0c856 100644 --- a/internal/application/mihomo/runtime.go +++ b/internal/application/mihomo/runtime.go @@ -4,13 +4,11 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" - "reflect" "sync" "time" - "github.com/leonfox28/simplus/internal/mihomosupervisor" + runtime "github.com/leonfox28/simplus/internal/domain/mihomo" ) var ( @@ -43,7 +41,7 @@ type RuntimeManager struct { Store RuntimeStore Artifacts ArtifactResolver Core CoreStatusReader - Supervisor mihomosupervisor.API + Supervisor runtime.API Now func() time.Time mu sync.Mutex } @@ -53,21 +51,21 @@ func NewRuntimeManager( store RuntimeStore, artifacts ArtifactResolver, core CoreStatusReader, - supervisor mihomosupervisor.API, + supervisor runtime.API, ) (*RuntimeManager, error) { if !filepath.IsAbs(root) { return nil, fmt.Errorf("%w: root must be absolute", ErrRuntimeManagerConfiguration) } - if runtimeDependencyMissing(store) { + if store == nil { return nil, fmt.Errorf("%w: runtime store is required", ErrRuntimeManagerConfiguration) } - if runtimeDependencyMissing(artifacts) { + if artifacts == nil { return nil, fmt.Errorf("%w: artifact resolver is required", ErrRuntimeManagerConfiguration) } - if runtimeDependencyMissing(core) { + if core == nil { return nil, fmt.Errorf("%w: core status reader is required", ErrRuntimeManagerConfiguration) } - if runtimeDependencyMissing(supervisor) { + if supervisor == nil { return nil, fmt.Errorf("%w: supervisor is required", ErrRuntimeManagerConfiguration) } return &RuntimeManager{ @@ -80,19 +78,6 @@ func NewRuntimeManager( }, nil } -func runtimeDependencyMissing(dependency any) bool { - if dependency == nil { - return true - } - value := reflect.ValueOf(dependency) - switch value.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return value.IsNil() - default: - return false - } -} - func (manager *RuntimeManager) Status(ctx context.Context) (RuntimeStatus, error) { manager.mu.Lock() defer manager.mu.Unlock() @@ -207,16 +192,12 @@ func (manager *RuntimeManager) startLocked(ctx context.Context, subscriptionID s return manager.startConfigLocked(ctx, subscriptionID, configPath) } func (manager *RuntimeManager) startConfigLocked(ctx context.Context, subscriptionID, configPath string) error { - info, err := os.Stat(configPath) - if err != nil || !info.Mode().IsRegular() { - return ErrConfigNotReady - } core, err := manager.Core.Status() if err != nil || !core.Installed { return ErrConfigNotReady } - if _, err := manager.Supervisor.Start(ctx, mihomosupervisor.StartRequest{SubscriptionID: subscriptionID, BinaryPath: core.BinaryPath, ConfigPath: configPath}); err != nil { - if errors.Is(err, mihomosupervisor.ErrStartupFailed) { + if _, err := manager.Supervisor.Start(ctx, runtime.StartRequest{SubscriptionID: subscriptionID, BinaryPath: core.BinaryPath, ConfigPath: configPath}); err != nil { + if errors.Is(err, runtime.ErrStartupFailed) { return ErrRuntimeStartupFailed } return err @@ -228,7 +209,7 @@ func (manager *RuntimeManager) startConfigLocked(ctx context.Context, subscripti return nil } func (manager *RuntimeManager) stopLocked(ctx context.Context) error { - if err := manager.Supervisor.Stop(ctx); err != nil && !errors.Is(err, mihomosupervisor.ErrNotRunning) { + if err := manager.Supervisor.Stop(ctx); err != nil && !errors.Is(err, runtime.ErrNotRunning) { return err } return manager.clearRunning(ctx) diff --git a/internal/application/mihomo/subscriptions.go b/internal/application/mihomo/subscriptions.go index 7b29324..0b82b06 100644 --- a/internal/application/mihomo/subscriptions.go +++ b/internal/application/mihomo/subscriptions.go @@ -8,9 +8,6 @@ import ( "encoding/base64" "errors" "fmt" - "io" - "net" - "net/http" "net/url" "regexp" "sort" @@ -24,13 +21,8 @@ import ( var subscriptionIDPattern = regexp.MustCompile(`^subscription_[A-Za-z0-9_-]{22}$`) var subscriptionDefaultNamePattern = regexp.MustCompile(`^[A-Z2-7]{6}$`) -// Subscription providers use the client identifier to choose an output -// dialect. clash.meta requests the complete YAML document needed to preserve -// proxy fields when building the generated Mihomo configuration. -const subscriptionUserAgent = "clash.meta" - var ( - ErrSubscriptionInvalid = errors.New("Mihomo subscription request is invalid") + ErrSubscriptionInvalid = domain.ErrSubscriptionInvalid ErrSubscriptionNotFound = errors.New("Mihomo subscription not found") ) @@ -78,19 +70,28 @@ type SubscriptionView struct { } type SubscriptionService struct { - Store SubscriptionStore - Secrets SecretCipher - Now func() time.Time - HTTPClient *http.Client - Artifacts SubscriptionArtifactManager + Store SubscriptionStore + Secrets SecretCipher + Now func() time.Time + Fetcher SubscriptionFetcher + Artifacts SubscriptionArtifactManager } -func NewSubscriptionService(store SubscriptionStore, secrets SecretCipher, artifacts ...SubscriptionArtifactManager) *SubscriptionService { - service := &SubscriptionService{Store: store, Secrets: secrets, Now: time.Now, HTTPClient: newSubscriptionHTTPClient()} - if len(artifacts) != 0 { - service.Artifacts = artifacts[0] +func NewSubscriptionService(store SubscriptionStore, secrets SecretCipher, fetcher SubscriptionFetcher, artifacts ...SubscriptionArtifactManager) (*SubscriptionService, error) { + if store == nil || fetcher == nil || len(artifacts) > 1 { + return nil, errors.New("Mihomo subscription dependencies are incomplete") + } + if secrets == nil { + secrets = disabledSubscriptionSecrets{} + } + var artifact SubscriptionArtifactManager = DisabledSubscriptionArtifacts{} + if len(artifacts) == 1 { + if artifacts[0] == nil { + return nil, errors.New("Mihomo artifact implementation is required") + } + artifact = artifacts[0] } - return service + return &SubscriptionService{Store: store, Secrets: secrets, Fetcher: fetcher, Artifacts: artifact, Now: time.Now}, nil } func (service *SubscriptionService) List(ctx context.Context) ([]SubscriptionView, error) { @@ -148,7 +149,7 @@ func (service *SubscriptionService) Create(ctx context.Context, name, rawURL str if strings.TrimSpace(name) == "" { name = defaultSubscriptionDisplayName(id) } - name, parsed, err := validateSubscriptionInput(name, rawURL) + name, parsed, err := domain.ValidateSubscriptionInput(name, rawURL) if err != nil { return SubscriptionView{}, err } @@ -184,7 +185,7 @@ func (service *SubscriptionService) Update(ctx context.Context, id, name, rawURL } current.DisplayName, current.Enabled, current.UpdatedAt = name, enabled, service.Now().UTC() if strings.TrimSpace(rawURL) != "" { - _, parsed, err := validateSubscriptionInput(name, rawURL) + _, parsed, err := domain.ValidateSubscriptionInput(name, rawURL) if err != nil { return SubscriptionView{}, err } @@ -261,29 +262,10 @@ func (service *SubscriptionService) subscriptionURL(ctx context.Context, id stri return nil, item, fmt.Errorf("migrate Mihomo subscription URL to plaintext: %w", err) } } - _, parsed, err := validateSubscriptionInput(item.DisplayName, plaintext) + _, parsed, err := domain.ValidateSubscriptionInput(item.DisplayName, plaintext) return parsed, item, err } -func validateSubscriptionInput(name, rawURL string) (string, *url.URL, error) { - name = strings.TrimSpace(name) - if name == "" || len([]rune(name)) > 80 || len(rawURL) > 4096 { - return "", nil, ErrSubscriptionInvalid - } - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { - return "", nil, ErrSubscriptionInvalid - } - host := parsed.Hostname() - if strings.EqualFold(host, "localhost") { - return "", nil, ErrSubscriptionInvalid - } - if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsMulticast()) { - return "", nil, ErrSubscriptionInvalid - } - return name, parsed, nil -} - func newSubscriptionID() (string, error) { raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { @@ -301,40 +283,26 @@ func subscriptionView(item domain.Subscription) SubscriptionView { } func (service *SubscriptionService) Refresh(ctx context.Context, id string) (SubscriptionView, []domain.Node, error) { - if service == nil || service.HTTPClient == nil || service.Now == nil || !subscriptionIDPattern.MatchString(id) { + if service == nil || service.Fetcher == nil || service.Now == nil || !subscriptionIDPattern.MatchString(id) { return SubscriptionView{}, nil, ErrSubscriptionInvalid } target, item, err := service.subscriptionURL(ctx, id) if err != nil { return SubscriptionView{}, nil, err } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + body, err := service.Fetcher.Fetch(ctx, target.String()) if err != nil { return service.refreshFailed(ctx, item, "SUBSCRIPTION_FETCH_FAILED") } - request.Header.Set("Accept", "application/yaml,text/yaml,text/plain,application/octet-stream") - request.Header.Set("User-Agent", subscriptionUserAgent) - response, err := service.HTTPClient.Do(request) - if err != nil { - return service.refreshFailed(ctx, item, "SUBSCRIPTION_FETCH_FAILED") - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return service.refreshFailed(ctx, item, "SUBSCRIPTION_FETCH_FAILED") - } - body, err := io.ReadAll(io.LimitReader(response.Body, 5<<20+1)) - if err != nil || len(body) > 5<<20 { - return service.refreshFailed(ctx, item, "SUBSCRIPTION_FETCH_FAILED") - } nodes, err := parseSubscriptionNodes(id, body) if err != nil { return service.refreshFailed(ctx, item, "SUBSCRIPTION_PARSE_FAILED") } - if service.Artifacts == nil { - return service.refreshFailed(ctx, item, "SUBSCRIPTION_CONFIG_UNAVAILABLE") - } if _, err := service.Artifacts.BuildSubscription(ctx, id, body, nodes); err != nil { code := "SUBSCRIPTION_CONFIG_GENERATION_FAILED" + if errors.Is(err, errSubscriptionArtifactsDisabled) { + code = "SUBSCRIPTION_CONFIG_UNAVAILABLE" + } if errors.Is(err, ErrConfigValidationFailed) { code = "SUBSCRIPTION_CONFIG_VALIDATION_FAILED" } @@ -535,36 +503,36 @@ func summarizedNode(subscriptionID, name, kind string) (domain.Node, error) { return domain.Node{SubscriptionID: subscriptionID, ID: id, DisplayName: name, Kind: kind}, nil } -func newSubscriptionHTTPClient() *http.Client { - transport := &http.Transport{Proxy: nil, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 20 * time.Second} - transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil || len(addresses) == 0 { - return nil, fmt.Errorf("resolve subscription host: %w", err) - } - for _, address := range addresses { - if isUnsafeSubscriptionIP(address.IP) { - return nil, errors.New("subscription host resolved to a private or local address") - } - } - var dialer net.Dialer - return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) - } - client := &http.Client{Transport: transport, Timeout: 30 * time.Second} - client.CheckRedirect = func(request *http.Request, via []*http.Request) error { - if len(via) > 3 { - return errors.New("too many subscription redirects") - } - _, _, err := validateSubscriptionInput("redirect", request.URL.String()) - return err - } - return client +type SubscriptionFetcher interface { + Fetch(context.Context, string) ([]byte, error) +} + +var errSubscriptionArtifactsDisabled = errors.New("Mihomo subscription artifacts are disabled") + +type DisabledSubscriptionFetcher struct{} + +func (DisabledSubscriptionFetcher) Fetch(context.Context, string) ([]byte, error) { + return nil, errors.New("Mihomo subscription fetching is disabled") +} + +type DisabledSubscriptionArtifacts struct{} + +func (DisabledSubscriptionArtifacts) BuildSubscription(context.Context, string, []byte, []domain.Node) (ArtifactMetadata, error) { + return ArtifactMetadata{}, errSubscriptionArtifactsDisabled +} +func (DisabledSubscriptionArtifacts) Select(context.Context, string) (ConfigStatus, error) { + return ConfigStatus{}, errSubscriptionArtifactsDisabled +} +func (DisabledSubscriptionArtifacts) DeleteSubscriptionArtifacts(string) error { return nil } +func (DisabledSubscriptionArtifacts) Artifact(string) (ArtifactMetadata, string, error) { + return ArtifactMetadata{}, "", errSubscriptionArtifactsDisabled } -func isUnsafeSubscriptionIP(ip net.IP) bool { - return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsMulticast() +type disabledSubscriptionSecrets struct{} + +func (disabledSubscriptionSecrets) Encrypt(string, []byte) ([]byte, error) { + return nil, ErrSubscriptionInvalid +} +func (disabledSubscriptionSecrets) Decrypt(string, []byte) ([]byte, error) { + return nil, ErrSubscriptionInvalid } diff --git a/internal/application/mihomo/subscriptions_integration_test.go b/internal/application/mihomo/subscriptions_integration_test.go index 97fb871..44d9422 100644 --- a/internal/application/mihomo/subscriptions_integration_test.go +++ b/internal/application/mihomo/subscriptions_integration_test.go @@ -22,7 +22,10 @@ func TestSubscriptionCRUDStoresAndReturnsPlaintextURLInPrivateDatabase(t *testin if err != nil { t.Fatal(err) } - service := app.NewSubscriptionService(stores, keyring) + service, err := app.NewSubscriptionService(stores, keyring, app.DisabledSubscriptionFetcher{}) + if err != nil { + t.Fatal(err) + } created, err := service.Create(ctx, "", "https://subscription.example/api?token=highly-secret-token", true) if err != nil { t.Fatal(err) diff --git a/internal/application/mihomo/subscriptions_test.go b/internal/application/mihomo/subscriptions_test.go index eb4988d..2ad1cec 100644 --- a/internal/application/mihomo/subscriptions_test.go +++ b/internal/application/mihomo/subscriptions_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/leonfox28/simplus/internal/subscriptionhttp" + domain "github.com/leonfox28/simplus/internal/domain/mihomo" ) @@ -104,9 +106,13 @@ func newRefreshTestService(transport http.RoundTripper, artifacts SubscriptionAr }, selected: refreshTestSubscriptionID, } - service := NewSubscriptionService(store, nil) - service.Artifacts = artifacts - service.HTTPClient = &http.Client{Transport: transport} + if artifacts == nil { + artifacts = DisabledSubscriptionArtifacts{} + } + service, err := NewSubscriptionService(store, nil, &subscriptionhttp.Client{HTTP: &http.Client{Transport: transport}}, artifacts) + if err != nil { + panic(err) + } service.Now = func() time.Time { return now } return service, store } @@ -157,11 +163,11 @@ func TestParseSubscriptionNodesSupportsBase64URIListAndRejectsEmpty(t *testing.T func TestValidateSubscriptionInputRejectsPrivateTargets(t *testing.T) { for _, raw := range []string{"http://example.com/sub", "https://localhost/sub", "https://127.0.0.1/sub", "https://192.168.50.1/sub", "https://[::1]/sub"} { - if _, _, err := validateSubscriptionInput("test", raw); err == nil { + if _, _, err := domain.ValidateSubscriptionInput("test", raw); err == nil { t.Errorf("accepted %s", raw) } } - if _, parsed, err := validateSubscriptionInput("test", "https://subscription.example/path?token=secret"); err != nil || parsed.Hostname() != "subscription.example" { + if _, parsed, err := domain.ValidateSubscriptionInput("test", "https://subscription.example/path?token=secret"); err != nil || parsed.Hostname() != "subscription.example" { t.Fatalf("public URL = %v, %v", parsed, err) } } @@ -191,27 +197,14 @@ func TestSubscriptionRefreshNegotiatesGeneratableMihomoYAML(t *testing.T) { } return subscriptionResponse(http.StatusOK, yamlFixture), nil }), nil) - artifacts := &ConfigManager{ - Root: t.TempDir(), - Store: store, - Core: coreStatusStub{CoreStatus{Installed: true, Version: "v1.19.29", BinaryPath: "/installed/mihomo"}}, - Run: func(context.Context, string, ...string) ([]byte, error) { - return []byte("configuration test is successful"), nil - }, - Now: service.Now, - ControllerAddress: "127.0.0.1:19090", - } - service.Artifacts = artifacts + service.Artifacts = &refreshArtifactStub{} view, nodes, err := service.Refresh(context.Background(), refreshTestSubscriptionID) if err != nil { t.Fatal(err) } - if subscriptionUserAgent != "clash.meta" { - t.Fatalf("subscription User-Agent constant = %q", subscriptionUserAgent) - } - if userAgent != subscriptionUserAgent { - t.Fatalf("User-Agent = %q", userAgent) + if userAgent != "clash.meta" { + t.Fatalf("User-Agent=%q", userAgent) } if accept != "application/yaml,text/yaml,text/plain,application/octet-stream" { t.Fatalf("Accept = %q", accept) @@ -225,9 +218,7 @@ func TestSubscriptionRefreshNegotiatesGeneratableMihomoYAML(t *testing.T) { if len(nodes) != 1 || nodes[0].ProxyYAML == "" { t.Fatalf("downloaded nodes are not usable for config generation: %#v", nodes) } - if metadata, _, artifactErr := artifacts.Artifact(refreshTestSubscriptionID); artifactErr != nil || metadata.ConfigSHA256 == "" { - t.Fatalf("artifact metadata=%#v err=%v", metadata, artifactErr) - } + } func TestSubscriptionRefreshReturnsCredentialSafeTypedFetchErrors(t *testing.T) { diff --git a/internal/application/modem/assembly_helpers_test.go b/internal/application/modem/assembly_helpers_test.go new file mode 100644 index 0000000..2960ee7 --- /dev/null +++ b/internal/application/modem/assembly_helpers_test.go @@ -0,0 +1,31 @@ +package modem + +import ( + "context" + + domain "github.com/leonfox28/simplus/internal/domain/modem" +) + +type RFController interface { + State(context.Context, string) (string, error) + Set(context.Context, string, bool) (string, error) +} +type legacyTestReader struct { + controller RFController + service *Service +} + +func (r legacyTestReader) Read(ctx context.Context, id string) (domain.RuntimeStatus, error) { + state, err := r.controller.State(ctx, id) + topology, _ := r.service.inventory.Topology(ctx) + presence := observations(topology)[id].simPresence + return domain.RuntimeStatus{RFState: state, SIMPresence: presence, Cellular: domain.UnavailableCellularStatus()}, err +} +func (s *Service) UseRFController(c RFController) { + s.rf = c + s.runtime = legacyTestReader{c, s} + if r, ok := c.(RuntimeStatusReader); ok { + s.runtime = r + } +} +func (s *Service) UseEquipmentIdentityReader(r EquipmentIdentityReader) { s.identity = r } diff --git a/internal/application/modem/service.go b/internal/application/modem/service.go index 699e4ae..2d3c2ac 100644 --- a/internal/application/modem/service.go +++ b/internal/application/modem/service.go @@ -45,11 +45,6 @@ type Inventory interface { Topology(context.Context) (inventory.Topology, error) } -type RFController interface { - State(context.Context, string) (string, error) - Set(context.Context, string, bool) (string, error) -} - type RuntimeStatusReader interface { Read(context.Context, string) (domain.RuntimeStatus, error) } @@ -74,42 +69,26 @@ type Service struct { now func() time.Time rf RFSetter runtime RuntimeStatusReader - legacyRF RFController identity EquipmentIdentityReader mu sync.Mutex } -func (service *Service) UseRFController(controller RFController) { - if service != nil { - service.rf = controller - service.legacyRF = controller - service.runtime, _ = controller.(RuntimeStatusReader) - } -} - -func (service *Service) UseRuntimeStatusReader(reader RuntimeStatusReader) { - if service != nil { - service.runtime = reader +func New(repository Repository, inventoryService Inventory, options ...Options) (*Service, error) { + if repository == nil || inventoryService == nil { + return nil, errors.New("managed modem service is not configured") } -} -func (service *Service) UseRFSetter(setter RFSetter) { - if service != nil { - service.rf = setter + ports := Options{RF: DisabledHardware{}, Runtime: DisabledHardware{}, Identity: DisabledIdentity{}} + if len(options) > 1 { + return nil, errors.New("multiple modem configurations") } -} - -func (service *Service) UseEquipmentIdentityReader(reader EquipmentIdentityReader) { - if service != nil { - service.identity = reader + if len(options) == 1 { + ports = options[0] } -} - -func New(repository Repository, inventoryService Inventory) (*Service, error) { - if repository == nil || inventoryService == nil { - return nil, errors.New("managed modem service is not configured") + if ports.RF == nil || ports.Runtime == nil || ports.Identity == nil { + return nil, errors.New("modem capability implementation is required") } - return &Service{repository: repository, inventory: inventoryService, random: rand.Reader, now: time.Now}, nil + return &Service{repository: repository, inventory: inventoryService, random: rand.Reader, now: time.Now, rf: ports.RF, runtime: ports.Runtime, identity: ports.Identity}, nil } func (service *Service) List(ctx context.Context) ([]domain.View, error) { @@ -148,10 +127,6 @@ func (service *Service) List(ctx context.Context) ([]domain.View, error) { view.SIMPresence = status.SIMPresence view.Cellular = status.Cellular } - } else if service.legacyRF != nil && view.Capabilities.RFControl { - if state, stateErr := service.legacyRF.State(ctx, current.id); stateErr == nil { - view.RFState = state - } } } views = append(views, view) @@ -565,3 +540,23 @@ func mergeCapabilities(left, right hardware.Capabilities) hardware.Capabilities EUICCProfiles: left.EUICCProfiles || right.EUICCProfiles, } } + +type Options struct { + RF RFSetter + Runtime RuntimeStatusReader + Identity EquipmentIdentityReader +} +type DisabledHardware struct{} + +func (DisabledHardware) Read(context.Context, string) (domain.RuntimeStatus, error) { + return domain.RuntimeStatus{}, ErrRFUnavailable +} +func (DisabledHardware) Set(context.Context, string, bool) (string, error) { + return "", ErrRFUnavailable +} + +type DisabledIdentity struct{} + +func (DisabledIdentity) Read(context.Context, string) (EquipmentIdentity, error) { + return EquipmentIdentity{}, ErrEquipmentIdentityUnavailable +} diff --git a/internal/application/notification/binding.go b/internal/application/notification/binding.go index 1bf96ca..d5b198c 100644 --- a/internal/application/notification/binding.go +++ b/internal/application/notification/binding.go @@ -35,7 +35,7 @@ var ( ErrBindingUnavailable = errors.New("feishu binding is unavailable") ) -var defaultBindingEvents = []string{"call.incoming", "call.missed", "sms.failed", "sms.received", "system.degraded"} +var defaultBindingEvents = append([]string(nil), domain.EventKinds...) type BindingView struct { State, VerificationURL, ChannelID, ErrorCode string @@ -43,6 +43,8 @@ type BindingView struct { } type bindingController struct { + work sync.WaitGroup + closed bool mu sync.Mutex generation uint64 state BindingView @@ -55,7 +57,7 @@ func newBindingController() *bindingController { return &bindingController{processCtx: context.Background(), state: BindingView{State: BindingStateIdle}} } -func (s *Service) ConfigureFeishuBinding(processCtx context.Context, registrar FeishuRegistrar, messenger FeishuMessenger, onChange func()) { +func (s *Service) configureFeishuBinding(processCtx context.Context, registrar FeishuRegistrar, messenger FeishuMessenger, onChange func()) { if processCtx == nil { processCtx = context.Background() } @@ -76,6 +78,10 @@ func (s *Service) StartFeishuBinding(_ context.Context) (BindingView, error) { return BindingView{}, ErrBindingUnavailable } s.binding.mu.Lock() + if s.binding.closed { + s.binding.mu.Unlock() + return BindingView{}, ErrBindingUnavailable + } if s.binding.state.State == BindingStateWaiting || s.binding.state.State == BindingStateTesting { state := s.binding.state s.binding.mu.Unlock() @@ -107,8 +113,9 @@ func (s *Service) StartFeishuBinding(_ context.Context) (BindingView, error) { s.binding.state.VerificationURL = registration.VerificationURL s.binding.state.ExpiresAt = registration.ExpiresAt.UTC() state := s.binding.state + s.binding.work.Add(1) s.binding.mu.Unlock() - go s.completeFeishuBinding(attemptCtx, generation, registration) + go func() { defer s.binding.work.Done(); s.completeFeishuBinding(attemptCtx, generation, registration) }() return state, nil } @@ -136,7 +143,7 @@ func (s *Service) completeFeishuBinding(ctx context.Context, generation uint64, s.finishBindingFailure(generation, err) return } - if err := validateFeishuResult(result); err != nil { + if err := ValidateFeishuResult(result); err != nil { s.finishBindingFailure(generation, err) return } @@ -252,3 +259,15 @@ func bindingFailureCode(err error) string { return BindingErrorProviderFailed } } + +// Close stops admission and waits before the process closes its database. +func (s *Service) Close() { + s.binding.mu.Lock() + s.binding.closed = true + s.binding.generation++ + if s.binding.cancel != nil { + s.binding.cancel() + } + s.binding.mu.Unlock() + s.binding.work.Wait() +} diff --git a/internal/application/notification/binding_test.go b/internal/application/notification/binding_test.go index b780dc6..7fb8340 100644 --- a/internal/application/notification/binding_test.go +++ b/internal/application/notification/binding_test.go @@ -1,15 +1,9 @@ package notification import ( - "bytes" - "compress/gzip" "context" - "encoding/base64" - "encoding/json" "errors" - "io" "net/http" - "net/url" "strings" "sync" "testing" @@ -78,7 +72,7 @@ func TestFeishuBindingTestsBeforePersistingAndSupportsAppDelivery(t *testing.T) return nil }) changed := make(chan struct{}, 1) - service.ConfigureFeishuBinding(context.Background(), registrar, messenger, func() { changed <- struct{}{} }) + service.configureFeishuBinding(context.Background(), registrar, messenger, func() { changed <- struct{}{} }) waiting, err := service.StartFeishuBinding(context.Background()) if err != nil || waiting.State != BindingStateWaiting || !strings.HasPrefix(waiting.VerificationURL, "https://accounts.feishu.cn/") { t.Fatalf("waiting = %#v, err = %v", waiting, err) @@ -98,7 +92,7 @@ func TestFeishuBindingTestsBeforePersistingAndSupportsAppDelivery(t *testing.T) t.Fatalf("items = %#v, err = %v", items, err) } item := items[0] - if item.DeliveryMode != "feishu_app" || item.DisplayName != "飞书私聊" || !item.Enabled || item.LastDeliveryStatus != "success" || len(item.EventKinds) != 5 { + if item.DeliveryMode != "feishu_app" || item.DisplayName != "飞书私聊" || !item.Enabled || item.LastDeliveryStatus != "success" || len(item.EventKinds) != 9 { t.Fatalf("item = %#v", item) } select { @@ -144,7 +138,7 @@ func TestFeishuBindingCancelAndFailuresNeverPersist(t *testing.T) { store := &memoryStore{} service := newTestService(t, store) now := time.Now().UTC() - service.ConfigureFeishuBinding(context.Background(), registrarFake{ + service.configureFeishuBinding(context.Background(), registrarFake{ begin: func(context.Context) (FeishuRegistration, error) { return FeishuRegistration{DeviceCode: "device", VerificationURL: "https://accounts.feishu.cn/synthetic", ExpiresAt: now.Add(time.Minute)}, nil }, @@ -166,7 +160,7 @@ func TestFeishuBindingCancelAndFailuresNeverPersist(t *testing.T) { service := newTestService(t, store) blocked := make(chan struct{}) messengerCalls := 0 - service.ConfigureFeishuBinding(context.Background(), registrarFake{ + service.configureFeishuBinding(context.Background(), registrarFake{ begin: func(context.Context) (FeishuRegistration, error) { return FeishuRegistration{DeviceCode: "device", VerificationURL: "https://accounts.feishu.cn/synthetic", ExpiresAt: time.Now().Add(time.Minute)}, nil }, @@ -194,7 +188,7 @@ func TestFeishuBindingPersistenceFailureDoesNotCreateChannel(t *testing.T) { memory := &memoryStore{} service := newTestService(t, failingUpsertStore{memory}) now := time.Now().UTC() - service.ConfigureFeishuBinding(context.Background(), registrarFake{ + service.configureFeishuBinding(context.Background(), registrarFake{ begin: func(context.Context) (FeishuRegistration, error) { return FeishuRegistration{DeviceCode: "device", VerificationURL: "https://accounts.feishu.cn/synthetic", ExpiresAt: now.Add(time.Minute)}, nil }, @@ -217,7 +211,7 @@ func TestFeishuBindingProcessCancellationStopsWaitingAndTestingWithoutPersistenc service := newTestService(t, store) processCtx, cancelProcess := context.WithCancel(context.Background()) pollStarted := make(chan struct{}) - service.ConfigureFeishuBinding(processCtx, registrarFake{ + service.configureFeishuBinding(processCtx, registrarFake{ begin: func(context.Context) (FeishuRegistration, error) { return FeishuRegistration{DeviceCode: "device", VerificationURL: "https://accounts.feishu.cn/synthetic", ExpiresAt: time.Now().Add(time.Minute)}, nil }, @@ -249,7 +243,7 @@ func TestFeishuBindingProcessCancellationStopsWaitingAndTestingWithoutPersistenc service := newTestService(t, store) processCtx, cancelProcess := context.WithCancel(context.Background()) testStarted := make(chan struct{}) - service.ConfigureFeishuBinding(processCtx, registrarFake{ + service.configureFeishuBinding(processCtx, registrarFake{ begin: func(context.Context) (FeishuRegistration, error) { return FeishuRegistration{DeviceCode: "device", VerificationURL: "https://accounts.feishu.cn/synthetic", ExpiresAt: time.Now().Add(time.Minute)}, nil }, @@ -289,322 +283,3 @@ func waitBindingState(t *testing.T, service *Service, wanted string) BindingView t.Fatalf("binding did not reach %s: %#v", wanted, service.FeishuBindingStatus()) return BindingView{} } - -func TestFeishuBindingAcceptsCurrentOpaqueBeginResponse(t *testing.T) { - now := time.Unix(1000, 0).UTC() - deviceCode := strings.Repeat("synthetic +/%", 43) - beginBody, err := json.Marshal(map[string]any{ - "device_code": deviceCode, "verification_uri_complete": "https://open.feishu.cn/verify?user_code=synthetic", - "interval": 5, "expires_in": 3600, - }) - if err != nil { - t.Fatal(err) - } - client := NewFeishuClient() - client.Now = func() time.Time { return now } - waited := false - client.Wait = func(ctx context.Context, _ time.Duration) error { - if !waited { - waited = true - return nil - } - <-ctx.Done() - return ctx.Err() - } - pollDeviceCode := make(chan string, 1) - client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - if request.URL.Path != feishuRegistrationPath { - return nil, errors.New("unexpected synthetic request path") - } - requestBody, err := io.ReadAll(request.Body) - if err != nil { - return nil, err - } - form, err := url.ParseQuery(string(requestBody)) - if err != nil { - return nil, err - } - if form.Get("action") == "poll" { - pollDeviceCode <- form.Get("device_code") - return &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":"authorization_pending"}`))}, nil - } - return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(beginBody))}, nil - })} - - service := newTestService(t, &memoryStore{}) - service.ConfigureFeishuBinding(context.Background(), client, messengerFunc(func(context.Context, FeishuRegistrationResult, string) error { - t.Fatal("messenger called before authorization") - return nil - }), nil) - waiting, err := service.StartFeishuBinding(context.Background()) - if err != nil || waiting.State != BindingStateWaiting { - t.Fatalf("waiting = %#v, err = %v", waiting, err) - } - verification, err := url.Parse(waiting.VerificationURL) - if err != nil || verification.Hostname() != "open.feishu.cn" || !waiting.ExpiresAt.Equal(now.Add(time.Hour)) { - t.Fatalf("waiting = %#v, verification parse err = %v", waiting, err) - } - pollCode := <-pollDeviceCode - if pollCode != deviceCode || len(pollCode) <= 512 { - t.Fatalf("opaque device code was not preserved: length = %d", len(pollCode)) - } - if _, err := service.CancelFeishuBinding(); err != nil { - t.Fatal(err) - } -} - -func TestFeishuClientUsesMinimalCreateOnlyFlowAndPrivateOpenIDDelivery(t *testing.T) { - var requests []*http.Request - var bodies [][]byte - pollCount := 0 - client := NewFeishuClient() - client.Now = func() time.Time { return time.Unix(1000, 0).UTC() } - client.Wait = func(context.Context, time.Duration) error { return nil } - client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - body, _ := io.ReadAll(request.Body) - requests = append(requests, request.Clone(context.Background())) - bodies = append(bodies, body) - response := `{}` - status := http.StatusOK - switch { - case request.URL.Hostname() == "accounts.feishu.cn" && strings.Contains(string(body), "action=begin"): - response = `{"device_code":"device_synthetic","verification_uri_complete":"https://accounts.feishu.cn/verify?user_code=synthetic","interval":1,"expire_in":60}` - case request.URL.Hostname() == "accounts.feishu.cn": - pollCount++ - if pollCount == 1 { - status = http.StatusBadRequest - response = `{"error":"authorization_pending"}` - } else if pollCount == 2 { - status = http.StatusBadRequest - response = `{"error":"slow_down"}` - } else { - response = `{"client_id":"cli_synthetic","client_secret":"secret_synthetic","user_info":{"open_id":"ou_synthetic","tenant_brand":"feishu"}}` - } - case request.URL.Path == feishuTenantTokenPath: - response = `{"code":0,"tenant_access_token":"token_synthetic"}` - case request.URL.Path == feishuMessagePath: - response = `{"code":0}` - } - return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(response))}, nil - })} - registration, err := client.Begin(context.Background()) - if err != nil { - t.Fatal(err) - } - verification, _ := url.Parse(registration.VerificationURL) - if verification.Hostname() != "accounts.feishu.cn" || verification.Query().Get("createOnly") != "true" { - t.Fatalf("verification URL = %s", registration.VerificationURL) - } - compressed, err := base64.RawURLEncoding.DecodeString(verification.Query().Get("addons")) - if err != nil { - t.Fatal(err) - } - reader, err := gzip.NewReader(bytes.NewReader(compressed)) - if err != nil { - t.Fatal(err) - } - addonsBody, _ := io.ReadAll(reader) - var addons map[string]json.RawMessage - if err := json.Unmarshal(addonsBody, &addons); err != nil { - t.Fatal(err) - } - var preset bool - var scopes map[string][]string - if len(addons) != 2 || json.Unmarshal(addons["preset"], &preset) != nil || json.Unmarshal(addons["scopes"], &scopes) != nil || preset || len(scopes) != 1 || len(scopes["tenant"]) != 1 || scopes["tenant"][0] != "im:message:send_as_bot" { - t.Fatalf("addons = %s", addonsBody) - } - beginForm, err := url.ParseQuery(string(bodies[0])) - if err != nil { - t.Fatal(err) - } - if len(beginForm) != 4 || beginForm.Get("action") != "begin" || beginForm.Get("archetype") != "PersonalAgent" || beginForm.Get("auth_method") != "client_secret" || beginForm.Get("request_user_info") != "open_id" { - t.Fatalf("begin form = %#v", beginForm) - } - result, err := client.Poll(context.Background(), registration) - if err != nil { - t.Fatal(err) - } - if err := client.SendText(context.Background(), result, "synthetic binding test"); err != nil { - t.Fatal(err) - } - if pollCount != 3 || len(requests) != 6 { - t.Fatalf("requests = %d, polls = %d", len(requests), pollCount) - } - if !strings.Contains(string(bodies[5]), `"receive_id":"ou_synthetic"`) || requests[5].URL.Query().Get("receive_id_type") != "open_id" { - t.Fatalf("message request = %s %s", requests[5].URL, bodies[5]) - } -} - -func TestNormalizeFeishuRegistrationLifetime(t *testing.T) { - limitSeconds := int(feishuRegistrationLifetimeLimit / time.Second) - maxInt := int(^uint(0) >> 1) - for _, test := range []struct { - name string - currentSeconds int - legacySeconds int - intervalSeconds int - want time.Duration - wantError bool - }{ - {name: "current", currentSeconds: 3600, intervalSeconds: 5, want: time.Hour}, - {name: "legacy", legacySeconds: 60, intervalSeconds: 5, want: time.Minute}, - {name: "matching", currentSeconds: 75, legacySeconds: 75, intervalSeconds: 5, want: 75 * time.Second}, - {name: "default", legacySeconds: -1, intervalSeconds: 5, want: 600 * time.Second}, - {name: "limit", currentSeconds: limitSeconds, intervalSeconds: 5, want: feishuRegistrationLifetimeLimit}, - {name: "over limit", currentSeconds: limitSeconds + 1, intervalSeconds: 5, wantError: true}, - {name: "conflict", currentSeconds: 60, legacySeconds: 61, intervalSeconds: 5, wantError: true}, - {name: "shorter than interval", currentSeconds: 4, intervalSeconds: 5, wantError: true}, - {name: "extreme integer", currentSeconds: maxInt, intervalSeconds: 5, wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - got, err := normalizeFeishuRegistrationLifetime(test.currentSeconds, test.legacySeconds, test.intervalSeconds) - if test.wantError { - if !errors.Is(err, ErrFeishuProviderResultInvalid) { - t.Fatalf("error = %v, want %v", err, ErrFeishuProviderResultInvalid) - } - return - } - if err != nil || got != test.want { - t.Fatalf("lifetime = %s, err = %v, want %s", got, err, test.want) - } - }) - } -} - -func TestFeishuClientRejectsInvalidBeginResponses(t *testing.T) { - oversizeDeviceCode, err := json.Marshal(map[string]any{ - "device_code": strings.Repeat("x", feishuDeviceCodeLimit+1), "verification_uri_complete": "https://open.feishu.cn/verify", - "interval": 1, "expires_in": 60, - }) - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - status int - response string - wantError error - }{ - {name: "conflicting expiry", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify","interval":1,"expires_in":60,"expire_in":61}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "empty device code", status: http.StatusOK, response: `{"device_code":"","verification_uri_complete":"https://open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "oversize device code", status: http.StatusOK, response: string(oversizeDeviceCode), wantError: ErrFeishuProviderResultInvalid}, - {name: "empty verification URL", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "non-https", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"http://open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "untrusted host", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://example.invalid/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "host suffix", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn.example.invalid/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "userinfo", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://user@open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "port", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn:443/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "empty port", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn:/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "fragment", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify#fragment","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "oversize verification URL", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify?value=` + strings.Repeat("x", 2048) + `","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "provider error", status: http.StatusBadRequest, response: `{"error":"invalid_request"}`, wantError: ErrFeishuProviderUnavailable}, - {name: "non-2xx without error", status: http.StatusInternalServerError, response: `{}`, wantError: ErrFeishuProviderResultInvalid}, - {name: "oversize response", status: http.StatusOK, response: strings.Repeat("x", providerResponseLimit+1), wantError: ErrFeishuProviderUnavailable}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client := NewFeishuClient() - client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: test.status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(test.response))}, nil - })} - if _, err := client.Begin(context.Background()); !errors.Is(err, test.wantError) { - t.Fatalf("Begin error = %v, want %v", err, test.wantError) - } - }) - } - - client := NewFeishuClient() - client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return nil, errors.New("synthetic network failure") - })} - if _, err := client.Begin(context.Background()); !errors.Is(err, ErrFeishuProviderUnavailable) { - t.Fatalf("network error = %v", err) - } -} - -func TestFeishuClientMapsRegistrationErrorsFromHTTP400(t *testing.T) { - now := time.Unix(1000, 0).UTC() - for _, test := range []struct { - name string - response string - wantError error - }{ - {name: "denied", response: `{"error":"access_denied"}`, wantError: ErrFeishuAuthorizationDenied}, - {name: "expired", response: `{"error":"expired_token"}`, wantError: ErrFeishuAuthorizationExpired}, - {name: "unknown", response: `{"error":"synthetic_unknown"}`, wantError: ErrFeishuProviderUnavailable}, - {name: "missing error", response: `{}`, wantError: ErrFeishuProviderUnavailable}, - } { - t.Run(test.name, func(t *testing.T) { - client := NewFeishuClient() - client.Now = func() time.Time { return now } - client.Wait = func(context.Context, time.Duration) error { return nil } - client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(test.response))}, nil - })} - _, err := client.Poll(context.Background(), FeishuRegistration{ - DeviceCode: "device_synthetic", ExpiresAt: now.Add(time.Minute), PollInterval: time.Second, - }) - if !errors.Is(err, test.wantError) { - t.Fatalf("Poll error = %v, want %v", err, test.wantError) - } - }) - } -} - -func TestFeishuClientDoesNotPollAfterRegistrationExpiry(t *testing.T) { - now := time.Unix(1000, 0).UTC() - requests := 0 - client := NewFeishuClient() - client.Now = func() time.Time { return now } - client.Wait = func(_ context.Context, duration time.Duration) error { - if duration != time.Second { - t.Fatalf("wait duration = %s", duration) - } - now = now.Add(duration) - return nil - } - client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - requests++ - return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":"authorization_pending"}`))}, nil - })} - _, err := client.Poll(context.Background(), FeishuRegistration{ - DeviceCode: "device_synthetic", ExpiresAt: now.Add(time.Second), PollInterval: 5 * time.Second, - }) - if !errors.Is(err, ErrFeishuAuthorizationExpired) || requests != 0 { - t.Fatalf("poll err = %v, requests = %d", err, requests) - } -} - -func TestFeishuClientRequiresExplicitProviderSuccessCodes(t *testing.T) { - credentials := FeishuRegistrationResult{AppID: "cli_synthetic", AppSecret: "secret_synthetic", OpenID: "ou_synthetic", TenantBrand: "feishu"} - for _, test := range []struct { - name string - tokenResponse string - tokenStatus int - messageBody string - messageStatus int - }{ - {name: "token missing code", tokenResponse: `{"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, - {name: "token nonzero code", tokenResponse: `{"code":1,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, - {name: "token non-2xx", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusBadRequest, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, - {name: "message missing code", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{}`, messageStatus: http.StatusOK}, - {name: "message nonzero code", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":1}`, messageStatus: http.StatusOK}, - {name: "message non-2xx", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusBadRequest}, - } { - t.Run(test.name, func(t *testing.T) { - client := NewFeishuClient() - client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { - body := test.messageBody - status := test.messageStatus - if request.URL.Path == feishuTenantTokenPath { - body = test.tokenResponse - status = test.tokenStatus - } - return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil - })} - if err := client.SendText(context.Background(), credentials, "synthetic message"); !errors.Is(err, ErrFeishuProviderUnavailable) { - t.Fatalf("SendText error = %v", err) - } - }) - } -} diff --git a/internal/application/notification/feishu.go b/internal/application/notification/feishu.go index f918031..239d03f 100644 --- a/internal/application/notification/feishu.go +++ b/internal/application/notification/feishu.go @@ -1,42 +1,13 @@ package notification import ( - "bytes" - "compress/gzip" "context" - "encoding/base64" - "encoding/json" "errors" - "fmt" - "io" - "net/http" - "net/url" "regexp" "strings" "time" ) -const ( - feishuAccountsOrigin = "https://accounts.feishu.cn" - feishuOpenAPIOrigin = "https://open.feishu.cn" - feishuRegistrationPath = "/oauth/v1/app/registration" - feishuTenantTokenPath = "/open-apis/auth/v3/tenant_access_token/internal" - feishuMessagePath = "/open-apis/im/v1/messages" - providerResponseLimit = 64 << 10 - feishuDeviceCodeLimit = 4096 - feishuRegistrationLifetimeLimit = 24 * time.Hour -) - -var providerCredentialPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,512}$`) - -var ( - ErrFeishuAuthorizationDenied = errors.New("feishu authorization denied") - ErrFeishuAuthorizationExpired = errors.New("feishu authorization expired") - ErrFeishuProviderUnavailable = errors.New("feishu provider unavailable") - ErrFeishuProviderResultInvalid = errors.New("feishu provider result invalid") - ErrFeishuLarkUnsupported = errors.New("lark tenant is unsupported") -) - type FeishuRegistration struct { DeviceCode string VerificationURL string @@ -48,15 +19,6 @@ type FeishuRegistrationResult struct { AppID, AppSecret, OpenID, TenantBrand string } -type feishuBeginResponse struct { - DeviceCode string `json:"device_code"` - VerificationURIComplete string `json:"verification_uri_complete"` - Interval int `json:"interval"` - ExpiresIn int `json:"expires_in"` - ExpireIn int `json:"expire_in"` - Error string `json:"error"` -} - type FeishuRegistrar interface { Begin(context.Context) (FeishuRegistration, error) Poll(context.Context, FeishuRegistration) (FeishuRegistrationResult, error) @@ -66,204 +28,7 @@ type FeishuMessenger interface { SendText(context.Context, FeishuRegistrationResult, string) error } -type FeishuClient struct { - Client *http.Client - Now func() time.Time - Wait func(context.Context, time.Duration) error -} - -func NewFeishuClient() *FeishuClient { - client := &http.Client{ - Timeout: 15 * time.Second, - CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("feishu redirects are not allowed") }, - } - return &FeishuClient{Client: client, Now: time.Now, Wait: waitForFeishuPoll} -} - -func waitForFeishuPoll(ctx context.Context, interval time.Duration) error { - timer := time.NewTimer(interval) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func (client *FeishuClient) Begin(ctx context.Context) (FeishuRegistration, error) { - form := url.Values{ - "action": {"begin"}, "archetype": {"PersonalAgent"}, - "auth_method": {"client_secret"}, "request_user_info": {"open_id"}, - } - var response feishuBeginResponse - status, err := client.registrationRequest(ctx, form, &response) - if err != nil { - return FeishuRegistration{}, err - } - if response.Error != "" { - return FeishuRegistration{}, ErrFeishuProviderUnavailable - } - if status < 200 || status >= 300 || response.DeviceCode == "" || len(response.DeviceCode) > feishuDeviceCodeLimit { - return FeishuRegistration{}, ErrFeishuProviderResultInvalid - } - interval := response.Interval - if interval <= 0 { - interval = 5 - } - if interval > 60 { - return FeishuRegistration{}, ErrFeishuProviderResultInvalid - } - lifetime, err := normalizeFeishuRegistrationLifetime(response.ExpiresIn, response.ExpireIn, interval) - if err != nil { - return FeishuRegistration{}, err - } - verificationURL, err := buildFeishuVerificationURL(response.VerificationURIComplete) - if err != nil { - return FeishuRegistration{}, err - } - return FeishuRegistration{ - DeviceCode: response.DeviceCode, VerificationURL: verificationURL, - ExpiresAt: client.Now().UTC().Add(lifetime), - PollInterval: time.Duration(interval) * time.Second, - }, nil -} - -func normalizeFeishuRegistrationLifetime(currentSeconds, legacySeconds, intervalSeconds int) (time.Duration, error) { - if currentSeconds > 0 && legacySeconds > 0 && currentSeconds != legacySeconds { - return 0, ErrFeishuProviderResultInvalid - } - seconds := currentSeconds - if seconds <= 0 { - seconds = legacySeconds - } - if seconds <= 0 { - seconds = 600 - } - if seconds > int(feishuRegistrationLifetimeLimit/time.Second) || seconds < intervalSeconds { - return 0, ErrFeishuProviderResultInvalid - } - return time.Duration(seconds) * time.Second, nil -} - -func buildFeishuVerificationURL(raw string) (string, error) { - parsed, err := url.Parse(raw) - if err != nil || len(raw) > 2048 { - return "", ErrFeishuProviderResultInvalid - } - if parsed.Scheme != "https" || (parsed.Host != "open.feishu.cn" && parsed.Host != "accounts.feishu.cn") || parsed.User != nil || parsed.Fragment != "" { - return "", ErrFeishuProviderResultInvalid - } - addons, err := encodeFeishuAddons() - if err != nil { - return "", err - } - query := parsed.Query() - query.Set("from", "sdk") - query.Set("tp", "sdk") - query.Set("source", "go-sdk/simplus") - query.Set("name", "Simplus 飞书通知") - query.Set("desc", "Simplus 单向通知应用") - query.Set("createOnly", "true") - query.Set("addons", addons) - parsed.RawQuery = query.Encode() - if len(parsed.String()) > 4096 { - return "", ErrFeishuProviderResultInvalid - } - return parsed.String(), nil -} - -func encodeFeishuAddons() (string, error) { - body, err := json.Marshal(map[string]any{ - "preset": false, - "scopes": map[string][]string{"tenant": {"im:message:send_as_bot"}}, - }) - if err != nil { - return "", err - } - var compressed bytes.Buffer - writer := gzip.NewWriter(&compressed) - if _, err := writer.Write(body); err != nil { - _ = writer.Close() - return "", err - } - if err := writer.Close(); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(compressed.Bytes()), nil -} - -func (client *FeishuClient) Poll(ctx context.Context, registration FeishuRegistration) (FeishuRegistrationResult, error) { - interval := registration.PollInterval - if interval <= 0 { - interval = 5 * time.Second - } - for { - remaining := registration.ExpiresAt.Sub(client.Now()) - if remaining <= 0 { - return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired - } - wait := interval - if remaining < wait { - wait = remaining - } - if err := client.Wait(ctx, wait); err != nil { - return FeishuRegistrationResult{}, err - } - if !client.Now().Before(registration.ExpiresAt) { - return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired - } - var response struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - UserInfo *struct { - OpenID string `json:"open_id"` - TenantBrand string `json:"tenant_brand"` - } `json:"user_info"` - Error string `json:"error"` - } - status, err := client.registrationRequest(ctx, url.Values{"action": {"poll"}, "device_code": {registration.DeviceCode}}, &response) - if err != nil { - return FeishuRegistrationResult{}, err - } - if response.Error != "" { - switch response.Error { - case "authorization_pending": - continue - case "slow_down": - interval += 5 * time.Second - if interval > 60*time.Second { - return FeishuRegistrationResult{}, ErrFeishuProviderResultInvalid - } - continue - case "access_denied": - return FeishuRegistrationResult{}, ErrFeishuAuthorizationDenied - case "expired_token": - return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired - default: - return FeishuRegistrationResult{}, ErrFeishuProviderUnavailable - } - } - if status < 200 || status >= 300 { - return FeishuRegistrationResult{}, ErrFeishuProviderUnavailable - } - if response.UserInfo != nil && strings.EqualFold(response.UserInfo.TenantBrand, "lark") { - return FeishuRegistrationResult{}, ErrFeishuLarkUnsupported - } - if response.ClientID != "" || response.ClientSecret != "" { - if response.UserInfo == nil { - return FeishuRegistrationResult{}, ErrFeishuProviderResultInvalid - } - result := FeishuRegistrationResult{AppID: response.ClientID, AppSecret: response.ClientSecret, OpenID: response.UserInfo.OpenID, TenantBrand: response.UserInfo.TenantBrand} - if err := validateFeishuResult(result); err != nil { - return FeishuRegistrationResult{}, err - } - return result, nil - } - } -} - -func validateFeishuResult(result FeishuRegistrationResult) error { +func ValidateFeishuResult(result FeishuRegistrationResult) error { if strings.EqualFold(result.TenantBrand, "lark") { return ErrFeishuLarkUnsupported } @@ -273,105 +38,23 @@ func validateFeishuResult(result FeishuRegistrationResult) error { return nil } -func (client *FeishuClient) registrationRequest(ctx context.Context, form url.Values, target any) (int, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuAccountsOrigin+feishuRegistrationPath, strings.NewReader(form.Encode())) - if err != nil { - return 0, ErrFeishuProviderUnavailable - } - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - status, body, err := client.readBoundedResponse(request) - if err != nil { - return 0, err - } - if err := decodeFeishuJSON(body, target); err != nil { - return status, err - } - return status, nil -} +var ( + ErrFeishuAuthorizationDenied = errors.New("feishu authorization denied") + ErrFeishuAuthorizationExpired = errors.New("feishu authorization expired") + ErrFeishuProviderUnavailable = errors.New("feishu provider unavailable") + ErrFeishuProviderResultInvalid = errors.New("feishu provider result invalid") + ErrFeishuLarkUnsupported = errors.New("lark tenant is unsupported") +) +var providerCredentialPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,512}$`) -func (client *FeishuClient) SendText(ctx context.Context, credentials FeishuRegistrationResult, message string) error { - if err := validateFeishuResult(credentials); err != nil || message == "" || len([]rune(message)) > 4000 { - if err != nil { - return err - } - return ErrChannelInvalid - } - tokenBody, err := json.Marshal(map[string]string{"app_id": credentials.AppID, "app_secret": credentials.AppSecret}) - if err != nil { - return ErrFeishuProviderUnavailable - } - tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuOpenAPIOrigin+feishuTenantTokenPath, bytes.NewReader(tokenBody)) - if err != nil { - return ErrFeishuProviderUnavailable - } - tokenRequest.Header.Set("Content-Type", "application/json") - var tokenResponse struct { - Code *int `json:"code"` - TenantAccessToken string `json:"tenant_access_token"` - } - if err := client.doJSON(tokenRequest, &tokenResponse); err != nil { - return err - } - if tokenResponse.Code == nil || *tokenResponse.Code != 0 || !providerCredentialPattern.MatchString(tokenResponse.TenantAccessToken) { - return ErrFeishuProviderUnavailable - } - content, _ := json.Marshal(map[string]string{"text": message}) - messageBody, err := json.Marshal(map[string]string{"receive_id": credentials.OpenID, "msg_type": "text", "content": string(content)}) - if err != nil { - return ErrFeishuProviderUnavailable - } - messageRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuOpenAPIOrigin+feishuMessagePath+"?receive_id_type=open_id", bytes.NewReader(messageBody)) - if err != nil { - return ErrFeishuProviderUnavailable - } - messageRequest.Header.Set("Authorization", "Bearer "+tokenResponse.TenantAccessToken) - messageRequest.Header.Set("Content-Type", "application/json") - var messageResponse struct { - Code *int `json:"code"` - } - if err := client.doJSON(messageRequest, &messageResponse); err != nil { - return err - } - if messageResponse.Code == nil || *messageResponse.Code != 0 { - return ErrFeishuProviderUnavailable - } - return nil -} +type DisabledFeishu struct{} -func (client *FeishuClient) doJSON(request *http.Request, target any) error { - status, body, err := client.readBoundedResponse(request) - if err != nil { - return err - } - if status < 200 || status >= 300 { - return ErrFeishuProviderUnavailable - } - return decodeFeishuJSON(body, target) +func (DisabledFeishu) Begin(context.Context) (FeishuRegistration, error) { + return FeishuRegistration{}, ErrBindingUnavailable } - -func (client *FeishuClient) readBoundedResponse(request *http.Request) (int, []byte, error) { - request.Header.Set("Accept", "application/json") - request.Header.Set("User-Agent", "Simplus") - response, err := client.Client.Do(request) - if err != nil { - return 0, nil, fmt.Errorf("%w", ErrFeishuProviderUnavailable) - } - defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, providerResponseLimit+1)) - if err != nil || len(body) > providerResponseLimit || len(bytes.TrimSpace(body)) == 0 { - return 0, nil, ErrFeishuProviderUnavailable - } - return response.StatusCode, body, nil +func (DisabledFeishu) Poll(context.Context, FeishuRegistration) (FeishuRegistrationResult, error) { + return FeishuRegistrationResult{}, ErrBindingUnavailable } - -func decodeFeishuJSON(body []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(body)) - if err := decoder.Decode(target); err != nil { - return ErrFeishuProviderResultInvalid - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return ErrFeishuProviderResultInvalid - } - return nil +func (DisabledFeishu) SendText(context.Context, FeishuRegistrationResult, string) error { + return ErrBindingUnavailable } diff --git a/internal/application/notification/integration_test.go b/internal/application/notification/integration_test.go index 324e969..29150bd 100644 --- a/internal/application/notification/integration_test.go +++ b/internal/application/notification/integration_test.go @@ -40,7 +40,7 @@ func TestChannelCredentialsAreEncryptedInSQLiteAndOmittedFromViews(t *testing.T) if err != nil { t.Fatal(err) } - service, err := app.New(app.Dependencies{Store: stores, Secrets: keyring, Webhooks: notificationwebhook.NewClient()}) + service, err := app.New(app.Dependencies{Store: stores, Secrets: keyring, Webhooks: notificationwebhook.NewClient(), ProcessContext: ctx, Registrar: integrationRegistrar{}, Messenger: integrationMessenger{}}) if err != nil { t.Fatal(err) } @@ -76,11 +76,10 @@ func TestFeishuAppCredentialsAreIndependentlyEncryptedAndOmittedFromViews(t *tes if err != nil { t.Fatal(err) } - service, err := app.New(app.Dependencies{Store: stores, Secrets: keyring, Webhooks: notificationwebhook.NewClient()}) + service, err := app.New(app.Dependencies{Store: stores, Secrets: keyring, Webhooks: notificationwebhook.NewClient(), ProcessContext: ctx, Registrar: integrationRegistrar{}, Messenger: integrationMessenger{}}) if err != nil { t.Fatal(err) } - service.ConfigureFeishuBinding(ctx, integrationRegistrar{}, integrationMessenger{}, nil) if _, err := service.StartFeishuBinding(ctx); err != nil { t.Fatal(err) } diff --git a/internal/application/notification/service.go b/internal/application/notification/service.go index f5b5a84..1d1dc3e 100644 --- a/internal/application/notification/service.go +++ b/internal/application/notification/service.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "errors" "fmt" - "reflect" "regexp" "sort" "strings" @@ -20,7 +19,7 @@ var ErrChannelInvalid = errors.New("notification channel request is invalid") var ErrChannelNotFound = errors.New("notification channel not found") var ErrDependenciesInvalid = errors.New("notification dependencies are invalid") var ErrWebhookResultInvalid = errors.New("notification webhook delivery result is invalid") -var allowedEvents = map[string]struct{}{"sms.received": {}, "sms.failed": {}, "call.incoming": {}, "call.missed": {}, "system.degraded": {}} +var allowedEvents = map[string]struct{}{"vowifi.connected": {}, "vowifi.disconnected": {}, "cellular.connected": {}, "cellular.disconnected": {}, "sms.received": {}, "sms.failed": {}, "call.incoming": {}, "call.missed": {}, "system.degraded": {}} const ( WebhookURLByteLimit = 4096 @@ -67,6 +66,7 @@ type WebhookPort interface { } type Store interface { + DeliveryQueue ListNotificationChannels(context.Context) ([]domain.Channel, error) ReadNotificationChannel(context.Context, string) (domain.Channel, bool, error) UpsertNotificationChannel(context.Context, domain.Channel) error @@ -78,11 +78,16 @@ type SecretCipher interface { Decrypt(string, []byte) ([]byte, error) } type Dependencies struct { - Store Store - Secrets SecretCipher - Webhooks WebhookPort + ProcessContext context.Context + Registrar FeishuRegistrar + Messenger FeishuMessenger + OnChange func() + Store Store + Secrets SecretCipher + Webhooks WebhookPort } type ChannelView struct { + PendingCount, FailedCount int64 ID, Provider, DisplayName, DeliveryMode, TargetType string WebhookHint, LastDeliveryStatus, LastErrorCode string Enabled, SigningSecretConfigured bool @@ -100,33 +105,29 @@ type Service struct { } func New(dependencies Dependencies) (*Service, error) { - if notificationDependencyMissing(dependencies.Store) { + if dependencies.Store == nil { return nil, fmt.Errorf("%w: store is required", ErrDependenciesInvalid) } - if notificationDependencyMissing(dependencies.Secrets) { + if dependencies.Secrets == nil { return nil, fmt.Errorf("%w: secret cipher is required", ErrDependenciesInvalid) } - if notificationDependencyMissing(dependencies.Webhooks) { + if dependencies.Webhooks == nil { return nil, fmt.Errorf("%w: webhook port is required", ErrDependenciesInvalid) } - return &Service{ + service := &Service{ Store: dependencies.Store, Secrets: dependencies.Secrets, Webhooks: dependencies.Webhooks, Now: time.Now, binding: newBindingController(), - }, nil -} - -func notificationDependencyMissing(dependency any) bool { - if dependency == nil { - return true - } - value := reflect.ValueOf(dependency) - switch value.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return value.IsNil() - default: - return false } + if dependencies.Registrar == nil && dependencies.Messenger == nil { + dependencies.Registrar, dependencies.Messenger = DisabledFeishu{}, DisabledFeishu{} + } + if dependencies.Registrar == nil || dependencies.Messenger == nil { + return nil, ErrDependenciesInvalid + } + service.configureFeishuBinding(dependencies.ProcessContext, dependencies.Registrar, dependencies.Messenger, dependencies.OnChange) + return service, nil } + func (s *Service) List(ctx context.Context) ([]ChannelView, error) { items, err := s.Store.ListNotificationChannels(ctx) if err != nil { @@ -134,7 +135,13 @@ func (s *Service) List(ctx context.Context) ([]ChannelView, error) { } result := make([]ChannelView, 0, len(items)) for _, item := range items { - result = append(result, view(item)) + v := view(item) + counts, countErr := s.Store.NotificationDeliveryCounts(ctx, item.ID) + if countErr != nil { + return nil, countErr + } + v.PendingCount, v.FailedCount = counts.Pending, counts.Failed + result = append(result, v) } return result, nil } @@ -240,48 +247,22 @@ func (s *Service) Test(ctx context.Context, id string) (ChannelView, error) { return s.deliverOne(ctx, id, "Simplus 通知渠道测试成功") } func (s *Service) Notify(ctx context.Context, event, message string) error { - return s.notify(ctx, event, message, nil) -} - -func (s *Service) NotifyReceivedSMS(ctx context.Context, sender, body string) error { - if sender == "" || body == "" { - return ErrChannelInvalid - } - message := fmt.Sprintf("[Simplus] 新短信\n发件人:%s\n内容:\n%s", sender, body) - return s.notify(ctx, "sms.received", message, func(item domain.Channel) bool { - return item.Provider == string(WebhookProviderFeishu) - }) -} - -func (s *Service) NotifyReceivedSMSSummary(ctx context.Context, count int) error { - if count <= 0 { - return ErrChannelInvalid - } - message := fmt.Sprintf("[Simplus] 收到 %d 条新短信", count) - return s.notify(ctx, "sms.received", message, func(item domain.Channel) bool { - return item.Provider != string(WebhookProviderFeishu) - }) -} - -func (s *Service) notify(ctx context.Context, event, message string, include func(domain.Channel) bool) error { if _, ok := allowedEvents[event]; !ok || message == "" || len([]rune(message)) > WebhookMessageRuneLimit { return ErrChannelInvalid } - items, err := s.Store.ListNotificationChannels(ctx) + id, err := newChannelID() if err != nil { return err } - var failures []error - for _, item := range items { - if !item.Enabled || !contains(item.EventKinds, event) || include != nil && !include(item) { - continue - } - if _, err := s.deliver(ctx, item, message); err != nil { - failures = append(failures, fmt.Errorf("channel %s: %w", item.ID, err)) - } + return s.Enqueue(ctx, domain.Event{Key: id, Kind: event, ObjectID: "system", Message: message, ObservedAt: s.Now().UTC()}) +} +func (s *Service) Enqueue(ctx context.Context, event domain.Event) error { + if _, ok := allowedEvents[event.Kind]; !ok || event.Key == "" || event.ObjectID == "" || event.Message == "" || len([]rune(event.Message)) > WebhookMessageRuneLimit || event.ObservedAt.IsZero() { + return ErrChannelInvalid } - return errors.Join(failures...) + return s.Store.EnqueueNotification(ctx, event) } + func (s *Service) deliverOne(ctx context.Context, id, message string) (ChannelView, error) { item, found, err := s.Store.ReadNotificationChannel(ctx, id) if err != nil { @@ -343,7 +324,7 @@ func (s *Service) deliverWebhook(ctx context.Context, item domain.Channel, messa return view(item), ErrWebhookResultInvalid } _ = s.Store.RecordNotificationDelivery(ctx, item.ID, "failed", "DELIVERY_REJECTED", now) - return view(item), err + return view(item), errors.Join(ErrDeliveryPermanent, err) case "": if err != nil { return view(item), err diff --git a/internal/application/notification/service_test.go b/internal/application/notification/service_test.go index 01437a9..22c6dfa 100644 --- a/internal/application/notification/service_test.go +++ b/internal/application/notification/service_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - "reflect" "strings" "testing" "time" @@ -18,6 +17,8 @@ type deliveryRecord struct { } type memoryStore struct { + DeliveryQueue + queued []domain.Event channels map[string]domain.Channel records []deliveryRecord recordErr error @@ -107,10 +108,7 @@ func newTestService(t *testing.T, store Store) *Service { return service } -func TestNewRejectsMissingAndTypedNilDependencies(t *testing.T) { - var typedNilStore *memoryStore - var typedNilCipher *testCipher - var typedNilWebhooks *webhookPortFake +func TestNewRejectsMissingDependencies(t *testing.T) { validStore := &memoryStore{} validCipher := testCipher{} validWebhooks := &webhookPortFake{} @@ -119,11 +117,8 @@ func TestNewRejectsMissingAndTypedNilDependencies(t *testing.T) { deps Dependencies }{ {name: "missing store", deps: Dependencies{Secrets: validCipher, Webhooks: validWebhooks}}, - {name: "typed nil store", deps: Dependencies{Store: typedNilStore, Secrets: validCipher, Webhooks: validWebhooks}}, {name: "missing cipher", deps: Dependencies{Store: validStore, Webhooks: validWebhooks}}, - {name: "typed nil cipher", deps: Dependencies{Store: validStore, Secrets: typedNilCipher, Webhooks: validWebhooks}}, {name: "missing webhooks", deps: Dependencies{Store: validStore, Secrets: validCipher}}, - {name: "typed nil webhooks", deps: Dependencies{Store: validStore, Secrets: validCipher, Webhooks: typedNilWebhooks}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -332,136 +327,6 @@ func TestWebhookDeliveryPersistenceFailurePolicy(t *testing.T) { } } -func TestNotifyFiltersEventsAndBoundsMessages(t *testing.T) { - store := &memoryStore{channels: map[string]domain.Channel{}} - item := webhookChannel(t, store, "wecom") - item.Enabled = true - item.EventKinds = []string{"sms.received"} - store.channels[item.ID] = item - webhooks := &webhookPortFake{result: WebhookDeliveryResult{Outcome: WebhookDelivered}} - service, err := New(Dependencies{Store: store, Secrets: testCipher{}, Webhooks: webhooks}) - if err != nil { - t.Fatal(err) - } - if err := service.Notify(context.Background(), "call.incoming", "ignored"); err != nil { - t.Fatal(err) - } - if len(webhooks.deliverCalls) != 0 { - t.Fatalf("filtered event delivered: %#v", webhooks.deliverCalls) - } - if err := service.Notify(context.Background(), "sms.received", "received"); err != nil { - t.Fatal(err) - } - if len(webhooks.deliverCalls) != 1 { - t.Fatalf("delivery calls = %d", len(webhooks.deliverCalls)) - } - if err := service.Notify(context.Background(), "sms.received", strings.Repeat("界", WebhookMessageRuneLimit+1)); !errors.Is(err, ErrChannelInvalid) { - t.Fatalf("oversize message error = %v", err) - } -} - -func TestNotifyReceivedSMSDeliversExactContentOnlyToFeishuModes(t *testing.T) { - const ( - sender = "Service" - body = "第一行\nsecond line 🙂" - want = "[Simplus] 新短信\n发件人:Service\n内容:\n第一行\nsecond line 🙂" - ) - t.Run("webhook", func(t *testing.T) { - store := &memoryStore{channels: map[string]domain.Channel{}} - feishu := webhookChannelWithID(t, store, "channel_AAAAAAAAAAAAAAAAAAAAAA", "feishu") - feishu.Enabled = true - store.channels[feishu.ID] = feishu - wecom := webhookChannelWithID(t, store, "channel_BBBBBBBBBBBBBBBBBBBBBB", "wecom") - wecom.Enabled = true - store.channels[wecom.ID] = wecom - webhooks := &webhookPortFake{result: WebhookDeliveryResult{Outcome: WebhookDelivered}} - service, err := New(Dependencies{Store: store, Secrets: testCipher{}, Webhooks: webhooks}) - if err != nil { - t.Fatal(err) - } - - if err := service.NotifyReceivedSMS(context.Background(), sender, body); err != nil { - t.Fatal(err) - } - if len(webhooks.deliverCalls) != 1 || webhooks.deliverCalls[0].Provider != WebhookProviderFeishu || webhooks.deliverCalls[0].Message != want { - t.Fatalf("delivery calls = %#v", webhooks.deliverCalls) - } - }) - - t.Run("private app", func(t *testing.T) { - store := &memoryStore{channels: map[string]domain.Channel{}} - item := feishuAppChannel(t, store, "channel_CCCCCCCCCCCCCCCCCCCCCC") - var messages []string - service, err := New(Dependencies{Store: store, Secrets: testCipher{}, Webhooks: &webhookPortFake{}}) - if err != nil { - t.Fatal(err) - } - service.FeishuMessenger = messengerFunc(func(_ context.Context, _ FeishuRegistrationResult, message string) error { - messages = append(messages, message) - return nil - }) - - if err := service.NotifyReceivedSMS(context.Background(), sender, body); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(messages, []string{want}) || store.channels[item.ID].LastDeliveryStatus != "success" { - t.Fatalf("messages = %#v, channel = %#v", messages, store.channels[item.ID]) - } - }) -} - -func TestNotifyReceivedSMSSummaryPreservesNonFeishuBehavior(t *testing.T) { - store := &memoryStore{channels: map[string]domain.Channel{}} - feishu := webhookChannelWithID(t, store, "channel_AAAAAAAAAAAAAAAAAAAAAA", "feishu") - feishu.Enabled = true - store.channels[feishu.ID] = feishu - wecom := webhookChannelWithID(t, store, "channel_BBBBBBBBBBBBBBBBBBBBBB", "wecom") - wecom.Enabled = true - store.channels[wecom.ID] = wecom - webhooks := &webhookPortFake{result: WebhookDeliveryResult{Outcome: WebhookDelivered}} - service, err := New(Dependencies{Store: store, Secrets: testCipher{}, Webhooks: webhooks}) - if err != nil { - t.Fatal(err) - } - - if err := service.NotifyReceivedSMSSummary(context.Background(), 2); err != nil { - t.Fatal(err) - } - if len(webhooks.deliverCalls) != 1 || webhooks.deliverCalls[0].Provider != WebhookProviderWeCom || webhooks.deliverCalls[0].Message != "[Simplus] 收到 2 条新短信" { - t.Fatalf("delivery calls = %#v", webhooks.deliverCalls) - } -} - -func TestNotifyReceivedSMSAttemptsKeepLatestStatusAndSafeErrors(t *testing.T) { - const privateBody = "private-body-marker" - store := &memoryStore{channels: map[string]domain.Channel{}} - item := webhookChannel(t, store, "feishu") - item.Enabled = true - store.channels[item.ID] = item - deliveryError := errors.New("synthetic bounded delivery failure") - webhooks := &webhookPortFake{ - result: WebhookDeliveryResult{Outcome: WebhookNetworkFailed}, - deliverErr: deliveryError, - } - service, err := New(Dependencies{Store: store, Secrets: testCipher{}, Webhooks: webhooks}) - if err != nil { - t.Fatal(err) - } - - err = service.NotifyReceivedSMS(context.Background(), "10086", privateBody) - if !errors.Is(err, deliveryError) || strings.Contains(err.Error(), privateBody) || store.channels[item.ID].LastDeliveryStatus != "failed" { - t.Fatalf("first error = %v, channel = %#v", err, store.channels[item.ID]) - } - webhooks.result = WebhookDeliveryResult{Outcome: WebhookDelivered} - webhooks.deliverErr = nil - if err := service.NotifyReceivedSMS(context.Background(), "10086", "later success"); err != nil { - t.Fatal(err) - } - if store.channels[item.ID].LastDeliveryStatus != "success" || store.channels[item.ID].LastErrorCode != "" { - t.Fatalf("latest channel status = %#v", store.channels[item.ID]) - } -} - func webhookChannel(t *testing.T, store *memoryStore, provider string) domain.Channel { return webhookChannelWithID(t, store, "channel_AAAAAAAAAAAAAAAAAAAAAA", provider) } @@ -513,3 +378,24 @@ func feishuAppChannel(t *testing.T, store *memoryStore, id string) domain.Channe store.channels[id] = item return item } + +func (s *memoryStore) NotificationDeliveryCounts(context.Context, string) (domain.Counts, error) { + return domain.Counts{}, nil +} +func (s *memoryStore) EnqueueNotification(_ context.Context, event domain.Event) error { + s.queued = append(s.queued, event) + return nil +} +func TestNotifyOnlyEnqueuesAndBoundsMessages(t *testing.T) { + store := &memoryStore{} + service := newTestService(t, store) + if err := service.Notify(t.Context(), "cellular.connected", "synthetic"); err != nil { + t.Fatal(err) + } + if len(store.queued) != 1 || store.queued[0].Kind != "cellular.connected" || len(store.records) != 0 { + t.Fatal("event was not enqueued independently") + } + if err := service.Notify(t.Context(), "sms.received", strings.Repeat("界", WebhookMessageRuneLimit+1)); !errors.Is(err, ErrChannelInvalid) { + t.Fatal(err) + } +} diff --git a/internal/application/notification/worker.go b/internal/application/notification/worker.go new file mode 100644 index 0000000..eec0472 --- /dev/null +++ b/internal/application/notification/worker.go @@ -0,0 +1,119 @@ +package notification + +import ( + "context" + "errors" + "sync" + "time" + + domain "github.com/leonfox28/simplus/internal/domain/notification" +) + +type DeliveryQueue interface { + EnqueueNotification(context.Context, domain.Event) error + ClaimNotificationDeliveries(context.Context, time.Time, int) ([]domain.Delivery, error) + FinishNotificationDelivery(context.Context, domain.Delivery, string, string, time.Time) error + ResetInterruptedNotifications(context.Context, time.Time) error + NotificationDeliveryCounts(context.Context, string) (domain.Counts, error) +} + +var ErrDeliveryPermanent = errors.New("notification permanently rejected") + +func RetryDelay(attempt int64) time.Duration { + delay := 15 * time.Second + for attempt > 1 && delay < 5*time.Minute { + delay *= 2 + attempt-- + } + if delay > 5*time.Minute { + return 5 * time.Minute + } + return delay +} + +// Run owns a bounded worker pool. Only the first pending event in each +// object/kind/channel stream is claimable; slow streams do not block others. +func (s *Service) Run(ctx context.Context, onChange func(), report func(error)) { + for { + if err := s.Store.ResetInterruptedNotifications(ctx, s.Now()); err == nil { + break + } else if report != nil { + report(err) + } + timer := time.NewTimer(RetryDelay(1)) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + var workers sync.WaitGroup + defer workers.Wait() + complete := make(chan error, 20) + active := 0 + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + dispatch := func() { + if active >= 20 || ctx.Err() != nil { + return + } + items, err := s.Store.ClaimNotificationDeliveries(ctx, s.Now(), 20-active) + if err != nil { + if report != nil { + report(err) + } + return + } + for _, item := range items { + active++ + workers.Go(func() { complete <- s.deliverPending(ctx, item) }) + } + } + dispatch() + for { + select { + case <-ctx.Done(): + return + case err := <-complete: + active-- + if err != nil && report != nil { + report(err) + } + if onChange != nil { + onChange() + } + dispatch() + case <-ticker.C: + dispatch() + } + } +} + +func (s *Service) deliverPending(ctx context.Context, item domain.Delivery) error { + deliveryCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + channel, found, err := s.Store.ReadNotificationChannel(deliveryCtx, item.ChannelID) + state, code := "delivered", "" + if err == nil && (!found || !channel.Enabled || !contains(channel.EventKinds, item.EventKind)) { + state, code = "cancelled", "SUBSCRIPTION_CANCELLED" + } else { + if err == nil { + _, err = s.deliver(deliveryCtx, channel, item.Message) + } + if err != nil { + state, code = "pending", "DELIVERY_TEMPORARY_FAILED" + if errors.Is(err, ErrDeliveryPermanent) { + state, code = "failed", "DELIVERY_REJECTED" + } + } + } + // A bounded final write outlives cancellation, but Run waits for it before + // the database is closed. A lost provider response is deliberately retried. + finalize, done := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer done() + if finishErr := s.Store.FinishNotificationDelivery(finalize, item, state, code, s.Now().Add(RetryDelay(item.Attempts))); finishErr != nil { + return finishErr + } + return nil +} diff --git a/internal/application/notification/worker_integration_test.go b/internal/application/notification/worker_integration_test.go new file mode 100644 index 0000000..4c55546 --- /dev/null +++ b/internal/application/notification/worker_integration_test.go @@ -0,0 +1,132 @@ +package notification_test + +import ( + "context" + "errors" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + app "github.com/leonfox28/simplus/internal/application/notification" + domain "github.com/leonfox28/simplus/internal/domain/notification" + "github.com/leonfox28/simplus/internal/notificationwebhook" + "github.com/leonfox28/simplus/internal/security/secretbox" + "github.com/leonfox28/simplus/internal/storage/sqlite" +) + +type workerWebhooks struct { + *notificationwebhook.Client + mu sync.Mutex + calls map[string]int +} + +func (w *workerWebhooks) Deliver(ctx context.Context, r app.WebhookDeliveryRequest) (app.WebhookDeliveryResult, error) { + w.mu.Lock() + w.calls[r.Message]++ + attempt := w.calls[r.Message] + w.mu.Unlock() + if r.Message == "slow" { + <-ctx.Done() + return app.WebhookDeliveryResult{Outcome: app.WebhookNetworkFailed}, ctx.Err() + } + if r.Message == "lost reply" && attempt == 1 { + return app.WebhookDeliveryResult{Outcome: app.WebhookNetworkFailed}, errors.New("reply lost after delivery") + } + if r.Message == "rejected" { + return app.WebhookDeliveryResult{Outcome: app.WebhookRejected}, errors.New("permanent rejection") + } + return app.WebhookDeliveryResult{Outcome: app.WebhookDelivered}, nil +} +func TestWorkerIndependentStreamsRetryOrderPermanentFailureAndShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + root := t.TempDir() + store, err := sqlite.OpenSet(ctx, filepath.Join(root, "state")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + keys, err := secretbox.Open(filepath.Join(root, "key")) + if err != nil { + t.Fatal(err) + } + webhooks := &workerWebhooks{Client: notificationwebhook.NewClient(), calls: map[string]int{}} + s, err := app.New(app.Dependencies{Store: store, Secrets: keys, Webhooks: webhooks}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + s.Now = func() time.Time { return time.UnixMilli(clock.Load()) } + channel, err := s.Create(ctx, "wecom", "fixture", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=synthetic", "", true, []string{"vowifi.connected"}) + if err != nil { + t.Fatal(err) + } + for _, item := range []struct{ message, object string }{{"slow", "slow"}, {"lost reply", "retry"}, {"after retry", "retry"}, {"rejected", "rejected"}, {"fast", "fast"}} { + if err := store.EnqueueNotification(ctx, domain.Event{Key: item.message, Kind: "vowifi.connected", ObjectID: item.object, ConnectionKind: "vowifi", Message: item.message, ObservedAt: s.Now()}); err != nil { + t.Fatal(err) + } + } + done := make(chan struct{}) + go func() { defer close(done); s.Run(ctx, nil, nil) }() + waitState := func(message, state string) { + t.Helper() + deadline := time.After(4 * time.Second) + for { + var got string + err := store.DB.QueryRow(`SELECT state FROM notification_deliveries WHERE event_key=?`, message).Scan(&got) + if err == nil && got == state { + return + } + select { + case <-deadline: + t.Fatalf("%s state=%s err=%v want=%s", message, got, err, state) + case <-time.After(5 * time.Millisecond): + } + } + } + waitState("fast", "delivered") + waitState("rejected", "failed") + var attempt, next int64 + for deadline := time.Now().Add(4 * time.Second); ; { + err = store.DB.QueryRow(`SELECT attempts,next_at FROM notification_deliveries WHERE event_key='lost reply' AND state='pending'`).Scan(&attempt, &next) + if err == nil && attempt == 1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("retry not persisted: %v", err) + } + time.Sleep(5 * time.Millisecond) + } + if next-clock.Load() != 15000 { + t.Fatalf("first retry delay=%d", next-clock.Load()) + } + webhooks.mu.Lock() + tail := webhooks.calls["after retry"] + webhooks.mu.Unlock() + if tail != 0 { + t.Fatal("later event overtook failed event") + } + clock.Add(16000) + waitState("after retry", "delivered") + webhooks.mu.Lock() + repeated := webhooks.calls["lost reply"] + webhooks.mu.Unlock() + if repeated != 2 { + t.Fatalf("lost response attempts=%d", repeated) + } + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("worker did not drain") + } + waitState("slow", "pending") + counts, err := store.NotificationDeliveryCounts(t.Context(), channel.ID) + if err != nil || counts.Pending != 1 || counts.Failed != 1 { + t.Fatalf("final counts=%+v %v", counts, err) + } +} diff --git a/internal/application/setup/service.go b/internal/application/setup/service.go index 7d05d38..d63658a 100644 --- a/internal/application/setup/service.go +++ b/internal/application/setup/service.go @@ -2,463 +2,62 @@ package setup import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" "errors" "fmt" - "io" - "math" - "net" - "net/url" - "path/filepath" "regexp" - "sort" "strings" "sync" "time" "unicode" "unicode/utf8" - - "github.com/leonfox28/simplus/internal/domain/managementtls" ) const ( InstallationUninitialized = "uninitialized" InstallationReady = "ready" InstallationMaintenance = "maintenance" - - PhaseBootstrapRequired = "bootstrap-required" - PhaseComplete = "complete" - PhaseMaintenance = "maintenance" - - FlowCreateNew = "create-new" - - bootstrapTokenBytes = 32 - sessionTokenBytes = 32 ) -var ( - ErrSetupUnavailable = errors.New("setup is not available in the current installation state") - ErrBootstrapInvalidOrExpired = errors.New("bootstrap code is invalid, expired, or already consumed") - ErrSetupSessionUnauthorized = errors.New("setup session is missing, invalid, or expired") - ErrAdministratorRequestInvalid = errors.New("initial administrator request is invalid") - ErrStorageRequestInvalid = errors.New("setup storage request is invalid") - ErrSetupPrerequisiteMissing = errors.New("a required earlier setup step is incomplete") - ErrHTTPSRequestInvalid = errors.New("setup HTTPS request is invalid") - ErrHTTPSConfirmationInvalid = errors.New("setup HTTPS confirmation is invalid") - ErrHardwareReviewInvalid = errors.New("setup hardware review is invalid or incomplete") - ErrSetupPreflightFailed = errors.New("final setup preflight failed") - ErrDependenciesInvalid = errors.New("setup dependencies are invalid") -) +var ErrSetupUnavailable = errors.New("installation is not available") +var ErrAdministratorRequestInvalid = errors.New("initial administrator request is invalid") +var administratorUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,31}$`) type StateStore interface { InstallationState(context.Context) (string, error) } - -type AuthorizationStore interface { - ReplaceSetupBootstrap(context.Context, [32]byte, int64, int64) error - ConsumeSetupBootstrap(context.Context, [32]byte, [32]byte, int64, int64) (bool, error) - ReadSetupSession(context.Context, [32]byte, int64) (int64, string, bool, error) - RevokeSetupAuthorization(context.Context) error -} - type AdministratorStore interface { ConfigureInitialAdministrator(context.Context, string, string, string, time.Time) error ReadInitialAdministrator(context.Context) (string, string, bool, error) } - -type PasswordHasher interface { - Hash(string) (string, error) -} - -type StorageStore interface { - SetupDataRoot() string - ConfigureSetupStorage(context.Context, string, string, uint64, uint64, uint64, uint64, time.Time) error - ReadSetupStorage(context.Context) (string, string, uint64, uint64, uint64, uint64, bool, error) -} - -type DirectoryIdentity struct { - Path string - Device uint64 - Inode uint64 -} - -type DirectoryPreparer func(string) (DirectoryIdentity, error) - -type ManagementTLSStore interface { - ConfigureManagementTLS(context.Context, managementtls.Configuration) error - ReadManagementTLS(context.Context) (managementtls.Configuration, bool, error) - ConfirmManagementTLS(context.Context, string, time.Time) (bool, error) -} - -type SecretProtector interface { - Encrypt(string, []byte) ([]byte, error) -} - -type SecretProtectorOpener func() (SecretProtector, error) - -type LocalCABundle struct { - CACertificatePEM []byte - CAPrivateKeyPEM []byte - LeafCertificatePEM []byte - LeafPrivateKeyPEM []byte - RootFingerprint string - LeafNotAfter time.Time - SANs []string -} - -type LocalCAGenerator func(time.Time, []string) (LocalCABundle, error) - -type HardwareReviewStore interface { - ConfirmSetupHardware(context.Context, string, int, int, time.Time) error - ReadSetupHardwareReview(context.Context) (string, int, int, bool, error) -} - -type CompletionStore interface { - CompleteInitialSetup(context.Context, string, time.Time) (bool, error) -} - -type Dependencies struct { - StateStore StateStore - AuthorizationStore AuthorizationStore - AdministratorStore AdministratorStore - PasswordHasher PasswordHasher - StorageStore StorageStore - DirectoryPreparer DirectoryPreparer - ManagementTLSStore ManagementTLSStore - SecretProtectorOpener SecretProtectorOpener - LocalCAGenerator LocalCAGenerator - HardwareReviewStore HardwareReviewStore - CompletionStore CompletionStore - Random io.Reader - Now func() time.Time -} - -var ( - administratorUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,31}$`) - hardwareIdentifierPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) - hardwareDigestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) -) - +type PasswordHasher interface{ Hash(string) (string, error) } +type AdministratorInput struct{ Username, Password, PasswordConfirmation, InstanceDefaultLocale string } type Status struct { - InstallationState string - Phase string - SetupRequired bool - BusinessAPIAvailable bool - BootstrapGenerationAvailable bool - SupportedFlows []string -} - -type BootstrapGrant struct { - Code string - ExpiresAt time.Time + InstallationState string + BusinessAPIAvailable, SetupRequired bool } - -type Session struct { - ExpiresAt time.Time - SelectedFlow string - AdministratorConfigured bool - AdministratorUsername string - InstanceDefaultLocale string - StorageConfigured bool - DataRoot string - RecordingsRoot string - HTTPSConfigured bool - HTTPSConfirmed bool - HTTPSMode string - HTTPSListenURL string - HTTPSRootFingerprint string - HTTPSLeafNotAfter time.Time - HardwareReviewed bool - HardwareDeviceCount int - HardwareLineCount int - HardwareInventoryDigest string -} - -type AdministratorInput struct { - Username string - Password string - PasswordConfirmation string - InstanceDefaultLocale string -} - -type StorageInput struct { - RecordingsRoot string -} - -type HTTPSInput struct { - Mode string - ListenHost string - ListenPort int - SubjectAlternativeNames []string -} - -type HardwareLine struct { - ID string - PhysicalDeviceID string - SubscriptionProfileID string -} - -type HardwareDevice struct { - ID string - Transport string - State string - ModemFunctionCount int - SIMSlotCount int - ResourceGroupCount int -} - -type HardwareReviewInput struct { - TopologyDigest string - Devices []HardwareDevice - Lines []HardwareLine -} - -type Completion struct { - InstallationState string - ManagementURL string - LoginRequired bool -} - -type SessionGrant struct { - Token string - Session +type Dependencies struct { + StateStore StateStore + AdministratorStore AdministratorStore + PasswordHasher PasswordHasher } - type Service struct { - stateStore StateStore - authorization AuthorizationStore - administrator AdministratorStore - passwordHasher PasswordHasher - storage StorageStore - prepareDirectory DirectoryPreparer - managementTLS ManagementTLSStore - openSecretProtector SecretProtectorOpener - generateLocalCA LocalCAGenerator - hardwareReview HardwareReviewStore - completionStore CompletionStore - random io.Reader - now func() time.Time - bootstrapTimeout time.Duration - sessionTimeout time.Duration - mutationMu sync.Mutex + stateStore StateStore + administrator AdministratorStore + passwordHasher PasswordHasher + now func() time.Time + mutationMu sync.Mutex } -func New(dependencies Dependencies) (*Service, error) { - if dependencies.StateStore == nil { - return nil, fmt.Errorf("%w: StateStore is required", ErrDependenciesInvalid) - } - if (dependencies.AdministratorStore == nil) != (dependencies.PasswordHasher == nil) { - return nil, fmt.Errorf("%w: AdministratorStore and PasswordHasher must be configured together", ErrDependenciesInvalid) - } - if (dependencies.StorageStore == nil) != (dependencies.DirectoryPreparer == nil) { - return nil, fmt.Errorf("%w: StorageStore and DirectoryPreparer must be configured together", ErrDependenciesInvalid) - } - if (dependencies.SecretProtectorOpener == nil) != (dependencies.LocalCAGenerator == nil) { - return nil, fmt.Errorf("%w: SecretProtectorOpener and LocalCAGenerator must be configured together", ErrDependenciesInvalid) - } - if dependencies.ManagementTLSStore == nil && dependencies.SecretProtectorOpener != nil { - return nil, fmt.Errorf("%w: Local CA dependencies require ManagementTLSStore", ErrDependenciesInvalid) - } - random := dependencies.Random - if random == nil { - random = rand.Reader - } - now := dependencies.Now - if now == nil { - now = time.Now - } - service := &Service{ - stateStore: dependencies.StateStore, - authorization: dependencies.AuthorizationStore, - administrator: dependencies.AdministratorStore, - passwordHasher: dependencies.PasswordHasher, - storage: dependencies.StorageStore, - prepareDirectory: dependencies.DirectoryPreparer, - managementTLS: dependencies.ManagementTLSStore, - openSecretProtector: dependencies.SecretProtectorOpener, - generateLocalCA: dependencies.LocalCAGenerator, - hardwareReview: dependencies.HardwareReviewStore, - completionStore: dependencies.CompletionStore, - random: random, - now: now, - bootstrapTimeout: 10 * time.Minute, - sessionTimeout: 30 * time.Minute, +func New(d Dependencies) (*Service, error) { + if d.StateStore == nil || d.AdministratorStore == nil || d.PasswordHasher == nil { + return nil, errors.New("installation dependencies are incomplete") } - return service, nil + return &Service{stateStore: d.StateStore, administrator: d.AdministratorStore, passwordHasher: d.PasswordHasher, now: time.Now}, nil } - func (service *Service) Status(ctx context.Context) (Status, error) { state, err := service.installationState(ctx) - if err != nil { - return Status{}, err - } - - switch state { - case InstallationUninitialized: - return Status{ - InstallationState: state, - Phase: PhaseBootstrapRequired, - SetupRequired: true, - BusinessAPIAvailable: false, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{FlowCreateNew}, - }, nil - case InstallationReady: - return Status{ - InstallationState: state, - Phase: PhaseComplete, - SetupRequired: false, - BusinessAPIAvailable: true, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{}, - }, nil - case InstallationMaintenance: - return Status{ - InstallationState: state, - Phase: PhaseMaintenance, - SetupRequired: false, - BusinessAPIAvailable: false, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{}, - }, nil - default: - return Status{}, fmt.Errorf("unsupported installation state %q", state) - } + return Status{InstallationState: state, BusinessAPIAvailable: state == InstallationReady, SetupRequired: state == InstallationUninitialized}, err } - -func (service *Service) GenerateBootstrap(ctx context.Context) (BootstrapGrant, error) { - if service == nil { - return BootstrapGrant{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.authorization == nil { - return BootstrapGrant{}, fmt.Errorf("setup authorization store is not configured") - } - if err := service.requireUninitialized(ctx); err != nil { - return BootstrapGrant{}, err - } - code, hash, err := service.newToken(bootstrapTokenBytes) - if err != nil { - return BootstrapGrant{}, fmt.Errorf("generate bootstrap code: %w", err) - } - now := service.currentTime() - expiresAt := now.Add(service.bootstrapTimeout) - if err := service.authorization.ReplaceSetupBootstrap(ctx, hash, now.Unix(), expiresAt.Unix()); err != nil { - return BootstrapGrant{}, fmt.Errorf("persist bootstrap authorization: %w", err) - } - return BootstrapGrant{Code: code, ExpiresAt: expiresAt}, nil -} - -func (service *Service) ConsumeBootstrap(ctx context.Context, code string) (SessionGrant, error) { - if service == nil { - return SessionGrant{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.authorization == nil { - return SessionGrant{}, fmt.Errorf("setup authorization store is not configured") - } - if err := service.requireUninitialized(ctx); err != nil { - return SessionGrant{}, err - } - bootstrapHash, err := tokenHash(code, bootstrapTokenBytes) - if err != nil { - return SessionGrant{}, ErrBootstrapInvalidOrExpired - } - sessionToken, sessionHash, err := service.newToken(sessionTokenBytes) - if err != nil { - return SessionGrant{}, fmt.Errorf("generate setup session: %w", err) - } - now := service.currentTime() - expiresAt := now.Add(service.sessionTimeout) - consumed, err := service.authorization.ConsumeSetupBootstrap( - ctx, - bootstrapHash, - sessionHash, - now.Unix(), - expiresAt.Unix(), - ) - if err != nil { - return SessionGrant{}, fmt.Errorf("consume bootstrap authorization: %w", err) - } - if !consumed { - return SessionGrant{}, ErrBootstrapInvalidOrExpired - } - session, err := service.sessionDetails(ctx, expiresAt, FlowCreateNew) - if err != nil { - return SessionGrant{}, err - } - return SessionGrant{Token: sessionToken, Session: session}, nil -} - -func (service *Service) ReadSession(ctx context.Context, token string) (Session, error) { - if service == nil || service.authorization == nil { - return Session{}, fmt.Errorf("setup authorization store is not configured") - } - if err := service.requireUninitialized(ctx); err != nil { - return Session{}, err - } - hash, err := tokenHash(token, sessionTokenBytes) - if err != nil { - return Session{}, ErrSetupSessionUnauthorized - } - now := service.currentTime() - expiresAtUnix, selectedFlow, found, err := service.authorization.ReadSetupSession(ctx, hash, now.Unix()) - if err != nil { - return Session{}, fmt.Errorf("read setup session: %w", err) - } - if !found || selectedFlow != FlowCreateNew { - return Session{}, ErrSetupSessionUnauthorized - } - return service.sessionDetails(ctx, time.Unix(expiresAtUnix, 0).UTC(), selectedFlow) -} - -func (service *Service) ConfigureAdministrator(ctx context.Context, token string, input AdministratorInput) (Session, error) { - if service == nil { - return Session{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.administrator == nil || service.passwordHasher == nil { - return Session{}, fmt.Errorf("initial administrator setup is not configured") - } - if _, err := service.ReadSession(ctx, token); err != nil { - return Session{}, err - } - username, err := validateAdministratorInput(input) - if err != nil { - return Session{}, err - } - passwordHash, err := service.passwordHasher.Hash(input.Password) - if err != nil { - return Session{}, fmt.Errorf("hash initial administrator password: %w", err) - } - // Revalidate after the deliberately expensive password hash. GenerateBootstrap - // shares mutationMu, so a root-issued replacement grant cannot race this write. - if _, err := service.ReadSession(ctx, token); err != nil { - return Session{}, err - } - if err := service.administrator.ConfigureInitialAdministrator( - ctx, - username, - passwordHash, - input.InstanceDefaultLocale, - service.currentTime(), - ); err != nil { - if errors.Is(err, ErrSetupUnavailable) { - return Session{}, err - } - return Session{}, fmt.Errorf("persist initial administrator: %w", err) - } - return service.ReadSession(ctx, token) -} - -// ProvisionAdministrator creates the sole administrator from the root-only -// control plane. It is idempotent for upgrades and never replaces an existing -// credential. func (service *Service) ProvisionAdministrator(ctx context.Context, input AdministratorInput) (bool, error) { if service == nil { return false, fmt.Errorf("setup service is not configured") @@ -492,418 +91,6 @@ func (service *Service) ProvisionAdministrator(ctx context.Context, input Admini return true, nil } -// BeginAdministratorSetup issues a restricted setup session after the -// administrator has authenticated. It replaces the root URL exchange without -// granting access to business APIs before the instance is ready. -func (service *Service) BeginAdministratorSetup(ctx context.Context) (SessionGrant, error) { - if service == nil || service.authorization == nil { - return SessionGrant{}, fmt.Errorf("setup authorization store is not configured") - } - if service.administrator == nil { - return SessionGrant{}, fmt.Errorf("initial administrator setup is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if err := service.requireUninitialized(ctx); err != nil { - return SessionGrant{}, err - } - _, _, configured, err := service.administrator.ReadInitialAdministrator(ctx) - if err != nil { - return SessionGrant{}, fmt.Errorf("read provisioned administrator: %w", err) - } - if !configured { - return SessionGrant{}, ErrSetupPrerequisiteMissing - } - _, bootstrapHash, err := service.newToken(bootstrapTokenBytes) - if err != nil { - return SessionGrant{}, fmt.Errorf("generate internal setup authorization: %w", err) - } - sessionToken, sessionHash, err := service.newToken(sessionTokenBytes) - if err != nil { - return SessionGrant{}, fmt.Errorf("generate administrator setup session: %w", err) - } - now := service.currentTime() - expiresAt := now.Add(service.sessionTimeout) - if err := service.authorization.ReplaceSetupBootstrap(ctx, bootstrapHash, now.Unix(), expiresAt.Unix()); err != nil { - return SessionGrant{}, fmt.Errorf("persist internal setup authorization: %w", err) - } - consumed, err := service.authorization.ConsumeSetupBootstrap(ctx, bootstrapHash, sessionHash, now.Unix(), expiresAt.Unix()) - if err != nil || !consumed { - return SessionGrant{}, fmt.Errorf("create administrator setup session: %w", err) - } - session, err := service.sessionDetails(ctx, expiresAt, FlowCreateNew) - if err != nil { - return SessionGrant{}, err - } - return SessionGrant{Token: sessionToken, Session: session}, nil -} - -func (service *Service) ConfigureStorage(ctx context.Context, token string, input StorageInput) (Session, error) { - if service == nil { - return Session{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.storage == nil || service.prepareDirectory == nil { - return Session{}, fmt.Errorf("setup storage is not configured") - } - session, err := service.ReadSession(ctx, token) - if err != nil { - return Session{}, err - } - if !session.AdministratorConfigured { - return Session{}, ErrSetupPrerequisiteMissing - } - dataRoot := service.storage.SetupDataRoot() - recordingsRoot := input.RecordingsRoot - if recordingsRoot == "" { - recordingsRoot = filepath.Join(dataRoot, "recordings") - } - if !filepath.IsAbs(recordingsRoot) || filepath.Clean(recordingsRoot) != recordingsRoot || recordingsRoot == dataRoot { - return Session{}, ErrStorageRequestInvalid - } - dataRelativeToRecordings, err := filepath.Rel(recordingsRoot, dataRoot) - if err != nil || (dataRelativeToRecordings != ".." && !strings.HasPrefix(dataRelativeToRecordings, ".."+string(filepath.Separator))) { - return Session{}, ErrStorageRequestInvalid - } - dataIdentity, err := service.prepareDirectory(dataRoot) - if err != nil { - return Session{}, fmt.Errorf("validate setup data root: %w", err) - } - recordingsIdentity, err := service.prepareDirectory(recordingsRoot) - if err != nil { - return Session{}, fmt.Errorf("validate setup recordings root: %w", err) - } - if dataIdentity.Device > math.MaxInt64 || dataIdentity.Inode > math.MaxInt64 || - recordingsIdentity.Device > math.MaxInt64 || recordingsIdentity.Inode > math.MaxInt64 { - return Session{}, errors.New("setup storage identity exceeds the durable integer range") - } - if err := service.storage.ConfigureSetupStorage( - ctx, - dataIdentity.Path, - recordingsIdentity.Path, - dataIdentity.Device, - dataIdentity.Inode, - recordingsIdentity.Device, - recordingsIdentity.Inode, - service.currentTime(), - ); err != nil { - return Session{}, fmt.Errorf("persist setup storage: %w", err) - } - return service.ReadSession(ctx, token) -} - -func (service *Service) ConfigureHTTPS(ctx context.Context, token string, input HTTPSInput) (Session, error) { - if service == nil { - return Session{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.managementTLS == nil { - return Session{}, fmt.Errorf("management TLS setup is not configured") - } - session, err := service.ReadSession(ctx, token) - if err != nil { - return Session{}, err - } - if !session.StorageConfigured { - return Session{}, ErrSetupPrerequisiteMissing - } - mode := managementtls.Mode(input.Mode) - host := strings.TrimSpace(input.ListenHost) - if input.ListenPort < 1 || input.ListenPort > 65535 || host == "" { - return Session{}, ErrHTTPSRequestInvalid - } - configuration := managementtls.Configuration{ - Mode: mode, - ListenHost: host, - ListenPort: input.ListenPort, - ConfiguredAt: service.currentTime(), - } - switch mode { - case managementtls.ModeLoopbackOnly: - if !isLoopbackHost(host) || len(input.SubjectAlternativeNames) != 0 { - return Session{}, ErrHTTPSRequestInvalid - } - configuration.Confirmed = true - case managementtls.ModeLocalCA: - if service.openSecretProtector == nil || service.generateLocalCA == nil || isUnspecifiedOrUnsafeHost(host) { - return Session{}, ErrHTTPSRequestInvalid - } - sans := append([]string(nil), input.SubjectAlternativeNames...) - sans = append(sans, host) - bundle, err := service.generateLocalCA(service.currentTime(), sans) - if err != nil { - return Session{}, ErrHTTPSRequestInvalid - } - protector, err := service.openSecretProtector() - if err != nil { - return Session{}, fmt.Errorf("open management TLS key protector: %w", err) - } - encryptedCAKey, err := protector.Encrypt("management-tls-ca-private-key-v1", bundle.CAPrivateKeyPEM) - if err != nil { - return Session{}, fmt.Errorf("protect local CA private key: %w", err) - } - encryptedLeafKey, err := protector.Encrypt("management-tls-leaf-private-key-v1", bundle.LeafPrivateKeyPEM) - if err != nil { - return Session{}, fmt.Errorf("protect management leaf private key: %w", err) - } - clear(bundle.CAPrivateKeyPEM) - clear(bundle.LeafPrivateKeyPEM) - configuration.SubjectAlternativeNames = bundle.SANs - configuration.CACertificatePEM = bundle.CACertificatePEM - configuration.LeafCertificatePEM = bundle.LeafCertificatePEM - configuration.EncryptedCAPrivateKey = encryptedCAKey - configuration.EncryptedLeafPrivateKey = encryptedLeafKey - configuration.RootFingerprintSHA256 = bundle.RootFingerprint - configuration.LeafNotAfter = bundle.LeafNotAfter - configuration.Confirmed = false - default: - return Session{}, ErrHTTPSRequestInvalid - } - if err := service.managementTLS.ConfigureManagementTLS(ctx, configuration); err != nil { - return Session{}, fmt.Errorf("persist management TLS setup: %w", err) - } - return service.ReadSession(ctx, token) -} - -func (service *Service) ConfirmHTTPS(ctx context.Context, token, rootFingerprint string) (Session, error) { - if service == nil { - return Session{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.managementTLS == nil { - return Session{}, fmt.Errorf("management TLS setup is not configured") - } - if _, err := service.ReadSession(ctx, token); err != nil { - return Session{}, err - } - confirmed, err := service.managementTLS.ConfirmManagementTLS(ctx, strings.TrimSpace(rootFingerprint), service.currentTime()) - if err != nil { - return Session{}, fmt.Errorf("confirm management TLS setup: %w", err) - } - if !confirmed { - return Session{}, ErrHTTPSConfirmationInvalid - } - return service.ReadSession(ctx, token) -} - -func (service *Service) ReadRootCertificate(ctx context.Context, token string) ([]byte, string, error) { - if _, err := service.ReadSession(ctx, token); err != nil { - return nil, "", err - } - if service.managementTLS == nil { - return nil, "", fmt.Errorf("management TLS setup is not configured") - } - configuration, found, err := service.managementTLS.ReadManagementTLS(ctx) - if err != nil { - return nil, "", fmt.Errorf("read management TLS root certificate: %w", err) - } - if !found || configuration.Mode != managementtls.ModeLocalCA || len(configuration.CACertificatePEM) == 0 { - return nil, "", ErrHTTPSRequestInvalid - } - return append([]byte(nil), configuration.CACertificatePEM...), configuration.RootFingerprintSHA256, nil -} - -func (service *Service) ConfirmHardwareReview(ctx context.Context, token string, input HardwareReviewInput) (Session, error) { - if service == nil { - return Session{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.hardwareReview == nil { - return Session{}, fmt.Errorf("setup hardware review is not configured") - } - session, err := service.ReadSession(ctx, token) - if err != nil { - return Session{}, err - } - if !session.HTTPSConfigured || !session.HTTPSConfirmed { - return Session{}, ErrSetupPrerequisiteMissing - } - digest, err := hardwareDigest(input) - if err != nil { - return Session{}, err - } - if err := service.hardwareReview.ConfirmSetupHardware(ctx, digest, len(input.Devices), len(input.Lines), service.currentTime()); err != nil { - return Session{}, fmt.Errorf("persist setup hardware review: %w", err) - } - return service.ReadSession(ctx, token) -} - -func (service *Service) Complete(ctx context.Context, token string, _ HardwareReviewInput) (Completion, error) { - if service == nil { - return Completion{}, fmt.Errorf("setup service is not configured") - } - service.mutationMu.Lock() - defer service.mutationMu.Unlock() - if service.completionStore == nil || service.storage == nil { - return Completion{}, fmt.Errorf("setup completion is not configured") - } - session, err := service.ReadSession(ctx, token) - if err != nil { - return Completion{}, err - } - if !session.AdministratorConfigured || !session.StorageConfigured { - return Completion{}, ErrSetupPrerequisiteMissing - } - dataRoot, recordingsRoot, dataDevice, dataInode, recordingsDevice, recordingsInode, configured, err := service.storage.ReadSetupStorage(ctx) - if err != nil || !configured { - return Completion{}, ErrSetupPreflightFailed - } - dataIdentity, err := service.prepareDirectory(dataRoot) - if err != nil || dataIdentity.Device != dataDevice || dataIdentity.Inode != dataInode { - return Completion{}, ErrSetupPreflightFailed - } - recordingsIdentity, err := service.prepareDirectory(recordingsRoot) - if err != nil || recordingsIdentity.Device != recordingsDevice || recordingsIdentity.Inode != recordingsInode { - return Completion{}, ErrSetupPreflightFailed - } - completed, err := service.completionStore.CompleteInitialSetup(ctx, "", service.currentTime()) - if err != nil { - return Completion{}, fmt.Errorf("atomically complete initial setup: %w", err) - } - if !completed { - return Completion{}, ErrSetupPreflightFailed - } - // The core transition is authoritative. A leftover runtime row cannot be used - // once installation_state is ready and will be removed on later maintenance. - _ = service.authorization.RevokeSetupAuthorization(ctx) - return Completion{ - InstallationState: InstallationReady, - ManagementURL: "/", - LoginRequired: true, - }, nil -} - -func hardwareDigest(input HardwareReviewInput) (string, error) { - if !hardwareDigestPattern.MatchString(input.TopologyDigest) || len(input.Devices) > 1024 || len(input.Lines) > 1024 { - return "", ErrHardwareReviewInvalid - } - devices := append([]HardwareDevice(nil), input.Devices...) - sort.Slice(devices, func(left, right int) bool { return devices[left].ID < devices[right].ID }) - lines := append([]HardwareLine(nil), input.Lines...) - sort.Slice(lines, func(left, right int) bool { return lines[left].ID < lines[right].ID }) - seenDevices := make(map[string]struct{}, len(devices)) - seenLines := make(map[string]struct{}, len(lines)) - seenProfiles := make(map[string]struct{}, len(lines)) - var canonical strings.Builder - fmt.Fprintf(&canonical, "topology=%s\ndevices=%d\n", input.TopologyDigest, len(devices)) - for _, device := range devices { - if !hardwareIdentifierPattern.MatchString(device.ID) || device.Transport == "" || device.State == "" || - device.ModemFunctionCount < 0 || device.SIMSlotCount < 0 || device.ResourceGroupCount < 0 { - return "", ErrHardwareReviewInvalid - } - if _, duplicate := seenDevices[device.ID]; duplicate { - return "", ErrHardwareReviewInvalid - } - seenDevices[device.ID] = struct{}{} - fmt.Fprintf(&canonical, "device=%q|transport=%q|state=%q|functions=%d|slots=%d|groups=%d\n", - device.ID, device.Transport, device.State, device.ModemFunctionCount, device.SIMSlotCount, device.ResourceGroupCount) - } - for _, line := range lines { - if !hardwareIdentifierPattern.MatchString(line.ID) || !hardwareIdentifierPattern.MatchString(line.PhysicalDeviceID) || - !hardwareIdentifierPattern.MatchString(line.SubscriptionProfileID) { - return "", ErrHardwareReviewInvalid - } - if _, present := seenDevices[line.PhysicalDeviceID]; !present { - return "", ErrHardwareReviewInvalid - } - if _, duplicate := seenLines[line.ID]; duplicate { - return "", ErrHardwareReviewInvalid - } - if _, duplicate := seenProfiles[line.SubscriptionProfileID]; duplicate { - return "", ErrHardwareReviewInvalid - } - seenLines[line.ID] = struct{}{} - seenProfiles[line.SubscriptionProfileID] = struct{}{} - fmt.Fprintf(&canonical, "line=%q device=%q profile=%q\n", line.ID, line.PhysicalDeviceID, line.SubscriptionProfileID) - } - digest := sha256.Sum256([]byte(canonical.String())) - return fmt.Sprintf("%x", digest), nil -} - -func (service *Service) sessionDetails(ctx context.Context, expiresAt time.Time, selectedFlow string) (Session, error) { - session := Session{ExpiresAt: expiresAt, SelectedFlow: selectedFlow, InstanceDefaultLocale: "en-US"} - if service.administrator == nil { - return session, nil - } - username, locale, configured, err := service.administrator.ReadInitialAdministrator(ctx) - if err != nil { - return Session{}, fmt.Errorf("read initial administrator setup: %w", err) - } - session.AdministratorConfigured = configured - session.AdministratorUsername = username - session.InstanceDefaultLocale = locale - if service.storage == nil { - return session, nil - } - dataRoot, recordingsRoot, _, _, _, _, storageConfigured, err := service.storage.ReadSetupStorage(ctx) - if err != nil { - return Session{}, fmt.Errorf("read setup storage: %w", err) - } - if recordingsRoot == "" && dataRoot != "" { - recordingsRoot = filepath.Join(dataRoot, "recordings") - } - session.StorageConfigured = storageConfigured - session.DataRoot = dataRoot - session.RecordingsRoot = recordingsRoot - if service.managementTLS == nil { - return session, nil - } - managementTLS, tlsConfigured, err := service.managementTLS.ReadManagementTLS(ctx) - if err != nil { - return Session{}, fmt.Errorf("read management TLS setup: %w", err) - } - if tlsConfigured { - session.HTTPSConfigured = true - session.HTTPSConfirmed = managementTLS.Confirmed - session.HTTPSMode = string(managementTLS.Mode) - session.HTTPSListenURL = managementListenURL(managementTLS) - session.HTTPSRootFingerprint = managementTLS.RootFingerprintSHA256 - session.HTTPSLeafNotAfter = managementTLS.LeafNotAfter - } - if service.hardwareReview == nil { - return session, nil - } - digest, deviceCount, lineCount, reviewed, err := service.hardwareReview.ReadSetupHardwareReview(ctx) - if err != nil { - return Session{}, fmt.Errorf("read setup hardware review: %w", err) - } - session.HardwareReviewed = reviewed - session.HardwareInventoryDigest = digest - session.HardwareDeviceCount = deviceCount - session.HardwareLineCount = lineCount - return session, nil -} - -func isLoopbackHost(host string) bool { - if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} - -func isUnspecifiedOrUnsafeHost(host string) bool { - if strings.ContainsAny(host, "/\\") { - return true - } - if ip := net.ParseIP(host); ip != nil { - return ip.IsUnspecified() || ip.IsMulticast() - } - return host == "" || strings.Contains(host, "*") -} - -func managementListenURL(configuration managementtls.Configuration) string { - scheme := "https" - if configuration.Mode == managementtls.ModeLoopbackOnly { - scheme = "http" - } - return (&url.URL{Scheme: scheme, Host: net.JoinHostPort(configuration.ListenHost, fmt.Sprintf("%d", configuration.ListenPort))}).String() -} - func validateAdministratorInput(input AdministratorInput) (string, error) { username := strings.ToLower(strings.TrimSpace(input.Username)) if !administratorUsernamePattern.MatchString(username) { @@ -954,22 +141,3 @@ func (service *Service) requireUninitialized(ctx context.Context) error { func (service *Service) currentTime() time.Time { return service.now().UTC().Truncate(time.Second) } - -func (service *Service) newToken(size int) (string, [32]byte, error) { - var zero [32]byte - bytes := make([]byte, size) - if _, err := io.ReadFull(service.random, bytes); err != nil { - return "", zero, err - } - token := base64.RawURLEncoding.EncodeToString(bytes) - return token, sha256.Sum256([]byte(token)), nil -} - -func tokenHash(token string, expectedBytes int) ([32]byte, error) { - var zero [32]byte - decoded, err := base64.RawURLEncoding.Strict().DecodeString(token) - if err != nil || len(decoded) != expectedBytes || base64.RawURLEncoding.EncodeToString(decoded) != token { - return zero, errors.New("invalid token encoding") - } - return sha256.Sum256([]byte(token)), nil -} diff --git a/internal/application/setup/service_test.go b/internal/application/setup/service_test.go index 2873ce1..e7b4561 100644 --- a/internal/application/setup/service_test.go +++ b/internal/application/setup/service_test.go @@ -1,723 +1,63 @@ -package setup +package setup_test import ( - "bytes" - "context" - "crypto/sha256" - "errors" - "reflect" - "strings" - "sync" + "path/filepath" "testing" - "time" - "github.com/leonfox28/simplus/internal/domain/managementtls" + "github.com/leonfox28/simplus/internal/application/auth" + "github.com/leonfox28/simplus/internal/application/setup" + "github.com/leonfox28/simplus/internal/security/password" + "github.com/leonfox28/simplus/internal/storage/sqlite" ) -type testStore struct { - mu sync.Mutex - - state string - stateErr error - - bootstrapHash [32]byte - bootstrapExpires int64 - bootstrapConsumed bool - sessions map[[32]byte]testSession - - administratorUsername string - administratorHash string - administratorLocale string - - dataRoot string - recordingsRoot string - storageConfigured bool - dataDevice uint64 - dataInode uint64 - recordingsDevice uint64 - recordingsInode uint64 - - managementTLS managementtls.Configuration - managementTLSFound bool - - hardwareDigest string - hardwareDeviceCount int - hardwareLineCount int - hardwareReviewed bool -} - -type testSession struct { - expiresAt int64 - flow string -} - -func (store *testStore) InstallationState(context.Context) (string, error) { - return store.state, store.stateErr -} - -func (store *testStore) ReplaceSetupBootstrap(_ context.Context, hash [32]byte, _ int64, expires int64) error { - store.mu.Lock() - defer store.mu.Unlock() - store.bootstrapHash = hash - store.bootstrapExpires = expires - store.bootstrapConsumed = false - store.sessions = make(map[[32]byte]testSession) - return nil -} - -func (store *testStore) ConsumeSetupBootstrap(_ context.Context, bootstrapHash, sessionHash [32]byte, now, expires int64) (bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - if store.bootstrapHash != bootstrapHash || store.bootstrapConsumed || store.bootstrapExpires <= now { - return false, nil - } - store.bootstrapConsumed = true - if store.sessions == nil { - store.sessions = make(map[[32]byte]testSession) - } - store.sessions[sessionHash] = testSession{expiresAt: expires, flow: FlowCreateNew} - return true, nil -} - -func (store *testStore) RevokeSetupAuthorization(context.Context) error { - store.mu.Lock() - defer store.mu.Unlock() - store.sessions = make(map[[32]byte]testSession) - store.bootstrapHash = [32]byte{} - return nil -} - -func (store *testStore) ReadSetupSession(_ context.Context, hash [32]byte, now int64) (int64, string, bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - session, ok := store.sessions[hash] - if !ok || session.expiresAt <= now { - return 0, "", false, nil - } - return session.expiresAt, session.flow, true, nil -} - -func (store *testStore) ConfigureInitialAdministrator(_ context.Context, username, passwordHash, locale string, _ time.Time) error { - store.mu.Lock() - defer store.mu.Unlock() - if store.state != InstallationUninitialized { - return ErrSetupUnavailable - } - store.administratorUsername = username - store.administratorHash = passwordHash - store.administratorLocale = locale - return nil -} - -func (store *testStore) ReadInitialAdministrator(context.Context) (string, string, bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - locale := store.administratorLocale - if locale == "" { - locale = "en-US" - } - return store.administratorUsername, locale, store.administratorUsername != "", nil -} - -func (store *testStore) SetupDataRoot() string { - if store.dataRoot == "" { - return "/srv/simplus/data" - } - return store.dataRoot -} - -func (store *testStore) ConfigureSetupStorage( - _ context.Context, - dataRoot string, - recordingsRoot string, - dataDevice, dataInode, recordingsDevice, recordingsInode uint64, - _ time.Time, -) error { - store.mu.Lock() - defer store.mu.Unlock() - store.dataRoot = dataRoot - store.recordingsRoot = recordingsRoot - store.storageConfigured = true - store.dataDevice = dataDevice - store.dataInode = dataInode - store.recordingsDevice = recordingsDevice - store.recordingsInode = recordingsInode - return nil -} - -func (store *testStore) ReadSetupStorage(context.Context) (string, string, uint64, uint64, uint64, uint64, bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - dataRoot := store.dataRoot - if dataRoot == "" { - dataRoot = "/srv/simplus/data" - } - return dataRoot, store.recordingsRoot, store.dataDevice, store.dataInode, store.recordingsDevice, store.recordingsInode, store.storageConfigured, nil -} - -func (store *testStore) ConfigureManagementTLS(_ context.Context, configuration managementtls.Configuration) error { - store.mu.Lock() - defer store.mu.Unlock() - store.managementTLS = configuration - store.managementTLSFound = true - return nil -} - -func (store *testStore) ReadManagementTLS(context.Context) (managementtls.Configuration, bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - return store.managementTLS, store.managementTLSFound, nil -} - -func (store *testStore) ConfirmManagementTLS(_ context.Context, fingerprint string, now time.Time) (bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - if !store.managementTLSFound || store.managementTLS.RootFingerprintSHA256 != fingerprint { - return false, nil - } - store.managementTLS.Confirmed = true - store.managementTLS.ConfiguredAt = now - return true, nil -} - -func (store *testStore) ConfirmSetupHardware(_ context.Context, digest string, deviceCount, lineCount int, _ time.Time) error { - store.mu.Lock() - defer store.mu.Unlock() - store.hardwareDigest = digest - store.hardwareDeviceCount = deviceCount - store.hardwareLineCount = lineCount - store.hardwareReviewed = true - return nil -} - -func (store *testStore) ReadSetupHardwareReview(context.Context) (string, int, int, bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - return store.hardwareDigest, store.hardwareDeviceCount, store.hardwareLineCount, store.hardwareReviewed, nil -} - -func (store *testStore) CompleteInitialSetup(_ context.Context, _ string, _ time.Time) (bool, error) { - store.mu.Lock() - defer store.mu.Unlock() - if store.state != InstallationUninitialized || store.administratorUsername == "" || !store.storageConfigured { - return false, nil - } - store.state = InstallationReady - return true, nil -} - -type testProtector struct{} - -func (testProtector) Encrypt(label string, plaintext []byte) ([]byte, error) { - return append([]byte(label+":"), plaintext...), nil -} - -type testPasswordHasher struct { - hash string - err error -} - -func (hasher testPasswordHasher) Hash(string) (string, error) { - return hasher.hash, hasher.err -} - -func testTime() time.Time { - return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) -} - -func newTestService(t *testing.T, store *testStore) *Service { - t.Helper() - return newTestServiceWithClock(t, store, testTime) -} - -func newTestServiceWithClock(t *testing.T, store *testStore, now func() time.Time) *Service { - t.Helper() - service, err := New(testServiceDependencies(store, now)) - if err != nil { - t.Fatal(err) - } - return service -} - -func testServiceDependencies(store *testStore, now func() time.Time) Dependencies { - randomBytes := append(bytes.Repeat([]byte{0x11}, 32), bytes.Repeat([]byte{0x22}, 32)...) - randomBytes = append(randomBytes, bytes.Repeat([]byte{0x33}, 32)...) - return Dependencies{ - StateStore: store, - AuthorizationStore: store, - AdministratorStore: store, - PasswordHasher: testPasswordHasher{hash: "$argon2id$test-password-hash"}, - StorageStore: store, - DirectoryPreparer: func(path string) (DirectoryIdentity, error) { - return DirectoryIdentity{Path: path, Device: 1, Inode: 2}, nil - }, - ManagementTLSStore: store, - SecretProtectorOpener: func() (SecretProtector, error) { - return testProtector{}, nil - }, - LocalCAGenerator: func(now time.Time, sans []string) (LocalCABundle, error) { - return LocalCABundle{ - CACertificatePEM: []byte("test-ca-certificate"), - CAPrivateKeyPEM: []byte("test-ca-private-key"), - LeafCertificatePEM: []byte("test-leaf-certificate"), - LeafPrivateKeyPEM: []byte("test-leaf-private-key"), - RootFingerprint: strings.Repeat("a", 64), - LeafNotAfter: now.Add(90 * 24 * time.Hour), - SANs: append([]string(nil), sans...), - }, nil - }, - HardwareReviewStore: store, - CompletionStore: store, - Random: bytes.NewReader(randomBytes), - Now: now, - } -} - -func newStateService(t *testing.T, stateStore StateStore) *Service { - t.Helper() - service, err := New(Dependencies{StateStore: stateStore}) - if err != nil { - t.Fatal(err) - } - return service -} - -func TestNewValidatesExplicitDependencyShapes(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - preparer := func(path string) (DirectoryIdentity, error) { return DirectoryIdentity{Path: path}, nil } - opener := func() (SecretProtector, error) { return testProtector{}, nil } - generator := func(time.Time, []string) (LocalCABundle, error) { return LocalCABundle{}, nil } - - invalid := []struct { - name string - dependencies Dependencies - }{ - {name: "missing state store", dependencies: Dependencies{}}, - {name: "administrator without password hasher", dependencies: Dependencies{StateStore: store, AdministratorStore: store}}, - {name: "password hasher without administrator", dependencies: Dependencies{StateStore: store, PasswordHasher: testPasswordHasher{}}}, - {name: "storage without directory preparer", dependencies: Dependencies{StateStore: store, StorageStore: store}}, - {name: "directory preparer without storage", dependencies: Dependencies{StateStore: store, DirectoryPreparer: preparer}}, - {name: "secret opener without generator", dependencies: Dependencies{StateStore: store, ManagementTLSStore: store, SecretProtectorOpener: opener}}, - {name: "generator without secret opener", dependencies: Dependencies{StateStore: store, ManagementTLSStore: store, LocalCAGenerator: generator}}, - {name: "local CA without management TLS", dependencies: Dependencies{StateStore: store, SecretProtectorOpener: opener, LocalCAGenerator: generator}}, - } - for _, test := range invalid { - t.Run(test.name, func(t *testing.T) { - service, err := New(test.dependencies) - if service != nil || !errors.Is(err, ErrDependenciesInvalid) { - t.Fatalf("New() = %#v, %v", service, err) - } - }) - } - - service, err := New(Dependencies{StateStore: store}) - if err != nil { - t.Fatal(err) - } - if _, err := service.BeginAdministratorSetup(context.Background()); err == nil || !strings.Contains(err.Error(), "authorization store") { - t.Fatalf("reduced BeginAdministratorSetup error = %v", err) - } - - service, err = New(Dependencies{StateStore: store, AuthorizationStore: store}) - if err != nil { - t.Fatal(err) - } - if _, err := service.BeginAdministratorSetup(context.Background()); err == nil || !strings.Contains(err.Error(), "administrator setup") { - t.Fatalf("reduced BeginAdministratorSetup error = %v", err) - } -} - -func TestStatusMapsInstallationStateToSetupBoundary(t *testing.T) { - for _, test := range []struct { - name string - state string - expected Status - }{ - { - name: "uninitialized", - state: InstallationUninitialized, - expected: Status{ - InstallationState: InstallationUninitialized, - Phase: PhaseBootstrapRequired, - SetupRequired: true, - BusinessAPIAvailable: false, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{FlowCreateNew}, - }, - }, - { - name: "ready", - state: InstallationReady, - expected: Status{ - InstallationState: InstallationReady, - Phase: PhaseComplete, - SetupRequired: false, - BusinessAPIAvailable: true, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{}, - }, - }, - { - name: "maintenance", - state: InstallationMaintenance, - expected: Status{ - InstallationState: InstallationMaintenance, - Phase: PhaseMaintenance, - SetupRequired: false, - BusinessAPIAvailable: false, - BootstrapGenerationAvailable: false, - SupportedFlows: []string{}, - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - store := &testStore{state: test.state} - status, err := newStateService(t, store).Status(context.Background()) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(status, test.expected) { - t.Fatalf("status = %#v, want %#v", status, test.expected) - } - }) - } -} - -func TestProvisionAdministratorIsNoOpForExistingReadyInstance(t *testing.T) { - store := &testStore{state: InstallationReady, administratorUsername: "simplus_admin", administratorLocale: "zh-CN"} - created, err := newTestService(t, store).ProvisionAdministrator(context.Background(), AdministratorInput{ - Username: "simplus_admin", Password: "replacement-password", PasswordConfirmation: "replacement-password", InstanceDefaultLocale: "zh-CN", - }) - if err != nil || created { - t.Fatalf("ready provisioning = %t, %v", created, err) - } -} - -func TestBootstrapIsSingleUseAndCreatesRestrictedSession(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - service := newTestService(t, store) - - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(grant.Code) != 43 { - t.Fatalf("bootstrap code length = %d, want 43", len(grant.Code)) - } - if grant.ExpiresAt.Sub(testTime()) != 10*time.Minute { - t.Fatalf("bootstrap lifetime = %s", grant.ExpiresAt.Sub(testTime())) - } - if store.bootstrapHash != sha256.Sum256([]byte(grant.Code)) { - t.Fatal("authorization store did not retain exactly the bootstrap-code hash") - } - - sessionGrant, err := service.ConsumeBootstrap(context.Background(), grant.Code) +func TestProvisionMakesInstanceReadyAndLoginWorksAfterRestart(t *testing.T) { + root := filepath.Join(t.TempDir(), "control-v2") + store, err := sqlite.OpenSet(t.Context(), root) if err != nil { t.Fatal(err) } - if len(sessionGrant.Token) != 43 { - t.Fatalf("session token length = %d, want 43", len(sessionGrant.Token)) - } - if sessionGrant.ExpiresAt.Sub(testTime()) != 30*time.Minute { - t.Fatalf("session lifetime = %s", sessionGrant.ExpiresAt.Sub(testTime())) - } - if _, err := service.ConsumeBootstrap(context.Background(), grant.Code); !errors.Is(err, ErrBootstrapInvalidOrExpired) { - t.Fatalf("second bootstrap consumption error = %v", err) - } - - if sessionGrant.SelectedFlow != FlowCreateNew { - t.Fatalf("selected flow = %q", sessionGrant.SelectedFlow) - } - resumed, err := service.ReadSession(context.Background(), sessionGrant.Token) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(resumed, sessionGrant.Session) { - t.Fatalf("resumed session = %#v, want %#v", resumed, sessionGrant.Session) - } -} - -func TestConfigureAdministratorPersistsOnlyAfterAuthorizedValidation(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - service := newTestService(t, store) - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - sessionGrant, err := service.ConsumeBootstrap(context.Background(), grant.Code) - if err != nil { - t.Fatal(err) - } - - session, err := service.ConfigureAdministrator(context.Background(), sessionGrant.Token, AdministratorInput{ - Username: " Leon ", - Password: "correct horse battery staple", - PasswordConfirmation: "correct horse battery staple", - InstanceDefaultLocale: "zh-CN", - }) - if err != nil { - t.Fatal(err) - } - if !session.AdministratorConfigured || session.AdministratorUsername != "leon" || session.InstanceDefaultLocale != "zh-CN" { - t.Fatalf("configured session = %#v", session) - } - if store.administratorHash != "$argon2id$test-password-hash" { - t.Fatalf("stored hash = %q", store.administratorHash) - } - - for _, input := range []AdministratorInput{ - {Username: "bad user", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "en-US"}, - {Username: "admin", Password: "too-short", PasswordConfirmation: "too-short", InstanceDefaultLocale: "en-US"}, - {Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "different password value", InstanceDefaultLocale: "en-US"}, - {Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "fr-FR"}, - } { - if _, err := service.ConfigureAdministrator(context.Background(), sessionGrant.Token, input); !errors.Is(err, ErrAdministratorRequestInvalid) { - t.Fatalf("ConfigureAdministrator(%#v) error = %v", input, err) - } - } - if _, err := service.ConfigureAdministrator(context.Background(), "not-a-token", AdministratorInput{ - Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "en-US", - }); !errors.Is(err, ErrSetupSessionUnauthorized) { - t.Fatalf("unauthorized administrator setup error = %v", err) - } -} - -func TestConfigureStorageRequiresAdministratorAndPersistsValidatedRoots(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - service := newTestService(t, store) - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - sessionGrant, err := service.ConsumeBootstrap(context.Background(), grant.Code) - if err != nil { - t.Fatal(err) - } - if _, err := service.ConfigureStorage(context.Background(), sessionGrant.Token, StorageInput{}); !errors.Is(err, ErrSetupPrerequisiteMissing) { - t.Fatalf("storage without administrator error = %v", err) - } - if _, err := service.ConfigureAdministrator(context.Background(), sessionGrant.Token, AdministratorInput{ - Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "en-US", - }); err != nil { - t.Fatal(err) - } - session, err := service.ConfigureStorage(context.Background(), sessionGrant.Token, StorageInput{}) - if err != nil { - t.Fatal(err) - } - if !session.StorageConfigured || session.DataRoot != "/srv/simplus/data" || session.RecordingsRoot != "/srv/simplus/data/recordings" { - t.Fatalf("storage session = %#v", session) - } - if _, err := service.ConfigureStorage(context.Background(), sessionGrant.Token, StorageInput{RecordingsRoot: "/srv"}); !errors.Is(err, ErrStorageRequestInvalid) { - t.Fatalf("ancestor recordings root error = %v", err) - } - if _, err := service.ConfigureStorage(context.Background(), sessionGrant.Token, StorageInput{RecordingsRoot: "relative"}); !errors.Is(err, ErrStorageRequestInvalid) { - t.Fatalf("relative recordings root error = %v", err) - } -} - -func TestConfigureHTTPSSupportsLoopbackAndConfirmedLocalCA(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - service := newTestService(t, store) - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { - t.Fatal(err) - } - sessionGrant, err := service.ConsumeBootstrap(context.Background(), grant.Code) - if err != nil { - t.Fatal(err) - } - if _, err := service.ConfigureHTTPS(context.Background(), sessionGrant.Token, HTTPSInput{Mode: "loopback-only", ListenHost: "127.0.0.1", ListenPort: 8080}); !errors.Is(err, ErrSetupPrerequisiteMissing) { - t.Fatalf("HTTPS without storage error = %v", err) - } - if _, err := service.ConfigureAdministrator(context.Background(), sessionGrant.Token, AdministratorInput{ - Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "en-US", - }); err != nil { - t.Fatal(err) - } - if _, err := service.ConfigureStorage(context.Background(), sessionGrant.Token, StorageInput{}); err != nil { - t.Fatal(err) - } - loopback, err := service.ConfigureHTTPS(context.Background(), sessionGrant.Token, HTTPSInput{ - Mode: "loopback-only", ListenHost: "127.0.0.1", ListenPort: 8080, - }) - if err != nil { - t.Fatal(err) - } - if !loopback.HTTPSConfigured || !loopback.HTTPSConfirmed || loopback.HTTPSListenURL != "http://127.0.0.1:8080" { - t.Fatalf("loopback HTTPS session = %#v", loopback) - } - - candidate, err := service.ConfigureHTTPS(context.Background(), sessionGrant.Token, HTTPSInput{ - Mode: "local-ca", ListenHost: "192.168.50.10", ListenPort: 8443, SubjectAlternativeNames: []string{"simplus.local"}, - }) - if err != nil { - t.Fatal(err) - } - if !candidate.HTTPSConfigured || candidate.HTTPSConfirmed || candidate.HTTPSListenURL != "https://192.168.50.10:8443" || candidate.HTTPSRootFingerprint == "" { - t.Fatalf("local CA candidate = %#v", candidate) - } - certificate, fingerprint, err := service.ReadRootCertificate(context.Background(), sessionGrant.Token) + hasher := password.NewDefaultHasher() + service, err := setup.New(setup.Dependencies{StateStore: store, AdministratorStore: store, PasswordHasher: hasher}) if err != nil { t.Fatal(err) } - if len(certificate) == 0 || fingerprint != candidate.HTTPSRootFingerprint { - t.Fatalf("root certificate/fingerprint = %d/%q", len(certificate), fingerprint) - } - if _, err := service.ConfirmHTTPS(context.Background(), sessionGrant.Token, "wrong"); !errors.Is(err, ErrHTTPSConfirmationInvalid) { - t.Fatalf("wrong fingerprint confirmation error = %v", err) - } - confirmed, err := service.ConfirmHTTPS(context.Background(), sessionGrant.Token, fingerprint) - if err != nil { - t.Fatal(err) - } - if !confirmed.HTTPSConfirmed { - t.Fatalf("confirmed HTTPS session = %#v", confirmed) - } - devices := []HardwareDevice{{ID: "device-1", Transport: "usb", State: "available", ModemFunctionCount: 1, SIMSlotCount: 1, ResourceGroupCount: 1}} - topologyDigest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - if _, err := service.ConfirmHardwareReview(context.Background(), sessionGrant.Token, HardwareReviewInput{ - TopologyDigest: topologyDigest, - Devices: devices, - Lines: []HardwareLine{{ID: "line-1", PhysicalDeviceID: "missing-device", SubscriptionProfileID: "profile-1"}}, - }); !errors.Is(err, ErrHardwareReviewInvalid) { - t.Fatalf("unknown device review error = %v", err) - } - hardware, err := service.ConfirmHardwareReview(context.Background(), sessionGrant.Token, HardwareReviewInput{ - TopologyDigest: topologyDigest, - Devices: devices, - Lines: []HardwareLine{{ID: "line-1", PhysicalDeviceID: "device-1", SubscriptionProfileID: "profile-1"}}, - }) + input := setup.AdministratorInput{Username: "admin", Password: "synthetic password for tests", PasswordConfirmation: "synthetic password for tests", InstanceDefaultLocale: "zh-CN"} + // Force the ready transition to fail, proving that the credential rolls back too. + _, err = store.DB.Exec(`CREATE TRIGGER reject_ready BEFORE UPDATE ON installation_state BEGIN SELECT RAISE(ABORT, 'fixture'); END`) if err != nil { t.Fatal(err) } - if !hardware.HardwareReviewed || hardware.HardwareDeviceCount != 1 || hardware.HardwareLineCount != 1 || len(hardware.HardwareInventoryDigest) != 64 { - t.Fatalf("hardware review session = %#v", hardware) - } - if _, err := service.ConfigureHTTPS(context.Background(), sessionGrant.Token, HTTPSInput{Mode: "local-ca", ListenHost: "0.0.0.0", ListenPort: 8443}); !errors.Is(err, ErrHTTPSRequestInvalid) { - t.Fatalf("unspecified listen host error = %v", err) - } - completion, err := service.Complete(context.Background(), sessionGrant.Token, HardwareReviewInput{ - TopologyDigest: topologyDigest, - Devices: devices, - Lines: []HardwareLine{{ID: "line-1", PhysicalDeviceID: "device-1", SubscriptionProfileID: "profile-1"}}, - }) - if err != nil { - t.Fatal(err) - } - if completion.InstallationState != InstallationReady || completion.ManagementURL != "/" || !completion.LoginRequired { - t.Fatalf("completion = %#v", completion) - } - if _, err := service.ReadSession(context.Background(), sessionGrant.Token); !errors.Is(err, ErrSetupUnavailable) { - t.Fatalf("completed setup session error = %v", err) + if _, err = service.ProvisionAdministrator(t.Context(), input); err == nil { + t.Fatal("accepted failed transaction") } -} - -func TestConfigureHTTPSMapsAndClearsApplicationLocalCABundle(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - caPrivateKey := []byte("synthetic-ca-private-key") - leafPrivateKey := []byte("synthetic-leaf-private-key") - leafNotAfter := testTime().Add(90 * 24 * time.Hour) - var generatorTime time.Time - var generatorSANs []string - dependencies := testServiceDependencies(store, testTime) - dependencies.LocalCAGenerator = func(now time.Time, sans []string) (LocalCABundle, error) { - generatorTime = now - generatorSANs = append([]string(nil), sans...) - return LocalCABundle{ - CACertificatePEM: []byte("synthetic-ca-certificate"), - CAPrivateKeyPEM: caPrivateKey, - LeafCertificatePEM: []byte("synthetic-leaf-certificate"), - LeafPrivateKeyPEM: leafPrivateKey, - RootFingerprint: strings.Repeat("b", 64), - LeafNotAfter: leafNotAfter, - SANs: []string{"normalized.example", "192.168.50.10"}, - }, nil + _, _, found, err := store.ReadInitialAdministrator(t.Context()) + if err != nil || found { + t.Fatalf("credential escaped transaction: %v %v", found, err) } - service, err := New(dependencies) - if err != nil { - t.Fatal(err) + store.DB.Exec(`DROP TRIGGER reject_ready`) + created, err := service.ProvisionAdministrator(t.Context(), input) + if err != nil || !created { + t.Fatalf("provision=%v %v", created, err) } - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { + if err = store.Close(); err != nil { t.Fatal(err) } - session, err := service.ConsumeBootstrap(context.Background(), grant.Code) + store, err = sqlite.OpenSet(t.Context(), root) if err != nil { t.Fatal(err) } - if _, err := service.ConfigureAdministrator(context.Background(), session.Token, AdministratorInput{ - Username: "admin", Password: "correct horse battery staple", PasswordConfirmation: "correct horse battery staple", InstanceDefaultLocale: "en-US", - }); err != nil { - t.Fatal(err) - } - if _, err := service.ConfigureStorage(context.Background(), session.Token, StorageInput{}); err != nil { - t.Fatal(err) - } - if _, err := service.ConfigureHTTPS(context.Background(), session.Token, HTTPSInput{ - Mode: "local-ca", ListenHost: "192.168.50.10", ListenPort: 8443, SubjectAlternativeNames: []string{"simplus.local"}, - }); err != nil { - t.Fatal(err) - } - - if generatorTime != testTime() || !reflect.DeepEqual(generatorSANs, []string{"simplus.local", "192.168.50.10"}) { - t.Fatalf("Local CA generator inputs = %s, %#v", generatorTime, generatorSANs) - } - if !bytes.Equal(caPrivateKey, make([]byte, len(caPrivateKey))) || !bytes.Equal(leafPrivateKey, make([]byte, len(leafPrivateKey))) { - t.Fatal("Setup retained plaintext Local CA private-key bytes") + defer store.Close() + state, err := store.InstallationState(t.Context()) + if err != nil || state != "ready" { + t.Fatalf("state=%s %v", state, err) } - configuration := store.managementTLS - if string(configuration.CACertificatePEM) != "synthetic-ca-certificate" || - string(configuration.LeafCertificatePEM) != "synthetic-leaf-certificate" || - string(configuration.EncryptedCAPrivateKey) != "management-tls-ca-private-key-v1:synthetic-ca-private-key" || - string(configuration.EncryptedLeafPrivateKey) != "management-tls-leaf-private-key-v1:synthetic-leaf-private-key" || - configuration.RootFingerprintSHA256 != strings.Repeat("b", 64) || - !configuration.LeafNotAfter.Equal(leafNotAfter) || - !reflect.DeepEqual(configuration.SubjectAlternativeNames, []string{"normalized.example", "192.168.50.10"}) { - t.Fatalf("persisted Local CA configuration = %#v", configuration) - } -} - -func TestBootstrapAndSessionFailClosed(t *testing.T) { - store := &testStore{state: InstallationUninitialized} - currentTime := testTime() - service := newTestServiceWithClock(t, store, func() time.Time { return currentTime }) - if _, err := service.ConsumeBootstrap(context.Background(), "not-a-token"); !errors.Is(err, ErrBootstrapInvalidOrExpired) { - t.Fatalf("invalid bootstrap error = %v", err) - } - if _, err := service.ReadSession(context.Background(), "not-a-token"); !errors.Is(err, ErrSetupSessionUnauthorized) { - t.Fatalf("invalid session error = %v", err) - } - grant, err := service.GenerateBootstrap(context.Background()) - if err != nil { + if _, err = auth.NewService(store, store, hasher).Login(t.Context(), input.Username, input.Password); err != nil { t.Fatal(err) } - currentTime = grant.ExpiresAt - if _, err := service.ConsumeBootstrap(context.Background(), grant.Code); !errors.Is(err, ErrBootstrapInvalidOrExpired) { - t.Fatalf("expired bootstrap error = %v", err) - } - - store.state = InstallationReady - if _, err := service.GenerateBootstrap(context.Background()); !errors.Is(err, ErrSetupUnavailable) { - t.Fatalf("ready bootstrap error = %v", err) - } -} - -func TestStatusFailsClosed(t *testing.T) { - storeError := errors.New("storage unavailable") - store := &testStore{stateErr: storeError} - if _, err := newStateService(t, store).Status(context.Background()); !errors.Is(err, storeError) { - t.Fatalf("store error = %v", err) - } - store.stateErr = nil - store.state = "broken" - if _, err := newStateService(t, store).Status(context.Background()); err == nil { - t.Fatal("Status accepted an unsupported installation state") - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - store.state = InstallationReady - if _, err := newStateService(t, store).Status(ctx); !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled status error = %v", err) + service, _ = setup.New(setup.Dependencies{StateStore: store, AdministratorStore: store, PasswordHasher: hasher}) + input.Password = "replacement" + input.PasswordConfirmation = input.Password + if created, err = service.ProvisionAdministrator(t.Context(), input); err != nil || created { + t.Fatalf("reprovision=%v %v", created, err) } } diff --git a/internal/application/vowifi/service.go b/internal/application/vowifi/service.go index 4105ae7..329b231 100644 --- a/internal/application/vowifi/service.go +++ b/internal/application/vowifi/service.go @@ -3,7 +3,6 @@ package vowifi import ( "context" "errors" - "reflect" "sort" "sync" "time" @@ -13,7 +12,6 @@ import ( mihomoapp "github.com/leonfox28/simplus/internal/application/mihomo" lineegressdomain "github.com/leonfox28/simplus/internal/domain/lineegress" domain "github.com/leonfox28/simplus/internal/domain/vowifi" - "github.com/leonfox28/simplus/internal/vowifisupervisor" ) var ( @@ -44,23 +42,22 @@ type Service struct { Inventory Inventory Egress Egress Mihomo MihomoRuntime - Supervisor vowifisupervisor.API + Supervisor domain.API Now func() time.Time - mu sync.Mutex + mu sync.Mutex + gates map[string]chan struct{} } -func New(store Store, inventoryService Inventory, egress Egress, mihomo MihomoRuntime, supervisor vowifisupervisor.API) (*Service, error) { +func New(store Store, inventoryService Inventory, egress Egress, mihomo MihomoRuntime, supervisor domain.API) (*Service, error) { if store == nil || inventoryService == nil || egress == nil || mihomo == nil || supervisor == nil { return nil, errors.New("Host VoWiFi service is not configured") } - return &Service{Store: store, Inventory: inventoryService, Egress: egress, Mihomo: mihomo, Supervisor: supervisor, Now: time.Now}, nil + return &Service{Store: store, Inventory: inventoryService, Egress: egress, Mihomo: mihomo, Supervisor: supervisor, Now: time.Now, gates: make(map[string]chan struct{})}, nil } func (service *Service) List(ctx context.Context) ([]domain.State, error) { - service.mu.Lock() - defer service.mu.Unlock() - return service.listLocked(ctx) + return service.list(ctx) } // Available implements the messaging transport availability contract. SMS is @@ -73,15 +70,18 @@ func (service *Service) Available(ctx context.Context, lineID string) bool { } for _, state := range states { if state.LineID == lineID { - return state.Online && state.State == vowifisupervisor.StateOnline + return state.Online && state.State == domain.StateOnline } } return false } func (service *Service) Activate(ctx context.Context, lineID string) (domain.State, error) { - service.mu.Lock() - defer service.mu.Unlock() + release, err := service.lockLine(ctx, lineID) + if err != nil { + return domain.State{}, err + } + defer release() environment, err := service.environment(ctx) if err != nil { return domain.State{}, err @@ -108,7 +108,7 @@ func (service *Service) Activate(ctx context.Context, lineID string) (domain.Sta } request := supervisorRequest(*line, *egress) status, err := service.Supervisor.Start(ctx, request) - if errors.Is(err, vowifisupervisor.ErrAlreadyRunning) { + if errors.Is(err, domain.ErrAlreadyRunning) { statuses, listErr := service.Supervisor.List(ctx) if listErr != nil { return domain.State{}, listErr @@ -128,8 +128,11 @@ func (service *Service) Activate(ctx context.Context, lineID string) (domain.Sta } func (service *Service) Deactivate(ctx context.Context, lineID string) (domain.State, error) { - service.mu.Lock() - defer service.mu.Unlock() + release, err := service.lockLine(ctx, lineID) + if err != nil { + return domain.State{}, err + } + defer release() environment, err := service.environment(ctx) if err != nil { return domain.State{}, err @@ -143,7 +146,7 @@ func (service *Service) Deactivate(ctx context.Context, lineID string) (domain.S } status, err := service.Supervisor.Stop(ctx, lineID) runtime := &status - if errors.Is(err, vowifisupervisor.ErrNotRunning) { + if errors.Is(err, domain.ErrNotRunning) { runtime = nil } else if err != nil { return domain.State{}, err @@ -157,103 +160,177 @@ func (service *Service) Deactivate(ctx context.Context, lineID string) (domain.S // Reconcile makes persistent administrator intent match the privileged runtime // fact. It never changes a Line's identity binding or egress selection. -func (service *Service) Reconcile(ctx context.Context) error { +func (service *Service) lockLine(ctx context.Context, id string) (func(), error) { service.mu.Lock() - defer service.mu.Unlock() + if service.gates == nil { + service.gates = make(map[string]chan struct{}) + } + gate := service.gates[id] + if gate == nil { + gate = make(chan struct{}, 1) + service.gates[id] = gate + } + service.mu.Unlock() + select { + case gate <- struct{}{}: + return func() { <-gate }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (service *Service) reconcileLine(ctx context.Context, lineID string) error { + release, err := service.lockLine(ctx, lineID) + if err != nil { + return err + } + defer release() environment, err := service.environment(ctx) if err != nil { return err } - recovered := false - for lineID, desired := range environment.desiredByLine { - line, egress := environment.lineByID[lineID], environment.egressByLine[lineID] - if desired && line != nil && hardwareReady(*line) && egress != nil && requiresMihomoRecovery(*egress) { - if err := service.recoverMihomo(ctx, *egress); err != nil { - return err - } - recovered = true + desired := environment.desiredByLine[lineID] + line, egress, ready := environment.readyLine(lineID) + if desired && line != nil && hardwareReady(*line) && egress != nil && requiresMihomoRecovery(*egress) { + if err := service.recoverMihomo(ctx, *egress); err != nil { + return err } - } - if recovered { environment, err = service.environment(ctx) if err != nil { return err } + line, egress, ready = environment.readyLine(lineID) } - for lineID, status := range environment.runtimeByLine { - desired := environment.desiredByLine[lineID] - _, _, ready := environment.readyLine(lineID) - if !desired || !ready { - if status.State != vowifisupervisor.StateStopped { - if _, stopErr := service.Supervisor.Stop(ctx, lineID); stopErr != nil && !errors.Is(stopErr, vowifisupervisor.ErrNotRunning) { - return stopErr - } + existing := environment.runtimeByLine[lineID] + if !desired || !ready { + if existing != nil && existing.State != domain.StateStopped { + _, err := service.Supervisor.Stop(ctx, lineID) + if !errors.Is(err, domain.ErrNotRunning) { + return err } } + return nil } - for lineID, desired := range environment.desiredByLine { - if !desired { - continue - } - line, egress, ready := environment.readyLine(lineID) - if !ready { - continue - } - existing := environment.runtimeByLine[lineID] - request := supervisorRequest(*line, *egress) - if existing != nil && existing.State != vowifisupervisor.StateStopped && existing.State != vowifisupervisor.StateFailed { - if existing.EgressMode == request.EgressMode && existing.CountryCode == request.CountryCode { - continue - } - if _, stopErr := service.Supervisor.Stop(ctx, lineID); stopErr != nil && !errors.Is(stopErr, vowifisupervisor.ErrNotRunning) { - return stopErr - } + request := supervisorRequest(*line, *egress) + if existing != nil && existing.State != domain.StateStopped && existing.State != domain.StateFailed { + if existing.EgressMode == request.EgressMode && existing.CountryCode == request.CountryCode { + return nil } - if _, startErr := service.Supervisor.Start(ctx, request); startErr != nil && !errors.Is(startErr, vowifisupervisor.ErrAlreadyRunning) { - return startErr + if _, err := service.Supervisor.Stop(ctx, lineID); err != nil && !errors.Is(err, domain.ErrNotRunning) { + return err } } - return nil + _, err = service.Supervisor.Start(ctx, request) + if errors.Is(err, domain.ErrAlreadyRunning) { + return nil + } + return err +} + +func (service *Service) Reconcile(ctx context.Context) error { + environment, err := service.environment(ctx) + if err != nil { + return err + } + ids := map[string]bool{} + for id := range environment.desiredByLine { + ids[id] = true + } + for id := range environment.runtimeByLine { + ids[id] = true + } + var result error + // The synchronous entry point is useful for bounded administrative checks. + // Run schedules each line independently for continuous reconciliation. + for id := range ids { + result = errors.Join(result, service.reconcileLine(ctx, id)) + } + return result } func (service *Service) Run(ctx context.Context, interval time.Duration, report func(error)) { if interval < time.Second { interval = 10 * time.Second } - var previous []domain.State - reconcile := func() { - if err := service.Reconcile(ctx); err != nil { - if report != nil { - report(err) - } - return - } - current, err := service.List(ctx) + type completion struct { + id string + err error + } + done := make(chan completion) + var work sync.WaitGroup + defer work.Wait() + active := map[string]bool{} + next := map[string]time.Time{} + retries := map[string]time.Duration{} + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + schedule := func() { + cycle, cancel := context.WithTimeout(ctx, 5*time.Second) + environment, err := service.environment(cycle) + cancel() if err != nil { if report != nil { report(err) } return } - if report != nil && !reflect.DeepEqual(previous, current) { + ids := map[string]bool{} + for id := range environment.desiredByLine { + ids[id] = true + } + for id := range environment.runtimeByLine { + ids[id] = true + } + if len(ids) == 0 && report != nil { report(nil) } - previous = current + for id := range ids { + if active[id] || time.Now().Before(next[id]) { + continue + } + active[id] = true + work.Go(func() { + cycle, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + err := service.reconcileLine(cycle, id) + select { + case done <- completion{id, err}: + case <-ctx.Done(): + } + }) + } } - reconcile() - ticker := time.NewTicker(interval) - defer ticker.Stop() + schedule() for { select { case <-ctx.Done(): return case <-ticker.C: - reconcile() + schedule() + case result := <-done: + delete(active, result.id) + delay := interval + if result.err != nil { + delay = retries[result.id] * 2 + if delay < 15*time.Second { + delay = 15 * time.Second + } + if delay > 5*time.Minute { + delay = 5 * time.Minute + } + retries[result.id] = delay + } else { + delete(retries, result.id) + } + next[result.id] = time.Now().Add(delay) + if report != nil { + report(result.err) + } } } } -func (service *Service) listLocked(ctx context.Context) ([]domain.State, error) { +func (service *Service) list(ctx context.Context) ([]domain.State, error) { environment, err := service.environment(ctx) if err != nil { return nil, err @@ -274,7 +351,7 @@ type runtimeEnvironment struct { lineByID map[string]*inventory.Line egressByLine map[string]*lineegressapp.View desiredByLine map[string]bool - runtimeByLine map[string]*vowifisupervisor.Status + runtimeByLine map[string]*domain.Status } func (service *Service) environment(ctx context.Context) (runtimeEnvironment, error) { @@ -296,7 +373,7 @@ func (service *Service) environment(ctx context.Context) (runtimeEnvironment, er } environment := runtimeEnvironment{ lineByID: make(map[string]*inventory.Line), egressByLine: make(map[string]*lineegressapp.View), - desiredByLine: make(map[string]bool), runtimeByLine: make(map[string]*vowifisupervisor.Status), + desiredByLine: make(map[string]bool), runtimeByLine: make(map[string]*domain.Status), } for index := range topology.Lines { line := &topology.Lines[index] @@ -326,13 +403,13 @@ func (environment runtimeEnvironment) readyLine(lineID string) (*inventory.Line, return line, egress, ready } -func supervisorRequest(line inventory.Line, egress lineegressapp.View) vowifisupervisor.StartRequest { - mode := vowifisupervisor.EgressDirect +func supervisorRequest(line inventory.Line, egress lineegressapp.View) domain.StartRequest { + mode := domain.EgressDirect country := "" if egress.Mode == lineegressdomain.ModeMihomoCountry { - mode, country = vowifisupervisor.EgressMihomoCountry, egress.CountryCode + mode, country = domain.EgressMihomoCountry, egress.CountryCode } - return vowifisupervisor.StartRequest{ + return domain.StartRequest{ LineID: line.ID, HardwareLineID: line.RuntimeLineID, EgressMode: mode, CountryCode: country, } } @@ -355,12 +432,12 @@ func (service *Service) recoverMihomo(ctx context.Context, egress lineegressapp. return err } -func stateFor(line inventory.Line, desired bool, egress lineegressapp.View, runtime *vowifisupervisor.Status) domain.State { +func stateFor(line inventory.Line, desired bool, egress lineegressapp.View, runtime *domain.Status) domain.State { state := domain.State{ LineID: line.ID, DesiredActive: desired, EgressMode: egress.Mode, CountryCode: egress.CountryCode, CountryName: egress.CountryName, Eligible: hardwareReady(line) && egress.Ready, - ReadinessCode: egress.ReadinessReason, State: vowifisupervisor.StateStopped, + ReadinessCode: egress.ReadinessReason, State: domain.StateStopped, } if !line.Capabilities.HostVoWiFiAuth { state.ReadinessCode = "LINE_VOWIFI_UNSUPPORTED" diff --git a/internal/architecture/boundaries_test.go b/internal/architecture/boundaries_test.go new file mode 100644 index 0000000..9a286d7 --- /dev/null +++ b/internal/architecture/boundaries_test.go @@ -0,0 +1,54 @@ +package architecture + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestProductionDependencyDirection(t *testing.T) { + root := filepath.Join("..", "..", "internal") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, spec := range file.Imports { + dependency, err := strconv.Unquote(spec.Path.Value) + if err != nil { + return err + } + if strings.HasPrefix(relative, "domain/") && (strings.Contains(dependency, "/internal/application/") || strings.Contains(dependency, "/internal/storage/") || strings.Contains(dependency, "/internal/api/") || strings.Contains(dependency, "/internal/agentapi")) { + t.Errorf("domain imports outer layer: %s -> %s", relative, dependency) + } + if strings.HasPrefix(relative, "application/") { + for _, forbidden := range []string{"net/http", "os", "os/exec", "/internal/storage/", "/internal/agentapi", "/internal/vowifisupervisor", "/internal/mihomosupervisor", "/internal/feishu", "/internal/mihomoassets"} { + if dependency == forbidden || strings.Contains(dependency, forbidden) && strings.HasPrefix(forbidden, "/internal/") { + t.Errorf("application imports adapter: %s -> %s", relative, dependency) + } + } + } + if strings.Contains(dependency, "/internal/vowifihil") && !strings.HasPrefix(relative, "vowifihil/") { + t.Errorf("production depends on HIL: %s -> %s", relative, dependency) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/command/placeholder.go b/internal/command/placeholder.go deleted file mode 100644 index a2e0d17..0000000 --- a/internal/command/placeholder.go +++ /dev/null @@ -1,18 +0,0 @@ -package command - -import ( - "fmt" - "io" - - "github.com/leonfox28/simplus/internal/buildinfo" -) - -func RunPlaceholder(name string, args []string, stdout, stderr io.Writer) int { - if len(args) == 1 && (args[0] == "--version" || args[0] == "version") { - info := buildinfo.Current() - fmt.Fprintf(stdout, "%s %s (%s)\n", name, info.Version, info.Commit) - return 0 - } - fmt.Fprintf(stderr, "%s runtime is not enabled in the foundation milestone; use --version\n", name) - return 2 -} diff --git a/internal/config/config.go b/internal/config/config.go index f38946b..9a0a1a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,7 +16,6 @@ import ( const ( BackendSimulator = "simulator" BackendHardware = "hardware" - BackendReplay = "replay" ) type Config struct { @@ -146,9 +145,9 @@ func (cfg Config) Validate() error { } switch cfg.Runtime.Backend { - case BackendSimulator, BackendHardware, BackendReplay: + case BackendSimulator, BackendHardware: default: - return fmt.Errorf("runtime.backend must be simulator, hardware, or replay: %q", cfg.Runtime.Backend) + return fmt.Errorf("runtime.backend must be simulator or hardware: %q", cfg.Runtime.Backend) } if cfg.Runtime.Backend == BackendHardware { if strings.TrimSpace(cfg.Runtime.AgentSocket) == "" || !filepath.IsAbs(cfg.Runtime.AgentSocket) { diff --git a/internal/connectivityagent/source.go b/internal/connectivityagent/source.go new file mode 100644 index 0000000..bee61bd --- /dev/null +++ b/internal/connectivityagent/source.go @@ -0,0 +1,66 @@ +package connectivityagent + +import ( + "context" + "errors" + + "github.com/leonfox28/simplus/internal/agentapi" + app "github.com/leonfox28/simplus/internal/application/connectivity" +) + +type Client interface { + Snapshot(context.Context, bool) (agentapi.Snapshot, error) + Probe(context.Context, agentapi.ProbeRequest) (agentapi.ProbeResponse, error) +} +type Source struct{ Client Client } + +func (s Source) Targets(ctx context.Context) ([]app.ProbeTarget, error) { + snapshot, err := s.Client.Snapshot(ctx, true) + if err != nil { + return nil, err + } + targets := make([]app.ProbeTarget, 0, len(snapshot.Devices)) + for _, d := range snapshot.Devices { + targets = append(targets, app.ProbeTarget{Instance: snapshot.AgentInstanceID, ID: d.ID, Revision: snapshot.Revision, Generation: snapshot.Generation, DeviceGeneration: d.Generation}) + } + return targets, nil +} +func (s Source) Probe(ctx context.Context, target app.ProbeTarget) (app.CellularSample, error) { + probe, err := s.Client.Probe(ctx, agentapi.ProbeRequest{DeviceIDs: []string{target.ID}}) + if err != nil { + return app.CellularSample{}, err + } + if probe.AgentInstanceID != target.Instance || probe.SnapshotGeneration != target.Generation || probe.SnapshotRevision != target.Revision || len(probe.Devices) != 1 || probe.Devices[0].DeviceID != target.ID { + return app.CellularSample{}, errors.New("cellular observation target changed") + } + d := probe.Devices[0] + sample := app.CellularSample{Fingerprint: d.Identity.EquipmentIdentityFingerprint, ObservedAt: probe.ObservedAt} + if len(sample.Fingerprint) != 64 { + return sample, nil + } + // Partial reads and unknown SIM/RF states never manufacture disconnection. + if d.State != agentapi.ProbeStateComplete { + return sample, nil + } + if d.SIM.State == agentapi.SIMStateAbsent || d.SIM.State == agentapi.SIMStateLocked { + sample.Known = true + sample.Reason = "sim_unavailable" + return sample, nil + } + if d.RF.State == agentapi.RFStateOff || d.RF.State == agentapi.RFStateMinimum { + sample.Known = true + sample.Reason = "rf_off" + return sample, nil + } + if d.SIM.State != agentapi.SIMStatePresent || d.SIM.PrimaryLockState != agentapi.PrimaryLockReady || d.RF.State != agentapi.RFStateOn { + return sample, nil + } + classification := agentapi.ClassifyCellular(d) + switch classification.State { + case agentapi.CellularRegisteredHome, agentapi.CellularRegisteredRoaming: + sample.Known, sample.Connected, sample.Reason = true, true, "registered" + case agentapi.CellularSearching, agentapi.CellularDenied, agentapi.CellularNotRegistered: + sample.Known, sample.Reason = true, "registration_lost" + } + return sample, nil +} diff --git a/internal/connectivityagent/source_test.go b/internal/connectivityagent/source_test.go new file mode 100644 index 0000000..65daa39 --- /dev/null +++ b/internal/connectivityagent/source_test.go @@ -0,0 +1,50 @@ +package connectivityagent + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/leonfox28/simplus/internal/agentapi" + app "github.com/leonfox28/simplus/internal/application/connectivity" +) + +type fixtureClient struct{ response agentapi.ProbeResponse } + +func (c fixtureClient) Snapshot(context.Context, bool) (agentapi.Snapshot, error) { + return agentapi.Snapshot{}, nil +} +func (c fixtureClient) Probe(context.Context, agentapi.ProbeRequest) (agentapi.ProbeResponse, error) { + return c.response, nil +} +func TestOnlyConfirmedRegistrationAndLossProduceObservations(t *testing.T) { + target := app.ProbeTarget{Instance: "01234567-89ab-cdef-0123-456789abcdef", ID: "usb-1-3", Generation: 1, Revision: strings.Repeat("a", 64)} + for _, test := range []struct { + name, state, sim, rf, registration string + known, connected bool + }{ + {"home", agentapi.ProbeStateComplete, agentapi.SIMStatePresent, agentapi.RFStateOn, agentapi.RegistrationRegisteredHome, true, true}, + {"roaming", agentapi.ProbeStateComplete, agentapi.SIMStatePresent, agentapi.RFStateOn, agentapi.RegistrationRegisteredRoaming, true, true}, + {"searching", agentapi.ProbeStateComplete, agentapi.SIMStatePresent, agentapi.RFStateOn, agentapi.RegistrationSearching, true, false}, + {"unknown", agentapi.ProbeStateComplete, agentapi.SIMStatePresent, agentapi.RFStateOn, agentapi.RegistrationUnknown, false, false}, + {"timeout", agentapi.ProbeStateFailed, agentapi.SIMStateAbsent, agentapi.RFStateOff, agentapi.RegistrationUnknown, false, false}, + {"SIM absent", agentapi.ProbeStateComplete, agentapi.SIMStateAbsent, agentapi.RFStateOn, agentapi.RegistrationUnknown, true, false}, + {"RF off", agentapi.ProbeStateComplete, agentapi.SIMStatePresent, agentapi.RFStateOff, agentapi.RegistrationUnknown, true, false}, + } { + t.Run(test.name, func(t *testing.T) { + probe := agentapi.DeviceProbe{DeviceID: target.ID, State: test.state, SIM: agentapi.SIMObservation{State: test.sim, PrimaryLockState: agentapi.PrimaryLockReady}, RF: agentapi.RFObservation{State: test.rf}, Registrations: []agentapi.RegistrationObservation{{Domain: agentapi.RegistrationDomainCS, State: agentapi.RegistrationUnknown}, {Domain: agentapi.RegistrationDomainPacket, State: agentapi.RegistrationUnknown}, {Domain: agentapi.RegistrationDomainEPS, State: test.registration}}} + probe.Identity.EquipmentIdentityFingerprint = strings.Repeat("b", 64) + source := Source{Client: fixtureClient{agentapi.ProbeResponse{AgentInstanceID: target.Instance, SnapshotGeneration: target.Generation, SnapshotRevision: target.Revision, ObservedAt: time.Now(), Devices: []agentapi.DeviceProbe{probe}}}} + sample, err := source.Probe(t.Context(), target) + if err != nil || sample.Known != test.known || sample.Connected != test.connected { + t.Fatalf("sample=%+v err=%v", sample, err) + } + stale := target + stale.Instance = "fedcba98-7654-3210-fedc-ba9876543210" + if _, err := source.Probe(t.Context(), stale); err == nil { + t.Fatal("stale instance accepted") + } + }) + } +} diff --git a/internal/containercontract/contract_test.go b/internal/containercontract/contract_test.go index d797025..28a3091 100644 --- a/internal/containercontract/contract_test.go +++ b/internal/containercontract/contract_test.go @@ -70,7 +70,7 @@ func readCompose(t *testing.T) composeFile { func TestComposePreservesThreeProcessPrivilegeBoundaries(t *testing.T) { compose := readCompose(t) - for _, name := range []string{"data-init", "agent", "netd", "app", "bootstrap"} { + for _, name := range []string{"data-init", "agent", "netd", "app", "provision"} { if _, found := compose.Services[name]; !found { t.Fatalf("compose service %q is missing", name) } @@ -112,6 +112,13 @@ func TestComposePreservesThreeProcessPrivilegeBoundaries(t *testing.T) { assertBindMount(t, agent, "/sys/bus/usb-serial/drivers/option1/new_id", "/host/sys/bus/usb-serial/drivers/option1/new_id", false, true) netd := compose.Services["netd"] + assertBindMount(t, netd, "./data/control-v2/mihomo", "/var/lib/simplus/mihomo", false, false) + for _, mount := range netd.Volumes { + if mount["type"] == "bind" && mount["source"] != "./data/control-v2/mihomo" { + t.Fatalf("netd exposes unnecessary host state: %#v", mount) + } + } + if netd.NetworkMode == "host" || !reflect.DeepEqual(netd.Networks, []string{"runtime"}) { t.Fatalf("netd network boundary = mode %q networks %#v", netd.NetworkMode, netd.Networks) } @@ -140,7 +147,7 @@ func TestComposePreservesThreeProcessPrivilegeBoundaries(t *testing.T) { if dataInit.User != "0:0" || !reflect.DeepEqual(dataInit.CapAdd, []string{"CHOWN", "DAC_OVERRIDE", "FOWNER"}) || dataInit.NetworkMode != "none" { t.Fatalf("data-init privilege boundary = %#v", dataInit) } - bootstrap := compose.Services["bootstrap"] + bootstrap := compose.Services["provision"] if bootstrap.User != "0:0" || !reflect.DeepEqual(bootstrap.CapAdd, []string{"DAC_OVERRIDE"}) || bootstrap.NetworkMode != "none" { t.Fatalf("bootstrap privilege boundary = %#v", bootstrap) } @@ -148,12 +155,12 @@ func TestComposePreservesThreeProcessPrivilegeBoundaries(t *testing.T) { func TestSourceComposeRequiresExplicitDevelopmentTagAndRuntimeVolumes(t *testing.T) { compose := readCompose(t) - for _, name := range []string{"data-init", "agent", "netd", "app", "bootstrap"} { + for _, name := range []string{"data-init", "agent", "netd", "app", "provision"} { image := compose.Services[name].Image if strings.HasSuffix(image, ":latest") || !strings.Contains(image, "${SIMPLUS_IMAGE_TAG:?set SIMPLUS_IMAGE_TAG for source-tree development validation}") { t.Fatalf("service %q does not require the explicit source-development image tag: %q", name, image) } - if len(compose.Services[name].Healthcheck) == 0 && name != "data-init" && name != "bootstrap" { + if len(compose.Services[name].Healthcheck) == 0 && name != "data-init" && name != "provision" { t.Fatalf("service %q has no typed healthcheck", name) } } @@ -313,7 +320,7 @@ func TestDataInitKeepsPrivateFixedUIDStateAndDoesNotOverwriteCore(t *testing.T) } text := string(body) for _, required := range []string{ - `prepare_directory "$root/core" 10001 10001 0700`, + `prepare_directory "$root/control-v2" 10001 10001 0700`, `prepare_directory "$root/agent" 10002 10002 0700`, `[ ! -L "$core_manifest" ] && [ -f "$core_manifest" ]`, `refusing to guess active state`, diff --git a/internal/containercontract/release_bundle_test.go b/internal/containercontract/release_bundle_test.go index 5c45751..da27771 100644 --- a/internal/containercontract/release_bundle_test.go +++ b/internal/containercontract/release_bundle_test.go @@ -117,7 +117,7 @@ func TestContainerReleaseBundleIsDeterministicAndAllowlisted(t *testing.T) { "agent": "ghcr.io/leonfox28/simplus-agent:v0.1.0", "netd": "ghcr.io/leonfox28/simplus-netd:v0.1.0", "app": "ghcr.io/leonfox28/simplus-control:v0.1.0", - "bootstrap": "ghcr.io/leonfox28/simplus-control:v0.1.0", + "provision": "ghcr.io/leonfox28/simplus-control:v0.1.0", } for name, wantImage := range wantImages { if got := compose.Services[name].Image; got != wantImage { diff --git a/internal/control/bootstrap.go b/internal/control/bootstrap.go index 65d2694..b3f8b77 100644 --- a/internal/control/bootstrap.go +++ b/internal/control/bootstrap.go @@ -3,7 +3,6 @@ package control import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -11,19 +10,12 @@ import ( "log/slog" "net" "net/http" - "time" setupapp "github.com/leonfox28/simplus/internal/application/setup" ) -const bootstrapPath = "/v1/bootstrap" const provisionAdministratorPath = "/v1/administrator/provision" -type BootstrapResponse struct { - Code string `json:"code"` - ExpiresAt time.Time `json:"expiresAt"` -} - type ProvisionAdministratorRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -34,23 +26,22 @@ type ProvisionAdministratorResponse struct { Created bool `json:"created"` } -type BootstrapHandler struct { +type ProvisionHandler struct { setup *setupapp.Service logger *slog.Logger } -func NewBootstrapHandler(service *setupapp.Service, logger *slog.Logger) http.Handler { +func NewProvisionHandler(service *setupapp.Service, logger *slog.Logger) http.Handler { if logger == nil { logger = slog.Default() } - handler := &BootstrapHandler{setup: service, logger: logger} + handler := &ProvisionHandler{setup: service, logger: logger} mux := http.NewServeMux() - mux.HandleFunc(bootstrapPath, handler.generate) mux.HandleFunc(provisionAdministratorPath, handler.provisionAdministrator) return mux } -func (handler *BootstrapHandler) provisionAdministrator(w http.ResponseWriter, r *http.Request) { +func (handler *ProvisionHandler) provisionAdministrator(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -116,75 +107,6 @@ func postControlJSON[Request any, Response any](ctx context.Context, socketPath, return result, nil } -func (handler *BootstrapHandler) generate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - grant, err := handler.setup.GenerateBootstrap(r.Context()) - if err != nil { - if errors.Is(err, setupapp.ErrSetupUnavailable) { - writeControlJSON(w, http.StatusConflict, map[string]string{"code": "SETUP_UNAVAILABLE"}) - return - } - handler.logger.ErrorContext(r.Context(), "root bootstrap generation failed", "error", err) - writeControlJSON(w, http.StatusInternalServerError, map[string]string{"code": "BOOTSTRAP_GENERATION_FAILED"}) - return - } - writeControlJSON(w, http.StatusCreated, BootstrapResponse{Code: grant.Code, ExpiresAt: grant.ExpiresAt}) -} - -func GenerateBootstrap(ctx context.Context, socketPath string) (BootstrapResponse, error) { - transport := &http.Transport{ - Proxy: nil, - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - var dialer net.Dialer - return dialer.DialContext(ctx, "unix", socketPath) - }, - DisableKeepAlives: true, - } - defer transport.CloseIdleConnections() - client := &http.Client{Transport: transport} - request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://simplusd"+bootstrapPath, nil) - if err != nil { - return BootstrapResponse{}, err - } - response, err := client.Do(request) - if err != nil { - return BootstrapResponse{}, fmt.Errorf("contact root control socket: %w", err) - } - defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, 4097)) - if err != nil { - return BootstrapResponse{}, fmt.Errorf("read root control response: %w", err) - } - if len(body) > 4096 { - return BootstrapResponse{}, fmt.Errorf("root control response is too large") - } - if response.StatusCode != http.StatusCreated { - var failure struct { - Code string `json:"code"` - } - if json.Unmarshal(body, &failure) == nil && failure.Code != "" { - return BootstrapResponse{}, fmt.Errorf("root control rejected bootstrap generation: %s", failure.Code) - } - return BootstrapResponse{}, fmt.Errorf("root control rejected bootstrap generation with HTTP %d", response.StatusCode) - } - var result BootstrapResponse - if err := json.Unmarshal(body, &result); err != nil { - return BootstrapResponse{}, fmt.Errorf("decode root control response: %w", err) - } - decoded, err := base64.RawURLEncoding.Strict().DecodeString(result.Code) - if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != result.Code { - return BootstrapResponse{}, fmt.Errorf("root control returned an invalid bootstrap code") - } - if result.ExpiresAt.IsZero() { - return BootstrapResponse{}, fmt.Errorf("root control returned no expiry") - } - return result, nil -} - func writeControlJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") diff --git a/internal/control/bootstrap_test.go b/internal/control/bootstrap_test.go index 0a10e43..4ce3508 100644 --- a/internal/control/bootstrap_test.go +++ b/internal/control/bootstrap_test.go @@ -14,23 +14,6 @@ import ( "github.com/leonfox28/simplus/internal/storage/sqlite" ) -func newBootstrapSetupService(t *testing.T, stores *sqlite.Set, withAdministrator bool) *setup.Service { - t.Helper() - dependencies := setup.Dependencies{ - StateStore: stores, - AuthorizationStore: stores, - } - if withAdministrator { - dependencies.AdministratorStore = stores - dependencies.PasswordHasher = password.NewDefaultHasher() - } - service, err := setup.New(dependencies) - if err != nil { - t.Fatal(err) - } - return service -} - func TestBootstrapControlRejectsUnauthorizedUnixPeer(t *testing.T) { ctx := context.Background() temporaryRoot, err := os.MkdirTemp("/tmp", "simplus-control-denied-") @@ -54,15 +37,15 @@ func TestBootstrapControlRejectsUnauthorizedUnixPeer(t *testing.T) { if err != nil { t.Fatal(err) } - server := &http.Server{Handler: control.NewBootstrapHandler(newBootstrapSetupService(t, stores, false), slog.Default())} + server := &http.Server{Handler: control.NewProvisionHandler(newBootstrapSetupService(t, stores, false), slog.Default())} serveDone := make(chan error, 1) go func() { serveDone <- server.Serve(listener) }() - if _, err := control.GenerateBootstrap(ctx, socketPath); err == nil { + if _, err := control.ProvisionAdministrator(ctx, socketPath, control.ProvisionAdministratorRequest{}); err == nil { t.Fatal("unauthorized Unix peer generated a bootstrap grant") } var grants int - if err := stores.Runtime.QueryRow(`SELECT count(*) FROM setup_bootstrap_grant`).Scan(&grants); err != nil { + if err := stores.DB.QueryRow(`SELECT count(*) FROM administrators`).Scan(&grants); err != nil { t.Fatal(err) } if grants != 0 { @@ -76,50 +59,6 @@ func TestBootstrapControlRejectsUnauthorizedUnixPeer(t *testing.T) { } } -func TestBootstrapControlRoundTripOverAuthorizedUnixPeer(t *testing.T) { - ctx := context.Background() - temporaryRoot, err := os.MkdirTemp("/tmp", "simplus-control-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(temporaryRoot) }) - dataRoot := filepath.Join(temporaryRoot, "data") - if err := os.Mkdir(dataRoot, 0o700); err != nil { - t.Fatal(err) - } - stores, err := sqlite.OpenSet(ctx, filepath.Join(dataRoot, "db")) - if err != nil { - t.Fatal(err) - } - defer stores.Close() - - socketPath := control.SocketPath(dataRoot) - listener, err := control.ListenRootOnly(socketPath, uint32(os.Geteuid())) - if err != nil { - t.Fatal(err) - } - server := &http.Server{Handler: control.NewBootstrapHandler(newBootstrapSetupService(t, stores, false), slog.Default())} - serveDone := make(chan error, 1) - go func() { serveDone <- server.Serve(listener) }() - - response, err := control.GenerateBootstrap(ctx, socketPath) - if err != nil { - t.Fatal(err) - } - if len(response.Code) != 43 || response.ExpiresAt.IsZero() { - t.Fatalf("bootstrap response = %#v", response) - } - if err := server.Shutdown(ctx); err != nil { - t.Fatal(err) - } - if err := <-serveDone; err != nil && err != http.ErrServerClosed { - t.Fatal(err) - } - if _, err := os.Lstat(socketPath); !os.IsNotExist(err) { - t.Fatalf("control socket remained after shutdown: %v", err) - } -} - func TestAdministratorProvisioningIsOneTimeOverRootControlSocket(t *testing.T) { ctx := context.Background() root, err := os.MkdirTemp("/tmp", "simplus-provision-") @@ -141,7 +80,7 @@ func TestAdministratorProvisioningIsOneTimeOverRootControlSocket(t *testing.T) { if err != nil { t.Fatal(err) } - server := &http.Server{Handler: control.NewBootstrapHandler(newBootstrapSetupService(t, stores, true), slog.Default())} + server := &http.Server{Handler: control.NewProvisionHandler(newBootstrapSetupService(t, stores, true), slog.Default())} serveDone := make(chan error, 1) go func() { serveDone <- server.Serve(listener) }() request := control.ProvisionAdministratorRequest{Username: "simplus_admin", Password: "first-generated-password-123", Locale: "zh-CN"} @@ -169,3 +108,12 @@ func TestAdministratorProvisioningIsOneTimeOverRootControlSocket(t *testing.T) { t.Fatal(err) } } + +func newBootstrapSetupService(t *testing.T, stores *sqlite.Set, _ bool) *setup.Service { + t.Helper() + service, err := setup.New(setup.Dependencies{StateStore: stores, AdministratorStore: stores, PasswordHasher: password.NewDefaultHasher()}) + if err != nil { + t.Fatal(err) + } + return service +} diff --git a/internal/domain/connectivity/changes.go b/internal/domain/connectivity/changes.go new file mode 100644 index 0000000..2052d7f --- /dev/null +++ b/internal/domain/connectivity/changes.go @@ -0,0 +1,32 @@ +package connectivity + +import "time" + +const Version = 1 +const Capacity = 1024 + +type Cursor struct { + Instance string `json:"instance"` + Sequence uint64 `json:"sequence"` +} +type State struct { + ObjectID string `json:"objectId"` + Connected bool `json:"connected"` + ObservedAt time.Time `json:"observedAt"` + Reason string `json:"reason"` +} +type Change struct { + Sequence uint64 `json:"sequence"` + State State `json:"state"` +} +type Snapshot struct { + Version int `json:"version"` + Cursor Cursor `json:"cursor"` + States []State `json:"states"` +} +type Changes struct { + Version int `json:"version"` + Cursor Cursor `json:"cursor"` + Reset bool `json:"reset"` + Events []Change `json:"events"` +} diff --git a/internal/domain/hardware/model.go b/internal/domain/hardware/model.go index c5a2263..6952db0 100644 --- a/internal/domain/hardware/model.go +++ b/internal/domain/hardware/model.go @@ -150,6 +150,7 @@ type Line struct { } type Snapshot struct { + AgentInstanceID string Generation uint64 ObservedAt time.Time Devices []PhysicalDevice diff --git a/internal/domain/managementtls/configuration.go b/internal/domain/managementtls/configuration.go deleted file mode 100644 index 0420941..0000000 --- a/internal/domain/managementtls/configuration.go +++ /dev/null @@ -1,30 +0,0 @@ -package managementtls - -import "time" - -type Mode string - -const ( - ModeLoopbackOnly Mode = "loopback-only" - ModeLocalCA Mode = "local-ca" - ModeImported Mode = "imported" -) - -func (mode Mode) Valid() bool { - return mode == ModeLoopbackOnly || mode == ModeLocalCA || mode == ModeImported -} - -type Configuration struct { - Mode Mode - ListenHost string - ListenPort int - SubjectAlternativeNames []string - CACertificatePEM []byte - LeafCertificatePEM []byte - EncryptedCAPrivateKey []byte - EncryptedLeafPrivateKey []byte - RootFingerprintSHA256 string - LeafNotAfter time.Time - Confirmed bool - ConfiguredAt time.Time -} diff --git a/internal/domain/mihomo/runtime.go b/internal/domain/mihomo/runtime.go new file mode 100644 index 0000000..c5f0acf --- /dev/null +++ b/internal/domain/mihomo/runtime.go @@ -0,0 +1,35 @@ +package mihomo + +import ( + "context" + "errors" + "time" +) + +var ( + ErrAlreadyRunning = errors.New("Mihomo is already running") + ErrNotRunning = errors.New("Mihomo is not running") + ErrRequestInvalid = errors.New("Mihomo supervisor request is invalid") + ErrStartupFailed = errors.New("Mihomo startup failed") +) + +type StartRequest struct { + SubscriptionID string `json:"subscriptionId"` + BinaryPath string `json:"binaryPath"` + ConfigPath string `json:"configPath"` +} + +type Status struct { + Running bool `json:"running"` + PID int `json:"pid"` + SubscriptionID string `json:"subscriptionId"` + BinaryPath string `json:"binaryPath"` + ConfigPath string `json:"configPath"` + StartedAt time.Time `json:"startedAt"` +} + +type API interface { + Status(context.Context) (Status, error) + Start(context.Context, StartRequest) (Status, error) + Stop(context.Context) error +} diff --git a/internal/domain/mihomo/validation.go b/internal/domain/mihomo/validation.go new file mode 100644 index 0000000..430ccbb --- /dev/null +++ b/internal/domain/mihomo/validation.go @@ -0,0 +1,29 @@ +package mihomo + +import ( + "errors" + "net" + "net/url" + "strings" +) + +func ValidateSubscriptionInput(name, rawURL string) (string, *url.URL, error) { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > 80 || len(rawURL) > 4096 { + return "", nil, ErrSubscriptionInvalid + } + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return "", nil, ErrSubscriptionInvalid + } + host := parsed.Hostname() + if strings.EqualFold(host, "localhost") { + return "", nil, ErrSubscriptionInvalid + } + if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsMulticast()) { + return "", nil, ErrSubscriptionInvalid + } + return name, parsed, nil +} + +var ErrSubscriptionInvalid = errors.New("Mihomo subscription request is invalid") diff --git a/internal/domain/notification/delivery.go b/internal/domain/notification/delivery.go new file mode 100644 index 0000000..5b8dd86 --- /dev/null +++ b/internal/domain/notification/delivery.go @@ -0,0 +1,49 @@ +package notification + +import "time" + +var EventKinds = []string{"sms.received", "sms.failed", "call.incoming", "call.missed", "system.degraded", "vowifi.connected", "vowifi.disconnected", "cellular.connected", "cellular.disconnected"} + +type Event struct { + Key, Kind, ObjectID, ConnectionKind string + Message, FeishuMessage string + ObservedAt time.Time +} + +type Delivery struct { + ID int64 + ChannelID, EventKind, ObjectID, ConnectionKind, Message string + Attempts int64 +} + +type Counts struct{ Pending, Failed int64 } + +type Observation struct { + ObjectID, DisplayName, Kind, Reason string + Connected bool + ObservedAt time.Time +} + +type Cursor struct { + Source, Instance string + Sequence uint64 + Gaps int64 +} + +// ConnectionMessage deliberately accepts only a fixed reason vocabulary. +func ConnectionMessage(o Observation) string { + kind := "蜂窝网络" + if o.Kind == "vowifi" { + kind = "VoWiFi" + } + state := "已断开" + if o.Connected { + state = "已连接" + } + reason := map[string]string{"registered": "注册成功", "registration_lost": "失去网络注册", "device_removed": "模组已拔出", "rf_off": "射频已关闭", "sim_unavailable": "SIM 不可用", "stopped": "主动停用", "reconnecting": "正在重新连接", "worker_exited": "连接进程退出"}[o.Reason] + message := "[Simplus] " + o.DisplayName + "\n" + kind + ":" + state + "\n观测时间:" + o.ObservedAt.UTC().Format(time.RFC3339) + if reason != "" { + message += "\n原因:" + reason + } + return message +} diff --git a/internal/domain/sms/transport.go b/internal/domain/sms/transport.go new file mode 100644 index 0000000..0d28fea --- /dev/null +++ b/internal/domain/sms/transport.go @@ -0,0 +1,105 @@ +package sms + +import ( + "context" + "time" + + "github.com/leonfox28/simplus/internal/smscodec" +) + +type SendSMSCommand struct { + AgentInstanceID string + OperationID string + MessageID string + LineID string + PhysicalDeviceID string + ModemFunctionID string + Destination string + Body string + Segments []smscodec.Segment + DeviceGeneration uint64 + ExpectedEquipmentFingerprint string + ExpectedSubscriptionFingerprint string +} + +type SendSMSResult struct { + ProviderMessageID string + State string + ErrorCode string +} + +type Sender interface { + SendSMS(context.Context, SendSMSCommand) (SendSMSResult, error) +} + +type InboxMessageReference struct { + SourceMessageID string + ReceivedAt time.Time +} + +type InboxMessage struct { + SourceMessageID string + Sender string + Body string + ReceivedAt time.Time + Segment *smscodec.Segment +} + +type InboxTarget struct { + AgentInstanceID string + LineID string + PhysicalDeviceID string + DeviceGeneration uint64 + ExpectedEquipmentFingerprint string + ExpectedSubscriptionFingerprint string +} + +type Inbox interface { + ListSMS(context.Context, InboxTarget) ([]InboxMessageReference, error) + ReadSMS(context.Context, InboxTarget, string) (InboxMessage, error) + AcknowledgeSMS(context.Context, InboxTarget, string, string) error +} + +type SubmitReport struct { + MessageID string + ProviderMessageID string + State string + ErrorCode string + CompletedAt time.Time +} + +type SubmitReportInbox interface { + ListSMSSubmitReports(context.Context, InboxTarget) ([]SubmitReport, error) + AcknowledgeSMSSubmitReport(context.Context, InboxTarget, string, string) error +} + +type TransportError struct { + Code string +} + +func (err *TransportError) Error() string { + if err == nil || err.Code == "" { + return "SMS transport failed" + } + return "SMS transport failed: " + err.Code +} + +const ( + ErrorOutcomeUnknownAfterRestart = "SEND_OUTCOME_UNKNOWN_AFTER_RESTART" + ErrorSendOutcomeUnknown = "SMS_SEND_OUTCOME_UNKNOWN" + ErrorAcceptedAwaitingReport = "IMS_SMS_ACCEPTED_AWAITING_REPORT" + ErrorCancelledBeforeDispatch = "SEND_CANCELLED_BEFORE_DISPATCH" + ErrorTransportFailed = "SMS_TRANSPORT_FAILED" + ErrorSIMNotReady = "SMS_SIM_NOT_READY" + ErrorSIMIdentityChanged = "SMS_SIM_IDENTITY_CHANGED" + ErrorEquipmentIdentityChanged = "SMS_EQUIPMENT_IDENTITY_CHANGED" + ErrorRFOff = "SMS_RF_OFF" + ErrorRegistrationDenied = "SMS_REGISTRATION_DENIED" + ErrorNotRegistered = "SMS_NOT_REGISTERED" + ErrorStatusUnavailable = "SMS_STATUS_UNAVAILABLE" + ErrorDeviceStale = "SMS_DEVICE_STALE" + SendStateAccepted = "accepted" + SendStateSent = "sent" + SendStateFailed = "failed" + SendStateUnconfirmed = "unconfirmed" +) diff --git a/internal/domain/vowifi/runtime.go b/internal/domain/vowifi/runtime.go new file mode 100644 index 0000000..5187b75 --- /dev/null +++ b/internal/domain/vowifi/runtime.go @@ -0,0 +1,55 @@ +package vowifi + +import ( + "context" + "errors" + "time" +) + +type StartRequest struct { + LineID string `json:"lineId"` + HardwareLineID string `json:"hardwareLineId"` + EgressMode string `json:"egressMode"` + CountryCode string `json:"countryCode"` +} + +type Status struct { + LineID string `json:"lineId"` + State string `json:"state"` + Stage string `json:"stage,omitempty"` + Online bool `json:"online"` + EgressMode string `json:"egressMode"` + CountryCode string `json:"countryCode"` + StartedAt time.Time `json:"startedAt,omitempty"` + RegisteredAt time.Time `json:"registeredAt,omitempty"` + NextRefresh time.Time `json:"nextRefreshAt,omitempty"` + PhoneNumber string `json:"phoneNumber"` + Attempt int `json:"attempt"` + ErrorCode string `json:"errorCode,omitempty"` +} + +type API interface { + List(context.Context) ([]Status, error) + Start(context.Context, StartRequest) (Status, error) + Stop(context.Context, string) (Status, error) +} + +const ( + EgressDirect = "direct" + EgressMihomoCountry = "mihomo-country" + StateStopped = "stopped" + StateStarting = "starting" + StateConnecting = "connecting" + StateRegistering = "registering" + StateOnline = "online" + StateReconnecting = "reconnecting" + StateStopping = "stopping" + StateFailed = "failed" +) + +var ( + ErrAlreadyRunning = errors.New("Host VoWiFi Line is already running") + ErrNotRunning = errors.New("Host VoWiFi Line is not running") + ErrRequestInvalid = errors.New("Host VoWiFi supervisor request is invalid") + ErrStartupFailed = errors.New("Host VoWiFi worker startup failed") +) diff --git a/internal/feishu/client.go b/internal/feishu/client.go new file mode 100644 index 0000000..5fe6c7a --- /dev/null +++ b/internal/feishu/client.go @@ -0,0 +1,372 @@ +package feishu + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + app "github.com/leonfox28/simplus/internal/application/notification" +) + +const ( + feishuAccountsOrigin = "https://accounts.feishu.cn" + feishuOpenAPIOrigin = "https://open.feishu.cn" + feishuRegistrationPath = "/oauth/v1/app/registration" + feishuTenantTokenPath = "/open-apis/auth/v3/tenant_access_token/internal" + feishuMessagePath = "/open-apis/im/v1/messages" + providerResponseLimit = 64 << 10 + feishuDeviceCodeLimit = 4096 + feishuRegistrationLifetimeLimit = 24 * time.Hour +) + +var providerCredentialPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,512}$`) + +type feishuBeginResponse struct { + DeviceCode string `json:"device_code"` + VerificationURIComplete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + ExpireIn int `json:"expire_in"` + Error string `json:"error"` +} + +type FeishuClient struct { + Client *http.Client + Now func() time.Time + Wait func(context.Context, time.Duration) error +} + +func NewFeishuClient() *FeishuClient { + client := &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("feishu redirects are not allowed") }, + } + return &FeishuClient{Client: client, Now: time.Now, Wait: waitForFeishuPoll} +} + +func waitForFeishuPoll(ctx context.Context, interval time.Duration) error { + timer := time.NewTimer(interval) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (client *FeishuClient) Begin(ctx context.Context) (FeishuRegistration, error) { + form := url.Values{ + "action": {"begin"}, "archetype": {"PersonalAgent"}, + "auth_method": {"client_secret"}, "request_user_info": {"open_id"}, + } + var response feishuBeginResponse + status, err := client.registrationRequest(ctx, form, &response) + if err != nil { + return FeishuRegistration{}, err + } + if response.Error != "" { + return FeishuRegistration{}, ErrFeishuProviderUnavailable + } + if status < 200 || status >= 300 || response.DeviceCode == "" || len(response.DeviceCode) > feishuDeviceCodeLimit { + return FeishuRegistration{}, ErrFeishuProviderResultInvalid + } + interval := response.Interval + if interval <= 0 { + interval = 5 + } + if interval > 60 { + return FeishuRegistration{}, ErrFeishuProviderResultInvalid + } + lifetime, err := normalizeFeishuRegistrationLifetime(response.ExpiresIn, response.ExpireIn, interval) + if err != nil { + return FeishuRegistration{}, err + } + verificationURL, err := buildFeishuVerificationURL(response.VerificationURIComplete) + if err != nil { + return FeishuRegistration{}, err + } + return FeishuRegistration{ + DeviceCode: response.DeviceCode, VerificationURL: verificationURL, + ExpiresAt: client.Now().UTC().Add(lifetime), + PollInterval: time.Duration(interval) * time.Second, + }, nil +} + +func normalizeFeishuRegistrationLifetime(currentSeconds, legacySeconds, intervalSeconds int) (time.Duration, error) { + if currentSeconds > 0 && legacySeconds > 0 && currentSeconds != legacySeconds { + return 0, ErrFeishuProviderResultInvalid + } + seconds := currentSeconds + if seconds <= 0 { + seconds = legacySeconds + } + if seconds <= 0 { + seconds = 600 + } + if seconds > int(feishuRegistrationLifetimeLimit/time.Second) || seconds < intervalSeconds { + return 0, ErrFeishuProviderResultInvalid + } + return time.Duration(seconds) * time.Second, nil +} + +func buildFeishuVerificationURL(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || len(raw) > 2048 { + return "", ErrFeishuProviderResultInvalid + } + if parsed.Scheme != "https" || (parsed.Host != "open.feishu.cn" && parsed.Host != "accounts.feishu.cn") || parsed.User != nil || parsed.Fragment != "" { + return "", ErrFeishuProviderResultInvalid + } + addons, err := encodeFeishuAddons() + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("from", "sdk") + query.Set("tp", "sdk") + query.Set("source", "go-sdk/simplus") + query.Set("name", "Simplus 飞书通知") + query.Set("desc", "Simplus 单向通知应用") + query.Set("createOnly", "true") + query.Set("addons", addons) + parsed.RawQuery = query.Encode() + if len(parsed.String()) > 4096 { + return "", ErrFeishuProviderResultInvalid + } + return parsed.String(), nil +} + +func encodeFeishuAddons() (string, error) { + body, err := json.Marshal(map[string]any{ + "preset": false, + "scopes": map[string][]string{"tenant": {"im:message:send_as_bot"}}, + }) + if err != nil { + return "", err + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write(body); err != nil { + _ = writer.Close() + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(compressed.Bytes()), nil +} + +func (client *FeishuClient) Poll(ctx context.Context, registration FeishuRegistration) (FeishuRegistrationResult, error) { + interval := registration.PollInterval + if interval <= 0 { + interval = 5 * time.Second + } + for { + remaining := registration.ExpiresAt.Sub(client.Now()) + if remaining <= 0 { + return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired + } + wait := interval + if remaining < wait { + wait = remaining + } + if err := client.Wait(ctx, wait); err != nil { + return FeishuRegistrationResult{}, err + } + if !client.Now().Before(registration.ExpiresAt) { + return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired + } + var response struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + UserInfo *struct { + OpenID string `json:"open_id"` + TenantBrand string `json:"tenant_brand"` + } `json:"user_info"` + Error string `json:"error"` + } + status, err := client.registrationRequest(ctx, url.Values{"action": {"poll"}, "device_code": {registration.DeviceCode}}, &response) + if err != nil { + return FeishuRegistrationResult{}, err + } + if response.Error != "" { + switch response.Error { + case "authorization_pending": + continue + case "slow_down": + interval += 5 * time.Second + if interval > 60*time.Second { + return FeishuRegistrationResult{}, ErrFeishuProviderResultInvalid + } + continue + case "access_denied": + return FeishuRegistrationResult{}, ErrFeishuAuthorizationDenied + case "expired_token": + return FeishuRegistrationResult{}, ErrFeishuAuthorizationExpired + default: + return FeishuRegistrationResult{}, ErrFeishuProviderUnavailable + } + } + if status < 200 || status >= 300 { + return FeishuRegistrationResult{}, ErrFeishuProviderUnavailable + } + if response.UserInfo != nil && strings.EqualFold(response.UserInfo.TenantBrand, "lark") { + return FeishuRegistrationResult{}, ErrFeishuLarkUnsupported + } + if response.ClientID != "" || response.ClientSecret != "" { + if response.UserInfo == nil { + return FeishuRegistrationResult{}, ErrFeishuProviderResultInvalid + } + result := FeishuRegistrationResult{AppID: response.ClientID, AppSecret: response.ClientSecret, OpenID: response.UserInfo.OpenID, TenantBrand: response.UserInfo.TenantBrand} + if err := app.ValidateFeishuResult(result); err != nil { + return FeishuRegistrationResult{}, err + } + return result, nil + } + } +} + +func (client *FeishuClient) registrationRequest(ctx context.Context, form url.Values, target any) (int, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuAccountsOrigin+feishuRegistrationPath, strings.NewReader(form.Encode())) + if err != nil { + return 0, ErrFeishuProviderUnavailable + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + status, body, err := client.readBoundedResponse(request) + if err != nil { + return 0, err + } + if err := decodeFeishuJSON(body, target); err != nil { + return status, err + } + return status, nil +} + +func (client *FeishuClient) SendText(ctx context.Context, credentials FeishuRegistrationResult, message string) error { + if err := app.ValidateFeishuResult(credentials); err != nil || message == "" || len([]rune(message)) > 4000 { + if err != nil { + return err + } + return ErrChannelInvalid + } + tokenBody, err := json.Marshal(map[string]string{"app_id": credentials.AppID, "app_secret": credentials.AppSecret}) + if err != nil { + return ErrFeishuProviderUnavailable + } + tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuOpenAPIOrigin+feishuTenantTokenPath, bytes.NewReader(tokenBody)) + if err != nil { + return ErrFeishuProviderUnavailable + } + tokenRequest.Header.Set("Content-Type", "application/json") + var tokenResponse struct { + Code *int `json:"code"` + TenantAccessToken string `json:"tenant_access_token"` + } + if err := client.doJSON(tokenRequest, &tokenResponse); err != nil { + return err + } + if tokenResponse.Code != nil && *tokenResponse.Code != 0 { + return deliveryFailure(*tokenResponse.Code) + } + if tokenResponse.Code == nil || !providerCredentialPattern.MatchString(tokenResponse.TenantAccessToken) { + return ErrFeishuProviderUnavailable + } + content, _ := json.Marshal(map[string]string{"text": message}) + messageBody, err := json.Marshal(map[string]string{"receive_id": credentials.OpenID, "msg_type": "text", "content": string(content)}) + if err != nil { + return ErrFeishuProviderUnavailable + } + messageRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, feishuOpenAPIOrigin+feishuMessagePath+"?receive_id_type=open_id", bytes.NewReader(messageBody)) + if err != nil { + return ErrFeishuProviderUnavailable + } + messageRequest.Header.Set("Authorization", "Bearer "+tokenResponse.TenantAccessToken) + messageRequest.Header.Set("Content-Type", "application/json") + var messageResponse struct { + Code *int `json:"code"` + } + if err := client.doJSON(messageRequest, &messageResponse); err != nil { + return err + } + if messageResponse.Code != nil && *messageResponse.Code != 0 { + return deliveryFailure(*messageResponse.Code) + } + if messageResponse.Code == nil { + return ErrFeishuProviderUnavailable + } + return nil +} + +func (client *FeishuClient) doJSON(request *http.Request, target any) error { + status, body, err := client.readBoundedResponse(request) + if err != nil { + return err + } + if status >= 400 && status < 500 && status != 408 && status != 429 { + return errors.Join(ErrFeishuProviderUnavailable, app.ErrDeliveryPermanent) + } + if status < 200 || status >= 300 { + return ErrFeishuProviderUnavailable + } + return decodeFeishuJSON(body, target) +} + +func (client *FeishuClient) readBoundedResponse(request *http.Request) (int, []byte, error) { + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "Simplus") + response, err := client.Client.Do(request) + if err != nil { + return 0, nil, fmt.Errorf("%w", ErrFeishuProviderUnavailable) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, providerResponseLimit+1)) + if err != nil || len(body) > providerResponseLimit || len(bytes.TrimSpace(body)) == 0 { + return 0, nil, ErrFeishuProviderUnavailable + } + return response.StatusCode, body, nil +} + +func decodeFeishuJSON(body []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + if err := decoder.Decode(target); err != nil { + return ErrFeishuProviderResultInvalid + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return ErrFeishuProviderResultInvalid + } + return nil +} + +type FeishuRegistration = app.FeishuRegistration +type FeishuRegistrationResult = app.FeishuRegistrationResult + +var ( + ErrFeishuAuthorizationDenied = app.ErrFeishuAuthorizationDenied + ErrFeishuAuthorizationExpired = app.ErrFeishuAuthorizationExpired + ErrFeishuProviderUnavailable = app.ErrFeishuProviderUnavailable + ErrFeishuProviderResultInvalid = app.ErrFeishuProviderResultInvalid + ErrFeishuLarkUnsupported = app.ErrFeishuLarkUnsupported + ErrChannelInvalid = app.ErrChannelInvalid +) + +func deliveryFailure(code int) error { + // The message API documents 230001 as a parameter error. Unknown codes, + // including token expiry and rate limits, remain retryable. + // https://open.feishu.cn/document/server-docs/im-v1/message/create + if code == 230001 { + return errors.Join(ErrFeishuProviderUnavailable, app.ErrDeliveryPermanent) + } + return ErrFeishuProviderUnavailable +} diff --git a/internal/feishu/client_test.go b/internal/feishu/client_test.go new file mode 100644 index 0000000..ce4d2ac --- /dev/null +++ b/internal/feishu/client_test.go @@ -0,0 +1,339 @@ +package feishu + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestFeishuBindingAcceptsCurrentOpaqueBeginResponse(t *testing.T) { + now := time.Unix(1000, 0).UTC() + deviceCode := strings.Repeat("synthetic +/%", 43) + beginBody, err := json.Marshal(map[string]any{ + "device_code": deviceCode, "verification_uri_complete": "https://open.feishu.cn/verify?user_code=synthetic", + "interval": 5, "expires_in": 3600, + }) + if err != nil { + t.Fatal(err) + } + client := NewFeishuClient() + client.Now = func() time.Time { return now } + waited := false + client.Wait = func(ctx context.Context, _ time.Duration) error { + if !waited { + waited = true + return nil + } + <-ctx.Done() + return ctx.Err() + } + pollDeviceCode := make(chan string, 1) + client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != feishuRegistrationPath { + return nil, errors.New("unexpected synthetic request path") + } + requestBody, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + form, err := url.ParseQuery(string(requestBody)) + if err != nil { + return nil, err + } + if form.Get("action") == "poll" { + pollDeviceCode <- form.Get("device_code") + return &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":"authorization_pending"}`))}, nil + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(beginBody))}, nil + })} + + waiting, err := client.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + pollCtx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := client.Poll(pollCtx, waiting); done <- err }() + verification, err := url.Parse(waiting.VerificationURL) + if err != nil || verification.Hostname() != "open.feishu.cn" || !waiting.ExpiresAt.Equal(now.Add(time.Hour)) { + t.Fatalf("waiting = %#v, verification parse err = %v", waiting, err) + } + pollCode := <-pollDeviceCode + if pollCode != deviceCode || len(pollCode) <= 512 { + t.Fatalf("opaque device code was not preserved: length = %d", len(pollCode)) + } + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancel=%v", err) + } +} + +func TestFeishuClientUsesMinimalCreateOnlyFlowAndPrivateOpenIDDelivery(t *testing.T) { + var requests []*http.Request + var bodies [][]byte + pollCount := 0 + client := NewFeishuClient() + client.Now = func() time.Time { return time.Unix(1000, 0).UTC() } + client.Wait = func(context.Context, time.Duration) error { return nil } + client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(request.Body) + requests = append(requests, request.Clone(context.Background())) + bodies = append(bodies, body) + response := `{}` + status := http.StatusOK + switch { + case request.URL.Hostname() == "accounts.feishu.cn" && strings.Contains(string(body), "action=begin"): + response = `{"device_code":"device_synthetic","verification_uri_complete":"https://accounts.feishu.cn/verify?user_code=synthetic","interval":1,"expire_in":60}` + case request.URL.Hostname() == "accounts.feishu.cn": + pollCount++ + if pollCount == 1 { + status = http.StatusBadRequest + response = `{"error":"authorization_pending"}` + } else if pollCount == 2 { + status = http.StatusBadRequest + response = `{"error":"slow_down"}` + } else { + response = `{"client_id":"cli_synthetic","client_secret":"secret_synthetic","user_info":{"open_id":"ou_synthetic","tenant_brand":"feishu"}}` + } + case request.URL.Path == feishuTenantTokenPath: + response = `{"code":0,"tenant_access_token":"token_synthetic"}` + case request.URL.Path == feishuMessagePath: + response = `{"code":0}` + } + return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(response))}, nil + })} + registration, err := client.Begin(context.Background()) + if err != nil { + t.Fatal(err) + } + verification, _ := url.Parse(registration.VerificationURL) + if verification.Hostname() != "accounts.feishu.cn" || verification.Query().Get("createOnly") != "true" { + t.Fatalf("verification URL = %s", registration.VerificationURL) + } + compressed, err := base64.RawURLEncoding.DecodeString(verification.Query().Get("addons")) + if err != nil { + t.Fatal(err) + } + reader, err := gzip.NewReader(bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + addonsBody, _ := io.ReadAll(reader) + var addons map[string]json.RawMessage + if err := json.Unmarshal(addonsBody, &addons); err != nil { + t.Fatal(err) + } + var preset bool + var scopes map[string][]string + if len(addons) != 2 || json.Unmarshal(addons["preset"], &preset) != nil || json.Unmarshal(addons["scopes"], &scopes) != nil || preset || len(scopes) != 1 || len(scopes["tenant"]) != 1 || scopes["tenant"][0] != "im:message:send_as_bot" { + t.Fatalf("addons = %s", addonsBody) + } + beginForm, err := url.ParseQuery(string(bodies[0])) + if err != nil { + t.Fatal(err) + } + if len(beginForm) != 4 || beginForm.Get("action") != "begin" || beginForm.Get("archetype") != "PersonalAgent" || beginForm.Get("auth_method") != "client_secret" || beginForm.Get("request_user_info") != "open_id" { + t.Fatalf("begin form = %#v", beginForm) + } + result, err := client.Poll(context.Background(), registration) + if err != nil { + t.Fatal(err) + } + if err := client.SendText(context.Background(), result, "synthetic binding test"); err != nil { + t.Fatal(err) + } + if pollCount != 3 || len(requests) != 6 { + t.Fatalf("requests = %d, polls = %d", len(requests), pollCount) + } + if !strings.Contains(string(bodies[5]), `"receive_id":"ou_synthetic"`) || requests[5].URL.Query().Get("receive_id_type") != "open_id" { + t.Fatalf("message request = %s %s", requests[5].URL, bodies[5]) + } +} + +func TestNormalizeFeishuRegistrationLifetime(t *testing.T) { + limitSeconds := int(feishuRegistrationLifetimeLimit / time.Second) + maxInt := int(^uint(0) >> 1) + for _, test := range []struct { + name string + currentSeconds int + legacySeconds int + intervalSeconds int + want time.Duration + wantError bool + }{ + {name: "current", currentSeconds: 3600, intervalSeconds: 5, want: time.Hour}, + {name: "legacy", legacySeconds: 60, intervalSeconds: 5, want: time.Minute}, + {name: "matching", currentSeconds: 75, legacySeconds: 75, intervalSeconds: 5, want: 75 * time.Second}, + {name: "default", legacySeconds: -1, intervalSeconds: 5, want: 600 * time.Second}, + {name: "limit", currentSeconds: limitSeconds, intervalSeconds: 5, want: feishuRegistrationLifetimeLimit}, + {name: "over limit", currentSeconds: limitSeconds + 1, intervalSeconds: 5, wantError: true}, + {name: "conflict", currentSeconds: 60, legacySeconds: 61, intervalSeconds: 5, wantError: true}, + {name: "shorter than interval", currentSeconds: 4, intervalSeconds: 5, wantError: true}, + {name: "extreme integer", currentSeconds: maxInt, intervalSeconds: 5, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := normalizeFeishuRegistrationLifetime(test.currentSeconds, test.legacySeconds, test.intervalSeconds) + if test.wantError { + if !errors.Is(err, ErrFeishuProviderResultInvalid) { + t.Fatalf("error = %v, want %v", err, ErrFeishuProviderResultInvalid) + } + return + } + if err != nil || got != test.want { + t.Fatalf("lifetime = %s, err = %v, want %s", got, err, test.want) + } + }) + } +} + +func TestFeishuClientRejectsInvalidBeginResponses(t *testing.T) { + oversizeDeviceCode, err := json.Marshal(map[string]any{ + "device_code": strings.Repeat("x", feishuDeviceCodeLimit+1), "verification_uri_complete": "https://open.feishu.cn/verify", + "interval": 1, "expires_in": 60, + }) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + status int + response string + wantError error + }{ + {name: "conflicting expiry", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify","interval":1,"expires_in":60,"expire_in":61}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "empty device code", status: http.StatusOK, response: `{"device_code":"","verification_uri_complete":"https://open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "oversize device code", status: http.StatusOK, response: string(oversizeDeviceCode), wantError: ErrFeishuProviderResultInvalid}, + {name: "empty verification URL", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "non-https", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"http://open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "untrusted host", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://example.invalid/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "host suffix", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn.example.invalid/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "userinfo", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://user@open.feishu.cn/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "port", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn:443/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "empty port", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn:/verify","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "fragment", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify#fragment","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "oversize verification URL", status: http.StatusOK, response: `{"device_code":"device","verification_uri_complete":"https://open.feishu.cn/verify?value=` + strings.Repeat("x", 2048) + `","interval":1,"expires_in":60}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "provider error", status: http.StatusBadRequest, response: `{"error":"invalid_request"}`, wantError: ErrFeishuProviderUnavailable}, + {name: "non-2xx without error", status: http.StatusInternalServerError, response: `{}`, wantError: ErrFeishuProviderResultInvalid}, + {name: "oversize response", status: http.StatusOK, response: strings.Repeat("x", providerResponseLimit+1), wantError: ErrFeishuProviderUnavailable}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := NewFeishuClient() + client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: test.status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(test.response))}, nil + })} + if _, err := client.Begin(context.Background()); !errors.Is(err, test.wantError) { + t.Fatalf("Begin error = %v, want %v", err, test.wantError) + } + }) + } + + client := NewFeishuClient() + client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("synthetic network failure") + })} + if _, err := client.Begin(context.Background()); !errors.Is(err, ErrFeishuProviderUnavailable) { + t.Fatalf("network error = %v", err) + } +} + +func TestFeishuClientMapsRegistrationErrorsFromHTTP400(t *testing.T) { + now := time.Unix(1000, 0).UTC() + for _, test := range []struct { + name string + response string + wantError error + }{ + {name: "denied", response: `{"error":"access_denied"}`, wantError: ErrFeishuAuthorizationDenied}, + {name: "expired", response: `{"error":"expired_token"}`, wantError: ErrFeishuAuthorizationExpired}, + {name: "unknown", response: `{"error":"synthetic_unknown"}`, wantError: ErrFeishuProviderUnavailable}, + {name: "missing error", response: `{}`, wantError: ErrFeishuProviderUnavailable}, + } { + t.Run(test.name, func(t *testing.T) { + client := NewFeishuClient() + client.Now = func() time.Time { return now } + client.Wait = func(context.Context, time.Duration) error { return nil } + client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(test.response))}, nil + })} + _, err := client.Poll(context.Background(), FeishuRegistration{ + DeviceCode: "device_synthetic", ExpiresAt: now.Add(time.Minute), PollInterval: time.Second, + }) + if !errors.Is(err, test.wantError) { + t.Fatalf("Poll error = %v, want %v", err, test.wantError) + } + }) + } +} + +func TestFeishuClientDoesNotPollAfterRegistrationExpiry(t *testing.T) { + now := time.Unix(1000, 0).UTC() + requests := 0 + client := NewFeishuClient() + client.Now = func() time.Time { return now } + client.Wait = func(_ context.Context, duration time.Duration) error { + if duration != time.Second { + t.Fatalf("wait duration = %s", duration) + } + now = now.Add(duration) + return nil + } + client.Client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests++ + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"error":"authorization_pending"}`))}, nil + })} + _, err := client.Poll(context.Background(), FeishuRegistration{ + DeviceCode: "device_synthetic", ExpiresAt: now.Add(time.Second), PollInterval: 5 * time.Second, + }) + if !errors.Is(err, ErrFeishuAuthorizationExpired) || requests != 0 { + t.Fatalf("poll err = %v, requests = %d", err, requests) + } +} + +func TestFeishuClientRequiresExplicitProviderSuccessCodes(t *testing.T) { + credentials := FeishuRegistrationResult{AppID: "cli_synthetic", AppSecret: "secret_synthetic", OpenID: "ou_synthetic", TenantBrand: "feishu"} + for _, test := range []struct { + name string + tokenResponse string + tokenStatus int + messageBody string + messageStatus int + }{ + {name: "token missing code", tokenResponse: `{"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, + {name: "token nonzero code", tokenResponse: `{"code":1,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, + {name: "token non-2xx", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusBadRequest, messageBody: `{"code":0}`, messageStatus: http.StatusOK}, + {name: "message missing code", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{}`, messageStatus: http.StatusOK}, + {name: "message nonzero code", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":1}`, messageStatus: http.StatusOK}, + {name: "message non-2xx", tokenResponse: `{"code":0,"tenant_access_token":"token_synthetic"}`, tokenStatus: http.StatusOK, messageBody: `{"code":0}`, messageStatus: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + client := NewFeishuClient() + client.Client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + body := test.messageBody + status := test.messageStatus + if request.URL.Path == feishuTenantTokenPath { + body = test.tokenResponse + status = test.tokenStatus + } + return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + if err := client.SendText(context.Background(), credentials, "synthetic message"); !errors.Is(err, ErrFeishuProviderUnavailable) { + t.Fatalf("SendText error = %v", err) + } + }) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/internal/ims/config.go b/internal/ims/config.go new file mode 100644 index 0000000..f6efaf5 --- /dev/null +++ b/internal/ims/config.go @@ -0,0 +1,194 @@ +package ims + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "regexp" + "strings" + + "github.com/leonfox28/simplus/internal/agentapi" +) + +const ( + SIMAKASocket = "/run/simplus-agent/sim-aka.sock" + ReadOnlyAgentSocket = "/run/simplus-agent/simplus-agent.sock" + + ConnectionName = "vowifi-ims" + IMSAPN = "ims" + EPDGFQDN = "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org" +) + +var ( + lowerHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) + deviceID = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) + imsi = regexp.MustCompile(`^23415[0-9]{10}$`) + eapIdentity = regexp.MustCompile(`^023415[0-9]{10}@nai\.epc\.mnc015\.mcc234\.3gppnetwork\.org$`) +) + +// Input is the transient, root-only material needed to configure a single +// supported operator EAP-AKA connection. The IMSI and derived identity must only be +// kept in memory or in the root-owned runtime directory, which the runner removes. +type Input struct { + Target agentapi.SIMAKATarget + IMSI string +} + +type Config struct { + StrongSwan []byte + VICI []byte +} + +type RuntimePaths struct { + RunDirectory string + StrongSwanConfig string + VICISocket string + LogPipe string +} + +type ConnectionInput struct { + Version int `json:"version"` + Identity string `json:"identity"` +} + +func PathsFor(runDirectory string) (RuntimePaths, error) { + if !filepath.IsAbs(runDirectory) { + return RuntimePaths{}, errors.New("runtime directory must be absolute") + } + runDirectory = filepath.Clean(runDirectory) + if runDirectory == "/" || !strings.HasPrefix(runDirectory, "/run/") { + return RuntimePaths{}, errors.New("runtime directory must be private tmpfs state") + } + return RuntimePaths{ + RunDirectory: runDirectory, StrongSwanConfig: filepath.Join(runDirectory, "strongswan.conf"), + VICISocket: filepath.Join(runDirectory, "charon.vici"), LogPipe: filepath.Join(runDirectory, "charon.pipe"), + }, nil +} + +func BuildAt(input Input, paths RuntimePaths) (Config, error) { + if err := validateInput(input); err != nil { + return Config{}, err + } + expected, err := PathsFor(paths.RunDirectory) + if err != nil || paths != expected { + return Config{}, errors.New("invalid strongSwan runtime paths") + } + identity := "0" + input.IMSI + "@nai.epc.mnc015.mcc234.3gppnetwork.org" + target := input.Target + + strongSwan := fmt.Sprintf(`charon { + load_modular = yes + install_routes = no + install_virtual_ip = yes + filelog { + hil-pipe { + path = %s + append = no + flush_line = yes + ike_name = no + default = 1 + ike = 2 + enc = 1 + net = 1 + knl = 1 + cfg = 1 + job = -1 + } + } + plugins { + random { load = yes } + nonce { load = yes } + openssl { load = yes } + sha1 { load = yes } + sha2 { load = yes } + aes { load = yes } + fips-prf { load = yes } + hmac { load = yes } + kdf { load = yes } + x509 { load = yes } + pubkey { load = yes } + pem { load = yes } + pkcs1 { load = yes } + pkcs8 { load = yes } + revocation { load = yes } + constraints { load = yes } + kernel-netlink { load = yes } + socket-default { load = yes } + vici { + load = yes + socket = unix://%s + } + eap-identity { load = yes } + eap-aka { load = yes } + p-cscf { + load = yes + enable { + %s = yes + } + } + simplus-simaka { + load = yes + socket = %s + agent_instance_id = %s + snapshot_generation = %d + snapshot_revision = %s + device_id = %s + device_generation = %d + identity_fingerprint = %s + expected_identity = %s + } + } +} + +charon-systemd { + journal { + default = -1 + } +} +`, paths.LogPipe, paths.VICISocket, ConnectionName, SIMAKASocket, target.AgentInstanceID, + target.SnapshotGeneration, target.SnapshotRevision, target.DeviceID, + target.DeviceGeneration, target.IdentityFingerprint, identity) + + viciConfig, err := json.Marshal(ConnectionInput{Version: 1, Identity: identity}) + if err != nil { + return Config{}, errors.New("encode transient VICI input") + } + viciConfig = append(viciConfig, '\n') + return Config{StrongSwan: []byte(strongSwan), VICI: viciConfig}, nil +} + +func ParseConnectionInput(data []byte) (ConnectionInput, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var input ConnectionInput + if err := decoder.Decode(&input); err != nil { + return ConnectionInput{}, errors.New("invalid transient VICI input") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return ConnectionInput{}, errors.New("invalid transient VICI input") + } + if input.Version != 1 || !eapIdentity.MatchString(input.Identity) { + return ConnectionInput{}, errors.New("invalid transient VICI input") + } + return input, nil +} + +func validateInput(input Input) error { + target := input.Target + if !agentapi.IsValidAgentInstanceID(target.AgentInstanceID) || + target.SnapshotGeneration == 0 || !lowerHex64.MatchString(target.SnapshotRevision) || + !deviceID.MatchString(target.DeviceID) || target.DeviceGeneration == 0 || + !lowerHex64.MatchString(target.IdentityFingerprint) { + return errors.New("invalid fenced SIM AKA target") + } + if !imsi.MatchString(input.IMSI) { + return errors.New("active SIM is not the expected Vodafone UK/VOXI identity") + } + if strings.ContainsAny(target.DeviceID, "{}=\n\r\t ") { + return errors.New("invalid fenced SIM AKA target") + } + return nil +} diff --git a/internal/ims/config_defaults_test.go b/internal/ims/config_defaults_test.go new file mode 100644 index 0000000..75e8e04 --- /dev/null +++ b/internal/ims/config_defaults_test.go @@ -0,0 +1,14 @@ +package ims + +const ( + RunDirectory = "/run/simplus-vowifi-hil" + StrongSwanConfig = RunDirectory + "/strongswan.conf" + VICIConfig = RunDirectory + "/vici.json" + VICISocket = RunDirectory + "/charon.vici" + LogPipe = RunDirectory + "/charon.pipe" +) + +func Build(input Input) (Config, error) { + paths, _ := PathsFor(RunDirectory) + return BuildAt(input, paths) +} diff --git a/internal/vowifihil/config_test.go b/internal/ims/config_test.go similarity index 99% rename from internal/vowifihil/config_test.go rename to internal/ims/config_test.go index 31ebd11..67f52af 100644 --- a/internal/vowifihil/config_test.go +++ b/internal/ims/config_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "strings" diff --git a/internal/vowifihil/ims_fixtures_test.go b/internal/ims/ims_fixtures_test.go similarity index 88% rename from internal/vowifihil/ims_fixtures_test.go rename to internal/ims/ims_fixtures_test.go index 16a0cf2..dfb4d50 100644 --- a/internal/vowifihil/ims_fixtures_test.go +++ b/internal/ims/ims_fixtures_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims const ( testIMSHomeDomain = "ims.mnc015.mcc234.3gppnetwork.org" diff --git a/internal/vowifihil/imsaka.go b/internal/ims/imsaka.go similarity index 99% rename from internal/vowifihil/imsaka.go rename to internal/ims/imsaka.go index cab8dae..d40cccb 100644 --- a/internal/vowifihil/imsaka.go +++ b/internal/ims/imsaka.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "context" diff --git a/internal/vowifihil/imsaka_test.go b/internal/ims/imsaka_test.go similarity index 99% rename from internal/vowifihil/imsaka_test.go rename to internal/ims/imsaka_test.go index dea18b5..7885053 100644 --- a/internal/vowifihil/imsaka_test.go +++ b/internal/ims/imsaka_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "encoding/base64" diff --git a/internal/ims/preflight.go b/internal/ims/preflight.go new file mode 100644 index 0000000..a6598e2 --- /dev/null +++ b/internal/ims/preflight.go @@ -0,0 +1,124 @@ +package ims + +import ( + "context" + "errors" + "regexp" + + "github.com/leonfox28/simplus/internal/agentapi" +) + +var hardwareLineIDPattern = regexp.MustCompile(`^agent-line-[0-9a-f]{32}$`) + +// Inspection is transient authentication material. Callers must keep it in +// memory and must never log, persist, or return its identity fields. +type Inspection struct { + Target agentapi.SIMAKATarget + IMSI string + IMSIdentity agentapi.SIMIMSIdentityResponse +} + +// InspectHostVoWiFiLine resolves one Line through the Agent's evidence-backed +// host-vowifi-auth capability. The common worker does not know the modem model +// and does not inspect RF state; model-specific SIM communication remains +// behind the Agent adapter selected for the matched device. +func InspectHostVoWiFiLine(ctx context.Context, lineID string) (Inspection, error) { + if !hardwareLineIDPattern.MatchString(lineID) { + return Inspection{}, errors.New("invalid Host VoWiFi Line") + } + readOnly, err := agentapi.NewClient(ReadOnlyAgentSocket) + if err != nil { + return Inspection{}, err + } + snapshot, err := readOnly.Snapshot(ctx, true) + if err != nil { + return Inspection{}, err + } + deviceIDs := make([]string, 0, len(snapshot.Devices)) + deviceByID := make(map[string]agentapi.DeviceReport, len(snapshot.Devices)) + for _, device := range snapshot.Devices { + if !hasObservedCapability(device, "sim-auth") || !hasObservedCapability(device, "host-vowifi-auth") { + continue + } + deviceIDs = append(deviceIDs, device.ID) + deviceByID[device.ID] = device + } + if len(deviceIDs) == 0 { + return Inspection{}, errors.New("no Host VoWiFi authentication adapter is available") + } + probe, err := readOnly.Probe(ctx, agentapi.ProbeRequest{DeviceIDs: deviceIDs}) + if err != nil || probe.AgentInstanceID != snapshot.AgentInstanceID || + probe.SnapshotGeneration != snapshot.Generation || probe.SnapshotRevision != snapshot.Revision { + return Inspection{}, errors.New("probe fence changed") + } + type match struct { + device agentapi.DeviceReport + observed agentapi.DeviceProbe + } + matches := []match{} + for _, observed := range probe.Devices { + device, exists := deviceByID[observed.DeviceID] + if !exists || observed.State != agentapi.ProbeStateComplete || + observed.SIM.State != agentapi.SIMStatePresent || observed.SIM.PrimaryLockState != agentapi.PrimaryLockReady || + len(observed.SIM.IdentityFingerprint) != 64 || observed.ActiveCallCount == nil || *observed.ActiveCallCount != 0 { + continue + } + if "agent-line-"+observed.SIM.IdentityFingerprint[:32] == lineID { + matches = append(matches, match{device: device, observed: observed}) + } + } + if len(matches) != 1 { + return Inspection{}, errors.New("Host VoWiFi Line does not resolve to exactly one ready SIM authentication adapter") + } + selected := matches[0] + target := agentapi.SIMAKATarget{ + AgentInstanceID: snapshot.AgentInstanceID, SnapshotGeneration: snapshot.Generation, + SnapshotRevision: snapshot.Revision, DeviceID: selected.device.ID, DeviceGeneration: selected.device.Generation, + IdentityFingerprint: selected.observed.SIM.IdentityFingerprint, + } + return readSIMAuthInspection(ctx, snapshot.AgentInstanceID, target) +} + +func readSIMAuthInspection(ctx context.Context, agentInstanceID string, target agentapi.SIMAKATarget) (Inspection, error) { + rootOnly, err := agentapi.NewClient(SIMAKASocket) + if err != nil { + return Inspection{}, err + } + hello, err := rootOnly.Hello(ctx) + if err != nil || hello.AgentInstanceID != agentInstanceID || + !containsString(hello.Features, agentapi.FeatureSIMAKAHIL) || + !containsString(hello.Features, agentapi.FeatureSIMIMSHIL) { + return Inspection{}, errors.New("SIM authentication socket is not current") + } + identity, err := rootOnly.SIMAKAIdentity(ctx, agentapi.SIMAKAIdentityRequest{SIMAKATarget: target}) + if err != nil { + return Inspection{}, err + } + profile, err := rootOnly.SIMIMSProfile(ctx, agentapi.SIMIMSProfileRequest{SIMAKATarget: target}) + if err != nil { + return Inspection{}, err + } + imsIdentity, err := rootOnly.SIMIMSIdentity(ctx, agentapi.SIMIMSIdentityRequest{SIMAKATarget: target}) + if err != nil || imsIdentity.IdentitySource != profile.IdentitySource { + return Inspection{}, errors.New("SIM IMS identity is unavailable") + } + return Inspection{Target: target, IMSI: identity.IMSI, IMSIdentity: imsIdentity}, nil +} + +func hasObservedCapability(device agentapi.DeviceReport, expected string) bool { + for _, capability := range device.Capabilities { + if capability.Capability == expected { + return capability.Status == agentapi.EvidenceObserved + } + } + return false +} + +func containsString(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} diff --git a/internal/vowifihil/session.go b/internal/ims/session.go similarity index 99% rename from internal/vowifihil/session.go rename to internal/ims/session.go index 7c967d3..fb45d41 100644 --- a/internal/vowifihil/session.go +++ b/internal/ims/session.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "context" diff --git a/internal/vowifihil/session_test.go b/internal/ims/session_test.go similarity index 99% rename from internal/vowifihil/session_test.go rename to internal/ims/session_test.go index f8f257d..d7e3fb2 100644 --- a/internal/vowifihil/session_test.go +++ b/internal/ims/session_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "context" diff --git a/internal/vowifihil/sip.go b/internal/ims/sip.go similarity index 99% rename from internal/vowifihil/sip.go rename to internal/ims/sip.go index 8cc27a7..9bfc698 100644 --- a/internal/vowifihil/sip.go +++ b/internal/ims/sip.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "encoding/base64" diff --git a/internal/vowifihil/sip_test.go b/internal/ims/sip_test.go similarity index 99% rename from internal/vowifihil/sip_test.go rename to internal/ims/sip_test.go index e9e5a0a..9aecb78 100644 --- a/internal/vowifihil/sip_test.go +++ b/internal/ims/sip_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "net/netip" diff --git a/internal/vowifihil/sms_rp.go b/internal/ims/sms_rp.go similarity index 99% rename from internal/vowifihil/sms_rp.go rename to internal/ims/sms_rp.go index 6cba4b5..9161d16 100644 --- a/internal/vowifihil/sms_rp.go +++ b/internal/ims/sms_rp.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "errors" diff --git a/internal/vowifihil/sms_rp_test.go b/internal/ims/sms_rp_test.go similarity index 99% rename from internal/vowifihil/sms_rp_test.go rename to internal/ims/sms_rp_test.go index befd3ef..2773c9d 100644 --- a/internal/vowifihil/sms_rp_test.go +++ b/internal/ims/sms_rp_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "bytes" diff --git a/internal/vowifihil/sms_session.go b/internal/ims/sms_session.go similarity index 99% rename from internal/vowifihil/sms_session.go rename to internal/ims/sms_session.go index 00e8267..73dbe3a 100644 --- a/internal/vowifihil/sms_session.go +++ b/internal/ims/sms_session.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "context" diff --git a/internal/vowifihil/sms_session_test.go b/internal/ims/sms_session_test.go similarity index 99% rename from internal/vowifihil/sms_session_test.go rename to internal/ims/sms_session_test.go index 69c0d3b..1e115d1 100644 --- a/internal/vowifihil/sms_session_test.go +++ b/internal/ims/sms_session_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "bytes" diff --git a/internal/vowifihil/sms_sip.go b/internal/ims/sms_sip.go similarity index 99% rename from internal/vowifihil/sms_sip.go rename to internal/ims/sms_sip.go index 510febf..5aba2f7 100644 --- a/internal/vowifihil/sms_sip.go +++ b/internal/ims/sms_sip.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "errors" diff --git a/internal/vowifihil/sms_sip_test.go b/internal/ims/sms_sip_test.go similarity index 99% rename from internal/vowifihil/sms_sip_test.go rename to internal/ims/sms_sip_test.go index 4b27e98..578ec51 100644 --- a/internal/vowifihil/sms_sip_test.go +++ b/internal/ims/sms_sip_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "bytes" diff --git a/internal/vowifihil/vici.go b/internal/ims/vici.go similarity index 99% rename from internal/vowifihil/vici.go rename to internal/ims/vici.go index a96efed..5ce3782 100644 --- a/internal/vowifihil/vici.go +++ b/internal/ims/vici.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "context" diff --git a/internal/vowifihil/vici_test.go b/internal/ims/vici_test.go similarity index 97% rename from internal/vowifihil/vici_test.go rename to internal/ims/vici_test.go index ecc9b62..4b5e37e 100644 --- a/internal/vowifihil/vici_test.go +++ b/internal/ims/vici_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "errors" diff --git a/internal/vowifihil/xfrm.go b/internal/ims/xfrm.go similarity index 99% rename from internal/vowifihil/xfrm.go rename to internal/ims/xfrm.go index 532a6b4..bd5e992 100644 --- a/internal/vowifihil/xfrm.go +++ b/internal/ims/xfrm.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "bytes" diff --git a/internal/vowifihil/xfrm_test.go b/internal/ims/xfrm_test.go similarity index 99% rename from internal/vowifihil/xfrm_test.go rename to internal/ims/xfrm_test.go index 2e6bb84..a4396c8 100644 --- a/internal/vowifihil/xfrm_test.go +++ b/internal/ims/xfrm_test.go @@ -1,4 +1,4 @@ -package vowifihil +package ims import ( "net/netip" diff --git a/internal/lifecycle/requests.go b/internal/lifecycle/requests.go new file mode 100644 index 0000000..bafd216 --- /dev/null +++ b/internal/lifecycle/requests.go @@ -0,0 +1,31 @@ +package lifecycle + +import ( + "net/http" + "sync" +) + +// Requests closes admission before waiting, so no handler can add database or +// child-process work after shutdown has passed its final barrier. +type Requests struct { + mu sync.Mutex + closed bool + work sync.WaitGroup +} + +func (r *Requests) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + if r.closed { + r.mu.Unlock() + http.Error(w, "service stopping", http.StatusServiceUnavailable) + return + } + r.work.Add(1) + r.mu.Unlock() + defer r.work.Done() + next.ServeHTTP(w, req) + }) +} +func (r *Requests) StopAdmission() { r.mu.Lock(); r.closed = true; r.mu.Unlock() } +func (r *Requests) Wait() { r.work.Wait() } diff --git a/internal/lifecycle/requests_test.go b/internal/lifecycle/requests_test.go new file mode 100644 index 0000000..bdc0bd8 --- /dev/null +++ b/internal/lifecycle/requests_test.go @@ -0,0 +1,32 @@ +package lifecycle + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestStopAdmissionDrainsActiveRequestsBeforeResourcesClose(t *testing.T) { + var requests Requests + entered, release := make(chan struct{}), make(chan struct{}) + handler := requests.Handler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { close(entered); <-release })) + done := make(chan struct{}) + go func() { handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)); close(done) }() + <-entered + requests.StopAdmission() + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest("GET", "/", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("admission=%d", response.Code) + } + waited := make(chan struct{}) + go func() { requests.Wait(); close(waited) }() + select { + case <-waited: + t.Fatal("active handler escaped shutdown barrier") + default: + } + close(release) + <-done + <-waited +} diff --git a/internal/application/mihomo/config.go b/internal/mihomoassets/config.go similarity index 92% rename from internal/application/mihomo/config.go rename to internal/mihomoassets/config.go index 2fc19e0..89fc2b0 100644 --- a/internal/application/mihomo/config.go +++ b/internal/mihomoassets/config.go @@ -1,11 +1,10 @@ -package mihomo +package mihomoassets import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -13,16 +12,10 @@ import ( "strings" "time" - "github.com/leonfox28/simplus/internal/domain/lineegress" domain "github.com/leonfox28/simplus/internal/domain/mihomo" "gopkg.in/yaml.v3" ) -var ( - ErrConfigNotReady = errors.New("Mihomo configuration is not ready") - ErrConfigValidationFailed = errors.New("Mihomo configuration validation failed") -) - type ConfigStore interface { ReadMihomoSubscription(context.Context, string) (domain.Subscription, bool, error) ListMihomoSubscriptionNodes(context.Context, string) ([]domain.Node, error) @@ -30,31 +23,6 @@ type ConfigStore interface { WriteMihomoSelectedSubscription(context.Context, string, time.Time) error } -type CountrySummary struct { - Code, Name string - NodeCount int -} - -type ArtifactMetadata struct { - SubscriptionID string `json:"subscriptionId"` - Version string `json:"version"` - RawSHA256 string `json:"rawSha256"` - ConfigSHA256 string `json:"configSha256"` - CoreVersion string `json:"coreVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Countries []CountrySummary `json:"countries"` -} - -type ConfigStatus struct { - Published bool `json:"published"` - Launchable bool `json:"launchable"` - SHA256 string `json:"sha256"` - GeneratedAt time.Time `json:"generatedAt"` - ErrorCode string `json:"errorCode"` - SelectedSubscriptionID string `json:"selectedSubscriptionId"` - RunningSubscriptionID string `json:"runningSubscriptionId"` -} - type ConfigManager struct { Root string Store ConfigStore @@ -427,13 +395,6 @@ func countryFlag(code string) string { runes := []rune(strings.ToUpper(code)) return string([]rune{0x1F1E6 + runes[0] - 'A', 0x1F1E6 + runes[1] - 'A'}) } -func countryPort(code string) int { - return lineegress.CountryListenerPort(strings.ToUpper(code)) -} - -func CountryListenerPort(code string) int { - return lineegress.CountryListenerPort(code) -} func writePrivateFile(path string, body []byte) error { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) diff --git a/internal/application/mihomo/config_test.go b/internal/mihomoassets/config_test.go similarity index 99% rename from internal/application/mihomo/config_test.go rename to internal/mihomoassets/config_test.go index 71c2b0c..f6530b5 100644 --- a/internal/application/mihomo/config_test.go +++ b/internal/mihomoassets/config_test.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "context" diff --git a/internal/application/mihomo/core.go b/internal/mihomoassets/core.go similarity index 95% rename from internal/application/mihomo/core.go rename to internal/mihomoassets/core.go index 47c54b3..420621e 100644 --- a/internal/application/mihomo/core.go +++ b/internal/mihomoassets/core.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "compress/gzip" @@ -19,23 +19,8 @@ import ( const maxExpandedBinaryBytes = 256 << 20 -var ErrVersionAlreadyInstalled = errors.New("Mihomo version is already installed") - type CommandRunner func(context.Context, string, ...string) ([]byte, error) -type CoreStatus struct { - Installed bool `json:"installed"` - Version string `json:"version"` - Architecture string `json:"architecture"` - SHA256 string `json:"sha256"` - BinaryPath string `json:"-"` - InstalledAt time.Time `json:"installedAt"` -} - -type CoreStatusReader interface { - Status() (CoreStatus, error) -} - type CoreManager struct { Root string Releases *ReleaseClient diff --git a/internal/application/mihomo/core_test.go b/internal/mihomoassets/core_test.go similarity index 99% rename from internal/application/mihomo/core_test.go rename to internal/mihomoassets/core_test.go index 8eadaab..355b0cf 100644 --- a/internal/application/mihomo/core_test.go +++ b/internal/mihomoassets/core_test.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "bytes" diff --git a/internal/application/mihomo/dashboard.go b/internal/mihomoassets/dashboard.go similarity index 91% rename from internal/application/mihomo/dashboard.go rename to internal/mihomoassets/dashboard.go index 9625a5f..8f5e8dd 100644 --- a/internal/application/mihomo/dashboard.go +++ b/internal/mihomoassets/dashboard.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "crypto/rand" @@ -10,18 +10,8 @@ import ( "regexp" ) -const ZashboardVersion = "v3.6.0" - var dashboardSecretPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) -type DashboardStatus struct { - Available bool - Version string - ControllerAddress string - URL string - Secret string -} - type DashboardManager struct { Root string ControllerAddress string diff --git a/internal/application/mihomo/dashboard_test.go b/internal/mihomoassets/dashboard_test.go similarity index 98% rename from internal/application/mihomo/dashboard_test.go rename to internal/mihomoassets/dashboard_test.go index d8c21db..af84cd9 100644 --- a/internal/application/mihomo/dashboard_test.go +++ b/internal/mihomoassets/dashboard_test.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "os" diff --git a/internal/mihomoassets/ports.go b/internal/mihomoassets/ports.go new file mode 100644 index 0000000..260729a --- /dev/null +++ b/internal/mihomoassets/ports.go @@ -0,0 +1,24 @@ +package mihomoassets + +import ( + "regexp" + + app "github.com/leonfox28/simplus/internal/application/mihomo" +) + +type CoreStatus = app.CoreStatus +type CoreStatusReader = app.CoreStatusReader +type CountrySummary = app.CountrySummary +type ArtifactMetadata = app.ArtifactMetadata +type ConfigStatus = app.ConfigStatus +type DashboardStatus = app.DashboardStatus +type Candidate = app.Candidate + +var ErrVersionAlreadyInstalled = app.ErrVersionAlreadyInstalled +var ErrConfigNotReady = app.ErrConfigNotReady +var ErrConfigValidationFailed = app.ErrConfigValidationFailed +var subscriptionIDPattern = regexp.MustCompile(`^subscription_[A-Za-z0-9_-]{22}$`) + +const ZashboardVersion = app.ZashboardVersion + +func countryPort(code string) int { return app.CountryListenerPort(code) } diff --git a/internal/application/mihomo/release.go b/internal/mihomoassets/release.go similarity index 94% rename from internal/application/mihomo/release.go rename to internal/mihomoassets/release.go index fe8c796..7bdb3ac 100644 --- a/internal/application/mihomo/release.go +++ b/internal/mihomoassets/release.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "context" @@ -39,15 +39,6 @@ type Release struct { Assets []ReleaseAsset `json:"assets"` } -type Candidate struct { - Version string `json:"version"` - AssetName string `json:"assetName"` - DownloadURL string `json:"downloadUrl"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` - Architecture string `json:"architecture"` -} - type ReleaseClient struct { HTTPClient *http.Client MetadataURL string diff --git a/internal/application/mihomo/release_test.go b/internal/mihomoassets/release_test.go similarity index 99% rename from internal/application/mihomo/release_test.go rename to internal/mihomoassets/release_test.go index c7905f7..2915fcb 100644 --- a/internal/application/mihomo/release_test.go +++ b/internal/mihomoassets/release_test.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "net/url" diff --git a/internal/application/mihomo/runtime_test.go b/internal/mihomoassets/runtime_integration_test.go similarity index 79% rename from internal/application/mihomo/runtime_test.go rename to internal/mihomoassets/runtime_integration_test.go index 862ccb2..1eb0c44 100644 --- a/internal/application/mihomo/runtime_test.go +++ b/internal/mihomoassets/runtime_integration_test.go @@ -1,4 +1,4 @@ -package mihomo +package mihomoassets import ( "context" @@ -8,6 +8,8 @@ import ( "testing" "time" + app "github.com/leonfox28/simplus/internal/application/mihomo" + "github.com/leonfox28/simplus/internal/mihomosupervisor" ) @@ -47,15 +49,11 @@ func TestNewRuntimeManagerRejectsInvalidDependencies(t *testing.T) { root := t.TempDir() config, store, _ := readyConfigFixture(root) supervisor := &runtimeSupervisorFake{} - var typedNilStore *configStoreStub - var typedNilArtifacts *ConfigManager - var typedNilCore *CoreManager - var typedNilSupervisor *runtimeSupervisorFake tests := []struct { name string root string - store RuntimeStore - artifacts ArtifactResolver + store app.RuntimeStore + artifacts app.ArtifactResolver core CoreStatusReader supervisor mihomosupervisor.API }{ @@ -65,15 +63,11 @@ func TestNewRuntimeManagerRejectsInvalidDependencies(t *testing.T) { {name: "missing artifact resolver", root: root, store: store, core: config.Core, supervisor: supervisor}, {name: "missing core reader", root: root, store: store, artifacts: config, supervisor: supervisor}, {name: "missing supervisor", root: root, store: store, artifacts: config, core: config.Core}, - {name: "typed nil store", root: root, store: typedNilStore, artifacts: config, core: config.Core, supervisor: supervisor}, - {name: "typed nil artifact resolver", root: root, store: store, artifacts: typedNilArtifacts, core: config.Core, supervisor: supervisor}, - {name: "typed nil core reader", root: root, store: store, artifacts: config, core: typedNilCore, supervisor: supervisor}, - {name: "typed nil supervisor", root: root, store: store, artifacts: config, core: config.Core, supervisor: typedNilSupervisor}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - manager, err := NewRuntimeManager(test.root, test.store, test.artifacts, test.core, test.supervisor) - if manager != nil || !errors.Is(err, ErrRuntimeManagerConfiguration) { + manager, err := app.NewRuntimeManager(test.root, test.store, test.artifacts, test.core, test.supervisor) + if manager != nil || !errors.Is(err, app.ErrRuntimeManagerConfiguration) { t.Fatalf("manager=%#v err=%v", manager, err) } }) @@ -99,7 +93,7 @@ func TestRuntimeStartAndStopUseSelectedArtifactWithoutImplicitRestart(t *testing t.Fatal(err) } supervisor := &runtimeSupervisorFake{} - runtime, err := NewRuntimeManager(root, store, config, config.Core, supervisor) + runtime, err := app.NewRuntimeManager(root, store, config, config.Core, supervisor) if err != nil { t.Fatal(err) } @@ -110,7 +104,7 @@ func TestRuntimeStartAndStopUseSelectedArtifactWithoutImplicitRestart(t *testing if len(supervisor.starts) != 1 || supervisor.starts[0].SubscriptionID != configTestSubscriptionID || supervisor.starts[0].BinaryPath != helper || !filepath.IsAbs(supervisor.starts[0].ConfigPath) { t.Fatalf("start requests=%#v", supervisor.starts) } - if _, err := runtime.Start(context.Background()); err != ErrRuntimeAlreadyRunning { + if _, err := runtime.Start(context.Background()); err != app.ErrRuntimeAlreadyRunning { t.Fatalf("second start err=%v", err) } if _, err := config.BuildSubscription(context.Background(), configTestSubscriptionID, []byte("updated fixture"), nodes); err != nil { diff --git a/internal/mihomosupervisor/local.go b/internal/mihomosupervisor/local.go index 74ca38a..7ddb2d1 100644 --- a/internal/mihomosupervisor/local.go +++ b/internal/mihomosupervisor/local.go @@ -32,6 +32,10 @@ type Local struct { processGID uint32 dropUser bool mu sync.Mutex + workers sync.WaitGroup + closing bool + owned *os.Process + ownedDone chan struct{} } func NewLocal(root string) (*Local, error) { @@ -78,6 +82,9 @@ func (local *Local) statusLocked() (Status, error) { func (local *Local) Start(ctx context.Context, request StartRequest) (Status, error) { local.mu.Lock() defer local.mu.Unlock() + if local.closing { + return Status{}, ErrRequestInvalid + } if err := local.validateRequest(request); err != nil { return Status{}, err } @@ -105,25 +112,29 @@ func (local *Local) Start(ctx context.Context, request StartRequest) (Status, er status := Status{Running: true, PID: command.Process.Pid, SubscriptionID: request.SubscriptionID, BinaryPath: request.BinaryPath, ConfigPath: request.ConfigPath, StartedAt: local.Now().UTC()} if err := writeAtomicPrivateFile(local.manifestPath(), status); err != nil { _ = command.Process.Kill() + _ = command.Wait() _ = logFile.Close() return Status{}, err } - go func() { + done := make(chan struct{}) + local.owned, local.ownedDone = command.Process, done + local.workers.Go(func() { + defer close(done) _ = command.Wait() _ = logFile.Close() - }() + }) timer := time.NewTimer(500 * time.Millisecond) select { case <-ctx.Done(): timer.Stop() - _ = terminate(status) + local.stopOwnedLocked() _ = os.Remove(local.manifestPath()) return Status{}, ctx.Err() case <-timer.C: } alive, listenerFailed := manifestAlive(status), startupListenerFailed(logPath, startOffset) if !alive || listenerFailed { - _ = terminate(status) + local.stopOwnedLocked() _ = os.Remove(local.manifestPath()) return Status{}, fmt.Errorf("%w: process_alive=%t listener_failed=%t", ErrStartupFailed, alive, listenerFailed) } @@ -142,6 +153,15 @@ func (local *Local) processAttributes() *syscall.SysProcAttr { func (local *Local) Stop(context.Context) error { local.mu.Lock() defer local.mu.Unlock() + if local.owned != nil { + local.stopOwnedLocked() + local.workers.Wait() + err := os.Remove(local.manifestPath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } status, err := local.statusLocked() if err != nil || !status.Running { return ErrNotRunning @@ -152,6 +172,35 @@ func (local *Local) Stop(context.Context) error { return os.Remove(local.manifestPath()) } +// The caller holds mu, preventing a concurrent start from replacing ownership. +func (local *Local) stopOwnedLocked() { + if local.owned == nil { + return + } + _ = local.owned.Signal(syscall.SIGTERM) + timer := time.NewTimer(5 * time.Second) + select { + case <-local.ownedDone: + timer.Stop() + case <-timer.C: + _ = local.owned.Kill() + <-local.ownedDone + } + local.owned, local.ownedDone = nil, nil +} + +// Close prevents new starts and waits for all owned child processes to be reaped. +func (local *Local) Close(ctx context.Context) error { + local.mu.Lock() + local.closing = true + local.mu.Unlock() + err := local.Stop(ctx) + if errors.Is(err, ErrNotRunning) { + return nil + } + return err +} + func (local *Local) validateRequest(request StartRequest) error { if !subscriptionIDPattern.MatchString(request.SubscriptionID) || !filepath.IsAbs(request.BinaryPath) || !filepath.IsAbs(request.ConfigPath) { return ErrRequestInvalid diff --git a/internal/mihomosupervisor/local_test.go b/internal/mihomosupervisor/local_test.go index 9ec6034..0421189 100644 --- a/internal/mihomosupervisor/local_test.go +++ b/internal/mihomosupervisor/local_test.go @@ -98,3 +98,23 @@ func TestLocalRejectsListenerStartupFailureAndLeavesNoProcess(t *testing.T) { t.Fatalf("status=%#v err=%v", status, err) } } + +func TestCloseReapsOwnedChildEvenWithoutManifest(t *testing.T) { + local, request := supervisorFixture(t, "#!/bin/sh\ntrap 'exit 0' TERM INT\nwhile :; do sleep 1; done\n") + status, err := local.Start(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(local.manifestPath()); err != nil { + t.Fatal(err) + } + if err := local.Close(t.Context()); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(status.PID, 0); err != syscall.ESRCH { + t.Fatalf("child survived close: %v", err) + } + if _, err := local.Start(t.Context(), request); !errors.Is(err, ErrRequestInvalid) { + t.Fatalf("start after close=%v", err) + } +} diff --git a/internal/mihomosupervisor/types.go b/internal/mihomosupervisor/types.go index 6be2bad..a39fa31 100644 --- a/internal/mihomosupervisor/types.go +++ b/internal/mihomosupervisor/types.go @@ -1,35 +1,12 @@ package mihomosupervisor -import ( - "context" - "errors" - "time" -) +import "github.com/leonfox28/simplus/internal/domain/mihomo" -var ( - ErrAlreadyRunning = errors.New("Mihomo is already running") - ErrNotRunning = errors.New("Mihomo is not running") - ErrRequestInvalid = errors.New("Mihomo supervisor request is invalid") - ErrStartupFailed = errors.New("Mihomo startup failed") -) +type StartRequest = mihomo.StartRequest +type Status = mihomo.Status +type API = mihomo.API -type StartRequest struct { - SubscriptionID string `json:"subscriptionId"` - BinaryPath string `json:"binaryPath"` - ConfigPath string `json:"configPath"` -} - -type Status struct { - Running bool `json:"running"` - PID int `json:"pid"` - SubscriptionID string `json:"subscriptionId"` - BinaryPath string `json:"binaryPath"` - ConfigPath string `json:"configPath"` - StartedAt time.Time `json:"startedAt"` -} - -type API interface { - Status(context.Context) (Status, error) - Start(context.Context, StartRequest) (Status, error) - Stop(context.Context) error -} +var ErrAlreadyRunning = mihomo.ErrAlreadyRunning +var ErrNotRunning = mihomo.ErrNotRunning +var ErrRequestInvalid = mihomo.ErrRequestInvalid +var ErrStartupFailed = mihomo.ErrStartupFailed diff --git a/internal/application/modem/agent_identity.go b/internal/modemagent/agent_identity.go similarity index 98% rename from internal/application/modem/agent_identity.go rename to internal/modemagent/agent_identity.go index 3abf1d6..dc77821 100644 --- a/internal/application/modem/agent_identity.go +++ b/internal/modemagent/agent_identity.go @@ -1,4 +1,4 @@ -package modem +package modemagent import ( "context" diff --git a/internal/application/modem/agent_identity_test.go b/internal/modemagent/agent_identity_test.go similarity index 98% rename from internal/application/modem/agent_identity_test.go rename to internal/modemagent/agent_identity_test.go index 4f29170..4f98b16 100644 --- a/internal/application/modem/agent_identity_test.go +++ b/internal/modemagent/agent_identity_test.go @@ -1,4 +1,4 @@ -package modem +package modemagent import ( "context" diff --git a/internal/application/modem/agent_rf.go b/internal/modemagent/agent_rf.go similarity index 99% rename from internal/application/modem/agent_rf.go rename to internal/modemagent/agent_rf.go index 5f29490..4906bde 100644 --- a/internal/application/modem/agent_rf.go +++ b/internal/modemagent/agent_rf.go @@ -1,4 +1,4 @@ -package modem +package modemagent import ( "context" diff --git a/internal/application/modem/agent_rf_test.go b/internal/modemagent/agent_rf_test.go similarity index 99% rename from internal/application/modem/agent_rf_test.go rename to internal/modemagent/agent_rf_test.go index e28fbef..af75274 100644 --- a/internal/application/modem/agent_rf_test.go +++ b/internal/modemagent/agent_rf_test.go @@ -1,4 +1,4 @@ -package modem +package modemagent import ( "context" diff --git a/internal/application/modem/agent_target.go b/internal/modemagent/agent_target.go similarity index 98% rename from internal/application/modem/agent_target.go rename to internal/modemagent/agent_target.go index 971b62a..b306a9b 100644 --- a/internal/application/modem/agent_target.go +++ b/internal/modemagent/agent_target.go @@ -1,4 +1,4 @@ -package modem +package modemagent import ( "context" diff --git a/internal/modemagent/ports.go b/internal/modemagent/ports.go new file mode 100644 index 0000000..fa46c92 --- /dev/null +++ b/internal/modemagent/ports.go @@ -0,0 +1,8 @@ +package modemagent + +import app "github.com/leonfox28/simplus/internal/application/modem" + +type EquipmentIdentity = app.EquipmentIdentity + +var ErrEquipmentIdentityUnavailable = app.ErrEquipmentIdentityUnavailable +var ErrRFUnavailable = app.ErrRFUnavailable diff --git a/internal/notificationwebhook/client.go b/internal/notificationwebhook/client.go index 5955145..d1c13f8 100644 --- a/internal/notificationwebhook/client.go +++ b/internal/notificationwebhook/client.go @@ -99,10 +99,22 @@ func (client *Client) Deliver(ctx context.Context, delivery notification.Webhook } defer response.Body.Close() responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, providerResponseLimit+1)) - if readErr != nil || len(responseBody) > providerResponseLimit || response.StatusCode < 200 || response.StatusCode >= 300 || - !deliverySucceeded(delivery.Provider, responseBody) { + if readErr != nil || len(responseBody) > providerResponseLimit || response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode >= 500 { + return notification.WebhookDeliveryResult{Outcome: notification.WebhookNetworkFailed}, ErrNetworkFailed + } + if response.StatusCode < 200 || response.StatusCode >= 300 { return notification.WebhookDeliveryResult{Outcome: notification.WebhookRejected}, ErrProviderRejected } + code, known := deliveryCode(delivery.Provider, responseBody) + if !known || code != 0 { + // Only a confirmed invalid/removed WeCom webhook is permanent. Unknown + // provider codes and malformed replies may follow a successful delivery. + // https://docs.cloudbase.net/recipes/connect-wecom-webhook-cloud-function + if known && delivery.Provider == notification.WebhookProviderWeCom && code == 93000 { + return notification.WebhookDeliveryResult{Outcome: notification.WebhookRejected}, ErrProviderRejected + } + return notification.WebhookDeliveryResult{Outcome: notification.WebhookNetworkFailed}, ErrNetworkFailed + } return notification.WebhookDeliveryResult{Outcome: notification.WebhookDelivered}, nil } @@ -124,18 +136,24 @@ func deliveryBody(delivery notification.WebhookDeliveryRequest) ([]byte, error) } } -func deliverySucceeded(provider notification.WebhookProvider, body []byte) bool { +func deliveryCode(provider notification.WebhookProvider, body []byte) (int, bool) { var response struct { ErrCode *int `json:"errcode"` Code *int `json:"code"` } if json.Unmarshal(body, &response) != nil { - return false + return 0, false } if provider == notification.WebhookProviderWeCom { - return response.ErrCode != nil && *response.ErrCode == 0 + if response.ErrCode != nil { + return *response.ErrCode, true + } + return 0, false + } + if provider == notification.WebhookProviderFeishu && response.Code != nil { + return *response.Code, true } - return provider == notification.WebhookProviderFeishu && response.Code != nil && *response.Code == 0 + return 0, false } var _ notification.WebhookPort = (*Client)(nil) diff --git a/internal/notificationwebhook/client_test.go b/internal/notificationwebhook/client_test.go index cc4e823..1f622de 100644 --- a/internal/notificationwebhook/client_test.go +++ b/internal/notificationwebhook/client_test.go @@ -316,11 +316,15 @@ func TestDeliverRejectsBoundedProviderFailures(t *testing.T) { body []byte bodyReader io.Reader wantOK bool + permanent bool }{ {name: "exact response limit", status: http.StatusOK, body: exactLimit, wantOK: true}, {name: "response over limit", status: http.StatusOK, body: append(exactLimit, ' ')}, {name: "read failure", status: http.StatusOK, bodyReader: failingReader{}}, {name: "non 2xx", status: http.StatusBadGateway, body: []byte(`{"errcode":0}`)}, + {name: "invalid webhook", status: http.StatusOK, body: []byte(`{"errcode":93000}`), permanent: true}, + {name: "permission denied", status: http.StatusForbidden, permanent: true}, + {name: "rate limit", status: http.StatusTooManyRequests}, {name: "malformed", status: http.StatusOK, body: []byte(`{`)}, {name: "missing code", status: http.StatusOK, body: []byte(`{}`)}, {name: "nonzero code", status: http.StatusOK, body: []byte(`{"errcode":1}`)}, @@ -357,7 +361,11 @@ func TestDeliverRejectsBoundedProviderFailures(t *testing.T) { } return } - if result.Outcome != notification.WebhookRejected || err != ErrProviderRejected { + wantOutcome, wantErr := notification.WebhookNetworkFailed, ErrNetworkFailed + if test.permanent { + wantOutcome, wantErr = notification.WebhookRejected, ErrProviderRejected + } + if result.Outcome != wantOutcome || err != wantErr { t.Fatalf("result = %#v, error = %v", result, err) } }) @@ -368,7 +376,7 @@ func TestProviderRejectionErrorDoesNotExposeResponseBody(t *testing.T) { const bodyMarker = "provider-private-body-marker" client := NewClient() client.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { - return response(http.StatusBadGateway, bodyMarker), nil + return response(http.StatusForbidden, bodyMarker), nil }) result, err := client.Deliver(context.Background(), notification.WebhookDeliveryRequest{ Provider: notification.WebhookProviderWeCom, diff --git a/internal/security/managementcert/local_ca.go b/internal/security/managementcert/local_ca.go deleted file mode 100644 index 91a6c98..0000000 --- a/internal/security/managementcert/local_ca.go +++ /dev/null @@ -1,198 +0,0 @@ -package managementcert - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/hex" - "encoding/pem" - "errors" - "fmt" - "math/big" - "net" - "strings" - "time" -) - -type Bundle struct { - CACertificatePEM []byte - CAPrivateKeyPEM []byte - LeafCertificatePEM []byte - LeafPrivateKeyPEM []byte - RootFingerprint string - LeafNotAfter time.Time - SANs []string -} - -func GenerateLocalCA(now time.Time, subjectAlternativeNames []string) (Bundle, error) { - normalizedSANs, dnsNames, ipAddresses, err := normalizeSANs(subjectAlternativeNames) - if err != nil { - return Bundle{}, err - } - caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return Bundle{}, fmt.Errorf("generate local CA key: %w", err) - } - leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return Bundle{}, fmt.Errorf("generate management leaf key: %w", err) - } - caSerial, err := randomSerial() - if err != nil { - return Bundle{}, err - } - leafSerial, err := randomSerial() - if err != nil { - return Bundle{}, err - } - now = now.UTC().Truncate(time.Second) - caTemplate := &x509.Certificate{ - SerialNumber: caSerial, - Subject: pkix.Name{CommonName: "Simplus Local Root CA"}, - NotBefore: now.Add(-5 * time.Minute), - NotAfter: now.AddDate(10, 0, 0), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, - BasicConstraintsValid: true, - IsCA: true, - MaxPathLen: 0, - MaxPathLenZero: true, - } - caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) - if err != nil { - return Bundle{}, fmt.Errorf("create local CA certificate: %w", err) - } - leafTemplate := &x509.Certificate{ - SerialNumber: leafSerial, - Subject: pkix.Name{CommonName: normalizedSANs[0]}, - NotBefore: now.Add(-5 * time.Minute), - NotAfter: now.Add(90 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: dnsNames, - IPAddresses: ipAddresses, - } - leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, &leafKey.PublicKey, caKey) - if err != nil { - return Bundle{}, fmt.Errorf("create management leaf certificate: %w", err) - } - caKeyDER, err := x509.MarshalPKCS8PrivateKey(caKey) - if err != nil { - return Bundle{}, fmt.Errorf("encode local CA key: %w", err) - } - leafKeyDER, err := x509.MarshalPKCS8PrivateKey(leafKey) - if err != nil { - return Bundle{}, fmt.Errorf("encode management leaf key: %w", err) - } - caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) - leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}) - caKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: caKeyDER}) - leafKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: leafKeyDER}) - if _, err := tls.X509KeyPair(leafPEM, leafKeyPEM); err != nil { - return Bundle{}, fmt.Errorf("self-check management keypair: %w", err) - } - caCertificate, err := x509.ParseCertificate(caDER) - if err != nil { - return Bundle{}, fmt.Errorf("parse generated local CA: %w", err) - } - leafCertificate, err := x509.ParseCertificate(leafDER) - if err != nil { - return Bundle{}, fmt.Errorf("parse generated management leaf: %w", err) - } - roots := x509.NewCertPool() - roots.AddCert(caCertificate) - for _, name := range normalizedSANs { - if _, err := leafCertificate.Verify(x509.VerifyOptions{Roots: roots, DNSName: name, CurrentTime: now}); err != nil { - return Bundle{}, fmt.Errorf("verify generated management certificate for %q: %w", name, err) - } - } - fingerprintDigest := sha256.Sum256(caDER) - return Bundle{ - CACertificatePEM: caPEM, - CAPrivateKeyPEM: caKeyPEM, - LeafCertificatePEM: leafPEM, - LeafPrivateKeyPEM: leafKeyPEM, - RootFingerprint: colonFingerprint(fingerprintDigest[:]), - LeafNotAfter: leafTemplate.NotAfter, - SANs: normalizedSANs, - }, nil -} - -func normalizeSANs(values []string) ([]string, []string, []net.IP, error) { - if len(values) == 0 || len(values) > 16 { - return nil, nil, nil, errors.New("management certificate requires between 1 and 16 SANs") - } - seen := make(map[string]struct{}, len(values)) - normalized := make([]string, 0, len(values)) - dnsNames := make([]string, 0, len(values)) - ipAddresses := make([]net.IP, 0, len(values)) - for _, raw := range values { - value := strings.TrimSpace(raw) - if ip := net.ParseIP(value); ip != nil { - value = ip.String() - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - normalized = append(normalized, value) - ipAddresses = append(ipAddresses, ip) - continue - } - value = strings.TrimSuffix(strings.ToLower(value), ".") - if !validDNSName(value) { - return nil, nil, nil, fmt.Errorf("invalid management certificate SAN %q", raw) - } - if _, exists := seen[value]; exists { - continue - } - seen[value] = struct{}{} - normalized = append(normalized, value) - dnsNames = append(dnsNames, value) - } - if len(normalized) == 0 { - return nil, nil, nil, errors.New("management certificate SAN list became empty") - } - return normalized, dnsNames, ipAddresses, nil -} - -func validDNSName(value string) bool { - if value == "" || len(value) > 253 || strings.Contains(value, "*") { - return false - } - labels := strings.Split(value, ".") - for _, label := range labels { - if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { - return false - } - for _, character := range label { - if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' { - return false - } - } - } - return true -} - -func randomSerial() (*big.Int, error) { - limit := new(big.Int).Lsh(big.NewInt(1), 128) - serial, err := rand.Int(rand.Reader, limit) - if err != nil { - return nil, fmt.Errorf("generate certificate serial: %w", err) - } - if serial.Sign() == 0 { - serial.SetInt64(1) - } - return serial, nil -} - -func colonFingerprint(digest []byte) string { - hexadecimal := strings.ToUpper(hex.EncodeToString(digest)) - parts := make([]string, 0, len(hexadecimal)/2) - for index := 0; index < len(hexadecimal); index += 2 { - parts = append(parts, hexadecimal[index:index+2]) - } - return strings.Join(parts, ":") -} diff --git a/internal/security/managementcert/local_ca_test.go b/internal/security/managementcert/local_ca_test.go deleted file mode 100644 index 6bc4347..0000000 --- a/internal/security/managementcert/local_ca_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package managementcert - -import ( - "crypto/tls" - "crypto/x509" - "encoding/pem" - "strings" - "testing" - "time" -) - -func TestGenerateLocalCACreatesNinetyDayLeafForNormalizedSANs(t *testing.T) { - now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) - bundle, err := GenerateLocalCA(now, []string{"SIMPLUS.local.", "192.168.50.10", "192.168.50.10"}) - if err != nil { - t.Fatal(err) - } - if len(bundle.SANs) != 2 || bundle.SANs[0] != "simplus.local" || bundle.SANs[1] != "192.168.50.10" { - t.Fatalf("SANs = %#v", bundle.SANs) - } - if bundle.LeafNotAfter.Sub(now) != 90*24*time.Hour { - t.Fatalf("leaf lifetime = %s", bundle.LeafNotAfter.Sub(now)) - } - if len(strings.Split(bundle.RootFingerprint, ":")) != 32 { - t.Fatalf("root fingerprint = %q", bundle.RootFingerprint) - } - if _, err := tls.X509KeyPair(bundle.LeafCertificatePEM, bundle.LeafPrivateKeyPEM); err != nil { - t.Fatal(err) - } - leafBlock, _ := pem.Decode(bundle.LeafCertificatePEM) - caBlock, _ := pem.Decode(bundle.CACertificatePEM) - if leafBlock == nil || caBlock == nil { - t.Fatal("generated certificate PEM is invalid") - } - leaf, err := x509.ParseCertificate(leafBlock.Bytes) - if err != nil { - t.Fatal(err) - } - ca, err := x509.ParseCertificate(caBlock.Bytes) - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - roots.AddCert(ca) - for _, name := range bundle.SANs { - if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, DNSName: name, CurrentTime: now}); err != nil { - t.Fatalf("verify %q: %v", name, err) - } - } -} - -func TestGenerateLocalCARejectsUnsafeSANs(t *testing.T) { - for _, sans := range [][]string{ - nil, - {"*.example.com"}, - {"bad_name"}, - {strings.Repeat("a", 64) + ".example"}, - } { - if _, err := GenerateLocalCA(time.Now(), sans); err == nil { - t.Fatalf("accepted SANs %#v", sans) - } - } -} diff --git a/internal/simulator/connectivity.go b/internal/simulator/connectivity.go new file mode 100644 index 0000000..ebabeca --- /dev/null +++ b/internal/simulator/connectivity.go @@ -0,0 +1,207 @@ +// Package simulator provides deterministic, in-memory capability adapters. +// It never opens a modem or starts a network process. +package simulator + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + "github.com/google/uuid" + app "github.com/leonfox28/simplus/internal/application/connectivity" + "github.com/leonfox28/simplus/internal/application/inventory" + stream "github.com/leonfox28/simplus/internal/domain/connectivity" + "github.com/leonfox28/simplus/internal/domain/modem" + "github.com/leonfox28/simplus/internal/domain/vowifi" +) + +type Inventory interface { + Topology(context.Context) (inventory.Topology, error) +} +type Cellular struct { + inventory Inventory + mu sync.Mutex + off map[string]bool +} + +func NewCellular(inventory Inventory) *Cellular { + return &Cellular{inventory: inventory, off: map[string]bool{}} +} +func (s *Cellular) Targets(ctx context.Context) ([]app.ProbeTarget, error) { + topology, err := s.inventory.Topology(ctx) + if err != nil { + return nil, err + } + result := make([]app.ProbeTarget, 0, len(topology.Devices)) + for _, d := range topology.Devices { + result = append(result, app.ProbeTarget{Instance: topology.AgentInstanceID, ID: d.ID, Generation: topology.Generation, DeviceGeneration: d.Generation, Revision: topology.Revision}) + } + return result, nil +} +func (s *Cellular) Read(ctx context.Context, id string) (modem.RuntimeStatus, error) { + topology, err := s.inventory.Topology(ctx) + if err != nil { + return modem.RuntimeStatus{}, err + } + found := false + for _, d := range topology.Devices { + if d.ID == id { + found = true + } + } + if !found { + return modem.RuntimeStatus{}, errors.New("simulator device absent") + } + s.mu.Lock() + off := s.off[id] + s.mu.Unlock() + status := modem.RuntimeStatus{RFState: modem.RFStateOn, SIMPresence: modem.SIMPresencePresent, Cellular: modem.UnavailableCellularStatus()} + status.Cellular.State = modem.CellularRegisteredHome + status.Cellular.ErrorCode = "" + status.Cellular.ObservedAt = time.Now().UTC() + status.Cellular.Registrations = []modem.CellularRegistration{{Domain: "cs", State: "registered-home"}, {Domain: "packet", State: "registered-home"}, {Domain: "eps", State: "registered-home"}} + if off { + status.RFState = modem.RFStateOff + status.Cellular.State = modem.CellularRFOff + for i := range status.Cellular.Registrations { + status.Cellular.Registrations[i].State = "not-registered" + } + } + return status, nil +} +func (s *Cellular) Set(ctx context.Context, id string, enabled bool) (string, error) { + if _, err := s.Read(ctx, id); err != nil { + return "", err + } + s.mu.Lock() + s.off[id] = !enabled + s.mu.Unlock() + if enabled { + return modem.RFStateOn, nil + } + return modem.RFStateOff, nil +} +func (s *Cellular) Probe(ctx context.Context, target app.ProbeTarget) (app.CellularSample, error) { + topology, err := s.inventory.Topology(ctx) + if err != nil { + return app.CellularSample{}, err + } + if topology.AgentInstanceID != target.Instance { + return app.CellularSample{}, errors.New("simulator target stale") + } + for _, d := range topology.Devices { + if d.ID == target.ID && d.Generation == target.DeviceGeneration { + status, err := s.Read(ctx, d.ID) + if err != nil { + return app.CellularSample{}, err + } + online := status.RFState == modem.RFStateOn + reason := "registered" + if !online { + reason = "rf_off" + } + return app.CellularSample{Fingerprint: d.EquipmentIdentityFingerprint, Known: true, Connected: online, Reason: reason, ObservedAt: time.Now().UTC()}, nil + } + } + return app.CellularSample{}, errors.New("simulator target stale") +} + +type VoWiFi struct { + mu sync.Mutex + cursor stream.Cursor + states map[string]vowifi.Status + events []stream.Change +} + +func NewVoWiFi() *VoWiFi { + return &VoWiFi{cursor: stream.Cursor{Instance: uuid.NewString()}, states: map[string]vowifi.Status{}} +} +func (s *VoWiFi) List(ctx context.Context) ([]vowifi.Status, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + result := make([]vowifi.Status, 0, len(s.states)) + for _, v := range s.states { + result = append(result, v) + } + sort.Slice(result, func(i, j int) bool { return result[i].LineID < result[j].LineID }) + return result, nil +} +func (s *VoWiFi) Start(ctx context.Context, r vowifi.StartRequest) (vowifi.Status, error) { + if err := ctx.Err(); err != nil { + return vowifi.Status{}, err + } + if r.LineID == "" || r.HardwareLineID == "" { + return vowifi.Status{}, vowifi.ErrRequestInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if old := s.states[r.LineID]; old.Online { + return old, vowifi.ErrAlreadyRunning + } + now := time.Now().UTC() + status := vowifi.Status{LineID: r.LineID, State: vowifi.StateOnline, Online: true, EgressMode: r.EgressMode, CountryCode: r.CountryCode, StartedAt: now, RegisteredAt: now, NextRefresh: now.Add(time.Hour), Attempt: 1} + s.states[r.LineID] = status + s.change(r.LineID, true, "registered", now) + return status, nil +} +func (s *VoWiFi) Stop(ctx context.Context, id string) (vowifi.Status, error) { + if err := ctx.Err(); err != nil { + return vowifi.Status{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + status, found := s.states[id] + if !found || !status.Online { + return status, vowifi.ErrNotRunning + } + status.Online = false + status.State = vowifi.StateStopped + s.states[id] = status + s.change(id, false, "stopped", time.Now().UTC()) + return status, nil +} +func (s *VoWiFi) change(id string, online bool, reason string, at time.Time) { + s.cursor.Sequence++ + s.events = append(s.events, stream.Change{Sequence: s.cursor.Sequence, State: stream.State{ObjectID: id, Connected: online, ObservedAt: at, Reason: reason}}) + if len(s.events) > stream.Capacity { + s.events = append([]stream.Change(nil), s.events[len(s.events)-stream.Capacity:]...) + } +} +func (s *VoWiFi) ConnectionSnapshot(ctx context.Context) (stream.Snapshot, error) { + if err := ctx.Err(); err != nil { + return stream.Snapshot{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + result := stream.Snapshot{Version: stream.Version, Cursor: s.cursor, States: []stream.State{}} + for id, status := range s.states { + reason := "stopped" + if status.Online { + reason = "registered" + } + result.States = append(result.States, stream.State{ObjectID: id, Connected: status.Online, Reason: reason, ObservedAt: time.Now().UTC()}) + } + return result, nil +} +func (s *VoWiFi) ConnectionChanges(ctx context.Context, c stream.Cursor) (stream.Changes, error) { + if err := ctx.Err(); err != nil { + return stream.Changes{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + result := stream.Changes{Version: stream.Version, Cursor: s.cursor, Events: []stream.Change{}} + result.Reset = c.Instance != s.cursor.Instance || c.Sequence > s.cursor.Sequence || (len(s.events) > 0 && c.Sequence < s.events[0].Sequence-1) + if !result.Reset { + for _, e := range s.events { + if e.Sequence > c.Sequence { + result.Events = append(result.Events, e) + } + } + } + return result, nil +} diff --git a/internal/application/messaging/agent_sms.go b/internal/smstransport/agent_sms.go similarity index 78% rename from internal/application/messaging/agent_sms.go rename to internal/smstransport/agent_sms.go index 5b87191..e76f168 100644 --- a/internal/application/messaging/agent_sms.go +++ b/internal/smstransport/agent_sms.go @@ -1,4 +1,4 @@ -package messaging +package smstransport import ( "context" @@ -10,15 +10,14 @@ import ( ) type AgentSMSGateway struct { - client agentapi.SMSClientAPI - instanceID string + client agentapi.SMSClientAPI } -func NewAgentSMSGateway(client agentapi.SMSClientAPI, instanceID string) (*AgentSMSGateway, error) { - if client == nil || !agentapi.IsValidAgentInstanceID(instanceID) { +func NewAgentSMSGateway(client agentapi.SMSClientAPI) (*AgentSMSGateway, error) { + if client == nil { return nil, errors.New("Agent SMS gateway configuration is invalid") } - return &AgentSMSGateway{client: client, instanceID: instanceID}, nil + return &AgentSMSGateway{client: client}, nil } func (gateway *AgentSMSGateway) SendSMS(ctx context.Context, command SendSMSCommand) (SendSMSResult, error) { @@ -27,7 +26,7 @@ func (gateway *AgentSMSGateway) SendSMS(ctx context.Context, command SendSMSComm return SendSMSResult{}, &TransportError{Code: "SMS_ENCODING_INVALID"} } response, err := gateway.client.SendSMS(ctx, agentapi.SMSSendRequest{ - OperationID: command.OperationID, AgentInstanceID: gateway.instanceID, DeviceID: command.PhysicalDeviceID, + OperationID: command.OperationID, AgentInstanceID: command.AgentInstanceID, DeviceID: command.PhysicalDeviceID, Destination: command.Destination, Body: command.Body, DeviceGeneration: command.DeviceGeneration, ExpectedEquipmentFingerprint: command.ExpectedEquipmentFingerprint, ExpectedSubscriptionFingerprint: command.ExpectedSubscriptionFingerprint, @@ -37,7 +36,7 @@ func (gateway *AgentSMSGateway) SendSMS(ctx context.Context, command SendSMSComm target error code string }{ - {agentapi.ErrSMSOutcomeUnknown, ErrorSendOutcomeUnknown}, {agentapi.ErrSMSSIMNotReady, ErrorSIMNotReady}, + {agentapi.ErrSMSAgentStale, ErrorDeviceStale}, {agentapi.ErrSMSNotDispatched, ErrorCancelledBeforeDispatch}, {agentapi.ErrSMSUnsupported, "SMS_UNSUPPORTED"}, {agentapi.ErrSMSOutcomeUnknown, ErrorSendOutcomeUnknown}, {agentapi.ErrSMSSIMNotReady, ErrorSIMNotReady}, {agentapi.ErrSMSSIMIdentity, ErrorSIMIdentityChanged}, {agentapi.ErrSMSEquipmentIdentity, ErrorEquipmentIdentityChanged}, {agentapi.ErrSMSRFOff, ErrorRFOff}, {agentapi.ErrSMSRegistrationDenied, ErrorRegistrationDenied}, {agentapi.ErrSMSNotRegistered, ErrorNotRegistered}, {agentapi.ErrSMSStatusUnavailable, ErrorStatusUnavailable}, @@ -48,13 +47,13 @@ func (gateway *AgentSMSGateway) SendSMS(ctx context.Context, command SendSMSComm return SendSMSResult{}, &TransportError{Code: mapping.code} } } - return SendSMSResult{}, &TransportError{Code: "AGENT_SMS_SEND_FAILED"} + return SendSMSResult{}, &TransportError{Code: ErrorSendOutcomeUnknown} } return SendSMSResult{ProviderMessageID: response.Submission.MessageID, State: SendStateSent}, nil } func (gateway *AgentSMSGateway) ListSMS(ctx context.Context, target InboxTarget) ([]InboxMessageReference, error) { - response, err := gateway.client.ListSMS(ctx, agentapi.SMSListRequest{AgentInstanceID: gateway.instanceID, DeviceID: target.PhysicalDeviceID, + response, err := gateway.client.ListSMS(ctx, agentapi.SMSListRequest{AgentInstanceID: target.AgentInstanceID, DeviceID: target.PhysicalDeviceID, DeviceGeneration: target.DeviceGeneration, ExpectedEquipmentFingerprint: target.ExpectedEquipmentFingerprint, ExpectedSubscriptionFingerprint: target.ExpectedSubscriptionFingerprint}) if err != nil { @@ -69,7 +68,7 @@ func (gateway *AgentSMSGateway) ListSMS(ctx context.Context, target InboxTarget) func (gateway *AgentSMSGateway) ReadSMS(ctx context.Context, target InboxTarget, messageID string) (InboxMessage, error) { response, err := gateway.client.ReadSMS(ctx, agentapi.SMSReadRequest{ - AgentInstanceID: gateway.instanceID, DeviceID: target.PhysicalDeviceID, MessageID: messageID, + AgentInstanceID: target.AgentInstanceID, DeviceID: target.PhysicalDeviceID, MessageID: messageID, DeviceGeneration: target.DeviceGeneration, ExpectedEquipmentFingerprint: target.ExpectedEquipmentFingerprint, ExpectedSubscriptionFingerprint: target.ExpectedSubscriptionFingerprint, }) @@ -84,7 +83,7 @@ func (gateway *AgentSMSGateway) ReadSMS(ctx context.Context, target InboxTarget, func (gateway *AgentSMSGateway) AcknowledgeSMS(ctx context.Context, target InboxTarget, messageID, operationID string) error { _, err := gateway.client.AcknowledgeSMS(ctx, agentapi.SMSAcknowledgeRequest{ - OperationID: operationID, AgentInstanceID: gateway.instanceID, DeviceID: target.PhysicalDeviceID, MessageID: messageID, + OperationID: operationID, AgentInstanceID: target.AgentInstanceID, DeviceID: target.PhysicalDeviceID, MessageID: messageID, DeviceGeneration: target.DeviceGeneration, ExpectedEquipmentFingerprint: target.ExpectedEquipmentFingerprint, ExpectedSubscriptionFingerprint: target.ExpectedSubscriptionFingerprint, }) diff --git a/internal/smstransport/ports.go b/internal/smstransport/ports.go new file mode 100644 index 0000000..c8db8ce --- /dev/null +++ b/internal/smstransport/ports.go @@ -0,0 +1,32 @@ +package smstransport + +import "github.com/leonfox28/simplus/internal/domain/sms" + +type SendSMSCommand = sms.SendSMSCommand +type SendSMSResult = sms.SendSMSResult +type Sender = sms.Sender +type InboxMessageReference = sms.InboxMessageReference +type InboxMessage = sms.InboxMessage +type InboxTarget = sms.InboxTarget +type Inbox = sms.Inbox +type SubmitReport = sms.SubmitReport +type SubmitReportInbox = sms.SubmitReportInbox +type TransportError = sms.TransportError + +const ErrorOutcomeUnknownAfterRestart = sms.ErrorOutcomeUnknownAfterRestart +const ErrorSendOutcomeUnknown = sms.ErrorSendOutcomeUnknown +const ErrorAcceptedAwaitingReport = sms.ErrorAcceptedAwaitingReport +const ErrorCancelledBeforeDispatch = sms.ErrorCancelledBeforeDispatch +const ErrorTransportFailed = sms.ErrorTransportFailed +const ErrorSIMNotReady = sms.ErrorSIMNotReady +const ErrorSIMIdentityChanged = sms.ErrorSIMIdentityChanged +const ErrorEquipmentIdentityChanged = sms.ErrorEquipmentIdentityChanged +const ErrorRFOff = sms.ErrorRFOff +const ErrorRegistrationDenied = sms.ErrorRegistrationDenied +const ErrorNotRegistered = sms.ErrorNotRegistered +const ErrorStatusUnavailable = sms.ErrorStatusUnavailable +const ErrorDeviceStale = sms.ErrorDeviceStale +const SendStateAccepted = sms.SendStateAccepted +const SendStateSent = sms.SendStateSent +const SendStateFailed = sms.SendStateFailed +const SendStateUnconfirmed = sms.SendStateUnconfirmed diff --git a/internal/application/messaging/vowifi_sms.go b/internal/smstransport/vowifi_sms.go similarity index 93% rename from internal/application/messaging/vowifi_sms.go rename to internal/smstransport/vowifi_sms.go index 5b81fce..ab47c17 100644 --- a/internal/application/messaging/vowifi_sms.go +++ b/internal/smstransport/vowifi_sms.go @@ -1,4 +1,4 @@ -package messaging +package smstransport import ( "context" @@ -31,8 +31,10 @@ func (gateway *VoWiFiSMSGateway) SendSMS(ctx context.Context, command SendSMSCom return SendSMSResult{}, &TransportError{Code: ErrorSendOutcomeUnknown} case errors.Is(err, vowifisupervisor.ErrSMSRejected): return SendSMSResult{}, &TransportError{Code: "IMS_SMS_REJECTED"} - default: + case errors.Is(err, vowifisupervisor.ErrSMSNotDispatched), errors.Is(err, vowifisupervisor.ErrSMSUnavailable), errors.Is(err, vowifisupervisor.ErrNotRunning), errors.Is(err, vowifisupervisor.ErrRequestInvalid): return SendSMSResult{}, &TransportError{Code: "IMS_SMS_UNAVAILABLE"} + default: + return SendSMSResult{}, &TransportError{Code: ErrorSendOutcomeUnknown} } } return SendSMSResult{ diff --git a/internal/storage/sqlite/administrator.go b/internal/storage/sqlite/administrator.go index c91db45..13e5a87 100644 --- a/internal/storage/sqlite/administrator.go +++ b/internal/storage/sqlite/administrator.go @@ -22,10 +22,10 @@ func (set *Set) ConfigureInitialAdministrator( locale string, now time.Time, ) error { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return fmt.Errorf("core database is not open") } - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin initial administrator transaction: %w", err) } @@ -55,7 +55,7 @@ ON CONFLICT(singleton) DO UPDATE SET } if _, err := tx.ExecContext(ctx, ` UPDATE installation_state -SET instance_default_locale = ? +SET instance_default_locale = ?, state = 'ready' WHERE singleton = 1 `, locale); err != nil { return fmt.Errorf("persist initial instance locale: %w", err) @@ -67,11 +67,11 @@ WHERE singleton = 1 } func (set *Set) ReadInitialAdministrator(ctx context.Context) (username string, locale string, configured bool, err error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return "", "", false, fmt.Errorf("core database is not open") } var storedUsername sql.NullString - err = set.Core.QueryRowContext(ctx, ` + err = set.DB.QueryRowContext(ctx, ` SELECT administrators.username, installation_state.instance_default_locale FROM installation_state LEFT JOIN administrators ON administrators.singleton = installation_state.singleton @@ -87,11 +87,11 @@ WHERE installation_state.singleton = 1 } func (set *Set) ReadAdministratorCredential(ctx context.Context, username string) (AdministratorCredential, error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return AdministratorCredential{}, fmt.Errorf("core database is not open") } var credential AdministratorCredential - err := set.Core.QueryRowContext(ctx, ` + err := set.DB.QueryRowContext(ctx, ` SELECT username, password_hash, session_generation FROM administrators WHERE singleton = 1 AND username = ? @@ -107,10 +107,10 @@ WHERE singleton = 1 AND username = ? } func (set *Set) ChangeAdministratorPassword(ctx context.Context, username, passwordHash string, expectedGeneration int64, now time.Time) (bool, error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return false, fmt.Errorf("core database is not open") } - result, err := set.Core.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE administrators SET password_hash = ?, password_version = password_version + 1, diff --git a/internal/storage/sqlite/administrator_test.go b/internal/storage/sqlite/administrator_test.go index 385bf68..508d762 100644 --- a/internal/storage/sqlite/administrator_test.go +++ b/internal/storage/sqlite/administrator_test.go @@ -7,7 +7,7 @@ import ( "time" ) -func TestConfigureInitialAdministratorPersistsAndRotatesCredentialGeneration(t *testing.T) { +func TestConfigureInitialAdministratorPersistsOnceAndCompletesInstallation(t *testing.T) { ctx := context.Background() set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) if err != nil { @@ -19,7 +19,7 @@ func TestConfigureInitialAdministratorPersistsAndRotatesCredentialGeneration(t * if err != nil { t.Fatal(err) } - if configured || username != "" || locale != "en-US" { + if configured || username != "" || locale != "zh-CN" { t.Fatalf("initial administrator = username %q, locale %q, configured %t", username, locale, configured) } @@ -43,19 +43,12 @@ func TestConfigureInitialAdministratorPersistsAndRotatesCredentialGeneration(t * t.Fatalf("first credential = %#v", credential) } - const secondHash = "$argon2id$v1$m=8192,t=1,p=1$WVlZWVlZWVlZWVlZWVlZWQ$WVlZWVlZWVlZWVlZWVlZWQ" - if err := set.ConfigureInitialAdministrator(ctx, "admin", secondHash, "en-US", now.Add(time.Minute)); err != nil { - t.Fatal(err) - } - if old, err := set.ReadAdministratorCredential(ctx, "leon"); err != nil || old.Found { - t.Fatalf("old username lookup = %#v, %v", old, err) - } - credential, err = set.ReadAdministratorCredential(ctx, "admin") - if err != nil { - t.Fatal(err) + if err := set.ConfigureInitialAdministrator(ctx, "admin", firstHash, "en-US", now); err == nil { + t.Fatal("replaced installed administrator") } - if !credential.Found || credential.PasswordHash != secondHash || credential.SessionGeneration != 2 { - t.Fatalf("rotated credential = %#v", credential) + state, err := set.InstallationState(ctx) + if err != nil || state != "ready" { + t.Fatalf("state=%s %v", state, err) } } @@ -66,7 +59,7 @@ func TestConfigureInitialAdministratorRejectsReadyInstance(t *testing.T) { t.Fatal(err) } defer set.Close() - if _, err := set.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { + if _, err := set.DB.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { t.Fatal(err) } if err := set.ConfigureInitialAdministrator(ctx, "admin", "$argon2id$v1$m=8192,t=1,p=1$WlpaWlpaWlpaWlpaWlpaWg$WlpaWlpaWlpaWlpaWlpaWg", "en-US", time.Now()); err == nil { diff --git a/internal/storage/sqlite/auth.go b/internal/storage/sqlite/auth.go index c44826e..34f86d4 100644 --- a/internal/storage/sqlite/auth.go +++ b/internal/storage/sqlite/auth.go @@ -7,10 +7,10 @@ import ( ) func (set *Set) ReadAdministrator(ctx context.Context) (username, passwordHash, locale string, sessionGeneration int64, found bool, err error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return "", "", "", 0, false, fmt.Errorf("core database is not open") } - err = set.Core.QueryRowContext(ctx, ` + err = set.DB.QueryRowContext(ctx, ` SELECT administrators.username, administrators.password_hash, installation_state.instance_default_locale, administrators.session_generation FROM administrators JOIN installation_state ON installation_state.singleton = administrators.singleton @@ -32,10 +32,10 @@ func (set *Set) CreateAdministratorSession( sessionGeneration int64, createdAtUnix, expiresAtUnix int64, ) error { - if set == nil || set.Runtime == nil { + if set == nil || set.DB == nil { return fmt.Errorf("runtime database is not open") } - tx, err := set.Runtime.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin administrator session creation: %w", err) } @@ -57,11 +57,11 @@ INSERT INTO administrator_sessions ( } func (set *Set) ReadAdministratorSession(ctx context.Context, tokenHash [32]byte, nowUnix int64) (username string, csrfHash [32]byte, sessionGeneration, expiresAtUnix int64, found bool, err error) { - if set == nil || set.Runtime == nil { + if set == nil || set.DB == nil { return "", csrfHash, 0, 0, false, fmt.Errorf("runtime database is not open") } var rawCSRF []byte - err = set.Runtime.QueryRowContext(ctx, ` + err = set.DB.QueryRowContext(ctx, ` SELECT username, csrf_hash, session_generation, expires_at_unix FROM administrator_sessions WHERE token_hash = ? AND expires_at_unix > ? @@ -76,27 +76,27 @@ WHERE token_hash = ? AND expires_at_unix > ? return "", csrfHash, 0, 0, false, fmt.Errorf("stored administrator CSRF hash has invalid length") } copy(csrfHash[:], rawCSRF) - if _, err := set.Runtime.ExecContext(ctx, `UPDATE administrator_sessions SET last_seen_at_unix = ? WHERE token_hash = ?`, nowUnix, tokenHash[:]); err != nil { + if _, err := set.DB.ExecContext(ctx, `UPDATE administrator_sessions SET last_seen_at_unix = ? WHERE token_hash = ?`, nowUnix, tokenHash[:]); err != nil { return "", csrfHash, 0, 0, false, fmt.Errorf("touch administrator session: %w", err) } return username, csrfHash, sessionGeneration, expiresAtUnix, true, nil } func (set *Set) DeleteAdministratorSession(ctx context.Context, tokenHash [32]byte) error { - if set == nil || set.Runtime == nil { + if set == nil || set.DB == nil { return fmt.Errorf("runtime database is not open") } - if _, err := set.Runtime.ExecContext(ctx, `DELETE FROM administrator_sessions WHERE token_hash = ?`, tokenHash[:]); err != nil { + if _, err := set.DB.ExecContext(ctx, `DELETE FROM administrator_sessions WHERE token_hash = ?`, tokenHash[:]); err != nil { return fmt.Errorf("delete administrator session: %w", err) } return nil } func (set *Set) DeleteAllAdministratorSessions(ctx context.Context) error { - if set == nil || set.Runtime == nil { + if set == nil || set.DB == nil { return fmt.Errorf("runtime database is not open") } - if _, err := set.Runtime.ExecContext(ctx, `DELETE FROM administrator_sessions`); err != nil { + if _, err := set.DB.ExecContext(ctx, `DELETE FROM administrator_sessions`); err != nil { return fmt.Errorf("delete all administrator sessions: %w", err) } return nil diff --git a/internal/storage/sqlite/calls.go b/internal/storage/sqlite/calls.go index 1a187a4..e3bcf06 100644 --- a/internal/storage/sqlite/calls.go +++ b/internal/storage/sqlite/calls.go @@ -12,7 +12,7 @@ import ( ) func (set *Set) CreateCall(ctx context.Context, value call.Record) (call.Record, bool, error) { - result, err := set.Calls.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` INSERT INTO call_records (call_id, operation_id, line_id, remote_address, direction, state, end_reason, created_at_unix_ms, updated_at_unix_ms) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?) ON CONFLICT(operation_id) DO NOTHING `, value.ID, value.OperationID, value.LineID, value.RemoteAddress, value.Direction, value.State, value.CreatedAt.UnixMilli(), value.UpdatedAt.UnixMilli()) @@ -39,7 +39,7 @@ func (set *Set) SetCallState(ctx context.Context, id, state, reason string, at t if state == call.StateEnded || state == call.StateFailed { ended = at.UTC().UnixMilli() } - result, err := set.Calls.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE call_records SET state = ?, end_reason = ?, updated_at_unix_ms = MAX(created_at_unix_ms, ?), answered_at_unix_ms = COALESCE(answered_at_unix_ms, ?), ended_at_unix_ms = COALESCE(ended_at_unix_ms, ?) WHERE call_id = ? @@ -61,7 +61,7 @@ func (set *Set) ListCalls(ctx context.Context, limit int) ([]call.Record, error) } func (set *Set) ListCallsPage(ctx context.Context, request pagination.Request) (pagination.Page[call.Record], error) { - if set == nil || set.Calls == nil || request.Limit < 1 || request.Limit > pagination.MaximumLimit { + if set == nil || set.DB == nil || request.Limit < 1 || request.Limit > pagination.MaximumLimit { return pagination.Page[call.Record]{}, fmt.Errorf("invalid call page request") } query := ` @@ -79,7 +79,7 @@ WHERE (created_at_unix_ms, call_id) < (?, ?) ORDER BY created_at_unix_ms DESC, call_id DESC LIMIT ?` args = []any{request.After.CreatedAt.UTC().UnixMilli(), request.After.ID, request.Limit + 1} } - rows, err := set.Calls.QueryContext(ctx, query, args...) + rows, err := set.DB.QueryContext(ctx, query, args...) if err != nil { return pagination.Page[call.Record]{}, fmt.Errorf("list calls: %w", err) } @@ -114,7 +114,7 @@ func (set *Set) GetCallByID(ctx context.Context, id string) (call.Record, bool, func (set *Set) HasActiveCallForLine(ctx context.Context, lineID string) (bool, error) { var exists bool - err := set.Calls.QueryRowContext(ctx, ` + err := set.DB.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM call_records WHERE line_id = ? AND state IN ('incoming', 'dialing', 'active')) `, lineID).Scan(&exists) if err != nil { @@ -124,7 +124,7 @@ SELECT EXISTS(SELECT 1 FROM call_records WHERE line_id = ? AND state IN ('incomi } func (set *Set) ReconcileCalls(ctx context.Context, reason string, at time.Time) (int64, error) { - result, err := set.Calls.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE call_records SET state = 'failed', end_reason = ?, updated_at_unix_ms = MAX(created_at_unix_ms, ?), ended_at_unix_ms = ? WHERE state IN ('incoming', 'dialing', 'active') `, reason, at.UTC().UnixMilli(), at.UTC().UnixMilli()) @@ -135,10 +135,10 @@ WHERE state IN ('incoming', 'dialing', 'active') } func (set *Set) callByOperation(ctx context.Context, operationID string) (call.Record, bool, error) { - return scanOptionalCall(set.Calls.QueryRowContext(ctx, `SELECT call_id, operation_id, line_id, remote_address, direction, state, end_reason, created_at_unix_ms, updated_at_unix_ms, answered_at_unix_ms, ended_at_unix_ms FROM call_records WHERE operation_id = ?`, operationID)) + return scanOptionalCall(set.DB.QueryRowContext(ctx, `SELECT call_id, operation_id, line_id, remote_address, direction, state, end_reason, created_at_unix_ms, updated_at_unix_ms, answered_at_unix_ms, ended_at_unix_ms FROM call_records WHERE operation_id = ?`, operationID)) } func (set *Set) callByID(ctx context.Context, id string) (call.Record, bool, error) { - return scanOptionalCall(set.Calls.QueryRowContext(ctx, `SELECT call_id, operation_id, line_id, remote_address, direction, state, end_reason, created_at_unix_ms, updated_at_unix_ms, answered_at_unix_ms, ended_at_unix_ms FROM call_records WHERE call_id = ?`, id)) + return scanOptionalCall(set.DB.QueryRowContext(ctx, `SELECT call_id, operation_id, line_id, remote_address, direction, state, end_reason, created_at_unix_ms, updated_at_unix_ms, answered_at_unix_ms, ended_at_unix_ms FROM call_records WHERE call_id = ?`, id)) } type callScanner interface{ Scan(...any) error } diff --git a/internal/storage/sqlite/contacts.go b/internal/storage/sqlite/contacts.go index 3a78ea1..a1a4be5 100644 --- a/internal/storage/sqlite/contacts.go +++ b/internal/storage/sqlite/contacts.go @@ -13,10 +13,10 @@ import ( ) func (set *Set) CreateContact(ctx context.Context, value contact.Contact) (contact.Contact, error) { - if set == nil || set.Contacts == nil { + if set == nil || set.DB == nil { return contact.Contact{}, errors.New("contacts database is not open") } - _, err := set.Contacts.ExecContext(ctx, ` + _, err := set.DB.ExecContext(ctx, ` INSERT INTO contacts (contact_id, display_name, phone_number, created_at_unix_ms, updated_at_unix_ms) VALUES (?, ?, ?, ?, ?) `, value.ID, value.DisplayName, value.PhoneNumber, value.CreatedAt.UTC().UnixMilli(), value.UpdatedAt.UTC().UnixMilli()) @@ -34,10 +34,10 @@ VALUES (?, ?, ?, ?, ?) } func (set *Set) UpdateContact(ctx context.Context, value contact.Contact) (contact.Contact, error) { - if set == nil || set.Contacts == nil { + if set == nil || set.DB == nil { return contact.Contact{}, errors.New("contacts database is not open") } - result, err := set.Contacts.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE contacts SET display_name = ?, phone_number = ?, updated_at_unix_ms = MAX(created_at_unix_ms, ?) WHERE contact_id = ? `, value.DisplayName, value.PhoneNumber, value.UpdatedAt.UTC().UnixMilli(), value.ID) @@ -56,10 +56,10 @@ WHERE contact_id = ? } func (set *Set) DeleteContact(ctx context.Context, id string) error { - if set == nil || set.Contacts == nil { + if set == nil || set.DB == nil { return errors.New("contacts database is not open") } - result, err := set.Contacts.ExecContext(ctx, `DELETE FROM contacts WHERE contact_id = ?`, id) + result, err := set.DB.ExecContext(ctx, `DELETE FROM contacts WHERE contact_id = ?`, id) if err != nil { return fmt.Errorf("delete contact: %w", err) } @@ -74,10 +74,10 @@ func (set *Set) DeleteContact(ctx context.Context, id string) error { } func (set *Set) ListContacts(ctx context.Context) ([]contact.Contact, error) { - if set == nil || set.Contacts == nil { + if set == nil || set.DB == nil { return nil, errors.New("contacts database is not open") } - rows, err := set.Contacts.QueryContext(ctx, ` + rows, err := set.DB.QueryContext(ctx, ` SELECT contact_id, display_name, phone_number, created_at_unix_ms, updated_at_unix_ms FROM contacts ORDER BY display_name COLLATE NOCASE, contact_id `) @@ -100,7 +100,7 @@ FROM contacts ORDER BY display_name COLLATE NOCASE, contact_id } func (set *Set) contactByID(ctx context.Context, id string) (contact.Contact, bool, error) { - row := set.Contacts.QueryRowContext(ctx, ` + row := set.DB.QueryRowContext(ctx, ` SELECT contact_id, display_name, phone_number, created_at_unix_ms, updated_at_unix_ms FROM contacts WHERE contact_id = ? `, id) diff --git a/internal/storage/sqlite/euicc.go b/internal/storage/sqlite/euicc.go index 7d943d1..a7f0819 100644 --- a/internal/storage/sqlite/euicc.go +++ b/internal/storage/sqlite/euicc.go @@ -8,7 +8,7 @@ import ( ) func (set *Set) ListSimulatorEUICCProfiles(ctx context.Context) ([]euicc.Profile, error) { - rows, err := set.Core.QueryContext(ctx, `SELECT profile_id, display_name, display_identity_hint, active FROM simulator_euicc_profiles ORDER BY profile_id`) + rows, err := set.DB.QueryContext(ctx, `SELECT profile_id, display_name, display_identity_hint, active FROM simulator_euicc_profiles ORDER BY profile_id`) if err != nil { return nil, fmt.Errorf("list simulator eUICC profiles: %w", err) } @@ -26,7 +26,7 @@ func (set *Set) ListSimulatorEUICCProfiles(ctx context.Context) ([]euicc.Profile return values, rows.Err() } func (set *Set) SwitchSimulatorEUICCProfile(ctx context.Context, id string) error { - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return err } diff --git a/internal/storage/sqlite/generated/core/models.go b/internal/storage/sqlite/generated/core/models.go index 8b7a662..f74ba9e 100644 --- a/internal/storage/sqlite/generated/core/models.go +++ b/internal/storage/sqlite/generated/core/models.go @@ -18,6 +18,53 @@ type Administrator struct { UpdatedAtUtc string `json:"updated_at_utc"` } +type AdministratorSession struct { + TokenHash []byte `json:"token_hash"` + CsrfHash []byte `json:"csrf_hash"` + Username string `json:"username"` + SessionGeneration int64 `json:"session_generation"` + CreatedAtUnix int64 `json:"created_at_unix"` + ExpiresAtUnix int64 `json:"expires_at_unix"` + LastSeenAtUnix int64 `json:"last_seen_at_unix"` +} + +type CallRecord struct { + CallID string `json:"call_id"` + OperationID string `json:"operation_id"` + LineID string `json:"line_id"` + RemoteAddress string `json:"remote_address"` + Direction string `json:"direction"` + State string `json:"state"` + EndReason string `json:"end_reason"` + CreatedAtUnixMs int64 `json:"created_at_unix_ms"` + UpdatedAtUnixMs int64 `json:"updated_at_unix_ms"` + AnsweredAtUnixMs sql.NullInt64 `json:"answered_at_unix_ms"` + EndedAtUnixMs sql.NullInt64 `json:"ended_at_unix_ms"` +} + +type ConnectionCheckpoint struct { + ObjectID string `json:"object_id"` + Kind string `json:"kind"` + Connected int64 `json:"connected"` + Revision int64 `json:"revision"` + ObservedAt int64 `json:"observed_at"` +} + +type ConnectionCursor struct { + Source string `json:"source"` + Instance string `json:"instance"` + Sequence int64 `json:"sequence"` + Gaps int64 `json:"gaps"` +} + +type Contact struct { + ContactID string `json:"contact_id"` + DisplayName string `json:"display_name"` + PhoneNumber string `json:"phone_number"` + CreatedAtUnixMs int64 `json:"created_at_unix_ms"` + UpdatedAtUnixMs int64 `json:"updated_at_unix_ms"` +} + type DatasetMetadatum struct { Singleton int64 `json:"singleton"` Dataset string `json:"dataset"` @@ -79,22 +126,6 @@ type ManagedModem struct { UpdatedAtUtc string `json:"updated_at_utc"` } -type ManagementTl struct { - Singleton int64 `json:"singleton"` - Mode string `json:"mode"` - ListenHost string `json:"listen_host"` - ListenPort int64 `json:"listen_port"` - SubjectAlternativeNames string `json:"subject_alternative_names"` - CaCertificatePem []byte `json:"ca_certificate_pem"` - LeafCertificatePem []byte `json:"leaf_certificate_pem"` - EncryptedCaPrivateKey []byte `json:"encrypted_ca_private_key"` - EncryptedLeafPrivateKey []byte `json:"encrypted_leaf_private_key"` - RootFingerprintSha256 string `json:"root_fingerprint_sha256"` - LeafNotAfterUtc sql.NullString `json:"leaf_not_after_utc"` - Confirmed int64 `json:"confirmed"` - ConfiguredAtUtc string `json:"configured_at_utc"` -} - type MihomoRuntimeSelection struct { Singleton int64 `json:"singleton"` SelectedSubscriptionID string `json:"selected_subscription_id"` @@ -143,23 +174,25 @@ type NotificationChannel struct { UpdatedAtUtc string `json:"updated_at_utc"` } -type SetupHardwareReview struct { - Singleton int64 `json:"singleton"` - InventoryDigestSha256 string `json:"inventory_digest_sha256"` - DeviceCount int64 `json:"device_count"` - LineCount int64 `json:"line_count"` - ReviewedAtUtc string `json:"reviewed_at_utc"` +type NotificationDelivery struct { + ID int64 `json:"id"` + EventKey string `json:"event_key"` + ChannelID string `json:"channel_id"` + EventKind string `json:"event_kind"` + ObjectID string `json:"object_id"` + ConnectionKind string `json:"connection_kind"` + Message string `json:"message"` + State string `json:"state"` + Attempts int64 `json:"attempts"` + NextAt int64 `json:"next_at"` + ObservedAt int64 `json:"observed_at"` + LastError string `json:"last_error"` } -type SetupStorage struct { - Singleton int64 `json:"singleton"` - DataRoot string `json:"data_root"` - RecordingsRoot string `json:"recordings_root"` - DataDevice int64 `json:"data_device"` - DataInode int64 `json:"data_inode"` - RecordingsDevice int64 `json:"recordings_device"` - RecordingsInode int64 `json:"recordings_inode"` - ConfiguredAtUtc string `json:"configured_at_utc"` +type NotificationSubscription struct { + ChannelID string `json:"channel_id"` + EventKind string `json:"event_kind"` + SubscribedAt int64 `json:"subscribed_at"` } type SimulatorEuiccProfile struct { @@ -169,6 +202,42 @@ type SimulatorEuiccProfile struct { Active int64 `json:"active"` } +type SmsInboundFragment struct { + GroupID string `json:"group_id"` + Part int64 `json:"part"` + SourceMessageID string `json:"source_message_id"` + LineID string `json:"line_id"` + Sender string `json:"sender"` + Encoding string `json:"encoding"` + ConcatReference int64 `json:"concat_reference"` + Total int64 `json:"total"` + UnitCount int64 `json:"unit_count"` + UserData []byte `json:"user_data"` + ReceivedAtUnixMs int64 `json:"received_at_unix_ms"` +} + +type SmsMessage struct { + RecordSequence int64 `json:"record_sequence"` + MessageID string `json:"message_id"` + OperationID string `json:"operation_id"` + Direction string `json:"direction"` + LineID string `json:"line_id"` + RemoteAddress string `json:"remote_address"` + Body string `json:"body"` + Status string `json:"status"` + ProviderMessageID string `json:"provider_message_id"` + ErrorCode string `json:"error_code"` + CreatedAtUnixMs int64 `json:"created_at_unix_ms"` + UpdatedAtUnixMs int64 `json:"updated_at_unix_ms"` + SentAtUnixMs sql.NullInt64 `json:"sent_at_unix_ms"` +} + +type SmsMessageUnread struct { + UnreadID int64 `json:"unread_id"` + MessageID string `json:"message_id"` + RemoteAddress string `json:"remote_address"` +} + type VowifiLineDesire struct { LineID string `json:"line_id"` DesiredActive int64 `json:"desired_active"` diff --git a/internal/storage/sqlite/generated/core/notifications.sql.go b/internal/storage/sqlite/generated/core/notifications.sql.go new file mode 100644 index 0000000..bbe121e --- /dev/null +++ b/internal/storage/sqlite/generated/core/notifications.sql.go @@ -0,0 +1,310 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: notifications.sql + +package coredb + +import ( + "context" +) + +const addNotificationSubscriptions = `-- name: AddNotificationSubscriptions :exec +INSERT INTO notification_subscriptions(channel_id,event_kind,subscribed_at) +SELECT ?1,value,?2 FROM json_each(?3) WHERE ?4=1 +ON CONFLICT(channel_id,event_kind) DO NOTHING +` + +type AddNotificationSubscriptionsParams struct { + ChannelID string `json:"channel_id"` + SubscribedAt int64 `json:"subscribed_at"` + EventKinds interface{} `json:"event_kinds"` + Enabled interface{} `json:"enabled"` +} + +func (q *Queries) AddNotificationSubscriptions(ctx context.Context, arg AddNotificationSubscriptionsParams) error { + _, err := q.db.ExecContext(ctx, addNotificationSubscriptions, + arg.ChannelID, + arg.SubscribedAt, + arg.EventKinds, + arg.Enabled, + ) + return err +} + +const cancelChannelNotifications = `-- name: CancelChannelNotifications :exec +UPDATE notification_deliveries SET state='cancelled',last_error='SUBSCRIPTION_CANCELLED' +WHERE channel_id=?1 AND state IN ('pending','delivering') +AND (?2=0 OR event_kind NOT IN (SELECT value FROM json_each(?3))) +` + +type CancelChannelNotificationsParams struct { + ChannelID string `json:"channel_id"` + Enabled interface{} `json:"enabled"` + EventKinds interface{} `json:"event_kinds"` +} + +func (q *Queries) CancelChannelNotifications(ctx context.Context, arg CancelChannelNotificationsParams) error { + _, err := q.db.ExecContext(ctx, cancelChannelNotifications, arg.ChannelID, arg.Enabled, arg.EventKinds) + return err +} + +const claimNotificationDeliveries = `-- name: ClaimNotificationDeliveries :many +UPDATE notification_deliveries SET state='delivering', attempts=attempts+1, next_at=?1 +WHERE id IN ( + SELECT d.id FROM notification_deliveries d + WHERE d.state IN ('pending','delivering') AND d.next_at<=?2 + AND NOT EXISTS(SELECT 1 FROM notification_deliveries previous + WHERE previous.channel_id=d.channel_id AND previous.object_id=d.object_id + AND previous.connection_kind=d.connection_kind AND previous.id'' THEN ?5 ELSE ?6 END AS message, + ?7 AS next_at,?8 AS observed_at +FROM (SELECT id,provider,enabled,event_kinds FROM notification_channels + UNION ALL SELECT id,'feishu' AS provider,enabled,event_kinds FROM feishu_app_notification_channels) AS c +WHERE c.enabled=1 AND EXISTS (SELECT 1 FROM notification_subscriptions s WHERE s.channel_id=c.id AND s.event_kind=?2 AND s.subscribed_at<=?9) AND EXISTS (SELECT 1 FROM json_each(c.event_kinds) WHERE value=?2) +ON CONFLICT(event_key,channel_id) DO NOTHING +` + +type EnqueueNotificationParams struct { + EventKey string `json:"event_key"` + EventKind string `json:"event_kind"` + ObjectID string `json:"object_id"` + ConnectionKind string `json:"connection_kind"` + FeishuMessage interface{} `json:"feishu_message"` + Message string `json:"message"` + Now int64 `json:"now"` + ObservedAt int64 `json:"observed_at"` + SubscriptionAt int64 `json:"subscription_at"` +} + +func (q *Queries) EnqueueNotification(ctx context.Context, arg EnqueueNotificationParams) error { + _, err := q.db.ExecContext(ctx, enqueueNotification, + arg.EventKey, + arg.EventKind, + arg.ObjectID, + arg.ConnectionKind, + arg.FeishuMessage, + arg.Message, + arg.Now, + arg.ObservedAt, + arg.SubscriptionAt, + ) + return err +} + +const finishNotificationDelivery = `-- name: FinishNotificationDelivery :exec +UPDATE notification_deliveries SET state=?1,next_at=?2,last_error=?3 +WHERE id=?4 AND state='delivering' AND attempts=?5 +` + +type FinishNotificationDeliveryParams struct { + State string `json:"state"` + NextAt int64 `json:"next_at"` + LastError string `json:"last_error"` + ID int64 `json:"id"` + Attempts int64 `json:"attempts"` +} + +func (q *Queries) FinishNotificationDelivery(ctx context.Context, arg FinishNotificationDeliveryParams) error { + _, err := q.db.ExecContext(ctx, finishNotificationDelivery, + arg.State, + arg.NextAt, + arg.LastError, + arg.ID, + arg.Attempts, + ) + return err +} + +const readConnectionCheckpoint = `-- name: ReadConnectionCheckpoint :one +SELECT connected,revision,observed_at FROM connection_checkpoints WHERE object_id=? AND kind=? +` + +type ReadConnectionCheckpointParams struct { + ObjectID string `json:"object_id"` + Kind string `json:"kind"` +} + +type ReadConnectionCheckpointRow struct { + Connected int64 `json:"connected"` + Revision int64 `json:"revision"` + ObservedAt int64 `json:"observed_at"` +} + +func (q *Queries) ReadConnectionCheckpoint(ctx context.Context, arg ReadConnectionCheckpointParams) (ReadConnectionCheckpointRow, error) { + row := q.db.QueryRowContext(ctx, readConnectionCheckpoint, arg.ObjectID, arg.Kind) + var i ReadConnectionCheckpointRow + err := row.Scan(&i.Connected, &i.Revision, &i.ObservedAt) + return i, err +} + +const readConnectionCursor = `-- name: ReadConnectionCursor :one +SELECT instance,sequence,gaps FROM connection_cursors WHERE source=? +` + +type ReadConnectionCursorRow struct { + Instance string `json:"instance"` + Sequence int64 `json:"sequence"` + Gaps int64 `json:"gaps"` +} + +func (q *Queries) ReadConnectionCursor(ctx context.Context, source string) (ReadConnectionCursorRow, error) { + row := q.db.QueryRowContext(ctx, readConnectionCursor, source) + var i ReadConnectionCursorRow + err := row.Scan(&i.Instance, &i.Sequence, &i.Gaps) + return i, err +} + +const removeInactiveNotificationSubscriptions = `-- name: RemoveInactiveNotificationSubscriptions :exec +DELETE FROM notification_subscriptions WHERE channel_id=?1 +AND (?2=0 OR event_kind NOT IN(SELECT value FROM json_each(?3))) +` + +type RemoveInactiveNotificationSubscriptionsParams struct { + ChannelID string `json:"channel_id"` + Enabled interface{} `json:"enabled"` + EventKinds interface{} `json:"event_kinds"` +} + +func (q *Queries) RemoveInactiveNotificationSubscriptions(ctx context.Context, arg RemoveInactiveNotificationSubscriptionsParams) error { + _, err := q.db.ExecContext(ctx, removeInactiveNotificationSubscriptions, arg.ChannelID, arg.Enabled, arg.EventKinds) + return err +} + +const resetInterruptedNotifications = `-- name: ResetInterruptedNotifications :exec +UPDATE notification_deliveries SET state='pending',next_at=?1 WHERE state='delivering' +` + +func (q *Queries) ResetInterruptedNotifications(ctx context.Context, now int64) error { + _, err := q.db.ExecContext(ctx, resetInterruptedNotifications, now) + return err +} + +const writeConnectionCheckpoint = `-- name: WriteConnectionCheckpoint :exec +INSERT INTO connection_checkpoints(object_id,kind,connected,revision,observed_at) VALUES(?,?,?,?,?) +ON CONFLICT(object_id,kind) DO UPDATE SET connected=excluded.connected,revision=excluded.revision,observed_at=excluded.observed_at +` + +type WriteConnectionCheckpointParams struct { + ObjectID string `json:"object_id"` + Kind string `json:"kind"` + Connected int64 `json:"connected"` + Revision int64 `json:"revision"` + ObservedAt int64 `json:"observed_at"` +} + +func (q *Queries) WriteConnectionCheckpoint(ctx context.Context, arg WriteConnectionCheckpointParams) error { + _, err := q.db.ExecContext(ctx, writeConnectionCheckpoint, + arg.ObjectID, + arg.Kind, + arg.Connected, + arg.Revision, + arg.ObservedAt, + ) + return err +} + +const writeConnectionCursor = `-- name: WriteConnectionCursor :exec +INSERT INTO connection_cursors(source,instance,sequence,gaps) VALUES(?,?,?,?) +ON CONFLICT(source) DO UPDATE SET instance=excluded.instance,sequence=excluded.sequence,gaps=excluded.gaps +` + +type WriteConnectionCursorParams struct { + Source string `json:"source"` + Instance string `json:"instance"` + Sequence int64 `json:"sequence"` + Gaps int64 `json:"gaps"` +} + +func (q *Queries) WriteConnectionCursor(ctx context.Context, arg WriteConnectionCursorParams) error { + _, err := q.db.ExecContext(ctx, writeConnectionCursor, + arg.Source, + arg.Instance, + arg.Sequence, + arg.Gaps, + ) + return err +} diff --git a/internal/storage/sqlite/line_egress.go b/internal/storage/sqlite/line_egress.go index e83fec2..a933105 100644 --- a/internal/storage/sqlite/line_egress.go +++ b/internal/storage/sqlite/line_egress.go @@ -9,7 +9,7 @@ import ( ) func (set *Set) ListLineEgressBindings(ctx context.Context) ([]lineegress.Binding, error) { - rows, err := set.Core.QueryContext(ctx, `SELECT line_id, mode, country_code, updated_at_utc FROM line_egress_bindings ORDER BY line_id`) + rows, err := set.DB.QueryContext(ctx, `SELECT line_id, mode, country_code, updated_at_utc FROM line_egress_bindings ORDER BY line_id`) if err != nil { return nil, fmt.Errorf("list line egress bindings: %w", err) } @@ -34,7 +34,7 @@ func (set *Set) ListLineEgressBindings(ctx context.Context) ([]lineegress.Bindin } func (set *Set) UpsertLineEgressBinding(ctx context.Context, binding lineegress.Binding) error { - _, err := set.Core.ExecContext(ctx, ` + _, err := set.DB.ExecContext(ctx, ` INSERT INTO line_egress_bindings (line_id, mode, country_code, updated_at_utc) VALUES (?, ?, ?, ?) ON CONFLICT(line_id) DO UPDATE SET diff --git a/internal/storage/sqlite/managed_lines.go b/internal/storage/sqlite/managed_lines.go index 63844aa..ce3b751 100644 --- a/internal/storage/sqlite/managed_lines.go +++ b/internal/storage/sqlite/managed_lines.go @@ -9,10 +9,10 @@ import ( ) func (set *Set) ListManagedLines(ctx context.Context) ([]domain.Record, error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return nil, fmt.Errorf("core database is not open") } - rows, err := set.Core.QueryContext(ctx, ` + rows, err := set.DB.QueryContext(ctx, ` SELECT id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, subscription_display_hint, display_name, created_at_utc, updated_at_utc FROM managed_lines @@ -47,10 +47,10 @@ ORDER BY created_at_utc, id`) } func (set *Set) CreateManagedLine(ctx context.Context, record domain.Record) error { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return fmt.Errorf("core database is not open") } - _, err := set.Core.ExecContext(ctx, ` + _, err := set.DB.ExecContext(ctx, ` INSERT INTO managed_lines ( id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, subscription_display_hint, display_name, created_at_utc, updated_at_utc @@ -64,10 +64,10 @@ INSERT INTO managed_lines ( } func (set *Set) UpdateManagedLine(ctx context.Context, lineID, displayName string, updatedAt time.Time) error { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return fmt.Errorf("core database is not open") } - result, err := set.Core.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE managed_lines SET display_name = ?, updated_at_utc = ? WHERE id = ?`, displayName, updatedAt.UTC().Format(time.RFC3339Nano), lineID) diff --git a/internal/storage/sqlite/managed_modems.go b/internal/storage/sqlite/managed_modems.go index 524616a..f76c761 100644 --- a/internal/storage/sqlite/managed_modems.go +++ b/internal/storage/sqlite/managed_modems.go @@ -10,10 +10,10 @@ import ( ) func (set *Set) ListManagedModems(ctx context.Context) ([]domain.Record, error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return nil, fmt.Errorf("core database is not open") } - rows, err := set.Core.QueryContext(ctx, ` + rows, err := set.DB.QueryContext(ctx, ` SELECT id, legacy_hardware_device_id, equipment_identity_fingerprint, usb_serial_fingerprint, display_name, model, transport, capability_mask, created_at_utc, updated_at_utc FROM managed_modems @@ -49,10 +49,10 @@ ORDER BY created_at_utc, id`) } func (set *Set) CreateManagedModem(ctx context.Context, record domain.Record) error { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return fmt.Errorf("core database is not open") } - _, err := set.Core.ExecContext(ctx, ` + _, err := set.DB.ExecContext(ctx, ` INSERT INTO managed_modems ( id, legacy_hardware_device_id, equipment_identity_fingerprint, usb_serial_fingerprint, display_name, model, transport, capability_mask, created_at_utc, updated_at_utc @@ -67,10 +67,10 @@ INSERT INTO managed_modems ( } func (set *Set) BindManagedModemIdentity(ctx context.Context, modemID, equipmentFingerprint, usbSerialFingerprint string, updatedAt time.Time) error { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return fmt.Errorf("core database is not open") } - result, err := set.Core.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE managed_modems SET legacy_hardware_device_id = '', equipment_identity_fingerprint = ?, usb_serial_fingerprint = ?, updated_at_utc = ? WHERE id = ? AND (equipment_identity_fingerprint = '' OR equipment_identity_fingerprint = ?)`, diff --git a/internal/storage/sqlite/management_tls.go b/internal/storage/sqlite/management_tls.go deleted file mode 100644 index bd2a1d7..0000000 --- a/internal/storage/sqlite/management_tls.go +++ /dev/null @@ -1,182 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "github.com/leonfox28/simplus/internal/domain/managementtls" -) - -func (set *Set) ConfigureManagementTLS(ctx context.Context, configuration managementtls.Configuration) error { - if set == nil || set.Core == nil { - return fmt.Errorf("core database is not open") - } - tx, err := set.Core.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin management TLS transaction: %w", err) - } - defer tx.Rollback() - var state string - if err := tx.QueryRowContext(ctx, `SELECT state FROM installation_state WHERE singleton = 1`).Scan(&state); err != nil { - return fmt.Errorf("read installation state for management TLS: %w", err) - } - if state != InstallationUninitialized { - return fmt.Errorf("management TLS setup requires uninitialized state, found %q", state) - } - var leafNotAfter any - if !configuration.LeafNotAfter.IsZero() { - leafNotAfter = configuration.LeafNotAfter.UTC().Format(time.RFC3339Nano) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO management_tls ( - singleton, - mode, - listen_host, - listen_port, - subject_alternative_names, - ca_certificate_pem, - leaf_certificate_pem, - encrypted_ca_private_key, - encrypted_leaf_private_key, - root_fingerprint_sha256, - leaf_not_after_utc, - confirmed, - configured_at_utc -) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(singleton) DO UPDATE SET - mode = excluded.mode, - listen_host = excluded.listen_host, - listen_port = excluded.listen_port, - subject_alternative_names = excluded.subject_alternative_names, - ca_certificate_pem = excluded.ca_certificate_pem, - leaf_certificate_pem = excluded.leaf_certificate_pem, - encrypted_ca_private_key = excluded.encrypted_ca_private_key, - encrypted_leaf_private_key = excluded.encrypted_leaf_private_key, - root_fingerprint_sha256 = excluded.root_fingerprint_sha256, - leaf_not_after_utc = excluded.leaf_not_after_utc, - confirmed = excluded.confirmed, - configured_at_utc = excluded.configured_at_utc -`, - string(configuration.Mode), - configuration.ListenHost, - configuration.ListenPort, - strings.Join(configuration.SubjectAlternativeNames, "\n"), - nonNilBytes(configuration.CACertificatePEM), - nonNilBytes(configuration.LeafCertificatePEM), - nonNilBytes(configuration.EncryptedCAPrivateKey), - nonNilBytes(configuration.EncryptedLeafPrivateKey), - configuration.RootFingerprintSHA256, - leafNotAfter, - boolInteger(configuration.Confirmed), - configuration.ConfiguredAt.UTC().Format(time.RFC3339Nano), - ); err != nil { - return fmt.Errorf("persist management TLS: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit management TLS: %w", err) - } - return nil -} - -func (set *Set) ReadManagementTLS(ctx context.Context) (managementtls.Configuration, bool, error) { - if set == nil || set.Core == nil { - return managementtls.Configuration{}, false, fmt.Errorf("core database is not open") - } - var configuration managementtls.Configuration - var mode string - var sans string - var leafNotAfter sql.NullString - var confirmed int - var configuredAt string - err := set.Core.QueryRowContext(ctx, ` -SELECT - mode, - listen_host, - listen_port, - subject_alternative_names, - ca_certificate_pem, - leaf_certificate_pem, - encrypted_ca_private_key, - encrypted_leaf_private_key, - root_fingerprint_sha256, - leaf_not_after_utc, - confirmed, - configured_at_utc -FROM management_tls -WHERE singleton = 1 -`).Scan( - &mode, - &configuration.ListenHost, - &configuration.ListenPort, - &sans, - &configuration.CACertificatePEM, - &configuration.LeafCertificatePEM, - &configuration.EncryptedCAPrivateKey, - &configuration.EncryptedLeafPrivateKey, - &configuration.RootFingerprintSHA256, - &leafNotAfter, - &confirmed, - &configuredAt, - ) - if errors.Is(err, sql.ErrNoRows) { - return managementtls.Configuration{}, false, nil - } - if err != nil { - return managementtls.Configuration{}, false, fmt.Errorf("read management TLS: %w", err) - } - configuration.Mode = managementtls.Mode(mode) - if sans != "" { - configuration.SubjectAlternativeNames = strings.Split(sans, "\n") - } - if leafNotAfter.Valid { - configuration.LeafNotAfter, err = time.Parse(time.RFC3339Nano, leafNotAfter.String) - if err != nil { - return managementtls.Configuration{}, false, fmt.Errorf("parse management TLS leaf expiry: %w", err) - } - } - configuration.ConfiguredAt, err = time.Parse(time.RFC3339Nano, configuredAt) - if err != nil { - return managementtls.Configuration{}, false, fmt.Errorf("parse management TLS configured time: %w", err) - } - configuration.Confirmed = confirmed == 1 - return configuration, true, nil -} - -func (set *Set) ConfirmManagementTLS(ctx context.Context, rootFingerprint string, now time.Time) (bool, error) { - if set == nil || set.Core == nil { - return false, fmt.Errorf("core database is not open") - } - result, err := set.Core.ExecContext(ctx, ` -UPDATE management_tls -SET confirmed = 1, configured_at_utc = ? -WHERE singleton = 1 - AND mode IN ('local-ca', 'imported') - AND root_fingerprint_sha256 = ? -`, now.UTC().Format(time.RFC3339Nano), rootFingerprint) - if err != nil { - return false, fmt.Errorf("confirm management TLS: %w", err) - } - rows, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("read management TLS confirmation result: %w", err) - } - return rows == 1, nil -} - -func nonNilBytes(value []byte) []byte { - if value == nil { - return []byte{} - } - return value -} - -func boolInteger(value bool) int { - if value { - return 1 - } - return 0 -} diff --git a/internal/storage/sqlite/management_tls_test.go b/internal/storage/sqlite/management_tls_test.go deleted file mode 100644 index 4e83110..0000000 --- a/internal/storage/sqlite/management_tls_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package sqlite - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/leonfox28/simplus/internal/domain/managementtls" -) - -func TestManagementTLSPersistsAndConfirmsCandidate(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - if _, found, err := set.ReadManagementTLS(ctx); err != nil || found { - t.Fatalf("initial management TLS found/error = %t/%v", found, err) - } - now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) - configuration := managementtls.Configuration{ - Mode: managementtls.ModeLocalCA, - ListenHost: "192.168.50.10", - ListenPort: 8443, - SubjectAlternativeNames: []string{"simplus.local", "192.168.50.10"}, - CACertificatePEM: []byte("ca"), - LeafCertificatePEM: []byte("leaf"), - EncryptedCAPrivateKey: []byte("encrypted-ca"), - EncryptedLeafPrivateKey: []byte("encrypted-leaf"), - RootFingerprintSHA256: "AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA", - LeafNotAfter: now.Add(90 * 24 * time.Hour), - Confirmed: false, - ConfiguredAt: now, - } - if err := set.ConfigureManagementTLS(ctx, configuration); err != nil { - t.Fatal(err) - } - stored, found, err := set.ReadManagementTLS(ctx) - if err != nil { - t.Fatal(err) - } - if !found || stored.Mode != configuration.Mode || stored.ListenHost != configuration.ListenHost || stored.Confirmed || len(stored.EncryptedLeafPrivateKey) == 0 { - t.Fatalf("stored management TLS = %#v", stored) - } - if confirmed, err := set.ConfirmManagementTLS(ctx, "wrong", now); err != nil || confirmed { - t.Fatalf("wrong fingerprint confirmation = %t/%v", confirmed, err) - } - confirmed, err := set.ConfirmManagementTLS(ctx, configuration.RootFingerprintSHA256, now.Add(time.Minute)) - if err != nil || !confirmed { - t.Fatalf("confirmation = %t/%v", confirmed, err) - } - stored, _, err = set.ReadManagementTLS(ctx) - if err != nil { - t.Fatal(err) - } - if !stored.Confirmed { - t.Fatal("confirmed flag was not persisted") - } -} - -func TestManagementTLSAcceptsConfirmedLoopbackWithoutKeys(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - configuration := managementtls.Configuration{ - Mode: managementtls.ModeLoopbackOnly, - ListenHost: "127.0.0.1", - ListenPort: 8080, - Confirmed: true, - ConfiguredAt: time.Now(), - } - if err := set.ConfigureManagementTLS(ctx, configuration); err != nil { - t.Fatal(err) - } - stored, found, err := set.ReadManagementTLS(ctx) - if err != nil || !found || !stored.Confirmed || stored.Mode != managementtls.ModeLoopbackOnly { - t.Fatalf("loopback configuration = %#v, %t, %v", stored, found, err) - } -} diff --git a/internal/storage/sqlite/messages.go b/internal/storage/sqlite/messages.go index cc71d24..f9a1145 100644 --- a/internal/storage/sqlite/messages.go +++ b/internal/storage/sqlite/messages.go @@ -8,8 +8,10 @@ import ( "fmt" "time" + notification "github.com/leonfox28/simplus/internal/domain/notification" "github.com/leonfox28/simplus/internal/domain/pagination" "github.com/leonfox28/simplus/internal/domain/sms" + coredb "github.com/leonfox28/simplus/internal/storage/sqlite/generated/core" ) var ( @@ -20,7 +22,7 @@ var ( const inboundSMSFragmentAssemblyWindow = 10 * time.Minute func (set *Set) CreateOutboundSMS(ctx context.Context, message sms.Message) (sms.Message, bool, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return sms.Message{}, false, fmt.Errorf("messages database is not open") } if message.ID == "" || message.OperationID == "" || message.Direction != sms.DirectionOutbound || @@ -30,7 +32,7 @@ func (set *Set) CreateOutboundSMS(ctx context.Context, message sms.Message) (sms } createdAt := message.CreatedAt.UTC().UnixMilli() updatedAt := message.UpdatedAt.UTC().UnixMilli() - result, err := set.Messages.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` INSERT INTO sms_messages ( message_id, operation_id, direction, line_id, remote_address, body, status, provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms @@ -44,7 +46,7 @@ ON CONFLICT(operation_id) DO NOTHING if err != nil { return sms.Message{}, false, fmt.Errorf("read outbound SMS creation result: %w", err) } - stored, found, err := set.smsByOperationID(ctx, message.OperationID) + stored, found, err := set.SMSByOperationID(ctx, message.OperationID) if err != nil { return sms.Message{}, false, err } @@ -61,7 +63,7 @@ ON CONFLICT(operation_id) DO NOTHING } func (set *Set) CreateInboundSMS(ctx context.Context, message sms.Message) (sms.Message, bool, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return sms.Message{}, false, fmt.Errorf("messages database is not open") } if message.ID == "" || message.OperationID == "" || message.Direction != sms.DirectionInbound || @@ -74,7 +76,7 @@ func (set *Set) CreateInboundSMS(ctx context.Context, message sms.Message) (sms. if updatedAt < createdAt { updatedAt = createdAt } - tx, err := set.Messages.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + tx, err := set.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { return sms.Message{}, false, fmt.Errorf("begin inbound SMS transaction: %w", err) } @@ -119,6 +121,12 @@ INSERT INTO sms_message_unread (message_id, remote_address) VALUES (?, ?) `, stored.ID, stored.RemoteAddress); err != nil { return sms.Message{}, false, fmt.Errorf("create inbound SMS unread marker: %w", err) } + if err := enqueueNotification(ctx, coredb.New(tx), notification.Event{ + Key: "sms:" + stored.ID, Kind: "sms.received", ObjectID: stored.ID, + Message: "[Simplus] 收到 1 条新短信", FeishuMessage: fmt.Sprintf("[Simplus] 新短信\n发件人:%s\n内容:\n%s", stored.RemoteAddress, stored.Body), ObservedAt: stored.CreatedAt, + }); err != nil { + return sms.Message{}, false, err + } if err := tx.Commit(); err != nil { return sms.Message{}, false, fmt.Errorf("commit inbound SMS: %w", err) } @@ -126,14 +134,14 @@ INSERT INTO sms_message_unread (message_id, remote_address) VALUES (?, ?) } func (set *Set) MarkOutboundSMSSent(ctx context.Context, messageID, providerMessageID string, completedAt time.Time) (sms.Message, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return sms.Message{}, fmt.Errorf("messages database is not open") } if messageID == "" || providerMessageID == "" || completedAt.IsZero() { return sms.Message{}, fmt.Errorf("invalid outbound SMS success") } completedAtUnixMilli := completedAt.UTC().UnixMilli() - result, err := set.Messages.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE sms_messages SET status = 'sent', provider_message_id = ?, error_code = '', @@ -163,13 +171,13 @@ WHERE message_id = ? AND direction = 'outbound' AND ( func (set *Set) MarkOutboundSMSFailed(ctx context.Context, messageID, providerMessageID, errorCode string, completedAt time.Time) (sms.Message, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return sms.Message{}, fmt.Errorf("messages database is not open") } if messageID == "" || errorCode == "" || completedAt.IsZero() { return sms.Message{}, fmt.Errorf("invalid outbound SMS failure") } - result, err := set.Messages.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE sms_messages SET status = 'failed', provider_message_id = CASE WHEN ? = '' THEN provider_message_id ELSE ? END, @@ -199,13 +207,13 @@ WHERE message_id = ? AND direction = 'outbound' AND ( func (set *Set) MarkOutboundSMSUnconfirmed(ctx context.Context, messageID, providerMessageID, errorCode string, completedAt time.Time) (sms.Message, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return sms.Message{}, fmt.Errorf("messages database is not open") } if messageID == "" || errorCode == "" || completedAt.IsZero() { return sms.Message{}, fmt.Errorf("invalid unconfirmed outbound SMS") } - result, err := set.Messages.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE sms_messages SET status = 'unconfirmed', provider_message_id = CASE WHEN ? = '' THEN provider_message_id ELSE ? END, @@ -233,13 +241,13 @@ WHERE message_id = ? AND direction = 'outbound' AND ( } func (set *Set) MarkQueuedOutboundSMSUnconfirmed(ctx context.Context, errorCode string, completedAt time.Time) (int64, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return 0, fmt.Errorf("messages database is not open") } if errorCode == "" || completedAt.IsZero() { return 0, fmt.Errorf("invalid outbound SMS reconciliation") } - result, err := set.Messages.ExecContext(ctx, ` + result, err := set.DB.ExecContext(ctx, ` UPDATE sms_messages SET status = 'unconfirmed', error_code = ?, updated_at_unix_ms = MAX(updated_at_unix_ms, ?) WHERE direction = 'outbound' AND status = 'queued' @@ -268,14 +276,14 @@ func (set *Set) ListSMSPage(ctx context.Context, request pagination.Request, lin // remote-address-only page. The page and boundary are read from one SQLite // transaction so the token never claims messages outside that snapshot. func (set *Set) ListSMSPageWithUnread(ctx context.Context, request pagination.Request, lineID, remoteAddress string) (pagination.Page[sms.Message], *sms.UnreadBoundary, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return pagination.Page[sms.Message]{}, nil, fmt.Errorf("messages database is not open") } if request.Limit < 1 || request.Limit > pagination.MaximumLimit || (lineID != "" && remoteAddress == "") { return pagination.Page[sms.Message]{}, nil, fmt.Errorf("invalid SMS page request") } if lineID == "" && remoteAddress != "" && request.After == nil { - tx, err := set.Messages.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + tx, err := set.DB.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return pagination.Page[sms.Message]{}, nil, fmt.Errorf("begin SMS page snapshot: %w", err) } @@ -305,7 +313,7 @@ LIMIT 1 } return page, &boundary, nil } - page, err := listSMSPage(ctx, set.Messages, request, lineID, remoteAddress) + page, err := listSMSPage(ctx, set.DB, request, lineID, remoteAddress) return page, nil, err } @@ -454,13 +462,13 @@ SELECT message_id, line_id, remote_address FROM sms_messages WHERE record_sequen } func (set *Set) ListSMSConversationPage(ctx context.Context, request pagination.Request) (pagination.Page[sms.ConversationSummary], error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return pagination.Page[sms.ConversationSummary]{}, fmt.Errorf("messages database is not open") } if request.Limit < 1 || request.Limit > pagination.MaximumLimit { return pagination.Page[sms.ConversationSummary]{}, fmt.Errorf("invalid SMS conversation page request") } - afterSequence, err := resolveSMSRecordSequence(ctx, set.Messages, request.After, "", "") + afterSequence, err := resolveSMSRecordSequence(ctx, set.DB, request.After, "", "") if err != nil { return pagination.Page[sms.ConversationSummary]{}, err } @@ -495,7 +503,7 @@ WHERE latest.position = 1` } query += ` ORDER BY latest.record_sequence DESC LIMIT ?` args = append(args, request.Limit+1) - rows, err := set.Messages.QueryContext(ctx, query, args...) + rows, err := set.DB.QueryContext(ctx, query, args...) if err != nil { return pagination.Page[sms.ConversationSummary]{}, fmt.Errorf("list SMS conversations: %w", err) } @@ -527,21 +535,21 @@ WHERE latest.position = 1` } func (set *Set) CountSMSConversations(ctx context.Context) (int64, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return 0, fmt.Errorf("messages database is not open") } var count int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(DISTINCT remote_address) FROM sms_messages`).Scan(&count); err != nil { + if err := set.DB.QueryRowContext(ctx, `SELECT COUNT(DISTINCT remote_address) FROM sms_messages`).Scan(&count); err != nil { return 0, fmt.Errorf("count SMS conversations: %w", err) } return count, nil } func (set *Set) MarkSMSConversationRead(ctx context.Context, remoteAddress string, boundary sms.UnreadBoundary) (bool, error) { - if set == nil || set.Messages == nil || remoteAddress == "" || boundary.UnreadID <= 0 || boundary.MessageID == "" { + if set == nil || set.DB == nil || remoteAddress == "" || boundary.UnreadID <= 0 || boundary.MessageID == "" { return false, sms.ErrReadBoundaryInvalid } - tx, err := set.Messages.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + tx, err := set.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { return false, fmt.Errorf("begin SMS read-state transaction: %w", err) } @@ -587,11 +595,11 @@ DELETE FROM sms_message_unread WHERE remote_address = ? AND unread_id <= ? } func (set *Set) CountSMS(ctx context.Context) (int64, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return 0, fmt.Errorf("messages database is not open") } var count int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages`).Scan(&count); err != nil { + if err := set.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages`).Scan(&count); err != nil { return 0, fmt.Errorf("count SMS: %w", err) } return count, nil @@ -602,10 +610,10 @@ func (set *Set) CountSMS(ctx context.Context) (int64, error) { // small explicit bound. Recovery code uses this instead of a count/list pair // so a concurrent write cannot change which rows are correlated. func (set *Set) ListAllSMSBounded(ctx context.Context, maximum int) ([]sms.Message, error) { - if set == nil || set.Messages == nil || maximum < 1 || maximum > pagination.MaximumLimit { + if set == nil || set.DB == nil || maximum < 1 || maximum > pagination.MaximumLimit { return nil, fmt.Errorf("invalid bounded complete SMS request") } - tx, err := set.Messages.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + tx, err := set.DB.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return nil, fmt.Errorf("begin complete SMS snapshot: %w", err) } @@ -631,24 +639,35 @@ func (set *Set) ListAllSMSBounded(ctx context.Context, maximum int) ([]sms.Messa } func (set *Set) DeleteSMS(ctx context.Context, messageID string) error { - if set == nil || set.Messages == nil || messageID == "" { + if set == nil || set.DB == nil || messageID == "" { return fmt.Errorf("invalid SMS deletion") } - result, err := set.Messages.ExecContext(ctx, `DELETE FROM sms_messages WHERE message_id = ?`, messageID) + tx, err := set.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `DELETE FROM sms_messages WHERE message_id = ?`, messageID) if err != nil { return fmt.Errorf("delete SMS: %w", err) } - return requireOneSMSMutation(result) + if err := requireOneSMSMutation(result); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE notification_deliveries SET state='cancelled',last_error='OBJECT_DELETED' WHERE object_id=? AND state IN ('pending','delivering')`, messageID); err != nil { + return err + } + return tx.Commit() } func (set *Set) StoreInboundSMSFragment(ctx context.Context, fragment sms.InboundFragment) ([]sms.InboundFragment, bool, error) { - if set == nil || set.Messages == nil { + if set == nil || set.DB == nil { return nil, false, fmt.Errorf("messages database is not open") } if !validInboundSMSFragment(fragment) { return nil, false, fmt.Errorf("invalid inbound SMS fragment") } - tx, err := set.Messages.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + tx, err := set.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) if err != nil { return nil, false, fmt.Errorf("begin inbound SMS fragment transaction: %w", err) } @@ -797,10 +816,10 @@ func inboundSMSFragmentPart(fragments []sms.InboundFragment, part int) (sms.Inbo } func (set *Set) PruneInboundSMSFragments(ctx context.Context, before time.Time) (int64, error) { - if set == nil || set.Messages == nil || before.IsZero() { + if set == nil || set.DB == nil || before.IsZero() { return 0, fmt.Errorf("invalid inbound SMS fragment pruning") } - result, err := set.Messages.ExecContext(ctx, `DELETE FROM sms_inbound_fragments WHERE received_at_unix_ms < ?`, before.UTC().UnixMilli()) + result, err := set.DB.ExecContext(ctx, `DELETE FROM sms_inbound_fragments WHERE received_at_unix_ms < ?`, before.UTC().UnixMilli()) if err != nil { return 0, fmt.Errorf("prune inbound SMS fragments: %w", err) } @@ -876,8 +895,8 @@ func consistentInboundSMSFragmentGroup(fragments []sms.InboundFragment, expected return true } -func (set *Set) smsByOperationID(ctx context.Context, operationID string) (sms.Message, bool, error) { - row := set.Messages.QueryRowContext(ctx, ` +func (set *Set) SMSByOperationID(ctx context.Context, operationID string) (sms.Message, bool, error) { + row := set.DB.QueryRowContext(ctx, ` SELECT message_id, operation_id, direction, line_id, remote_address, body, status, provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms FROM sms_messages @@ -887,7 +906,7 @@ WHERE operation_id = ? } func (set *Set) smsByID(ctx context.Context, messageID string) (sms.Message, bool, error) { - row := set.Messages.QueryRowContext(ctx, ` + row := set.DB.QueryRowContext(ctx, ` SELECT message_id, operation_id, direction, line_id, remote_address, body, status, provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms FROM sms_messages @@ -897,7 +916,7 @@ WHERE message_id = ? } func (set *Set) smsInboundBySource(ctx context.Context, lineID, providerMessageID string) (sms.Message, bool, error) { - row := set.Messages.QueryRowContext(ctx, ` + row := set.DB.QueryRowContext(ctx, ` SELECT message_id, operation_id, direction, line_id, remote_address, body, status, provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms FROM sms_messages diff --git a/internal/storage/sqlite/messages_test.go b/internal/storage/sqlite/messages_test.go index 23f8210..2c8411f 100644 --- a/internal/storage/sqlite/messages_test.go +++ b/internal/storage/sqlite/messages_test.go @@ -215,7 +215,7 @@ func TestSMSRecordSequenceOwnsHistorySummaryReplayStatusAndConcurrentWrites(t *t } } var count, distinct int - if err := set.Messages.QueryRowContext(ctx, ` + if err := set.DB.QueryRowContext(ctx, ` SELECT COUNT(*), COUNT(DISTINCT record_sequence) FROM sms_messages WHERE message_id LIKE 'msg_concurrent_%' `).Scan(&count, &distinct); err != nil { @@ -226,7 +226,7 @@ FROM sms_messages WHERE message_id LIKE 'msg_concurrent_%' } var maximum int64 var maximumID string - if err := set.Messages.QueryRowContext(ctx, ` + if err := set.DB.QueryRowContext(ctx, ` SELECT record_sequence, message_id FROM sms_messages WHERE message_id LIKE 'msg_concurrent_%' @@ -245,7 +245,7 @@ LIMIT 1 t.Fatal(err) } var afterDelete int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT record_sequence FROM sms_messages WHERE message_id = 'msg_sequence_after_delete001'`).Scan(&afterDelete); err != nil { + if err := set.DB.QueryRowContext(ctx, `SELECT record_sequence FROM sms_messages WHERE message_id = 'msg_sequence_after_delete001'`).Scan(&afterDelete); err != nil { t.Fatal(err) } if afterDelete <= maximum { @@ -388,7 +388,7 @@ func TestCreateInboundSMSRollsBackWhenUnreadMarkerCannotBeCreated(t *testing.T) t.Fatal(err) } defer set.Close() - if _, err := set.Messages.ExecContext(ctx, `DROP TABLE sms_message_unread`); err != nil { + if _, err := set.DB.ExecContext(ctx, `DROP TABLE sms_message_unread`); err != nil { t.Fatal(err) } messageID := "msg_atomic_unread_000001" @@ -402,7 +402,7 @@ func TestCreateInboundSMSRollsBackWhenUnreadMarkerCannotBeCreated(t *testing.T) t.Fatal("CreateInboundSMS succeeded without the unread ledger") } var count int - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages WHERE message_id = ?`, messageID).Scan(&count); err != nil || count != 0 { + if err := set.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages WHERE message_id = ?`, messageID).Scan(&count); err != nil || count != 0 { t.Fatalf("rolled-back inbound count=%d error=%v", count, err) } } diff --git a/internal/storage/sqlite/migrations/calls/00001_initial.sql b/internal/storage/sqlite/migrations/calls/00001_initial.sql deleted file mode 100644 index a6fe248..0000000 --- a/internal/storage/sqlite/migrations/calls/00001_initial.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up -CREATE TABLE dataset_metadata ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dataset TEXT NOT NULL CHECK (dataset = 'calls'), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); -INSERT INTO dataset_metadata (singleton, dataset, schema_version) VALUES (1, 'calls', 1); - --- +goose Down -DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/calls/00002_call_records.sql b/internal/storage/sqlite/migrations/calls/00002_call_records.sql deleted file mode 100644 index 4c29c62..0000000 --- a/internal/storage/sqlite/migrations/calls/00002_call_records.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up -CREATE TABLE call_records ( - call_id TEXT PRIMARY KEY CHECK (length(call_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 3 AND 21), - direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')), - state TEXT NOT NULL CHECK (state IN ('incoming', 'dialing', 'active', 'ended', 'failed')), - end_reason TEXT NOT NULL DEFAULT '' CHECK (length(end_reason) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - answered_at_unix_ms INTEGER, - ended_at_unix_ms INTEGER -) WITHOUT ROWID; - -CREATE INDEX call_records_created_at_idx ON call_records(created_at_unix_ms DESC, call_id); -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; - --- +goose Down -DROP INDEX call_records_created_at_idx; -DROP TABLE call_records; -UPDATE dataset_metadata SET schema_version = 1 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/calls/00003_keyset_pagination.sql b/internal/storage/sqlite/migrations/calls/00003_keyset_pagination.sql deleted file mode 100644 index efd4698..0000000 --- a/internal/storage/sqlite/migrations/calls/00003_keyset_pagination.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up -CREATE INDEX call_records_page_idx - ON call_records(created_at_unix_ms DESC, call_id DESC); -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; - --- +goose Down -DROP INDEX call_records_page_idx; -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/contacts/00001_initial.sql b/internal/storage/sqlite/migrations/contacts/00001_initial.sql deleted file mode 100644 index 434b9ca..0000000 --- a/internal/storage/sqlite/migrations/contacts/00001_initial.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up -CREATE TABLE dataset_metadata ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dataset TEXT NOT NULL CHECK (dataset = 'contacts'), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); -INSERT INTO dataset_metadata (singleton, dataset, schema_version) VALUES (1, 'contacts', 1); - --- +goose Down -DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/contacts/00002_contacts.sql b/internal/storage/sqlite/migrations/contacts/00002_contacts.sql deleted file mode 100644 index 60f89cd..0000000 --- a/internal/storage/sqlite/migrations/contacts/00002_contacts.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up -CREATE TABLE contacts ( - contact_id TEXT PRIMARY KEY CHECK (length(contact_id) BETWEEN 16 AND 128), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - phone_number TEXT NOT NULL UNIQUE CHECK (length(phone_number) BETWEEN 3 AND 21), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms) -) WITHOUT ROWID; - -CREATE INDEX contacts_display_name_idx - ON contacts(display_name COLLATE NOCASE, contact_id); - -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; - --- +goose Down -DROP INDEX contacts_display_name_idx; -DROP TABLE contacts; -UPDATE dataset_metadata SET schema_version = 1 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/control/00001_initial.sql b/internal/storage/sqlite/migrations/control/00001_initial.sql new file mode 100644 index 0000000..a3c24b3 --- /dev/null +++ b/internal/storage/sqlite/migrations/control/00001_initial.sql @@ -0,0 +1,298 @@ +-- +goose Up +CREATE TABLE dataset_metadata ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + dataset TEXT NOT NULL CHECK (dataset = 'control'), + schema_version INTEGER NOT NULL CHECK (schema_version >= 1) +); +INSERT INTO dataset_metadata VALUES (1, 'control', 1); + +CREATE TABLE administrators ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + username TEXT NOT NULL CHECK ( + length(username) BETWEEN 3 AND 32 + AND username = lower(username) + AND username NOT GLOB '*[^a-z0-9._-]*' + AND substr(username, 1, 1) GLOB '[a-z0-9]' + ), + password_hash TEXT NOT NULL CHECK ( + length(password_hash) BETWEEN 64 AND 512 + AND password_hash LIKE '$argon2id$%' + ), + password_version INTEGER NOT NULL CHECK (password_version >= 1), + session_generation INTEGER NOT NULL CHECK (session_generation >= 1), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE feishu_app_notification_channels ( + id TEXT PRIMARY KEY CHECK ( + length(id) = 30 + AND id GLOB 'channel_[A-Za-z0-9_-]*' + AND substr(id, 9) NOT GLOB '*[^A-Za-z0-9_-]*' + ), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), + app_id_ciphertext BLOB NOT NULL CHECK (length(app_id_ciphertext) BETWEEN 32 AND 8192), + app_secret_ciphertext BLOB NOT NULL CHECK (length(app_secret_ciphertext) BETWEEN 32 AND 8192), + recipient_open_id_ciphertext BLOB NOT NULL CHECK (length(recipient_open_id_ciphertext) BETWEEN 32 AND 8192), + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + event_kinds TEXT NOT NULL CHECK ( + length(event_kinds) BETWEEN 2 AND 512 + AND json_valid(event_kinds) + AND json_type(event_kinds) = 'array' + AND json_array_length(event_kinds) BETWEEN 1 AND 9 + ), + last_delivery_at_utc TEXT, + last_delivery_status TEXT NOT NULL CHECK (last_delivery_status IN ('never', 'success', 'failed')), + last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE installation_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + state TEXT NOT NULL CHECK (state IN ('uninitialized', 'ready', 'maintenance')), + instance_default_locale TEXT NOT NULL CHECK (instance_default_locale IN ('zh-CN', 'en-US')), + created_at_utc TEXT NOT NULL +, initialized_at_utc TEXT, instance_generation INTEGER NOT NULL DEFAULT 1 CHECK (instance_generation >= 1)); + +CREATE TABLE line_egress_bindings ( + line_id TEXT PRIMARY KEY CHECK (length(line_id) BETWEEN 1 AND 64), + mode TEXT NOT NULL CHECK (mode IN ('direct', 'mihomo-country')), + country_code TEXT NOT NULL CHECK ( + (mode = 'direct' AND country_code = '') OR + (mode = 'mihomo-country' AND length(country_code) = 2 AND country_code = upper(country_code)) + ), + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE "managed_lines" ( + id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 5) = 'line_'), + managed_modem_id TEXT NOT NULL REFERENCES managed_modems(id) ON DELETE RESTRICT, + sim_slot_index INTEGER NOT NULL CHECK (sim_slot_index BETWEEN 0 AND 255), + subscription_identity_fingerprint TEXT NOT NULL CHECK (length(subscription_identity_fingerprint) = 64), + subscription_display_hint TEXT NOT NULL CHECK (length(subscription_display_hint) BETWEEN 1 AND 64), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL, + UNIQUE (managed_modem_id, subscription_identity_fingerprint) +); + +CREATE TABLE "managed_modems" ( + id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 6) = 'modem_'), + legacy_hardware_device_id TEXT NOT NULL DEFAULT '' CHECK (length(legacy_hardware_device_id) <= 64), + equipment_identity_fingerprint TEXT NOT NULL DEFAULT '' CHECK (equipment_identity_fingerprint = '' OR length(equipment_identity_fingerprint) = 64), + usb_serial_fingerprint TEXT NOT NULL DEFAULT '' CHECK (usb_serial_fingerprint = '' OR length(usb_serial_fingerprint) = 64), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), + model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 120), + transport TEXT NOT NULL CHECK (transport IN ('simulated', 'usb', 'uart')), + capability_mask INTEGER NOT NULL CHECK (capability_mask BETWEEN 0 AND 16383), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + + +CREATE TABLE mihomo_runtime_selection ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + selected_subscription_id TEXT NOT NULL DEFAULT '', + running_subscription_id TEXT NOT NULL DEFAULT '', + updated_at_utc TEXT NOT NULL +); + +CREATE TABLE mihomo_subscription_nodes ( + subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE CASCADE, + node_id TEXT NOT NULL CHECK (node_id GLOB 'node_[A-Za-z0-9_-]*'), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), + kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 32), proxy_yaml TEXT NOT NULL DEFAULT '', country_code TEXT NOT NULL DEFAULT '', country_name TEXT NOT NULL DEFAULT '', + PRIMARY KEY (subscription_id, node_id) +); + +CREATE TABLE mihomo_subscriptions ( + id TEXT PRIMARY KEY CHECK (id GLOB 'subscription_[A-Za-z0-9_-]*'), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), + url_ciphertext BLOB NOT NULL CHECK (length(url_ciphertext) BETWEEN 32 AND 8192), + url_hint TEXT NOT NULL CHECK (length(url_hint) BETWEEN 1 AND 255), + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + last_refresh_at_utc TEXT, + last_refresh_status TEXT NOT NULL CHECK (last_refresh_status IN ('never', 'success', 'failed')), + node_count INTEGER NOT NULL DEFAULT 0 CHECK (node_count BETWEEN 0 AND 10000), + last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +, url_plaintext TEXT NOT NULL DEFAULT ''); + +CREATE TABLE notification_channels ( + id TEXT PRIMARY KEY CHECK (id GLOB 'channel_[A-Za-z0-9_-]*'), + provider TEXT NOT NULL CHECK (provider IN ('wecom', 'feishu')), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), + webhook_ciphertext BLOB NOT NULL CHECK (length(webhook_ciphertext) BETWEEN 32 AND 8192), + webhook_hint TEXT NOT NULL CHECK (length(webhook_hint) BETWEEN 1 AND 255), + signing_secret_ciphertext BLOB, + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + event_kinds TEXT NOT NULL CHECK (length(event_kinds) BETWEEN 2 AND 512), + last_delivery_at_utc TEXT, + last_delivery_status TEXT NOT NULL CHECK (last_delivery_status IN ('never', 'success', 'failed')), + last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL +); + + + +CREATE TABLE simulator_euicc_profiles ( + profile_id TEXT PRIMARY KEY CHECK (profile_id IN ('simulator-euicc-profile-a', 'simulator-euicc-profile-b')), + display_name TEXT NOT NULL, + display_identity_hint TEXT NOT NULL, + active INTEGER NOT NULL CHECK (active IN (0, 1)) +) WITHOUT ROWID; + +CREATE TABLE vowifi_line_desires ( + line_id TEXT PRIMARY KEY CHECK (length(line_id) BETWEEN 1 AND 64), + desired_active INTEGER NOT NULL CHECK (desired_active IN (0, 1)), + updated_at_utc TEXT NOT NULL +); + +CREATE UNIQUE INDEX managed_modems_equipment_identity_unique + ON managed_modems(equipment_identity_fingerprint) + WHERE equipment_identity_fingerprint <> ''; + +CREATE UNIQUE INDEX simulator_euicc_one_active_idx ON simulator_euicc_profiles(active) WHERE active = 1; + +CREATE TABLE contacts ( + contact_id TEXT PRIMARY KEY CHECK (length(contact_id) BETWEEN 16 AND 128), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), + phone_number TEXT NOT NULL UNIQUE CHECK (length(phone_number) BETWEEN 3 AND 21), + created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), + updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms) +) WITHOUT ROWID; + +CREATE INDEX contacts_display_name_idx + ON contacts(display_name COLLATE NOCASE, contact_id); + +CREATE TABLE sms_inbound_fragments ( + group_id TEXT NOT NULL CHECK (length(group_id) BETWEEN 16 AND 128), + part INTEGER NOT NULL CHECK (part BETWEEN 1 AND 255), + source_message_id TEXT NOT NULL CHECK (length(source_message_id) BETWEEN 16 AND 128), + line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), + sender TEXT NOT NULL CHECK (length(sender) BETWEEN 1 AND 21), + encoding TEXT NOT NULL CHECK (encoding IN ('gsm7', 'ucs2')), + concat_reference INTEGER NOT NULL CHECK (concat_reference BETWEEN 0 AND 255), + total INTEGER NOT NULL CHECK (total BETWEEN 2 AND 255 AND part <= total), + unit_count INTEGER NOT NULL CHECK (unit_count BETWEEN 1 AND 255), + user_data BLOB NOT NULL CHECK (length(user_data) BETWEEN 1 AND 140), + received_at_unix_ms INTEGER NOT NULL CHECK (received_at_unix_ms > 0), + PRIMARY KEY (group_id, part), + UNIQUE (line_id, source_message_id) +) WITHOUT ROWID; + +CREATE TABLE sms_message_unread ( + unread_id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE + REFERENCES sms_messages(message_id) ON DELETE CASCADE, + remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21) +); + +CREATE TABLE "sms_messages" ( + record_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE CHECK (length(message_id) BETWEEN 16 AND 128), + operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), + direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), + line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), + remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), + body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), + status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), + provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), + error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), + created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), + updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), + sent_at_unix_ms INTEGER, + CHECK ( + (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) + OR (status = 'unconfirmed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) + OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) + OR (status = 'failed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) + OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) + ) +); + +CREATE INDEX sms_inbound_fragments_group_candidate_idx + ON sms_inbound_fragments(line_id, sender, encoding, concat_reference, total, received_at_unix_ms); + +CREATE INDEX sms_inbound_fragments_received_at_idx + ON sms_inbound_fragments(received_at_unix_ms); + +CREATE INDEX sms_message_unread_remote_idx + ON sms_message_unread(remote_address, unread_id); + +CREATE UNIQUE INDEX sms_messages_inbound_source_idx + ON sms_messages(line_id, provider_message_id) + WHERE direction = 'inbound' AND provider_message_id <> ''; + +CREATE INDEX sms_messages_line_remote_sequence_idx + ON sms_messages(line_id, remote_address, record_sequence DESC); + +CREATE INDEX sms_messages_remote_sequence_idx + ON sms_messages(remote_address, record_sequence DESC); + +CREATE TABLE call_records ( + call_id TEXT PRIMARY KEY CHECK (length(call_id) BETWEEN 16 AND 128), + operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), + line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), + remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 3 AND 21), + direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')), + state TEXT NOT NULL CHECK (state IN ('incoming', 'dialing', 'active', 'ended', 'failed')), + end_reason TEXT NOT NULL DEFAULT '' CHECK (length(end_reason) <= 64), + created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), + updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), + answered_at_unix_ms INTEGER, + ended_at_unix_ms INTEGER +) WITHOUT ROWID; + +CREATE INDEX call_records_created_at_idx ON call_records(created_at_unix_ms DESC, call_id); + +CREATE INDEX call_records_page_idx + ON call_records(created_at_unix_ms DESC, call_id DESC); + +CREATE TABLE administrator_sessions ( + token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32), + csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32), + username TEXT NOT NULL, + session_generation INTEGER NOT NULL CHECK (session_generation >= 1), + created_at_unix INTEGER NOT NULL, + expires_at_unix INTEGER NOT NULL, + last_seen_at_unix INTEGER NOT NULL +) WITHOUT ROWID; + + + +CREATE INDEX administrator_sessions_expiry_idx + ON administrator_sessions(expires_at_unix); + + +INSERT INTO installation_state (singleton, state, instance_default_locale, created_at_utc) +VALUES (1, 'uninitialized', 'zh-CN', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO mihomo_runtime_selection VALUES (1, '', '', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO simulator_euicc_profiles VALUES + ('simulator-euicc-profile-a', 'Simulator eUICC Profile A', 'ICCID •••• 2001', 1), + ('simulator-euicc-profile-b', 'Simulator eUICC Profile B', 'ICCID •••• 2002', 0); + +-- +goose Down +PRAGMA defer_foreign_keys = ON; +DROP TABLE administrator_sessions; +DROP TABLE call_records; +DROP TABLE sms_messages; +DROP TABLE sms_message_unread; +DROP TABLE sms_inbound_fragments; +DROP TABLE contacts; +DROP TABLE vowifi_line_desires; +DROP TABLE simulator_euicc_profiles; +DROP TABLE notification_channels; +DROP TABLE mihomo_subscriptions; +DROP TABLE mihomo_subscription_nodes; +DROP TABLE mihomo_runtime_selection; +DROP TABLE managed_modems; +DROP TABLE managed_lines; +DROP TABLE line_egress_bindings; +DROP TABLE installation_state; +DROP TABLE feishu_app_notification_channels; +DROP TABLE administrators; +DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/control/00002_notifications.sql b/internal/storage/sqlite/migrations/control/00002_notifications.sql new file mode 100644 index 0000000..71e4703 --- /dev/null +++ b/internal/storage/sqlite/migrations/control/00002_notifications.sql @@ -0,0 +1,50 @@ +-- +goose Up +CREATE TABLE notification_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL, + channel_id TEXT NOT NULL, + event_kind TEXT NOT NULL, + object_id TEXT NOT NULL, + connection_kind TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL CHECK(length(message) BETWEEN 1 AND 4000), + state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','delivering','delivered','failed','cancelled')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0), + next_at INTEGER NOT NULL, + observed_at INTEGER NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + UNIQUE(event_key, channel_id) +); +CREATE INDEX notification_deliveries_due ON notification_deliveries(state,next_at,id); +CREATE INDEX notification_deliveries_order ON notification_deliveries(channel_id,object_id,connection_kind,id); +CREATE TABLE notification_subscriptions ( + channel_id TEXT NOT NULL, + event_kind TEXT NOT NULL, + subscribed_at INTEGER NOT NULL, + PRIMARY KEY(channel_id,event_kind) +); +INSERT INTO notification_subscriptions(channel_id,event_kind,subscribed_at) +SELECT c.id,j.value,CAST(unixepoch(c.created_at_utc,'subsec')*1000 AS INTEGER) +FROM (SELECT id,enabled,event_kinds,created_at_utc FROM notification_channels + UNION ALL SELECT id,enabled,event_kinds,created_at_utc FROM feishu_app_notification_channels) c, + json_each(c.event_kinds) j WHERE c.enabled=1; +CREATE TABLE connection_checkpoints ( + object_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('vowifi','cellular')), + connected INTEGER NOT NULL CHECK(connected IN (0,1)), + revision INTEGER NOT NULL, + observed_at INTEGER NOT NULL, + PRIMARY KEY(object_id,kind) +); +CREATE TABLE connection_cursors ( + source TEXT PRIMARY KEY, + instance TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK(sequence>=0), + gaps INTEGER NOT NULL DEFAULT 0 +); +UPDATE dataset_metadata SET schema_version=2 WHERE singleton=1; +-- +goose Down +DROP TABLE connection_cursors; +DROP TABLE connection_checkpoints; +DROP TABLE notification_deliveries; +DROP TABLE notification_subscriptions; +UPDATE dataset_metadata SET schema_version=1 WHERE singleton=1; diff --git a/internal/storage/sqlite/migrations/core/00001_initial.sql b/internal/storage/sqlite/migrations/core/00001_initial.sql deleted file mode 100644 index e787a34..0000000 --- a/internal/storage/sqlite/migrations/core/00001_initial.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up -CREATE TABLE dataset_metadata ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dataset TEXT NOT NULL CHECK (dataset = 'core'), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); -INSERT INTO dataset_metadata (singleton, dataset, schema_version) VALUES (1, 'core', 1); - -CREATE TABLE installation_state ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - state TEXT NOT NULL CHECK (state IN ('uninitialized', 'ready', 'maintenance')), - instance_default_locale TEXT NOT NULL CHECK (instance_default_locale IN ('zh-CN', 'en-US')), - created_at_utc TEXT NOT NULL -); -INSERT INTO installation_state (singleton, state, instance_default_locale, created_at_utc) -VALUES (1, 'uninitialized', 'en-US', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); - --- +goose Down -DROP TABLE installation_state; -DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/core/00002_subscription_profile_access_modes.sql b/internal/storage/sqlite/migrations/core/00002_subscription_profile_access_modes.sql deleted file mode 100644 index 60283b1..0000000 --- a/internal/storage/sqlite/migrations/core/00002_subscription_profile_access_modes.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up -CREATE TABLE subscription_profile_access_modes ( - subscription_profile_id TEXT PRIMARY KEY - CHECK ( - length(subscription_profile_id) BETWEEN 1 AND 64 - AND subscription_profile_id NOT GLOB '*[^a-z0-9-]*' - AND subscription_profile_id GLOB '[a-z0-9]*' - ), - access_mode TEXT NOT NULL - CHECK (access_mode IN ('cellular-native', 'host-vowifi-only', 'hold-rf-off')), - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; - --- +goose Down -DROP TABLE subscription_profile_access_modes; -UPDATE dataset_metadata SET schema_version = 1 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00003_initial_administrator.sql b/internal/storage/sqlite/migrations/core/00003_initial_administrator.sql deleted file mode 100644 index 517578e..0000000 --- a/internal/storage/sqlite/migrations/core/00003_initial_administrator.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up -CREATE TABLE administrators ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - username TEXT NOT NULL CHECK ( - length(username) BETWEEN 3 AND 32 - AND username = lower(username) - AND username NOT GLOB '*[^a-z0-9._-]*' - AND substr(username, 1, 1) GLOB '[a-z0-9]' - ), - password_hash TEXT NOT NULL CHECK ( - length(password_hash) BETWEEN 64 AND 512 - AND password_hash LIKE '$argon2id$%' - ), - password_version INTEGER NOT NULL CHECK (password_version >= 1), - session_generation INTEGER NOT NULL CHECK (session_generation >= 1), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); - -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; - --- +goose Down -DROP TABLE administrators; -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00004_setup_storage.sql b/internal/storage/sqlite/migrations/core/00004_setup_storage.sql deleted file mode 100644 index 1ca5105..0000000 --- a/internal/storage/sqlite/migrations/core/00004_setup_storage.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up -CREATE TABLE setup_storage ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - data_root TEXT NOT NULL CHECK (substr(data_root, 1, 1) = '/' AND length(data_root) <= 4096), - recordings_root TEXT NOT NULL CHECK (substr(recordings_root, 1, 1) = '/' AND length(recordings_root) <= 4096), - data_device INTEGER NOT NULL CHECK (data_device >= 0), - data_inode INTEGER NOT NULL CHECK (data_inode > 0), - recordings_device INTEGER NOT NULL CHECK (recordings_device >= 0), - recordings_inode INTEGER NOT NULL CHECK (recordings_inode > 0), - configured_at_utc TEXT NOT NULL -); - -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; - --- +goose Down -DROP TABLE setup_storage; -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00005_management_tls.sql b/internal/storage/sqlite/migrations/core/00005_management_tls.sql deleted file mode 100644 index 063f7bb..0000000 --- a/internal/storage/sqlite/migrations/core/00005_management_tls.sql +++ /dev/null @@ -1,42 +0,0 @@ --- +goose Up -CREATE TABLE management_tls ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - mode TEXT NOT NULL CHECK (mode IN ('loopback-only', 'local-ca', 'imported')), - listen_host TEXT NOT NULL CHECK (length(listen_host) BETWEEN 1 AND 255), - listen_port INTEGER NOT NULL CHECK (listen_port BETWEEN 1 AND 65535), - subject_alternative_names TEXT NOT NULL, - ca_certificate_pem BLOB NOT NULL, - leaf_certificate_pem BLOB NOT NULL, - encrypted_ca_private_key BLOB NOT NULL, - encrypted_leaf_private_key BLOB NOT NULL, - root_fingerprint_sha256 TEXT NOT NULL CHECK (length(root_fingerprint_sha256) IN (0, 95)), - leaf_not_after_utc TEXT, - confirmed INTEGER NOT NULL CHECK (confirmed IN (0, 1)), - configured_at_utc TEXT NOT NULL, - CHECK ( - (mode = 'loopback-only' - AND listen_host IN ('127.0.0.1', '::1', 'localhost') - AND length(subject_alternative_names) = 0 - AND length(ca_certificate_pem) = 0 - AND length(leaf_certificate_pem) = 0 - AND length(encrypted_ca_private_key) = 0 - AND length(encrypted_leaf_private_key) = 0 - AND length(root_fingerprint_sha256) = 0 - AND leaf_not_after_utc IS NULL - AND confirmed = 1) - OR - (mode IN ('local-ca', 'imported') - AND length(subject_alternative_names) > 0 - AND length(ca_certificate_pem) > 0 - AND length(leaf_certificate_pem) > 0 - AND length(encrypted_leaf_private_key) > 0 - AND length(root_fingerprint_sha256) = 95 - AND leaf_not_after_utc IS NOT NULL) - ) -); - -UPDATE dataset_metadata SET schema_version = 5 WHERE singleton = 1; - --- +goose Down -DROP TABLE management_tls; -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00006_setup_hardware_review.sql b/internal/storage/sqlite/migrations/core/00006_setup_hardware_review.sql deleted file mode 100644 index 3ee630e..0000000 --- a/internal/storage/sqlite/migrations/core/00006_setup_hardware_review.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE setup_hardware_review ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - inventory_digest_sha256 TEXT NOT NULL CHECK (length(inventory_digest_sha256) = 64), - device_count INTEGER NOT NULL CHECK (device_count >= 0), - line_count INTEGER NOT NULL CHECK (line_count >= 0), - reviewed_at_utc TEXT NOT NULL -); - -UPDATE dataset_metadata SET schema_version = 6 WHERE singleton = 1; - --- +goose Down -DROP TABLE setup_hardware_review; -UPDATE dataset_metadata SET schema_version = 5 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00007_installation_generation.sql b/internal/storage/sqlite/migrations/core/00007_installation_generation.sql deleted file mode 100644 index 358713b..0000000 --- a/internal/storage/sqlite/migrations/core/00007_installation_generation.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -ALTER TABLE installation_state ADD COLUMN initialized_at_utc TEXT; -ALTER TABLE installation_state ADD COLUMN instance_generation INTEGER NOT NULL DEFAULT 1 CHECK (instance_generation >= 1); -UPDATE dataset_metadata SET schema_version = 7 WHERE singleton = 1; - --- +goose Down -ALTER TABLE installation_state DROP COLUMN instance_generation; -ALTER TABLE installation_state DROP COLUMN initialized_at_utc; -UPDATE dataset_metadata SET schema_version = 6 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00008_simulator_euicc.sql b/internal/storage/sqlite/migrations/core/00008_simulator_euicc.sql deleted file mode 100644 index 4ae078f..0000000 --- a/internal/storage/sqlite/migrations/core/00008_simulator_euicc.sql +++ /dev/null @@ -1,17 +0,0 @@ --- +goose Up -CREATE TABLE simulator_euicc_profiles ( - profile_id TEXT PRIMARY KEY CHECK (profile_id IN ('simulator-euicc-profile-a', 'simulator-euicc-profile-b')), - display_name TEXT NOT NULL, - display_identity_hint TEXT NOT NULL, - active INTEGER NOT NULL CHECK (active IN (0, 1)) -) WITHOUT ROWID; -CREATE UNIQUE INDEX simulator_euicc_one_active_idx ON simulator_euicc_profiles(active) WHERE active = 1; -INSERT INTO simulator_euicc_profiles VALUES - ('simulator-euicc-profile-a', 'Simulator eUICC Profile A', 'ICCID •••• 2001', 1), - ('simulator-euicc-profile-b', 'Simulator eUICC Profile B', 'ICCID •••• 2002', 0); -UPDATE dataset_metadata SET schema_version = 8 WHERE singleton = 1; - --- +goose Down -DROP INDEX simulator_euicc_one_active_idx; -DROP TABLE simulator_euicc_profiles; -UPDATE dataset_metadata SET schema_version = 7 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00009_simulator_vowifi.sql b/internal/storage/sqlite/migrations/core/00009_simulator_vowifi.sql deleted file mode 100644 index 35c94f9..0000000 --- a/internal/storage/sqlite/migrations/core/00009_simulator_vowifi.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE TABLE simulator_vowifi_lines ( - line_id TEXT PRIMARY KEY CHECK (line_id IN ('simulator-line-1','simulator-line-2')), - mode TEXT NOT NULL CHECK (mode IN ('direct','mihomo-required')), - mihomo_state TEXT NOT NULL CHECK (mihomo_state IN ('running','stopped','failed')) -) WITHOUT ROWID; -INSERT INTO simulator_vowifi_lines VALUES ('simulator-line-1','direct','stopped'),('simulator-line-2','direct','stopped'); -UPDATE dataset_metadata SET schema_version = 9 WHERE singleton = 1; --- +goose Down -DROP TABLE simulator_vowifi_lines; -UPDATE dataset_metadata SET schema_version = 8 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00010_mihomo_management.sql b/internal/storage/sqlite/migrations/core/00010_mihomo_management.sql deleted file mode 100644 index 9e47317..0000000 --- a/internal/storage/sqlite/migrations/core/00010_mihomo_management.sql +++ /dev/null @@ -1,40 +0,0 @@ --- +goose Up -CREATE TABLE mihomo_subscriptions ( - id TEXT PRIMARY KEY CHECK (id GLOB 'subscription_[A-Za-z0-9_-]*'), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - url_ciphertext BLOB NOT NULL CHECK (length(url_ciphertext) BETWEEN 32 AND 8192), - url_hint TEXT NOT NULL CHECK (length(url_hint) BETWEEN 1 AND 255), - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - last_refresh_at_utc TEXT, - last_refresh_status TEXT NOT NULL CHECK (last_refresh_status IN ('never', 'success', 'failed')), - node_count INTEGER NOT NULL DEFAULT 0 CHECK (node_count BETWEEN 0 AND 10000), - last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); - -CREATE TABLE mihomo_subscription_nodes ( - subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE CASCADE, - node_id TEXT NOT NULL CHECK (node_id GLOB 'node_[A-Za-z0-9_-]*'), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 32), - PRIMARY KEY (subscription_id, node_id) -); - -CREATE TABLE mihomo_egress_profiles ( - id TEXT PRIMARY KEY CHECK (id GLOB 'egress_[A-Za-z0-9_-]*'), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE RESTRICT, - selected_node_id TEXT NOT NULL, - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); - -UPDATE dataset_metadata SET schema_version = 10 WHERE singleton = 1; - --- +goose Down -DROP TABLE mihomo_egress_profiles; -DROP TABLE mihomo_subscription_nodes; -DROP TABLE mihomo_subscriptions; -UPDATE dataset_metadata SET schema_version = 9 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00011_mihomo_profile_node_lifecycle.sql b/internal/storage/sqlite/migrations/core/00011_mihomo_profile_node_lifecycle.sql deleted file mode 100644 index 529ec16..0000000 --- a/internal/storage/sqlite/migrations/core/00011_mihomo_profile_node_lifecycle.sql +++ /dev/null @@ -1,32 +0,0 @@ --- +goose Up --- Version 10 used a node foreign key that prevented a subscription refresh --- from replacing nodes. Profiles must survive and become fail-closed instead. -ALTER TABLE mihomo_egress_profiles RENAME TO mihomo_egress_profiles_v10; -CREATE TABLE mihomo_egress_profiles ( - id TEXT PRIMARY KEY CHECK (id GLOB 'egress_[A-Za-z0-9_-]*'), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE RESTRICT, - selected_node_id TEXT NOT NULL, - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -INSERT INTO mihomo_egress_profiles SELECT * FROM mihomo_egress_profiles_v10; -DROP TABLE mihomo_egress_profiles_v10; -UPDATE dataset_metadata SET schema_version = 11 WHERE singleton = 1; - --- +goose Down -ALTER TABLE mihomo_egress_profiles RENAME TO mihomo_egress_profiles_v11; -CREATE TABLE mihomo_egress_profiles ( - id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE RESTRICT, - selected_node_id TEXT NOT NULL, - enabled INTEGER NOT NULL, - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL, - FOREIGN KEY (subscription_id, selected_node_id) REFERENCES mihomo_subscription_nodes(subscription_id, node_id) ON DELETE RESTRICT -); -INSERT INTO mihomo_egress_profiles SELECT * FROM mihomo_egress_profiles_v11; -DROP TABLE mihomo_egress_profiles_v11; -UPDATE dataset_metadata SET schema_version = 10 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00012_notification_channels.sql b/internal/storage/sqlite/migrations/core/00012_notification_channels.sql deleted file mode 100644 index a44c56d..0000000 --- a/internal/storage/sqlite/migrations/core/00012_notification_channels.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up -CREATE TABLE notification_channels ( - id TEXT PRIMARY KEY CHECK (id GLOB 'channel_[A-Za-z0-9_-]*'), - provider TEXT NOT NULL CHECK (provider IN ('wecom', 'feishu')), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - webhook_ciphertext BLOB NOT NULL CHECK (length(webhook_ciphertext) BETWEEN 32 AND 8192), - webhook_hint TEXT NOT NULL CHECK (length(webhook_hint) BETWEEN 1 AND 255), - signing_secret_ciphertext BLOB, - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - event_kinds TEXT NOT NULL CHECK (length(event_kinds) BETWEEN 2 AND 512), - last_delivery_at_utc TEXT, - last_delivery_status TEXT NOT NULL CHECK (last_delivery_status IN ('never', 'success', 'failed')), - last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 12 WHERE singleton = 1; - --- +goose Down -DROP TABLE notification_channels; -UPDATE dataset_metadata SET schema_version = 11 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00013_mihomo_owned_config.sql b/internal/storage/sqlite/migrations/core/00013_mihomo_owned_config.sql deleted file mode 100644 index de2c202..0000000 --- a/internal/storage/sqlite/migrations/core/00013_mihomo_owned_config.sql +++ /dev/null @@ -1,12 +0,0 @@ --- +goose Up -ALTER TABLE mihomo_subscriptions ADD COLUMN url_plaintext TEXT NOT NULL DEFAULT ''; -ALTER TABLE mihomo_subscription_nodes ADD COLUMN proxy_yaml TEXT NOT NULL DEFAULT ''; -ALTER TABLE mihomo_subscription_nodes ADD COLUMN country_code TEXT NOT NULL DEFAULT ''; -ALTER TABLE mihomo_subscription_nodes ADD COLUMN country_name TEXT NOT NULL DEFAULT ''; -ALTER TABLE mihomo_egress_profiles ADD COLUMN selection_type TEXT NOT NULL DEFAULT 'node'; -ALTER TABLE mihomo_egress_profiles ADD COLUMN selected_country_code TEXT NOT NULL DEFAULT ''; -ALTER TABLE mihomo_egress_profiles ADD COLUMN source_cidr TEXT NOT NULL DEFAULT ''; -UPDATE dataset_metadata SET schema_version = 13 WHERE singleton = 1; - --- +goose Down -UPDATE dataset_metadata SET schema_version = 12 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00014_mihomo_egress_line_binding.sql b/internal/storage/sqlite/migrations/core/00014_mihomo_egress_line_binding.sql deleted file mode 100644 index 3f9c542..0000000 --- a/internal/storage/sqlite/migrations/core/00014_mihomo_egress_line_binding.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up -ALTER TABLE mihomo_egress_profiles ADD COLUMN line_id TEXT NOT NULL DEFAULT ''; -CREATE UNIQUE INDEX mihomo_egress_profiles_line_unique ON mihomo_egress_profiles(line_id) WHERE line_id <> ''; -UPDATE dataset_metadata SET schema_version = 14 WHERE singleton = 1; - --- +goose Down -DROP INDEX mihomo_egress_profiles_line_unique; -UPDATE dataset_metadata SET schema_version = 13 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00015_mihomo_runtime_selection.sql b/internal/storage/sqlite/migrations/core/00015_mihomo_runtime_selection.sql deleted file mode 100644 index b458aba..0000000 --- a/internal/storage/sqlite/migrations/core/00015_mihomo_runtime_selection.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE mihomo_runtime_selection ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - selected_subscription_id TEXT NOT NULL DEFAULT '', - running_subscription_id TEXT NOT NULL DEFAULT '', - updated_at_utc TEXT NOT NULL -); -INSERT INTO mihomo_runtime_selection(singleton, selected_subscription_id, running_subscription_id, updated_at_utc) -VALUES (1, '', '', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -UPDATE dataset_metadata SET schema_version = 15 WHERE singleton = 1; - --- +goose Down -DROP TABLE mihomo_runtime_selection; -UPDATE dataset_metadata SET schema_version = 14 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00016_line_egress_bindings.sql b/internal/storage/sqlite/migrations/core/00016_line_egress_bindings.sql deleted file mode 100644 index 5e76e5b..0000000 --- a/internal/storage/sqlite/migrations/core/00016_line_egress_bindings.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up -CREATE TABLE line_egress_bindings ( - line_id TEXT PRIMARY KEY CHECK (length(line_id) BETWEEN 1 AND 64), - mode TEXT NOT NULL CHECK (mode IN ('direct', 'mihomo-country')), - country_code TEXT NOT NULL CHECK ( - (mode = 'direct' AND country_code = '') OR - (mode = 'mihomo-country' AND length(country_code) = 2 AND country_code = upper(country_code)) - ), - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 16 WHERE singleton = 1; - --- +goose Down -DROP TABLE line_egress_bindings; -UPDATE dataset_metadata SET schema_version = 15 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00017_vowifi_desired_state.sql b/internal/storage/sqlite/migrations/core/00017_vowifi_desired_state.sql deleted file mode 100644 index 113931d..0000000 --- a/internal/storage/sqlite/migrations/core/00017_vowifi_desired_state.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE TABLE vowifi_line_desires ( - line_id TEXT PRIMARY KEY CHECK (length(line_id) BETWEEN 1 AND 64), - desired_active INTEGER NOT NULL CHECK (desired_active IN (0, 1)), - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 17 WHERE singleton = 1; - --- +goose Down -DROP TABLE vowifi_line_desires; -UPDATE dataset_metadata SET schema_version = 16 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00018_managed_modems.sql b/internal/storage/sqlite/migrations/core/00018_managed_modems.sql deleted file mode 100644 index b1a0467..0000000 --- a/internal/storage/sqlite/migrations/core/00018_managed_modems.sql +++ /dev/null @@ -1,16 +0,0 @@ --- +goose Up -CREATE TABLE managed_modems ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 6) = 'modem_'), - hardware_device_id TEXT NOT NULL UNIQUE CHECK (length(hardware_device_id) BETWEEN 1 AND 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 120), - transport TEXT NOT NULL CHECK (transport IN ('simulated', 'usb', 'uart')), - capability_mask INTEGER NOT NULL CHECK (capability_mask BETWEEN 0 AND 16383), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 18 WHERE singleton = 1; - --- +goose Down -DROP TABLE managed_modems; -UPDATE dataset_metadata SET schema_version = 17 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00019_managed_modem_equipment_identity.sql b/internal/storage/sqlite/migrations/core/00019_managed_modem_equipment_identity.sql deleted file mode 100644 index 05e5f85..0000000 --- a/internal/storage/sqlite/migrations/core/00019_managed_modem_equipment_identity.sql +++ /dev/null @@ -1,47 +0,0 @@ --- +goose Up -CREATE TABLE managed_modems_v19 ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 6) = 'modem_'), - legacy_hardware_device_id TEXT NOT NULL DEFAULT '' CHECK (length(legacy_hardware_device_id) <= 64), - equipment_identity_fingerprint TEXT NOT NULL DEFAULT '' CHECK (equipment_identity_fingerprint = '' OR length(equipment_identity_fingerprint) = 64), - usb_serial_fingerprint TEXT NOT NULL DEFAULT '' CHECK (usb_serial_fingerprint = '' OR length(usb_serial_fingerprint) = 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 120), - transport TEXT NOT NULL CHECK (transport IN ('simulated', 'usb', 'uart')), - capability_mask INTEGER NOT NULL CHECK (capability_mask BETWEEN 0 AND 16383), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -INSERT INTO managed_modems_v19 ( - id, legacy_hardware_device_id, equipment_identity_fingerprint, usb_serial_fingerprint, - display_name, model, transport, capability_mask, created_at_utc, updated_at_utc -) -SELECT id, hardware_device_id, '', '', display_name, model, transport, capability_mask, created_at_utc, updated_at_utc -FROM managed_modems; -DROP TABLE managed_modems; -ALTER TABLE managed_modems_v19 RENAME TO managed_modems; -CREATE UNIQUE INDEX managed_modems_equipment_identity_unique - ON managed_modems(equipment_identity_fingerprint) - WHERE equipment_identity_fingerprint <> ''; -UPDATE dataset_metadata SET schema_version = 19 WHERE singleton = 1; - --- +goose Down -CREATE TABLE managed_modems_v18 ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 6) = 'modem_'), - hardware_device_id TEXT NOT NULL UNIQUE CHECK (length(hardware_device_id) BETWEEN 1 AND 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - model TEXT NOT NULL CHECK (length(model) BETWEEN 1 AND 120), - transport TEXT NOT NULL CHECK (transport IN ('simulated', 'usb', 'uart')), - capability_mask INTEGER NOT NULL CHECK (capability_mask BETWEEN 0 AND 16383), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -INSERT INTO managed_modems_v18 ( - id, hardware_device_id, display_name, model, transport, capability_mask, created_at_utc, updated_at_utc -) -SELECT id, - CASE WHEN legacy_hardware_device_id <> '' THEN legacy_hardware_device_id ELSE 'migrated-' || substr(id, 7) END, - display_name, model, transport, capability_mask, created_at_utc, updated_at_utc -FROM managed_modems; -DROP TABLE managed_modems; -ALTER TABLE managed_modems_v18 RENAME TO managed_modems; -UPDATE dataset_metadata SET schema_version = 18 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00020_managed_lines.sql b/internal/storage/sqlite/migrations/core/00020_managed_lines.sql deleted file mode 100644 index ce91e5f..0000000 --- a/internal/storage/sqlite/migrations/core/00020_managed_lines.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up -CREATE TABLE managed_lines ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 5) = 'line_'), - managed_modem_id TEXT NOT NULL REFERENCES managed_modems(id) ON DELETE RESTRICT, - sim_slot_index INTEGER NOT NULL CHECK (sim_slot_index BETWEEN 0 AND 255), - subscription_identity_fingerprint TEXT NOT NULL CHECK (length(subscription_identity_fingerprint) = 64), - subscription_display_hint TEXT NOT NULL CHECK (length(subscription_display_hint) BETWEEN 1 AND 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - access_mode TEXT NOT NULL CHECK (access_mode IN ('cellular-native', 'host-vowifi-only', 'hold-rf-off')), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL, - UNIQUE (managed_modem_id, subscription_identity_fingerprint) -); -UPDATE dataset_metadata SET schema_version = 20 WHERE singleton = 1; - --- +goose Down -DROP TABLE managed_lines; -UPDATE dataset_metadata SET schema_version = 19 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00021_stable_simulator_access_paths.sql b/internal/storage/sqlite/migrations/core/00021_stable_simulator_access_paths.sql deleted file mode 100644 index 6498791..0000000 --- a/internal/storage/sqlite/migrations/core/00021_stable_simulator_access_paths.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up -DROP TABLE simulator_vowifi_lines; -CREATE TABLE simulator_access_paths ( - line_id TEXT PRIMARY KEY REFERENCES managed_lines(id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('direct','mihomo-required')), - mihomo_state TEXT NOT NULL CHECK (mihomo_state IN ('running','stopped','failed')) -) WITHOUT ROWID; -UPDATE dataset_metadata SET schema_version = 21 WHERE singleton = 1; - --- +goose Down -DROP TABLE simulator_access_paths; -CREATE TABLE simulator_vowifi_lines ( - line_id TEXT PRIMARY KEY CHECK (line_id IN ('simulator-line-1','simulator-line-2')), - mode TEXT NOT NULL CHECK (mode IN ('direct','mihomo-required')), - mihomo_state TEXT NOT NULL CHECK (mihomo_state IN ('running','stopped','failed')) -) WITHOUT ROWID; -INSERT INTO simulator_vowifi_lines VALUES ('simulator-line-1','direct','stopped'),('simulator-line-2','direct','stopped'); -UPDATE dataset_metadata SET schema_version = 20 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/core/00022_line_identity_path_decoupling.sql b/internal/storage/sqlite/migrations/core/00022_line_identity_path_decoupling.sql deleted file mode 100644 index 9724481..0000000 --- a/internal/storage/sqlite/migrations/core/00022_line_identity_path_decoupling.sql +++ /dev/null @@ -1,116 +0,0 @@ --- +goose NO TRANSACTION - --- +goose Up -PRAGMA foreign_keys = OFF; -BEGIN IMMEDIATE; - --- Versions 13-15 stored an obsolete per-Line Mihomo profile. Preserve an --- enabled country choice only when the newer Line binding has no value. -INSERT OR IGNORE INTO line_egress_bindings (line_id, mode, country_code, updated_at_utc) -SELECT profile.line_id, 'mihomo-country', profile.selected_country_code, profile.updated_at_utc -FROM mihomo_egress_profiles AS profile -JOIN managed_lines AS line ON line.id = profile.line_id -WHERE profile.enabled = 1 - AND profile.selection_type = 'country' - AND length(profile.selected_country_code) = 2 - AND profile.selected_country_code = upper(profile.selected_country_code); - --- Older Host VoWiFi Lines treated a missing binding as implicit direct. --- Materialize that choice before absence becomes the fail-closed --- "unconfigured" state. -INSERT INTO line_egress_bindings (line_id, mode, country_code, updated_at_utc) -SELECT line.id, 'direct', '', line.updated_at_utc -FROM managed_lines AS line -LEFT JOIN line_egress_bindings AS binding ON binding.line_id = line.id -WHERE line.access_mode = 'host-vowifi-only' AND binding.line_id IS NULL; - -CREATE TABLE managed_lines_next ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 5) = 'line_'), - managed_modem_id TEXT NOT NULL REFERENCES managed_modems(id) ON DELETE RESTRICT, - sim_slot_index INTEGER NOT NULL CHECK (sim_slot_index BETWEEN 0 AND 255), - subscription_identity_fingerprint TEXT NOT NULL CHECK (length(subscription_identity_fingerprint) = 64), - subscription_display_hint TEXT NOT NULL CHECK (length(subscription_display_hint) BETWEEN 1 AND 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL, - UNIQUE (managed_modem_id, subscription_identity_fingerprint) -); -INSERT INTO managed_lines_next ( - id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, - subscription_display_hint, display_name, created_at_utc, updated_at_utc -) -SELECT id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, - subscription_display_hint, display_name, created_at_utc, updated_at_utc -FROM managed_lines; -DROP TABLE managed_lines; -ALTER TABLE managed_lines_next RENAME TO managed_lines; -DROP TABLE subscription_profile_access_modes; -DROP TABLE simulator_access_paths; -DROP TABLE mihomo_egress_profiles; -UPDATE dataset_metadata SET schema_version = 22 WHERE singleton = 1; - -COMMIT; -PRAGMA foreign_keys = ON; - --- +goose Down -PRAGMA foreign_keys = OFF; -BEGIN IMMEDIATE; - -CREATE TABLE subscription_profile_access_modes ( - subscription_profile_id TEXT PRIMARY KEY, - access_mode TEXT NOT NULL CHECK (access_mode IN ('cellular-native', 'host-vowifi-only', 'hold-rf-off')), - updated_at_utc TEXT NOT NULL -) WITHOUT ROWID; - -CREATE TABLE simulator_access_paths ( - line_id TEXT PRIMARY KEY REFERENCES managed_lines(id) ON DELETE CASCADE, - mode TEXT NOT NULL CHECK (mode IN ('direct', 'mihomo-required')), - mihomo_state TEXT NOT NULL CHECK (mihomo_state IN ('running', 'stopped', 'failed')) -) WITHOUT ROWID; - -CREATE TABLE mihomo_egress_profiles ( - id TEXT PRIMARY KEY CHECK (id GLOB 'egress_[A-Za-z0-9_-]*'), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - subscription_id TEXT NOT NULL REFERENCES mihomo_subscriptions(id) ON DELETE RESTRICT, - selected_node_id TEXT NOT NULL, - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL, - selection_type TEXT NOT NULL DEFAULT 'node', - selected_country_code TEXT NOT NULL DEFAULT '', - source_cidr TEXT NOT NULL DEFAULT '', - line_id TEXT NOT NULL DEFAULT '' -); -CREATE UNIQUE INDEX mihomo_egress_profiles_line_unique - ON mihomo_egress_profiles(line_id) WHERE line_id <> ''; - -CREATE TABLE managed_lines_previous ( - id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 16 AND 64 AND substr(id, 1, 5) = 'line_'), - managed_modem_id TEXT NOT NULL REFERENCES managed_modems(id) ON DELETE RESTRICT, - sim_slot_index INTEGER NOT NULL CHECK (sim_slot_index BETWEEN 0 AND 255), - subscription_identity_fingerprint TEXT NOT NULL CHECK (length(subscription_identity_fingerprint) = 64), - subscription_display_hint TEXT NOT NULL CHECK (length(subscription_display_hint) BETWEEN 1 AND 64), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 120), - access_mode TEXT NOT NULL CHECK (access_mode IN ('cellular-native', 'host-vowifi-only', 'hold-rf-off')), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL, - UNIQUE (managed_modem_id, subscription_identity_fingerprint) -); -INSERT INTO managed_lines_previous ( - id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, - subscription_display_hint, display_name, access_mode, created_at_utc, updated_at_utc -) -SELECT line.id, line.managed_modem_id, line.sim_slot_index, line.subscription_identity_fingerprint, - line.subscription_display_hint, line.display_name, - CASE WHEN binding.line_id IS NOT NULL OR desire.line_id IS NOT NULL - THEN 'host-vowifi-only' ELSE 'hold-rf-off' END, - line.created_at_utc, line.updated_at_utc -FROM managed_lines AS line -LEFT JOIN line_egress_bindings AS binding ON binding.line_id = line.id -LEFT JOIN vowifi_line_desires AS desire ON desire.line_id = line.id; -DROP TABLE managed_lines; -ALTER TABLE managed_lines_previous RENAME TO managed_lines; -UPDATE dataset_metadata SET schema_version = 21 WHERE singleton = 1; - -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/internal/storage/sqlite/migrations/core/00023_feishu_app_notification_channels.sql b/internal/storage/sqlite/migrations/core/00023_feishu_app_notification_channels.sql deleted file mode 100644 index 4bbd680..0000000 --- a/internal/storage/sqlite/migrations/core/00023_feishu_app_notification_channels.sql +++ /dev/null @@ -1,29 +0,0 @@ --- +goose Up -CREATE TABLE feishu_app_notification_channels ( - id TEXT PRIMARY KEY CHECK ( - length(id) = 30 - AND id GLOB 'channel_[A-Za-z0-9_-]*' - AND substr(id, 9) NOT GLOB '*[^A-Za-z0-9_-]*' - ), - display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 80), - app_id_ciphertext BLOB NOT NULL CHECK (length(app_id_ciphertext) BETWEEN 32 AND 8192), - app_secret_ciphertext BLOB NOT NULL CHECK (length(app_secret_ciphertext) BETWEEN 32 AND 8192), - recipient_open_id_ciphertext BLOB NOT NULL CHECK (length(recipient_open_id_ciphertext) BETWEEN 32 AND 8192), - enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), - event_kinds TEXT NOT NULL CHECK ( - length(event_kinds) BETWEEN 2 AND 512 - AND json_valid(event_kinds) - AND json_type(event_kinds) = 'array' - AND json_array_length(event_kinds) BETWEEN 1 AND 5 - ), - last_delivery_at_utc TEXT, - last_delivery_status TEXT NOT NULL CHECK (last_delivery_status IN ('never', 'success', 'failed')), - last_error_code TEXT NOT NULL DEFAULT '' CHECK (length(last_error_code) <= 64), - created_at_utc TEXT NOT NULL, - updated_at_utc TEXT NOT NULL -); -UPDATE dataset_metadata SET schema_version = 23 WHERE singleton = 1; - --- +goose Down -DROP TABLE feishu_app_notification_channels; -UPDATE dataset_metadata SET schema_version = 22 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00001_initial.sql b/internal/storage/sqlite/migrations/messages/00001_initial.sql deleted file mode 100644 index 52d4e49..0000000 --- a/internal/storage/sqlite/migrations/messages/00001_initial.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up -CREATE TABLE dataset_metadata ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dataset TEXT NOT NULL CHECK (dataset = 'messages'), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); -INSERT INTO dataset_metadata (singleton, dataset, schema_version) VALUES (1, 'messages', 1); - --- +goose Down -DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/messages/00002_sms_messages.sql b/internal/storage/sqlite/migrations/messages/00002_sms_messages.sql deleted file mode 100644 index c32af01..0000000 --- a/internal/storage/sqlite/migrations/messages/00002_sms_messages.sql +++ /dev/null @@ -1,38 +0,0 @@ --- +goose Up -CREATE TABLE sms_messages ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; - -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; - --- +goose Down -DROP INDEX sms_messages_inbound_source_idx; -DROP INDEX sms_messages_line_created_at_idx; -DROP INDEX sms_messages_created_at_idx; -DROP TABLE sms_messages; -UPDATE dataset_metadata SET schema_version = 1 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00003_inbound_sms_fragments.sql b/internal/storage/sqlite/migrations/messages/00003_inbound_sms_fragments.sql deleted file mode 100644 index aa8179f..0000000 --- a/internal/storage/sqlite/migrations/messages/00003_inbound_sms_fragments.sql +++ /dev/null @@ -1,29 +0,0 @@ --- +goose Up -CREATE TABLE sms_inbound_fragments ( - group_id TEXT NOT NULL CHECK (length(group_id) BETWEEN 16 AND 128), - part INTEGER NOT NULL CHECK (part BETWEEN 1 AND 255), - source_message_id TEXT NOT NULL CHECK (length(source_message_id) BETWEEN 16 AND 128), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - sender TEXT NOT NULL CHECK (length(sender) BETWEEN 1 AND 21), - encoding TEXT NOT NULL CHECK (encoding IN ('gsm7', 'ucs2')), - concat_reference INTEGER NOT NULL CHECK (concat_reference BETWEEN 0 AND 255), - total INTEGER NOT NULL CHECK (total BETWEEN 2 AND 255 AND part <= total), - unit_count INTEGER NOT NULL CHECK (unit_count BETWEEN 1 AND 255), - user_data BLOB NOT NULL CHECK (length(user_data) BETWEEN 1 AND 140), - received_at_unix_ms INTEGER NOT NULL CHECK (received_at_unix_ms > 0), - PRIMARY KEY (group_id, part), - UNIQUE (line_id, source_message_id) -) WITHOUT ROWID; - -CREATE INDEX sms_inbound_fragments_received_at_idx - ON sms_inbound_fragments(received_at_unix_ms); -CREATE INDEX sms_inbound_fragments_group_candidate_idx - ON sms_inbound_fragments(line_id, sender, encoding, concat_reference, total, received_at_unix_ms); - -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; - --- +goose Down -DROP INDEX sms_inbound_fragments_group_candidate_idx; -DROP INDEX sms_inbound_fragments_received_at_idx; -DROP TABLE sms_inbound_fragments; -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00004_unconfirmed_outbound_sms.sql b/internal/storage/sqlite/migrations/messages/00004_unconfirmed_outbound_sms.sql deleted file mode 100644 index 0de7923..0000000 --- a/internal/storage/sqlite/migrations/messages/00004_unconfirmed_outbound_sms.sql +++ /dev/null @@ -1,87 +0,0 @@ --- +goose Up -CREATE TABLE sms_messages_v4 ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'unconfirmed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -INSERT INTO sms_messages_v4 ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v4 RENAME TO sms_messages; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; - -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; - --- +goose Down -CREATE TABLE sms_messages_v3 ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -INSERT INTO sms_messages_v3 ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT message_id, operation_id, direction, line_id, remote_address, body, - CASE WHEN status = 'unconfirmed' THEN 'failed' ELSE status END, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v3 RENAME TO sms_messages; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; - -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00005_async_ims_submit_reports.sql b/internal/storage/sqlite/migrations/messages/00005_async_ims_submit_reports.sql deleted file mode 100644 index d1bc247..0000000 --- a/internal/storage/sqlite/migrations/messages/00005_async_ims_submit_reports.sql +++ /dev/null @@ -1,88 +0,0 @@ --- +goose Up -CREATE TABLE sms_messages_v5 ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'unconfirmed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -INSERT INTO sms_messages_v5 ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v5 RENAME TO sms_messages; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; - -UPDATE dataset_metadata SET schema_version = 5 WHERE singleton = 1; - --- +goose Down -CREATE TABLE sms_messages_v4 ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'unconfirmed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND provider_message_id = '' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -INSERT INTO sms_messages_v4 ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT message_id, operation_id, direction, line_id, remote_address, body, status, - CASE WHEN direction = 'outbound' AND status IN ('unconfirmed', 'failed') THEN '' ELSE provider_message_id END, - error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v4 RENAME TO sms_messages; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; - -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00006_keyset_pagination.sql b/internal/storage/sqlite/migrations/messages/00006_keyset_pagination.sql deleted file mode 100644 index 3867495..0000000 --- a/internal/storage/sqlite/migrations/messages/00006_keyset_pagination.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE INDEX sms_messages_page_idx - ON sms_messages(created_at_unix_ms DESC, message_id DESC); -CREATE INDEX sms_messages_conversation_page_idx - ON sms_messages(line_id, remote_address, created_at_unix_ms DESC, message_id DESC); -UPDATE dataset_metadata SET schema_version = 6 WHERE singleton = 1; - --- +goose Down -DROP INDEX sms_messages_conversation_page_idx; -DROP INDEX sms_messages_page_idx; -UPDATE dataset_metadata SET schema_version = 5 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00007_recipient_conversations.sql b/internal/storage/sqlite/migrations/messages/00007_recipient_conversations.sql deleted file mode 100644 index cdbeaca..0000000 --- a/internal/storage/sqlite/migrations/messages/00007_recipient_conversations.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up -CREATE INDEX sms_messages_remote_page_idx - ON sms_messages(remote_address, created_at_unix_ms DESC, message_id DESC); - -CREATE TABLE sms_message_unread ( - unread_id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id TEXT NOT NULL UNIQUE - REFERENCES sms_messages(message_id) ON DELETE CASCADE, - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21) -); - -CREATE INDEX sms_message_unread_remote_idx - ON sms_message_unread(remote_address, unread_id); - -UPDATE dataset_metadata SET schema_version = 7 WHERE singleton = 1; - --- +goose Down -DROP INDEX sms_message_unread_remote_idx; -DROP TABLE sms_message_unread; -DROP INDEX sms_messages_remote_page_idx; -UPDATE dataset_metadata SET schema_version = 6 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/messages/00008_sms_record_sequence.sql b/internal/storage/sqlite/migrations/messages/00008_sms_record_sequence.sql deleted file mode 100644 index 1c0efc1..0000000 --- a/internal/storage/sqlite/migrations/messages/00008_sms_record_sequence.sql +++ /dev/null @@ -1,111 +0,0 @@ --- +goose NO TRANSACTION --- +goose Up -PRAGMA foreign_keys = OFF; -BEGIN IMMEDIATE; - -CREATE TABLE sms_messages_v8 ( - record_sequence INTEGER PRIMARY KEY AUTOINCREMENT, - message_id TEXT NOT NULL UNIQUE CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'unconfirmed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -); - -INSERT INTO sms_messages_v8 ( - record_sequence, message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT ROW_NUMBER() OVER ( - ORDER BY CASE direction WHEN 'inbound' THEN updated_at_unix_ms ELSE created_at_unix_ms END, - created_at_unix_ms, - message_id - ), - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v8 RENAME TO sms_messages; - -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; -CREATE INDEX sms_messages_remote_sequence_idx - ON sms_messages(remote_address, record_sequence DESC); -CREATE INDEX sms_messages_line_remote_sequence_idx - ON sms_messages(line_id, remote_address, record_sequence DESC); - -UPDATE dataset_metadata SET schema_version = 8 WHERE singleton = 1; -COMMIT; -PRAGMA foreign_keys = ON; - --- +goose Down -PRAGMA foreign_keys = OFF; -BEGIN IMMEDIATE; - -CREATE TABLE sms_messages_v7 ( - message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 16 AND 128), - operation_id TEXT NOT NULL UNIQUE CHECK (length(operation_id) BETWEEN 16 AND 128), - direction TEXT NOT NULL CHECK (direction IN ('outbound', 'inbound')), - line_id TEXT NOT NULL CHECK (length(line_id) BETWEEN 1 AND 64), - remote_address TEXT NOT NULL CHECK (length(remote_address) BETWEEN 1 AND 21), - body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 1600), - status TEXT NOT NULL CHECK (status IN ('queued', 'unconfirmed', 'sent', 'failed', 'received')), - provider_message_id TEXT NOT NULL DEFAULT '' CHECK (length(provider_message_id) <= 128), - error_code TEXT NOT NULL DEFAULT '' CHECK (length(error_code) <= 64), - created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), - updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms), - sent_at_unix_ms INTEGER, - CHECK ( - (status = 'queued' AND direction = 'outbound' AND provider_message_id = '' AND error_code = '' AND sent_at_unix_ms IS NULL) - OR (status = 'unconfirmed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'sent' AND direction = 'outbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms >= created_at_unix_ms) - OR (status = 'failed' AND direction = 'outbound' AND error_code <> '' AND sent_at_unix_ms IS NULL) - OR (status = 'received' AND direction = 'inbound' AND provider_message_id <> '' AND error_code = '' AND sent_at_unix_ms IS NULL) - ) -) WITHOUT ROWID; - -INSERT INTO sms_messages_v7 ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) -SELECT message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -FROM sms_messages -ORDER BY record_sequence; - -DROP TABLE sms_messages; -ALTER TABLE sms_messages_v7 RENAME TO sms_messages; - -CREATE INDEX sms_messages_created_at_idx - ON sms_messages(created_at_unix_ms DESC, message_id); -CREATE INDEX sms_messages_line_created_at_idx - ON sms_messages(line_id, created_at_unix_ms DESC, message_id); -CREATE UNIQUE INDEX sms_messages_inbound_source_idx - ON sms_messages(line_id, provider_message_id) - WHERE direction = 'inbound' AND provider_message_id <> ''; -CREATE INDEX sms_messages_page_idx - ON sms_messages(created_at_unix_ms DESC, message_id DESC); -CREATE INDEX sms_messages_conversation_page_idx - ON sms_messages(line_id, remote_address, created_at_unix_ms DESC, message_id DESC); -CREATE INDEX sms_messages_remote_page_idx - ON sms_messages(remote_address, created_at_unix_ms DESC, message_id DESC); - -UPDATE dataset_metadata SET schema_version = 7 WHERE singleton = 1; -COMMIT; -PRAGMA foreign_keys = ON; diff --git a/internal/storage/sqlite/migrations/runtime/00001_initial.sql b/internal/storage/sqlite/migrations/runtime/00001_initial.sql deleted file mode 100644 index 4b05489..0000000 --- a/internal/storage/sqlite/migrations/runtime/00001_initial.sql +++ /dev/null @@ -1,10 +0,0 @@ --- +goose Up -CREATE TABLE dataset_metadata ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dataset TEXT NOT NULL CHECK (dataset = 'runtime'), - schema_version INTEGER NOT NULL CHECK (schema_version >= 1) -); -INSERT INTO dataset_metadata (singleton, dataset, schema_version) VALUES (1, 'runtime', 1); - --- +goose Down -DROP TABLE dataset_metadata; diff --git a/internal/storage/sqlite/migrations/runtime/00002_setup_authorization.sql b/internal/storage/sqlite/migrations/runtime/00002_setup_authorization.sql deleted file mode 100644 index 81cf550..0000000 --- a/internal/storage/sqlite/migrations/runtime/00002_setup_authorization.sql +++ /dev/null @@ -1,25 +0,0 @@ --- +goose Up -CREATE TABLE setup_bootstrap_grant ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - token_hash BLOB NOT NULL CHECK (length(token_hash) = 32), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - expires_at_unix INTEGER NOT NULL CHECK (expires_at_unix > created_at_unix), - consumed_at_unix INTEGER CHECK (consumed_at_unix IS NULL OR consumed_at_unix >= created_at_unix) -); - -CREATE TABLE setup_sessions ( - token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - expires_at_unix INTEGER NOT NULL CHECK (expires_at_unix > created_at_unix), - selected_flow TEXT CHECK (selected_flow IS NULL OR selected_flow IN ('create-new', 'restore')), - updated_at_unix INTEGER NOT NULL CHECK (updated_at_unix >= created_at_unix) -) WITHOUT ROWID; - -CREATE INDEX setup_sessions_expiry_idx ON setup_sessions (expires_at_unix); -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; - --- +goose Down -DROP INDEX setup_sessions_expiry_idx; -DROP TABLE setup_sessions; -DROP TABLE setup_bootstrap_grant; -UPDATE dataset_metadata SET schema_version = 1 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/runtime/00003_v1_create_only_setup.sql b/internal/storage/sqlite/migrations/runtime/00003_v1_create_only_setup.sql deleted file mode 100644 index e51f0e7..0000000 --- a/internal/storage/sqlite/migrations/runtime/00003_v1_create_only_setup.sql +++ /dev/null @@ -1,39 +0,0 @@ --- +goose Up -CREATE TABLE setup_sessions_v1 ( - token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - expires_at_unix INTEGER NOT NULL CHECK (expires_at_unix > created_at_unix), - selected_flow TEXT NOT NULL CHECK (selected_flow = 'create-new'), - updated_at_unix INTEGER NOT NULL CHECK (updated_at_unix >= created_at_unix) -) WITHOUT ROWID; - -INSERT INTO setup_sessions_v1 ( - token_hash, created_at_unix, expires_at_unix, selected_flow, updated_at_unix -) -SELECT token_hash, created_at_unix, expires_at_unix, 'create-new', updated_at_unix -FROM setup_sessions; - -DROP INDEX setup_sessions_expiry_idx; -DROP TABLE setup_sessions; -ALTER TABLE setup_sessions_v1 RENAME TO setup_sessions; -CREATE INDEX setup_sessions_expiry_idx ON setup_sessions (expires_at_unix); -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; - --- +goose Down -CREATE TABLE setup_sessions_v2 ( - token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - expires_at_unix INTEGER NOT NULL CHECK (expires_at_unix > created_at_unix), - selected_flow TEXT CHECK (selected_flow IS NULL OR selected_flow IN ('create-new', 'restore')), - updated_at_unix INTEGER NOT NULL CHECK (updated_at_unix >= created_at_unix) -) WITHOUT ROWID; - -INSERT INTO setup_sessions_v2 -SELECT token_hash, created_at_unix, expires_at_unix, selected_flow, updated_at_unix -FROM setup_sessions; - -DROP INDEX setup_sessions_expiry_idx; -DROP TABLE setup_sessions; -ALTER TABLE setup_sessions_v2 RENAME TO setup_sessions; -CREATE INDEX setup_sessions_expiry_idx ON setup_sessions (expires_at_unix); -UPDATE dataset_metadata SET schema_version = 2 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/runtime/00004_admin_sessions.sql b/internal/storage/sqlite/migrations/runtime/00004_admin_sessions.sql deleted file mode 100644 index ab3326c..0000000 --- a/internal/storage/sqlite/migrations/runtime/00004_admin_sessions.sql +++ /dev/null @@ -1,21 +0,0 @@ --- +goose Up --- Runtime-only administrator browser sessions. These rows survive ordinary --- service restarts but are never backed up or restored. -CREATE TABLE administrator_sessions ( - token_hash BLOB PRIMARY KEY CHECK (length(token_hash) = 32), - csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32), - username TEXT NOT NULL, - session_generation INTEGER NOT NULL CHECK (session_generation >= 1), - created_at_unix INTEGER NOT NULL, - expires_at_unix INTEGER NOT NULL, - last_seen_at_unix INTEGER NOT NULL -) WITHOUT ROWID; - -CREATE INDEX administrator_sessions_expiry_idx - ON administrator_sessions(expires_at_unix); -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; - --- +goose Down -DROP INDEX administrator_sessions_expiry_idx; -DROP TABLE administrator_sessions; -UPDATE dataset_metadata SET schema_version = 3 WHERE singleton = 1; diff --git a/internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql b/internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql deleted file mode 100644 index 6f53d74..0000000 --- a/internal/storage/sqlite/migrations/runtime/00005_resource_group_leases.sql +++ /dev/null @@ -1,54 +0,0 @@ --- +goose Up -CREATE TABLE resource_group_fences ( - resource_group_id TEXT PRIMARY KEY, - resource_group_generation INTEGER NOT NULL CHECK (resource_group_generation > 0), - fencing_token INTEGER NOT NULL CHECK (fencing_token > 0) -) WITHOUT ROWID; - -CREATE TABLE resource_group_leases ( - lease_id TEXT PRIMARY KEY, - operation_id TEXT NOT NULL UNIQUE, - resource_group_id TEXT NOT NULL, - lease_kind TEXT NOT NULL CHECK (lease_kind IN ('operation', 'call')), - purpose TEXT NOT NULL, - holder TEXT NOT NULL, - resource_group_generation INTEGER NOT NULL CHECK (resource_group_generation > 0), - fencing_token INTEGER NOT NULL CHECK (fencing_token > 0), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - expires_at_unix INTEGER NOT NULL CHECK (expires_at_unix > created_at_unix), - CHECK (length(lease_id) BETWEEN 16 AND 128), - CHECK (length(operation_id) BETWEEN 1 AND 128), - CHECK (length(resource_group_id) BETWEEN 1 AND 64), - CHECK (length(purpose) BETWEEN 1 AND 64), - CHECK (length(holder) BETWEEN 1 AND 128) -); - -CREATE INDEX resource_group_leases_active_idx - ON resource_group_leases(resource_group_id, expires_at_unix, lease_kind); - --- Used operation IDs are deliberately retained after lease expiry so delayed retries cannot reacquire a new fence. --- A future bounded durable command-outcome ledger must assume dedupe ownership before these rows may be pruned. -CREATE TABLE resource_group_lease_operations ( - operation_id TEXT PRIMARY KEY, - lease_id TEXT NOT NULL UNIQUE, - resource_group_id TEXT NOT NULL, - lease_kind TEXT NOT NULL CHECK (lease_kind IN ('operation', 'call')), - purpose TEXT NOT NULL, - holder TEXT NOT NULL, - resource_group_generation INTEGER NOT NULL CHECK (resource_group_generation > 0), - fencing_token INTEGER NOT NULL CHECK (fencing_token > 0), - created_at_unix INTEGER NOT NULL CHECK (created_at_unix > 0), - CHECK (length(operation_id) BETWEEN 1 AND 128), - CHECK (length(lease_id) BETWEEN 16 AND 128), - CHECK (length(resource_group_id) BETWEEN 1 AND 64), - CHECK (length(purpose) BETWEEN 1 AND 64), - CHECK (length(holder) BETWEEN 1 AND 128) -) WITHOUT ROWID; - -UPDATE dataset_metadata SET schema_version = 5 WHERE singleton = 1; - --- +goose Down -DROP TABLE IF EXISTS resource_group_lease_operations; -DROP TABLE IF EXISTS resource_group_leases; -DROP TABLE IF EXISTS resource_group_fences; -UPDATE dataset_metadata SET schema_version = 4 WHERE singleton = 1; diff --git a/internal/storage/sqlite/mihomo.go b/internal/storage/sqlite/mihomo.go index f680b39..c53e0e6 100644 --- a/internal/storage/sqlite/mihomo.go +++ b/internal/storage/sqlite/mihomo.go @@ -10,7 +10,7 @@ import ( ) func (set *Set) ListMihomoSubscriptions(ctx context.Context) ([]domain.Subscription, error) { - rows, err := set.Core.QueryContext(ctx, `SELECT id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc FROM mihomo_subscriptions ORDER BY display_name, id`) + rows, err := set.DB.QueryContext(ctx, `SELECT id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc FROM mihomo_subscriptions ORDER BY display_name, id`) if err != nil { return nil, fmt.Errorf("list Mihomo subscriptions: %w", err) } @@ -27,7 +27,7 @@ func (set *Set) ListMihomoSubscriptions(ctx context.Context) ([]domain.Subscript } func (set *Set) ReadMihomoSubscription(ctx context.Context, id string) (domain.Subscription, bool, error) { - item, err := scanMihomoSubscription(set.Core.QueryRowContext(ctx, `SELECT id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc FROM mihomo_subscriptions WHERE id = ?`, id)) + item, err := scanMihomoSubscription(set.DB.QueryRowContext(ctx, `SELECT id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc FROM mihomo_subscriptions WHERE id = ?`, id)) if err == sql.ErrNoRows { return domain.Subscription{}, false, nil } @@ -69,7 +69,7 @@ func scanMihomoSubscription(row rowScanner) (domain.Subscription, error) { func (set *Set) UpsertMihomoSubscription(ctx context.Context, item domain.Subscription) error { nowCreated := item.CreatedAt.UTC().Format(time.RFC3339Nano) nowUpdated := item.UpdatedAt.UTC().Format(time.RFC3339Nano) - _, err := set.Core.ExecContext(ctx, `INSERT INTO mihomo_subscriptions (id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, NULL, 'never', 0, '', ?, ?) ON CONFLICT(id) DO UPDATE SET display_name=excluded.display_name, url_ciphertext=excluded.url_ciphertext, url_plaintext=excluded.url_plaintext, url_hint=excluded.url_hint, enabled=excluded.enabled, updated_at_utc=excluded.updated_at_utc`, item.ID, item.DisplayName, item.URLCiphertext, item.URLPlaintext, item.URLHint, boolInt(item.Enabled), nowCreated, nowUpdated) + _, err := set.DB.ExecContext(ctx, `INSERT INTO mihomo_subscriptions (id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, last_refresh_at_utc, last_refresh_status, node_count, last_error_code, created_at_utc, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, NULL, 'never', 0, '', ?, ?) ON CONFLICT(id) DO UPDATE SET display_name=excluded.display_name, url_ciphertext=excluded.url_ciphertext, url_plaintext=excluded.url_plaintext, url_hint=excluded.url_hint, enabled=excluded.enabled, updated_at_utc=excluded.updated_at_utc`, item.ID, item.DisplayName, item.URLCiphertext, item.URLPlaintext, item.URLHint, boolInt(item.Enabled), nowCreated, nowUpdated) if err != nil { return fmt.Errorf("upsert Mihomo subscription: %w", err) } @@ -77,7 +77,7 @@ func (set *Set) UpsertMihomoSubscription(ctx context.Context, item domain.Subscr } func (set *Set) DeleteMihomoSubscription(ctx context.Context, id string) (bool, error) { - result, err := set.Core.ExecContext(ctx, `DELETE FROM mihomo_subscriptions WHERE id = ?`, id) + result, err := set.DB.ExecContext(ctx, `DELETE FROM mihomo_subscriptions WHERE id = ?`, id) if err != nil { return false, fmt.Errorf("delete Mihomo subscription: %w", err) } @@ -86,7 +86,7 @@ func (set *Set) DeleteMihomoSubscription(ctx context.Context, id string) (bool, } func (set *Set) ReplaceMihomoSubscriptionNodes(ctx context.Context, subscriptionID string, nodes []domain.Node, refreshedAt time.Time, status, errorCode string) error { - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return err } @@ -111,7 +111,7 @@ func (set *Set) ReplaceMihomoSubscriptionNodes(ctx context.Context, subscription } func (set *Set) ListMihomoSubscriptionNodes(ctx context.Context, subscriptionID string) ([]domain.Node, error) { - rows, err := set.Core.QueryContext(ctx, `SELECT subscription_id, node_id, display_name, kind, proxy_yaml, country_code, country_name FROM mihomo_subscription_nodes WHERE subscription_id=? ORDER BY display_name, node_id`, subscriptionID) + rows, err := set.DB.QueryContext(ctx, `SELECT subscription_id, node_id, display_name, kind, proxy_yaml, country_code, country_name FROM mihomo_subscription_nodes WHERE subscription_id=? ORDER BY display_name, node_id`, subscriptionID) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (set *Set) ListMihomoSubscriptionNodes(ctx context.Context, subscriptionID } func (set *Set) MarkMihomoSubscriptionRefreshFailure(ctx context.Context, subscriptionID, errorCode string, refreshedAt time.Time) error { - result, err := set.Core.ExecContext(ctx, `UPDATE mihomo_subscriptions SET last_refresh_at_utc=?, last_refresh_status='failed', last_error_code=?, updated_at_utc=? WHERE id=?`, refreshedAt.UTC().Format(time.RFC3339Nano), errorCode, refreshedAt.UTC().Format(time.RFC3339Nano), subscriptionID) + result, err := set.DB.ExecContext(ctx, `UPDATE mihomo_subscriptions SET last_refresh_at_utc=?, last_refresh_status='failed', last_error_code=?, updated_at_utc=? WHERE id=?`, refreshedAt.UTC().Format(time.RFC3339Nano), errorCode, refreshedAt.UTC().Format(time.RFC3339Nano), subscriptionID) if err != nil { return err } @@ -143,7 +143,7 @@ func (set *Set) MarkMihomoSubscriptionRefreshFailure(ctx context.Context, subscr } func (set *Set) ReadMihomoRuntimeSelection(ctx context.Context) (selected, running string, err error) { - err = set.Core.QueryRowContext(ctx, `SELECT selected_subscription_id, running_subscription_id FROM mihomo_runtime_selection WHERE singleton=1`).Scan(&selected, &running) + err = set.DB.QueryRowContext(ctx, `SELECT selected_subscription_id, running_subscription_id FROM mihomo_runtime_selection WHERE singleton=1`).Scan(&selected, &running) if err != nil { return "", "", fmt.Errorf("read Mihomo runtime selection: %w", err) } @@ -151,7 +151,7 @@ func (set *Set) ReadMihomoRuntimeSelection(ctx context.Context) (selected, runni } func (set *Set) WriteMihomoSelectedSubscription(ctx context.Context, id string, at time.Time) error { - result, err := set.Core.ExecContext(ctx, `UPDATE mihomo_runtime_selection SET selected_subscription_id=?, updated_at_utc=? WHERE singleton=1`, id, at.UTC().Format(time.RFC3339Nano)) + result, err := set.DB.ExecContext(ctx, `UPDATE mihomo_runtime_selection SET selected_subscription_id=?, updated_at_utc=? WHERE singleton=1`, id, at.UTC().Format(time.RFC3339Nano)) if err != nil { return fmt.Errorf("write selected Mihomo subscription: %w", err) } @@ -163,7 +163,7 @@ func (set *Set) WriteMihomoSelectedSubscription(ctx context.Context, id string, } func (set *Set) WriteMihomoRunningSubscription(ctx context.Context, id string, at time.Time) error { - result, err := set.Core.ExecContext(ctx, `UPDATE mihomo_runtime_selection SET running_subscription_id=?, updated_at_utc=? WHERE singleton=1`, id, at.UTC().Format(time.RFC3339Nano)) + result, err := set.DB.ExecContext(ctx, `UPDATE mihomo_runtime_selection SET running_subscription_id=?, updated_at_utc=? WHERE singleton=1`, id, at.UTC().Format(time.RFC3339Nano)) if err != nil { return fmt.Errorf("write running Mihomo subscription: %w", err) } diff --git a/internal/storage/sqlite/notification_queue.go b/internal/storage/sqlite/notification_queue.go new file mode 100644 index 0000000..054e9dd --- /dev/null +++ b/internal/storage/sqlite/notification_queue.go @@ -0,0 +1,120 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "time" + + domain "github.com/leonfox28/simplus/internal/domain/notification" + db "github.com/leonfox28/simplus/internal/storage/sqlite/generated/core" +) + +func enqueueNotification(ctx context.Context, q *db.Queries, event domain.Event) error { + if !slices.Contains(domain.EventKinds, event.Kind) || event.Key == "" || event.ObjectID == "" || event.Message == "" || event.ObservedAt.IsZero() { + return errors.New("invalid notification event") + } + // Connection observations may be consumed after a monitoring gap. Do not + // deliver observations older than the current subscription. Other events + // originate at this durable commit (SMS provider timestamps may be older). + subscriptionAt := time.Now().UnixMilli() + if event.ConnectionKind != "" { + subscriptionAt = event.ObservedAt.UnixMilli() + } + return q.EnqueueNotification(ctx, db.EnqueueNotificationParams{EventKey: event.Key, EventKind: event.Kind, ObjectID: event.ObjectID, ConnectionKind: event.ConnectionKind, Message: event.Message, FeishuMessage: event.FeishuMessage, ObservedAt: event.ObservedAt.UnixMilli(), Now: 0, SubscriptionAt: subscriptionAt}) +} +func (set *Set) EnqueueNotification(ctx context.Context, event domain.Event) error { + return enqueueNotification(ctx, db.New(set.DB), event) +} +func (set *Set) ClaimNotificationDeliveries(ctx context.Context, now time.Time, limit int) ([]domain.Delivery, error) { + rows, err := db.New(set.DB).ClaimNotificationDeliveries(ctx, db.ClaimNotificationDeliveriesParams{Now: now.UnixMilli(), LeaseUntil: now.Add(30 * time.Second).UnixMilli(), BatchLimit: int64(limit)}) + if err != nil { + return nil, err + } + items := make([]domain.Delivery, 0, len(rows)) + for _, r := range rows { + items = append(items, domain.Delivery{ID: r.ID, ChannelID: r.ChannelID, EventKind: r.EventKind, ObjectID: r.ObjectID, ConnectionKind: r.ConnectionKind, Message: r.Message, Attempts: r.Attempts}) + } + return items, nil +} +func (set *Set) FinishNotificationDelivery(ctx context.Context, item domain.Delivery, state, code string, next time.Time) error { + return db.New(set.DB).FinishNotificationDelivery(ctx, db.FinishNotificationDeliveryParams{ID: item.ID, Attempts: item.Attempts, State: state, LastError: code, NextAt: next.UnixMilli()}) +} +func (set *Set) ResetInterruptedNotifications(ctx context.Context, now time.Time) error { + return db.New(set.DB).ResetInterruptedNotifications(ctx, now.UnixMilli()) +} +func (set *Set) NotificationDeliveryCounts(ctx context.Context, id string) (domain.Counts, error) { + c, err := db.New(set.DB).CountNotificationDeliveries(ctx, id) + return domain.Counts{Pending: c.Pending, Failed: c.Failed}, err +} +func (set *Set) ReadConnectionCursor(ctx context.Context, source string) (domain.Cursor, error) { + c, err := db.New(set.DB).ReadConnectionCursor(ctx, source) + if errors.Is(err, sql.ErrNoRows) { + return domain.Cursor{Source: source}, nil + } + return domain.Cursor{Source: source, Instance: c.Instance, Sequence: uint64(c.Sequence), Gaps: c.Gaps}, err +} + +// CommitConnectionObservations atomically advances the source cursor, updates +// confirmed state, and selects the subscriptions that exist at that instant. +func (set *Set) CommitConnectionObservations(ctx context.Context, previous, next domain.Cursor, observations []domain.Observation) error { + tx, err := set.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + q := db.New(tx) + if next.Source != "" { + stored, readErr := q.ReadConnectionCursor(ctx, next.Source) + if readErr != nil && !errors.Is(readErr, sql.ErrNoRows) { + return readErr + } + if stored.Instance != previous.Instance || uint64(stored.Sequence) != previous.Sequence || stored.Gaps != previous.Gaps { + return errors.New("connection cursor conflict") + } + } + for _, o := range observations { + if o.ObjectID == "" || o.DisplayName == "" || (o.Kind != "cellular" && o.Kind != "vowifi") || o.ObservedAt.IsZero() { + return errors.New("invalid connection observation") + } + old, readErr := q.ReadConnectionCheckpoint(ctx, db.ReadConnectionCheckpointParams{ObjectID: o.ObjectID, Kind: o.Kind}) + found := readErr == nil + if readErr != nil && !errors.Is(readErr, sql.ErrNoRows) { + return readErr + } + if next.Source == "" && found && o.ObservedAt.UnixMilli() <= old.ObservedAt { + continue + } + revision := old.Revision + changed := (!found && o.Connected) || (found && (old.Connected == 1) != o.Connected) + if changed { + revision++ + state := "disconnected" + if o.Connected { + state = "connected" + } + if err := enqueueNotification(ctx, q, domain.Event{Key: fmt.Sprintf("connection:%s:%s:%d", o.Kind, o.ObjectID, revision), Kind: o.Kind + "." + state, ObjectID: o.ObjectID, ConnectionKind: o.Kind, Message: domain.ConnectionMessage(o), ObservedAt: o.ObservedAt}); err != nil { + return err + } + } + if err := q.WriteConnectionCheckpoint(ctx, db.WriteConnectionCheckpointParams{ObjectID: o.ObjectID, Kind: o.Kind, Connected: int64(boolInt(o.Connected)), Revision: revision, ObservedAt: o.ObservedAt.UnixMilli()}); err != nil { + return err + } + } + if next.Source != "" { + if err := q.WriteConnectionCursor(ctx, db.WriteConnectionCursorParams{Source: next.Source, Instance: next.Instance, Sequence: int64(next.Sequence), Gaps: next.Gaps}); err != nil { + return err + } + } + return tx.Commit() +} + +func (set *Set) HasConnectionCheckpoint(ctx context.Context, objectID, kind string) (bool, error) { + _, err := db.New(set.DB).ReadConnectionCheckpoint(ctx, db.ReadConnectionCheckpointParams{ObjectID: objectID, Kind: kind}) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} diff --git a/internal/storage/sqlite/notification_queue_test.go b/internal/storage/sqlite/notification_queue_test.go new file mode 100644 index 0000000..3c2435d --- /dev/null +++ b/internal/storage/sqlite/notification_queue_test.go @@ -0,0 +1,238 @@ +package sqlite + +import ( + "path/filepath" + "testing" + "time" + + domain "github.com/leonfox28/simplus/internal/domain/notification" + "github.com/leonfox28/simplus/internal/domain/sms" +) + +func queueChannel(t *testing.T, set *Set, id string) domain.Channel { + t.Helper() + c := domain.Channel{ID: id, Provider: "wecom", DeliveryMode: domain.DeliveryModeWebhook, DisplayName: "测试渠道", WebhookCiphertext: make([]byte, 40), WebhookHint: "qyapi.weixin.qq.com", Enabled: true, EventKinds: domain.EventKinds, LastDeliveryStatus: "never", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := set.UpsertNotificationChannel(t.Context(), c); err != nil { + t.Fatal(err) + } + return c +} + +func TestConnectionCheckpointAndOutboxCommitTogetherAndReplayAfterRestart(t *testing.T) { + ctx := t.Context() + root := filepath.Join(t.TempDir(), "state") + s, err := OpenSet(ctx, root) + if err != nil { + t.Fatal(err) + } + c := queueChannel(t, s, "channel_AAAAAAAAAAAAAAAAAAAAAA") + previous := domain.Cursor{Source: "vowifi"} + next := domain.Cursor{Source: "vowifi", Instance: "instance-1", Sequence: 3} + at := time.Now().UTC() + states := []domain.Observation{ + {ObjectID: "line-A", DisplayName: "线路 A", Kind: "vowifi", Connected: true, ObservedAt: at}, + {ObjectID: "line-A", DisplayName: "线路 A", Kind: "vowifi", Connected: false, ObservedAt: at}, + {ObjectID: "line-A", DisplayName: "线路 A", Kind: "vowifi", Connected: true, ObservedAt: at}, + {ObjectID: "line-B", DisplayName: "线路 B", Kind: "vowifi", Connected: false, ObservedAt: at}, + } + if _, err := s.DB.Exec(`CREATE TRIGGER fail_queue BEFORE INSERT ON notification_deliveries BEGIN SELECT RAISE(ABORT,'test'); END`); err != nil { + t.Fatal(err) + } + if err := s.CommitConnectionObservations(ctx, previous, next, states); err == nil { + t.Fatal("transaction unexpectedly committed") + } + got, err := s.ReadConnectionCursor(ctx, "vowifi") + if err != nil || got != previous { + t.Fatalf("cursor advanced: %+v %v", got, err) + } + var count int + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM connection_checkpoints`).Scan(&count); err != nil || count != 0 { + t.Fatalf("checkpoint escaped rollback: %d %v", count, err) + } + if _, err := s.DB.Exec(`DROP TRIGGER fail_queue`); err != nil { + t.Fatal(err) + } + if err := s.CommitConnectionObservations(ctx, previous, next, states); err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = OpenSet(ctx, root) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.CommitConnectionObservations(ctx, previous, next, states); err == nil { + t.Fatal("stale cursor replay accepted") + } + if err := s.CommitConnectionObservations(ctx, next, next, states[2:]); err != nil { + t.Fatal(err) + } + counts, err := s.NotificationDeliveryCounts(ctx, c.ID) + if err != nil || counts.Pending != 3 { + t.Fatalf("pending=%+v %v", counts, err) + } + for _, kind := range []string{"vowifi.connected", "vowifi.disconnected", "vowifi.connected"} { + items, err := s.ClaimNotificationDeliveries(ctx, at.Add(time.Minute), 20) + if err != nil || len(items) != 1 || items[0].EventKind != kind { + t.Fatalf("ordered items=%+v %v", items, err) + } + if err := s.FinishNotificationDelivery(ctx, items[0], "delivered", "", at); err != nil { + t.Fatal(err) + } + } +} + +func TestDeliveryRetryLeaseIndependentStreamsAndCancellation(t *testing.T) { + ctx := t.Context() + s, err := OpenSet(ctx, filepath.Join(t.TempDir(), "state")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + c := queueChannel(t, s, "channel_AAAAAAAAAAAAAAAAAAAAAA") + at := time.Now() + for _, event := range []domain.Event{ + {Key: "a1", Kind: "cellular.connected", ObjectID: "a", ConnectionKind: "cellular", Message: "online", ObservedAt: at}, + {Key: "a2", Kind: "cellular.disconnected", ObjectID: "a", ConnectionKind: "cellular", Message: "offline", ObservedAt: at}, + {Key: "b1", Kind: "cellular.connected", ObjectID: "b", ConnectionKind: "cellular", Message: "online", ObservedAt: at}, + } { + if err := s.EnqueueNotification(ctx, event); err != nil { + t.Fatal(err) + } + } + items, err := s.ClaimNotificationDeliveries(ctx, at, 20) + if err != nil || len(items) != 2 { + t.Fatalf("items=%+v %v", items, err) + } + var slow domain.Delivery + for _, item := range items { + if item.ObjectID == "a" { + slow = item + err = s.FinishNotificationDelivery(ctx, item, "pending", "TEMPORARY", at.Add(time.Minute)) + } else { + err = s.FinishNotificationDelivery(ctx, item, "delivered", "", at) + } + if err != nil { + t.Fatal(err) + } + } + if items, err := s.ClaimNotificationDeliveries(ctx, at, 20); err != nil || len(items) != 0 { + t.Fatalf("retry bypassed order: %+v %v", items, err) + } + items, err = s.ClaimNotificationDeliveries(ctx, at.Add(time.Minute), 20) + if err != nil || len(items) != 1 || items[0].ID != slow.ID || items[0].Attempts != 2 { + t.Fatalf("retry=%+v %v", items, err) + } + // A crash after dispatch cannot lose work. Its result may be delivered twice externally. + if err := s.ResetInterruptedNotifications(ctx, at); err != nil { + t.Fatal(err) + } + items, err = s.ClaimNotificationDeliveries(ctx, at, 20) + if err != nil || len(items) != 1 || items[0].Attempts != 3 { + t.Fatalf("recovery=%+v %v", items, err) + } + c.EventKinds = []string{"sms.received"} + if err := s.UpsertNotificationChannel(ctx, c); err != nil { + t.Fatal(err) + } + // A late completion from the cancelled subscription cannot resurrect it. + if err := s.FinishNotificationDelivery(ctx, items[0], "pending", "TEMPORARY", at); err != nil { + t.Fatal(err) + } + counts, err := s.NotificationDeliveryCounts(ctx, c.ID) + if err != nil || counts.Pending != 0 { + t.Fatalf("cancelled=%+v %v", counts, err) + } +} + +func TestInboundUnreadAndNotificationAreAtomic(t *testing.T) { + ctx := t.Context() + s, err := OpenSet(ctx, filepath.Join(t.TempDir(), "state")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + c := queueChannel(t, s, "channel_AAAAAAAAAAAAAAAAAAAAAA") + at := time.Now().UTC() + m := sms.Message{ID: "msg_inbound_atomic", Direction: sms.DirectionInbound, LineID: "line_AAAAAAAAAAAAAAAAAAAAAA", RemoteAddress: "+12025550123", Body: "synthetic", OperationID: "inbound_atomic_operation", ProviderMessageID: "source_atomic", Status: sms.StatusReceived, CreatedAt: at, UpdatedAt: at} + if _, err := s.DB.Exec(`CREATE TRIGGER fail_queue BEFORE INSERT ON notification_deliveries BEGIN SELECT RAISE(ABORT,'test'); END`); err != nil { + t.Fatal(err) + } + if _, _, err := s.CreateInboundSMS(ctx, m); err == nil { + t.Fatal("inbound committed without its notification") + } + var count int + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM sms_messages`).Scan(&count); err != nil || count != 0 { + t.Fatalf("message escaped rollback %d %v", count, err) + } + if _, err := s.DB.Exec(`DROP TRIGGER fail_queue`); err != nil { + t.Fatal(err) + } + if _, replay, err := s.CreateInboundSMS(ctx, m); err != nil || replay { + t.Fatalf("inbound=%t %v", replay, err) + } + if _, replay, err := s.CreateInboundSMS(ctx, m); err != nil || !replay { + t.Fatalf("replay=%t %v", replay, err) + } + counts, err := s.NotificationDeliveryCounts(ctx, c.ID) + if err != nil || counts.Pending != 1 { + t.Fatalf("outbox=%+v %v", counts, err) + } + if err := s.DeleteSMS(ctx, m.ID); err != nil { + t.Fatal(err) + } + counts, err = s.NotificationDeliveryCounts(ctx, c.ID) + if err != nil || counts.Pending != 0 { + t.Fatalf("deleted message pending=%+v %v", counts, err) + } +} + +func TestSubscriptionStartDoesNotReplayBufferedConnectionHistory(t *testing.T) { + s, err := OpenSet(t.Context(), filepath.Join(t.TempDir(), "state")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + c := queueChannel(t, s, "channel_AAAAAAAAAAAAAAAAAAAAAA") + event := func(key string, at time.Time) { + t.Helper() + if err := s.EnqueueNotification(t.Context(), domain.Event{Key: key, Kind: "vowifi.connected", ObjectID: "line-A", ConnectionKind: "vowifi", Message: "fixture", ObservedAt: at}); err != nil { + t.Fatal(err) + } + } + event("before", c.CreatedAt.Add(-time.Second)) + event("after", c.CreatedAt.Add(time.Second)) + counts, _ := s.NotificationDeliveryCounts(t.Context(), c.ID) + if counts.Pending != 1 { + t.Fatalf("historical notification=%+v", counts) + } + c.EventKinds = []string{"sms.received"} + c.UpdatedAt = c.CreatedAt.Add(2 * time.Second) + if err := s.UpsertNotificationChannel(t.Context(), c); err != nil { + t.Fatal(err) + } + c.EventKinds = domain.EventKinds + c.UpdatedAt = c.CreatedAt.Add(3 * time.Second) + if err := s.UpsertNotificationChannel(t.Context(), c); err != nil { + t.Fatal(err) + } + event("while unsubscribed", c.CreatedAt.Add(2500*time.Millisecond)) + event("resubscribed", c.CreatedAt.Add(4*time.Second)) + counts, _ = s.NotificationDeliveryCounts(t.Context(), c.ID) + if counts.Pending != 1 { + t.Fatalf("resubscription backfilled history=%+v", counts) + } + // Editing an unchanged subscription must retain its original start time. + c.DisplayName = "renamed" + c.UpdatedAt = c.CreatedAt.Add(10 * time.Second) + if err := s.UpsertNotificationChannel(t.Context(), c); err != nil { + t.Fatal(err) + } + event("buffered while subscribed", c.CreatedAt.Add(5*time.Second)) + counts, _ = s.NotificationDeliveryCounts(t.Context(), c.ID) + if counts.Pending != 2 { + t.Fatalf("rename reset subscription=%+v", counts) + } +} diff --git a/internal/storage/sqlite/notifications.go b/internal/storage/sqlite/notifications.go index 594b91d..0183ff4 100644 --- a/internal/storage/sqlite/notifications.go +++ b/internal/storage/sqlite/notifications.go @@ -9,13 +9,14 @@ import ( "time" domain "github.com/leonfox28/simplus/internal/domain/notification" + coredb "github.com/leonfox28/simplus/internal/storage/sqlite/generated/core" ) const webhookNotificationColumns = `id,provider,display_name,webhook_ciphertext,webhook_hint,signing_secret_ciphertext,enabled,event_kinds,last_delivery_at_utc,last_delivery_status,last_error_code,created_at_utc,updated_at_utc` const feishuAppNotificationColumns = `id,display_name,app_id_ciphertext,app_secret_ciphertext,recipient_open_id_ciphertext,enabled,event_kinds,last_delivery_at_utc,last_delivery_status,last_error_code,created_at_utc,updated_at_utc` func (set *Set) ListNotificationChannels(ctx context.Context) ([]domain.Channel, error) { - webhookRows, err := set.Core.QueryContext(ctx, `SELECT `+webhookNotificationColumns+` FROM notification_channels`) + webhookRows, err := set.DB.QueryContext(ctx, `SELECT `+webhookNotificationColumns+` FROM notification_channels`) if err != nil { return nil, err } @@ -35,7 +36,7 @@ func (set *Set) ListNotificationChannels(ctx context.Context) ([]domain.Channel, return nil, err } - appRows, err := set.Core.QueryContext(ctx, `SELECT `+feishuAppNotificationColumns+` FROM feishu_app_notification_channels`) + appRows, err := set.DB.QueryContext(ctx, `SELECT `+feishuAppNotificationColumns+` FROM feishu_app_notification_channels`) if err != nil { return nil, err } @@ -67,14 +68,14 @@ func sortNotificationChannels(items []domain.Channel) { } func (set *Set) ReadNotificationChannel(ctx context.Context, id string) (domain.Channel, bool, error) { - item, err := scanWebhookNotificationChannel(set.Core.QueryRowContext(ctx, `SELECT `+webhookNotificationColumns+` FROM notification_channels WHERE id=?`, id)) + item, err := scanWebhookNotificationChannel(set.DB.QueryRowContext(ctx, `SELECT `+webhookNotificationColumns+` FROM notification_channels WHERE id=?`, id)) if err == nil { return item, true, nil } if !errors.Is(err, sql.ErrNoRows) { return domain.Channel{}, false, err } - item, err = scanFeishuAppNotificationChannel(set.Core.QueryRowContext(ctx, `SELECT `+feishuAppNotificationColumns+` FROM feishu_app_notification_channels WHERE id=?`, id)) + item, err = scanFeishuAppNotificationChannel(set.DB.QueryRowContext(ctx, `SELECT `+feishuAppNotificationColumns+` FROM feishu_app_notification_channels WHERE id=?`, id)) if errors.Is(err, sql.ErrNoRows) { return domain.Channel{}, false, nil } @@ -147,7 +148,7 @@ func (set *Set) UpsertNotificationChannel(ctx context.Context, item domain.Chann if err != nil { return err } - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return err } @@ -186,15 +187,28 @@ func (set *Set) UpsertNotificationChannel(ctx context.Context, item domain.Chann if err != nil { return err } + q := coredb.New(tx) + if err := q.RemoveInactiveNotificationSubscriptions(ctx, coredb.RemoveInactiveNotificationSubscriptionsParams{ChannelID: item.ID, Enabled: int64(boolInt(item.Enabled)), EventKinds: string(events)}); err != nil { + return err + } + if err := q.AddNotificationSubscriptions(ctx, coredb.AddNotificationSubscriptionsParams{ChannelID: item.ID, Enabled: int64(boolInt(item.Enabled)), EventKinds: string(events), SubscribedAt: item.UpdatedAt.UnixMilli()}); err != nil { + return err + } + if err := coredb.New(tx).CancelChannelNotifications(ctx, coredb.CancelChannelNotificationsParams{ChannelID: item.ID, Enabled: int64(boolInt(item.Enabled)), EventKinds: string(events)}); err != nil { + return err + } return tx.Commit() } func (set *Set) DeleteNotificationChannel(ctx context.Context, id string) (bool, error) { - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return false, err } defer tx.Rollback() + if err := coredb.New(tx).DeleteNotificationSubscriptions(ctx, id); err != nil { + return false, err + } webhook, err := tx.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id) if err != nil { return false, err @@ -211,6 +225,9 @@ func (set *Set) DeleteNotificationChannel(ctx context.Context, id string) (bool, if err != nil { return false, err } + if err := coredb.New(tx).CancelChannelNotifications(ctx, coredb.CancelChannelNotificationsParams{ChannelID: id, Enabled: 0, EventKinds: "[]"}); err != nil { + return false, err + } if err := tx.Commit(); err != nil { return false, err } @@ -219,7 +236,7 @@ func (set *Set) DeleteNotificationChannel(ctx context.Context, id string) (bool, func (set *Set) RecordNotificationDelivery(ctx context.Context, id, status, errorCode string, at time.Time) error { formatted := at.UTC().Format(time.RFC3339Nano) - tx, err := set.Core.BeginTx(ctx, nil) + tx, err := set.DB.BeginTx(ctx, nil) if err != nil { return err } diff --git a/internal/storage/sqlite/notifications_test.go b/internal/storage/sqlite/notifications_test.go index f7f973c..efa8771 100644 --- a/internal/storage/sqlite/notifications_test.go +++ b/internal/storage/sqlite/notifications_test.go @@ -2,14 +2,12 @@ package sqlite import ( "context" - "database/sql" "path/filepath" "strings" "testing" "time" domain "github.com/leonfox28/simplus/internal/domain/notification" - "github.com/pressly/goose/v3" ) func TestFeishuAppNotificationChannelReopensMergesAndRejectsCrossTableCollision(t *testing.T) { @@ -84,64 +82,3 @@ func TestFeishuAppNotificationChannelReopensMergesAndRejectsCrossTableCollision( t.Fatalf("deleted found = %t, err = %v", found, err) } } - -func TestFeishuAppNotificationMigrationDownKeepsWebhookAndReupgradeStartsEmpty(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC() - webhook := domain.Channel{ID: "channel_CCCCCCCCCCCCCCCCCCCCCC", Provider: "feishu", DeliveryMode: domain.DeliveryModeWebhook, DisplayName: "Legacy", WebhookCiphertext: make([]byte, 40), WebhookHint: "open.feishu.cn", Enabled: true, EventKinds: []string{"system.degraded"}, LastDeliveryStatus: "never", CreatedAt: now, UpdatedAt: now} - app := domain.Channel{ID: "channel_DDDDDDDDDDDDDDDDDDDDDD", Provider: "feishu", DeliveryMode: domain.DeliveryModeFeishuApp, DisplayName: "App", FeishuAppIDCiphertext: make([]byte, 40), FeishuAppSecretCiphertext: make([]byte, 40), FeishuRecipientOpenIDCiphertext: make([]byte, 40), Enabled: true, EventKinds: []string{"system.degraded"}, LastDeliveryStatus: "success", LastDeliveryAt: now, CreatedAt: now, UpdatedAt: now} - if err := set.UpsertNotificationChannel(ctx, webhook); err != nil { - t.Fatal(err) - } - if err := set.UpsertNotificationChannel(ctx, app); err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - database, err := sql.Open("sqlite", filepath.Join(root, "core.sqlite3")) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/core", 22) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - var version, webhookCount, appTables int - if err := database.QueryRowContext(ctx, `SELECT schema_version FROM dataset_metadata WHERE singleton=1`).Scan(&version); err != nil { - t.Fatal(err) - } - if err := database.QueryRowContext(ctx, `SELECT count(*) FROM notification_channels`).Scan(&webhookCount); err != nil { - t.Fatal(err) - } - if err := database.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='feishu_app_notification_channels'`).Scan(&appTables); err != nil { - t.Fatal(err) - } - if version != 22 || webhookCount != 1 || appTables != 0 { - t.Fatalf("down state version=%d webhook=%d appTables=%d", version, webhookCount, appTables) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - items, err := set.ListNotificationChannels(ctx) - if err != nil || len(items) != 1 || items[0].ID != webhook.ID { - t.Fatalf("reupgrade items=%#v err=%v", items, err) - } -} diff --git a/internal/storage/sqlite/queries/core/notifications.sql b/internal/storage/sqlite/queries/core/notifications.sql new file mode 100644 index 0000000..140052d --- /dev/null +++ b/internal/storage/sqlite/queries/core/notifications.sql @@ -0,0 +1,65 @@ +-- name: EnqueueNotification :exec +INSERT INTO notification_deliveries(event_key,channel_id,event_kind,object_id,connection_kind,message,next_at,observed_at) +SELECT sqlc.arg(event_key) AS event_key, c.id AS channel_id, sqlc.arg(event_kind) AS event_kind, sqlc.arg(object_id) AS object_id, sqlc.arg(connection_kind) AS connection_kind, + CASE WHEN c.provider='feishu' AND sqlc.arg(feishu_message)<>'' THEN sqlc.arg(feishu_message) ELSE sqlc.arg(message) END AS message, + sqlc.arg(now) AS next_at,sqlc.arg(observed_at) AS observed_at +FROM (SELECT id,provider,enabled,event_kinds FROM notification_channels + UNION ALL SELECT id,'feishu' AS provider,enabled,event_kinds FROM feishu_app_notification_channels) AS c +WHERE c.enabled=1 AND EXISTS (SELECT 1 FROM notification_subscriptions s WHERE s.channel_id=c.id AND s.event_kind=sqlc.arg(event_kind) AND s.subscribed_at<=sqlc.arg(subscription_at)) AND EXISTS (SELECT 1 FROM json_each(c.event_kinds) WHERE value=sqlc.arg(event_kind)) +ON CONFLICT(event_key,channel_id) DO NOTHING; + +-- name: ClaimNotificationDeliveries :many +UPDATE notification_deliveries SET state='delivering', attempts=attempts+1, next_at=sqlc.arg(lease_until) +WHERE id IN ( + SELECT d.id FROM notification_deliveries d + WHERE d.state IN ('pending','delivering') AND d.next_at<=sqlc.arg(now) + AND NOT EXISTS(SELECT 1 FROM notification_deliveries previous + WHERE previous.channel_id=d.channel_id AND previous.object_id=d.object_id + AND previous.connection_kind=d.connection_kind AND previous.id uint64(latestGeneration): - if _, err := tx.ExecContext(ctx, ` -DELETE FROM resource_group_leases -WHERE resource_group_id = ? AND resource_group_generation < ? -`, request.ResourceGroupID, request.ResourceGroupGeneration); err != nil { - return ResourceLease{}, false, fmt.Errorf("fence stale-generation resource leases: %w", err) - } - if _, err := tx.ExecContext(ctx, ` -UPDATE resource_group_fences SET resource_group_generation = ? WHERE resource_group_id = ? -`, request.ResourceGroupGeneration, request.ResourceGroupID); err != nil { - return ResourceLease{}, false, fmt.Errorf("advance resource group generation fence: %w", err) - } - } - if operation, found, err := resourceLeaseOperationByID(ctx, tx, request.OperationID); err != nil { - return ResourceLease{}, false, err - } else if found { - if operation.ResourceGroupID != request.ResourceGroupID || operation.Kind != request.Kind || operation.Purpose != request.Purpose || - operation.Holder != request.Holder || operation.ResourceGroupGeneration != request.ResourceGroupGeneration { - return ResourceLease{}, false, ErrResourceLeaseReplay - } - existing, active, err := resourceLeaseByID(ctx, tx, operation.LeaseID) - if err != nil { - return ResourceLease{}, false, err - } - if !active { - return ResourceLease{}, false, ErrResourceLeaseClosed - } - if err := tx.Commit(); err != nil { - return ResourceLease{}, false, fmt.Errorf("commit replayed resource lease: %w", err) - } - return existing, true, nil - } - - var operationCount, callCount int - if err := tx.QueryRowContext(ctx, ` -SELECT - COALESCE(SUM(CASE WHEN lease_kind = 'operation' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN lease_kind = 'call' THEN 1 ELSE 0 END), 0) -FROM resource_group_leases -WHERE resource_group_id = ? AND expires_at_unix > ? -`, request.ResourceGroupID, nowUnix).Scan(&operationCount, &callCount); err != nil { - return ResourceLease{}, false, fmt.Errorf("count active resource leases: %w", err) - } - switch request.Kind { - case ResourceLeaseOperation: - if operationCount >= request.MaxConcurrentOperations || callCount > 0 { - return ResourceLease{}, false, ErrResourceGroupBusy - } - case ResourceLeaseCall: - if operationCount > 0 || callCount >= request.MaxActiveCalls { - return ResourceLease{}, false, ErrResourceGroupBusy - } - default: - return ResourceLease{}, false, fmt.Errorf("unsupported resource lease kind %q", request.Kind) - } - - var fencingToken int64 - if err := tx.QueryRowContext(ctx, ` -INSERT INTO resource_group_fences (resource_group_id, resource_group_generation, fencing_token) -VALUES (?, ?, 1) -ON CONFLICT(resource_group_id) DO UPDATE SET - resource_group_generation = excluded.resource_group_generation, - fencing_token = fencing_token + 1 -RETURNING fencing_token -`, request.ResourceGroupID, request.ResourceGroupGeneration).Scan(&fencingToken); err != nil { - return ResourceLease{}, false, fmt.Errorf("advance resource lease fence: %w", err) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO resource_group_leases ( - lease_id, operation_id, resource_group_id, lease_kind, purpose, holder, - resource_group_generation, fencing_token, created_at_unix, expires_at_unix -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -`, request.LeaseID, request.OperationID, request.ResourceGroupID, request.Kind, request.Purpose, request.Holder, - request.ResourceGroupGeneration, fencingToken, nowUnix, request.ExpiresAt.UTC().Unix()); err != nil { - return ResourceLease{}, false, fmt.Errorf("insert resource lease: %w", err) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO resource_group_lease_operations ( - operation_id, lease_id, resource_group_id, lease_kind, purpose, holder, - resource_group_generation, fencing_token, created_at_unix -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -`, request.OperationID, request.LeaseID, request.ResourceGroupID, request.Kind, request.Purpose, request.Holder, - request.ResourceGroupGeneration, fencingToken, nowUnix); err != nil { - return ResourceLease{}, false, fmt.Errorf("insert resource lease operation: %w", err) - } - if err := tx.Commit(); err != nil { - return ResourceLease{}, false, fmt.Errorf("commit resource lease: %w", err) - } - return ResourceLease{ - LeaseID: request.LeaseID, OperationID: request.OperationID, ResourceGroupID: request.ResourceGroupID, Kind: request.Kind, - Purpose: request.Purpose, Holder: request.Holder, ResourceGroupGeneration: request.ResourceGroupGeneration, - FencingToken: uint64(fencingToken), CreatedAt: request.Now.UTC(), ExpiresAt: request.ExpiresAt.UTC(), - }, false, nil -} - -func (set *Set) RenewResourceGroupLease(ctx context.Context, leaseID string, fencingToken uint64, now, expiresAt time.Time) (ResourceLease, error) { - if set == nil || set.Runtime == nil { - return ResourceLease{}, fmt.Errorf("runtime database is not configured") - } - if leaseID == "" || fencingToken == 0 || now.IsZero() || !expiresAt.After(now) { - return ResourceLease{}, fmt.Errorf("invalid resource lease renewal") - } - result, err := set.Runtime.ExecContext(ctx, ` -UPDATE resource_group_leases -SET expires_at_unix = ? -WHERE lease_id = ? AND fencing_token = ? AND expires_at_unix > ? -`, expiresAt.UTC().Unix(), leaseID, fencingToken, now.UTC().Unix()) - if err != nil { - return ResourceLease{}, fmt.Errorf("renew resource lease: %w", err) - } - changed, err := result.RowsAffected() - if err != nil { - return ResourceLease{}, fmt.Errorf("read resource lease renewal result: %w", err) - } - if changed != 1 { - return ResourceLease{}, ErrResourceLeaseMissing - } - lease, found, err := resourceLeaseByID(ctx, set.Runtime, leaseID) - if err != nil { - return ResourceLease{}, err - } - if !found { - return ResourceLease{}, ErrResourceLeaseMissing - } - return lease, nil -} - -func (set *Set) ReleaseResourceGroupLease(ctx context.Context, leaseID string, fencingToken uint64) error { - if set == nil || set.Runtime == nil { - return fmt.Errorf("runtime database is not configured") - } - if leaseID == "" || fencingToken == 0 { - return fmt.Errorf("invalid resource lease release") - } - result, err := set.Runtime.ExecContext(ctx, `DELETE FROM resource_group_leases WHERE lease_id = ? AND fencing_token = ?`, leaseID, fencingToken) - if err != nil { - return fmt.Errorf("release resource lease: %w", err) - } - changed, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("read resource lease release result: %w", err) - } - if changed != 1 { - return ErrResourceLeaseMissing - } - return nil -} - -func (set *Set) ActiveResourceGroupLeases(ctx context.Context, groupID string, now time.Time) ([]ResourceLease, error) { - if set == nil || set.Runtime == nil { - return nil, fmt.Errorf("runtime database is not configured") - } - rows, err := set.Runtime.QueryContext(ctx, ` -SELECT lease_id, operation_id, resource_group_id, lease_kind, purpose, holder, - resource_group_generation, fencing_token, created_at_unix, expires_at_unix -FROM resource_group_leases -WHERE resource_group_id = ? AND expires_at_unix > ? -ORDER BY fencing_token -`, groupID, now.UTC().Unix()) - if err != nil { - return nil, fmt.Errorf("query active resource leases: %w", err) - } - defer rows.Close() - var leases []ResourceLease - for rows.Next() { - lease, err := scanResourceLease(rows) - if err != nil { - return nil, err - } - leases = append(leases, lease) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate active resource leases: %w", err) - } - return leases, nil -} - -func validateResourceLeaseAcquire(request ResourceLeaseAcquire) error { - if len(request.LeaseID) < 16 || len(request.LeaseID) > 128 || request.OperationID == "" || len(request.OperationID) > 128 || - request.ResourceGroupID == "" || len(request.ResourceGroupID) > 64 || request.Purpose == "" || len(request.Purpose) > 64 || - request.Holder == "" || len(request.Holder) > 128 || request.ResourceGroupGeneration == 0 || request.ResourceGroupGeneration > math.MaxInt64 || request.Now.IsZero() || - !request.ExpiresAt.After(request.Now) || request.ExpiresAt.Sub(request.Now) > 10*time.Minute || - request.MaxActiveCalls < 0 || request.MaxActiveCalls > 64 || request.MaxConcurrentOperations < 1 || request.MaxConcurrentOperations > 64 { - return fmt.Errorf("invalid resource lease request") - } - if request.Kind != ResourceLeaseOperation && request.Kind != ResourceLeaseCall { - return fmt.Errorf("invalid resource lease kind") - } - if request.Kind == ResourceLeaseCall && request.MaxActiveCalls == 0 { - return fmt.Errorf("resource group does not allow calls") - } - return nil -} - -type leaseScanner interface { - Scan(...any) error -} - -func resourceLeaseOperationByID(ctx context.Context, queryer interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -}, operationID string) (ResourceLease, bool, error) { - var operation ResourceLease - var generation, fencingToken uint64 - var createdAt int64 - err := queryer.QueryRowContext(ctx, ` -SELECT lease_id, operation_id, resource_group_id, lease_kind, purpose, holder, - resource_group_generation, fencing_token, created_at_unix -FROM resource_group_lease_operations WHERE operation_id = ? -`, operationID).Scan(&operation.LeaseID, &operation.OperationID, &operation.ResourceGroupID, &operation.Kind, &operation.Purpose, - &operation.Holder, &generation, &fencingToken, &createdAt) - if errors.Is(err, sql.ErrNoRows) { - return ResourceLease{}, false, nil - } - if err != nil { - return ResourceLease{}, false, fmt.Errorf("read resource lease operation: %w", err) - } - operation.ResourceGroupGeneration = generation - operation.FencingToken = fencingToken - operation.CreatedAt = time.Unix(createdAt, 0).UTC() - return operation, true, nil -} - -func resourceLeaseByID(ctx context.Context, queryer interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -}, leaseID string) (ResourceLease, bool, error) { - return scanOptionalResourceLease(queryer.QueryRowContext(ctx, ` -SELECT lease_id, operation_id, resource_group_id, lease_kind, purpose, holder, - resource_group_generation, fencing_token, created_at_unix, expires_at_unix -FROM resource_group_leases WHERE lease_id = ? -`, leaseID)) -} - -func scanOptionalResourceLease(scanner leaseScanner) (ResourceLease, bool, error) { - lease, err := scanResourceLease(scanner) - if errors.Is(err, sql.ErrNoRows) { - return ResourceLease{}, false, nil - } - if err != nil { - return ResourceLease{}, false, err - } - return lease, true, nil -} - -func scanResourceLease(scanner leaseScanner) (ResourceLease, error) { - var lease ResourceLease - var generation, fencingToken uint64 - var createdAt, expiresAt int64 - if err := scanner.Scan(&lease.LeaseID, &lease.OperationID, &lease.ResourceGroupID, &lease.Kind, &lease.Purpose, &lease.Holder, - &generation, &fencingToken, &createdAt, &expiresAt); err != nil { - return ResourceLease{}, err - } - lease.ResourceGroupGeneration = generation - lease.FencingToken = fencingToken - lease.CreatedAt = time.Unix(createdAt, 0).UTC() - lease.ExpiresAt = time.Unix(expiresAt, 0).UTC() - return lease, nil -} diff --git a/internal/storage/sqlite/resource_leases_test.go b/internal/storage/sqlite/resource_leases_test.go deleted file mode 100644 index 6b66805..0000000 --- a/internal/storage/sqlite/resource_leases_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package sqlite - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "sync" - "testing" - "time" -) - -func TestResourceGroupLeaseFencesConflictsAndReplays(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - now := time.Unix(1_700_000_000, 0) - request := leaseRequest("lease-operation-0001", "operation-1", ResourceLeaseOperation, now) - lease, replayed, err := set.AcquireResourceGroupLease(ctx, request) - if err != nil { - t.Fatal(err) - } - if replayed || lease.FencingToken != 1 { - t.Fatalf("lease = %#v replayed=%t", lease, replayed) - } - replayedLease, replayed, err := set.AcquireResourceGroupLease(ctx, request) - if err != nil { - t.Fatal(err) - } - if !replayed || replayedLease != lease { - t.Fatalf("replayed lease = %#v replayed=%t", replayedLease, replayed) - } - changed := request - changed.Holder = "different-holder" - if _, _, err := set.AcquireResourceGroupLease(ctx, changed); !errors.Is(err, ErrResourceLeaseReplay) { - t.Fatalf("changed replay error = %v", err) - } - call := leaseRequest("lease-call-00000001", "call-1", ResourceLeaseCall, now) - if _, _, err := set.AcquireResourceGroupLease(ctx, call); !errors.Is(err, ErrResourceGroupBusy) { - t.Fatalf("call during operation error = %v", err) - } - if err := set.ReleaseResourceGroupLease(ctx, lease.LeaseID, lease.FencingToken+1); !errors.Is(err, ErrResourceLeaseMissing) { - t.Fatalf("stale release error = %v", err) - } - if err := set.ReleaseResourceGroupLease(ctx, lease.LeaseID, lease.FencingToken); err != nil { - t.Fatal(err) - } - callLease, _, err := set.AcquireResourceGroupLease(ctx, call) - if err != nil { - t.Fatal(err) - } - if callLease.FencingToken != 2 { - t.Fatalf("call fencing token = %d", callLease.FencingToken) - } - if _, _, err := set.AcquireResourceGroupLease(ctx, leaseRequest("lease-call-00000002", "call-2", ResourceLeaseCall, now)); !errors.Is(err, ErrResourceGroupBusy) { - t.Fatalf("second call error = %v", err) - } - newGeneration := leaseRequest("lease-operation-0002", "operation-2", ResourceLeaseOperation, now) - newGeneration.ResourceGroupGeneration = 2 - newLease, _, err := set.AcquireResourceGroupLease(ctx, newGeneration) - if err != nil { - t.Fatal(err) - } - if newLease.FencingToken != 3 || newLease.ResourceGroupGeneration != 2 { - t.Fatalf("new-generation lease = %#v", newLease) - } - staleGeneration := leaseRequest("lease-operation-0003", "operation-3", ResourceLeaseOperation, now) - if _, _, err := set.AcquireResourceGroupLease(ctx, staleGeneration); !errors.Is(err, ErrResourceLeaseStaleGeneration) { - t.Fatalf("stale-generation acquire error = %v", err) - } - active, err := set.ActiveResourceGroupLeases(ctx, "group-1", now) - if err != nil || len(active) != 1 || active[0].LeaseID != newLease.LeaseID { - t.Fatalf("active after stale-generation acquire = %#v err=%v", active, err) - } - if err := set.ReleaseResourceGroupLease(ctx, callLease.LeaseID, callLease.FencingToken); !errors.Is(err, ErrResourceLeaseMissing) { - t.Fatalf("stale-generation release error = %v", err) - } -} - -func TestResourceGroupLeaseRenewExpiryAndRestart(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - now := time.Unix(1_700_000_000, 0) - lease, _, err := set.AcquireResourceGroupLease(ctx, leaseRequest("lease-operation-0001", "operation-1", ResourceLeaseOperation, now)) - if err != nil { - t.Fatal(err) - } - renewed, err := set.RenewResourceGroupLease(ctx, lease.LeaseID, lease.FencingToken, now.Add(10*time.Second), now.Add(90*time.Second)) - if err != nil { - t.Fatal(err) - } - if !renewed.ExpiresAt.Equal(now.Add(90 * time.Second)) { - t.Fatalf("renewed expiry = %s", renewed.ExpiresAt) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - active, err := set.ActiveResourceGroupLeases(ctx, "group-1", now.Add(20*time.Second)) - if err != nil || len(active) != 1 || active[0].FencingToken != lease.FencingToken { - t.Fatalf("active after restart = %#v err=%v", active, err) - } - expiredReplay := leaseRequest("lease-operation-retry", "operation-1", ResourceLeaseOperation, now.Add(91*time.Second)) - if _, _, err := set.AcquireResourceGroupLease(ctx, expiredReplay); !errors.Is(err, ErrResourceLeaseClosed) { - t.Fatalf("expired operation replay error = %v", err) - } - next, _, err := set.AcquireResourceGroupLease(ctx, leaseRequest("lease-operation-0002", "operation-2", ResourceLeaseOperation, now.Add(91*time.Second))) - if err != nil { - t.Fatal(err) - } - if next.FencingToken != lease.FencingToken+1 { - t.Fatalf("next fence = %d", next.FencingToken) - } - if _, err := set.RenewResourceGroupLease(ctx, lease.LeaseID, lease.FencingToken, now.Add(91*time.Second), now.Add(120*time.Second)); !errors.Is(err, ErrResourceLeaseMissing) { - t.Fatalf("expired renewal error = %v", err) - } -} - -func TestResourceGroupLeaseConcurrentAcquisitionHasOneWinner(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - now := time.Unix(1_700_000_000, 0) - const contenders = 12 - start := make(chan struct{}) - var wait sync.WaitGroup - var mu sync.Mutex - winners := 0 - busy := 0 - for index := 0; index < contenders; index++ { - wait.Add(1) - go func(index int) { - defer wait.Done() - <-start - request := leaseRequest(fmt.Sprintf("lease-operation-%04d", index), fmt.Sprintf("operation-%d", index), ResourceLeaseOperation, now) - _, _, err := set.AcquireResourceGroupLease(ctx, request) - mu.Lock() - defer mu.Unlock() - switch { - case err == nil: - winners++ - case errors.Is(err, ErrResourceGroupBusy): - busy++ - default: - t.Errorf("contender %d error = %v", index, err) - } - }(index) - } - close(start) - wait.Wait() - if winners != 1 || busy != contenders-1 { - t.Fatalf("winners=%d busy=%d", winners, busy) - } -} - -func leaseRequest(leaseID, operationID, kind string, now time.Time) ResourceLeaseAcquire { - return ResourceLeaseAcquire{ - LeaseID: leaseID, OperationID: operationID, ResourceGroupID: "group-1", Kind: kind, - Purpose: "test-operation", Holder: "test-holder", ResourceGroupGeneration: 1, - MaxActiveCalls: 1, MaxConcurrentOperations: 1, Now: now, ExpiresAt: now.Add(time.Minute), - } -} diff --git a/internal/storage/sqlite/setup_authorization.go b/internal/storage/sqlite/setup_authorization.go deleted file mode 100644 index e9e5e17..0000000 --- a/internal/storage/sqlite/setup_authorization.go +++ /dev/null @@ -1,130 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" -) - -func (set *Set) ReplaceSetupBootstrap(ctx context.Context, tokenHash [32]byte, createdAtUnix, expiresAtUnix int64) error { - if set == nil || set.Runtime == nil { - return fmt.Errorf("runtime database is not open") - } - tx, err := set.Runtime.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin bootstrap replacement: %w", err) - } - defer tx.Rollback() - - if _, err := tx.ExecContext(ctx, `DELETE FROM setup_sessions`); err != nil { - return fmt.Errorf("revoke setup sessions: %w", err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO setup_bootstrap_grant ( - singleton, token_hash, created_at_unix, expires_at_unix, consumed_at_unix - ) VALUES (1, ?, ?, ?, NULL) - ON CONFLICT(singleton) DO UPDATE SET - token_hash = excluded.token_hash, - created_at_unix = excluded.created_at_unix, - expires_at_unix = excluded.expires_at_unix, - consumed_at_unix = NULL - `, tokenHash[:], createdAtUnix, expiresAtUnix); err != nil { - return fmt.Errorf("store setup bootstrap grant: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit bootstrap replacement: %w", err) - } - return nil -} - -func (set *Set) ConsumeSetupBootstrap( - ctx context.Context, - bootstrapHash [32]byte, - sessionHash [32]byte, - consumedAtUnix int64, - sessionExpiresAtUnix int64, -) (bool, error) { - if set == nil || set.Runtime == nil { - return false, fmt.Errorf("runtime database is not open") - } - tx, err := set.Runtime.BeginTx(ctx, nil) - if err != nil { - return false, fmt.Errorf("begin bootstrap consumption: %w", err) - } - defer tx.Rollback() - - result, err := tx.ExecContext(ctx, ` - UPDATE setup_bootstrap_grant - SET consumed_at_unix = ? - WHERE singleton = 1 - AND token_hash = ? - AND consumed_at_unix IS NULL - AND expires_at_unix > ? - `, consumedAtUnix, bootstrapHash[:], consumedAtUnix) - if err != nil { - return false, fmt.Errorf("consume setup bootstrap grant: %w", err) - } - rows, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("read bootstrap consumption result: %w", err) - } - if rows != 1 { - return false, nil - } - - if _, err := tx.ExecContext(ctx, `DELETE FROM setup_sessions WHERE expires_at_unix <= ?`, consumedAtUnix); err != nil { - return false, fmt.Errorf("delete expired setup sessions: %w", err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO setup_sessions ( - token_hash, created_at_unix, expires_at_unix, selected_flow, updated_at_unix - ) VALUES (?, ?, ?, 'create-new', ?) - `, sessionHash[:], consumedAtUnix, sessionExpiresAtUnix, consumedAtUnix); err != nil { - return false, fmt.Errorf("create setup session: %w", err) - } - if err := tx.Commit(); err != nil { - return false, fmt.Errorf("commit bootstrap consumption: %w", err) - } - return true, nil -} - -func (set *Set) RevokeSetupAuthorization(ctx context.Context) error { - if set == nil || set.Runtime == nil { - return fmt.Errorf("runtime database is not open") - } - tx, err := set.Runtime.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin setup authorization revocation: %w", err) - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM setup_sessions`); err != nil { - return fmt.Errorf("delete setup sessions: %w", err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM setup_bootstrap_grant`); err != nil { - return fmt.Errorf("delete setup bootstrap grant: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit setup authorization revocation: %w", err) - } - return nil -} - -func (set *Set) ReadSetupSession(ctx context.Context, tokenHash [32]byte, nowUnix int64) (int64, string, bool, error) { - if set == nil || set.Runtime == nil { - return 0, "", false, fmt.Errorf("runtime database is not open") - } - var expiresAtUnix int64 - var selectedFlow string - err := set.Runtime.QueryRowContext(ctx, ` - SELECT expires_at_unix, selected_flow - FROM setup_sessions - WHERE token_hash = ? AND expires_at_unix > ? - `, tokenHash[:], nowUnix).Scan(&expiresAtUnix, &selectedFlow) - if err == sql.ErrNoRows { - return 0, "", false, nil - } - if err != nil { - return 0, "", false, fmt.Errorf("read setup session: %w", err) - } - return expiresAtUnix, selectedFlow, true, nil -} diff --git a/internal/storage/sqlite/setup_authorization_test.go b/internal/storage/sqlite/setup_authorization_test.go deleted file mode 100644 index fbcf397..0000000 --- a/internal/storage/sqlite/setup_authorization_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package sqlite - -import ( - "context" - "crypto/sha256" - "path/filepath" - "sync" - "sync/atomic" - "testing" -) - -func TestSetupBootstrapConsumptionIsAtomicAndSessionSurvivesRestart(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - - bootstrapHash := sha256.Sum256([]byte("bootstrap")) - if err := set.ReplaceSetupBootstrap(ctx, bootstrapHash, 100, 200); err != nil { - t.Fatal(err) - } - - var consumedCount atomic.Int32 - var winningHash [32]byte - var winnerMu sync.Mutex - var wait sync.WaitGroup - for index := range 8 { - wait.Add(1) - go func(index int) { - defer wait.Done() - sessionHash := sha256.Sum256([]byte{byte(index + 1)}) - consumed, err := set.ConsumeSetupBootstrap(ctx, bootstrapHash, sessionHash, 110, 300) - if err != nil { - t.Errorf("ConsumeSetupBootstrap(%d): %v", index, err) - return - } - if consumed { - consumedCount.Add(1) - winnerMu.Lock() - winningHash = sessionHash - winnerMu.Unlock() - } - }(index) - } - wait.Wait() - if consumedCount.Load() != 1 { - t.Fatalf("successful bootstrap consumptions = %d, want 1", consumedCount.Load()) - } - - if err := set.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - - expires, flow, found, err := set.ReadSetupSession(ctx, winningHash, 120) - if err != nil { - t.Fatal(err) - } - if !found || expires != 300 || flow != "create-new" { - t.Fatalf("session = found:%v expires:%d flow:%q", found, expires, flow) - } - - replacement := sha256.Sum256([]byte("replacement")) - if err := set.ReplaceSetupBootstrap(ctx, replacement, 130, 230); err != nil { - t.Fatal(err) - } - if _, _, found, err := set.ReadSetupSession(ctx, winningHash, 131); err != nil { - t.Fatal(err) - } else if found { - t.Fatal("generating a replacement bootstrap did not revoke prior setup sessions") - } -} diff --git a/internal/storage/sqlite/setup_completion.go b/internal/storage/sqlite/setup_completion.go deleted file mode 100644 index 42144cc..0000000 --- a/internal/storage/sqlite/setup_completion.go +++ /dev/null @@ -1,32 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "time" -) - -func (set *Set) CompleteInitialSetup(ctx context.Context, _ string, now time.Time) (bool, error) { - if set == nil || set.Core == nil { - return false, fmt.Errorf("core database is not open") - } - result, err := set.Core.ExecContext(ctx, ` -UPDATE installation_state -SET - state = 'ready', - initialized_at_utc = ?, - instance_generation = instance_generation + 1 -WHERE singleton = 1 - AND state = 'uninitialized' - AND EXISTS (SELECT 1 FROM administrators WHERE singleton = 1) - AND EXISTS (SELECT 1 FROM setup_storage WHERE singleton = 1) -`, now.UTC().Format(time.RFC3339Nano)) - if err != nil { - return false, fmt.Errorf("complete initial setup: %w", err) - } - rows, err := result.RowsAffected() - if err != nil { - return false, fmt.Errorf("read initial setup completion result: %w", err) - } - return rows == 1, nil -} diff --git a/internal/storage/sqlite/setup_completion_test.go b/internal/storage/sqlite/setup_completion_test.go deleted file mode 100644 index 9171fb8..0000000 --- a/internal/storage/sqlite/setup_completion_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package sqlite - -import ( - "context" - "path/filepath" - "testing" - "time" -) - -func TestCompleteInitialSetupAtomicallyRequiresEveryPrerequisite(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) - const digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - if completed, err := set.CompleteInitialSetup(ctx, digest, now); err != nil || completed { - t.Fatalf("completion without prerequisites = %t/%v", completed, err) - } - if err := set.ConfigureInitialAdministrator(ctx, "admin", "$argon2id$v1$m=8192,t=1,p=1$WlpaWlpaWlpaWlpaWlpaWg$WlpaWlpaWlpaWlpaWlpaWg", "en-US", now); err != nil { - t.Fatal(err) - } - if completed, err := set.CompleteInitialSetup(ctx, digest, now); err != nil || completed { - t.Fatalf("completion without storage = %t/%v", completed, err) - } - if err := set.ConfigureSetupStorage(ctx, set.Root, filepath.Join(set.Root, "recordings"), 1, 2, 1, 3, now); err != nil { - t.Fatal(err) - } - completed, err := set.CompleteInitialSetup(ctx, digest, now) - if err != nil || !completed { - t.Fatalf("completion = %t/%v", completed, err) - } - var state string - var initializedAt string - var generation int - if err := set.Core.QueryRowContext(ctx, `SELECT state, initialized_at_utc, instance_generation FROM installation_state WHERE singleton = 1`).Scan(&state, &initializedAt, &generation); err != nil { - t.Fatal(err) - } - if state != InstallationReady || initializedAt == "" || generation != 2 { - t.Fatalf("installation state = %q %q %d", state, initializedAt, generation) - } - if completed, err := set.CompleteInitialSetup(ctx, digest, now); err != nil || completed { - t.Fatalf("second completion = %t/%v", completed, err) - } -} diff --git a/internal/storage/sqlite/setup_hardware.go b/internal/storage/sqlite/setup_hardware.go deleted file mode 100644 index 6349a5b..0000000 --- a/internal/storage/sqlite/setup_hardware.go +++ /dev/null @@ -1,71 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" -) - -func (set *Set) ConfirmSetupHardware(ctx context.Context, digest string, deviceCount, lineCount int, now time.Time) error { - if set == nil || set.Core == nil { - return fmt.Errorf("core database is not open") - } - tx, err := set.Core.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin setup hardware transaction: %w", err) - } - defer tx.Rollback() - var state string - if err := tx.QueryRowContext(ctx, `SELECT state FROM installation_state WHERE singleton = 1`).Scan(&state); err != nil { - return fmt.Errorf("read installation state for hardware review: %w", err) - } - if state != InstallationUninitialized { - return fmt.Errorf("hardware review requires uninitialized state, found %q", state) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO setup_hardware_review ( - singleton, inventory_digest_sha256, device_count, line_count, reviewed_at_utc -) VALUES (1, ?, ?, ?, ?) -ON CONFLICT(singleton) DO UPDATE SET - inventory_digest_sha256 = excluded.inventory_digest_sha256, - device_count = excluded.device_count, - line_count = excluded.line_count, - reviewed_at_utc = excluded.reviewed_at_utc -`, digest, deviceCount, lineCount, now.UTC().Format(time.RFC3339Nano)); err != nil { - return fmt.Errorf("persist setup hardware review: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit setup hardware review: %w", err) - } - return nil -} - -func (set *Set) ReadSetupHardwareReview(ctx context.Context) (digest string, deviceCount, lineCount int, reviewed bool, err error) { - if set == nil || set.Core == nil { - return "", 0, 0, false, fmt.Errorf("core database is not open") - } - err = set.Core.QueryRowContext(ctx, ` -SELECT inventory_digest_sha256, device_count, line_count -FROM setup_hardware_review -WHERE singleton = 1 -`).Scan(&digest, &deviceCount, &lineCount) - if errors.Is(err, sql.ErrNoRows) { - return "", 0, 0, false, nil - } - if err != nil { - return "", 0, 0, false, fmt.Errorf("read setup hardware review: %w", err) - } - return digest, deviceCount, lineCount, true, nil -} - -func (set *Set) InvalidateSetupHardwareReview(ctx context.Context) error { - if set == nil || set.Core == nil { - return fmt.Errorf("core database is not open") - } - if _, err := set.Core.ExecContext(ctx, `DELETE FROM setup_hardware_review WHERE singleton = 1`); err != nil { - return fmt.Errorf("invalidate setup hardware review: %w", err) - } - return nil -} diff --git a/internal/storage/sqlite/setup_hardware_test.go b/internal/storage/sqlite/setup_hardware_test.go deleted file mode 100644 index 83dbaed..0000000 --- a/internal/storage/sqlite/setup_hardware_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package sqlite - -import ( - "context" - "path/filepath" - "testing" - "time" -) - -func TestSetupHardwareReviewPersists(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - if _, _, _, reviewed, err := set.ReadSetupHardwareReview(ctx); err != nil || reviewed { - t.Fatalf("initial hardware review = %t/%v", reviewed, err) - } - const digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - if err := set.ConfirmSetupHardware(ctx, digest, 1, 1, time.Now()); err != nil { - t.Fatal(err) - } - storedDigest, devices, lines, reviewed, err := set.ReadSetupHardwareReview(ctx) - if err != nil { - t.Fatal(err) - } - if !reviewed || storedDigest != digest || devices != 1 || lines != 1 { - t.Fatalf("hardware review = %q %d %d %t", storedDigest, devices, lines, reviewed) - } -} diff --git a/internal/storage/sqlite/setup_storage.go b/internal/storage/sqlite/setup_storage.go deleted file mode 100644 index e45d899..0000000 --- a/internal/storage/sqlite/setup_storage.go +++ /dev/null @@ -1,122 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" -) - -func (set *Set) SetupDataRoot() string { - if set == nil { - return "" - } - return set.Root -} - -func (set *Set) ConfigureSetupStorage( - ctx context.Context, - dataRoot string, - recordingsRoot string, - dataDevice uint64, - dataInode uint64, - recordingsDevice uint64, - recordingsInode uint64, - now time.Time, -) error { - if set == nil || set.Core == nil { - return fmt.Errorf("core database is not open") - } - if dataRoot != set.Root { - return errors.New("setup data root does not match the open database root") - } - tx, err := set.Core.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin setup storage transaction: %w", err) - } - defer tx.Rollback() - var state string - if err := tx.QueryRowContext(ctx, `SELECT state FROM installation_state WHERE singleton = 1`).Scan(&state); err != nil { - return fmt.Errorf("read installation state for storage setup: %w", err) - } - if state != InstallationUninitialized { - return fmt.Errorf("storage setup requires uninitialized state, found %q", state) - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO setup_storage ( - singleton, - data_root, - recordings_root, - data_device, - data_inode, - recordings_device, - recordings_inode, - configured_at_utc -) VALUES (1, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(singleton) DO UPDATE SET - data_root = excluded.data_root, - recordings_root = excluded.recordings_root, - data_device = excluded.data_device, - data_inode = excluded.data_inode, - recordings_device = excluded.recordings_device, - recordings_inode = excluded.recordings_inode, - configured_at_utc = excluded.configured_at_utc -`, - dataRoot, - recordingsRoot, - int64(dataDevice), - int64(dataInode), - int64(recordingsDevice), - int64(recordingsInode), - now.UTC().Format(time.RFC3339Nano), - ); err != nil { - return fmt.Errorf("persist setup storage: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit setup storage: %w", err) - } - return nil -} - -func (set *Set) ReadSetupStorage(ctx context.Context) ( - dataRoot string, - recordingsRoot string, - dataDevice uint64, - dataInode uint64, - recordingsDevice uint64, - recordingsInode uint64, - configured bool, - err error, -) { - if set == nil || set.Core == nil { - return "", "", 0, 0, 0, 0, false, fmt.Errorf("core database is not open") - } - var storedDataDevice, storedDataInode, storedRecordingsDevice, storedRecordingsInode int64 - err = set.Core.QueryRowContext(ctx, ` -SELECT data_root, recordings_root, data_device, data_inode, recordings_device, recordings_inode -FROM setup_storage -WHERE singleton = 1 -`).Scan( - &dataRoot, - &recordingsRoot, - &storedDataDevice, - &storedDataInode, - &storedRecordingsDevice, - &storedRecordingsInode, - ) - if errors.Is(err, sql.ErrNoRows) { - return set.Root, "", 0, 0, 0, 0, false, nil - } - if err != nil { - return "", "", 0, 0, 0, 0, false, fmt.Errorf("read setup storage: %w", err) - } - return dataRoot, - recordingsRoot, - uint64(storedDataDevice), - uint64(storedDataInode), - uint64(storedRecordingsDevice), - uint64(storedRecordingsInode), - true, - nil -} diff --git a/internal/storage/sqlite/setup_storage_test.go b/internal/storage/sqlite/setup_storage_test.go deleted file mode 100644 index 0b3c148..0000000 --- a/internal/storage/sqlite/setup_storage_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package sqlite - -import ( - "context" - "path/filepath" - "testing" - "time" -) - -func TestSetupStoragePersistsStableDirectoryIdentities(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - dataRoot, recordingsRoot, _, _, _, _, configured, err := set.ReadSetupStorage(ctx) - if err != nil { - t.Fatal(err) - } - if configured || dataRoot != set.Root || recordingsRoot != "" { - t.Fatalf("initial setup storage = data %q recordings %q configured %t", dataRoot, recordingsRoot, configured) - } - - recordingsRoot = filepath.Join(filepath.Dir(set.Root), "recordings") - if err := set.ConfigureSetupStorage(ctx, set.Root, recordingsRoot, 1, 2, 3, 4, time.Now()); err != nil { - t.Fatal(err) - } - dataRoot, storedRecordingsRoot, dataDevice, dataInode, recordingsDevice, recordingsInode, configured, err := set.ReadSetupStorage(ctx) - if err != nil { - t.Fatal(err) - } - if !configured || dataRoot != set.Root || storedRecordingsRoot != recordingsRoot || dataDevice != 1 || dataInode != 2 || recordingsDevice != 3 || recordingsInode != 4 { - t.Fatalf("persisted setup storage = %q %q %d %d %d %d %t", dataRoot, storedRecordingsRoot, dataDevice, dataInode, recordingsDevice, recordingsInode, configured) - } -} - -func TestSetupStorageRejectsDifferentDataRootOrReadyState(t *testing.T) { - ctx := context.Background() - set, err := OpenSet(ctx, filepath.Join(t.TempDir(), "db")) - if err != nil { - t.Fatal(err) - } - defer set.Close() - if err := set.ConfigureSetupStorage(ctx, "/different", "/recordings", 1, 2, 3, 4, time.Now()); err == nil { - t.Fatal("different data root was accepted") - } - if _, err := set.Core.ExecContext(ctx, `UPDATE installation_state SET state = 'ready' WHERE singleton = 1`); err != nil { - t.Fatal(err) - } - if err := set.ConfigureSetupStorage(ctx, set.Root, "/recordings", 1, 2, 3, 4, time.Now()); err == nil { - t.Fatal("ready instance storage update was accepted") - } -} diff --git a/internal/storage/sqlite/state.go b/internal/storage/sqlite/state.go index 9ffae5a..19edd2a 100644 --- a/internal/storage/sqlite/state.go +++ b/internal/storage/sqlite/state.go @@ -14,10 +14,10 @@ const ( ) func (set *Set) InstallationState(ctx context.Context) (string, error) { - if set == nil || set.Core == nil { + if set == nil || set.DB == nil { return "", fmt.Errorf("core database is not open") } - state, err := coredb.New(set.Core).GetInstallationState(ctx) + state, err := coredb.New(set.DB).GetInstallationState(ctx) if err != nil { return "", fmt.Errorf("read installation state: %w", err) } diff --git a/internal/storage/sqlite/store.go b/internal/storage/sqlite/store.go index 7c6e64e..e213a1d 100644 --- a/internal/storage/sqlite/store.go +++ b/internal/storage/sqlite/store.go @@ -18,23 +18,13 @@ import ( ) const ( - CoreDataset = "core" - ContactsDataset = "contacts" - MessagesDataset = "messages" - CallsDataset = "calls" - RuntimeDataset = "runtime" + ControlDataset = "control" storageMarkerName = ".simplus-storage-root" - storageMarkerContent = "simplus-storage-root-v1\n" + storageMarkerContent = "simplus-control-state-v2\n" ) -var datasetNames = []string{ - CoreDataset, - ContactsDataset, - MessagesDataset, - CallsDataset, - RuntimeDataset, -} +var datasetNames = []string{ControlDataset} //go:embed migrations/*/*.sql var migrationFiles embed.FS @@ -42,12 +32,8 @@ var migrationFiles embed.FS var migrationMu sync.Mutex type Set struct { - Root string - Core *sql.DB - Contacts *sql.DB - Messages *sql.DB - Calls *sql.DB - Runtime *sql.DB + Root string + DB *sql.DB } type fileIdentity struct { @@ -75,43 +61,19 @@ func OpenSet(ctx context.Context, root string) (*Set, error) { return nil, err } - opened := make(map[string]*sql.DB, len(datasetNames)) - for _, name := range datasetNames { - db, err := openDataset(ctx, canonicalRoot, name) - if err != nil { - for _, existing := range opened { - _ = existing.Close() - } - return nil, err - } - opened[name] = db + db, err := openDataset(ctx, canonicalRoot, ControlDataset) + if err != nil { + return nil, err } - - return &Set{ - Root: canonicalRoot, - Core: opened[CoreDataset], - Contacts: opened[ContactsDataset], - Messages: opened[MessagesDataset], - Calls: opened[CallsDataset], - Runtime: opened[RuntimeDataset], - }, nil + return &Set{Root: canonicalRoot, DB: db}, nil } func (set *Set) Close() error { - if set == nil { + if set == nil || set.DB == nil { return nil } - var joined error - for _, db := range []*sql.DB{set.Runtime, set.Calls, set.Messages, set.Contacts, set.Core} { - if db == nil { - continue - } - if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { - joined = errors.Join(joined, err) - } - joined = errors.Join(joined, db.Close()) - } - return joined + _, err := set.DB.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) + return errors.Join(err, set.DB.Close()) } func prepareRoot(root string) (string, error) { diff --git a/internal/storage/sqlite/store_test.go b/internal/storage/sqlite/store_test.go index 4471a77..b1b7614 100644 --- a/internal/storage/sqlite/store_test.go +++ b/internal/storage/sqlite/store_test.go @@ -10,446 +10,9 @@ import ( "runtime" "strings" "testing" - "time" - - "github.com/leonfox28/simplus/internal/domain/call" - "github.com/leonfox28/simplus/internal/domain/pagination" - "github.com/leonfox28/simplus/internal/domain/sms" - "github.com/pressly/goose/v3" ) -func TestKeysetIndexMigrationsDownAndReopenPreserveHistory(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 8, 7, 16, 0, 0, 0, time.UTC) - if _, _, err := set.CreateOutboundSMS(ctx, sms.Message{ - ID: "msg_migration_000000000", OperationID: "operation-migration-msg-01", Direction: sms.DirectionOutbound, - LineID: "line_AQEBAQEBAQEBAQEBAQEBAQ", RemoteAddress: "13800138000", Body: "preserved", - Status: sms.StatusQueued, CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if _, _, err := set.CreateCall(ctx, call.Record{ - ID: "call_migration_0000000", OperationID: "operation-migration-call1", LineID: "line_AQEBAQEBAQEBAQEBAQEBAQ", - RemoteAddress: "13800138000", Direction: call.DirectionOutbound, State: call.StateDialing, - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - for _, dataset := range []struct { - name, migrationPath string - indexes []string - version int64 - }{ - {MessagesDataset, "migrations/messages", []string{"sms_messages_page_idx", "sms_messages_conversation_page_idx"}, 5}, - {CallsDataset, "migrations/calls", []string{"call_records_page_idx"}, 2}, - } { - database, err := sql.Open("sqlite", filepath.Join(root, dataset.name+".sqlite3")) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, dataset.migrationPath, dataset.version) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - var schemaVersion int64 - if err := database.QueryRowContext(ctx, `SELECT schema_version FROM dataset_metadata WHERE singleton = 1`).Scan(&schemaVersion); err != nil { - t.Fatal(err) - } - if schemaVersion != dataset.version { - t.Fatalf("%s down migration version=%d, want %d", dataset.name, schemaVersion, dataset.version) - } - for _, index := range dataset.indexes { - var indexCount int - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, index).Scan(&indexCount); err != nil { - t.Fatal(err) - } - if indexCount != 0 { - t.Fatalf("%s down migration retained index %s", dataset.name, index) - } - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - if messages, err := set.ListSMS(ctx, 10); err != nil || len(messages) != 1 || messages[0].Body != "preserved" { - t.Fatalf("messages after reopen=%#v error=%v", messages, err) - } - if calls, err := set.ListCalls(ctx, 10); err != nil || len(calls) != 1 || calls[0].ID != "call_migration_0000000" { - t.Fatalf("calls after reopen=%#v error=%v", calls, err) - } - for _, check := range []struct { - database *sql.DB - index string - }{ - {set.Messages, "sms_messages_remote_sequence_idx"}, - {set.Messages, "sms_messages_line_remote_sequence_idx"}, - {set.Calls, "call_records_page_idx"}, - } { - var count int - if err := check.database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, check.index).Scan(&count); err != nil || count != 1 { - t.Fatalf("reopened index %s count=%d error=%v", check.index, count, err) - } - } -} - -func TestMessagesRecordSequenceMigrationPreservesUnreadAndRepairsHistoricalOrder(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - - database, err := sql.Open("sqlite", filepath.Join(root, MessagesDataset+".sqlite3")) - if err != nil { - t.Fatal(err) - } - database.SetMaxOpenConns(1) - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/messages", 7) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - base := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC).UnixMilli() - if _, err := database.ExecContext(ctx, ` -INSERT INTO sms_messages ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) VALUES - ('msg_v8_outbound_00000001', 'operation-v8-outbound-0001', 'outbound', 'line_AQEBAQEBAQEBAQEBAQEBAQ', '+12025550123', 'outbound', 'queued', '', '', ?, ?, NULL), - ('msg_v8_inbound_000000001', 'operation-v8-inbound-00001', 'inbound', 'line_AgICAgICAgICAgICAgICAg', '+12025550123', 'inbound', 'received', 'provider-v8-inbound-1', '', ?, ?, NULL) -`, base+100, base+100, base, base+500); err != nil { - t.Fatal(err) - } - if _, err := database.ExecContext(ctx, ` -INSERT INTO sms_message_unread (unread_id, message_id, remote_address) -VALUES (41, 'msg_v8_inbound_000000001', '+12025550123') -`); err != nil { - t.Fatal(err) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - var schemaVersion int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT schema_version FROM dataset_metadata WHERE singleton = 1`).Scan(&schemaVersion); err != nil || schemaVersion != 8 { - t.Fatalf("upgraded schema version=%d error=%v", schemaVersion, err) - } - for index, want := range map[string]int{ - "sms_messages_remote_sequence_idx": 1, - "sms_messages_line_remote_sequence_idx": 1, - "sms_messages_page_idx": 0, - "sms_messages_conversation_page_idx": 0, - "sms_messages_remote_page_idx": 0, - } { - var count int - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, index).Scan(&count); err != nil || count != want { - t.Fatalf("upgraded index %s count=%d want=%d error=%v", index, count, want, err) - } - } - page, err := set.ListSMSPage(ctx, pagination.Request{Limit: 10}, "", "") - if err != nil || len(page.Items) != 2 || page.Items[0].ID != "msg_v8_inbound_000000001" || page.Items[1].ID != "msg_v8_outbound_00000001" { - t.Fatalf("upgraded page=%#v error=%v", page, err) - } - var outboundSequence, inboundSequence, unreadID int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT record_sequence FROM sms_messages WHERE message_id = 'msg_v8_outbound_00000001'`).Scan(&outboundSequence); err != nil { - t.Fatal(err) - } - if err := set.Messages.QueryRowContext(ctx, `SELECT record_sequence FROM sms_messages WHERE message_id = 'msg_v8_inbound_000000001'`).Scan(&inboundSequence); err != nil { - t.Fatal(err) - } - if outboundSequence != 1 || inboundSequence != 2 { - t.Fatalf("backfilled sequences outbound=%d inbound=%d", outboundSequence, inboundSequence) - } - if err := set.Messages.QueryRowContext(ctx, `SELECT unread_id FROM sms_message_unread WHERE message_id = 'msg_v8_inbound_000000001'`).Scan(&unreadID); err != nil || unreadID != 41 { - t.Fatalf("preserved unread id=%d error=%v", unreadID, err) - } - if _, _, err := set.CreateInboundSMS(ctx, sms.Message{ - ID: "msg_v8_inbound_000000002", OperationID: "operation-v8-inbound-00002", Direction: sms.DirectionInbound, - LineID: "line_AgICAgICAgICAgICAgICAg", RemoteAddress: "+12025550123", Body: "new inbound", Status: sms.StatusReceived, - ProviderMessageID: "provider-v8-inbound-2", CreatedAt: time.UnixMilli(base - 100).UTC(), UpdatedAt: time.UnixMilli(base + 600).UTC(), - }); err != nil { - t.Fatal(err) - } - var newSequence, newUnreadID int64 - if err := set.Messages.QueryRowContext(ctx, ` -SELECT message.record_sequence, unread.unread_id -FROM sms_messages message JOIN sms_message_unread unread USING (message_id) -WHERE message.message_id = 'msg_v8_inbound_000000002' -`).Scan(&newSequence, &newUnreadID); err != nil || newSequence <= inboundSequence || newUnreadID <= unreadID { - t.Fatalf("new sequence=%d unread=%d error=%v", newSequence, newUnreadID, err) - } - var foreignKeyViolations int - rows, err := set.Messages.QueryContext(ctx, `PRAGMA foreign_key_check`) - if err != nil { - t.Fatal(err) - } - for rows.Next() { - foreignKeyViolations++ - } - if err := rows.Close(); err != nil { - t.Fatal(err) - } - if foreignKeyViolations != 0 { - t.Fatalf("foreign key violations=%d", foreignKeyViolations) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - - database, err = sql.Open("sqlite", filepath.Join(root, MessagesDataset+".sqlite3")) - if err != nil { - t.Fatal(err) - } - database.SetMaxOpenConns(1) - migrationMu.Lock() - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/messages", 7) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - if err := database.QueryRowContext(ctx, `SELECT schema_version FROM dataset_metadata WHERE singleton = 1`).Scan(&schemaVersion); err != nil || schemaVersion != 7 { - t.Fatalf("down schema version=%d error=%v", schemaVersion, err) - } - for index, want := range map[string]int{ - "sms_messages_created_at_idx": 1, - "sms_messages_line_created_at_idx": 1, - "sms_messages_page_idx": 1, - "sms_messages_conversation_page_idx": 1, - "sms_messages_remote_page_idx": 1, - "sms_messages_remote_sequence_idx": 0, - "sms_messages_line_remote_sequence_idx": 0, - "sms_message_unread_remote_idx": 1, - } { - var count int - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, index).Scan(&count); err != nil || count != want { - t.Fatalf("down index %s count=%d want=%d error=%v", index, count, want, err) - } - } - var messages, unread int - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages`).Scan(&messages); err != nil { - t.Fatal(err) - } - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_message_unread`).Scan(&unread); err != nil { - t.Fatal(err) - } - if messages != 3 || unread != 2 { - t.Fatalf("down migration messages=%d unread=%d", messages, unread) - } - var preservedUnreadID int64 - if err := database.QueryRowContext(ctx, `SELECT MAX(unread_id) FROM sms_message_unread`).Scan(&preservedUnreadID); err != nil || preservedUnreadID != newUnreadID { - t.Fatalf("down unread watermark=%d want=%d error=%v", preservedUnreadID, newUnreadID, err) - } - if _, err := database.ExecContext(ctx, `SELECT record_sequence FROM sms_messages LIMIT 1`); err == nil { - t.Fatal("v7 table retained record_sequence") - } - var foreignKeysEnabled int - if err := database.QueryRowContext(ctx, `PRAGMA foreign_keys`).Scan(&foreignKeysEnabled); err != nil || foreignKeysEnabled != 1 { - t.Fatalf("down foreign_keys=%d error=%v", foreignKeysEnabled, err) - } - rows, err = database.QueryContext(ctx, `PRAGMA foreign_key_check`) - if err != nil { - t.Fatal(err) - } - foreignKeyViolations = 0 - for rows.Next() { - foreignKeyViolations++ - } - if err := rows.Close(); err != nil { - t.Fatal(err) - } - if foreignKeyViolations != 0 { - t.Fatalf("down foreign key violations=%d", foreignKeyViolations) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages`).Scan(&messages); err != nil || messages != 3 { - t.Fatalf("re-upgraded messages=%d error=%v", messages, err) - } - if err := set.Messages.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_message_unread`).Scan(&unread); err != nil || unread != 2 { - t.Fatalf("re-upgraded unread=%d error=%v", unread, err) - } - if err := set.Messages.QueryRowContext(ctx, `SELECT MAX(unread_id) FROM sms_message_unread`).Scan(&preservedUnreadID); err != nil || preservedUnreadID != newUnreadID { - t.Fatalf("re-upgraded unread watermark=%d want=%d error=%v", preservedUnreadID, newUnreadID, err) - } - var maximumSequence int64 - if err := set.Messages.QueryRowContext(ctx, `SELECT MAX(record_sequence) FROM sms_messages`).Scan(&maximumSequence); err != nil { - t.Fatal(err) - } - if _, _, err := set.CreateInboundSMS(ctx, sms.Message{ - ID: "msg_v8_inbound_000000003", OperationID: "operation-v8-inbound-00003", Direction: sms.DirectionInbound, - LineID: "line_AgICAgICAgICAgICAgICAg", RemoteAddress: "+12025550123", Body: "after re-upgrade", Status: sms.StatusReceived, - ProviderMessageID: "provider-v8-inbound-3", CreatedAt: time.UnixMilli(base - 200).UTC(), UpdatedAt: time.UnixMilli(base + 700).UTC(), - }); err != nil { - t.Fatal(err) - } - if err := set.Messages.QueryRowContext(ctx, ` -SELECT message.record_sequence, unread.unread_id -FROM sms_messages message JOIN sms_message_unread unread USING (message_id) -WHERE message.message_id = 'msg_v8_inbound_000000003' -`).Scan(&newSequence, &newUnreadID); err != nil || newSequence <= maximumSequence || newUnreadID <= preservedUnreadID { - t.Fatalf("post-re-upgrade sequence=%d unread=%d previous sequence=%d unread=%d error=%v", - newSequence, newUnreadID, maximumSequence, preservedUnreadID, err) - } -} - -func TestMessagesConversationMigrationInitializesOldHistoryReadAndPreservesMessages(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC) - old, _, err := set.CreateInboundSMS(ctx, sms.Message{ - ID: "msg_migration_v7_old0001", OperationID: "operation-migration-v7-old", Direction: sms.DirectionInbound, - LineID: "line_AQEBAQEBAQEBAQEBAQEBAQ", RemoteAddress: "+447700900123", Body: "old history", - Status: sms.StatusReceived, ProviderMessageID: "provider-migration-v7-old", CreatedAt: now, UpdatedAt: now, - }) - if err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - database, err := sql.Open("sqlite", filepath.Join(root, MessagesDataset+".sqlite3")) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/messages", 6) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - for _, object := range []string{"sms_message_unread", "sms_messages_remote_page_idx", "sms_message_unread_remote_idx"} { - var count int - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE name = ?`, object).Scan(&count); err != nil || count != 0 { - t.Fatalf("down migration object %s count=%d error=%v", object, count, err) - } - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - conversations, err := set.ListSMSConversationPage(ctx, pagination.Request{Limit: 10}) - if err != nil || len(conversations.Items) != 1 || conversations.Items[0].UnreadCount != 0 || conversations.Items[0].LastMessage.ID != old.ID { - t.Fatalf("upgraded old conversations=%#v error=%v", conversations, err) - } - if _, _, err := set.CreateInboundSMS(ctx, sms.Message{ - ID: "msg_migration_v7_new0001", OperationID: "operation-migration-v7-new", Direction: sms.DirectionInbound, - LineID: old.LineID, RemoteAddress: old.RemoteAddress, Body: "new history", Status: sms.StatusReceived, - ProviderMessageID: "provider-migration-v7-new", CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), - }); err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - conversations, err = set.ListSMSConversationPage(ctx, pagination.Request{Limit: 10}) - if err != nil || len(conversations.Items) != 1 || conversations.Items[0].UnreadCount != 1 { - t.Fatalf("reopened conversations=%#v error=%v", conversations, err) - } - messages, err := set.ListSMS(ctx, 10) - if err != nil || len(messages) != 2 { - t.Fatalf("preserved messages=%#v error=%v", messages, err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - database, err = sql.Open("sqlite", filepath.Join(root, MessagesDataset+".sqlite3")) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/messages", 6) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - var preserved int - if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM sms_messages`).Scan(&preserved); err != nil || preserved != 2 { - t.Fatalf("down-migrated message count=%d error=%v", preserved, err) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - conversations, err = set.ListSMSConversationPage(ctx, pagination.Request{Limit: 10}) - if err != nil || len(conversations.Items) != 1 || conversations.Items[0].UnreadCount != 0 { - t.Fatalf("re-upgraded conversations=%#v error=%v", conversations, err) - } - messages, err = set.ListSMS(ctx, 10) - if err != nil || len(messages) != 2 { - t.Fatalf("re-upgraded messages=%#v error=%v", messages, err) - } -} - -func TestOpenSetCreatesFiveSecuredWALDatabases(t *testing.T) { +func TestOpenSetCreatesOneSecuredWALDatabase(t *testing.T) { root := filepath.Join(t.TempDir(), "db") set, err := OpenSet(context.Background(), root) if err != nil { @@ -489,14 +52,14 @@ func TestOpenSetCreatesFiveSecuredWALDatabases(t *testing.T) { } var journalMode string - if err := set.Core.QueryRow(`PRAGMA journal_mode`).Scan(&journalMode); err != nil { + if err := set.DB.QueryRow(`PRAGMA journal_mode`).Scan(&journalMode); err != nil { t.Fatal(err) } if journalMode != "wal" { t.Fatalf("journal_mode = %q", journalMode) } var trustedSchema int - if err := set.Core.QueryRow(`PRAGMA trusted_schema`).Scan(&trustedSchema); err != nil { + if err := set.DB.QueryRow(`PRAGMA trusted_schema`).Scan(&trustedSchema); err != nil { t.Fatal(err) } if trustedSchema != 0 { @@ -510,7 +73,7 @@ func TestOpenSetRejectsMissingRequiredSchemaObject(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := set.Core.Exec(`DROP TABLE installation_state`); err != nil { + if _, err := set.DB.Exec(`DROP TABLE installation_state`); err != nil { t.Fatal(err) } if err := set.Close(); err != nil { @@ -530,7 +93,7 @@ func TestOpenSetRejectsAlteredMigrationSchema(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := set.Core.Exec(`ALTER TABLE goose_db_version ADD COLUMN unexpected TEXT`); err != nil { + if _, err := set.DB.Exec(`ALTER TABLE goose_db_version ADD COLUMN unexpected TEXT`); err != nil { t.Fatal(err) } if err := set.Close(); err != nil { @@ -544,231 +107,6 @@ func TestOpenSetRejectsAlteredMigrationSchema(t *testing.T) { } } -func TestMessagesV3DatabaseUpgradesToUnconfirmedOutcome(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - - database, err := sql.Open("sqlite", filepath.Join(root, MessagesDataset+".sqlite3")) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - goose.SetBaseFS(migrationFiles) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/messages", 3) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) - if _, err := database.ExecContext(ctx, ` -INSERT INTO sms_messages ( - message_id, operation_id, direction, line_id, remote_address, body, status, - provider_message_id, error_code, created_at_unix_ms, updated_at_unix_ms, sent_at_unix_ms -) VALUES (?, ?, 'outbound', ?, ?, ?, 'queued', '', '', ?, ?, NULL) -`, "msg_upgrade0123456789012345", "operation-upgrade01234567", "simulator-line-1", "13800138000", "upgrade", now.UnixMilli(), now.UnixMilli()); err != nil { - _ = database.Close() - t.Fatal(err) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - count, err := set.MarkQueuedOutboundSMSUnconfirmed(ctx, "SEND_OUTCOME_UNKNOWN_AFTER_RESTART", now.Add(time.Minute)) - if err != nil || count != 1 { - t.Fatalf("mark upgraded queue unconfirmed: count=%d error=%v", count, err) - } - messages, err := set.ListSMS(ctx, 10) - if err != nil { - t.Fatal(err) - } - if len(messages) != 1 || messages[0].Status != sms.StatusUnconfirmed { - t.Fatalf("upgraded messages = %#v", messages) - } -} - -func TestCoreV21UpgradeSeparatesLineIdentityFromAccessPath(t *testing.T) { - ctx := context.Background() - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - - path := filepath.Join(root, CoreDataset+".sqlite3") - database, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - migrationMu.Lock() - goose.SetLogger(goose.NopLogger()) - err = goose.SetDialect("sqlite3") - if err == nil { - err = goose.DownToContext(ctx, database, "migrations/core", 21) - } - migrationMu.Unlock() - if err != nil { - _ = database.Close() - t.Fatal(err) - } - now := time.Date(2026, 8, 6, 8, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) - const modemID = "modem_AQEBAQEBAQEBAQEBAQEBAQ" - if _, err := database.ExecContext(ctx, ` -INSERT INTO managed_modems ( - id, legacy_hardware_device_id, equipment_identity_fingerprint, usb_serial_fingerprint, - display_name, model, transport, capability_mask, created_at_utc, updated_at_utc -) VALUES (?, '', ?, '', 'ML307A', 'ML307A', 'usb', 0, ?, ?)`, modemID, strings.Repeat("a", 64), now, now); err != nil { - _ = database.Close() - t.Fatal(err) - } - lines := []struct { - id, fingerprint, hint, mode string - }{ - {"line_AQEBAQEBAQEBAQEBAQEBAQ", strings.Repeat("b", 64), "ICCID •••• 0001", "host-vowifi-only"}, - {"line_AgICAgICAgICAgICAgICAg", strings.Repeat("c", 64), "ICCID •••• 0002", "host-vowifi-only"}, - {"line_AwMDAwMDAwMDAwMDAwMDAw", strings.Repeat("d", 64), "ICCID •••• 0003", "host-vowifi-only"}, - } - for _, line := range lines { - if _, err := database.ExecContext(ctx, ` -INSERT INTO managed_lines ( - id, managed_modem_id, sim_slot_index, subscription_identity_fingerprint, - subscription_display_hint, display_name, access_mode, created_at_utc, updated_at_utc -) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)`, line.id, modemID, line.fingerprint, line.hint, line.hint, line.mode, now, now); err != nil { - _ = database.Close() - t.Fatal(err) - } - } - if _, err := database.ExecContext(ctx, ` -INSERT INTO simulator_access_paths (line_id, mode, mihomo_state) -VALUES (?, 'mihomo-required', 'failed')`, lines[0].id); err != nil { - _ = database.Close() - t.Fatal(err) - } - const subscriptionID = "subscription_AQEBAQEBAQEBAQEBAQEBAQ" - if _, err := database.ExecContext(ctx, ` -INSERT INTO mihomo_subscriptions ( - id, display_name, url_ciphertext, url_plaintext, url_hint, enabled, - last_refresh_at_utc, last_refresh_status, node_count, last_error_code, - created_at_utc, updated_at_utc -) VALUES (?, 'Legacy', ?, 'https://example.invalid/subscription', 'example.invalid', 1, - ?, 'success', 1, '', ?, ?)`, subscriptionID, make([]byte, 32), now, now, now, now); err != nil { - _ = database.Close() - t.Fatal(err) - } - if _, err := database.ExecContext(ctx, ` -INSERT INTO mihomo_egress_profiles ( - id, display_name, subscription_id, selected_node_id, enabled, - created_at_utc, updated_at_utc, selection_type, selected_country_code, - source_cidr, line_id -) VALUES ('egress_AQEBAQEBAQEBAQEBAQEBAQ', 'Legacy JP', ?, '', 1, - ?, ?, 'country', 'JP', '', ?)`, subscriptionID, now, now, lines[2].id); err != nil { - _ = database.Close() - t.Fatal(err) - } - if _, err := database.ExecContext(ctx, ` -INSERT INTO line_egress_bindings (line_id, mode, country_code, updated_at_utc) -VALUES (?, 'mihomo-country', 'GB', ?)`, lines[1].id, now); err != nil { - _ = database.Close() - t.Fatal(err) - } - if _, err := database.ExecContext(ctx, ` -INSERT INTO vowifi_line_desires (line_id, desired_active, updated_at_utc) -VALUES (?, 1, ?)`, lines[1].id, now); err != nil { - _ = database.Close() - t.Fatal(err) - } - if err := database.Close(); err != nil { - t.Fatal(err) - } - - set, err = OpenSet(ctx, root) - if err != nil { - t.Fatal(err) - } - defer set.Close() - var accessModeColumns int - if err := set.Core.QueryRowContext(ctx, `SELECT count(*) FROM pragma_table_info('managed_lines') WHERE name = 'access_mode'`).Scan(&accessModeColumns); err != nil { - t.Fatal(err) - } - if accessModeColumns != 0 { - t.Fatal("managed_lines.access_mode survived the v22 migration") - } - var legacyTables int - if err := set.Core.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'subscription_profile_access_modes'`).Scan(&legacyTables); err != nil { - t.Fatal(err) - } - if legacyTables != 0 { - t.Fatal("legacy access-mode table survived the v22 migration") - } - var legacyAccessPathTables int - if err := set.Core.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'simulator_access_paths'`).Scan(&legacyAccessPathTables); err != nil { - t.Fatal(err) - } - if legacyAccessPathTables != 0 { - t.Fatal("legacy Simulator access-path table survived the v22 migration") - } - var legacyMihomoEgressTables int - if err := set.Core.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'mihomo_egress_profiles'`).Scan(&legacyMihomoEgressTables); err != nil { - t.Fatal(err) - } - if legacyMihomoEgressTables != 0 { - t.Fatal("legacy Mihomo egress-profile table survived the v22 migration") - } - managedLines, err := set.ListManagedLines(ctx) - if err != nil || len(managedLines) != len(lines) { - t.Fatalf("managed lines=%#v error=%v", managedLines, err) - } - for index, record := range managedLines { - if record.ID != lines[index].id || record.SubscriptionIdentityFingerprint != lines[index].fingerprint || - record.DisplayName != lines[index].hint || record.SubscriptionDisplayHint != lines[index].hint { - t.Fatalf("managed line %d=%#v", index, record) - } - } - bindings, err := set.ListLineEgressBindings(ctx) - if err != nil || len(bindings) != 3 { - t.Fatalf("bindings=%#v error=%v", bindings, err) - } - byLine := make(map[string]string, len(bindings)) - for _, binding := range bindings { - byLine[binding.LineID] = binding.Mode + ":" + binding.CountryCode - } - if byLine[lines[0].id] != "direct:" || byLine[lines[1].id] != "mihomo-country:GB" || - byLine[lines[2].id] != "mihomo-country:JP" { - t.Fatalf("migrated bindings=%#v", byLine) - } - desires, err := set.ListVoWiFiDesires(ctx) - if err != nil || len(desires) != 1 || !desires[0].DesiredActive || desires[0].LineID != lines[1].id { - t.Fatalf("migrated desires=%#v error=%v", desires, err) - } - rows, err := set.Core.QueryContext(ctx, `PRAGMA foreign_key_check`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - if rows.Next() { - t.Fatal("v22 migration left a foreign-key violation") - } -} - func TestOpenSetRejectsCorruptDatabaseAtIntegrityPreflight(t *testing.T) { root := filepath.Join(t.TempDir(), "db") set, err := OpenSet(context.Background(), root) @@ -779,7 +117,7 @@ func TestOpenSetRejectsCorruptDatabaseAtIntegrityPreflight(t *testing.T) { t.Fatal(err) } - path := filepath.Join(root, "core.sqlite3") + path := filepath.Join(root, "control.sqlite3") file, err := os.OpenFile(path, os.O_WRONLY, 0) if err != nil { t.Fatal(err) @@ -839,45 +177,6 @@ func TestOpenSetRejectsRelativeRoot(t *testing.T) { } } -func TestOpenSetRejectsSwappedDatasetFiles(t *testing.T) { - root := filepath.Join(t.TempDir(), "db") - set, err := OpenSet(context.Background(), root) - if err != nil { - t.Fatal(err) - } - if err := set.Close(); err != nil { - t.Fatal(err) - } - - corePath := filepath.Join(root, "core.sqlite3") - contactsPath := filepath.Join(root, "contacts.sqlite3") - temporaryPath := filepath.Join(root, "swap.sqlite3") - if err := os.Rename(corePath, temporaryPath); err != nil { - t.Fatal(err) - } - if err := os.Rename(contactsPath, corePath); err != nil { - t.Fatal(err) - } - if err := os.Rename(temporaryPath, contactsPath); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(corePath) - if err != nil { - t.Fatal(err) - } - - if _, err := OpenSet(context.Background(), root); err == nil { - t.Fatal("OpenSet() accepted swapped dataset files") - } - after, err := os.ReadFile(corePath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(before, after) { - t.Fatal("OpenSet() mutated a swapped dataset before rejecting it") - } -} - func TestOpenSetRejectsUnidentifiedDatabaseWithoutMutation(t *testing.T) { root := filepath.Join(t.TempDir(), "db") if err := os.Mkdir(root, 0o700); err != nil { @@ -886,7 +185,7 @@ func TestOpenSetRejectsUnidentifiedDatabaseWithoutMutation(t *testing.T) { if _, err := prepareRoot(root); err != nil { t.Fatal(err) } - path := filepath.Join(root, "core.sqlite3") + path := filepath.Join(root, "control.sqlite3") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) @@ -928,7 +227,7 @@ func TestOpenSetRejectsSymlinkDataset(t *testing.T) { if _, err := prepareRoot(root); err != nil { t.Fatal(err) } - if err := os.Symlink(filepath.Join(t.TempDir(), "target.sqlite3"), filepath.Join(root, "core.sqlite3")); err != nil { + if err := os.Symlink(filepath.Join(t.TempDir(), "target.sqlite3"), filepath.Join(root, "control.sqlite3")); err != nil { t.Fatal(err) } if _, err := OpenSet(context.Background(), root); err == nil { @@ -944,7 +243,7 @@ func TestOpenSetRejectsViewOnlyDatabaseWithoutMutation(t *testing.T) { if _, err := prepareRoot(root); err != nil { t.Fatal(err) } - path := filepath.Join(root, "core.sqlite3") + path := filepath.Join(root, "control.sqlite3") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) @@ -983,7 +282,7 @@ func TestOpenSetRejectsHardLinkedDataset(t *testing.T) { if err := set.Close(); err != nil { t.Fatal(err) } - corePath := filepath.Join(root, "core.sqlite3") + corePath := filepath.Join(root, "control.sqlite3") aliasPath := filepath.Join(t.TempDir(), "core-alias.sqlite3") if err := os.Link(corePath, aliasPath); err != nil { t.Fatal(err) @@ -1056,3 +355,30 @@ func TestOpenSetRejectsSymlinkRoot(t *testing.T) { t.Fatal("OpenSet() accepted a symlink root") } } + +func TestLegacyLayoutIsRejectedWithoutChangingAnyExistingFile(t *testing.T) { + root := filepath.Join(t.TempDir(), "old") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatal(err) + } + originals := map[string][]byte{storageMarkerName: []byte("simplus-storage-root-v1\n"), "core.sqlite3": []byte("synthetic legacy database"), "messages.sqlite3": []byte("synthetic legacy messages")} + for name, data := range originals { + if err := os.WriteFile(filepath.Join(root, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + if s, err := OpenSet(t.Context(), root); err == nil { + _ = s.Close() + t.Fatal("old layout accepted") + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != len(originals) { + t.Fatalf("legacy directory was modified: %v %v", entries, err) + } + for name, want := range originals { + got, err := os.ReadFile(filepath.Join(root, name)) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("legacy %s modified", name) + } + } +} diff --git a/internal/storage/sqlite/vowifi.go b/internal/storage/sqlite/vowifi.go index 6b3adf7..0a675fd 100644 --- a/internal/storage/sqlite/vowifi.go +++ b/internal/storage/sqlite/vowifi.go @@ -9,7 +9,7 @@ import ( ) func (set *Set) ListVoWiFiDesires(ctx context.Context) ([]domain.Desire, error) { - rows, err := set.Core.QueryContext(ctx, `SELECT line_id, desired_active, updated_at_utc FROM vowifi_line_desires ORDER BY line_id`) + rows, err := set.DB.QueryContext(ctx, `SELECT line_id, desired_active, updated_at_utc FROM vowifi_line_desires ORDER BY line_id`) if err != nil { return nil, fmt.Errorf("list Host VoWiFi desires: %w", err) } @@ -40,7 +40,7 @@ func (set *Set) PutVoWiFiDesire(ctx context.Context, value domain.Desire) error if value.DesiredActive { desired = 1 } - _, err := set.Core.ExecContext(ctx, ` + _, err := set.DB.ExecContext(ctx, ` INSERT INTO vowifi_line_desires (line_id, desired_active, updated_at_utc) VALUES (?, ?, ?) ON CONFLICT(line_id) DO UPDATE SET diff --git a/internal/subscriptionhttp/client.go b/internal/subscriptionhttp/client.go new file mode 100644 index 0000000..94d863a --- /dev/null +++ b/internal/subscriptionhttp/client.go @@ -0,0 +1,75 @@ +package subscriptionhttp + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "time" + + domain "github.com/leonfox28/simplus/internal/domain/mihomo" +) + +func newSubscriptionHTTPClient() *http.Client { + transport := &http.Transport{Proxy: nil, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 20 * time.Second} + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addresses) == 0 { + return nil, fmt.Errorf("resolve subscription host: %w", err) + } + for _, address := range addresses { + if isUnsafeSubscriptionIP(address.IP) { + return nil, errors.New("subscription host resolved to a private or local address") + } + } + var dialer net.Dialer + return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + } + client := &http.Client{Transport: transport, Timeout: 30 * time.Second} + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) > 3 { + return errors.New("too many subscription redirects") + } + _, _, err := domain.ValidateSubscriptionInput("redirect", request.URL.String()) + return err + } + return client +} + +func isUnsafeSubscriptionIP(ip net.IP) bool { + return ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsMulticast() +} + +type Client struct{ HTTP *http.Client } + +func New() *Client { return &Client{HTTP: newSubscriptionHTTPClient()} } +func (c *Client) Fetch(ctx context.Context, target string) ([]byte, error) { + if _, _, err := domain.ValidateSubscriptionInput("subscription", target); err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/yaml,text/yaml,text/plain,application/octet-stream") + request.Header.Set("User-Agent", "clash.meta") + response, err := c.HTTP.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, errors.New("subscription provider rejected request") + } + body, err := io.ReadAll(io.LimitReader(response.Body, 5<<20+1)) + if err != nil || len(body) > 5<<20 { + return nil, errors.New("subscription response unavailable") + } + return body, nil +} diff --git a/internal/testsupport/simulatorserver/main.go b/internal/testsupport/simulatorserver/main.go new file mode 100644 index 0000000..b68c209 --- /dev/null +++ b/internal/testsupport/simulatorserver/main.go @@ -0,0 +1,88 @@ +//go:build integration + +// simulatorserver runs the real control process with disposable Simulator state. +// The rejecting proxy prevents fixture notification credentials reaching a provider. +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/leonfox28/simplus/internal/application/setup" + "github.com/leonfox28/simplus/internal/security/password" + "github.com/leonfox28/simplus/internal/storage/sqlite" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + if len(os.Args) != 3 { + return fmt.Errorf("usage: simulatorserver ") + } + root, err := os.MkdirTemp("", "simplus-browser-") + if err != nil { + return err + } + defer os.RemoveAll(root) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + db, err := sqlite.OpenSet(ctx, filepath.Join(root, "state")) + if err != nil { + return err + } + install, err := setup.New(setup.Dependencies{StateStore: db, AdministratorStore: db, PasswordHasher: password.NewDefaultHasher()}) + if err != nil { + _ = db.Close() + return err + } + _, err = install.ProvisionAdministrator(ctx, setup.AdministratorInput{Username: "browser_admin", Password: "integration-test-password", PasswordConfirmation: "integration-test-password", InstanceDefaultLocale: "zh-CN"}) + closeErr := db.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + proxy := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })} + defer proxy.Close() + go func() { _ = proxy.Serve(listener) }() + command := exec.Command(os.Args[1]) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM} + command.Stdout, command.Stderr = os.Stdout, os.Stderr + command.Env = append(os.Environ(), "SIMPLUS_DATA_ROOT="+root, "SIMPLUS_BACKEND=simulator", "SIMPLUS_LISTEN_ADDR=127.0.0.1:4183", "SIMPLUS_WEB_ROOT="+os.Args[2], "SIMPLUS_CONTROL_SOCKET="+filepath.Join(root, "control.sock"), "SIMPLUS_MIHOMO_SUPERVISOR_SOCKET=", "HTTPS_PROXY=http://"+listener.Addr().String(), "HTTP_PROXY=http://"+listener.Addr().String(), "NO_PROXY=127.0.0.1,localhost") + if err := command.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + select { + case err := <-done: + return err + case <-ctx.Done(): + } + _ = command.Process.Signal(syscall.SIGTERM) + select { + case err := <-done: + return err + case <-time.After(35 * time.Second): + _ = command.Process.Kill() + <-done + return fmt.Errorf("simulator shutdown timed out") + } +} diff --git a/internal/vowifihil/config.go b/internal/vowifihil/config.go index 467eacc..9204bb8 100644 --- a/internal/vowifihil/config.go +++ b/internal/vowifihil/config.go @@ -1,206 +1,20 @@ package vowifihil import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "path/filepath" - "regexp" - "strings" - - "github.com/leonfox28/simplus/internal/agentapi" -) - -const ( - RunDirectory = "/run/simplus-vowifi-hil" - StrongSwanConfig = RunDirectory + "/strongswan.conf" - VICIConfig = RunDirectory + "/vici.json" - VICISocket = RunDirectory + "/charon.vici" - LogPipe = RunDirectory + "/charon.pipe" - SIMAKASocket = "/run/simplus-agent/sim-aka.sock" - ReadOnlyAgentSocket = "/run/simplus-agent/simplus-agent.sock" - - ConnectionName = "vowifi-ims" - IMSAPN = "ims" - EPDGFQDN = "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org" -) - -var ( - lowerHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) - deviceID = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) - imsi = regexp.MustCompile(`^23415[0-9]{10}$`) - eapIdentity = regexp.MustCompile(`^023415[0-9]{10}@nai\.epc\.mnc015\.mcc234\.3gppnetwork\.org$`) + "github.com/leonfox28/simplus/internal/ims" ) -// Input is the transient, root-only material needed to configure a single -// ML307A/VOXI EAP-AKA HIL attempt. The IMSI and derived identity must only be -// kept in memory or in the root-owned RunDirectory, which the runner removes. -type Input struct { - Target agentapi.SIMAKATarget - IMSI string -} - -type Config struct { - StrongSwan []byte - VICI []byte -} - -type RuntimePaths struct { - RunDirectory string - StrongSwanConfig string - VICISocket string - LogPipe string -} - -type ConnectionInput struct { - Version int `json:"version"` - Identity string `json:"identity"` -} - -func Build(input Input) (Config, error) { - return BuildAt(input, RuntimePaths{ +func Build(input ims.Input) (ims.Config, error) { + return ims.BuildAt(input, ims.RuntimePaths{ RunDirectory: RunDirectory, StrongSwanConfig: StrongSwanConfig, VICISocket: VICISocket, LogPipe: LogPipe, }) } -func PathsFor(runDirectory string) (RuntimePaths, error) { - if !filepath.IsAbs(runDirectory) { - return RuntimePaths{}, errors.New("runtime directory must be absolute") - } - runDirectory = filepath.Clean(runDirectory) - if runDirectory == "/" || !strings.HasPrefix(runDirectory, "/run/") { - return RuntimePaths{}, errors.New("runtime directory must be private tmpfs state") - } - return RuntimePaths{ - RunDirectory: runDirectory, StrongSwanConfig: filepath.Join(runDirectory, "strongswan.conf"), - VICISocket: filepath.Join(runDirectory, "charon.vici"), LogPipe: filepath.Join(runDirectory, "charon.pipe"), - }, nil -} - -func BuildAt(input Input, paths RuntimePaths) (Config, error) { - if err := validateInput(input); err != nil { - return Config{}, err - } - expected, err := PathsFor(paths.RunDirectory) - if err != nil || paths != expected { - return Config{}, errors.New("invalid strongSwan runtime paths") - } - identity := "0" + input.IMSI + "@nai.epc.mnc015.mcc234.3gppnetwork.org" - target := input.Target - - strongSwan := fmt.Sprintf(`charon { - load_modular = yes - install_routes = no - install_virtual_ip = yes - filelog { - hil-pipe { - path = %s - append = no - flush_line = yes - ike_name = no - default = 1 - ike = 2 - enc = 1 - net = 1 - knl = 1 - cfg = 1 - job = -1 - } - } - plugins { - random { load = yes } - nonce { load = yes } - openssl { load = yes } - sha1 { load = yes } - sha2 { load = yes } - aes { load = yes } - fips-prf { load = yes } - hmac { load = yes } - kdf { load = yes } - x509 { load = yes } - pubkey { load = yes } - pem { load = yes } - pkcs1 { load = yes } - pkcs8 { load = yes } - revocation { load = yes } - constraints { load = yes } - kernel-netlink { load = yes } - socket-default { load = yes } - vici { - load = yes - socket = unix://%s - } - eap-identity { load = yes } - eap-aka { load = yes } - p-cscf { - load = yes - enable { - %s = yes - } - } - simplus-simaka { - load = yes - socket = %s - agent_instance_id = %s - snapshot_generation = %d - snapshot_revision = %s - device_id = %s - device_generation = %d - identity_fingerprint = %s - expected_identity = %s - } - } -} - -charon-systemd { - journal { - default = -1 - } -} -`, paths.LogPipe, paths.VICISocket, ConnectionName, SIMAKASocket, target.AgentInstanceID, - target.SnapshotGeneration, target.SnapshotRevision, target.DeviceID, - target.DeviceGeneration, target.IdentityFingerprint, identity) - - viciConfig, err := json.Marshal(ConnectionInput{Version: 1, Identity: identity}) - if err != nil { - return Config{}, errors.New("encode transient VICI input") - } - viciConfig = append(viciConfig, '\n') - return Config{StrongSwan: []byte(strongSwan), VICI: viciConfig}, nil -} - -func ParseConnectionInput(data []byte) (ConnectionInput, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var input ConnectionInput - if err := decoder.Decode(&input); err != nil { - return ConnectionInput{}, errors.New("invalid transient VICI input") - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return ConnectionInput{}, errors.New("invalid transient VICI input") - } - if input.Version != 1 || !eapIdentity.MatchString(input.Identity) { - return ConnectionInput{}, errors.New("invalid transient VICI input") - } - return input, nil -} - -func validateInput(input Input) error { - target := input.Target - if !agentapi.IsValidAgentInstanceID(target.AgentInstanceID) || - target.SnapshotGeneration == 0 || !lowerHex64.MatchString(target.SnapshotRevision) || - !deviceID.MatchString(target.DeviceID) || target.DeviceGeneration == 0 || - !lowerHex64.MatchString(target.IdentityFingerprint) { - return errors.New("invalid fenced SIM AKA target") - } - if !imsi.MatchString(input.IMSI) { - return errors.New("active SIM is not the expected Vodafone UK/VOXI identity") - } - if strings.ContainsAny(target.DeviceID, "{}=\n\r\t ") { - return errors.New("invalid fenced SIM AKA target") - } - return nil -} +const ( + RunDirectory = "/run/simplus-vowifi-hil" + StrongSwanConfig = RunDirectory + "/strongswan.conf" + VICIConfig = RunDirectory + "/vici.json" + VICISocket = RunDirectory + "/charon.vici" + LogPipe = RunDirectory + "/charon.pipe" +) diff --git a/internal/vowifihil/preflight.go b/internal/vowifihil/preflight.go index 1ac5702..f8bdcf9 100644 --- a/internal/vowifihil/preflight.go +++ b/internal/vowifihil/preflight.go @@ -3,147 +3,47 @@ package vowifihil import ( "context" "errors" - "regexp" + + "github.com/leonfox28/simplus/internal/ims" "github.com/leonfox28/simplus/internal/agentapi" ) -var hardwareLineIDPattern = regexp.MustCompile(`^agent-line-[0-9a-f]{32}$`) - -// Inspection is transient root-only HIL material. Callers must keep it in -// memory and must never log, persist, or return its identity fields. -type Inspection struct { - Target agentapi.SIMAKATarget - IMSI string - IMSIdentity agentapi.SIMIMSIdentityResponse -} - -// InspectML307AVOXI preserves the stricter preflight used by the historical -// one-shot HIL tools. Product workers use InspectHostVoWiFiLine instead. The -// wrapper verifies the HIL's documented ML307A/RF-off baseline, then delegates -// SIM communication to the same model-independent authentication path. -func InspectML307AVOXI(ctx context.Context) (Inspection, error) { - readOnly, err := agentapi.NewClient(ReadOnlyAgentSocket) +func InspectML307AVOXI(ctx context.Context) (ims.Inspection, error) { + readOnly, err := agentapi.NewClient(ims.ReadOnlyAgentSocket) if err != nil { - return Inspection{}, err + return ims.Inspection{}, err } snapshot, err := readOnly.Snapshot(ctx, true) if err != nil { - return Inspection{}, err + return ims.Inspection{}, err } deviceIDs := []string{} for _, device := range snapshot.Devices { - if device.Profile == agentapi.ProfileML307A && hasObservedCapability(device, "sim-auth") && hasObservedCapability(device, "host-vowifi-auth") { + if device.Profile == agentapi.ProfileML307A && observedCapability(device, "sim-auth") && observedCapability(device, "host-vowifi-auth") { deviceIDs = append(deviceIDs, device.ID) } } if len(deviceIDs) != 1 { - return Inspection{}, errors.New("expected exactly one ML307A HIL target") + return ims.Inspection{}, errors.New("expected exactly one ML307A HIL target") } probe, err := readOnly.Probe(ctx, agentapi.ProbeRequest{DeviceIDs: deviceIDs}) if err != nil || probe.AgentInstanceID != snapshot.AgentInstanceID || probe.SnapshotGeneration != snapshot.Generation || probe.SnapshotRevision != snapshot.Revision || len(probe.Devices) != 1 { - return Inspection{}, errors.New("probe fence changed") + return ims.Inspection{}, errors.New("probe fence changed") } observed := probe.Devices[0] if observed.DeviceID != deviceIDs[0] || observed.State != agentapi.ProbeStateComplete || observed.RF.State != agentapi.RFStateOff || observed.SIM.State != agentapi.SIMStatePresent || observed.SIM.PrimaryLockState != agentapi.PrimaryLockReady || len(observed.SIM.IdentityFingerprint) != 64 || observed.ActiveCallCount == nil || *observed.ActiveCallCount != 0 { - return Inspection{}, errors.New("ML307A HIL target is not ready and RF-off") - } - return InspectHostVoWiFiLine(ctx, "agent-line-"+observed.SIM.IdentityFingerprint[:32]) -} - -// InspectHostVoWiFiLine resolves one Line through the Agent's evidence-backed -// host-vowifi-auth capability. The common worker does not know the modem model -// and does not inspect RF state; model-specific SIM communication remains -// behind the Agent adapter selected for the matched device. -func InspectHostVoWiFiLine(ctx context.Context, lineID string) (Inspection, error) { - if !hardwareLineIDPattern.MatchString(lineID) { - return Inspection{}, errors.New("invalid Host VoWiFi Line") - } - readOnly, err := agentapi.NewClient(ReadOnlyAgentSocket) - if err != nil { - return Inspection{}, err - } - snapshot, err := readOnly.Snapshot(ctx, true) - if err != nil { - return Inspection{}, err - } - deviceIDs := make([]string, 0, len(snapshot.Devices)) - deviceByID := make(map[string]agentapi.DeviceReport, len(snapshot.Devices)) - for _, device := range snapshot.Devices { - if !hasObservedCapability(device, "sim-auth") || !hasObservedCapability(device, "host-vowifi-auth") { - continue - } - deviceIDs = append(deviceIDs, device.ID) - deviceByID[device.ID] = device - } - if len(deviceIDs) == 0 { - return Inspection{}, errors.New("no Host VoWiFi authentication adapter is available") - } - probe, err := readOnly.Probe(ctx, agentapi.ProbeRequest{DeviceIDs: deviceIDs}) - if err != nil || probe.AgentInstanceID != snapshot.AgentInstanceID || - probe.SnapshotGeneration != snapshot.Generation || probe.SnapshotRevision != snapshot.Revision { - return Inspection{}, errors.New("probe fence changed") - } - type match struct { - device agentapi.DeviceReport - observed agentapi.DeviceProbe + return ims.Inspection{}, errors.New("ML307A HIL target is not ready and RF-off") } - matches := []match{} - for _, observed := range probe.Devices { - device, exists := deviceByID[observed.DeviceID] - if !exists || observed.State != agentapi.ProbeStateComplete || - observed.SIM.State != agentapi.SIMStatePresent || observed.SIM.PrimaryLockState != agentapi.PrimaryLockReady || - len(observed.SIM.IdentityFingerprint) != 64 || observed.ActiveCallCount == nil || *observed.ActiveCallCount != 0 { - continue - } - if "agent-line-"+observed.SIM.IdentityFingerprint[:32] == lineID { - matches = append(matches, match{device: device, observed: observed}) - } - } - if len(matches) != 1 { - return Inspection{}, errors.New("Host VoWiFi Line does not resolve to exactly one ready SIM authentication adapter") - } - selected := matches[0] - target := agentapi.SIMAKATarget{ - AgentInstanceID: snapshot.AgentInstanceID, SnapshotGeneration: snapshot.Generation, - SnapshotRevision: snapshot.Revision, DeviceID: selected.device.ID, DeviceGeneration: selected.device.Generation, - IdentityFingerprint: selected.observed.SIM.IdentityFingerprint, - } - return readSIMAuthInspection(ctx, snapshot.AgentInstanceID, target) -} - -func readSIMAuthInspection(ctx context.Context, agentInstanceID string, target agentapi.SIMAKATarget) (Inspection, error) { - rootOnly, err := agentapi.NewClient(SIMAKASocket) - if err != nil { - return Inspection{}, err - } - hello, err := rootOnly.Hello(ctx) - if err != nil || hello.AgentInstanceID != agentInstanceID || - !containsString(hello.Features, agentapi.FeatureSIMAKAHIL) || - !containsString(hello.Features, agentapi.FeatureSIMIMSHIL) { - return Inspection{}, errors.New("SIM authentication socket is not current") - } - identity, err := rootOnly.SIMAKAIdentity(ctx, agentapi.SIMAKAIdentityRequest{SIMAKATarget: target}) - if err != nil { - return Inspection{}, err - } - profile, err := rootOnly.SIMIMSProfile(ctx, agentapi.SIMIMSProfileRequest{SIMAKATarget: target}) - if err != nil { - return Inspection{}, err - } - imsIdentity, err := rootOnly.SIMIMSIdentity(ctx, agentapi.SIMIMSIdentityRequest{SIMAKATarget: target}) - if err != nil || imsIdentity.IdentitySource != profile.IdentitySource { - return Inspection{}, errors.New("SIM IMS identity is unavailable") - } - return Inspection{Target: target, IMSI: identity.IMSI, IMSIdentity: imsIdentity}, nil + return ims.InspectHostVoWiFiLine(ctx, "agent-line-"+observed.SIM.IdentityFingerprint[:32]) } -func hasObservedCapability(device agentapi.DeviceReport, expected string) bool { +func observedCapability(device agentapi.DeviceReport, expected string) bool { for _, capability := range device.Capabilities { if capability.Capability == expected { return capability.Status == agentapi.EvidenceObserved @@ -151,12 +51,3 @@ func hasObservedCapability(device agentapi.DeviceReport, expected string) bool { } return false } - -func containsString(values []string, expected string) bool { - for _, value := range values { - if value == expected { - return true - } - } - return false -} diff --git a/internal/vowifisupervisor/changes.go b/internal/vowifisupervisor/changes.go new file mode 100644 index 0000000..14d42ce --- /dev/null +++ b/internal/vowifisupervisor/changes.go @@ -0,0 +1,156 @@ +package vowifisupervisor + +import ( + "context" + "net/http" + "sort" + "time" + + "github.com/google/uuid" + stream "github.com/leonfox28/simplus/internal/domain/connectivity" +) + +type ChangeAPI interface { + ConnectionSnapshot(context.Context) (stream.Snapshot, error) + ConnectionChanges(context.Context, stream.Cursor) (stream.Changes, error) +} + +func (local *Local) connectionCursorLocked() stream.Cursor { + if local.connectionInstance == "" { + local.connectionInstance = uuid.NewString() + } + return stream.Cursor{Instance: local.connectionInstance, Sequence: local.connectionSequence} +} +func (local *Local) recordConnectionLocked(current *instance, before bool, reason string) { + if before == current.status.Online { + return + } + local.connectionCursorLocked() + local.connectionSequence++ + at := local.Now().UTC() + current.observedAt = at + if current.status.Online { + reason = "registered" + } + change := stream.Change{Sequence: local.connectionSequence, State: stream.State{ObjectID: current.status.LineID, Connected: current.status.Online, ObservedAt: at, Reason: reason}} + local.connectionEvents = append(local.connectionEvents, change) + if len(local.connectionEvents) > stream.Capacity { + copy(local.connectionEvents, local.connectionEvents[len(local.connectionEvents)-stream.Capacity:]) + local.connectionEvents = local.connectionEvents[:stream.Capacity] + } +} +func (local *Local) ConnectionSnapshot(context.Context) (stream.Snapshot, error) { + local.mu.Lock() + defer local.mu.Unlock() + result := stream.Snapshot{Version: stream.Version, Cursor: local.connectionCursorLocked(), States: []stream.State{}} + for _, current := range local.instances { + at := current.observedAt + if at.IsZero() { + at = local.Now().UTC() + } + result.States = append(result.States, stream.State{ObjectID: current.status.LineID, Connected: current.status.Online, ObservedAt: at}) + } + sort.Slice(result.States, func(i, j int) bool { return result.States[i].ObjectID < result.States[j].ObjectID }) + return result, nil +} +func (local *Local) ConnectionChanges(_ context.Context, after stream.Cursor) (stream.Changes, error) { + local.mu.Lock() + defer local.mu.Unlock() + result := stream.Changes{Version: stream.Version, Cursor: local.connectionCursorLocked(), Events: []stream.Change{}} + if after.Instance != result.Cursor.Instance || after.Sequence > result.Cursor.Sequence || result.Cursor.Sequence-after.Sequence > stream.Capacity { + result.Reset = true + return result, nil + } + for _, event := range local.connectionEvents { + if event.Sequence > after.Sequence { + result.Events = append(result.Events, event) + } + } + return result, nil +} +func (client *Client) ConnectionSnapshot(ctx context.Context) (stream.Snapshot, error) { + var result stream.Snapshot + err := client.request(ctx, http.MethodGet, "/v1/vowifi/connections/snapshot", nil, &result) + if err != nil { + return result, err + } + if result.Version != stream.Version || !validConnectionCursor(result.Cursor) || len(result.States) > 256 { + return stream.Snapshot{}, ErrRequestInvalid + } + seen := map[string]bool{} + for _, s := range result.States { + if !validConnectionState(s) || seen[s.ObjectID] { + return stream.Snapshot{}, ErrRequestInvalid + } + seen[s.ObjectID] = true + } + return result, nil +} +func (client *Client) ConnectionChanges(ctx context.Context, after stream.Cursor) (stream.Changes, error) { + var result stream.Changes + if !validConnectionCursor(after) { + return result, ErrRequestInvalid + } + if err := client.request(ctx, http.MethodPost, "/v1/vowifi/connections/changes", after, &result); err != nil { + return result, err + } + if result.Version != stream.Version || !validConnectionCursor(result.Cursor) || len(result.Events) > stream.Capacity { + return stream.Changes{}, ErrRequestInvalid + } + if result.Reset { + if len(result.Events) != 0 { + return stream.Changes{}, ErrRequestInvalid + } + return result, nil + } + if result.Cursor.Instance != after.Instance || result.Cursor.Sequence < after.Sequence || uint64(len(result.Events)) != result.Cursor.Sequence-after.Sequence { + return stream.Changes{}, ErrRequestInvalid + } + for i, event := range result.Events { + if event.Sequence != after.Sequence+uint64(i)+1 || !validConnectionState(event.State) { + return stream.Changes{}, ErrRequestInvalid + } + } + return result, nil +} +func validConnectionCursor(c stream.Cursor) bool { + _, err := uuid.Parse(c.Instance) + return err == nil && len(c.Instance) == 36 && c.Sequence <= uint64(^uint64(0)>>1) +} +func validConnectionState(s stream.State) bool { + if !managedLinePattern.MatchString(s.ObjectID) || s.ObservedAt.IsZero() || s.ObservedAt.After(time.Now().Add(time.Minute)) { + return false + } + switch s.Reason { + case "", "registered", "stopped", "reconnecting", "worker_exited": + return true + } + return false +} +func registerConnectionHandlers(mux *http.ServeMux, api ChangeAPI) { + mux.HandleFunc("GET /connections/snapshot", func(w http.ResponseWriter, r *http.Request) { + if api == nil { + writeJSON(w, 503, errorResponse{Code: "CONNECTION_SOURCE_UNAVAILABLE"}) + return + } + result, err := api.ConnectionSnapshot(r.Context()) + if err != nil { + writeJSON(w, 503, errorResponse{Code: "CONNECTION_SOURCE_UNAVAILABLE"}) + return + } + writeJSON(w, 200, result) + }) + mux.HandleFunc("POST /connections/changes", func(w http.ResponseWriter, r *http.Request) { + var cursor stream.Cursor + if api == nil || !decodeRequest(w, r, &cursor) || !validConnectionCursor(cursor) { + writeJSON(w, 400, errorResponse{Code: "REQUEST_INVALID"}) + return + } + result, err := api.ConnectionChanges(r.Context(), cursor) + if err != nil { + writeJSON(w, 503, errorResponse{Code: "CONNECTION_SOURCE_UNAVAILABLE"}) + return + } + writeJSON(w, 200, result) + }) +} diff --git a/internal/vowifisupervisor/changes_test.go b/internal/vowifisupervisor/changes_test.go new file mode 100644 index 0000000..c70a4ad --- /dev/null +++ b/internal/vowifisupervisor/changes_test.go @@ -0,0 +1,73 @@ +package vowifisupervisor + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + stream "github.com/leonfox28/simplus/internal/domain/connectivity" +) + +func TestWorkerOutputContinuesPastOneMiBAndIgnoresRegistrationRefresh(t *testing.T) { + local := &Local{Now: time.Now, instances: map[string]*instance{}} + current := &instance{request: StartRequest{LineID: testManagedLineID}, status: Status{LineID: testManagedLineID}, ready: make(chan struct{})} + local.instances[testManagedLineID] = current + baseline, _ := local.ConnectionSnapshot(t.Context()) + var output bytes.Buffer + encoder := json.NewEncoder(&output) + for i := 0; i < 15000; i++ { + if err := encoder.Encode(workerEvent{LineID: testManagedLineID, State: StateOnline, Online: true, RegisteredAt: time.Now(), Attempt: i}); err != nil { + t.Fatal(err) + } + } + if err := encoder.Encode(workerEvent{LineID: testManagedLineID, State: StateReconnecting, Attempt: 15000}); err != nil { + t.Fatal(err) + } + if output.Len() <= 1<<20 { + t.Fatal("fixture too small") + } + if err := local.readWorkerEvents(current, &output); err != nil { + t.Fatal(err) + } + changes, err := local.ConnectionChanges(t.Context(), baseline.Cursor) + if err != nil || changes.Reset || len(changes.Events) != 2 || !changes.Events[0].State.Connected || changes.Events[1].State.Connected { + t.Fatalf("changes=%+v %v", changes, err) + } + if err := local.readWorkerEvents(current, strings.NewReader(strings.Repeat("x", 17<<10))); err == nil { + t.Fatal("unbounded single message accepted") + } +} + +func TestConnectionStreamResetReplayAndSnapshotCursor(t *testing.T) { + local := &Local{Now: time.Now, instances: map[string]*instance{}} + current := &instance{status: Status{LineID: testManagedLineID}} + local.instances[testManagedLineID] = current + baseline, _ := local.ConnectionSnapshot(t.Context()) + for i := 0; i < stream.Capacity+1; i++ { + before := current.status.Online + current.status.Online = !before + local.recordConnectionLocked(current, before, "reconnecting") + } + overflow, _ := local.ConnectionChanges(t.Context(), baseline.Cursor) + if !overflow.Reset || len(overflow.Events) != 0 { + t.Fatalf("overflow=%+v", overflow) + } + recent := baseline.Cursor + recent.Sequence = 1 + events, _ := local.ConnectionChanges(t.Context(), recent) + if events.Reset || len(events.Events) != stream.Capacity || events.Events[0].Sequence != 2 { + t.Fatalf("retained events=%+v", events.Cursor) + } + snapshot, _ := local.ConnectionSnapshot(t.Context()) + replay, _ := local.ConnectionChanges(t.Context(), snapshot.Cursor) + if replay.Reset || len(replay.Events) != 0 || snapshot.Cursor != events.Cursor || len(snapshot.States) != 1 || !snapshot.States[0].Connected { + t.Fatalf("snapshot=%+v replay=%+v", snapshot, replay) + } + restarted := &Local{Now: time.Now} + reset, _ := restarted.ConnectionChanges(t.Context(), snapshot.Cursor) + if !reset.Reset || reset.Cursor.Instance == snapshot.Cursor.Instance { + t.Fatalf("restart=%+v", reset) + } +} diff --git a/internal/vowifisupervisor/client.go b/internal/vowifisupervisor/client.go index fd8c370..3b1b873 100644 --- a/internal/vowifisupervisor/client.go +++ b/internal/vowifisupervisor/client.go @@ -9,7 +9,9 @@ import ( "io" "net" "net/http" + "net/http/httptrace" "path/filepath" + "sync/atomic" "time" ) @@ -51,12 +53,20 @@ func (client *Client) SendSMS(ctx context.Context, request SMSSendRequest) (SMSS if !validSMSSendRequest(request) { return SMSSendResponse{}, ErrRequestInvalid } + var connected atomic.Bool + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(httptrace.GotConnInfo) { connected.Store(true) }}) var response SMSSendResponse if err := client.request(ctx, http.MethodPost, "/v1/vowifi/sms/send", request, &response); err != nil { - return SMSSendResponse{}, err + if errors.Is(err, ErrSMSRejected) || errors.Is(err, ErrRequestInvalid) || errors.Is(err, ErrNotRunning) || errors.Is(err, ErrSMSUnavailable) { + return SMSSendResponse{}, err + } + if !connected.Load() { + return SMSSendResponse{}, ErrSMSNotDispatched + } + return SMSSendResponse{}, ErrSMSOutcomeUnknown } if !validSMSSendResponse(response) { - return SMSSendResponse{}, ErrSMSUnavailable + return SMSSendResponse{}, ErrSMSOutcomeUnknown } return response, nil } @@ -177,7 +187,14 @@ func (client *Client) request(ctx context.Context, method, path string, input, o } } if output != nil { - return json.NewDecoder(io.LimitReader(response.Body, maxSMSResponseBytes)).Decode(output) + decoder := json.NewDecoder(io.LimitReader(response.Body, maxSMSResponseBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return errors.New("invalid trailing supervisor response") + } } return nil } diff --git a/internal/vowifisupervisor/http.go b/internal/vowifisupervisor/http.go index 3878e2c..2a05908 100644 --- a/internal/vowifisupervisor/http.go +++ b/internal/vowifisupervisor/http.go @@ -14,6 +14,8 @@ type errorResponse struct { func NewHandler(supervisor API, logger *slog.Logger) http.Handler { mux := http.NewServeMux() + changes, _ := supervisor.(ChangeAPI) + registerConnectionHandlers(mux, changes) sms, _ := supervisor.(SMSAPI) mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) { statuses, err := supervisor.List(r.Context()) diff --git a/internal/vowifisupervisor/local.go b/internal/vowifisupervisor/local.go index cfbf014..f2304ac 100644 --- a/internal/vowifisupervisor/local.go +++ b/internal/vowifisupervisor/local.go @@ -15,6 +15,8 @@ import ( "sync" "syscall" "time" + + stream "github.com/leonfox28/simplus/internal/domain/connectivity" ) var safeStatusToken = regexp.MustCompile(`^[A-Z0-9_-]{0,64}$`) @@ -32,6 +34,8 @@ type workerEvent struct { } type instance struct { + observedAt time.Time + cancel context.CancelFunc request StartRequest plan networkPlan status Status @@ -45,12 +49,16 @@ type instance struct { } type Local struct { - Root string - Executable string - Now func() time.Time - Network *networkManager + connectionInstance string + connectionSequence uint64 + connectionEvents []stream.Change + Root string + Executable string + Now func() time.Time + Network *networkManager mu sync.Mutex + closing bool instances map[string]*instance } @@ -88,59 +96,67 @@ func (local *Local) Start(ctx context.Context, request StartRequest) (Status, er if !validStartRequest(request) { return Status{}, ErrRequestInvalid } - local.mu.Lock() - if current := local.instances[request.LineID]; current != nil && - current.status.State != StateStopped && current.status.State != StateFailed { - status := current.status - local.mu.Unlock() - return status, ErrAlreadyRunning - } - if stale := local.instances[request.LineID]; stale != nil && stale.cleanup != nil { - cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) - cleanupErr := local.Network.Cleanup(cleanupCtx, stale.plan) - cancel() - if cleanupErr != nil { - status := stale.status - local.mu.Unlock() - return status, cleanupErr - } - stale.cleanup = nil - } plan, err := buildNetworkPlan(request) if err != nil { - local.mu.Unlock() return Status{}, err } + startCtx, cancelStart := context.WithCancel(ctx) + defer cancelStart() runtimeDir := filepath.Join(local.Root, plan.Token) - if err := os.RemoveAll(runtimeDir); err != nil { - local.mu.Unlock() - return Status{}, err - } - if err := os.Mkdir(runtimeDir, 0o700); err != nil { + current := &instance{request: request, plan: plan, runtimeDir: runtimeDir, ready: make(chan struct{}), done: make(chan struct{}), cancel: cancelStart, + status: Status{LineID: request.LineID, State: StateStarting, Stage: "network", EgressMode: request.EgressMode, CountryCode: request.CountryCode, StartedAt: local.Now().UTC()}} + local.mu.Lock() + stale := local.instances[request.LineID] + if local.closing { local.mu.Unlock() - return Status{}, err + return Status{}, ErrNotRunning } - if err := writeNetworkManifest(filepath.Join(runtimeDir, "network.json"), plan); err != nil { - _ = os.RemoveAll(runtimeDir) + if stale != nil && stale.status.State != StateStopped && stale.status.State != StateFailed { + status := stale.status local.mu.Unlock() - return Status{}, err + return status, ErrAlreadyRunning } - current := &instance{request: request, plan: plan, runtimeDir: runtimeDir, ready: make(chan struct{}), done: make(chan struct{}), - status: Status{LineID: request.LineID, State: StateStarting, Stage: "network", EgressMode: request.EgressMode, - CountryCode: request.CountryCode, StartedAt: local.Now().UTC()}} local.instances[request.LineID] = current local.mu.Unlock() - - setupCtx, cancelSetup := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + if stale != nil && stale.cleanup != nil { + cleanupCtx, cancel := context.WithTimeout(startCtx, 15*time.Second) + cleanupErr := local.Network.Cleanup(cleanupCtx, stale.plan) + cancel() + if cleanupErr != nil { + local.failStart(current, "NETWORK_CLEANUP_FAILED", false) + return local.status(current), cleanupErr + } + } + for _, prepare := range []func() error{ + func() error { return os.RemoveAll(runtimeDir) }, + func() error { return os.Mkdir(runtimeDir, 0o700) }, + func() error { return writeNetworkManifest(filepath.Join(runtimeDir, "network.json"), plan) }, + } { + if err := prepare(); err != nil { + local.failStart(current, "WORKER_START_FAILED", true) + return local.status(current), err + } + } + setupCtx, cancelSetup := context.WithTimeout(startCtx, 15*time.Second) err = local.Network.Setup(setupCtx, plan) cancelSetup() if err != nil { local.failStart(current, "NETWORK_SETUP_FAILED", !errors.Is(err, errNetworkCleanupFailed)) - return current.status, fmt.Errorf("%w: %v", ErrStartupFailed, err) + return local.status(current), fmt.Errorf("%w: %v", ErrStartupFailed, err) + } + local.mu.Lock() + var stdout io.ReadCloser + var command *exec.Cmd + err = startCtx.Err() + if err == nil { + stdout, command, err = local.workerCommand(current, runtimeDir) + current.command = command } - stdout, command, err := local.workerCommand(current, runtimeDir) + local.mu.Unlock() if err != nil { - cleanupErr := local.Network.Cleanup(context.Background(), plan) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + cleanupErr := local.Network.Cleanup(cleanupCtx, plan) + cancel() code := "WORKER_START_FAILED" if cleanupErr != nil { code = "NETWORK_CLEANUP_FAILED" @@ -149,11 +165,8 @@ func (local *Local) Start(ctx context.Context, request StartRequest) (Status, er if cleanupErr != nil { err = errors.Join(err, cleanupErr) } - return current.status, fmt.Errorf("%w: %v", ErrStartupFailed, err) + return local.status(current), fmt.Errorf("%w: %v", ErrStartupFailed, err) } - local.mu.Lock() - current.command = command - local.mu.Unlock() go local.monitor(current, runtimeDir, stdout) timer := time.NewTimer(5 * time.Second) @@ -197,28 +210,17 @@ func (local *Local) workerCommand(current *instance, runtimeDir string) (io.Read func (local *Local) monitor(current *instance, runtimeDir string, stdout io.ReadCloser) { defer stdout.Close() - scanner := bufio.NewScanner(io.LimitReader(stdout, 1<<20)) - scanner.Buffer(make([]byte, 4096), 16<<10) - for scanner.Scan() { - var event workerEvent - if json.Unmarshal(scanner.Bytes(), &event) != nil || !validWorkerEvent(event, current.request.LineID) { - continue - } - local.mu.Lock() - current.status.State = event.State - current.status.Stage = event.Stage - current.status.Online = event.Online - current.status.RegisteredAt = event.RegisteredAt - current.status.NextRefresh = event.NextRefresh - current.status.PhoneNumber = event.PhoneNumber - current.status.Attempt = event.Attempt - if event.ErrorCode != "" || event.Online { - current.status.ErrorCode = event.ErrorCode - } - local.mu.Unlock() - current.once.Do(func() { close(current.ready) }) + readErr := local.readWorkerEvents(current, stdout) + if readErr != nil && current.command.Process != nil { + _ = current.command.Process.Kill() } waitErr := current.command.Wait() + local.mu.Lock() + beforeExit := current.status.Online + current.status.Online = false + current.status.PhoneNumber = "" + local.recordConnectionLocked(current, beforeExit, "worker_exited") + local.mu.Unlock() cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) cleanupErr := local.Network.Cleanup(cleanupCtx, current.plan) cancel() @@ -227,6 +229,7 @@ func (local *Local) monitor(current *instance, runtimeDir string, stdout io.Read } local.mu.Lock() + before := current.status.Online current.cleanup = cleanupErr if cleanupErr != nil { current.status.State, current.status.Stage, current.status.Online = StateFailed, "cleanup", false @@ -244,6 +247,7 @@ func (local *Local) monitor(current *instance, runtimeDir string, stdout io.Read } _ = waitErr } + local.recordConnectionLocked(current, before, "worker_exited") local.mu.Unlock() current.once.Do(func() { close(current.ready) }) close(current.done) @@ -255,14 +259,22 @@ func (local *Local) Stop(ctx context.Context, lineID string) (Status, error) { } local.mu.Lock() current := local.instances[lineID] - if current == nil || current.status.State == StateStopped || current.command == nil { + if current == nil || current.status.State == StateStopped { local.mu.Unlock() return Status{}, ErrNotRunning } + before := current.status.Online current.stop = true current.status.State, current.status.Stage, current.status.Online = StateStopping, "cleanup", false current.status.PhoneNumber = "" - process := current.command.Process + local.recordConnectionLocked(current, before, "stopped") + if current.cancel != nil { + current.cancel() + } + var process *os.Process + if current.command != nil { + process = current.command.Process + } local.mu.Unlock() if process != nil { _ = process.Signal(syscall.SIGTERM) @@ -284,38 +296,69 @@ func (local *Local) Stop(ctx context.Context, lineID string) (Status, error) { case <-current.done: } local.mu.Lock() - status := current.status cleanupErr := current.cleanup local.mu.Unlock() if cleanupErr != nil { - return status, cleanupErr + cleanupErr = local.Network.Cleanup(ctx, current.plan) + if cleanupErr == nil { + cleanupErr = os.RemoveAll(filepath.Join(local.Root, current.plan.Token)) + } } - return status, nil + local.mu.Lock() + current.cleanup = cleanupErr + if cleanupErr == nil { + current.status.State, current.status.Stage, current.status.ErrorCode = StateStopped, "", "" + } + status := current.status + local.mu.Unlock() + return status, cleanupErr } func (local *Local) Close(ctx context.Context) error { + local.mu.Lock() + local.closing = true + local.mu.Unlock() statuses, _ := local.List(ctx) + var work sync.WaitGroup + var mu sync.Mutex var closeErr error for _, status := range statuses { - if status.State == StateStopped || status.State == StateFailed { + if status.State == StateStopped { continue } - if _, err := local.Stop(ctx, status.LineID); err != nil && !errors.Is(err, ErrNotRunning) && closeErr == nil { - closeErr = err - } + work.Go(func() { + if _, err := local.Stop(ctx, status.LineID); err != nil && !errors.Is(err, ErrNotRunning) { + mu.Lock() + closeErr = errors.Join(closeErr, err) + mu.Unlock() + } + }) } + work.Wait() return closeErr } +func (local *Local) status(current *instance) Status { + local.mu.Lock() + defer local.mu.Unlock() + return current.status +} + func (local *Local) failStart(current *instance, code string, removeRuntime bool) { + if removeRuntime { + _ = os.RemoveAll(filepath.Join(local.Root, current.plan.Token)) + } local.mu.Lock() current.status.State, current.status.Stage, current.status.Online, current.status.ErrorCode = StateFailed, "startup", false, code - local.mu.Unlock() + if !removeRuntime { + current.cleanup = errNetworkCleanupFailed + } + if current.stop && removeRuntime { + current.status.State = StateStopped + } current.once.Do(func() { close(current.ready) }) close(current.done) - if removeRuntime { - _ = os.RemoveAll(filepath.Join(local.Root, current.plan.Token)) - } + local.mu.Unlock() } func (local *Local) cleanupStale() error { @@ -358,3 +401,34 @@ func validWorkerEvent(event workerEvent, lineID string) bool { } return event.Online == (event.State == StateOnline) && (event.Online || event.PhoneNumber == "") } + +func (local *Local) readWorkerEvents(current *instance, reader io.Reader) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 4096), 16<<10) + for scanner.Scan() { + var event workerEvent + if json.Unmarshal(scanner.Bytes(), &event) != nil || !validWorkerEvent(event, current.request.LineID) { + continue + } + local.mu.Lock() + if current.stop { + local.mu.Unlock() + continue + } + before := current.status.Online + current.status.State = event.State + current.status.Stage = event.Stage + current.status.Online = event.Online + current.status.RegisteredAt = event.RegisteredAt + current.status.NextRefresh = event.NextRefresh + current.status.PhoneNumber = event.PhoneNumber + current.status.Attempt = event.Attempt + if event.ErrorCode != "" || event.Online { + current.status.ErrorCode = event.ErrorCode + } + local.recordConnectionLocked(current, before, "reconnecting") + local.mu.Unlock() + current.once.Do(func() { close(current.ready) }) + } + return scanner.Err() +} diff --git a/internal/vowifisupervisor/types.go b/internal/vowifisupervisor/types.go index d1f7475..e5613a7 100644 --- a/internal/vowifisupervisor/types.go +++ b/internal/vowifisupervisor/types.go @@ -7,20 +7,22 @@ import ( "strings" "time" "unicode/utf8" + + domain "github.com/leonfox28/simplus/internal/domain/vowifi" ) const ( - EgressDirect = "direct" - EgressMihomoCountry = "mihomo-country" - - StateStopped = "stopped" - StateStarting = "starting" - StateConnecting = "connecting" - StateRegistering = "registering" - StateOnline = "online" - StateReconnecting = "reconnecting" - StateStopping = "stopping" - StateFailed = "failed" + EgressDirect = domain.EgressDirect + EgressMihomoCountry = domain.EgressMihomoCountry + + StateStopped = domain.StateStopped + StateStarting = domain.StateStarting + StateConnecting = domain.StateConnecting + StateRegistering = domain.StateRegistering + StateOnline = domain.StateOnline + StateReconnecting = domain.StateReconnecting + StateStopping = domain.StateStopping + StateFailed = domain.StateFailed SMSSubmitAccepted = "accepted" SMSSubmitSent = "sent" @@ -29,13 +31,14 @@ const ( ) var ( - ErrAlreadyRunning = errors.New("Host VoWiFi Line is already running") - ErrNotRunning = errors.New("Host VoWiFi Line is not running") - ErrRequestInvalid = errors.New("Host VoWiFi supervisor request is invalid") - ErrStartupFailed = errors.New("Host VoWiFi worker startup failed") + ErrAlreadyRunning = domain.ErrAlreadyRunning + ErrNotRunning = domain.ErrNotRunning + ErrRequestInvalid = domain.ErrRequestInvalid + ErrStartupFailed = domain.ErrStartupFailed ErrSMSUnavailable = errors.New("Host VoWiFi SMS is unavailable") ErrSMSMessageNotFound = errors.New("Host VoWiFi SMS message was not found") ErrSMSOutcomeUnknown = errors.New("Host VoWiFi SMS outcome is unknown") + ErrSMSNotDispatched = errors.New("Host VoWiFi SMS request was not dispatched") ErrSMSRejected = errors.New("Host VoWiFi SMS was rejected") ) @@ -49,12 +52,6 @@ var ( // StartRequest contains a stable business Line and the current opaque hardware // target resolved by the unprivileged application. The privileged supervisor // still resolves every executable path, port and kernel object itself. -type StartRequest struct { - LineID string `json:"lineId"` - HardwareLineID string `json:"hardwareLineId"` - EgressMode string `json:"egressMode"` - CountryCode string `json:"countryCode"` -} type StopRequest struct { LineID string `json:"lineId"` @@ -63,20 +60,6 @@ type StopRequest struct { // Status is safe for the management plane. It intentionally excludes PID, // namespace/interface names, addresses, P-CSCF, SPIs and authentication data. // PhoneNumber is present only when IMS returned an unambiguous E.164 identity. -type Status struct { - LineID string `json:"lineId"` - State string `json:"state"` - Stage string `json:"stage,omitempty"` - Online bool `json:"online"` - EgressMode string `json:"egressMode"` - CountryCode string `json:"countryCode"` - StartedAt time.Time `json:"startedAt,omitempty"` - RegisteredAt time.Time `json:"registeredAt,omitempty"` - NextRefresh time.Time `json:"nextRefreshAt,omitempty"` - PhoneNumber string `json:"phoneNumber"` - Attempt int `json:"attempt"` - ErrorCode string `json:"errorCode,omitempty"` -} type StatusList struct { Lines []Status `json:"lines"` @@ -170,12 +153,6 @@ type SMSSubmitReportAcknowledgeRequest struct { ProviderMessageID string `json:"providerMessageId"` } -type API interface { - List(context.Context) ([]Status, error) - Start(context.Context, StartRequest) (Status, error) - Stop(context.Context, string) (Status, error) -} - type SMSAPI interface { SendSMS(context.Context, SMSSendRequest) (SMSSendResponse, error) ListSMS(context.Context, string) ([]SMSMessageReference, error) @@ -233,3 +210,7 @@ func validSMSSubmitReport(report SMSSubmitReport) bool { return false } } + +type StartRequest = domain.StartRequest +type Status = domain.Status +type API = domain.API diff --git a/internal/vowifisupervisor/worker.go b/internal/vowifisupervisor/worker.go index d1bf4c1..1d31081 100644 --- a/internal/vowifisupervisor/worker.go +++ b/internal/vowifisupervisor/worker.go @@ -18,7 +18,7 @@ import ( "syscall" "time" - "github.com/leonfox28/simplus/internal/vowifihil" + "github.com/leonfox28/simplus/internal/ims" ) var pcscfLogPattern = regexp.MustCompile(`received P-CSCF server IP ([0-9]{1,3}(?:\.[0-9]{1,3}){3})`) @@ -93,16 +93,16 @@ func runWorkerAttempt(ctx context.Context, config WorkerConfig, attempt int, emi defer os.RemoveAll(attemptDir) preflightCtx, cancelPreflight := context.WithTimeout(ctx, 35*time.Second) - inspection, err := vowifihil.InspectHostVoWiFiLine(preflightCtx, config.HardwareLineID) + inspection, err := ims.InspectHostVoWiFiLine(preflightCtx, config.HardwareLineID) cancelPreflight() if err != nil { return attemptFailure{"SIM_PREFLIGHT_FAILED"} } - paths, err := vowifihil.PathsFor(attemptDir) + paths, err := ims.PathsFor(attemptDir) if err != nil { return attemptFailure{"RUNTIME_PATH_INVALID"} } - strongSwan, err := vowifihil.BuildAt(vowifihil.Input{Target: inspection.Target, IMSI: inspection.IMSI}, paths) + strongSwan, err := ims.BuildAt(ims.Input{Target: inspection.Target, IMSI: inspection.IMSI}, paths) inspection.IMSI = "" if err != nil { return attemptFailure{"STRONGSWAN_CONFIG_FAILED"} @@ -148,13 +148,13 @@ func runWorkerAttempt(ctx context.Context, config WorkerConfig, attempt int, emi if !waitForSocket(ctx, paths.VICISocket, charonDone, 8*time.Second) { return attemptFailure{"STRONGSWAN_NOT_READY"} } - input, err := vowifihil.ParseConnectionInput(strongSwan.VICI) + input, err := ims.ParseConnectionInput(strongSwan.VICI) if err != nil { return attemptFailure{"STRONGSWAN_CONFIG_FAILED"} } emit(workerEvent{State: StateConnecting, Stage: "EPDG", Attempt: attempt}) initiateCtx, cancelInitiate := context.WithTimeout(ctx, 55*time.Second) - err = vowifihil.Initiate(initiateCtx, paths.VICISocket, input) + err = ims.Initiate(initiateCtx, paths.VICISocket, input) cancelInitiate() if err != nil { return attemptFailure{latestDiagnostic(diagnostics, func() string { return trace.failureCode(initiateErrorCode(err)) })} @@ -168,18 +168,18 @@ func runWorkerAttempt(ctx context.Context, config WorkerConfig, attempt int, emi return attemptFailure{"PCSCF_MISSING"} } emit(workerEvent{State: StateRegistering, Stage: "IMS", Attempt: attempt}) - var session *vowifihil.IMSSession - var registration vowifihil.IMSRegistrationResult + var session *ims.IMSSession + var registration ims.IMSRegistrationResult for _, address := range pcscfAddresses { registerCtx, cancelRegister := context.WithTimeout(ctx, 50*time.Second) - session, registration, err = vowifihil.EstablishIMSSession(registerCtx, source, address, inspection) + session, registration, err = ims.EstablishIMSSession(registerCtx, source, address, inspection) cancelRegister() if err == nil { break } } if session == nil || err != nil { - code := vowifihil.IMSRegistrationFailureCode(err) + code := ims.IMSRegistrationFailureCode(err) if code == "" { code = "IMS_REGISTER_FAILED" } @@ -217,7 +217,7 @@ func runWorkerAttempt(ctx context.Context, config WorkerConfig, attempt int, emi pollCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) err := session.PollSMS(pollCtx) cancel() - if err != nil && !errors.Is(err, vowifihil.ErrIMSSMSUnavailable) { + if err != nil && !errors.Is(err, ims.ErrIMSSMSUnavailable) { return attemptFailure{"IMS_SMS_RECEIVE_FAILED"} } case <-keepalive.C: @@ -248,15 +248,15 @@ func runWorkerAttempt(ctx context.Context, config WorkerConfig, attempt int, emi func imsRefreshErrorCode(err error) string { switch { - case errors.Is(err, vowifihil.ErrIMSReauthenticationRequired): + case errors.Is(err, ims.ErrIMSReauthenticationRequired): return "IMS_REAUTH_REQUIRED" - case errors.Is(err, vowifihil.ErrIMSRefreshIntervalRejected): + case errors.Is(err, ims.ErrIMSRefreshIntervalRejected): return "IMS_REFRESH_INTERVAL_REJECTED" - case errors.Is(err, vowifihil.ErrIMSRefreshRejected): + case errors.Is(err, ims.ErrIMSRefreshRejected): return "IMS_REFRESH_REJECTED" - case errors.Is(err, vowifihil.ErrIMSRefreshNoResponse): + case errors.Is(err, ims.ErrIMSRefreshNoResponse): return "IMS_REFRESH_NO_RESPONSE" - case errors.Is(err, vowifihil.ErrIMSRefreshResponseUnmatched): + case errors.Is(err, ims.ErrIMSRefreshResponseUnmatched): return "IMS_REFRESH_RESPONSE_UNMATCHED" default: return "IMS_REFRESH_FAILED" @@ -265,15 +265,15 @@ func imsRefreshErrorCode(err error) string { func initiateErrorCode(err error) string { switch { - case errors.Is(err, vowifihil.ErrVICIUnavailable): + case errors.Is(err, ims.ErrVICIUnavailable): return "STRONGSWAN_VICI_UNAVAILABLE" - case errors.Is(err, vowifihil.ErrRequiredPluginsUnavailable): + case errors.Is(err, ims.ErrRequiredPluginsUnavailable): return "STRONGSWAN_PLUGINS_MISSING" - case errors.Is(err, vowifihil.ErrConnectionLoadFailed): + case errors.Is(err, ims.ErrConnectionLoadFailed): return "STRONGSWAN_CONNECTION_LOAD_FAILED" - case errors.Is(err, vowifihil.ErrConnectionVerifyFailed): + case errors.Is(err, ims.ErrConnectionVerifyFailed): return "STRONGSWAN_CONNECTION_VERIFY_FAILED" - case errors.Is(err, vowifihil.ErrConnectionInitiateFailed): + case errors.Is(err, ims.ErrConnectionInitiateFailed): return "EPDG_CONNECT_FAILED" default: return "EPDG_CONNECT_FAILED" diff --git a/internal/vowifisupervisor/worker_sms.go b/internal/vowifisupervisor/worker_sms.go index 51169b6..ef9264e 100644 --- a/internal/vowifisupervisor/worker_sms.go +++ b/internal/vowifisupervisor/worker_sms.go @@ -13,14 +13,14 @@ import ( "time" "github.com/leonfox28/simplus/internal/agentapi" + "github.com/leonfox28/simplus/internal/ims" "github.com/leonfox28/simplus/internal/smscodec" - "github.com/leonfox28/simplus/internal/vowifihil" ) const workerSMSSocketName = "sms.sock" type workerSMSService struct { - session *vowifihil.IMSSession + session *ims.IMSSession mu sync.Mutex tpMR byte } @@ -131,11 +131,11 @@ func mapIMSSMSError(err error) error { switch { case err == nil: return nil - case errors.Is(err, vowifihil.ErrIMSSMSMessageNotFound): + case errors.Is(err, ims.ErrIMSSMSMessageNotFound): return ErrSMSMessageNotFound - case errors.Is(err, vowifihil.ErrIMSSMSOutcomeUnknown): + case errors.Is(err, ims.ErrIMSSMSOutcomeUnknown): return ErrSMSOutcomeUnknown - case errors.Is(err, vowifihil.ErrIMSSMSRejected): + case errors.Is(err, ims.ErrIMSSMSRejected): return ErrSMSRejected default: return ErrSMSUnavailable diff --git a/internal/vowifisupervisor/worker_test.go b/internal/vowifisupervisor/worker_test.go index e8bc944..8104dca 100644 --- a/internal/vowifisupervisor/worker_test.go +++ b/internal/vowifisupervisor/worker_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/leonfox28/simplus/internal/vowifihil" + "github.com/leonfox28/simplus/internal/ims" ) func TestStrongSwanLogReaderContinuesPastTwoMiB(t *testing.T) { @@ -55,22 +55,22 @@ func TestStrongSwanDiagnosticsExposeOnlyFixedSafeCodes(t *testing.T) { } func TestInitiateErrorsMapToSafeStages(t *testing.T) { - if code := initiateErrorCode(vowifihil.ErrRequiredPluginsUnavailable); code != "STRONGSWAN_PLUGINS_MISSING" { + if code := initiateErrorCode(ims.ErrRequiredPluginsUnavailable); code != "STRONGSWAN_PLUGINS_MISSING" { t.Fatalf("code=%q", code) } - if code := initiateErrorCode(vowifihil.ErrConnectionInitiateFailed); code != "EPDG_CONNECT_FAILED" { + if code := initiateErrorCode(ims.ErrConnectionInitiateFailed); code != "EPDG_CONNECT_FAILED" { t.Fatalf("code=%q", code) } } func TestIMSRefreshErrorsMapToSafeStages(t *testing.T) { cases := map[error]string{ - vowifihil.ErrIMSReauthenticationRequired: "IMS_REAUTH_REQUIRED", - vowifihil.ErrIMSRefreshIntervalRejected: "IMS_REFRESH_INTERVAL_REJECTED", - vowifihil.ErrIMSRefreshRejected: "IMS_REFRESH_REJECTED", - vowifihil.ErrIMSRefreshNoResponse: "IMS_REFRESH_NO_RESPONSE", - vowifihil.ErrIMSRefreshResponseUnmatched: "IMS_REFRESH_RESPONSE_UNMATCHED", - errors.New("transport failed"): "IMS_REFRESH_FAILED", + ims.ErrIMSReauthenticationRequired: "IMS_REAUTH_REQUIRED", + ims.ErrIMSRefreshIntervalRejected: "IMS_REFRESH_INTERVAL_REJECTED", + ims.ErrIMSRefreshRejected: "IMS_REFRESH_REJECTED", + ims.ErrIMSRefreshNoResponse: "IMS_REFRESH_NO_RESPONSE", + ims.ErrIMSRefreshResponseUnmatched: "IMS_REFRESH_RESPONSE_UNMATCHED", + errors.New("transport failed"): "IMS_REFRESH_FAILED", } for input, expected := range cases { if actual := imsRefreshErrorCode(input); actual != expected { diff --git a/packaging/container/README.md b/packaging/container/README.md index 4d05153..e3777d1 100644 --- a/packaging/container/README.md +++ b/packaging/container/README.md @@ -32,7 +32,7 @@ sha256sum -c "simplus-compose--linux-amd64.tar.gz.sha256" && LICENSE 和 THIRD_PARTY_NOTICES 随同归档提供,以保持同版本的安全检查、来源标识和 合规材料,不是额外安装包。 -持久数据将写入 `/opt/simplus/data/core` 和 `/opt/simplus/data/agent`。首次启动前确认 +持久数据将写入 `/opt/simplus/data/control-v2` 和 `/opt/simplus/data/agent`。首次启动前确认 该目录位于持久存储,并确定实例外、访问受限的备份位置。 宿主准备会修改模块加载配置并执行 `modprobe option`,请先审阅脚本,再显式运行: @@ -43,12 +43,12 @@ sudo bash prepare-container-host.sh && docker compose config --quiet && docker compose pull && docker compose up -d && - docker compose wait bootstrap && + docker compose wait provision && docker compose ps && - docker compose logs bootstrap + docker compose logs provision ``` -`bootstrap` 只在全新实例首次启动时输出随机管理员密码。立即保存并在首次登录后修改。 +`provision` 只在全新实例首次启动时输出随机管理员密码。立即保存并在首次登录后修改。 已有实例重建时不会覆盖密码或再次输出原密码。Web 和 controller 只能暴露给可信局域网。 升级前停止写入并完整备份 `/opt/simplus/data`,再下载和校验新版部署包、保留自己的 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7410f63..3beaf80 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,5 @@ overrides: audit: level: moderate ignore: [] + +storeDir: ~/.cache/simplus/pnpm-store diff --git a/scripts/dev/browser-simulator.sh b/scripts/dev/browser-simulator.sh new file mode 100755 index 0000000..07ef8a5 --- /dev/null +++ b/scripts/dev/browser-simulator.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR=$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +REPO_ROOT=$(CDPATH= cd "$SCRIPT_DIR/../.." && pwd -P) +cd "$REPO_ROOT" +mkdir -p .dev/bin +go build -o .dev/bin/simplusd-browser ./cmd/simplusd +go build -tags=integration -o .dev/bin/simulator-browser ./internal/testsupport/simulatorserver +corepack pnpm --dir web build +exec .dev/bin/simulator-browser "$REPO_ROOT/.dev/bin/simplusd-browser" "$REPO_ROOT/web/dist" diff --git a/scripts/dev/run-sim-test.sh b/scripts/dev/run-sim-test.sh index 3fe9a5d..73b6b26 100755 --- a/scripts/dev/run-sim-test.sh +++ b/scripts/dev/run-sim-test.sh @@ -130,21 +130,14 @@ wait_for_url "http://127.0.0.1:$api_port/api/v1/system/health" || { cat "$TMP_ROOT/normal.log" >&2 fail 'API did not become reachable during normal startup' } -wait_for_url "http://127.0.0.1:$api_port/api/v1/setup/status" || { - cat "$TMP_ROOT/normal.log" >&2 - fail 'setup status API did not become reachable during normal startup' -} -curl --fail --silent "http://127.0.0.1:$api_port/api/v1/setup/status" | node -e ' +curl --fail --silent "http://127.0.0.1:$api_port/api/v1/system/health" | node -e ' const chunks = [] - process.stdin.on("data", (chunk) => chunks.push(chunk)) + process.stdin.on("data", chunk => chunks.push(chunk)) process.stdin.on("end", () => { - const setup = JSON.parse(Buffer.concat(chunks).toString("utf8")) - if (setup.installationState !== "uninitialized" || setup.phase !== "bootstrap-required") process.exit(1) - if (setup.setupRequired !== true || setup.businessApiAvailable !== false) process.exit(1) - if (setup.bootstrapGenerationAvailable !== false) process.exit(1) - if (setup.supportedFlows?.join(",") !== "create-new") process.exit(1) + const health = JSON.parse(Buffer.concat(chunks).toString("utf8")) + if (health.installationState !== "uninitialized") process.exit(1) }) -' || fail 'setup API did not return the fail-closed first-run boundary' +' || fail 'new instance must wait for administrator provisioning' for locked_path in inventory hardware/topology; do locked_body="$TMP_ROOT/locked-${locked_path//\//-}.json" locked_status=$(curl --silent --output "$locked_body" --write-out '%{http_code}' \ @@ -156,15 +149,10 @@ for locked_path in inventory hardware/topology; do if (error.code !== "INSTANCE_NOT_INITIALIZED" || error.retryable !== false) process.exit(1) ' "$locked_body" || fail "$locked_path API did not return the stable setup lock error" done -session_body="$TMP_ROOT/setup-session.json" -session_status=$(curl --silent --output "$session_body" --write-out '%{http_code}' \ - "http://127.0.0.1:$api_port/api/v1/setup/session") -[[ $session_status == 401 ]] || fail "setup session status was $session_status without a cookie, expected 401" -node -e ' - const fs = require("node:fs") - const error = JSON.parse(fs.readFileSync(process.argv[1], "utf8")) - if (error.code !== "SETUP_SESSION_UNAUTHORIZED" || error.retryable !== false) process.exit(1) -' "$session_body" || fail 'setup session API did not reject an absent restricted session' +for removed_path in setup/status setup/session; do + status=$(curl --silent --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:$api_port/api/v1/$removed_path") + [[ $status == 404 ]] || fail "removed setup route returned $status, expected 404" +done wait_for_url "http://127.0.0.1:$web_port/" 300 || { cat "$TMP_ROOT/normal.log" >&2 fail 'Vite did not become reachable during normal startup' diff --git a/sqlc.yaml b/sqlc.yaml index 1b73b77..43c954a 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,7 +1,7 @@ version: "2" sql: - engine: sqlite - schema: internal/storage/sqlite/migrations/core + schema: internal/storage/sqlite/migrations/control queries: internal/storage/sqlite/queries/core gen: go: diff --git a/web/e2e-simulator/control.spec.ts b/web/e2e-simulator/control.spec.ts new file mode 100644 index 0000000..d92c7db --- /dev/null +++ b/web/e2e-simulator/control.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test' + +test('installed Simulator manages devices, SMS, calls and durable connection notices', async ({ page }) => { + await page.goto('/login') + await page.getByPlaceholder('管理员用户名').fill('browser_admin') + await page.getByPlaceholder('密码').fill('integration-test-password') + await page.getByRole('button', { name: /登\s*录/ }).click() + await expect(page.getByRole('heading', { name: '概览' })).toBeVisible() + await expect(page).toHaveURL(/\/dashboard$/) + const api = page.request + const csrf = (await page.context().cookies()).find(cookie => cookie.name === 'simplus_csrf')?.value ?? '' + const headers = { Origin: 'http://127.0.0.1:4183', 'X-Simplus-CSRF': csrf } + const post = async (path: string, data: unknown) => { + const response = await api.post(`/api/v1/${path}`, { data, headers }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() + } + const put = async (path: string, data: unknown) => { + const response = await api.put(`/api/v1/${path}`, { data, headers }) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() + } + const get = async (path: string) => { + const response = await api.get(`/api/v1/${path}`) + expect(response.ok(), await response.text()).toBeTruthy() + return response.json() + } + + await page.goto('/notifications') + await page.getByRole('combobox', { name: /平台/ }).click() + await page.getByText('企业微信', { exact: true }).click() + await page.getByLabel('名称', { exact: true }).fill('Browser notifications') + await page.getByLabel('Webhook', { exact: true }).fill('https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=synthetic-browser') + await expect(page.getByRole('checkbox', { checked: true })).toHaveCount(9) + await page.getByRole('button', { name: '添加 Webhook' }).click() + await expect(page.getByText('Browser notifications', { exact: true })).toBeVisible() + const channel = (await get('notification-channels')).channels[0] + await put(`notification-channels/${channel.id}`, { provider: 'wecom', displayName: channel.displayName, webhookUrl: '', signingSecret: '', enabled: true, eventKinds: channel.eventKinds.filter((kind: string) => !kind.startsWith('sms.')) }) + const pending = async () => (await get('notification-channels')).channels[0].pendingCount as number + + const candidates = (await get('modem-candidates')).candidates + expect(candidates).toHaveLength(2) + const modems = [] + for (const candidate of candidates) modems.push(await post('modems', { candidateId: candidate.candidateId })) + await expect.poll(pending, { timeout: 15_000 }).toBe(2) + const lineCandidates = (await get('line-candidates')).candidates + const lines = [] + for (const [index, candidate] of lineCandidates.entries()) lines.push(await post('lines', { candidateId: candidate.candidateId, displayName: `Browser Line ${index + 1}` })) + expect(lines).toHaveLength(2) + await page.goto('/lines') + await expect(page.getByText('Browser Line 1', { exact: true })).toBeVisible() + await expect(page.getByText('Browser Line 2', { exact: true })).toBeVisible() + await put(`lines/${lines[0].id}/egress`, { mode: 'direct', countryCode: '' }) + await post(`vowifi-lines/${lines[0].id}/activate`, {}) + await expect.poll(pending).toBe(3) + await post(`vowifi-lines/${lines[0].id}/deactivate`, {}) + await post(`vowifi-lines/${lines[0].id}/activate`, {}) + await expect.poll(pending).toBe(5) + await put(`modems/${modems[0].id}/rf-state`, { enabled: false }) + await expect.poll(pending, { timeout: 15_000 }).toBe(6) + await put(`modems/${modems[0].id}/rf-state`, { enabled: true }) + await expect.poll(pending, { timeout: 15_000 }).toBe(7) + // Stop IMS before native-SMS simulation; transport selection remains explicit. + await post(`vowifi-lines/${lines[0].id}/deactivate`, {}) + + await page.goto('/messages') + await page.getByRole('button', { name: /新建短信/ }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('combobox', { name: /收件人/ }).fill('+12025550123') + await dialog.getByRole('button', { name: /确\s*定|OK/ }).click() + await page.getByLabel('短信内容').fill('Browser Simulator outbound') + await page.getByRole('button', { name: /发送短信/ }).click() + await expect(page.getByLabel('短信记录').getByText('Browser Simulator outbound', { exact: true })).toBeVisible() + await expect(page.getByText('已发送', { exact: true }).first()).toBeVisible() + + await page.goto('/calls') + await page.getByRole('button', { name: '模拟来电' }).click() + await page.getByRole('button', { name: '接听', exact: true }).click() + await expect(page.getByText('通话中', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '挂断', exact: true }).click() + await expect(page.getByText('已结束', { exact: true })).toBeVisible() + await expect.poll(pending).toBeGreaterThanOrEqual(9) + // The local rejecting proxy keeps delivery pending; disabling cancels it. + await put(`notification-channels/${channel.id}`, { provider: 'wecom', displayName: channel.displayName, webhookUrl: '', signingSecret: '', enabled: false, eventKinds: channel.eventKinds }) + await expect.poll(pending).toBe(0) + await page.goto('/notifications') + await expect(page.getByRole('cell', { name: '0 / 0', exact: true })).toBeVisible() +}) diff --git a/web/e2e/app.spec.ts b/web/e2e/app.spec.ts index f4f962a..6b1158a 100644 --- a/web/e2e/app.spec.ts +++ b/web/e2e/app.spec.ts @@ -147,7 +147,7 @@ async function installApi(page: Page, authenticated = false) { id: 'channel_AAAAAAAAAAAAAAAAAAAAAA', provider: 'feishu', deliveryMode: 'feishu_app', targetType: 'authorized_user', displayName: 'Synthetic Feishu DM', webhookHint: 'open.feishu.cn', signingSecretConfigured: false, enabled: true, eventKinds: ['sms.received', 'sms.failed', 'call.incoming', 'call.missed', 'system.degraded'], - lastDeliveryAt: '2026-08-11T00:00:00Z', lastDeliveryStatus: 'success', lastErrorCode: '', + lastDeliveryAt: '2026-08-11T00:00:00Z', pendingCount: 0, failedCount: 0, lastDeliveryStatus: 'success', lastErrorCode: '', }] }) if (path === '/api/v1/message-conversations') return json(route, { conversations: [{ diff --git a/web/package.json b/web/package.json index 0c00815..08f86e9 100644 --- a/web/package.json +++ b/web/package.json @@ -10,8 +10,9 @@ "generate:api": "openapi-ts", "test": "vitest run", "typecheck": "tsc --noEmit", - "build": "pnpm run generate:api && tsc --noEmit && vite build", - "e2e": "playwright test" + "build": "tsc --noEmit && vite build", + "e2e": "playwright test", + "e2e:simulator": "playwright test --config=playwright.simulator.config.ts" }, "dependencies": { "@ant-design/icons": "^6.3.2", diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 3c9014e..87b9598 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './e2e', fullyParallel: false, + workers: 2, + timeout: 60_000, retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? 'github' : 'list', use: { diff --git a/web/playwright.simulator.config.ts b/web/playwright.simulator.config.ts new file mode 100644 index 0000000..ed55e03 --- /dev/null +++ b/web/playwright.simulator.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e-simulator', + workers: 1, + retries: 0, + timeout: 60_000, + use: { ...devices['Desktop Chrome'], baseURL: 'http://127.0.0.1:4183', trace: 'retain-on-failure' }, + webServer: { + command: 'bash ../scripts/dev/browser-simulator.sh', + url: 'http://127.0.0.1:4183/api/v1/system/health', + reuseExistingServer: false, + timeout: 180_000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 40_000 }, + }, +}) diff --git a/web/src/api/setupClient.ts b/web/src/api/configureClient.ts similarity index 82% rename from web/src/api/setupClient.ts rename to web/src/api/configureClient.ts index 3c0634b..a68225f 100644 --- a/web/src/api/setupClient.ts +++ b/web/src/api/configureClient.ts @@ -20,10 +20,7 @@ export function configureApiClient() { reference: apiError?.reference, }) const path = request ? new URL(request.url).pathname : '' - // Setup authorization uses a separate HttpOnly cookie, while a rejected - // login is not an expired administrator session. Their expected 401s - // must not clear private state or trigger route recovery. - const expectedUnauthorized = path.startsWith('/api/v1/setup/') || path === '/api/v1/auth/login' + const expectedUnauthorized = path === '/api/v1/auth/login' if (response.status === 401 && !expectedUnauthorized) notifySessionExpired() return normalized } diff --git a/web/src/api/generated/@tanstack/react-query.gen.ts b/web/src/api/generated/@tanstack/react-query.gen.ts index fbec3ff..e2dec02 100644 --- a/web/src/api/generated/@tanstack/react-query.gen.ts +++ b/web/src/api/generated/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { activateEuiccProfile, activateVoWiFiLine, addManagedLine, addManagedModem, cancelFeishuNotificationBinding, changeAdministratorPassword, completeSetup, confirmSetupHardware, confirmSetupHttps, consumeSetupBootstrap, controlCall, createContact, createMihomoSubscription, createNotificationChannel, deactivateVoWiFiLine, deleteContact, deleteMessage, deleteMihomoSubscription, deleteNotificationChannel, dialCall, getAuthSession, getEuiccState, getFeishuNotificationBinding, getHardwareTopology, getInventory, getLatestMihomoCore, getMihomoConfigStatus, getMihomoCoreStatus, getMihomoDashboardStatus, getMihomoRuntimeStatus, getSetupHardwareTopology, getSetupHttpsRootCertificate, getSetupInventory, getSetupSession, getSetupStatus, getSystemHealth, installLatestMihomoCore, listCalls, listContacts, listLineCandidates, listLineEgressBindings, listManagedLines, listManagedModems, listMessageConversations, listMessages, listMihomoSubscriptionNodes, listMihomoSubscriptions, listModemCandidates, listNotificationChannels, listVoWiFiLines, login, logout, markMessageConversationRead, type Options, publishMihomoConfig, putLineEgressBinding, putSetupAdministrator, putSetupHttps, putSetupStorage, readManagedModemEquipmentIdentity, refreshMihomoSubscription, restartMihomo, selectMihomoSubscription, sendMessage, setManagedModemRfState, simulateIncomingCall, startFeishuNotificationBinding, startMihomo, stopMihomo, testNotificationChannel, updateContact, updateManagedLine, updateMihomoSubscription, updateNotificationChannel } from '../sdk.gen'; -import type { ActivateEuiccProfileData, ActivateEuiccProfileError, ActivateEuiccProfileResponse, ActivateVoWiFiLineData, ActivateVoWiFiLineError, ActivateVoWiFiLineResponse, AddManagedLineData, AddManagedLineError, AddManagedLineResponse, AddManagedModemData, AddManagedModemError, AddManagedModemResponse, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingError, CancelFeishuNotificationBindingResponse, ChangeAdministratorPasswordData, ChangeAdministratorPasswordError, ChangeAdministratorPasswordResponse, CompleteSetupData, CompleteSetupError, CompleteSetupResponse, ConfirmSetupHardwareData, ConfirmSetupHardwareError, ConfirmSetupHardwareResponse, ConfirmSetupHttpsData, ConfirmSetupHttpsError, ConfirmSetupHttpsResponse, ConsumeSetupBootstrapData, ConsumeSetupBootstrapError, ConsumeSetupBootstrapResponse, ControlCallData, ControlCallError, ControlCallResponse, CreateContactData, CreateContactError, CreateContactResponse, CreateMihomoSubscriptionData, CreateMihomoSubscriptionError, CreateMihomoSubscriptionResponse, CreateNotificationChannelData, CreateNotificationChannelError, CreateNotificationChannelResponse, DeactivateVoWiFiLineData, DeactivateVoWiFiLineError, DeactivateVoWiFiLineResponse, DeleteContactData, DeleteContactError, DeleteContactResponse, DeleteMessageData, DeleteMessageError, DeleteMessageResponse, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionError, DeleteMihomoSubscriptionResponse, DeleteNotificationChannelData, DeleteNotificationChannelError, DeleteNotificationChannelResponse, DialCallData, DialCallError, DialCallResponse, GetAuthSessionData, GetAuthSessionError, GetAuthSessionResponse, GetEuiccStateData, GetEuiccStateError, GetEuiccStateResponse, GetFeishuNotificationBindingData, GetFeishuNotificationBindingError, GetFeishuNotificationBindingResponse, GetHardwareTopologyData, GetHardwareTopologyError, GetHardwareTopologyResponse, GetInventoryData, GetInventoryError, GetInventoryResponse, GetLatestMihomoCoreData, GetLatestMihomoCoreError, GetLatestMihomoCoreResponse, GetMihomoConfigStatusData, GetMihomoConfigStatusError, GetMihomoConfigStatusResponse, GetMihomoCoreStatusData, GetMihomoCoreStatusError, GetMihomoCoreStatusResponse, GetMihomoDashboardStatusData, GetMihomoDashboardStatusError, GetMihomoDashboardStatusResponse, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponse, GetSetupHardwareTopologyData, GetSetupHardwareTopologyError, GetSetupHardwareTopologyResponse, GetSetupHttpsRootCertificateData, GetSetupHttpsRootCertificateError, GetSetupHttpsRootCertificateResponse, GetSetupInventoryData, GetSetupInventoryError, GetSetupInventoryResponse, GetSetupSessionData, GetSetupSessionError, GetSetupSessionResponse, GetSetupStatusData, GetSetupStatusError, GetSetupStatusResponse, GetSystemHealthData, GetSystemHealthError, GetSystemHealthResponse, InstallLatestMihomoCoreData, InstallLatestMihomoCoreError, InstallLatestMihomoCoreResponse, ListCallsData, ListCallsError, ListCallsResponse, ListContactsData, ListContactsError, ListContactsResponse, ListLineCandidatesData, ListLineCandidatesError, ListLineCandidatesResponse, ListLineEgressBindingsData, ListLineEgressBindingsResponse, ListManagedLinesData, ListManagedLinesError, ListManagedLinesResponse, ListManagedModemsData, ListManagedModemsError, ListManagedModemsResponse, ListMessageConversationsData, ListMessageConversationsError, ListMessageConversationsResponse, ListMessagesData, ListMessagesError, ListMessagesResponse, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesError, ListMihomoSubscriptionNodesResponse, ListMihomoSubscriptionsData, ListMihomoSubscriptionsError, ListMihomoSubscriptionsResponse, ListModemCandidatesData, ListModemCandidatesError, ListModemCandidatesResponse, ListNotificationChannelsData, ListNotificationChannelsResponse, ListVoWiFiLinesData, ListVoWiFiLinesError, ListVoWiFiLinesResponse, LoginData, LoginError, LoginResponse, LogoutData, LogoutError, LogoutResponse, MarkMessageConversationReadData, MarkMessageConversationReadError, MarkMessageConversationReadResponse, PublishMihomoConfigData, PublishMihomoConfigError, PublishMihomoConfigResponse, PutLineEgressBindingData, PutLineEgressBindingError, PutLineEgressBindingResponse, PutSetupAdministratorData, PutSetupAdministratorError, PutSetupAdministratorResponse, PutSetupHttpsData, PutSetupHttpsError, PutSetupHttpsResponse, PutSetupStorageData, PutSetupStorageError, PutSetupStorageResponse, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityError, ReadManagedModemEquipmentIdentityResponse, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionError, RefreshMihomoSubscriptionResponse, RestartMihomoData, RestartMihomoError, RestartMihomoResponse, SelectMihomoSubscriptionData, SelectMihomoSubscriptionError, SelectMihomoSubscriptionResponse, SendMessageData, SendMessageError, SendMessageResponse, SetManagedModemRfStateData, SetManagedModemRfStateError, SetManagedModemRfStateResponse, SimulateIncomingCallData, SimulateIncomingCallError, SimulateIncomingCallResponse, StartFeishuNotificationBindingData, StartFeishuNotificationBindingError, StartFeishuNotificationBindingResponse, StartMihomoData, StartMihomoError, StartMihomoResponse, StopMihomoData, StopMihomoError, StopMihomoResponse, TestNotificationChannelData, TestNotificationChannelError, TestNotificationChannelResponse, UpdateContactData, UpdateContactError, UpdateContactResponse, UpdateManagedLineData, UpdateManagedLineError, UpdateManagedLineResponse, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionError, UpdateMihomoSubscriptionResponse, UpdateNotificationChannelData, UpdateNotificationChannelError, UpdateNotificationChannelResponse } from '../types.gen'; +import { activateEuiccProfile, activateVoWiFiLine, addManagedLine, addManagedModem, cancelFeishuNotificationBinding, changeAdministratorPassword, controlCall, createContact, createMihomoSubscription, createNotificationChannel, deactivateVoWiFiLine, deleteContact, deleteMessage, deleteMihomoSubscription, deleteNotificationChannel, dialCall, getAuthSession, getEuiccState, getFeishuNotificationBinding, getHardwareTopology, getInventory, getLatestMihomoCore, getMihomoConfigStatus, getMihomoCoreStatus, getMihomoDashboardStatus, getMihomoRuntimeStatus, getSystemHealth, installLatestMihomoCore, listCalls, listContacts, listLineCandidates, listLineEgressBindings, listManagedLines, listManagedModems, listMessageConversations, listMessages, listMihomoSubscriptionNodes, listMihomoSubscriptions, listModemCandidates, listNotificationChannels, listVoWiFiLines, login, logout, markMessageConversationRead, type Options, publishMihomoConfig, putLineEgressBinding, readManagedModemEquipmentIdentity, refreshMihomoSubscription, restartMihomo, selectMihomoSubscription, sendMessage, setManagedModemRfState, simulateIncomingCall, startFeishuNotificationBinding, startMihomo, stopMihomo, testNotificationChannel, updateContact, updateManagedLine, updateMihomoSubscription, updateNotificationChannel } from '../sdk.gen'; +import type { ActivateEuiccProfileData, ActivateEuiccProfileError, ActivateEuiccProfileResponse, ActivateVoWiFiLineData, ActivateVoWiFiLineError, ActivateVoWiFiLineResponse, AddManagedLineData, AddManagedLineError, AddManagedLineResponse, AddManagedModemData, AddManagedModemError, AddManagedModemResponse, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingError, CancelFeishuNotificationBindingResponse, ChangeAdministratorPasswordData, ChangeAdministratorPasswordError, ChangeAdministratorPasswordResponse, ControlCallData, ControlCallError, ControlCallResponse, CreateContactData, CreateContactError, CreateContactResponse, CreateMihomoSubscriptionData, CreateMihomoSubscriptionError, CreateMihomoSubscriptionResponse, CreateNotificationChannelData, CreateNotificationChannelError, CreateNotificationChannelResponse, DeactivateVoWiFiLineData, DeactivateVoWiFiLineError, DeactivateVoWiFiLineResponse, DeleteContactData, DeleteContactError, DeleteContactResponse, DeleteMessageData, DeleteMessageError, DeleteMessageResponse, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionError, DeleteMihomoSubscriptionResponse, DeleteNotificationChannelData, DeleteNotificationChannelError, DeleteNotificationChannelResponse, DialCallData, DialCallError, DialCallResponse, GetAuthSessionData, GetAuthSessionError, GetAuthSessionResponse, GetEuiccStateData, GetEuiccStateError, GetEuiccStateResponse, GetFeishuNotificationBindingData, GetFeishuNotificationBindingError, GetFeishuNotificationBindingResponse, GetHardwareTopologyData, GetHardwareTopologyError, GetHardwareTopologyResponse, GetInventoryData, GetInventoryError, GetInventoryResponse, GetLatestMihomoCoreData, GetLatestMihomoCoreError, GetLatestMihomoCoreResponse, GetMihomoConfigStatusData, GetMihomoConfigStatusError, GetMihomoConfigStatusResponse, GetMihomoCoreStatusData, GetMihomoCoreStatusError, GetMihomoCoreStatusResponse, GetMihomoDashboardStatusData, GetMihomoDashboardStatusError, GetMihomoDashboardStatusResponse, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponse, GetSystemHealthData, GetSystemHealthError, GetSystemHealthResponse, InstallLatestMihomoCoreData, InstallLatestMihomoCoreError, InstallLatestMihomoCoreResponse, ListCallsData, ListCallsError, ListCallsResponse, ListContactsData, ListContactsError, ListContactsResponse, ListLineCandidatesData, ListLineCandidatesError, ListLineCandidatesResponse, ListLineEgressBindingsData, ListLineEgressBindingsResponse, ListManagedLinesData, ListManagedLinesError, ListManagedLinesResponse, ListManagedModemsData, ListManagedModemsError, ListManagedModemsResponse, ListMessageConversationsData, ListMessageConversationsError, ListMessageConversationsResponse, ListMessagesData, ListMessagesError, ListMessagesResponse, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesError, ListMihomoSubscriptionNodesResponse, ListMihomoSubscriptionsData, ListMihomoSubscriptionsError, ListMihomoSubscriptionsResponse, ListModemCandidatesData, ListModemCandidatesError, ListModemCandidatesResponse, ListNotificationChannelsData, ListNotificationChannelsResponse, ListVoWiFiLinesData, ListVoWiFiLinesError, ListVoWiFiLinesResponse, LoginData, LoginError, LoginResponse, LogoutData, LogoutError, LogoutResponse, MarkMessageConversationReadData, MarkMessageConversationReadError, MarkMessageConversationReadResponse, PublishMihomoConfigData, PublishMihomoConfigError, PublishMihomoConfigResponse, PutLineEgressBindingData, PutLineEgressBindingError, PutLineEgressBindingResponse, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityError, ReadManagedModemEquipmentIdentityResponse, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionError, RefreshMihomoSubscriptionResponse, RestartMihomoData, RestartMihomoError, RestartMihomoResponse, SelectMihomoSubscriptionData, SelectMihomoSubscriptionError, SelectMihomoSubscriptionResponse, SendMessageData, SendMessageError, SendMessageResponse, SetManagedModemRfStateData, SetManagedModemRfStateError, SetManagedModemRfStateResponse, SimulateIncomingCallData, SimulateIncomingCallError, SimulateIncomingCallResponse, StartFeishuNotificationBindingData, StartFeishuNotificationBindingError, StartFeishuNotificationBindingResponse, StartMihomoData, StartMihomoError, StartMihomoResponse, StopMihomoData, StopMihomoError, StopMihomoResponse, TestNotificationChannelData, TestNotificationChannelError, TestNotificationChannelResponse, UpdateContactData, UpdateContactError, UpdateContactResponse, UpdateManagedLineData, UpdateManagedLineError, UpdateManagedLineResponse, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionError, UpdateMihomoSubscriptionResponse, UpdateNotificationChannelData, UpdateNotificationChannelError, UpdateNotificationChannelResponse } from '../types.gen'; /** * Create an administrator browser session @@ -1199,215 +1199,6 @@ export const readManagedModemEquipmentIdentityMutation = (options?: Partial) => createQueryKey('getSetupStatus', options, false, ['setup']); - -/** - * Read the first-run boundary without opening normal business APIs - */ -export const getSetupStatusOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getSetupStatus({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getSetupStatusQueryKey(options) -}); - -/** - * Exchange a one-time root-generated bootstrap code for a restricted setup session - */ -export const consumeSetupBootstrapMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await consumeSetupBootstrap({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -export const getSetupSessionQueryKey = (options?: Options) => createQueryKey('getSetupSession', options, false, ['setup']); - -/** - * Resume the restricted first-run session from its HttpOnly cookie - */ -export const getSetupSessionOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getSetupSession({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getSetupSessionQueryKey(options) -}); - -/** - * Create or replace the sole administrator while first-run setup remains uninitialized - */ -export const putSetupAdministratorMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await putSetupAdministrator({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Validate and persist the data and recording roots for a new instance - */ -export const putSetupStorageMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await putSetupStorage({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Configure loopback management or generate a local-CA HTTPS candidate - */ -export const putSetupHttpsMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await putSetupHttps({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Confirm the displayed SHA-256 local root fingerprint - */ -export const confirmSetupHttpsMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await confirmSetupHttps({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -export const getSetupHttpsRootCertificateQueryKey = (options?: Options) => createQueryKey('getSetupHttpsRootCertificate', options, false, ['setup']); - -/** - * Read the public local root certificate for explicit trust installation - */ -export const getSetupHttpsRootCertificateOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getSetupHttpsRootCertificate({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getSetupHttpsRootCertificateQueryKey(options) -}); - -export const getSetupInventoryQueryKey = (options?: Options) => createQueryKey('getSetupInventory', options, false, ['setup']); - -/** - * Read the restricted HIL-0 inventory while the instance remains RF Off - */ -export const getSetupInventoryOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getSetupInventory({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getSetupInventoryQueryKey(options) -}); - -export const getSetupHardwareTopologyQueryKey = (options?: Options) => createQueryKey('getSetupHardwareTopology', options, false, ['setup', 'inventory']); - -/** - * Read the RF-Off normalized hardware topology during first-run setup - */ -export const getSetupHardwareTopologyOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await getSetupHardwareTopology({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: getSetupHardwareTopologyQueryKey(options) -}); - -/** - * Confirm the current read-only hardware inventory - */ -export const confirmSetupHardwareMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await confirmSetupHardware({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - -/** - * Revalidate every setup prerequisite and atomically enter ready - */ -export const completeSetupMutation = (options?: Partial>): UseMutationOptions> => { - const mutationOptions: UseMutationOptions> = { - mutationFn: async (fnOptions) => { - const { data } = await completeSetup({ - ...options, - ...fnOptions, - throwOnError: true - }); - return data; - } - }; - return mutationOptions; -}; - export const getSystemHealthQueryKey = (options?: Options) => createQueryKey('getSystemHealth', options, false, ['system']); /** diff --git a/web/src/api/generated/index.ts b/web/src/api/generated/index.ts index ff0499f..68c7526 100644 --- a/web/src/api/generated/index.ts +++ b/web/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { activateEuiccProfile, activateVoWiFiLine, addManagedLine, addManagedModem, cancelFeishuNotificationBinding, changeAdministratorPassword, completeSetup, confirmSetupHardware, confirmSetupHttps, consumeSetupBootstrap, controlCall, createContact, createMihomoSubscription, createNotificationChannel, deactivateVoWiFiLine, deleteContact, deleteMessage, deleteMihomoSubscription, deleteNotificationChannel, dialCall, getAuthSession, getEuiccState, getFeishuNotificationBinding, getHardwareTopology, getInventory, getLatestMihomoCore, getMihomoConfigStatus, getMihomoCoreStatus, getMihomoDashboardStatus, getMihomoRuntimeStatus, getSetupHardwareTopology, getSetupHttpsRootCertificate, getSetupInventory, getSetupSession, getSetupStatus, getSystemHealth, installLatestMihomoCore, listCalls, listContacts, listLineCandidates, listLineEgressBindings, listManagedLines, listManagedModems, listMessageConversations, listMessages, listMihomoSubscriptionNodes, listMihomoSubscriptions, listModemCandidates, listNotificationChannels, listVoWiFiLines, login, logout, markMessageConversationRead, type Options, publishMihomoConfig, putLineEgressBinding, putSetupAdministrator, putSetupHttps, putSetupStorage, readManagedModemEquipmentIdentity, refreshMihomoSubscription, restartMihomo, selectMihomoSubscription, sendMessage, setManagedModemRfState, simulateIncomingCall, startFeishuNotificationBinding, startMihomo, stopMihomo, streamEvents, testNotificationChannel, updateContact, updateManagedLine, updateMihomoSubscription, updateNotificationChannel } from './sdk.gen'; -export type { ActivateEuiccProfileData, ActivateEuiccProfileError, ActivateEuiccProfileErrors, ActivateEuiccProfileResponse, ActivateEuiccProfileResponses, ActivateVoWiFiLineData, ActivateVoWiFiLineError, ActivateVoWiFiLineErrors, ActivateVoWiFiLineResponse, ActivateVoWiFiLineResponses, AddManagedLineData, AddManagedLineError, AddManagedLineErrors, AddManagedLineRequest, AddManagedLineResponse, AddManagedLineResponses, AddManagedModemData, AddManagedModemError, AddManagedModemErrors, AddManagedModemRequest, AddManagedModemResponse, AddManagedModemResponses, ApiError, AuthSessionResponse, BackendKind, Call, CallActionRequest, CallListResponse, CallStartRequest, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingError, CancelFeishuNotificationBindingErrors, CancelFeishuNotificationBindingResponse, CancelFeishuNotificationBindingResponses, CellularErrorCode, CellularRegistration, CellularRegistrationDomain, CellularRegistrationState, CellularSignalState, CellularState, ChangeAdministratorPasswordData, ChangeAdministratorPasswordError, ChangeAdministratorPasswordErrors, ChangeAdministratorPasswordRequest, ChangeAdministratorPasswordResponse, ChangeAdministratorPasswordResponses, ClientOptions, CompleteSetupData, CompleteSetupError, CompleteSetupErrors, CompleteSetupResponse, CompleteSetupResponses, ConfigureSetupAdministratorRequest, ConfigureSetupHttpsRequest, ConfigureSetupStorageRequest, ConfirmSetupHardwareData, ConfirmSetupHardwareError, ConfirmSetupHardwareErrors, ConfirmSetupHardwareResponse, ConfirmSetupHardwareResponses, ConfirmSetupHttpsData, ConfirmSetupHttpsError, ConfirmSetupHttpsErrors, ConfirmSetupHttpsRequest, ConfirmSetupHttpsResponse, ConfirmSetupHttpsResponses, ConsumeBootstrapRequest, ConsumeSetupBootstrapData, ConsumeSetupBootstrapError, ConsumeSetupBootstrapErrors, ConsumeSetupBootstrapResponse, ConsumeSetupBootstrapResponses, Contact, ContactListResponse, ContactMutationRequest, ControlCallData, ControlCallError, ControlCallErrors, ControlCallResponse, ControlCallResponses, CreateContactData, CreateContactError, CreateContactErrors, CreateContactResponse, CreateContactResponses, CreateMihomoSubscriptionData, CreateMihomoSubscriptionError, CreateMihomoSubscriptionErrors, CreateMihomoSubscriptionResponse, CreateMihomoSubscriptionResponses, CreateNotificationChannelData, CreateNotificationChannelError, CreateNotificationChannelErrors, CreateNotificationChannelResponse, CreateNotificationChannelResponses, DeactivateVoWiFiLineData, DeactivateVoWiFiLineError, DeactivateVoWiFiLineErrors, DeactivateVoWiFiLineResponse, DeactivateVoWiFiLineResponses, DeleteContactData, DeleteContactError, DeleteContactErrors, DeleteContactResponse, DeleteContactResponses, DeleteMessageData, DeleteMessageError, DeleteMessageErrors, DeleteMessageResponse, DeleteMessageResponses, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionError, DeleteMihomoSubscriptionErrors, DeleteMihomoSubscriptionResponse, DeleteMihomoSubscriptionResponses, DeleteNotificationChannelData, DeleteNotificationChannelError, DeleteNotificationChannelErrors, DeleteNotificationChannelResponse, DeleteNotificationChannelResponses, DeviceTransport, DialCallData, DialCallError, DialCallErrors, DialCallResponse, DialCallResponses, EuiccProfile, EuiccState, FeishuNotificationBinding, GetAuthSessionData, GetAuthSessionError, GetAuthSessionErrors, GetAuthSessionResponse, GetAuthSessionResponses, GetEuiccStateData, GetEuiccStateError, GetEuiccStateErrors, GetEuiccStateResponse, GetEuiccStateResponses, GetFeishuNotificationBindingData, GetFeishuNotificationBindingError, GetFeishuNotificationBindingErrors, GetFeishuNotificationBindingResponse, GetFeishuNotificationBindingResponses, GetHardwareTopologyData, GetHardwareTopologyError, GetHardwareTopologyErrors, GetHardwareTopologyResponse, GetHardwareTopologyResponses, GetInventoryData, GetInventoryError, GetInventoryErrors, GetInventoryResponse, GetInventoryResponses, GetLatestMihomoCoreData, GetLatestMihomoCoreError, GetLatestMihomoCoreErrors, GetLatestMihomoCoreResponse, GetLatestMihomoCoreResponses, GetMihomoConfigStatusData, GetMihomoConfigStatusError, GetMihomoConfigStatusErrors, GetMihomoConfigStatusResponse, GetMihomoConfigStatusResponses, GetMihomoCoreStatusData, GetMihomoCoreStatusError, GetMihomoCoreStatusErrors, GetMihomoCoreStatusResponse, GetMihomoCoreStatusResponses, GetMihomoDashboardStatusData, GetMihomoDashboardStatusError, GetMihomoDashboardStatusErrors, GetMihomoDashboardStatusResponse, GetMihomoDashboardStatusResponses, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponse, GetMihomoRuntimeStatusResponses, GetSetupHardwareTopologyData, GetSetupHardwareTopologyError, GetSetupHardwareTopologyErrors, GetSetupHardwareTopologyResponse, GetSetupHardwareTopologyResponses, GetSetupHttpsRootCertificateData, GetSetupHttpsRootCertificateError, GetSetupHttpsRootCertificateErrors, GetSetupHttpsRootCertificateResponse, GetSetupHttpsRootCertificateResponses, GetSetupInventoryData, GetSetupInventoryError, GetSetupInventoryErrors, GetSetupInventoryResponse, GetSetupInventoryResponses, GetSetupSessionData, GetSetupSessionError, GetSetupSessionErrors, GetSetupSessionResponse, GetSetupSessionResponses, GetSetupStatusData, GetSetupStatusError, GetSetupStatusErrors, GetSetupStatusResponse, GetSetupStatusResponses, GetSystemHealthData, GetSystemHealthError, GetSystemHealthErrors, GetSystemHealthResponse, GetSystemHealthResponses, HardwareBackend, HardwareCapabilities, HardwareGeneration, HardwareLineDetail, HardwareTopologyResponse, HealthResponse, HealthStatus, InstallationState, InstallLatestMihomoCoreData, InstallLatestMihomoCoreError, InstallLatestMihomoCoreErrors, InstallLatestMihomoCoreResponse, InstallLatestMihomoCoreResponses, InventoryResponse, LineCandidate, LineCandidateList, LineEgressBinding, LineEgressBindingList, LineEgressBindingMutation, LineState, LineSummary, ListCallsData, ListCallsError, ListCallsErrors, ListCallsResponse, ListCallsResponses, ListContactsData, ListContactsError, ListContactsErrors, ListContactsResponse, ListContactsResponses, ListLineCandidatesData, ListLineCandidatesError, ListLineCandidatesErrors, ListLineCandidatesResponse, ListLineCandidatesResponses, ListLineEgressBindingsData, ListLineEgressBindingsResponse, ListLineEgressBindingsResponses, ListManagedLinesData, ListManagedLinesError, ListManagedLinesErrors, ListManagedLinesResponse, ListManagedLinesResponses, ListManagedModemsData, ListManagedModemsError, ListManagedModemsErrors, ListManagedModemsResponse, ListManagedModemsResponses, ListMessageConversationsData, ListMessageConversationsError, ListMessageConversationsErrors, ListMessageConversationsResponse, ListMessageConversationsResponses, ListMessagesData, ListMessagesError, ListMessagesErrors, ListMessagesResponse, ListMessagesResponses, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesError, ListMihomoSubscriptionNodesErrors, ListMihomoSubscriptionNodesResponse, ListMihomoSubscriptionNodesResponses, ListMihomoSubscriptionsData, ListMihomoSubscriptionsError, ListMihomoSubscriptionsErrors, ListMihomoSubscriptionsResponse, ListMihomoSubscriptionsResponses, ListModemCandidatesData, ListModemCandidatesError, ListModemCandidatesErrors, ListModemCandidatesResponse, ListModemCandidatesResponses, ListNotificationChannelsData, ListNotificationChannelsResponse, ListNotificationChannelsResponses, ListVoWiFiLinesData, ListVoWiFiLinesError, ListVoWiFiLinesErrors, ListVoWiFiLinesResponse, ListVoWiFiLinesResponses, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutError, LogoutErrors, LogoutResponse, LogoutResponses, ManagedLine, ManagedLineList, ManagedLineState, ManagedModem, ManagedModemCellularStatus, ManagedModemEquipmentIdentity, ManagedModemList, ManagedModemRfState, ManagedModemSimPresence, ManagedModemState, MarkMessageConversationReadData, MarkMessageConversationReadError, MarkMessageConversationReadErrors, MarkMessageConversationReadResponse, MarkMessageConversationReadResponses, MarkSmsConversationReadRequest, MihomoConfigStatus, MihomoCoreCandidate, MihomoCoreStatus, MihomoDashboardStatus, MihomoNode, MihomoNodeList, MihomoRuntimeStatus, MihomoSubscription, MihomoSubscriptionCreateRequest, MihomoSubscriptionList, MihomoSubscriptionMutation, MihomoSubscriptionRefresh, ModemCandidate, ModemCandidateList, ModemCandidateReadinessReason, ModemFunctionDetail, ModemSupportStatus, NotificationChannel, NotificationChannelList, NotificationChannelMutation, NotificationEventKind, PageCursor, PageLimit, PhoneNumberObservation, PhoneNumberSource, PhysicalDeviceDetail, PhysicalDeviceState, PhysicalDeviceSummary, PublishMihomoConfigData, PublishMihomoConfigError, PublishMihomoConfigErrors, PublishMihomoConfigResponse, PublishMihomoConfigResponses, PutLineEgressBindingData, PutLineEgressBindingError, PutLineEgressBindingErrors, PutLineEgressBindingResponse, PutLineEgressBindingResponses, PutSetupAdministratorData, PutSetupAdministratorError, PutSetupAdministratorErrors, PutSetupAdministratorResponse, PutSetupAdministratorResponses, PutSetupHttpsData, PutSetupHttpsError, PutSetupHttpsErrors, PutSetupHttpsResponse, PutSetupHttpsResponses, PutSetupStorageData, PutSetupStorageError, PutSetupStorageErrors, PutSetupStorageResponse, PutSetupStorageResponses, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityError, ReadManagedModemEquipmentIdentityErrors, ReadManagedModemEquipmentIdentityResponse, ReadManagedModemEquipmentIdentityResponses, RealtimeAttention, RealtimeEvent, RealtimeTopic, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionError, RefreshMihomoSubscriptionErrors, RefreshMihomoSubscriptionResponse, RefreshMihomoSubscriptionResponses, ResourceGroupDetail, ResourceKind, RestartMihomoData, RestartMihomoError, RestartMihomoErrors, RestartMihomoResponse, RestartMihomoResponses, SelectMihomoSubscriptionData, SelectMihomoSubscriptionError, SelectMihomoSubscriptionErrors, SelectMihomoSubscriptionResponse, SelectMihomoSubscriptionResponses, SendMessageData, SendMessageError, SendMessageErrors, SendMessageResponse, SendMessageResponses, SendSmsRequest, SetManagedModemRfStateData, SetManagedModemRfStateError, SetManagedModemRfStateErrors, SetManagedModemRfStateRequest, SetManagedModemRfStateResponse, SetManagedModemRfStateResponses, SetupCompletionResponse, SetupFlow, SetupHttpsMode, SetupPhase, SetupRootCertificateResponse, SetupSessionResponse, SetupStatusResponse, SimIdentityState, SimMediaDetail, SimMediaKind, SimSlotDetail, SimSlotPresence, SimulateIncomingCallData, SimulateIncomingCallError, SimulateIncomingCallErrors, SimulateIncomingCallResponse, SimulateIncomingCallResponses, SmsConversationListResponse, SmsConversationSummary, SmsDirection, SmsMessage, SmsMessageListResponse, SmsStatus, StartFeishuNotificationBindingData, StartFeishuNotificationBindingError, StartFeishuNotificationBindingErrors, StartFeishuNotificationBindingResponse, StartFeishuNotificationBindingResponses, StartMihomoData, StartMihomoError, StartMihomoErrors, StartMihomoResponse, StartMihomoResponses, StopMihomoData, StopMihomoError, StopMihomoErrors, StopMihomoResponse, StopMihomoResponses, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, SubscriptionProfileDetail, SubscriptionProfileState, TestNotificationChannelData, TestNotificationChannelError, TestNotificationChannelErrors, TestNotificationChannelResponse, TestNotificationChannelResponses, TopologyRevision, UpdateContactData, UpdateContactError, UpdateContactErrors, UpdateContactResponse, UpdateContactResponses, UpdateManagedLineData, UpdateManagedLineError, UpdateManagedLineErrors, UpdateManagedLineRequest, UpdateManagedLineResponse, UpdateManagedLineResponses, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionError, UpdateMihomoSubscriptionErrors, UpdateMihomoSubscriptionResponse, UpdateMihomoSubscriptionResponses, UpdateNotificationChannelData, UpdateNotificationChannelError, UpdateNotificationChannelErrors, UpdateNotificationChannelResponse, UpdateNotificationChannelResponses, VoWiFiLineState, VoWiFiLineStateList } from './types.gen'; +export { activateEuiccProfile, activateVoWiFiLine, addManagedLine, addManagedModem, cancelFeishuNotificationBinding, changeAdministratorPassword, controlCall, createContact, createMihomoSubscription, createNotificationChannel, deactivateVoWiFiLine, deleteContact, deleteMessage, deleteMihomoSubscription, deleteNotificationChannel, dialCall, getAuthSession, getEuiccState, getFeishuNotificationBinding, getHardwareTopology, getInventory, getLatestMihomoCore, getMihomoConfigStatus, getMihomoCoreStatus, getMihomoDashboardStatus, getMihomoRuntimeStatus, getSystemHealth, installLatestMihomoCore, listCalls, listContacts, listLineCandidates, listLineEgressBindings, listManagedLines, listManagedModems, listMessageConversations, listMessages, listMihomoSubscriptionNodes, listMihomoSubscriptions, listModemCandidates, listNotificationChannels, listVoWiFiLines, login, logout, markMessageConversationRead, type Options, publishMihomoConfig, putLineEgressBinding, readManagedModemEquipmentIdentity, refreshMihomoSubscription, restartMihomo, selectMihomoSubscription, sendMessage, setManagedModemRfState, simulateIncomingCall, startFeishuNotificationBinding, startMihomo, stopMihomo, streamEvents, testNotificationChannel, updateContact, updateManagedLine, updateMihomoSubscription, updateNotificationChannel } from './sdk.gen'; +export type { ActivateEuiccProfileData, ActivateEuiccProfileError, ActivateEuiccProfileErrors, ActivateEuiccProfileResponse, ActivateEuiccProfileResponses, ActivateVoWiFiLineData, ActivateVoWiFiLineError, ActivateVoWiFiLineErrors, ActivateVoWiFiLineResponse, ActivateVoWiFiLineResponses, AddManagedLineData, AddManagedLineError, AddManagedLineErrors, AddManagedLineRequest, AddManagedLineResponse, AddManagedLineResponses, AddManagedModemData, AddManagedModemError, AddManagedModemErrors, AddManagedModemRequest, AddManagedModemResponse, AddManagedModemResponses, ApiError, AuthSessionResponse, BackendKind, Call, CallActionRequest, CallListResponse, CallStartRequest, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingError, CancelFeishuNotificationBindingErrors, CancelFeishuNotificationBindingResponse, CancelFeishuNotificationBindingResponses, CellularErrorCode, CellularRegistration, CellularRegistrationDomain, CellularRegistrationState, CellularSignalState, CellularState, ChangeAdministratorPasswordData, ChangeAdministratorPasswordError, ChangeAdministratorPasswordErrors, ChangeAdministratorPasswordRequest, ChangeAdministratorPasswordResponse, ChangeAdministratorPasswordResponses, ClientOptions, Contact, ContactListResponse, ContactMutationRequest, ControlCallData, ControlCallError, ControlCallErrors, ControlCallResponse, ControlCallResponses, CreateContactData, CreateContactError, CreateContactErrors, CreateContactResponse, CreateContactResponses, CreateMihomoSubscriptionData, CreateMihomoSubscriptionError, CreateMihomoSubscriptionErrors, CreateMihomoSubscriptionResponse, CreateMihomoSubscriptionResponses, CreateNotificationChannelData, CreateNotificationChannelError, CreateNotificationChannelErrors, CreateNotificationChannelResponse, CreateNotificationChannelResponses, DeactivateVoWiFiLineData, DeactivateVoWiFiLineError, DeactivateVoWiFiLineErrors, DeactivateVoWiFiLineResponse, DeactivateVoWiFiLineResponses, DeleteContactData, DeleteContactError, DeleteContactErrors, DeleteContactResponse, DeleteContactResponses, DeleteMessageData, DeleteMessageError, DeleteMessageErrors, DeleteMessageResponse, DeleteMessageResponses, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionError, DeleteMihomoSubscriptionErrors, DeleteMihomoSubscriptionResponse, DeleteMihomoSubscriptionResponses, DeleteNotificationChannelData, DeleteNotificationChannelError, DeleteNotificationChannelErrors, DeleteNotificationChannelResponse, DeleteNotificationChannelResponses, DeviceTransport, DialCallData, DialCallError, DialCallErrors, DialCallResponse, DialCallResponses, EuiccProfile, EuiccState, FeishuNotificationBinding, GetAuthSessionData, GetAuthSessionError, GetAuthSessionErrors, GetAuthSessionResponse, GetAuthSessionResponses, GetEuiccStateData, GetEuiccStateError, GetEuiccStateErrors, GetEuiccStateResponse, GetEuiccStateResponses, GetFeishuNotificationBindingData, GetFeishuNotificationBindingError, GetFeishuNotificationBindingErrors, GetFeishuNotificationBindingResponse, GetFeishuNotificationBindingResponses, GetHardwareTopologyData, GetHardwareTopologyError, GetHardwareTopologyErrors, GetHardwareTopologyResponse, GetHardwareTopologyResponses, GetInventoryData, GetInventoryError, GetInventoryErrors, GetInventoryResponse, GetInventoryResponses, GetLatestMihomoCoreData, GetLatestMihomoCoreError, GetLatestMihomoCoreErrors, GetLatestMihomoCoreResponse, GetLatestMihomoCoreResponses, GetMihomoConfigStatusData, GetMihomoConfigStatusError, GetMihomoConfigStatusErrors, GetMihomoConfigStatusResponse, GetMihomoConfigStatusResponses, GetMihomoCoreStatusData, GetMihomoCoreStatusError, GetMihomoCoreStatusErrors, GetMihomoCoreStatusResponse, GetMihomoCoreStatusResponses, GetMihomoDashboardStatusData, GetMihomoDashboardStatusError, GetMihomoDashboardStatusErrors, GetMihomoDashboardStatusResponse, GetMihomoDashboardStatusResponses, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponse, GetMihomoRuntimeStatusResponses, GetSystemHealthData, GetSystemHealthError, GetSystemHealthErrors, GetSystemHealthResponse, GetSystemHealthResponses, HardwareBackend, HardwareCapabilities, HardwareGeneration, HardwareLineDetail, HardwareTopologyResponse, HealthResponse, HealthStatus, InstallationState, InstallLatestMihomoCoreData, InstallLatestMihomoCoreError, InstallLatestMihomoCoreErrors, InstallLatestMihomoCoreResponse, InstallLatestMihomoCoreResponses, InventoryResponse, LineCandidate, LineCandidateList, LineEgressBinding, LineEgressBindingList, LineEgressBindingMutation, LineState, LineSummary, ListCallsData, ListCallsError, ListCallsErrors, ListCallsResponse, ListCallsResponses, ListContactsData, ListContactsError, ListContactsErrors, ListContactsResponse, ListContactsResponses, ListLineCandidatesData, ListLineCandidatesError, ListLineCandidatesErrors, ListLineCandidatesResponse, ListLineCandidatesResponses, ListLineEgressBindingsData, ListLineEgressBindingsResponse, ListLineEgressBindingsResponses, ListManagedLinesData, ListManagedLinesError, ListManagedLinesErrors, ListManagedLinesResponse, ListManagedLinesResponses, ListManagedModemsData, ListManagedModemsError, ListManagedModemsErrors, ListManagedModemsResponse, ListManagedModemsResponses, ListMessageConversationsData, ListMessageConversationsError, ListMessageConversationsErrors, ListMessageConversationsResponse, ListMessageConversationsResponses, ListMessagesData, ListMessagesError, ListMessagesErrors, ListMessagesResponse, ListMessagesResponses, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesError, ListMihomoSubscriptionNodesErrors, ListMihomoSubscriptionNodesResponse, ListMihomoSubscriptionNodesResponses, ListMihomoSubscriptionsData, ListMihomoSubscriptionsError, ListMihomoSubscriptionsErrors, ListMihomoSubscriptionsResponse, ListMihomoSubscriptionsResponses, ListModemCandidatesData, ListModemCandidatesError, ListModemCandidatesErrors, ListModemCandidatesResponse, ListModemCandidatesResponses, ListNotificationChannelsData, ListNotificationChannelsResponse, ListNotificationChannelsResponses, ListVoWiFiLinesData, ListVoWiFiLinesError, ListVoWiFiLinesErrors, ListVoWiFiLinesResponse, ListVoWiFiLinesResponses, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutError, LogoutErrors, LogoutResponse, LogoutResponses, ManagedLine, ManagedLineList, ManagedLineState, ManagedModem, ManagedModemCellularStatus, ManagedModemEquipmentIdentity, ManagedModemList, ManagedModemRfState, ManagedModemSimPresence, ManagedModemState, MarkMessageConversationReadData, MarkMessageConversationReadError, MarkMessageConversationReadErrors, MarkMessageConversationReadResponse, MarkMessageConversationReadResponses, MarkSmsConversationReadRequest, MihomoConfigStatus, MihomoCoreCandidate, MihomoCoreStatus, MihomoDashboardStatus, MihomoNode, MihomoNodeList, MihomoRuntimeStatus, MihomoSubscription, MihomoSubscriptionCreateRequest, MihomoSubscriptionList, MihomoSubscriptionMutation, MihomoSubscriptionRefresh, ModemCandidate, ModemCandidateList, ModemCandidateReadinessReason, ModemFunctionDetail, ModemSupportStatus, NotificationChannel, NotificationChannelList, NotificationChannelMutation, NotificationEventKind, PageCursor, PageLimit, PhoneNumberObservation, PhoneNumberSource, PhysicalDeviceDetail, PhysicalDeviceState, PhysicalDeviceSummary, PublishMihomoConfigData, PublishMihomoConfigError, PublishMihomoConfigErrors, PublishMihomoConfigResponse, PublishMihomoConfigResponses, PutLineEgressBindingData, PutLineEgressBindingError, PutLineEgressBindingErrors, PutLineEgressBindingResponse, PutLineEgressBindingResponses, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityError, ReadManagedModemEquipmentIdentityErrors, ReadManagedModemEquipmentIdentityResponse, ReadManagedModemEquipmentIdentityResponses, RealtimeAttention, RealtimeEvent, RealtimeTopic, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionError, RefreshMihomoSubscriptionErrors, RefreshMihomoSubscriptionResponse, RefreshMihomoSubscriptionResponses, ResourceGroupDetail, ResourceKind, RestartMihomoData, RestartMihomoError, RestartMihomoErrors, RestartMihomoResponse, RestartMihomoResponses, SelectMihomoSubscriptionData, SelectMihomoSubscriptionError, SelectMihomoSubscriptionErrors, SelectMihomoSubscriptionResponse, SelectMihomoSubscriptionResponses, SendMessageData, SendMessageError, SendMessageErrors, SendMessageResponse, SendMessageResponses, SendSmsRequest, SetManagedModemRfStateData, SetManagedModemRfStateError, SetManagedModemRfStateErrors, SetManagedModemRfStateRequest, SetManagedModemRfStateResponse, SetManagedModemRfStateResponses, SimIdentityState, SimMediaDetail, SimMediaKind, SimSlotDetail, SimSlotPresence, SimulateIncomingCallData, SimulateIncomingCallError, SimulateIncomingCallErrors, SimulateIncomingCallResponse, SimulateIncomingCallResponses, SmsConversationListResponse, SmsConversationSummary, SmsDirection, SmsMessage, SmsMessageListResponse, SmsStatus, StartFeishuNotificationBindingData, StartFeishuNotificationBindingError, StartFeishuNotificationBindingErrors, StartFeishuNotificationBindingResponse, StartFeishuNotificationBindingResponses, StartMihomoData, StartMihomoError, StartMihomoErrors, StartMihomoResponse, StartMihomoResponses, StopMihomoData, StopMihomoError, StopMihomoErrors, StopMihomoResponse, StopMihomoResponses, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, SubscriptionProfileDetail, SubscriptionProfileState, TestNotificationChannelData, TestNotificationChannelError, TestNotificationChannelErrors, TestNotificationChannelResponse, TestNotificationChannelResponses, TopologyRevision, UpdateContactData, UpdateContactError, UpdateContactErrors, UpdateContactResponse, UpdateContactResponses, UpdateManagedLineData, UpdateManagedLineError, UpdateManagedLineErrors, UpdateManagedLineRequest, UpdateManagedLineResponse, UpdateManagedLineResponses, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionError, UpdateMihomoSubscriptionErrors, UpdateMihomoSubscriptionResponse, UpdateMihomoSubscriptionResponses, UpdateNotificationChannelData, UpdateNotificationChannelError, UpdateNotificationChannelErrors, UpdateNotificationChannelResponse, UpdateNotificationChannelResponses, VoWiFiLineState, VoWiFiLineStateList } from './types.gen'; diff --git a/web/src/api/generated/sdk.gen.ts b/web/src/api/generated/sdk.gen.ts index a4457c6..05a39f3 100644 --- a/web/src/api/generated/sdk.gen.ts +++ b/web/src/api/generated/sdk.gen.ts @@ -4,8 +4,8 @@ import * as z from 'zod'; import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { ActivateEuiccProfileData, ActivateEuiccProfileErrors, ActivateEuiccProfileResponses, ActivateVoWiFiLineData, ActivateVoWiFiLineErrors, ActivateVoWiFiLineResponses, AddManagedLineData, AddManagedLineErrors, AddManagedLineResponses, AddManagedModemData, AddManagedModemErrors, AddManagedModemResponses, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingErrors, CancelFeishuNotificationBindingResponses, ChangeAdministratorPasswordData, ChangeAdministratorPasswordErrors, ChangeAdministratorPasswordResponses, CompleteSetupData, CompleteSetupErrors, CompleteSetupResponses, ConfirmSetupHardwareData, ConfirmSetupHardwareErrors, ConfirmSetupHardwareResponses, ConfirmSetupHttpsData, ConfirmSetupHttpsErrors, ConfirmSetupHttpsResponses, ConsumeSetupBootstrapData, ConsumeSetupBootstrapErrors, ConsumeSetupBootstrapResponses, ControlCallData, ControlCallErrors, ControlCallResponses, CreateContactData, CreateContactErrors, CreateContactResponses, CreateMihomoSubscriptionData, CreateMihomoSubscriptionErrors, CreateMihomoSubscriptionResponses, CreateNotificationChannelData, CreateNotificationChannelErrors, CreateNotificationChannelResponses, DeactivateVoWiFiLineData, DeactivateVoWiFiLineErrors, DeactivateVoWiFiLineResponses, DeleteContactData, DeleteContactErrors, DeleteContactResponses, DeleteMessageData, DeleteMessageErrors, DeleteMessageResponses, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionErrors, DeleteMihomoSubscriptionResponses, DeleteNotificationChannelData, DeleteNotificationChannelErrors, DeleteNotificationChannelResponses, DialCallData, DialCallErrors, DialCallResponses, GetAuthSessionData, GetAuthSessionErrors, GetAuthSessionResponses, GetEuiccStateData, GetEuiccStateErrors, GetEuiccStateResponses, GetFeishuNotificationBindingData, GetFeishuNotificationBindingErrors, GetFeishuNotificationBindingResponses, GetHardwareTopologyData, GetHardwareTopologyErrors, GetHardwareTopologyResponses, GetInventoryData, GetInventoryErrors, GetInventoryResponses, GetLatestMihomoCoreData, GetLatestMihomoCoreErrors, GetLatestMihomoCoreResponses, GetMihomoConfigStatusData, GetMihomoConfigStatusErrors, GetMihomoConfigStatusResponses, GetMihomoCoreStatusData, GetMihomoCoreStatusErrors, GetMihomoCoreStatusResponses, GetMihomoDashboardStatusData, GetMihomoDashboardStatusErrors, GetMihomoDashboardStatusResponses, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponses, GetSetupHardwareTopologyData, GetSetupHardwareTopologyErrors, GetSetupHardwareTopologyResponses, GetSetupHttpsRootCertificateData, GetSetupHttpsRootCertificateErrors, GetSetupHttpsRootCertificateResponses, GetSetupInventoryData, GetSetupInventoryErrors, GetSetupInventoryResponses, GetSetupSessionData, GetSetupSessionErrors, GetSetupSessionResponses, GetSetupStatusData, GetSetupStatusErrors, GetSetupStatusResponses, GetSystemHealthData, GetSystemHealthErrors, GetSystemHealthResponses, InstallLatestMihomoCoreData, InstallLatestMihomoCoreErrors, InstallLatestMihomoCoreResponses, ListCallsData, ListCallsErrors, ListCallsResponses, ListContactsData, ListContactsErrors, ListContactsResponses, ListLineCandidatesData, ListLineCandidatesErrors, ListLineCandidatesResponses, ListLineEgressBindingsData, ListLineEgressBindingsResponses, ListManagedLinesData, ListManagedLinesErrors, ListManagedLinesResponses, ListManagedModemsData, ListManagedModemsErrors, ListManagedModemsResponses, ListMessageConversationsData, ListMessageConversationsErrors, ListMessageConversationsResponses, ListMessagesData, ListMessagesErrors, ListMessagesResponses, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesErrors, ListMihomoSubscriptionNodesResponses, ListMihomoSubscriptionsData, ListMihomoSubscriptionsErrors, ListMihomoSubscriptionsResponses, ListModemCandidatesData, ListModemCandidatesErrors, ListModemCandidatesResponses, ListNotificationChannelsData, ListNotificationChannelsResponses, ListVoWiFiLinesData, ListVoWiFiLinesErrors, ListVoWiFiLinesResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutErrors, LogoutResponses, MarkMessageConversationReadData, MarkMessageConversationReadErrors, MarkMessageConversationReadResponses, PublishMihomoConfigData, PublishMihomoConfigErrors, PublishMihomoConfigResponses, PutLineEgressBindingData, PutLineEgressBindingErrors, PutLineEgressBindingResponses, PutSetupAdministratorData, PutSetupAdministratorErrors, PutSetupAdministratorResponses, PutSetupHttpsData, PutSetupHttpsErrors, PutSetupHttpsResponses, PutSetupStorageData, PutSetupStorageErrors, PutSetupStorageResponses, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityErrors, ReadManagedModemEquipmentIdentityResponses, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionErrors, RefreshMihomoSubscriptionResponses, RestartMihomoData, RestartMihomoErrors, RestartMihomoResponses, SelectMihomoSubscriptionData, SelectMihomoSubscriptionErrors, SelectMihomoSubscriptionResponses, SendMessageData, SendMessageErrors, SendMessageResponses, SetManagedModemRfStateData, SetManagedModemRfStateErrors, SetManagedModemRfStateResponses, SimulateIncomingCallData, SimulateIncomingCallErrors, SimulateIncomingCallResponses, StartFeishuNotificationBindingData, StartFeishuNotificationBindingErrors, StartFeishuNotificationBindingResponses, StartMihomoData, StartMihomoErrors, StartMihomoResponses, StopMihomoData, StopMihomoErrors, StopMihomoResponses, StreamEventsData, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, TestNotificationChannelData, TestNotificationChannelErrors, TestNotificationChannelResponses, UpdateContactData, UpdateContactErrors, UpdateContactResponses, UpdateManagedLineData, UpdateManagedLineErrors, UpdateManagedLineResponses, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionErrors, UpdateMihomoSubscriptionResponses, UpdateNotificationChannelData, UpdateNotificationChannelErrors, UpdateNotificationChannelResponses } from './types.gen'; -import { zActivateEuiccProfilePath, zActivateEuiccProfileResponse, zActivateVoWiFiLinePath, zActivateVoWiFiLineResponse, zAddManagedLineBody, zAddManagedLineResponse, zAddManagedModemBody, zAddManagedModemResponse, zCancelFeishuNotificationBindingResponse, zChangeAdministratorPasswordBody, zChangeAdministratorPasswordResponse, zCompleteSetupResponse, zConfirmSetupHardwareResponse, zConfirmSetupHttpsBody, zConfirmSetupHttpsResponse, zConsumeSetupBootstrapBody, zConsumeSetupBootstrapResponse, zControlCallBody, zControlCallPath, zControlCallResponse, zCreateContactBody, zCreateContactResponse, zCreateMihomoSubscriptionBody, zCreateMihomoSubscriptionResponse, zCreateNotificationChannelBody, zCreateNotificationChannelResponse, zDeactivateVoWiFiLinePath, zDeactivateVoWiFiLineResponse, zDeleteContactPath, zDeleteContactResponse, zDeleteMessagePath, zDeleteMessageResponse, zDeleteMihomoSubscriptionPath, zDeleteMihomoSubscriptionResponse, zDeleteNotificationChannelPath, zDeleteNotificationChannelResponse, zDialCallBody, zDialCallResponse, zGetAuthSessionResponse, zGetEuiccStateResponse, zGetFeishuNotificationBindingResponse, zGetHardwareTopologyResponse, zGetInventoryResponse, zGetLatestMihomoCoreResponse, zGetMihomoConfigStatusResponse, zGetMihomoCoreStatusResponse, zGetMihomoDashboardStatusResponse, zGetMihomoRuntimeStatusResponse, zGetSetupHardwareTopologyResponse, zGetSetupHttpsRootCertificateResponse, zGetSetupInventoryResponse, zGetSetupSessionResponse, zGetSetupStatusResponse, zGetSystemHealthResponse, zInstallLatestMihomoCoreResponse, zListCallsQuery, zListCallsResponse, zListContactsResponse, zListLineCandidatesResponse, zListLineEgressBindingsResponse, zListManagedLinesResponse, zListManagedModemsResponse, zListMessageConversationsQuery, zListMessageConversationsResponse, zListMessagesQuery, zListMessagesResponse, zListMihomoSubscriptionNodesPath, zListMihomoSubscriptionNodesResponse, zListMihomoSubscriptionsResponse, zListModemCandidatesResponse, zListNotificationChannelsResponse, zListVoWiFiLinesResponse, zLoginBody, zLoginResponse, zLogoutResponse, zMarkMessageConversationReadBody, zMarkMessageConversationReadResponse, zPublishMihomoConfigResponse, zPutLineEgressBindingBody, zPutLineEgressBindingPath, zPutLineEgressBindingResponse, zPutSetupAdministratorBody, zPutSetupAdministratorResponse, zPutSetupHttpsBody, zPutSetupHttpsResponse, zPutSetupStorageBody, zPutSetupStorageResponse, zReadManagedModemEquipmentIdentityPath, zReadManagedModemEquipmentIdentityResponse, zRefreshMihomoSubscriptionPath, zRefreshMihomoSubscriptionResponse, zRestartMihomoResponse, zSelectMihomoSubscriptionPath, zSelectMihomoSubscriptionResponse, zSendMessageBody, zSendMessageResponse, zSetManagedModemRfStateBody, zSetManagedModemRfStatePath, zSetManagedModemRfStateResponse, zSimulateIncomingCallBody, zSimulateIncomingCallResponse, zStartFeishuNotificationBindingResponse, zStartMihomoResponse, zStopMihomoResponse, zStreamEventsResponse, zTestNotificationChannelPath, zTestNotificationChannelResponse, zUpdateContactBody, zUpdateContactPath, zUpdateContactResponse, zUpdateManagedLineBody, zUpdateManagedLinePath, zUpdateManagedLineResponse, zUpdateMihomoSubscriptionBody, zUpdateMihomoSubscriptionPath, zUpdateMihomoSubscriptionResponse, zUpdateNotificationChannelBody, zUpdateNotificationChannelPath, zUpdateNotificationChannelResponse } from './zod.gen'; +import type { ActivateEuiccProfileData, ActivateEuiccProfileErrors, ActivateEuiccProfileResponses, ActivateVoWiFiLineData, ActivateVoWiFiLineErrors, ActivateVoWiFiLineResponses, AddManagedLineData, AddManagedLineErrors, AddManagedLineResponses, AddManagedModemData, AddManagedModemErrors, AddManagedModemResponses, CancelFeishuNotificationBindingData, CancelFeishuNotificationBindingErrors, CancelFeishuNotificationBindingResponses, ChangeAdministratorPasswordData, ChangeAdministratorPasswordErrors, ChangeAdministratorPasswordResponses, ControlCallData, ControlCallErrors, ControlCallResponses, CreateContactData, CreateContactErrors, CreateContactResponses, CreateMihomoSubscriptionData, CreateMihomoSubscriptionErrors, CreateMihomoSubscriptionResponses, CreateNotificationChannelData, CreateNotificationChannelErrors, CreateNotificationChannelResponses, DeactivateVoWiFiLineData, DeactivateVoWiFiLineErrors, DeactivateVoWiFiLineResponses, DeleteContactData, DeleteContactErrors, DeleteContactResponses, DeleteMessageData, DeleteMessageErrors, DeleteMessageResponses, DeleteMihomoSubscriptionData, DeleteMihomoSubscriptionErrors, DeleteMihomoSubscriptionResponses, DeleteNotificationChannelData, DeleteNotificationChannelErrors, DeleteNotificationChannelResponses, DialCallData, DialCallErrors, DialCallResponses, GetAuthSessionData, GetAuthSessionErrors, GetAuthSessionResponses, GetEuiccStateData, GetEuiccStateErrors, GetEuiccStateResponses, GetFeishuNotificationBindingData, GetFeishuNotificationBindingErrors, GetFeishuNotificationBindingResponses, GetHardwareTopologyData, GetHardwareTopologyErrors, GetHardwareTopologyResponses, GetInventoryData, GetInventoryErrors, GetInventoryResponses, GetLatestMihomoCoreData, GetLatestMihomoCoreErrors, GetLatestMihomoCoreResponses, GetMihomoConfigStatusData, GetMihomoConfigStatusErrors, GetMihomoConfigStatusResponses, GetMihomoCoreStatusData, GetMihomoCoreStatusErrors, GetMihomoCoreStatusResponses, GetMihomoDashboardStatusData, GetMihomoDashboardStatusErrors, GetMihomoDashboardStatusResponses, GetMihomoRuntimeStatusData, GetMihomoRuntimeStatusResponses, GetSystemHealthData, GetSystemHealthErrors, GetSystemHealthResponses, InstallLatestMihomoCoreData, InstallLatestMihomoCoreErrors, InstallLatestMihomoCoreResponses, ListCallsData, ListCallsErrors, ListCallsResponses, ListContactsData, ListContactsErrors, ListContactsResponses, ListLineCandidatesData, ListLineCandidatesErrors, ListLineCandidatesResponses, ListLineEgressBindingsData, ListLineEgressBindingsResponses, ListManagedLinesData, ListManagedLinesErrors, ListManagedLinesResponses, ListManagedModemsData, ListManagedModemsErrors, ListManagedModemsResponses, ListMessageConversationsData, ListMessageConversationsErrors, ListMessageConversationsResponses, ListMessagesData, ListMessagesErrors, ListMessagesResponses, ListMihomoSubscriptionNodesData, ListMihomoSubscriptionNodesErrors, ListMihomoSubscriptionNodesResponses, ListMihomoSubscriptionsData, ListMihomoSubscriptionsErrors, ListMihomoSubscriptionsResponses, ListModemCandidatesData, ListModemCandidatesErrors, ListModemCandidatesResponses, ListNotificationChannelsData, ListNotificationChannelsResponses, ListVoWiFiLinesData, ListVoWiFiLinesErrors, ListVoWiFiLinesResponses, LoginData, LoginErrors, LoginResponses, LogoutData, LogoutErrors, LogoutResponses, MarkMessageConversationReadData, MarkMessageConversationReadErrors, MarkMessageConversationReadResponses, PublishMihomoConfigData, PublishMihomoConfigErrors, PublishMihomoConfigResponses, PutLineEgressBindingData, PutLineEgressBindingErrors, PutLineEgressBindingResponses, ReadManagedModemEquipmentIdentityData, ReadManagedModemEquipmentIdentityErrors, ReadManagedModemEquipmentIdentityResponses, RefreshMihomoSubscriptionData, RefreshMihomoSubscriptionErrors, RefreshMihomoSubscriptionResponses, RestartMihomoData, RestartMihomoErrors, RestartMihomoResponses, SelectMihomoSubscriptionData, SelectMihomoSubscriptionErrors, SelectMihomoSubscriptionResponses, SendMessageData, SendMessageErrors, SendMessageResponses, SetManagedModemRfStateData, SetManagedModemRfStateErrors, SetManagedModemRfStateResponses, SimulateIncomingCallData, SimulateIncomingCallErrors, SimulateIncomingCallResponses, StartFeishuNotificationBindingData, StartFeishuNotificationBindingErrors, StartFeishuNotificationBindingResponses, StartMihomoData, StartMihomoErrors, StartMihomoResponses, StopMihomoData, StopMihomoErrors, StopMihomoResponses, StreamEventsData, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, TestNotificationChannelData, TestNotificationChannelErrors, TestNotificationChannelResponses, UpdateContactData, UpdateContactErrors, UpdateContactResponses, UpdateManagedLineData, UpdateManagedLineErrors, UpdateManagedLineResponses, UpdateMihomoSubscriptionData, UpdateMihomoSubscriptionErrors, UpdateMihomoSubscriptionResponses, UpdateNotificationChannelData, UpdateNotificationChannelErrors, UpdateNotificationChannelResponses } from './types.gen'; +import { zActivateEuiccProfilePath, zActivateEuiccProfileResponse, zActivateVoWiFiLinePath, zActivateVoWiFiLineResponse, zAddManagedLineBody, zAddManagedLineResponse, zAddManagedModemBody, zAddManagedModemResponse, zCancelFeishuNotificationBindingResponse, zChangeAdministratorPasswordBody, zChangeAdministratorPasswordResponse, zControlCallBody, zControlCallPath, zControlCallResponse, zCreateContactBody, zCreateContactResponse, zCreateMihomoSubscriptionBody, zCreateMihomoSubscriptionResponse, zCreateNotificationChannelBody, zCreateNotificationChannelResponse, zDeactivateVoWiFiLinePath, zDeactivateVoWiFiLineResponse, zDeleteContactPath, zDeleteContactResponse, zDeleteMessagePath, zDeleteMessageResponse, zDeleteMihomoSubscriptionPath, zDeleteMihomoSubscriptionResponse, zDeleteNotificationChannelPath, zDeleteNotificationChannelResponse, zDialCallBody, zDialCallResponse, zGetAuthSessionResponse, zGetEuiccStateResponse, zGetFeishuNotificationBindingResponse, zGetHardwareTopologyResponse, zGetInventoryResponse, zGetLatestMihomoCoreResponse, zGetMihomoConfigStatusResponse, zGetMihomoCoreStatusResponse, zGetMihomoDashboardStatusResponse, zGetMihomoRuntimeStatusResponse, zGetSystemHealthResponse, zInstallLatestMihomoCoreResponse, zListCallsQuery, zListCallsResponse, zListContactsResponse, zListLineCandidatesResponse, zListLineEgressBindingsResponse, zListManagedLinesResponse, zListManagedModemsResponse, zListMessageConversationsQuery, zListMessageConversationsResponse, zListMessagesQuery, zListMessagesResponse, zListMihomoSubscriptionNodesPath, zListMihomoSubscriptionNodesResponse, zListMihomoSubscriptionsResponse, zListModemCandidatesResponse, zListNotificationChannelsResponse, zListVoWiFiLinesResponse, zLoginBody, zLoginResponse, zLogoutResponse, zMarkMessageConversationReadBody, zMarkMessageConversationReadResponse, zPublishMihomoConfigResponse, zPutLineEgressBindingBody, zPutLineEgressBindingPath, zPutLineEgressBindingResponse, zReadManagedModemEquipmentIdentityPath, zReadManagedModemEquipmentIdentityResponse, zRefreshMihomoSubscriptionPath, zRefreshMihomoSubscriptionResponse, zRestartMihomoResponse, zSelectMihomoSubscriptionPath, zSelectMihomoSubscriptionResponse, zSendMessageBody, zSendMessageResponse, zSetManagedModemRfStateBody, zSetManagedModemRfStatePath, zSetManagedModemRfStateResponse, zSimulateIncomingCallBody, zSimulateIncomingCallResponse, zStartFeishuNotificationBindingResponse, zStartMihomoResponse, zStopMihomoResponse, zStreamEventsResponse, zTestNotificationChannelPath, zTestNotificationChannelResponse, zUpdateContactBody, zUpdateContactPath, zUpdateContactResponse, zUpdateManagedLineBody, zUpdateManagedLinePath, zUpdateManagedLineResponse, zUpdateMihomoSubscriptionBody, zUpdateMihomoSubscriptionPath, zUpdateMihomoSubscriptionResponse, zUpdateNotificationChannelBody, zUpdateNotificationChannelPath, zUpdateNotificationChannelResponse } from './zod.gen'; export type Options = Options2 & { /** @@ -947,194 +947,6 @@ export const readManagedModemEquipmentIdentity = (options?: Options): RequestResult => (options?.client ?? client).get({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zGetSetupStatusResponse.parseAsync(data), - url: '/api/v1/setup/status', - ...options -}); - -/** - * Exchange a one-time root-generated bootstrap code for a restricted setup session - */ -export const consumeSetupBootstrap = (options: Options): RequestResult => (options.client ?? client).post({ - requestValidator: async (data) => await z.object({ - body: zConsumeSetupBootstrapBody, - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zConsumeSetupBootstrapResponse.parseAsync(data), - url: '/api/v1/setup/bootstrap', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Resume the restricted first-run session from its HttpOnly cookie - */ -export const getSetupSession = (options?: Options): RequestResult => (options?.client ?? client).get({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zGetSetupSessionResponse.parseAsync(data), - url: '/api/v1/setup/session', - ...options -}); - -/** - * Create or replace the sole administrator while first-run setup remains uninitialized - */ -export const putSetupAdministrator = (options: Options): RequestResult => (options.client ?? client).put({ - requestValidator: async (data) => await z.object({ - body: zPutSetupAdministratorBody, - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zPutSetupAdministratorResponse.parseAsync(data), - url: '/api/v1/setup/administrator', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Validate and persist the data and recording roots for a new instance - */ -export const putSetupStorage = (options: Options): RequestResult => (options.client ?? client).put({ - requestValidator: async (data) => await z.object({ - body: zPutSetupStorageBody, - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zPutSetupStorageResponse.parseAsync(data), - url: '/api/v1/setup/storage', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Configure loopback management or generate a local-CA HTTPS candidate - */ -export const putSetupHttps = (options: Options): RequestResult => (options.client ?? client).put({ - requestValidator: async (data) => await z.object({ - body: zPutSetupHttpsBody, - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zPutSetupHttpsResponse.parseAsync(data), - url: '/api/v1/setup/https', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Confirm the displayed SHA-256 local root fingerprint - */ -export const confirmSetupHttps = (options: Options): RequestResult => (options.client ?? client).post({ - requestValidator: async (data) => await z.object({ - body: zConfirmSetupHttpsBody, - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zConfirmSetupHttpsResponse.parseAsync(data), - url: '/api/v1/setup/https/confirm', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Read the public local root certificate for explicit trust installation - */ -export const getSetupHttpsRootCertificate = (options?: Options): RequestResult => (options?.client ?? client).get({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zGetSetupHttpsRootCertificateResponse.parseAsync(data), - url: '/api/v1/setup/https/root-certificate', - ...options -}); - -/** - * Read the restricted HIL-0 inventory while the instance remains RF Off - */ -export const getSetupInventory = (options?: Options): RequestResult => (options?.client ?? client).get({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zGetSetupInventoryResponse.parseAsync(data), - url: '/api/v1/setup/inventory', - ...options -}); - -/** - * Read the RF-Off normalized hardware topology during first-run setup - */ -export const getSetupHardwareTopology = (options?: Options): RequestResult => (options?.client ?? client).get({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zGetSetupHardwareTopologyResponse.parseAsync(data), - url: '/api/v1/setup/hardware/topology', - ...options -}); - -/** - * Confirm the current read-only hardware inventory - */ -export const confirmSetupHardware = (options?: Options): RequestResult => (options?.client ?? client).post({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zConfirmSetupHardwareResponse.parseAsync(data), - url: '/api/v1/setup/hardware/confirm', - ...options -}); - -/** - * Revalidate every setup prerequisite and atomically enter ready - */ -export const completeSetup = (options?: Options): RequestResult => (options?.client ?? client).post({ - requestValidator: async (data) => await z.object({ - body: z.never().optional(), - path: z.never().optional(), - query: z.never().optional() - }).parseAsync(data), - responseValidator: async (data) => await zCompleteSetupResponse.parseAsync(data), - url: '/api/v1/setup/complete', - ...options -}); - /** * Read the local control-plane health snapshot */ diff --git a/web/src/api/generated/types.gen.ts b/web/src/api/generated/types.gen.ts index 844360d..f4206df 100644 --- a/web/src/api/generated/types.gen.ts +++ b/web/src/api/generated/types.gen.ts @@ -4,79 +4,11 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; -export type SetupCompletionResponse = { - installationState: 'ready'; - managementUrl: string; - loginRequired: true; -}; - export type HealthStatus = 'ok' | 'degraded'; export type InstallationState = 'uninitialized' | 'ready' | 'maintenance'; -export type SetupPhase = 'bootstrap-required' | 'complete' | 'maintenance'; - -export type SetupFlow = 'create-new'; - -export type ConsumeBootstrapRequest = { - bootstrapCode: string; -}; - -export type ConfigureSetupAdministratorRequest = { - username: string; - password: string; - passwordConfirmation: string; - instanceDefaultLocale: 'zh-CN' | 'en-US'; -}; - -export type ConfigureSetupStorageRequest = { - recordingsRoot: string; -}; - -export type SetupHttpsMode = 'loopback-only' | 'local-ca'; - -export type ConfigureSetupHttpsRequest = { - mode: SetupHttpsMode; - listenHost: string; - listenPort: number; - subjectAlternativeNames: Array; -}; - -export type ConfirmSetupHttpsRequest = { - rootFingerprintSha256: string; -}; - -export type SetupRootCertificateResponse = { - pem: string; - rootFingerprintSha256: string; -}; - -export type SetupSessionResponse = { - authorized: true; - expiresAt: string; - selectedFlow: SetupFlow; - supportedFlows: [ - SetupFlow - ]; - administratorConfigured: boolean; - administratorUsername: string; - instanceDefaultLocale: 'zh-CN' | 'en-US'; - storageConfigured: boolean; - dataRoot: string; - recordingsRoot: string; - httpsConfigured: boolean; - httpsConfirmed: boolean; - httpsMode: '' | 'loopback-only' | 'local-ca' | 'imported'; - httpsListenUrl: string; - httpsRootFingerprint: string; - httpsLeafNotAfter: string; - hardwareReviewed: boolean; - hardwareDeviceCount: number; - hardwareLineCount: number; - hardwareInventoryDigest: string; -}; - -export type BackendKind = 'simulator' | 'hardware' | 'replay'; +export type BackendKind = 'simulator' | 'hardware'; export type DeviceTransport = 'simulated' | 'usb' | 'uart'; @@ -516,15 +448,6 @@ export type SendSmsRequest = { body: string; }; -export type SetupStatusResponse = { - installationState: InstallationState; - phase: SetupPhase; - setupRequired: boolean; - businessApiAvailable: boolean; - bootstrapGenerationAvailable: boolean; - supportedFlows: Array; -}; - export type HealthResponse = { status: HealthStatus; version: string; @@ -689,9 +612,11 @@ export type MihomoDashboardStatus = { secret: string; }; -export type NotificationEventKind = 'sms.received' | 'sms.failed' | 'call.incoming' | 'call.missed' | 'system.degraded'; +export type NotificationEventKind = 'sms.received' | 'sms.failed' | 'call.incoming' | 'call.missed' | 'system.degraded' | 'vowifi.connected' | 'vowifi.disconnected' | 'cellular.connected' | 'cellular.disconnected'; export type NotificationChannel = { + pendingCount: number; + failedCount: number; id: string; provider: 'wecom' | 'feishu'; deliveryMode: 'webhook' | 'feishu_app'; @@ -2881,518 +2806,6 @@ export type ReadManagedModemEquipmentIdentityResponses = { export type ReadManagedModemEquipmentIdentityResponse = ReadManagedModemEquipmentIdentityResponses[keyof ReadManagedModemEquipmentIdentityResponses]; -export type GetSetupStatusData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/status'; -}; - -export type GetSetupStatusErrors = { - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * The installation state could not be read - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type GetSetupStatusError = GetSetupStatusErrors[keyof GetSetupStatusErrors]; - -export type GetSetupStatusResponses = { - /** - * Current first-run boundary - */ - 200: SetupStatusResponse; -}; - -export type GetSetupStatusResponse = GetSetupStatusResponses[keyof GetSetupStatusResponses]; - -export type ConsumeSetupBootstrapData = { - body: ConsumeBootstrapRequest; - path?: never; - query?: never; - url: '/api/v1/setup/bootstrap'; -}; - -export type ConsumeSetupBootstrapErrors = { - /** - * The request body is invalid - */ - 400: ApiError; - /** - * The bootstrap code is invalid, expired, or already consumed - */ - 401: ApiError; - /** - * First-run setup is not available in the current installation state - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * The bootstrap exchange could not be completed - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type ConsumeSetupBootstrapError = ConsumeSetupBootstrapErrors[keyof ConsumeSetupBootstrapErrors]; - -export type ConsumeSetupBootstrapResponses = { - /** - * Authorized setup session; the opaque session token is stored only in an HttpOnly cookie - */ - 200: SetupSessionResponse; -}; - -export type ConsumeSetupBootstrapResponse = ConsumeSetupBootstrapResponses[keyof ConsumeSetupBootstrapResponses]; - -export type GetSetupSessionData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/session'; -}; - -export type GetSetupSessionErrors = { - /** - * No live setup session is present - */ - 401: ApiError; - /** - * First-run setup is not available in the current installation state - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * The setup session could not be read - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type GetSetupSessionError = GetSetupSessionErrors[keyof GetSetupSessionErrors]; - -export type GetSetupSessionResponses = { - /** - * Current restricted setup session - */ - 200: SetupSessionResponse; -}; - -export type GetSetupSessionResponse = GetSetupSessionResponses[keyof GetSetupSessionResponses]; - -export type PutSetupAdministratorData = { - body: ConfigureSetupAdministratorRequest; - path?: never; - query?: never; - url: '/api/v1/setup/administrator'; -}; - -export type PutSetupAdministratorErrors = { - /** - * Username, password confirmation, password policy, locale, or body is invalid - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * First-run setup is not available in the current installation state - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * The administrator configuration could not be persisted - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type PutSetupAdministratorError = PutSetupAdministratorErrors[keyof PutSetupAdministratorErrors]; - -export type PutSetupAdministratorResponses = { - /** - * Initial administrator configuration persisted; password material is never returned - */ - 200: SetupSessionResponse; -}; - -export type PutSetupAdministratorResponse = PutSetupAdministratorResponses[keyof PutSetupAdministratorResponses]; - -export type PutSetupStorageData = { - body: ConfigureSetupStorageRequest; - path?: never; - query?: never; - url: '/api/v1/setup/storage'; -}; - -export type PutSetupStorageErrors = { - /** - * The recording root is invalid or conflicts with the live data root - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * An earlier setup prerequisite is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * A storage root failed validation or persistence - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type PutSetupStorageError = PutSetupStorageErrors[keyof PutSetupStorageErrors]; - -export type PutSetupStorageResponses = { - /** - * Storage roots were identity-checked, write-tested, and persisted - */ - 200: SetupSessionResponse; -}; - -export type PutSetupStorageResponse = PutSetupStorageResponses[keyof PutSetupStorageResponses]; - -export type PutSetupHttpsData = { - body: ConfigureSetupHttpsRequest; - path?: never; - query?: never; - url: '/api/v1/setup/https'; -}; - -export type PutSetupHttpsErrors = { - /** - * Listener, mode, port, or SAN configuration is invalid - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * Storage is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * Certificate generation, key protection, or persistence failed - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type PutSetupHttpsError = PutSetupHttpsErrors[keyof PutSetupHttpsErrors]; - -export type PutSetupHttpsResponses = { - /** - * Management endpoint configuration persisted - */ - 200: SetupSessionResponse; -}; - -export type PutSetupHttpsResponse = PutSetupHttpsResponses[keyof PutSetupHttpsResponses]; - -export type ConfirmSetupHttpsData = { - body: ConfirmSetupHttpsRequest; - path?: never; - query?: never; - url: '/api/v1/setup/https/confirm'; -}; - -export type ConfirmSetupHttpsErrors = { - /** - * The fingerprint does not match the current candidate - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * Setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * Confirmation persistence failed - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type ConfirmSetupHttpsError = ConfirmSetupHttpsErrors[keyof ConfirmSetupHttpsErrors]; - -export type ConfirmSetupHttpsResponses = { - /** - * The HTTPS candidate was confirmed - */ - 200: SetupSessionResponse; -}; - -export type ConfirmSetupHttpsResponse = ConfirmSetupHttpsResponses[keyof ConfirmSetupHttpsResponses]; - -export type GetSetupHttpsRootCertificateData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/https/root-certificate'; -}; - -export type GetSetupHttpsRootCertificateErrors = { - /** - * The current setup mode has no local root certificate - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * The public root certificate could not be read - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type GetSetupHttpsRootCertificateError = GetSetupHttpsRootCertificateErrors[keyof GetSetupHttpsRootCertificateErrors]; - -export type GetSetupHttpsRootCertificateResponses = { - /** - * Public root certificate and independently displayed fingerprint - */ - 200: SetupRootCertificateResponse; -}; - -export type GetSetupHttpsRootCertificateResponse = GetSetupHttpsRootCertificateResponses[keyof GetSetupHttpsRootCertificateResponses]; - -export type GetSetupInventoryData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/inventory'; -}; - -export type GetSetupInventoryErrors = { - /** - * No live setup session is present - */ - 401: ApiError; - /** - * HTTPS setup is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * Inventory could not be read - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type GetSetupInventoryError = GetSetupInventoryErrors[keyof GetSetupInventoryErrors]; - -export type GetSetupInventoryResponses = { - /** - * Current read-only device and line snapshot - */ - 200: InventoryResponse; -}; - -export type GetSetupInventoryResponse = GetSetupInventoryResponses[keyof GetSetupInventoryResponses]; - -export type GetSetupHardwareTopologyData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/hardware/topology'; -}; - -export type GetSetupHardwareTopologyErrors = { - /** - * No live setup session is present - */ - 401: ApiError; - /** - * HTTPS confirmation is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The setup request authority is invalid - */ - 421: ApiError; - /** - * The hardware topology could not be produced or failed validation - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type GetSetupHardwareTopologyError = GetSetupHardwareTopologyErrors[keyof GetSetupHardwareTopologyErrors]; - -export type GetSetupHardwareTopologyResponses = { - /** - * Current normalized RF-Off hardware topology - */ - 200: HardwareTopologyResponse; -}; - -export type GetSetupHardwareTopologyResponse = GetSetupHardwareTopologyResponses[keyof GetSetupHardwareTopologyResponses]; - -export type ConfirmSetupHardwareData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/hardware/confirm'; -}; - -export type ConfirmSetupHardwareErrors = { - /** - * The current hardware topology is invalid - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * HTTPS setup is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * Hardware review could not be persisted - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type ConfirmSetupHardwareError = ConfirmSetupHardwareErrors[keyof ConfirmSetupHardwareErrors]; - -export type ConfirmSetupHardwareResponses = { - /** - * Current hardware inventory digest was persisted - */ - 200: SetupSessionResponse; -}; - -export type ConfirmSetupHardwareResponse = ConfirmSetupHardwareResponses[keyof ConfirmSetupHardwareResponses]; - -export type CompleteSetupData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/setup/complete'; -}; - -export type CompleteSetupErrors = { - /** - * Current hardware no longer matches the reviewed RF-Off snapshot - */ - 400: ApiError; - /** - * No live setup session is present - */ - 401: ApiError; - /** - * A prerequisite is incomplete or setup is unavailable - */ - 409: ApiError; - /** - * The HTTP authority is not a permitted loopback host - */ - 421: ApiError; - /** - * Final storage, TLS, or atomic state transition failed - */ - 500: ApiError; - /** - * The request exceeded the bounded control-plane deadline - */ - 504: ApiError; -}; - -export type CompleteSetupError = CompleteSetupErrors[keyof CompleteSetupErrors]; - -export type CompleteSetupResponses = { - /** - * The instance is ready and requires normal administrator login - */ - 200: SetupCompletionResponse; -}; - -export type CompleteSetupResponse = CompleteSetupResponses[keyof CompleteSetupResponses]; - export type GetSystemHealthData = { body?: never; path?: never; diff --git a/web/src/api/generated/zod.gen.ts b/web/src/api/generated/zod.gen.ts index b2f6408..7d34da0 100644 --- a/web/src/api/generated/zod.gen.ts +++ b/web/src/api/generated/zod.gen.ts @@ -2,12 +2,6 @@ import * as z from 'zod'; -export const zSetupCompletionResponse = z.object({ - installationState: z.enum(['ready']), - managementUrl: z.string().min(1).max(2048), - loginRequired: z.literal(true) -}); - export const zHealthStatus = z.enum(['ok', 'degraded']); export const zInstallationState = z.enum([ @@ -16,80 +10,7 @@ export const zInstallationState = z.enum([ 'maintenance' ]); -export const zSetupPhase = z.enum([ - 'bootstrap-required', - 'complete', - 'maintenance' -]); - -export const zSetupFlow = z.enum(['create-new']); - -export const zConsumeBootstrapRequest = z.object({ - bootstrapCode: z.string().regex(/^[A-Za-z0-9_-]{43}$/) -}); - -export const zConfigureSetupAdministratorRequest = z.object({ - username: z.string().min(3).max(32).regex(/^[A-Za-z0-9][A-Za-z0-9._-]{2,31}$/), - password: z.string().min(12).max(128), - passwordConfirmation: z.string().min(12).max(128), - instanceDefaultLocale: z.enum(['zh-CN', 'en-US']) -}); - -export const zConfigureSetupStorageRequest = z.object({ - recordingsRoot: z.string().min(1).max(4096).regex(/^\//) -}); - -export const zSetupHttpsMode = z.enum(['loopback-only', 'local-ca']); - -export const zConfigureSetupHttpsRequest = z.object({ - mode: zSetupHttpsMode, - listenHost: z.string().min(1).max(255), - listenPort: z.int().gte(1).lte(65535), - subjectAlternativeNames: z.array(z.string().min(1).max(253)).max(16) -}); - -export const zConfirmSetupHttpsRequest = z.object({ - rootFingerprintSha256: z.string().regex(/^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/) -}); - -export const zSetupRootCertificateResponse = z.object({ - pem: z.string().min(1), - rootFingerprintSha256: z.string().regex(/^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/) -}); - -export const zSetupSessionResponse = z.object({ - authorized: z.literal(true), - expiresAt: z.iso.datetime(), - selectedFlow: zSetupFlow, - supportedFlows: z.tuple([zSetupFlow]), - administratorConfigured: z.boolean(), - administratorUsername: z.string().max(32), - instanceDefaultLocale: z.enum(['zh-CN', 'en-US']), - storageConfigured: z.boolean(), - dataRoot: z.string().max(4096), - recordingsRoot: z.string().max(4096), - httpsConfigured: z.boolean(), - httpsConfirmed: z.boolean(), - httpsMode: z.enum([ - '', - 'loopback-only', - 'local-ca', - 'imported' - ]), - httpsListenUrl: z.string().max(2048), - httpsRootFingerprint: z.string().max(95), - httpsLeafNotAfter: z.string().max(64), - hardwareReviewed: z.boolean(), - hardwareDeviceCount: z.int().gte(0), - hardwareLineCount: z.int().gte(0), - hardwareInventoryDigest: z.string().max(64) -}); - -export const zBackendKind = z.enum([ - 'simulator', - 'hardware', - 'replay' -]); +export const zBackendKind = z.enum(['simulator', 'hardware']); export const zDeviceTransport = z.enum([ 'simulated', @@ -628,15 +549,6 @@ export const zSendSmsRequest = z.object({ body: z.string().min(1).max(1600) }); -export const zSetupStatusResponse = z.object({ - installationState: zInstallationState, - phase: zSetupPhase, - setupRequired: z.boolean(), - businessApiAvailable: z.boolean(), - bootstrapGenerationAvailable: z.boolean(), - supportedFlows: z.array(zSetupFlow).max(1) -}); - export const zHealthResponse = z.object({ status: zHealthStatus, version: z.string(), @@ -837,10 +749,16 @@ export const zNotificationEventKind = z.enum([ 'sms.failed', 'call.incoming', 'call.missed', - 'system.degraded' + 'system.degraded', + 'vowifi.connected', + 'vowifi.disconnected', + 'cellular.connected', + 'cellular.disconnected' ]); export const zNotificationChannel = z.object({ + pendingCount: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), + failedCount: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }), id: z.string().regex(/^channel_[A-Za-z0-9_-]{22}$/), provider: z.enum(['wecom', 'feishu']), deliveryMode: z.enum(['webhook', 'feishu_app']), @@ -849,7 +767,7 @@ export const zNotificationChannel = z.object({ webhookHint: z.enum(['qyapi.weixin.qq.com', 'open.feishu.cn']), signingSecretConfigured: z.boolean(), enabled: z.boolean(), - eventKinds: z.array(zNotificationEventKind).min(1).max(5), + eventKinds: z.array(zNotificationEventKind).min(1).max(9), lastDeliveryAt: z.string().max(64), lastDeliveryStatus: z.enum([ 'never', @@ -865,7 +783,7 @@ export const zNotificationChannelMutation = z.object({ webhookUrl: z.string().max(4096), signingSecret: z.string().max(512), enabled: z.boolean(), - eventKinds: z.array(zNotificationEventKind).min(1).max(5) + eventKinds: z.array(zNotificationEventKind).min(1).max(9) }); export const zNotificationChannelList = z.object({ @@ -1344,76 +1262,6 @@ export const zReadManagedModemEquipmentIdentityPath = z.object({ */ export const zReadManagedModemEquipmentIdentityResponse = zManagedModemEquipmentIdentity; -/** - * Current first-run boundary - */ -export const zGetSetupStatusResponse = zSetupStatusResponse; - -export const zConsumeSetupBootstrapBody = zConsumeBootstrapRequest; - -/** - * Authorized setup session; the opaque session token is stored only in an HttpOnly cookie - */ -export const zConsumeSetupBootstrapResponse = zSetupSessionResponse; - -/** - * Current restricted setup session - */ -export const zGetSetupSessionResponse = zSetupSessionResponse; - -export const zPutSetupAdministratorBody = zConfigureSetupAdministratorRequest; - -/** - * Initial administrator configuration persisted; password material is never returned - */ -export const zPutSetupAdministratorResponse = zSetupSessionResponse; - -export const zPutSetupStorageBody = zConfigureSetupStorageRequest; - -/** - * Storage roots were identity-checked, write-tested, and persisted - */ -export const zPutSetupStorageResponse = zSetupSessionResponse; - -export const zPutSetupHttpsBody = zConfigureSetupHttpsRequest; - -/** - * Management endpoint configuration persisted - */ -export const zPutSetupHttpsResponse = zSetupSessionResponse; - -export const zConfirmSetupHttpsBody = zConfirmSetupHttpsRequest; - -/** - * The HTTPS candidate was confirmed - */ -export const zConfirmSetupHttpsResponse = zSetupSessionResponse; - -/** - * Public root certificate and independently displayed fingerprint - */ -export const zGetSetupHttpsRootCertificateResponse = zSetupRootCertificateResponse; - -/** - * Current read-only device and line snapshot - */ -export const zGetSetupInventoryResponse = zInventoryResponse; - -/** - * Current normalized RF-Off hardware topology - */ -export const zGetSetupHardwareTopologyResponse = zHardwareTopologyResponse; - -/** - * Current hardware inventory digest was persisted - */ -export const zConfirmSetupHardwareResponse = zSetupSessionResponse; - -/** - * The instance is ready and requires normal administrator login - */ -export const zCompleteSetupResponse = zSetupCompletionResponse; - /** * Current health snapshot */ diff --git a/web/src/api/runtime.test.ts b/web/src/api/runtime.test.ts index f4af64f..eb151ce 100644 --- a/web/src/api/runtime.test.ts +++ b/web/src/api/runtime.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getAuthSession, getSetupSession, login, sendMessage } from './generated/sdk.gen' -import { configureApiClient } from './setupClient' -import { onSessionExpired } from './session' +import { getAuthSession, login, sendMessage } from './generated/sdk.gen' +import { configureApiClient } from './configureClient' +import { advanceSession, onSessionExpired } from './session' import { runtimeFetch } from './runtime' describe('API runtime', () => { @@ -24,12 +24,11 @@ describe('API runtime', () => { expect(request.headers.get('X-Simplus-CSRF')).toBe('test-token') }) - it('never adds CSRF to login or setup mutations', async () => { + it('never adds CSRF to login', async () => { const fetch = vi.fn(async (_request: Request) => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })) vi.stubGlobal('fetch', fetch) await runtimeFetch('/api/v1/auth/login', { method: 'POST', body: '{}' }) - await runtimeFetch('/api/v1/setup/bootstrap/consume', { method: 'POST', body: '{}' }) - expect(fetch.mock.calls).toHaveLength(2) + expect(fetch.mock.calls).toHaveLength(1) for (const [request] of fetch.mock.calls) expect(request.headers.has('X-Simplus-CSRF')).toBe(false) }) @@ -44,17 +43,6 @@ describe('API runtime', () => { unsubscribe() }) - it('keeps setup-session authorization separate from administrator expiry', async () => { - const expired = vi.fn() - const unsubscribe = onSessionExpired(expired) - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ code: 'SETUP_SESSION_UNAUTHORIZED', retryable: false }), { status: 401, headers: { 'Content-Type': 'application/json' } }))) - await expect(getSetupSession({ throwOnError: true })).rejects.toMatchObject({ - kind: 'http', code: 'SETUP_SESSION_UNAUTHORIZED', retryable: false, status: 401, - }) - expect(expired).not.toHaveBeenCalled() - unsubscribe() - }) - it('does not treat rejected credentials as an expired administrator session', async () => { const expired = vi.fn() const unsubscribe = onSessionExpired(expired) @@ -95,6 +83,16 @@ describe('API runtime', () => { }) }) + it('discards responses from an earlier administrator session', async () => { + let finish!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { finish = resolve }))) + const pending = getAuthSession({ throwOnError: true }) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + advanceSession() + finish(new Response(JSON.stringify({ username: 'old-admin', locale: 'zh-CN', expiresAt: '2099-01-01T00:00:00Z' }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + await expect(pending).rejects.toMatchObject({ kind: 'aborted', code: 'REQUEST_ABORTED' }) + }) + it('distinguishes an explicit abort from a network failure', async () => { const controller = new AbortController() vi.stubGlobal('fetch', vi.fn(async (request: Request) => new Promise((_resolve, reject) => { diff --git a/web/src/api/runtime.ts b/web/src/api/runtime.ts index 26f4962..949e19e 100644 --- a/web/src/api/runtime.ts +++ b/web/src/api/runtime.ts @@ -1,4 +1,5 @@ import type { CreateClientConfig } from './generated/client.gen' +import { sessionGeneration } from './session' import { ApiClientError } from './errors' const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']) @@ -34,13 +35,13 @@ export async function runtimeFetch(input: RequestInfo | URL, init?: RequestInit) const path = new URL(source.url).pathname if ( mutatingMethods.has(source.method) && - path !== '/api/v1/auth/login' && - !path.startsWith('/api/v1/setup/') + path !== '/api/v1/auth/login' ) { const csrf = cookieValue('simplus_csrf') if (csrf) headers.set('X-Simplus-CSRF', csrf) } + const generation = sessionGeneration() const controller = new AbortController() let timedOut = false const abortFromSource = () => controller.abort(source.signal.reason) @@ -52,12 +53,17 @@ export async function runtimeFetch(input: RequestInfo | URL, init?: RequestInit) }, requestTimeout(source)) try { - return await globalThis.fetch(new Request(source, { + const response = await globalThis.fetch(new Request(source, { credentials: 'same-origin', headers, signal: controller.signal, })) + if (generation !== sessionGeneration()) { + throw new ApiClientError({ kind: 'aborted', code: 'REQUEST_ABORTED', retryable: false }) + } + return response } catch (error) { + if (error instanceof ApiClientError) throw error if (timedOut) { throw new ApiClientError({ kind: 'timeout', code: 'API_TIMEOUT', retryable: true }) } diff --git a/web/src/api/session.ts b/web/src/api/session.ts index 957c651..edae14d 100644 --- a/web/src/api/session.ts +++ b/web/src/api/session.ts @@ -1,8 +1,13 @@ type SessionExpiredListener = () => void +let generation = 0 +export const sessionGeneration = () => generation +export function advanceSession() { generation++ } + const listeners = new Set() export function notifySessionExpired() { + advanceSession() for (const listener of listeners) listener() } diff --git a/web/src/app/AppProviders.tsx b/web/src/app/AppProviders.tsx index 079f813..35ae50d 100644 --- a/web/src/app/AppProviders.tsx +++ b/web/src/app/AppProviders.tsx @@ -3,10 +3,10 @@ import zhCN from 'antd/locale/zh_CN' import { QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import { BrowserRouter } from 'react-router' -import { configureApiClient } from '@/api/setupClient' +import { configureApiClient } from '@/api/configureClient' import { createAppQueryClient } from '@/api/queryClient' import { AppRouter } from './AppRouter' -import { BootstrapGate } from './BootstrapGate' +import { SessionGate } from './SessionGate' configureApiClient() @@ -16,7 +16,7 @@ export function AppProviders() { - + diff --git a/web/src/app/AppRouter.tsx b/web/src/app/AppRouter.tsx index 4f6ff24..51154c1 100644 --- a/web/src/app/AppRouter.tsx +++ b/web/src/app/AppRouter.tsx @@ -5,19 +5,17 @@ import { AppShell } from './AppShell' const Calls = lazy(() => import('@/pages/Calls')) const Dashboard = lazy(() => import('@/pages/Dashboard')) -const Lines = lazy(() => import('@/pages/Lines')) +const Lines = lazy(() => import('@/features/lines/Lines')) const Login = lazy(() => import('@/pages/Login')) -const Messages = lazy(() => import('@/pages/Messages')) +const Messages = lazy(() => import('@/features/messages/Messages')) const Mihomo = lazy(() => import('@/pages/Mihomo')) const Modems = lazy(() => import('@/pages/Modems')) -const Notifications = lazy(() => import('@/pages/Notifications')) +const Notifications = lazy(() => import('@/features/notifications/Notifications')) const Settings = lazy(() => import('@/pages/Settings')) -const Setup = lazy(() => import('@/pages/Setup')) export function AppRouter() { return }> } /> - } /> }> } /> } /> diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index f7fca47..dcbef1a 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -1,3 +1,4 @@ +import { advanceSession } from '@/api/session' import { LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons' import { App, Avatar, Button, Drawer, Dropdown, Flex, Grid, Layout, Menu, Typography } from 'antd' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -24,6 +25,7 @@ export function AppShell() { const logout = useMutation({ ...logoutMutation(), onSuccess: async () => { + advanceSession() await queryClient.cancelQueries() queryClient.clear() navigate('/login', { replace: true }) diff --git a/web/src/app/BootstrapGate.test.tsx b/web/src/app/BootstrapGate.test.tsx deleted file mode 100644 index e69f3d6..0000000 --- a/web/src/app/BootstrapGate.test.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { App } from 'antd' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' -import { - getAuthSessionQueryKey, - getSetupStatusQueryKey, -} from '@/api/generated/@tanstack/react-query.gen' -import { notifySessionExpired } from '@/api/session' -import { configureApiClient } from '@/api/setupClient' -import { json } from '@/test/render' -import LoginPage from '@/pages/Login' -import SetupPage from '@/pages/Setup' -import { BootstrapGate } from './BootstrapGate' - -configureApiClient() - -const setupRequired = { - installationState: 'uninitialized' as const, - phase: 'bootstrap-required' as const, - setupRequired: true, - businessApiAvailable: false, - bootstrapGenerationAvailable: false, - supportedFlows: ['create-new' as const], -} - -const authorizedSetupSession = { - authorized: true as const, - expiresAt: '2099-01-01T00:00:00Z', - selectedFlow: 'create-new' as const, - supportedFlows: ['create-new' as const], - administratorConfigured: true, - administratorUsername: 'synthetic_admin', - instanceDefaultLocale: 'zh-CN' as const, - storageConfigured: false, - dataRoot: '/synthetic/data', - recordingsRoot: '/synthetic/recordings', - httpsConfigured: false, - httpsConfirmed: false, - httpsMode: '' as const, - httpsListenUrl: '', - httpsRootFingerprint: '', - httpsLeafNotAfter: '', - hardwareReviewed: false, - hardwareDeviceCount: 0, - hardwareLineCount: 0, - hardwareInventoryDigest: '', -} - -function CurrentPath() { - return {useLocation().pathname} -} - -describe('BootstrapGate session recovery', () => { - afterEach(() => { - vi.unstubAllGlobals() - window.history.replaceState({}, '', '/') - }) - - it('clears private snapshots and replace-navigates after a session expires', async () => { - const setup = { - installationState: 'ready' as const, - phase: 'complete' as const, - setupRequired: false, - businessApiAvailable: true, - bootstrapGenerationAvailable: false, - supportedFlows: ['create-new' as const], - } - const session = { - username: 'synthetic_admin', locale: 'zh-CN', expiresAt: '2099-01-01T00:00:00Z', - } - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - if (path === '/api/v1/setup/status') return json(setup) - if (path === '/api/v1/auth/session') return json({ code: 'AUTH_SESSION_UNAUTHORIZED', retryable: false }, 401) - throw new Error(`unexpected ${path}`) - })) - - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) - queryClient.setQueryData(getSetupStatusQueryKey(), setup) - queryClient.setQueryData(getAuthSessionQueryKey(), session) - const privateKey = [{ _id: 'listMessages', tags: ['messages'] }] as const - queryClient.setQueryData(privateKey, { messages: [{ id: 'private-snapshot' }] }) - - render( - - 受保护页面} /> - 登录页面} /> - - ) - expect(await screen.findByText('受保护页面')).toBeInTheDocument() - - await act(async () => notifySessionExpired()) - - expect(await screen.findByText('登录页面')).toBeInTheDocument() - expect(queryClient.getQueryData(privateKey)).toBeUndefined() - }) - - it.each(['/', '/dashboard', '/login'])('settles an ordinary uninitialized visit from %s on login', async (initialPath) => { - const requests: string[] = [] - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - requests.push(path) - if (path === '/api/v1/setup/status') return json(setupRequired) - throw new Error(`unexpected ${path}`) - })) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( - - - 根页面} /> - 受保护页面} /> - 登录页面} /> - - ) - - expect(await screen.findByText('登录页面')).toBeInTheDocument() - expect(screen.getByLabelText('当前路径')).toHaveTextContent('/login') - await new Promise((resolve) => window.setTimeout(resolve, 50)) - expect(requests.filter((path) => path === '/api/v1/setup/status')).toHaveLength(1) - expect(requests).not.toContain('/api/v1/setup/session') - expect(requests).not.toContain('/api/v1/auth/session') - }) - - it('keeps a direct setup URL reachable for bootstrap grants and reloads', async () => { - const requests: string[] = [] - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - requests.push(path) - if (path === '/api/v1/setup/status') return json(setupRequired) - if (path === '/api/v1/setup/session') return json({ code: 'SETUP_SESSION_UNAUTHORIZED', retryable: false }, 401) - throw new Error(`unexpected ${path}`) - })) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - render( - - - } /> - 登录页面} /> - - ) - - expect(await screen.findByText('初始化授权不可用')).toBeInTheDocument() - expect(screen.getByLabelText('当前路径')).toHaveTextContent('/setup') - await new Promise((resolve) => window.setTimeout(resolve, 50)) - expect(requests.filter((path) => path === '/api/v1/setup/status')).toHaveLength(1) - expect(requests.filter((path) => path === '/api/v1/setup/session')).toHaveLength(1) - expect(requests).not.toContain('/api/v1/auth/session') - }) - - it('exchanges a direct bootstrap grant without visiting login', async () => { - const bootstrapCode = 'A'.repeat(43) - window.history.replaceState({}, '', `/setup#bootstrap=${bootstrapCode}`) - const requests: string[] = [] - let bootstrapBody: unknown - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - requests.push(path) - if (path === '/api/v1/setup/status') return json(setupRequired) - if (path === '/api/v1/setup/bootstrap') { - bootstrapBody = await request.clone().json() - return json(authorizedSetupSession) - } - throw new Error(`unexpected ${path}`) - })) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) - render( - - - } /> - 登录页面} /> - - ) - - expect(await screen.findByText('当前管理员:synthetic_admin')).toBeInTheDocument() - expect(screen.getByLabelText('当前路径')).toHaveTextContent('/setup') - expect(window.location.hash).toBe('') - expect(bootstrapBody).toEqual({ bootstrapCode }) - expect(requests).toEqual(['/api/v1/setup/status', '/api/v1/setup/bootstrap']) - }) - - it('moves the first administrator login from an ordinary URL into setup', async () => { - const requests: string[] = [] - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - requests.push(path) - if (path === '/api/v1/setup/status') return json(setupRequired) - if (path === '/api/v1/auth/login') { - return json({ username: 'synthetic_admin', locale: 'zh-CN', expiresAt: '2099-01-01T00:00:00Z' }) - } - if (path === '/api/v1/setup/session') return json(authorizedSetupSession) - throw new Error(`unexpected ${path}`) - })) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) - render( - - - 根页面} /> - } /> - } /> - - ) - - expect(await screen.findByPlaceholderText('管理员用户名')).toBeInTheDocument() - expect(screen.getByLabelText('当前路径')).toHaveTextContent('/login') - fireEvent.change(screen.getByPlaceholderText('管理员用户名'), { target: { value: 'synthetic_admin' } }) - fireEvent.change(screen.getByPlaceholderText('密码'), { target: { value: 'synthetic-password' } }) - fireEvent.click(screen.getByRole('button', { name: /登\s*录/ })) - - expect(await screen.findByText('当前管理员:synthetic_admin')).toBeInTheDocument() - expect(screen.getByLabelText('当前路径')).toHaveTextContent('/setup') - await new Promise((resolve) => window.setTimeout(resolve, 50)) - expect(requests).toEqual([ - '/api/v1/setup/status', - '/api/v1/auth/login', - '/api/v1/setup/session', - ]) - }) -}) diff --git a/web/src/app/SessionGate.test.tsx b/web/src/app/SessionGate.test.tsx new file mode 100644 index 0000000..cecf8cf --- /dev/null +++ b/web/src/app/SessionGate.test.tsx @@ -0,0 +1,55 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App } from 'antd' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { + getAuthSessionQueryKey, + getSystemHealthQueryKey, +} from '@/api/generated/@tanstack/react-query.gen' +import { notifySessionExpired } from '@/api/session' +import { configureApiClient } from '@/api/configureClient' +import { json } from '@/test/render' +import { SessionGate } from './SessionGate' + +configureApiClient() + +describe('SessionGate session recovery', () => { + afterEach(() => { + vi.unstubAllGlobals() + window.history.replaceState({}, '', '/') + }) + + it('clears private snapshots and replace-navigates after a session expires', async () => { + const health = { status: 'ok', version: 'test', apiVersion: 'v1', installationState: 'ready', databaseCount: 1, backend: 'simulator' } + const session = { + username: 'synthetic_admin', locale: 'zh-CN', expiresAt: '2099-01-01T00:00:00Z', + } + vi.stubGlobal('fetch', vi.fn(async (request: Request) => { + const path = new URL(request.url).pathname + if (path === '/api/v1/system/health') return json(health) + if (path === '/api/v1/auth/session') return json({ code: 'AUTH_SESSION_UNAUTHORIZED', retryable: false }, 401) + throw new Error(`unexpected ${path}`) + })) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + queryClient.setQueryData(getSystemHealthQueryKey(), health) + queryClient.setQueryData(getAuthSessionQueryKey(), session) + const privateKey = [{ _id: 'listMessages', tags: ['messages'] }] as const + queryClient.setQueryData(privateKey, { messages: [{ id: 'private-snapshot' }] }) + + render( + + 受保护页面} /> + 登录页面} /> + + ) + expect(await screen.findByText('受保护页面')).toBeInTheDocument() + + await act(async () => notifySessionExpired()) + + expect(await screen.findByText('登录页面')).toBeInTheDocument() + expect(queryClient.getQueryData(privateKey)).toBeUndefined() + }) + +}) diff --git a/web/src/app/BootstrapGate.tsx b/web/src/app/SessionGate.tsx similarity index 58% rename from web/src/app/BootstrapGate.tsx rename to web/src/app/SessionGate.tsx index 614c52b..c42bd25 100644 --- a/web/src/app/BootstrapGate.tsx +++ b/web/src/app/SessionGate.tsx @@ -1,9 +1,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Button, Result, Spin } from 'antd' -import { useEffect, type ReactNode } from 'react' +import { useEffect, useRef, type ReactNode } from 'react' import { Navigate, useLocation, useNavigate } from 'react-router' import { ApiClientError, displayApiError } from '@/api/errors' -import { getAuthSessionOptions, getSetupStatusOptions } from '@/api/generated/@tanstack/react-query.gen' +import { getAuthSessionOptions, getSystemHealthOptions } from '@/api/generated/@tanstack/react-query.gen' import { onSessionExpired } from '@/api/session' import { AuthContext } from './auth' @@ -11,37 +11,35 @@ function FullPageLoading() { return
} -export function BootstrapGate({ children }: { children: ReactNode }) { +export function SessionGate({ children }: { children: ReactNode }) { const location = useLocation() const navigate = useNavigate() const queryClient = useQueryClient() - const setup = useQuery(getSetupStatusOptions()) - const setupRequired = setup.data?.setupRequired === true - const session = useQuery({ ...getAuthSessionOptions(), enabled: setup.isSuccess && !setupRequired }) + const expired = useRef(false) + const health = useQuery(getSystemHealthOptions()) + const ready = health.data?.installationState === 'ready' + const session = useQuery({ ...getAuthSessionOptions(), enabled: health.isSuccess && ready }) + + useEffect(() => { if (session.data) expired.current = false }, [session.data]) useEffect(() => onSessionExpired(() => { - if (location.pathname === '/login') return - void queryClient.cancelQueries().then(() => { - queryClient.clear() - navigate('/login', { replace: true }) - }) + if (expired.current) return + expired.current = true + void queryClient.cancelQueries() + queryClient.clear() + navigate('/login', { replace: true }) }), [location.pathname, navigate, queryClient]) - if (setup.isPending) return - if (setup.error) { + if (health.isPending) return + if (health.error) { return void setup.refetch()}>重试} + subTitle={displayApiError(health.error)} + extra={} /> } - if (setupRequired) { - return location.pathname === '/setup' || location.pathname === '/login' - ? <>{children} - : - } - if (location.pathname === '/setup') return + if (!ready) return void health.refetch()}>刷新} /> if (session.isPending) return const unauthenticated = session.error instanceof ApiClientError && session.error.status === 401 diff --git a/web/src/pages/Lines.test.tsx b/web/src/features/lines/Lines.test.tsx similarity index 100% rename from web/src/pages/Lines.test.tsx rename to web/src/features/lines/Lines.test.tsx diff --git a/web/src/pages/Lines.tsx b/web/src/features/lines/Lines.tsx similarity index 86% rename from web/src/pages/Lines.tsx rename to web/src/features/lines/Lines.tsx index aace79c..def8c83 100644 --- a/web/src/pages/Lines.tsx +++ b/web/src/features/lines/Lines.tsx @@ -1,3 +1,4 @@ +import { type CountryOption, type LineRow, lineStateLabels, candidateReasonLabels, readinessLabels, voWiFiStateLabels, voWiFiReadinessLabels, phoneNumbers, egressLabel, canRequestVoWiFiActivation } from './presentation' import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, Button, Card, Descriptions, Divider, Drawer, Empty, Flex, Grid, Input, Modal, Radio, Select, Space, Table, Tag, Typography } from 'antd' @@ -21,58 +22,9 @@ import { putLineEgressBindingMutation, updateManagedLineMutation, } from '@/api/generated/@tanstack/react-query.gen' -import type { LineCandidate, LineEgressBinding, ManagedLine, PhoneNumberObservation, PhoneNumberSource, VoWiFiLineState } from '@/api/generated/types.gen' +import type { LineCandidate, LineEgressBinding, ManagedLine, VoWiFiLineState } from '@/api/generated/types.gen' import { PageHeader, PageSection, ResponsiveDataView } from '@/components/Page' -type CountryOption = { code: string; name: string } -type LineRow = ManagedLine & { binding?: LineEgressBinding; voWiFi?: VoWiFiLineState } - -const lineStateLabels: Record = { - ready: '就绪', 'modem-offline': '模组离线', 'sim-unavailable': 'SIM / Profile 不可用', -} -const candidateReasonLabels: Record = { - READY: '可添加', MODEM_OFFLINE: '模组离线', SIM_ABSENT: '未插入 SIM', - SIM_UNAVAILABLE: 'SIM / Profile 不可用', ALREADY_ADDED: '已添加', BINDING_CONFLICT: '绑定身份冲突', -} -const readinessLabels: Record = { - READY: '出口可用', EGRESS_NOT_CONFIGURED: '尚未配置出口', LINE_VOWIFI_UNSUPPORTED: '线路不支持 Host VoWiFi', - SUBSCRIPTION_NOT_SELECTED: '尚未选择订阅', COUNTRY_NOT_FOUND: '当前订阅没有该国家', - MIHOMO_NOT_RUNNING: 'Mihomo 未运行', MIHOMO_RESTART_REQUIRED: '等待 Mihomo 重启应用订阅', -} -const voWiFiStateLabels: Record = { - stopped: '已停用', starting: '正在启动', connecting: '连接 ePDG', registering: '注册 IMS', - online: '在线', reconnecting: '正在重连', stopping: '正在停用', failed: '运行失败', -} -const voWiFiReadinessLabels: Record = { - READY: '可以激活', EGRESS_NOT_CONFIGURED: '请先明确配置出口', LINE_VOWIFI_UNSUPPORTED: '线路不支持 Host VoWiFi', - LINE_HARDWARE_NOT_READY: '模组或 SIM / Profile 尚未就绪', SUBSCRIPTION_NOT_SELECTED: '尚未选择订阅', - COUNTRY_NOT_FOUND: '当前订阅没有该国家', MIHOMO_NOT_RUNNING: 'Mihomo 未运行', - MIHOMO_RESTART_REQUIRED: '需要先重启 Mihomo 应用订阅', -} -const phoneNumberSourceLabels: Record = { - 'cellular-sim': '蜂窝 SIM', ims: 'IMS', -} - -function phoneNumbers(observations: PhoneNumberObservation[]) { - if (!observations.length) return 未获取 - return {observations.map((observation) => - {observation.number} - {observation.sources.map((source) => {phoneNumberSourceLabels[source]})} - )} -} - -function egressLabel(binding?: LineEgressBinding) { - if (!binding || binding.mode === 'unconfigured') return '未配置' - if (binding.mode === 'direct') return '直连' - return `${binding.countryName || binding.countryCode} (${binding.countryCode})` -} - -function canRequestVoWiFiActivation(state?: VoWiFiLineState) { - return Boolean(state && !state.desiredActive && ( - state.eligible || state.readinessCode === 'MIHOMO_NOT_RUNNING' || state.readinessCode === 'MIHOMO_RESTART_REQUIRED' - )) -} - export default function Lines() { const compact = !Grid.useBreakpoint().md const queryClient = useQueryClient() diff --git a/web/src/features/lines/presentation.tsx b/web/src/features/lines/presentation.tsx new file mode 100644 index 0000000..18ba641 --- /dev/null +++ b/web/src/features/lines/presentation.tsx @@ -0,0 +1,50 @@ +import { Flex, Tag, Typography } from 'antd' +import type { LineCandidate, LineEgressBinding, ManagedLine, PhoneNumberObservation, PhoneNumberSource, VoWiFiLineState } from '@/api/generated/types.gen' +export type CountryOption = { code: string; name: string } +export type LineRow = ManagedLine & { binding?: LineEgressBinding; voWiFi?: VoWiFiLineState } + +export const lineStateLabels: Record = { + ready: '就绪', 'modem-offline': '模组离线', 'sim-unavailable': 'SIM / Profile 不可用', +} +export const candidateReasonLabels: Record = { + READY: '可添加', MODEM_OFFLINE: '模组离线', SIM_ABSENT: '未插入 SIM', + SIM_UNAVAILABLE: 'SIM / Profile 不可用', ALREADY_ADDED: '已添加', BINDING_CONFLICT: '绑定身份冲突', +} +export const readinessLabels: Record = { + READY: '出口可用', EGRESS_NOT_CONFIGURED: '尚未配置出口', LINE_VOWIFI_UNSUPPORTED: '线路不支持 Host VoWiFi', + SUBSCRIPTION_NOT_SELECTED: '尚未选择订阅', COUNTRY_NOT_FOUND: '当前订阅没有该国家', + MIHOMO_NOT_RUNNING: 'Mihomo 未运行', MIHOMO_RESTART_REQUIRED: '等待 Mihomo 重启应用订阅', +} +export const voWiFiStateLabels: Record = { + stopped: '已停用', starting: '正在启动', connecting: '连接 ePDG', registering: '注册 IMS', + online: '在线', reconnecting: '正在重连', stopping: '正在停用', failed: '运行失败', +} +export const voWiFiReadinessLabels: Record = { + READY: '可以激活', EGRESS_NOT_CONFIGURED: '请先明确配置出口', LINE_VOWIFI_UNSUPPORTED: '线路不支持 Host VoWiFi', + LINE_HARDWARE_NOT_READY: '模组或 SIM / Profile 尚未就绪', SUBSCRIPTION_NOT_SELECTED: '尚未选择订阅', + COUNTRY_NOT_FOUND: '当前订阅没有该国家', MIHOMO_NOT_RUNNING: 'Mihomo 未运行', + MIHOMO_RESTART_REQUIRED: '需要先重启 Mihomo 应用订阅', +} +export const phoneNumberSourceLabels: Record = { + 'cellular-sim': '蜂窝 SIM', ims: 'IMS', +} + +export function phoneNumbers(observations: PhoneNumberObservation[]) { + if (!observations.length) return 未获取 + return {observations.map((observation) => + {observation.number} + {observation.sources.map((source) => {phoneNumberSourceLabels[source]})} + )} +} + +export function egressLabel(binding?: LineEgressBinding) { + if (!binding || binding.mode === 'unconfigured') return '未配置' + if (binding.mode === 'direct') return '直连' + return `${binding.countryName || binding.countryCode} (${binding.countryCode})` +} + +export function canRequestVoWiFiActivation(state?: VoWiFiLineState) { + return Boolean(state && !state.desiredActive && ( + state.eligible || state.readinessCode === 'MIHOMO_NOT_RUNNING' || state.readinessCode === 'MIHOMO_RESTART_REQUIRED' + )) +} diff --git a/web/src/pages/Messages.test.tsx b/web/src/features/messages/Messages.test.tsx similarity index 90% rename from web/src/pages/Messages.test.tsx rename to web/src/features/messages/Messages.test.tsx index e7f5455..fd6dc80 100644 --- a/web/src/pages/Messages.test.tsx +++ b/web/src/features/messages/Messages.test.tsx @@ -42,6 +42,7 @@ function installMatchMedia(matches: boolean) { } function installApi(requests: Request[], options?: { + sendStatus?: 'queued' | 'unconfirmed' historyCursor?: boolean alpha?: boolean deletedLine?: boolean @@ -89,7 +90,7 @@ function installApi(requests: Request[], options?: { const body = await request.clone().json() as { operationId: string; lineId: string; destination: string; body: string } return json(sms({ id: 'message_sent_1234567', operationId: body.operationId, direction: 'outbound', lineId: body.lineId, - remoteAddress: body.destination, body: body.body, status: 'sent', providerMessageId: 'provider-message-sent', + remoteAddress: body.destination, body: body.body, status: options?.sendStatus ?? 'sent', providerMessageId: 'provider-message-sent', createdAt: '2026-08-10T09:00:00Z', updatedAt: '2026-08-10T09:00:00Z', sentAt: '2026-08-10T09:00:00Z', }), 201) } @@ -270,3 +271,24 @@ describe('Messages conversations', () => { }) }) + + +describe('uncertain submission recovery', () => { + it.each(['queued', 'unconfirmed'] as const)('keeps the original %s operation after the device becomes unavailable', async (sendStatus) => { + const requests: Request[] = [] + const options: { sendStatus: 'queued' | 'unconfirmed'; allIneligible?: boolean } = { sendStatus } + installApi(requests, options) + const { queryClient } = renderPage() + fireEvent.click(await screen.findByRole('button', { name: /测试联系人/ })) + await screen.findByLabelText('短信内容') + fireEvent.change(screen.getByLabelText('短信内容'), { target: { value: 'synthetic uncertain submission' } }) + fireEvent.click(screen.getByRole('button', { name: /发送短信/ })) + await screen.findByText('发送结果尚未确认') + options.allIneligible = true + await queryClient.invalidateQueries() + fireEvent.click(screen.getByRole('button', { name: /查询发送结果/ })) + await waitFor(() => expect(requests.filter(request => request.method === 'POST' && new URL(request.url).pathname === '/api/v1/messages')).toHaveLength(2)) + const submissions = requests.filter(request => request.method === 'POST' && new URL(request.url).pathname === '/api/v1/messages') + expect(await submissions[1]!.clone().json()).toEqual(await submissions[0]!.clone().json()) + }) +}) diff --git a/web/src/pages/Messages.tsx b/web/src/features/messages/Messages.tsx similarity index 94% rename from web/src/pages/Messages.tsx rename to web/src/features/messages/Messages.tsx index 793f64b..89e9976 100644 --- a/web/src/pages/Messages.tsx +++ b/web/src/features/messages/Messages.tsx @@ -1,3 +1,4 @@ +import { useSMSSubmission } from './useSMSSubmission' import { ArrowLeftOutlined, ContactsOutlined, @@ -43,12 +44,11 @@ import { listMessagesInfiniteOptions, listMessagesInfiniteQueryKey, markMessageConversationReadMutation, - sendMessageMutation, updateContactMutation, } from '@/api/generated/@tanstack/react-query.gen' import type { Contact, ManagedLine } from '@/api/generated/types.gen' -import { smsMessagesForDisplay } from '@/messages/order' -import { smsStatusPresentation } from '@/messages/status' +import { smsMessagesForDisplay } from '@/features/messages/order' +import { smsStatusPresentation } from '@/features/messages/status' type ComposerValues = { body: string } type ContactValues = { displayName: string; phoneNumber: string } @@ -56,10 +56,6 @@ type NewConversationValues = { recipient: string } const numericAddress = /^\+?[0-9]{3,20}$/ -function operationId() { - return crypto.randomUUID?.() ?? `operation_${Date.now()}_abcdefghijkl` -} - function canSendOnLine(line: ManagedLine) { return line.state === 'ready' && (line.capabilities.sms || line.capabilities.hostVoWifiAuth) } @@ -88,6 +84,7 @@ export default function Messages() { const [mobileDetail, setMobileDetail] = useState(false) const [selectedLineID, setSelectedLineID] = useState('') const [operationError, setOperationError] = useState() + const [newConversationOpen, setNewConversationOpen] = useState(false) const [contactsOpen, setContactsOpen] = useState(false) const [editingContactID, setEditingContactID] = useState('') @@ -144,21 +141,7 @@ export default function Messages() { ]) } - const send = useMutation({ - ...sendMessageMutation(), - onSuccess: async (result) => { - await invalidateMessages() - composerForm.resetFields(['body']) - if (temporaryAddress === result.remoteAddress) setTemporaryAddress('') - if (result.status === 'unconfirmed') { - void message.warning(result.errorCode === 'IMS_SMS_ACCEPTED_AWAITING_REPORT' - ? '短信已提交,正在等待运营商确认,请勿重复发送。' - : '短信可能已经送达,但运营商未返回最终确认,请勿重复发送。') - } else if (result.status === 'failed') void message.error('短信发送失败。') - else void message.success('短信已发送。') - }, - onError: setOperationError, - }) + const { send, pendingSend, submit } = useSMSSubmission({ form: composerForm, lineId: selectedLineID, address: selectedAddress, temporaryAddress, setTemporaryAddress, invalidateMessages, onError: setOperationError }) const removeMessage = useMutation({ ...deleteMessageMutation(), onSuccess: invalidateMessages, @@ -416,17 +399,18 @@ export default function Messages() { {addressReplyable && !selectedLineID && eligibleLines.length === 0 && } {addressReplyable && selectedLineID && !selectedLineAvailable && } {linesQuery.isError && } + {pendingSend && !send.isPending && } form={composerForm} layout="vertical" onFinish={(values) => { setOperationError(undefined) - send.mutate({ body: { operationId: operationId(), lineId: selectedLineID, destination: selectedAddress, body: values.body } }) + submit(values.body) }}> - - + - + :
} diff --git a/web/src/messages/order.test.ts b/web/src/features/messages/order.test.ts similarity index 100% rename from web/src/messages/order.test.ts rename to web/src/features/messages/order.test.ts diff --git a/web/src/messages/order.ts b/web/src/features/messages/order.ts similarity index 100% rename from web/src/messages/order.ts rename to web/src/features/messages/order.ts diff --git a/web/src/messages/status.test.ts b/web/src/features/messages/status.test.ts similarity index 100% rename from web/src/messages/status.test.ts rename to web/src/features/messages/status.test.ts diff --git a/web/src/messages/status.ts b/web/src/features/messages/status.ts similarity index 100% rename from web/src/messages/status.ts rename to web/src/features/messages/status.ts diff --git a/web/src/features/messages/useSMSSubmission.ts b/web/src/features/messages/useSMSSubmission.ts new file mode 100644 index 0000000..bd54079 --- /dev/null +++ b/web/src/features/messages/useSMSSubmission.ts @@ -0,0 +1,50 @@ +import { useMutation } from '@tanstack/react-query' +import { App, type FormInstance } from 'antd' +import { useRef, useState } from 'react' +import { sessionGeneration } from '@/api/session' +import { ApiClientError } from '@/api/errors' +import { sendMessageMutation } from '@/api/generated/@tanstack/react-query.gen' +import type { SendMessageData } from '@/api/generated/types.gen' + +type Request = SendMessageData['body'] +type Options = { form: FormInstance<{ body: string }>; lineId: string; address: string; temporaryAddress: string; setTemporaryAddress: (value: string) => void; invalidateMessages: () => Promise; onError: (error: unknown) => void } + +export function useSMSSubmission(options: Options) { + const { message } = App.useApp() + const requestSession = useRef(sessionGeneration()) + const [pendingSend, setPendingSend] = useState() + const currentSelection = useRef(options) + currentSelection.current = options + const send = useMutation({ + ...sendMessageMutation(), + onSuccess: async (result, variables) => { + if (requestSession.current !== sessionGeneration()) return + if (result.status === 'sent' || result.status === 'failed') { + setPendingSend(undefined) + if (currentSelection.current.address === variables.body.destination && currentSelection.current.lineId === variables.body.lineId && options.form.getFieldValue('body') === variables.body.body) options.form.resetFields(['body']) + } + await options.invalidateMessages() + if (currentSelection.current.temporaryAddress === result.remoteAddress) options.setTemporaryAddress('') + if (result.status === 'unconfirmed') { + void message.warning(result.errorCode === 'IMS_SMS_ACCEPTED_AWAITING_REPORT' + ? '短信已提交,正在等待运营商确认,请勿重复发送。' + : '短信可能已经送达,但运营商未返回最终确认,请勿重复发送。') + } else if (result.status === 'queued') void message.warning('短信正在处理,请查询原请求。') + else if (result.status === 'failed') void message.error('短信发送失败。') + else void message.success('短信已发送。') + }, + onError: (error) => { + if (requestSession.current !== sessionGeneration()) return + if (error instanceof ApiClientError && error.kind === 'http' && error.status && error.status >= 400 && error.status < 500 && error.status !== 408) setPendingSend(undefined) + options.onError(error) + }, + }) + + function submit(body: string) { + const request = pendingSend ?? { operationId: crypto.randomUUID?.() ?? `operation_${Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')}`, lineId: options.lineId, destination: options.address, body } + requestSession.current = sessionGeneration() + setPendingSend(request) + send.mutate({ body: request }) + } + return { send, pendingSend, submit } +} diff --git a/web/src/pages/Notifications.test.tsx b/web/src/features/notifications/Notifications.test.tsx similarity index 97% rename from web/src/pages/Notifications.test.tsx rename to web/src/features/notifications/Notifications.test.tsx index 8848eff..360096a 100644 --- a/web/src/pages/Notifications.test.tsx +++ b/web/src/features/notifications/Notifications.test.tsx @@ -13,7 +13,7 @@ const appChannel = { id: 'channel_AAAAAAAAAAAAAAAAAAAAAA', provider: 'feishu', deliveryMode: 'feishu_app', targetType: 'authorized_user', displayName: '飞书私聊', webhookHint: 'open.feishu.cn', signingSecretConfigured: false, enabled: true, eventKinds: ['sms.received', 'sms.failed', 'call.incoming', 'call.missed', 'system.degraded'], - lastDeliveryAt: '2026-08-11T00:00:00Z', lastDeliveryStatus: 'success', lastErrorCode: '', + lastDeliveryAt: '2026-08-11T00:00:00Z', pendingCount: 0, failedCount: 0, lastDeliveryStatus: 'success', lastErrorCode: '', } describe('Feishu notification binding', () => { diff --git a/web/src/pages/Notifications.tsx b/web/src/features/notifications/Notifications.tsx similarity index 86% rename from web/src/pages/Notifications.tsx rename to web/src/features/notifications/Notifications.tsx index 96a13d7..8387b67 100644 --- a/web/src/pages/Notifications.tsx +++ b/web/src/features/notifications/Notifications.tsx @@ -1,3 +1,4 @@ +import { eventLabels, events, bindingMessages, bindingErrors } from './presentation' import { DeleteOutlined, EditOutlined, ExportOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, Button, Card, Checkbox, Descriptions, Form, Grid, Input, Modal, Popconfirm, Select, Space, Tag, Typography } from 'antd' @@ -16,7 +17,7 @@ import { testNotificationChannelMutation, updateNotificationChannelMutation, } from '@/api/generated/@tanstack/react-query.gen' -import type { FeishuNotificationBinding, NotificationChannel, NotificationEventKind } from '@/api/generated/types.gen' +import type { NotificationChannel, NotificationEventKind } from '@/api/generated/types.gen' import { PageHeader, PageSection, ResponsiveDataView } from '@/components/Page' type ChannelValues = { @@ -28,26 +29,6 @@ type ChannelValues = { } type EditValues = { displayName: string; eventKinds: NotificationEventKind[] } -const events: NotificationEventKind[] = ['sms.received', 'sms.failed', 'call.incoming', 'call.missed', 'system.degraded'] -const bindingMessages: Record = { - idle: '尚未发起绑定。', - waiting: '等待管理员在飞书完成授权。', - testing: '授权完成,正在发送绑定测试消息。', - succeeded: '飞书私聊绑定成功。', - failed: '绑定失败,可重新生成验证链接。', - expired: '验证链接已过期,请重新发起绑定。', - cancelled: '绑定已取消。', -} -const bindingErrors: Record = { - FEISHU_BINDING_DENIED: '管理员拒绝了飞书授权。', - FEISHU_BINDING_EXPIRED: '飞书授权已过期。', - FEISHU_BINDING_LARK_UNSUPPORTED: '当前仅支持飞书中国版租户。', - FEISHU_BINDING_RESULT_INVALID: '飞书返回了无法使用的授权结果。', - FEISHU_BINDING_PROVIDER_FAILED: '暂时无法连接飞书授权服务。', - FEISHU_BINDING_TEST_FAILED: '授权完成,但测试私聊未能送达。飞书侧应用可能已保留。', - FEISHU_BINDING_PERSIST_FAILED: '测试消息已发送,但本地保存失败。飞书侧应用可能已保留。', -} - export default function Notifications() { const compact = !Grid.useBreakpoint().md const queryClient = useQueryClient() @@ -113,7 +94,8 @@ export default function Notifications() { { title: '名称', dataIndex: 'displayName' }, { title: '平台', dataIndex: 'provider' }, { title: '目标', render: (_, record) => record.targetType === 'authorized_user' ? '授权用户私聊' : record.webhookHint }, - { title: '事件', dataIndex: 'eventKinds', render: (value: NotificationEventKind[]) => {value.map((event) => {event})} }, + { title: '事件', dataIndex: 'eventKinds', render: (value: NotificationEventKind[]) => {value.map((event) => {eventLabels[event]})} }, + { title: '待投递 / 失败', render: (_value, record) => `${record.pendingCount} / ${record.failedCount}` }, { title: '最近投递', dataIndex: 'lastDeliveryStatus' }, { title: '状态', dataIndex: 'enabled', render: (value: boolean) => {value ? '启用' : '停用'} }, { title: '操作', render: (_, record) => actions(record) }, @@ -144,7 +126,7 @@ export default function Notifications() { - form={form} layout={compact ? 'vertical' : 'inline'} initialValues={{ eventKinds: [] }} onFinish={(values) => { + form={form} layout={compact ? 'vertical' : 'inline'} initialValues={{ eventKinds: events }} onFinish={(values) => { setOperationError(undefined) create.mutate({ body: { ...values, signingSecret: values.signingSecret ?? '', enabled: true } }) }}> @@ -152,7 +134,7 @@ export default function Notifications() { - ({ value: event, label: event }))} /> + ({ value: event, label: eventLabels[event] }))} /> @@ -160,14 +142,15 @@ export default function Notifications() { {record.enabled ? '启用' : '停用'}}>{record.eventKinds.map((event) => {event})} }, + { key: 'events', label: '事件', children: {record.eventKinds.map((event) => {eventLabels[event]})} }, + { key: 'queue', label: '待投递 / 失败', children: `${record.pendingCount} / ${record.failedCount}` }, { key: 'delivery', label: '最近投递', children: record.lastDeliveryStatus || '—' }, ]} />{actions(record)}} /> setEditing(undefined)} onOk={() => editForm.submit()} confirmLoading={Boolean(editing && busyKey === `update:${editing.id}`)}> form={editForm} layout="vertical" onFinish={(values) => { if (editing) mutateSettings(editing, values) }}> - ({ value: event, label: event }))} /> + ({ value: event, label: eventLabels[event] }))} /> diff --git a/web/src/features/notifications/presentation.ts b/web/src/features/notifications/presentation.ts new file mode 100644 index 0000000..6a5fa85 --- /dev/null +++ b/web/src/features/notifications/presentation.ts @@ -0,0 +1,24 @@ +import type { FeishuNotificationBinding, NotificationEventKind } from '@/api/generated/types.gen' +export const eventLabels: Record = { + 'sms.received': '收到短信', 'sms.failed': '短信失败', 'call.incoming': '来电', 'call.missed': '未接来电', 'system.degraded': '系统异常', + 'vowifi.connected': 'VoWiFi 连接', 'vowifi.disconnected': 'VoWiFi 断开', 'cellular.connected': '蜂窝网络连接', 'cellular.disconnected': '蜂窝网络丢失', +} +export const events = Object.keys(eventLabels) as NotificationEventKind[] +export const bindingMessages: Record = { + idle: '尚未发起绑定。', + waiting: '等待管理员在飞书完成授权。', + testing: '授权完成,正在发送绑定测试消息。', + succeeded: '飞书私聊绑定成功。', + failed: '绑定失败,可重新生成验证链接。', + expired: '验证链接已过期,请重新发起绑定。', + cancelled: '绑定已取消。', +} +export const bindingErrors: Record = { + FEISHU_BINDING_DENIED: '管理员拒绝了飞书授权。', + FEISHU_BINDING_EXPIRED: '飞书授权已过期。', + FEISHU_BINDING_LARK_UNSUPPORTED: '当前仅支持飞书中国版租户。', + FEISHU_BINDING_RESULT_INVALID: '飞书返回了无法使用的授权结果。', + FEISHU_BINDING_PROVIDER_FAILED: '暂时无法连接飞书授权服务。', + FEISHU_BINDING_TEST_FAILED: '授权完成,但测试私聊未能送达。飞书侧应用可能已保留。', + FEISHU_BINDING_PERSIST_FAILED: '测试消息已发送,但本地保存失败。飞书侧应用可能已保留。', +} diff --git a/web/src/pages/Login.test.tsx b/web/src/pages/Login.test.tsx index 7a6f597..b67b99f 100644 --- a/web/src/pages/Login.test.tsx +++ b/web/src/pages/Login.test.tsx @@ -3,9 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { App } from 'antd' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSetupStatusQueryKey } from '@/api/generated/@tanstack/react-query.gen' -import type { SetupStatusResponse } from '@/api/generated/types.gen' -import { configureApiClient } from '@/api/setupClient' +import { configureApiClient } from '@/api/configureClient' import { json } from '@/test/render' import LoginPage from './Login' @@ -39,18 +37,7 @@ describe('LoginPage password-manager semantics', () => { expect(password).toHaveAttribute('autocomplete', 'current-password') }) - it.each([ - ['setup is incomplete', true, '/setup'], - ['setup is complete', false, '/dashboard'], - ] as const)('uses cached setup status after login when %s', async (_label, setupRequired, expectedPath) => { - const setup: SetupStatusResponse = { - installationState: setupRequired ? 'uninitialized' : 'ready', - phase: setupRequired ? 'bootstrap-required' : 'complete', - setupRequired, - businessApiAvailable: !setupRequired, - bootstrapGenerationAvailable: false, - supportedFlows: ['create-new'], - } + it('opens the dashboard after administrator login', async () => { const requests: string[] = [] vi.stubGlobal('fetch', vi.fn(async (request: Request) => { const path = new URL(request.url).pathname @@ -61,12 +48,10 @@ describe('LoginPage password-manager semantics', () => { throw new Error(`unexpected ${path}`) })) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) - queryClient.setQueryData(getSetupStatusQueryKey(), setup) render( } /> - 初始化页面} /> 概览页面} /> ) @@ -75,7 +60,7 @@ describe('LoginPage password-manager semantics', () => { fireEvent.change(screen.getByPlaceholderText('密码'), { target: { value: 'synthetic-password' } }) fireEvent.click(screen.getByRole('button', { name: /登\s*录/ })) - await waitFor(() => expect(screen.getByLabelText('当前路径')).toHaveTextContent(expectedPath)) + await waitFor(() => expect(screen.getByLabelText('当前路径')).toHaveTextContent('/dashboard')) expect(requests).toEqual(['/api/v1/auth/login']) }) }) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 4a7c2e7..7ce8a1b 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -4,8 +4,8 @@ import { Alert, App, Button, Card, Form, Input, Typography } from 'antd' import { useState } from 'react' import { useNavigate } from 'react-router' import { displayApiError } from '@/api/errors' -import { getAuthSessionQueryKey, getSetupStatusQueryKey, loginMutation } from '@/api/generated/@tanstack/react-query.gen' -import type { SetupStatusResponse } from '@/api/generated/types.gen' +import { getAuthSessionQueryKey, loginMutation } from '@/api/generated/@tanstack/react-query.gen' +import { advanceSession } from '@/api/session' type LoginValues = { username: string; password: string } @@ -17,10 +17,11 @@ export default function LoginPage() { const login = useMutation({ ...loginMutation(), onSuccess: (session) => { + advanceSession() + queryClient.clear() queryClient.setQueryData(getAuthSessionQueryKey(), session) - const setup = queryClient.getQueryData(getSetupStatusQueryKey()) void message.success('登录成功') - navigate(setup?.setupRequired ? '/setup' : '/dashboard', { replace: true }) + navigate('/dashboard', { replace: true }) }, onError: setError, }) diff --git a/web/src/pages/RemainingPages.test.tsx b/web/src/pages/RemainingPages.test.tsx index be0c2c7..5050c8e 100644 --- a/web/src/pages/RemainingPages.test.tsx +++ b/web/src/pages/RemainingPages.test.tsx @@ -3,9 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { json, renderPage } from '@/test/render' import Dashboard from './Dashboard' import Mihomo from './Mihomo' -import Notifications from './Notifications' +import Notifications from '@/features/notifications/Notifications' import Settings from './Settings' -import Setup from './Setup' describe('remaining page boundaries', () => { afterEach(() => vi.unstubAllGlobals()) @@ -65,7 +64,7 @@ describe('remaining page boundaries', () => { id: 'channel_AAAAAAAAAAAAAAAAAAAAAA', provider: 'wecom', displayName: 'Synthetic Channel', deliveryMode: 'webhook', targetType: 'webhook', webhookHint: 'qyapi.weixin.qq.com', signingSecretConfigured: true, enabled: true, - eventKinds: ['sms.received'], lastDeliveryAt: '', lastDeliveryStatus: 'never', lastErrorCode: '', + eventKinds: ['sms.received'], lastDeliveryAt: '', pendingCount: 0, failedCount: 0, lastDeliveryStatus: 'never', lastErrorCode: '', }] }) throw new Error(`unexpected ${path}`) })) @@ -87,21 +86,4 @@ describe('remaining page boundaries', () => { expect(fetch).not.toHaveBeenCalled() }) - it('resumes a bounded setup session without exposing bootstrap material', async () => { - vi.stubGlobal('fetch', vi.fn(async (request: Request) => { - const path = new URL(request.url).pathname - if (path === '/api/v1/setup/session') return json({ - authorized: true, expiresAt: '2099-01-01T00:00:00Z', selectedFlow: 'create-new', supportedFlows: ['create-new'], - administratorConfigured: true, administratorUsername: 'synthetic_admin', instanceDefaultLocale: 'zh-CN', - storageConfigured: false, dataRoot: '/synthetic/data', recordingsRoot: '/synthetic/recordings', - httpsConfigured: false, httpsConfirmed: false, httpsMode: '', httpsListenUrl: '', httpsRootFingerprint: '', httpsLeafNotAfter: '', - hardwareReviewed: false, hardwareDeviceCount: 0, hardwareLineCount: 0, hardwareInventoryDigest: '', - }) - throw new Error(`unexpected ${path}`) - })) - renderPage() - expect(await screen.findByText('当前管理员:synthetic_admin')).toBeInTheDocument() - expect(screen.getByLabelText('录音目录')).toHaveValue('/synthetic/recordings') - expect(window.location.hash).not.toContain('bootstrap=') - }) }) diff --git a/web/src/pages/Setup.tsx b/web/src/pages/Setup.tsx deleted file mode 100644 index 53a3654..0000000 --- a/web/src/pages/Setup.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Alert, Button, Card, Form, Input, Result, Spin } from 'antd' -import { useEffect, useMemo, useState } from 'react' -import { useNavigate } from 'react-router' -import { displayApiError } from '@/api/errors' -import { - completeSetupMutation, - consumeSetupBootstrapMutation, - getSetupSessionOptions, - getSetupSessionQueryKey, - getSetupStatusQueryKey, - putSetupStorageMutation, -} from '@/api/generated/@tanstack/react-query.gen' -import type { SetupSessionResponse } from '@/api/generated/types.gen' -import { PageHeader } from '@/components/Page' - -type StorageValues = { recordingsRoot: string } - -export default function SetupPage() { - const navigate = useNavigate() - const queryClient = useQueryClient() - const [error, setError] = useState() - const bootstrapCode = useMemo(() => new URLSearchParams(window.location.hash.slice(1)).get('bootstrap') ?? '', []) - const session = useQuery({ ...getSetupSessionOptions(), enabled: !bootstrapCode }) - const consume = useMutation({ - ...consumeSetupBootstrapMutation(), - onSuccess: (value) => queryClient.setQueryData(getSetupSessionQueryKey(), value), - onError: setError, - }) - useEffect(() => { - if (!bootstrapCode || consume.isPending || consume.isSuccess || consume.isError) return - window.history.replaceState({}, '', `${window.location.pathname}${window.location.search}`) - consume.mutate({ body: { bootstrapCode } }) - }, [bootstrapCode, consume]) - const setupSession = (consume.data ?? session.data) as SetupSessionResponse | undefined - const storage = useMutation({ ...putSetupStorageMutation(), onError: setError }) - const complete = useMutation({ - ...completeSetupMutation(), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ queryKey: getSetupStatusQueryKey() }) - const target = new URL(result.managementUrl, window.location.href) - if (target.origin !== window.location.origin) window.location.replace(target.href) - else navigate('/login', { replace: true }) - }, - onError: setError, - }) - const loading = session.isPending || consume.isPending - if (loading) return
- if (!setupSession) return - return
- - - - {Boolean(error) && } - - layout="vertical" - initialValues={{ recordingsRoot: setupSession.recordingsRoot }} - onFinish={async (values) => { - setError(undefined) - if (!setupSession.storageConfigured) { - try { - await storage.mutateAsync({ body: { recordingsRoot: values.recordingsRoot } }) - } catch { - return - } - } - complete.mutate({}) - }} - > - - - - -
-} diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx index de44213..3c9c05d 100644 --- a/web/src/test/render.tsx +++ b/web/src/test/render.tsx @@ -3,7 +3,7 @@ import { App } from 'antd' import type { ReactElement } from 'react' import { render } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { configureApiClient } from '@/api/setupClient' +import { configureApiClient } from '@/api/configureClient' import type { HardwareCapabilities, ManagedLine } from '@/api/generated/types.gen' configureApiClient() diff --git a/web/src/viteConfig.test.ts b/web/src/viteConfig.test.ts index 1e803f3..e69c6bd 100644 --- a/web/src/viteConfig.test.ts +++ b/web/src/viteConfig.test.ts @@ -4,7 +4,7 @@ import { loadConfigFromFile } from 'vite' import { describe, expect, it } from 'vitest' describe('Vite development proxy', () => { - it('preserves the trusted LAN Host used by setup completion URLs', async () => { + it('preserves the trusted LAN Host checked by the management API', async () => { const loaded = await loadConfigFromFile( { command: 'serve', mode: 'test' }, path.resolve(process.cwd(), 'vite.config.ts'), diff --git a/web/vite.config.ts b/web/vite.config.ts index 6d02625..a1c3c0a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -19,13 +19,22 @@ export default defineConfig({ proxy: { '/api': { target: process.env.VITE_API_PROXY_TARGET ?? 'http://127.0.0.1:8080', - // Preserve the trusted LAN-facing authority. Setup completion derives - // its management URL from this validated Host rather than the proxy target. + // Preserve the authority checked by the management HTTP boundary. changeOrigin: false, }, }, }, build: { outDir: 'dist', + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { name: 'react-runtime', test: /node_modules\/(?:react|react-dom|scheduler)\// }, + { name: 'api-validation', test: /node_modules\/zod\/|api\/generated\/zod\.gen/ }, + ], + }, + }, + }, }, }) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 2d8c2de..4170858 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ }, test: { environment: 'jsdom', + maxWorkers: 2, setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.{ts,tsx}'], },