mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Initial
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# ---- Binaries / build outputs ----
|
# ---- Binaries / build outputs ----
|
||||||
/vocat
|
/vocat
|
||||||
/vocat.exe
|
/vocat.exe
|
||||||
|
/build/
|
||||||
|
/release.py
|
||||||
/build/vocat
|
/build/vocat
|
||||||
/build/vocat-linux-amd64
|
/build/vocat-linux-amd64
|
||||||
/build/vocat-linux-amd64.exe
|
/build/vocat-linux-amd64.exe
|
||||||
@@ -27,9 +29,13 @@ web/node_modules/
|
|||||||
build/__pycache__/
|
build/__pycache__/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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 ----
|
# ---- Docs / scratch ----
|
||||||
*.md
|
*.md
|
||||||
|
!README.md
|
||||||
*.txt
|
*.txt
|
||||||
build/lists/
|
build/lists/
|
||||||
|
|
||||||
|
|||||||
+49
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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 <INSTALL_SCRIPT_URL> | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
用官方 install.sh 脚本的 raw 链接替换 `<INSTALL_SCRIPT_URL>`。脚本会:选择语言(中 / 英)、检测架构(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://<host>: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 <REPOSITORY_URL>
|
||||||
|
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 即表示你承诺确保你的使用是授权的,并符合适用许可、法律、电信法规、运营商政策与测试要求。若不同意项目许可条款或使用限制,请勿使用本软件。
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/server"
|
"vocat/internal/server"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
|
"vocat/internal/update"
|
||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
"vocat/internal/vowifi/ike"
|
"vocat/internal/vowifi/ike"
|
||||||
"vocat/internal/vowifi/ims"
|
"vocat/internal/vowifi/ims"
|
||||||
@@ -30,10 +31,44 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000)
|
||||||
logger := slog.New(logs)
|
logger := slog.New(logs)
|
||||||
|
|
||||||
|
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 {
|
if err := run(logger, logs); err != nil {
|
||||||
logger.Error("server stopped", "error", err)
|
logger.Error("server stopped", "error", err)
|
||||||
os.Exit(1)
|
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 {
|
func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||||
@@ -125,6 +160,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
}
|
}
|
||||||
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
||||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||||
|
handler.StartTelegramBot(pollContext)
|
||||||
|
handler.StartSMSNotificationDispatchers(pollContext)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Address,
|
Addr: cfg.Address,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
module vocat
|
module vocat
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.41.0
|
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
|
modernc.org/sqlite v1.38.2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +18,6 @@ require (
|
|||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -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 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -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 + ")"
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vocat/internal/auth"
|
"vocat/internal/auth"
|
||||||
|
"vocat/internal/buildinfo"
|
||||||
"vocat/internal/i18n"
|
"vocat/internal/i18n"
|
||||||
"vocat/internal/loghub"
|
"vocat/internal/loghub"
|
||||||
"vocat/internal/store"
|
"vocat/internal/store"
|
||||||
@@ -302,8 +303,8 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"version": "0.1.0-dev",
|
"version": buildinfo.Version,
|
||||||
"build_time": "",
|
"build_time": buildinfo.BuildTime,
|
||||||
"config": "VOCAT_CONFIG and environment",
|
"config": "VOCAT_CONFIG and environment",
|
||||||
"os": runtime.GOOS,
|
"os": runtime.GOOS,
|
||||||
"architecture": runtime.GOARCH,
|
"architecture": runtime.GOARCH,
|
||||||
@@ -319,7 +320,7 @@ func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"available": false,
|
"available": false,
|
||||||
"version": "0.1.0-dev",
|
"version": buildinfo.Version,
|
||||||
"message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
|
"message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -254,11 +254,28 @@ func validateNotificationField(
|
|||||||
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
if len(value) > limit || strings.ContainsAny(value, "\x00") {
|
||||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
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 {
|
if _, err := parseOutboundURL(value, false); err != nil {
|
||||||
return fmt.Errorf("%s is not a valid HTTP URL", field)
|
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 name == "from_address" && value != "" {
|
||||||
if _, err := mail.ParseAddress(value); err != nil {
|
if _, err := mail.ParseAddress(value); err != nil {
|
||||||
return fmt.Errorf("%s is not a valid email address", field)
|
return fmt.Errorf("%s is not a valid email address", field)
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
|||||||
body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`,
|
body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`,
|
||||||
code: "invalid_notification_config",
|
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",
|
name: "unknown field",
|
||||||
body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`,
|
body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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": "[email protected]", "to_addresses": []any{"[email protected]"}},
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestApplySMSDeliveryReportTracksEverySubmittedPart(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database := openTestStore(t, ":memory:")
|
database := openTestStore(t, ":memory:")
|
||||||
|
|||||||
@@ -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))
|
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
|
// ApplySMSDeliveryReport attaches a TP-STATUS report to the newest matching
|
||||||
// outbound submission and advances its aggregate delivery state. Multipart
|
// outbound submission and advances its aggregate delivery state. Multipart
|
||||||
// messages become delivered only after every submitted part is reported.
|
// messages become delivered only after every submitted part is reported.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// "<hash> <filename>" 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
|
||||||
|
}
|
||||||
@@ -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 <raw url> | 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' </dev/urandom | head -c 32)
|
||||||
|
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
|
||||||
|
printf 'VOCAT_ADMIN_PASSWORD=%s\n' "$secret" > "$ENV_FILE"
|
||||||
|
chmod 0600 "$ENV_FILE"
|
||||||
|
FIRST_INSTALL=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- systemd unit ------------------------------------------------------------
|
||||||
|
write_unit() {
|
||||||
|
cat > "$UNIT_PATH" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=vocat
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=${VOCAT_USER}
|
||||||
|
EnvironmentFile=${ENV_FILE}
|
||||||
|
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||||
|
ExecStart=${BINARY_PATH}
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
chmod 0644 "$UNIT_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_and_start() {
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now vocat
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main --------------------------------------------------------------------
|
||||||
|
resolve_target_version
|
||||||
|
detect_arch
|
||||||
|
skip_if_equal
|
||||||
|
download_and_verify
|
||||||
|
install_binary
|
||||||
|
ensure_user
|
||||||
|
ensure_data_dir
|
||||||
|
setup_env
|
||||||
|
write_unit
|
||||||
|
enable_and_start
|
||||||
|
|
||||||
|
if [ "$FIRST_INSTALL" -eq 1 ]; then
|
||||||
|
secret=$(grep -E '^VOCAT_ADMIN_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)
|
||||||
|
echo
|
||||||
|
msg "================ 安装完成 ================" "================ Install complete ================"
|
||||||
|
msg "首次安装已生成管理员初始密码 (仅显示一次):" "First-install admin password (shown once):"
|
||||||
|
echo
|
||||||
|
echo " $secret"
|
||||||
|
echo
|
||||||
|
msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now."
|
||||||
|
msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:"
|
||||||
|
echo " sudo vocat menu"
|
||||||
|
msg "==========================================" "=============================================="
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
msg "================ 更新完成 ================" "================ Update complete ================"
|
||||||
|
msg "已更新到 $TARGET_VERSION,服务已重启。" "Updated to $TARGET_VERSION; service restarted."
|
||||||
|
msg "管理员密码保持不变。" "Admin password unchanged."
|
||||||
|
msg "==========================================" "=============================================="
|
||||||
|
fi
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<title>vocat · EC20 出厂专业检测工具</title>
|
<title>vocat · 高通模块专业测试工具</title>
|
||||||
<script src="/theme-init.js"></script>
|
<script src="/theme-init.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cx } from "../lib/utils";
|
import { cx } from "../lib/utils";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { message } from "./ui/message";
|
|
||||||
import * as api from "../api";
|
|
||||||
|
|
||||||
const PHRASES = { zh: "我同意并确认", en: "I agree and confirm" } as const;
|
const PHRASES = { zh: "我同意并确认", en: "I agree and confirm" } as const;
|
||||||
|
|
||||||
@@ -30,105 +28,107 @@ function Item({ index, children }: { index: number; children: React.ReactNode })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中文条款(新增:仅限高通模块检测/测试卡、禁止 MCC 460、仅限美国硬件企业开发商)。
|
// 中文条款(与 README.md 的许可 / 使用 / 免责条款对齐)。
|
||||||
function ZhItems() {
|
function ZhItems() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item index={1}>
|
<Item index={1}>
|
||||||
本软件(vocat)属于个人开发者业余时间开发的工具软件,支持高通模块调试,仅供技术研究、学习交流及企业内部测试使用。
|
本软件(vocat)为 source-available(源码可见)软件,依据 Vocat Research & Evaluation License 分发,
|
||||||
<strong className="text-indigo-600 dark:text-indigo-400">严禁用于任何形式的商业出售或转售</strong>
|
<strong className="text-indigo-600 dark:text-indigo-400">并非 OSI 认证的开源许可</strong>
|
||||||
,严禁作为生产环境的基础设施。
|
。仅授权用于研究、教育、开发及高通蜂窝模组硬件功能验证;获取源码并不自动授予商用、再发布不受限修改版本或移除防滥用控制的权利。
|
||||||
|
</Item>
|
||||||
|
<Item index={2}>
|
||||||
|
本项目用于对自研 / 定制高通模组(首发 Quectel EC20)进行功能验证与故障诊断。仅应使用测试卡、开发卡、实验室卡、授权 eSIM profile,或本人拥有 / 被明确授权测试的 SIM/eSIM 资源;
|
||||||
|
<strong className="text-indigo-600 dark:text-indigo-400">不得使用属于他人的生产用订户凭证</strong>。
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={2}>本项目仅用于高通模块功能正常检测使用,仅限接入测试类卡片使用。</Item>
|
|
||||||
<Item index={3}>
|
<Item index={3}>
|
||||||
<strong className="text-red-500 dark:text-red-400">禁止 MCC 460 卡片进行测试。</strong>
|
对 MCC 460 / 461(中国大陆)SIM 卡,系统将<strong className="text-red-500 dark:text-red-400">自动强制飞行模式并写入卡策略</strong>
|
||||||
|
;向 +86 号段发送短信会被服务端拦截。上述为代码层强制控制,严禁移除、绕过、禁用、伪装或破坏。
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={4}>
|
<Item index={4}>
|
||||||
本软件面向使用配套设备进行高通模块调试的企业开发者提供。允许企业使用本软件对其设备进行调试,但
|
<strong className="text-red-500 dark:text-red-400">禁止用途:</strong>
|
||||||
<strong className="text-red-500 dark:text-red-400">严禁商业出售或转售</strong>
|
未授权接入电信网络、冒用他人订户或设备、SIM 克隆、未授权 eSIM 开通、使用被盗 / 泄露凭证、电信欺诈、大规模群发短信、绕过运营商鉴权或合法限制、未授权拦截 / 监听、干扰移动网络基础设施,以及商业电信服务。
|
||||||
;如发现在此范围外的违规使用,
|
|
||||||
<strong className="text-red-500 dark:text-red-400">我们将会自动锁定软件以及拉黑您的卡片 EID</strong>。
|
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={5}>
|
<Item index={5}>
|
||||||
使用者承诺将严格遵守所在国家或地区的相关法律法规。
|
未经书面授权不得商用。修改或再发布版本
|
||||||
<strong className="text-red-500 dark:text-red-400">
|
<strong className="text-red-500 dark:text-red-400">
|
||||||
严禁将本软件用于电信诈骗、垃圾短信发送、非法网络代理、渗透测试等任何非法或违规场景
|
不得以移除或绕过地域限制、SIM / MCC 限制、设备数量限制、鉴权机制、完整性校验或防滥用控制为主要目的
|
||||||
</strong>
|
</strong>
|
||||||
。
|
,并须保留版权、许可与署名声明。
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={6}>
|
<Item index={6}>
|
||||||
本软件涉及底层 Modem 通信操作,可能包含未知的缺陷。对于因使用本软件引发的硬件损坏、通信资费异常、隐私泄露等直接或间接损失,
|
软件按 “AS IS” 提供,不附带任何明示或暗示的担保。作者、维护者、贡献者与分发者不对使用或滥用造成的损失负责,包括 SIM 卡损坏、eSIM profile 丢失、SIM 停用、modem / 基带故障、PCB / 模组 / 宿主设备损坏、网络服务中断、运营商 / 账户限制、数据丢失、监管后果及未授权的电信活动;
|
||||||
<strong>由使用者自行承担所有责任</strong>。
|
<strong>使用者有责任确保其使用符合适用法律、运营商政策与合同义务</strong>。
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={7}>
|
<Item index={7}>
|
||||||
一旦点击继续即表示无条件接受本协议。如果您拒绝,本软件将立即触发自毁与环境清理机制以确保设备安全。
|
点击继续即表示你承诺:你的使用是授权的,并符合适用许可、法律、电信法规、运营商政策与测试要求。若拒绝,本软件将被卸载并清理运行环境。
|
||||||
</Item>
|
</Item>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// English clauses (mirror of the Chinese items).
|
// English clauses (mirror of the Chinese items, aligned with README.md).
|
||||||
function EnItems() {
|
function EnItems() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item index={1}>
|
<Item index={1}>
|
||||||
This software (vocat) is a utility built by an independent developer in their spare time. It supports Qualcomm
|
This software (vocat) is source-available software distributed under the Vocat Research & Evaluation License
|
||||||
module debugging and is provided only for technical research, learning, and enterprise internal testing.{" "}
|
and{" "}
|
||||||
<strong className="text-indigo-600 dark:text-indigo-400">
|
<strong className="text-indigo-600 dark:text-indigo-400">
|
||||||
It is strictly prohibited to sell or resell it commercially in any form
|
is not an OSI-approved open-source license
|
||||||
</strong>{" "}
|
</strong>
|
||||||
or to use it as production infrastructure.
|
. It is authorized only for research, education, development, and hardware function verification of Qualcomm
|
||||||
|
cellular modules; access to source code does not automatically grant the right to commercial use, redistribute
|
||||||
|
unrestricted modified versions, or remove anti-abuse controls.
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={2}>
|
<Item index={2}>
|
||||||
This project is intended solely for verifying the proper functioning of Qualcomm modules; only test-class
|
This project is for function verification and fault diagnosis of custom Qualcomm modules (primarily Quectel
|
||||||
SIM cards may be connected.
|
EC20). Only test, development, or lab SIM cards, authorized eSIM profiles, or SIM/eSIM resources you own or are
|
||||||
|
explicitly authorized to test may be used;{" "}
|
||||||
|
<strong className="text-indigo-600 dark:text-indigo-400">
|
||||||
|
production subscriber credentials belonging to others must not be used
|
||||||
|
</strong>
|
||||||
|
.
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={3}>
|
<Item index={3}>
|
||||||
|
For MCC 460 / 461 (Chinese mainland) SIM cards, the system will{" "}
|
||||||
<strong className="text-red-500 dark:text-red-400">
|
<strong className="text-red-500 dark:text-red-400">
|
||||||
Testing with MCC 460 (China) SIM cards is strictly prohibited.
|
automatically force airplane mode and write a card policy
|
||||||
</strong>
|
</strong>
|
||||||
|
; SMS to +86 numbers is blocked by the server. These are code-enforced controls and must not be removed,
|
||||||
|
bypassed, disabled, disguised, or tampered with.
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={4}>
|
<Item index={4}>
|
||||||
This software is provided to enterprise developers who use supporting equipment to debug Qualcomm modules.
|
<strong className="text-red-500 dark:text-red-400">Prohibited uses:</strong>{" "}
|
||||||
Enterprises may use this software to debug their own equipment, but{" "}
|
unauthorized network access, impersonating another subscriber or device, SIM cloning, unauthorized eSIM
|
||||||
<strong className="text-red-500 dark:text-red-400">
|
provisioning, use of stolen or leaked credentials, telecom fraud, mass SMS sending, bypassing operator
|
||||||
commercial sale or resale is strictly prohibited
|
authentication or lawful restrictions, unauthorized interception, disrupting mobile network infrastructure,
|
||||||
</strong>
|
and commercial telecom services.
|
||||||
; if misuse outside this scope is detected,{" "}
|
|
||||||
<strong className="text-red-500 dark:text-red-400">
|
|
||||||
the software will be automatically locked and your card's EID will be blacklisted
|
|
||||||
</strong>
|
|
||||||
.
|
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={5}>
|
<Item index={5}>
|
||||||
The user undertakes to strictly comply with the laws and regulations of their country or region.{" "}
|
Commercial use is not permitted without written authorization. Modified or redistributed versions{" "}
|
||||||
<strong className="text-red-500 dark:text-red-400">
|
<strong className="text-red-500 dark:text-red-400">
|
||||||
It is strictly prohibited to use this software for telecom fraud, spam messaging, illegal network
|
must not have removing or bypassing regional, SIM, MCC, device-count, authentication, integrity, or
|
||||||
proxying, penetration testing, or any other illegal or non-compliant scenario
|
anti-abuse controls as their primary purpose
|
||||||
</strong>
|
</strong>
|
||||||
.
|
, and must retain copyright, license, and attribution notices.
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={6}>
|
<Item index={6}>
|
||||||
This software involves low-level modem communication and may contain unknown defects.{" "}
|
The software is provided “AS IS” without any express or implied warranty. The authors, maintainers,
|
||||||
<strong>The user bears all responsibility</strong> for any direct or indirect losses arising from its use,
|
contributors, and distributors are not liable for losses from use or misuse, including SIM damage, eSIM profile
|
||||||
including hardware damage, abnormal carrier charges, or privacy leakage.
|
loss, SIM deactivation, modem/baseband failure, PCB/module/host device damage, network service interruption,
|
||||||
|
operator/account restrictions, data loss, regulatory consequences, and unauthorized telecom activity;{" "}
|
||||||
|
<strong>the user is responsible for ensuring their use complies with applicable laws, operator policies, and contractual obligations</strong>.
|
||||||
</Item>
|
</Item>
|
||||||
<Item index={7}>
|
<Item index={7}>
|
||||||
Clicking continue constitutes unconditional acceptance of this agreement. If you decline, the software
|
Clicking continue signifies that you agree to ensure your use is authorized and complies with the applicable
|
||||||
will immediately trigger its self-destruct and environment cleanup mechanism to keep the device safe.
|
license, laws, telecom regulations, operator policies, and testing requirements. If you decline, the software
|
||||||
|
will be uninstalled and its environment cleaned up.
|
||||||
</Item>
|
</Item>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const OVERLAY_STYLE =
|
|
||||||
"display:flex;height:100vh;background:#0a0a0a;align-items:center;justify-content:center;" +
|
|
||||||
"font-size:24px;color:#ef4444;font-weight:bold;font-family:sans-serif;flex-direction:column;gap:16px;";
|
|
||||||
const OVERLAY_ICON =
|
|
||||||
'<svg style="width:64px;height:64px;" fill="none" viewBox="0 0 24 24" stroke="currentColor">' +
|
|
||||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' +
|
|
||||||
'd="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>';
|
|
||||||
|
|
||||||
// Disclaimer / EULA overlay shown after login (first run requires typing the
|
// Disclaimer / EULA overlay shown after login (first run requires typing the
|
||||||
// phrase; subsequent periodic confirmations only require a click).
|
// phrase; subsequent periodic confirmations only require a click).
|
||||||
export function Disclaimer({
|
export function Disclaimer({
|
||||||
@@ -145,14 +145,7 @@ export function Disclaimer({
|
|||||||
const canAgree = !firstTime || typed === phrase;
|
const canAgree = !firstTime || typed === phrase;
|
||||||
|
|
||||||
function reject() {
|
function reject() {
|
||||||
message.warning(zh ? t("正在退出并清理软件...") : "Exiting and cleaning up...");
|
window.close();
|
||||||
api
|
|
||||||
.api("/system/uninstall", { method: "POST" })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
const text = zh ? t("软件已被卸载 / 服务已终止") : "Software uninstalled / service stopped";
|
|
||||||
document.body.innerHTML = `<div style="${OVERLAY_STYLE}"><div>${OVERLAY_ICON}</div><div>${text}</div></div>`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -202,7 +195,7 @@ export function Disclaimer({
|
|||||||
onClick={reject}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function UpstreamRowCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-0.5 hidden h-3.5 w-px bg-gray-200 dark:bg-gray-700 sm:block" />
|
<div className="mx-0.5 hidden h-3.5 w-px bg-gray-200 dark:bg-gray-700 sm:block" />
|
||||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>
|
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>
|
||||||
{t("设备绑定")}
|
<span className="hidden sm:inline">{t("设备绑定")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)} />
|
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)} />
|
||||||
<Button size="small" variant="danger" icon={<DeleteRegular />} onClick={() => onDelete(row)} />
|
<Button size="small" variant="danger" icon={<DeleteRegular />} onClick={() => onDelete(row)} />
|
||||||
@@ -64,8 +64,8 @@ export function UpstreamSection({ rows, loading, error, onRetry, onNew, onEdit,
|
|||||||
<ErrorState className="mb-6" title={t("加载上游代理失败")} message={error.message} statusCode={error.status} retryText={t("重试")} onRetry={onRetry} />
|
<ErrorState className="mb-6" title={t("加载上游代理失败")} message={error.message} statusCode={error.status} retryText={t("重试")} onRetry={onRetry} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="ui-card p-6">
|
<div className="ui-card p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#0ea5e9] to-[#0284c7] text-white shadow-lg shadow-indigo-500/25">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[#0ea5e9] to-[#0284c7] text-white shadow-lg shadow-indigo-500/25">
|
||||||
<GlobeRegular className="text-[20px]" />
|
<GlobeRegular className="text-[20px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ export function TelegramTab({ value, onChange }: ChannelProps<TelegramForm>) {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<ChannelHeader title={t("启用 Telegram 机器人")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
<ChannelHeader title={t("启用 Telegram 机器人")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
|
{t("启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。")}
|
||||||
|
</div>
|
||||||
<Field label="Bot Token">
|
<Field label="Bot Token">
|
||||||
<Input value={value.botToken} onChange={(e) => onChange({ botToken: e.target.value })} disabled={off} placeholder="xxxx:yyyy" />
|
<Input value={value.botToken} onChange={(e) => onChange({ botToken: e.target.value })} disabled={off} placeholder="xxxx:yyyy" />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<Field label="Chat ID">
|
<Field label="Chat ID" hint={t("接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。")}>
|
||||||
<Input value={value.chatId} onChange={(e) => onChange({ chatId: e.target.value })} disabled={off} type="number" inputMode="numeric" placeholder={t("例如 123456")} />
|
<Input value={value.chatId} onChange={(e) => onChange({ chatId: e.target.value })} disabled={off} type="number" inputMode="numeric" placeholder={t("例如 123456")} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Admin ID">
|
<Field label="Admin ID" hint={t("只有该 Telegram 用户可以执行控制命令;留空时仅推送通知,不接受命令。")}>
|
||||||
<Input value={value.adminId} onChange={(e) => onChange({ adminId: e.target.value })} disabled={off} type="number" inputMode="numeric" placeholder={t("例如 123456")} />
|
<Input value={value.adminId} onChange={(e) => onChange({ adminId: e.target.value })} disabled={off} type="number" inputMode="numeric" placeholder={t("例如 123456")} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +55,9 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
|
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
||||||
|
</div>
|
||||||
<Field label="Token">
|
<Field label="Token">
|
||||||
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import {
|
|||||||
CheckmarkRegular,
|
CheckmarkRegular,
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
GlobeRegular,
|
GlobeRegular,
|
||||||
ShieldCheckmarkRegular,
|
|
||||||
WarningRegular,
|
WarningRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import type { SecuritySettings } from "../../types";
|
import type { SecuritySettings } from "../../types";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { cx } from "../../lib/utils";
|
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { Input } from "../ui/Input";
|
import { Input } from "../ui/Input";
|
||||||
import { Switch } from "../ui/Switch";
|
import { Switch } from "../ui/Switch";
|
||||||
@@ -148,24 +146,15 @@ export function NetworkAccessCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{!clientAllowed && (
|
||||||
className={cx(
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
|
||||||
"flex items-center gap-2 rounded-lg border p-3 text-xs",
|
|
||||||
clientAllowed
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200"
|
|
||||||
: "border-red-200 bg-red-50 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{clientAllowed ? (
|
|
||||||
<ShieldCheckmarkRegular className="shrink-0" />
|
|
||||||
) : (
|
|
||||||
<WarningRegular className="shrink-0" />
|
<WarningRegular className="shrink-0" />
|
||||||
)}
|
|
||||||
<span className="font-mono">{clientIp || "--"}</span>
|
<span className="font-mono">{clientIp || "--"}</span>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{clientAllowed ? t("当前连接允许访问") : t("当前连接将被拒绝,保存后可能无法继续访问")}
|
{t("当前连接将被拒绝,保存后可能无法继续访问")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ function hasAnyUrl(urls: string[]): boolean {
|
|||||||
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SMSOnlyHint() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
|
||||||
|
{t("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const BARK_LEVEL_OPTIONS = [
|
const BARK_LEVEL_OPTIONS = [
|
||||||
{ value: "timeSensitive", label: "Time-Sensitive (timeSensitive)" },
|
{ value: "timeSensitive", label: "Time-Sensitive (timeSensitive)" },
|
||||||
{ value: "active", label: "Active (active)" },
|
{ value: "active", label: "Active (active)" },
|
||||||
@@ -42,6 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SMSOnlyHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -87,6 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SMSOnlyHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
|
||||||
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
<Field label={t("SMTP 主机")} className="sm:col-span-5">
|
||||||
@@ -152,6 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SMSOnlyHint />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<UrlListEditor
|
<UrlListEditor
|
||||||
urls={value.urls}
|
urls={value.urls}
|
||||||
@@ -204,13 +216,13 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
|
|||||||
lang === "zh" ? (
|
lang === "zh" ? (
|
||||||
<>
|
<>
|
||||||
支持占位符:<code>{"{{text}}"}</code>、<code>{"{{event}}"}</code>、<code>{"{{timestamp}}"}</code>、<code>{"{{device_id}}"}</code>、
|
支持占位符:<code>{"{{text}}"}</code>、<code>{"{{event}}"}</code>、<code>{"{{timestamp}}"}</code>、<code>{"{{device_id}}"}</code>、
|
||||||
<code>{"{{device_name}}"}</code>、<code>{"{{device_label}}"}</code>。留空则直接发送原始 text。
|
<code>{"{{device_name}}"}</code>、<code>{"{{device_label}}"}</code>、<code>{"{{number}}"}</code>、<code>{"{{time}}"}</code>。留空则使用标准短信模板。
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Supported placeholders: <code>{"{{text}}"}</code>, <code>{"{{event}}"}</code>, <code>{"{{timestamp}}"}</code>,{" "}
|
Supported placeholders: <code>{"{{text}}"}</code>, <code>{"{{event}}"}</code>, <code>{"{{timestamp}}"}</code>,{" "}
|
||||||
<code>{"{{device_id}}"}</code>, <code>{"{{device_name}}"}</code>, <code>{"{{device_label}}"}</code>. Leave empty to send the
|
<code>{"{{device_id}}"}</code>, <code>{"{{device_name}}"}</code>, <code>{"{{device_label}}"}</code>, <code>{"{{number}}"}</code>, and
|
||||||
raw text.
|
<code>{"{{time}}"}</code>. 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 })}
|
onChange={(e) => onChange({ textTemplate: e.target.value })}
|
||||||
disabled={off}
|
disabled={off}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="{{device_label}} {{text}}"
|
placeholder={"收到新短信\n设备 {{device_label}}\n号码 {{number}}\n时间 {{time}}\n内容 {{text}}"}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
|
|||||||
retryMax: num(webhook.retryMax, 3),
|
retryMax: num(webhook.retryMax, 3),
|
||||||
textTemplate:
|
textTemplate:
|
||||||
webhook.textTemplate === null || webhook.textTemplate === undefined
|
webhook.textTemplate === null || webhook.textTemplate === undefined
|
||||||
? "{{device_label}} {{text}}"
|
? "收到新短信\n设备 {{device_label}}\n号码 {{number}}\n时间 {{time}}\n内容 {{text}}"
|
||||||
: String(webhook.textTemplate),
|
: String(webhook.textTemplate),
|
||||||
headers: recordToHeaderRows(webhook.headers),
|
headers: recordToHeaderRows(webhook.headers),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Drawer } from "../ui/Drawer";
|
|||||||
import { ErrorBoundary } from "../ui/ErrorBoundary";
|
import { ErrorBoundary } from "../ui/ErrorBoundary";
|
||||||
import { cx } from "../../lib/utils";
|
import { cx } from "../../lib/utils";
|
||||||
import { BrandLogo } from "./BrandLogo";
|
import { BrandLogo } from "./BrandLogo";
|
||||||
|
import { VersionBadge } from "./VersionBadge";
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: "/", label: "仪表盘", icon: BoardRegular, end: true },
|
{ to: "/", label: "仪表盘", icon: BoardRegular, end: true },
|
||||||
@@ -138,7 +139,7 @@ export function AuthenticatedShell({
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="sidebar-brand-title">vocat</div>
|
<div className="sidebar-brand-title">vocat</div>
|
||||||
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("EC20 出厂检测工具")}</div>
|
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("高通模块测试工具")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +154,7 @@ export function AuthenticatedShell({
|
|||||||
<BrandLogo className="sidebar-brand-logo" />
|
<BrandLogo className="sidebar-brand-logo" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<div className="sidebar-brand-title">vocat</div>
|
<div className="sidebar-brand-title">vocat</div>
|
||||||
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("EC20 出厂检测工具")}</div>
|
<div className="text-[10px] font-medium leading-tight tracking-wide text-gray-400 dark:text-gray-500">{t("高通模块测试工具")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{menuList(false)}
|
{menuList(false)}
|
||||||
@@ -178,6 +179,7 @@ export function AuthenticatedShell({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<VersionBadge />
|
||||||
<LanguageSwitch />
|
<LanguageSwitch />
|
||||||
<SwitchDark isDark={isDark} onToggle={onToggleTheme} />
|
<SwitchDark isDark={isDark} onToggle={onToggleTheme} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api<SystemInfo>("/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 (
|
||||||
|
<span
|
||||||
|
className="flex h-7 items-center justify-center rounded-lg px-2 font-mono text-xs text-gray-400 select-none dark:text-gray-500"
|
||||||
|
title={version ? `vocat v${version}` : "vocat dev build"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-6
@@ -70,8 +70,8 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"请输入用户名和密码": "Please enter your username and password",
|
"请输入用户名和密码": "Please enter your username and password",
|
||||||
欢迎回来: "Welcome back",
|
欢迎回来: "Welcome back",
|
||||||
"登录失败,请检查凭证": "Sign-in failed. Check your credentials.",
|
"登录失败,请检查凭证": "Sign-in failed. Check your credentials.",
|
||||||
"EC20 出厂专业检测工具": "EC20 Factory Professional Test Tool",
|
"高通模块专业测试工具": "Qualcomm Module Professional Test Tool",
|
||||||
"EC20 出厂检测工具": "EC20 Factory Test Tool",
|
"高通模块测试工具": "Qualcomm Module Test Tool",
|
||||||
用户名: "Username",
|
用户名: "Username",
|
||||||
密码: "Password",
|
密码: "Password",
|
||||||
登录: "Sign In",
|
登录: "Sign In",
|
||||||
@@ -162,7 +162,6 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
信任代理请求头: "Trust Proxy Headers",
|
信任代理请求头: "Trust Proxy Headers",
|
||||||
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
|
"仅在系统位于可信反向代理之后时开启,按 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.",
|
"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",
|
"当前连接将被拒绝,保存后可能无法继续访问": "Current connection will be denied; you may lose access after saving",
|
||||||
访问策略加载失败: "Failed to load access policy",
|
访问策略加载失败: "Failed to load access policy",
|
||||||
访问策略已保存: "Access policy saved",
|
访问策略已保存: "Access policy saved",
|
||||||
@@ -172,8 +171,16 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
|
|
||||||
// ---- 设置页:Bot 渠道(Telegram/Pushplus) ----
|
// ---- 设置页:Bot 渠道(Telegram/Pushplus) ----
|
||||||
"启用 Telegram 机器人": "Enable Telegram Bot",
|
"启用 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",
|
"启用 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",
|
"例如 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)",
|
"TG API 反代(可选)": "TG API Reverse Proxy (optional)",
|
||||||
"HTTP 代理(可选)": "HTTP Proxy (optional)",
|
"HTTP 代理(可选)": "HTTP Proxy (optional)",
|
||||||
"反向代理地址 (例如 https://api.telegram.org/bot%s/%s)":
|
"反向代理地址 (例如 https://api.telegram.org/bot%s/%s)":
|
||||||
@@ -543,7 +550,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"扫描超时或模组忙,请稍后重试": "Scan timed out or the modem is busy; please retry later",
|
"扫描超时或模组忙,请稍后重试": "Scan timed out or the modem is busy; please retry later",
|
||||||
"正在请求模组扫描可用网络...": "Requesting a network scan from the modem...",
|
"正在请求模组扫描可用网络...": "Requesting a network scan from the modem...",
|
||||||
"运营商扫描需要开启蜂窝射频;请先关闭飞行模式,再手动开始扫描。": "Carrier scanning requires the cellular radio. Turn off airplane mode, then start the scan manually.",
|
"运营商扫描需要开启蜂窝射频;请先关闭飞行模式,再手动开始扫描。": "Carrier scanning requires the cellular radio. Turn off airplane mode, then start the scan manually.",
|
||||||
"拒绝并卸载": "Decline & Uninstall",
|
"拒绝&退出程序": "Decline & Exit",
|
||||||
"指令下发失败": "Failed to issue the command",
|
"指令下发失败": "Failed to issue the command",
|
||||||
"排序": "Sort",
|
"排序": "Sort",
|
||||||
"排序:信号": "Sort: Signal",
|
"排序:信号": "Sort: Signal",
|
||||||
@@ -589,7 +596,6 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"正在搜索周围网络,这可能需要 1-3 分钟...": "Scanning for nearby networks; this may take 1-3 minutes...",
|
"正在搜索周围网络,这可能需要 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)...",
|
"正在注册到 {plmn},请稍候(可能需要 1-2 分钟)...": "Registering to {plmn}; please wait (this may take 1-2 minutes)...",
|
||||||
"正在连接...": "Connecting...",
|
"正在连接...": "Connecting...",
|
||||||
"正在退出并清理软件...": "Exiting and cleaning up...",
|
|
||||||
"此SIM卡可能不支持 eUICC 功能": "This SIM card may not support eUICC",
|
"此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.",
|
"此类 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",
|
"此类设备固定 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<string, string> = {
|
|||||||
"超时(ms)": "Timeout (ms)",
|
"超时(ms)": "Timeout (ms)",
|
||||||
"轮换失败": "Rotation failed",
|
"轮换失败": "Rotation failed",
|
||||||
"轮换请求已发送": "Rotation request sent",
|
"轮换请求已发送": "Rotation request sent",
|
||||||
"软件已被卸载 / 服务已终止": "Software uninstalled / service stopped",
|
|
||||||
"输入新名称": "Enter a new name",
|
"输入新名称": "Enter a new name",
|
||||||
"输入菜单选项数字": "Enter the menu option number",
|
"输入菜单选项数字": "Enter the menu option number",
|
||||||
"运营商扫描完成": "Carrier scan completed",
|
"运营商扫描完成": "Carrier scan completed",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
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]);
|
}, [lang]);
|
||||||
|
|
||||||
// 语言偏好存数据库(GET 无需鉴权):任意设备/浏览器打开都是同一种语言。
|
// 语言偏好存数据库(GET 无需鉴权):任意设备/浏览器打开都是同一种语言。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
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 { api, apiMessage, camelize } from "../api";
|
||||||
import type { CardPolicy, DeviceConfig, DeviceListItem, DiscoveredDevice } from "../types";
|
import type { CardPolicy, DeviceConfig, DeviceListItem, DiscoveredDevice } from "../types";
|
||||||
import { usePolling } from "../lib/usePolling";
|
import { usePolling } from "../lib/usePolling";
|
||||||
|
import { useMediaQuery } from "../lib/useMediaQuery";
|
||||||
import { Button, PageHeader, RefreshButton, ErrorState, ListSkeleton, Tabs, confirmDialog, message } from "../components/ui";
|
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 { DeviceListPanel, type StatusFilter, type SortDir, type SortKey } from "../components/devices/DeviceListPanel";
|
||||||
import { DeviceDetailHeader } from "../components/devices/DeviceDetailHeader";
|
import { DeviceDetailHeader } from "../components/devices/DeviceDetailHeader";
|
||||||
@@ -76,6 +77,17 @@ export default function DevicesPage() {
|
|||||||
const searchParamsRef = useRef(searchParams);
|
const searchParamsRef = useRef(searchParams);
|
||||||
searchParamsRef.current = 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) => {
|
const loadDetail = useCallback(async (id: string) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
@@ -633,6 +645,7 @@ export default function DevicesPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="devices-layout">
|
<div className="devices-layout">
|
||||||
|
{(!isMobile || !selectedId) && (
|
||||||
<DeviceListPanel
|
<DeviceListPanel
|
||||||
loading={listLoading}
|
loading={listLoading}
|
||||||
query={query}
|
query={query}
|
||||||
@@ -649,7 +662,13 @@ export default function DevicesPage() {
|
|||||||
onSortDirChange={setSortDir}
|
onSortDirChange={setSortDir}
|
||||||
onSelectDevice={(id) => selectDevice(id)}
|
onSelectDevice={(id) => selectDevice(id)}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 space-y-4">
|
)}
|
||||||
|
<div className={`min-w-0 space-y-4 ${isMobile && selectedId ? "" : isMobile ? "hidden" : ""}`}>
|
||||||
|
{isMobile && selectedId && detail ? (
|
||||||
|
<Button variant="text" onClick={handleBackToList} icon={<ChevronLeftRegular />}>
|
||||||
|
{t("返回")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<>
|
<>
|
||||||
<DeviceDetailHeader
|
<DeviceDetailHeader
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowRightRegular, LockClosedRegular, PersonRegular } from "@fluentui/r
|
|||||||
import { useAuth } from "../store/auth";
|
import { useAuth } from "../store/auth";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { message } from "../components/ui/message";
|
import { message } from "../components/ui/message";
|
||||||
|
import { BrandLogo } from "../components/shell/BrandLogo";
|
||||||
|
|
||||||
const INPUT_CLASS =
|
const INPUT_CLASS =
|
||||||
"w-full rounded-lg border border-gray-200 bg-white/70 py-3 pl-10 pr-4 font-mono text-sm text-gray-900 placeholder-gray-400 outline-none transition-all focus:border-indigo-500/40 focus:ring-2 focus:ring-indigo-500/25 dark:border-white/10 dark:bg-black/20 dark:text-gray-100 dark:placeholder-gray-500";
|
"w-full rounded-lg border border-gray-200 bg-white/70 py-3 pl-10 pr-4 font-mono text-sm text-gray-900 placeholder-gray-400 outline-none transition-all focus:border-indigo-500/40 focus:ring-2 focus:ring-indigo-500/25 dark:border-white/10 dark:bg-black/20 dark:text-gray-100 dark:placeholder-gray-500";
|
||||||
@@ -44,13 +45,13 @@ export default function LoginPage() {
|
|||||||
<div className="group relative overflow-hidden rounded-2xl border border-gray-100 bg-white/70 p-8 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-[#141418]/70">
|
<div className="group relative overflow-hidden rounded-2xl border border-gray-100 bg-white/70 p-8 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-[#141418]/70">
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-500/8 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-500/8 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||||
<div className="relative z-10 mb-10 text-center">
|
<div className="relative z-10 mb-10 text-center">
|
||||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-[#0ea5e9] text-2xl font-bold text-white shadow-lg shadow-indigo-500/20 transition-transform duration-300 group-hover:scale-105">
|
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-white shadow-lg shadow-indigo-500/20 ring-1 ring-black/5 transition-transform duration-300 group-hover:scale-105 dark:bg-white/10 dark:ring-white/10">
|
||||||
V
|
<BrandLogo className="h-14 w-14" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-3xl font-bold text-transparent dark:from-white dark:to-gray-400">
|
<h2 className="bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-3xl font-bold text-transparent dark:from-white dark:to-gray-400">
|
||||||
vocat
|
vocat
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-sm tracking-wide text-gray-500 dark:text-gray-400">{t("EC20 出厂专业检测工具")}</p>
|
<p className="mt-3 text-sm tracking-wide text-gray-500 dark:text-gray-400">{t("高通模块专业测试工具")}</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submit} className="relative z-10 space-y-6">
|
<form onSubmit={submit} className="relative z-10 space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
+11
-11
@@ -207,26 +207,26 @@ export default function LogsPage() {
|
|||||||
title={t("实时日志")}
|
title={t("实时日志")}
|
||||||
subtitle={t("查看系统运行日志,支持过滤和搜索")}
|
subtitle={t("查看系统运行日志,支持过滤和搜索")}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={togglePause}
|
onClick={togglePause}
|
||||||
variant={paused ? "success" : "warning"}
|
variant={paused ? "success" : "warning"}
|
||||||
className="!border-0"
|
className="!border-0 flex-1 justify-center sm:flex-none"
|
||||||
icon={paused ? <PlayRegular /> : <PauseRegular />}
|
icon={paused ? <PlayRegular /> : <PauseRegular />}
|
||||||
>
|
>
|
||||||
{paused ? t("继续") : t("暂停")}
|
{paused ? t("继续") : t("暂停")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={clearLogs} className="!border-0" icon={<DeleteRegular />}>
|
<Button onClick={clearLogs} className="!border-0 flex-1 justify-center sm:flex-none" icon={<DeleteRegular />}>
|
||||||
{t("清空")}
|
{t("清空")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={exportLogs} variant="primary" className="!border-0" icon={<ArrowDownloadRegular />}>
|
<Button onClick={exportLogs} variant="primary" className="!border-0 flex-1 justify-center sm:flex-none" icon={<ArrowDownloadRegular />}>
|
||||||
{t("导出")}
|
{t("导出")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cx("w-2 h-2 rounded-full", connected ? "bg-green-500 animate-pulse" : "bg-red-500")}
|
className={cx("w-2 h-2 rounded-full", connected ? "bg-green-500 animate-pulse" : "bg-red-500")}
|
||||||
@@ -239,7 +239,7 @@ export default function LogsPage() {
|
|||||||
{connError}
|
{connError}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex-1" />
|
<div className="hidden sm:block flex-1" />
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<Switch checked={autoTail} onChange={setAutoTail} ariaLabel={t("自动追尾")} />
|
<Switch checked={autoTail} onChange={setAutoTail} ariaLabel={t("自动追尾")} />
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t("自动追尾")}</span>
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t("自动追尾")}</span>
|
||||||
@@ -247,19 +247,19 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-card p-4 mb-4">
|
<div className="ui-card p-4 mb-4">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<Select
|
<Select
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(v) => setLevel(v as Level)}
|
onChange={(v) => setLevel(v as Level)}
|
||||||
placeholder={t("日志级别")}
|
placeholder={t("日志级别")}
|
||||||
className="w-32"
|
className="w-full sm:w-40"
|
||||||
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
options={LEVEL_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t("搜索日志内容...")}
|
placeholder={t("搜索日志内容...")}
|
||||||
className="w-64"
|
className="w-full sm:w-64"
|
||||||
suffix={
|
suffix={
|
||||||
search ? (
|
search ? (
|
||||||
<button
|
<button
|
||||||
@@ -273,7 +273,7 @@ export default function LogsPage() {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-400">
|
<span className="text-sm text-gray-400 sm:ml-auto">
|
||||||
{t("显示")} {filtered.length} / {logs.length} {t("条")}
|
{t("显示")} {filtered.length} / {logs.length} {t("条")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +284,7 @@ export default function LogsPage() {
|
|||||||
<div className="ui-card overflow-hidden">
|
<div className="ui-card overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={logContainerRef}
|
ref={logContainerRef}
|
||||||
className="h-[60vh] overflow-auto font-mono text-sm bg-gray-900 dark:bg-black text-gray-100 p-4"
|
className="h-[60vh] min-h-[280px] overflow-auto font-mono text-sm bg-gray-900 dark:bg-black text-gray-100 p-4"
|
||||||
>
|
>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="text-gray-500 text-center py-8">
|
<div className="text-gray-500 text-center py-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user