From a5dd0bf088eb5f6606a064ba4cb922f31da6efd5 Mon Sep 17 00:00:00 2001 From: MengMengCode Date: Sun, 9 Aug 2026 14:43:34 +0800 Subject: [PATCH] Initial --- .dockerignore | 36 + .github/workflows/docker.yml | 65 ++ .github/workflows/release.yml | 137 +++ .gitignore | 6 + Dockerfile | 49 + LICENSE | 225 ++++ README.md | 557 +++++++++ cmd/vocat/cli.go | 37 + cmd/vocat/main.go | 43 +- cmd/vocat/menu.go | 377 ++++++ go.mod | 5 +- go.sum | 6 +- internal/buildinfo/buildinfo.go | 23 + internal/server/general_api.go | 7 +- internal/server/settings_api.go | 19 +- internal/server/settings_api_test.go | 15 + internal/server/sms_notifications.go | 438 +++++++ internal/server/sms_notifications_test.go | 54 + internal/server/telegram_bot.go | 1010 +++++++++++++++++ internal/server/telegram_bot_test.go | 62 + internal/store/domain_test.go | 37 + internal/store/sms.go | 41 + internal/update/asset_test.go | 24 + internal/update/github.go | 101 ++ internal/update/update.go | 316 ++++++ internal/update/verify.go | 58 + scripts/install.sh | 239 ++++ web/index.html | 2 +- web/src/components/Disclaimer.tsx | 119 +- web/src/components/proxy/UpstreamSection.tsx | 6 +- web/src/components/settings/BotTabs.tsx | 10 +- .../components/settings/NetworkAccessCard.tsx | 27 +- web/src/components/settings/PushTabs.tsx | 20 +- web/src/components/settings/model.ts | 2 +- .../components/shell/AuthenticatedShell.tsx | 6 +- web/src/components/shell/VersionBadge.tsx | 32 + web/src/lib/i18n-en.ts | 17 +- web/src/lib/i18n.tsx | 2 +- web/src/lib/useMediaQuery.ts | 17 + web/src/pages/DevicesPage.tsx | 55 +- web/src/pages/LoginPage.tsx | 7 +- web/src/pages/LogsPage.tsx | 22 +- 42 files changed, 4186 insertions(+), 145 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/vocat/cli.go create mode 100644 cmd/vocat/menu.go create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/server/sms_notifications.go create mode 100644 internal/server/sms_notifications_test.go create mode 100644 internal/server/telegram_bot.go create mode 100644 internal/server/telegram_bot_test.go create mode 100644 internal/update/asset_test.go create mode 100644 internal/update/github.go create mode 100644 internal/update/update.go create mode 100644 internal/update/verify.go create mode 100644 scripts/install.sh create mode 100644 web/src/components/shell/VersionBadge.tsx create mode 100644 web/src/lib/useMediaQuery.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0601a0c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Exclude everything not needed for the multi-stage build to keep context small. +.git +.gitignore +.github +.vscode +.idea +.claude +build/ +data/ +**/node_modules/ +web/dist/ +web/build/ +**/__pycache__/ +*.pyc +*.pyo +*.exe +*.dll +*.so +*.dylib +*.test +*.out +.DS_Store +Thumbs.db +Dockerfile +.dockerignore + +# Documentation is fine in build context but not strictly required; keep README +# available for reference and exclude everything else markdown. +*.md +!README.md + +# Local-only scripts and scratch output. +build/lists/ +*.cookies +*.session +vc.jar diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..aa99edf --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,65 @@ +name: docker + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Lowercase repository owner for ghcr.io + id: repo + run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Derive version from tag + id: version + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=0.0.0-dev" >> "$GITHUB_OUTPUT" + fi + echo "build_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + BUILD_TIME=${{ steps.version.outputs.build_time }} + tags: | + ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}:latest + ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}:${{ steps.version.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7013013 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,137 @@ +name: release-binaries + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + web: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Build embedded web UI + working-directory: web + run: | + npm ci --no-audit --no-fund + npm run build + - name: Upload embedded web UI + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + retention-days: 1 + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Test + run: go test ./... + + binaries: + needs: [web, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: linux-amd64 + goarch: amd64 + goarm: "" + filename: vocat-linux-amd64 + - target: linux-386 + goarch: "386" + goarm: "" + filename: vocat-linux-386 + - target: linux-arm64 + goarch: arm64 + goarm: "" + filename: vocat-linux-arm64 + - target: linux-armv7 + goarch: arm + goarm: "7" + filename: vocat-linux-armv7 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Download embedded web UI + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + - name: Build ${{ matrix.target }} + env: + GOOS: linux + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + CGO_ENABLED: "0" + OUTPUT: dist/${{ matrix.filename }} + VERSION: ${{ github.ref_name }} + run: | + mkdir -p dist + VERSION="${VERSION#v}" + BUILD_TIME="$(git show -s --format=%cI HEAD)" + go build -trimpath \ + -ldflags "-s -w -X vocat/internal/buildinfo.Version=${VERSION} -X vocat/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o "$OUTPUT" \ + ./cmd/vocat + chmod 0755 "$OUTPUT" + - name: Upload ${{ matrix.target }} + uses: actions/upload-artifact@v4 + with: + name: binary-${{ matrix.target }} + path: dist/${{ matrix.filename }} + if-no-files-found: error + retention-days: 1 + + github-release: + if: github.ref_type == 'tag' + needs: binaries + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Download binaries + uses: actions/download-artifact@v4 + with: + pattern: binary-* + path: dist + merge-multiple: true + - name: Generate checksums + working-directory: dist + run: sha256sum vocat-linux-* | sort > SHA256SUMS + - name: Create or update GitHub Release + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --clobber + else + gh release create "$GITHUB_REF_NAME" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + --verify-tag + fi diff --git a/.gitignore b/.gitignore index 5514800..d81703a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # ---- Binaries / build outputs ---- /vocat /vocat.exe +/build/ +/release.py /build/vocat /build/vocat-linux-amd64 /build/vocat-linux-amd64.exe @@ -27,9 +29,13 @@ web/node_modules/ build/__pycache__/ __pycache__/ *.pyc +# Local-only helpers carry internal hostnames/credentials and must never be +# committed. The whole build directory and the root release.py are ignored. +build/*.py # ---- Docs / scratch ---- *.md +!README.md *.txt build/lists/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f2b892 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1.7 + +# ---- Stage 1: build the web frontend ---- +FROM node:20-alpine AS web-builder +WORKDIR /web +COPY web/package.json web/package-lock.json* ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# ---- Stage 2: build the Go binary ---- +FROM golang:1.25-alpine AS go-builder +RUN apk add --no-cache git +WORKDIR /src + +ARG VERSION=0.1.0-dev +ARG BUILD_TIME="" + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# Overlay the freshly built frontend so go:embed web/dist picks it up. +COPY --from=web-builder /web/dist ./web/dist + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags "-s -w -X vocat/internal/buildinfo.Version=${VERSION} -X vocat/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o /out/vocat \ + ./cmd/vocat + +# ---- Stage 3: minimal runtime ---- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata && \ + addgroup -S -g 1000 vocat && \ + adduser -S -D -H -u 1000 -G vocat vocat + +RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \ + chown -R vocat:vocat /opt/vocat + +COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat + +USER vocat +VOLUME ["/opt/vocat/data"] +EXPOSE 7575 +ENV VOCAT_ADDR=0.0.0.0:7575 \ + VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db + +ENTRYPOINT ["/opt/vocat/bin/vocat"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..53fa7ef --- /dev/null +++ b/LICENSE @@ -0,0 +1,225 @@ +# Vocat Research & Evaluation License + +Version 1.0 + +Copyright (c) 2026 Vocat Project Authors + +All rights reserved except as expressly provided under this License. + +## 1. Purpose + +Vocat ("the Software") is a source-available telecommunications hardware testing system intended for research, education, development, and functional validation of Qualcomm-based cellular modules, including developer-built EC20-based hardware. + +This License grants limited permission to access and use the Software only under the conditions described below. + +## 2. Permitted Use + +Subject to full compliance with this License, you may use the Software solely for: + +a. personal, non-commercial hardware development; + +b. academic or educational research; + +c. use by schools, universities, laboratories, or non-profit organizations; + +d. functional testing of telecommunications modules that you own or are explicitly authorized to test; and + +e. testing performed with authorized test SIM cards, test eSIM profiles, development credentials, and approved testing infrastructure. + +No other rights are granted unless separately authorized in writing by the copyright holder. + +## 3. Non-Commercial Restriction + +The Software may not be used, directly or indirectly, for commercial or profit-oriented activities without prior written authorization from the copyright holder. + +Prohibited commercial activities include, but are not limited to: + +a. selling access to the Software; + +b. providing paid module-testing services using the Software; + +c. incorporating the Software into a commercial product or service; + +d. operating the Software as part of a revenue-generating telecommunications platform; or + +e. distributing modified versions for commercial benefit. + +## 4. Geographic Authorization + +Unless separately authorized in writing, operation of the Software is permitted only within the United States. + +Compilation, deployment, operation, or execution of the Software from unauthorized jurisdictions is prohibited. + +The Software may implement technical controls designed to verify whether an execution environment satisfies applicable geographic authorization requirements. + +## 5. Authorized SIM and eSIM Testing + +The Software may only be used with SIM cards, eSIM profiles, subscriber identities, credentials, or telecommunications resources that: + +a. are specifically designated for testing or development; or + +b. the user has explicit authorization to use for such testing. + +Users must not use production subscriber credentials belonging to another person or organization without authorization. + +The Software may reject SIM/eSIM resources that do not satisfy its testing policies. + +## 6. Restricted MCC/MNC Access + +For security, compliance, and anti-abuse purposes, certain Mobile Country Codes (MCCs), Mobile Network Codes (MNCs), operators, subscriber identities, or network environments may be restricted. + +This may include, without limitation, SIM cards associated with MCC 460. + +Users must not circumvent such restrictions by modifying subscriber identifiers, device configuration, runtime state, network routing, source code, binaries, or other technical mechanisms. + +## 7. Device Limits + +The Software may impose restrictions on the number of modules, modems, SIM/eSIM resources, computers, or testing devices that may be registered or tested. + +Users must not circumvent or artificially expand these limits. + +## 8. Evaluation Period + +Unless otherwise authorized, each authorized installation of the Software is provided for a maximum evaluation period of fourteen (14) days. + +After completing the applicable testing activity or reaching the end of the authorized evaluation period, whichever occurs first, the user must discontinue use of the Software and remove the applicable installation. + +A separate written authorization may extend this period. + +## 9. Security and Anti-Abuse Controls + +The Software may contain technical safeguards intended to enforce licensing, security, testing, and anti-abuse requirements. + +Such safeguards may include: + +* authorization validation; +* integrity verification; +* SIM/eSIM eligibility validation; +* MCC/MNC restrictions; +* geographic restrictions; +* device registration limits; +* expiration controls; +* runtime integrity checks; and +* automatic disablement or secure cleanup mechanisms. + +You may not intentionally circumvent, disable, remove, patch, spoof, interfere with, or otherwise defeat these safeguards. + +## 10. Modification + +You may modify the Software solely for your own authorized research, development, or educational purposes. + +Any modification must continue to comply with this License. + +Modification of the Software for the primary purpose of circumventing licensing restrictions, security protections, geographic restrictions, SIM/eSIM restrictions, device limits, authorization mechanisms, or anti-abuse controls is prohibited. + +## 11. Redistribution and Forks + +Public or private forks may be created solely for legitimate development or research purposes, provided that the fork continues to comply with this License. + +You may not publish, distribute, advertise, or make available a modified version of Vocat that intentionally: + +a. removes or disables the anti-abuse mechanisms; + +b. bypasses geographic restrictions; + +c. bypasses SIM/eSIM eligibility checks; + +d. bypasses device-count restrictions; + +e. bypasses authorization expiration; + +f. disables integrity validation; or + +g. facilitates activity otherwise prohibited by this License. + +Redistribution of a permitted modified version must retain: + +* this License; +* applicable copyright notices; +* attribution notices; and +* notices identifying material modifications made to the Software. + +## 12. Prohibited Uses + +The Software must not be used to: + +a. access telecommunications networks without authorization; + +b. impersonate another subscriber or device; + +c. use stolen, leaked, cloned, or otherwise unauthorized SIM/eSIM credentials; + +d. interfere with mobile network infrastructure; + +e. evade carrier security or access controls; + +f. bypass lawful carrier restrictions; + +g. facilitate telecommunications fraud; + +h. conduct unauthorized interception or surveillance; + +i. damage telecommunications equipment or networks; or + +j. violate applicable law or applicable carrier/network policies. + +## 13. Automatic Enforcement + +Where technically implemented, violation of authorization requirements may cause the Software to automatically refuse operation or disable affected functionality. + +Security-sensitive information, temporary credentials, cached testing information, or locally deployed runtime components may also be securely removed when required by the Software's security architecture. + +Such mechanisms are intended solely to protect the Software and associated testing infrastructure. + +## 14. No Warranty + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, RELIABILITY, AVAILABILITY, OR FITNESS FOR TELECOMMUNICATIONS USE. + +USE OF CELLULAR MODEMS, SIM CARDS, ESIM PROFILES, BASEBAND HARDWARE, RADIO EQUIPMENT, OR TELECOMMUNICATIONS NETWORKS MAY INVOLVE RISKS. + +YOU ASSUME ALL RISKS ARISING FROM USE OF THE SOFTWARE. + +## 15. Limitation of Liability + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE AUTHORS, COPYRIGHT HOLDERS, CONTRIBUTORS, AND DISTRIBUTORS OF THE SOFTWARE SHALL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY DAMAGES ARISING FROM THE USE OR INABILITY TO USE THE SOFTWARE. + +THIS INCLUDES, WITHOUT LIMITATION: + +* SIM or eSIM damage or deactivation; +* modem or baseband malfunction; +* hardware damage; +* loss of telecommunications service; +* loss of data; +* account or carrier restrictions; +* network access restrictions; +* regulatory consequences; +* service interruption; or +* damages resulting from unauthorized or prohibited use. + +## 16. Unauthorized Regional Use + +The authors and contributors assume no responsibility for use of the Software in any jurisdiction, territory, network, or environment where its use is unauthorized, restricted, or prohibited. + +The user is solely responsible for determining whether their intended use complies with applicable laws, regulations, network requirements, and contractual obligations. + +## 17. Termination + +Your rights under this License terminate automatically if you materially violate any provision of this License. + +Upon termination, you must cease using the Software and remove all copies under your control, except where retention is required by applicable law. + +## 18. Additional Authorization + +The copyright holder may grant separate written authorization for commercial use, additional jurisdictions, extended evaluation periods, additional testing devices, research partnerships, or other uses otherwise restricted by this License. + +Such authorization applies only to the party and scope expressly identified in writing. + +## 19. No Trademark Rights + +This License does not grant permission to use the Vocat name, logo, trademarks, service marks, or branding in a manner that suggests endorsement, sponsorship, certification, or official affiliation. + +## 20. Acceptance + +By downloading, compiling, installing, executing, modifying, or using the Software, you acknowledge that you have read and understood this License and agree to comply with its terms. + +If you do not agree to these terms, you are not granted permission to use the Software. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf27f88 --- /dev/null +++ b/README.md @@ -0,0 +1,557 @@ +# Vocat + +Vocat(代号)是一套面向 Qualcomm 蜂窝模组(首发 **Quectel EC20**)的**高通模块专业测试工具**,用于对自研 / 定制 EC20 外置模组进行功能验证与故障诊断。 + +它提供一个集中的 Web 测试环境,覆盖 AT 指令、USSD、短信收发检测、Wi-Fi Calling(VoWiFi)能力检测、eSIM 状态与卡策略管理、上游代理与设备绑定等常用功能,适用于开发者、研究人员、学校与实验室在授权测试环境下验证自研硬件是否工作正常。 + +> **重要声明:** Vocat 是 source-available(源码可见)软件,仅授权用于研究、教育、开发与硬件功能验证。不得用于商业电信服务、未授权网络接入、冒用他人身份或绕过运营商限制。详见 [LICENSE](LICENSE)。 + +--- + +## 概述 + +围绕 Qualcomm 蜂窝模组(如 Quectel EC20)开发定制硬件时,问题可能来自多个层面: + +- USB 通信 +- SIM 接口走线 +- 模组初始化 +- 供电稳定性 +- 基带通信 +- AT 指令通信 +- 短信收发检测 +- 运营商兼容性 +- IMS / Wi-Fi Calling 能力 +- eSIM / EID 相关限制 + +Vocat 为上述功能提供标准化测试环境,帮助判断自研模组行为是否符合预期。 + +典型场景: + +- 测试新组装的 EC20 USB 转接板 +- PCB 贴片完成后验证 EC20 通信 +- 验证 SIM 接口功能 +- 诊断 AT 指令通信问题 +- 检测短信收发能力 +- 检查基础运营商功能 +- 测试实验室蜂窝硬件 +- 蜂窝模组行为的教学演示 + +--- + +## 支持硬件 + +Vocat 主要面向 Qualcomm 蜂窝模组。首发目标平台: + +- Quectel EC20 +- EC20 Mini PCIe 变体 +- 基于 EC20 的定制 USB 转接板 +- 自研 EC20 核心 / 转接板 + +其它 Qualcomm 模组若暴露兼容的 modem 接口与 AT 指令功能,也可能可用。未显式列出的硬件不保证兼容。 + +--- + +## 功能 + +### 1. AT 指令检测 + +验证所连蜂窝模组是否正确响应标准 AT 指令。示例指令: + +```text +AT +ATI +AT+CPIN? +AT+CSQ +AT+COPS? +AT+CREG? +AT+CGREG? +``` + +可帮助识别: + +- USB 通信问题 +- 串口配置问题 +- 模组初始化失败 +- SIM 检测问题 +- 注册问题 +- 固件通信问题 + +> 出于安全考虑,一组会改变模组射频 / 分组域状态或直接拨号、发卡的指令(如 `+CFUN=`、`+CGATT=`、`+CGACT=`、`+CUSD=`、`+CMGS`、`ATD`、`ATA`、`ATH` 等)在 Web AT 通道被服务端拦截。需要执行这些操作时,请使用 Telegram 机器人或专用的检测端点。 + +### 2. USSD 检测 + +测试模组发送与接收 USSD 请求的能力。该功能主要面向开发用 SIM 卡与授权实验室测试环境。可用性取决于:模组固件、SIM 能力、运营商、网络配置与当前注册状态。 + +### 3. Wi-Fi Calling(VoWiFi)能力检测 + +提供与 Wi-Fi Calling 能力相关的诊断检测,检查 modem / IMS / SIM / 网络等信息,辅助判断所连模组是否**具备**支持 VoWiFi 的能力。成功的能力检测**不保证**在特定运营商下 VoWiFi 可用——实际可用性还取决于运营商开通、SIM 订阅、IMS 配置、固件、设备认证、网络策略、ePDG 接入与运营商允许名单等。 + +Vocat 的 VoWiFi 实现支持**上游 SOCKS5 代理**:可配置每个国家 / 区域对应的代理规则,并将设备绑定到指定上游,绑定变更会触发 VoWiFi 重连。上游代理在接入前会进行真实探测(TCP 连接 + SOCKS5 握手 + **UDP Associate** 探测,VoWiFi 依赖 UDP)。 + +### 4. 短信收发检测 + +测试所连模组的短信收发能力。检测功能包括: + +- 短信能力检测 +- 短信存储查看 +- 短信发送测试 +- 短信接收测试 +- modem 短信配置检查 + +只应使用授权的测试用 SIM 卡。为防止误用,向 `+86` 号段发送短信会被服务端拦截。 + +### 5. eSIM 与卡策略管理 + +EID / eSIM 相关验证,面向授权开发与测试环境: + +- eSIM 资产盘点:查看本机已写入的 eSIM profile(状态、ICCID、运营商等) +- profile 切换 / 禁用 / 重命名 / 删除 +- **eSIM 下载**:通过运营商 websheet 与 GSMA RSP 流程下载 profile(GET + SSE 流式返回下载进度) +- **卡策略(Card Policy)**:按 ICCID 配置 VoWiFi / 飞行模式 / APN / IP 版本等,策略持久化于数据库 + +结果仅作诊断参考。Vocat 不代表任何移动网络运营商、eSIM 平台、SM-DP+、EUM、GSMA 机构或设备厂商行事。 + +### 6. 模组信息 + +在模组支持时,可采集基础 modem 信息:厂商、型号、固件版本、IMEI、SIM 状态、ICCID、IMSI、网络注册状态、服务运营商、信号强度、USB modem 接口。敏感信息只应在授权测试环境下采集。 + +### 7. 日志与审计 + +- 实时日志流(SSE)与历史日志查询,支持等级、来源、搜索过滤、自动追尾、暂停、清空与导出 +- 日志保留策略可配置:无限制 / 按条数 / 按天数 +- 审计事件(auth、config 变更等)落库可查 + +### 8. 通知 + +支持 5 个通知渠道:**Telegram、Email、Webhook、Bark、PushPlus**。每个渠道可独立配置与测试连通性。短信到达可触发通知分发(每渠道独立 goroutine);Webhook 通知附带 HMAC-SHA256 签名与渲染模板。所有外发目标经 SSRF 防护(拦截 localhost、内网、云元数据等)。 + +### 9. Telegram 机器人 + +内置 Telegram bot(长轮询),提供指令式交互:查询设备状态、eSIM、切换 profile、VoWiFi 能力检测、短信查看与发送、`/call` 拨号并自动挂断(无语音)。敏感操作有内联键盘二次确认 + 随机令牌 + 2 分钟 TTL。配置在轮询之间热加载。 + +### 10. 响应式 Web 界面 + +前端为 React + Vite + Tailwind,支持桌面 / 平板 / 手机多尺寸自适应:手机端日志页控件堆叠 + 横向滚动、设备页列表↔详情互斥切换 + 返回键、主从页容器查询双列布局,以及中英文双语界面。 + +### 11. 硬件验证流程 + +可作为自研 EC20 板卡硬件验证流程的一环: + +```text +Custom PCB + ↓ +EC20 Module + ↓ +USB Interface + ↓ +Vocat + ↓ +AT / SIM / 短信 / 网络 / VoWiFi / eSIM 诊断 +``` + +PCB 贴片后尤为有用,可帮助判断问题源自硬件、USB 走线、供电、SIM 走线、固件、宿主系统还是运营商侧配置。 + +--- + +## 安全与访问控制 + +Vocat 实现了以下**实际生效**的安全与访问控制机制: + +- **认证与会话**:用户名 / 密码登录,`vocat_session`(HttpOnly)+ CSRF 双提交令牌(`vocat_csrf`),SameSite=Strict,`VOCAT_SECURE_COOKIES` 下启用 Secure + HSTS。 +- **登录限流**:登录端点限流,防暴力破解。 +- **网络访问控制**:可在设置中配置 `internal`(默认,仅 RFC1918 + loopback + link-local + ULA)或 `public` 模式,并维护自定义 CIDR 允许名单;非允许 IP 的请求被 403 拒绝。 +- **AT 指令防护**:拦截会修改射频 / 分组域状态或直接拨号发卡的指令。 +- **SIM 区域策略**:对 MCC 460 / 461(中国大陆)SIM 卡自动强制飞行模式并写入 `auto_region_block` 卡策略;VoWiFi 启用前亦做同样检查。 +- **短信目的端防护**:拦截向 `+86` 号段发送短信。 +- **设备数量限制**:单实例最多注册 5 台设备。 +- **SSRF 防护**:通知外发目标经地址解析与受限 dialer,拦截内网 / localhost / 云元数据。 +- **安全响应头**:X-Content-Type-Options、Referrer-Policy、Permissions-Policy、X-Frame-Options、CSP、HSTS。 +- **自更新 SHA256 校验**:CLI 自更新流程对下载的发布产物按 `SHA256SUMS` 校验后再替换二进制。 + +**关于 LICENSE 中的其它约束:** [LICENSE](LICENSE) 在法律层面对商用、地域、评估期、SIM 授权等作出约束。其中部分条款(如 14 天评估期、仅限美国地域、运行时完整性校验)属于**许可条款**,由用户依约遵守,Vocat 当前未在代码中对应实现强制技术控制;上文列出的均为代码中实际存在并生效的控制。 + +用户不得故意移除、绕过、禁用、伪装、修补、干扰或破坏上述安全机制。 + +--- + +## SIM 与 eSIM 授权策略 + +Vocat 只应与以下 SIM / eSIM 资源配合使用: + +- 测试用 SIM 卡 +- 开发用 SIM 卡 +- 实验室用 SIM 卡 +- 授权的 eSIM profile +- 用户拥有或被明确授权测试的 SIM/eSIM 资源 + +未经授权不得使用属于他人的生产用订户凭证。Vocat 可拒绝不满足测试策略的 SIM 卡或 eSIM profile。 + +--- + +## 禁止用途 + +Vocat 不得用于: + +- 未授权接入电信网络 +- 冒用他人订户或设备 +- SIM 克隆 +- 未授权的 eSIM 开通 +- 使用被盗 / 泄露的订户凭证 +- 电信欺诈 +- 绕过运营商鉴权 +- 绕过运营商合法限制 +- 未授权拦截 / 监听 +- 大规模群发短信 +- 干扰移动网络基础设施 +- 商业电信服务 +- 未经授权出售 Vocat 访问权限 +- 绕过 Vocat 安全控制 +- 发布以绕过使用限制为主要目的的修改版本 + +任何使用须遵守适用法律、法规、运营商政策与授权要求。 + +--- + +## 商用与衍生 + +Vocat 面向个人开发、教育、学术研究、学校 / 大学实验室、非营利研究与授权的电信硬件开发。**未经书面授权不得商用。** + +受限商用示例:付费模组检测服务、出售 Vocat 托管实例访问、并入商业电信产品、作为商业 SIM 检测平台运营、倒卖修改版本。 + +Vocat 为 source-available。可在 [LICENSE](LICENSE) 许可范围内为合法研究 / 教育 / 开发 / 调试 / 硬件兼容测试目的检视与修改源码。分支不得以移除或绕过地域限制、SIM 限制、MCC/MNC 限制、设备数量限制、评估期限制、鉴权机制、完整性校验或防滥用控制为主要目的。再发布的允许修改版本须保留版权声明、许可声明、署名与修改说明。 + +--- + +## 隐私与数据安全 + +Vocat 只应部署在用户有授权访问所测 modem 与订户信息的环境中。诊断信息可能包含:IMEI、ICCID、IMSI、EID、运营商信息、模组信息、固件信息、网络注册状态、信号信息、短信测试数据。 + +部署运维者有责任妥善保护此类信息。**不要**向公网公开暴露包含敏感电信信息的 Vocat 实例。 + +部署建议: + +- 不要将 modem 控制接口直接暴露到公网 +- 远程部署使用强认证 +- 收紧容器与串口设备权限 +- 保护含订户标识的日志 +- 不要将凭证提交到 Git 或写入源码 +- 定期审计已部署实例 + +敏感配置应通过环境变量(见下)或密钥管理系统存储。 + +--- + +## 运行要求 + +推荐环境: + +- Linux(amd64、386、arm64 或 armv7) +- 对蜂窝模组的 USB 访问 +- 受支持的 Qualcomm modem +- 授权的测试 SIM 或 eSIM +- 需要时的网络连接 + +--- + +## 安装 + +Vocat 提供两种部署形态:**二进制 + systemd**(推荐,开箱即用随机初始密码与自更新)与 **Docker**(容器化,适合隔离运行)。Vocat 不随附 `docker-compose.yml` 或 `.env.example`;如需编排或环境文件,请自行创建。 + +### 方式 1 — 一键安装脚本(二进制 + systemd) + +```bash +curl -fsSL | sudo bash +``` + +用官方 install.sh 脚本的 raw 链接替换 ``。脚本会:选择语言(中 / 英)、检测架构(amd64 / 386 / arm64 / armv7)、下载二进制与 `SHA256SUMS` 并校验、创建 `vocat` 系统用户、写入 systemd unit、首次安装时生成 32 位随机管理员密码(写入仅一次显示)。详见 [scripts/install.sh](scripts/install.sh)。 + +安装指定版本: + +```bash +sudo bash install.sh 0.1.0 +``` + +强制重装相同版本: + +```bash +sudo bash install.sh --force +``` + +安装完成后,服务默认监听 `0.0.0.0:7575`,浏览器访问 `http://:7575`,用户名 `admin`,首次密码见终端一次性输出。 + +### 方式 2 — Docker + +仓库根目录提供 [Dockerfile](Dockerfile),多阶段构建:`node:20-alpine` 编译前端 → `golang:1.25-alpine` 交叉编译 Go(含 buildinfo ldflags,并通过 `go:embed` 将前端打进二进制)→ `alpine:3.20` 运行时(非 root `vocat` 用户,uid/gid 1000)。 + +构建并运行: + +```bash +git clone +cd vocat +docker build -t vocat . +docker run -d --name vocat -p 7575:7575 \ + -v vocat-data:/opt/vocat/data \ + --device /dev/ttyUSB0 \ + vocat +``` + +容器默认值:`VOCAT_ADDR=0.0.0.0:7575`,`VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db`,`VOLUME /opt/vocat/data`,`EXPOSE 7575`。**默认管理员为 `admin` / `admin`,请登录后立即修改**(Web 设置或 `docker exec ... vocat menu`)。 + +### USB 设备访问 + +容器需直通蜂窝 modem 所在的串口设备: + +```yaml +services: + vocat: + devices: + - /dev/ttyUSB0:/dev/ttyUSB0 + - /dev/ttyUSB1:/dev/ttyUSB1 + - /dev/ttyUSB2:/dev/ttyUSB2 + - /dev/ttyUSB3:/dev/ttyUSB3 +``` + +实际设备名取决于宿主系统、模组固件、USB composition 与驱动配置。可查看可用串口: + +```bash +ls /dev/ttyUSB* +ls /dev/ttyACM* +``` + +--- + +## 配置 + +Vocat 通过 `VOCAT_*` 环境变量配置,可选地用 JSON 配置文件(路径由 `VOCAT_CONFIG` 指定,严格反序列化,字段可见 `internal/config`)。环境变量优先级高于配置文件。 + +| 环境变量 | 默认值 | 说明 | +|---|---|---| +| `VOCAT_ADDR` | `0.0.0.0:7575` | 监听地址与端口 | +| `VOCAT_DATABASE_PATH` | `./data/vocat.db`(Docker:`/opt/vocat/data/vocat.db`) | SQLite 数据库路径 | +| `VOCAT_ADMIN_USERNAME` | `admin` | 管理员用户名 | +| `VOCAT_ADMIN_PASSWORD` | `admin` | 管理员密码(首次安装脚本会随机生成) | +| `VOCAT_SESSION_TTL` | `24h`(5m–720h) | 会话有效期 | +| `VOCAT_SECURE_COOKIES` | `false` | 启用 Secure cookie + HSTS,HTTPS 对外部署时建议开启 | +| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 优雅关闭超时 | +| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576`(1m–10m) | 请求体大小上限 | +| `VOCAT_CONFIG` | — | JSON 配置文件路径 | +| `VOCAT_REPO` | `your-org/vocat` | install.sh / CLI 自更新使用的 GitHub repo(owner/name) | + +切勿将真实密码提交到仓库。 + +--- + +## 快速开始 + +安装后: + +1. 将 EC20 模组连到测试主机。 +2. 确认操作系统检测到 modem(`lsusb` / `ls /dev/ttyUSB*`)。 +3. 插入授权的测试 SIM 卡。 +4. 启动 Vocat 并登录 Web(默认 `admin`,密码见安装输出)。 +5. 选择检测到的 modem 接口。 +6. 运行基础模组检测。 +7. 查看诊断结果。 + +推荐检测顺序: + +```text +USB 检测 + ↓ +AT 通信 + ↓ +模组信息 + ↓ +SIM 检测 + ↓ +网络注册 + ↓ +USSD 检测 + ↓ +短信收发检测 + ↓ +VoWiFi 诊断 + ↓ +eSIM / EID 验证 +``` + +### 首次检测建议 + +先验证基础通信,再跑高级诊断: + +```text +AT → 期望 OK +ATI → 返回型号 / 固件 +``` + +若模组无响应,检查:USB 线缆、USB D+/D− 走线、模组供电、串口选择、USB 驱动、模组启动状态、PCB 焊接、地线连接。 + +--- + +## 命令行 + +Vocat 二进制支持子命令: + +```bash +vocat # 前台运行 Web 服务(默认) +vocat version # 查看版本与构建时间 +vocat update # 自更新(从 GitHub Releases 拉取,SHA256 校验后原子替换;仅 Linux) +vocat menu # root 交互菜单:改密 / 重启服务 / 卸载 +vocat help # 用法 +``` + +`vocat update` 支持 `--check`(仅检查)、`--force`、`--repo`、`--target`、`--token` 等标志。Web 端的「检查更新」当前为有意留空的 no-op(不接可信更新源),自更新仅通过 CLI 进行。 + +`vocat menu` 要求 root 与交互式 TTY,用于在无 Web 访问时执行运维操作。 + +--- + +## 故障排查 + +### 模组未识别 + +```bash +lsusb +ls /dev/ttyUSB* +``` + +可能原因:USB 走线错误、模组供电不足、缺 USB 驱动、USB 线损坏、模组未启动、USB composition 不对、PCB 贴片问题。 + +### AT 指令无响应 + +确认选择了正确的串口——EC20 可能暴露多个串口,并非每个都用于 AT 指令。执行 `AT`,正常应返回 `OK`。 + +### SIM 未识别 + +```text +AT+CPIN? +``` + +响应可能为 SIM ready / PIN required / SIM unavailable。若未检测到,检查 SIM_VDD / SIM_DATA / SIM_CLK / SIM_RST / 地线 / SIM 插座焊接 / SIM 方向。 + +### 网络注册失败 + +```text +AT+CSQ +AT+COPS? +AT+CREG? +AT+CGREG? +``` + +注册取决于 SIM、网络可用性、运营商政策、支持频段、天线、固件与授权状态。 + +### 短信不工作 + +检查:SIM 注册、短信能力、信号质量、运营商支持、正确的 modem 端口;若目标是 `+86` 号段会被服务端拦截。 + +--- + +## 项目范围 + +Vocat 是诊断工具。它**不是**: + +- 移动网络 / MVNO +- 运营商开通平台 +- SM-DP+ / SM-DS +- eSIM 发行方 +- SIM 克隆平台 +- 电信拦截平台 +- 运营商鉴权绕过工具 + +本项目用于辅助授权开发者进行硬件验证。 + +--- + +## 负责任使用 + +蜂窝模组与受监管的电信基础设施交互。测试前请确保: + +1. 你拥有或被授权使用该硬件。 +2. 你被授权使用该 SIM/eSIM profile。 +3. 网络允许拟进行的测试活动。 +4. 你的测试符合适用法律。 +5. 你的设备不干扰电信基础设施。 + +存疑时,请使用隔离或运营商认可的实验室环境。 + +--- + +## 许可 + +Vocat 依据 **Vocat Research & Evaluation License** 分发。这是 source-available 许可,**不是** OSI 认证的开源许可。访问源码不自动授予:商用、再发布不受限的修改版本、移除防滥用控制、绕过地域限制或绕过 SIM 限制的权利。 + +完整条款见 [LICENSE](LICENSE)。 + +--- + +## 免责声明 + +Vocat 以授权研究、教育、开发与电信硬件测试为目的提供。软件按 **"AS IS"** 提供,不附带任何明示或暗示的担保。作者、维护者、贡献者与分发者不对使用或滥用 Vocat 造成的损失负责,包括但不限于:SIM 卡损坏、eSIM profile 丢失、SIM 停用、modem / 基带故障、PCB / 蜂窝模组 / 宿主设备损坏、网络服务中断、运营商 / 账户限制、数据丢失、服务中断、监管后果与未授权的电信活动。 + +用户有责任确保其使用 Vocat 符合适用的法律、法规、电信要求、运营商政策、网络政策与合同义务。项目维护者对在受限地域或环境中未授权部署或运行 Vocat 不承担责任。 + +--- + +## 安全问题 + +发现 Vocat 的安全问题,请**不要**立即在公开 issue 中发布利用细节,而应私下联系项目维护者。报告宜包含:问题描述、受影响版本、复现条件、潜在影响、可选的缓解方案。请勿在报告中附带真实订户凭证、SIM 密钥、鉴权密钥或个人信息。 + +--- + +## 贡献 + +欢迎与合法硬件测试和诊断相关的贡献,例如:更多 modem 兼容、更好的 EC20 检测、AT 指令诊断改进、USB 检测改进、短信检测改进、文档改进、UI 改进、Bug 修复、Docker 改进、硬件兼容文档。 + +以禁用或绕过项目安全 / 防滥用限制为主要目的的贡献不予接受。提交 PR 前:测试变更、说明改动、描述测试所用硬件、避免附敏感订户信息、确保贡献符合项目许可。 + +--- + +## 开发状态 + +Vocat 是实验性开发与硬件测试项目。版本间,接口、兼容性、命令、配置格式与安全机制可能变更。请勿将 Vocat 用于生产电信基础设施。 + +--- + +## FAQ + +### Vocat 是开源项目吗? +Vocat 是 source-available,但不是 OSI 认证开源许可。源码可在 Vocat Research & Evaluation License 授权范围内检视与修改。 + +### 可以商用吗? +未经书面授权不行。默认许可仅授权研究、开发、教育与非营利测试用途。 + +### 可以用日常 SIM 卡吗? +Vocat 设计用于授权测试或开发用 SIM/eSIM。使用生产订户凭证可能受部署策略限制(例如对中国大陆 SIM 自动飞行模式)。 + +### Vocat 能解锁蜂窝模组吗? +不能。Vocat 用于诊断与功能验证。 + +### Vocat 会绕过运营商限制吗? +不会。Vocat 不用于绕过运营商鉴权、开通要求、认证要求或网络安全控制。 + +### VoWiFi 检测成功就等于 VoWiFi 可用吗? +不是。运营商侧开通与认证要求仍可能阻止 VoWiFi 实际可用。 + +### Vocat 只支持 EC20 吗? +EC20 是首发与主要开发目标。后续版本可能支持更多 Qualcomm 蜂窝模组。 + +### 默认密码是什么? +二进制 + systemd 首次安装脚本会生成 32 位随机密码并仅显示一次;Docker 镜像默认 `admin` / `admin`,须登录后立即修改。 + +--- + +## 致谢 + +Vocat 可能与第三方开发的硬件、软件、协议或技术交互。所有第三方名称、商标、产品名与公司名归其各自所有者所有。对 Qualcomm、Quectel、EC20、移动网络运营商、GSMA 技术等的引用仅用于识别与互操作说明。Vocat 与上述机构无关联、未被赞助、未被背书,除非另有明确声明。 + +--- + +## 联系 + +用于:安全报告、研究合作、教育使用、延长测试授权、商用授权咨询、附加地域授权——请通过官方 Vocat 项目仓库或指定项目联系渠道联系维护者。 + +--- + +## 最终声明 + +下载、编译、安装、修改或运行 Vocat 即表示你承诺确保你的使用是授权的,并符合适用许可、法律、电信法规、运营商政策与测试要求。若不同意项目许可条款或使用限制,请勿使用本软件。 diff --git a/cmd/vocat/cli.go b/cmd/vocat/cli.go new file mode 100644 index 0000000..4c444ad --- /dev/null +++ b/cmd/vocat/cli.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "io" + + "vocat/internal/buildinfo" +) + +func runVersion() { + fmt.Println("vocat " + buildinfo.Build()) +} + +func printUsage(w io.Writer) { + fmt.Fprintf(w, `vocat %s + +Usage: + vocat Run the vocat server (default; same as no arguments). + vocat version Print the build version and exit. + vocat update Check GitHub for a newer release and self-update. + Flags: + --check Only report whether an update is available. + --repo owner/name GitHub repository (default: $VOCAT_REPO). + --target path Binary to replace (default: running exe). + --force Reinstall even at the same version. + Environment: + VOCAT_REPO Fallback for --repo. + GITHUB_TOKEN Optional bearer token for private repos + or higher rate limits. + vocat menu Interactive lifecycle menu (run as root on the host): + change password, restart service, uninstall. + vocat help Show this help message. + +When run without a subcommand, vocat starts the HTTP server using +VOCAT_* environment variables or $VOCAT_CONFIG for configuration. +`, buildinfo.Version) +} diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 365c1dd..5404bee 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -19,6 +19,7 @@ import ( "vocat/internal/loghub" "vocat/internal/server" "vocat/internal/store" + "vocat/internal/update" "vocat/internal/vowifi" "vocat/internal/vowifi/ike" "vocat/internal/vowifi/ims" @@ -30,12 +31,46 @@ import ( func main() { logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000) logger := slog.New(logs) - if err := run(logger, logs); err != nil { - logger.Error("server stopped", "error", err) - os.Exit(1) + + args := os.Args[1:] + switch subcommand, rest := splitSubcommand(args); subcommand { + case "": + // No subcommand: run the server. Backward-compatible with the + // existing systemd unit (ExecStart=/opt/vocat/bin/vocat). + if err := run(logger, logs); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } + case "version", "-v", "--version": + runVersion() + case "update": + if err := update.Run(logger, rest); err != nil { + logger.Error("update failed", "error", err) + os.Exit(1) + } + case "menu": + if err := runMenu(logger); err != nil { + logger.Error("menu failed", "error", err) + os.Exit(1) + } + case "help", "-h", "--help": + printUsage(os.Stdout) + default: + fmt.Fprintf(os.Stderr, "vocat: unknown subcommand %q\n\n", subcommand) + printUsage(os.Stderr) + os.Exit(2) } } +// splitSubcommand returns the first non-flag token as the subcommand and the +// remaining args. An empty arg list yields ("", nil) → server mode. +func splitSubcommand(args []string) (string, []string) { + if len(args) == 0 { + return "", nil + } + return args[0], args[1:] +} + func run(logger *slog.Logger, logs *loghub.Hub) error { cfg, err := config.Load() if err != nil { @@ -125,6 +160,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { } go handler.StartLogRetentionLoop(pollContext, time.Minute) go handler.StartSMSSyncLoop(pollContext, 15*time.Second) + handler.StartTelegramBot(pollContext) + handler.StartSMSNotificationDispatchers(pollContext) httpServer := &http.Server{ Addr: cfg.Address, diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go new file mode 100644 index 0000000..9c0a0b1 --- /dev/null +++ b/cmd/vocat/menu.go @@ -0,0 +1,377 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "golang.org/x/term" + + "vocat/internal/auth" + "vocat/internal/config" + "vocat/internal/store" +) + +//envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD. +// EnsureAdmin reseeds the DB from it on every start, so change-password must +// rewrite it or the next restart reverts the password. +const envFilePath = "/etc/vocat/env" + +const systemdUnitPath = "/etc/systemd/system/vocat.service" + +// runMenu is the interactive lifecycle menu: change password, restart the +// systemd unit, or fully uninstall vocat. It must run as root on the host +// (needs systemctl + the 0600 env file). Docker deployments do not use it. +func runMenu(logger *slog.Logger) error { + if os.Geteuid() != 0 { + return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)") + } + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return errors.New("vocat menu requires an interactive terminal") + } + + lang := promptLanguage() + menu := newMenu(lang) + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Println() + fmt.Println(menu.title()) + for _, opt := range menu.options() { + fmt.Printf(" %s\n", opt) + } + fmt.Print(menu.prompt()) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("read menu choice: %w", err) + } + choice := strings.TrimSpace(line) + switch choice { + case "1": + if err := menuChangePassword(reader, menu, logger); err != nil { + fmt.Println(menu.errorPrefix(err)) + } + case "2": + if err := menuRestart(menu); err != nil { + fmt.Println(menu.errorPrefix(err)) + } + case "3": + if err := menuUninstall(reader, menu); err != nil { + fmt.Println(menu.errorPrefix(err)) + } + case "0", "": + fmt.Println(menu.bye()) + return nil + default: + fmt.Println(menu.invalid()) + } + } +} + +// promptLanguage asks for 中文 (1) or English (2) once per invocation. The +// user chose to re-ask every run rather than persist a language preference. +func promptLanguage() string { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Println("选择语言 / Select language: 1) 中文 2) English") + fmt.Print("> ") + line, err := reader.ReadString('\n') + if err != nil { + return "zh" + } + switch strings.TrimSpace(line) { + case "1", "": + return "zh" + case "2": + return "en" + } + } +} + +func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("%w: %v", errMenuConfig, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + database, err := store.Open(ctx, cfg.DatabasePath) + if err != nil { + return fmt.Errorf("%w: %v", errMenuStore, err) + } + defer database.Close() + + authService, err := auth.New(database, auth.Options{SessionTTL: cfg.SessionTTL}) + if err != nil { + return fmt.Errorf("%w: %v", errMenuAuth, err) + } + + fmt.Print(m.currentPassword()) + currentPw, err := readPasswordMasked() + if err != nil { + return err + } + fmt.Print(m.newPassword()) + newPw, err := readPasswordMasked() + if err != nil { + return err + } + fmt.Print(m.confirmPassword()) + confirmPw, err := readPasswordMasked() + if err != nil { + return err + } + fmt.Println() + if newPw != confirmPw { + return errPasswordsDiffer + } + if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + return errCurrentWrong + } + return fmt.Errorf("%w: %v", errMenuAuth, err) + } + // Persist the new plaintext to the env file so the next EnsureAdmin (on + // restart) agrees with the hash we just wrote to the DB. Without this the + // restart reverts the password to whatever the env file still holds. + if err := rewriteEnvPassword(newPw); err != nil { + logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err) + return fmt.Errorf("%w: %v", errMenuEnvWrite, err) + } + fmt.Println(m.passwordChanged()) + return nil +} + +// readPasswordMasked reads a password with echo disabled. term.ReadPassword +// does not return the trailing newline, so we print one for a clean prompt. +func readPasswordMasked() (string, error) { + fd := int(os.Stdin.Fd()) + bytes, err := term.ReadPassword(fd) + fmt.Println() + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return string(bytes), nil +} + +// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the +// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic: +// the temp file lives in the same directory so os.Rename stays on one +// filesystem. +func rewriteEnvPassword(newPassword string) error { + const key = "VOCAT_ADMIN_PASSWORD=" + var lines []string + if data, err := os.ReadFile(envFilePath); err == nil { + lines = strings.Split(string(data), "\n") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + replaced := false + for i, line := range lines { + if strings.HasPrefix(line, key) { + lines[i] = key + newPassword + replaced = true + break + } + } + if !replaced { + lines = append(lines, key+newPassword) + } + content := strings.Join(lines, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + + dir := envFilePath[:strings.LastIndex(envFilePath, "/")] + tmp, err := os.CreateTemp(dir, ".vocat-env-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.WriteString(content); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, envFilePath) +} + +func menuRestart(m *menu) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return errNoSystemctl + } + cmd := exec.Command("systemctl", "restart", "vocat") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out))) + } + fmt.Println(m.restarted()) + return nil +} + +// menuUninstall performs full removal: stop/disable the unit, delete the unit, +// remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload +// systemd, and best-effort delete the vocat user. +func menuUninstall(reader *bufio.Reader, m *menu) error { + fmt.Println(m.uninstallWarn()) + fmt.Print(m.uninstallConfirm()) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("read confirmation: %w", err) + } + if strings.TrimSpace(line) != "yes" { + fmt.Println(m.uninstallCancelled()) + return nil + } + + runIgnore := func(name string, args ...string) { + _ = exec.Command(name, args...).Run() + } + runIgnore("systemctl", "stop", "vocat") + runIgnore("systemctl", "disable", "vocat") + _ = os.Remove(systemdUnitPath) + _ = os.RemoveAll("/opt/vocat") + _ = os.Remove(envFilePath) + _ = os.Remove("/etc/vocat") // succeeds only when empty + runIgnore("systemctl", "daemon-reload") + runIgnore("userdel", "vocat") + + fmt.Println(m.uninstalled()) + return nil +} + +// menu-local sentinel errors so callers can map them to localized messages. +var ( + errCurrentWrong = errors.New("menu: current password is incorrect") + errPasswordsDiffer = errors.New("menu: passwords do not match") + errNoSystemctl = errors.New("menu: systemctl not found") + errRestartFailed = errors.New("menu: restart failed") + errMenuConfig = errors.New("menu: load configuration") + errMenuStore = errors.New("menu: open database") + errMenuAuth = errors.New("menu: auth service") + errMenuEnvWrite = errors.New("menu: write env file") +) + +// ---- i18n ---- + +type menu struct{ lang string } + +func newMenu(lang string) *menu { return &menu{lang: lang} } + +// msg returns the localized string for a key. Each key carries [zh, en]. +func (m *menu) msg(key string) string { + const zh, en = 0, 1 + table := map[string][2]string{ + "title": {"vocat 管理菜单", "vocat management menu"}, + "opt_change": {"1) 修改密码", "1) Change password"}, + "opt_restart": {"2) 重启服务", "2) Restart service"}, + "opt_uninstall": {"3) 卸载程序", "3) Uninstall"}, + "opt_exit": {"0) 退出", "0) Exit"}, + "prompt": {"请选择: ", "Select: "}, + "invalid": {"无效选项,请重试。", "Invalid choice, try again."}, + "bye": {"再见。", "Bye."}, + "cur_pw": {"当前密码: ", "Current password: "}, + "new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "}, + "confirm_pw": {"确认新密码: ", "Confirm new password: "}, + "pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."}, + "restarted": {"服务已重启。", "Service restarted."}, + "uninstall_warn": { + "警告: 将删除程序、数据与配置,且不可恢复!", + "WARNING: removes the program, data and config. Irreversible!", + }, + "uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "}, + "uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."}, + "uninstalled": {"vocat 已卸载。", "vocat uninstalled."}, + } + entry, ok := table[key] + if !ok { + return key + } + if m.lang == "en" { + return entry[en] + } + return entry[zh] +} + +func (m *menu) title() string { return m.msg("title") } +func (m *menu) prompt() string { return m.msg("prompt") } +func (m *menu) invalid() string { return m.msg("invalid") } +func (m *menu) bye() string { return m.msg("bye") } +func (m *menu) currentPassword() string { return m.msg("cur_pw") } +func (m *menu) newPassword() string { return m.msg("new_pw") } +func (m *menu) confirmPassword() string { return m.msg("confirm_pw") } +func (m *menu) passwordChanged() string { return m.msg("pw_changed") } +func (m *menu) restarted() string { return m.msg("restarted") } +func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") } +func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") } +func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") } +func (m *menu) uninstalled() string { return m.msg("uninstalled") } + +func (m *menu) options() []string { + return []string{m.msg("opt_change"), m.msg("opt_restart"), m.msg("opt_uninstall"), m.msg("opt_exit")} +} + +func (m *menu) errorPrefix(err error) string { + switch { + case errors.Is(err, errCurrentWrong): + if m.lang == "en" { + return "Current password is incorrect." + } + return "当前密码不正确。" + case errors.Is(err, errPasswordsDiffer): + if m.lang == "en" { + return "Passwords do not match." + } + return "两次输入的密码不一致。" + case errors.Is(err, errNoSystemctl): + if m.lang == "en" { + return "systemctl not found." + } + return "未找到 systemctl。" + case errors.Is(err, errRestartFailed): + if m.lang == "en" { + return "Restart failed." + } + return "重启失败。" + case errors.Is(err, errMenuConfig): + if m.lang == "en" { + return "Failed to load configuration." + } + return "加载配置失败。" + case errors.Is(err, errMenuStore): + if m.lang == "en" { + return "Failed to open the database." + } + return "打开数据库失败。" + case errors.Is(err, errMenuAuth): + if m.lang == "en" { + return "Auth service error." + } + return "认证服务错误。" + case errors.Is(err, errMenuEnvWrite): + if m.lang == "en" { + return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "." + } + return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。" + default: + if m.lang == "en" { + return "Error: " + err.Error() + } + return "错误: " + err.Error() + } +} diff --git a/go.mod b/go.mod index 2298c5b..7f0e4bd 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module vocat -go 1.23.0 +go 1.25.0 require ( go.bug.st/serial v1.6.4 golang.org/x/crypto v0.41.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.34.0 modernc.org/sqlite v1.38.2 ) @@ -16,7 +18,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.35.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4c6731d..8b50492 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,10 @@ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..a8dddff --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,23 @@ +// Package buildinfo exposes the build-time version metadata injected via +// -ldflags "-X vocat/internal/buildinfo.Version=... -X vocat/internal/buildinfo.BuildTime=...". +// It is imported by the server (to report version through /api/system/info), +// the CLI subcommands (vocat version / update), and the self-updater (to +// compare the running build against a GitHub release). +package buildinfo + +// Version is the semantic version of this build. It defaults to the dev +// sentinel when no -ldflags override is supplied. +var Version = "0.1.0-dev" + +// BuildTime is the UTC timestamp the binary was built at (RFC3339), or empty +// for a local dev build. +var BuildTime = "" + +// Build returns a human-readable version string. When BuildTime is populated +// it appends the timestamp in parentheses. +func Build() string { + if BuildTime == "" { + return Version + } + return Version + " (" + BuildTime + ")" +} diff --git a/internal/server/general_api.go b/internal/server/general_api.go index 64181c5..818434e 100644 --- a/internal/server/general_api.go +++ b/internal/server/general_api.go @@ -13,6 +13,7 @@ import ( "time" "vocat/internal/auth" + "vocat/internal/buildinfo" "vocat/internal/i18n" "vocat/internal/loghub" "vocat/internal/store" @@ -302,8 +303,8 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ - "version": "0.1.0-dev", - "build_time": "", + "version": buildinfo.Version, + "build_time": buildinfo.BuildTime, "config": "VOCAT_CONFIG and environment", "os": runtime.GOOS, "architecture": runtime.GOARCH, @@ -319,7 +320,7 @@ func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "available": false, - "version": "0.1.0-dev", + "version": buildinfo.Version, "message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"), }, }) diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index 034823a..1cde8c8 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -254,11 +254,28 @@ func validateNotificationField( if len(value) > limit || strings.ContainsAny(value, "\x00") { return fmt.Errorf("%s is too long or contains invalid characters", field) } - if (name == "base_url" || name == "proxy") && value != "" { + if name == "base_url" && value != "" { + if _, err := parseOutboundURL(value, true); err != nil { + return fmt.Errorf("%s must be an absolute HTTPS URL", field) + } + } + if name == "proxy" && value != "" { if _, err := parseOutboundURL(value, false); err != nil { return fmt.Errorf("%s is not a valid HTTP URL", field) } } + if channel == "telegram" && name == "chat_id" && strings.TrimSpace(value) != "" { + chatID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || chatID == 0 { + return fmt.Errorf("%s must be a non-zero integer", field) + } + } + if channel == "telegram" && name == "admin_id" && strings.TrimSpace(value) != "" { + adminID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil || adminID <= 0 { + return fmt.Errorf("%s must be a positive integer", field) + } + } if name == "from_address" && value != "" { if _, err := mail.ParseAddress(value); err != nil { return fmt.Errorf("%s is not a valid email address", field) diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 4826054..8015540 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -155,6 +155,21 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`, code: "invalid_notification_config", }, + { + name: "invalid Telegram chat id", + body: `{"telegram":{"enabled":true,"chat_id":"group-name"}}`, + code: "invalid_notification_config", + }, + { + name: "invalid Telegram admin id", + body: `{"telegram":{"enabled":true,"admin_id":"-1"}}`, + code: "invalid_notification_config", + }, + { + name: "insecure Telegram base URL", + body: `{"telegram":{"enabled":true,"base_url":"http://example.com"}}`, + code: "invalid_notification_config", + }, { name: "unknown field", body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`, diff --git a/internal/server/sms_notifications.go b/internal/server/sms_notifications.go new file mode 100644 index 0000000..9ec6f96 --- /dev/null +++ b/internal/server/sms_notifications.go @@ -0,0 +1,438 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/mail" + "net/smtp" + "strconv" + "strings" + "time" + + "vocat/internal/store" +) + +const smsNotificationPollInterval = 2 * time.Second + +var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"} + +type smsNotification struct { + DeviceID string + DeviceName string + DeviceLabel string + Number string + Time time.Time + Content string +} + +func (value smsNotification) Text() string { + return strings.Join([]string{ + "收到新短信", + "设备 " + value.DeviceLabel, + "号码 " + value.Number, + "时间 " + value.Time.Local().Format("2006-01-02 15:04:05"), + "内容 " + value.Content, + }, "\n") +} + +func (value smsNotification) DetailText() string { + lines := strings.Split(value.Text(), "\n") + return strings.Join(lines[1:], "\n") +} + +// StartSMSNotificationDispatchers delivers future inbound messages to the +// notification-only providers. Each provider owns its cursor so a failing +// webhook, SMTP server, or push service cannot block the other providers. +func (s *Server) StartSMSNotificationDispatchers(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + for _, channel := range smsOnlyNotificationChannels { + channel := channel + go s.runSMSNotificationChannel(ctx, channel) + } +} + +func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string) { + var cursor int64 + cursorInitialized := false + lastError := "" + lastErrorAt := time.Time{} + for ctx.Err() == nil { + if !cursorInitialized { + latest, err := s.store.LatestSMSMessageID(ctx) + if err != nil { + if err.Error() != lastError || time.Since(lastErrorAt) >= time.Minute { + s.logSMSNotificationError(channel, err) + lastError, lastErrorAt = err.Error(), time.Now() + } + if !waitTelegram(ctx, smsNotificationPollInterval) { + return + } + continue + } + cursor, cursorInitialized = latest, true + lastError = "" + } + config, enabled, configErr := s.smsNotificationConfig(ctx, channel) + if configErr != nil { + if configErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute { + s.logSMSNotificationError(channel, configErr) + lastError, lastErrorAt = configErr.Error(), time.Now() + } + } else if !enabled { + if newest, latestErr := s.store.LatestSMSMessageID(ctx); latestErr == nil { + cursor = newest + } + lastError = "" + } else { + messages, listErr := s.store.ListInboundSMSAfterID(ctx, cursor, 100) + if listErr != nil { + if listErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute { + s.logSMSNotificationError(channel, listErr) + lastError, lastErrorAt = listErr.Error(), time.Now() + } + } else { + for _, message := range messages { + notification := s.newSMSNotification(ctx, message) + if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil { + if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute { + s.logSMSNotificationError(channel, sendErr) + lastError, lastErrorAt = sendErr.Error(), time.Now() + } + break + } + cursor = message.ID + lastError = "" + } + } + } + if !waitTelegram(ctx, smsNotificationPollInterval) { + return + } + } +} + +func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map[string]any, bool, error) { + setting, err := s.store.NotificationSetting(ctx, channel) + if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + return nil, false, fmt.Errorf("decode %s notification config: %w", channel, err) + } + if err := validateSMSNotificationConfig(channel, config); err != nil { + return nil, false, err + } + return config, true, nil +} + +func validateSMSNotificationConfig(channel string, config map[string]any) error { + switch channel { + case "bark", "email", "webhook": + if err := validateNotificationTestConfig(channel, config); err != nil { + return err + } + case "pushplus": + if token := strings.TrimSpace(configString(config, "token")); token == "" || token == store.SecretMask { + return errors.New("pushplus.token is required") + } + default: + return fmt.Errorf("unsupported SMS notification channel %q", channel) + } + return nil +} + +func (s *Server) newSMSNotification(ctx context.Context, message store.SMSMessage) smsNotification { + name := "" + if device, err := s.store.Device(ctx, message.DeviceID); err == nil { + name = strings.TrimSpace(device.Name) + } + return smsNotification{ + DeviceID: message.DeviceID, + DeviceName: name, + DeviceLabel: firstNonEmpty(name, message.DeviceID, "--"), + Number: firstNonEmpty(message.Peer, "--"), + Time: message.Timestamp, + Content: message.Body, + } +} + +func (s *Server) logSMSNotificationError(channel string, err error) { + if err != nil && s.logger != nil { + s.logger.Warn("send inbound SMS notification", "channel", channel, "error", err) + } +} + +func sendSMSNotification(ctx context.Context, channel string, config map[string]any, message smsNotification) error { + switch channel { + case "bark": + return sendBarkSMSNotification(ctx, config, message) + case "email": + return sendEmailSMSNotification(ctx, config, message) + case "pushplus": + return sendPushplusSMSNotification(ctx, config, message) + case "webhook": + return sendWebhookSMSNotification(ctx, config, message) + default: + return fmt.Errorf("unsupported SMS notification channel %q", channel) + } +} + +func sendBarkSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + client, err := restrictedHTTPClient(ctx, 6*time.Second, "") + if err != nil { + return err + } + payload := map[string]any{"title": "收到新短信", "body": message.DetailText()} + for _, field := range []string{"group", "icon", "level"} { + if value := configString(config, field); value != "" { + payload[field] = value + } + } + encoded, _ := json.Marshal(payload) + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(encoded)) + if err != nil { + return fmt.Errorf("create Bark notification request: %w", err) + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-sms-notification/1") + if err := performNotificationRequest(client, request, false); err != nil { + return err + } + } + return nil +} + +func sendWebhookSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + rendered := message.Text() + if template := configString(config, "text_template"); strings.TrimSpace(template) != "" { + rendered = renderSMSWebhookTemplate(template, message) + } + payload, _ := json.Marshal(map[string]any{ + "event": "sms.received", + "message": rendered, + "timestamp": message.Time.UTC().Format(time.RFC3339), + "device_id": message.DeviceID, + "device_name": message.DeviceName, + "device_label": message.DeviceLabel, + "number": message.Number, + "content": message.Content, + }) + timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second) + client, err := restrictedHTTPClient(ctx, timeout, "") + if err != nil { + return err + } + retries := configInt(config, "retry_max") + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + var sendErr error + for attempt := 0; attempt <= retries; attempt++ { + request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if requestErr != nil { + return fmt.Errorf("create webhook notification request: %w", requestErr) + } + for name, value := range configStringMap(config, "headers") { + request.Header.Set(name, value) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-sms-notification/1") + if secret := configString(config, "secret"); secret != "" { + signature := hmac.New(sha256.New, []byte(secret)) + _, _ = signature.Write(payload) + request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil))) + } + sendErr = performNotificationRequest(client, request, false) + if sendErr == nil { + break + } + } + if sendErr != nil { + return sendErr + } + } + return nil +} + +func renderSMSWebhookTemplate(template string, message smsNotification) string { + replacements := map[string]string{ + "{{text}}": message.Content, + "{{content}}": message.Content, + "{{event}}": "sms.received", + "{{timestamp}}": message.Time.UTC().Format(time.RFC3339), + "{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"), + "{{number}}": message.Number, + "{{device_id}}": message.DeviceID, + "{{device_name}}": message.DeviceName, + "{{device_label}}": message.DeviceLabel, + } + for placeholder, value := range replacements { + template = strings.ReplaceAll(template, placeholder, value) + } + return template +} + +func sendPushplusSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + destination, err := validateOutboundURL(ctx, "https://www.pushplus.plus/send", true) + if err != nil { + return err + } + payload := map[string]any{ + "token": configString(config, "token"), + "title": "收到新短信", + "content": message.DetailText(), + "template": "txt", + "timestamp": time.Now().UnixMilli(), + } + if topic := configString(config, "topic"); topic != "" { + payload["topic"] = topic + } + if channel := configString(config, "channel"); channel != "" { + payload["channel"] = channel + } + encoded, _ := json.Marshal(payload) + client, err := restrictedHTTPClient(ctx, 8*time.Second, "") + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, destination.String(), bytes.NewReader(encoded)) + if err != nil { + return fmt.Errorf("create Pushplus notification request: %w", err) + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-sms-notification/1") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("send Pushplus notification: %w", err) + } + defer response.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + if readErr != nil { + return fmt.Errorf("read Pushplus response: %w", readErr) + } + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if response.StatusCode < 200 || response.StatusCode >= 300 || json.Unmarshal(body, &result) != nil || result.Code != 200 { + return fmt.Errorf("%w: Pushplus HTTP %d code %d %s", errProviderRejected, response.StatusCode, result.Code, result.Msg) + } + return nil +} + +func sendEmailSMSNotification(ctx context.Context, config map[string]any, message smsNotification) error { + host := strings.TrimSpace(configString(config, "smtp_host")) + port := configInt(config, "smtp_port") + if port == 0 { + port = 587 + } + timeout := 8 * time.Second + connection, err := dialRestricted(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout) + if err != nil { + return fmt.Errorf("connect SMTP server: %w", err) + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil { + return fmt.Errorf("set SMTP deadline: %w", err) + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host} + useSSL, _ := config["use_ssl"].(bool) + implicitTLS := port == 465 || useSSL + if implicitTLS { + secure := tls.Client(connection, tlsConfig) + if err := secure.HandshakeContext(ctx); err != nil { + return fmt.Errorf("establish SMTP TLS: %w", err) + } + connection = secure + } + client, err := smtp.NewClient(connection, host) + if err != nil { + return fmt.Errorf("start SMTP session: %w", err) + } + defer client.Close() + if !implicitTLS { + if available, _ := client.Extension("STARTTLS"); !available { + return errors.New("SMTP server does not offer STARTTLS") + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("start SMTP TLS: %w", err) + } + } + username, password := configString(config, "username"), configString(config, "password") + if username != "" { + if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil { + return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected) + } + } + from, err := mail.ParseAddress(configString(config, "from_address")) + if err != nil { + return fmt.Errorf("parse sender address: %w", err) + } + recipients := make([]*mail.Address, 0) + for _, item := range configStrings(config, "to_addresses") { + address, err := mail.ParseAddress(item) + if err != nil { + return fmt.Errorf("parse recipient address: %w", err) + } + recipients = append(recipients, address) + } + if err := client.Mail(from.Address); err != nil { + return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected) + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient.Address); err != nil { + return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected) + } + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("%w: SMTP message rejected", errProviderRejected) + } + email := strings.Join([]string{ + "Date: " + time.Now().UTC().Format(time.RFC1123Z), + "From: " + from.String(), + "To: " + joinMailAddresses(recipients), + "Subject: " + mime.QEncoding.Encode("UTF-8", "收到新短信 - "+message.DeviceLabel), + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: 8bit", + "", + message.Text(), + "", + }, "\r\n") + if _, err := io.WriteString(writer, email); err != nil { + _ = writer.Close() + return fmt.Errorf("write SMTP notification: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected) + } + if err := client.Quit(); err != nil { + return fmt.Errorf("finish SMTP session: %w", err) + } + return nil +} diff --git a/internal/server/sms_notifications_test.go b/internal/server/sms_notifications_test.go new file mode 100644 index 0000000..e1b3510 --- /dev/null +++ b/internal/server/sms_notifications_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "strings" + "testing" + "time" +) + +func TestSMSNotificationTextMatchesUserFacingTemplate(t *testing.T) { + location := time.FixedZone("UTC+8", 8*60*60) + previousLocation := time.Local + time.Local = location + t.Cleanup(func() { time.Local = previousLocation }) + message := smsNotification{ + DeviceID: "device-1", DeviceLabel: "EC20", Number: "+447386", + Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "你好鸭", + } + want := "收到新短信\n设备 EC20\n号码 +447386\n时间 2026-08-08 17:25:35\n内容 你好鸭" + if got := message.Text(); got != want { + t.Fatalf("smsNotification.Text() = %q, want %q", got, want) + } + if strings.HasPrefix(message.DetailText(), "收到新短信") { + t.Fatalf("DetailText() unexpectedly repeats the title: %q", message.DetailText()) + } +} + +func TestRenderSMSWebhookTemplate(t *testing.T) { + message := smsNotification{ + DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", + Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello", + } + got := renderSMSWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{number}}|{{text}}|{{content}}", message) + want := "sms.received|device-1|客厅|EC20|+447386|hello|hello" + if got != want { + t.Fatalf("renderSMSWebhookTemplate() = %q, want %q", got, want) + } +} + +func TestValidateSMSNotificationConfig(t *testing.T) { + valid := map[string]map[string]any{ + "bark": {"urls": []any{"https://api.day.app/key"}}, + "email": {"smtp_host": "smtp.example.com", "from_address": "from@example.com", "to_addresses": []any{"to@example.com"}}, + "pushplus": {"token": "secret"}, + "webhook": {"urls": []any{"https://example.com/hook"}}, + } + for channel, config := range valid { + if err := validateSMSNotificationConfig(channel, config); err != nil { + t.Errorf("validateSMSNotificationConfig(%q) error = %v", channel, err) + } + } + if err := validateSMSNotificationConfig("pushplus", map[string]any{}); err == nil { + t.Fatal("missing Pushplus token was accepted") + } +} diff --git a/internal/server/telegram_bot.go b/internal/server/telegram_bot.go new file mode 100644 index 0000000..c8196e5 --- /dev/null +++ b/internal/server/telegram_bot.go @@ -0,0 +1,1010 @@ +package server + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" + "vocat/internal/vowifi" + vowifiruntime "vocat/internal/vowifi/runtime" +) + +const ( + telegramPollInterval = 3 * time.Second + telegramNotificationPeriod = 2 * time.Second + telegramConfirmationTTL = 2 * time.Minute + telegramMaxDialDuration = 10 * time.Minute +) + +type telegramRuntimeConfig struct { + Token string + ChatID string + AdminID int64 + BaseURL string + Proxy string +} + +type telegramBot struct { + server *Server + + pendingMu sync.Mutex + pending map[string]telegramPendingAction + + logMu sync.Mutex + lastLogTime time.Time + lastLogText string +} + +type telegramPendingAction struct { + Kind string + DeviceID string + Argument string + Text string + Duration time.Duration + ChatID int64 + AdminID int64 + CreatedAt time.Time + TargetAID string + TargetICCID string +} + +type telegramAPIResponse struct { + OK bool `json:"ok"` + Description string `json:"description"` + Result json.RawMessage `json:"result"` +} + +type telegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message *telegramMessage `json:"message"` + CallbackQuery *telegramCallbackQuery `json:"callback_query"` +} + +type telegramMessage struct { + MessageID int64 `json:"message_id"` + From *telegramUser `json:"from"` + Chat telegramChat `json:"chat"` + Text string `json:"text"` +} + +type telegramUser struct { + ID int64 `json:"id"` +} + +type telegramChat struct { + ID int64 `json:"id"` +} + +type telegramCallbackQuery struct { + ID string `json:"id"` + From telegramUser `json:"from"` + Message *telegramMessage `json:"message"` + Data string `json:"data"` +} + +// StartTelegramBot starts both the Telegram command poller and durable inbound +// SMS notifier. Configuration is reloaded between polls, so saving Settings +// takes effect without restarting vocat. +func (s *Server) StartTelegramBot(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + bot := &telegramBot{ + server: s, + pending: make(map[string]telegramPendingAction), + } + go bot.poll(ctx) + go bot.notifyInboundSMS(ctx) +} + +func (bot *telegramBot) poll(ctx context.Context) { + activeToken := "" + var offset int64 + for ctx.Err() == nil { + config, enabled, err := bot.loadConfig(ctx) + if err != nil { + bot.warn("load Telegram bot configuration", err) + if !waitTelegram(ctx, telegramPollInterval) { + return + } + continue + } + if !enabled { + activeToken = "" + offset = 0 + if !waitTelegram(ctx, telegramPollInterval) { + return + } + continue + } + if config.Token != activeToken { + offset, err = bot.bootstrap(ctx, config) + if err != nil { + bot.warn("start Telegram bot polling", err) + if !waitTelegram(ctx, telegramPollInterval) { + return + } + continue + } + activeToken = config.Token + } + pollContext, cancel := context.WithTimeout(ctx, 10*time.Second) + updates, pollErr := bot.getUpdates(pollContext, config, offset, 5) + cancel() + if pollErr != nil { + bot.warn("poll Telegram updates", pollErr) + if !waitTelegram(ctx, telegramPollInterval) { + return + } + continue + } + for _, update := range updates { + if update.UpdateID >= offset { + offset = update.UpdateID + 1 + } + update := update + go bot.handleUpdate(ctx, config, update) + } + } +} + +// bootstrap discards stale Telegram updates. Replaying an old /sms, /call, or +// /switch command after a service restart would be unsafe even though each +// command has its own confirmation step. +func (bot *telegramBot) bootstrap(ctx context.Context, config telegramRuntimeConfig) (int64, error) { + requestContext, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + updates, err := bot.getUpdates(requestContext, config, -1, 0) + if err != nil { + return 0, err + } + var offset int64 + for _, update := range updates { + if update.UpdateID >= offset { + offset = update.UpdateID + 1 + } + } + commands := []map[string]string{ + {"command": "status", "description": "查看设备状态"}, + {"command": "esim", "description": "查看已安装 eSIM Profile"}, + {"command": "wfc", "description": "管理 WiFi Calling"}, + {"command": "sms", "description": "发送短信(需要确认)"}, + {"command": "call", "description": "限时拨号并自动挂断(需要确认)"}, + {"command": "calls", "description": "查看当前通话"}, + {"command": "hangup", "description": "挂断通话"}, + {"command": "help", "description": "查看命令帮助"}, + } + _ = bot.call(requestContext, config, "setMyCommands", map[string]any{"commands": commands}, nil) + return offset, nil +} + +func (bot *telegramBot) getUpdates( + ctx context.Context, + config telegramRuntimeConfig, + offset int64, + timeout int, +) ([]telegramUpdate, error) { + payload := map[string]any{ + "offset": offset, + "timeout": timeout, + "allowed_updates": []string{"message", "callback_query"}, + } + var updates []telegramUpdate + if err := bot.call(ctx, config, "getUpdates", payload, &updates); err != nil { + return nil, err + } + return updates, nil +} + +func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) { + if callback := update.CallbackQuery; callback != nil { + if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) { + _ = bot.answerCallback(ctx, config, callback.ID, "无权限") + return + } + _ = bot.answerCallback(ctx, config, callback.ID, "") + bot.handleCallback(ctx, config, callback) + return + } + message := update.Message + if message == nil || message.From == nil || !bot.authorized(config, message.Chat.ID, message.From.ID) { + return + } + command, remainder := parseTelegramCommand(message.Text) + if command == "" { + return + } + switch command { + case "start", "menu", "help": + bot.sendHelp(ctx, config, message.Chat.ID) + case "status", "devices": + bot.sendDeviceStatus(ctx, config, message.Chat.ID, strings.TrimSpace(remainder)) + case "esim": + bot.sendESIMProfiles(ctx, config, message.Chat.ID, strings.TrimSpace(remainder)) + case "switch": + parts := strings.Fields(remainder) + if len(parts) != 2 { + bot.sendText(ctx, config, message.Chat.ID, "用法:/switch <设备ID> <目标ICCID>", nil) + return + } + bot.confirmESIMSwitch(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1]) + case "wfc", "wificalling": + parts := strings.Fields(remainder) + if len(parts) != 2 { + bot.sendText(ctx, config, message.Chat.ID, "用法:/wfc <设备ID> ", nil) + return + } + bot.handleVoWiFi(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1]) + case "sms": + parts := splitTelegramArguments(remainder, 3) + if len(parts) != 3 { + bot.sendText(ctx, config, message.Chat.ID, "用法:/sms <设备ID> <号码> <短信内容>", nil) + return + } + bot.confirmSMS(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], parts[2]) + case "call": + parts := strings.Fields(remainder) + if len(parts) != 3 { + bot.sendText(ctx, config, message.Chat.ID, "用法:/call <设备ID> <号码> <持续秒数>\n拨号后将在指定时间自动挂断,不处理通话音频。", nil) + return + } + seconds, err := strconv.Atoi(parts[2]) + if err != nil || seconds < 1 || time.Duration(seconds)*time.Second > telegramMaxDialDuration { + bot.sendText(ctx, config, message.Chat.ID, "持续时间必须是 1–600 秒。", nil) + return + } + bot.confirmCall(ctx, config, message.Chat.ID, message.From.ID, parts[0], parts[1], time.Duration(seconds)*time.Second) + case "answer": + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "answer") + case "hangup": + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "hangup") + case "calls": + bot.executeSimpleCallAction(ctx, config, message.Chat.ID, message.From.ID, strings.TrimSpace(remainder), "status") + default: + bot.sendText(ctx, config, message.Chat.ID, "未知命令。发送 /help 查看可用操作。", nil) + } +} + +func (bot *telegramBot) handleCallback(ctx context.Context, config telegramRuntimeConfig, callback *telegramCallbackQuery) { + data := strings.TrimSpace(callback.Data) + if data == "menu:status" { + bot.sendDeviceStatus(ctx, config, callback.Message.Chat.ID, "") + return + } + if data == "menu:help" { + bot.sendHelp(ctx, config, callback.Message.Chat.ID) + return + } + decision, token, found := strings.Cut(data, ":") + if !found || (decision != "confirm" && decision != "cancel") { + return + } + action, ok := bot.takePending(token, callback.Message.Chat.ID, callback.From.ID) + if !ok { + bot.sendText(ctx, config, callback.Message.Chat.ID, "该确认已过期或已处理。", nil) + return + } + if decision == "cancel" { + bot.sendText(ctx, config, callback.Message.Chat.ID, "操作已取消。", nil) + return + } + switch action.Kind { + case "sms": + bot.sendText(ctx, config, action.ChatID, "正在提交短信…", nil) + result, err := bot.executeSMS(ctx, action) + bot.finishAction(ctx, config, action, "telegram.sms.send", result, err) + case "esim_switch": + bot.sendText(ctx, config, action.ChatID, "正在切换 Profile 并等待模块恢复校验…", nil) + result, err := bot.executeESIMSwitch(ctx, action) + bot.finishAction(ctx, config, action, "telegram.esim.switch", result, err) + case "call": + result, err := bot.executeTimedCall(ctx, config, action) + bot.finishAction(ctx, config, action, "telegram.call.dial", result, err) + } +} + +func (bot *telegramBot) sendHelp(ctx context.Context, config telegramRuntimeConfig, chatID int64) { + text := strings.Join([]string{ + "vocat Telegram 控制", "", + "/status [设备ID] — 查看设备、SIM、蜂窝与 VoWiFi 状态", + "/esim <设备ID> — 只读查看已安装 Profile", + "/switch <设备ID> — 切换到已安装 Profile(需确认)", + "/wfc <设备ID> — 管理 WiFi Calling", + "/sms <设备ID> <号码> <内容> — 发送短信(需确认)", + "/call <设备ID> <号码> <秒数> — 拨号并在 1–600 秒后自动挂断(需确认)", + "/calls <设备ID> — 查看模块当前通话", + "/answer <设备ID> — 接听蜂窝来电", + "/hangup <设备ID> — 立即挂断", + "", + "Bot 不提供 eSIM 下载、删除或改名,也不采集或转发通话音频。控制命令只接受设置中的 Admin ID。", + }, "\n") + keyboard := map[string]any{"inline_keyboard": [][]map[string]string{{ + {"text": "📊 设备状态", "callback_data": "menu:status"}, + {"text": "❓ 帮助", "callback_data": "menu:help"}, + }}} + bot.sendText(ctx, config, chatID, text, keyboard) +} + +func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRuntimeConfig, chatID int64, onlyID string) { + configs, err := bot.server.store.ListDevices(ctx) + if err != nil { + bot.sendText(ctx, config, chatID, "读取设备失败:"+err.Error(), nil) + return + } + var blocks []string + for _, stored := range configs { + if onlyID != "" && stored.ID != onlyID { + continue + } + entry, _, present := bot.server.physicalForConfig(stored) + lines := []string{fmt.Sprintf("📡 %s (%s)", firstNonEmpty(stored.Name, stored.ID), stored.ID)} + if !present { + lines = append(lines, "设备:离线") + } else { + lines = append(lines, "设备:在线") + if snapshot := entry.Snapshot; snapshot != nil { + lines = append(lines, + "SIM:"+map[bool]string{true: "Ready", false: firstNonEmpty(snapshot.SIMStatus, "未就绪")}[snapshot.SIMReady], + "ICCID:"+firstNonEmpty(snapshot.ICCID, "--"), + "IMSI:"+firstNonEmpty(snapshot.IMSI, "--"), + "号码:"+firstNonEmpty(snapshot.Phone.Number, "--"), + "运营商:"+firstNonEmpty(snapshot.OperatorName, snapshot.OperatorCode, "--"), + "蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode], + ) + } + } + if bot.server.vowifi != nil { + if state, stateErr := bot.server.vowifi.State(stored.ID); stateErr == nil { + lines = append(lines, + fmt.Sprintf("VoWiFi:%s · Tunnel=%t IMS=%t SMS=%t", firstNonEmpty(string(state.Phase), "idle"), state.TunnelReady, state.IMSReady, state.SMSReady), + ) + if state.LastError != "" { + lines = append(lines, "最后错误:"+state.LastError) + } + } + } + blocks = append(blocks, strings.Join(lines, "\n")) + } + if len(blocks) == 0 { + bot.sendText(ctx, config, chatID, "未找到设备 "+onlyID, nil) + return + } + bot.sendText(ctx, config, chatID, strings.Join(blocks, "\n\n"), nil) +} + +func (bot *telegramBot) sendESIMProfiles(ctx context.Context, config telegramRuntimeConfig, chatID int64, deviceID string) { + if deviceID == "" { + bot.sendText(ctx, config, chatID, "用法:/esim <设备ID>", nil) + return + } + _, _, physicalID, err := bot.device(deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "读取 eSIM 失败:"+err.Error(), nil) + return + } + readContext, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + inventory, err := bot.server.devices.ESIMInventory(readContext, physicalID) + if err != nil { + bot.sendText(ctx, config, chatID, "读取 eSIM 失败:"+err.Error(), nil) + return + } + if len(inventory) == 0 { + bot.sendText(ctx, config, chatID, "该设备没有可用的 eUICC/Profile。", nil) + return + } + lines := []string{"📲 " + deviceID + " 已安装 Profile(只读)"} + for index, group := range inventory { + lines = append(lines, fmt.Sprintf("\neUICC #%d · …%s", index+1, tailDigits(group.Info.EID, 4))) + for _, profile := range group.Info.Profiles { + state := "Disabled" + if profile.State == 1 { + state = "Enabled" + } + name := firstNonEmpty(profile.Nickname, profile.Name, profile.ServiceProvider, "未命名") + lines = append(lines, fmt.Sprintf("• %s · %s\n %s", name, state, profile.ICCID)) + } + } + lines = append(lines, "\n切换:/switch "+deviceID+" <目标ICCID>") + bot.sendText(ctx, config, chatID, strings.Join(lines, "\n"), nil) +} + +func (bot *telegramBot) confirmESIMSwitch(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, iccid string) { + _, _, physicalID, err := bot.device(deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "无法切换:"+err.Error(), nil) + return + } + readContext, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + inventory, err := bot.server.devices.ESIMInventory(readContext, physicalID) + if err != nil { + bot.sendText(ctx, config, chatID, "无法读取 Profile:"+err.Error(), nil) + return + } + var target *device.EsimProfile + var targetAID string + for groupIndex := range inventory { + for profileIndex := range inventory[groupIndex].Info.Profiles { + profile := &inventory[groupIndex].Info.Profiles[profileIndex] + if profile.ICCID == iccid { + target = profile + targetAID = inventory[groupIndex].Info.AID + break + } + } + } + if target == nil { + bot.sendText(ctx, config, chatID, "目标 ICCID 不在该设备已安装 Profile 中。", nil) + return + } + if target.State == 1 { + bot.sendText(ctx, config, chatID, "目标 Profile 已经处于 Enabled。", nil) + return + } + action := telegramPendingAction{ + Kind: "esim_switch", DeviceID: deviceID, ChatID: chatID, AdminID: adminID, + CreatedAt: time.Now(), TargetAID: targetAID, TargetICCID: target.ICCID, + } + name := firstNonEmpty(target.Nickname, target.Name, target.ServiceProvider, "未命名") + bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认将设备 %s 切换到:\n%s\nICCID %s?\n\nBot 只会执行 EnableProfile,不会下载或删除 Profile。", deviceID, name, target.ICCID)) +} + +func (bot *telegramBot) confirmSMS(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, phone, text string) { + phone = strings.TrimSpace(phone) + text = strings.TrimSpace(text) + if _, _, _, err := bot.device(deviceID); err != nil { + bot.sendText(ctx, config, chatID, "无法发送:"+err.Error(), nil) + return + } + if blocked, reason := blockedSMSDestination(phone); blocked { + bot.sendText(ctx, config, chatID, "无法发送:"+reason, nil) + return + } + if text == "" { + bot.sendText(ctx, config, chatID, "短信内容不能为空。", nil) + return + } + action := telegramPendingAction{ + Kind: "sms", DeviceID: deviceID, Argument: phone, Text: text, + ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + } + bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认通过设备 %s 发送短信?\n收件人:%s\n内容:%s", deviceID, phone, truncateTelegramText(text, 800))) +} + +func (bot *telegramBot) confirmCall(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, number string, duration time.Duration) { + if !validTelegramDialNumber(number) { + bot.sendText(ctx, config, chatID, "拨号号码无效,只允许一个可选的前导 + 和 3–20 位数字。", nil) + return + } + if _, entry, _, err := bot.device(deviceID); err != nil { + bot.sendText(ctx, config, chatID, "无法拨号:"+err.Error(), nil) + return + } else if entry.Snapshot != nil && entry.Snapshot.FlightMode { + bot.sendText(ctx, config, chatID, "设备处于飞行模式,蜂窝语音拨号不可用。当前 Bot 不实现 IMS 语音或音频处理。", nil) + return + } + action := telegramPendingAction{ + Kind: "call", DeviceID: deviceID, Argument: number, Duration: duration, + ChatID: chatID, AdminID: adminID, CreatedAt: time.Now(), + } + bot.askConfirmation(ctx, config, action, fmt.Sprintf("确认通过设备 %s 拨打 %s?\n持续:%d 秒,然后自动挂断。\n不会采集或处理通话音频。", deviceID, number, int(duration/time.Second))) +} + +func (bot *telegramBot) askConfirmation(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction, text string) { + token, err := bot.putPending(action) + if err != nil { + bot.sendText(ctx, config, action.ChatID, "创建确认失败:"+err.Error(), nil) + return + } + keyboard := map[string]any{"inline_keyboard": [][]map[string]string{{ + {"text": "✅ 确认", "callback_data": "confirm:" + token}, + {"text": "❌ 取消", "callback_data": "cancel:" + token}, + }}} + bot.sendText(ctx, config, action.ChatID, text, keyboard) +} + +func (bot *telegramBot) putPending(action telegramPendingAction) (string, error) { + raw := make([]byte, 8) + if _, err := cryptorand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + bot.pendingMu.Lock() + defer bot.pendingMu.Unlock() + now := time.Now() + for key, value := range bot.pending { + if now.Sub(value.CreatedAt) > telegramConfirmationTTL { + delete(bot.pending, key) + } + } + bot.pending[token] = action + return token, nil +} + +func (bot *telegramBot) takePending(token string, chatID, adminID int64) (telegramPendingAction, bool) { + bot.pendingMu.Lock() + defer bot.pendingMu.Unlock() + action, ok := bot.pending[token] + if ok { + delete(bot.pending, token) + } + if !ok || action.ChatID != chatID || action.AdminID != adminID || time.Since(action.CreatedAt) > telegramConfirmationTTL { + return telegramPendingAction{}, false + } + return action, true +} + +func (bot *telegramBot) executeSMS(ctx context.Context, action telegramPendingAction) (string, error) { + payload, _ := json.Marshal(map[string]string{ + "device_id": action.DeviceID, + "phone": action.Argument, + "message": action.Text, + }) + request := httptest.NewRequest(http.MethodPost, "/api/sms/send", bytes.NewReader(payload)).WithContext(ctx) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + bot.server.handleSMSSend(recorder, request) + var response struct { + Data map[string]any `json:"data"` + Error *apiError `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + return "", fmt.Errorf("decode SMS result: %w", err) + } + if recorder.Code >= http.StatusBadRequest || response.Error != nil { + if response.Error != nil { + return "", errors.New(response.Error.Message) + } + return "", fmt.Errorf("SMS submission returned HTTP %d", recorder.Code) + } + return fmt.Sprintf("短信已提交。\n通道:%v\n结果:%v\n送达确认:%v", response.Data["transport"], response.Data["outcome"], response.Data["delivery_confirmed"]), nil +} + +func (bot *telegramBot) executeESIMSwitch(ctx context.Context, action telegramPendingAction) (string, error) { + _, _, physicalID, err := bot.device(action.DeviceID) + if err != nil { + return "", err + } + operationContext, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if err := bot.server.devices.ESIMSwitchProfile(operationContext, physicalID, action.TargetICCID, action.TargetAID); err != nil { + return "", err + } + return "Profile 切换成功,模块恢复后已校验当前 ICCID:" + action.TargetICCID, nil +} + +func (bot *telegramBot) executeTimedCall(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction) (string, error) { + _, entry, physicalID, err := bot.device(action.DeviceID) + if err != nil { + return "", err + } + if entry.Snapshot != nil && entry.Snapshot.FlightMode { + return "", errors.New("device is in airplane mode") + } + dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second) + response, err := bot.server.devices.ExecuteAT(dialContext, physicalID, "ATD"+action.Argument+";") + cancelDial() + if err != nil { + return "", fmt.Errorf("拨号失败: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") { + return "", fmt.Errorf("拨号未被模块接受: %s", formatTelegramAT(response)) + } + bot.sendText(ctx, config, action.ChatID, fmt.Sprintf("📞 已开始拨打 %s,将在 %d 秒后自动挂断。", action.Argument, int(action.Duration/time.Second)), nil) + timer := time.NewTimer(action.Duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-timer.C: + } + hangContext, cancelHang := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelHang() + hangResponse, hangErr := bot.server.devices.ExecuteAT(hangContext, physicalID, "ATH") + if hangErr != nil { + return "", fmt.Errorf("拨号已执行,但自动挂断失败: %w", hangErr) + } + if !strings.EqualFold(strings.TrimSpace(hangResponse.Final), "OK") { + return "", fmt.Errorf("拨号已执行,但模块未确认自动挂断: %s", formatTelegramAT(hangResponse)) + } + return fmt.Sprintf("拨号动作完成:%s,持续 %d 秒后已自动挂断。", action.Argument, int(action.Duration/time.Second)), nil +} + +func (bot *telegramBot) executeSimpleCallAction(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, action string) { + if deviceID == "" { + bot.sendText(ctx, config, chatID, fmt.Sprintf("用法:/%s <设备ID>", map[string]string{"status": "calls", "answer": "answer", "hangup": "hangup"}[action]), nil) + return + } + _, _, physicalID, err := bot.device(deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil) + return + } + command := map[string]string{"status": "AT+CLCC", "answer": "ATA", "hangup": "ATH"}[action] + operationContext, cancel := context.WithTimeout(ctx, 20*time.Second) + response, err := bot.server.devices.ExecuteAT(operationContext, physicalID, command) + cancel() + outcome := "success" + if err != nil { + outcome = "failure" + bot.sendText(ctx, config, chatID, "通话操作失败:"+err.Error(), nil) + } else { + text := formatTelegramAT(response) + if action == "status" && strings.TrimSpace(response.Text()) == "" { + text = "当前没有活动通话。" + } + bot.sendText(ctx, config, chatID, text, nil) + } + bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.call."+action, "device", deviceID, outcome, "telegram") +} + +func (bot *telegramBot) handleVoWiFi(ctx context.Context, config telegramRuntimeConfig, chatID, adminID int64, deviceID, operation string) { + stored, entry, _, err := bot.device(deviceID) + if err != nil { + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), nil) + return + } + if bot.server.vowifi == nil { + bot.sendText(ctx, config, chatID, "VoWiFi runtime 不可用。", nil) + return + } + operation = strings.ToLower(strings.TrimSpace(operation)) + if operation == "status" { + state, stateErr := bot.server.vowifi.State(deviceID) + if stateErr != nil { + bot.sendText(ctx, config, chatID, "读取 VoWiFi 状态失败:"+stateErr.Error(), nil) + return + } + bot.sendText(ctx, config, chatID, formatTelegramVoWiFiState(state), nil) + return + } + var state vowifi.State + switch operation { + case "on", "off": + enabled := operation == "on" + if enabled && entry.Snapshot != nil { + if reason := device.RegionBlockReason(entry.Snapshot.IMSI); reason != "" { + bot.sendText(ctx, config, chatID, "VoWiFi 操作被拒绝:"+reason, nil) + return + } + } + previous := stored.VoWiFiEnabled + stored.VoWiFiEnabled = enabled + if err = bot.server.store.UpsertDevice(ctx, stored); err == nil { + state, err = bot.server.vowifi.RequestEnabled(deviceID, enabled) + } + if err != nil { + stored.VoWiFiEnabled = previous + _ = bot.server.store.UpsertDevice(ctx, stored) + if errors.Is(err, vowifiruntime.ErrOperationInProgress) && state.Enabled == enabled { + err = nil + } + } + case "reconnect": + if !stored.VoWiFiEnabled { + err = errors.New("请先启用 VoWiFi") + } else { + state, err = bot.server.vowifi.RequestReconnect(deviceID) + } + default: + bot.sendText(ctx, config, chatID, "操作必须是 status、on、off 或 reconnect。", nil) + return + } + outcome := "success" + if err != nil { + outcome = "failure" + bot.sendText(ctx, config, chatID, "VoWiFi 操作失败:"+err.Error(), nil) + } else { + bot.sendText(ctx, config, chatID, "VoWiFi 操作已受理。\n"+formatTelegramVoWiFiState(state), nil) + } + bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", adminID), "telegram.vowifi."+operation, "device", deviceID, outcome, "telegram") +} + +func (bot *telegramBot) finishAction(ctx context.Context, config telegramRuntimeConfig, action telegramPendingAction, auditAction, result string, err error) { + outcome := "success" + if err != nil { + outcome = "failure" + bot.sendText(ctx, config, action.ChatID, "操作失败:"+err.Error(), nil) + } else { + bot.sendText(ctx, config, action.ChatID, "✅ "+result, nil) + } + bot.server.recordAudit(ctx, fmt.Sprintf("telegram:%d", action.AdminID), auditAction, "device", action.DeviceID, outcome, "telegram") +} + +func (bot *telegramBot) notifyInboundSMS(ctx context.Context) { + cursorInitialized := false + var cursor int64 + for ctx.Err() == nil { + if !cursorInitialized { + latest, err := bot.server.store.LatestSMSMessageID(ctx) + if err != nil { + bot.warn("initialize Telegram SMS cursor", err) + if !waitTelegram(ctx, telegramNotificationPeriod) { + return + } + continue + } + cursor, cursorInitialized = latest, true + } + config, enabled, err := bot.loadConfig(ctx) + if err != nil { + bot.warn("load Telegram SMS notification configuration", err) + } else if !enabled { + if latest, latestErr := bot.server.store.LatestSMSMessageID(ctx); latestErr == nil { + cursor = latest + } + } else { + messages, listErr := bot.server.store.ListInboundSMSAfterID(ctx, cursor, 100) + if listErr != nil { + bot.warn("list Telegram SMS notifications", listErr) + } else { + for _, message := range messages { + text := fmt.Sprintf("📩 新短信\n设备:%s\n来自:%s\n时间:%s\n\n%s", message.DeviceID, message.Peer, message.Timestamp.Local().Format("2006-01-02 15:04:05"), message.Body) + if sendErr := bot.sendText(ctx, config, 0, text, nil); sendErr != nil { + bot.warn("send Telegram SMS notification", sendErr) + break + } + cursor = message.ID + } + } + } + if !waitTelegram(ctx, telegramNotificationPeriod) { + return + } + } +} + +func (bot *telegramBot) device(deviceID string) (store.Device, device.Device, string, error) { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + return store.Device{}, device.Device{}, "", errors.New("设备 ID 不能为空") + } + stored, err := bot.server.store.Device(context.Background(), deviceID) + if err != nil { + return store.Device{}, device.Device{}, "", err + } + entry, physicalID, present := bot.server.physicalForConfig(stored) + if !present { + return stored, entry, "", errors.New("设备不在线") + } + return stored, entry, physicalID, nil +} + +func (bot *telegramBot) authorized(config telegramRuntimeConfig, chatID, userID int64) bool { + return config.AdminID > 0 && userID == config.AdminID && strconv.FormatInt(chatID, 10) == config.ChatID +} + +func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig, bool, error) { + setting, err := bot.server.store.NotificationSetting(ctx, "telegram") + if errors.Is(err, store.ErrNotFound) { + return telegramRuntimeConfig{}, false, nil + } + if err != nil { + return telegramRuntimeConfig{}, false, err + } + if !setting.Enabled { + return telegramRuntimeConfig{}, false, nil + } + var raw map[string]any + if err := json.Unmarshal(setting.Config, &raw); err != nil { + return telegramRuntimeConfig{}, false, fmt.Errorf("decode Telegram config: %w", err) + } + config := telegramRuntimeConfig{ + Token: configString(raw, "bot_token"), + ChatID: configString(raw, "chat_id"), + BaseURL: configString(raw, "base_url"), + Proxy: configString(raw, "proxy"), + } + if config.BaseURL == "" { + config.BaseURL = "https://api.telegram.org" + } + if admin := configString(raw, "admin_id"); admin != "" { + config.AdminID, err = strconv.ParseInt(admin, 10, 64) + if err != nil || config.AdminID <= 0 { + return telegramRuntimeConfig{}, false, errors.New("telegram.admin_id must be a positive integer") + } + } + if !telegramTokenPattern.MatchString(config.Token) || config.ChatID == "" { + return telegramRuntimeConfig{}, false, errors.New("Telegram bot token or chat id is invalid") + } + return config, true, nil +} + +func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error { + base, err := validateOutboundURL(ctx, config.BaseURL, true) + if err != nil { + return err + } + base.Path = strings.TrimRight(base.Path, "/") + "/bot" + config.Token + "/" + method + base.RawPath, base.RawQuery, base.Fragment = "", "", "" + body, err := json.Marshal(payload) + if err != nil { + return err + } + client, err := restrictedHTTPClient(ctx, 10*time.Second, config.Proxy) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, base.String(), bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-telegram-bot/1") + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return err + } + var envelope telegramAPIResponse + if err := json.Unmarshal(responseBody, &envelope); err != nil { + return fmt.Errorf("decode Telegram response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 || !envelope.OK { + return fmt.Errorf("Telegram %s failed: HTTP %d %s", method, response.StatusCode, envelope.Description) + } + if result != nil && len(envelope.Result) != 0 { + if err := json.Unmarshal(envelope.Result, result); err != nil { + return fmt.Errorf("decode Telegram %s result: %w", method, err) + } + } + return nil +} + +func (bot *telegramBot) sendText(ctx context.Context, config telegramRuntimeConfig, chatID int64, text string, replyMarkup any) error { + target := config.ChatID + if chatID != 0 { + target = strconv.FormatInt(chatID, 10) + } + payload := map[string]any{ + "chat_id": target, + "text": truncateTelegramText(text, 3900), + } + if replyMarkup != nil { + payload["reply_markup"] = replyMarkup + } + requestContext, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return bot.call(requestContext, config, "sendMessage", payload, nil) +} + +func (bot *telegramBot) answerCallback(ctx context.Context, config telegramRuntimeConfig, callbackID, text string) error { + payload := map[string]any{"callback_query_id": callbackID} + if text != "" { + payload["text"] = text + } + requestContext, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + return bot.call(requestContext, config, "answerCallbackQuery", payload, nil) +} + +func (bot *telegramBot) warn(message string, err error) { + if err == nil || bot.server.logger == nil { + return + } + now := time.Now() + text := err.Error() + bot.logMu.Lock() + if text == bot.lastLogText && now.Sub(bot.lastLogTime) < time.Minute { + bot.logMu.Unlock() + return + } + bot.lastLogText, bot.lastLogTime = text, now + bot.logMu.Unlock() + bot.server.logger.Warn(message, "error", err) +} + +func parseTelegramCommand(text string) (string, string) { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, "/") { + return "", "" + } + commandToken, remainder, _ := strings.Cut(text, " ") + commandToken = strings.TrimPrefix(commandToken, "/") + if at := strings.IndexByte(commandToken, '@'); at >= 0 { + commandToken = commandToken[:at] + } + return strings.ToLower(strings.TrimSpace(commandToken)), strings.TrimSpace(remainder) +} + +func splitTelegramArguments(value string, count int) []string { + fields := strings.Fields(value) + if len(fields) == 0 || count <= 0 { + return nil + } + if len(fields) <= count { + return fields + } + result := append([]string(nil), fields[:count-1]...) + return append(result, strings.Join(fields[count-1:], " ")) +} + +func validTelegramDialNumber(number string) bool { + number = strings.TrimSpace(number) + if strings.HasPrefix(number, "+") { + number = number[1:] + } + if len(number) < 3 || len(number) > 20 { + return false + } + for _, character := range number { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func formatTelegramAT(response modem.Response) string { + parts := make([]string, 0, 2) + if text := strings.TrimSpace(response.Text()); text != "" { + parts = append(parts, text) + } + if final := strings.TrimSpace(response.Final); final != "" { + parts = append(parts, final) + } + if len(parts) == 0 { + return "模块没有返回结果" + } + return strings.Join(parts, "\n") +} + +func formatTelegramVoWiFiState(state vowifi.State) string { + lines := []string{ + fmt.Sprintf("状态:%s", firstNonEmpty(string(state.Phase), "idle")), + fmt.Sprintf("SIM=%t Access=%t Tunnel=%t IMS=%t SMS=%t", state.SIMReady, state.AccessReady, state.TunnelReady, state.IMSReady, state.SMSReady), + } + if state.LastReason != "" { + lines = append(lines, "原因:"+state.LastReason) + } + if state.LastError != "" { + lines = append(lines, "错误:"+state.LastError) + } + return strings.Join(lines, "\n") +} + +func truncateTelegramText(value string, maximum int) string { + runes := []rune(value) + if maximum <= 0 || len(runes) <= maximum { + return value + } + return string(runes[:maximum]) + "…" +} + +func tailDigits(value string, count int) string { + value = strings.TrimSpace(value) + if count <= 0 || len(value) <= count { + return value + } + return value[len(value)-count:] +} + +func waitTelegram(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/internal/server/telegram_bot_test.go b/internal/server/telegram_bot_test.go new file mode 100644 index 0000000..f1f7176 --- /dev/null +++ b/internal/server/telegram_bot_test.go @@ -0,0 +1,62 @@ +package server + +import ( + "testing" + "time" + + "vocat/internal/modem" +) + +func TestParseTelegramCommand(t *testing.T) { + command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ") + if command != "sms" || remainder != "EC20 +447700900123 hello world" { + t.Fatalf("parseTelegramCommand() = %q, %q", command, remainder) + } + if command, _ := parseTelegramCommand("ordinary message"); command != "" { + t.Fatalf("non-command parsed as %q", command) + } +} + +func TestSplitTelegramArgumentsPreservesMessageBody(t *testing.T) { + parts := splitTelegramArguments(" EC20 +447700900123 code with spaces ", 3) + if len(parts) != 3 || parts[0] != "EC20" || parts[1] != "+447700900123" || parts[2] != "code with spaces" { + t.Fatalf("splitTelegramArguments() = %#v", parts) + } +} + +func TestValidTelegramDialNumber(t *testing.T) { + for _, value := range []string{"10086", "+447700900123", "12345678901234567890"} { + if !validTelegramDialNumber(value) { + t.Errorf("validTelegramDialNumber(%q) = false", value) + } + } + for _, value := range []string{"12", "+", "123;ATH", "12 34", "123456789012345678901"} { + if validTelegramDialNumber(value) { + t.Errorf("validTelegramDialNumber(%q) = true", value) + } + } +} + +func TestTelegramPendingActionIsAuthorizedOneShot(t *testing.T) { + bot := &telegramBot{pending: make(map[string]telegramPendingAction)} + action := telegramPendingAction{Kind: "call", ChatID: -1001, AdminID: 42, CreatedAt: time.Now()} + token, err := bot.putPending(action) + if err != nil { + t.Fatal(err) + } + if _, ok := bot.takePending(token, -1001, 41); ok { + t.Fatal("different administrator consumed pending action") + } + if _, ok := bot.takePending(token, -1001, 42); ok { + t.Fatal("an unauthorized attempt must invalidate the one-time action") + } +} + +func TestFormatTelegramATIncludesFinalResult(t *testing.T) { + if got := formatTelegramAT(modem.Response{Final: "OK"}); got != "OK" { + t.Fatalf("formatTelegramAT(OK) = %q", got) + } + if got := formatTelegramAT(modem.Response{Lines: []string{"+CLCC: 1"}, Final: "OK"}); got != "+CLCC: 1\nOK" { + t.Fatalf("formatTelegramAT(lines) = %q", got) + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 1898165..012fa14 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -291,6 +291,43 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) { } } +func TestListInboundSMSAfterIDUsesDurableInsertionCursor(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + mustSaveDevice(t, database, "ec20-1", "EC20") + old, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "old-inbound", DeviceID: "ec20-1", Peer: "10086", + Direction: "inbound", Body: "old", Status: "received", + }) + if err != nil { + t.Fatal(err) + } + if _, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "new-outbound", DeviceID: "ec20-1", Peer: "10010", + Direction: "outbound", Body: "sent", Status: "sent", + }); err != nil { + t.Fatal(err) + } + newInbound, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "new-inbound", DeviceID: "ec20-1", Peer: "95533", + Direction: "received", Body: "new", Status: "received", + }) + if err != nil { + t.Fatal(err) + } + latest, err := database.LatestSMSMessageID(ctx) + if err != nil || latest != newInbound.ID { + t.Fatalf("LatestSMSMessageID() = %d, %v; want %d", latest, err, newInbound.ID) + } + messages, err := database.ListInboundSMSAfterID(ctx, old.ID, 100) + if err != nil { + t.Fatal(err) + } + if len(messages) != 1 || messages[0].ID != newInbound.ID { + t.Fatalf("ListInboundSMSAfterID() = %#v", messages) + } +} + func TestApplySMSDeliveryReportTracksEverySubmittedPart(t *testing.T) { ctx := context.Background() database := openTestStore(t, ":memory:") diff --git a/internal/store/sms.go b/internal/store/sms.go index 05b608f..953496c 100644 --- a/internal/store/sms.go +++ b/internal/store/sms.go @@ -162,6 +162,47 @@ func (s *Store) SMSMessage(ctx context.Context, id int64) (SMSMessage, error) { return scanSMSMessage(s.db.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, id)) } +// LatestSMSMessageID returns the current durable cursor used by notification +// consumers. Starting at this value avoids replaying the entire SMS archive +// whenever the service or a notification provider is restarted. +func (s *Store) LatestSMSMessageID(ctx context.Context) (int64, error) { + var id int64 + if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM sms_messages`).Scan(&id); err != nil { + return 0, fmt.Errorf("read latest SMS id: %w", err) + } + return id, nil +} + +// ListInboundSMSAfterID returns newly inserted inbound messages in durable ID +// order. Telegram advances this cursor only after considering each item, so +// timestamp corrections and duplicate modem synchronisations cannot reorder or +// duplicate notifications. +func (s *Store) ListInboundSMSAfterID(ctx context.Context, afterID int64, limit int) ([]SMSMessage, error) { + if afterID < 0 { + afterID = 0 + } + rows, err := s.db.QueryContext(ctx, smsMessageSelect+` + WHERE id > ? AND direction IN ('inbound', 'received') + ORDER BY id ASC + LIMIT ?`, afterID, normalizedLimit(limit)) + if err != nil { + return nil, fmt.Errorf("list new inbound SMS messages: %w", err) + } + defer rows.Close() + values := make([]SMSMessage, 0) + for rows.Next() { + value, scanErr := scanSMSMessage(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan new inbound SMS message: %w", scanErr) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate new inbound SMS messages: %w", err) + } + return values, nil +} + // ApplySMSDeliveryReport attaches a TP-STATUS report to the newest matching // outbound submission and advances its aggregate delivery state. Multipart // messages become delivered only after every submitted part is reported. diff --git a/internal/update/asset_test.go b/internal/update/asset_test.go new file mode 100644 index 0000000..8be8b5b --- /dev/null +++ b/internal/update/asset_test.go @@ -0,0 +1,24 @@ +package update + +import ( + "reflect" + "testing" +) + +func TestAssetNamesFor(t *testing.T) { + tests := []struct { + goos string + goarch string + want []string + }{ + {"linux", "amd64", []string{"vocat-linux-amd64"}}, + {"linux", "386", []string{"vocat-linux-386"}}, + {"linux", "arm64", []string{"vocat-linux-arm64"}}, + {"linux", "arm", []string{"vocat-linux-armv7", "vocat-linux-arm"}}, + } + for _, item := range tests { + if got := assetNamesFor(item.goos, item.goarch); !reflect.DeepEqual(got, item.want) { + t.Errorf("assetNamesFor(%q, %q) = %#v, want %#v", item.goos, item.goarch, got, item.want) + } + } +} diff --git a/internal/update/github.go b/internal/update/github.go new file mode 100644 index 0000000..79aae47 --- /dev/null +++ b/internal/update/github.go @@ -0,0 +1,101 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Release mirrors the subset of the GitHub releases API response that the +// self-updater consumes. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Assets []Asset `json:"assets"` +} + +// Asset is a single downloadable artifact attached to a release. +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +const githubAPI = "https://api.github.com" + +// LatestRelease fetches the newest published release for repo (form +// "owner/name"). A non-empty token is sent as a Bearer header, which is +// required for private repositories and lifts the unauthenticated rate limit. +func LatestRelease(ctx context.Context, repo, token string) (*Release, error) { + repo = strings.TrimSpace(repo) + if repo == "" { + return nil, fmt.Errorf("update: repository not configured (set --repo or VOCAT_REPO)") + } + if strings.Count(repo, "/") != 1 { + return nil, fmt.Errorf("update: invalid repository %q (expected owner/name)", repo) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI+"/repos/"+repo+"/releases/latest", nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("update: fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden { + // The releases API returns 403 (not 404) when rate-limited. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("update: GitHub API rejected the request (likely rate-limited): %s", strings.TrimSpace(string(body))) + } + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("update: no published release found for %s", repo) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("update: GitHub API returned %s", resp.Status) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("update: decode release JSON: %w", err) + } + return &release, nil +} + +// downloadAsset streams a release asset into dst, honoring the request context. +// The token is applied for consistency with the API call (GitHub release assets +// redirect to a pre-signed S3 URL; the token is dropped on redirect, which is +// the expected public-CDN flow). +func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/octet-stream") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("update: download asset: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("update: asset download returned %s", resp.Status) + } + if _, err := io.Copy(dst, resp.Body); err != nil { + return fmt.Errorf("update: read asset body: %w", err) + } + return nil +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..9fe781b --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,316 @@ +// Package update implements the `vocat update` self-updater. It queries the +// GitHub Releases API for a newer build, downloads the matching Linux binary +// for the current architecture, verifies it against a published SHA256SUMS, +// atomically replaces the running binary on disk, and restarts the vocat +// systemd unit. +// +// Trust model: GitHub TLS guarantees the channel; the repository owner controls +// which assets are published; SHA256SUMS guards integrity. There is no GPG +// signature verification — an accepted trade-off for a closed-network testing +// tool. The web UI's check-update button remains an intentional no-op; only the +// CLI performs code replacement. +package update + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "vocat/internal/buildinfo" +) + +// Options captures the resolved flags for an update invocation. +type Options struct { + Check bool // report-only + Repo string // owner/name + Target string // binary path to replace + Force bool // reinstall even at equal version + Token string // optional GitHub bearer token + Help bool // print usage, do nothing +} + +// Run executes the update subcommand. It returns nil on success or when an +// update is reported-but-not-applied under --check; it returns an error only +// when something concrete went wrong. +func Run(logger *slog.Logger, args []string) error { + opts, err := parseFlags(args) + if err != nil { + return err + } + if opts.Help { + printUpdateUsage() + return nil + } + if opts.Repo == "" { + opts.Repo = strings.TrimSpace(os.Getenv("VOCAT_REPO")) + } + if opts.Token == "" { + opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + } + if opts.Repo == "" { + return fmt.Errorf("update: no repository configured (set --repo=owner/name or VOCAT_REPO)") + } + if opts.Target == "" { + opts.Target = resolveDefaultTarget() + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + logger.Info("checking for updates", "repo", opts.Repo, "current", buildinfo.Version) + release, err := LatestRelease(ctx, opts.Repo, opts.Token) + if err != nil { + return err + } + latest := strings.TrimPrefix(release.TagName, "v") + if latest == "" { + latest = release.TagName + } + + if latest == buildinfo.Version && !opts.Force { + logger.Info("already up to date", "version", buildinfo.Version) + fmt.Printf("vocat %s is already the latest release.\n", buildinfo.Version) + return nil + } + if opts.Check { + fmt.Printf("update available: %s -> %s\n", buildinfo.Version, latest) + if release.Body != "" { + fmt.Println(strings.TrimSpace(release.Body)) + } + return nil + } + + logger.Info("update available", "current", buildinfo.Version, "latest", latest) + return applyUpdate(ctx, logger, opts, release, latest) +} + +func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string) error { + assetNames := assetNamesFor(runtime.GOOS, runtime.GOARCH) + var asset *Asset + for _, name := range assetNames { + if asset = findAsset(release, name); asset != nil { + break + } + } + if asset == nil { + return fmt.Errorf("update: release %s has none of assets %q for %s/%s", release.TagName, assetNames, runtime.GOOS, runtime.GOARCH) + } + + sumsAsset := findAsset(release, "SHA256SUMS") + if sumsAsset == nil { + return fmt.Errorf("update: release %s missing SHA256SUMS — refusing to install unverified", release.TagName) + } + + // The temp file MUST live in the same directory as the target so os.Rename + // stays on one filesystem; a cross-device rename fails with EXDEV. + targetDir := filepath.Dir(opts.Target) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("update: ensure target dir %s: %w", targetDir, err) + } + tmp, err := os.CreateTemp(targetDir, ".vocat-update-*") + if err != nil { + return fmt.Errorf("update: create temp file: %w", err) + } + tmpPath := tmp.Name() + cleanup := func() { _ = os.Remove(tmpPath) } + defer func() { + if tmp != nil { + _ = tmp.Close() + } + }() + + logger.Info("downloading binary", "asset", asset.Name, "size", asset.Size, "url", asset.BrowserDownloadURL) + if err := downloadAsset(ctx, asset.BrowserDownloadURL, opts.Token, tmp); err != nil { + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("update: finalize temp file: %w", err) + } + tmp = nil + + var sums bytes.Buffer + if err := downloadAsset(ctx, sumsAsset.BrowserDownloadURL, opts.Token, &sums); err != nil { + cleanup() + return err + } + expectedHash, err := ParseSHA256SUMS(sums.String(), asset.Name) + if err != nil { + cleanup() + return err + } + ok, err := VerifyFileSHA256(tmpPath, expectedHash) + if err != nil { + cleanup() + return err + } + if !ok { + cleanup() + return fmt.Errorf("update: sha256 mismatch for %s — refusing to install", asset.Name) + } + logger.Info("verified binary", "sha256", expectedHash) + + if err := os.Chmod(tmpPath, 0o755); err != nil { + cleanup() + return fmt.Errorf("update: chmod temp binary: %w", err) + } + if err := backupAndReplace(opts.Target, tmpPath); err != nil { + cleanup() + return err + } + logger.Info("installed new binary", "target", opts.Target, "version", latest) + fmt.Printf("vocat updated to %s.\n", latest) + + if err := restartService(logger); err != nil { + // The file replacement already succeeded; a restart failure is not + // fatal — the operator can restart the service manually. + fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err) + fmt.Println("Restart the vocat service manually to apply the new build.") + } + return nil +} + +// backupAndReplace renames the current binary aside, then moves the verified +// temp file into place. Both renames are atomic on the same filesystem. On +// Linux the kernel holds the running binary's inode, so replacing it mid-flight +// is safe. +func backupAndReplace(target, tmp string) error { + backup := target + ".previous" + if _, err := os.Stat(target); err == nil { + _ = os.Remove(backup) + if err := os.Rename(target, backup); err != nil { + return fmt.Errorf("update: move current binary aside: %w", err) + } + } + if err := os.Rename(tmp, target); err != nil { + // Best-effort rollback so the operator is not left without a binary. + if _, statErr := os.Stat(backup); statErr == nil { + _ = os.Rename(backup, target) + } + return fmt.Errorf("update: move new binary into place: %w", err) + } + _ = os.Remove(backup) + return nil +} + +// restartService restarts the vocat systemd unit. If systemctl is unavailable +// (non-systemd hosts, containers), it returns an error the caller surfaces as +// a non-fatal warning. +func restartService(logger *slog.Logger) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return fmt.Errorf("systemctl not found in PATH") + } + cmd := exec.Command("systemctl", "restart", "vocat") + if out, err := cmd.CombinedOutput(); err != nil { + logger.Warn("systemctl restart failed", "error", err, "output", string(out)) + return fmt.Errorf("systemctl restart vocat: %w", err) + } + return nil +} + +// resolveDefaultTarget returns the conventional install path when present, +// falling back to the running executable. This lets `vocat update` "just work" +// on the standard systemd host without flags. +func resolveDefaultTarget() string { + const defaultPath = "/opt/vocat/bin/vocat" + if _, err := os.Stat(defaultPath); err == nil { + return defaultPath + } + exe, err := os.Executable() + if err != nil { + return defaultPath + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return exe + } + return resolved +} + +func findAsset(release *Release, name string) *Asset { + for i := range release.Assets { + if release.Assets[i].Name == name { + return &release.Assets[i] + } + } + return nil +} + +func assetNamesFor(goos, goarch string) []string { + if goos == "linux" && goarch == "arm" { + // Official 32-bit ARM builds target GOARM=7. Keep the generic legacy + // name as a fallback for installations consuming an older release. + return []string{"vocat-linux-armv7", "vocat-linux-arm"} + } + return []string{fmt.Sprintf("vocat-%s-%s", goos, goarch)} +} + +func printUpdateUsage() { + fmt.Println(`Usage: vocat update [flags] + +Fetch the latest release from GitHub and replace this binary in place. + +Flags: + --check Report whether an update is available, then exit. + --force Reinstall even when already at the latest version. + --repo owner/name GitHub repository (default: $VOCAT_REPO). + --target path Binary to replace (default: /opt/vocat/bin/vocat if + present, otherwise the running executable). + --token token GitHub bearer token (default: $GITHUB_TOKEN). + -h, --help Show this help. + +Environment: + VOCAT_REPO Fallback for --repo. + GITHUB_TOKEN Fallback for --token. Required for private repos and + recommended to avoid unauthenticated rate limits.`) +} + +func parseFlags(args []string) (Options, error) { + var opts Options + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--check": + opts.Check = true + case arg == "--force": + opts.Force = true + case arg == "--repo": + i++ + if i >= len(args) { + return opts, fmt.Errorf("update: --repo requires a value") + } + opts.Repo = args[i] + case strings.HasPrefix(arg, "--repo="): + opts.Repo = strings.TrimPrefix(arg, "--repo=") + case arg == "--target": + i++ + if i >= len(args) { + return opts, fmt.Errorf("update: --target requires a value") + } + opts.Target = args[i] + case strings.HasPrefix(arg, "--target="): + opts.Target = strings.TrimPrefix(arg, "--target=") + case arg == "--token": + i++ + if i >= len(args) { + return opts, fmt.Errorf("update: --token requires a value") + } + opts.Token = args[i] + case strings.HasPrefix(arg, "--token="): + opts.Token = strings.TrimPrefix(arg, "--token=") + case arg == "-h" || arg == "--help": + opts.Help = true + default: + return opts, fmt.Errorf("update: unknown flag %q", arg) + } + } + return opts, nil +} diff --git a/internal/update/verify.go b/internal/update/verify.go new file mode 100644 index 0000000..2acf42a --- /dev/null +++ b/internal/update/verify.go @@ -0,0 +1,58 @@ +package update + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + "os" + "strings" +) + +// ParseSHA256SUMS scans the contents of a GNU-style sha256sums file (one +// " " line per entry) and returns the hex digest recorded for +// filename. Both the binary ("hash name") and text ("hash *name") forms are +// accepted. An empty content or a missing entry yields an error. +func ParseSHA256SUMS(content, filename string) (string, error) { + for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Format: "<64-hex> [ *]name". Split on the first run of whitespace. + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + hash := fields[0] + name := strings.TrimPrefix(strings.Join(fields[1:], " "), "*") + if name == filename { + if len(hash) != 64 { + return "", fmt.Errorf("update: malformed sha256 %q for %s", hash, filename) + } + return strings.ToLower(hash), nil + } + } + return "", fmt.Errorf("update: %s not found in SHA256SUMS", filename) +} + +// VerifyFileSHA256 hashes the file at path and reports whether its hex digest +// matches expectedHex (constant-time comparison). +func VerifyFileSHA256(path, expectedHex string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false, err + } + actual := h.Sum(nil) + want, err := hex.DecodeString(strings.TrimSpace(expectedHex)) + if err != nil { + return false, fmt.Errorf("update: invalid expected hash: %w", err) + } + return subtle.ConstantTimeCompare(actual, want) == 1, nil +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..af560b2 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# +# vocat install / update script for binary + systemd deployments. +# +# Usage: +# sudo bash install.sh [version] # install a specific version +# sudo bash install.sh # install latest release +# sudo bash install.sh --force # reinstall even at the same version +# curl -fsSL | sudo bash # one-liner (latest) +# +# Behavior: +# - Prompts for script language (中文 / English) as soon as it runs. +# - If the installed version equals the target version, does nothing (unless --force). +# - On first install, generates a random 32-char admin password, writes it to +# /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE. +# - On update, preserves the existing env file and credentials. +# - (Re)writes the systemd unit and restarts the service. +# +# Published script: must contain no secrets, IPs, or passwords. + +set -euo pipefail + +# --- Publisher configuration ------------------------------------------------- +# Default GitHub repository in owner/name form. Publishers: set this to your +# own repo, or override per-run with VOCAT_REPO. +REPO="${VOCAT_REPO:-your-org/vocat}" + +INSTALL_DIR="/opt/vocat/bin" +BINARY_PATH="${INSTALL_DIR}/vocat" +ENV_DIR="/etc/vocat" +ENV_FILE="${ENV_DIR}/env" +UNIT_PATH="/etc/systemd/system/vocat.service" +VOCAT_USER="vocat" + +# --- Language ---------------------------------------------------------------- +LANG_CHOICE="" + +msg() { + # $1 = zh text, $2 = en text + if [ "$LANG_CHOICE" = "en" ]; then + printf '%s\n' "$2" + else + printf '%s\n' "$1" + fi +} + +prompt_language() { + while true; do + echo "选择语言 / Select language: 1) 中文 2) English" + printf '> ' + read -r choice + case "$choice" in + 1|"") LANG_CHOICE="zh"; return ;; + 2) LANG_CHOICE="en"; return ;; + esac + done +} + +die() { + msg "$1" "$2" >&2 + exit 1 +} + +# --- Root -------------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "请以 root 身份运行此脚本。" "Run this script as root." + +prompt_language + +# --- Parse args -------------------------------------------------------------- +FORCE=0 +TARGET_VERSION="" +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + -h|--help) + msg "用法: sudo bash install.sh [--force] [版本]" "Usage: sudo bash install.sh [--force] [version]" + exit 0 + ;; + *) TARGET_VERSION="${arg#v}" ;; + esac +done + +# --- Resolve target version -------------------------------------------------- +resolve_target_version() { + if [ -n "$TARGET_VERSION" ]; then + TARGET_VERSION="${TARGET_VERSION#v}" + return + fi + local api_url="https://api.github.com/repos/${REPO}/releases/latest" + local auth_hdr=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth_hdr=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + local resp + resp=$(curl -fsSL "${auth_hdr[@]}" "$api_url") || die "无法获取最新版本信息。检查网络或 REPO 设置。" "Failed to fetch latest release. Check network or REPO." + # Parse "tag_name": "vX.Y.Z" without jq. + local tag + tag=$(printf '%s\n' "$resp" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + [ -n "$tag" ] || die "无法解析最新版本的 tag_name。" "Could not parse tag_name from the release response." + TARGET_VERSION="${tag#v}" +} + +# --- Skip if already installed at the same version --------------------------- +skip_if_equal() { + [ -x "$BINARY_PATH" ] || return 0 + [ "$FORCE" -eq 1 ] && return 0 + local installed + installed=$("$BINARY_PATH" version 2>/dev/null | awk '{print $2}' | sed -E 's/[[:space:]]*\(.*$//') || return 0 + [ -z "$installed" ] && return 0 + if [ "$installed" = "$TARGET_VERSION" ]; then + msg "已安装版本 $installed,与目标版本相同,跳过更新。" "Installed version $installed equals target; skipping." + exit 0 + fi + msg "当前 $installed -> $TARGET_VERSION,开始更新。" "Updating $installed -> $TARGET_VERSION." +} + +# --- Detect architecture ----------------------------------------------------- +detect_arch() { + case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + i386|i486|i586|i686) ARCH="386" ;; + aarch64|arm64) ARCH="arm64" ;; + armv7l|armv7*) ARCH="armv7" ;; + *) die "不支持的架构: $(uname -m)" "Unsupported architecture: $(uname -m)" ;; + esac +} + +# --- Download + verify ------------------------------------------------------- +VOCAT_TMP="" +download_and_verify() { + VOCAT_TMP=$(mktemp -d) + trap 'rm -rf "$VOCAT_TMP"' EXIT + local base="https://github.com/${REPO}/releases/download/v${TARGET_VERSION}" + local asset="vocat-linux-${ARCH}" + msg "下载 $asset ..." "Downloading $asset ..." + curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary." + curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS." + + local expected actual + # Match a line whose filename field equals the asset (with optional binary-mode * prefix). + expected=$(awk -v a="$asset" '$2 == a || $2 == ("*" a) {print $1; exit}' "${VOCAT_TMP}/SHA256SUMS") + [ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS." + actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}') + [ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed." +} + +# --- Install binary ---------------------------------------------------------- +install_binary() { + install -d -m 0755 "$INSTALL_DIR" + install -m 0755 "${VOCAT_TMP}/vocat" "$BINARY_PATH" +} + +# --- System user (idempotent) ------------------------------------------------ +ensure_user() { + if id "$VOCAT_USER" >/dev/null 2>&1; then + return + fi + useradd --system --no-create-home --shell /usr/sbin/nologin "$VOCAT_USER" +} + +# --- Data directory ---------------------------------------------------------- +ensure_data_dir() { + install -d -m 0755 /opt/vocat/data + chown -R "$VOCAT_USER":"$VOCAT_USER" /opt/vocat || true +} + +# --- Env file (first install only) ------------------------------------------- +# Generates a random 32-char secret, stores it in the 0600 env file, and flags +# FIRST_INSTALL so we can print the secret once at the end. +FIRST_INSTALL=0 +setup_env() { + if [ -f "$ENV_FILE" ]; then + return + fi + install -d -m 0755 "$ENV_DIR" + local secret + secret=$(tr -dc 'A-Za-z0-9' "$ENV_FILE" + chmod 0600 "$ENV_FILE" + FIRST_INSTALL=1 +} + +# --- systemd unit ------------------------------------------------------------ +write_unit() { + cat > "$UNIT_PATH" <' + - ''; - // Disclaimer / EULA overlay shown after login (first run requires typing the // phrase; subsequent periodic confirmations only require a click). export function Disclaimer({ @@ -145,14 +145,7 @@ export function Disclaimer({ const canAgree = !firstTime || typed === phrase; function reject() { - message.warning(zh ? t("正在退出并清理软件...") : "Exiting and cleaning up..."); - api - .api("/system/uninstall", { method: "POST" }) - .catch(() => {}) - .finally(() => { - const text = zh ? t("软件已被卸载 / 服务已终止") : "Software uninstalled / service stopped"; - document.body.innerHTML = `
${OVERLAY_ICON}
${text}
`; - }); + window.close(); } return ( @@ -202,7 +195,7 @@ export function Disclaimer({ onClick={reject} className="flex-1 rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm font-bold tracking-wide text-gray-500 transition-all duration-300 hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-red-900/50 dark:hover:bg-red-900/20 dark:hover:text-red-400" > - {zh ? t("拒绝并卸载") : "Decline & Uninstall"} + {zh ? t("拒绝&退出程序") : "Decline & Exit"} } /> +
支持占位符:{"{{text}}"}{"{{event}}"}{"{{timestamp}}"}{"{{device_id}}"}、 - {"{{device_name}}"}{"{{device_label}}"}。留空则直接发送原始 text。 + {"{{device_name}}"}{"{{device_label}}"}{"{{number}}"}{"{{time}}"}。留空则使用标准短信模板。 ) : ( <> Supported placeholders: {"{{text}}"}, {"{{event}}"}, {"{{timestamp}}"},{" "} - {"{{device_id}}"}, {"{{device_name}}"}, {"{{device_label}}"}. Leave empty to send the - raw text. + {"{{device_id}}"}, {"{{device_name}}"}, {"{{device_label}}"}, {"{{number}}"}, and + {"{{time}}"}. Leave empty to use the standard SMS template. ) } @@ -220,7 +232,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp onChange={(e) => onChange({ textTemplate: e.target.value })} disabled={off} rows={2} - placeholder="{{device_label}} {{text}}" + placeholder={"收到新短信\n设备 {{device_label}}\n号码 {{number}}\n时间 {{time}}\n内容 {{text}}"} />
diff --git a/web/src/components/settings/model.ts b/web/src/components/settings/model.ts index 8954889..d5569df 100644 --- a/web/src/components/settings/model.ts +++ b/web/src/components/settings/model.ts @@ -150,7 +150,7 @@ export function formsFromNotifications(data: Partial): Not retryMax: num(webhook.retryMax, 3), textTemplate: webhook.textTemplate === null || webhook.textTemplate === undefined - ? "{{device_label}} {{text}}" + ? "收到新短信\n设备 {{device_label}}\n号码 {{number}}\n时间 {{time}}\n内容 {{text}}" : String(webhook.textTemplate), headers: recordToHeaderRows(webhook.headers), }, diff --git a/web/src/components/shell/AuthenticatedShell.tsx b/web/src/components/shell/AuthenticatedShell.tsx index d2f6455..0d7431b 100644 --- a/web/src/components/shell/AuthenticatedShell.tsx +++ b/web/src/components/shell/AuthenticatedShell.tsx @@ -20,6 +20,7 @@ import { Drawer } from "../ui/Drawer"; import { ErrorBoundary } from "../ui/ErrorBoundary"; import { cx } from "../../lib/utils"; import { BrandLogo } from "./BrandLogo"; +import { VersionBadge } from "./VersionBadge"; const NAV = [ { to: "/", label: "仪表盘", icon: BoardRegular, end: true }, @@ -138,7 +139,7 @@ export function AuthenticatedShell({ {!collapsed && (
vocat
-
{t("EC20 出厂检测工具")}
+
{t("高通模块测试工具")}
)}
@@ -153,7 +154,7 @@ export function AuthenticatedShell({
vocat
-
{t("EC20 出厂检测工具")}
+
{t("高通模块测试工具")}
{menuList(false)} @@ -178,6 +179,7 @@ export function AuthenticatedShell({
+
diff --git a/web/src/components/shell/VersionBadge.tsx b/web/src/components/shell/VersionBadge.tsx new file mode 100644 index 0000000..521413d --- /dev/null +++ b/web/src/components/shell/VersionBadge.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { api } from "../../api"; +import type { SystemInfo } from "../../types"; + +export function VersionBadge() { + const [version, setVersion] = useState(""); + + useEffect(() => { + let cancelled = false; + api("/system/info") + .then((info) => { + if (!cancelled) setVersion(info?.version ?? ""); + }) + .catch(() => { + // A failed info probe leaves the badge at its dev fallback; the + // shell still renders and other clusters are unaffected. + }); + return () => { + cancelled = true; + }; + }, []); + + const label = version ? `v${version}` : "vdev"; + return ( + + {label} + + ); +} diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index ea29c77..b668403 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -70,8 +70,8 @@ export const EN_DICT: Record = { "请输入用户名和密码": "Please enter your username and password", 欢迎回来: "Welcome back", "登录失败,请检查凭证": "Sign-in failed. Check your credentials.", - "EC20 出厂专业检测工具": "EC20 Factory Professional Test Tool", - "EC20 出厂检测工具": "EC20 Factory Test Tool", + "高通模块专业测试工具": "Qualcomm Module Professional Test Tool", + "高通模块测试工具": "Qualcomm Module Test Tool", 用户名: "Username", 密码: "Password", 登录: "Sign In", @@ -162,7 +162,6 @@ export const EN_DICT: Record = { 信任代理请求头: "Trust Proxy Headers", "仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。": "Enable only behind a trusted reverse proxy; the source is then determined by X-Forwarded-For. Otherwise clients can spoof that header to bypass the internal restriction.", - 当前连接允许访问: "Current connection is allowed", "当前连接将被拒绝,保存后可能无法继续访问": "Current connection will be denied; you may lose access after saving", 访问策略加载失败: "Failed to load access policy", 访问策略已保存: "Access policy saved", @@ -172,8 +171,16 @@ export const EN_DICT: Record = { // ---- 设置页:Bot 渠道(Telegram/Pushplus) ---- "启用 Telegram 机器人": "Enable Telegram Bot", + "启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。": + "When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.", "启用 Pushplus 推送": "Enable Pushplus", + "该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。": + "This channel only pushes newly received SMS messages and provides no device controls. Every SMS is pushed separately and is not merged by content.", "例如 123456": "e.g. 123456", + "接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。": + "Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.", + "只有该 Telegram 用户可以执行控制命令;留空时仅推送通知,不接受命令。": + "Only this Telegram user may run control commands. Leave blank for notifications only.", "TG API 反代(可选)": "TG API Reverse Proxy (optional)", "HTTP 代理(可选)": "HTTP Proxy (optional)", "反向代理地址 (例如 https://api.telegram.org/bot%s/%s)": @@ -543,7 +550,7 @@ export const EN_DICT: Record = { "扫描超时或模组忙,请稍后重试": "Scan timed out or the modem is busy; please retry later", "正在请求模组扫描可用网络...": "Requesting a network scan from the modem...", "运营商扫描需要开启蜂窝射频;请先关闭飞行模式,再手动开始扫描。": "Carrier scanning requires the cellular radio. Turn off airplane mode, then start the scan manually.", - "拒绝并卸载": "Decline & Uninstall", + "拒绝&退出程序": "Decline & Exit", "指令下发失败": "Failed to issue the command", "排序": "Sort", "排序:信号": "Sort: Signal", @@ -589,7 +596,6 @@ export const EN_DICT: Record = { "正在搜索周围网络,这可能需要 1-3 分钟...": "Scanning for nearby networks; this may take 1-3 minutes...", "正在注册到 {plmn},请稍候(可能需要 1-2 分钟)...": "Registering to {plmn}; please wait (this may take 1-2 minutes)...", "正在连接...": "Connecting...", - "正在退出并清理软件...": "Exiting and cleaning up...", "此SIM卡可能不支持 eUICC 功能": "This SIM card may not support eUICC", "此类 WWAN QMI 设备运行后端固定为 QMI;AT 口仍会保留给 AT 终端。": "This WWAN QMI device is fixed to the QMI backend; the AT port remains available for the AT terminal.", "此类设备固定 MBIM,AT 口仅用于终端": "This device is fixed to MBIM; the AT port is for the terminal only", @@ -657,7 +663,6 @@ export const EN_DICT: Record = { "超时(ms)": "Timeout (ms)", "轮换失败": "Rotation failed", "轮换请求已发送": "Rotation request sent", - "软件已被卸载 / 服务已终止": "Software uninstalled / service stopped", "输入新名称": "Enter a new name", "输入菜单选项数字": "Enter the menu option number", "运营商扫描完成": "Carrier scan completed", diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index d94587c..9095a55 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -43,7 +43,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) { useEffect(() => { document.documentElement.lang = lang === "zh" ? "zh-CN" : "en"; - document.title = lang === "zh" ? "vocat · EC20 出厂专业检测工具" : "vocat · EC20 Factory Professional Test Tool"; + document.title = lang === "zh" ? "vocat · 高通模块专业测试工具" : "vocat · Qualcomm Module Professional Test Tool"; }, [lang]); // 语言偏好存数据库(GET 无需鉴权):任意设备/浏览器打开都是同一种语言。 diff --git a/web/src/lib/useMediaQuery.ts b/web/src/lib/useMediaQuery.ts new file mode 100644 index 0000000..324c9a8 --- /dev/null +++ b/web/src/lib/useMediaQuery.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +// Returns whether a CSS media query currently matches. SSR-safe (defaults false). +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const mq = window.matchMedia(query); + const update = () => setMatches(mq.matches); + update(); + window.addEventListener("resize", update, { passive: true }); + return () => window.removeEventListener("resize", update); + }, [query]); + + return matches; +} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 1b0c0d1..97a6c01 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { ArrowSyncRegular, AddRegular } from "@fluentui/react-icons"; +import { ArrowSyncRegular, AddRegular, ChevronLeftRegular } from "@fluentui/react-icons"; import { api, apiMessage, camelize } from "../api"; import type { CardPolicy, DeviceConfig, DeviceListItem, DiscoveredDevice } from "../types"; import { usePolling } from "../lib/usePolling"; +import { useMediaQuery } from "../lib/useMediaQuery"; import { Button, PageHeader, RefreshButton, ErrorState, ListSkeleton, Tabs, confirmDialog, message } from "../components/ui"; import { DeviceListPanel, type StatusFilter, type SortDir, type SortKey } from "../components/devices/DeviceListPanel"; import { DeviceDetailHeader } from "../components/devices/DeviceDetailHeader"; @@ -76,6 +77,17 @@ export default function DevicesPage() { const searchParamsRef = useRef(searchParams); searchParamsRef.current = searchParams; + const isMobile = useMediaQuery("(max-width: 767px)"); + + const handleBackToList = useCallback(() => { + setSelectedId(""); + const p = new URLSearchParams(searchParamsRef.current); + p.delete("device"); + p.delete("tab"); + setSearchParams(p, { replace: true }); + setDetail(null); + }, [setSearchParams]); + const loadDetail = useCallback(async (id: string) => { if (!id) { setDetail(null); @@ -633,23 +645,30 @@ export default function DevicesPage() { /> ) : null}
- selectDevice(id)} - /> -
+ {(!isMobile || !selectedId) && ( + selectDevice(id)} + /> + )} +
+ {isMobile && selectedId && detail ? ( + + ) : null} {detail ? ( <>
-
- V +
+

vocat

-

{t("EC20 出厂专业检测工具")}

+

{t("高通模块专业测试工具")}

diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index eca94c2..fde3a85 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -207,26 +207,26 @@ export default function LogsPage() { title={t("实时日志")} subtitle={t("查看系统运行日志,支持过滤和搜索")} actions={ -
+
- -
} /> -
+
) : null} -
+
-
+
setSearch(e.target.value)} placeholder={t("搜索日志内容...")} - className="w-64" + className="w-full sm:w-64" suffix={ search ? (
@@ -284,7 +284,7 @@ export default function LogsPage() {
{filtered.length === 0 ? (