mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2780dd96de | ||
|
|
2922d6a275 | ||
|
|
288e856fdb | ||
|
|
f17d925c4c | ||
|
|
296f963885 | ||
|
|
1b9546a73d | ||
|
|
22487dbb1f | ||
|
|
f9bb38aabe | ||
|
|
5bb5808706 | ||
|
|
d70937cc47 | ||
|
|
eab658dc90 | ||
|
|
0d738d4ce4 | ||
|
|
962c58fdd1 | ||
|
|
7b2e005b37 | ||
|
|
f1e70ecee5 | ||
|
|
f012c556e9 | ||
|
|
ab8bbbc1ed | ||
|
|
609a591045 | ||
|
|
461054615b | ||
|
|
020fb619a9 | ||
|
|
3cc73f1885 | ||
|
|
48fc4c5ab5 | ||
|
|
707ca3c124 | ||
|
|
a09f9af646 | ||
|
|
928ba7746e | ||
|
|
21f210d219 | ||
|
|
8a260e86f1 | ||
|
|
5b8d1a86e8 | ||
|
|
4df0ae0c7d | ||
|
|
d8828ff26a | ||
|
|
337aa3c0ab | ||
|
|
97ca84bbfc | ||
|
|
cc477571ac | ||
|
|
c19156e46a | ||
|
|
0e68dc6893 | ||
|
|
1fc6ea9b6c | ||
|
|
85f8790e1e | ||
|
|
3d117749b2 | ||
|
|
3604319faa | ||
|
|
9fc3f1c5b8 | ||
|
|
93b0cf718c | ||
|
|
3939b061af | ||
|
|
03c8a2ceae | ||
|
|
d7a9fc9774 | ||
|
|
147721f237 | ||
|
|
e24be6ef29 | ||
|
|
6f2b7bf395 | ||
|
|
b79c06dace |
@@ -0,0 +1,6 @@
|
||||
# Copy this file to .env and fill in real values before `docker compose up -d`.
|
||||
# .env is gitignored; .env.example is tracked as a template.
|
||||
|
||||
# Admin password for the web UI. REQUIRED — the server refuses to start safely
|
||||
# without it once exposed. Pick a strong password.
|
||||
VOCAT_ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
test:
|
||||
needs: web
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -44,6 +45,11 @@ jobs:
|
||||
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: Test
|
||||
run: go test ./...
|
||||
|
||||
@@ -66,6 +72,10 @@ jobs:
|
||||
goarch: arm64
|
||||
goarm: ""
|
||||
filename: vocat-linux-arm64
|
||||
- target: linux-aarch64
|
||||
goarch: arm64
|
||||
goarm: ""
|
||||
filename: vocat-linux-aarch64
|
||||
- target: linux-armv7
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
@@ -100,6 +110,14 @@ jobs:
|
||||
-o "$OUTPUT" \
|
||||
./cmd/vocat
|
||||
chmod 0755 "$OUTPUT"
|
||||
if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then
|
||||
echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2
|
||||
readelf -l "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ matrix.goarch }}" = "amd64" ]; then
|
||||
"$OUTPUT" version
|
||||
fi
|
||||
- name: Upload ${{ matrix.target }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
vc.jar
|
||||
*.cookies
|
||||
*.session
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ---- Frontend build products ----
|
||||
web/dist/
|
||||
@@ -33,9 +36,12 @@ __pycache__/
|
||||
# committed. The whole build directory and the root release.py are ignored.
|
||||
build/*.py
|
||||
|
||||
/extension/
|
||||
|
||||
# ---- Docs / scratch ----
|
||||
*.md
|
||||
!README.md
|
||||
!docs/**/*.md
|
||||
*.txt
|
||||
build/lists/
|
||||
|
||||
@@ -49,3 +55,4 @@ Thumbs.db
|
||||
|
||||
# ---- Claude Code / agent ----
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
+13
-5
@@ -1,20 +1,25 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Stage 1: build the web frontend ----
|
||||
FROM node:20-alpine AS web-builder
|
||||
# Build toolchains run natively on the BuildKit host. Without BUILDPLATFORM,
|
||||
# the arm64 branch executes npm and the Go compiler through QEMU, which is much
|
||||
# slower and makes npm ci appear to hang despite producing no progress output.
|
||||
# ---- Stage 1: build the web frontend once on the native builder ----
|
||||
FROM --platform=$BUILDPLATFORM 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
|
||||
# ---- Stage 2: cross-compile the Go binary on the native builder ----
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS go-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /src
|
||||
|
||||
ARG VERSION=0.1.0-dev
|
||||
ARG BUILD_TIME=""
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -23,7 +28,7 @@ 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 \
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X vocat/internal/buildinfo.Version=${VERSION} -X vocat/internal/buildinfo.BuildTime=${BUILD_TIME}" \
|
||||
-o /out/vocat \
|
||||
@@ -40,6 +45,9 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \
|
||||
|
||||
COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat
|
||||
|
||||
# Symlink into /usr/local/bin so `docker exec <ctr> vocat ...` finds it via $PATH.
|
||||
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat
|
||||
|
||||
USER vocat
|
||||
VOLUME ["/opt/vocat/data"]
|
||||
EXPOSE 7575
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_aarch64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
@@ -22,6 +22,8 @@
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
**English** | [简体中文](docs/README.zh-CN.md)
|
||||
|
||||
Vocat is an open-source web control panel and engineering toolkit for Quectel EC20/EC25-class cellular modems. It combines modem discovery, live radio status, AT and USSD terminals, SMS, WiFi Calling, eSIM management, network selection, proxy routing, notifications, audit logs, and release automation in one self-contained service.
|
||||
|
||||
The backend is written in Go, the interface is built with React and TypeScript, and the production frontend is embedded into the Go binary. A single executable contains the web application and uses SQLite for persistent state.
|
||||
@@ -63,24 +65,44 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
|
||||
|
||||
### One-click Linux installation
|
||||
|
||||
As root (including OpenWrt/Kwrt, where `sudo` is normally absent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/main/scripts/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
From a normal user on a distribution with sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Check the host's VoWiFi/XFRM prerequisites without installing VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/main/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.2.0
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts
|
||||
to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed.
|
||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||
never force-install kmods built for a different kernel.
|
||||
|
||||
The installer:
|
||||
|
||||
- detects `amd64`, `386`, `arm64`, or `armv7`;
|
||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
||||
- downloads the matching GitHub Release binary;
|
||||
- verifies it against `SHA256SUMS`;
|
||||
- installs Vocat under `/opt/vocat`;
|
||||
- creates a dedicated system user and systemd service;
|
||||
- creates a hardened systemd service with the hardware and network access required by Vocat;
|
||||
- stores runtime configuration in `/etc/vocat/env`;
|
||||
- generates a random initial administrator password on first installation.
|
||||
|
||||
@@ -99,6 +121,7 @@ Download the matching binary and `SHA256SUMS` from GitHub Releases:
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32-bit | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Verify and install it:
|
||||
@@ -107,26 +130,51 @@ Verify and install it:
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
This manual command runs Vocat in the foreground. Use `vocat serve` so the
|
||||
process starts the server directly; running `vocat` without arguments as root
|
||||
on a TTY opens the interactive management menu instead. Use the one-click
|
||||
installer when a managed systemd service and automatic restart are required.
|
||||
|
||||
### Docker
|
||||
|
||||
For a Linux host that must discover every attached supported Quectel modem and
|
||||
continue seeing USB hot-plug events, run Vocat in hardware-access mode:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
-p 7575:7575 \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--device /dev/ttyUSB2:/dev/ttyUSB2 \
|
||||
--device /dev/cdc-wdm0:/dev/cdc-wdm0 \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Adjust device mappings for the modem interfaces available on the host. Additional network capabilities or host networking may be required for QMI and WiFi Calling experiments.
|
||||
Open `http://<server-address>:7575` after the container starts. Host networking
|
||||
is required so QMI network interfaces remain visible to Vocat, while privileged
|
||||
device access is required for serial ports, QMI control nodes, TUN interfaces,
|
||||
network configuration, and devices added after the container starts. The
|
||||
`/dev` bind mount makes new `ttyUSB*`, `ttyACM*`, and `cdc-wdm*` nodes visible
|
||||
without recreating the container.
|
||||
|
||||
This mode intentionally gives Vocat broad access to the host's devices and
|
||||
network stack. Use it only on a trusted Linux host. The automatic discovery
|
||||
currently identifies supported Quectel USB modems (USB vendor ID `2c7c`), not
|
||||
arbitrary modem brands. Mapping only individual nodes with `--device`, such as
|
||||
`/dev/ttyUSB2` and `/dev/cdc-wdm0`, limits the container to those fixed nodes
|
||||
and does not provide complete multi-device or hot-plug discovery.
|
||||
|
||||
The GHCR image is published for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
@@ -144,7 +192,7 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marks session cookies as secure when HTTPS is used. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Maximum API request body size. |
|
||||
| `VOCAT_REPO` | empty | GitHub repository used by the self-updater, in `owner/name` form. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. |
|
||||
| `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. |
|
||||
|
||||
Do not store Telegram tokens, SMTP passwords, webhook secrets, SIM credentials, or other private data in the repository. Configure them through the application settings or protected environment files.
|
||||
@@ -232,7 +280,7 @@ go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
|
||||
Pushing a version tag starts two GitHub Actions workflows:
|
||||
|
||||
- `release-binaries` builds and publishes `amd64`, `386`, `arm64`, and `armv7` binaries plus `SHA256SUMS`.
|
||||
- `release-binaries` builds and publishes `amd64`, `386`, `arm64`, `aarch64`, and `armv7` binaries plus `SHA256SUMS`.
|
||||
- `docker` builds and publishes a multi-architecture image to GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
@@ -272,6 +320,21 @@ go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Thanks
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
||||
|
||||
## Buy me a coffee
|
||||
|
||||
| Network | Address |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
|
||||
+12
-6
@@ -15,23 +15,29 @@ func printUsage(w io.Writer) {
|
||||
fmt.Fprintf(w, `vocat %s
|
||||
|
||||
Usage:
|
||||
vocat Run the vocat server (default; same as no arguments).
|
||||
vocat No arguments: interactive management menu when run as
|
||||
root on a TTY, otherwise the server. systemd (non-TTY)
|
||||
starts the server unchanged.
|
||||
vocat serve Run the server in the foreground (use from a TTY when
|
||||
vocat without arguments would enter the menu).
|
||||
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).
|
||||
--repo owner/name GitHub repository (default: $VOCAT_REPO or MengMengCode/VoCat).
|
||||
--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 menu Interactive lifecycle menu (root on the host):
|
||||
toggle language, change password, change the Web port,
|
||||
restart, update, 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.
|
||||
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
|
||||
HTTP server using VOCAT_* environment variables or $VOCAT_CONFIG for
|
||||
configuration.
|
||||
`, buildinfo.Version)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
// developerEnabledSettingKey is the app_settings key that gates the entire
|
||||
// plugin/extension system. When absent the developer mode defaults to off, so
|
||||
// a fresh install exposes no plugin surface until an operator explicitly turns
|
||||
// it on with `vocat develop on` and restarts the service.
|
||||
const developerEnabledSettingKey = developer.EnabledSettingKey
|
||||
|
||||
// runDevelop handles the hidden `vocat develop on|off` subcommand. It is
|
||||
// intentionally excluded from printUsage and the interactive menu: the plugin
|
||||
// system is an opt-in developer surface, and the toggle must be typed in full
|
||||
// to activate it. The flag is persisted to app_settings and takes effect on
|
||||
// the next server start (run() reads it before creating the plugin manager).
|
||||
func runDevelop(args []string, logger *slog.Logger) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(`usage: vocat develop <on|off>`)
|
||||
}
|
||||
enabled, ok := parseDevelopArg(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf(`vocat develop: invalid argument %q (expected "on" or "off")`, args[0])
|
||||
}
|
||||
|
||||
// Match the menu's env resolution. An operator runs `vocat develop` on the
|
||||
// host where the shell has not sourced /etc/vocat/env (a systemd
|
||||
// EnvironmentFile, not a shell rc) and VOCAT_DATABASE_PATH is unset, so
|
||||
// config.Load() would otherwise resolve a CWD-relative ./data/vocat.db — a
|
||||
// different database than /opt/vocat/data/vocat.db the service reads. The
|
||||
// flag would then be written to a DB the service never opens, silently.
|
||||
loadMenuEnv()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
payload, err := json.Marshal(map[string]bool{"enabled": enabled})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode developer flag: %w", err)
|
||||
}
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{
|
||||
Key: developerEnabledSettingKey,
|
||||
Value: payload,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("persist developer flag: %w", err)
|
||||
}
|
||||
if !enabled {
|
||||
if err := developer.ResetExperimental(ctx, database); err != nil {
|
||||
return fmt.Errorf("reset developer settings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if enabled {
|
||||
fmt.Printf("开发者模式已开启。重启 vocat 服务后插件功能生效。\n数据库:%s\n", cfg.DatabasePath)
|
||||
fmt.Printf("Developer mode enabled. Restart the vocat service for plugins to take effect.\nDatabase: %s\n", cfg.DatabasePath)
|
||||
} else {
|
||||
fmt.Printf("开发者模式已关闭。重启 vocat 服务后插件功能将停用。\n数据库:%s\n", cfg.DatabasePath)
|
||||
fmt.Printf("Developer mode disabled. Restart the vocat service to deactivate plugins.\nDatabase: %s\n", cfg.DatabasePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDevelopArg(arg string) (bool, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(arg)) {
|
||||
case "on", "1", "true", "yes":
|
||||
return true, true
|
||||
case "off", "0", "false", "no":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// isDeveloperEnabled reads the persisted developer-mode flag. A missing record
|
||||
// or an unparseable value resolves to false — the system defaults closed, so
|
||||
// any read failure keeps plugins off rather than exposing them by accident.
|
||||
func isDeveloperEnabled(ctx context.Context, database *store.Store) bool {
|
||||
return developer.Enabled(ctx, database)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
// The modem, PC/SC reader, XFRM policies and listener are host resources,
|
||||
// not database resources. Lock per OS user so a diagnostic instance using a
|
||||
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
|
||||
// the managed service. Prefer /run because systemd's PrivateTmp would
|
||||
// otherwise hide the managed service's lock from a manually started process.
|
||||
// The UID-specific directory still permits intentionally isolated users to
|
||||
// operate independently; development hosts without writable /run fall back
|
||||
// to TempDir.
|
||||
uid := os.Geteuid()
|
||||
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
|
||||
if uid == 0 {
|
||||
directory = "/run/vocat"
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
directory = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(directory, "vocat-server.lock")
|
||||
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, errors.New("another vocat server already controls this host's modem resources")
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(firstDatabase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
secondDatabase := filepath.Join(t.TempDir(), "other.db")
|
||||
second, err := lockServerInstance(secondDatabase)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
}
|
||||
+708
-58
@@ -2,21 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/pcsc"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
@@ -35,8 +46,25 @@ func main() {
|
||||
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).
|
||||
// No subcommand: TTY+root → interactive menu (operator on the host);
|
||||
// otherwise run the server. systemd runs vocat with stdin=/dev/null
|
||||
// (non-TTY) so the unit keeps starting the server unchanged. Non-root
|
||||
// on a TTY also falls through to the server rather than erroring on
|
||||
// runMenu's root requirement.
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) && os.Geteuid() == 0 {
|
||||
if err := runMenu(logger); err != nil {
|
||||
logger.Error("menu failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
if err := run(logger, logs); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
case "serve":
|
||||
// Explicit foreground server. Use this when vocat with no arguments
|
||||
// would otherwise enter the menu (root on a TTY) but a server is wanted.
|
||||
if err := run(logger, logs); err != nil {
|
||||
logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -53,6 +81,14 @@ func main() {
|
||||
logger.Error("menu failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "develop":
|
||||
// Hidden subcommand: intentionally not listed in printUsage or the
|
||||
// interactive menu. It toggles the developer-mode flag that gates the
|
||||
// entire plugin/extension system; the flag takes effect on next start.
|
||||
if err := runDevelop(rest, logger); err != nil {
|
||||
logger.Error("develop failed", "error", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
printUsage(os.Stdout)
|
||||
default:
|
||||
@@ -76,6 +112,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer instanceLock.Close()
|
||||
if cfg.UsesDefaultCredentials() {
|
||||
logger.Warn(
|
||||
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
|
||||
@@ -90,6 +131,50 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
developerEnabled := isDeveloperEnabled(startupContext, database)
|
||||
pluginRoot := filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins")
|
||||
legacyExportProxyConfig := filepath.Join(pluginRoot, exportproxy.ReservedID, "data", "configs.json")
|
||||
if !developerEnabled {
|
||||
if err := developer.ResetExperimental(startupContext, database); err != nil {
|
||||
return fmt.Errorf("reset disabled developer settings: %w", err)
|
||||
}
|
||||
if err := exportproxy.RemoveLegacyConfig(legacyExportProxyConfig); err != nil {
|
||||
return fmt.Errorf("remove legacy export proxy configuration: %w", err)
|
||||
}
|
||||
}
|
||||
httpsManager, err := httpsmode.New(
|
||||
startupContext,
|
||||
database,
|
||||
filepath.Join(filepath.Dir(cfg.DatabasePath), "tls"),
|
||||
cfg.Address,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure self-signed HTTPS: %w", err)
|
||||
}
|
||||
|
||||
// The plugin/extension system is gated behind a hidden developer-mode flag.
|
||||
// When off (the default) the manager is never created and the server receives
|
||||
// a nil Extensions handle, so every /extensions* and /plugin-assets/* route
|
||||
// returns 503/404 and the SPA hides the plugin surface.
|
||||
var extensionManager *extensions.Manager
|
||||
var exportProxyManager *exportproxy.Manager
|
||||
if developerEnabled {
|
||||
exportProxyManager, err = exportproxy.New(startupContext, database, logger, legacyExportProxyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in export proxy: %w", err)
|
||||
}
|
||||
defer exportProxyManager.Close()
|
||||
extensionManager, err = extensions.NewManager(
|
||||
pluginRoot,
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create plugin manager: %w", err)
|
||||
}
|
||||
defer extensionManager.Close()
|
||||
} else {
|
||||
logger.Info("developer mode is off; plugin system disabled")
|
||||
}
|
||||
|
||||
authService, err := auth.New(database, auth.Options{
|
||||
SessionTTL: cfg.SessionTTL,
|
||||
@@ -105,7 +190,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
return err
|
||||
}
|
||||
|
||||
deviceManager, err := device.NewManager(device.Options{})
|
||||
cardReaders := pcsc.New()
|
||||
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create device manager: %w", err)
|
||||
}
|
||||
@@ -115,6 +201,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil {
|
||||
logger.Warn("automatic first-run device provisioning failed", "error", err)
|
||||
}
|
||||
configureDeviceBackends(startupContext, logger, database, deviceManager)
|
||||
restoreDefaultCellularRadios(startupContext, logger, database, deviceManager)
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -125,17 +213,26 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
pollContext, cancelPolling := context.WithCancel(context.Background())
|
||||
defer cancelPolling()
|
||||
go pollDeviceSnapshots(pollContext, logger, database, deviceManager)
|
||||
go restoreConfiguredCellularData(pollContext, logger, database, deviceManager)
|
||||
go collectCellularTraffic(pollContext, logger, database)
|
||||
go persistLogsToStore(pollContext, logger, logs, database)
|
||||
if !developerEnabled {
|
||||
go disableAllDeveloperCellularData(pollContext, logger, database, deviceManager)
|
||||
} else {
|
||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||
}
|
||||
|
||||
vowifiManager, err := configureVoWiFiRuntime(
|
||||
startupContext,
|
||||
logger,
|
||||
database,
|
||||
deviceManager,
|
||||
cardReaders,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||
}
|
||||
go reconcileCardPolicies(pollContext, logger, database, deviceManager, vowifiManager)
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -154,6 +251,12 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
Logger: logger,
|
||||
SecureCookies: cfg.SecureCookies,
|
||||
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
||||
Extensions: extensionManager,
|
||||
ExportProxy: exportProxyManager,
|
||||
DeveloperEnabled: developerEnabled,
|
||||
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
||||
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
||||
HTTPS: httpsManager,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -162,16 +265,37 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||
handler.StartTelegramBot(pollContext)
|
||||
handler.StartSMSNotificationDispatchers(pollContext)
|
||||
handler.StartAutomaticTasks(pollContext)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
serverConfig := func(handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
}
|
||||
plainHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if httpsManager.Enabled() {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" {
|
||||
host = cfg.Address
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
plainServer := serverConfig(plainHandler)
|
||||
tlsServer := serverConfig(handler)
|
||||
baseListener, err := net.Listen("tcp", cfg.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", cfg.Address, err)
|
||||
}
|
||||
protocolMux := httpsmode.NewMultiplexer(baseListener, httpsManager)
|
||||
|
||||
signalContext, stopSignals := signal.NotifyContext(
|
||||
context.Background(),
|
||||
@@ -180,10 +304,17 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
)
|
||||
defer stopSignals()
|
||||
|
||||
serverError := make(chan error, 1)
|
||||
serverError := make(chan error, 2)
|
||||
go func() {
|
||||
logger.Info("HTTP server listening", "address", cfg.Address)
|
||||
err := httpServer.ListenAndServe()
|
||||
logger.Info("HTTP server listening", "address", cfg.Address, "self_signed_https", httpsManager.Enabled())
|
||||
err := plainServer.Serve(protocolMux.Plain())
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverError <- err
|
||||
}()
|
||||
go func() {
|
||||
err := tlsServer.Serve(tls.NewListener(protocolMux.TLS(), httpsManager.TLSConfig()))
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
@@ -192,21 +323,235 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
|
||||
select {
|
||||
case err := <-serverError:
|
||||
_ = protocolMux.Close()
|
||||
return err
|
||||
case <-signalContext.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
}
|
||||
// Long-lived SSE and polling handlers use this context. Stop them before
|
||||
// http.Server.Shutdown so they do not consume the entire graceful-shutdown
|
||||
// deadline while waiting for a stream that is intentionally still active.
|
||||
cancelPolling()
|
||||
|
||||
shutdownContext, cancelShutdown := context.WithTimeout(
|
||||
context.Background(),
|
||||
cfg.ShutdownTimeout,
|
||||
)
|
||||
defer cancelShutdown()
|
||||
if err := httpServer.Shutdown(shutdownContext); err != nil {
|
||||
_ = httpServer.Close()
|
||||
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
||||
shutdownErrors := make(chan error, 2)
|
||||
go func() { shutdownErrors <- plainServer.Shutdown(shutdownContext) }()
|
||||
go func() { shutdownErrors <- tlsServer.Shutdown(shutdownContext) }()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
_ = protocolMux.Close()
|
||||
for range 2 {
|
||||
if err := <-shutdownErrors; err != nil {
|
||||
_ = plainServer.Close()
|
||||
_ = tlsServer.Close()
|
||||
return fmt.Errorf("graceful HTTP shutdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureDeviceBackends(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("configure device backends: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil {
|
||||
continue
|
||||
}
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil {
|
||||
logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
|
||||
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// restoreDefaultCellularRadios applies an explicitly saved cellular policy
|
||||
// after restart. Missing policies remain RF-off and are claimed by the safe
|
||||
// default policy; there is no automatic cellular fallback.
|
||||
func restoreDefaultCellularRadios(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular recovery: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
if config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil || entry.Snapshot == nil || !entry.Snapshot.FlightMode {
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid == "" {
|
||||
continue
|
||||
}
|
||||
if iccid != "" {
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
switch {
|
||||
case policyErr == nil && policy.AirplaneEnabled:
|
||||
continue
|
||||
case errors.Is(policyErr, store.ErrNotFound):
|
||||
continue
|
||||
case policyErr != nil && !errors.Is(policyErr, store.ErrNotFound):
|
||||
logger.Warn("startup cellular recovery: read card policy", "device_id", config.ID, "error", policyErr)
|
||||
continue
|
||||
}
|
||||
}
|
||||
restoreContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, err = manager.SetFlight(restoreContext, entry.ID, false)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular recovery failed", "device_id", config.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
logger.Info("restored cellular radio after disabled VoWiFi", "device_id", config.ID, "iccid", iccid)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreConfiguredCellularData(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular data recovery: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
if !config.NetworkEnabled || config.VoWiFiEnabled {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
networkRequest := device.NetworkRequest{
|
||||
Enabled: true, APN: config.APN, IPVersion: "IPV4V6", Backend: config.DeviceBackend,
|
||||
}
|
||||
if entry.Snapshot != nil {
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if policy, policyErr := database.CardPolicy(ctx, iccid); policyErr == nil {
|
||||
networkRequest.APN = policy.APN
|
||||
if policy.IPVersion != "" {
|
||||
networkRequest.IPVersion = policy.IPVersion
|
||||
}
|
||||
if profile, profileErr := database.CardAPNProfileByAPN(ctx, iccid, policy.APN, policy.IPVersion); profileErr == nil {
|
||||
networkRequest.Username = profile.Username
|
||||
networkRequest.Password = profile.Password
|
||||
networkRequest.Authentication = profile.AuthType
|
||||
if entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
networkRequest.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataContext, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
_, err = manager.SetNetwork(dataContext, entry.ID, networkRequest)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("startup cellular data recovery failed", "device_id", config.ID)
|
||||
continue
|
||||
}
|
||||
logger.Info("restored protected cellular data route", "device_id", config.ID, "interface", config.Interface)
|
||||
}
|
||||
}
|
||||
|
||||
func disableAllDeveloperCellularData(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
) {
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("developer cleanup: list devices", "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
continue
|
||||
}
|
||||
entry, err := mapper.Get(config.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
disableContext, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err = manager.SetNetwork(disableContext, entry.ID, device.NetworkRequest{Enabled: false, Backend: config.DeviceBackend})
|
||||
cancel()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: stop cellular data", "device_id", config.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func watchDeveloperDisable(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
exportProxy *exportproxy.Manager,
|
||||
legacyConfigPath string,
|
||||
) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if developer.Enabled(ctx, database) {
|
||||
continue
|
||||
}
|
||||
if exportProxy != nil {
|
||||
if err := exportProxy.DeleteAllAndDisable(ctx); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: delete export proxies", "error", err)
|
||||
}
|
||||
}
|
||||
if err := exportproxy.RemoveLegacyConfig(legacyConfigPath); err != nil {
|
||||
logger.Warn("developer cleanup: remove legacy export proxy configuration", "error", err)
|
||||
}
|
||||
if err := developer.ResetExperimental(ctx, database); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("developer cleanup: reset settings", "error", err)
|
||||
}
|
||||
disableAllDeveloperCellularData(ctx, logger, database, manager)
|
||||
logger.Info("developer mode disabled; roaming data and export proxies were removed")
|
||||
return
|
||||
}
|
||||
}
|
||||
return <-serverError
|
||||
}
|
||||
|
||||
func configureVoWiFiRuntime(
|
||||
@@ -214,15 +559,33 @@ func configureVoWiFiRuntime(
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
deviceManager *device.Manager,
|
||||
cardReaders *pcsc.Service,
|
||||
) (*vowifiruntime.Manager, error) {
|
||||
mapper := integration.ATMapper{
|
||||
Store: database,
|
||||
Devices: deviceManager,
|
||||
}
|
||||
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
|
||||
ec20Adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
|
||||
// The test deployment is deliberately non-cellular. VoWiFi teardown
|
||||
// may restore CFUN, but it must never reactivate a PDP context.
|
||||
RestoreCellularData: false,
|
||||
// VoWiFi is always fail-closed with respect to cellular RF. Its teardown
|
||||
// leaves CFUN=4; only the explicit airplane-mode-off endpoint restores
|
||||
// CFUN=1.
|
||||
PureAirplanePolicy: func(deviceID string) bool {
|
||||
deviceConfig, configErr := database.Device(context.Background(), deviceID)
|
||||
return configErr == nil && deviceConfig.VoWiFiEnabled
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
|
||||
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
|
||||
if resolveErr != nil {
|
||||
return pcsc.Selector{}, "", resolveErr
|
||||
}
|
||||
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -239,6 +602,10 @@ func configureVoWiFiRuntime(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
|
||||
}
|
||||
adapter := vowifiDeviceAdapter(ec20Adapter)
|
||||
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
adapter = pcscAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
|
||||
},
|
||||
})
|
||||
@@ -254,6 +621,21 @@ func configureVoWiFiRuntime(
|
||||
return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err)
|
||||
}
|
||||
if deviceConfig.VoWiFiEnabled {
|
||||
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
||||
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
|
||||
if flightErr != nil {
|
||||
// A modem can be temporarily unavailable while OpenWrt/procd is
|
||||
// restarting the service (notably after loading XFRM modules). Do
|
||||
// not take the Web/API service down with it: the orchestrator below
|
||||
// remains fail-closed and its runtime manager retries until CFUN=4
|
||||
// can be established.
|
||||
logger.Warn(
|
||||
"VoWiFi startup radio protection deferred to automatic retry",
|
||||
"device_id", deviceConfig.ID,
|
||||
"error", flightErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err)
|
||||
@@ -263,10 +645,66 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
const (
|
||||
vowifiStartupRadioAttempts = 3
|
||||
vowifiStartupRadioDelay = time.Second
|
||||
)
|
||||
|
||||
type flightModeSetter interface {
|
||||
SetFlight(context.Context, string, bool) (device.FlightResult, error)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
|
||||
return protectVoWiFiStartupRadioWithRetry(
|
||||
ctx,
|
||||
manager,
|
||||
physicalID,
|
||||
vowifiStartupRadioAttempts,
|
||||
vowifiStartupRadioDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadioWithRetry(
|
||||
ctx context.Context,
|
||||
manager flightModeSetter,
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt+1 == attempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type vowifiDeviceAdapter interface {
|
||||
vowifi.SIMIdentityReader
|
||||
vowifi.AKAProvider
|
||||
vowifi.RadioController
|
||||
}
|
||||
|
||||
func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter *vowifi.EC20Adapter,
|
||||
adapter vowifiDeviceAdapter,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
@@ -299,9 +737,20 @@ func newVoWiFiOrchestrator(
|
||||
if message.Concat != nil && message.Concat.Total > 0 {
|
||||
partsTotal = message.Concat.Total
|
||||
}
|
||||
messageID := message.MessageID
|
||||
if message.Concat != nil && message.Concat.Total > 1 {
|
||||
// A segment of a carrier-split long SMS over IMS. Address the whole
|
||||
// message with a stable id so SaveSMSMessage folds every segment
|
||||
// into one progressively merged row instead of one row per segment.
|
||||
messageID = store.StableConcatMessageID(
|
||||
"ims", deviceConfig.ModemIMEI, message.DeviceID, message.From,
|
||||
message.Concat.Reference, message.Concat.Total,
|
||||
)
|
||||
}
|
||||
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: message.MessageID,
|
||||
MessageID: messageID,
|
||||
DeviceID: message.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: message.IMSI,
|
||||
Peer: message.From,
|
||||
Direction: "inbound",
|
||||
@@ -318,6 +767,7 @@ func newVoWiFiOrchestrator(
|
||||
OnSMSStatus: func(ctx context.Context, report ims.ReceivedSMSStatus) error {
|
||||
deliveryReport := store.SMSDeliveryReport{
|
||||
DeviceID: report.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: report.IMSI,
|
||||
Peer: report.To,
|
||||
Source: "ims",
|
||||
@@ -384,9 +834,18 @@ func provisionDiscoveredDevices(
|
||||
candidate := discovered.Candidate
|
||||
backend := "at"
|
||||
control := candidate.ATPort.OpenPath()
|
||||
deviceType := store.DeviceTypePCIeEC20EC25
|
||||
esimTransport := backend
|
||||
if candidate.QMIControl != "" {
|
||||
backend = "qmi"
|
||||
control = candidate.QMIControl
|
||||
esimTransport = backend
|
||||
}
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
backend = "pcsc"
|
||||
control = candidate.ReaderName
|
||||
deviceType = store.DeviceTypeUSBSIMReader
|
||||
esimTransport = "pcsc"
|
||||
}
|
||||
name := candidate.Product
|
||||
if name == "" || strings.EqualFold(name, "Android") {
|
||||
@@ -395,6 +854,7 @@ func provisionDiscoveredDevices(
|
||||
if err := database.UpsertDevice(ctx, store.Device{
|
||||
ID: discovered.ID,
|
||||
Name: name,
|
||||
DeviceType: deviceType,
|
||||
Interface: candidate.NetworkInterface,
|
||||
ControlDevice: control,
|
||||
ATPort: candidate.ATPort.OpenPath(),
|
||||
@@ -405,10 +865,10 @@ func provisionDiscoveredDevices(
|
||||
StopBits: 1,
|
||||
Parity: "none",
|
||||
DeviceBackend: backend,
|
||||
ESIMTransport: backend,
|
||||
ESIMTransport: esimTransport,
|
||||
NetworkEnabled: false,
|
||||
SMSEnabled: true,
|
||||
VoWiFiEnabled: false,
|
||||
VoWiFiEnabled: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -468,20 +928,42 @@ func pollDeviceSnapshots(
|
||||
logger.Debug("periodic modem discovery failed", "error", err)
|
||||
return
|
||||
}
|
||||
for _, entry := range manager.List() {
|
||||
// Hotplug can replace the physical discovery ID. Rebind each configured
|
||||
// device's selected QMI/AT control plane before collecting its snapshot.
|
||||
configureDeviceBackends(ctx, logger, database, manager)
|
||||
entries := manager.List()
|
||||
// Each physical modem owns its own operation lock. Refresh them in
|
||||
// parallel so a slow or wedged EC20 on one hub port cannot delay signal
|
||||
// and identity updates for every other modem by 30 seconds at a time.
|
||||
var refreshGroup sync.WaitGroup
|
||||
refreshSlots := make(chan struct{}, 4)
|
||||
for _, entry := range entries {
|
||||
if !entry.Discovered {
|
||||
continue
|
||||
}
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
|
||||
snapshot, err := manager.Refresh(refreshContext, entry.ID)
|
||||
cancelRefresh()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", err)
|
||||
}
|
||||
if err == nil && ctx.Err() == nil {
|
||||
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
}
|
||||
entry := entry
|
||||
refreshGroup.Add(1)
|
||||
go func() {
|
||||
defer refreshGroup.Done()
|
||||
select {
|
||||
case refreshSlots <- struct{}{}:
|
||||
defer func() { <-refreshSlots }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second)
|
||||
snapshot, refreshErr := manager.Refresh(refreshContext, entry.ID)
|
||||
cancelRefresh()
|
||||
if refreshErr != nil && ctx.Err() == nil {
|
||||
logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", refreshErr)
|
||||
}
|
||||
if refreshErr == nil && ctx.Err() == nil {
|
||||
enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
enforceDefaultSafeCardPolicy(ctx, logger, database, manager, entry.ID, &snapshot)
|
||||
}
|
||||
}()
|
||||
}
|
||||
refreshGroup.Wait()
|
||||
}
|
||||
refresh()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
@@ -496,6 +978,183 @@ func pollDeviceSnapshots(
|
||||
}
|
||||
}
|
||||
|
||||
// enforceDefaultSafeCardPolicy handles a newly inserted physical SIM or a
|
||||
// profile that has never had a policy. RF is turned off before the default is
|
||||
// persisted; the VoWiFi runtime reconciler then starts service asynchronously.
|
||||
func enforceDefaultSafeCardPolicy(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
physicalID string,
|
||||
snapshot *device.Snapshot,
|
||||
) {
|
||||
if snapshot == nil || !snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) == "" ||
|
||||
device.RegionBlockReason(snapshot.IMSI) != "" {
|
||||
return
|
||||
}
|
||||
iccid := strings.TrimSpace(snapshot.ICCID)
|
||||
if _, err := database.CardPolicy(ctx, iccid); err == nil {
|
||||
return
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
logger.Warn("default card policy: read policy", "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, err := manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warn("default card policy: failed to establish airplane mode", "device_id", physicalID, "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{
|
||||
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6", Source: "default",
|
||||
}); err != nil {
|
||||
logger.Warn("default card policy: persist policy", "iccid", iccid, "error", err)
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil || entry.ID != physicalID {
|
||||
continue
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
config.VoWiFiEnabled = true
|
||||
if err := database.UpsertDevice(ctx, config); err != nil {
|
||||
logger.Warn("default card policy: update device policy", "device_id", config.ID, "error", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
logger.Info("new SIM protected by default VoWiFi/airplane policy", "device_id", physicalID, "iccid", iccid)
|
||||
}
|
||||
|
||||
func reconcileCardPolicies(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
database *store.Store,
|
||||
manager *device.Manager,
|
||||
vowifiManager *vowifiruntime.Manager,
|
||||
) {
|
||||
observedCards := make(map[string]string)
|
||||
reconcile := func() {
|
||||
policies, policyListErr := database.ListCardPolicies(ctx)
|
||||
if policyListErr == nil {
|
||||
for _, policy := range policies {
|
||||
if !policy.VoWiFiEnabled || (policy.AirplaneEnabled && !policy.NetworkEnabled) {
|
||||
continue
|
||||
}
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
logger.Warn("reconcile card policy: normalize stored RF-safe VoWiFi policy", "iccid", policy.ICCID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mapper := integration.ATMapper{Store: database, Devices: manager}
|
||||
for _, config := range configs {
|
||||
entry, mapErr := mapper.Get(config.ID)
|
||||
if mapErr != nil || entry.Snapshot == nil {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
|
||||
if iccid == "" {
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
|
||||
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
observedCards[config.ID] = "missing"
|
||||
}
|
||||
continue
|
||||
}
|
||||
previousObserved := observedCards[config.ID]
|
||||
observedCards[config.ID] = iccid
|
||||
policy, policyErr := database.CardPolicy(ctx, iccid)
|
||||
if policyErr != nil {
|
||||
continue
|
||||
}
|
||||
if policy.VoWiFiEnabled && (!policy.AirplaneEnabled || policy.NetworkEnabled) {
|
||||
policy.AirplaneEnabled = true
|
||||
policy.NetworkEnabled = false
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
logger.Warn("reconcile card policy: normalize RF-safe VoWiFi policy", "device_id", config.ID, "iccid", iccid, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
deviceChanged := false
|
||||
if config.VoWiFiEnabled != policy.VoWiFiEnabled || (policy.VoWiFiEnabled && config.NetworkEnabled) {
|
||||
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
||||
if policy.VoWiFiEnabled {
|
||||
config.NetworkEnabled = false
|
||||
}
|
||||
deviceChanged = true
|
||||
}
|
||||
if config.APN != strings.TrimSpace(policy.APN) {
|
||||
config.APN = strings.TrimSpace(policy.APN)
|
||||
deviceChanged = true
|
||||
}
|
||||
if deviceChanged {
|
||||
if err := database.UpsertDevice(ctx, config); err != nil {
|
||||
logger.Warn("reconcile card policy: update device", "device_id", config.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
state, stateErr := vowifiManager.State(config.ID)
|
||||
if policy.VoWiFiEnabled {
|
||||
if !entry.Snapshot.FlightMode {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = manager.SetFlight(flightContext, entry.ID, true)
|
||||
cancel()
|
||||
}
|
||||
switch {
|
||||
case stateErr != nil || !state.Enabled:
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, true)
|
||||
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
|
||||
_, _ = vowifiManager.RequestReconnect(config.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stateErr == nil && state.Enabled {
|
||||
_, _ = vowifiManager.RequestEnabled(config.ID, false)
|
||||
continue
|
||||
}
|
||||
if policy.AirplaneEnabled != entry.Snapshot.FlightMode {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, _ = manager.SetFlight(flightContext, entry.ID, policy.AirplaneEnabled)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
reconcile()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reconcile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cardPolicySourceRegionBlock marks a card policy that was written automatically
|
||||
// because the inserted SIM belongs to a region the product does not serve. It
|
||||
// doubles as the persistent record that the radio was forced off by us, so the
|
||||
@@ -538,15 +1197,18 @@ func enforceCardRegion(
|
||||
}
|
||||
}
|
||||
if snapshot.ICCID != "" {
|
||||
policy := store.CardPolicy{
|
||||
ICCID: snapshot.ICCID,
|
||||
NetworkEnabled: false,
|
||||
VoWiFiEnabled: false,
|
||||
AirplaneEnabled: true,
|
||||
IPVersion: "IPV4V6",
|
||||
Source: cardPolicySourceRegionBlock,
|
||||
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
|
||||
policyErr = nil
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = false
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = cardPolicySourceRegionBlock
|
||||
if policyErr != nil && ctx.Err() == nil {
|
||||
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
|
||||
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
"region block: failed to persist card policy",
|
||||
"device_id", id, "iccid", snapshot.ICCID, "error", err,
|
||||
@@ -562,10 +1224,10 @@ func enforceCardRegion(
|
||||
liftCardRegionBlock(ctx, logger, database, manager, id, snapshot)
|
||||
}
|
||||
|
||||
// liftCardRegionBlock reverses an automatic region block once the current SIM
|
||||
// is positively confirmed to be allowed. It restores the radio only when an
|
||||
// outstanding auto-forced block exists, so it never overrides a flight mode the
|
||||
// user enabled deliberately.
|
||||
// liftCardRegionBlock removes the regional marker once an allowed SIM is
|
||||
// confirmed. It deliberately does not restore RF: the replacement SIM is
|
||||
// picked up by enforceDefaultSafeCardPolicy and remains in airplane/VoWiFi
|
||||
// mode until an explicit user action.
|
||||
func liftCardRegionBlock(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
@@ -590,18 +1252,6 @@ func liftCardRegionBlock(
|
||||
if len(outstanding) == 0 {
|
||||
return
|
||||
}
|
||||
if snapshot.FlightMode {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err := manager.SetFlight(flightContext, id, false)
|
||||
cancelFlight()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
"region block: failed to restore radio",
|
||||
"device_id", id, "error", err,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, policy := range outstanding {
|
||||
if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil {
|
||||
logger.Warn(
|
||||
@@ -611,7 +1261,7 @@ func liftCardRegionBlock(
|
||||
}
|
||||
}
|
||||
logger.Info(
|
||||
"region block lifted; SIM is allowed",
|
||||
"region marker removed; allowed SIM remains RF protected",
|
||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,11 +152,7 @@ func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) {
|
||||
client := &fakeModemClient{steps: []fakeStep{
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 4"}},
|
||||
{command: "AT+CFUN=1"},
|
||||
{command: "AT+CFUN?", lines: []string{"+CFUN: 1"}},
|
||||
}}
|
||||
client := &fakeModemClient{}
|
||||
manager := newRegionTestManager(t, client)
|
||||
database := newRegionTestStore(t)
|
||||
|
||||
|
||||
+428
-74
@@ -3,11 +3,14 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,18 +19,77 @@ import (
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
//envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
|
||||
// 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"
|
||||
|
||||
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
||||
// discoverable so the menu works on installations made before the installer
|
||||
// and service template converged on /etc/vocat/env.
|
||||
const legacyEnvFilePath = "/etc/vocat/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.
|
||||
// defaultDatabasePath is the install-default SQLite location written into the
|
||||
// systemd unit by scripts/install.sh. Used only when VOCAT_DATABASE_PATH is not
|
||||
// already set in the operator's environment.
|
||||
const defaultDatabasePath = "/opt/vocat/data/vocat.db"
|
||||
|
||||
// uiPreferencesSettingKey is the same app_settings key the Web UI's
|
||||
// /api/settings/preferences handler reads and writes (see general_api.go). The
|
||||
// menu toggles language through it so a single preference is shared with the
|
||||
// SPA and the backend i18n layer.
|
||||
const uiPreferencesSettingKey = "ui.preferences"
|
||||
|
||||
// loadMenuEnv ensures the menu reaches the production config that systemd
|
||||
// would otherwise inject. When an operator runs `sudo vocat` on the host, the
|
||||
// shell has not sourced /etc/vocat/env (a systemd EnvironmentFile, not a shell
|
||||
// rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a
|
||||
// CWD-relative ./data/vocat.db — a different, empty database than
|
||||
// /opt/vocat/data/vocat.db the service uses. This loads the installed env file
|
||||
// for VOCAT_ADMIN_PASSWORD and pins VOCAT_DATABASE_PATH to the install default,
|
||||
// without overriding any value the operator already exported.
|
||||
func loadMenuEnv() {
|
||||
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
||||
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
||||
}
|
||||
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
if _, ok := os.LookupEnv(key); !ok {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func menuEnvFilePath() string {
|
||||
if _, err := os.Stat(envFilePath); err == nil {
|
||||
return envFilePath
|
||||
}
|
||||
if _, err := os.Stat(legacyEnvFilePath); err == nil {
|
||||
return legacyEnvFilePath
|
||||
}
|
||||
return envFilePath
|
||||
}
|
||||
|
||||
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
||||
// change the Web listener port, restart the systemd unit, self-update, 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)")
|
||||
@@ -37,8 +99,13 @@ func runMenu(logger *slog.Logger) error {
|
||||
return errors.New("vocat menu requires an interactive terminal")
|
||||
}
|
||||
|
||||
lang := promptLanguage()
|
||||
loadMenuEnv()
|
||||
|
||||
lang, langErr := loadMenuLanguage()
|
||||
menu := newMenu(lang)
|
||||
if langErr != nil {
|
||||
logger.Warn("menu: load language preference failed; defaulting to English", "error", langErr)
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
@@ -55,44 +122,73 @@ func runMenu(logger *slog.Logger) error {
|
||||
choice := strings.TrimSpace(line)
|
||||
switch choice {
|
||||
case "1":
|
||||
if err := menuChangePassword(reader, menu, logger); err != nil {
|
||||
if err := menuToggleLanguage(menu, logger); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "2":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
if err := menuChangePassword(reader, menu, logger); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
if err := menuUninstall(reader, menu); err != nil {
|
||||
if err := menuChangeWebPort(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "0", "":
|
||||
fmt.Println(menu.bye())
|
||||
return nil
|
||||
case "4":
|
||||
if err := menuRestart(menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "5":
|
||||
if err := menuUpdate(menu, logger); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "0":
|
||||
if err := menuUninstall(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
} else {
|
||||
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"
|
||||
}
|
||||
// loadMenuLanguage reads the persisted UI preference (the same ui.preferences
|
||||
// app_setting the Web UI writes) and returns "zh" or "en". When no record
|
||||
// exists yet it returns "en", matching the Web default in writeUIPreferences.
|
||||
// Any failure is surfaced to the caller, which logs a warning and keeps the
|
||||
// default rather than blocking the menu.
|
||||
func loadMenuLanguage() (string, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return "en", fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return "en", fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
setting, err := database.AppSetting(ctx, uiPreferencesSettingKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "en", nil
|
||||
}
|
||||
return "en", fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
var prefs struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.Unmarshal(setting.Value, &prefs); err != nil {
|
||||
return "en", nil
|
||||
}
|
||||
if prefs.Language == "zh" {
|
||||
return "zh", nil
|
||||
}
|
||||
return "en", nil
|
||||
}
|
||||
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
|
||||
@@ -167,9 +263,18 @@ func readPasswordMasked() (string, error) {
|
||||
// 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="
|
||||
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
||||
}
|
||||
|
||||
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
||||
// write is atomic and rejects line breaks so one setting cannot inject another.
|
||||
func rewriteEnvValue(path, name, value string) error {
|
||||
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return errors.New("invalid environment setting")
|
||||
}
|
||||
key := name + "="
|
||||
var lines []string
|
||||
if data, err := os.ReadFile(envFilePath); err == nil {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(string(data), "\n")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
@@ -178,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, key) {
|
||||
lines[i] = key + newPassword
|
||||
lines[i] = key + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
lines = append(lines, key+newPassword)
|
||||
lines = append(lines, key+value)
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return writeEnvFileAtomic(path, []byte(content))
|
||||
}
|
||||
|
||||
dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
|
||||
func writeEnvFileAtomic(path string, content []byte) error {
|
||||
dirIndex := strings.LastIndexAny(path, "/\\")
|
||||
if dirIndex < 0 {
|
||||
return errors.New("environment file path has no directory")
|
||||
}
|
||||
dir := path[:dirIndex]
|
||||
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 {
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
@@ -209,10 +321,175 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, envFilePath)
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
}
|
||||
fmt.Println(m.currentWebAddress(cfg.Address))
|
||||
fmt.Println(m.reverseProxyNotice())
|
||||
fmt.Print(m.newWebPort(currentPortText))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Web port: %w", err)
|
||||
}
|
||||
portText := strings.TrimSpace(line)
|
||||
if portText == "" {
|
||||
fmt.Println(m.webPortCancelled())
|
||||
return nil
|
||||
}
|
||||
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
|
||||
if err != nil {
|
||||
return errInvalidWebPort
|
||||
}
|
||||
currentPort, _ := strconv.Atoi(currentPortText)
|
||||
if newPort == currentPort {
|
||||
fmt.Println(m.webPortUnchanged())
|
||||
return nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", newAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
|
||||
environmentPath := menuEnvFilePath()
|
||||
original, readErr := os.ReadFile(environmentPath)
|
||||
originalExisted := readErr == nil
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
|
||||
}
|
||||
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
|
||||
}
|
||||
if err := restartVocatService(); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
|
||||
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
|
||||
_ = restartVocatService()
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errRestartFailed, err)
|
||||
}
|
||||
_ = os.Setenv("VOCAT_ADDR", newAddress)
|
||||
fmt.Println(m.webPortChanged(newAddress))
|
||||
return nil
|
||||
}
|
||||
|
||||
func webAddressWithPort(address, portText string) (string, int, error) {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
port, err := strconv.Atoi(strings.TrimSpace(portText))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", 0, errInvalidWebPort
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
|
||||
}
|
||||
|
||||
func waitForWebListener(address string, timeout time.Duration) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch host {
|
||||
case "", "0.0.0.0":
|
||||
host = "127.0.0.1"
|
||||
case "::":
|
||||
host = "::1"
|
||||
}
|
||||
target := net.JoinHostPort(host, port)
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
|
||||
if dialErr == nil {
|
||||
_ = connection.Close()
|
||||
return nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
|
||||
}
|
||||
|
||||
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
|
||||
if existed {
|
||||
return writeEnvFileAtomic(path, content)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuToggleLanguage flips the persisted language preference between "zh" and
|
||||
// "en" by writing the same ui.preferences app_setting the Web UI uses, then
|
||||
// switches the menu's own language so the next prompt renders in the new
|
||||
// language. The Web SPA picks up the change on its next preferences fetch; the
|
||||
// menu never needs to call i18n.Set itself since it carries its own lang copy.
|
||||
func menuToggleLanguage(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(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
database, err := store.Open(ctx, cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
current := m.lang
|
||||
next := "zh"
|
||||
if current == "zh" {
|
||||
next = "en"
|
||||
}
|
||||
payload, err := json.Marshal(map[string]string{"language": next})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{
|
||||
Key: uiPreferencesSettingKey,
|
||||
Value: payload,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
m.lang = next
|
||||
fmt.Println(m.languageSwitched())
|
||||
return nil
|
||||
}
|
||||
|
||||
func menuRestart(m *menu) error {
|
||||
if err := restartVocatService(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartVocatService() error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
}
|
||||
@@ -220,7 +497,26 @@ func menuRestart(m *menu) error {
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Println(m.restarted())
|
||||
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// menuUpdate delegates to the self-updater in internal/update. It resolves the
|
||||
// repo the same way update.Run itself does ($VOCAT_REPO or the default) and lets
|
||||
// that package handle the check/download/verify/replace/restart flow. The
|
||||
// running menu process keeps the old binary until the operator exits; only the
|
||||
// systemd service runs the new build after restartService.
|
||||
func menuUpdate(m *menu, logger *slog.Logger) error {
|
||||
repo := strings.TrimSpace(os.Getenv("VOCAT_REPO"))
|
||||
if repo == "" {
|
||||
repo = update.DefaultRepository
|
||||
}
|
||||
fmt.Println(m.updateChecking())
|
||||
if err := update.Run(logger, []string{"--repo", repo}); err != nil {
|
||||
return fmt.Errorf("%w: %v", errUpdateFailed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
_ = os.Remove(systemdUnitPath)
|
||||
_ = os.RemoveAll("/opt/vocat")
|
||||
_ = os.Remove(envFilePath)
|
||||
_ = os.Remove(legacyEnvFilePath)
|
||||
_ = os.Remove("/etc/vocat") // succeeds only when empty
|
||||
runIgnore("systemctl", "daemon-reload")
|
||||
runIgnore("userdel", "vocat")
|
||||
@@ -257,14 +554,18 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
|
||||
|
||||
// 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")
|
||||
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")
|
||||
errUpdateFailed = errors.New("menu: update 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")
|
||||
errMenuPortWrite = errors.New("menu: write Web port")
|
||||
errInvalidWebPort = errors.New("menu: invalid Web port")
|
||||
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
||||
)
|
||||
|
||||
// ---- i18n ----
|
||||
@@ -277,26 +578,41 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
|
||||
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."},
|
||||
"title": {"vocat 管理菜单", "vocat management menu"},
|
||||
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
|
||||
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
|
||||
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
|
||||
"opt_restart": {"4) 重启软件", "4) Restart software"},
|
||||
"opt_update": {"5) 更新软件", "5) Update software"},
|
||||
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
|
||||
"prompt": {"请选择: ", "Select: "},
|
||||
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
|
||||
"cur_pw": {"当前密码: ", "Current password: "},
|
||||
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
|
||||
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
|
||||
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
|
||||
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
|
||||
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
|
||||
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
|
||||
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
|
||||
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
|
||||
"reverse_proxy_notice": {
|
||||
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
|
||||
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
|
||||
},
|
||||
"lang_switched": {
|
||||
"语言已切换。Web 界面下次刷新后同步。",
|
||||
"Language switched. The web UI syncs on next refresh.",
|
||||
},
|
||||
"upd_checking": {"正在检查更新…", "Checking for updates…"},
|
||||
"restarted": {"软件已重启。", "Software restarted."},
|
||||
"uninstall_warn": {
|
||||
"警告: 将删除程序、数据与配置,且不可恢复!",
|
||||
"WARNING: removes the program, data and config. Irreversible!",
|
||||
},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
|
||||
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
|
||||
}
|
||||
entry, ok := table[key]
|
||||
if !ok {
|
||||
@@ -308,22 +624,40 @@ func (m *menu) msg(key string) string {
|
||||
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) 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) 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) currentWebAddress(address string) string {
|
||||
return fmt.Sprintf(m.msg("current_web_address"), address)
|
||||
}
|
||||
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
|
||||
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
|
||||
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
|
||||
func (m *menu) webPortChanged(address string) string {
|
||||
return fmt.Sprintf(m.msg("web_port_changed"), address)
|
||||
}
|
||||
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
|
||||
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
|
||||
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
|
||||
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) 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")}
|
||||
return []string{
|
||||
m.msg("opt_lang"),
|
||||
m.msg("opt_change"),
|
||||
m.msg("opt_port"),
|
||||
m.msg("opt_restart"),
|
||||
m.msg("opt_update"),
|
||||
m.msg("opt_uninstall"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *menu) errorPrefix(err error) string {
|
||||
@@ -348,6 +682,11 @@ func (m *menu) errorPrefix(err error) string {
|
||||
return "Restart failed."
|
||||
}
|
||||
return "重启失败。"
|
||||
case errors.Is(err, errUpdateFailed):
|
||||
if m.lang == "en" {
|
||||
return "Update failed."
|
||||
}
|
||||
return "更新失败。"
|
||||
case errors.Is(err, errMenuConfig):
|
||||
if m.lang == "en" {
|
||||
return "Failed to load configuration."
|
||||
@@ -365,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
|
||||
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 "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
||||
case errors.Is(err, errInvalidWebPort):
|
||||
if m.lang == "en" {
|
||||
return "Invalid port. Enter a number from 1 to 65535."
|
||||
}
|
||||
return "端口无效,请输入 1 到 65535。"
|
||||
case errors.Is(err, errWebPortUnavailable):
|
||||
if m.lang == "en" {
|
||||
return "The new Web port is unavailable or already in use."
|
||||
}
|
||||
return "新的 Web 端口不可用或已被占用。"
|
||||
case errors.Is(err, errMenuPortWrite):
|
||||
if m.lang == "en" {
|
||||
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
|
||||
default:
|
||||
if m.lang == "en" {
|
||||
return "Error: " + err.Error()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebAddressWithPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
port string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
|
||||
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
|
||||
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
|
||||
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
|
||||
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
|
||||
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
|
||||
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, _, err := webAddressWithPort(test.address, test.port)
|
||||
if test.wantErr {
|
||||
if !errors.Is(err, errInvalidWebPort) {
|
||||
t.Fatalf("error = %v, want errInvalidWebPort", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "env")
|
||||
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(content)
|
||||
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
t.Fatalf("rewritten env = %q", got)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
|
||||
t.Fatal("environment line injection was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
options := strings.Join(newMenu(lang).options(), "\n")
|
||||
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
|
||||
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const cellularTrafficSampleInterval = 30 * time.Second
|
||||
|
||||
type interfaceTrafficSample struct {
|
||||
interfaceName string
|
||||
rxBytes uint64
|
||||
txBytes uint64
|
||||
}
|
||||
|
||||
func collectCellularTraffic(ctx context.Context, logger *slog.Logger, database *store.Store) {
|
||||
previous := make(map[string]interfaceTrafficSample)
|
||||
var lastPrune time.Time
|
||||
collect := func() {
|
||||
now := time.Now()
|
||||
if lastPrune.IsZero() || now.Sub(lastPrune) >= 24*time.Hour {
|
||||
lastPrune = now
|
||||
if _, err := database.DeleteTrafficBefore(ctx, now.Add(-35*24*time.Hour)); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("prune old cellular traffic", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
configs, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
logger.Warn("list devices for cellular traffic collection", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
active := make(map[string]struct{}, len(configs))
|
||||
for _, config := range configs {
|
||||
interfaceName := strings.TrimSpace(config.Interface)
|
||||
if !config.NetworkEnabled || interfaceName == "" {
|
||||
delete(previous, config.ID)
|
||||
continue
|
||||
}
|
||||
active[config.ID] = struct{}{}
|
||||
|
||||
rxBytes, txBytes, err := readInterfaceTrafficCounters(interfaceName)
|
||||
if err != nil {
|
||||
// Interfaces can briefly disappear while QMI reconnects. The next
|
||||
// successful read establishes a fresh baseline, so no reconnect
|
||||
// traffic is accidentally counted twice.
|
||||
delete(previous, config.ID)
|
||||
continue
|
||||
}
|
||||
rxDelta, txDelta, ok := trafficCounterDelta(previous[config.ID], interfaceName, rxBytes, txBytes)
|
||||
previous[config.ID] = interfaceTrafficSample{
|
||||
interfaceName: interfaceName,
|
||||
rxBytes: rxBytes,
|
||||
txBytes: txBytes,
|
||||
}
|
||||
if !ok || (rxDelta == 0 && txDelta == 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for bucket, periodStart := range trafficBucketPeriods(time.Now()) {
|
||||
if err := database.AddTrafficBucket(ctx, store.TrafficBucket{
|
||||
DeviceID: config.ID,
|
||||
Bucket: bucket,
|
||||
PeriodStart: periodStart,
|
||||
RXBytes: rxDelta,
|
||||
TXBytes: txDelta,
|
||||
}); err != nil && ctx.Err() == nil {
|
||||
logger.Warn("record cellular traffic", "device", config.ID, "bucket", bucket, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for deviceID := range previous {
|
||||
if _, ok := active[deviceID]; !ok {
|
||||
delete(previous, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect()
|
||||
ticker := time.NewTicker(cellularTrafficSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trafficCounterDelta(previous interfaceTrafficSample, interfaceName string, rxBytes, txBytes uint64) (int64, int64, bool) {
|
||||
if previous.interfaceName == "" || previous.interfaceName != interfaceName || rxBytes < previous.rxBytes || txBytes < previous.txBytes {
|
||||
return 0, 0, false
|
||||
}
|
||||
rxDelta := rxBytes - previous.rxBytes
|
||||
txDelta := txBytes - previous.txBytes
|
||||
if rxDelta > math.MaxInt64 || txDelta > math.MaxInt64 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int64(rxDelta), int64(txDelta), true
|
||||
}
|
||||
|
||||
func trafficBucketPeriods(now time.Time) map[string]time.Time {
|
||||
local := now.In(time.Local)
|
||||
year, month, day := local.Date()
|
||||
dayStart := time.Date(year, month, day, 0, 0, 0, 0, time.Local).UTC()
|
||||
return map[string]time.Time{
|
||||
"hour": now.UTC().Truncate(time.Minute),
|
||||
"day": now.UTC().Truncate(time.Hour),
|
||||
"week": dayStart,
|
||||
"month": dayStart,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readInterfaceTrafficCounters(interfaceName string) (uint64, uint64, error) {
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
read := func(counter string) (uint64, error) {
|
||||
value, err := os.ReadFile(filepath.Join("/sys/class/net", iface.Name, "statistics", counter))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(string(value)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s %s counter: %w", iface.Name, counter, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
rxBytes, err := read("rx_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
txBytes, err := read("tx_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return rxBytes, txBytes, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
func readInterfaceTrafficCounters(string) (uint64, uint64, error) {
|
||||
return 0, 0, errors.New("interface traffic counters are only available on Linux")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
previous := interfaceTrafficSample{interfaceName: "wwan0", rxBytes: 100, txBytes: 50}
|
||||
rx, tx, ok := trafficCounterDelta(previous, "wwan0", 175, 90)
|
||||
if !ok || rx != 75 || tx != 40 {
|
||||
t.Fatalf("delta = (%d, %d, %v), want (75, 40, true)", rx, tx, ok)
|
||||
}
|
||||
if _, _, ok := trafficCounterDelta(previous, "wwan1", 175, 90); ok {
|
||||
t.Fatal("interface change must establish a new baseline")
|
||||
}
|
||||
if _, _, ok := trafficCounterDelta(previous, "wwan0", 90, 40); ok {
|
||||
t.Fatal("counter reset must establish a new baseline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficBucketPeriods(t *testing.T) {
|
||||
now := time.Date(2026, 8, 10, 12, 34, 56, 0, time.Local)
|
||||
periods := trafficBucketPeriods(now)
|
||||
if got := periods["hour"]; !got.Equal(now.UTC().Truncate(time.Minute)) {
|
||||
t.Fatalf("hour period = %s", got)
|
||||
}
|
||||
if got := periods["day"]; !got.Equal(now.UTC().Truncate(time.Hour)) {
|
||||
t.Fatalf("day period = %s", got)
|
||||
}
|
||||
localDay := periods["week"].In(time.Local)
|
||||
if localDay.Hour() != 0 || localDay.Minute() != 0 || localDay.Day() != 10 {
|
||||
t.Fatalf("week period = %s, want local day start", periods["week"])
|
||||
}
|
||||
if !periods["month"].Equal(periods["week"]) {
|
||||
t.Fatal("week and month should share daily periods")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
type startupFlightSetter struct {
|
||||
errors []error
|
||||
calls int
|
||||
id string
|
||||
}
|
||||
|
||||
func (setter *startupFlightSetter) SetFlight(
|
||||
_ context.Context,
|
||||
id string,
|
||||
enabled bool,
|
||||
) (device.FlightResult, error) {
|
||||
setter.calls++
|
||||
setter.id = id
|
||||
if !enabled {
|
||||
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
|
||||
}
|
||||
if setter.calls <= len(setter.errors) {
|
||||
return device.FlightResult{}, setter.errors[setter.calls-1]
|
||||
}
|
||||
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
|
||||
transient := errors.New("modem is reopening")
|
||||
setter := &startupFlightSetter{errors: []error{transient, transient}}
|
||||
if err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 3, 0,
|
||||
); err != nil {
|
||||
t.Fatalf("protect startup radio: %v", err)
|
||||
}
|
||||
if setter.calls != 3 || setter.id != "quectel-1" {
|
||||
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
|
||||
first := errors.New("first")
|
||||
last := errors.New("last")
|
||||
setter := &startupFlightSetter{errors: []error{first, last}}
|
||||
err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 2, 0,
|
||||
)
|
||||
if !errors.Is(err, last) || setter.calls != 2 {
|
||||
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ Restart=on-failure
|
||||
RestartSec=3s
|
||||
TimeoutStartSec=30s
|
||||
TimeoutStopSec=20s
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
EnvironmentFile=/etc/vocat/vocat.env
|
||||
|
||||
@@ -29,7 +28,7 @@ ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
ReadWritePaths=/opt/vocat/data
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# VoCat Docker Compose deployment.
|
||||
#
|
||||
# First-time setup:
|
||||
# cp .env.example .env # then edit VOCAT_ADMIN_PASSWORD
|
||||
# docker compose pull # fetch the prebuilt GHCR image
|
||||
# docker compose up -d # start
|
||||
#
|
||||
# Build locally from this repo instead of using the GHCR image:
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# In-container binary self-update is intentionally disabled (VOCAT_CONTAINER=docker
|
||||
# makes the server return 409 on the apply endpoint). Update by pulling a new
|
||||
# image and recreating the container:
|
||||
# docker compose pull && docker compose up -d
|
||||
|
||||
services:
|
||||
vocat:
|
||||
# Use the prebuilt multi-arch image from GHCR. Override with
|
||||
# --build to compile from the local Dockerfile instead.
|
||||
image: ghcr.io/mengmengcode/vocat:latest
|
||||
pull_policy: missing
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: vocat
|
||||
restart: unless-stopped
|
||||
|
||||
# Host network mode: the export-proxy plugin uses SO_BINDTODEVICE to pin
|
||||
# outbound proxy traffic to the modem interface (wwan0) so roaming data
|
||||
# egresses only the module — never the host's default route. That syscall
|
||||
# needs the host network namespace visible inside the container, which
|
||||
# network_mode: host provides directly. Port publishing is therefore
|
||||
# meaningless (the container shares the host stack and vocat binds
|
||||
# 0.0.0.0:7575 itself); proxy ports opened by the plugin are likewise
|
||||
# reachable on the host IP without explicit mapping.
|
||||
network_mode: host
|
||||
|
||||
# VoWiFi / eSIM / IMS paths need raw sockets (IPsec, netlink). The systemd
|
||||
# unit grants CAP_NET_ADMIN + CAP_NET_RAW; mirror that here.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
|
||||
environment:
|
||||
# Marks the process as containerized: the web UI then advertises
|
||||
# "pull new image" instead of attempting an in-place binary update.
|
||||
VOCAT_CONTAINER: docker
|
||||
# VOCAT_ADDR / VOCAT_DATABASE_PATH are set in the Dockerfile; override
|
||||
# only if you want non-default values. Sensitive values come from .env.
|
||||
VOCAT_ADMIN_PASSWORD: ${VOCAT_ADMIN_PASSWORD:?set VOCAT_ADMIN_PASSWORD in .env}
|
||||
|
||||
volumes:
|
||||
# SQLite database + persistent state. Named volume (not a bind mount)
|
||||
# because the container runs as uid 1000 (vocat) while a bind-mounted
|
||||
# host dir would be root-owned and unwritable. Docker gives the named
|
||||
# volume the image's uid 1000 ownership automatically.
|
||||
- vocat-data:/opt/vocat/data
|
||||
|
||||
volumes:
|
||||
vocat-data:
|
||||
@@ -0,0 +1,311 @@
|
||||
<p align="center">
|
||||
<img src="../web/public/favicon.svg" width="96" alt="Vocat">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VoCat</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-7-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-Embedded-003B57?style=flat-square&logo=sqlite&logoColor=white">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Linux" src="https://img.shields.io/badge/Linux-amd64_%7C_386_%7C_arm64_%7C_armv7-FCC624?style=flat-square&logo=linux&logoColor=111111">
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-Multi--Arch-2496ED?style=flat-square&logo=docker&logoColor=white">
|
||||
<img alt="WiFi Calling" src="https://img.shields.io/badge/WiFi_Calling-IMS_SMS-7B1FA2?style=flat-square">
|
||||
<img alt="eSIM" src="https://img.shields.io/badge/eSIM-LPA_%2F_eUICC-009688?style=flat-square">
|
||||
<img alt="Telegram" src="https://img.shields.io/badge/Telegram-Bot-26A5E4?style=flat-square&logo=telegram&logoColor=white">
|
||||
<img alt="GitHub Actions" src="https://img.shields.io/badge/GitHub_Actions-Release-2088FF?style=flat-square&logo=githubactions&logoColor=white">
|
||||
</p>
|
||||
|
||||
[English](../README.md) | **简体中文**
|
||||
|
||||
Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制面板与工程工具套件。它在一个自包含的服务中整合了模组发现、实时射频状态、AT 与 USSD 终端、短信、WiFi Calling(WiFi 通话)、eSIM 管理、网络选择、代理路由、通知、审计日志以及发布自动化。
|
||||
|
||||
后端使用 Go 编写,界面采用 React 与 TypeScript 构建,生产环境前端被嵌入进 Go 二进制中。单个可执行文件即包含完整的 Web 应用,并使用 SQLite 进行持久化存储。
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## 功能
|
||||
|
||||
| 领域 | Vocat 提供的能力 |
|
||||
| --- | --- |
|
||||
| 设备管理 | 自动串口/USB 发现、多模组支持、设备友好名称、概览实时刷新、模组重启、飞行模式以及 USB 网卡模式控制。 |
|
||||
| 射频与网络 | 注册状态、运营商、信号指标、RSRP/RSRQ/SINR、网络模式、频段、信道、运营商扫描以及自动/手动选网。 |
|
||||
| AT 与 USSD | 交互式 AT 终端、命令历史、原始模组响应、USSD 发起/继续/取消流程以及清晰的模组错误上报。 |
|
||||
| 短信 | 蜂窝与 IMS 短信直接发送、入站同步、长短信合并、送达报告、会话历史、未读状态、时间戳以及逐条消息的送达状态。 |
|
||||
| WiFi Calling | IKEv2/ePDG 隧道建立、EAP-AKA 鉴权、IMS 注册、IMS 短信、重连控制、状态诊断以及按设备路由。 |
|
||||
| eSIM 与 eUICC | eUICC 发现、EID 与生产信息、证书元数据、多 eUICC 清单、已安装配置文件列表、启用/禁用/切换操作,以及在卡片支持时进行下载、重命名和删除。 |
|
||||
| 卡策略 | 基于 ICCID 的 WiFi Calling 与飞行模式行为,策略即时应用。 |
|
||||
| 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 |
|
||||
| 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 |
|
||||
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制、短信发送、定时拨号并自动挂断、通话状态、接听与挂断命令。敏感操作需要管理员确认。 |
|
||||
| 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 |
|
||||
| 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 |
|
||||
|
||||
## 支持的硬件
|
||||
|
||||
Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络接口的 Quectel 模组,包括:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Quectel EG25 系列
|
||||
- 兼容的 EG600 及相关模组
|
||||
|
||||
可用功能取决于模组固件、USB 复合设备配置、SIM/eSIM 能力、主机驱动、无线网络以及运营商配置。
|
||||
|
||||
## 安装
|
||||
|
||||
### Linux 一键安装
|
||||
|
||||
已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
普通 Linux 用户且系统装有 sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
只检查 VoWiFi/XFRM 环境,不安装 VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
安装指定版本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||
- 下载对应的 GitHub Release 二进制;
|
||||
- 对照 `SHA256SUMS` 进行校验;
|
||||
- 将 Vocat 安装到 `/opt/vocat`;
|
||||
- 创建具有 Vocat 所需硬件与网络访问权限的强化版 systemd 服务;
|
||||
- 将运行时配置存放在 `/etc/vocat/env`;
|
||||
- 首次安装时生成随机初始管理员密码。
|
||||
|
||||
安装完成后打开:
|
||||
|
||||
```text
|
||||
http://<服务器地址>:7575
|
||||
```
|
||||
|
||||
### 手动二进制安装
|
||||
|
||||
从 GitHub Releases 下载对应的二进制与 `SHA256SUMS`:
|
||||
|
||||
| 平台 | 发布文件 |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 位 | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
校验并安装:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
sudo install -d -m 0755 /opt/vocat/bin /opt/vocat/data
|
||||
sudo install -m 0755 vocat-linux-amd64 /opt/vocat/bin/vocat
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
该手动命令会在前台运行 Vocat。请使用 `vocat serve` 以直接启动服务器;在 TTY 下以 root 运行无参数的 `vocat` 会进入交互式管理菜单。如需托管的 systemd 服务与自动重启,请使用一键安装脚本。
|
||||
|
||||
### Docker
|
||||
|
||||
如果 Linux 主机需要发现每一个接入的受支持 Quectel 模组,并持续感知 USB 热插拔事件,请以硬件访问模式运行 Vocat:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-e VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
容器启动后打开 `http://<服务器地址>:7575`。主机网络是必需的,这样 QMI 网络接口才能对 Vocat 可见;而特权设备访问是串口、QMI 控制节点、TUN 接口、网络配置以及容器启动后新增设备所必需的。`/dev` 挂载使新的 `ttyUSB*`、`ttyACM*` 和 `cdc-wdm*` 节点无需重建容器即可见。
|
||||
|
||||
该模式有意赋予 Vocat 对主机设备与网络栈的广泛访问权限,仅在受信任的 Linux 主机上使用。自动发现目前仅识别受支持的 Quectel USB 模组(USB 厂商 ID `2c7c`),不识别任意品牌的模组。仅用 `--device` 映射单个节点(例如 `/dev/ttyUSB2` 与 `/dev/cdc-wdm0`)会将容器限定在这些固定节点上,无法提供完整的多设备或热插拔发现。
|
||||
|
||||
GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP 监听地址。 |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite 数据库路径。 |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | 初始管理员用户名。 |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | 初始管理员密码。暴露服务前请务必修改。 |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | 鉴权会话有效期。 |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | 在使用 HTTPS 时将会话 Cookie 标记为安全。 |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | 优雅关闭超时时间。 |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | API 请求体最大字节数。 |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自更新器使用的受信任 GitHub 仓库,格式为 `owner/name`。 |
|
||||
| `GITHUB_TOKEN` | 空 | 可选的 GitHub token,用于私有仓库或更高的 API 限额。 |
|
||||
|
||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||
|
||||
## Telegram 机器人
|
||||
|
||||
启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持:
|
||||
|
||||
```text
|
||||
/status [设备]
|
||||
/esim <设备>
|
||||
/switch <设备> <iccid>
|
||||
/wfc <设备> <status|on|off|reconnect>
|
||||
/sms <设备> <号码> <内容>
|
||||
/call <设备> <号码> <秒数>
|
||||
/calls <设备>
|
||||
/answer <设备>
|
||||
/hangup <设备>
|
||||
```
|
||||
|
||||
配置文件切换、短信提交与拨号使用一次性确认按钮。定时拨号会执行模组拨号动作,并在 1–600 秒后自动挂断;不会捕获或处理通话音频。机器人不暴露 eSIM 下载、删除或重命名命令。
|
||||
|
||||
## 更新
|
||||
|
||||
检查是否有更新的 GitHub Release:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
安装最新发布版:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
更新器会下载与当前 Linux 架构匹配的二进制,使用已发布的 `SHA256SUMS` 进行校验,原子性地替换可执行文件,并在可用时重启 `vocat` systemd 服务。
|
||||
|
||||
Docker 安装的更新方式:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
拉取新镜像后重建容器。
|
||||
|
||||
## 开发
|
||||
|
||||
依赖要求:
|
||||
|
||||
- Go 1.25 或更新版本
|
||||
- Node.js 20 或更新版本
|
||||
- npm
|
||||
|
||||
运行前端开发服务器:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
构建嵌入的前端并启动后端:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
运行全部测试:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
构建生产二进制:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## 发布自动化
|
||||
|
||||
推送版本标签会触发两个 GitHub Actions 工作流:
|
||||
|
||||
- `release-binaries` 构建并发布 `amd64`、`386`、`arm64` 与 `armv7` 二进制及 `SHA256SUMS`。
|
||||
- `docker` 构建并向 GitHub Container Registry 发布多架构镜像。
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
cmd/vocat/ 应用入口与 CLI
|
||||
internal/device/ 模组发现与设备控制
|
||||
internal/modem/ AT 会话与响应处理
|
||||
internal/server/ HTTP API、通知与内嵌 Web 服务器
|
||||
internal/store/ SQLite 持久化
|
||||
internal/update/ GitHub Release 自更新器
|
||||
internal/vowifi/ IKE、EAP-AKA、IMS 与 WiFi Calling 运行时
|
||||
scripts/install.sh Linux 安装与更新脚本
|
||||
web/src/ React 与 TypeScript 前端
|
||||
.github/workflows/ 二进制与 Docker 发布自动化
|
||||
```
|
||||
|
||||
## 合规使用
|
||||
|
||||
蜂窝模组与 eSIM 操作可能影响用户服务、已存储的配置文件、网络注册以及硬件状态。请做好备份,谨慎审视破坏性操作,并仅在您被允许操作所连接的硬件与网络资源的合法环境中使用本软件。
|
||||
|
||||
Vocat 不会绕过运营商鉴权、网络策略、硬件安全或 eSIM 信任要求。支持某项操作意味着 Vocat 能够向模组或 eUICC 发起该请求;但设备、配置文件、网络或运营商仍可能拒绝。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 与 Pull Request。请保持改动聚焦,在可行处附带测试,避免提交凭据或用户数据,并清晰地说明硬件相关行为。
|
||||
|
||||
提交改动前:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## 致谢
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — 专注服务器的社群
|
||||
- [Linux.do](https://linux.do) — 富有启发的技术社群
|
||||
- [iniwex5](https://github.com/iniwex5) — 风格与功能指南
|
||||
|
||||
## 许可证
|
||||
|
||||
参见 [LICENSE](../LICENSE)。
|
||||
@@ -0,0 +1,383 @@
|
||||
# 企业微信消息推送实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
|
||||
|
||||
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
|
||||
|
||||
**技术栈:** Go 1.25、标准库 `net/http` 与 `encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
|
||||
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
|
||||
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
|
||||
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
|
||||
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
|
||||
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
|
||||
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
|
||||
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
|
||||
- 修改:`web/src/types.ts`,扩展通知设置类型。
|
||||
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
|
||||
|
||||
### 任务 1:企业微信模板与响应判定
|
||||
|
||||
**文件:**
|
||||
- 创建:`internal/server/wecom_notification_test.go`
|
||||
- 创建:`internal/server/wecom_notification.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的模板与响应测试**
|
||||
|
||||
```go
|
||||
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
|
||||
payload, err := renderWecomPayload(
|
||||
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
|
||||
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
|
||||
)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
|
||||
t.Fatalf("payload = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
|
||||
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
|
||||
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
|
||||
t.Fatalf("template %q was accepted", template)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
|
||||
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:FAIL,提示 `renderWecomPayload`、`wecomTemplateValues` 和 `validateWecomResponse` 未定义。
|
||||
|
||||
- [ ] **步骤 3:实现最少的模板与响应代码**
|
||||
|
||||
在 `internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{` 或 `}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
|
||||
|
||||
```go
|
||||
type wecomTemplateValues map[string]string
|
||||
|
||||
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
|
||||
for _, name := range wecomTemplateVariableNames {
|
||||
encoded, _ := json.Marshal(values[name])
|
||||
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
|
||||
}
|
||||
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
|
||||
return nil, errors.New("wecom.payload_template contains an unsupported variable")
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
|
||||
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
|
||||
}
|
||||
return []byte(template), nil
|
||||
}
|
||||
|
||||
func validateWecomResponse(status int, body []byte) error {
|
||||
var result struct { ErrCode int `json:"errcode"` }
|
||||
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
|
||||
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomTestValues(now time.Time) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "test", "title": "vocat", "message": "vocat notification test",
|
||||
"timestamp": now.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
|
||||
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
|
||||
if err != nil { return err }
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil { return err }
|
||||
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(payload))
|
||||
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
|
||||
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
|
||||
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
|
||||
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
|
||||
|
||||
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
|
||||
|
||||
### 任务 2:设置 API 与敏感 Webhook URL
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的 API 测试**
|
||||
|
||||
```go
|
||||
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
|
||||
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
|
||||
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
|
||||
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
|
||||
}
|
||||
|
||||
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
|
||||
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
|
||||
|
||||
预期:FAIL,设置 API 返回 `invalid_notification_channel`。
|
||||
|
||||
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
|
||||
|
||||
在 `notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings` 和 `payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig`、`handleNotificationTest` 和发送分支中支持 `wecom`。
|
||||
|
||||
```go
|
||||
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
|
||||
|
||||
case "wecom":
|
||||
return []string{"urls"}
|
||||
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
```
|
||||
|
||||
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
|
||||
|
||||
```go
|
||||
case "wecom_template":
|
||||
var template string
|
||||
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
|
||||
return fmt.Errorf("%s must be a template string", field)
|
||||
}
|
||||
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
|
||||
return err
|
||||
|
||||
case "wecom":
|
||||
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
|
||||
return errors.New("wecom.urls and wecom.payload_template are required")
|
||||
}
|
||||
```
|
||||
|
||||
测试消息的变量必须为 `event: "test"`、`title: "vocat"`、`message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
|
||||
|
||||
预期:PASS,GET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
|
||||
|
||||
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 3:接入短信与自动任务分发
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
|
||||
- [ ] **步骤 1:编写失败的事件变量测试**
|
||||
|
||||
```go
|
||||
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
|
||||
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
|
||||
values := wecomSMSValues(message)
|
||||
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
|
||||
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
|
||||
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
|
||||
t.Fatalf("values = %#v", values)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试验证失败**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
|
||||
|
||||
预期:FAIL,两个事件变量构建函数未定义。
|
||||
|
||||
- [ ] **步骤 3:实现分发接入**
|
||||
|
||||
在企业微信模块中实现 `wecomSMSValues` 和 `wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch:
|
||||
|
||||
```go
|
||||
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomSMSValues(message))
|
||||
```
|
||||
|
||||
```go
|
||||
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
|
||||
for _, channel := range channels {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
|
||||
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
|
||||
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
|
||||
}
|
||||
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
```
|
||||
|
||||
保持既有游标、错误限流日志和其他通道的行为不变。
|
||||
|
||||
- [ ] **步骤 4:运行测试验证通过**
|
||||
|
||||
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
|
||||
|
||||
预期:PASS,`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
|
||||
|
||||
- [ ] **步骤 5:提交本任务**
|
||||
|
||||
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
|
||||
|
||||
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 4:企业微信配置界面
|
||||
|
||||
**文件:**
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:扩展前端类型和表单映射**
|
||||
|
||||
在 `NotificationSettings` 与 `NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
|
||||
|
||||
```ts
|
||||
export interface WecomForm {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
payloadTemplate: string;
|
||||
}
|
||||
|
||||
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
|
||||
"msgtype": "text",
|
||||
"text": { "content": {{message}} }
|
||||
}`;
|
||||
```
|
||||
|
||||
`formsFromNotifications` 读取 `payload_template`,`buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
|
||||
|
||||
- [ ] **步骤 2:实现企业微信页签与测试请求**
|
||||
|
||||
在 `PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
|
||||
|
||||
```tsx
|
||||
<Field label={t("JSON 请求体模板")} hint={<span>变量必须作为 JSON 值使用,例如 <code>{'{{message}}'}</code>。</span>}>
|
||||
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
在 `SettingsPage.tsx` 增加 `testingWecom`、`onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
|
||||
|
||||
- [ ] **步骤 3:运行前端构建验证**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:Vite 类型检查与生产构建均以退出码 0 完成。
|
||||
|
||||
- [ ] **步骤 4:提交本任务**
|
||||
|
||||
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
|
||||
|
||||
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
|
||||
|
||||
### 任务 5:完整验证
|
||||
|
||||
**文件:**
|
||||
- 修改:`internal/server/wecom_notification.go`
|
||||
- 修改:`internal/server/wecom_notification_test.go`
|
||||
- 修改:`internal/server/settings_api.go`
|
||||
- 修改:`internal/server/settings_api_test.go`
|
||||
- 修改:`internal/store/settings.go`
|
||||
- 修改:`internal/server/sms_notifications.go`
|
||||
- 修改:`internal/server/sms_notifications_test.go`
|
||||
- 修改:`internal/server/automatic_task_notifications.go`
|
||||
- 修改:`web/src/types.ts`
|
||||
- 修改:`web/src/components/settings/model.ts`
|
||||
- 修改:`web/src/components/settings/PushTabs.tsx`
|
||||
- 修改:`web/src/pages/SettingsPage.tsx`
|
||||
|
||||
- [ ] **步骤 1:格式化 Go 代码**
|
||||
|
||||
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
|
||||
|
||||
预期:所有修改的 Go 文件采用项目标准格式。
|
||||
|
||||
- [ ] **步骤 2:运行前端生产构建**
|
||||
|
||||
运行:`npm run build`
|
||||
|
||||
工作目录:`web`
|
||||
|
||||
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
|
||||
|
||||
- [ ] **步骤 3:运行后端回归测试**
|
||||
|
||||
运行:`go test ./...`
|
||||
|
||||
预期:所有目标包通过,无失败测试;`cmd/vocat` 和 `web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
|
||||
|
||||
- [ ] **步骤 4:检查最终变更**
|
||||
|
||||
运行:`git diff --check && git status --short`
|
||||
|
||||
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 企业微信消息推送设计
|
||||
|
||||
## 目标
|
||||
|
||||
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
|
||||
|
||||
## 配置模型
|
||||
|
||||
`wecom` 配置包含:
|
||||
|
||||
- `enabled`:是否启用通道。
|
||||
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
|
||||
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
|
||||
不使用逗号、空格或换行分隔多个 URL。
|
||||
- `payload_template`:完整 JSON 请求体模板。
|
||||
|
||||
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
|
||||
|
||||
## 模板语义
|
||||
|
||||
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text`、`markdown`、`news` 或 `template_card`。
|
||||
|
||||
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": {{message}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可用变量:
|
||||
|
||||
- 通用:`{{event}}`、`{{title}}`、`{{message}}`、`{{timestamp}}`。
|
||||
- 短信事件:`{{content}}`、`{{number}}`、`{{device_id}}`、`{{device_name}}`、`{{device_label}}`、`{{time}}`。
|
||||
|
||||
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
|
||||
|
||||
## 发送流程
|
||||
|
||||
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
|
||||
|
||||
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
|
||||
|
||||
## Web 与 API
|
||||
|
||||
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
|
||||
|
||||
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
|
||||
|
||||
## 验证
|
||||
|
||||
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
|
||||
@@ -3,10 +3,11 @@ module vocat
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/term v0.43.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -16,12 +18,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
@@ -31,8 +33,8 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package developer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func Enabled(ctx context.Context, database *store.Store) bool {
|
||||
setting, err := database.AppSetting(ctx, EnabledSettingKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var document struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
return json.Unmarshal(setting.Value, &document) == nil && document.Enabled
|
||||
}
|
||||
|
||||
const (
|
||||
EnabledSettingKey = "developer.enabled"
|
||||
DeviceLimitSettingKey = "developer.device_limit"
|
||||
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
||||
DefaultDeviceLimit = 5
|
||||
MaxDeviceLimit = 128
|
||||
DefaultSMSHourlyLimit = 10
|
||||
MaxSMSHourlyLimit = 1000
|
||||
)
|
||||
|
||||
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
if !enabled {
|
||||
return DefaultDeviceLimit
|
||||
}
|
||||
setting, err := database.AppSetting(ctx, DeviceLimitSettingKey)
|
||||
if err != nil {
|
||||
return DefaultDeviceLimit
|
||||
}
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxDeviceLimit {
|
||||
return DefaultDeviceLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error {
|
||||
if limit < 1 || limit > MaxDeviceLimit {
|
||||
return fmt.Errorf("device limit must be between 1 and %d", MaxDeviceLimit)
|
||||
}
|
||||
value, err := json.Marshal(map[string]int{"limit": limit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value})
|
||||
}
|
||||
|
||||
// SMSHourlyLimit is enforced regardless of developer mode. Developer mode
|
||||
// only controls whether administrators can see and modify this value.
|
||||
func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
|
||||
setting, err := database.AppSetting(ctx, SMSHourlyLimitKey)
|
||||
if err != nil {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error {
|
||||
if limit < 1 || limit > MaxSMSHourlyLimit {
|
||||
return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit)
|
||||
}
|
||||
value, err := json.Marshal(map[string]int{"limit": limit})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value})
|
||||
}
|
||||
|
||||
// ResetExperimental restores every mutable developer-only setting. It is
|
||||
// called both by `vocat develop off` and at startup whenever developer mode is
|
||||
// disabled, so stale database values cannot silently remain active.
|
||||
func ResetExperimental(ctx context.Context, database *store.Store) error {
|
||||
httpsValue, err := json.Marshal(map[string]bool{"enabled": false})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resetErrors []error
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: httpsValue}); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset self-signed HTTPS: %w", err))
|
||||
}
|
||||
if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err))
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err))
|
||||
}
|
||||
if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err))
|
||||
}
|
||||
devices, err := database.ListDevices(ctx)
|
||||
if err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("list devices while disabling roaming data: %w", err))
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
if !device.NetworkEnabled {
|
||||
continue
|
||||
}
|
||||
device.NetworkEnabled = false
|
||||
if err := database.UpsertDevice(ctx, device); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("disable roaming data for device %s: %w", device.ID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
policies, err := database.ListCardPolicies(ctx)
|
||||
if err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("list card policies while disabling roaming data: %w", err))
|
||||
} else {
|
||||
for _, policy := range policies {
|
||||
if !policy.NetworkEnabled {
|
||||
continue
|
||||
}
|
||||
policy.NetworkEnabled = false
|
||||
if err := database.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
resetErrors = append(resetErrors, fmt.Errorf("disable roaming policy for card %s: %w", policy.ICCID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(resetErrors...)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package developer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/httpsmode"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := SetDeviceLimit(ctx, database, 24); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: "8901000000000000001", NetworkEnabled: true, IPVersion: "IPV4V6"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: json.RawMessage(`[]`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ResetExperimental(ctx, database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit {
|
||||
t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit)
|
||||
}
|
||||
if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit {
|
||||
t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit)
|
||||
}
|
||||
setting, err := database.AppSetting(ctx, httpsmode.SettingKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(setting.Value, &document); err != nil || document.Enabled {
|
||||
t.Fatalf("HTTPS setting = %s, error = %v", setting.Value, err)
|
||||
}
|
||||
device, err := database.Device(ctx, "modem-1")
|
||||
if err != nil || device.NetworkEnabled {
|
||||
t.Fatalf("device roaming data was not disabled: %+v, %v", device, err)
|
||||
}
|
||||
policy, err := database.CardPolicy(ctx, "8901000000000000001")
|
||||
if err != nil || policy.NetworkEnabled {
|
||||
t.Fatalf("card roaming policy was not disabled: %+v, %v", policy, err)
|
||||
}
|
||||
if _, err := database.AppSetting(ctx, exportproxy.SettingKey); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("export proxy configurations were not deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDeviceLimitValidatesRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if SetDeviceLimit(ctx, database, 0) == nil || SetDeviceLimit(ctx, database, MaxDeviceLimit+1) == nil {
|
||||
t.Fatal("out-of-range device limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
|
||||
t.Fatal("out-of-range SMS hourly limit was accepted")
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 25); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The offline table is generated by scripts/update-carriers.py from Android's
|
||||
// versioned carrier ID database, with the previous global table retained as a
|
||||
// fallback for PLMNs that Android does not yet catalogue.
|
||||
//
|
||||
//go:embed mccmnc.json
|
||||
var carrierDatabaseJSON []byte
|
||||
|
||||
type carrierDatabase struct {
|
||||
Carriers map[string][]string `json:"c"`
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
var database carrierDatabase
|
||||
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
|
||||
panic("device: invalid embedded MCC/MNC database: " + err.Error())
|
||||
}
|
||||
return database
|
||||
}()
|
||||
|
||||
// CarrierForPLMN returns the offline carrier display name and ISO alpha-2
|
||||
// country/territory code for a numeric five- or six-digit PLMN.
|
||||
func CarrierForPLMN(plmn string) (name, countryCode string, ok bool) {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
if !decimalDigits(plmn, 5, 6) {
|
||||
return "", "", false
|
||||
}
|
||||
entry, ok := globalCarrierDatabase.Carriers[plmn]
|
||||
if !ok || len(entry) == 0 || strings.TrimSpace(entry[0]) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
name = strings.TrimSpace(entry[0])
|
||||
if len(entry) > 1 {
|
||||
countryCode = strings.ToUpper(strings.TrimSpace(entry[1]))
|
||||
}
|
||||
return name, countryCode, true
|
||||
}
|
||||
|
||||
// CarrierForIMSI resolves the home PLMN carried by an IMSI. MNCs may contain
|
||||
// either two or three digits, so prefer an exact six-digit database match and
|
||||
// then fall back to the five-digit form. This avoids treating the first three
|
||||
// subscriber digits as a three-digit MNC for networks such as 234-33.
|
||||
func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
return "", "", "", false
|
||||
}
|
||||
for _, length := range []int{6, 5} {
|
||||
if len(imsi) < length {
|
||||
continue
|
||||
}
|
||||
candidate := imsi[:length]
|
||||
carrier, country, found := CarrierForPLMN(candidate)
|
||||
if found {
|
||||
return candidate, carrier, country, true
|
||||
}
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return FlightResult{}, err
|
||||
}
|
||||
if manager.candidateFor(state).HardwareKind == "pcsc" {
|
||||
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
|
||||
+269
-29
@@ -13,6 +13,40 @@ import (
|
||||
|
||||
var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`)
|
||||
|
||||
// ValidAPN reports whether value can safely be used as a modem PDP-context APN.
|
||||
// An empty value is valid and means that the modem/operator default should be used.
|
||||
func ValidAPN(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return value == "" || apnPattern.MatchString(value)
|
||||
}
|
||||
|
||||
func validNetworkCredential(value string) bool {
|
||||
if len(value) > 128 || strings.ContainsAny(value, "\r\n\x00\"") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeNetworkAuthentication(value string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "", "NONE":
|
||||
return "NONE"
|
||||
case "PAP":
|
||||
return "PAP"
|
||||
case "CHAP":
|
||||
return "CHAP"
|
||||
case "PAP_OR_CHAP":
|
||||
return "PAP_OR_CHAP"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) SetNetwork(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
@@ -23,9 +57,16 @@ func (manager *Manager) SetNetwork(
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
apn := strings.TrimSpace(request.APN)
|
||||
if request.Enabled && !apnPattern.MatchString(apn) {
|
||||
if request.Enabled && !ValidAPN(apn) {
|
||||
return NetworkResult{}, ErrInvalidNetworkAPN
|
||||
}
|
||||
if !validNetworkCredential(request.Username) || !validNetworkCredential(request.Password) {
|
||||
return NetworkResult{}, errors.New("APN username or password contains unsupported characters")
|
||||
}
|
||||
authentication := normalizeNetworkAuthentication(request.Authentication)
|
||||
if authentication == "" {
|
||||
return NetworkResult{}, errors.New("authentication type must be NONE, PAP, CHAP, or PAP_OR_CHAP")
|
||||
}
|
||||
ipVersion := normalizeIPVersion(request.IPVersion)
|
||||
if ipVersion == "" {
|
||||
return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6")
|
||||
@@ -43,8 +84,29 @@ func (manager *Manager) SetNetwork(
|
||||
}
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion)
|
||||
backend := strings.ToLower(strings.TrimSpace(request.Backend))
|
||||
if backend == "" {
|
||||
if candidate.QMIControl != "" && candidate.NetworkInterface != "" {
|
||||
backend = "qmi"
|
||||
} else {
|
||||
backend = "at"
|
||||
}
|
||||
}
|
||||
if backend != "at" && backend != "qmi" {
|
||||
return NetworkResult{}, fmt.Errorf("unsupported cellular data backend %q", request.Backend)
|
||||
}
|
||||
if backend == "qmi" {
|
||||
if candidate.QMIControl == "" || candidate.NetworkInterface == "" {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable)
|
||||
}
|
||||
result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication)
|
||||
if err != nil && (request.Username != "" || request.Password != "") {
|
||||
// qmi-network output is outside our control and may echo values read
|
||||
// from its temporary profile. Do not return that output when the
|
||||
// profile contains credentials.
|
||||
return NetworkResult{}, errors.New("authenticated QMI cellular data operation failed")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
client, err := manager.clientLocked(ctx, state, candidate)
|
||||
@@ -53,13 +115,32 @@ func (manager *Manager) SetNetwork(
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
if request.Enabled {
|
||||
commands := []string{
|
||||
fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn),
|
||||
"AT+CGATT=1",
|
||||
"AT+CGACT=1,1",
|
||||
type networkCommand struct {
|
||||
value string
|
||||
sensitive bool
|
||||
}
|
||||
commands := []networkCommand{
|
||||
{value: fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn)},
|
||||
}
|
||||
if authentication != "NONE" {
|
||||
authCode := map[string]int{"PAP": 1, "CHAP": 2, "PAP_OR_CHAP": 3}[authentication]
|
||||
commands = append(commands, networkCommand{
|
||||
value: fmt.Sprintf(`AT+CGAUTH=1,%d,"%s","%s"`, authCode, request.Username, request.Password),
|
||||
sensitive: true,
|
||||
})
|
||||
}
|
||||
commands = append(commands,
|
||||
networkCommand{value: "AT+CGATT=1"},
|
||||
networkCommand{value: "AT+CGACT=1,1"},
|
||||
)
|
||||
for _, command := range commands {
|
||||
if _, err := manager.command(ctx, client, command); err != nil {
|
||||
var err error
|
||||
if command.sensitive {
|
||||
_, err = manager.sensitiveCommand(ctx, client, command.value)
|
||||
} else {
|
||||
_, err = manager.command(ctx, client, command.value)
|
||||
}
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return NetworkResult{}, err
|
||||
}
|
||||
@@ -225,7 +306,7 @@ func (manager *Manager) SetOperatorSelection(
|
||||
accessTechnologyValue *int,
|
||||
) (OperatorSelection, error) {
|
||||
result := OperatorSelection{Mode: 0}
|
||||
command := "AT+COPS=0"
|
||||
command := ""
|
||||
if !automatic {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
||||
@@ -265,28 +346,187 @@ func (manager *Manager) SetOperatorSelection(
|
||||
// the lock is not aborted while registration is still in progress.
|
||||
lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
|
||||
defer cancel()
|
||||
if _, err := client.Execute(lockCtx, command); err != nil {
|
||||
manager.setResult(id, state, nil, errors.New("operator selection command failed"))
|
||||
if automatic {
|
||||
result, err = restoreAutomaticOperatorSelection(lockCtx, client)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return result, err
|
||||
}
|
||||
response, err := client.Execute(lockCtx, command)
|
||||
if err != nil || !response.OK() {
|
||||
if err == nil {
|
||||
err = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
|
||||
}
|
||||
rollbackOperatorSelection(manager, client)
|
||||
wrapped := fmt.Errorf("manual operator selection failed and automatic selection was restored: %w", err)
|
||||
manager.setResult(id, state, nil, wrapped)
|
||||
return OperatorSelection{}, wrapped
|
||||
}
|
||||
actual, err := queryOperatorSelection(lockCtx, client)
|
||||
if err != nil {
|
||||
rollbackOperatorSelection(manager, client)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
|
||||
}
|
||||
if actual.Mode != 1 || actual.Operator != plmn {
|
||||
rollbackOperatorSelection(manager, client)
|
||||
err := fmt.Errorf("network %s did not accept registration; automatic selection was restored (modem reported mode=%d operator=%q)", plmn, actual.Mode, actual.Operator)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
if !automatic {
|
||||
response, err := client.Execute(lockCtx, "AT+COPS?")
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err)
|
||||
}
|
||||
actual, err := parseOperatorSelection(response)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
if actual.Mode != 1 || actual.Operator != plmn {
|
||||
err := fmt.Errorf("network %s did not accept registration; modem reports mode=%d operator=%q", plmn, actual.Mode, actual.Operator)
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
result = actual
|
||||
}
|
||||
result = actual
|
||||
manager.setResult(id, state, nil, nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func queryOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
|
||||
response, err := client.Execute(ctx, "AT+COPS?")
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
if !response.OK() {
|
||||
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
|
||||
}
|
||||
return parseOperatorSelection(response)
|
||||
}
|
||||
|
||||
// restoreAutomaticOperatorSelection clears both a manual PLMN latch and an
|
||||
// old RAT-only scan restriction. The latter is important on EC20 modules:
|
||||
// COPS=0 alone can remain effectively LTE-only after an earlier lock, unlike a
|
||||
// phone's normal automatic GSM/WCDMA/LTE acquisition policy.
|
||||
func restoreAutomaticOperatorSelection(ctx context.Context, client modem.Client) (OperatorSelection, error) {
|
||||
// Older firmware may not implement nwscanmode; COPS auto is still useful in
|
||||
// that case, so this compatibility reset is best effort.
|
||||
_, _ = client.Execute(ctx, `AT+QCFG="nwscanmode",0,1`)
|
||||
_, _ = client.Execute(ctx, "AT+COPS=2")
|
||||
response, err := client.Execute(ctx, "AT+COPS=0")
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
if !response.OK() {
|
||||
return OperatorSelection{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
|
||||
}
|
||||
actual, err := queryOperatorSelection(ctx, client)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, fmt.Errorf("verify automatic operator selection: %w", err)
|
||||
}
|
||||
if actual.Mode != 0 {
|
||||
return OperatorSelection{}, fmt.Errorf("modem did not enter automatic operator selection (mode=%d operator=%q)", actual.Mode, actual.Operator)
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
func rollbackOperatorSelection(manager *Manager, client modem.Client) {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
defer cancel()
|
||||
_, _ = restoreAutomaticOperatorSelection(rollbackCtx, client)
|
||||
}
|
||||
|
||||
// ReRegisterOperator detaches from the network and reapplies the modem's
|
||||
// current automatic/manual selection. This is intentionally different from a
|
||||
// passive refresh: it forces a new registration attempt without changing the
|
||||
// user's lock policy.
|
||||
func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (OperatorSelection, error) {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
state.opMu.Lock()
|
||||
defer state.opMu.Unlock()
|
||||
if err := manager.validateActive(id, state); err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
longCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout)
|
||||
defer cancel()
|
||||
|
||||
current, err := queryOperatorSelection(longCtx, client)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
manual := current.Mode == 1 || current.Mode == 4
|
||||
if manual && !decimalPLMN(current.Operator) {
|
||||
response, formatErr := client.Execute(longCtx, "AT+COPS=3,2")
|
||||
if formatErr != nil || !response.OK() {
|
||||
if formatErr == nil {
|
||||
formatErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
|
||||
}
|
||||
manager.setResult(id, state, nil, formatErr)
|
||||
return OperatorSelection{}, formatErr
|
||||
}
|
||||
current, err = queryOperatorSelection(longCtx, client)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
manual = current.Mode == 1 || current.Mode == 4
|
||||
}
|
||||
|
||||
if !manual {
|
||||
result, restoreErr := restoreAutomaticOperatorSelection(longCtx, client)
|
||||
manager.setResult(id, state, nil, restoreErr)
|
||||
return result, restoreErr
|
||||
}
|
||||
desired := ""
|
||||
if manual {
|
||||
if !decimalPLMN(current.Operator) {
|
||||
return OperatorSelection{}, errors.New("current manual operator is not available as a numeric PLMN")
|
||||
}
|
||||
desired = fmt.Sprintf(`AT+COPS=1,2,"%s"`, current.Operator)
|
||||
if code, ok := accessTechnologyCode(current.AccessTechnology); ok {
|
||||
desired += fmt.Sprintf(",%d", code)
|
||||
}
|
||||
}
|
||||
for _, command := range []string{"AT+COPS=2", desired} {
|
||||
response, executeErr := client.Execute(longCtx, command)
|
||||
if executeErr != nil {
|
||||
manager.setResult(id, state, nil, executeErr)
|
||||
return OperatorSelection{}, executeErr
|
||||
}
|
||||
if !response.OK() {
|
||||
executeErr = &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines}
|
||||
manager.setResult(id, state, nil, executeErr)
|
||||
return OperatorSelection{}, executeErr
|
||||
}
|
||||
}
|
||||
result, err := queryOperatorSelection(longCtx, client)
|
||||
manager.setResult(id, state, nil, err)
|
||||
if err != nil {
|
||||
return OperatorSelection{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decimalPLMN(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return (len(value) == 5 || len(value) == 6) && strings.IndexFunc(value, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
}) < 0
|
||||
}
|
||||
|
||||
func accessTechnologyCode(name string) (int, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "GSM":
|
||||
return 0, true
|
||||
case "UTRAN":
|
||||
return 2, true
|
||||
case "EDGE":
|
||||
return 3, true
|
||||
case "HSDPA":
|
||||
return 4, true
|
||||
case "HSUPA":
|
||||
return 5, true
|
||||
case "HSPA":
|
||||
return 6, true
|
||||
case "LTE":
|
||||
return 7, true
|
||||
case "NR5G":
|
||||
return 9, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
+224
-25
@@ -4,9 +4,13 @@ package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +23,9 @@ func setQMINetwork(
|
||||
enabled bool,
|
||||
apn string,
|
||||
ipVersion string,
|
||||
username string,
|
||||
password string,
|
||||
authentication string,
|
||||
) (NetworkResult, error) {
|
||||
qmiNetwork, err := exec.LookPath("qmi-network")
|
||||
if err != nil {
|
||||
@@ -31,7 +38,20 @@ func setQMINetwork(
|
||||
profilePath := profile.Name()
|
||||
defer os.Remove(profilePath)
|
||||
ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion]
|
||||
if _, err := fmt.Fprintf(profile, "APN=%s\nIP_TYPE=%s\nPROXY=yes\n", apn, ipType); err != nil {
|
||||
profileText := fmt.Sprintf("IP_TYPE=%s\nPROXY=yes\n", ipType)
|
||||
if apn != "" {
|
||||
profileText = "APN=" + apn + "\n" + profileText
|
||||
}
|
||||
if username != "" {
|
||||
profileText += "APN_USER=" + shellProfileValue(username) + "\n"
|
||||
}
|
||||
if password != "" {
|
||||
profileText += "APN_PASS=" + shellProfileValue(password) + "\n"
|
||||
}
|
||||
if authentication != "" && authentication != "NONE" {
|
||||
profileText += "APN_AUTH=" + shellProfileValue(strings.ToLower(authentication)) + "\n"
|
||||
}
|
||||
if _, err := fmt.Fprint(profile, profileText); err != nil {
|
||||
_ = profile.Close()
|
||||
return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err)
|
||||
}
|
||||
@@ -54,36 +74,42 @@ func setQMINetwork(
|
||||
lowerDetail := strings.ToLower(detail)
|
||||
idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") ||
|
||||
strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network"))
|
||||
if !idempotentStop {
|
||||
idempotentStart := enabled && (strings.Contains(lowerDetail, "already started") ||
|
||||
strings.Contains(lowerDetail, "already connected"))
|
||||
if !idempotentStop && !idempotentStart {
|
||||
return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail)
|
||||
}
|
||||
}
|
||||
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
|
||||
linkAction := "down"
|
||||
if enabled {
|
||||
linkAction = "up"
|
||||
}
|
||||
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
|
||||
if linkErr != nil {
|
||||
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
|
||||
}
|
||||
ipCommand, lookErr := exec.LookPath("ip")
|
||||
if lookErr != nil {
|
||||
return NetworkResult{}, fmt.Errorf("%w: install iproute2 to control %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
|
||||
}
|
||||
linkAction := "down"
|
||||
if enabled {
|
||||
linkAction = "up"
|
||||
}
|
||||
linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput()
|
||||
if linkErr != nil {
|
||||
return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput)))
|
||||
}
|
||||
if enabled {
|
||||
if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil {
|
||||
dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput()
|
||||
if dhcpErr != nil {
|
||||
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
|
||||
defer cancelRollback()
|
||||
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
|
||||
if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil {
|
||||
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
|
||||
}
|
||||
return NetworkResult{}, fmt.Errorf("QMI session started but DHCP failed: %w: %s", dhcpErr, strings.TrimSpace(string(dhcpOutput)))
|
||||
}
|
||||
if value := strings.TrimSpace(string(dhcpOutput)); value != "" {
|
||||
detail = strings.TrimSpace(detail + "\n" + value)
|
||||
}
|
||||
busybox, busyboxErr := exec.LookPath("busybox")
|
||||
if busyboxErr != nil {
|
||||
return NetworkResult{}, fmt.Errorf("%w: busybox udhcpc is required for %s", ErrDataBackendUnavailable, candidate.NetworkInterface)
|
||||
}
|
||||
dhcpDetail, dhcpErr := configureExportProxyDHCP(ctx, busybox, ipCommand, candidate.NetworkInterface)
|
||||
if dhcpErr != nil {
|
||||
rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout)
|
||||
defer cancelRollback()
|
||||
clearExportProxyRoute(rollbackCtx, candidate.NetworkInterface)
|
||||
_, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput()
|
||||
_, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput()
|
||||
return NetworkResult{}, fmt.Errorf("QMI session started but protected DHCP failed: %w", dhcpErr)
|
||||
}
|
||||
detail = strings.TrimSpace(detail + "\n" + dhcpDetail)
|
||||
} else {
|
||||
clearExportProxyRoute(ctx, candidate.NetworkInterface)
|
||||
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "addr", "flush", "dev", candidate.NetworkInterface, "scope", "global").CombinedOutput()
|
||||
}
|
||||
return NetworkResult{
|
||||
Enabled: enabled,
|
||||
@@ -96,4 +122,177 @@ func setQMINetwork(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shellProfileValue(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
// exportProxyRouteIdentity must stay in sync with the Export Proxy plugin's
|
||||
// Linux socket mark. Unmarked host traffic never sees the cellular default
|
||||
// route; only plugin sockets carrying this mark are policy-routed to it.
|
||||
func exportProxyRouteIdentity(networkInterface string) (mark uint32, table, priority int) {
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(networkInterface))
|
||||
value := hash.Sum32()
|
||||
mark = 0x56000000 | (value & 0x00ffffff)
|
||||
table = 20000 + int(value%10000)
|
||||
priority = 20000 + int(value%10000)
|
||||
return
|
||||
}
|
||||
|
||||
func configureExportProxyDHCP(ctx context.Context, busybox, ipCommand, networkInterface string) (string, error) {
|
||||
lease, err := os.CreateTemp("", "vocat-dhcp-lease-*.env")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
leasePath := lease.Name()
|
||||
_ = lease.Close()
|
||||
_ = os.Remove(leasePath)
|
||||
defer os.Remove(leasePath)
|
||||
script, err := os.CreateTemp("", "vocat-udhcpc-*.sh")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scriptPath := script.Name()
|
||||
defer os.Remove(scriptPath)
|
||||
scriptText := fmt.Sprintf(`#!/bin/sh
|
||||
case "$1" in
|
||||
bound|renew)
|
||||
(umask 077; printf 'ip=%%s\nsubnet=%%s\nrouter=%%s\ndns=%%s\n' "$ip" "$subnet" "$router" "$dns" > %q)
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`, leasePath)
|
||||
if _, err := script.WriteString(scriptText); err != nil {
|
||||
_ = script.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := script.Chmod(0o700); err != nil {
|
||||
_ = script.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := script.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", networkInterface, "-s", scriptPath).CombinedOutput()
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(string(output)), "address family not supported") {
|
||||
return "", fmt.Errorf("udhcpc cannot open its link-layer socket: allow AF_PACKET in the vocat systemd service RestrictAddressFamilies setting: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("udhcpc: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
raw, err := os.ReadFile(leasePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read DHCP lease: %w", err)
|
||||
}
|
||||
values := make(map[string]string)
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if found {
|
||||
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
address := net.ParseIP(values["ip"]).To4()
|
||||
maskIP := net.ParseIP(values["subnet"]).To4()
|
||||
if address == nil || maskIP == nil {
|
||||
return "", errors.New("DHCP returned no valid IPv4 address/subnet")
|
||||
}
|
||||
mask := net.IPMask(maskIP)
|
||||
ones, bits := mask.Size()
|
||||
if bits != 32 || ones < 0 {
|
||||
return "", errors.New("DHCP returned an invalid IPv4 subnet")
|
||||
}
|
||||
network := address.Mask(mask)
|
||||
routers := strings.Fields(values["router"])
|
||||
if len(routers) > 0 && net.ParseIP(routers[0]).To4() == nil {
|
||||
return "", errors.New("DHCP returned an invalid IPv4 gateway")
|
||||
}
|
||||
if result, addrErr := exec.CommandContext(ctx, ipCommand, "-4", "addr", "replace", fmt.Sprintf("%s/%d", address.String(), ones), "dev", networkInterface).CombinedOutput(); addrErr != nil {
|
||||
return "", fmt.Errorf("configure cellular address: %w: %s", addrErr, strings.TrimSpace(string(result)))
|
||||
}
|
||||
mark, table, priority := exportProxyRouteIdentity(networkInterface)
|
||||
clearExportProxyRoute(ctx, networkInterface)
|
||||
connectedCIDR := fmt.Sprintf("%s/%d", network.String(), ones)
|
||||
if result, routeErr := exec.CommandContext(ctx, ipCommand, "-4", "route", "replace", "table", strconv.Itoa(table), connectedCIDR, "dev", networkInterface, "scope", "link", "src", address.String()).CombinedOutput(); routeErr != nil {
|
||||
clearExportProxyRoute(ctx, networkInterface)
|
||||
return "", fmt.Errorf("install protected connected route: %w: %s", routeErr, strings.TrimSpace(string(result)))
|
||||
}
|
||||
defaultArgs := []string{"-4", "route", "replace", "table", strconv.Itoa(table), "default"}
|
||||
if len(routers) > 0 {
|
||||
defaultArgs = append(defaultArgs, "via", routers[0])
|
||||
}
|
||||
defaultArgs = append(defaultArgs, "dev", networkInterface, "onlink")
|
||||
if result, routeErr := exec.CommandContext(ctx, ipCommand, defaultArgs...).CombinedOutput(); routeErr != nil {
|
||||
clearExportProxyRoute(ctx, networkInterface)
|
||||
return "", fmt.Errorf("install protected default route: %w: %s", routeErr, strings.TrimSpace(string(result)))
|
||||
}
|
||||
markText := fmt.Sprintf("0x%x", mark)
|
||||
result, err := exec.CommandContext(ctx, ipCommand, "rule", "add", "priority", strconv.Itoa(priority), "fwmark", markText, "lookup", strconv.Itoa(table)).CombinedOutput()
|
||||
if err != nil {
|
||||
clearExportProxyRoute(ctx, networkInterface)
|
||||
return "", fmt.Errorf("install protected routing rule: %w: %s", err, strings.TrimSpace(string(result)))
|
||||
}
|
||||
if err := writeExportProxyDNS(networkInterface, strings.Fields(values["dns"])); err != nil {
|
||||
clearExportProxyRoute(ctx, networkInterface)
|
||||
return "", fmt.Errorf("publish protected DNS configuration: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("protected DHCP lease %s/%d", address.String(), ones), nil
|
||||
}
|
||||
|
||||
func exportProxyDNSPath(networkInterface string) string {
|
||||
safeName := strings.Map(func(character rune) rune {
|
||||
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
|
||||
character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.' {
|
||||
return character
|
||||
}
|
||||
return '_'
|
||||
}, networkInterface)
|
||||
return "/run/vocat/cellular-" + safeName + ".dns"
|
||||
}
|
||||
|
||||
func writeExportProxyDNS(networkInterface string, servers []string) error {
|
||||
valid := make([]string, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
if address := net.ParseIP(server); address != nil {
|
||||
valid = append(valid, address.String())
|
||||
}
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
// This is used only by marked Export Proxy sockets. It never changes the
|
||||
// host resolver and is merely a fallback for carriers omitting DHCP DNS.
|
||||
valid = []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
if err := os.MkdirAll("/run/vocat", 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp("/run/vocat", ".cellular-dns-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if _, err := temporary.WriteString(strings.Join(valid, "\n") + "\n"); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, exportProxyDNSPath(networkInterface))
|
||||
}
|
||||
|
||||
func clearExportProxyRoute(ctx context.Context, networkInterface string) {
|
||||
_ = os.Remove(exportProxyDNSPath(networkInterface))
|
||||
ipCommand, err := exec.LookPath("ip")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mark, table, priority := exportProxyRouteIdentity(networkInterface)
|
||||
_, _ = exec.CommandContext(ctx, ipCommand, "rule", "del", "priority", strconv.Itoa(priority), "fwmark", fmt.Sprintf("0x%x", mark), "lookup", strconv.Itoa(table)).CombinedOutput()
|
||||
_, _ = exec.CommandContext(ctx, ipCommand, "-4", "route", "flush", "table", strconv.Itoa(table)).CombinedOutput()
|
||||
}
|
||||
|
||||
const managerCommandCleanupTimeout = 15 * time.Second
|
||||
|
||||
@@ -15,6 +15,9 @@ func setQMINetwork(
|
||||
bool,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
) (NetworkResult, error) {
|
||||
return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ package device
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
|
||||
@@ -35,6 +38,52 @@ func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkATBackendAppliesPAPCredentials(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
|
||||
{command: `AT+CGAUTH=1,1,"gg","p"`, response: okResponse()},
|
||||
{command: "AT+CGATT=1", response: okResponse()},
|
||||
{command: "AT+CGACT=1,1", response: okResponse()},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
|
||||
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
|
||||
Username: "gg", Password: "p", Authentication: "PAP",
|
||||
}); err != nil {
|
||||
t.Fatalf("enable authenticated network: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkDoesNotExposeAPNCredentialsInErrorsOrState(t *testing.T) {
|
||||
const username = "private-user"
|
||||
const password = "private-password"
|
||||
command := `AT+CGAUTH=1,1,"` + username + `","` + password + `"`
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+CGDCONT=1,"IPV4V6","giffgaff.com"`, response: okResponse()},
|
||||
{command: command, err: &modem.CommandError{Command: command, Final: "ERROR"}},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
_, err := manager.SetNetwork(context.Background(), id, NetworkRequest{
|
||||
Enabled: true, APN: "giffgaff.com", IPVersion: "IPV4V6",
|
||||
Username: username, Password: password, Authentication: "PAP",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SetNetwork() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), username) || strings.Contains(err.Error(), password) || strings.Contains(err.Error(), "AT+CGAUTH") {
|
||||
t.Fatalf("SetNetwork() exposed credentials: %q", err)
|
||||
}
|
||||
entry, getErr := manager.Get(id)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if strings.Contains(entry.LastError, username) || strings.Contains(entry.LastError, password) || strings.Contains(entry.LastError, "AT+CGAUTH") {
|
||||
t.Fatalf("device state exposed credentials: %q", entry.LastError)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) {
|
||||
client := &transcriptClient{}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
@@ -74,7 +123,10 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)},
|
||||
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: "AT+COPS=0", response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
act := 7
|
||||
@@ -89,7 +141,7 @@ func TestOperatorSelectionManualAndAutomatic(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("automatic selection: %v", err)
|
||||
}
|
||||
if selection.Mode != 0 || selection.Operator != "" {
|
||||
if selection.Mode != 0 || selection.Operator != "46001" {
|
||||
t.Fatalf("automatic selection = %#v", selection)
|
||||
}
|
||||
client.assertDone(t)
|
||||
@@ -99,6 +151,10 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+COPS=1,2,"46000",7`, response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: "AT+COPS=0", response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
act := 7
|
||||
@@ -107,3 +163,77 @@ func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) {
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestOperatorSelectionCommandFailureRestoresAutomaticMode(t *testing.T) {
|
||||
selectionErr := errors.New("+CME ERROR: 30")
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: `AT+COPS=1,2,"46000",7`, err: selectionErr},
|
||||
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: "AT+COPS=0", response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
act := 7
|
||||
_, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act)
|
||||
if !errors.Is(err, selectionErr) {
|
||||
t.Fatalf("error = %v, want wrapped selection error", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestReRegisterOperatorReappliesAutomaticMode(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: "AT+COPS=0", response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
selection, err := manager.ReRegisterOperator(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selection.Mode != 0 || selection.Operator != "46001" {
|
||||
t.Fatalf("selection = %#v", selection)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestReRegisterOperatorPreservesManualLock(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: `AT+COPS=1,2,"46003",7`, response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46003",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
selection, err := manager.ReRegisterOperator(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selection.Mode != 1 || selection.Operator != "46003" || selection.AccessTechnology != "LTE" {
|
||||
t.Fatalf("selection = %#v", selection)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestReRegisterOperatorRecoversDeregisteredModeWithAutomaticSelection(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 2`)},
|
||||
{command: `AT+QCFG="nwscanmode",0,1`, response: okResponse()},
|
||||
{command: "AT+COPS=2", response: okResponse()},
|
||||
{command: "AT+COPS=0", response: okResponse()},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,2,"46001",7`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
selection, err := manager.ReRegisterOperator(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selection.Mode != 0 || selection.Operator != "46001" {
|
||||
t.Fatalf("selection = %#v", selection)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
+86
-49
@@ -3,14 +3,18 @@ package device
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the
|
||||
@@ -24,25 +28,33 @@ import (
|
||||
// header.functionExecutionStatus (with statusCodeData.message holding the
|
||||
// human-readable failure, e.g. "The matchingID is not found").
|
||||
type es9pClient struct {
|
||||
smdp string
|
||||
http *http.Client
|
||||
smdp string
|
||||
endpoint *url.URL
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newES9PClient(smdp string) *es9pClient {
|
||||
// The eUICC — not the host — is the root of trust for RSP: during
|
||||
// AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against
|
||||
// its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature
|
||||
// the card will accept. The host TLS layer is transport only, and a minimal
|
||||
// embedded box may ship no CA bundle (this is exactly what broke on the test
|
||||
// machine), so we don't anchor host TLS to system roots. InsecureSkipVerify
|
||||
// is safe here specifically because the card does the authoritative check.
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor
|
||||
var smdpAddressPattern = regexp.MustCompile(`^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?$`)
|
||||
|
||||
func newES9PClient(ctx context.Context, smdp string) (*es9pClient, error) {
|
||||
smdp = strings.TrimSpace(smdp)
|
||||
if !smdpAddressPattern.MatchString(smdp) {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate, err := url.Parse("https://" + smdp)
|
||||
if err != nil || candidate.Hostname() == "" || candidate.User != nil ||
|
||||
(candidate.Path != "" && candidate.Path != "/") || candidate.RawQuery != "" || candidate.Fragment != "" {
|
||||
return nil, errors.New("esim: SM-DP+ address must be a hostname with an optional port")
|
||||
}
|
||||
candidate.Path = ""
|
||||
validated, err := netguard.ValidatePublicURL(ctx, candidate.String(), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: unsafe SM-DP+ address: %w", err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimSpace(smdp),
|
||||
http: &http.Client{Timeout: 90 * time.Second, Transport: transport},
|
||||
}
|
||||
smdp: validated.Host,
|
||||
endpoint: validated,
|
||||
http: netguard.NewPublicHTTPClient(90*time.Second, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s
|
||||
@@ -80,12 +92,13 @@ type es9pStatusCodeData struct {
|
||||
// is decided the way lpac decides it: a non-success execution status, or a
|
||||
// missing required output field, yields an es9pError carrying the SM-DP+ message.
|
||||
func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) {
|
||||
url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/" + function
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -149,31 +162,31 @@ func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error {
|
||||
// human-readable failure when the SM-DP+ omits statusCodeData.message. Table
|
||||
// mirrors lpac's euicc/es9p_errors.c.
|
||||
var es9pErrorTable = map[[2]string]string{
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
{"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile",
|
||||
{"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid",
|
||||
{"8.1.1", "2.2"}: "EID is missing in the context of this order",
|
||||
{"8.1.1", "3.1"}: "a different EID is already associated with this ICCID",
|
||||
{"8.1.1", "3.8"}: "EID doesn't match the expected value",
|
||||
{"8.1.2", "6.1"}: "EUM Certificate is invalid",
|
||||
{"8.1.2", "6.3"}: "EUM Certificate has expired",
|
||||
{"8.1.3", "6.1"}: "eUICC Certificate is invalid",
|
||||
{"8.1.3", "6.3"}: "eUICC Certificate has expired",
|
||||
{"8.2", "1.2"}: "Profile has not yet been released",
|
||||
{"8.2", "3.7"}: "BPP is not available for a new binding",
|
||||
{"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type",
|
||||
{"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device",
|
||||
{"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID",
|
||||
{"8.2.6", "3.3"}: "Conflicting MatchingID value",
|
||||
{"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused",
|
||||
{"8.2.7", "2.2"}: "Confirmation Code is missing",
|
||||
{"8.2.7", "3.8"}: "Confirmation Code is refused",
|
||||
{"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded",
|
||||
{"8.8.1", "3.8"}: "Invalid SM-DP+ Address",
|
||||
{"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC",
|
||||
{"8.8.5", "4.1"}: "The Download order has expired",
|
||||
{"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded",
|
||||
{"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown",
|
||||
{"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.",
|
||||
}
|
||||
|
||||
func es9pErrorMessage(subjectCode, reasonCode string) string {
|
||||
@@ -254,10 +267,10 @@ func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge,
|
||||
// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download
|
||||
// authorization needed for PrepareDownload.
|
||||
type es9pAuthenticateResult struct {
|
||||
TransactionID string
|
||||
TransactionID string
|
||||
ProfileMetadata []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpSigned2 []byte
|
||||
SmdpSignature2 []byte
|
||||
SmdpCertificate []byte
|
||||
}
|
||||
|
||||
@@ -287,7 +300,7 @@ func (c *es9pClient) authenticateClient(ctx context.Context, transactionID strin
|
||||
|
||||
func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) {
|
||||
root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{
|
||||
"transactionId": transactionID,
|
||||
"transactionId": transactionID,
|
||||
"prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse),
|
||||
}, "boundProfilePackage")
|
||||
if err != nil {
|
||||
@@ -300,10 +313,34 @@ func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID s
|
||||
// for the download case). It is best-effort: the profile is already installed, so
|
||||
// a notification failure is reported by the caller as a warning, not a failure.
|
||||
func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error {
|
||||
_, err := c.call(ctx, "handleNotification", map[string]string{
|
||||
endpoint := *c.endpoint
|
||||
endpoint.Path = "/gsma/rsp2/es9plus/handleNotification"
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"pendingNotification": es9pBase64Encode(pendingNotification),
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "gsma-rsp-lpad")
|
||||
request.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2")
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("es9p handleNotification: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
// SGP.22 defines HandleNotification as a notification-handler function:
|
||||
// success is an empty HTTP 204 response, not the JSON envelope returned by
|
||||
// ordinary ES9+ request-response functions.
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("es9p handleNotification: receiver returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelSession aborts an in-flight download so the SM-DP+ releases the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -16,9 +17,15 @@ func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
client := newES9PClient(strings.TrimPrefix(server.URL, "https://"))
|
||||
client.http = server.Client()
|
||||
return client
|
||||
endpoint, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &es9pClient{
|
||||
smdp: strings.TrimPrefix(server.URL, "https://"),
|
||||
endpoint: endpoint,
|
||||
http: server.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func successEnvelope(fields map[string]any) map[string]any {
|
||||
@@ -33,6 +40,21 @@ func successEnvelope(fields map[string]any) map[string]any {
|
||||
|
||||
func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) }
|
||||
|
||||
func TestNewES9PClientRejectsUnsafeAddress(t *testing.T) {
|
||||
for _, address := range []string{
|
||||
"https://rsp.example.com",
|
||||
"127.0.0.1",
|
||||
"169.254.169.254",
|
||||
"rsp.example.com/unexpected/path",
|
||||
"user:[email protected]",
|
||||
"rsp.example.com\r\nX-Injected: yes",
|
||||
} {
|
||||
if _, err := newES9PClient(context.Background(), address); err == nil {
|
||||
t.Errorf("newES9PClient(%q) accepted an unsafe address", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateAuthenticationSuccess(t *testing.T) {
|
||||
signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,3 +163,34 @@ func TestGetBoundProfilePackageSuccess(t *testing.T) {
|
||||
t.Fatalf("bpp = %X, want %X", got, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotificationRequiresHTTP204(t *testing.T) {
|
||||
pending := []byte{0xBF, 0x37, 0x00}
|
||||
client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/gsma/rsp2/es9plus/handleNotification" {
|
||||
t.Errorf("path = %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" {
|
||||
t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol"))
|
||||
}
|
||||
var request map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(request["pendingNotification"])
|
||||
if err != nil || !bytes.Equal(decoded, pending) {
|
||||
t.Errorf("pendingNotification = %q (%X), err=%v", request["pendingNotification"], decoded, err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err != nil {
|
||||
t.Fatalf("handleNotification: %v", err)
|
||||
}
|
||||
|
||||
client = newTestES9P(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(successEnvelope(nil))
|
||||
})
|
||||
if err := client.handleNotification(context.Background(), pending); err == nil || !strings.Contains(err.Error(), "HTTP 200") {
|
||||
t.Fatalf("HTTP 200 error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+203
-58
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"vocat/internal/i18n"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
|
||||
@@ -26,6 +27,12 @@ import (
|
||||
// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10).
|
||||
const isdRAID = "A0000005591010FFFFFFFF8900000100"
|
||||
|
||||
// xesimISDRAID is the alternate ISD-R application exposed by XeSIM cards.
|
||||
// It implements the same ES10 interface, but is not selectable through the
|
||||
// standard ...0100 AID. Selecting it is a read-only capability probe; profile
|
||||
// state is never changed during discovery.
|
||||
const xesimISDRAID = "A0000005591010FFFFFFFF8900000177"
|
||||
|
||||
// eSTK multi-SE products expose each eUICC storage through its own vendor
|
||||
// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that
|
||||
// AID silently hides the second storage.
|
||||
@@ -184,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
|
||||
|
||||
// euiccChannel is an open logical channel to the eUICC's ISD-R.
|
||||
type euiccChannel struct {
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
manager *Manager
|
||||
id string
|
||||
channel int
|
||||
pcscSession *pcsc.Session
|
||||
resetOnClose bool
|
||||
}
|
||||
|
||||
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
|
||||
@@ -237,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return channel, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
|
||||
manager.releaseStaleEuiccChannel(ctx, id) {
|
||||
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
|
||||
// interrupted APDU transaction can leave channel 1 allocated, after
|
||||
// which every eSIM page load returns 6A81 until reboot. Closing the
|
||||
// orphan while holding the shared UICC transaction lock makes the
|
||||
// operation self-healing without disturbing an active AKA exchange.
|
||||
continue
|
||||
}
|
||||
if !isTransientEuiccCME(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,11 +271,24 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
|
||||
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
|
||||
return err == nil && sw == 0x9000
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
|
||||
return manager.openEuiccOnceAID(ctx, id, isdRAID)
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
|
||||
state, lookupErr := manager.lookup(id)
|
||||
if lookupErr != nil {
|
||||
return nil, lookupErr
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
|
||||
}
|
||||
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 90 00". This EC20
|
||||
// firmware requires the explicit one-byte expected length: Le=00 opens a
|
||||
// channel but then rejects SELECT ISD-R at the AT+CSIM layer.
|
||||
@@ -296,20 +327,63 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile
|
||||
// state. The vendor product applet is selected only as a read-only capability
|
||||
// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK
|
||||
// integration, the generic GSMA AID is not appended after an eSTK SE opens,
|
||||
// because it aliases one of the same storages.
|
||||
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
|
||||
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
|
||||
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
|
||||
if err != nil || sw != 0x9000 || len(payload) != 1 {
|
||||
session.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
|
||||
}
|
||||
return nil, errNoLogicalChannel
|
||||
}
|
||||
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
|
||||
aidHex = strings.ToUpper(strings.TrimSpace(aidHex))
|
||||
aid, err := hex.DecodeString(aidHex)
|
||||
if err != nil || len(aid) == 0 || len(aid) > 255 {
|
||||
channel.close(context.Background())
|
||||
return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex)
|
||||
}
|
||||
selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...)
|
||||
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
|
||||
if err != nil || selectSW != 0x9000 {
|
||||
channel.close(context.Background())
|
||||
return nil, errNoEUICC
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
|
||||
// changing any profile state. The vendor product applet and candidate ISD-R
|
||||
// applications are selected only as read-only capability probes. Per
|
||||
// OpenEUICC's eSTK integration, generic AIDs are not appended after an eSTK SE
|
||||
// opens, because the standard AID aliases one of the same storages.
|
||||
func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string {
|
||||
product, err := manager.openEuiccAID(ctx, id, estkProductAID)
|
||||
if err != nil {
|
||||
return []string{isdRAID}
|
||||
if err == nil {
|
||||
product.close(context.Background())
|
||||
|
||||
var found []string
|
||||
for _, aid := range []string{estkSE0AID, estkSE1AID} {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
channel.close(context.Background())
|
||||
found = append(found, aid)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
return found
|
||||
}
|
||||
}
|
||||
product.close(context.Background())
|
||||
|
||||
var found []string
|
||||
for _, aid := range []string{estkSE0AID, estkSE1AID} {
|
||||
for _, aid := range []string{isdRAID, xesimISDRAID} {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -317,10 +391,12 @@ func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []stri
|
||||
channel.close(context.Background())
|
||||
found = append(found, aid)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return []string{isdRAID}
|
||||
if len(found) > 0 {
|
||||
return found
|
||||
}
|
||||
return found
|
||||
// Preserve the old error path for a physical SIM with no eUICC. The caller
|
||||
// retries the standard AID once and returns ErrNoEUICC to the HTTP layer.
|
||||
return []string{isdRAID}
|
||||
}
|
||||
|
||||
func isTransientEuiccCME(err error) bool {
|
||||
@@ -332,7 +408,23 @@ func isTransientEuiccCME(err error) bool {
|
||||
// close releases the logical channel (MANAGE CHANNEL close).
|
||||
func (channel *euiccChannel) close(ctx context.Context) {
|
||||
closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00}
|
||||
_, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU)
|
||||
_, _, _ = channel.exchange(ctx, closeAPDU)
|
||||
if channel.pcscSession != nil {
|
||||
if channel.resetOnClose {
|
||||
_ = channel.pcscSession.CloseWithReset()
|
||||
} else {
|
||||
_ = channel.pcscSession.Close()
|
||||
}
|
||||
channel.pcscSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
|
||||
if channel.pcscSession != nil {
|
||||
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
|
||||
return payload, int(sw), err
|
||||
}
|
||||
return channel.manager.csim(ctx, channel.id, apdu)
|
||||
}
|
||||
|
||||
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
|
||||
@@ -340,7 +432,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
|
||||
// and returns the assembled payload.
|
||||
func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) {
|
||||
apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel)
|
||||
payload, sw, err := channel.manager.csim(ctx, channel.id, apdu)
|
||||
payload, sw, err := channel.exchange(ctx, apdu)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -349,7 +441,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
|
||||
for sw>>8 == 0x61 && guard < 24 {
|
||||
guard++
|
||||
getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)}
|
||||
frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse)
|
||||
frag, nextSW, err := channel.exchange(ctx, getResponse)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -523,26 +615,35 @@ func validProfileICCID(iccid string) bool {
|
||||
|
||||
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
|
||||
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
if cached, ok := manager.cachedESIMInfo(id); ok {
|
||||
return cached, nil
|
||||
}
|
||||
return EsimInfo{}, errESIMRecovering
|
||||
}
|
||||
channel, err := manager.openEuicc(ctx, id)
|
||||
if err != nil {
|
||||
return EsimInfo{}, err
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
|
||||
channel.close(context.Background())
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
info := EsimInfo{AID: aid, Profiles: parseProfilesInfo(payload)}
|
||||
manager.cacheESIMInfo(id, info)
|
||||
return info, nil
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo
|
||||
if err != nil {
|
||||
return EsimInfo{}, err
|
||||
if lastErr != nil {
|
||||
return EsimInfo{}, lastErr
|
||||
}
|
||||
info := EsimInfo{Profiles: parseProfilesInfo(payload)}
|
||||
manager.cacheESIMInfo(id, info)
|
||||
return info, nil
|
||||
return EsimInfo{}, ErrNoEUICC
|
||||
}
|
||||
|
||||
// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile.
|
||||
@@ -555,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
manager.lockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
|
||||
if err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -581,6 +682,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// Release the logical channel before any reset: openEuicc's csim holds
|
||||
// opMu only for the duration of each APDU, so by here the lock is free.
|
||||
closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout)
|
||||
channel.resetOnClose = channel.pcscSession != nil
|
||||
channel.close(closeContext)
|
||||
cancelClose()
|
||||
if err != nil {
|
||||
@@ -588,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// detached reset is safe in either case and prevents an uncertain switch
|
||||
// from leaving the modem's SIM cache unusable.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
|
||||
@@ -597,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
result, ok := enableProfileResult(payload)
|
||||
if !ok {
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
if err := enableProfileResponseError(byte(result), payload); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
manager.markCachedProfileEnabled(id, iccid)
|
||||
@@ -609,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
||||
// call pending until the live modem ICCID proves that the switch took effect.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
|
||||
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
|
||||
defer cancelVerify()
|
||||
@@ -751,9 +853,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
|
||||
// initiating HTTP request. EC20 commonly drops the AT port while processing
|
||||
// CFUN=1,1, so the reset error is intentionally followed by discovery retries.
|
||||
func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
if !manager.isPCSCDevice(id) {
|
||||
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
|
||||
_ = manager.rebootForProfileSwitch(resetContext, id)
|
||||
cancelReset()
|
||||
}
|
||||
manager.refreshAfterProfileSwitch(id)
|
||||
}
|
||||
|
||||
@@ -766,6 +870,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
|
||||
// the next attempt. All errors are swallowed: this is best-effort self-healing
|
||||
// and setResult already records the last failure for the UI.
|
||||
func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
if manager.isPCSCDevice(id) {
|
||||
time.Sleep(750 * time.Millisecond)
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return
|
||||
}
|
||||
const (
|
||||
settle = 8 * time.Second
|
||||
interval = 4 * time.Second
|
||||
@@ -774,7 +892,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
time.Sleep(settle)
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
|
||||
_, err := manager.Refresh(ctx, id)
|
||||
_, _ = manager.Discover(ctx)
|
||||
_, flightErr := manager.SetFlight(ctx, id, true)
|
||||
var err error
|
||||
if flightErr == nil {
|
||||
_, err = manager.Refresh(ctx, id)
|
||||
} else {
|
||||
err = flightErr
|
||||
}
|
||||
cancel()
|
||||
if err == nil {
|
||||
return
|
||||
@@ -783,6 +908,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) isPCSCDevice(id string) bool {
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
|
||||
}
|
||||
|
||||
// enableProfileResult extracts the EnableProfile result code (tag 80) from the
|
||||
// ES10c response body. ok is false when no result code is present.
|
||||
func enableProfileResult(payload []byte) (int, bool) {
|
||||
@@ -852,25 +985,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
|
||||
var lastICCID string
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
if manager.isPCSCDevice(id) {
|
||||
snapshot, err := manager.Refresh(ctx, id)
|
||||
if err == nil {
|
||||
lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
if lastICCID == expected {
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
lastErr = err
|
||||
} else {
|
||||
for _, command := range []string{"AT+CCID", "AT+QCCID"} {
|
||||
commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout)
|
||||
response, err := manager.ExecuteAT(commandContext, id, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
if live == "" {
|
||||
lastErr = errors.New("modem response contained no valid ICCID")
|
||||
continue
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
lastICCID = live
|
||||
if live == expected {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
|
||||
break
|
||||
}
|
||||
if attempt+1 < attempts {
|
||||
select {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -68,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,13 +102,24 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
|
||||
}
|
||||
|
||||
deleted := &EsimDeleteResult{}
|
||||
var warnings []string
|
||||
if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil {
|
||||
if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore {
|
||||
deleted.SpaceDelta = int64(freeAfter - freeBefore)
|
||||
}
|
||||
} else {
|
||||
deleted.Warning = "Profile was deleted, but reclaimed storage could not be read"
|
||||
warnings = append(warnings, "Profile 已删除,但无法读取释放的存储空间")
|
||||
}
|
||||
// DeleteProfile creates a signed notification only when the Profile metadata
|
||||
// configured a receiver. Flush all retained notifications so earlier events
|
||||
// for the same receiver cannot be overtaken by this delete event.
|
||||
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
notifyErr := channel.deliverPendingNotifications(notifyContext)
|
||||
cancelNotify()
|
||||
if notifyErr != nil {
|
||||
warnings = append(warnings, "Profile 已删除,但运营商通知发送失败;通知已保留在 eUICC,可稍后重发")
|
||||
}
|
||||
deleted.Warning = strings.Join(warnings, ";")
|
||||
manager.removeCachedProfile(id, strings.TrimSpace(iccid))
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EsimDownloadParams are the SPA download form fields, mapped from the
|
||||
@@ -47,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
}
|
||||
}
|
||||
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
report("preflight", "正在检查 eUICC 剩余空间...", 10)
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
|
||||
@@ -74,7 +75,10 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newES9PClient(smdp)
|
||||
client, err := newES9PClient(ctx, smdp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30)
|
||||
init, err := client.initiateAuthentication(ctx, challenge, info1)
|
||||
@@ -127,15 +131,24 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccid, err := installationResult(installResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report("notify", "正在向运营商发送下载通知...", 90)
|
||||
iccid, installErr := installationResult(installResponse)
|
||||
warning := ""
|
||||
if err := client.handleNotification(ctx, installResponse); err != nil {
|
||||
warning = "Profile 已安装,但下载通知发送失败"
|
||||
notification, notificationErr := parsePendingNotification(installResponse)
|
||||
if notificationErr == nil {
|
||||
// Loading the final BPP segment is the commit point. Finish the operator
|
||||
// acknowledgement even if the browser closes its SSE connection now.
|
||||
notifyContext, cancelNotify := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
notificationErr = channel.deliverNotification(notifyContext, notification)
|
||||
cancelNotify()
|
||||
}
|
||||
if notificationErr != nil {
|
||||
warning = "Profile 安装结果已保留在 eUICC,但向运营商上报失败,可在当前通知列表中重发"
|
||||
}
|
||||
// Error installation results must be reported too. Return the card-side
|
||||
// installation failure only after making that best-effort ES9+ attempt.
|
||||
if installErr != nil {
|
||||
return nil, installErr
|
||||
}
|
||||
|
||||
freeAfter := freeBefore
|
||||
@@ -215,19 +228,28 @@ type EsimChipInfo struct {
|
||||
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
|
||||
// the chip header. It takes the eSIM lock like the other card ops.
|
||||
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
channel, err := manager.openEuicc(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
info, err := readEsimChipInfo(ctx, channel, isdRAID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
info, err := readEsimChipInfo(ctx, channel, aid)
|
||||
channel.close(context.Background())
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
return &info, nil
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, ErrNoEUICC
|
||||
}
|
||||
|
||||
func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) {
|
||||
@@ -264,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
|
||||
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
|
||||
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
|
||||
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
return nil, errESIMRecovering
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sw != 0x9000 {
|
||||
if !es10StatusOK(sw) {
|
||||
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
|
||||
}
|
||||
assembled = append(assembled, payload...)
|
||||
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
|
||||
return assembled, nil
|
||||
}
|
||||
|
||||
// 91xx is a successful UICC result with a proactive SIM Toolkit command
|
||||
// pending. EnableProfile commonly returns it on direct PC/SC transports because
|
||||
// the requested refresh is delivered to the terminal rather than consumed by
|
||||
// modem firmware. Resetting the card after the operation applies that refresh.
|
||||
func es10StatusOK(sw int) bool {
|
||||
return sw == 0x9000 || sw>>8 == 0x91
|
||||
}
|
||||
|
||||
// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes.
|
||||
func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) {
|
||||
payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00})
|
||||
|
||||
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
|
||||
t.Fatalf("expected ok=false when extCardResource absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
|
||||
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
|
||||
if !es10StatusOK(status) {
|
||||
t.Fatalf("status %04X should be successful", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
|
||||
if es10StatusOK(status) {
|
||||
t.Fatalf("status %04X should fail", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EsimNotification is one notification retained by an eUICC until its receiver
|
||||
// acknowledges it through ES9+.HandleNotification.
|
||||
type EsimNotification struct {
|
||||
SequenceNumber uint64 `json:"sequenceNumber"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ICCID string `json:"iccid,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
AIDHex string `json:"aidHex,omitempty"`
|
||||
CanRetry bool `json:"canRetry"`
|
||||
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func encodePositiveInteger(value uint64) []byte {
|
||||
if value == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
encoded := make([]byte, 8)
|
||||
for index := len(encoded) - 1; index >= 0; index-- {
|
||||
encoded[index] = byte(value & 0xff)
|
||||
value >>= 8
|
||||
}
|
||||
for len(encoded) > 1 && encoded[0] == 0 {
|
||||
encoded = encoded[1:]
|
||||
}
|
||||
if encoded[0]&0x80 != 0 {
|
||||
encoded = append([]byte{0}, encoded...)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func decodePositiveInteger(encoded []byte) (uint64, bool) {
|
||||
if len(encoded) == 0 || len(encoded) > 9 || encoded[0]&0x80 != 0 {
|
||||
return 0, false
|
||||
}
|
||||
if len(encoded) == 9 {
|
||||
if encoded[0] != 0 {
|
||||
return 0, false
|
||||
}
|
||||
encoded = encoded[1:]
|
||||
}
|
||||
var value uint64
|
||||
for _, octet := range encoded {
|
||||
value = value<<8 | uint64(octet)
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func buildRetrieveNotificationsRequest(sequenceNumber *uint64) []byte {
|
||||
if sequenceNumber == nil {
|
||||
return derConstruct(0xBF2B)
|
||||
}
|
||||
return derConstruct(0xBF2B, derEncode(0x80, encodePositiveInteger(*sequenceNumber)))
|
||||
}
|
||||
|
||||
func buildListNotificationsRequest() []byte {
|
||||
return derConstruct(0xBF28)
|
||||
}
|
||||
|
||||
func buildRemoveNotificationRequest(sequenceNumber uint64) []byte {
|
||||
return derConstruct(0xBF30, derEncode(0x80, encodePositiveInteger(sequenceNumber)))
|
||||
}
|
||||
|
||||
func notificationEventName(bitString []byte) string {
|
||||
if len(bitString) < 2 || bitString[0] > 7 {
|
||||
return ""
|
||||
}
|
||||
bitCount := (len(bitString)-1)*8 - int(bitString[0])
|
||||
for bit := 0; bit < bitCount; bit++ {
|
||||
if bitString[1+bit/8]&(0x80>>uint(bit%8)) == 0 {
|
||||
continue
|
||||
}
|
||||
switch bit {
|
||||
case 0:
|
||||
return "install"
|
||||
case 1, 4:
|
||||
return "enable"
|
||||
case 2, 5:
|
||||
return "disable"
|
||||
case 3, 6:
|
||||
return "delete"
|
||||
case 7:
|
||||
return "rpm"
|
||||
default:
|
||||
return fmt.Sprintf("event-%d", bit)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func notificationFromMetadata(metadata *derNode) (EsimNotification, error) {
|
||||
sequenceNumber, ok := decodePositiveInteger(derValue(metadata.children, 0x80))
|
||||
if !ok {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has an invalid sequence number")
|
||||
}
|
||||
address := strings.TrimSpace(string(derValue(metadata.children, 0x0C)))
|
||||
if address == "" {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has no receiver address")
|
||||
}
|
||||
return EsimNotification{
|
||||
SequenceNumber: sequenceNumber,
|
||||
Event: notificationEventName(derValue(metadata.children, 0x81)),
|
||||
ICCID: decodeICCID(derValue(metadata.children, 0x5A)),
|
||||
Address: address,
|
||||
CanRetry: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePendingNotification(raw []byte) (EsimNotification, error) {
|
||||
metadataNodes := derFindAll(derParse(raw), 0xBF2F)
|
||||
if len(metadataNodes) == 0 {
|
||||
return EsimNotification{}, errors.New("esim: pending notification has no metadata")
|
||||
}
|
||||
notification, err := notificationFromMetadata(metadataNodes[0])
|
||||
if err != nil {
|
||||
return EsimNotification{}, err
|
||||
}
|
||||
notification.raw = append([]byte(nil), raw...)
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
func parseNotificationMetadataList(payload []byte) ([]EsimNotification, error) {
|
||||
tag, headerLength, totalLength, err := derElementAt(payload, 0)
|
||||
if err != nil || tag != 0xBF28 || totalLength != len(payload) {
|
||||
return nil, fmt.Errorf("esim: unexpected ListNotification response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
value := payload[headerLength:totalLength]
|
||||
responseNodes := derParse(value)
|
||||
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
|
||||
return nil, fmt.Errorf("esim: eUICC could not list notifications (result %X)", responseNodes[0].value)
|
||||
}
|
||||
metadataNodes := derFindAll(responseNodes, 0xBF2F)
|
||||
notifications := make([]EsimNotification, 0, len(metadataNodes))
|
||||
for _, metadata := range metadataNodes {
|
||||
notification, parseErr := notificationFromMetadata(metadata)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
sort.SliceStable(notifications, func(left, right int) bool {
|
||||
if notifications[left].Address == notifications[right].Address {
|
||||
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
|
||||
}
|
||||
return notifications[left].Address < notifications[right].Address
|
||||
})
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func parsePendingNotifications(payload []byte) ([]EsimNotification, error) {
|
||||
tag, headerLength, totalLength, err := derElementAt(payload, 0)
|
||||
if err != nil || tag != 0xBF2B || totalLength != len(payload) {
|
||||
return nil, fmt.Errorf("esim: unexpected RetrieveNotificationsList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
value := payload[headerLength:totalLength]
|
||||
responseNodes := derParse(value)
|
||||
if len(responseNodes) == 1 && (responseNodes[0].tag == 0x81 || responseNodes[0].tag == 0x80 || responseNodes[0].tag == 0x02) {
|
||||
errorCode := responseNodes[0].value
|
||||
return nil, fmt.Errorf("esim: eUICC could not retrieve notifications (result %X)", errorCode)
|
||||
}
|
||||
// The notificationList CHOICE alternative is encoded as context tag A0 by
|
||||
// AUTOMATIC TAGS on newer eUICCs. Older cards are also seen returning the
|
||||
// SEQUENCE OF contents directly. Accept both without including the list
|
||||
// wrapper in the PendingNotification sent to ES9+.
|
||||
if len(responseNodes) == 1 && responseNodes[0].tag == 0xA0 {
|
||||
value = responseNodes[0].value
|
||||
} else if len(responseNodes) == 1 && responseNodes[0].tag == 0x30 && firstChild(responseNodes[0].children, 0xBF2F) == nil {
|
||||
value = responseNodes[0].value
|
||||
}
|
||||
|
||||
var notifications []EsimNotification
|
||||
for offset := 0; offset < len(value); {
|
||||
_, _, elementLength, elementErr := derElementAt(value, offset)
|
||||
if elementErr != nil {
|
||||
return nil, elementErr
|
||||
}
|
||||
raw := value[offset : offset+elementLength]
|
||||
notification, parseErr := parsePendingNotification(raw)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
offset += elementLength
|
||||
}
|
||||
sort.SliceStable(notifications, func(left, right int) bool {
|
||||
if notifications[left].Address == notifications[right].Address {
|
||||
return notifications[left].SequenceNumber < notifications[right].SequenceNumber
|
||||
}
|
||||
return notifications[left].Address < notifications[right].Address
|
||||
})
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func removeNotificationResult(payload []byte) error {
|
||||
roots := derParse(payload)
|
||||
if len(roots) != 1 || roots[0].tag != 0xBF30 {
|
||||
return fmt.Errorf("esim: unexpected RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
result := derValue(roots[0].children, 0x80)
|
||||
if len(result) == 0 {
|
||||
result = derValue(roots[0].children, 0x02)
|
||||
}
|
||||
if len(result) != 1 {
|
||||
return fmt.Errorf("esim: malformed RemoveNotificationFromList response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
switch result[0] {
|
||||
case 0, 1: // ok, or already removed after an earlier acknowledged retry
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("esim: eUICC could not remove notification (result %d)", result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) retrieveNotifications(ctx context.Context, sequenceNumber *uint64) ([]EsimNotification, error) {
|
||||
payload, err := channel.es10(ctx, buildRetrieveNotificationsRequest(sequenceNumber))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsePendingNotifications(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) listNotifications(ctx context.Context) ([]EsimNotification, error) {
|
||||
payload, err := channel.es10(ctx, buildListNotificationsRequest())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseNotificationMetadataList(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) removeNotification(ctx context.Context, sequenceNumber uint64) error {
|
||||
payload, err := channel.es10(ctx, buildRemoveNotificationRequest(sequenceNumber))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeNotificationResult(payload)
|
||||
}
|
||||
|
||||
func (channel *euiccChannel) deliverNotification(ctx context.Context, notification EsimNotification) error {
|
||||
client, err := newES9PClient(ctx, notification.Address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.handleNotification(ctx, notification.raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := channel.removeNotification(ctx, notification.SequenceNumber); err != nil {
|
||||
return fmt.Errorf("notification acknowledged but could not be removed from eUICC: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverPendingNotifications sends each receiver's notifications oldest first.
|
||||
// A failed item stops only that receiver's group so a later sequence number can
|
||||
// never overtake it and make the older notification stale.
|
||||
func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) error {
|
||||
notifications, err := channel.listNotifications(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blockedAddresses := make(map[string]bool)
|
||||
var failures []error
|
||||
for _, notification := range notifications {
|
||||
if blockedAddresses[notification.Address] {
|
||||
continue
|
||||
}
|
||||
pending, retrieveErr := channel.retrieveNotifications(ctx, ¬ification.SequenceNumber)
|
||||
if retrieveErr == nil {
|
||||
retrieveErr = fmt.Errorf("esim: notification %d was not returned by eUICC", notification.SequenceNumber)
|
||||
for _, candidate := range pending {
|
||||
if candidate.SequenceNumber == notification.SequenceNumber {
|
||||
retrieveErr = channel.deliverNotification(ctx, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
blockedAddresses[notification.Address] = true
|
||||
failures = append(failures, fmt.Errorf("notification %d to %s: %w", notification.SequenceNumber, notification.Address, retrieveErr))
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
// ESIMNotifications returns the notifications retained across every eUICC
|
||||
// storage exposed by the physical card.
|
||||
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var all []EsimNotification
|
||||
var lastErr error
|
||||
succeeded := false
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
channel, err := manager.openEuiccAID(ctx, id, aid)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
notifications, retrieveErr := channel.listNotifications(ctx)
|
||||
channel.close(context.Background())
|
||||
if retrieveErr != nil {
|
||||
lastErr = retrieveErr
|
||||
continue
|
||||
}
|
||||
succeeded = true
|
||||
for index := range notifications {
|
||||
notifications[index].AIDHex = aid
|
||||
}
|
||||
all = append(all, notifications...)
|
||||
}
|
||||
if !succeeded && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ESIMRetryNotification sends one retained notification and removes it from the
|
||||
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
|
||||
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer channel.close(context.Background())
|
||||
notifications, err := channel.retrieveNotifications(ctx, &sequenceNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, notification := range notifications {
|
||||
if notification.SequenceNumber == sequenceNumber {
|
||||
return channel.deliverNotification(ctx, notification)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("esim: notification %d was not found", sequenceNumber)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPositiveIntegerEncodingRoundTripsFullUint64Range(t *testing.T) {
|
||||
for _, value := range []uint64{0, 1, 127, 128, 255, 256, ^uint64(0)} {
|
||||
encoded := encodePositiveInteger(value)
|
||||
decoded, ok := decodePositiveInteger(encoded)
|
||||
if !ok || decoded != value {
|
||||
t.Errorf("round trip %d: encoded=%X decoded=%d ok=%t", value, encoded, decoded, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address, iccid string) []byte {
|
||||
t.Helper()
|
||||
iccidBCD, err := encodeICCID(iccid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return derConstruct(0xBF2F,
|
||||
derEncode(0x80, []byte{sequence}),
|
||||
derEncode(0x81, event),
|
||||
derEncode(0x0C, []byte(address)),
|
||||
derEncode(0x5A, iccidBCD),
|
||||
)
|
||||
}
|
||||
|
||||
func TestParsePendingNotifications(t *testing.T) {
|
||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
|
||||
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
|
||||
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||
|
||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
||||
if err != nil {
|
||||
t.Fatalf("parsePendingNotifications: %v", err)
|
||||
}
|
||||
if len(notifications) != 2 {
|
||||
t.Fatalf("notifications = %#v", notifications)
|
||||
}
|
||||
// Results are grouped by receiver, then sorted by sequence number.
|
||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
||||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
|
||||
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
||||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
|
||||
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||
}
|
||||
|
||||
metadata, err := parseNotificationMetadataList(derConstruct(0xBF28, derConstruct(0xA0, installMetadata, deleteMetadata)))
|
||||
if err != nil || len(metadata) != 2 {
|
||||
t.Fatalf("parseNotificationMetadataList = %#v, %v", metadata, err)
|
||||
}
|
||||
if metadata[0].SequenceNumber != 9 || metadata[0].Event != "delete" || len(metadata[0].raw) != 0 {
|
||||
t.Fatalf("listed metadata = %#v", metadata[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationRequestsAndRemoveResult(t *testing.T) {
|
||||
if got := buildListNotificationsRequest(); !bytes.Equal(got, []byte{0xBF, 0x28, 0x00}) {
|
||||
t.Fatalf("list request = %X", got)
|
||||
}
|
||||
if got := buildRetrieveNotificationsRequest(nil); !bytes.Equal(got, []byte{0xBF, 0x2B, 0x00}) {
|
||||
t.Fatalf("retrieve all request = %X", got)
|
||||
}
|
||||
sequenceNumber := uint64(128)
|
||||
wantRetrieve := []byte{0xBF, 0x2B, 0x04, 0x80, 0x02, 0x00, 0x80}
|
||||
if got := buildRetrieveNotificationsRequest(&sequenceNumber); !bytes.Equal(got, wantRetrieve) {
|
||||
t.Fatalf("retrieve request = %X, want %X", got, wantRetrieve)
|
||||
}
|
||||
wantRemove := []byte{0xBF, 0x30, 0x04, 0x80, 0x02, 0x00, 0x80}
|
||||
if got := buildRemoveNotificationRequest(sequenceNumber); !bytes.Equal(got, wantRemove) {
|
||||
t.Fatalf("remove request = %X, want %X", got, wantRemove)
|
||||
}
|
||||
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x00}); err != nil {
|
||||
t.Fatalf("removeNotificationResult(ok): %v", err)
|
||||
}
|
||||
if err := removeNotificationResult([]byte{0xBF, 0x30, 0x03, 0x80, 0x01, 0x7F}); err == nil {
|
||||
t.Fatal("undefinedError response was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePendingNotificationsRejectsMalformedMetadata(t *testing.T) {
|
||||
missingAddress := derConstruct(0x30, derConstruct(0xBF2F,
|
||||
derEncode(0x80, []byte{1}),
|
||||
derEncode(0x81, []byte{4, 0x10}),
|
||||
))
|
||||
if _, err := parsePendingNotifications(derConstruct(0xBF2B, missingAddress)); err == nil {
|
||||
t.Fatal("notification without receiver address was accepted")
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -244,6 +244,45 @@ func TestTransientEuiccCMEClassification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEuiccAIDsFindsXeSIMAlternateISDR(t *testing.T) {
|
||||
manageChannel := clientStep{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"019000"`),
|
||||
}
|
||||
closeChannel := clientStep{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
}
|
||||
selectStep := func(aid, response string) clientStep {
|
||||
return clientStep{
|
||||
command: fmt.Sprintf(`AT+CSIM=42,"01A4040010%s"`, aid),
|
||||
response: okResponse(fmt.Sprintf(`+CSIM: 4,"%s"`, response)),
|
||||
}
|
||||
}
|
||||
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
// No eSTK product applet on this card.
|
||||
manageChannel,
|
||||
selectStep(estkProductAID, "6A82"),
|
||||
closeChannel,
|
||||
// XeSIM does not expose the standard GSMA ...0100 application.
|
||||
manageChannel,
|
||||
selectStep(isdRAID, "6A82"),
|
||||
closeChannel,
|
||||
// Its dedicated ...0177 ISD-R is selectable.
|
||||
manageChannel,
|
||||
selectStep(xesimISDRAID, "9000"),
|
||||
closeChannel,
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
aids := manager.discoverEuiccAIDs(context.Background(), id)
|
||||
if len(aids) != 1 || aids[0] != xesimISDRAID {
|
||||
t.Fatalf("discovered AIDs = %#v, want XeSIM %s", aids, xesimISDRAID)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||
cause := &modem.CommandError{
|
||||
Command: `AT+CSIM=10,"0070000001"`,
|
||||
@@ -255,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"006A81"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"019000"`),
|
||||
},
|
||||
{
|
||||
command: fmt.Sprintf(
|
||||
`AT+CSIM=42,"01A4040010%s"`,
|
||||
isdRAID,
|
||||
),
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
manager.lockESIM()
|
||||
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
|
||||
if err == nil {
|
||||
channel.close(context.Background())
|
||||
}
|
||||
manager.unlockESIM()
|
||||
if err != nil {
|
||||
t.Fatalf("open eUICC after orphaned channel: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestWaitForESIMRecovery(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
|
||||
|
||||
+142
-4
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
@@ -19,10 +20,12 @@ type Options struct {
|
||||
LongTimeout time.Duration
|
||||
SMSTimeout time.Duration
|
||||
ScanTimeout time.Duration
|
||||
CardReaders *pcsc.Service
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
|
||||
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
|
||||
esimRecoveryMu sync.Mutex
|
||||
esimRecoveries map[string]chan struct{}
|
||||
@@ -34,11 +37,29 @@ type Manager struct {
|
||||
longTimeout time.Duration
|
||||
smsTimeout time.Duration
|
||||
scanTimeout time.Duration
|
||||
cardReaders *pcsc.Service
|
||||
started bool
|
||||
devices map[string]*managedDevice
|
||||
ussdSessions map[string]ussdSession
|
||||
}
|
||||
|
||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
|
||||
// Individual AT commands are already serialized per modem, but a logical-
|
||||
// channel transaction spans several commands and must not be interleaved.
|
||||
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
|
||||
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
|
||||
|
||||
func (manager *Manager) lockESIM() {
|
||||
manager.esimMu.Lock()
|
||||
manager.uiccMu.Lock()
|
||||
}
|
||||
|
||||
func (manager *Manager) unlockESIM() {
|
||||
manager.uiccMu.Unlock()
|
||||
manager.esimMu.Unlock()
|
||||
}
|
||||
|
||||
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
|
||||
// Cancel can be routed back to the right modem. The modem owns the actual
|
||||
// network session; this map only records which device a session id belongs to.
|
||||
@@ -50,6 +71,8 @@ type ussdSession struct {
|
||||
type managedDevice struct {
|
||||
opMu sync.Mutex
|
||||
candidate modem.Candidate
|
||||
backend string
|
||||
lastICCID string
|
||||
client modem.Client
|
||||
snapshot *Snapshot
|
||||
lastError string
|
||||
@@ -57,6 +80,7 @@ type managedDevice struct {
|
||||
discovered bool
|
||||
preFlightMode *int
|
||||
resetClientOnLock bool
|
||||
simPIN string
|
||||
}
|
||||
|
||||
func NewManager(options Options) (*Manager, error) {
|
||||
@@ -80,6 +104,9 @@ func NewManager(options Options) (*Manager, error) {
|
||||
// AT+COPS=? can take well over a minute while the modem sweeps every band.
|
||||
options.ScanTimeout = 150 * time.Second
|
||||
}
|
||||
if options.CardReaders == nil {
|
||||
options.CardReaders = pcsc.New()
|
||||
}
|
||||
return &Manager{
|
||||
discoverer: options.Discoverer,
|
||||
opener: options.Opener,
|
||||
@@ -87,6 +114,7 @@ func NewManager(options Options) (*Manager, error) {
|
||||
longTimeout: options.LongTimeout,
|
||||
smsTimeout: options.SMSTimeout,
|
||||
scanTimeout: options.ScanTimeout,
|
||||
cardReaders: options.CardReaders,
|
||||
devices: make(map[string]*managedDevice),
|
||||
ussdSessions: make(map[string]ussdSession),
|
||||
esimRecoveries: make(map[string]chan struct{}),
|
||||
@@ -144,9 +172,23 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
candidates, err := manager.discoverer.Discover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
candidates, modemErr := manager.discoverer.Discover(ctx)
|
||||
readers, readerErr := manager.cardReaders.Readers(ctx)
|
||||
if readerErr == nil {
|
||||
for _, reader := range readers {
|
||||
candidates = append(candidates, modem.Candidate{
|
||||
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
|
||||
ReaderName: reader.Name, USBPath: reader.USBPath,
|
||||
VendorID: reader.VendorID, ProductID: reader.ProductID,
|
||||
Manufacturer: reader.Manufacturer, Product: reader.Product,
|
||||
})
|
||||
}
|
||||
}
|
||||
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
|
||||
return nil, errors.Join(modemErr, readerErr)
|
||||
}
|
||||
if modemErr != nil && len(candidates) == 0 {
|
||||
return nil, modemErr
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
|
||||
@@ -358,16 +400,95 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
candidate := manager.candidateFor(state)
|
||||
if candidate.HardwareKind == pcsc.HardwareKind {
|
||||
return manager.refreshCardReader(ctx, id, state, candidate)
|
||||
}
|
||||
backend := manager.backendFor(state)
|
||||
client, err := manager.clientLocked(ctx, state, candidate)
|
||||
if err != nil {
|
||||
manager.setResult(id, state, nil, err)
|
||||
return Snapshot{}, err
|
||||
}
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, client)
|
||||
previousICCID := state.lastICCID
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
|
||||
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
|
||||
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
}
|
||||
manager.setResult(id, state, &snapshot, err)
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
|
||||
result := Snapshot{
|
||||
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
|
||||
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
|
||||
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
|
||||
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, pcsc.ErrNoCard):
|
||||
result.SIMStatus = ""
|
||||
err = nil
|
||||
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
|
||||
result.SIMStatus = "SIM PIN"
|
||||
result.Warnings = []string{err.Error()}
|
||||
err = nil
|
||||
default:
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
} else {
|
||||
result.SIMStatus = "READY"
|
||||
result.SIMReady = true
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
manager.setResult(id, state, &result, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
|
||||
// It is deliberately never retained in runtime snapshots or logs.
|
||||
func (manager *Manager) SetSIMPin(id, pin string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
state := manager.devices[id]
|
||||
if state == nil || !state.discovered {
|
||||
return ErrNotFound
|
||||
}
|
||||
state.simPIN = strings.TrimSpace(pin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBackend selects which control plane supplies registration and data state.
|
||||
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
|
||||
func (manager *Manager) SetBackend(id, backend string) error {
|
||||
backend = strings.ToLower(strings.TrimSpace(backend))
|
||||
if backend != "at" && backend != "qmi" && backend != "pcsc" {
|
||||
return fmt.Errorf("unsupported device backend %q", backend)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
state := manager.devices[id]
|
||||
if state == nil || !state.discovered {
|
||||
return ErrNotFound
|
||||
}
|
||||
state.backend = backend
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) backendFor(state *managedDevice) string {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
return state.backend
|
||||
}
|
||||
|
||||
func (manager *Manager) ExecuteAT(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
@@ -529,3 +650,20 @@ func (manager *Manager) command(
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// sensitiveCommand executes an AT command containing credentials or other
|
||||
// authentication material. Modem errors commonly echo the complete command,
|
||||
// so neither the returned error nor the retained device state may wrap it.
|
||||
func (manager *Manager) sensitiveCommand(
|
||||
ctx context.Context,
|
||||
client modem.Client,
|
||||
command string,
|
||||
) (modem.Response, error) {
|
||||
commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout)
|
||||
defer cancel()
|
||||
response, err := client.Execute(commandCtx, command)
|
||||
if err != nil {
|
||||
return response, errors.New("sensitive modem command failed")
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,45 @@ import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/pcsc"
|
||||
)
|
||||
|
||||
type testPCSCBackend struct{ readers []pcsc.Reader }
|
||||
|
||||
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
|
||||
return append([]pcsc.Reader(nil), backend.readers...), nil
|
||||
}
|
||||
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
|
||||
return nil, pcsc.ErrNoCard
|
||||
}
|
||||
|
||||
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
|
||||
manager, err := NewManager(Options{
|
||||
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
|
||||
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
|
||||
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
|
||||
}}}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
|
||||
items := manager.List()
|
||||
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
|
||||
t.Fatalf("discovered readers = %#v", items)
|
||||
}
|
||||
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
|
||||
t.Fatalf("reader snapshot = %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
@@ -19,6 +56,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
),
|
||||
},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{
|
||||
command: "AT+CCID",
|
||||
response: modem.Response{Final: "+CME ERROR: 100"},
|
||||
err: errors.New("CCID unsupported"),
|
||||
},
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{
|
||||
command: `AT+QENG="servingcell"`,
|
||||
@@ -27,14 +72,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
),
|
||||
},
|
||||
{command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)},
|
||||
{command: "AT+CEREG?", response: okResponse(`+CEREG: 0,5`)},
|
||||
{command: "AT+CGSN", response: okResponse("867123456789012")},
|
||||
{
|
||||
command: "AT+CCID",
|
||||
response: modem.Response{Final: "+CME ERROR: 100"},
|
||||
err: errors.New("CCID unsupported"),
|
||||
},
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 1")},
|
||||
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)},
|
||||
}}
|
||||
@@ -66,12 +105,14 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
t.Fatalf("signal metrics = %#v", snapshot)
|
||||
}
|
||||
if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" ||
|
||||
snapshot.Channel != "1650" || snapshot.OperatorName != "China Mobile" {
|
||||
snapshot.Channel != "1650" || snapshot.OperatorName != "China Unicom" ||
|
||||
snapshot.OperatorCode != "46001" ||
|
||||
snapshot.RegistrationStatus != 5 || snapshot.RegistrationSource != "CEREG" {
|
||||
t.Fatalf("network = %#v", snapshot)
|
||||
}
|
||||
if snapshot.IMEI != "867123456789012" ||
|
||||
snapshot.ICCID != "8986001234567890123" ||
|
||||
snapshot.IMSI != "460001234567890" {
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
|
||||
t.Fatalf("subscriber identifiers = %#v", snapshot)
|
||||
}
|
||||
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
|
||||
@@ -93,6 +134,18 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestParseSPNASCIIAndUCS2(t *testing.T) {
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("ASCII SPN = %q", got)
|
||||
}
|
||||
if got := parseSPN(okResponse(`+CRSM: 144,0,"0080004C00650062006100720061FFFF"`)); got != "Lebara" {
|
||||
t.Fatalf("UCS2 SPN = %q", got)
|
||||
}
|
||||
if got := parseSPN(okResponse(`+CRSM: 106,130,""`)); got != "" {
|
||||
t.Fatalf("failed CRSM SPN = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) {
|
||||
response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}}
|
||||
if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" {
|
||||
@@ -119,6 +172,57 @@ func TestManagerRequiresStartAndKnownDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerBackendSelectionIsExplicit(t *testing.T) {
|
||||
manager, id := newStartedTestManager(t, &transcriptClient{})
|
||||
if err := manager.SetBackend(id, "qmi"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.backendFor(state); got != "qmi" {
|
||||
t.Fatalf("backend = %q, want qmi", got)
|
||||
}
|
||||
if err := manager.SetBackend(id, "mbim"); err == nil {
|
||||
t.Fatal("unsupported backend was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{command: "ATI", response: okResponse("Quectel", "EC20", "Revision: test")},
|
||||
{command: "AT+CPIN?", response: okResponse("+CPIN: READY")},
|
||||
{command: "AT+CCID", response: okResponse("+CCID: 8900000000000000002")},
|
||||
// This must precede CIMI, signal, serving-cell and operator queries.
|
||||
{command: "AT+CFUN=4", response: okResponse()},
|
||||
{command: "AT+CIMI", response: okResponse("234150000000002")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
{command: "AT+CEREG?", response: okResponse("+CEREG: 0,0")},
|
||||
{command: "AT+CGSN", response: okResponse("867123456789012")},
|
||||
{command: "AT+CFUN?", response: okResponse("+CFUN: 4")},
|
||||
{command: "AT+CNUM", response: okResponse(`+CNUM: "","+447700900002",145`)},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
state, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.lastICCID = "8900000000000000001"
|
||||
|
||||
snapshot, err := manager.Refresh(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snapshot.SIMChanged || !snapshot.FlightMode || snapshot.OperatingMode != 4 {
|
||||
t.Fatalf("changed SIM snapshot = %#v", snapshot)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) {
|
||||
const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"`
|
||||
client := &transcriptClient{steps: []clientStep{{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
//go:build linux
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func readPlatformRegistration(ctx context.Context, candidate modem.Candidate) (platformRegistration, bool) {
|
||||
control := strings.TrimSpace(candidate.QMIControl)
|
||||
if control == "" {
|
||||
return platformRegistration{}, false
|
||||
}
|
||||
qmicli, err := exec.LookPath("qmicli")
|
||||
if err != nil {
|
||||
return platformRegistration{}, false
|
||||
}
|
||||
queryContext, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(
|
||||
queryContext,
|
||||
qmicli,
|
||||
"-d", control,
|
||||
"--device-open-proxy",
|
||||
"--nas-get-serving-system",
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return platformRegistration{}, false
|
||||
}
|
||||
return parseQMIRegistration(string(output))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func readPlatformRegistration(context.Context, modem.Candidate) (platformRegistration, bool) {
|
||||
return platformRegistration{}, false
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type platformRegistration struct {
|
||||
Status int
|
||||
PLMN string
|
||||
Name string
|
||||
PSAttached bool
|
||||
}
|
||||
|
||||
var qmiQuotedFieldPattern = regexp.MustCompile(`(?i)^\s*([^:]+):\s*'([^']*)'\s*$`)
|
||||
|
||||
func parseQMIRegistration(output string) (platformRegistration, bool) {
|
||||
result := platformRegistration{}
|
||||
registrationState := ""
|
||||
roaming := false
|
||||
mcc := ""
|
||||
mnc := ""
|
||||
pcsDigit := false
|
||||
for _, rawLine := range strings.Split(output, "\n") {
|
||||
match := qmiQuotedFieldPattern.FindStringSubmatch(strings.TrimSpace(rawLine))
|
||||
if len(match) != 3 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(match[1]))
|
||||
value := strings.TrimSpace(match[2])
|
||||
switch key {
|
||||
case "registration state":
|
||||
registrationState = strings.ToLower(value)
|
||||
case "roaming status":
|
||||
roaming = strings.EqualFold(value, "on")
|
||||
case "ps":
|
||||
result.PSAttached = strings.EqualFold(value, "attached")
|
||||
case "mcc":
|
||||
if mcc == "" {
|
||||
mcc = value
|
||||
}
|
||||
case "mnc":
|
||||
if mnc == "" {
|
||||
mnc = value
|
||||
}
|
||||
case "description":
|
||||
if result.Name == "" {
|
||||
result.Name = value
|
||||
}
|
||||
case "mnc with pcs digit":
|
||||
pcsDigit = strings.EqualFold(value, "yes")
|
||||
}
|
||||
}
|
||||
switch registrationState {
|
||||
case "registered":
|
||||
result.Status = 1
|
||||
if roaming {
|
||||
result.Status = 5
|
||||
}
|
||||
case "not-registered-searching", "searching":
|
||||
result.Status = 2
|
||||
case "registration-denied", "denied":
|
||||
result.Status = 3
|
||||
case "not-registered":
|
||||
result.Status = 0
|
||||
default:
|
||||
return platformRegistration{}, false
|
||||
}
|
||||
if decimalDigits(mcc, 3, 3) && decimalDigits(mnc, 1, 3) {
|
||||
width := 2
|
||||
if pcsDigit {
|
||||
width = 3
|
||||
}
|
||||
for len(mnc) < width {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
result.PLMN = mcc + mnc
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package device
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseQMIRegistrationRegisteredRoaming(t *testing.T) {
|
||||
output := `
|
||||
Registration state: 'registered'
|
||||
CS: 'attached'
|
||||
PS: 'attached'
|
||||
Roaming status: 'on'
|
||||
Current PLMN:
|
||||
MCC: '460'
|
||||
MNC: '1'
|
||||
Description: 'UNICOM'
|
||||
Full operator code info:
|
||||
MCC: '460'
|
||||
MNC: '1'
|
||||
MNC with PCS digit: 'no'
|
||||
`
|
||||
result, found := parseQMIRegistration(output)
|
||||
if !found || result.Status != 5 || !result.PSAttached || result.PLMN != "46001" || result.Name != "UNICOM" {
|
||||
t.Fatalf("registration = %#v, found=%v", result, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQMIRegistrationSearching(t *testing.T) {
|
||||
result, found := parseQMIRegistration("Registration state: 'not-registered-searching'\nPS: 'detached'")
|
||||
if !found || result.Status != 2 || result.PSAttached {
|
||||
t.Fatalf("registration = %#v, found=%v", result, found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestParseRegistrationStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want int
|
||||
}{
|
||||
{line: "+CEREG: 0,5", want: 5},
|
||||
{line: "+CGREG: 2,1,\"FFFE\",\"06698D06\",7", want: 1},
|
||||
{line: "+CREG: 2", want: 2},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, ok := parseRegistrationStatus(modem.Response{Lines: []string{test.line}})
|
||||
if !ok || got != test.want {
|
||||
t.Fatalf("parseRegistrationStatus(%q) = %d, %v", test.line, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -14,6 +14,7 @@ type ScannedOperator struct {
|
||||
Name string `json:"name"`
|
||||
Short string `json:"shortName,omitempty"`
|
||||
Numeric string `json:"numeric"`
|
||||
Country string `json:"countryCode,omitempty"`
|
||||
Act string `json:"act,omitempty"`
|
||||
}
|
||||
|
||||
@@ -75,11 +76,19 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
name, country, _ := CarrierForPLMN(fields[3])
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(fields[1])
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(fields[3])
|
||||
}
|
||||
operator := ScannedOperator{
|
||||
Status: operatorScanStatus(fields[0]),
|
||||
Name: fields[1],
|
||||
Name: name,
|
||||
Short: fields[2],
|
||||
Numeric: fields[3],
|
||||
Country: country,
|
||||
}
|
||||
if len(fields) >= 5 {
|
||||
operator.Act = accessTechnology(fields[4])
|
||||
@@ -90,6 +99,20 @@ func parseOperatorScan(response modem.Response) []ScannedOperator {
|
||||
return operators
|
||||
}
|
||||
|
||||
// carrierNameForPLMN resolves the numeric serving PLMN through the bundled
|
||||
// global carrier database. Some EC20 firmware returns an empty, localized, or
|
||||
// stale long name even though the MCC/MNC is correct. The numeric identity is
|
||||
// the authoritative value used for network selection.
|
||||
func carrierNameForPLMN(plmn, fallback string) string {
|
||||
if name, _, ok := CarrierForPLMN(plmn); ok {
|
||||
return name
|
||||
}
|
||||
if fallback = strings.TrimSpace(fallback); fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(plmn)
|
||||
}
|
||||
|
||||
// extractScanTuples returns the contents of each top-level parenthesised group,
|
||||
// ignoring parentheses inside quoted strings.
|
||||
func extractScanTuples(payload string) []string {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestParseOperatorScanNormalizesMainlandCarrierNamesByPLMN(t *testing.T) {
|
||||
response := modem.Response{Lines: []string{
|
||||
`+COPS: (1,"CMCC","CMCC","46000",7),(1,"wrong modem name","CU","46001",7),(1,"","CT","46011",7),(1,"CBN","CBN","46015",7)`,
|
||||
}}
|
||||
operators := parseOperatorScan(response)
|
||||
if len(operators) != 4 {
|
||||
t.Fatalf("operators = %#v", operators)
|
||||
}
|
||||
want := []string{"China Mobile", "China Unicom", "China Telecom", "China Broadnet"}
|
||||
for index := range want {
|
||||
if operators[index].Name != want[index] {
|
||||
t.Fatalf("operator %d name = %q, want %q", index, operators[index].Name, want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierNameForPLMNUsesGlobalDatabase(t *testing.T) {
|
||||
if got := carrierNameForPLMN("23415", "stale modem name"); got != "Vodafone" {
|
||||
t.Fatalf("carrier name = %q", got)
|
||||
}
|
||||
if got := carrierNameForPLMN("26202", ""); got != "Vodafone" {
|
||||
t.Fatalf("German carrier name = %q", got)
|
||||
}
|
||||
if got := carrierNameForPLMN("310260", ""); got != "T-Mobile - US" {
|
||||
t.Fatalf("US carrier name = %q", got)
|
||||
}
|
||||
if got := carrierNameForPLMN("99999", "Test Network"); got != "Test Network" {
|
||||
t.Fatalf("unknown carrier fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"23415": "GB",
|
||||
"23487": "GB",
|
||||
"26202": "DE",
|
||||
"310260": "US",
|
||||
"22201": "IT",
|
||||
"72405": "BR",
|
||||
"46015": "CN",
|
||||
}
|
||||
for plmn, wantCountry := range tests {
|
||||
name, country, ok := CarrierForPLMN(plmn)
|
||||
if !ok || name == "" || country != wantCountry {
|
||||
t.Errorf("CarrierForPLMN(%q) = (%q, %q, %v), want a name and country %q", plmn, name, country, ok, wantCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
wantPLMN string
|
||||
wantCountry string
|
||||
}{
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||
if !ok || plmn != item.wantPLMN || name == "" || country != item.wantCountry {
|
||||
t.Errorf("CarrierForIMSI(%q) = (%q, %q, %q, %v), want PLMN %q and country %q", item.imsi, plmn, name, country, ok, item.wantPLMN, item.wantCountry)
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
-14
@@ -3,12 +3,14 @@ package device
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf16"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
@@ -17,6 +19,8 @@ func (manager *Manager) readSnapshot(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
candidate modem.Candidate,
|
||||
backend string,
|
||||
previousICCID string,
|
||||
client modem.Client,
|
||||
) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
@@ -47,11 +51,43 @@ func (manager *Manager) readSnapshot(
|
||||
if response, ok := optional("AT+CPIN?"); ok {
|
||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||
}
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
previousICCID = strings.TrimSpace(previousICCID)
|
||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||
// A different physical SIM must never inherit the previous card's
|
||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||
// or operator state; policy reconciliation will then start VoWiFi.
|
||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
||||
}
|
||||
snapshot.SIMChanged = true
|
||||
}
|
||||
if response, ok := optional("AT+CIMI"); ok {
|
||||
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
|
||||
}
|
||||
// EF_SPN is the SIM-issued brand (for example "Lebara"), which is distinct
|
||||
// from the IMSI sponsor/core PLMN. A Lebara UK subscription may therefore
|
||||
// legitimately carry a Vodafone NL IMSI while still presenting Lebara as
|
||||
// its customer-facing operator. Failure is intentionally silent because
|
||||
// EF_SPN is optional and some physical SIMs deny CRSM access to it.
|
||||
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
|
||||
snapshot.SPN = parseSPN(response)
|
||||
}
|
||||
if response, ok := optional("AT+CSQ"); ok {
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
servingPLMN := ""
|
||||
if response, ok := optional(`AT+QENG="servingcell"`); ok {
|
||||
metrics := parseQENG(response)
|
||||
servingPLMN = metrics.PLMN
|
||||
snapshot.AccessTech = metrics.AccessTech
|
||||
snapshot.Band = metrics.Band
|
||||
snapshot.Channel = metrics.Channel
|
||||
@@ -64,12 +100,45 @@ func (manager *Manager) readSnapshot(
|
||||
}
|
||||
if response, ok := optional("AT+COPS?"); ok {
|
||||
operator := parseCOPS(response)
|
||||
snapshot.OperatorName = operator.Name
|
||||
snapshot.OperatorCode = operator.Code
|
||||
if operator.Code != "" {
|
||||
snapshot.OperatorCode = operator.Code
|
||||
} else {
|
||||
snapshot.OperatorCode = servingPLMN
|
||||
}
|
||||
snapshot.OperatorName = carrierNameForPLMN(snapshot.OperatorCode, operator.Name)
|
||||
if snapshot.AccessTech == "" {
|
||||
snapshot.AccessTech = operator.AccessTech
|
||||
}
|
||||
}
|
||||
for _, command := range []string{"AT+CEREG?", "AT+CGREG?", "AT+CREG?"} {
|
||||
response, registrationErr := manager.command(ctx, client, command)
|
||||
if registrationErr != nil {
|
||||
continue
|
||||
}
|
||||
if status, found := parseRegistrationStatus(response); found {
|
||||
snapshot.RegistrationStatus = status
|
||||
snapshot.RegistrationSource = strings.TrimSuffix(strings.TrimPrefix(command, "AT+"), "?")
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(backend, "qmi") {
|
||||
registration, found := readPlatformRegistration(ctx, candidate)
|
||||
if found {
|
||||
snapshot.RegistrationStatus = registration.Status
|
||||
snapshot.RegistrationSource = "QMI NAS"
|
||||
snapshot.PSAttached = registration.PSAttached
|
||||
if registration.PLMN != "" {
|
||||
snapshot.OperatorCode = registration.PLMN
|
||||
snapshot.OperatorName = carrierNameForPLMN(registration.PLMN, registration.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if snapshot.RegistrationSource == "" && (snapshot.OperatorName != "" || snapshot.OperatorCode != "") {
|
||||
// Older firmware can omit registration queries while COPS still proves
|
||||
// that an operator is selected.
|
||||
snapshot.RegistrationStatus = 1
|
||||
snapshot.RegistrationSource = "COPS"
|
||||
}
|
||||
if response, ok := optional("AT+CGSN"); ok {
|
||||
snapshot.IMEI = parseIdentifier(
|
||||
response,
|
||||
@@ -79,18 +148,6 @@ func (manager *Manager) readSnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||
if ccidErr != nil {
|
||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||
}
|
||||
if ccidErr != nil {
|
||||
snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error())
|
||||
} else {
|
||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||
}
|
||||
if response, ok := optional("AT+CIMI"); ok {
|
||||
snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18)
|
||||
}
|
||||
if response, ok := optional("AT+CFUN?"); ok {
|
||||
if mode, found := parseCFUN(response); found {
|
||||
snapshot.OperatingMode = mode
|
||||
@@ -107,6 +164,72 @@ func (manager *Manager) readSnapshot(
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func parseSPN(response modem.Response) string {
|
||||
value := valueAfterPrefix(response, "+CRSM:")
|
||||
fields := csvValues(value)
|
||||
if len(fields) < 3 {
|
||||
return ""
|
||||
}
|
||||
sw1, sw1Err := strconv.Atoi(strings.TrimSpace(fields[0]))
|
||||
sw2, sw2Err := strconv.Atoi(strings.TrimSpace(fields[1]))
|
||||
if sw1Err != nil || sw2Err != nil || (sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f) || sw2 < 0 || sw2 > 255 {
|
||||
return ""
|
||||
}
|
||||
raw, err := hex.DecodeString(strings.Trim(strings.TrimSpace(fields[2]), `"`))
|
||||
if err != nil || len(raw) < 2 {
|
||||
return ""
|
||||
}
|
||||
alpha := raw[1:] // byte 0 is the display-condition bit field.
|
||||
for len(alpha) > 0 && (alpha[len(alpha)-1] == 0xff || alpha[len(alpha)-1] == 0x00) {
|
||||
alpha = alpha[:len(alpha)-1]
|
||||
}
|
||||
if len(alpha) == 0 {
|
||||
return ""
|
||||
}
|
||||
if alpha[0] == 0x80 {
|
||||
ucs2 := alpha[1:]
|
||||
if len(ucs2)%2 != 0 {
|
||||
ucs2 = ucs2[:len(ucs2)-1]
|
||||
}
|
||||
units := make([]uint16, 0, len(ucs2)/2)
|
||||
for index := 0; index+1 < len(ucs2); index += 2 {
|
||||
unit := uint16(ucs2[index])<<8 | uint16(ucs2[index+1])
|
||||
if unit != 0xffff && unit != 0 {
|
||||
units = append(units, unit)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(utf16.Decode(units)))
|
||||
}
|
||||
// EF_SPN uses the unpacked GSM default alphabet. Its printable Latin subset
|
||||
// is byte-compatible with UTF-8/ASCII and covers operator brands in practice.
|
||||
printable := make([]byte, 0, len(alpha))
|
||||
for _, value := range alpha {
|
||||
if value >= 0x20 && value <= 0x7e {
|
||||
printable = append(printable, value)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(printable))
|
||||
}
|
||||
|
||||
func parseRegistrationStatus(response modem.Response) (int, bool) {
|
||||
for _, prefix := range []string{"+CEREG:", "+CGREG:", "+CREG:"} {
|
||||
values := csvValues(valueAfterPrefix(response, prefix))
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
index := 0
|
||||
// Query responses are <n>,<stat>; unsolicited responses are <stat>.
|
||||
if len(values) >= 2 {
|
||||
index = 1
|
||||
}
|
||||
status, err := strconv.Atoi(strings.TrimSpace(values[index]))
|
||||
if err == nil && status >= 0 && status <= 10 {
|
||||
return status, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseATI(lines []string) (manufacturer, model, firmware string) {
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -159,6 +282,7 @@ func parseCSQ(response modem.Response) (raw, percent, dbm *int) {
|
||||
}
|
||||
|
||||
type qengMetrics struct {
|
||||
PLMN string
|
||||
AccessTech string
|
||||
Band string
|
||||
Channel string
|
||||
@@ -179,6 +303,9 @@ func parseQENG(response modem.Response) qengMetrics {
|
||||
}
|
||||
result := qengMetrics{AccessTech: strings.ToUpper(values[2])}
|
||||
if strings.EqualFold(values[2], "LTE") && len(values) >= 17 {
|
||||
if decimalDigits(values[4], 3, 3) && decimalDigits(values[5], 2, 3) {
|
||||
result.PLMN = values[4] + values[5]
|
||||
}
|
||||
result.Channel = values[8]
|
||||
if values[9] != "" {
|
||||
result.Band = "B" + values[9]
|
||||
@@ -193,6 +320,13 @@ func parseQENG(response modem.Response) qengMetrics {
|
||||
return qengMetrics{}
|
||||
}
|
||||
|
||||
func decimalDigits(value string, minimum, maximum int) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return len(value) >= minimum && len(value) <= maximum && strings.IndexFunc(value, func(character rune) bool {
|
||||
return character < '0' || character > '9'
|
||||
}) < 0
|
||||
}
|
||||
|
||||
type operatorInfo struct {
|
||||
Name string
|
||||
Code string
|
||||
|
||||
+42
-32
@@ -11,6 +11,7 @@ var (
|
||||
ErrNotStarted = errors.New("device manager is not started")
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrNoATPort = errors.New("device has no usable AT port")
|
||||
ErrUnsupportedCapability = errors.New("device does not support this capability")
|
||||
ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode")
|
||||
ErrSMSInvalidRecipient = errors.New("invalid SMS recipient")
|
||||
ErrSMSEmpty = errors.New("SMS text is empty")
|
||||
@@ -24,9 +25,13 @@ var (
|
||||
)
|
||||
|
||||
type NetworkRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Enabled bool `json:"enabled"`
|
||||
APN string `json:"apn"`
|
||||
IPVersion string `json:"ipVersion"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Authentication string `json:"authentication,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkResult struct {
|
||||
@@ -73,35 +78,40 @@ const (
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Port string `json:"port"`
|
||||
Responsive bool `json:"responsive"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
Firmware string `json:"firmware"`
|
||||
SIMStatus string `json:"simStatus"`
|
||||
SIMReady bool `json:"simReady"`
|
||||
SignalRaw *int `json:"signalRaw,omitempty"`
|
||||
SignalPercent *int `json:"signalPercent,omitempty"`
|
||||
RSSIDBm *int `json:"rssiDbm,omitempty"`
|
||||
RSRP *int `json:"rsrp,omitempty"`
|
||||
RSRQ *int `json:"rsrq,omitempty"`
|
||||
SINR *int `json:"sinr,omitempty"`
|
||||
AccessTech string `json:"accessTech"`
|
||||
Band string `json:"band"`
|
||||
Channel string `json:"channel"`
|
||||
OperatorName string `json:"operatorName"`
|
||||
OperatorCode string `json:"operatorCode"`
|
||||
IMEI string `json:"imei"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
RadioOff bool `json:"radioOff"`
|
||||
Phone PhoneNumber `json:"phone"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Port string `json:"port"`
|
||||
Responsive bool `json:"responsive"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
Firmware string `json:"firmware"`
|
||||
SIMStatus string `json:"simStatus"`
|
||||
SIMReady bool `json:"simReady"`
|
||||
SIMChanged bool `json:"simChanged,omitempty"`
|
||||
SignalRaw *int `json:"signalRaw,omitempty"`
|
||||
SignalPercent *int `json:"signalPercent,omitempty"`
|
||||
RSSIDBm *int `json:"rssiDbm,omitempty"`
|
||||
RSRP *int `json:"rsrp,omitempty"`
|
||||
RSRQ *int `json:"rsrq,omitempty"`
|
||||
SINR *int `json:"sinr,omitempty"`
|
||||
AccessTech string `json:"accessTech"`
|
||||
Band string `json:"band"`
|
||||
Channel string `json:"channel"`
|
||||
OperatorName string `json:"operatorName"`
|
||||
OperatorCode string `json:"operatorCode"`
|
||||
RegistrationStatus int `json:"registrationStatus"`
|
||||
RegistrationSource string `json:"registrationSource"`
|
||||
PSAttached bool `json:"psAttached"`
|
||||
IMEI string `json:"imei"`
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
SPN string `json:"spn,omitempty"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
RadioOff bool `json:"radioOff"`
|
||||
Phone PhoneNumber `json:"phone"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type USSDResult struct {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"hash/fnv"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func platformSupported() error { return nil }
|
||||
|
||||
func boundDialer(networkInterface string) net.Dialer {
|
||||
return net.Dialer{Control: func(_, _ string, raw syscall.RawConn) error {
|
||||
var bindError error
|
||||
err := raw.Control(func(fd uintptr) {
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, int(exportRouteMark(networkInterface))); err != nil {
|
||||
bindError = err
|
||||
return
|
||||
}
|
||||
bindError = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, networkInterface)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bindError
|
||||
}}
|
||||
}
|
||||
|
||||
func exportRouteMark(networkInterface string) uint32 {
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(networkInterface))
|
||||
return 0x56000000 | (hash.Sum32() & 0x00ffffff)
|
||||
}
|
||||
|
||||
func boundResolver(networkInterface string) *net.Resolver {
|
||||
dialer := boundDialer(networkInterface)
|
||||
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
var lastError error
|
||||
for _, server := range exportRouteDNSServers(networkInterface) {
|
||||
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(server, "53"))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastError = err
|
||||
}
|
||||
return nil, lastError
|
||||
}}
|
||||
}
|
||||
|
||||
func exportRouteDNSServers(networkInterface string) []string {
|
||||
if !validInterfaceName(networkInterface) {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
root, err := os.OpenRoot("/run/vocat")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.Open("cellular-" + networkInterface + ".dns")
|
||||
if err != nil {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
defer file.Close()
|
||||
servers := make([]string, 0, 2)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if value := strings.TrimSpace(scanner.Text()); net.ParseIP(value) != nil {
|
||||
servers = append(servers, value)
|
||||
}
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return []string{"1.1.1.1", "8.8.8.8"}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// Linux IFNAMSIZ is 16 including the terminator. Restricting names here both
|
||||
// matches kernel interface names and prevents a stored device value from ever
|
||||
// becoming a filesystem path component.
|
||||
func validInterfaceName(value string) bool {
|
||||
if value == "" || len(value) > 15 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character > unicode.MaxASCII || !(character >= 'a' && character <= 'z' ||
|
||||
character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' ||
|
||||
character == '-' || character == '_' || character == '.') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidInterfaceName(t *testing.T) {
|
||||
for _, value := range []string{"wwan0", "wwp0s20f0u5i4", "rmnet_data0", "usb.1"} {
|
||||
if !validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = false", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", ".", "..", "../wwan0", `..\wwan0`, "wwan0/evil", "interface-name-too-long"} {
|
||||
if validInterfaceName(value) {
|
||||
t.Errorf("validInterfaceName(%q) = true", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
func platformSupported() error { return errors.New("built-in export proxy is only available on Linux") }
|
||||
func boundDialer(string) net.Dialer { return net.Dialer{} }
|
||||
func boundResolver(string) *net.Resolver { return net.DefaultResolver }
|
||||
@@ -0,0 +1,88 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ipInfoURL = "https://ipinfo.io/json"
|
||||
|
||||
type PublicIPInfo struct {
|
||||
IP string `json:"ip"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
}
|
||||
|
||||
// LookupPublicIP sends the lookup through the same marked, interface-bound
|
||||
// dialer and isolated DNS resolver as Export Proxy. It therefore reports the
|
||||
// modem's roaming exit rather than the host or browser's default connection.
|
||||
func LookupPublicIP(ctx context.Context, networkInterface string) (PublicIPInfo, error) {
|
||||
networkInterface = strings.TrimSpace(networkInterface)
|
||||
if networkInterface == "" {
|
||||
return PublicIPInfo{}, errors.New("cellular network interface is required")
|
||||
}
|
||||
if err := platformSupported(); err != nil {
|
||||
return PublicIPInfo{}, err
|
||||
}
|
||||
dialer := boundDialer(networkInterface)
|
||||
resolver := boundResolver(networkInterface)
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
return dialTarget(ctx, address, &dialer, resolver)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
ResponseHeaderTimeout: 12 * time.Second,
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ipInfoURL, nil)
|
||||
if err != nil {
|
||||
return PublicIPInfo{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", "VoCat/1.0")
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
return PublicIPInfo{}, fmt.Errorf("query ipinfo.io through %s: %w", networkInterface, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
return PublicIPInfo{}, fmt.Errorf("ipinfo.io returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return decodePublicIPInfo(io.LimitReader(response.Body, 64<<10))
|
||||
}
|
||||
|
||||
func decodePublicIPInfo(reader io.Reader) (PublicIPInfo, error) {
|
||||
var response struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
Org string `json:"org"`
|
||||
}
|
||||
if err := json.NewDecoder(reader).Decode(&response); err != nil {
|
||||
return PublicIPInfo{}, fmt.Errorf("decode ipinfo.io response: %w", err)
|
||||
}
|
||||
response.IP = strings.TrimSpace(response.IP)
|
||||
response.Country = strings.ToUpper(strings.TrimSpace(response.Country))
|
||||
if net.ParseIP(response.IP) == nil {
|
||||
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid IP address")
|
||||
}
|
||||
if len(response.Country) != 2 {
|
||||
return PublicIPInfo{}, errors.New("ipinfo.io response contained no valid country code")
|
||||
}
|
||||
return PublicIPInfo{
|
||||
IP: response.IP, CountryCode: response.Country,
|
||||
Region: strings.TrimSpace(response.Region), City: strings.TrimSpace(response.City),
|
||||
Organization: strings.TrimSpace(response.Org),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodePublicIPInfo(t *testing.T) {
|
||||
info, err := decodePublicIPInfo(strings.NewReader(`{"ip":"203.0.113.8","city":"London","region":"England","country":"gb","org":"AS64500 Test"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.IP != "203.0.113.8" || info.CountryCode != "GB" || info.Region != "England" || info.City != "London" {
|
||||
t.Fatalf("info = %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePublicIPInfoRejectsInvalidResponse(t *testing.T) {
|
||||
if _, err := decodePublicIPInfo(strings.NewReader(`{"ip":"not-an-ip","country":"GB"}`)); err == nil {
|
||||
t.Fatal("invalid IP was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingKey = "developer.export_proxy.configs"
|
||||
PasswordMask = "••••••••"
|
||||
ReservedID = "export-proxy"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("export proxy configuration not found")
|
||||
ErrDisabled = errors.New("export proxy is disabled")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Interface string `json:"interface"`
|
||||
Mode string `json:"mode"`
|
||||
ListenHost string `json:"listen_host"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AuthEnabled bool `json:"auth_enabled"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
Listen string `json:"listen"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
configs []Config
|
||||
listeners map[string]net.Listener
|
||||
started map[string]time.Time
|
||||
lastError map[string]string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, database *store.Store, logger *slog.Logger, legacyConfigPath string) (*Manager, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("export proxy store is required")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
manager := &Manager{
|
||||
store: database, logger: logger,
|
||||
listeners: make(map[string]net.Listener),
|
||||
started: make(map[string]time.Time),
|
||||
lastError: make(map[string]string),
|
||||
}
|
||||
migrated, err := manager.load(ctx, legacyConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if migrated {
|
||||
if err := manager.saveLocked(ctx); err != nil {
|
||||
return nil, fmt.Errorf("migrate legacy export proxy configurations: %w", err)
|
||||
}
|
||||
_ = RemoveLegacyConfig(legacyConfigPath)
|
||||
}
|
||||
|
||||
for _, config := range manager.configs {
|
||||
if config.Enabled {
|
||||
if err := manager.start(ctx, config.ID); err != nil {
|
||||
manager.logger.Warn("start built-in export proxy", "id", config.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func RemoveLegacyConfig(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
err := os.Remove(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (manager *Manager) load(ctx context.Context, legacyConfigPath string) (bool, error) {
|
||||
setting, err := manager.store.AppSetting(ctx, SettingKey)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(setting.Value, &manager.configs); err != nil {
|
||||
return false, fmt.Errorf("decode export proxy configurations: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return false, err
|
||||
}
|
||||
legacy, err := os.ReadFile(strings.TrimSpace(legacyConfigPath))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || strings.TrimSpace(legacyConfigPath) == "" {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(legacy, &manager.configs); err != nil {
|
||||
return false, fmt.Errorf("decode legacy export proxy configurations: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) saveLocked(ctx context.Context) error {
|
||||
raw, err := json.Marshal(manager.configs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return manager.store.UpsertAppSetting(ctx, store.AppSetting{Key: SettingKey, Value: raw, Sensitive: true})
|
||||
}
|
||||
|
||||
func (manager *Manager) Configs() ([]Config, error) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.disabled {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
result := make([]Config, len(manager.configs))
|
||||
for index, config := range manager.configs {
|
||||
result[index] = redact(config)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EnabledConfigForDevice returns the first enabled configuration bound to the
|
||||
// given device, reporting whether one exists. It is used to block turning off a
|
||||
// device's roaming data while one of its export proxies is still running.
|
||||
func (manager *Manager) EnabledConfigForDevice(deviceID string) (Config, bool) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.disabled {
|
||||
return Config{}, false
|
||||
}
|
||||
for _, config := range manager.configs {
|
||||
if config.DeviceID == deviceID && config.Enabled {
|
||||
return redact(config), true
|
||||
}
|
||||
}
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
func (manager *Manager) Status() ([]Status, error) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.disabled {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
result := make([]Status, 0, len(manager.configs))
|
||||
for _, config := range manager.configs {
|
||||
status := Status{ID: config.ID, Name: config.Name, Mode: config.Mode, Enabled: config.Enabled, Error: manager.lastError[config.ID]}
|
||||
if listener := manager.listeners[config.ID]; listener != nil {
|
||||
status.Running = true
|
||||
status.Listen = listener.Addr().String()
|
||||
status.StartedAt = manager.started[config.ID]
|
||||
}
|
||||
result = append(result, status)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Create(ctx context.Context, config Config) (Config, error) {
|
||||
config.ID = generateID()
|
||||
if err := manager.prepareConfig(ctx, &config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
if manager.disabled {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, ErrDisabled
|
||||
}
|
||||
if err := manager.checkPortLocked(config, ""); err != nil {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, err
|
||||
}
|
||||
manager.configs = append(manager.configs, config)
|
||||
if err := manager.saveLocked(ctx); err != nil {
|
||||
manager.configs = manager.configs[:len(manager.configs)-1]
|
||||
manager.mu.Unlock()
|
||||
return Config{}, err
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if config.Enabled {
|
||||
if err := manager.start(ctx, config.ID); err != nil {
|
||||
_ = manager.Delete(context.Background(), config.ID)
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
return redact(config), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Update(ctx context.Context, id string, incoming Config) (Config, error) {
|
||||
incoming.ID = strings.TrimSpace(id)
|
||||
manager.mu.Lock()
|
||||
if manager.disabled {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, ErrDisabled
|
||||
}
|
||||
existing, index := manager.configByIDLocked(incoming.ID)
|
||||
manager.mu.Unlock()
|
||||
if index < 0 {
|
||||
return Config{}, ErrNotFound
|
||||
}
|
||||
if incoming.Password == "" || incoming.Password == PasswordMask {
|
||||
incoming.Password = existing.Password
|
||||
}
|
||||
if err := manager.prepareConfig(ctx, &incoming); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
if manager.disabled {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, ErrDisabled
|
||||
}
|
||||
existing, index = manager.configByIDLocked(incoming.ID)
|
||||
if index < 0 {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, ErrNotFound
|
||||
}
|
||||
if err := manager.checkPortLocked(incoming, incoming.ID); err != nil {
|
||||
manager.mu.Unlock()
|
||||
return Config{}, err
|
||||
}
|
||||
wasRunning := manager.listeners[incoming.ID] != nil
|
||||
runtimeChanged := existing.Mode != incoming.Mode || existing.Interface != incoming.Interface ||
|
||||
existing.ListenHost != incoming.ListenHost || existing.ListenPort != incoming.ListenPort ||
|
||||
existing.AuthEnabled != incoming.AuthEnabled || existing.Username != incoming.Username || existing.Password != incoming.Password
|
||||
manager.configs[index] = incoming
|
||||
if err := manager.saveLocked(ctx); err != nil {
|
||||
manager.configs[index] = existing
|
||||
manager.mu.Unlock()
|
||||
return Config{}, err
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case !incoming.Enabled:
|
||||
manager.stop(incoming.ID)
|
||||
case !wasRunning || runtimeChanged || !existing.Enabled:
|
||||
if err := manager.start(ctx, incoming.ID); err != nil {
|
||||
return redact(incoming), err
|
||||
}
|
||||
}
|
||||
return redact(incoming), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Delete(ctx context.Context, id string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.disabled {
|
||||
return ErrDisabled
|
||||
}
|
||||
_, index := manager.configByIDLocked(strings.TrimSpace(id))
|
||||
if index < 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
manager.stopLocked(id)
|
||||
manager.configs = append(manager.configs[:index], manager.configs[index+1:]...)
|
||||
return manager.saveLocked(ctx)
|
||||
}
|
||||
|
||||
// DeleteAllAndDisable is irreversible for the active developer-mode session:
|
||||
// it closes every listener, removes every saved proxy, and rejects new work.
|
||||
func (manager *Manager) DeleteAllAndDisable(ctx context.Context) error {
|
||||
manager.mu.Lock()
|
||||
for id := range manager.listeners {
|
||||
manager.stopLocked(id)
|
||||
}
|
||||
manager.configs = nil
|
||||
manager.disabled = true
|
||||
manager.mu.Unlock()
|
||||
err := manager.store.DeleteAppSetting(ctx, SettingKey)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (manager *Manager) Close() error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
manager.disabled = true
|
||||
for id := range manager.listeners {
|
||||
manager.stopLocked(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) prepareConfig(ctx context.Context, config *Config) error {
|
||||
config.Name = strings.TrimSpace(config.Name)
|
||||
config.DeviceID = strings.TrimSpace(config.DeviceID)
|
||||
config.Interface = strings.TrimSpace(config.Interface)
|
||||
config.Mode = strings.ToLower(strings.TrimSpace(config.Mode))
|
||||
config.ListenHost = strings.TrimSpace(config.ListenHost)
|
||||
config.Username = strings.TrimSpace(config.Username)
|
||||
if config.Name == "" {
|
||||
config.Name = "proxy-" + config.ID[:4]
|
||||
}
|
||||
if config.DeviceID == "" {
|
||||
return errors.New("device is required")
|
||||
}
|
||||
device, err := manager.store.Device(ctx, config.DeviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return errors.New("configured device was not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(device.Interface) == "" {
|
||||
return errors.New("the selected device has no cellular interface")
|
||||
}
|
||||
if config.Interface != "" && config.Interface != device.Interface {
|
||||
return errors.New("proxy interface does not match the selected device")
|
||||
}
|
||||
config.Interface = device.Interface
|
||||
if config.Enabled && !device.NetworkEnabled {
|
||||
return errors.New("enable roaming data on the selected device before starting its export proxy")
|
||||
}
|
||||
if config.Mode != "http" && config.Mode != "socks5" {
|
||||
return errors.New("mode must be http or socks5")
|
||||
}
|
||||
if config.ListenHost == "" {
|
||||
config.ListenHost = "0.0.0.0"
|
||||
}
|
||||
if net.ParseIP(config.ListenHost) == nil && config.ListenHost != "localhost" {
|
||||
return errors.New("listen host must be an IP address")
|
||||
}
|
||||
if config.ListenPort < 0 || config.ListenPort > 65535 {
|
||||
return errors.New("listen port must be between 0 and 65535")
|
||||
}
|
||||
if config.AuthEnabled {
|
||||
if config.Username == "" {
|
||||
return errors.New("username is required when authentication is enabled")
|
||||
}
|
||||
if len(config.Username) > 128 || len(config.Password) > 128 {
|
||||
return errors.New("proxy credentials are too long")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) checkPortLocked(config Config, excludeID string) error {
|
||||
if config.ListenPort == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, current := range manager.configs {
|
||||
if current.ID != excludeID && current.ListenPort == config.ListenPort && current.ListenHost == config.ListenHost {
|
||||
return fmt.Errorf("port %d is already used by another export proxy", config.ListenPort)
|
||||
}
|
||||
}
|
||||
if existing, _ := manager.configByIDLocked(excludeID); excludeID != "" &&
|
||||
existing.ListenHost == config.ListenHost && existing.ListenPort == config.ListenPort {
|
||||
return nil
|
||||
}
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("port %d is already in use", config.ListenPort)
|
||||
}
|
||||
_ = listener.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) start(ctx context.Context, id string) error {
|
||||
manager.mu.Lock()
|
||||
if manager.disabled {
|
||||
manager.mu.Unlock()
|
||||
return ErrDisabled
|
||||
}
|
||||
config, index := manager.configByIDLocked(id)
|
||||
if index < 0 || !config.Enabled {
|
||||
manager.mu.Unlock()
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := platformSupported(); err != nil {
|
||||
manager.lastError[id] = err.Error()
|
||||
manager.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
manager.stopLocked(id)
|
||||
listener, err := net.Listen("tcp", net.JoinHostPort(config.ListenHost, strconv.Itoa(config.ListenPort)))
|
||||
if err != nil {
|
||||
manager.lastError[id] = err.Error()
|
||||
manager.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
if config.ListenPort == 0 {
|
||||
config.ListenPort = listener.Addr().(*net.TCPAddr).Port
|
||||
manager.configs[index] = config
|
||||
if err := manager.saveLocked(ctx); err != nil {
|
||||
_ = listener.Close()
|
||||
manager.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
delete(manager.lastError, id)
|
||||
manager.listeners[id] = listener
|
||||
manager.started[id] = time.Now().UTC()
|
||||
manager.mu.Unlock()
|
||||
go manager.serve(listener, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) stop(id string) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
manager.stopLocked(id)
|
||||
}
|
||||
|
||||
func (manager *Manager) stopLocked(id string) {
|
||||
if listener := manager.listeners[id]; listener != nil {
|
||||
_ = listener.Close()
|
||||
delete(manager.listeners, id)
|
||||
}
|
||||
delete(manager.started, id)
|
||||
}
|
||||
|
||||
func (manager *Manager) serve(listener net.Listener, config Config) {
|
||||
dialer := boundDialer(config.Interface)
|
||||
resolver := boundResolver(config.Interface)
|
||||
for {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(client net.Conn) {
|
||||
defer client.Close()
|
||||
var err error
|
||||
if config.Mode == "http" {
|
||||
err = serveHTTP(client, config, &dialer, resolver)
|
||||
} else {
|
||||
err = serveSOCKS(client, config, &dialer, resolver)
|
||||
}
|
||||
if err != nil {
|
||||
manager.logger.Debug("export proxy connection closed", "id", config.ID, "error", err)
|
||||
}
|
||||
}(connection)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) configByIDLocked(id string) (Config, int) {
|
||||
for index, config := range manager.configs {
|
||||
if config.ID == id {
|
||||
return config, index
|
||||
}
|
||||
}
|
||||
return Config{}, -1
|
||||
}
|
||||
|
||||
func redact(config Config) Config {
|
||||
if config.Password != "" {
|
||||
config.Password = PasswordMask
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
value := make([]byte, 4)
|
||||
_, _ = rand.Read(value)
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestManagerPersistsAndDeletesDisabledConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, slog.New(slog.NewTextHandler(io.Discard, nil)), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID == "" || created.Interface != "wwan0" {
|
||||
t.Fatalf("created = %+v", created)
|
||||
}
|
||||
configs, err := manager.Configs()
|
||||
if err != nil || len(configs) != 1 {
|
||||
t.Fatalf("configs = %+v, %v", configs, err)
|
||||
}
|
||||
if err := manager.DeleteAllAndDisable(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.Configs(); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("Configs after disable = %v", err)
|
||||
}
|
||||
if _, err := database.AppSetting(ctx, SettingKey); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("setting remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRequiresRoamingDataForEnabledProxy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
_, err = manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true})
|
||||
if err == nil {
|
||||
t.Fatal("enabled proxy was accepted while roaming data was disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerEnabledConfigForDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "modem-2", Name: "modem-2", Interface: "wwan1", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := New(ctx, database, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("reported an enabled config before any was created")
|
||||
}
|
||||
// A disabled config bound to modem-1 must not count.
|
||||
if _, err := manager.Create(ctx, Config{DeviceID: "modem-1", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("disabled config counted as enabled")
|
||||
}
|
||||
// An enabled config bound to modem-2 counts only for modem-2. The listener start
|
||||
// is Linux-only, so the config is created disabled and flipped on in memory to
|
||||
// exercise the query without binding a port.
|
||||
created, err := manager.Create(ctx, Config{DeviceID: "modem-2", Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 0, AuthEnabled: true, Username: "u", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.mu.Lock()
|
||||
for index := range manager.configs {
|
||||
if manager.configs[index].ID == created.ID {
|
||||
manager.configs[index].Enabled = true
|
||||
}
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if _, ok := manager.EnabledConfigForDevice("modem-1"); ok {
|
||||
t.Fatal("config bound to another device counted")
|
||||
}
|
||||
found, ok := manager.EnabledConfigForDevice("modem-2")
|
||||
if !ok {
|
||||
t.Fatal("enabled config not found for its device")
|
||||
}
|
||||
if found.Password != PasswordMask {
|
||||
t.Fatalf("password not redacted: %+v", found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func serveHTTP(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
|
||||
reader := bufio.NewReader(client)
|
||||
request, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.AuthEnabled && !httpAuthorized(request, config) {
|
||||
_, _ = client.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"vocat-export-proxy\"\r\n\r\n"))
|
||||
return errors.New("HTTP proxy authentication required")
|
||||
}
|
||||
if request.Method == http.MethodConnect {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
|
||||
target, err := dialTarget(ctx, request.URL.Host, dialer, resolver)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
if buffered := reader.Buffered(); buffered > 0 {
|
||||
if value, err := reader.Peek(buffered); err == nil {
|
||||
_, _ = target.Write(value)
|
||||
_, _ = reader.Discard(buffered)
|
||||
}
|
||||
}
|
||||
pipe(client, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
request.Header.Del("Proxy-Authorization")
|
||||
request.Header.Del("Proxy-Connection")
|
||||
request.RequestURI = ""
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
|
||||
return dialTarget(ctx, address, dialer, resolver)
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
response, err := transport.RoundTrip(request)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprint(client, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return response.Write(client)
|
||||
}
|
||||
|
||||
func httpAuthorized(request *http.Request, config Config) bool {
|
||||
header := strings.TrimSpace(strings.TrimPrefix(request.Header.Get("Proxy-Authorization"), "Basic "))
|
||||
decoded, err := base64.StdEncoding.DecodeString(header)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
return len(parts) == 2 && parts[0] == config.Username && parts[1] == config.Password
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const proxyTimeout = 30 * time.Second
|
||||
|
||||
func dialTarget(ctx context.Context, address string, dialer *net.Dialer, resolver *net.Resolver) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
ips, err := resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
connection, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip.IP.String(), port))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%w: no addresses for %s", errors.ErrUnsupported, host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func pipe(left, right net.Conn) {
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = copyConnection(right, left); done <- struct{}{} }()
|
||||
go func() { _, _ = copyConnection(left, right); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
|
||||
func copyConnection(destination net.Conn, source net.Conn) (int64, error) {
|
||||
written, err := io.CopyBuffer(destination, source, make([]byte, 32*1024))
|
||||
if err == nil && written > 0 {
|
||||
if connection, ok := destination.(interface{ CloseWrite() error }); ok {
|
||||
_ = connection.CloseWrite()
|
||||
}
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package exportproxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func serveSOCKS(client net.Conn, config Config, dialer *net.Dialer, resolver *net.Resolver) error {
|
||||
reader := bufio.NewReader(client)
|
||||
version, err := reader.ReadByte()
|
||||
if err != nil || version != 5 {
|
||||
return errors.New("unsupported SOCKS version")
|
||||
}
|
||||
methodCount, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
methods := make([]byte, methodCount)
|
||||
if _, err := io.ReadFull(reader, methods); err != nil {
|
||||
return err
|
||||
}
|
||||
chosen := byte(0xff)
|
||||
if config.AuthEnabled && hasMethod(methods, 2) {
|
||||
chosen = 2
|
||||
} else if !config.AuthEnabled && hasMethod(methods, 0) {
|
||||
chosen = 0
|
||||
}
|
||||
if _, err := client.Write([]byte{5, chosen}); err != nil || chosen == 0xff {
|
||||
return errors.New("no acceptable SOCKS authentication method")
|
||||
}
|
||||
if chosen == 2 {
|
||||
if err := socksAuthenticate(reader, client, config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != 5 || header[1] != 1 {
|
||||
_ = writeSocksReply(client, 7)
|
||||
return errors.New("only SOCKS5 CONNECT is supported")
|
||||
}
|
||||
host, port, err := readSocksAddress(reader, header[3])
|
||||
if err != nil {
|
||||
_ = writeSocksReply(client, 1)
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), proxyTimeout)
|
||||
target, err := dialTarget(ctx, net.JoinHostPort(host, strconv.Itoa(port)), dialer, resolver)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = writeSocksReply(client, 5)
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
if err := writeSocksReply(client, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if buffered := reader.Buffered(); buffered > 0 {
|
||||
if value, err := reader.Peek(buffered); err == nil {
|
||||
_, _ = target.Write(value)
|
||||
_, _ = reader.Discard(buffered)
|
||||
}
|
||||
}
|
||||
pipe(client, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func socksAuthenticate(reader *bufio.Reader, writer io.Writer, config Config) error {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, header); err != nil || header[0] != 1 {
|
||||
return errors.New("invalid SOCKS authentication request")
|
||||
}
|
||||
username := make([]byte, int(header[1]))
|
||||
if _, err := io.ReadFull(reader, username); err != nil {
|
||||
return err
|
||||
}
|
||||
length, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(reader, password); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(username) != config.Username || string(password) != config.Password {
|
||||
_, _ = writer.Write([]byte{1, 1})
|
||||
return errors.New("SOCKS authentication failed")
|
||||
}
|
||||
_, err = writer.Write([]byte{1, 0})
|
||||
return err
|
||||
}
|
||||
|
||||
func hasMethod(methods []byte, wanted byte) bool {
|
||||
for _, method := range methods {
|
||||
if method == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readSocksAddress(reader *bufio.Reader, kind byte) (string, int, error) {
|
||||
var host string
|
||||
switch kind {
|
||||
case 1:
|
||||
value := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = net.IP(value).String()
|
||||
case 3:
|
||||
length, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
value := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = string(value)
|
||||
case 4:
|
||||
value := make([]byte, 16)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
host = net.IP(value).String()
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unsupported SOCKS address type %d", kind)
|
||||
}
|
||||
value := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, int(binary.BigEndian.Uint16(value)), nil
|
||||
}
|
||||
|
||||
func writeSocksReply(connection net.Conn, code byte) error {
|
||||
_, err := connection.Write([]byte{5, code, 0, 1, 0, 0, 0, 0, 0, 0})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/netguard"
|
||||
)
|
||||
|
||||
const maxPackageBytes int64 = 64 << 20
|
||||
|
||||
// This syntactic guard gives the request boundary an explicit allowlist. The
|
||||
// resolved addresses are still checked again by netguard before dialing.
|
||||
var publicHTTPSURLPattern = regexp.MustCompile(`^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:[/?#][^\r\n]*)?$`)
|
||||
|
||||
type Plugin struct {
|
||||
Manifest
|
||||
Enabled bool `json:"enabled"`
|
||||
BackendAvailable bool `json:"backend_available"`
|
||||
BackendRunning bool `json:"backend_running"`
|
||||
BackendError string `json:"backend_error,omitempty"`
|
||||
InstalledAt string `json:"installed_at"`
|
||||
SHA256 string `json:"sha256"`
|
||||
|
||||
dir string
|
||||
command *exec.Cmd
|
||||
backend *url.URL
|
||||
installed time.Time
|
||||
}
|
||||
|
||||
type stateFile struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
root string
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
plugins map[string]*Plugin
|
||||
}
|
||||
|
||||
func NewManager(root string, logger *slog.Logger) (*Manager, error) {
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve plugin directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create plugin directory: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
manager := &Manager{
|
||||
root: root, logger: logger, plugins: make(map[string]*Plugin),
|
||||
client: netguard.NewPublicHTTPClient(45*time.Second, true),
|
||||
}
|
||||
if err := manager.scan(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) scan() error {
|
||||
entries, err := os.ReadDir(manager.root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !pluginIDPattern.MatchString(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(manager.root, entry.Name())
|
||||
plugin, err := loadPlugin(dir)
|
||||
if err != nil {
|
||||
manager.logger.Warn("skip invalid plugin", "directory", dir, "error", err)
|
||||
continue
|
||||
}
|
||||
if plugin.ID == exportproxy.ReservedID {
|
||||
manager.logger.Info("skip legacy Export Proxy plugin; functionality is built in", "directory", dir)
|
||||
continue
|
||||
}
|
||||
manager.plugins[plugin.ID] = plugin
|
||||
if plugin.Enabled {
|
||||
manager.startLocked(plugin)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadPlugin(dir string) (*Plugin, error) {
|
||||
file, err := os.Open(filepath.Join(dir, ManifestFilename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifest, err := DecodeManifest(file)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Base(dir) != manifest.ID {
|
||||
return nil, errors.New("plugin directory does not match manifest id")
|
||||
}
|
||||
var state stateFile
|
||||
stateData, err := os.ReadFile(filepath.Join(dir, ".vocat-state.json"))
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(stateData, &state); err != nil {
|
||||
return nil, fmt.Errorf("decode plugin state: %w", err)
|
||||
}
|
||||
}
|
||||
command, available := manifest.BackendCommand()
|
||||
if available {
|
||||
_, err = os.Stat(filepath.Join(dir, filepath.FromSlash(command)))
|
||||
available = err == nil
|
||||
}
|
||||
return &Plugin{
|
||||
Manifest: manifest, Enabled: state.Enabled, BackendAvailable: available,
|
||||
InstalledAt: state.InstalledAt.UTC().Format(time.RFC3339), SHA256: state.SHA256,
|
||||
dir: dir, installed: state.InstalledAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) List() []Plugin {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
result := make([]Plugin, 0, len(manager.plugins))
|
||||
for _, plugin := range manager.plugins {
|
||||
copy := *plugin
|
||||
copy.command = nil
|
||||
copy.backend = nil
|
||||
result = append(result, copy)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
return result
|
||||
}
|
||||
|
||||
func (manager *Manager) InstallURL(ctx context.Context, rawURL, expectedSHA string) (Plugin, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if !publicHTTPSURLPattern.MatchString(rawURL) {
|
||||
return Plugin{}, errors.New("plugin URL must be a public absolute HTTPS URL")
|
||||
}
|
||||
parsed, err := netguard.ValidatePublicURL(ctx, rawURL, true)
|
||||
if err != nil {
|
||||
return Plugin{}, fmt.Errorf("plugin URL must be a public absolute HTTPS URL: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
response, err := manager.client.Do(request)
|
||||
if err != nil {
|
||||
return Plugin{}, fmt.Errorf("download plugin: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return Plugin{}, fmt.Errorf("download plugin: HTTP %d", response.StatusCode)
|
||||
}
|
||||
return manager.Install(response.Body, expectedSHA)
|
||||
}
|
||||
|
||||
func (manager *Manager) Install(reader io.Reader, expectedSHA string) (Plugin, error) {
|
||||
temp, err := os.CreateTemp(manager.root, ".upload-*.vocat-plugin")
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
hash := sha256.New()
|
||||
written, copyErr := io.Copy(io.MultiWriter(temp, hash), io.LimitReader(reader, maxPackageBytes+1))
|
||||
closeErr := temp.Close()
|
||||
if copyErr != nil {
|
||||
return Plugin{}, copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Plugin{}, closeErr
|
||||
}
|
||||
if written > maxPackageBytes {
|
||||
return Plugin{}, fmt.Errorf("plugin package exceeds %d MiB", maxPackageBytes>>20)
|
||||
}
|
||||
actualSHA := hex.EncodeToString(hash.Sum(nil))
|
||||
if expected := strings.ToLower(strings.TrimSpace(expectedSHA)); expected != "" && expected != actualSHA {
|
||||
return Plugin{}, errors.New("plugin package SHA-256 does not match")
|
||||
}
|
||||
|
||||
archive, err := zip.OpenReader(tempName)
|
||||
if err != nil {
|
||||
return Plugin{}, errors.New("plugin package must be a ZIP archive")
|
||||
}
|
||||
defer archive.Close()
|
||||
manifest, err := manifestFromArchive(archive.File)
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
if manifest.ID == exportproxy.ReservedID {
|
||||
return Plugin{}, errors.New("plugin ID export-proxy is reserved by the built-in Export Proxy feature")
|
||||
}
|
||||
staging, err := os.MkdirTemp(manager.root, ".install-"+manifest.ID+"-")
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
if err := extractArchive(archive.File, staging); err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
installedAt := time.Now().UTC()
|
||||
state := stateFile{Enabled: true, InstalledAt: installedAt, SHA256: actualSHA}
|
||||
if err := writeState(staging, state); err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
target := filepath.Join(manager.root, manifest.ID)
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if _, exists := manager.plugins[manifest.ID]; exists {
|
||||
return Plugin{}, fmt.Errorf("plugin %q is already installed; uninstall it before replacing", manifest.ID)
|
||||
}
|
||||
if err := os.Rename(staging, target); err != nil {
|
||||
return Plugin{}, fmt.Errorf("activate plugin: %w", err)
|
||||
}
|
||||
plugin, err := loadPlugin(target)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(target)
|
||||
return Plugin{}, err
|
||||
}
|
||||
manager.plugins[plugin.ID] = plugin
|
||||
manager.startLocked(plugin)
|
||||
return publicPlugin(plugin), nil
|
||||
}
|
||||
|
||||
func manifestFromArchive(files []*zip.File) (Manifest, error) {
|
||||
for _, file := range files {
|
||||
name := strings.ReplaceAll(file.Name, `\`, "/")
|
||||
if name != ManifestFilename {
|
||||
continue
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
manifest, decodeErr := DecodeManifest(reader)
|
||||
_ = reader.Close()
|
||||
return manifest, decodeErr
|
||||
}
|
||||
return Manifest{}, fmt.Errorf("plugin package is missing root %s", ManifestFilename)
|
||||
}
|
||||
|
||||
func extractArchive(files []*zip.File, staging string) error {
|
||||
var expanded int64
|
||||
for _, file := range files {
|
||||
name := strings.ReplaceAll(file.Name, `\`, "/")
|
||||
if strings.HasSuffix(name, "/") {
|
||||
continue
|
||||
}
|
||||
if !safeRelativePath(name) || file.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("plugin package contains unsafe path %q", file.Name)
|
||||
}
|
||||
expanded += int64(file.UncompressedSize64)
|
||||
if expanded > maxPackageBytes*4 {
|
||||
return errors.New("expanded plugin package is too large")
|
||||
}
|
||||
target := filepath.Join(staging, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(0o640)
|
||||
if file.Mode()&0o111 != 0 {
|
||||
mode = 0o750
|
||||
}
|
||||
output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||
if err == nil {
|
||||
_, err = io.Copy(output, input)
|
||||
_ = output.Close()
|
||||
}
|
||||
_ = input.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeState(dir string, state stateFile) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, ".vocat-state.json"), data, 0o600)
|
||||
}
|
||||
|
||||
func (manager *Manager) SetEnabled(id string, enabled bool) (Plugin, error) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
plugin := manager.plugins[id]
|
||||
if plugin == nil {
|
||||
return Plugin{}, os.ErrNotExist
|
||||
}
|
||||
if plugin.Enabled == enabled {
|
||||
return publicPlugin(plugin), nil
|
||||
}
|
||||
plugin.Enabled = enabled
|
||||
if err := writeState(plugin.dir, stateFile{Enabled: enabled, InstalledAt: plugin.installed, SHA256: plugin.SHA256}); err != nil {
|
||||
plugin.Enabled = !enabled
|
||||
return Plugin{}, err
|
||||
}
|
||||
if enabled {
|
||||
manager.startLocked(plugin)
|
||||
} else {
|
||||
manager.stopLocked(plugin)
|
||||
}
|
||||
return publicPlugin(plugin), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Uninstall(id string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
plugin := manager.plugins[id]
|
||||
if plugin == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
manager.stopLocked(plugin)
|
||||
delete(manager.plugins, id)
|
||||
clean := filepath.Clean(plugin.dir)
|
||||
if filepath.Dir(clean) != filepath.Clean(manager.root) || filepath.Base(clean) != id {
|
||||
return errors.New("refusing to remove plugin outside plugin directory")
|
||||
}
|
||||
return os.RemoveAll(clean)
|
||||
}
|
||||
|
||||
func (manager *Manager) ServeAsset(w http.ResponseWriter, r *http.Request, id, name string) {
|
||||
manager.mu.RLock()
|
||||
plugin := manager.plugins[id]
|
||||
manager.mu.RUnlock()
|
||||
if plugin == nil || !plugin.Enabled || !safeRelativePath(name) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
root, err := os.OpenRoot(plugin.dir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.Open(filepath.FromSlash(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(filepath.Ext(name))
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (manager *Manager) ProxyBackend(w http.ResponseWriter, r *http.Request, id string) {
|
||||
manager.mu.RLock()
|
||||
plugin := manager.plugins[id]
|
||||
var target *url.URL
|
||||
if plugin != nil && plugin.Enabled && plugin.BackendRunning && plugin.backend != nil {
|
||||
copy := *plugin.backend
|
||||
target = ©
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
if target == nil {
|
||||
http.Error(w, "plugin backend is not running", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(request *http.Request) {
|
||||
originalDirector(request)
|
||||
request.Header.Del("Cookie")
|
||||
request.Header.Del("X-CSRF-Token")
|
||||
request.Header.Del("Authorization")
|
||||
request.Header.Set("X-VoCat-Plugin-ID", id)
|
||||
}
|
||||
proxy.ModifyResponse = func(response *http.Response) error {
|
||||
response.Header.Del("Set-Cookie")
|
||||
return nil
|
||||
}
|
||||
prefix := "/api/extensions/" + id + "/backend"
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if r.URL.Path == "" {
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
manager.logger.Warn("plugin backend proxy failed", "plugin", id, "error", err)
|
||||
http.Error(w, "plugin backend is unavailable", http.StatusBadGateway)
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (manager *Manager) startLocked(plugin *Plugin) {
|
||||
commandPath, supported := plugin.BackendCommand()
|
||||
if !supported {
|
||||
if plugin.Backend != nil {
|
||||
plugin.BackendError = "backend does not support " + runtime.GOOS + "/" + runtime.GOARCH
|
||||
}
|
||||
return
|
||||
}
|
||||
fullCommand := filepath.Join(plugin.dir, filepath.FromSlash(commandPath))
|
||||
if _, err := os.Stat(fullCommand); err != nil {
|
||||
plugin.BackendError = "backend executable is missing"
|
||||
return
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(fullCommand, 0o750); err != nil {
|
||||
plugin.BackendError = "make backend executable: " + err.Error()
|
||||
return
|
||||
}
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
plugin.BackendError = err.Error()
|
||||
return
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
dataDir := filepath.Join(plugin.dir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
plugin.BackendError = err.Error()
|
||||
return
|
||||
}
|
||||
command := exec.Command(fullCommand)
|
||||
command.Dir = plugin.dir
|
||||
command.Env = append(os.Environ(),
|
||||
"VOCAT_PLUGIN_ID="+plugin.ID,
|
||||
"VOCAT_PLUGIN_LISTEN="+address,
|
||||
"VOCAT_PLUGIN_DATA_DIR="+dataDir,
|
||||
)
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
plugin.BackendError = err.Error()
|
||||
return
|
||||
}
|
||||
command.Stderr = command.Stdout
|
||||
if err := command.Start(); err != nil {
|
||||
plugin.BackendError = err.Error()
|
||||
return
|
||||
}
|
||||
plugin.command = command
|
||||
plugin.backend = &url.URL{Scheme: "http", Host: address}
|
||||
plugin.BackendRunning = true
|
||||
plugin.BackendError = ""
|
||||
go manager.captureOutput(plugin.ID, stdout)
|
||||
go manager.waitProcess(plugin.ID, command)
|
||||
}
|
||||
|
||||
func (manager *Manager) captureOutput(id string, reader io.Reader) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
manager.logger.Info("plugin output", "plugin", id, "message", scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) waitProcess(id string, command *exec.Cmd) {
|
||||
err := command.Wait()
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
plugin := manager.plugins[id]
|
||||
if plugin == nil || plugin.command != command {
|
||||
return
|
||||
}
|
||||
plugin.command = nil
|
||||
plugin.backend = nil
|
||||
plugin.BackendRunning = false
|
||||
if err != nil && plugin.Enabled {
|
||||
plugin.BackendError = err.Error()
|
||||
manager.logger.Warn("plugin backend exited", "plugin", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) stopLocked(plugin *Plugin) {
|
||||
command := plugin.command
|
||||
plugin.command = nil
|
||||
plugin.backend = nil
|
||||
plugin.BackendRunning = false
|
||||
if command != nil && command.Process != nil {
|
||||
_ = command.Process.Signal(os.Interrupt)
|
||||
go func() {
|
||||
timer := time.NewTimer(3 * time.Second)
|
||||
defer timer.Stop()
|
||||
<-timer.C
|
||||
_ = command.Process.Kill()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) Close() {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
for _, plugin := range manager.plugins {
|
||||
manager.stopLocked(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
func publicPlugin(plugin *Plugin) Plugin {
|
||||
copy := *plugin
|
||||
copy.command = nil
|
||||
copy.backend = nil
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallURLRejectsNonHTTPSAndPrivateDestinations(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
for _, raw := range []string{
|
||||
"http://example.com/plugin.zip",
|
||||
"https://[email protected]/plugin.zip",
|
||||
"https://example.com/plugin.zip\r\nX-Injected: yes",
|
||||
"https://127.0.0.1/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
} {
|
||||
if _, err := manager.InstallURL(context.Background(), raw, ""); err == nil {
|
||||
t.Errorf("InstallURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallListDisableAndUninstall(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
archive := testPackage(t, map[string]string{
|
||||
ManifestFilename: `{
|
||||
"schema_version":1,"id":"test-plugin","name":"Test","version":"1.0.0",
|
||||
"permissions":["devices.read"],
|
||||
"contributions":[{"id":"test-page","label":"Test","location":"sidebar","entry":"web/index.html"}]
|
||||
}`,
|
||||
"web/index.html": "<h1>test</h1>",
|
||||
})
|
||||
plugin, err := manager.Install(bytes.NewReader(archive), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plugin.ID != "test-plugin" || !plugin.Enabled || len(manager.List()) != 1 {
|
||||
t.Fatalf("unexpected installed plugin: %#v", plugin)
|
||||
}
|
||||
if _, err := manager.SetEnabled(plugin.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manager.List()[0].Enabled {
|
||||
t.Fatal("plugin remained enabled")
|
||||
}
|
||||
if err := manager.Uninstall(plugin.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manager.List()) != 0 {
|
||||
t.Fatal("plugin remained installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRejectsPathTraversal(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
archive := testPackage(t, map[string]string{
|
||||
ManifestFilename: `{
|
||||
"schema_version":1,"id":"bad-plugin","name":"Bad","version":"1",
|
||||
"contributions":[{"id":"bad-page","label":"Bad","location":"sidebar","entry":"web/index.html"}]
|
||||
}`,
|
||||
"../escaped": "bad",
|
||||
})
|
||||
if _, err := manager.Install(bytes.NewReader(archive), ""); err == nil || !strings.Contains(err.Error(), "unsafe path") {
|
||||
t.Fatalf("Install traversal error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallVerifiesSHA256(t *testing.T) {
|
||||
manager, err := NewManager(t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
archive := testPackage(t, map[string]string{
|
||||
ManifestFilename: `{
|
||||
"schema_version":1,"id":"hash-plugin","name":"Hash","version":"1",
|
||||
"contributions":[{"id":"hash-page","label":"Hash","location":"sidebar","entry":"web/index.html"}]
|
||||
}`,
|
||||
"web/index.html": "ok",
|
||||
})
|
||||
if _, err := manager.Install(bytes.NewReader(archive), strings.Repeat("0", 64)); err == nil {
|
||||
t.Fatal("Install accepted incorrect SHA-256")
|
||||
}
|
||||
}
|
||||
|
||||
func testPackage(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var output bytes.Buffer
|
||||
writer := zip.NewWriter(&output)
|
||||
for name, content := range files {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ManifestFilename = "vocat-plugin.json"
|
||||
SchemaVersion = 1
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}[a-z0-9]$`)
|
||||
|
||||
type Contribution struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
LabelZH string `json:"label_zh,omitempty"`
|
||||
Location string `json:"location"`
|
||||
After string `json:"after,omitempty"`
|
||||
Entry string `json:"entry"`
|
||||
}
|
||||
|
||||
type Backend struct {
|
||||
Commands map[string]string `json:"commands,omitempty"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
Contributions []Contribution `json:"contributions"`
|
||||
Backend *Backend `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
func DecodeManifest(reader io.Reader) (Manifest, error) {
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, 256<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest Manifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return Manifest{}, fmt.Errorf("decode %s: %w", ManifestFilename, err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Manifest{}, errors.New("plugin manifest must contain one JSON object")
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func (manifest Manifest) Validate() error {
|
||||
if manifest.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported plugin schema_version %d", manifest.SchemaVersion)
|
||||
}
|
||||
if !pluginIDPattern.MatchString(manifest.ID) {
|
||||
return errors.New("plugin id must be 3-64 lowercase letters, digits, or hyphens")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Name) == "" || len(manifest.Name) > 100 {
|
||||
return errors.New("plugin name is required and must not exceed 100 characters")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Version) == "" || len(manifest.Version) > 64 {
|
||||
return errors.New("plugin version is required and must not exceed 64 characters")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(manifest.Contributions))
|
||||
for _, contribution := range manifest.Contributions {
|
||||
if !pluginIDPattern.MatchString(contribution.ID) {
|
||||
return fmt.Errorf("invalid contribution id %q", contribution.ID)
|
||||
}
|
||||
if _, duplicate := seen[contribution.ID]; duplicate {
|
||||
return fmt.Errorf("duplicate contribution id %q", contribution.ID)
|
||||
}
|
||||
seen[contribution.ID] = struct{}{}
|
||||
if contribution.Location != "sidebar" && contribution.Location != "proxy" {
|
||||
return fmt.Errorf("contribution %q has unsupported location %q", contribution.ID, contribution.Location)
|
||||
}
|
||||
if strings.TrimSpace(contribution.Label) == "" {
|
||||
return fmt.Errorf("contribution %q requires a label", contribution.ID)
|
||||
}
|
||||
if !safeRelativePath(contribution.Entry) {
|
||||
return fmt.Errorf("contribution %q has an unsafe entry path", contribution.ID)
|
||||
}
|
||||
}
|
||||
if manifest.Backend != nil {
|
||||
if len(manifest.Backend.Commands) == 0 {
|
||||
return errors.New("plugin backend commands are empty")
|
||||
}
|
||||
for platform, command := range manifest.Backend.Commands {
|
||||
if !strings.Contains(platform, "/") || !safeRelativePath(command) {
|
||||
return fmt.Errorf("plugin backend command for %q is invalid", platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions := append([]string(nil), manifest.Permissions...)
|
||||
sort.Strings(permissions)
|
||||
for index, permission := range permissions {
|
||||
if strings.TrimSpace(permission) == "" || (index > 0 && permission == permissions[index-1]) {
|
||||
return errors.New("plugin permissions must be non-empty and unique")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manifest Manifest) BackendCommand() (string, bool) {
|
||||
if manifest.Backend == nil {
|
||||
return "", false
|
||||
}
|
||||
command, ok := manifest.Backend.Commands[runtime.GOOS+"/"+runtime.GOARCH]
|
||||
return command, ok
|
||||
}
|
||||
|
||||
func safeRelativePath(value string) bool {
|
||||
value = strings.ReplaceAll(strings.TrimSpace(value), `\`, "/")
|
||||
if value == "" || strings.HasPrefix(value, "/") || strings.Contains(value, ":") {
|
||||
return false
|
||||
}
|
||||
for _, segment := range strings.Split(value, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpsmode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func (conn *bufferedConn) Read(buffer []byte) (int, error) { return conn.reader.Read(buffer) }
|
||||
|
||||
type channelListener struct {
|
||||
address net.Addr
|
||||
conns chan net.Conn
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (listener *channelListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case conn := <-listener.conns:
|
||||
if conn == nil {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return conn, nil
|
||||
case <-listener.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
func (listener *channelListener) Close() error { return nil }
|
||||
func (listener *channelListener) Addr() net.Addr { return listener.address }
|
||||
|
||||
type Multiplexer struct {
|
||||
base net.Listener
|
||||
manager *Manager
|
||||
plain *channelListener
|
||||
tls *channelListener
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewMultiplexer(base net.Listener, manager *Manager) *Multiplexer {
|
||||
done := make(chan struct{})
|
||||
mux := &Multiplexer{
|
||||
base: base, manager: manager, done: done,
|
||||
plain: &channelListener{address: base.Addr(), conns: make(chan net.Conn, 64), done: done},
|
||||
tls: &channelListener{address: base.Addr(), conns: make(chan net.Conn, 64), done: done},
|
||||
}
|
||||
go mux.accept()
|
||||
return mux
|
||||
}
|
||||
|
||||
func (mux *Multiplexer) Plain() net.Listener { return mux.plain }
|
||||
func (mux *Multiplexer) TLS() net.Listener { return mux.tls }
|
||||
|
||||
func (mux *Multiplexer) Close() error {
|
||||
var err error
|
||||
mux.closeOnce.Do(func() {
|
||||
close(mux.done)
|
||||
err = mux.base.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (mux *Multiplexer) accept() {
|
||||
for {
|
||||
conn, err := mux.base.Accept()
|
||||
if err != nil {
|
||||
if !errors.Is(err, net.ErrClosed) {
|
||||
_ = mux.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
go mux.classify(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (mux *Multiplexer) classify(conn net.Conn) {
|
||||
reader := bufio.NewReaderSize(conn, 4096)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
first, err := reader.Peek(1)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
wrapped := &bufferedConn{Conn: conn, reader: reader}
|
||||
listener := mux.plain
|
||||
if first[0] == 0x16 {
|
||||
if !mux.manager.Enabled() {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
listener = mux.tls
|
||||
}
|
||||
select {
|
||||
case listener.conns <- wrapped:
|
||||
case <-mux.done:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package httpsmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const SettingKey = "transport.self_signed_https"
|
||||
|
||||
type State struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
HTTPURL string `json:"http_url"`
|
||||
HTTPSURL string `json:"https_url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
dir string
|
||||
address string
|
||||
enabled atomic.Bool
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
}
|
||||
|
||||
func New(ctx context.Context, database *store.Store, dir, address string) (*Manager, error) {
|
||||
manager := &Manager{store: database, dir: dir, address: address}
|
||||
setting, err := database.AppSetting(ctx, SettingKey)
|
||||
if err == nil {
|
||||
var document struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) == nil && document.Enabled {
|
||||
if err := manager.ensureCertificate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.enabled.Store(true)
|
||||
}
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Enabled() bool { return manager != nil && manager.enabled.Load() }
|
||||
|
||||
func (manager *Manager) SetEnabled(ctx context.Context, enabled bool) (State, error) {
|
||||
if enabled {
|
||||
if err := manager.ensureCertificate(); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(map[string]bool{"enabled": enabled})
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if err := manager.store.UpsertAppSetting(ctx, store.AppSetting{Key: SettingKey, Value: raw}); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
manager.enabled.Store(enabled)
|
||||
return manager.State(""), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) State(host string) State {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
host = manager.address
|
||||
}
|
||||
state := State{
|
||||
Enabled: manager.Enabled(),
|
||||
HTTPURL: "http://" + host,
|
||||
HTTPSURL: "https://" + host,
|
||||
}
|
||||
manager.mu.RLock()
|
||||
if manager.cert != nil && manager.cert.Leaf != nil {
|
||||
digest := sha256.Sum256(manager.cert.Leaf.Raw)
|
||||
encoded := strings.ToUpper(hex.EncodeToString(digest[:]))
|
||||
parts := make([]string, 0, len(encoded)/2)
|
||||
for len(encoded) >= 2 {
|
||||
parts = append(parts, encoded[:2])
|
||||
encoded = encoded[2:]
|
||||
}
|
||||
state.Fingerprint = strings.Join(parts, ":")
|
||||
state.NotAfter = manager.cert.Leaf.NotAfter
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
return state
|
||||
}
|
||||
|
||||
func (manager *Manager) TLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
if manager.cert == nil {
|
||||
return nil, errors.New("self-signed certificate is unavailable")
|
||||
}
|
||||
return manager.cert, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) CertificatePEM() ([]byte, error) {
|
||||
if err := manager.ensureCertificate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(filepath.Join(manager.dir, "selfsigned.crt"))
|
||||
}
|
||||
|
||||
func (manager *Manager) ensureCertificate() error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.cert != nil && manager.cert.Leaf != nil && time.Until(manager.cert.Leaf.NotAfter) > 30*24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(manager.dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create TLS directory: %w", err)
|
||||
}
|
||||
certPath := filepath.Join(manager.dir, "selfsigned.crt")
|
||||
keyPath := filepath.Join(manager.dir, "selfsigned.key")
|
||||
if cert, err := loadCertificate(certPath, keyPath); err == nil && time.Until(cert.Leaf.NotAfter) > 30*24*time.Hour {
|
||||
manager.cert = cert
|
||||
return nil
|
||||
}
|
||||
certPEM, keyPEM, err := generateCertificate(manager.address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePrivateFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePrivateFile(certPath, certPEM, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
cert, err := loadCertificate(certPath, keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.cert = cert
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCertificate(certPath, keyPath string) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cert.Certificate) == 0 {
|
||||
return nil, errors.New("certificate chain is empty")
|
||||
}
|
||||
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
func generateCertificate(address string) ([]byte, []byte, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serial, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "VoCat self-signed local certificate", Organization: []string{"VoCat"}},
|
||||
NotBefore: now.Add(-5 * time.Minute), NotAfter: now.AddDate(5, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
if hostname, hostnameErr := os.Hostname(); hostnameErr == nil && strings.TrimSpace(hostname) != "" {
|
||||
template.DNSNames = append(template.DNSNames, strings.TrimSpace(hostname))
|
||||
}
|
||||
if host, _, splitErr := net.SplitHostPort(address); splitErr == nil {
|
||||
if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() {
|
||||
template.IPAddresses = append(template.IPAddresses, ip)
|
||||
} else if host != "" && host != "0.0.0.0" && host != "::" {
|
||||
template.DNSNames = append(template.DNSNames, host)
|
||||
}
|
||||
}
|
||||
if interfaces, interfaceErr := net.InterfaceAddrs(); interfaceErr == nil {
|
||||
for _, item := range interfaces {
|
||||
text := item.String()
|
||||
if slash := strings.IndexByte(text, '/'); slash >= 0 {
|
||||
text = text[:slash]
|
||||
}
|
||||
if ip := net.ParseIP(strings.TrimSpace(text)); ip != nil && !ip.IsUnspecified() {
|
||||
template.IPAddresses = append(template.IPAddresses, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), nil
|
||||
}
|
||||
|
||||
func writePrivateFile(path string, data []byte, mode os.FileMode) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".tls-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
if err := temp.Chmod(mode); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempName, path)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package httpsmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestManagerPersistsToggleAndCertificate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
manager, err := New(ctx, database, filepath.Join(dir, "tls"), "0.0.0.0:7575")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := manager.SetEnabled(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Enabled || state.Fingerprint == "" || state.NotAfter.IsZero() {
|
||||
t.Fatalf("enabled state = %#v", state)
|
||||
}
|
||||
certificate, err := manager.CertificatePEM()
|
||||
if err != nil || len(certificate) == 0 {
|
||||
t.Fatalf("certificate = %d bytes, %v", len(certificate), err)
|
||||
}
|
||||
reloaded, err := New(ctx, database, filepath.Join(dir, "tls"), "0.0.0.0:7575")
|
||||
if err != nil || !reloaded.Enabled() {
|
||||
t.Fatalf("reloaded manager enabled=%v error=%v", reloaded.Enabled(), err)
|
||||
}
|
||||
if _, err := reloaded.SetEnabled(ctx, false); err != nil || reloaded.Enabled() {
|
||||
t.Fatalf("disable enabled=%v error=%v", reloaded.Enabled(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplexerRoutesPlainAndTLS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
database, err := store.Open(ctx, filepath.Join(dir, "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
manager, err := New(ctx, database, filepath.Join(dir, "tls"), "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.SetEnabled(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := NewMultiplexer(base, manager)
|
||||
defer mux.Close()
|
||||
|
||||
plainClient, err := net.Dial("tcp", base.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer plainClient.Close()
|
||||
if _, err := plainClient.Write([]byte("GET / HTTP/1.1\r\nHost: local\r\n\r\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plainServer, err := mux.Plain().Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer plainServer.Close()
|
||||
|
||||
tlsResult := make(chan error, 1)
|
||||
go func() {
|
||||
serverConn, acceptErr := mux.TLS().Accept()
|
||||
if acceptErr != nil {
|
||||
tlsResult <- acceptErr
|
||||
return
|
||||
}
|
||||
defer serverConn.Close()
|
||||
tlsResult <- tls.Server(serverConn, manager.TLSConfig()).Handshake()
|
||||
}()
|
||||
tlsClient, err := tls.Dial("tcp", base.Addr().String(), &tls.Config{InsecureSkipVerify: true}) // test-only local certificate
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = tlsClient.Close()
|
||||
if err := <-tlsResult; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ var zhToEn = map[string]string{
|
||||
// ---- devices ----
|
||||
"设备数量已达上限,最多只能添加 %d 台设备": "Device limit reached; at most %d devices can be added.",
|
||||
"SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi": "The SIM's home region is %s (MCC %s); this service does not provide data, SMS, or VoWiFi to cards from that region.",
|
||||
"请先禁用该设备已绑定的导出代理,再关闭漫游数据": "Disable the export proxy bound to this device before turning off roaming data.",
|
||||
|
||||
// ---- settings / update ----
|
||||
"未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "No trusted update source is configured; no files will be downloaded or executed from unknown addresses.",
|
||||
|
||||
+44
-22
@@ -127,6 +127,7 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
}
|
||||
return left.Name < right.Name
|
||||
})
|
||||
assignQuectelPortRoles(state.candidate.Ports)
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
result = append(result, state.candidate)
|
||||
}
|
||||
@@ -220,7 +221,13 @@ func readSerialAliases(root string) map[string]string {
|
||||
func candidateID(productID, serialNumber, usbName string) string {
|
||||
serialNumber = strings.TrimSpace(serialNumber)
|
||||
if serialNumber != "" && !strings.EqualFold(serialNumber, "android") {
|
||||
return "quectel-" + sanitizeID(serialNumber)
|
||||
// A surprising number of EC20/EC25 carrier boards expose the same
|
||||
// factory/default USB serial number. The device manager is keyed by this
|
||||
// value, so using the serial alone silently collapsed two modems connected
|
||||
// to the same hub into one entry. Include the physical USB topology in the
|
||||
// discovery key; configured devices remain stable through ATMapper's
|
||||
// USB-path/IMEI matching even when Linux renumbers ttyUSB nodes.
|
||||
return "quectel-" + sanitizeID(serialNumber+"-"+usbName)
|
||||
}
|
||||
return "quectel-" + sanitizeID(productID+"-"+usbName)
|
||||
}
|
||||
@@ -240,30 +247,45 @@ func sanitizeID(value string) string {
|
||||
return strings.Trim(result.String(), "-")
|
||||
}
|
||||
|
||||
func quecPortRole(interfaceNumber int, name string) PortRole {
|
||||
// Quectel exposes the same logical ports under more than one USB
|
||||
// composition. In both layouts seen on EC20/EC25 hardware the kernel
|
||||
// stable tty name is the stronger hint: ttyUSB0 is diagnostic and
|
||||
// ttyUSB2 is the primary AT port, even when their interface numbers are
|
||||
// 00/02 instead of 02/04.
|
||||
switch name {
|
||||
case "ttyUSB0":
|
||||
return PortRoleDiagnostic
|
||||
case "ttyUSB1":
|
||||
return PortRoleNMEA
|
||||
case "ttyUSB2":
|
||||
return PortRoleAT
|
||||
case "ttyUSB3":
|
||||
return PortRoleModem
|
||||
func assignQuectelPortRoles(ports []Port) {
|
||||
// ttyUSB numbers are allocated globally by Linux. A second modem therefore
|
||||
// commonly exposes ttyUSB4..ttyUSB7, so absolute tty names cannot identify
|
||||
// the logical AT port. Infer the Quectel composition once per physical USB
|
||||
// device and assign roles from that device's interface numbers.
|
||||
base := 0x02
|
||||
for _, port := range ports {
|
||||
if port.InterfaceNumber <= 0x01 {
|
||||
base = 0x00
|
||||
break
|
||||
}
|
||||
}
|
||||
for index := range ports {
|
||||
switch ports[index].InterfaceNumber - base {
|
||||
case 0:
|
||||
ports[index].Role = PortRoleDiagnostic
|
||||
case 1:
|
||||
ports[index].Role = PortRoleNMEA
|
||||
case 2:
|
||||
ports[index].Role = PortRoleAT
|
||||
case 3:
|
||||
ports[index].Role = PortRoleModem
|
||||
default:
|
||||
ports[index].Role = PortRoleUnknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quecPortRole(interfaceNumber int, name string) PortRole {
|
||||
// Initial best effort. assignQuectelPortRoles replaces this once every
|
||||
// interface belonging to the same physical modem has been collected.
|
||||
switch interfaceNumber {
|
||||
case 0x02:
|
||||
case 0x00:
|
||||
return PortRoleDiagnostic
|
||||
case 0x03:
|
||||
case 0x01:
|
||||
return PortRoleNMEA
|
||||
case 0x04:
|
||||
case 0x02:
|
||||
return PortRoleAT
|
||||
case 0x05:
|
||||
case 0x03:
|
||||
return PortRoleModem
|
||||
default:
|
||||
if name == "ttyUSB2" {
|
||||
@@ -279,9 +301,9 @@ func selectATPort(ports []Port) Port {
|
||||
for _, port := range ports {
|
||||
score := 0
|
||||
switch {
|
||||
case port.Name == "ttyUSB2":
|
||||
score = 120
|
||||
case port.Role == PortRoleAT:
|
||||
score = 120
|
||||
case port.Name == "ttyUSB2":
|
||||
score = 100
|
||||
case port.InterfaceNumber == 0x04:
|
||||
score = 90
|
||||
|
||||
@@ -123,6 +123,99 @@ func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoverySelectsATPortForSecondQMIUSBModem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
|
||||
for _, modem := range []struct {
|
||||
usbName string
|
||||
ttys []string
|
||||
wdm string
|
||||
}{
|
||||
{"1-6", []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"}, "cdc-wdm0"},
|
||||
{"1-5", []string{"ttyUSB4", "ttyUSB5", "ttyUSB6", "ttyUSB7"}, "cdc-wdm1"},
|
||||
} {
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName, "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName, "idProduct"), "0125\n")
|
||||
for number, tty := range modem.ttys {
|
||||
interfaceName := modem.usbName + ":1." + strconv.Itoa(number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustWrite(t, filepath.Join(usbRoot, modem.usbName+":1.4", "bInterfaceNumber"), "04\n")
|
||||
mustMkdir(t, filepath.Join(usbRoot, modem.usbName+":1.4", "usbmisc", modem.wdm))
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2", len(candidates))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
switch filepath.Base(candidate.USBPath) {
|
||||
case "1-5":
|
||||
if candidate.ATPort.Name != "ttyUSB6" || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("second modem AT port = %#v, want ttyUSB6", candidate.ATPort)
|
||||
}
|
||||
case "1-6":
|
||||
if candidate.ATPort.Name != "ttyUSB2" || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("first modem AT port = %#v, want ttyUSB2", candidate.ATPort)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected candidate USB path %q", candidate.USBPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryDoesNotCollapseModemsWithSharedFactorySerial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices")
|
||||
|
||||
for index, item := range []struct {
|
||||
usbName string
|
||||
ttyBase int
|
||||
}{
|
||||
{usbName: "1-5.1", ttyBase: 0},
|
||||
{usbName: "1-5.2", ttyBase: 4},
|
||||
} {
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idVendor"), "2c7c\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "idProduct"), "0125\n")
|
||||
mustWrite(t, filepath.Join(usbRoot, item.usbName, "serial"), "0123456789ABCDEF\n")
|
||||
for number := 0; number < 4; number++ {
|
||||
interfaceName := item.usbName + ":1." + strconv.Itoa(number)
|
||||
tty := fmt.Sprintf("ttyUSB%d", item.ttyBase+number)
|
||||
mustWrite(t, filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), fmt.Sprintf("%02x\n", number))
|
||||
mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(usbRoot, item.usbName+":1.4", "usbmisc", fmt.Sprintf("cdc-wdm%d", index)))
|
||||
}
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2", len(candidates))
|
||||
}
|
||||
if candidates[0].ID == candidates[1].ID {
|
||||
t.Fatalf("shared factory serial collapsed discovery IDs to %q", candidates[0].ID)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.SerialNumber != "0123456789ABCDEF" {
|
||||
t.Fatalf("serial = %q", candidate.SerialNumber)
|
||||
}
|
||||
if candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
|
||||
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
HardwareKind string `json:"hardwareKind,omitempty"`
|
||||
ReaderName string `json:"readerName,omitempty"`
|
||||
ID string `json:"id"`
|
||||
VendorID string `json:"vendorId"`
|
||||
ProductID string `json:"productId"`
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ValidatePublicURL accepts an absolute HTTP(S) URL only when every currently
|
||||
// resolved address is publicly routable. The transport returned by
|
||||
// NewPublicHTTPClient repeats the same check when it dials, which also prevents
|
||||
// DNS rebinding between validation and connection establishment.
|
||||
func ValidatePublicURL(ctx context.Context, raw string, requireHTTPS bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||
return nil, errors.New("destination must be an absolute HTTP URL")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return nil, errors.New("destination URL cannot contain user information")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTP or HTTPS")
|
||||
}
|
||||
if requireHTTPS && parsed.Scheme != "https" {
|
||||
return nil, errors.New("destination URL must use HTTPS")
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return nil, errors.New("destination URL has an invalid port")
|
||||
}
|
||||
}
|
||||
if _, err := resolvePublic(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// NewPublicHTTPClient creates a client that never uses environment proxies,
|
||||
// rejects private/special-use destinations at dial time, and validates every
|
||||
// redirect before following it.
|
||||
func NewPublicHTTPClient(timeout time.Duration, requireHTTPS bool) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: PublicDialer(timeout),
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 4 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
_, err := ValidatePublicURL(request.Context(), request.URL.String(), requireHTTPS)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PublicDialer resolves the original hostname and connects directly to one of
|
||||
// its validated public addresses. It does not pass the hostname back through a
|
||||
// second resolver, so a DNS rebinding response cannot redirect the connection.
|
||||
func PublicDialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse outbound address: %w", err)
|
||||
}
|
||||
addresses, err := resolvePublic(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
var lastErr error
|
||||
for _, address := range addresses {
|
||||
connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(address.String(), port))
|
||||
if err == nil {
|
||||
return connection, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("connect to public destination: %w", lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if literal, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicAddress(literal) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
}
|
||||
addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve destination: %w", err)
|
||||
}
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicAddress(address) {
|
||||
return nil, errors.New("destination resolves to a private or special-use address")
|
||||
}
|
||||
result = append(result, address)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("destination has no IP address")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var blockedNetworks = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("127.0.0.0/8"),
|
||||
netip.MustParsePrefix("169.254.0.0/16"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.0.0.0/24"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("224.0.0.0/4"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("::/128"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("fc00::/7"),
|
||||
netip.MustParsePrefix("fe80::/10"),
|
||||
netip.MustParsePrefix("ff00::/8"),
|
||||
// Block both the well-known and local-use NAT64 prefixes. Otherwise a
|
||||
// public-looking IPv6 literal could translate to a private IPv4 target.
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
}
|
||||
|
||||
func publicAddress(address netip.Addr) bool {
|
||||
if !address.IsValid() || !address.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
for _, blocked := range blockedNetworks {
|
||||
if blocked.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package netguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePublicURLRejectsUnsafeDestinations(t *testing.T) {
|
||||
tests := []string{
|
||||
"http://127.0.0.1/plugin.zip",
|
||||
"https://[::1]/plugin.zip",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://[64:ff9b::7f00:1]/",
|
||||
"https://[2002:7f00:1::]/",
|
||||
"file:///etc/passwd",
|
||||
"https://user:[email protected]/plugin.zip",
|
||||
}
|
||||
for _, raw := range tests {
|
||||
if _, err := ValidatePublicURL(context.Background(), raw, false); err == nil {
|
||||
t.Errorf("ValidatePublicURL(%q) accepted an unsafe destination", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicURLCanRequireHTTPS(t *testing.T) {
|
||||
if _, err := ValidatePublicURL(context.Background(), "http://8.8.8.8/plugin.zip", true); err == nil {
|
||||
t.Fatal("HTTP destination was accepted while HTTPS was required")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//go:build linux && (amd64 || arm64)
|
||||
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nativeBackend struct{}
|
||||
|
||||
func newNativeBackend() Backend { return &nativeBackend{} }
|
||||
|
||||
func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
||||
paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"}
|
||||
var failures []error
|
||||
seen := make(map[string]bool)
|
||||
for _, path := range paths {
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", path)
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
client, err := establishPCSCD(ctx, conn)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
failures = append(failures, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.closeContext(context.Background())
|
||||
states, err := client.readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readers := make([]Reader, 0, len(states))
|
||||
for _, state := range states {
|
||||
reader := Reader{
|
||||
Name: state.name,
|
||||
CardPresent: state.state&pcscCardPresent != 0,
|
||||
ATR: strings.ToUpper(hex.EncodeToString(state.atr)),
|
||||
}
|
||||
if path, ok := backend.readerUSBPath(ctx, client, state.name); ok {
|
||||
reader.USBPath = path
|
||||
reader.VendorID = readSysfsText(path, "idVendor")
|
||||
reader.ProductID = readSysfsText(path, "idProduct")
|
||||
reader.Manufacturer = readSysfsText(path, "manufacturer")
|
||||
reader.Product = readSysfsText(path, "product")
|
||||
} else {
|
||||
reader.USBPath = "pcsc:" + state.name
|
||||
}
|
||||
if reader.Product == "" {
|
||||
reader.Product = strings.TrimSpace(strings.TrimSuffix(state.name, " 00 00"))
|
||||
}
|
||||
readers = append(readers, reader)
|
||||
}
|
||||
return readers, nil
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) {
|
||||
card, _, err := client.connect(ctx, name, pcscShareDirect, 0)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
defer client.simpleCardCommand(context.Background(), pcscCmdDisconnect, card, &disposition)
|
||||
attribute, err := client.getAttrib(ctx, card, pcscAttrChannelID)
|
||||
if err != nil || len(attribute) < 4 {
|
||||
return "", false
|
||||
}
|
||||
channel := binary.LittleEndian.Uint32(attribute[:4])
|
||||
if channel>>16 != 0x0020 {
|
||||
return "", false
|
||||
}
|
||||
bus, device := int((channel>>8)&0xff), int(channel&0xff)
|
||||
entries, err := os.ReadDir("/sys/bus/usb/devices")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
|
||||
entryBus, busErr := readSysfsInt(path, "busnum")
|
||||
entryDevice, deviceErr := readSysfsInt(path, "devnum")
|
||||
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
|
||||
return entry.Name(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
|
||||
readers, err := backend.Readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader, ok := matchReader(readers, selector)
|
||||
if !ok {
|
||||
return nil, ErrReaderNotFound
|
||||
}
|
||||
if !reader.CardPresent {
|
||||
return nil, ErrNoCard
|
||||
}
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, protocol, err := client.connect(ctx, reader.Name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if err := client.simpleCardCommand(ctx, pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
_ = client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition)
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
|
||||
}
|
||||
return &nativeCard{client: client, handle: handle, protocol: protocol}, nil
|
||||
}
|
||||
|
||||
type nativeCard struct {
|
||||
client *pcscdClient
|
||||
handle int32
|
||||
protocol uint32
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
return card.transmit(ctx, append([]byte(nil), command...), 0)
|
||||
}
|
||||
|
||||
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
response, err := card.client.transmit(ctx, card.handle, card.protocol, append([]byte(nil), command...))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
last := len(response) - 2
|
||||
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
|
||||
}
|
||||
|
||||
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
|
||||
if depth > 8 {
|
||||
return nil, 0, errors.New("pcsc: too many APDU continuations")
|
||||
}
|
||||
data, status, err := card.TransmitRaw(ctx, command)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
sw1, sw2 := byte(status>>8), byte(status)
|
||||
if sw1 == 0x6c && len(command) >= 5 {
|
||||
retry := append([]byte(nil), command...)
|
||||
retry[len(retry)-1] = sw2
|
||||
return card.transmit(ctx, retry, depth+1)
|
||||
}
|
||||
if sw1 == 0x61 || sw1 == 0x9f {
|
||||
more, sw, err := card.transmit(ctx, []byte{0x00, 0xc0, 0x00, 0x00, sw2}, depth+1)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return append(data, more...), sw, nil
|
||||
}
|
||||
return data, status, ctx.Err()
|
||||
}
|
||||
|
||||
func (card *nativeCard) Close() error { return card.close(pcscLeaveCard) }
|
||||
|
||||
func (card *nativeCard) CloseWithReset() error { return card.close(pcscResetCard) }
|
||||
|
||||
func (card *nativeCard) close(disposition uint32) error {
|
||||
if card == nil || card.closed {
|
||||
return nil
|
||||
}
|
||||
card.closed = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result []error
|
||||
if card.client != nil {
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdEndTransaction, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdDisconnect, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if err := card.client.closeContext(ctx); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(result...)
|
||||
}
|
||||
|
||||
func readSysfsText(usbPath, name string) string {
|
||||
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(value))
|
||||
}
|
||||
|
||||
func readSysfsInt(path, name string) (int, error) {
|
||||
value, err := os.ReadFile(filepath.Join(path, name))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(value)))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !linux || (!amd64 && !arm64)
|
||||
|
||||
package pcsc
|
||||
|
||||
import "context"
|
||||
|
||||
type unsupportedBackend struct{}
|
||||
|
||||
func newNativeBackend() Backend { return unsupportedBackend{} }
|
||||
|
||||
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
|
||||
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pcsc-lite exposes a small, versioned protocol over its local Unix socket.
|
||||
// Speaking that protocol directly keeps VoCat's Linux binaries fully static;
|
||||
// loading libpcsclite through dlopen would pull a glibc interpreter into an
|
||||
// otherwise CGO-free build and make it unusable on musl-based routers.
|
||||
const (
|
||||
pcscProtocolMajor = 4
|
||||
pcscProtocolCurrentMinor = 6
|
||||
pcscProtocolOldestMinor = 4
|
||||
|
||||
pcscCmdEstablishContext = 0x01
|
||||
pcscCmdReleaseContext = 0x02
|
||||
pcscCmdConnect = 0x04
|
||||
pcscCmdDisconnect = 0x06
|
||||
pcscCmdBeginTransaction = 0x07
|
||||
pcscCmdEndTransaction = 0x08
|
||||
pcscCmdTransmit = 0x09
|
||||
pcscCmdGetAttrib = 0x0f
|
||||
pcscCmdVersion = 0x11
|
||||
pcscCmdGetReadersState = 0x12
|
||||
|
||||
pcscScopeSystem = 0x0002
|
||||
pcscProtocolT0 = 0x0001
|
||||
pcscProtocolT1 = 0x0002
|
||||
pcscProtocolAny = pcscProtocolT0 | pcscProtocolT1
|
||||
pcscShareShared = 0x0002
|
||||
pcscShareDirect = 0x0003
|
||||
pcscLeaveCard = 0x0000
|
||||
pcscResetCard = 0x0001
|
||||
pcscCardPresent = 0x0004
|
||||
pcscAttrChannelID = 0x00020110
|
||||
pcscMaxReaderName = 128
|
||||
pcscMaxATR = 33
|
||||
pcscMaxReaders = 16
|
||||
pcscReaderStateSize = 184
|
||||
pcscGetSetBodySize = 280
|
||||
pcscMaxAttribute = 264
|
||||
pcscMaxAPDUResponse = 65548
|
||||
pcscDefaultIOTimeout = 30 * time.Second
|
||||
pcscSuccess = uint32(0)
|
||||
pcscNoSmartcard = uint32(0x8010000c)
|
||||
pcscNoService = uint32(0x8010001d)
|
||||
pcscServiceStopped = uint32(0x8010001e)
|
||||
pcscNoReaders = uint32(0x8010002e)
|
||||
)
|
||||
|
||||
type pcscdClient struct {
|
||||
conn net.Conn
|
||||
contextID uint32
|
||||
serverMinor int32
|
||||
}
|
||||
|
||||
type pcscdReaderState struct {
|
||||
name string
|
||||
state uint32
|
||||
atr []byte
|
||||
protocol uint32
|
||||
}
|
||||
|
||||
func establishPCSCD(ctx context.Context, conn net.Conn) (*pcscdClient, error) {
|
||||
client := &pcscdClient{conn: conn}
|
||||
version := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], pcscProtocolCurrentMinor)
|
||||
for {
|
||||
if err := client.exchange(ctx, pcscCmdVersion, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
major := int32(binary.LittleEndian.Uint32(version[0:4]))
|
||||
client.serverMinor = int32(binary.LittleEndian.Uint32(version[4:8]))
|
||||
rv := binary.LittleEndian.Uint32(version[8:12])
|
||||
if rv == pcscSuccess {
|
||||
break
|
||||
}
|
||||
if rv != pcscServiceStopped || major != pcscProtocolMajor || client.serverMinor < pcscProtocolOldestMinor || client.serverMinor >= pcscProtocolCurrentMinor {
|
||||
return nil, pcscError("negotiate protocol", rv)
|
||||
}
|
||||
// pcsc-lite answers a newer client's first probe with its own
|
||||
// compatible minor version. Retry on the same connection with that
|
||||
// value, matching libpcsclite's official fallback behavior.
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], uint32(client.serverMinor))
|
||||
binary.LittleEndian.PutUint32(version[8:12], pcscSuccess)
|
||||
}
|
||||
if client.serverMinor < pcscProtocolOldestMinor {
|
||||
return nil, fmt.Errorf("pcsc: unsupported pcscd protocol %d.%d", pcscProtocolMajor, client.serverMinor)
|
||||
}
|
||||
body := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscScopeSystem)
|
||||
if err := client.exchange(ctx, pcscCmdEstablishContext, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[8:12]); rv != pcscSuccess {
|
||||
return nil, pcscError("establish context", rv)
|
||||
}
|
||||
client.contextID = binary.LittleEndian.Uint32(body[4:8])
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) exchange(ctx context.Context, command uint32, body []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return fmt.Errorf("pcsc: send command %02x: %w", command, err)
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: send command body %02x: %w", command, err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: receive command %02x: %w", command, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) send(ctx context.Context, command uint32, body, extra []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(client.conn, extra)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) setDeadline(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(pcscDefaultIOTimeout)
|
||||
if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
|
||||
deadline = value
|
||||
}
|
||||
return client.conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) readers(ctx context.Context) ([]pcscdReaderState, error) {
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[4:8], pcscCmdGetReadersState)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: request reader states: %w", err)
|
||||
}
|
||||
raw := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
if _, err := io.ReadFull(client.conn, raw); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: read reader states: %w", err)
|
||||
}
|
||||
result := make([]pcscdReaderState, 0, pcscMaxReaders)
|
||||
for offset := 0; offset < len(raw); offset += pcscReaderStateSize {
|
||||
state := raw[offset : offset+pcscReaderStateSize]
|
||||
name := cString(state[:pcscMaxReaderName])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
atrLen := int(binary.LittleEndian.Uint32(state[176:180]))
|
||||
if atrLen < 0 || atrLen > pcscMaxATR {
|
||||
atrLen = 0
|
||||
}
|
||||
result = append(result, pcscdReaderState{
|
||||
name: name,
|
||||
state: binary.LittleEndian.Uint32(state[132:136]),
|
||||
atr: append([]byte(nil), state[140:140+atrLen]...),
|
||||
protocol: binary.LittleEndian.Uint32(state[180:184]),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) connect(ctx context.Context, reader string, share, protocols uint32) (int32, uint32, error) {
|
||||
if len(reader) >= pcscMaxReaderName {
|
||||
return 0, 0, errors.New("pcsc: reader name is too long")
|
||||
}
|
||||
body := make([]byte, 152)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
copy(body[4:132], reader)
|
||||
binary.LittleEndian.PutUint32(body[132:136], share)
|
||||
binary.LittleEndian.PutUint32(body[136:140], protocols)
|
||||
if err := client.exchange(ctx, pcscCmdConnect, body); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[148:152]); rv != pcscSuccess {
|
||||
return 0, 0, pcscError("connect reader", rv)
|
||||
}
|
||||
return int32(binary.LittleEndian.Uint32(body[140:144])), binary.LittleEndian.Uint32(body[144:148]), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) simpleCardCommand(ctx context.Context, command uint32, card int32, disposition *uint32) error {
|
||||
size := 8
|
||||
if disposition != nil {
|
||||
size = 12
|
||||
}
|
||||
body := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
if disposition != nil {
|
||||
binary.LittleEndian.PutUint32(body[4:8], *disposition)
|
||||
}
|
||||
if err := client.exchange(ctx, command, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[size-4:]); rv != pcscSuccess {
|
||||
return pcscError("card command", rv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) transmit(ctx context.Context, card int32, protocol uint32, command []byte) ([]byte, error) {
|
||||
body := make([]byte, 32)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], protocol)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 8)
|
||||
binary.LittleEndian.PutUint32(body[12:16], uint32(len(command)))
|
||||
binary.LittleEndian.PutUint32(body[16:20], pcscProtocolAny)
|
||||
binary.LittleEndian.PutUint32(body[20:24], 8)
|
||||
binary.LittleEndian.PutUint32(body[24:28], pcscMaxAPDUResponse)
|
||||
if err := client.send(ctx, pcscCmdTransmit, body, command); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: transmit APDU: %w", err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU result: %w", err)
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[28:32]); rv != pcscSuccess {
|
||||
return nil, pcscError("transmit APDU", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[24:28])
|
||||
if length > pcscMaxAPDUResponse {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized APDU")
|
||||
}
|
||||
response := make([]byte, length)
|
||||
if _, err := io.ReadFull(client.conn, response); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) getAttrib(ctx context.Context, card int32, attribute uint32) ([]byte, error) {
|
||||
body := make([]byte, pcscGetSetBodySize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], attribute)
|
||||
binary.LittleEndian.PutUint32(body[272:276], pcscMaxAttribute)
|
||||
if err := client.exchange(ctx, pcscCmdGetAttrib, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[276:280]); rv != pcscSuccess {
|
||||
return nil, pcscError("get reader attribute", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[272:276])
|
||||
if length > pcscMaxAttribute {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized attribute")
|
||||
}
|
||||
return append([]byte(nil), body[8:8+length]...), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) closeContext(ctx context.Context) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return nil
|
||||
}
|
||||
body := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
err := client.exchange(ctx, pcscCmdReleaseContext, body)
|
||||
if err == nil {
|
||||
if rv := binary.LittleEndian.Uint32(body[4:8]); rv != pcscSuccess {
|
||||
err = pcscError("release context", rv)
|
||||
}
|
||||
}
|
||||
closeErr := client.conn.Close()
|
||||
client.conn = nil
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
|
||||
func pcscError(operation string, code uint32) error {
|
||||
switch code {
|
||||
case pcscNoSmartcard:
|
||||
return ErrNoCard
|
||||
case pcscNoService, pcscServiceStopped:
|
||||
return fmt.Errorf("%w: %s failed with PC/SC status %08X", ErrUnavailable, operation, code)
|
||||
case pcscNoReaders:
|
||||
return ErrReaderNotFound
|
||||
default:
|
||||
return fmt.Errorf("pcsc: %s failed with status %08X", operation, code)
|
||||
}
|
||||
}
|
||||
|
||||
func cString(value []byte) string {
|
||||
for index, current := range value {
|
||||
if current == 0 {
|
||||
return string(value[:index])
|
||||
}
|
||||
}
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func writeAll(writer io.Writer, value []byte) error {
|
||||
for len(value) > 0 {
|
||||
written, err := writer.Write(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written == 0 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
value = value[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCSCDClientLifecycleAndTransmit(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer serverConn.Close()
|
||||
serverDone <- servePCSCDTestSession(serverConn)
|
||||
}()
|
||||
|
||||
client, err := establishPCSCD(context.Background(), clientConn)
|
||||
if err != nil {
|
||||
t.Fatalf("establishPCSCD: %v", err)
|
||||
}
|
||||
states, err := client.readers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("readers: %v", err)
|
||||
}
|
||||
if len(states) != 1 || states[0].name != "VoCat Test Reader 00 00" || states[0].state&pcscCardPresent == 0 {
|
||||
t.Fatalf("states = %#v", states)
|
||||
}
|
||||
handle, protocol, err := client.connect(context.Background(), states[0].name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if handle != 42 || protocol != pcscProtocolT1 {
|
||||
t.Fatalf("handle/protocol = %d/%d", handle, protocol)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
response, err := client.transmit(context.Background(), handle, protocol, []byte{0x00, 0xa4, 0x00, 0x00})
|
||||
if err != nil {
|
||||
t.Fatalf("transmit: %v", err)
|
||||
}
|
||||
if !bytes.Equal(response, []byte{0x62, 0x02, 0x90, 0x00}) {
|
||||
t.Fatalf("response = %x", response)
|
||||
}
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdEndTransaction, handle, &disposition); err != nil {
|
||||
t.Fatalf("end: %v", err)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition); err != nil {
|
||||
t.Fatalf("disconnect: %v", err)
|
||||
}
|
||||
if err := client.closeContext(context.Background()); err != nil {
|
||||
t.Fatalf("close context: %v", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("fake pcscd: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func servePCSCDTestSession(conn net.Conn) error {
|
||||
for {
|
||||
header := make([]byte, 8)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(header[0:4])
|
||||
command := binary.LittleEndian.Uint32(header[4:8])
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case pcscCmdVersion:
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(body[4:8], pcscProtocolCurrentMinor)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdEstablishContext:
|
||||
binary.LittleEndian.PutUint32(body[4:8], 7)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdGetReadersState:
|
||||
states := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
copy(states, "VoCat Test Reader 00 00")
|
||||
binary.LittleEndian.PutUint32(states[132:136], pcscCardPresent)
|
||||
copy(states[140:143], []byte{0x3b, 0x00, 0x00})
|
||||
binary.LittleEndian.PutUint32(states[176:180], 3)
|
||||
binary.LittleEndian.PutUint32(states[180:184], pcscProtocolT1)
|
||||
if err := writeAll(conn, states); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdConnect:
|
||||
binary.LittleEndian.PutUint32(body[140:144], 42)
|
||||
binary.LittleEndian.PutUint32(body[144:148], pcscProtocolT1)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdBeginTransaction, pcscCmdEndTransaction, pcscCmdDisconnect:
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdTransmit:
|
||||
commandBody := make([]byte, binary.LittleEndian.Uint32(body[12:16]))
|
||||
if _, err := io.ReadFull(conn, commandBody); err != nil {
|
||||
return err
|
||||
}
|
||||
response := []byte{0x62, 0x02, 0x90, 0x00}
|
||||
binary.LittleEndian.PutUint32(body[24:28], uint32(len(response)))
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(conn, response); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdReleaseContext:
|
||||
return writeAll(conn, body)
|
||||
default:
|
||||
return errors.New("unexpected fake pcscd command")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const usimAIDPrefix = "A0000000871002"
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
backend Backend
|
||||
}
|
||||
|
||||
// Session is an exclusive connection to one smart card. It is used by eUICC
|
||||
// operations which must keep the same PC/SC transaction and logical channel
|
||||
// alive across a sequence of APDUs.
|
||||
type Session struct {
|
||||
service *Service
|
||||
card Card
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{backend: newNativeBackend()}
|
||||
}
|
||||
|
||||
func NewWithBackend(backend Backend) *Service {
|
||||
return &Service{backend: backend}
|
||||
}
|
||||
|
||||
func DeviceID(reader Reader) string {
|
||||
identity := strings.TrimSpace(reader.USBPath)
|
||||
if identity == "" {
|
||||
identity = strings.TrimSpace(reader.Name)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(identity))
|
||||
return "reader-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
return service.backend.Readers(ctx)
|
||||
}
|
||||
|
||||
// OpenSession opens one card and holds the service lock until Close. Callers
|
||||
// must close the returned session; this prevents AKA/identity reads from
|
||||
// interleaving with a stateful ES10 transaction.
|
||||
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := selector.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service.mu.Lock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
service.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return &Session{service: service, card: card}, nil
|
||||
}
|
||||
|
||||
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
|
||||
// 61xx continuation handling to the eUICC logical-channel implementation so
|
||||
// GET RESPONSE uses the correct channel CLA.
|
||||
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if session == nil || session.card == nil || session.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if raw, ok := session.card.(interface {
|
||||
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
|
||||
}); ok {
|
||||
return raw.TransmitRaw(ctx, command)
|
||||
}
|
||||
return session.card.Transmit(ctx, command)
|
||||
}
|
||||
|
||||
func (session *Session) Close() error {
|
||||
return session.close(false)
|
||||
}
|
||||
|
||||
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
|
||||
// EnableProfile requires this refresh boundary before the newly enabled USIM
|
||||
// application and ICCID become visible to subsequent callers.
|
||||
func (session *Session) CloseWithReset() error {
|
||||
return session.close(true)
|
||||
}
|
||||
|
||||
func (session *Session) close(reset bool) error {
|
||||
if session == nil || session.closed {
|
||||
return nil
|
||||
}
|
||||
session.closed = true
|
||||
var err error
|
||||
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
|
||||
err = resetter.CloseWithReset()
|
||||
} else {
|
||||
err = session.card.Close()
|
||||
}
|
||||
session.service.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
|
||||
readers, err := service.Readers(ctx)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
reader, ok := matchReader(readers, selector)
|
||||
if !ok {
|
||||
return Snapshot{}, ErrReaderNotFound
|
||||
}
|
||||
result := Snapshot{Reader: reader}
|
||||
if !reader.CardPresent {
|
||||
return result, ErrNoCard
|
||||
}
|
||||
identity, err := service.ReadIdentity(ctx, selector, pin)
|
||||
result.Identity = identity
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return Identity{}, ErrUnavailable
|
||||
}
|
||||
if err := selector.validate(); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
defer card.Close()
|
||||
return readIdentity(ctx, card, pin)
|
||||
}
|
||||
|
||||
func (service *Service) CheckReady(
|
||||
ctx context.Context,
|
||||
selector Selector,
|
||||
expectedICCID string,
|
||||
pin string,
|
||||
) (string, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer card.Close()
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
|
||||
return "", ErrCardChanged
|
||||
}
|
||||
aid, err := selectUSIM(ctx, card)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(aid)), nil
|
||||
}
|
||||
|
||||
func (service *Service) Authenticate(
|
||||
ctx context.Context,
|
||||
selector Selector,
|
||||
expectedICCID string,
|
||||
pin string,
|
||||
challenge AKAChallenge,
|
||||
) (AKAResult, error) {
|
||||
if service == nil || service.backend == nil {
|
||||
return AKAResult{}, ErrUnavailable
|
||||
}
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
card, err := service.backend.Open(ctx, selector)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
defer card.Close()
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
|
||||
return AKAResult{}, ErrCardChanged
|
||||
}
|
||||
if _, err := selectUSIM(ctx, card); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
return AKAResult{}, err
|
||||
}
|
||||
apdu := make([]byte, 0, 40)
|
||||
apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10)
|
||||
apdu = append(apdu, challenge.RAND[:]...)
|
||||
apdu = append(apdu, 0x10)
|
||||
apdu = append(apdu, challenge.AUTN[:]...)
|
||||
apdu = append(apdu, 0x00)
|
||||
data, sw, err := card.Transmit(ctx, apdu)
|
||||
if err != nil {
|
||||
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
|
||||
}
|
||||
if sw == 0x9862 {
|
||||
return AKAResult{}, ErrAKARejected
|
||||
}
|
||||
if sw != 0x9000 {
|
||||
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
|
||||
}
|
||||
return parseAKAResponse(data)
|
||||
}
|
||||
|
||||
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
|
||||
path := strings.TrimSpace(selector.USBPath)
|
||||
name := strings.TrimSpace(selector.ReaderName)
|
||||
for _, reader := range readers {
|
||||
if path != "" && reader.USBPath == path {
|
||||
return reader, true
|
||||
}
|
||||
}
|
||||
for _, reader := range readers {
|
||||
if name != "" && reader.Name == name {
|
||||
return reader, true
|
||||
}
|
||||
}
|
||||
return Reader{}, false
|
||||
}
|
||||
|
||||
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
|
||||
identity := Identity{PINTries: -1}
|
||||
iccid, err := readICCID(ctx, card)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.ICCID = iccid
|
||||
aid, err := selectUSIM(ctx, card)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
identity.USIMAID = append([]byte(nil), aid...)
|
||||
if err := verifyPIN(ctx, card, pin); err != nil {
|
||||
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
|
||||
var pinErr *PINError
|
||||
if errors.As(err, &pinErr) {
|
||||
identity.PINTries = pinErr.Tries
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
|
||||
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
|
||||
}
|
||||
imsiData, err := readBinary(ctx, card, 9)
|
||||
if err != nil {
|
||||
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
|
||||
}
|
||||
identity.IMSI, err = decodeIMSI(imsiData)
|
||||
if err != nil {
|
||||
return identity, err
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
|
||||
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
|
||||
length := int(data[3] & 0x0f)
|
||||
if length == 2 || length == 3 {
|
||||
identity.MNCLength = length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
identity.SPN = readSPN(ctx, card)
|
||||
}
|
||||
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
|
||||
identity.SMSC = readSMSC(ctx, card)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func readICCID(ctx context.Context, card Card) (string, error) {
|
||||
if err := selectMF(ctx, card); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
|
||||
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
|
||||
}
|
||||
data, err := readBinary(ctx, card, 10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
|
||||
}
|
||||
value := decodeSwappedBCD(data, false)
|
||||
if len(value) < 18 || len(value) > 22 {
|
||||
return "", errors.New("pcsc: card returned an invalid ICCID")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
|
||||
if err := selectMF(ctx, card); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
|
||||
}
|
||||
var usimAID []byte
|
||||
for record := 1; record <= 32; record++ {
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sw == 0x6A83 || sw == 0x9402 {
|
||||
break
|
||||
}
|
||||
if sw != 0x9000 {
|
||||
continue
|
||||
}
|
||||
aid := findTLV(data, 0x4F)
|
||||
if len(aid) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
|
||||
usimAID = append([]byte(nil), aid...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(usimAID) == 0 {
|
||||
return nil, ErrUSIMUnavailable
|
||||
}
|
||||
if _, err := selectApplication(ctx, card, usimAID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usimAID, nil
|
||||
}
|
||||
|
||||
func selectMF(ctx context.Context, card Card) error {
|
||||
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
|
||||
return requireStatus("select MF", sw, err)
|
||||
}
|
||||
|
||||
func selectFile(ctx context.Context, card Card, fileID []byte) error {
|
||||
if len(fileID) != 2 {
|
||||
return errors.New("pcsc: invalid file identifier")
|
||||
}
|
||||
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
|
||||
_, sw, err := card.Transmit(ctx, apdu)
|
||||
return requireStatus("select file", sw, err)
|
||||
}
|
||||
|
||||
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
|
||||
if len(aid) == 0 || len(aid) > 32 {
|
||||
return nil, errors.New("pcsc: invalid USIM AID")
|
||||
}
|
||||
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
|
||||
apdu = append(apdu, aid...)
|
||||
apdu = append(apdu, 0x00)
|
||||
data, sw, err := card.Transmit(ctx, apdu)
|
||||
if err := requireStatus("select USIM application", sw, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
|
||||
if length <= 0 || length > 256 {
|
||||
return nil, errors.New("pcsc: invalid binary read length")
|
||||
}
|
||||
le := byte(length)
|
||||
if length == 256 {
|
||||
le = 0
|
||||
}
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
|
||||
if err := requireStatus("read binary", sw, err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func verifyPIN(ctx context.Context, card Card, pin string) error {
|
||||
pin = strings.TrimSpace(pin)
|
||||
if pin == "" {
|
||||
return nil
|
||||
}
|
||||
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
|
||||
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
|
||||
}
|
||||
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
|
||||
if err != nil {
|
||||
return errors.New("pcsc: SIM PIN status check failed")
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
tries := -1
|
||||
if sw&0xFFF0 == 0x63C0 {
|
||||
tries = int(sw & 0x000F)
|
||||
if tries <= 2 {
|
||||
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
|
||||
}
|
||||
}
|
||||
body := bytes.Repeat([]byte{0xFF}, 8)
|
||||
copy(body, []byte(pin))
|
||||
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
|
||||
_, sw, err = card.Transmit(ctx, apdu)
|
||||
if err != nil {
|
||||
return errors.New("pcsc: SIM PIN verification transport failed")
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
if sw&0xFFF0 == 0x63C0 {
|
||||
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
|
||||
}
|
||||
return ErrPINRejected
|
||||
}
|
||||
|
||||
func requireStatus(operation string, sw uint16, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("pcsc: %s transport failed", operation)
|
||||
}
|
||||
if sw == 0x9000 {
|
||||
return nil
|
||||
}
|
||||
if sw == 0x6982 || sw == 0x9804 {
|
||||
return &PINError{Kind: ErrPINRequired, Tries: -1}
|
||||
}
|
||||
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
|
||||
}
|
||||
|
||||
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
|
||||
var result strings.Builder
|
||||
for _, octet := range value {
|
||||
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
|
||||
if dropFirstNibble {
|
||||
dropFirstNibble = false
|
||||
continue
|
||||
}
|
||||
if nibble == 0x0F {
|
||||
return result.String()
|
||||
}
|
||||
if nibble > 9 {
|
||||
return ""
|
||||
}
|
||||
result.WriteByte('0' + nibble)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func decodeIMSI(data []byte) (string, error) {
|
||||
if len(data) < 2 {
|
||||
return "", errors.New("pcsc: EF_IMSI is too short")
|
||||
}
|
||||
length := int(data[0])
|
||||
if length <= 0 || length > len(data)-1 {
|
||||
return "", errors.New("pcsc: EF_IMSI has an invalid length")
|
||||
}
|
||||
value := decodeSwappedBCD(data[1:1+length], true)
|
||||
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
|
||||
return "", errors.New("pcsc: card returned an invalid IMSI")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findTLV(data []byte, wanted byte) []byte {
|
||||
for len(data) >= 2 {
|
||||
tag := data[0]
|
||||
data = data[1:]
|
||||
length, consumed, ok := decodeTLVLength(data)
|
||||
if !ok || consumed+length > len(data) {
|
||||
return nil
|
||||
}
|
||||
value := data[consumed : consumed+length]
|
||||
if tag == wanted {
|
||||
return append([]byte(nil), value...)
|
||||
}
|
||||
if tag&0x20 != 0 {
|
||||
if nested := findTLV(value, wanted); len(nested) > 0 {
|
||||
return nested
|
||||
}
|
||||
}
|
||||
data = data[consumed+length:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if data[0]&0x80 == 0 {
|
||||
return int(data[0]), 1, true
|
||||
}
|
||||
count := int(data[0] & 0x7F)
|
||||
if count < 1 || count > 2 || len(data) < 1+count {
|
||||
return 0, 0, false
|
||||
}
|
||||
length = 0
|
||||
for _, octet := range data[1 : 1+count] {
|
||||
length = length<<8 | int(octet)
|
||||
}
|
||||
return length, 1 + count, true
|
||||
}
|
||||
|
||||
func parseAKAResponse(data []byte) (AKAResult, error) {
|
||||
if len(data) < 2 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
|
||||
}
|
||||
switch data[0] {
|
||||
case 0xDB:
|
||||
res, rest, ok := takeLV(data[1:])
|
||||
if !ok || len(res) < 4 || len(res) > 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
|
||||
}
|
||||
ck, rest, ok := takeLV(rest)
|
||||
if !ok || len(ck) != 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
|
||||
}
|
||||
ik, rest, ok := takeLV(rest)
|
||||
if !ok || len(ik) != 16 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kc, tail, valid := takeLV(rest)
|
||||
if !valid || len(kc) != 8 || len(tail) != 0 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
|
||||
}
|
||||
}
|
||||
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
|
||||
case 0xDC:
|
||||
auts, tail, ok := takeLV(data[1:])
|
||||
if !ok || len(auts) != 14 || len(tail) != 0 {
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
|
||||
}
|
||||
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
|
||||
default:
|
||||
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
|
||||
}
|
||||
}
|
||||
|
||||
func takeLV(data []byte) (value, rest []byte, ok bool) {
|
||||
if len(data) == 0 || int(data[0]) > len(data)-1 {
|
||||
return nil, data, false
|
||||
}
|
||||
length := int(data[0])
|
||||
return data[1 : 1+length], data[1+length:], true
|
||||
}
|
||||
|
||||
func readSPN(ctx context.Context, card Card) string {
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := readBinary(ctx, card, 17)
|
||||
if err != nil || len(data) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
|
||||
}
|
||||
|
||||
func readSMSC(ctx context.Context, card Card) string {
|
||||
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
|
||||
if err != nil || sw != 0x9000 || len(data) < 15 {
|
||||
return ""
|
||||
}
|
||||
sca := data[len(data)-15 : len(data)-3]
|
||||
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
|
||||
return ""
|
||||
}
|
||||
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
|
||||
if !decimalDigits(digits) {
|
||||
return ""
|
||||
}
|
||||
if sca[1]&0x70 == 0x10 {
|
||||
return "+" + digits
|
||||
}
|
||||
return digits
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type scriptedReply struct {
|
||||
data []byte
|
||||
sw uint16
|
||||
}
|
||||
|
||||
type scriptedCard struct {
|
||||
replies []scriptedReply
|
||||
calls [][]byte
|
||||
}
|
||||
|
||||
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
|
||||
card.calls = append(card.calls, append([]byte(nil), command...))
|
||||
if len(card.replies) == 0 {
|
||||
return nil, 0, errors.New("unexpected APDU")
|
||||
}
|
||||
reply := card.replies[0]
|
||||
card.replies = card.replies[1:]
|
||||
return append([]byte(nil), reply.data...), reply.sw, nil
|
||||
}
|
||||
|
||||
func (*scriptedCard) Close() error { return nil }
|
||||
|
||||
func TestDecodeIdentifiers(t *testing.T) {
|
||||
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
|
||||
t.Fatalf("ICCID BCD = %q", got)
|
||||
}
|
||||
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if imsi != "123456789012345" {
|
||||
t.Fatalf("IMSI = %q", imsi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
|
||||
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
|
||||
err := verifyPIN(context.Background(), card, "1234")
|
||||
if !errors.Is(err, ErrPINTriesLow) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(card.calls) != 1 {
|
||||
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAKAResponse(t *testing.T) {
|
||||
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
|
||||
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
|
||||
data = append(data, 0x10)
|
||||
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
|
||||
result, err := parseAKAResponse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
|
||||
t.Fatalf("unexpected AKA result: %#v", result)
|
||||
}
|
||||
|
||||
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
|
||||
t.Fatalf("unexpected sync result: %#v", syncResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
|
||||
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
|
||||
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
|
||||
if a != b || a == "" {
|
||||
t.Fatalf("device IDs = %q, %q", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const HardwareKind = "pcsc"
|
||||
|
||||
var (
|
||||
ErrUnsupported = errors.New("pcsc: platform is not supported")
|
||||
ErrUnavailable = errors.New("pcsc: service is unavailable")
|
||||
ErrReaderNotFound = errors.New("pcsc: reader not found")
|
||||
ErrNoCard = errors.New("pcsc: no card is inserted")
|
||||
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
|
||||
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
|
||||
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
|
||||
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
|
||||
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
|
||||
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
Name string
|
||||
USBPath string
|
||||
VendorID string
|
||||
ProductID string
|
||||
Manufacturer string
|
||||
Product string
|
||||
CardPresent bool
|
||||
ATR string
|
||||
}
|
||||
|
||||
type Selector struct {
|
||||
USBPath string
|
||||
ReaderName string
|
||||
}
|
||||
|
||||
func (selector Selector) validate() error {
|
||||
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
|
||||
return errors.New("pcsc: reader selector is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
ICCID string
|
||||
IMSI string
|
||||
MNCLength int
|
||||
USIMAID []byte
|
||||
SMSC string
|
||||
SPN string
|
||||
PINRequired bool
|
||||
PINTries int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Reader Reader
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
type AKAChallenge struct {
|
||||
RAND [16]byte
|
||||
AUTN [16]byte
|
||||
}
|
||||
|
||||
type AKAResult struct {
|
||||
RES []byte
|
||||
CK []byte
|
||||
IK []byte
|
||||
AUTS []byte
|
||||
SynchronizationFailure bool
|
||||
}
|
||||
|
||||
type PINError struct {
|
||||
Kind error
|
||||
Tries int
|
||||
}
|
||||
|
||||
func (err *PINError) Error() string {
|
||||
if err == nil {
|
||||
return "pcsc: SIM PIN error"
|
||||
}
|
||||
if err.Tries >= 0 {
|
||||
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
|
||||
}
|
||||
return err.Kind.Error()
|
||||
}
|
||||
|
||||
func (err *PINError) Unwrap() error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return err.Kind
|
||||
}
|
||||
|
||||
type Card interface {
|
||||
Transmit(context.Context, []byte) ([]byte, uint16, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
Readers(context.Context) ([]Reader, error)
|
||||
Open(context.Context, Selector) (Card, error)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
type automaticTaskNotification struct {
|
||||
Title string
|
||||
Text string
|
||||
Time time.Time
|
||||
Task store.AutomaticTask
|
||||
Run store.AutomaticTaskRun
|
||||
}
|
||||
|
||||
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
|
||||
deviceLabel := task.DeviceID
|
||||
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
|
||||
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
|
||||
}
|
||||
status := "成功"
|
||||
detail := firstNonEmpty(run.Output, "任务已完成")
|
||||
if run.Status != "success" {
|
||||
status = "失败"
|
||||
detail = firstNonEmpty(run.Error, "未知错误")
|
||||
}
|
||||
taskType := map[string]string{"sms": "发送短信", "call": "拨打电话", "public_ip": "获取漫游公网 IP"}[task.TaskType]
|
||||
environment := map[string]string{"vowifi": "VoWiFi", "cellular": "基站直连"}[task.Environment]
|
||||
notification := automaticTaskNotification{
|
||||
Title: "自动任务执行" + status,
|
||||
Text: strings.Join([]string{
|
||||
"自动任务执行" + status,
|
||||
"任务 " + task.Name,
|
||||
"设备 " + deviceLabel,
|
||||
"类型 " + firstNonEmpty(taskType, task.TaskType),
|
||||
"环境 " + firstNonEmpty(environment, task.Environment),
|
||||
"时间 " + run.FinishedAt.Local().Format("2006-01-02 15:04:05"),
|
||||
"结果 " + detail,
|
||||
}, "\n"),
|
||||
Time: run.FinishedAt, Task: task, Run: run,
|
||||
}
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
|
||||
setting, err := s.store.NotificationSetting(ctx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err)
|
||||
continue
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||
s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil {
|
||||
s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendAutomaticTaskNotification(ctx context.Context, channel string, config map[string]any, message automaticTaskNotification) error {
|
||||
switch channel {
|
||||
case "telegram":
|
||||
return sendTelegramTextNotification(ctx, config, message.Text)
|
||||
case "bark":
|
||||
return sendBarkTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "email":
|
||||
return sendEmailTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "pushplus":
|
||||
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
|
||||
case "webhook":
|
||||
return sendAutomaticTaskWebhook(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func sendTelegramTextNotification(ctx context.Context, config map[string]any, text string) error {
|
||||
token := configString(config, "bot_token")
|
||||
parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, configString(config, "proxy"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"chat_id": configString(config, "chat_id"), "text": text})
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
return performNotificationRequest(client, request, true)
|
||||
}
|
||||
|
||||
func sendBarkTextNotification(ctx context.Context, config map[string]any, title, text string) error {
|
||||
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"title": title, "body": text}
|
||||
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 err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
if err := performNotificationRequest(client, request, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendPushplusTextNotification(ctx context.Context, config map[string]any, title, text string) 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": title, "content": text, "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 err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
request.Header.Set("User-Agent", "vocat-automatic-task/1")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10))
|
||||
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 sendAutomaticTaskWebhook(ctx context.Context, config map[string]any, message automaticTaskNotification) error {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "automatic_task.completed", "message": message.Text,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339), "task_id": message.Task.ID,
|
||||
"task_name": message.Task.Name, "device_id": message.Task.DeviceID,
|
||||
"task_type": message.Task.TaskType, "environment": message.Task.Environment,
|
||||
"status": message.Run.Status, "attempts": message.Run.Attempts,
|
||||
"output": message.Run.Output, "error": message.Run.Error,
|
||||
})
|
||||
client, err := restrictedHTTPClient(ctx, durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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-automatic-task/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)))
|
||||
}
|
||||
if err := performNotificationRequest(client, request, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendEmailTextNotification(ctx context.Context, config map[string]any, subject, text string) 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 err
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return 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 err
|
||||
}
|
||||
connection = secure
|
||||
}
|
||||
client, err := smtp.NewClient(connection, host)
|
||||
if err != nil {
|
||||
return 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 err
|
||||
}
|
||||
}
|
||||
username, password := configString(config, "username"), configString(config, "password")
|
||||
if username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
from, err := parseMailAddress(configString(config, "from_address"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var recipients []*mail.Address
|
||||
for _, item := range configStrings(config, "to_addresses") {
|
||||
address, err := parseMailAddress(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipients = append(recipients, address)
|
||||
}
|
||||
if err := client.Mail(from.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(recipient.Address); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePlainTextMail(writer, from, recipients, subject, text); err != nil {
|
||||
_ = writer.Close()
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
automaticTaskPollInterval = 5 * time.Second
|
||||
automaticTaskMaxRuntime = 8 * time.Minute
|
||||
)
|
||||
|
||||
type automaticTaskPayload struct {
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type automaticTaskExecutionError struct {
|
||||
err error
|
||||
retryable bool
|
||||
}
|
||||
|
||||
func (value automaticTaskExecutionError) Error() string { return value.err.Error() }
|
||||
func (value automaticTaskExecutionError) Unwrap() error { return value.err }
|
||||
|
||||
type automaticTaskProgress func(string)
|
||||
|
||||
type automaticTaskEnvironmentSnapshot struct {
|
||||
config store.Device
|
||||
policy store.CardPolicy
|
||||
}
|
||||
|
||||
type automaticTaskScheduler struct {
|
||||
server *Server
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
queues map[string]chan store.AutomaticTaskRun
|
||||
}
|
||||
|
||||
func (s *Server) StartAutomaticTasks(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
scheduler := &automaticTaskScheduler{server: s, ctx: ctx, queues: make(map[string]chan store.AutomaticTaskRun)}
|
||||
s.automaticTasks = scheduler
|
||||
queued, err := s.store.RecoverAutomaticTaskRuns(ctx, time.Now().UTC())
|
||||
if err != nil {
|
||||
s.logger.Warn("recover automatic tasks", "error", err)
|
||||
} else {
|
||||
for _, run := range queued {
|
||||
scheduler.enqueue(run)
|
||||
}
|
||||
}
|
||||
go scheduler.run()
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) run() {
|
||||
ticker := time.NewTicker(automaticTaskPollInterval)
|
||||
defer ticker.Stop()
|
||||
scheduler.claim()
|
||||
for {
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
scheduler.claim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) claim() {
|
||||
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
if err != nil {
|
||||
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
scheduler.enqueue(run)
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) enqueue(run store.AutomaticTaskRun) {
|
||||
deviceID := strings.TrimSpace(run.DeviceID)
|
||||
scheduler.mu.Lock()
|
||||
queue := scheduler.queues[deviceID]
|
||||
if queue == nil {
|
||||
queue = make(chan store.AutomaticTaskRun, 100)
|
||||
scheduler.queues[deviceID] = queue
|
||||
go scheduler.worker(deviceID, queue)
|
||||
}
|
||||
scheduler.mu.Unlock()
|
||||
select {
|
||||
case queue <- run:
|
||||
case <-scheduler.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) worker(deviceID string, queue <-chan store.AutomaticTaskRun) {
|
||||
for {
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
return
|
||||
case run := <-queue:
|
||||
scheduler.execute(run)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
task, err := scheduler.server.store.AutomaticTask(scheduler.ctx, run.TaskID)
|
||||
if err != nil {
|
||||
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
run.Status, run.StartedAt = "running", time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
var output string
|
||||
for attempt := 1; attempt <= task.RetryCount+1; attempt++ {
|
||||
run.Attempts = attempt
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:正在检查设备和 eSIM Profile", attempt)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
progress := func(message string) {
|
||||
run.Output = fmt.Sprintf("第 %d 次尝试:%s", attempt, message)
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(scheduler.ctx, automaticTaskMaxRuntime)
|
||||
output, err = scheduler.server.executeAutomaticTask(operationContext, task, progress)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
var executionError automaticTaskExecutionError
|
||||
if errors.As(err, &executionError) && !executionError.retryable {
|
||||
break
|
||||
}
|
||||
if attempt <= task.RetryCount {
|
||||
// A device error may contain the full AT command, including APN
|
||||
// credentials. The persisted run retains a user-facing outcome; logs
|
||||
// contain only non-sensitive execution metadata.
|
||||
scheduler.server.logger.Warn("automatic task attempt failed", "task_id", task.ID, "device_id", task.DeviceID, "attempt", attempt)
|
||||
select {
|
||||
case <-scheduler.ctx.Done():
|
||||
break
|
||||
case <-time.After(time.Duration(attempt*5) * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
run.FinishedAt = time.Now().UTC()
|
||||
if err == nil {
|
||||
run.Status, run.Output, run.Error = "success", output, ""
|
||||
} else {
|
||||
run.Status, run.Error = "failed", err.Error()
|
||||
}
|
||||
if updateErr := scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run); updateErr != nil {
|
||||
scheduler.server.logger.Warn("finish automatic task run", "run_id", run.ID, "error", updateErr)
|
||||
}
|
||||
if task.Notify {
|
||||
go scheduler.server.notifyAutomaticTask(context.Background(), task, run)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) {
|
||||
progress("正在检查设备和 eSIM Profile")
|
||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iccid := strings.TrimSpace(task.ProfileICCID)
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
} else if policyErr != nil {
|
||||
return "", fmt.Errorf("read saved card policy: %w", policyErr)
|
||||
}
|
||||
snapshot := automaticTaskEnvironmentSnapshot{config: config, policy: policy}
|
||||
actionCompleted := false
|
||||
defer func() {
|
||||
progress("正在恢复该 Profile 原先保存的卡策略")
|
||||
if restoreErr := s.restoreAutomaticTaskEnvironment(physicalID, snapshot); restoreErr != nil {
|
||||
if err == nil && actionCompleted {
|
||||
output = ""
|
||||
err = automaticTaskExecutionError{err: fmt.Errorf("task completed but card policy restoration failed: %w", restoreErr), retryable: false}
|
||||
} else if err == nil {
|
||||
err = fmt.Errorf("restore card policy: %w", restoreErr)
|
||||
} else {
|
||||
err = fmt.Errorf("%w; card policy restoration also failed: %v", err, restoreErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := s.prepareAutomaticTaskEnvironment(ctx, &config, entry, physicalID, task, progress); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload automaticTaskPayload
|
||||
if err := json.Unmarshal(task.Payload, &payload); err != nil {
|
||||
return "", fmt.Errorf("decode task payload: %w", err)
|
||||
}
|
||||
switch task.TaskType {
|
||||
case "sms":
|
||||
progress("正在发送短信")
|
||||
output, err = s.executeAutomaticSMS(ctx, task, payload)
|
||||
case "call":
|
||||
progress("正在发起通话")
|
||||
output, err = s.executeAutomaticCall(ctx, task, payload)
|
||||
case "public_ip":
|
||||
progress("蜂窝数据已连接,正在查询漫游公网 IP")
|
||||
output, err = s.executeAutomaticPublicIP(ctx, config, task.ProfileICCID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported automatic task type %q", task.TaskType)
|
||||
}
|
||||
actionCompleted = err == nil
|
||||
return output, err
|
||||
}
|
||||
|
||||
func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (store.Device, device.Device, string, error) {
|
||||
config, err := s.store.Device(ctx, task.DeviceID)
|
||||
if err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
|
||||
return store.Device{}, device.Device{}, "", err
|
||||
}
|
||||
entry, physicalID, present := s.physicalForConfig(config)
|
||||
if !present || entry.Snapshot == nil {
|
||||
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
progress("正在切换到任务指定的 eSIM Profile")
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("enter airplane mode before profile switch: %w", err)
|
||||
}
|
||||
if err := s.devices.ESIMSwitchProfile(ctx, physicalID, task.ProfileICCID, task.ProfileAID); err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("switch eSIM profile: %w", err)
|
||||
}
|
||||
entry, physicalID, present = s.physicalForConfig(config)
|
||||
if !present {
|
||||
return store.Device{}, device.Device{}, "", errors.New("device did not recover after profile switch")
|
||||
}
|
||||
snapshot, err := s.devices.Refresh(ctx, physicalID)
|
||||
if err != nil {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("verify switched profile: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(snapshot.ICCID), strings.TrimSpace(task.ProfileICCID)) {
|
||||
return store.Device{}, device.Device{}, "", fmt.Errorf("profile verification failed: current ICCID is %s", firstNonEmpty(snapshot.ICCID, "unavailable"))
|
||||
}
|
||||
entry.Snapshot = &snapshot
|
||||
return config, entry, physicalID, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *store.Device, entry device.Device, physicalID string, task store.AutomaticTask, progress automaticTaskProgress) error {
|
||||
iccid := strings.TrimSpace(task.ProfileICCID)
|
||||
if task.Environment == "vowifi" {
|
||||
progress("正在准备 VoWiFi 执行环境")
|
||||
if task.TaskType == "public_ip" {
|
||||
return errors.New("public IP tasks cannot run over VoWiFi")
|
||||
}
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, true); err != nil {
|
||||
return fmt.Errorf("enable airplane mode for VoWiFi: %w", err)
|
||||
}
|
||||
config.VoWiFiEnabled, config.NetworkEnabled = true, false
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
policyErr = nil
|
||||
}
|
||||
if policyErr != nil {
|
||||
return policyErr
|
||||
}
|
||||
policy.NetworkEnabled = false
|
||||
policy.VoWiFiEnabled = true
|
||||
policy.AirplaneEnabled = true
|
||||
policy.Source = "automatic_task"
|
||||
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.vowifi == nil {
|
||||
return errors.New("VoWiFi runtime is unavailable")
|
||||
}
|
||||
state, stateErr := s.vowifi.State(config.ID)
|
||||
stateMatchesCard := state.ICCID == "" || strings.EqualFold(strings.TrimSpace(state.ICCID), iccid)
|
||||
if stateErr == nil && stateMatchesCard && state.IMSReady && (task.TaskType != "sms" || state.SMSReady) {
|
||||
return nil
|
||||
}
|
||||
if stateErr == nil && state.Enabled {
|
||||
_, stateErr = s.vowifi.RequestReconnect(config.ID)
|
||||
} else {
|
||||
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
|
||||
}
|
||||
if stateErr != nil {
|
||||
return fmt.Errorf("start VoWiFi: %w", stateErr)
|
||||
}
|
||||
return s.waitAutomaticVoWiFi(ctx, config.ID, iccid, task.TaskType == "sms")
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
|
||||
if _, stateErr = s.vowifi.RequestEnabled(config.ID, false); stateErr != nil {
|
||||
return fmt.Errorf("stop VoWiFi: %w", stateErr)
|
||||
}
|
||||
if err := s.waitAutomaticVoWiFiStopped(ctx, config.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
progress("正在开启蜂窝无线并启用自动选网")
|
||||
config.VoWiFiEnabled = false
|
||||
config.NetworkEnabled = task.TaskType == "public_ip"
|
||||
if err := s.store.UpsertDevice(ctx, *config); err != nil {
|
||||
return err
|
||||
}
|
||||
policy, policyErr := s.store.CardPolicy(ctx, iccid)
|
||||
if errors.Is(policyErr, store.ErrNotFound) {
|
||||
policy = defaultCardPolicy(iccid)
|
||||
policy.APN = config.APN
|
||||
} else if policyErr != nil {
|
||||
return policyErr
|
||||
}
|
||||
policy.NetworkEnabled = config.NetworkEnabled
|
||||
policy.VoWiFiEnabled = false
|
||||
policy.AirplaneEnabled = false
|
||||
policy.Source = "automatic_task"
|
||||
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.TaskType != "public_ip" {
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, false)); err != nil {
|
||||
s.logger.Warn("automatic task could not stop unused cellular data", "device_id", config.ID)
|
||||
}
|
||||
}
|
||||
if _, err := s.devices.SetFlight(ctx, physicalID, false); err != nil {
|
||||
return fmt.Errorf("enable cellular radio: %w", err)
|
||||
}
|
||||
if _, err := s.devices.SetOperatorSelection(ctx, physicalID, true, "", nil); err != nil {
|
||||
return fmt.Errorf("enable automatic network selection: %w", err)
|
||||
}
|
||||
if _, err := s.devices.ReRegisterOperator(ctx, physicalID); err != nil {
|
||||
return fmt.Errorf("re-register cellular network: %w", err)
|
||||
}
|
||||
progress("正在搜索并注册蜂窝网络(漫游注册可能需要数分钟)")
|
||||
if err := s.waitAutomaticCellular(ctx, physicalID, task.TaskType == "public_ip"); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.TaskType == "public_ip" {
|
||||
if !s.developerActive(ctx) {
|
||||
return errors.New("roaming public IP tasks require developer mode")
|
||||
}
|
||||
progress("已注册蜂窝网络,正在建立数据连接")
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
|
||||
return fmt.Errorf("start roaming data: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticVoWiFi(ctx context.Context, deviceID, iccid string, requireSMS bool) error {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
state, err := s.vowifi.State(deviceID)
|
||||
if err == nil && state.IMSReady && (!requireSMS || state.SMSReady) && (state.ICCID == "" || strings.EqualFold(state.ICCID, iccid)) {
|
||||
return nil
|
||||
}
|
||||
if err == nil && state.LastError != "" && !state.Active && !state.Enabled {
|
||||
return errors.New(state.LastError)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err == nil && state.LastError != "" {
|
||||
return fmt.Errorf("wait for VoWiFi readiness: %s", state.LastError)
|
||||
}
|
||||
return fmt.Errorf("wait for VoWiFi readiness: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticVoWiFiStopped(ctx context.Context, deviceID string) error {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
state, err := s.vowifi.State(deviceID)
|
||||
if err != nil || (!state.Active && !state.Enabled) {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for VoWiFi shutdown: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) waitAutomaticCellular(ctx context.Context, physicalID string, requirePacketAttach bool) error {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
stableSamples := 0
|
||||
for {
|
||||
snapshot, err := s.devices.Refresh(ctx, physicalID)
|
||||
registered := err == nil && (snapshot.RegistrationStatus == 1 || snapshot.RegistrationStatus == 5)
|
||||
if registered && (!requirePacketAttach || snapshot.PSAttached) {
|
||||
stableSamples++
|
||||
if stableSamples >= 2 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
stableSamples = 0
|
||||
}
|
||||
if err == nil && snapshot.RegistrationStatus == 3 {
|
||||
return errors.New("cellular network registration was denied")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for cellular registration: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticSMS(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
|
||||
body, _ := json.Marshal(map[string]any{"device_id": task.DeviceID, "phone": payload.Phone, "message": payload.Message})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/sms/send", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
s.handleSMSSend(recorder, request)
|
||||
if recorder.Code < 200 || recorder.Code >= 300 {
|
||||
failure := fmt.Errorf("send SMS failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
|
||||
// Once any part reached the modem/IMS transaction, retrying the whole
|
||||
// message could deliver a duplicate. Preparation failures remain safe to
|
||||
// retry according to the configured count.
|
||||
return "", automaticTaskExecutionError{err: failure, retryable: automaticSMSRetrySafe(recorder.Body.Bytes())}
|
||||
}
|
||||
return "短信已提交到 " + payload.Phone, nil
|
||||
}
|
||||
|
||||
func automaticSMSRetrySafe(body []byte) bool {
|
||||
var payload struct {
|
||||
Data struct {
|
||||
PartsAttempted int `json:"parts_attempted"`
|
||||
PartsAccepted int `json:"parts_accepted"`
|
||||
RetrySafe *bool `json:"retry_safe"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &payload) != nil {
|
||||
return false
|
||||
}
|
||||
if payload.Data.RetrySafe != nil {
|
||||
return *payload.Data.RetrySafe
|
||||
}
|
||||
return payload.Data.PartsAttempted == 0 && payload.Data.PartsAccepted == 0
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticCall(ctx context.Context, task store.AutomaticTask, payload automaticTaskPayload) (string, error) {
|
||||
config, err := s.store.Device(ctx, task.DeviceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, physicalID, present := s.physicalForConfig(config)
|
||||
if !present {
|
||||
return "", errors.New("configured device is offline")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"number": payload.Phone, "duration_seconds": payload.DurationSeconds})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/devices/calls/dial", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
s.handleCallAction(recorder, request, config, physicalID, "dial")
|
||||
if recorder.Code < 200 || recorder.Code >= 300 {
|
||||
return "", fmt.Errorf("dial failed (HTTP %d): %s", recorder.Code, compactAutomaticResponse(recorder.Body.Bytes()))
|
||||
}
|
||||
return fmt.Sprintf("已拨打 %s,将在 %d 秒后自动挂断", payload.Phone, payload.DurationSeconds), nil
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticPublicIP(ctx context.Context, config store.Device, iccid string) (string, error) {
|
||||
if strings.TrimSpace(config.Interface) == "" {
|
||||
return "", errors.New("device has no cellular network interface")
|
||||
}
|
||||
info, err := exportproxy.LookupPublicIP(ctx, config.Interface)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("detect roaming public IP: %w", err)
|
||||
}
|
||||
s.savePublicIP(config.ID, iccid, info)
|
||||
return strings.TrimSpace(fmt.Sprintf("公网 IP %s · %s %s", info.IP, info.CountryCode, info.Region)), nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot automaticTaskEnvironmentSnapshot) error {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
config, policy := snapshot.config, snapshot.policy
|
||||
desiredNetwork := policy.NetworkEnabled && !policy.VoWiFiEnabled && !policy.AirplaneEnabled
|
||||
config.APN = policy.APN
|
||||
config.NetworkEnabled = desiredNetwork
|
||||
config.VoWiFiEnabled = policy.VoWiFiEnabled
|
||||
var restoreErrors []error
|
||||
if err := s.store.UpsertCardPolicy(cleanupContext, policy); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("persist card policy: %w", err))
|
||||
}
|
||||
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
|
||||
}
|
||||
if config.DeviceType == store.DeviceTypeUSBSIMReader {
|
||||
if s.vowifi == nil {
|
||||
return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...)
|
||||
}
|
||||
state, stateErr := s.vowifi.State(config.ID)
|
||||
if policy.VoWiFiEnabled {
|
||||
if stateErr == nil && state.Enabled {
|
||||
_, stateErr = s.vowifi.RequestReconnect(config.ID)
|
||||
} else {
|
||||
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
|
||||
}
|
||||
} else if stateErr == nil && (state.Enabled || state.Active) {
|
||||
_, stateErr = s.vowifi.RequestEnabled(config.ID, false)
|
||||
}
|
||||
if stateErr != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr))
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
|
||||
if policy.VoWiFiEnabled {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
|
||||
}
|
||||
if s.vowifi == nil {
|
||||
restoreErrors = append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))
|
||||
} else if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && state.Enabled {
|
||||
if _, err := s.vowifi.RequestReconnect(config.ID); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
|
||||
}
|
||||
} else if _, err := s.vowifi.RequestEnabled(config.ID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore VoWiFi: %w", err))
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
if s.vowifi != nil {
|
||||
if state, stateErr := s.vowifi.State(config.ID); stateErr == nil && (state.Enabled || state.Active) {
|
||||
if _, err := s.vowifi.RequestEnabled(config.ID, false); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop VoWiFi: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if policy.AirplaneEnabled {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, true); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore airplane mode: %w", err))
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
if !desiredNetwork {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("stop cellular data: %w", err))
|
||||
}
|
||||
}
|
||||
if _, err := s.devices.SetFlight(cleanupContext, physicalID, false); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular radio: %w", err))
|
||||
}
|
||||
if desiredNetwork {
|
||||
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, true)); err != nil {
|
||||
restoreErrors = append(restoreErrors, fmt.Errorf("restore cellular data: %w", err))
|
||||
}
|
||||
}
|
||||
return errors.Join(restoreErrors...)
|
||||
}
|
||||
|
||||
func (s *Server) cardNetworkRequest(
|
||||
ctx context.Context,
|
||||
physicalID string,
|
||||
config store.Device,
|
||||
policy store.CardPolicy,
|
||||
enabled bool,
|
||||
) device.NetworkRequest {
|
||||
request := device.NetworkRequest{
|
||||
Enabled: enabled, APN: policy.APN, IPVersion: policy.IPVersion, Backend: config.DeviceBackend,
|
||||
}
|
||||
if request.IPVersion == "" {
|
||||
request.IPVersion = "IPV4V6"
|
||||
}
|
||||
profile, err := s.store.CardAPNProfileByAPN(ctx, policy.ICCID, policy.APN, policy.IPVersion)
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
request.Username = profile.Username
|
||||
request.Password = profile.Password
|
||||
request.Authentication = profile.AuthType
|
||||
if entry, getErr := s.devices.Get(physicalID); getErr == nil && entry.Snapshot != nil &&
|
||||
entry.Snapshot.RegistrationStatus == 5 && profile.RoamingIPVersion != "" {
|
||||
request.IPVersion = profile.RoamingIPVersion
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func compactAutomaticResponse(body []byte) string {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(body, &payload) == nil {
|
||||
if apiErr, ok := payload["error"].(map[string]any); ok {
|
||||
return firstNonEmpty(fmt.Sprint(apiErr["message"]), fmt.Sprint(apiErr["code"]), "request failed")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
func (s *Server) routeAutomaticTasksAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
|
||||
segments := splitAPIPath(cleanPath)
|
||||
if len(segments) == 0 || segments[0] != "automatic-tasks" {
|
||||
return false
|
||||
}
|
||||
if len(segments) == 1 {
|
||||
s.handleAutomaticTasks(w, r)
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 && segments[1] == "runs" {
|
||||
s.handleAutomaticTaskRuns(w, r)
|
||||
return true
|
||||
}
|
||||
id, err := strconv.ParseInt(segments[1], 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_task_id", "automatic task ID is invalid")
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 {
|
||||
s.handleAutomaticTask(w, r, id)
|
||||
return true
|
||||
}
|
||||
if len(segments) == 3 && segments[2] == "run" {
|
||||
s.handleAutomaticTaskRunNow(w, r, id)
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "automatic task endpoint not found")
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
tasks, err := s.store.ListAutomaticTasks(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
|
||||
case http.MethodPost:
|
||||
task, err := s.decodeAutomaticTask(r, 0)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"data": saved})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTask(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
task, err := s.decodeAutomaticTask(r, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_automatic_task", err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := s.store.SaveAutomaticTask(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": saved})
|
||||
case http.MethodDelete:
|
||||
if err := s.store.DeleteAutomaticTask(r.Context(), id); err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"runs": runs, "total": total}})
|
||||
}
|
||||
|
||||
func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if s.automaticTasks == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "scheduler_unavailable", "automatic task scheduler is unavailable")
|
||||
return
|
||||
}
|
||||
task, err := s.store.AutomaticTask(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
config, err := s.store.Device(r.Context(), task.DeviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
|
||||
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
|
||||
return
|
||||
}
|
||||
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
s.automaticTasks.enqueue(run)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": run})
|
||||
}
|
||||
|
||||
func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.AutomaticTask, error) {
|
||||
var request struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ProfileICCID string `json:"profile_iccid"`
|
||||
ProfileAID string `json:"profile_aid"`
|
||||
TaskType string `json:"task_type"`
|
||||
Environment string `json:"environment"`
|
||||
IntervalDays int `json:"interval_days"`
|
||||
StartDate string `json:"start_date"`
|
||||
RunTime string `json:"run_time"`
|
||||
Timezone string `json:"timezone"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Notify bool `json:"notify"`
|
||||
Payload automaticTaskPayload `json:"payload"`
|
||||
}
|
||||
if err := s.decodeJSON(nilResponseWriter{}, r, &request); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
request.Name, request.DeviceID = strings.TrimSpace(request.Name), strings.TrimSpace(request.DeviceID)
|
||||
request.ProfileICCID, request.ProfileAID = strings.TrimSpace(request.ProfileICCID), strings.TrimSpace(request.ProfileAID)
|
||||
request.TaskType, request.Environment = strings.ToLower(strings.TrimSpace(request.TaskType)), strings.ToLower(strings.TrimSpace(request.Environment))
|
||||
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
|
||||
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
|
||||
}
|
||||
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, errors.New("selected device does not exist")
|
||||
}
|
||||
if request.Environment != "vowifi" && request.Environment != "cellular" {
|
||||
return store.AutomaticTask{}, errors.New("environment must be vowifi or cellular")
|
||||
}
|
||||
if request.TaskType != "sms" && request.TaskType != "call" && request.TaskType != "public_ip" {
|
||||
return store.AutomaticTask{}, errors.New("unsupported task type")
|
||||
}
|
||||
if request.TaskType == "public_ip" && request.Environment != "cellular" {
|
||||
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
|
||||
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
|
||||
}
|
||||
if request.TaskType == "sms" {
|
||||
if !validDialNumber(request.Payload.Phone) || strings.TrimSpace(request.Payload.Message) == "" {
|
||||
return store.AutomaticTask{}, errors.New("SMS phone and message are required")
|
||||
}
|
||||
if blocked, reason := blockedSMSDestination(request.Payload.Phone); blocked {
|
||||
return store.AutomaticTask{}, errors.New(reason)
|
||||
}
|
||||
}
|
||||
if request.TaskType == "call" && (!validDialNumber(request.Payload.Phone) || request.Payload.DurationSeconds < 1 || request.Payload.DurationSeconds > 600) {
|
||||
return store.AutomaticTask{}, errors.New("call phone is required and automatic hang-up must be 1-600 seconds")
|
||||
}
|
||||
request.Timezone = strings.TrimSpace(request.Timezone)
|
||||
if request.Timezone == "" {
|
||||
request.Timezone = time.Local.String()
|
||||
}
|
||||
location, err := time.LoadLocation(request.Timezone)
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, errors.New("timezone must be a valid IANA time zone")
|
||||
}
|
||||
nextRun, err := nextAutomaticRun(request.StartDate, request.RunTime, request.IntervalDays, time.Now().In(location))
|
||||
if err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
payload, _ := json.Marshal(request.Payload)
|
||||
task := store.AutomaticTask{ID: id, Name: request.Name, Enabled: request.Enabled, DeviceID: request.DeviceID,
|
||||
ProfileICCID: request.ProfileICCID, ProfileAID: request.ProfileAID, TaskType: request.TaskType,
|
||||
Environment: request.Environment, IntervalDays: request.IntervalDays, StartDate: request.StartDate,
|
||||
RunTime: request.RunTime, Timezone: request.Timezone, Payload: payload, RetryCount: request.RetryCount, Notify: request.Notify, NextRunAt: nextRun.UTC()}
|
||||
if id != 0 {
|
||||
if previous, previousErr := s.store.AutomaticTask(r.Context(), id); previousErr == nil {
|
||||
task.CreatedAt, task.LastRunAt, task.LastStatus, task.LastError = previous.CreatedAt, previous.LastRunAt, previous.LastStatus, previous.LastError
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
|
||||
if config.DeviceType != store.DeviceTypeUSBSIMReader {
|
||||
return nil
|
||||
}
|
||||
if environment != "vowifi" {
|
||||
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
|
||||
}
|
||||
if taskType != "sms" && taskType != "call" {
|
||||
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
|
||||
location := now.Location()
|
||||
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
|
||||
if err != nil {
|
||||
return time.Time{}, errors.New("start_date and run_time must use YYYY-MM-DD and HH:MM")
|
||||
}
|
||||
for start.Before(now) {
|
||||
start = start.AddDate(0, 0, intervalDays)
|
||||
}
|
||||
return start, nil
|
||||
}
|
||||
|
||||
// nilResponseWriter is used only because decodeJSON's size/error contract is
|
||||
// shared with HTTP handlers; decode errors are returned to the real handler.
|
||||
type nilResponseWriter struct{}
|
||||
|
||||
func (nilResponseWriter) Header() http.Header { return make(http.Header) }
|
||||
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (nilResponseWriter) WriteHeader(statusCode int) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
|
||||
location := time.FixedZone("test", 8*60*60)
|
||||
now := time.Date(2026, 8, 10, 12, 0, 0, 0, location)
|
||||
next, err := nextAutomaticRun("2026-08-01", "09:30", 3, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 8, 13, 9, 30, 0, 0, location)
|
||||
if !next.Equal(want) {
|
||||
t.Fatalf("next run = %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
|
||||
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
|
||||
for _, test := range []struct {
|
||||
taskType string
|
||||
environment string
|
||||
wantError bool
|
||||
}{
|
||||
{taskType: "sms", environment: "vowifi"},
|
||||
{taskType: "call", environment: "vowifi"},
|
||||
{taskType: "sms", environment: "cellular", wantError: true},
|
||||
{taskType: "public_ip", environment: "cellular", wantError: true},
|
||||
} {
|
||||
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
|
||||
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
|
||||
if automaticSMSRetrySafe(unsafe) {
|
||||
t.Fatal("partially submitted SMS was considered safe to retry")
|
||||
}
|
||||
safe := []byte(`{"data":{"parts_attempted":0,"parts_accepted":0}}`)
|
||||
if !automaticSMSRetrySafe(safe) {
|
||||
t.Fatal("unattempted SMS was not considered safe to retry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const maxCallDuration = 10 * time.Minute
|
||||
|
||||
func (s *Server) handleCalls(w http.ResponseWriter, r *http.Request, config store.Device, physicalID string) bool {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return true
|
||||
}
|
||||
transport := s.callTransport(config.ID)
|
||||
if transport == "vowifi" {
|
||||
controller, ok := s.vowifi.(VoWiFiCallController)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling")
|
||||
return true
|
||||
}
|
||||
calls, err := controller.Calls(config.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "vowifi_call_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_id": config.ID, "transport": transport, "calls": calls,
|
||||
}})
|
||||
return true
|
||||
}
|
||||
response, err := s.devices.ExecuteAT(r.Context(), physicalID, "AT+CLCC")
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"device_id": config.ID,
|
||||
"transport": transport,
|
||||
"calls": parseCLCC(response),
|
||||
"raw": response.Text(),
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleCallAction(w http.ResponseWriter, r *http.Request, config store.Device, physicalID, action string) bool {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
command := ""
|
||||
duration := time.Duration(0)
|
||||
number := ""
|
||||
callID := ""
|
||||
switch action {
|
||||
case "dial":
|
||||
var request struct {
|
||||
Number string `json:"number"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
number = strings.TrimSpace(request.Number)
|
||||
if !validDialNumber(number) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_number", "phone number is invalid")
|
||||
return true
|
||||
}
|
||||
duration = time.Duration(request.DurationSeconds) * time.Second
|
||||
if duration < 0 || duration > maxCallDuration {
|
||||
writeError(w, http.StatusBadRequest, "invalid_duration", "duration_seconds must be 0 (no automatic hang-up) or between 1 and 600")
|
||||
return true
|
||||
}
|
||||
command = "ATD" + number + ";"
|
||||
case "answer":
|
||||
var request struct {
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
callID = strings.TrimSpace(request.CallID)
|
||||
command = "ATA"
|
||||
case "hangup":
|
||||
var request struct {
|
||||
CallID string `json:"call_id"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
callID = strings.TrimSpace(request.CallID)
|
||||
command = "ATH"
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "not_found", "call action not found")
|
||||
return true
|
||||
}
|
||||
|
||||
transport := s.callTransport(config.ID)
|
||||
if transport == "vowifi" {
|
||||
controller, ok := s.vowifi.(VoWiFiCallController)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "vowifi_voice_unavailable", "the active VoWiFi IMS session does not expose voice-call signalling")
|
||||
return true
|
||||
}
|
||||
var result any
|
||||
var err error
|
||||
switch action {
|
||||
case "dial":
|
||||
result, err = controller.DialCall(r.Context(), config.ID, number)
|
||||
case "answer":
|
||||
callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "ringing")
|
||||
if err == nil {
|
||||
result, err = controller.AnswerCall(r.Context(), config.ID, callID)
|
||||
}
|
||||
case "hangup":
|
||||
callID, err = resolveVoWiFiCallID(controller, config.ID, callID, "")
|
||||
if err == nil {
|
||||
err = controller.HangupCall(r.Context(), config.ID, callID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "vowifi_call_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
if action == "dial" {
|
||||
if call, ok := result.(vowifi.Call); ok {
|
||||
callID = call.ID
|
||||
}
|
||||
if duration > 0 {
|
||||
go s.hangupVoWiFiAfter(config.ID, callID, duration)
|
||||
}
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
|
||||
"accepted": true, "action": action, "number": number, "call_id": callID,
|
||||
"duration_seconds": int(duration / time.Second), "transport": transport, "call": result,
|
||||
}})
|
||||
return true
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
response, err := s.devices.ExecuteAT(operationContext, physicalID, command)
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.writeDeviceError(w, err)
|
||||
return true
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.Final), "OK") {
|
||||
writeError(w, http.StatusBadGateway, "call_rejected", "modem did not accept the call action")
|
||||
return true
|
||||
}
|
||||
if action == "dial" {
|
||||
if duration > 0 {
|
||||
go s.hangupAfter(config.ID, physicalID, duration)
|
||||
}
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "call."+action, "device", config.ID, "success", transport)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"data": map[string]any{
|
||||
"accepted": true, "action": action, "number": number,
|
||||
"duration_seconds": int(duration / time.Second), "transport": transport,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveVoWiFiCallID(controller VoWiFiCallController, deviceID, id, requiredState string) (string, error) {
|
||||
if id != "" {
|
||||
return id, nil
|
||||
}
|
||||
calls, err := controller.Calls(deviceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, call := range calls {
|
||||
if call.State != "ended" && call.State != "failed" && (requiredState == "" || call.State == requiredState) {
|
||||
return call.ID, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no matching active call")
|
||||
}
|
||||
|
||||
func (s *Server) hangupVoWiFiAfter(deviceID, callID string, duration time.Duration) {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
<-timer.C
|
||||
controller, ok := s.vowifi.(VoWiFiCallController)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := controller.HangupCall(ctx, deviceID, callID); err != nil {
|
||||
s.logger.Warn("automatic VoWiFi call hangup failed", "device_id", deviceID, "call_id", callID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) callTransport(deviceID string) string {
|
||||
if s.vowifi != nil {
|
||||
// Enabled is only the desired card policy. Calls can use IMS only after
|
||||
// registration has actually completed; otherwise keep using the modem's
|
||||
// circuit-switched call path instead of routing into an unavailable IMS
|
||||
// session.
|
||||
if state, err := s.vowifi.State(deviceID); err == nil && state.IMSReady {
|
||||
return "vowifi"
|
||||
}
|
||||
}
|
||||
return "cellular"
|
||||
}
|
||||
|
||||
func (s *Server) hangupAfter(deviceID, physicalID string, duration time.Duration) {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
<-timer.C
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := s.devices.ExecuteAT(ctx, physicalID, "ATH"); err != nil {
|
||||
s.logger.Warn("automatic call hangup failed", "device_id", deviceID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validDialNumber(value string) bool {
|
||||
if len(value) < 2 || len(value) > 32 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if character >= '0' && character <= '9' || (index == 0 && character == '+') || character == '*' || character == '#' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseCLCC(response modem.Response) []map[string]any {
|
||||
result := make([]map[string]any, 0)
|
||||
for _, line := range response.Lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(strings.ToUpper(line), "+CLCC:") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(strings.TrimSpace(strings.TrimPrefix(line, "+CLCC:")), ",")
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
integer := func(index int) int {
|
||||
value, _ := strconv.Atoi(strings.TrimSpace(fields[index]))
|
||||
return value
|
||||
}
|
||||
call := map[string]any{
|
||||
"index": integer(0), "direction": integer(1), "state": integer(2),
|
||||
"mode": integer(3), "multiparty": integer(4), "raw": line,
|
||||
}
|
||||
if len(fields) > 5 {
|
||||
call["number"] = strings.Trim(strings.TrimSpace(fields[5]), `"`)
|
||||
}
|
||||
result = append(result, call)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestParseCLCC(t *testing.T) {
|
||||
calls := parseCLCC(modem.Response{Lines: []string{
|
||||
`+CLCC: 1,1,4,0,0,"+447700900000",145`,
|
||||
`+CLCC: 2,0,0,0,0,"12345",129`,
|
||||
}})
|
||||
if len(calls) != 2 || calls[0]["number"] != "+447700900000" || calls[1]["state"] != 0 {
|
||||
t.Fatalf("parseCLCC = %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDialNumber(t *testing.T) {
|
||||
for _, value := range []string{"+447700900000", "12345", "*100#"} {
|
||||
if !validDialNumber(value) {
|
||||
t.Errorf("validDialNumber(%q) = false", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "+", "12;ATH", "12 34", "abc"} {
|
||||
if validDialNumber(value) {
|
||||
t.Errorf("validDialNumber(%q) = true", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallTransportRequiresIMSReady(t *testing.T) {
|
||||
controller := &fakeVoWiFiController{state: vowifi.State{Enabled: true}}
|
||||
server := &Server{vowifi: controller}
|
||||
if got := server.callTransport("ec20"); got != "cellular" {
|
||||
t.Fatalf("callTransport before IMS registration = %q, want cellular", got)
|
||||
}
|
||||
controller.state.IMSReady = true
|
||||
if got := server.callTransport("ec20"); got != "vowifi" {
|
||||
t.Fatalf("callTransport with IMS ready = %q, want vowifi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVoWiFiCallIDIgnoresTerminalCalls(t *testing.T) {
|
||||
controller := &fakeCallController{calls: []vowifi.Call{
|
||||
{ID: "failed", State: "failed"},
|
||||
{ID: "active", State: "active"},
|
||||
}}
|
||||
got, err := resolveVoWiFiCallID(controller, "ec20", "", "")
|
||||
if err != nil || got != "active" {
|
||||
t.Fatalf("resolveVoWiFiCallID() = %q, %v; want active", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCallController struct {
|
||||
calls []vowifi.Call
|
||||
}
|
||||
|
||||
func (controller *fakeCallController) Calls(string) ([]vowifi.Call, error) {
|
||||
return controller.calls, nil
|
||||
}
|
||||
|
||||
func (*fakeCallController) DialCall(context.Context, string, string) (vowifi.Call, error) {
|
||||
return vowifi.Call{}, nil
|
||||
}
|
||||
|
||||
func (*fakeCallController) AnswerCall(context.Context, string, string) (vowifi.Call, error) {
|
||||
return vowifi.Call{}, nil
|
||||
}
|
||||
|
||||
func (*fakeCallController) HangupCall(context.Context, string, string) error { return nil }
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const maxCallMediaMessage = 16 << 10
|
||||
|
||||
// handleCallMedia upgrades an authenticated same-origin request to a binary
|
||||
// PCM bridge. Each WebSocket message contains little-endian signed 16-bit,
|
||||
// 8 kHz, mono samples. RTP and codec details remain inside the IMS provider.
|
||||
func (s *Server) handleCallMedia(w http.ResponseWriter, r *http.Request, config store.Device) bool {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return true
|
||||
}
|
||||
if s.callTransport(config.ID) != "vowifi" {
|
||||
writeError(w, http.StatusNotImplemented, "call_media_unavailable", "browser audio is only available for an active VoWiFi IMS call")
|
||||
return true
|
||||
}
|
||||
callID := strings.TrimSpace(r.URL.Query().Get("call_id"))
|
||||
if callID == "" || len(callID) > 256 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_call_id", "call_id is required")
|
||||
return true
|
||||
}
|
||||
controller, ok := s.vowifi.(VoWiFiCallMediaController)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "call_media_unavailable", "the active IMS session does not expose RTP media")
|
||||
return true
|
||||
}
|
||||
media, err := controller.CallMedia(r.Context(), config.ID, callID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "call_media_unavailable", err.Error())
|
||||
return true
|
||||
}
|
||||
connection, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
connection.SetReadLimit(maxCallMediaMessage)
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
defer connection.Close(websocket.StatusNormalClosure, "call media closed")
|
||||
|
||||
downlink := make(chan error, 1)
|
||||
go func() {
|
||||
defer cancel()
|
||||
for {
|
||||
samples, readErr := media.ReadPCM(ctx)
|
||||
if readErr != nil {
|
||||
downlink <- readErr
|
||||
return
|
||||
}
|
||||
payload := make([]byte, len(samples)*2)
|
||||
for index, sample := range samples {
|
||||
binary.LittleEndian.PutUint16(payload[index*2:], uint16(sample))
|
||||
}
|
||||
if writeErr := connection.Write(ctx, websocket.MessageBinary, payload); writeErr != nil {
|
||||
downlink <- writeErr
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-downlink:
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
|
||||
s.logger.Debug("call media downlink closed", "device_id", config.ID, "call_id", callID, "error", err)
|
||||
}
|
||||
return true
|
||||
default:
|
||||
}
|
||||
messageType, payload, readErr := connection.Read(ctx)
|
||||
if readErr != nil {
|
||||
return true
|
||||
}
|
||||
if messageType != websocket.MessageBinary || len(payload) == 0 || len(payload)%2 != 0 {
|
||||
continue
|
||||
}
|
||||
samples := make([]int16, len(payload)/2)
|
||||
for index := range samples {
|
||||
samples[index] = int16(binary.LittleEndian.Uint16(payload[index*2:]))
|
||||
}
|
||||
if err := media.WritePCM(samples); err != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"vocat/internal/developer"
|
||||
)
|
||||
|
||||
func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.developerActive(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.writeDeveloperSettings(w, r)
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
DeviceLimit *int `json:"device_limit"`
|
||||
SMSHourlyLimit *int `json:"sms_hourly_limit"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if request.DeviceLimit == nil && request.SMSHourlyLimit == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required")
|
||||
return
|
||||
}
|
||||
if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range")
|
||||
return
|
||||
}
|
||||
if request.DeviceLimit != nil {
|
||||
if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated")
|
||||
}
|
||||
if request.SMSHourlyLimit != nil {
|
||||
if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error())
|
||||
return
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated")
|
||||
}
|
||||
s.writeDeveloperSettings(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
||||
"device_limit": developer.DeviceLimit(r.Context(), s.store, true),
|
||||
"default_device_limit": developer.DefaultDeviceLimit,
|
||||
"max_device_limit": developer.MaxDeviceLimit,
|
||||
"sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store),
|
||||
"default_sms_hourly_limit": developer.DefaultSMSHourlyLimit,
|
||||
"max_sms_hourly_limit": developer.MaxSMSHourlyLimit,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) {
|
||||
server := &Server{developerEnabled: false}
|
||||
for _, handler := range []func(http.ResponseWriter, *http.Request){
|
||||
server.handleDeveloperSettings,
|
||||
server.handleHTTPSSettings,
|
||||
server.handleHTTPSCertificate,
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, httptest.NewRequest(http.MethodGet, "/api/settings/developer", nil))
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("developer-only endpoint status = %d, want 404", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeveloperSettings(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
}
|
||||
}
|
||||
+721
-100
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -10,6 +11,10 @@ import (
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
// overviewStreamInterval is the cadence at which the overview SSE stream pushes
|
||||
// a fresh snapshot. It is a package var so tests can shorten it.
|
||||
var overviewStreamInterval = 2 * time.Second
|
||||
|
||||
// beginSSE prepares a response for Server-Sent Events and returns its response
|
||||
// controller for explicit flushes.
|
||||
func beginSSE(w http.ResponseWriter) *http.ResponseController {
|
||||
@@ -50,13 +55,27 @@ func (s *Server) handleOverviewStream(
|
||||
if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil {
|
||||
return true
|
||||
}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
ticker := time.NewTicker(overviewStreamInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return true
|
||||
case <-ticker.C:
|
||||
// The config passed in was read when the stream opened. Re-read it on
|
||||
// every tick so edits made while watching (roaming data, APN, VoWiFi,
|
||||
// name…) take effect; otherwise the stream keeps replaying the stale
|
||||
// snapshot and the UI flaps between SSE-old and REST-new values.
|
||||
fresh, err := s.store.Device(r.Context(), config.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// The device was deleted while streaming; end the stream.
|
||||
return true
|
||||
}
|
||||
// Transient store hiccup: keep the last known config for this tick.
|
||||
} else {
|
||||
config = fresh
|
||||
}
|
||||
currentEntry, _, present := s.physicalForConfig(config)
|
||||
overview := s.configuredDeviceOverview(config, currentEntry, present)
|
||||
if err := writeSSEEvent(w, controller, "overview", overview); err != nil {
|
||||
@@ -81,6 +100,7 @@ func operatorCandidateWire(op device.ScannedOperator) map[string]any {
|
||||
"operatorName": op.Name,
|
||||
"shortName": op.Short,
|
||||
"plmn": op.Numeric,
|
||||
"countryCode": op.Country,
|
||||
"rats": rats,
|
||||
"includesPcsDigit": false,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/developer"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/exportproxy"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
@@ -23,6 +31,46 @@ func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]an
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func TestParseModemAPNProfiles(t *testing.T) {
|
||||
profiles := parseModemAPNProfiles([]string{
|
||||
`+CGDCONT: 1,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 2,"IP","ims","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 3,"IPV4V6","internet","0.0.0.0",0,0`,
|
||||
`+CGDCONT: 4,"IP","","0.0.0.0",0,0`,
|
||||
})
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles = %#v", profiles)
|
||||
}
|
||||
if profiles[0].CID != 1 || profiles[0].APN != "internet" || profiles[0].IPVersion != "IPV4V6" {
|
||||
t.Fatalf("first profile = %#v", profiles[0])
|
||||
}
|
||||
if profiles[1].CID != 2 || profiles[1].APN != "ims" || profiles[1].IPVersion != "IP" {
|
||||
t.Fatalf("second profile = %#v", profiles[1])
|
||||
}
|
||||
}
|
||||
|
||||
type esimAIDCaptureController struct {
|
||||
fakeDeviceController
|
||||
switchAID string
|
||||
disableAID string
|
||||
renameAID string
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMSwitchProfile(_ context.Context, _, _, aidHex string) error {
|
||||
controller.switchAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMDisableProfile(_ context.Context, _, _, aidHex string) error {
|
||||
controller.disableAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func (controller *esimAIDCaptureController) ESIMRenameProfile(_ context.Context, _, _, _, aidHex string) error {
|
||||
controller.renameAID = aidHex
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
|
||||
groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}}
|
||||
chipInfo := map[string]any{
|
||||
@@ -40,6 +88,50 @@ func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalMatchesConfigRejectsDuplicateAndroidSerialAlias(t *testing.T) {
|
||||
config := store.Device{
|
||||
ID: "EC20",
|
||||
ATPort: "/dev/serial/by-id/usb-Android_Android-if02-port0",
|
||||
USBPath: "/sys/bus/usb/devices/1-6",
|
||||
ModemIMEI: "111111111111111",
|
||||
}
|
||||
newModem := device.Device{
|
||||
ID: "quectel-0125-1-5",
|
||||
Candidate: modem.Candidate{
|
||||
USBPath: "/sys/bus/usb/devices/1-5",
|
||||
ATPort: modem.Port{
|
||||
Path: "/dev/ttyUSB6",
|
||||
StablePath: config.ATPort,
|
||||
},
|
||||
},
|
||||
Snapshot: &device.Snapshot{IMEI: "222222222222222"},
|
||||
}
|
||||
if physicalMatchesConfig(newModem, config) {
|
||||
t.Fatal("different modem matched through a duplicated Android by-id alias")
|
||||
}
|
||||
|
||||
movedOriginal := newModem
|
||||
movedOriginal.Snapshot = &device.Snapshot{IMEI: config.ModemIMEI}
|
||||
if !physicalMatchesConfig(movedOriginal, config) {
|
||||
t.Fatal("same IMEI should follow the modem to a different USB port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDiscoveredDevicePrefersPhysicalIdentityOverSerialAlias(t *testing.T) {
|
||||
alias := "/dev/serial/by-id/usb-Android_Android-if02-port0"
|
||||
devices := []device.Device{
|
||||
{ID: "old", Candidate: modem.Candidate{USBPath: "/sys/bus/usb/devices/1-6", ATPort: modem.Port{StablePath: alias}}},
|
||||
{ID: "new", Candidate: modem.Candidate{USBPath: "/sys/bus/usb/devices/1-5", ATPort: modem.Port{StablePath: alias}}},
|
||||
}
|
||||
selected := findDiscoveredDevice(devices, deviceConfigPayload{
|
||||
USBPath: "/sys/bus/usb/devices/1-5",
|
||||
ATPort: alias,
|
||||
})
|
||||
if selected == nil || selected.ID != "new" {
|
||||
t.Fatalf("selected = %#v, want new physical USB device", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOperatorScanReturnsOperators(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
@@ -204,9 +296,25 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
}
|
||||
|
||||
// Switch happy path: a present device + fake controller switches by ICCID.
|
||||
present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}}
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "dev1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const switchedICCID = "8900000000000000001"
|
||||
if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{
|
||||
ICCID: switchedICCID, VoWiFiEnabled: false, AirplaneEnabled: false,
|
||||
APN: "profile.apn", IPVersion: "IP", Source: "manual",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := &esimAIDCaptureController{}
|
||||
present := &Server{store: database, logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: controller}
|
||||
swOK := httptest.NewRecorder()
|
||||
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`))
|
||||
swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
swReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true)
|
||||
if swOK.Code != http.StatusOK {
|
||||
@@ -215,10 +323,21 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true {
|
||||
t.Fatalf("switch data = %v", data)
|
||||
}
|
||||
if controller.switchAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("switch AID = %q, want XeSIM camelCase AID", controller.switchAID)
|
||||
}
|
||||
storedPolicy, err := database.CardPolicy(context.Background(), switchedICCID)
|
||||
if err != nil || storedPolicy.VoWiFiEnabled || storedPolicy.AirplaneEnabled || storedPolicy.APN != "profile.apn" || storedPolicy.IPVersion != "IP" {
|
||||
t.Fatalf("switch overwrote saved policy: %+v, %v", storedPolicy, err)
|
||||
}
|
||||
storedDevice, err := database.Device(context.Background(), "dev1")
|
||||
if err != nil || storedDevice.VoWiFiEnabled || storedDevice.APN != "profile.apn" {
|
||||
t.Fatalf("switch did not restore device policy: %+v, %v", storedDevice, err)
|
||||
}
|
||||
|
||||
// Disable happy path routes the active profile to ES10c DisableProfile.
|
||||
disableOK := httptest.NewRecorder()
|
||||
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
|
||||
disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
disableReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true)
|
||||
if disableOK.Code != http.StatusOK {
|
||||
@@ -227,10 +346,13 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true {
|
||||
t.Fatalf("disable data = %v", data)
|
||||
}
|
||||
if controller.disableAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("disable AID = %q, want XeSIM camelCase AID", controller.disableAID)
|
||||
}
|
||||
|
||||
// Rename happy path routes PATCH to ES10c SetNickname support.
|
||||
renameOK := httptest.NewRecorder()
|
||||
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`))
|
||||
renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aidHex":"A0000005591010FFFFFFFF8900000177"}`))
|
||||
renameReq.Header.Set("Content-Type", "application/json")
|
||||
present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true)
|
||||
if renameOK.Code != http.StatusOK {
|
||||
@@ -239,6 +361,9 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" {
|
||||
t.Fatalf("rename data = %v", data)
|
||||
}
|
||||
if controller.renameAID != "A0000005591010FFFFFFFF8900000177" {
|
||||
t.Fatalf("rename AID = %q, want XeSIM camelCase AID", controller.renameAID)
|
||||
}
|
||||
|
||||
// Download on a present device but with no smdp address reports 400.
|
||||
dlNoSmdp := httptest.NewRecorder()
|
||||
@@ -248,6 +373,64 @@ func TestHandleESIMShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEsimNotificationController struct {
|
||||
fakeDeviceController
|
||||
items []device.EsimNotification
|
||||
listErr error
|
||||
retryErr error
|
||||
retryDeviceID string
|
||||
retryAID string
|
||||
retrySequence uint64
|
||||
}
|
||||
|
||||
func (f *fakeEsimNotificationController) ESIMNotifications(context.Context, string) ([]device.EsimNotification, error) {
|
||||
return f.items, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeEsimNotificationController) ESIMRetryNotification(_ context.Context, deviceID, aidHex string, sequenceNumber uint64) error {
|
||||
f.retryDeviceID = deviceID
|
||||
f.retryAID = aidHex
|
||||
f.retrySequence = sequenceNumber
|
||||
return f.retryErr
|
||||
}
|
||||
|
||||
func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||
SequenceNumber: 12,
|
||||
Event: "delete",
|
||||
ICCID: "89441000400128014257",
|
||||
Address: "rsp.example.com",
|
||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||
CanRetry: true,
|
||||
}}}
|
||||
server := &Server{logger: regionTestLogger(), devices: controller}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
server.handleESIM(list, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", true)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
data := decodeData(t, list)
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("items = %#v", data["items"])
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
if item["sequenceNumber"] != float64(12) || item["event"] != "delete" || item["address"] != "rsp.example.com" {
|
||||
t.Fatalf("item = %#v", item)
|
||||
}
|
||||
|
||||
retry := httptest.NewRecorder()
|
||||
retryRequest := httptest.NewRequest(http.MethodPost, "/esim/notifications/12/actions/retry?aid_hex=A000", nil)
|
||||
server.handleESIM(retry, retryRequest, []string{"notifications", "12", "actions", "retry"}, "dev1", true)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("retry status = %d, body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
if controller.retryDeviceID != "dev1" || controller.retryAID != "A000" || controller.retrySequence != 12 {
|
||||
t.Fatalf("retry args = (%q, %q, %d)", controller.retryDeviceID, controller.retryAID, controller.retrySequence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFixUSBNet(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
@@ -278,6 +461,83 @@ func TestHandleUpdateApplyIsSafeNoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateCheckUsesTrustedRepository(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: regionTestLogger(),
|
||||
updateRepository: update.DefaultRepository,
|
||||
updateCheck: func(_ context.Context, repo, token, current string) (update.CheckResult, error) {
|
||||
if repo != update.DefaultRepository || token != "token" || current == "" {
|
||||
t.Fatalf("check arguments = %q, %q, %q", repo, token, current)
|
||||
}
|
||||
return update.CheckResult{
|
||||
Available: true,
|
||||
Current: current,
|
||||
Latest: "9.9.9",
|
||||
ReleaseNotes: "release notes",
|
||||
}, nil
|
||||
},
|
||||
updateToken: "token",
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUpdateCheck(recorder, httptest.NewRequest(http.MethodGet, "/check", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
if data["available"] != true || data["version"] != "9.9.9" || data["repository"] != update.DefaultRepository {
|
||||
t.Fatalf("check data = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateApplyInstallsFromTrustedRepository(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.SetAdmin(context.Background(), "admin", []byte("hash")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenHash := []byte("active-session")
|
||||
if err := database.CreateSession(
|
||||
context.Background(), 1, tokenHash, []byte("csrf"), time.Now().Add(time.Hour),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{
|
||||
store: database,
|
||||
logger: regionTestLogger(),
|
||||
updateRepository: update.DefaultRepository,
|
||||
updateApply: func(_ context.Context, _ *slog.Logger, options update.Options, restart bool) (update.CheckResult, error) {
|
||||
if options.Repo != update.DefaultRepository || restart {
|
||||
t.Fatalf("apply options = %#v, restart = %v", options, restart)
|
||||
}
|
||||
return update.CheckResult{Applied: true, Latest: "9.9.9"}, nil
|
||||
},
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
server.handleUpdateApply(recorder, httptest.NewRequest(http.MethodPost, "/apply", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
if data["applied"] != true || data["version"] != "9.9.9" || data["reauthentication_required"] != true {
|
||||
t.Fatalf("apply data = %#v", data)
|
||||
}
|
||||
if _, err := database.SessionByTokenHash(context.Background(), tokenHash); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("session must be revoked after update, got %v", err)
|
||||
}
|
||||
expired := map[string]bool{}
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.MaxAge < 0 {
|
||||
expired[cookie.Name] = true
|
||||
}
|
||||
}
|
||||
if !expired[sessionCookieName] || !expired[csrfCookieName] {
|
||||
t.Fatalf("auth cookies were not expired: %#v", recorder.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestE911WebsheetFlow(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
@@ -341,3 +601,230 @@ func TestE911WebsheetRejectsBadToken(t *testing.T) {
|
||||
t.Fatalf("bad token status = %d, want 403", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// readSSEEvent reads one Server-Sent-Events frame ("event:"/"data:" lines
|
||||
// terminated by a blank line) and returns the event name and data payload.
|
||||
func readSSEEvent(reader *bufio.Reader) (string, []byte, error) {
|
||||
var event string
|
||||
var data []byte
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
if event != "" || data != nil {
|
||||
return event, data, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, "event: "); ok {
|
||||
event = rest
|
||||
} else if rest, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
data = append(data, rest...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// awaitOverviewNetworkEnabled reads overview SSE events until one reports the
|
||||
// requested network_enabled value, or the stream ends / the request times out.
|
||||
func awaitOverviewNetworkEnabled(reader *bufio.Reader, want bool) error {
|
||||
for {
|
||||
event, data, err := readSSEEvent(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if event != "overview" {
|
||||
continue
|
||||
}
|
||||
var overview struct {
|
||||
NetworkEnabled bool `json:"network_enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &overview); err != nil {
|
||||
return err
|
||||
}
|
||||
if overview.NetworkEnabled == want {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The overview SSE stream must reflect edits made after it opened. Before the
|
||||
// fix it rebuilt every tick from the config snapshot captured when the stream
|
||||
// opened, so toggling roaming data off was immediately overwritten by the stale
|
||||
// "on" snapshot and the switch flapped. This test opens the stream with roaming
|
||||
// data on, turns it off in the store, and requires the stream to keep reporting
|
||||
// the new "off" state.
|
||||
func TestHandleOverviewStreamReflectsConfigChanges(t *testing.T) {
|
||||
previousInterval := overviewStreamInterval
|
||||
overviewStreamInterval = 10 * time.Millisecond
|
||||
t.Cleanup(func() { overviewStreamInterval = previousInterval })
|
||||
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{
|
||||
Key: developer.EnabledSettingKey,
|
||||
Value: []byte(`{"enabled":true}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, store.Device{ID: "dev1", Name: "Test device", NetworkEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: database, logger: regionTestLogger(), developerEnabled: true}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
config, err := database.Device(r.Context(), "dev1")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
}
|
||||
server.handleOverviewStream(w, r, config, device.Device{}, false)
|
||||
})
|
||||
testServer := httptest.NewServer(mux)
|
||||
t.Cleanup(testServer.Close)
|
||||
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, testServer.URL+"/stream", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = response.Body.Close() })
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("stream status = %d", response.StatusCode)
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
|
||||
// The stream opens with roaming data enabled.
|
||||
if err := awaitOverviewNetworkEnabled(reader, true); err != nil {
|
||||
t.Fatalf("initial overview never reported network_enabled=true: %v", err)
|
||||
}
|
||||
|
||||
// Turn roaming data off; the very next ticks must report the new state
|
||||
// instead of replaying the stale enabled snapshot.
|
||||
config, err := database.Device(ctx, "dev1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.NetworkEnabled = false
|
||||
if err := database.UpsertDevice(ctx, config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitOverviewNetworkEnabled(reader, false); err != nil {
|
||||
t.Fatalf("overview kept replaying stale network_enabled=true after the edit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Turning roaming data off must be refused while an enabled export proxy is
|
||||
// bound to the device; the user has to disable that binding first.
|
||||
func TestHandleCellularDataRejectsDisableWhileExportProxyActive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{
|
||||
Key: developer.EnabledSettingKey, Value: json.RawMessage(`{"enabled":true}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceConfig := store.Device{ID: "modem-1", Name: "modem-1", Interface: "wwan0", NetworkEnabled: true}
|
||||
if err := database.UpsertDevice(ctx, deviceConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed an already-enabled export proxy bound to the device. New only logs a
|
||||
// warning when the Linux-only listener cannot start on this platform, so the
|
||||
// enabled config still loads and the interlock sees it.
|
||||
seeded, err := json.Marshal([]exportproxy.Config{{
|
||||
ID: "proxy-1", Name: "proxy-1", DeviceID: "modem-1", Interface: "wwan0",
|
||||
Mode: "socks5", ListenHost: "127.0.0.1", ListenPort: 1080, Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: exportproxy.SettingKey, Value: seeded, Sensitive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxyManager, err := exportproxy.New(ctx, database, regionTestLogger(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = proxyManager.Close() })
|
||||
server := &Server{
|
||||
store: database,
|
||||
logger: regionTestLogger(),
|
||||
developerEnabled: true,
|
||||
exportProxy: proxyManager,
|
||||
devices: fakeDeviceController{},
|
||||
maxRequestBodyBytes: 1 << 20,
|
||||
}
|
||||
|
||||
patchOff := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPatch, "/api/devices/modem-1/cellular-data", strings.NewReader(`{"enabled":false}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if !server.handleCellularData(recorder, request, deviceConfig, "physical-1") {
|
||||
t.Fatal("handleCellularData did not handle the request")
|
||||
}
|
||||
return recorder
|
||||
}
|
||||
|
||||
// While the export proxy is enabled, turning roaming data off is rejected and
|
||||
// the stored config keeps roaming data on.
|
||||
recorder := patchOff()
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("disable with active proxy status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
var failure struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &failure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failure.Error.Code != "export_proxy_active" {
|
||||
t.Fatalf("error code = %q, body = %s", failure.Error.Code, recorder.Body)
|
||||
}
|
||||
stored, err := database.Device(ctx, "modem-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.NetworkEnabled {
|
||||
t.Fatal("roaming data was turned off despite the active export proxy")
|
||||
}
|
||||
|
||||
// Once the binding is disabled, the same request goes through.
|
||||
proxies, err := proxyManager.Configs()
|
||||
if err != nil || len(proxies) != 1 {
|
||||
t.Fatalf("configs = %+v, %v", proxies, err)
|
||||
}
|
||||
disabled := proxies[0]
|
||||
disabled.Enabled = false
|
||||
if _, err := proxyManager.Update(ctx, disabled.ID, disabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder = patchOff()
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("disable after proxy off status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err = database.Device(ctx, "modem-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.NetworkEnabled {
|
||||
t.Fatal("roaming data was not turned off after the export proxy was disabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: "stopping",
|
||||
ICCID: "89441000400128014257",
|
||||
IMSI: "234159608751160",
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
SMSReady: true,
|
||||
LocalPhone: "+447386083638",
|
||||
PhoneNumberSource: "ims_p_associated_uri",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{store: database}
|
||||
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{
|
||||
ICCID: "89104100000028106378",
|
||||
IMSI: "310380500712483",
|
||||
}}
|
||||
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1"}, entry)
|
||||
if got["vowifi_active"] != false {
|
||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||
}
|
||||
if got["local_phone"] == "+447386083638" {
|
||||
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
||||
}
|
||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||
if !ok || runtime["phase"] != "idle" || runtime["iccid"] != "89104100000028106378" {
|
||||
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryPrefersLiveVoWiFiStateOverStoredShutdownState(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20_1", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: "idle",
|
||||
ICCID: "89104100000028106378",
|
||||
LastReason: "disabled",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live := vowifi.State{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: vowifi.PhaseTunnelReady,
|
||||
Enabled: true,
|
||||
Active: true,
|
||||
ICCID: "89104100000028106378",
|
||||
SIMReady: true,
|
||||
AccessReady: true,
|
||||
TunnelReady: true,
|
||||
LastReason: "ipsec_tunnel_ready",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
s := &Server{store: database, vowifi: &fakeVoWiFiController{state: live}}
|
||||
entry := &device.Device{ID: "physical", Snapshot: &device.Snapshot{ICCID: live.ICCID}}
|
||||
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, entry)
|
||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||
if !ok || runtime["phase"] != string(vowifi.PhaseTunnelReady) || runtime["enabled"] != true {
|
||||
t.Fatalf("runtime = %#v", got["vowifi_runtime"])
|
||||
}
|
||||
if got["vowifi_active"] != true {
|
||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
|
||||
database, err := store.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
s := &Server{
|
||||
store: database,
|
||||
vowifi: &fakeVoWiFiController{state: vowifi.State{
|
||||
DeviceID: "ec20_1",
|
||||
Phase: vowifi.PhaseIdle,
|
||||
Enabled: false,
|
||||
LastReason: "disabled",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}},
|
||||
}
|
||||
got := s.configuredDeviceSummary(store.Device{ID: "ec20_1", VoWiFiEnabled: true}, nil)
|
||||
runtime := got["vowifi_runtime"].(map[string]any)
|
||||
if runtime["enabled"] != false || got["vowifi_active"] != false {
|
||||
t.Fatalf("summary = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{IMEI: "867123456789012"},
|
||||
{IMEI: "867123456789012", SIMStatus: "unknown"},
|
||||
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
|
||||
} {
|
||||
if snapshotHasSIM(snapshot) {
|
||||
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
for _, snapshot := range []*device.Snapshot{
|
||||
{SIMStatus: "pin_required"},
|
||||
{ICCID: "89441000400128014257"},
|
||||
{SIMReady: true},
|
||||
} {
|
||||
if !snapshotHasSIM(snapshot) {
|
||||
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writePlainTextMail constructs one RFC 5322 message without allowing values
|
||||
// supplied by notification configuration or device messages to create new
|
||||
// headers or MIME parts. Mailbox values have already passed net/mail parsing,
|
||||
// the subject is encoded as one encoded-word, and the body is base64 encoded.
|
||||
func writePlainTextMail(
|
||||
writer io.Writer,
|
||||
from *mail.Address,
|
||||
recipients []*mail.Address,
|
||||
subject string,
|
||||
body string,
|
||||
) error {
|
||||
if from == nil || len(recipients) == 0 {
|
||||
return errors.New("email sender and recipient are required")
|
||||
}
|
||||
if strings.ContainsAny(subject, "\r\n\x00") {
|
||||
return errors.New("email subject contains a prohibited control character")
|
||||
}
|
||||
fromHeader, err := validatedMailHeaderAddress(from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email sender: %w", err)
|
||||
}
|
||||
recipientHeaders := make([]string, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
header, err := validatedMailHeaderAddress(recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email recipient: %w", err)
|
||||
}
|
||||
recipientHeaders = append(recipientHeaders, header)
|
||||
}
|
||||
encodedBody := wrapMIMEBase64(base64.StdEncoding.EncodeToString([]byte(body)))
|
||||
message := strings.Join([]string{
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"From: " + fromHeader,
|
||||
"To: " + strings.Join(recipientHeaders, ", "),
|
||||
"Subject: " + mime.QEncoding.Encode("UTF-8", subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
encodedBody,
|
||||
"",
|
||||
}, "\r\n")
|
||||
|
||||
// The only values reaching this sink have been parsed as RFC mailboxes or
|
||||
// encoded as MIME encoded-words/base64 above. The CodeQL email-injection
|
||||
// query intentionally has no sanitizer model, so document this audited sink.
|
||||
// codeql[go/email-injection]
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
return fmt.Errorf("write email message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatedMailHeaderAddress keeps writePlainTextMail safe even if a future
|
||||
// caller constructs mail.Address directly instead of using parseMailAddress.
|
||||
func validatedMailHeaderAddress(address *mail.Address) (string, error) {
|
||||
if address == nil || address.Address == "" || strings.TrimSpace(address.Address) != address.Address ||
|
||||
strings.ContainsAny(address.Address, "\r\n\x00") {
|
||||
return "", errors.New("email address contains a prohibited control character")
|
||||
}
|
||||
parsed, err := mail.ParseAddress(address.Address)
|
||||
if err != nil || parsed.Name != "" || parsed.Address != address.Address {
|
||||
return "", errors.New("invalid email address")
|
||||
}
|
||||
for _, character := range address.Name {
|
||||
if character < 0x20 || character == 0x7f {
|
||||
return "", errors.New("email display name contains a prohibited control character")
|
||||
}
|
||||
}
|
||||
return formatMailAddress(address), nil
|
||||
}
|
||||
|
||||
func wrapMIMEBase64(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
const lineLength = 76
|
||||
lines := make([]string, 0, (len(value)+lineLength-1)/lineLength)
|
||||
for len(value) > lineLength {
|
||||
lines = append(lines, value[:lineLength])
|
||||
value = value[lineLength:]
|
||||
}
|
||||
lines = append(lines, value)
|
||||
return strings.Join(lines, "\r\n")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user