mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e68dc6893 | ||
|
|
1fc6ea9b6c | ||
|
|
85f8790e1e | ||
|
|
3d117749b2 | ||
|
|
3604319faa | ||
|
|
9fc3f1c5b8 | ||
|
|
93b0cf718c | ||
|
|
3939b061af | ||
|
|
03c8a2ceae | ||
|
|
d7a9fc9774 | ||
|
|
147721f237 | ||
|
|
e24be6ef29 | ||
|
|
6f2b7bf395 |
@@ -72,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"
|
||||
|
||||
@@ -33,9 +33,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/
|
||||
|
||||
|
||||
+10
-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 \
|
||||
|
||||
@@ -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.
|
||||
@@ -64,23 +66,23 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
|
||||
### One-click Linux installation
|
||||
|
||||
```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 | sudo bash
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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 +101,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,9 +110,17 @@ 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
|
||||
@@ -161,7 +172,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.
|
||||
@@ -249,7 +260,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
|
||||
@@ -289,6 +300,11 @@ 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
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+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, 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,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/config"
|
||||
"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.enabled"
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
setting, err := database.AppSetting(ctx, developerEnabledSettingKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
fmt.Fprintf(os.Stderr, "vocat: read developer flag failed; plugin system stays off: %v\n", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
var document struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(setting.Value, &document); err != nil {
|
||||
return false
|
||||
}
|
||||
return document.Enabled
|
||||
}
|
||||
+60
-2
@@ -9,13 +9,17 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/config"
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/server"
|
||||
"vocat/internal/store"
|
||||
@@ -35,8 +39,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 +74,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:
|
||||
@@ -91,6 +120,25 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 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.
|
||||
developerEnabled := isDeveloperEnabled(startupContext, database)
|
||||
var extensionManager *extensions.Manager
|
||||
if developerEnabled {
|
||||
extensionManager, err = extensions.NewManager(
|
||||
filepath.Join(filepath.Dir(cfg.DatabasePath), "plugins"),
|
||||
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,
|
||||
})
|
||||
@@ -154,6 +202,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
Logger: logger,
|
||||
SecureCookies: cfg.SecureCookies,
|
||||
MaxRequestBodyBytes: cfg.MaxRequestBodyBytes,
|
||||
Extensions: extensionManager,
|
||||
DeveloperEnabled: developerEnabled,
|
||||
UpdateRepository: strings.TrimSpace(os.Getenv("VOCAT_REPO")),
|
||||
UpdateToken: strings.TrimSpace(os.Getenv("GITHUB_TOKEN")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -196,6 +248,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
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(),
|
||||
@@ -302,6 +358,7 @@ func newVoWiFiOrchestrator(
|
||||
_, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: message.MessageID,
|
||||
DeviceID: message.DeviceID,
|
||||
ModemIMEI: deviceConfig.ModemIMEI,
|
||||
IMSI: message.IMSI,
|
||||
Peer: message.From,
|
||||
Direction: "inbound",
|
||||
@@ -318,6 +375,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",
|
||||
|
||||
+192
-43
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -16,18 +17,62 @@ 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"
|
||||
|
||||
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(envFilePath); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMenu is the interactive lifecycle menu: toggle language, change password,
|
||||
// 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 +82,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 +105,69 @@ 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 := menuRestart(menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "0", "":
|
||||
fmt.Println(menu.bye())
|
||||
return nil
|
||||
case "4":
|
||||
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 {
|
||||
@@ -212,6 +287,45 @@ func rewriteEnvPassword(newPassword string) error {
|
||||
return os.Rename(tmpName, envFilePath)
|
||||
}
|
||||
|
||||
// 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 := exec.LookPath("systemctl"); err != nil {
|
||||
return errNoSystemctl
|
||||
@@ -224,6 +338,23 @@ func menuRestart(m *menu) error {
|
||||
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
|
||||
}
|
||||
|
||||
// menuUninstall performs full removal: stop/disable the unit, delete the unit,
|
||||
// remove /opt/vocat (binary + data + SQLite DB), remove the env file, reload
|
||||
// systemd, and best-effort delete the vocat user.
|
||||
@@ -261,6 +392,7 @@ var (
|
||||
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")
|
||||
@@ -277,19 +409,24 @@ 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_restart": {"3) 重启软件", "3) Restart software"},
|
||||
"opt_update": {"4) 更新软件", "4) 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."},
|
||||
"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!",
|
||||
@@ -311,11 +448,12 @@ func (m *menu) msg(key string) string {
|
||||
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) 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") }
|
||||
@@ -323,7 +461,13 @@ func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled")
|
||||
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
|
||||
|
||||
func (m *menu) options() []string {
|
||||
return []string{m.msg("opt_change"), m.msg("opt_restart"), m.msg("opt_uninstall"), m.msg("opt_exit")}
|
||||
return []string{
|
||||
m.msg("opt_lang"),
|
||||
m.msg("opt_change"),
|
||||
m.msg("opt_restart"),
|
||||
m.msg("opt_update"),
|
||||
m.msg("opt_uninstall"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *menu) errorPrefix(err error) string {
|
||||
@@ -348,6 +492,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."
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<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 一键安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
安装指定版本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `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,518 @@
|
||||
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"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxPackageBytes int64 = 64 << 20
|
||||
|
||||
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: &http.Client{Timeout: 45 * time.Second},
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
|
||||
return Plugin{}, errors.New("plugin URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
filename := filepath.Join(plugin.dir, filepath.FromSlash(name))
|
||||
if !strings.HasPrefix(filepath.Clean(filename), filepath.Clean(plugin.dir)+string(os.PathSeparator)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file, err := os.Open(filename)
|
||||
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(filename))
|
||||
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,100 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
+37
-21
@@ -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)
|
||||
}
|
||||
@@ -240,30 +241,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 +295,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,54 @@ 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 TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices")
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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 < time.Second || duration > maxCallDuration {
|
||||
writeError(w, http.StatusBadRequest, "invalid_duration", "duration_seconds must be 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
|
||||
}
|
||||
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" {
|
||||
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 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 {
|
||||
if state, err := s.vowifi.State(deviceID); err == nil && state.Enabled {
|
||||
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,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,18 +251,33 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
func findDiscoveredDevice(devices []device.Device, config deviceConfigPayload) *device.Device {
|
||||
if config.ModemIMEI != "" {
|
||||
for index := range devices {
|
||||
if devices[index].Snapshot != nil && devices[index].Snapshot.IMEI == config.ModemIMEI {
|
||||
return &devices[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.USBPath != "" {
|
||||
for index := range devices {
|
||||
if devices[index].Candidate.USBPath == config.USBPath {
|
||||
return &devices[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.ControlDevice != "" {
|
||||
for index := range devices {
|
||||
if devices[index].Candidate.QMIControl == config.ControlDevice {
|
||||
return &devices[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range devices {
|
||||
candidate := devices[index].Candidate
|
||||
if config.ATPort != "" &&
|
||||
(candidate.ATPort.Path == config.ATPort || candidate.ATPort.OpenPath() == config.ATPort) {
|
||||
return &devices[index]
|
||||
}
|
||||
if config.ControlDevice != "" && candidate.QMIControl == config.ControlDevice {
|
||||
return &devices[index]
|
||||
}
|
||||
if config.USBPath != "" && candidate.USBPath == config.USBPath {
|
||||
return &devices[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -502,6 +517,16 @@ func (s *Server) handleDevicePath(
|
||||
return s.handleVoWiFiReconnect(w, r, config, physicalPresent)
|
||||
case "vowifi/e911/websheet":
|
||||
return s.handleE911Websheet(w, r, config)
|
||||
case "calls":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleCalls(w, r, config, physicalID)
|
||||
case "calls/dial", "calls/answer", "calls/hangup":
|
||||
if !s.requirePhysicalDevice(w, physicalPresent) {
|
||||
return true
|
||||
}
|
||||
return s.handleCallAction(w, r, config, physicalID, tail[1])
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1043,6 +1068,14 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
|
||||
if entry.ID == config.ID {
|
||||
return true
|
||||
}
|
||||
if config.ModemIMEI != "" && entry.Snapshot != nil && entry.Snapshot.IMEI != "" {
|
||||
return config.ModemIMEI == entry.Snapshot.IMEI
|
||||
}
|
||||
if config.USBPath != "" && candidate.USBPath != "" {
|
||||
return config.USBPath == candidate.USBPath
|
||||
}
|
||||
// Control and serial device nodes are allocation-order dependent. They are
|
||||
// only legacy fallbacks when no physical USB path or readable IMEI exists.
|
||||
if config.ATPort != "" &&
|
||||
(config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) {
|
||||
return true
|
||||
@@ -1050,12 +1083,7 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool {
|
||||
if config.ControlDevice != "" && config.ControlDevice == candidate.QMIControl {
|
||||
return true
|
||||
}
|
||||
if config.USBPath != "" && config.USBPath == candidate.USBPath {
|
||||
return true
|
||||
}
|
||||
return config.ModemIMEI != "" &&
|
||||
entry.Snapshot != nil &&
|
||||
config.ModemIMEI == entry.Snapshot.IMEI
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) configuredDeviceSummary(
|
||||
|
||||
@@ -3,13 +3,18 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
@@ -40,6 +45,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(),
|
||||
@@ -278,6 +327,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 {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxPluginUploadBytes int64 = 64 << 20
|
||||
|
||||
func (s *Server) routeExtensionAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
|
||||
if cleanPath == "extensions" {
|
||||
if s.extensions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
|
||||
return true
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": s.extensions.List()})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
return true
|
||||
}
|
||||
if cleanPath == "extensions/install-url" {
|
||||
if s.extensions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
|
||||
return true
|
||||
}
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
var request struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
ctx, cancel := contextWithTimeout(r, 60*time.Second)
|
||||
defer cancel()
|
||||
plugin, err := s.extensions.InstallURL(ctx, request.URL, request.SHA256)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_install_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "plugin.install_url", "plugin", plugin.ID, "success", request.URL)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"data": plugin})
|
||||
return true
|
||||
}
|
||||
if cleanPath == "extensions/upload" {
|
||||
if s.extensions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
|
||||
return true
|
||||
}
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return true
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "plugin upload must be multipart/form-data and no larger than 64 MiB")
|
||||
return true
|
||||
}
|
||||
file, _, err := r.FormFile("package")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_plugin_upload", "multipart field package is required")
|
||||
return true
|
||||
}
|
||||
defer file.Close()
|
||||
plugin, err := s.extensions.Install(io.LimitReader(file, maxPluginUploadBytes+1), r.FormValue("sha256"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "plugin_install_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
s.recordAudit(r.Context(), "admin", "plugin.upload", "plugin", plugin.ID, "success", "upload")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"data": plugin})
|
||||
return true
|
||||
}
|
||||
|
||||
segments := splitAPIPath(cleanPath)
|
||||
if len(segments) < 2 || segments[0] != "extensions" {
|
||||
return false
|
||||
}
|
||||
if s.extensions == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "extensions_unavailable", "plugin manager is unavailable")
|
||||
return true
|
||||
}
|
||||
id := segments[1]
|
||||
if len(segments) >= 3 && segments[2] == "backend" {
|
||||
s.extensions.ProxyBackend(w, r, id)
|
||||
return true
|
||||
}
|
||||
if len(segments) != 2 {
|
||||
writeError(w, http.StatusNotFound, "not_found", "plugin endpoint not found")
|
||||
return true
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var request struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := s.decodeJSON(w, r, &request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return true
|
||||
}
|
||||
plugin, err := s.extensions.SetEnabled(id, request.Enabled)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeError(w, http.StatusNotFound, "plugin_not_found", "plugin not found")
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "plugin_state_failed", err.Error())
|
||||
return true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": plugin})
|
||||
case http.MethodDelete:
|
||||
if err := s.extensions.Uninstall(id); errors.Is(err, os.ErrNotExist) {
|
||||
writeError(w, http.StatusNotFound, "plugin_not_found", "plugin not found")
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "plugin_uninstall_failed", err.Error())
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"uninstalled": true}})
|
||||
}
|
||||
default:
|
||||
w.Header().Set("Allow", "PUT, DELETE")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handlePluginAsset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
return
|
||||
}
|
||||
if !s.requireAuthenticated(w, r) {
|
||||
return
|
||||
}
|
||||
if s.extensions == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/plugin-assets/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.extensions.ServeAsset(w, r, parts[0], parts[1])
|
||||
}
|
||||
|
||||
func contextWithTimeout(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), timeout)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -17,10 +18,14 @@ import (
|
||||
"vocat/internal/i18n"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool {
|
||||
cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/")
|
||||
if s.routeExtensionAPI(w, r, cleanPath) {
|
||||
return true
|
||||
}
|
||||
if s.routeSMSAPI(w, r, cleanPath) {
|
||||
return true
|
||||
}
|
||||
@@ -309,6 +314,7 @@ func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
"os": runtime.GOOS,
|
||||
"architecture": runtime.GOARCH,
|
||||
"uptime": formatDuration(time.Since(s.startedAt)),
|
||||
"developer": s.developerEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -317,28 +323,135 @@ func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.updateRepository) == "" || s.updateCheck == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"available": false,
|
||||
"version": buildinfo.Version,
|
||||
"message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
result, err := s.updateCheck(
|
||||
ctx,
|
||||
s.updateRepository,
|
||||
s.updateToken,
|
||||
buildinfo.Version,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("check for updates failed", "repository", s.updateRepository, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "update_check_failed", err.Error())
|
||||
return
|
||||
}
|
||||
message := ""
|
||||
if result.Available {
|
||||
message = result.ReleaseNotes
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"available": false,
|
||||
"version": buildinfo.Version,
|
||||
"message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"),
|
||||
"available": result.Available,
|
||||
"current_version": result.Current,
|
||||
"version": result.Latest,
|
||||
"message": message,
|
||||
"repository": s.updateRepository,
|
||||
"is_docker": runningInDocker(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateApply deliberately performs no update. Without a configured,
|
||||
// trusted update channel the product never downloads or executes code, so an
|
||||
// apply request is acknowledged as a safe no-op rather than acted on.
|
||||
func runningInDocker() bool {
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("VOCAT_CONTAINER")), "docker")
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateApply(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.updateRepository) == "" || s.updateApply == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"applied": false,
|
||||
"message": i18n.T("未配置受信任的软件更新源;未执行任何更新。"),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if runningInDocker() {
|
||||
writeError(w, http.StatusConflict, "container_update_required", "pull the latest container image and recreate the container")
|
||||
return
|
||||
}
|
||||
s.updateMu.Lock()
|
||||
if s.updateApplying {
|
||||
s.updateMu.Unlock()
|
||||
writeError(w, http.StatusConflict, "update_busy", "another update is already in progress")
|
||||
return
|
||||
}
|
||||
s.updateApplying = true
|
||||
s.updateMu.Unlock()
|
||||
defer func() {
|
||||
s.updateMu.Lock()
|
||||
s.updateApplying = false
|
||||
s.updateMu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
result, err := s.updateApply(ctx, s.logger, update.Options{
|
||||
Repo: s.updateRepository,
|
||||
Token: s.updateToken,
|
||||
}, false)
|
||||
if err != nil {
|
||||
s.logger.Error("apply update failed", "repository", s.updateRepository, "error", err)
|
||||
writeError(w, http.StatusBadGateway, "update_apply_failed", err.Error())
|
||||
return
|
||||
}
|
||||
if !result.Applied {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"applied": false,
|
||||
"version": result.Latest,
|
||||
"message": "The installed version is already current.",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
// A binary update changes the trusted server code underneath every active
|
||||
// browser/API session. Revoke every durable token before scheduling the
|
||||
// restart and expire this client's cookies so all users must authenticate
|
||||
// against the newly installed version.
|
||||
if err := s.store.DeleteAllSessions(r.Context()); err != nil {
|
||||
s.logger.Error("revoke sessions after update failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update_session_revocation_failed", "The update was installed, but active sessions could not be revoked; restart the service and sign in again.")
|
||||
return
|
||||
}
|
||||
s.clearAuthCookies(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": map[string]any{
|
||||
"applied": false,
|
||||
"message": i18n.T("未配置受信任的软件更新源;未执行任何更新。"),
|
||||
"applied": true,
|
||||
"version": result.Latest,
|
||||
"reauthentication_required": true,
|
||||
"message": "Update verified and installed; all sessions were revoked and the service is restarting.",
|
||||
},
|
||||
})
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
if s.updateRestart != nil {
|
||||
restart := s.updateRestart
|
||||
logger := s.logger
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
if err := restart(logger); err != nil {
|
||||
logger.Error("restart after update failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
"time"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/extensions"
|
||||
"vocat/internal/loghub"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/update"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
@@ -39,6 +41,10 @@ type Options struct {
|
||||
Logger *slog.Logger
|
||||
SecureCookies bool
|
||||
MaxRequestBodyBytes int64
|
||||
Extensions *extensions.Manager
|
||||
DeveloperEnabled bool
|
||||
UpdateRepository string
|
||||
UpdateToken string
|
||||
}
|
||||
|
||||
// Server is the single HTTP handler for the JSON API and embedded SPA.
|
||||
@@ -60,6 +66,15 @@ type Server struct {
|
||||
accessMu sync.RWMutex
|
||||
access parsedAccessConfig
|
||||
loginLimiter *loginRateLimiter
|
||||
extensions *extensions.Manager
|
||||
developerEnabled bool
|
||||
updateRepository string
|
||||
updateToken string
|
||||
updateCheck func(context.Context, string, string, string) (update.CheckResult, error)
|
||||
updateApply func(context.Context, *slog.Logger, update.Options, bool) (update.CheckResult, error)
|
||||
updateRestart func(*slog.Logger) error
|
||||
updateMu sync.Mutex
|
||||
updateApplying bool
|
||||
}
|
||||
|
||||
func New(options Options) (*Server, error) {
|
||||
@@ -82,6 +97,9 @@ func New(options Options) (*Server, error) {
|
||||
if options.MaxRequestBodyBytes <= 0 {
|
||||
options.MaxRequestBodyBytes = 1 << 20
|
||||
}
|
||||
if strings.TrimSpace(options.UpdateRepository) == "" {
|
||||
options.UpdateRepository = update.DefaultRepository
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
store: options.Store,
|
||||
@@ -98,6 +116,13 @@ func New(options Options) (*Server, error) {
|
||||
startedAt: time.Now().UTC(),
|
||||
websheets: newWebsheetManager(),
|
||||
loginLimiter: newLoginRateLimiter(),
|
||||
extensions: options.Extensions,
|
||||
developerEnabled: options.DeveloperEnabled,
|
||||
updateRepository: strings.TrimSpace(options.UpdateRepository),
|
||||
updateToken: strings.TrimSpace(options.UpdateToken),
|
||||
updateCheck: update.CheckLatest,
|
||||
updateApply: update.ApplyLatest,
|
||||
updateRestart: update.RestartService,
|
||||
}
|
||||
server.loadAccessConfig(context.Background())
|
||||
server.loadUILanguage(context.Background())
|
||||
@@ -110,6 +135,7 @@ func New(options Options) (*Server, error) {
|
||||
mux.HandleFunc("/api", server.handleAPI)
|
||||
mux.HandleFunc("/api/", server.handleAPI)
|
||||
mux.HandleFunc("/websheets/", server.handleWebsheet)
|
||||
mux.HandleFunc("/plugin-assets/", server.handlePluginAsset)
|
||||
mux.HandleFunc("/", server.handleSPA)
|
||||
|
||||
server.handler = server.recoverPanics(
|
||||
@@ -127,6 +153,13 @@ type VoWiFiController interface {
|
||||
RequestReconnect(string) (vowifi.State, error)
|
||||
}
|
||||
|
||||
type VoWiFiCallController interface {
|
||||
Calls(string) ([]vowifi.Call, error)
|
||||
DialCall(context.Context, string, string) (vowifi.Call, error)
|
||||
AnswerCall(context.Context, string, string) (vowifi.Call, error)
|
||||
HangupCall(context.Context, string, string) error
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.handler.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -528,7 +561,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if strings.HasPrefix(r.URL.Path, "/websheets/") {
|
||||
if strings.HasPrefix(r.URL.Path, "/websheets/") || strings.HasPrefix(r.URL.Path, "/plugin-assets/") {
|
||||
// The self-hosted E911 websheet is embedded in an iframe by the SPA, so
|
||||
// it must be frameable same-origin. Every other route stays DENY.
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
|
||||
@@ -255,8 +255,8 @@ func validateNotificationField(
|
||||
return fmt.Errorf("%s is too long or contains invalid characters", field)
|
||||
}
|
||||
if name == "base_url" && value != "" {
|
||||
if _, err := parseOutboundURL(value, true); err != nil {
|
||||
return fmt.Errorf("%s must be an absolute HTTPS URL", field)
|
||||
if _, err := telegramAPIURL(value, "123456:validation-token", "sendMessage"); err != nil {
|
||||
return fmt.Errorf("%s must be an absolute HTTPS URL or a URL template with two %%s placeholders", field)
|
||||
}
|
||||
}
|
||||
if name == "proxy" && value != "" {
|
||||
@@ -551,8 +551,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
|
||||
return errors.New("telegram.chat_id is required")
|
||||
}
|
||||
if baseURL := configString(config, "base_url"); baseURL != "" {
|
||||
if _, err := parseOutboundURL(baseURL, true); err != nil {
|
||||
return errors.New("telegram.base_url must be an absolute HTTPS URL")
|
||||
if _, err := telegramAPIURL(baseURL, token, "sendMessage"); err != nil {
|
||||
return errors.New("telegram.base_url must be an absolute HTTPS URL or a URL template with two %s placeholders")
|
||||
}
|
||||
}
|
||||
case "email":
|
||||
@@ -660,19 +660,11 @@ func sendBarkNotificationTest(ctx context.Context, config map[string]any) error
|
||||
}
|
||||
|
||||
func sendTelegramNotificationTest(ctx context.Context, config map[string]any) error {
|
||||
baseURL := configString(config, "base_url")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.telegram.org"
|
||||
}
|
||||
parsed, err := validateOutboundURL(ctx, baseURL, true)
|
||||
token := configString(config, "bot_token")
|
||||
parsed, err := validateTelegramAPIURL(ctx, configString(config, "base_url"), token, "sendMessage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token := configString(config, "bot_token")
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/sendMessage"
|
||||
parsed.RawPath = ""
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
client, err := restrictedHTTPClient(ctx, 6*time.Second, configString(config, "proxy"))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -211,6 +211,30 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationSettingsAcceptsTelegramReverseProxyTemplate(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
recorder := test.request(
|
||||
t,
|
||||
http.MethodPut,
|
||||
"/api/settings/notifications",
|
||||
`{"telegram":{"enabled":true,"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1","base_url":"https://telegram.example.com/bot%s/%s"}}`,
|
||||
)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
stored, err := test.database.NotificationSetting(context.Background(), "telegram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(stored.Config, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config["base_url"] != "https://telegram.example.com/bot%s/%s" {
|
||||
t.Fatalf("stored Telegram base URL = %#v", config["base_url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) {
|
||||
test := newSettingsAPITest(t)
|
||||
var webhookHits atomic.Int32
|
||||
|
||||
+66
-18
@@ -46,10 +46,9 @@ func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
|
||||
s.syncModemSMS(r.Context(), deviceID)
|
||||
contacts, err := s.store.ListSMSContacts(r.Context(), store.SMSFilter{
|
||||
DeviceID: deviceID,
|
||||
Limit: queryLimit(r, 100),
|
||||
})
|
||||
filter := s.smsStoreFilter(r.Context(), deviceID, "")
|
||||
filter.Limit = queryLimit(r, 100)
|
||||
contacts, err := s.store.ListSMSContacts(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -59,6 +58,7 @@ func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) {
|
||||
result = append(result, map[string]any{
|
||||
"device_id": contact.DeviceID,
|
||||
"device_name": contact.DeviceName,
|
||||
"modem_imei": contact.ModemIMEI,
|
||||
"imsi": contact.IMSI,
|
||||
"local_phone": contact.LocalPhone,
|
||||
"peer": contact.Peer,
|
||||
@@ -78,6 +78,7 @@ func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
|
||||
modemIMEI := strings.TrimSpace(r.URL.Query().Get("modem_imei"))
|
||||
imsi := strings.TrimSpace(r.URL.Query().Get("imsi"))
|
||||
peer := strings.TrimSpace(r.URL.Query().Get("peer"))
|
||||
if peer == "" {
|
||||
@@ -87,12 +88,14 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.syncModemSMS(r.Context(), deviceID)
|
||||
messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
|
||||
DeviceID: deviceID,
|
||||
IMSI: imsi,
|
||||
Peer: peer,
|
||||
Limit: queryLimit(r, 100),
|
||||
})
|
||||
filter := s.smsStoreFilter(r.Context(), deviceID, modemIMEI)
|
||||
filter.IMSI = imsi
|
||||
filter.Peer = peer
|
||||
filter.Limit = queryLimit(r, 100)
|
||||
if beforeID, parseErr := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("before_id")), 10, 64); parseErr == nil && beforeID > 0 {
|
||||
filter.BeforeID = beforeID
|
||||
}
|
||||
messages, err := s.store.ListSMSMessages(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -110,12 +113,11 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": result})
|
||||
case http.MethodDelete:
|
||||
messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
|
||||
DeviceID: deviceID,
|
||||
IMSI: imsi,
|
||||
Peer: peer,
|
||||
Limit: 1000,
|
||||
})
|
||||
filter := s.smsStoreFilter(r.Context(), deviceID, modemIMEI)
|
||||
filter.IMSI = imsi
|
||||
filter.Peer = peer
|
||||
filter.Limit = 1000
|
||||
messages, err := s.store.ListSMSMessages(r.Context(), filter)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -147,6 +149,35 @@ func normalizeSMSDeviceFilter(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// smsStoreFilter resolves a mutable configured device ID to the modem's stable
|
||||
// IMEI. The ID is still used to address the live modem, but persisted history
|
||||
// remains attached to the same hardware after the user renames that ID.
|
||||
func (s *Server) smsStoreFilter(ctx context.Context, deviceID, requestedIMEI string) store.SMSFilter {
|
||||
filter := store.SMSFilter{ModemIMEI: strings.TrimSpace(requestedIMEI)}
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if deviceID == "" {
|
||||
return filter
|
||||
}
|
||||
filter.ModemIMEI = ""
|
||||
filter.DeviceID = deviceID
|
||||
config, err := s.store.Device(ctx, deviceID)
|
||||
if err != nil {
|
||||
return filter
|
||||
}
|
||||
imei := strings.TrimSpace(config.ModemIMEI)
|
||||
if entry, _, present := s.physicalForConfig(config); present {
|
||||
imei = firstNonEmpty(
|
||||
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
|
||||
imei,
|
||||
)
|
||||
}
|
||||
if imei != "" {
|
||||
filter.DeviceID = ""
|
||||
filter.ModemIMEI = imei
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// blockedSMSDestination reports whether the recipient is in a barred country.
|
||||
// Normalization mirrors the PDU/IMS paths so the block cannot be sidestepped by
|
||||
// dropping the leading "+" or using a 00 international prefix.
|
||||
@@ -227,6 +258,10 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
|
||||
modemIMEI := firstNonEmpty(
|
||||
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
|
||||
config.ModemIMEI,
|
||||
)
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"encoding": result.Encoding,
|
||||
"message_reference": result.MessageReference,
|
||||
@@ -245,13 +280,14 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
messageID := fmt.Sprintf(
|
||||
"at-submit:%s:%d:%d",
|
||||
request.DeviceID,
|
||||
firstNonEmpty(modemIMEI, request.DeviceID),
|
||||
result.MessageReference,
|
||||
result.SubmittedAt.UnixNano(),
|
||||
)
|
||||
saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
|
||||
MessageID: messageID,
|
||||
DeviceID: request.DeviceID,
|
||||
ModemIMEI: modemIMEI,
|
||||
IMSI: imsi,
|
||||
Peer: result.To,
|
||||
Direction: "outbound",
|
||||
@@ -354,9 +390,14 @@ func (s *Server) writeIMSSMSSendResult(
|
||||
"submission_status": result.SubmissionStatus,
|
||||
})
|
||||
imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
|
||||
modemIMEI := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI })
|
||||
if config, configErr := s.store.Device(r.Context(), deviceID); configErr == nil {
|
||||
modemIMEI = firstNonEmpty(modemIMEI, config.ModemIMEI)
|
||||
}
|
||||
saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
|
||||
MessageID: fmt.Sprintf("ims-submit:%s:%d", deviceID, result.SubmittedAt.UnixNano()),
|
||||
MessageID: fmt.Sprintf("ims-submit:%s:%d", firstNonEmpty(modemIMEI, deviceID), result.SubmittedAt.UnixNano()),
|
||||
DeviceID: deviceID,
|
||||
ModemIMEI: modemIMEI,
|
||||
IMSI: imsi,
|
||||
Peer: result.To,
|
||||
Direction: "outbound",
|
||||
@@ -494,11 +535,16 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
continue
|
||||
}
|
||||
imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
|
||||
modemIMEI := firstNonEmpty(
|
||||
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
|
||||
config.ModemIMEI,
|
||||
)
|
||||
for _, message := range messages {
|
||||
if message.Direction == device.SMSDirectionStatusReport &&
|
||||
message.MessageReference != nil && message.StatusCode != nil {
|
||||
_, applyErr := s.store.ApplySMSDeliveryReport(ctx, store.SMSDeliveryReport{
|
||||
DeviceID: config.ID,
|
||||
ModemIMEI: modemIMEI,
|
||||
IMSI: imsi,
|
||||
Peer: message.To,
|
||||
Source: "cellular_at",
|
||||
@@ -550,6 +596,7 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
|
||||
_, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: messageID,
|
||||
DeviceID: config.ID,
|
||||
ModemIMEI: modemIMEI,
|
||||
IMSI: imsi,
|
||||
Peer: peer,
|
||||
Direction: direction,
|
||||
@@ -605,6 +652,7 @@ func storedSMSResponse(message store.SMSMessage) map[string]any {
|
||||
"id": message.ID,
|
||||
"message_id": message.MessageID,
|
||||
"device_id": message.DeviceID,
|
||||
"modem_imei": message.ModemIMEI,
|
||||
"imsi": message.IMSI,
|
||||
"peer": message.Peer,
|
||||
"direction": message.Direction,
|
||||
|
||||
@@ -54,6 +54,48 @@ func TestSMSThreadAllDevicesUsesIMSIFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSThreadConfiguredDeviceUsesStableIMEI(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
const imei = "867394042309830"
|
||||
if err := database.UpsertDevice(ctx, store.Device{
|
||||
ID: "ec20_2", Name: "EC20 renamed", ModemIMEI: imei,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.SaveSMSMessage(ctx, store.SMSMessage{
|
||||
MessageID: "before-rename", DeviceID: "ec20_1", ModemIMEI: imei,
|
||||
IMSI: "imsi-a", Peer: "VOXI", Direction: "inbound", Body: "history",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: database}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/sms/thread?device_id=ec20_2&imsi=imsi-a&peer=VOXI",
|
||||
nil,
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
server.handleSMSThread(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(envelope.Data) != 1 || envelope.Data[0]["modem_imei"] != imei {
|
||||
t.Fatalf("thread data = %#v", envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSMSDeviceFilter(t *testing.T) {
|
||||
if got := normalizeSMSDeviceFilter(" ALL "); got != "" {
|
||||
t.Fatalf("all filter = %q", got)
|
||||
@@ -88,9 +130,9 @@ func TestSMSSendOutcome(t *testing.T) {
|
||||
|
||||
func TestBlockedSMSDestination(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
phone string
|
||||
block bool
|
||||
name string
|
||||
phone string
|
||||
block bool
|
||||
}{
|
||||
{"e164 china", "+8613800138000", true},
|
||||
{"no plus china", "8613800138000", true},
|
||||
|
||||
@@ -160,13 +160,27 @@ func validateSMSNotificationConfig(channel string, config map[string]any) error
|
||||
|
||||
func (s *Server) newSMSNotification(ctx context.Context, message store.SMSMessage) smsNotification {
|
||||
name := ""
|
||||
if device, err := s.store.Device(ctx, message.DeviceID); err == nil {
|
||||
deviceID := message.DeviceID
|
||||
if message.ModemIMEI != "" {
|
||||
if devices, err := s.store.ListDevices(ctx); err == nil {
|
||||
var newest time.Time
|
||||
for _, candidate := range devices {
|
||||
if candidate.ModemIMEI == message.ModemIMEI &&
|
||||
(newest.IsZero() || candidate.UpdatedAt.After(newest)) {
|
||||
deviceID = candidate.ID
|
||||
name = strings.TrimSpace(candidate.Name)
|
||||
newest = candidate.UpdatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if device, err := s.store.Device(ctx, deviceID); err == nil {
|
||||
name = strings.TrimSpace(device.Name)
|
||||
}
|
||||
return smsNotification{
|
||||
DeviceID: message.DeviceID,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: name,
|
||||
DeviceLabel: firstNonEmpty(name, message.DeviceID, "--"),
|
||||
DeviceLabel: firstNonEmpty(name, deviceID, "--"),
|
||||
Number: firstNonEmpty(message.Peer, "--"),
|
||||
Time: message.Timestamp,
|
||||
Content: message.Body,
|
||||
|
||||
@@ -811,7 +811,7 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
|
||||
Proxy: configString(raw, "proxy"),
|
||||
}
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://api.telegram.org"
|
||||
config.BaseURL = defaultTelegramBaseURL
|
||||
}
|
||||
if admin := configString(raw, "admin_id"); admin != "" {
|
||||
config.AdminID, err = strconv.ParseInt(admin, 10, 64)
|
||||
@@ -826,12 +826,10 @@ func (bot *telegramBot) loadConfig(ctx context.Context) (telegramRuntimeConfig,
|
||||
}
|
||||
|
||||
func (bot *telegramBot) call(ctx context.Context, config telegramRuntimeConfig, method string, payload any, result any) error {
|
||||
base, err := validateOutboundURL(ctx, config.BaseURL, true)
|
||||
base, err := validateTelegramAPIURL(ctx, config.BaseURL, config.Token, method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/bot" + config.Token + "/" + method
|
||||
base.RawPath, base.RawQuery, base.Fragment = "", "", ""
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,12 +1,57 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/modem"
|
||||
)
|
||||
|
||||
func TestTelegramAPIURLSupportsBaseAndTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "base URL",
|
||||
baseURL: "https://api.telegram.org",
|
||||
want: "https://api.telegram.org/bot123456:test-token/sendMessage",
|
||||
},
|
||||
{
|
||||
name: "reverse proxy template",
|
||||
baseURL: "https://telegram.example.com/bot%s/%s",
|
||||
want: "https://telegram.example.com/bot123456:test-token/sendMessage",
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
got, err := telegramAPIURL(item.baseURL, "123456:test-token", "sendMessage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != item.want {
|
||||
t.Fatalf("telegramAPIURL() = %q, want %q", got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramAPIURLRejectsMalformedTemplates(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"https://telegram.example.com/bot%s/sendMessage",
|
||||
"https://%s.example.com/bot/token/%s",
|
||||
"http://telegram.example.com/bot%s/%s",
|
||||
} {
|
||||
if _, err := telegramAPIURL(value, "123456:test-token", "sendMessage"); err == nil {
|
||||
t.Errorf("telegramAPIURL(%q) unexpectedly succeeded", value)
|
||||
} else if strings.TrimSpace(err.Error()) == "" {
|
||||
t.Errorf("telegramAPIURL(%q) returned an empty error", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTelegramCommand(t *testing.T) {
|
||||
command, remainder := parseTelegramCommand(" /sms@vocat_bot EC20 +447700900123 hello world ")
|
||||
if command != "sms" || remainder != "EC20 +447700900123 hello world" {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultTelegramBaseURL = "https://api.telegram.org"
|
||||
|
||||
// telegramAPIURL accepts either a Telegram API base URL or a printf-style
|
||||
// endpoint template whose two %s placeholders are the bot token and method.
|
||||
func telegramAPIURL(baseURL, token, method string) (*url.URL, error) {
|
||||
raw := strings.TrimSpace(baseURL)
|
||||
if raw == "" {
|
||||
raw = defaultTelegramBaseURL
|
||||
}
|
||||
|
||||
placeholderCount := strings.Count(raw, "%s")
|
||||
if placeholderCount != 0 && placeholderCount != 2 {
|
||||
return nil, errors.New("Telegram API URL must contain either no %s placeholders or exactly two")
|
||||
}
|
||||
if placeholderCount == 2 {
|
||||
if telegramPlaceholderInAuthority(raw) {
|
||||
return nil, errors.New("Telegram API URL placeholders are not allowed in the host")
|
||||
}
|
||||
endpoint := strings.Replace(raw, "%s", token, 1)
|
||||
endpoint = strings.Replace(endpoint, "%s", method, 1)
|
||||
return parseOutboundURL(endpoint, true)
|
||||
}
|
||||
|
||||
parsed, err := parseOutboundURL(raw, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/" + method
|
||||
parsed.RawPath = ""
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func validateTelegramAPIURL(ctx context.Context, baseURL, token, method string) (*url.URL, error) {
|
||||
parsed, err := telegramAPIURL(baseURL, token, method)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func telegramPlaceholderInAuthority(raw string) bool {
|
||||
schemeEnd := strings.Index(raw, "://")
|
||||
if schemeEnd < 0 {
|
||||
return false
|
||||
}
|
||||
authority := raw[schemeEnd+3:]
|
||||
if end := strings.IndexAny(authority, "/?#"); end >= 0 {
|
||||
authority = authority[:end]
|
||||
}
|
||||
return strings.Contains(authority, "%s")
|
||||
}
|
||||
@@ -206,11 +206,52 @@ func (s *Store) ListDevices(ctx context.Context) ([]Device, error) {
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDevice(ctx context.Context, id string) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM devices WHERE id = ?`, id)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin delete device %q: %w", id, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var modemIMEI string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT modem_imei FROM devices WHERE id = ?`, id).Scan(&modemIMEI); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("read device %q before deletion: %w", id, err)
|
||||
}
|
||||
// SMS history must outlive a mutable configured device ID. Anchor any
|
||||
// legacy ID-owned rows to the physical modem before the device row and its
|
||||
// runtime records are removed.
|
||||
if strings.TrimSpace(modemIMEI) != "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM sms_messages AS legacy
|
||||
WHERE legacy.device_id = ? AND legacy.modem_imei = '' AND legacy.message_id <> ''
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sms_messages current
|
||||
WHERE current.modem_imei = ?
|
||||
AND current.message_id = legacy.message_id
|
||||
)
|
||||
`, id, strings.TrimSpace(modemIMEI)); err != nil {
|
||||
return fmt.Errorf("deduplicate SMS history for device %q: %w", id, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sms_messages
|
||||
SET modem_imei = ?, updated_at = ?
|
||||
WHERE device_id = ? AND modem_imei = ''
|
||||
`, strings.TrimSpace(modemIMEI), time.Now().UTC().Unix(), id); err != nil {
|
||||
return fmt.Errorf("anchor SMS history for device %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete device %q: %w", id, err)
|
||||
}
|
||||
return requireAffected(result)
|
||||
if err := requireAffected(result); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit delete device %q: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const deviceSelect = `
|
||||
|
||||
@@ -70,6 +70,41 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration7BackfillsSMSModemIMEI(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "sms-imei.db")
|
||||
raw, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 6; version++ {
|
||||
for _, statement := range migrationStatements(version) {
|
||||
if _, err := raw.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatalf("create v%d schema: %v", version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := raw.ExecContext(ctx, `
|
||||
INSERT INTO devices (id, name, modem_imei, created_at, updated_at)
|
||||
VALUES ('ec20_1', 'EC20', '867394042309830', 100, 100);
|
||||
INSERT INTO sms_messages (
|
||||
message_id, device_id, peer, direction, message_time, created_at, updated_at
|
||||
) VALUES ('legacy-message', 'ec20_1', 'VOXI', 'inbound', 100, 100, 100);
|
||||
PRAGMA user_version = 6;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := raw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
database := openTestStore(t, path)
|
||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{ModemIMEI: "867394042309830"})
|
||||
if err != nil || len(messages) != 1 || messages[0].DeviceID != "ec20_1" {
|
||||
t.Fatalf("migrated SMS = %#v, %v", messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "ims-redelivery.db")
|
||||
@@ -291,6 +326,68 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSHistoryFollowsModemIMEIAfterDeviceIDRename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
const imei = "867394042309830"
|
||||
if err := database.UpsertDevice(ctx, Device{
|
||||
ID: "ec20_1", Name: "EC20 old", ModemIMEI: imei,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "network-old", DeviceID: "ec20_1",
|
||||
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "before rename",
|
||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.DeleteDevice(ctx, "ec20_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(ctx, Device{
|
||||
ID: "ec20_2", Name: "EC20 renamed", ModemIMEI: imei,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "network-new", DeviceID: "ec20_2", ModemIMEI: imei,
|
||||
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "after rename",
|
||||
Timestamp: time.Unix(1_700_000_060, 0).UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
contacts, err := database.ListSMSContacts(ctx, SMSFilter{ModemIMEI: imei})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contacts) != 1 || contacts[0].DeviceID != "ec20_2" ||
|
||||
contacts[0].ModemIMEI != imei || contacts[0].MessageCount != 2 {
|
||||
t.Fatalf("renamed hardware contact = %#v", contacts)
|
||||
}
|
||||
messages, err := database.ListSMSMessages(ctx, SMSFilter{
|
||||
ModemIMEI: imei, IMSI: "23415", Peer: "VOXI",
|
||||
})
|
||||
if err != nil || len(messages) != 2 {
|
||||
t.Fatalf("renamed hardware messages = %#v, %v", messages, err)
|
||||
}
|
||||
|
||||
// A retry that arrives after the rename updates the same hardware message,
|
||||
// rather than duplicating it under the new configured ID.
|
||||
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||
MessageID: "network-old", DeviceID: "ec20_2", ModemIMEI: imei,
|
||||
IMSI: "23415", Peer: "VOXI", Direction: "inbound", Body: "retry",
|
||||
Timestamp: time.Unix(1_700_000_000, 0).UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages, err = database.ListSMSMessages(ctx, SMSFilter{ModemIMEI: imei})
|
||||
if err != nil || len(messages) != 2 {
|
||||
t.Fatalf("retry after rename messages = %#v, %v", messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInboundSMSAfterIDUsesDurableInsertionCursor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, ":memory:")
|
||||
|
||||
@@ -81,6 +81,31 @@ func migrationStatements(version int) []string {
|
||||
`CREATE INDEX IF NOT EXISTS device_proxy_bindings_proxy_idx
|
||||
ON device_proxy_bindings(upstream_proxy_id)`,
|
||||
}
|
||||
case 7:
|
||||
return []string{
|
||||
`ALTER TABLE sms_messages
|
||||
ADD COLUMN modem_imei TEXT NOT NULL DEFAULT ''`,
|
||||
`UPDATE sms_messages
|
||||
SET modem_imei = COALESCE((
|
||||
SELECT NULLIF(d.modem_imei, '')
|
||||
FROM devices d
|
||||
WHERE d.id = sms_messages.device_id
|
||||
), '')
|
||||
WHERE modem_imei = ''`,
|
||||
`DELETE FROM sms_messages
|
||||
WHERE modem_imei <> '' AND message_id <> ''
|
||||
AND id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM sms_messages
|
||||
WHERE modem_imei <> '' AND message_id <> ''
|
||||
GROUP BY modem_imei, message_id
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS sms_messages_hardware_external_id_idx
|
||||
ON sms_messages(modem_imei, message_id)
|
||||
WHERE modem_imei <> '' AND message_id <> ''`,
|
||||
`CREATE INDEX IF NOT EXISTS sms_messages_hardware_thread_idx
|
||||
ON sms_messages(modem_imei, imsi, peer, message_time DESC, id DESC)`,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ type SMSMessage struct {
|
||||
ID int64
|
||||
MessageID string
|
||||
DeviceID string
|
||||
ModemIMEI string
|
||||
IMSI string
|
||||
Peer string
|
||||
Direction string
|
||||
@@ -143,19 +144,21 @@ type SMSMessage struct {
|
||||
}
|
||||
|
||||
type SMSFilter struct {
|
||||
DeviceID string
|
||||
IMSI string
|
||||
Peer string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
DeviceID string
|
||||
ModemIMEI string
|
||||
IMSI string
|
||||
Peer string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
BeforeID int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// SMSDeliveryReport is network evidence for one submitted SMS part. The
|
||||
// message reference is the TP-MR returned in SMS-STATUS-REPORT.
|
||||
type SMSDeliveryReport struct {
|
||||
DeviceID string
|
||||
ModemIMEI string
|
||||
IMSI string
|
||||
Peer string
|
||||
Source string
|
||||
@@ -170,6 +173,7 @@ type SMSDeliveryReport struct {
|
||||
type SMSContact struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ModemIMEI string
|
||||
IMSI string
|
||||
LocalPhone string
|
||||
Peer string
|
||||
|
||||
+61
-25
@@ -40,6 +40,7 @@ func saveSMSMessage(
|
||||
value SMSMessage,
|
||||
) (SMSMessage, error) {
|
||||
value.DeviceID = strings.TrimSpace(value.DeviceID)
|
||||
value.ModemIMEI = strings.TrimSpace(value.ModemIMEI)
|
||||
value.Peer = strings.TrimSpace(value.Peer)
|
||||
value.Direction = strings.ToLower(strings.TrimSpace(value.Direction))
|
||||
if value.DeviceID == "" {
|
||||
@@ -77,13 +78,13 @@ func saveSMSMessage(
|
||||
if value.ID > 0 {
|
||||
result, err := executor.ExecContext(ctx, `
|
||||
UPDATE sms_messages SET
|
||||
message_id = ?, device_id = ?, imsi = ?, peer = ?,
|
||||
message_id = ?, device_id = ?, modem_imei = ?, imsi = ?, peer = ?,
|
||||
direction = ?, body = ?, message_time = ?, status = ?,
|
||||
source = ?, parts_total = ?, delivery_state = ?, is_read = ?,
|
||||
extra_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
value.MessageID, value.DeviceID, value.IMSI, value.Peer,
|
||||
value.MessageID, value.DeviceID, value.ModemIMEI, value.IMSI, value.Peer,
|
||||
value.Direction, value.Body, value.Timestamp.Unix(), value.Status,
|
||||
value.Source, value.PartsTotal, value.DeliveryState,
|
||||
boolInt(value.Read), string(extra), value.UpdatedAt.Unix(), value.ID,
|
||||
@@ -99,11 +100,16 @@ func saveSMSMessage(
|
||||
|
||||
result, err := executor.ExecContext(ctx, `
|
||||
INSERT INTO sms_messages (
|
||||
message_id, device_id, imsi, peer, direction, body, message_time,
|
||||
message_id, device_id, modem_imei, imsi, peer, direction, body, message_time,
|
||||
status, source, parts_total, delivery_state, is_read, extra_json,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, message_id) WHERE message_id <> '' DO UPDATE SET
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO UPDATE SET
|
||||
device_id = excluded.device_id,
|
||||
modem_imei = CASE
|
||||
WHEN excluded.modem_imei <> '' THEN excluded.modem_imei
|
||||
ELSE sms_messages.modem_imei
|
||||
END,
|
||||
imsi = excluded.imsi,
|
||||
peer = excluded.peer,
|
||||
direction = excluded.direction,
|
||||
@@ -117,7 +123,7 @@ func saveSMSMessage(
|
||||
extra_json = excluded.extra_json,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
value.MessageID, value.DeviceID, value.IMSI, value.Peer,
|
||||
value.MessageID, value.DeviceID, value.ModemIMEI, value.IMSI, value.Peer,
|
||||
value.Direction, value.Body, value.Timestamp.Unix(), value.Status,
|
||||
value.Source, value.PartsTotal, value.DeliveryState,
|
||||
boolInt(value.Read), string(extra), value.CreatedAt.Unix(),
|
||||
@@ -127,10 +133,13 @@ func saveSMSMessage(
|
||||
return SMSMessage{}, fmt.Errorf("save SMS: %w", err)
|
||||
}
|
||||
if value.MessageID != "" {
|
||||
hardwareKey := smsHardwareKey(value.ModemIMEI, value.DeviceID)
|
||||
return scanSMSMessage(executor.QueryRowContext(
|
||||
ctx,
|
||||
smsMessageSelect+` WHERE device_id = ? AND message_id = ?`,
|
||||
value.DeviceID,
|
||||
smsMessageSelect+` WHERE
|
||||
COALESCE(NULLIF(modem_imei, ''), 'device:' || device_id) = ?
|
||||
AND message_id = ?`,
|
||||
hardwareKey,
|
||||
value.MessageID,
|
||||
))
|
||||
}
|
||||
@@ -207,7 +216,9 @@ func (s *Store) ListInboundSMSAfterID(ctx context.Context, afterID int64, limit
|
||||
// outbound submission and advances its aggregate delivery state. Multipart
|
||||
// messages become delivered only after every submitted part is reported.
|
||||
func (s *Store) ApplySMSDeliveryReport(ctx context.Context, report SMSDeliveryReport) (SMSMessage, error) {
|
||||
if report.DeviceID == "" || report.MessageReference < 0 || report.MessageReference > 255 {
|
||||
report.DeviceID = strings.TrimSpace(report.DeviceID)
|
||||
report.ModemIMEI = strings.TrimSpace(report.ModemIMEI)
|
||||
if (report.DeviceID == "" && report.ModemIMEI == "") || report.MessageReference < 0 || report.MessageReference > 255 {
|
||||
return SMSMessage{}, errors.New("invalid SMS delivery report identity")
|
||||
}
|
||||
if report.ReceivedAt.IsZero() {
|
||||
@@ -219,7 +230,7 @@ func (s *Store) ApplySMSDeliveryReport(ctx context.Context, report SMSDeliveryRe
|
||||
}
|
||||
defer tx.Rollback()
|
||||
query := smsMessageSelect + `
|
||||
WHERE device_id = ?
|
||||
WHERE ((? <> '' AND modem_imei = ?) OR (? = '' AND device_id = ?))
|
||||
AND direction IN ('outbound', 'sent')
|
||||
AND (? = '' OR imsi = ?)
|
||||
AND (? = '' OR peer = ?)
|
||||
@@ -229,7 +240,7 @@ func (s *Store) ApplySMSDeliveryReport(ctx context.Context, report SMSDeliveryRe
|
||||
rows, err := tx.QueryContext(
|
||||
ctx,
|
||||
query,
|
||||
report.DeviceID,
|
||||
report.ModemIMEI, report.ModemIMEI, report.ModemIMEI, report.DeviceID,
|
||||
report.IMSI, report.IMSI,
|
||||
report.Peer, report.Peer,
|
||||
report.Source, report.Source,
|
||||
@@ -449,28 +460,42 @@ func (s *Store) MarkSMSThreadRead(
|
||||
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
||||
where, args := smsWhere(filter, "m.")
|
||||
query := `
|
||||
WITH ranked AS (
|
||||
WITH resolved AS (
|
||||
SELECT
|
||||
m.id, m.device_id, m.imsi, m.peer, m.body, m.message_time,
|
||||
m.direction,
|
||||
m.*,
|
||||
COALESCE(NULLIF(m.modem_imei, ''), 'device:' || m.device_id) AS hardware_key,
|
||||
COALESCE((
|
||||
SELECT current_device.id
|
||||
FROM devices current_device
|
||||
WHERE m.modem_imei <> ''
|
||||
AND current_device.modem_imei = m.modem_imei
|
||||
ORDER BY current_device.updated_at DESC, current_device.id
|
||||
LIMIT 1
|
||||
), m.device_id) AS resolved_device_id
|
||||
FROM sms_messages m` + where + `
|
||||
), ranked AS (
|
||||
SELECT
|
||||
m.id, m.resolved_device_id, m.modem_imei, m.imsi, m.peer,
|
||||
m.body, m.message_time, m.direction,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY m.device_id, m.imsi, m.peer
|
||||
PARTITION BY m.hardware_key, m.imsi, m.peer
|
||||
ORDER BY m.message_time DESC, m.id DESC
|
||||
) AS row_number,
|
||||
SUM(CASE
|
||||
WHEN m.direction IN ('inbound', 'received') AND m.is_read = 0
|
||||
THEN 1 ELSE 0
|
||||
END) OVER (
|
||||
PARTITION BY m.device_id, m.imsi, m.peer
|
||||
PARTITION BY m.hardware_key, m.imsi, m.peer
|
||||
) AS unread_count,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY m.device_id, m.imsi, m.peer
|
||||
PARTITION BY m.hardware_key, m.imsi, m.peer
|
||||
) AS message_count
|
||||
FROM sms_messages m` + where + `
|
||||
FROM resolved m
|
||||
)
|
||||
SELECT
|
||||
r.device_id,
|
||||
r.resolved_device_id,
|
||||
COALESCE(d.name, ''),
|
||||
r.modem_imei,
|
||||
r.imsi,
|
||||
COALESCE(NULLIF(dr.phone_number, ''), NULLIF(vr.local_phone, ''), ''),
|
||||
r.peer,
|
||||
@@ -482,9 +507,9 @@ func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSCon
|
||||
r.unread_count,
|
||||
r.message_count
|
||||
FROM ranked r
|
||||
LEFT JOIN devices d ON d.id = r.device_id
|
||||
LEFT JOIN device_runtime dr ON dr.device_id = r.device_id
|
||||
LEFT JOIN vowifi_runtime vr ON vr.device_id = r.device_id
|
||||
LEFT JOIN devices d ON d.id = r.resolved_device_id
|
||||
LEFT JOIN device_runtime dr ON dr.device_id = r.resolved_device_id
|
||||
LEFT JOIN vowifi_runtime vr ON vr.device_id = r.resolved_device_id
|
||||
WHERE r.row_number = 1
|
||||
ORDER BY r.message_time DESC, r.id DESC
|
||||
LIMIT ?`
|
||||
@@ -500,7 +525,7 @@ func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSCon
|
||||
var value SMSContact
|
||||
var timestamp int64
|
||||
if err := rows.Scan(
|
||||
&value.DeviceID, &value.DeviceName, &value.IMSI,
|
||||
&value.DeviceID, &value.DeviceName, &value.ModemIMEI, &value.IMSI,
|
||||
&value.LocalPhone, &value.Peer, &value.DisplayName,
|
||||
&value.LastMessage, ×tamp, &value.Direction,
|
||||
&value.LastSMSID, &value.UnreadCount, &value.MessageCount,
|
||||
@@ -517,7 +542,7 @@ func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSCon
|
||||
}
|
||||
|
||||
const smsMessageSelect = `
|
||||
SELECT id, message_id, device_id, imsi, peer, direction, body,
|
||||
SELECT id, message_id, device_id, modem_imei, imsi, peer, direction, body,
|
||||
message_time, status, source, parts_total, delivery_state, is_read,
|
||||
extra_json, created_at, updated_at
|
||||
FROM sms_messages`
|
||||
@@ -528,7 +553,7 @@ func scanSMSMessage(row rowScanner) (SMSMessage, error) {
|
||||
var read int
|
||||
var extra string
|
||||
err := row.Scan(
|
||||
&value.ID, &value.MessageID, &value.DeviceID, &value.IMSI,
|
||||
&value.ID, &value.MessageID, &value.DeviceID, &value.ModemIMEI, &value.IMSI,
|
||||
&value.Peer, &value.Direction, &value.Body, &messageTime,
|
||||
&value.Status, &value.Source, &value.PartsTotal,
|
||||
&value.DeliveryState, &read, &extra, &createdAt, &updatedAt,
|
||||
@@ -554,6 +579,10 @@ func smsWhere(filter SMSFilter, prefix string) (string, []any) {
|
||||
clauses = append(clauses, prefix+`device_id = ?`)
|
||||
args = append(args, filter.DeviceID)
|
||||
}
|
||||
if filter.ModemIMEI != "" {
|
||||
clauses = append(clauses, prefix+`modem_imei = ?`)
|
||||
args = append(args, filter.ModemIMEI)
|
||||
}
|
||||
if filter.IMSI != "" {
|
||||
clauses = append(clauses, prefix+`imsi = ?`)
|
||||
args = append(args, filter.IMSI)
|
||||
@@ -580,6 +609,13 @@ func smsWhere(filter SMSFilter, prefix string) (string, []any) {
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func smsHardwareKey(modemIMEI, deviceID string) string {
|
||||
if modemIMEI = strings.TrimSpace(modemIMEI); modemIMEI != "" {
|
||||
return modemIMEI
|
||||
}
|
||||
return "device:" + strings.TrimSpace(deviceID)
|
||||
}
|
||||
|
||||
func normalizedLimit(value int) int {
|
||||
if value <= 0 {
|
||||
return 100
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const schemaVersion = 6
|
||||
const schemaVersion = 7
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
@@ -117,6 +117,14 @@ func migrate(ctx context.Context, db *sql.DB) error {
|
||||
}
|
||||
for _, statement := range migrationStatements(nextVersion) {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
// A database whose user_version was repaired or rolled back may
|
||||
// already contain this additive v7 column. The remaining v7 data
|
||||
// backfill and indexes are still safe and must be applied.
|
||||
if nextVersion == 7 &&
|
||||
strings.Contains(statement, "ADD COLUMN modem_imei") &&
|
||||
strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||
continue
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply sqlite migration %d: %w", nextVersion, err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestAssetNamesFor(t *testing.T) {
|
||||
}{
|
||||
{"linux", "amd64", []string{"vocat-linux-amd64"}},
|
||||
{"linux", "386", []string{"vocat-linux-386"}},
|
||||
{"linux", "arm64", []string{"vocat-linux-arm64"}},
|
||||
{"linux", "arm64", []string{"vocat-linux-arm64", "vocat-linux-aarch64"}},
|
||||
{"linux", "arm", []string{"vocat-linux-armv7", "vocat-linux-arm"}},
|
||||
}
|
||||
for _, item := range tests {
|
||||
|
||||
@@ -25,7 +25,20 @@ type Asset struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
const githubAPI = "https://api.github.com"
|
||||
// CheckResult describes a trusted release check without downloading assets.
|
||||
type CheckResult struct {
|
||||
Available bool
|
||||
Applied bool
|
||||
Current string
|
||||
Latest string
|
||||
ReleaseNotes string
|
||||
Release *Release
|
||||
}
|
||||
|
||||
const (
|
||||
githubAPI = "https://api.github.com"
|
||||
DefaultRepository = "MengMengCode/VoCat"
|
||||
)
|
||||
|
||||
// LatestRelease fetches the newest published release for repo (form
|
||||
// "owner/name"). A non-empty token is sent as a Bearer header, which is
|
||||
@@ -73,6 +86,27 @@ func LatestRelease(ctx context.Context, repo, token string) (*Release, error) {
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// CheckLatest fetches the newest release and performs a semantic version
|
||||
// comparison so development builds are never offered an older release.
|
||||
func CheckLatest(ctx context.Context, repo, token, current string) (CheckResult, error) {
|
||||
release, err := LatestRelease(ctx, repo, token)
|
||||
if err != nil {
|
||||
return CheckResult{}, err
|
||||
}
|
||||
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
|
||||
available, err := IsNewerVersion(current, latest)
|
||||
if err != nil {
|
||||
return CheckResult{}, fmt.Errorf("update: compare release versions: %w", err)
|
||||
}
|
||||
return CheckResult{
|
||||
Available: available,
|
||||
Current: current,
|
||||
Latest: latest,
|
||||
ReleaseNotes: strings.TrimSpace(release.Body),
|
||||
Release: release,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// downloadAsset streams a release asset into dst, honoring the request context.
|
||||
// The token is applied for consistency with the API call (GitHub release assets
|
||||
// redirect to a pre-signed S3 URL; the token is dropped on redirect, which is
|
||||
|
||||
+59
-27
@@ -7,8 +7,8 @@
|
||||
// Trust model: GitHub TLS guarantees the channel; the repository owner controls
|
||||
// which assets are published; SHA256SUMS guards integrity. There is no GPG
|
||||
// signature verification — an accepted trade-off for a closed-network testing
|
||||
// tool. The web UI's check-update button remains an intentional no-op; only the
|
||||
// CLI performs code replacement.
|
||||
// tool. Both the CLI and authenticated web UI use this same verified replacement
|
||||
// path.
|
||||
package update
|
||||
|
||||
import (
|
||||
@@ -51,12 +51,12 @@ func Run(logger *slog.Logger, args []string) error {
|
||||
if opts.Repo == "" {
|
||||
opts.Repo = strings.TrimSpace(os.Getenv("VOCAT_REPO"))
|
||||
}
|
||||
if opts.Repo == "" {
|
||||
opts.Repo = DefaultRepository
|
||||
}
|
||||
if opts.Token == "" {
|
||||
opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
|
||||
}
|
||||
if opts.Repo == "" {
|
||||
return fmt.Errorf("update: no repository configured (set --repo=owner/name or VOCAT_REPO)")
|
||||
}
|
||||
if opts.Target == "" {
|
||||
opts.Target = resolveDefaultTarget()
|
||||
}
|
||||
@@ -65,33 +65,55 @@ func Run(logger *slog.Logger, args []string) error {
|
||||
defer cancel()
|
||||
|
||||
logger.Info("checking for updates", "repo", opts.Repo, "current", buildinfo.Version)
|
||||
release, err := LatestRelease(ctx, opts.Repo, opts.Token)
|
||||
result, err := CheckLatest(ctx, opts.Repo, opts.Token, buildinfo.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest := strings.TrimPrefix(release.TagName, "v")
|
||||
if latest == "" {
|
||||
latest = release.TagName
|
||||
}
|
||||
|
||||
if latest == buildinfo.Version && !opts.Force {
|
||||
if !result.Available && !opts.Force {
|
||||
logger.Info("already up to date", "version", buildinfo.Version)
|
||||
fmt.Printf("vocat %s is already the latest release.\n", buildinfo.Version)
|
||||
return nil
|
||||
}
|
||||
if opts.Check {
|
||||
fmt.Printf("update available: %s -> %s\n", buildinfo.Version, latest)
|
||||
if release.Body != "" {
|
||||
fmt.Println(strings.TrimSpace(release.Body))
|
||||
fmt.Printf("update available: %s -> %s\n", buildinfo.Version, result.Latest)
|
||||
if result.ReleaseNotes != "" {
|
||||
fmt.Println(result.ReleaseNotes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("update available", "current", buildinfo.Version, "latest", latest)
|
||||
return applyUpdate(ctx, logger, opts, release, latest)
|
||||
logger.Info("update available", "current", buildinfo.Version, "latest", result.Latest)
|
||||
return applyUpdate(ctx, logger, opts, result.Release, result.Latest, true)
|
||||
}
|
||||
|
||||
func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string) error {
|
||||
// ApplyLatest downloads, verifies, and atomically installs the newest trusted
|
||||
// release. HTTP callers can pass restart=false and restart after flushing the
|
||||
// response.
|
||||
func ApplyLatest(ctx context.Context, logger *slog.Logger, opts Options, restart bool) (CheckResult, error) {
|
||||
if strings.TrimSpace(opts.Repo) == "" {
|
||||
opts.Repo = DefaultRepository
|
||||
}
|
||||
if strings.TrimSpace(opts.Token) == "" {
|
||||
opts.Token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
|
||||
}
|
||||
if strings.TrimSpace(opts.Target) == "" {
|
||||
opts.Target = resolveDefaultTarget()
|
||||
}
|
||||
result, err := CheckLatest(ctx, opts.Repo, opts.Token, buildinfo.Version)
|
||||
if err != nil {
|
||||
return CheckResult{}, err
|
||||
}
|
||||
if !result.Available && !opts.Force {
|
||||
return result, nil
|
||||
}
|
||||
if err := applyUpdate(ctx, logger, opts, result.Release, result.Latest, restart); err != nil {
|
||||
return CheckResult{}, err
|
||||
}
|
||||
result.Applied = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release *Release, latest string, restart bool) error {
|
||||
assetNames := assetNamesFor(runtime.GOOS, runtime.GOARCH)
|
||||
var asset *Asset
|
||||
for _, name := range assetNames {
|
||||
@@ -169,11 +191,13 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
logger.Info("installed new binary", "target", opts.Target, "version", latest)
|
||||
fmt.Printf("vocat updated to %s.\n", latest)
|
||||
|
||||
if err := restartService(logger); err != nil {
|
||||
// The file replacement already succeeded; a restart failure is not
|
||||
// fatal — the operator can restart the service manually.
|
||||
fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err)
|
||||
fmt.Println("Restart the vocat service manually to apply the new build.")
|
||||
if restart {
|
||||
if err := RestartService(logger); err != nil {
|
||||
// The file replacement already succeeded; a restart failure is not
|
||||
// fatal — the operator can restart the service manually.
|
||||
fmt.Printf("Binary replaced, but automatic restart failed: %v\n", err)
|
||||
fmt.Println("Restart the vocat service manually to apply the new build.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -201,14 +225,17 @@ func backupAndReplace(target, tmp string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartService restarts the vocat systemd unit. If systemctl is unavailable
|
||||
// RestartService restarts the vocat systemd unit. If systemctl is unavailable
|
||||
// (non-systemd hosts, containers), it returns an error the caller surfaces as
|
||||
// a non-fatal warning.
|
||||
func restartService(logger *slog.Logger) error {
|
||||
func RestartService(logger *slog.Logger) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("systemctl not found in PATH")
|
||||
}
|
||||
cmd := exec.Command("systemctl", "restart", "vocat")
|
||||
// Queue the restart and let systemctl exit before systemd stops this unit.
|
||||
// A blocking restart command becomes part of vocat.service's own cgroup and
|
||||
// waits for that same cgroup to terminate, creating a stop-timeout cycle.
|
||||
cmd := exec.Command("systemctl", "restart", "--no-block", "vocat")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("systemctl restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("systemctl restart vocat: %w", err)
|
||||
@@ -245,6 +272,11 @@ func findAsset(release *Release, name string) *Asset {
|
||||
}
|
||||
|
||||
func assetNamesFor(goos, goarch string) []string {
|
||||
if goos == "linux" && goarch == "arm64" {
|
||||
// AArch64 and arm64 name the same instruction set. Prefer the historic
|
||||
// release name and accept the explicit architecture alias as fallback.
|
||||
return []string{"vocat-linux-arm64", "vocat-linux-aarch64"}
|
||||
}
|
||||
if goos == "linux" && goarch == "arm" {
|
||||
// Official 32-bit ARM builds target GOARM=7. Keep the generic legacy
|
||||
// name as a fallback for installations consuming an older release.
|
||||
@@ -261,7 +293,7 @@ Fetch the latest release from GitHub and replace this binary in place.
|
||||
Flags:
|
||||
--check Report whether an update is available, then exit.
|
||||
--force Reinstall even when already at the latest version.
|
||||
--repo owner/name GitHub repository (default: $VOCAT_REPO).
|
||||
--repo owner/name GitHub repository (default: $VOCAT_REPO or MengMengCode/VoCat).
|
||||
--target path Binary to replace (default: /opt/vocat/bin/vocat if
|
||||
present, otherwise the running executable).
|
||||
--token token GitHub bearer token (default: $GITHUB_TOKEN).
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type semanticVersion struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
prerelease string
|
||||
}
|
||||
|
||||
// IsNewerVersion reports whether latest is newer than current. Both values
|
||||
// may include the conventional v prefix, prerelease suffixes, and build
|
||||
// metadata. Invalid release versions are rejected instead of triggering a
|
||||
// downgrade or an arbitrary file replacement.
|
||||
func IsNewerVersion(current, latest string) (bool, error) {
|
||||
currentVersion, err := parseSemanticVersion(current)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("current version: %w", err)
|
||||
}
|
||||
latestVersion, err := parseSemanticVersion(latest)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("latest version: %w", err)
|
||||
}
|
||||
if currentVersion.major != latestVersion.major {
|
||||
return latestVersion.major > currentVersion.major, nil
|
||||
}
|
||||
if currentVersion.minor != latestVersion.minor {
|
||||
return latestVersion.minor > currentVersion.minor, nil
|
||||
}
|
||||
if currentVersion.patch != latestVersion.patch {
|
||||
return latestVersion.patch > currentVersion.patch, nil
|
||||
}
|
||||
if currentVersion.prerelease == latestVersion.prerelease {
|
||||
return false, nil
|
||||
}
|
||||
if currentVersion.prerelease != "" && latestVersion.prerelease == "" {
|
||||
return true, nil
|
||||
}
|
||||
if currentVersion.prerelease == "" {
|
||||
return false, nil
|
||||
}
|
||||
return comparePrerelease(currentVersion.prerelease, latestVersion.prerelease) < 0, nil
|
||||
}
|
||||
|
||||
func parseSemanticVersion(raw string) (semanticVersion, error) {
|
||||
value := strings.TrimPrefix(strings.TrimSpace(raw), "v")
|
||||
if build := strings.IndexByte(value, '+'); build >= 0 {
|
||||
value = value[:build]
|
||||
}
|
||||
prerelease := ""
|
||||
hasPrerelease := false
|
||||
if dash := strings.IndexByte(value, '-'); dash >= 0 {
|
||||
hasPrerelease = true
|
||||
prerelease = value[dash+1:]
|
||||
value = value[:dash]
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 || (hasPrerelease && prerelease == "") {
|
||||
return semanticVersion{}, fmt.Errorf("%q is not a semantic version", raw)
|
||||
}
|
||||
numbers := make([]int, 3)
|
||||
for index, part := range parts {
|
||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
||||
return semanticVersion{}, fmt.Errorf("%q is not a semantic version", raw)
|
||||
}
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 {
|
||||
return semanticVersion{}, fmt.Errorf("%q is not a semantic version", raw)
|
||||
}
|
||||
numbers[index] = value
|
||||
}
|
||||
if strings.ContainsAny(prerelease, " \t\r\n") {
|
||||
return semanticVersion{}, fmt.Errorf("%q is not a semantic version", raw)
|
||||
}
|
||||
return semanticVersion{
|
||||
major: numbers[0],
|
||||
minor: numbers[1],
|
||||
patch: numbers[2],
|
||||
prerelease: prerelease,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func comparePrerelease(left, right string) int {
|
||||
leftParts := strings.Split(left, ".")
|
||||
rightParts := strings.Split(right, ".")
|
||||
for index := 0; index < len(leftParts) && index < len(rightParts); index++ {
|
||||
if leftParts[index] == rightParts[index] {
|
||||
continue
|
||||
}
|
||||
leftNumber, leftErr := strconv.Atoi(leftParts[index])
|
||||
rightNumber, rightErr := strconv.Atoi(rightParts[index])
|
||||
switch {
|
||||
case leftErr == nil && rightErr == nil:
|
||||
if leftNumber < rightNumber {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
case leftErr == nil:
|
||||
return -1
|
||||
case rightErr == nil:
|
||||
return 1
|
||||
case leftParts[index] < rightParts[index]:
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(leftParts) < len(rightParts) {
|
||||
return -1
|
||||
}
|
||||
if len(leftParts) > len(rightParts) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package update
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNewerVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
current string
|
||||
latest string
|
||||
want bool
|
||||
}{
|
||||
{"0.0.3", "v0.0.4", true},
|
||||
{"0.0.4", "v0.0.4", false},
|
||||
{"0.1.0-dev", "v0.0.4", false},
|
||||
{"0.1.0-dev", "v0.1.0", true},
|
||||
{"1.2.3-rc.1", "v1.2.3-rc.2", true},
|
||||
{"1.2.3", "v1.2.3-rc.2", false},
|
||||
}
|
||||
for _, item := range tests {
|
||||
got, err := IsNewerVersion(item.current, item.latest)
|
||||
if err != nil {
|
||||
t.Errorf("IsNewerVersion(%q, %q): %v", item.current, item.latest, err)
|
||||
continue
|
||||
}
|
||||
if got != item.want {
|
||||
t.Errorf("IsNewerVersion(%q, %q) = %v, want %v", item.current, item.latest, got, item.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewerVersionRejectsInvalidRelease(t *testing.T) {
|
||||
if _, err := IsNewerVersion("0.1.0", "nightly"); err == nil {
|
||||
t.Fatal("invalid latest version was accepted")
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,13 @@ func (relay *sessionRelay) run() {
|
||||
packet := append([]byte(nil), buffer[:n]...)
|
||||
if isIKE {
|
||||
if err := relay.handleIKE(packet); err != nil {
|
||||
if errors.Is(err, errMismatchedSessionSPIs) {
|
||||
// A reconnect can reuse the same NAT mapping while the ePDG still
|
||||
// has packets queued for the previous IKE SA. Those packets are
|
||||
// unrelated to this authenticated session and must be discarded;
|
||||
// treating one as fatal tears down the newly established CHILD_SA.
|
||||
continue
|
||||
}
|
||||
relay.fail(err)
|
||||
return
|
||||
}
|
||||
@@ -111,13 +118,15 @@ func (relay *sessionRelay) run() {
|
||||
}
|
||||
}
|
||||
|
||||
var errMismatchedSessionSPIs = errors.New("ike: session packet has mismatched SPIs")
|
||||
|
||||
func (relay *sessionRelay) handleIKE(packet []byte) error {
|
||||
header, _, err := parseIKEPacket(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if header.InitiatorSPI != relay.spii || header.ResponderSPI != relay.spir {
|
||||
return errors.New("ike: session packet has mismatched SPIs")
|
||||
return errMismatchedSessionSPIs
|
||||
}
|
||||
if header.Flags&flagResponse != 0 {
|
||||
return nil
|
||||
|
||||
@@ -178,3 +178,39 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
t.Fatal("relay did not send a NAT-T keepalive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(
|
||||
transport,
|
||||
legacyTestSuite(),
|
||||
ikeKeys{},
|
||||
spii,
|
||||
spir,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
defer relay.Close()
|
||||
|
||||
transport.incoming <- fakeSessionPacket{
|
||||
ike: true,
|
||||
data: ikeHeader{
|
||||
InitiatorSPI: [8]byte{9},
|
||||
ResponderSPI: [8]byte{8},
|
||||
Exchange: exchangeInformational,
|
||||
}.marshal(nil),
|
||||
}
|
||||
wantedESP := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa}
|
||||
transport.incoming <- fakeSessionPacket{data: wantedESP}
|
||||
|
||||
buffer := make([]byte, 64)
|
||||
count, err := relay.ReceiveESP(context.Background(), buffer)
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveESP() after stale IKE packet = %v", err)
|
||||
}
|
||||
if !bytes.Equal(buffer[:count], wantedESP) {
|
||||
t.Fatalf("ESP after stale IKE packet = %x, want %x", buffer[:count], wantedESP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package ims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCallNotFound = errors.New("ims: call not found")
|
||||
ErrCallState = errors.New("ims: call is not in the required state")
|
||||
)
|
||||
|
||||
type imsCall struct {
|
||||
public vowifi.Call
|
||||
callID string
|
||||
target string
|
||||
from string
|
||||
to string
|
||||
branch string
|
||||
cseq uint32
|
||||
invite *sipRequest
|
||||
respond func([]byte) error
|
||||
responses chan *sipResponse
|
||||
remoteTag string
|
||||
routes []string
|
||||
terminated bool
|
||||
}
|
||||
|
||||
func (session *Session) Calls() []vowifi.Call {
|
||||
session.callMu.Lock()
|
||||
defer session.callMu.Unlock()
|
||||
calls := make([]vowifi.Call, 0, len(session.calls))
|
||||
for _, call := range session.calls {
|
||||
if call.public.State != "ended" && call.public.State != "failed" {
|
||||
calls = append(calls, call.public)
|
||||
}
|
||||
}
|
||||
sort.Slice(calls, func(i, j int) bool { return calls[i].StartedAt.Before(calls[j].StartedAt) })
|
||||
return calls
|
||||
}
|
||||
|
||||
func (session *Session) DialCall(ctx context.Context, number string) (vowifi.Call, error) {
|
||||
number = strings.TrimSpace(number)
|
||||
if !validCallNumber(number) {
|
||||
return vowifi.Call{}, errors.New("ims: invalid dial number")
|
||||
}
|
||||
callToken, err := randomHex(18)
|
||||
if err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
branch, err := randomHex(12)
|
||||
if err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
callID := callToken + "@" + addressHost(session.conn.LocalAddr())
|
||||
target := "tel:" + number
|
||||
session.mu.Lock()
|
||||
cseq := session.cseq
|
||||
session.cseq++
|
||||
routes := append([]string(nil), session.evidence.ServiceRoute...)
|
||||
securityHeaders := runtimeSecurityHeaders(session.securityActive, session.securityAgreement.verifyValue)
|
||||
session.mu.Unlock()
|
||||
body := session.inactiveSDP()
|
||||
transportUpper := strings.ToUpper(session.transport)
|
||||
from := "<" + session.identity.public + ">;tag=" + session.fromTag
|
||||
to := "<" + target + ">"
|
||||
lines := []string{
|
||||
"INVITE " + target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
lines = append(lines, securityHeaders...)
|
||||
if len(routes) == 0 {
|
||||
lines = append(lines, "Route: <sip:"+session.endpoint.address()+";transport="+session.transport+";lr>")
|
||||
} else {
|
||||
for _, route := range routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+from,
|
||||
"To: "+to,
|
||||
"Call-ID: "+callID,
|
||||
fmt.Sprintf("CSeq: %d INVITE", cseq),
|
||||
"Contact: <sip:"+session.identity.user+"@"+session.contactAddress()+";transport="+session.transport+">",
|
||||
"P-Preferred-Identity: <"+session.identity.public+">",
|
||||
"Allow: INVITE, ACK, CANCEL, BYE, OPTIONS, MESSAGE",
|
||||
"Supported: timer",
|
||||
"Content-Type: application/sdp",
|
||||
"Content-Length: "+strconv.Itoa(len(body)), "", "",
|
||||
)
|
||||
request := append([]byte(strings.Join(lines, "\r\n")), body...)
|
||||
responses := make(chan *sipResponse, 8)
|
||||
key := sipTransactionKey{callID: callID, cseq: cseq, method: "INVITE"}
|
||||
session.transactionsMu.Lock()
|
||||
if _, duplicate := session.transactions[key]; duplicate {
|
||||
session.transactionsMu.Unlock()
|
||||
return vowifi.Call{}, errors.New("ims: duplicate call transaction")
|
||||
}
|
||||
session.transactions[key] = responses
|
||||
session.transactionsMu.Unlock()
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "outgoing", State: "dialing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: from, to: to, branch: branch, cseq: cseq, responses: responses,
|
||||
routes: routes,
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
session.writeMu.Lock()
|
||||
_, err = session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
if err != nil {
|
||||
session.transactionsMu.Lock()
|
||||
delete(session.transactions, key)
|
||||
session.transactionsMu.Unlock()
|
||||
session.callMu.Lock()
|
||||
delete(session.calls, callID)
|
||||
session.callMu.Unlock()
|
||||
return vowifi.Call{}, fmt.Errorf("ims: send SIP INVITE: %w", err)
|
||||
}
|
||||
go session.watchOutgoingCall(call, key)
|
||||
return call.public, nil
|
||||
}
|
||||
|
||||
func (session *Session) watchOutgoingCall(call *imsCall, key sipTransactionKey) {
|
||||
timer := time.NewTimer(2 * time.Minute)
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
session.transactionsMu.Lock()
|
||||
delete(session.transactions, key)
|
||||
session.transactionsMu.Unlock()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-session.refreshContext.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
session.setCallState(call.callID, "failed")
|
||||
return
|
||||
case response := <-call.responses:
|
||||
if response == nil {
|
||||
continue
|
||||
}
|
||||
if response.StatusCode < 200 {
|
||||
if response.StatusCode >= 180 {
|
||||
session.setCallState(call.callID, "ringing")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
session.callMu.Lock()
|
||||
call.to = response.value("To")
|
||||
call.remoteTag = headerParameter(call.to, "tag")
|
||||
if contact := headerURI(response.value("Contact")); contact != "" {
|
||||
call.target = contact
|
||||
}
|
||||
if recordRoutes := response.values("Record-Route"); len(recordRoutes) > 0 {
|
||||
call.routes = reverseStrings(recordRoutes)
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
_ = session.sendACK(call)
|
||||
session.setCallState(call.callID, "active")
|
||||
} else {
|
||||
session.setCallState(call.callID, "failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) AnswerCall(_ context.Context, id string) (vowifi.Call, error) {
|
||||
session.callMu.Lock()
|
||||
call := session.calls[id]
|
||||
if call == nil {
|
||||
session.callMu.Unlock()
|
||||
return vowifi.Call{}, ErrCallNotFound
|
||||
}
|
||||
if call.public.Direction != "incoming" || call.public.State != "ringing" || call.invite == nil || call.respond == nil {
|
||||
session.callMu.Unlock()
|
||||
return vowifi.Call{}, ErrCallState
|
||||
}
|
||||
request, respond := call.invite, call.respond
|
||||
session.callMu.Unlock()
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, session.inactiveSDP())
|
||||
if err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
if err := respond(response); err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
session.setCallState(id, "active")
|
||||
session.callMu.Lock()
|
||||
result := call.public
|
||||
session.callMu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (session *Session) HangupCall(ctx context.Context, id string) error {
|
||||
session.callMu.Lock()
|
||||
call := session.calls[id]
|
||||
if call == nil {
|
||||
session.callMu.Unlock()
|
||||
return ErrCallNotFound
|
||||
}
|
||||
state := call.public.State
|
||||
direction := call.public.Direction
|
||||
request, respond := call.invite, call.respond
|
||||
session.callMu.Unlock()
|
||||
if direction == "incoming" && state == "ringing" && request != nil && respond != nil {
|
||||
response, err := buildSIPResponseWithBody(request, 486, session.fromTag, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := respond(response); err != nil {
|
||||
return err
|
||||
}
|
||||
session.setCallState(id, "ended")
|
||||
return nil
|
||||
}
|
||||
method := "BYE"
|
||||
if direction == "outgoing" && (state == "dialing" || state == "ringing") {
|
||||
method = "CANCEL"
|
||||
}
|
||||
if err := session.sendDialogRequest(ctx, call, method); err != nil {
|
||||
return err
|
||||
}
|
||||
session.setCallState(id, "ended")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *Session) handleCallRequest(request *sipRequest, respond func([]byte) error) bool {
|
||||
switch request.Method {
|
||||
case "INVITE":
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
if callID == "" {
|
||||
return true
|
||||
}
|
||||
number := identityNumber(request.value("From"))
|
||||
target := headerURI(request.value("Contact"))
|
||||
if target == "" {
|
||||
target = request.URI
|
||||
}
|
||||
call := &imsCall{
|
||||
public: vowifi.Call{ID: callID, Number: number, Direction: "incoming", State: "ringing", StartedAt: time.Now().UTC()},
|
||||
callID: callID, target: target, from: request.value("To") + ";tag=" + session.fromTag,
|
||||
to: request.value("From"), invite: request, respond: respond, routes: request.values("Record-Route"),
|
||||
}
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
return true
|
||||
case "ACK":
|
||||
return true
|
||||
case "CANCEL", "BYE":
|
||||
response, err := buildSIPResponseWithBody(request, 200, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
}
|
||||
callID := strings.TrimSpace(request.value("Call-ID"))
|
||||
if request.Method == "CANCEL" {
|
||||
session.callMu.Lock()
|
||||
call := session.calls[callID]
|
||||
session.callMu.Unlock()
|
||||
if call != nil && call.invite != nil && call.respond != nil {
|
||||
if terminated, buildErr := buildSIPResponseWithBody(call.invite, 487, session.fromTag, nil); buildErr == nil {
|
||||
_ = call.respond(terminated)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.setCallState(callID, "ended")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (session *Session) sendACK(call *imsCall) error {
|
||||
request := session.buildDialogRequest(call, "ACK", call.cseq)
|
||||
session.writeMu.Lock()
|
||||
_, err := session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (session *Session) sendDialogRequest(ctx context.Context, call *imsCall, method string) error {
|
||||
cseq := call.cseq
|
||||
if method == "BYE" {
|
||||
session.mu.Lock()
|
||||
cseq = session.cseq
|
||||
session.cseq++
|
||||
session.mu.Unlock()
|
||||
}
|
||||
request := session.buildDialogRequest(call, method, cseq)
|
||||
if method == "ACK" {
|
||||
session.writeMu.Lock()
|
||||
_, err := session.conn.Write(request)
|
||||
session.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
response, err := session.exchangeRuntime(ctx, request, sipTransactionKey{callID: call.callID, cseq: cseq, method: method})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("ims: SIP %s rejected with %d", method, response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (session *Session) buildDialogRequest(call *imsCall, method string, cseq uint32) []byte {
|
||||
branch, _ := randomHex(12)
|
||||
if method == "CANCEL" {
|
||||
branch = call.branch
|
||||
}
|
||||
to := call.to
|
||||
if to == "" {
|
||||
to = "<" + call.target + ">"
|
||||
}
|
||||
lines := []string{
|
||||
method + " " + call.target + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", strings.ToUpper(session.transport), session.conn.LocalAddr().String(), branch),
|
||||
"Max-Forwards: 70",
|
||||
}
|
||||
for _, route := range call.routes {
|
||||
lines = append(lines, "Route: "+route)
|
||||
}
|
||||
lines = append(lines,
|
||||
"From: "+call.from,
|
||||
"To: "+to,
|
||||
"Call-ID: "+call.callID,
|
||||
fmt.Sprintf("CSeq: %d %s", cseq, method),
|
||||
"Content-Length: 0", "", "",
|
||||
)
|
||||
return []byte(strings.Join(lines, "\r\n"))
|
||||
}
|
||||
|
||||
func (session *Session) inactiveSDP() []byte {
|
||||
var localAddress net.Addr
|
||||
if session.conn != nil {
|
||||
localAddress = session.conn.LocalAddr()
|
||||
}
|
||||
local := addressIP(localAddress)
|
||||
if local == nil {
|
||||
local = net.IPv4zero
|
||||
}
|
||||
family := "IP4"
|
||||
if local.To4() == nil {
|
||||
family = "IP6"
|
||||
}
|
||||
text := fmt.Sprintf("v=0\r\no=- %d %d IN %s %s\r\ns=VoCat Calling Test\r\nc=IN %s %s\r\nt=0 0\r\nm=audio 9 RTP/AVP 0 8\r\na=inactive\r\n", time.Now().Unix(), time.Now().Unix(), family, local.String(), family, local.String())
|
||||
return []byte(text)
|
||||
}
|
||||
|
||||
func buildSIPResponseWithBody(request *sipRequest, status int, tag string, body []byte) ([]byte, error) {
|
||||
reasons := map[int]string{180: "Ringing", 200: "OK", 486: "Busy Here", 487: "Request Terminated"}
|
||||
reason := reasons[status]
|
||||
if reason == "" {
|
||||
return nil, errors.New("ims: unsupported call response status")
|
||||
}
|
||||
via := request.values("Via")
|
||||
from, to := request.value("From"), request.value("To")
|
||||
callID, cseq := request.value("Call-ID"), request.value("CSeq")
|
||||
if len(via) == 0 || from == "" || to == "" || callID == "" || cseq == "" {
|
||||
return nil, errors.New("ims: call request omitted a mandatory response header")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(to), ";tag=") {
|
||||
to += ";tag=" + tag
|
||||
}
|
||||
lines := []string{fmt.Sprintf("SIP/2.0 %d %s", status, reason)}
|
||||
for _, value := range via {
|
||||
lines = append(lines, "Via: "+value)
|
||||
}
|
||||
lines = append(lines, "From: "+from, "To: "+to, "Call-ID: "+callID, "CSeq: "+cseq)
|
||||
if len(body) > 0 {
|
||||
lines = append(lines, "Content-Type: application/sdp")
|
||||
}
|
||||
lines = append(lines, "Content-Length: "+strconv.Itoa(len(body)), "", "")
|
||||
return append([]byte(strings.Join(lines, "\r\n")), body...), nil
|
||||
}
|
||||
|
||||
func (session *Session) setCallState(id, state string) {
|
||||
session.callMu.Lock()
|
||||
if call := session.calls[id]; call != nil {
|
||||
call.public.State = state
|
||||
}
|
||||
session.callMu.Unlock()
|
||||
}
|
||||
|
||||
func validCallNumber(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 identityNumber(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if start := strings.Index(value, "<"); start >= 0 {
|
||||
if end := strings.Index(value[start:], ">"); end > 0 {
|
||||
value = value[start+1 : start+end]
|
||||
}
|
||||
}
|
||||
value = strings.TrimPrefix(value, "sip:")
|
||||
value = strings.TrimPrefix(value, "tel:")
|
||||
if at := strings.Index(value, "@"); at >= 0 {
|
||||
value = value[:at]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func headerParameter(value, name string) string {
|
||||
needle := ";" + strings.ToLower(name) + "="
|
||||
lower := strings.ToLower(value)
|
||||
index := strings.Index(lower, needle)
|
||||
if index < 0 {
|
||||
return ""
|
||||
}
|
||||
value = value[index+len(needle):]
|
||||
if end := strings.IndexAny(value, ";,> \t"); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
func headerURI(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if start := strings.Index(value, "<"); start >= 0 {
|
||||
if end := strings.Index(value[start+1:], ">"); end >= 0 {
|
||||
return strings.TrimSpace(value[start+1 : start+1+end])
|
||||
}
|
||||
}
|
||||
if end := strings.Index(value, ";"); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(value), "sip:") || strings.HasPrefix(strings.ToLower(value), "tel:") {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func reverseStrings(values []string) []string {
|
||||
result := append([]string(nil), values...)
|
||||
for left, right := 0, len(result)-1; left < right; left, right = left+1, right-1 {
|
||||
result[left], result[right] = result[right], result[left]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var _ vowifi.CallController = (*Session)(nil)
|
||||
@@ -0,0 +1,72 @@
|
||||
package ims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIncomingCallCanRingAndAnswerWithoutAudio(t *testing.T) {
|
||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-incoming",
|
||||
"From: <tel:+447700900001>;tag=remote",
|
||||
"To: <sip:[email protected]>",
|
||||
"Call-ID: [email protected]",
|
||||
"CSeq: 1 INVITE",
|
||||
"Content-Length: 0", "", "",
|
||||
}, "\r\n")))
|
||||
if err != nil || packet.Request == nil {
|
||||
t.Fatalf("parse INVITE: %v", err)
|
||||
}
|
||||
var responses [][]byte
|
||||
session.handleSIPRequest(packet.Request, func(response []byte) error {
|
||||
responses = append(responses, append([]byte(nil), response...))
|
||||
return nil
|
||||
})
|
||||
calls := session.Calls()
|
||||
if len(calls) != 1 || calls[0].Direction != "incoming" || calls[0].State != "ringing" || calls[0].Number != "+447700900001" {
|
||||
t.Fatalf("incoming Calls = %#v", calls)
|
||||
}
|
||||
if len(responses) != 1 || !strings.HasPrefix(string(responses[0]), "SIP/2.0 180 Ringing") {
|
||||
t.Fatalf("ringing response = %q", responses)
|
||||
}
|
||||
answered, err := session.AnswerCall(context.Background(), calls[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if answered.State != "active" || len(responses) != 2 || !strings.Contains(string(responses[1]), "a=inactive") {
|
||||
t.Fatalf("answered = %#v, response = %q", answered, responses[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||
session := &Session{fromTag: "local-tag", calls: make(map[string]*imsCall)}
|
||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-a",
|
||||
"From: <tel:+1>;tag=a", "To: <sip:[email protected]>",
|
||||
"Call-ID: reject-call", "CSeq: 1 INVITE", "Content-Length: 0", "", "",
|
||||
}, "\r\n")))
|
||||
if err != nil || packet.Request == nil {
|
||||
t.Fatalf("parse INVITE: %v", err)
|
||||
}
|
||||
var response []byte
|
||||
session.handleCallRequest(packet.Request, func(value []byte) error { response = append([]byte(nil), value...); return nil })
|
||||
if err := session.HangupCall(context.Background(), "reject-call"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(string(response), "SIP/2.0 486 Busy Here") {
|
||||
t.Fatalf("reject response = %q", response)
|
||||
}
|
||||
if len(session.Calls()) != 0 {
|
||||
t.Fatal("ended call remained active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidCallNumber(t *testing.T) {
|
||||
if !validCallNumber("+447700900000") || validCallNumber("12\r\nBYE") {
|
||||
t.Fatal("call number validation mismatch")
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,8 @@ type Session struct {
|
||||
inboundConnections map[net.Conn]struct{}
|
||||
smsMu sync.Mutex
|
||||
nextRPReference byte
|
||||
callMu sync.Mutex
|
||||
calls map[string]*imsCall
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
@@ -494,6 +496,7 @@ func newSession(
|
||||
failures: make(chan error, 1),
|
||||
transactions: make(map[sipTransactionKey]chan *sipResponse),
|
||||
inboundConnections: make(map[net.Conn]struct{}),
|
||||
calls: make(map[string]*imsCall),
|
||||
evidence: vowifi.IMSEvidence{
|
||||
RegistrationState: "registering",
|
||||
Transport: transport,
|
||||
@@ -741,11 +744,13 @@ func (session *Session) buildRegister(
|
||||
requestURI := "sip:" + session.identity.domain
|
||||
routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr"
|
||||
contact := fmt.Sprintf(
|
||||
"<sip:%s@%s;transport=%s>;+sip.instance=\"<%s>\";+g.3gpp.smsip",
|
||||
"<sip:%s@%s;transport=%s>;+sip.instance=\"<%s>\";+g.3gpp.smsip;audio;"+
|
||||
`+g.3gpp.icsi-ref="%s"`,
|
||||
session.identity.user,
|
||||
contactAddress,
|
||||
session.transport,
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
@@ -759,7 +764,7 @@ func (session *Session) buildRegister(
|
||||
"Contact: " + contact,
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: path, gruu",
|
||||
"Allow: REGISTER, OPTIONS",
|
||||
"Allow: REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS",
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
}
|
||||
if session.securityOffered() {
|
||||
|
||||
@@ -264,6 +264,9 @@ func (session *Session) exchangeRuntime(
|
||||
}
|
||||
|
||||
func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byte) error) {
|
||||
if session.handleCallRequest(request, respond) {
|
||||
return
|
||||
}
|
||||
status := 200
|
||||
switch request.Method {
|
||||
case "OPTIONS":
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestSessionReceivesAndAcknowledgesSMSOverIMS(t *testing.T) {
|
||||
|
||||
received := make(chan ReceivedSMS, 1)
|
||||
serverDone := make(chan error, 1)
|
||||
readyForClose := make(chan struct{})
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
go func() { serverDone <- serveInboundSMS(listener, nonce) }()
|
||||
go func() { serverDone <- serveInboundSMS(listener, nonce, readyForClose) }()
|
||||
provider, err := NewProvider(
|
||||
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||
Config{
|
||||
@@ -65,6 +66,11 @@ func TestSessionReceivesAndAcknowledgesSMSOverIMS(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for inbound SMS")
|
||||
}
|
||||
select {
|
||||
case <-readyForClose:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the inbound RP-ACK exchange")
|
||||
}
|
||||
if err := session.Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,9 +108,10 @@ func TestSessionSendsSMSOverIMS(t *testing.T) {
|
||||
defer listener.Close()
|
||||
_ = listener.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
serverDone := make(chan error, 1)
|
||||
readyForClose := make(chan struct{})
|
||||
statusReceived := make(chan ReceivedSMSStatus, 1)
|
||||
nonce := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
go func() { serverDone <- serveOutboundSMS(listener, nonce) }()
|
||||
go func() { serverDone <- serveOutboundSMS(listener, nonce, readyForClose) }()
|
||||
provider, err := NewProvider(
|
||||
smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}},
|
||||
Config{
|
||||
@@ -145,6 +152,11 @@ func TestSessionSendsSMSOverIMS(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for SMS delivery status")
|
||||
}
|
||||
select {
|
||||
case <-readyForClose:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the status-report RP-ACK exchange")
|
||||
}
|
||||
if err := session.Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -153,7 +165,7 @@ func TestSessionSendsSMSOverIMS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func serveInboundSMS(listener *net.UDPConn, nonce string) error {
|
||||
func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||
packet := make([]byte, 65535)
|
||||
count, remote, err := listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
@@ -229,6 +241,7 @@ func serveInboundSMS(listener *net.UDPConn, nonce string) error {
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
close(readyForClose)
|
||||
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
@@ -245,7 +258,7 @@ func serveInboundSMS(listener *net.UDPConn, nonce string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func serveOutboundSMS(listener *net.UDPConn, nonce string) error {
|
||||
func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- struct{}) error {
|
||||
packet := make([]byte, 65535)
|
||||
count, remote, err := listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
@@ -355,6 +368,7 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string) error {
|
||||
if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil {
|
||||
return err
|
||||
}
|
||||
close(readyForClose)
|
||||
|
||||
count, remote, err = listener.ReadFromUDP(packet)
|
||||
if err != nil {
|
||||
|
||||
@@ -512,6 +512,65 @@ func (orchestrator *Orchestrator) SendSMS(
|
||||
return sender.SendSMS(ctx, request)
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) Calls() ([]Call, error) {
|
||||
orchestrator.mu.Lock()
|
||||
resources := orchestrator.resources
|
||||
ready := orchestrator.state.IMSReady
|
||||
orchestrator.mu.Unlock()
|
||||
if resources == nil || resources.ims == nil || !ready {
|
||||
return nil, ErrNotRunning
|
||||
}
|
||||
controller, ok := resources.ims.(CallController)
|
||||
if !ok {
|
||||
return nil, ErrNotRunning
|
||||
}
|
||||
return controller.Calls(), nil
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) DialCall(ctx context.Context, number string) (Call, error) {
|
||||
return orchestrator.callAction(ctx, func(controller CallController) (Call, error) {
|
||||
return controller.DialCall(ctx, number)
|
||||
})
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) AnswerCall(ctx context.Context, id string) (Call, error) {
|
||||
return orchestrator.callAction(ctx, func(controller CallController) (Call, error) {
|
||||
return controller.AnswerCall(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) HangupCall(ctx context.Context, id string) error {
|
||||
_, err := orchestrator.callAction(ctx, func(controller CallController) (Call, error) {
|
||||
return Call{}, controller.HangupCall(ctx, id)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) callAction(
|
||||
ctx context.Context,
|
||||
action func(CallController) (Call, error),
|
||||
) (Call, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := orchestrator.lockOperation(ctx); err != nil {
|
||||
return Call{}, err
|
||||
}
|
||||
defer orchestrator.unlockOperation()
|
||||
orchestrator.mu.Lock()
|
||||
resources := orchestrator.resources
|
||||
ready := orchestrator.state.IMSReady
|
||||
orchestrator.mu.Unlock()
|
||||
if resources == nil || resources.ims == nil || !ready {
|
||||
return Call{}, ErrNotRunning
|
||||
}
|
||||
controller, ok := resources.ims.(CallController)
|
||||
if !ok {
|
||||
return Call{}, ErrNotRunning
|
||||
}
|
||||
return action(controller)
|
||||
}
|
||||
|
||||
func (orchestrator *Orchestrator) Close(ctx context.Context) error {
|
||||
_, err := orchestrator.Disable(ctx)
|
||||
return err
|
||||
|
||||
@@ -20,7 +20,11 @@ var (
|
||||
ErrClosed = errors.New("vowifi runtime: manager is closed")
|
||||
)
|
||||
|
||||
const defaultOperationTimeout = 2 * time.Minute
|
||||
const (
|
||||
defaultOperationTimeout = 2 * time.Minute
|
||||
defaultRetryInitial = 2 * time.Second
|
||||
defaultRetryMaximum = 30 * time.Second
|
||||
)
|
||||
|
||||
type StateHandler func(context.Context, vowifi.State) error
|
||||
type OrchestratorFactory func(context.Context, string) (*vowifi.Orchestrator, error)
|
||||
@@ -28,6 +32,8 @@ type OrchestratorFactory func(context.Context, string) (*vowifi.Orchestrator, er
|
||||
type Options struct {
|
||||
Logger *slog.Logger
|
||||
OperationTimeout time.Duration
|
||||
RetryInitial time.Duration
|
||||
RetryMaximum time.Duration
|
||||
OnState StateHandler
|
||||
Factory OrchestratorFactory
|
||||
}
|
||||
@@ -37,6 +43,8 @@ type Manager struct {
|
||||
cancel context.CancelFunc
|
||||
logger *slog.Logger
|
||||
operationTimeout time.Duration
|
||||
retryInitial time.Duration
|
||||
retryMaximum time.Duration
|
||||
onState StateHandler
|
||||
factory OrchestratorFactory
|
||||
|
||||
@@ -50,6 +58,11 @@ type entry struct {
|
||||
orchestrator *vowifi.Orchestrator
|
||||
busy bool
|
||||
reconnectPending bool
|
||||
disablePending bool
|
||||
desiredEnabled bool
|
||||
autoRetryPending bool
|
||||
retryFailures uint
|
||||
operationCancel context.CancelFunc
|
||||
stopWatch func()
|
||||
}
|
||||
|
||||
@@ -60,12 +73,23 @@ func New(options Options) *Manager {
|
||||
if options.OperationTimeout <= 0 {
|
||||
options.OperationTimeout = defaultOperationTimeout
|
||||
}
|
||||
if options.RetryInitial <= 0 {
|
||||
options.RetryInitial = defaultRetryInitial
|
||||
}
|
||||
if options.RetryMaximum <= 0 {
|
||||
options.RetryMaximum = defaultRetryMaximum
|
||||
}
|
||||
if options.RetryMaximum < options.RetryInitial {
|
||||
options.RetryMaximum = options.RetryInitial
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Manager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: options.Logger,
|
||||
operationTimeout: options.OperationTimeout,
|
||||
retryInitial: options.RetryInitial,
|
||||
retryMaximum: options.RetryMaximum,
|
||||
onState: options.OnState,
|
||||
factory: options.Factory,
|
||||
entries: make(map[string]*entry),
|
||||
@@ -184,6 +208,20 @@ func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.St
|
||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||
return vowifi.State{}, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
item.desiredEnabled = enabled
|
||||
if !enabled && item.busy {
|
||||
item.disablePending = true
|
||||
cancel := item.operationCancel
|
||||
state := item.orchestrator.State()
|
||||
manager.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return manager.startOperation(deviceID, false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
if enabled {
|
||||
_, err := orchestrator.Enable(ctx)
|
||||
@@ -198,6 +236,11 @@ func (manager *Manager) RequestReconnect(deviceID string) (vowifi.State, error)
|
||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||
return vowifi.State{}, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
if item := manager.entries[deviceID]; item != nil {
|
||||
item.desiredEnabled = true
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return manager.startOperation(deviceID, true, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Reconnect(ctx)
|
||||
return err
|
||||
@@ -225,6 +268,58 @@ func (manager *Manager) SendSMS(
|
||||
return item.orchestrator.SendSMS(ctx, request)
|
||||
}
|
||||
|
||||
func (manager *Manager) Calls(deviceID string) ([]vowifi.Call, error) {
|
||||
if err := manager.Ensure(manager.ctx, deviceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
manager.mu.Unlock()
|
||||
if item == nil {
|
||||
return nil, ErrNotRegistered
|
||||
}
|
||||
return item.orchestrator.Calls()
|
||||
}
|
||||
|
||||
func (manager *Manager) DialCall(ctx context.Context, deviceID, number string) (vowifi.Call, error) {
|
||||
if err := manager.Ensure(ctx, deviceID); err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
manager.mu.Unlock()
|
||||
if item == nil {
|
||||
return vowifi.Call{}, ErrNotRegistered
|
||||
}
|
||||
return item.orchestrator.DialCall(ctx, number)
|
||||
}
|
||||
|
||||
func (manager *Manager) AnswerCall(ctx context.Context, deviceID, id string) (vowifi.Call, error) {
|
||||
if err := manager.Ensure(ctx, deviceID); err != nil {
|
||||
return vowifi.Call{}, err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
manager.mu.Unlock()
|
||||
if item == nil {
|
||||
return vowifi.Call{}, ErrNotRegistered
|
||||
}
|
||||
return item.orchestrator.AnswerCall(ctx, id)
|
||||
}
|
||||
|
||||
func (manager *Manager) HangupCall(ctx context.Context, deviceID, id string) error {
|
||||
if err := manager.Ensure(ctx, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
manager.mu.Unlock()
|
||||
if item == nil {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
return item.orchestrator.HangupCall(ctx, id)
|
||||
}
|
||||
|
||||
func (manager *Manager) startOperation(
|
||||
deviceID string,
|
||||
coalesceReconnect bool,
|
||||
@@ -270,6 +365,17 @@ func (manager *Manager) runOperations(
|
||||
defer manager.wg.Done()
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout)
|
||||
manager.mu.Lock()
|
||||
if item.disablePending {
|
||||
item.disablePending = false
|
||||
item.reconnectPending = false
|
||||
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Disable(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
item.operationCancel = cancel
|
||||
manager.mu.Unlock()
|
||||
err := operation(ctx, item.orchestrator)
|
||||
cancel()
|
||||
if err != nil &&
|
||||
@@ -281,23 +387,114 @@ func (manager *Manager) runOperations(
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
state := item.orchestrator.State()
|
||||
manager.mu.Lock()
|
||||
if manager.closed || !item.reconnectPending {
|
||||
item.operationCancel = nil
|
||||
if manager.closed {
|
||||
item.busy = false
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
item.reconnectPending = false
|
||||
if item.disablePending {
|
||||
item.disablePending = false
|
||||
item.reconnectPending = false
|
||||
manager.mu.Unlock()
|
||||
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Disable(ctx)
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if item.reconnectPending {
|
||||
item.reconnectPending = false
|
||||
manager.mu.Unlock()
|
||||
|
||||
// Read the route only when this runs. If the user bound, unbound,
|
||||
// then rebound while busy, this reconnect uses the final persisted
|
||||
// binding instead of replaying stale intermediate routes.
|
||||
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Reconnect(ctx)
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
item.busy = false
|
||||
shouldRetry := item.desiredEnabled && state.Phase == vowifi.PhaseFailed
|
||||
if !shouldRetry && state.Phase != vowifi.PhaseFailed {
|
||||
item.retryFailures = 0
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
if shouldRetry {
|
||||
manager.scheduleAutoRetry(deviceID, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) scheduleAutoRetry(deviceID string, item *entry) {
|
||||
manager.mu.Lock()
|
||||
if manager.closed || item.busy || item.autoRetryPending || !item.desiredEnabled {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delay := manager.retryInitial
|
||||
for attempt := uint(0); attempt < item.retryFailures && delay < manager.retryMaximum; attempt++ {
|
||||
if delay > manager.retryMaximum/2 {
|
||||
delay = manager.retryMaximum
|
||||
break
|
||||
}
|
||||
delay *= 2
|
||||
}
|
||||
if delay > manager.retryMaximum {
|
||||
delay = manager.retryMaximum
|
||||
}
|
||||
item.retryFailures++
|
||||
item.autoRetryPending = true
|
||||
manager.wg.Add(1)
|
||||
manager.mu.Unlock()
|
||||
|
||||
manager.logger.Info(
|
||||
"VoWiFi automatic retry scheduled",
|
||||
"device_id", deviceID,
|
||||
"retry_in", delay,
|
||||
)
|
||||
go func() {
|
||||
defer manager.wg.Done()
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-manager.ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
item.autoRetryPending = false
|
||||
if manager.closed || manager.entries[deviceID] != item || !item.desiredEnabled {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
state := item.orchestrator.State()
|
||||
if state.Phase != vowifi.PhaseFailed {
|
||||
if state.Phase != vowifi.PhaseStopping {
|
||||
item.retryFailures = 0
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if item.busy {
|
||||
manager.mu.Unlock()
|
||||
return
|
||||
}
|
||||
item.busy = true
|
||||
manager.wg.Add(1)
|
||||
manager.mu.Unlock()
|
||||
|
||||
// Read the route only when this runs. If the user bound, unbound, then
|
||||
// rebound while busy, the single reconnect uses the final persisted
|
||||
// binding instead of replaying stale intermediate routes.
|
||||
operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Reconnect(ctx)
|
||||
go manager.runOperations(deviceID, item, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error {
|
||||
_, err := orchestrator.Retry(ctx)
|
||||
return err
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (manager *Manager) watch(deviceID string, states <-chan vowifi.State) {
|
||||
@@ -310,6 +507,20 @@ func (manager *Manager) watch(deviceID string, states <-chan vowifi.State) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if state.Phase == vowifi.PhaseFailed {
|
||||
manager.mu.Lock()
|
||||
item := manager.entries[deviceID]
|
||||
manager.mu.Unlock()
|
||||
if item != nil {
|
||||
manager.scheduleAutoRetry(deviceID, item)
|
||||
}
|
||||
} else if state.Phase == vowifi.PhaseSMSReady || !state.Enabled {
|
||||
manager.mu.Lock()
|
||||
if item := manager.entries[deviceID]; item != nil {
|
||||
item.retryFailures = 0
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
if manager.onState == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -63,6 +63,31 @@ func (fakeTunnelSession) Evidence() vowifi.TunnelEvidence {
|
||||
}
|
||||
func (fakeTunnelSession) Close(context.Context) error { return nil }
|
||||
|
||||
type flakyTunnelProvider struct {
|
||||
mu sync.Mutex
|
||||
attempts int
|
||||
failures int
|
||||
}
|
||||
|
||||
func (provider *flakyTunnelProvider) Start(
|
||||
context.Context,
|
||||
vowifi.TunnelRequest,
|
||||
) (vowifi.TunnelSession, error) {
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
provider.attempts++
|
||||
if provider.attempts <= provider.failures {
|
||||
return nil, errors.New("temporary tunnel failure")
|
||||
}
|
||||
return fakeTunnelSession{}, nil
|
||||
}
|
||||
|
||||
func (provider *flakyTunnelProvider) Attempts() int {
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
return provider.attempts
|
||||
}
|
||||
|
||||
type fakeIMSProvider struct{}
|
||||
type fakeIMSSession struct{}
|
||||
|
||||
@@ -104,6 +129,27 @@ func testOrchestrator(t *testing.T, id string) *vowifi.Orchestrator {
|
||||
return orchestrator
|
||||
}
|
||||
|
||||
func testOrchestratorWithTunnel(
|
||||
t *testing.T,
|
||||
id string,
|
||||
tunnel vowifi.TunnelProvider,
|
||||
) *vowifi.Orchestrator {
|
||||
t.Helper()
|
||||
orchestrator, err := vowifi.New(vowifi.Dependencies{
|
||||
SIM: fakeSIM{},
|
||||
AKA: fakeAKA{},
|
||||
Radio: fakeRadio{},
|
||||
Proxy: fakeProxy{},
|
||||
Tunnel: tunnel,
|
||||
IMS: fakeIMSProvider{},
|
||||
Phones: fakePhones{},
|
||||
}, vowifi.Options{DeviceID: id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return orchestrator
|
||||
}
|
||||
|
||||
func TestManagerRunsAndPublishesEnable(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var states []vowifi.State
|
||||
@@ -148,6 +194,79 @@ func TestManagerRunsAndPublishesEnable(t *testing.T) {
|
||||
t.Fatal("enable did not finish")
|
||||
}
|
||||
|
||||
func TestManagerRetriesEnabledPolicyUntilReady(t *testing.T) {
|
||||
provider := &flakyTunnelProvider{failures: 2}
|
||||
manager := New(Options{
|
||||
OperationTimeout: time.Second,
|
||||
RetryInitial: 5 * time.Millisecond,
|
||||
RetryMaximum: 10 * time.Millisecond,
|
||||
})
|
||||
t.Cleanup(func() { _ = manager.Close(context.Background()) })
|
||||
if err := manager.Register(testOrchestratorWithTunnel(t, "ec20", provider)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.RequestEnabled("ec20", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
state, err := manager.State("ec20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Phase == vowifi.PhaseSMSReady {
|
||||
if attempts := provider.Attempts(); attempts != 3 {
|
||||
t.Fatalf("tunnel attempts = %d, want 3", attempts)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("VoWiFi did not become ready after retries; attempts=%d", provider.Attempts())
|
||||
}
|
||||
|
||||
func TestManagerStopsAutomaticRetryWhenPolicyIsDisabled(t *testing.T) {
|
||||
provider := &flakyTunnelProvider{failures: 100}
|
||||
manager := New(Options{
|
||||
OperationTimeout: time.Second,
|
||||
RetryInitial: 100 * time.Millisecond,
|
||||
RetryMaximum: 100 * time.Millisecond,
|
||||
})
|
||||
t.Cleanup(func() { _ = manager.Close(context.Background()) })
|
||||
if err := manager.Register(testOrchestratorWithTunnel(t, "ec20", provider)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.RequestEnabled("ec20", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
manager.mu.Lock()
|
||||
pending := manager.entries["ec20"].autoRetryPending
|
||||
manager.mu.Unlock()
|
||||
if pending {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if _, err := manager.RequestEnabled("ec20", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if attempts := provider.Attempts(); attempts != 1 {
|
||||
t.Fatalf("tunnel attempts after disable = %d, want 1", attempts)
|
||||
}
|
||||
state, err := manager.State("ec20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Enabled || state.Phase != vowifi.PhaseIdle {
|
||||
t.Fatalf("state after disabling retry policy = %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsUnknownDevice(t *testing.T) {
|
||||
manager := New(Options{})
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -365,6 +365,25 @@ type SMSSender interface {
|
||||
SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error)
|
||||
}
|
||||
|
||||
// Call describes one signalling-only IMS call. VoCat intentionally does not
|
||||
// open, capture, or relay an RTP media stream for extension call tests.
|
||||
type Call struct {
|
||||
ID string `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Direction string `json:"direction"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// CallController is an optional capability of an IMS session. Implementations
|
||||
// manage SIP signalling only; audio handling is explicitly outside this API.
|
||||
type CallController interface {
|
||||
Calls() []Call
|
||||
DialCall(context.Context, string) (Call, error)
|
||||
AnswerCall(context.Context, string) (Call, error)
|
||||
HangupCall(context.Context, string) error
|
||||
}
|
||||
|
||||
// PhoneStore persists a number only after it was explicitly associated by IMS.
|
||||
type PhoneStore interface {
|
||||
SaveAssociatedNumber(context.Context, PhoneRecord) error
|
||||
|
||||
+74
-20
@@ -27,10 +27,10 @@ REPO="${VOCAT_REPO:-MengMengCode/VoCat}"
|
||||
|
||||
INSTALL_DIR="/opt/vocat/bin"
|
||||
BINARY_PATH="${INSTALL_DIR}/vocat"
|
||||
LINK_PATH="/usr/local/bin/vocat"
|
||||
ENV_DIR="/etc/vocat"
|
||||
ENV_FILE="${ENV_DIR}/env"
|
||||
UNIT_PATH="/etc/systemd/system/vocat.service"
|
||||
VOCAT_USER="vocat"
|
||||
|
||||
# --- Language ----------------------------------------------------------------
|
||||
LANG_CHOICE=""
|
||||
@@ -45,10 +45,20 @@ msg() {
|
||||
}
|
||||
|
||||
prompt_language() {
|
||||
if ! ( : </dev/tty ) 2>/dev/null; then
|
||||
case "${VOCAT_LANG:-en}" in
|
||||
zh|zh-CN|cn) LANG_CHOICE="zh" ;;
|
||||
*) LANG_CHOICE="en" ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
while true; do
|
||||
echo "选择语言 / Select language: 1) 中文 2) English"
|
||||
printf '> '
|
||||
read -r choice
|
||||
echo "选择语言 / Select language: 1) 中文 2) English" >/dev/tty
|
||||
printf '> ' >/dev/tty
|
||||
if ! read -r choice </dev/tty; then
|
||||
LANG_CHOICE="en"
|
||||
return
|
||||
fi
|
||||
case "$choice" in
|
||||
1|"") LANG_CHOICE="zh"; return ;;
|
||||
2) LANG_CHOICE="en"; return ;;
|
||||
@@ -108,6 +118,8 @@ skip_if_equal() {
|
||||
installed=$("$BINARY_PATH" version 2>/dev/null | awk '{print $2}' | sed -E 's/[[:space:]]*\(.*$//') || return 0
|
||||
[ -z "$installed" ] && return 0
|
||||
if [ "$installed" = "$TARGET_VERSION" ]; then
|
||||
install -d -m 0755 "$(dirname "$LINK_PATH")"
|
||||
ln -sfn "$BINARY_PATH" "$LINK_PATH"
|
||||
msg "已安装版本 $installed,与目标版本相同,跳过更新。" "Installed version $installed equals target; skipping."
|
||||
exit 0
|
||||
fi
|
||||
@@ -116,10 +128,12 @@ skip_if_equal() {
|
||||
|
||||
# --- Detect architecture -----------------------------------------------------
|
||||
detect_arch() {
|
||||
ARCH_FALLBACK=""
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
i386|i486|i586|i686) ARCH="386" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
aarch64) ARCH="aarch64"; ARCH_FALLBACK="arm64" ;;
|
||||
arm64) ARCH="arm64"; ARCH_FALLBACK="aarch64" ;;
|
||||
armv7l|armv7*) ARCH="armv7" ;;
|
||||
*) die "不支持的架构: $(uname -m)" "Unsupported architecture: $(uname -m)" ;;
|
||||
esac
|
||||
@@ -132,6 +146,9 @@ download_and_verify() {
|
||||
trap 'rm -rf "$VOCAT_TMP"' EXIT
|
||||
local base="https://github.com/${REPO}/releases/download/v${TARGET_VERSION}"
|
||||
local asset="vocat-linux-${ARCH}"
|
||||
if [ -n "$ARCH_FALLBACK" ] && ! curl -fsIL -o /dev/null "${base}/${asset}"; then
|
||||
asset="vocat-linux-${ARCH_FALLBACK}"
|
||||
fi
|
||||
msg "下载 $asset ..." "Downloading $asset ..."
|
||||
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
|
||||
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
|
||||
@@ -147,21 +164,19 @@ download_and_verify() {
|
||||
# --- Install binary ----------------------------------------------------------
|
||||
install_binary() {
|
||||
install -d -m 0755 "$INSTALL_DIR"
|
||||
install -m 0755 "${VOCAT_TMP}/vocat" "$BINARY_PATH"
|
||||
}
|
||||
|
||||
# --- System user (idempotent) ------------------------------------------------
|
||||
ensure_user() {
|
||||
if id "$VOCAT_USER" >/dev/null 2>&1; then
|
||||
return
|
||||
install -m 0755 "${VOCAT_TMP}/vocat" "${BINARY_PATH}.new"
|
||||
if [ -e "$BINARY_PATH" ]; then
|
||||
cp -a "$BINARY_PATH" "${BINARY_PATH}.bak"
|
||||
fi
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$VOCAT_USER"
|
||||
mv -f "${BINARY_PATH}.new" "$BINARY_PATH"
|
||||
install -d -m 0755 "$(dirname "$LINK_PATH")"
|
||||
ln -sfn "$BINARY_PATH" "$LINK_PATH"
|
||||
}
|
||||
|
||||
# --- Data directory ----------------------------------------------------------
|
||||
ensure_data_dir() {
|
||||
install -d -m 0755 /opt/vocat/data
|
||||
chown -R "$VOCAT_USER":"$VOCAT_USER" /opt/vocat || true
|
||||
chown -R root:root /opt/vocat
|
||||
}
|
||||
|
||||
# --- Env file (first install only) -------------------------------------------
|
||||
@@ -174,7 +189,7 @@ setup_env() {
|
||||
fi
|
||||
install -d -m 0755 "$ENV_DIR"
|
||||
local secret
|
||||
secret=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
|
||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
|
||||
printf 'VOCAT_ADMIN_PASSWORD=%s\n' "$secret" > "$ENV_FILE"
|
||||
chmod 0600 "$ENV_FILE"
|
||||
@@ -185,15 +200,46 @@ setup_env() {
|
||||
write_unit() {
|
||||
cat > "$UNIT_PATH" <<EOF
|
||||
[Unit]
|
||||
Description=vocat
|
||||
After=network.target
|
||||
Description=vocat cellular and VoWiFi control service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=${VOCAT_USER}
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/vocat
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
Environment=VOCAT_ADDR=0.0.0.0:7575
|
||||
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
ExecStart=${BINARY_PATH}
|
||||
Restart=on-failure
|
||||
RestartSec=3s
|
||||
TimeoutStartSec=30s
|
||||
# HTTP, VoWiFi, and modem cleanup have bounded shutdown contexts totalling up
|
||||
# to 30 seconds. Leave a small margin before systemd resorts to SIGKILL.
|
||||
TimeoutStopSec=40s
|
||||
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
# The web/CLI self-updater verifies a release in this directory and atomically
|
||||
# renames it over the running binary. Keep the rest of the host read-only.
|
||||
ReadWritePaths=/opt/vocat/data /opt/vocat/bin
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0077
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -203,7 +249,16 @@ EOF
|
||||
|
||||
enable_and_start() {
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now vocat
|
||||
systemctl enable vocat
|
||||
if systemctl restart vocat; then
|
||||
return
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary."
|
||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||
systemctl restart vocat || true
|
||||
fi
|
||||
die "vocat 服务启动失败。" "The vocat service failed to start."
|
||||
}
|
||||
|
||||
# --- Main --------------------------------------------------------------------
|
||||
@@ -212,7 +267,6 @@ detect_arch
|
||||
skip_if_equal
|
||||
download_and_verify
|
||||
install_binary
|
||||
ensure_user
|
||||
ensure_data_dir
|
||||
setup_env
|
||||
write_unit
|
||||
|
||||
@@ -15,6 +15,7 @@ import ProxyPage from "./pages/ProxyPage";
|
||||
import SmsPage from "./pages/SmsPage";
|
||||
import LogsPage from "./pages/LogsPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import ExtensionPage from "./pages/ExtensionPage";
|
||||
|
||||
const THEME_KEY = "theme";
|
||||
const DISCLAIMER_KEY = "vocat_disclaimer_agreed_at";
|
||||
@@ -109,6 +110,7 @@ function AppRoot() {
|
||||
<Route path="devices/*" element={<DevicesPage />} />
|
||||
<Route path="proxy" element={<ProxyPage />} />
|
||||
<Route path="sms" element={<SmsPage />} />
|
||||
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
+7
-2
@@ -74,8 +74,9 @@ export interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers);
|
||||
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
headers.set("Accept", options.raw ? "*/*" : "application/json");
|
||||
if (options.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (options.body !== undefined && !formBody) headers.set("Content-Type", "application/json");
|
||||
if (isMutation(method)) {
|
||||
const csrf = sessionStorage.getItem(CSRF_KEY);
|
||||
if (csrf) headers.set("X-CSRF-Token", csrf);
|
||||
@@ -86,7 +87,11 @@ export async function api<T>(path: string, options: RequestOptions = {}): Promis
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: options.body === undefined ? undefined : JSON.stringify(snakeize(options.body)),
|
||||
body: options.body === undefined
|
||||
? undefined
|
||||
: formBody
|
||||
? options.body as FormData
|
||||
: JSON.stringify(snakeize(options.body)),
|
||||
});
|
||||
|
||||
if (options.raw) return response as T;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function DiscoveredDeviceRow({
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">
|
||||
{device.controlPath} · AT: {device.atPort || "--"} · IMEI: {device.imei || "--"} · USB: {device.usbPath || "--"}
|
||||
</div>
|
||||
{degraded ? <div className="mt-1 text-xs text-amber-700">{t("无法读取 IMEI(控制口可能挂死),暂不可添加。")}</div> : null}
|
||||
{degraded ? <div className="mt-1 text-xs text-amber-700">{t("未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。")}</div> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AddRegular, ArrowUploadRegular, DeleteRegular, PlugConnectedRegular } from "@fluentui/react-icons";
|
||||
import { api, apiMessage } from "../../api";
|
||||
import { listPlugins, type InstalledPlugin } from "../../extensions";
|
||||
import { Button, confirmDialog, Input, message } from "../ui";
|
||||
import { CardDecor, CardIcon, CardTitle } from "./Cards";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export function PluginsCard() {
|
||||
const { t } = useI18n();
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
const [url, setURL] = useState("");
|
||||
const [sha256, setSHA256] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setPlugins(await listPlugins()); }
|
||||
catch (error) { message.error(apiMessage(error) || t("插件列表加载失败")); }
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
async function installURL() {
|
||||
if (!url.trim()) return message.warning(t("请输入插件包 URL"));
|
||||
const confirmed = await confirmDialog(
|
||||
t("插件页面以当前管理员权限运行,插件后端还可以运行外部代码。仅安装你完全信任的插件。"),
|
||||
t("安装外部插件?"),
|
||||
{ type: "warning", confirmText: t("安装") },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setBusy("install");
|
||||
try {
|
||||
await api("/extensions/install-url", { method: "POST", body: { url: url.trim(), sha256: sha256.trim() } });
|
||||
setURL(""); setSHA256("");
|
||||
message.success(t("插件已安装并启用"));
|
||||
await reload();
|
||||
window.dispatchEvent(new Event("vocat:plugins-changed"));
|
||||
} catch (error) { message.error(apiMessage(error) || t("插件安装失败")); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function upload(file?: File) {
|
||||
if (!file) return;
|
||||
const confirmed = await confirmDialog(
|
||||
t("插件页面以当前管理员权限运行,插件后端还可以运行外部代码。仅安装你完全信任的插件。"),
|
||||
t("安装上传的插件?"),
|
||||
{ type: "warning", confirmText: t("安装") },
|
||||
);
|
||||
if (!confirmed) { if (fileRef.current) fileRef.current.value = ""; return; }
|
||||
const form = new FormData();
|
||||
form.append("package", file);
|
||||
setBusy("upload");
|
||||
try {
|
||||
await api("/extensions/upload", { method: "POST", body: form });
|
||||
message.success(t("插件已安装并启用"));
|
||||
await reload();
|
||||
window.dispatchEvent(new Event("vocat:plugins-changed"));
|
||||
} catch (error) { message.error(apiMessage(error) || t("插件安装失败")); }
|
||||
finally { setBusy(""); if (fileRef.current) fileRef.current.value = ""; }
|
||||
}
|
||||
|
||||
async function toggle(plugin: InstalledPlugin) {
|
||||
setBusy(plugin.id);
|
||||
try {
|
||||
await api(`/extensions/${encodeURIComponent(plugin.id)}`, { method: "PUT", body: { enabled: !plugin.enabled } });
|
||||
await reload();
|
||||
window.dispatchEvent(new Event("vocat:plugins-changed"));
|
||||
} catch (error) { message.error(apiMessage(error) || t("插件状态更新失败")); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function uninstall(plugin: InstalledPlugin) {
|
||||
const confirmed = await confirmDialog(t("插件代码和插件数据将从本机删除。"), t("卸载插件?"), { type: "warning", confirmText: t("卸载") });
|
||||
if (!confirmed) return;
|
||||
setBusy(plugin.id);
|
||||
try {
|
||||
await api(`/extensions/${encodeURIComponent(plugin.id)}`, { method: "DELETE" });
|
||||
message.success(t("插件已卸载"));
|
||||
await reload();
|
||||
window.dispatchEvent(new Event("vocat:plugins-changed"));
|
||||
} catch (error) { message.error(apiMessage(error) || t("插件卸载失败")); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-card group relative overflow-hidden p-8 lg:col-span-2">
|
||||
<CardDecor />
|
||||
<div className="relative z-10 mb-6 flex items-center gap-3">
|
||||
<CardIcon><PlugConnectedRegular className="text-[24px]" /></CardIcon>
|
||||
<CardTitle title={t("插件")} subtitle={t("通过 URL 或本地插件包扩展 VoCat 功能")} />
|
||||
</div>
|
||||
<div className="relative z-10 grid gap-3 md:grid-cols-[1fr_18rem_auto]">
|
||||
<Input value={url} onChange={(event) => setURL(event.target.value)} placeholder="https://example.com/plugin.vocat-plugin" />
|
||||
<Input value={sha256} onChange={(event) => setSHA256(event.target.value)} placeholder={t("SHA-256(可选,推荐)")} />
|
||||
<Button variant="primary" icon={<AddRegular />} loading={busy === "install"} onClick={() => void installURL()}>{t("从 URL 安装")}</Button>
|
||||
</div>
|
||||
<div className="relative z-10 mt-3">
|
||||
<input ref={fileRef} type="file" accept=".zip,.vocat-plugin,application/zip" className="hidden" onChange={(event) => void upload(event.target.files?.[0])} />
|
||||
<Button icon={<ArrowUploadRegular />} loading={busy === "upload"} onClick={() => fileRef.current?.click()}>{t("上传插件包")}</Button>
|
||||
</div>
|
||||
<div className="relative z-10 mt-6 space-y-3">
|
||||
{plugins.length === 0 ? <div className="ui-panel-muted p-5 text-sm text-gray-500">{t("尚未安装插件")}</div> : plugins.map((plugin) => (
|
||||
<div key={plugin.id} className="ui-panel-muted flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold">{plugin.name} <span className="text-xs font-normal text-gray-400">v{plugin.version}</span></div>
|
||||
<div className="mt-1 text-xs text-gray-500">{plugin.description || plugin.id}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-gray-400">
|
||||
{(plugin.permissions || []).map((permission) => <span key={permission} className="rounded bg-black/5 px-2 py-1 dark:bg-white/5">{permission}</span>)}
|
||||
{plugin.backendError ? <span className="text-red-500">{plugin.backendError}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button loading={busy === plugin.id} onClick={() => void toggle(plugin)}>{plugin.enabled ? t("禁用") : t("启用")}</Button>
|
||||
<Button variant="danger" icon={<DeleteRegular />} disabled={busy === plugin.id} onClick={() => void uninstall(plugin)}>{t("卸载")}</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { ErrorBoundary } from "../ui/ErrorBoundary";
|
||||
import { cx } from "../../lib/utils";
|
||||
import { BrandLogo } from "./BrandLogo";
|
||||
import { VersionBadge } from "./VersionBadge";
|
||||
import { listPlugins, type InstalledPlugin } from "../../extensions";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "仪表盘", icon: BoardRegular, end: true },
|
||||
@@ -41,8 +42,9 @@ export function AuthenticatedShell({
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
const { logout, user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -57,6 +59,16 @@ export function AuthenticatedShell({
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => listPlugins().then((items) => {
|
||||
if (active) setPlugins(items || []);
|
||||
}).catch(() => undefined);
|
||||
void load();
|
||||
window.addEventListener("vocat:plugins-changed", load);
|
||||
return () => { active = false; window.removeEventListener("vocat:plugins-changed", load); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
@@ -79,16 +91,37 @@ export function AuthenticatedShell({
|
||||
}
|
||||
|
||||
function menuList(collapse: boolean) {
|
||||
const sidebarPlugins = plugins
|
||||
.filter((plugin) => plugin.enabled)
|
||||
.flatMap((plugin) => plugin.contributions
|
||||
.filter((contribution) => contribution.location === "sidebar")
|
||||
.map((contribution) => ({ plugin, contribution })));
|
||||
const navItems: Array<(typeof NAV)[number] | { to: string; label: string; icon: typeof GlobeRegular; pluginLabelZH?: string }> = [];
|
||||
for (const item of NAV) {
|
||||
navItems.push(item);
|
||||
if (item.to === "/sms") {
|
||||
for (const extension of sidebarPlugins.filter((entry) => !entry.contribution.after || entry.contribution.after === "sms")) {
|
||||
navItems.push({
|
||||
to: `/extensions/${encodeURIComponent(extension.plugin.id)}/${encodeURIComponent(extension.contribution.id)}`,
|
||||
label: extension.contribution.label,
|
||||
pluginLabelZH: extension.contribution.labelZh,
|
||||
icon: GlobeRegular,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<nav className={cx("sidebar-menu mt-2", collapse && "is-collapsed")} aria-label={t("主导航")}>
|
||||
{NAV.map((item) => {
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const label = t(item.label);
|
||||
const label = "pluginLabelZH" in item && item.pluginLabelZH
|
||||
? (lang === "zh" ? item.pluginLabelZH : item.label)
|
||||
: t(item.label);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
end={"end" in item ? item.end : undefined}
|
||||
title={collapse ? label : undefined}
|
||||
className={({ isActive }) => cx("vocat-menu-item", isActive && "is-active")}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ThreadQuery {
|
||||
peer: string;
|
||||
limit: number;
|
||||
deviceId?: string;
|
||||
modemImei?: string;
|
||||
imsi?: string;
|
||||
beforeTs?: string;
|
||||
beforeId?: number;
|
||||
@@ -38,6 +39,7 @@ export function getThread(q: ThreadQuery): Promise<SMSMessage[]> {
|
||||
peer: q.peer,
|
||||
limit: q.limit,
|
||||
device_id: q.deviceId,
|
||||
modem_imei: q.modemImei,
|
||||
imsi: q.imsi,
|
||||
before_ts: q.beforeTs,
|
||||
before_id: q.beforeId,
|
||||
@@ -78,9 +80,10 @@ export function deleteMessage(id: number): Promise<{ threadEmpty?: boolean }> {
|
||||
export interface DeleteThreadQuery {
|
||||
peer: string;
|
||||
deviceId?: string;
|
||||
modemImei?: string;
|
||||
imsi?: string;
|
||||
}
|
||||
|
||||
export function deleteThread(q: DeleteThreadQuery): Promise<unknown> {
|
||||
return api(`/sms/thread${qs({ peer: q.peer, device_id: q.deviceId, imsi: q.imsi })}`, { method: "DELETE" });
|
||||
return api(`/sms/thread${qs({ peer: q.peer, device_id: q.deviceId, modem_imei: q.modemImei, imsi: q.imsi })}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tl, useI18n } from "../../lib/i18n";
|
||||
// Aggregated conversation row built from an SMSContact (mirrors the VoHive `Os` mapper).
|
||||
export interface SmsThread {
|
||||
key: string;
|
||||
modemImei: string;
|
||||
imsi: string;
|
||||
peer: string;
|
||||
deviceId: string;
|
||||
@@ -79,8 +80,10 @@ export function dayLabel(ts: number): string {
|
||||
|
||||
export function deriveThread(c: SMSContact): SmsThread {
|
||||
const lastMessage = String(c.lastContent ?? c.lastMessage ?? "").slice(0, 80);
|
||||
const modemImei = String(c.modemImei || "").trim();
|
||||
return {
|
||||
key: `${c.imsi}|${c.peer}`,
|
||||
key: `${modemImei || `device:${c.deviceId}`}|${c.imsi}|${c.peer}`,
|
||||
modemImei,
|
||||
imsi: c.imsi,
|
||||
peer: c.peer,
|
||||
deviceId: c.deviceId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api } from "./api";
|
||||
|
||||
export interface PluginContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
labelZh?: string;
|
||||
location: "sidebar" | "proxy";
|
||||
after?: string;
|
||||
entry: string;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
permissions?: string[];
|
||||
contributions: PluginContribution[];
|
||||
enabled: boolean;
|
||||
backendAvailable: boolean;
|
||||
backendRunning: boolean;
|
||||
backendError?: string;
|
||||
installedAt: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export function listPlugins() {
|
||||
return api<InstalledPlugin[]>("/extensions");
|
||||
}
|
||||
|
||||
export function pluginAssetURL(plugin: InstalledPlugin, contribution: PluginContribution) {
|
||||
return `/plugin-assets/${encodeURIComponent(plugin.id)}/${contribution.entry.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
@@ -5,6 +5,30 @@
|
||||
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
|
||||
*/
|
||||
export const EN_DICT: Record<string, string> = {
|
||||
// External extensions.
|
||||
"插件": "Plugins",
|
||||
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
|
||||
"插件列表加载失败": "Failed to load plugins",
|
||||
"请输入插件包 URL": "Enter a plugin package URL",
|
||||
"插件页面以当前管理员权限运行,插件后端还可以运行外部代码。仅安装你完全信任的插件。":
|
||||
"Plugin pages run with the current administrator's privileges, and plugin backends can execute external code. Install only plugins you fully trust.",
|
||||
"安装外部插件?": "Install external plugin?",
|
||||
"安装上传的插件?": "Install uploaded plugin?",
|
||||
"插件已安装并启用": "Plugin installed and enabled",
|
||||
"插件安装失败": "Plugin installation failed",
|
||||
"插件状态更新失败": "Failed to update plugin state",
|
||||
"插件代码和插件数据将从本机删除。": "The plugin code and its data will be removed from this host.",
|
||||
"卸载插件?": "Uninstall plugin?",
|
||||
"卸载": "Uninstall",
|
||||
"插件已卸载": "Plugin uninstalled",
|
||||
"插件卸载失败": "Plugin uninstall failed",
|
||||
"从 URL 安装": "Install from URL",
|
||||
"上传插件包": "Upload plugin package",
|
||||
"SHA-256(可选,推荐)": "SHA-256 (optional, recommended)",
|
||||
"尚未安装插件": "No plugins installed",
|
||||
"插件加载失败": "Failed to load plugin",
|
||||
"插件不可用": "Plugin unavailable",
|
||||
"插件可能已被禁用、卸载或没有注册此页面。": "The plugin may be disabled, uninstalled, or may not register this page.",
|
||||
// Device-bound VoWiFi upstream routing.
|
||||
"设备绑定": "Device Bindings",
|
||||
"绑定后,该设备的 VoWiFi 建链和通信都会使用此 SOCKS5 代理;解绑后恢复直连。配置变更会立即尝试重连 VoWiFi。":
|
||||
@@ -565,6 +589,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"数据平面": "Data Plane",
|
||||
"方向": "Direction",
|
||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||
"No usable AT port was found (serial interfaces may still be enumerating). The system retries automatically; you can also rescan now.",
|
||||
"无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试": "Cannot read the device IMEI (the control port may be stuck); run AT!RESET or switch the USB composition and retry",
|
||||
"是": "Yes",
|
||||
"显示名称(可选)": "Display Name (optional)",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { apiMessage } from "../api";
|
||||
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
|
||||
import { ErrorState, ListSkeleton, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
export default function ExtensionPage() {
|
||||
const { pluginId = "", contributionId = "" } = useParams();
|
||||
const { t, lang } = useI18n();
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
listPlugins()
|
||||
.then((items) => setPlugins(items || []))
|
||||
.catch((reason) => setError(apiMessage(reason)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pluginId, contributionId]);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
const plugin = plugins.find((item) => item.id === pluginId && item.enabled);
|
||||
const contribution = plugin?.contributions.find((item) => item.id === contributionId && item.location === "sidebar");
|
||||
return plugin && contribution ? { plugin, contribution } : null;
|
||||
}, [plugins, pluginId, contributionId]);
|
||||
|
||||
if (loading) return <ListSkeleton rows={6} />;
|
||||
if (error) return <ErrorState title={t("插件加载失败")} message={error} />;
|
||||
if (!selected) return <ErrorState title={t("插件不可用")} message={t("插件可能已被禁用、卸载或没有注册此页面。")} />;
|
||||
const label = lang === "zh" && selected.contribution.labelZh
|
||||
? selected.contribution.labelZh
|
||||
: selected.contribution.label;
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader title={label} subtitle={`${selected.plugin.name} · ${selected.plugin.version}`} />
|
||||
<iframe
|
||||
title={label}
|
||||
src={pluginAssetURL(selected.plugin, selected.contribution)}
|
||||
className="h-[calc(100vh-10rem)] min-h-[560px] w-full rounded-xl border border-gray-200 bg-white dark:border-white/10 dark:bg-[#15151a]"
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { UpstreamDialog } from "../components/proxy/UpstreamDialog";
|
||||
import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog";
|
||||
import { UpstreamSection } from "../components/proxy/UpstreamSection";
|
||||
import { tf, useI18n } from "../lib/i18n";
|
||||
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
|
||||
|
||||
interface BindingMutationResult {
|
||||
reconnectRequested?: boolean;
|
||||
@@ -37,6 +38,7 @@ export default function ProxyPage() {
|
||||
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
|
||||
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
|
||||
const [busyDevice, setBusyDevice] = useState("");
|
||||
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
|
||||
|
||||
const proxyRows = useMemo<UpstreamRow[]>(
|
||||
() => proxies.map((proxy) => ({
|
||||
@@ -69,6 +71,16 @@ export default function ProxyPage() {
|
||||
void loadUpstream(true);
|
||||
}, [loadUpstream]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => listPlugins().then((items) => {
|
||||
if (active) setPlugins(items || []);
|
||||
}).catch(() => undefined);
|
||||
void load();
|
||||
window.addEventListener("vocat:plugins-changed", load);
|
||||
return () => { active = false; window.removeEventListener("vocat:plugins-changed", load); };
|
||||
}, []);
|
||||
|
||||
usePolling(() => {
|
||||
if (!upstreamLoading) void loadUpstream(false);
|
||||
}, 10000, false);
|
||||
@@ -232,6 +244,18 @@ export default function ProxyPage() {
|
||||
onDelete={removeUpstream}
|
||||
onOpenBindings={openBindingsDialog}
|
||||
/>
|
||||
{plugins.filter((plugin) => plugin.enabled).flatMap((plugin) =>
|
||||
plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => (
|
||||
<section key={`${plugin.id}:${contribution.id}`} className="ui-card mt-6 overflow-hidden p-0">
|
||||
<iframe
|
||||
title={contribution.label}
|
||||
src={pluginAssetURL(plugin, contribution)}
|
||||
className="h-[640px] w-full border-0 bg-white dark:bg-[#15151a]"
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
/>
|
||||
</section>
|
||||
)),
|
||||
)}
|
||||
<UpstreamDialog
|
||||
open={upstreamDialogOpen}
|
||||
editing={!!editingUpstream}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../components/settings/model";
|
||||
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
|
||||
import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
|
||||
import { PluginsCard } from "../components/settings/PluginsCard";
|
||||
|
||||
const EMPTY_PASSWORD: PasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
|
||||
|
||||
@@ -223,8 +224,13 @@ export default function SettingsPage() {
|
||||
setCheckingUpdate(true);
|
||||
try {
|
||||
// vocat 后端返回 {available, version, message}(参考实现是 has_update 等)
|
||||
const data = await api<{ available?: boolean; version?: string; message?: string }>("/system/update/check");
|
||||
const info: UpdateInfo = { hasUpdate: !!data?.available, latestVersion: data?.version, releaseNote: data?.message };
|
||||
const data = await api<{ available?: boolean; version?: string; message?: string; is_docker?: boolean }>("/system/update/check");
|
||||
const info: UpdateInfo = {
|
||||
hasUpdate: !!data?.available,
|
||||
latestVersion: data?.version,
|
||||
releaseNote: data?.message,
|
||||
isDocker: !!data?.is_docker,
|
||||
};
|
||||
setUpdateInfo(info);
|
||||
if (!info.hasUpdate) message.info(data?.message || t("当前已是最新版本"));
|
||||
} catch (error) {
|
||||
@@ -304,6 +310,8 @@ export default function SettingsPage() {
|
||||
onSave={onSaveSecurity}
|
||||
/>
|
||||
|
||||
{systemInfo.developer ? <PluginsCard /> : null}
|
||||
|
||||
<div className="notify-card ui-card group relative overflow-hidden p-8 lg:col-span-2">
|
||||
<CardDecor />
|
||||
<div className="relative z-10 mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -291,10 +291,11 @@ export default function SmsPage() {
|
||||
const id = ++threadReqId.current;
|
||||
if (!silent) setMessagesLoadingState(true);
|
||||
const query: ThreadQuery = { peer: thread.peer, limit: THREAD_PAGE };
|
||||
if (device !== "all") query.deviceId = device;
|
||||
else {
|
||||
query.imsi = thread.imsi;
|
||||
}
|
||||
if (device !== "all") query.deviceId = device;
|
||||
else {
|
||||
query.modemImei = thread.modemImei;
|
||||
query.imsi = thread.imsi;
|
||||
}
|
||||
try {
|
||||
const list = sortMessages(await getThread(query));
|
||||
if (id !== threadReqId.current) return false;
|
||||
@@ -425,10 +426,11 @@ export default function SmsPage() {
|
||||
try {
|
||||
const oldest = messagesRef.current[0];
|
||||
const query: ThreadQuery = { peer: thread.peer, limit: THREAD_PAGE, beforeTs: oldest.timestamp, beforeId: oldest.id };
|
||||
if (device !== "all") query.deviceId = device;
|
||||
else {
|
||||
query.imsi = thread.imsi;
|
||||
}
|
||||
if (device !== "all") query.deviceId = device;
|
||||
else {
|
||||
query.modemImei = thread.modemImei;
|
||||
query.imsi = thread.imsi;
|
||||
}
|
||||
const older = sortMessages(await getThread(query));
|
||||
setMessagesState([...older, ...messagesRef.current]);
|
||||
setHasMoreState(older.length === THREAD_PAGE);
|
||||
@@ -547,7 +549,7 @@ export default function SmsPage() {
|
||||
const q: DeleteThreadQuery =
|
||||
deviceRef.current !== "all"
|
||||
? { deviceId: deviceRef.current, peer: t.peer }
|
||||
: { deviceId: "all", imsi: t.imsi, peer: t.peer };
|
||||
: { deviceId: "all", modemImei: t.modemImei, imsi: t.imsi, peer: t.peer };
|
||||
await deleteThread(q);
|
||||
message.success(tl("已删除对话"));
|
||||
if (keyRef.current === t.key) clearSelection(true);
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface CardPolicy {
|
||||
export interface SMSContact {
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
modemImei?: string;
|
||||
imsi: string;
|
||||
localPhone?: string;
|
||||
peer: string;
|
||||
@@ -247,6 +248,7 @@ export interface SMSMessage {
|
||||
id: number;
|
||||
messageId?: string;
|
||||
deviceId: string;
|
||||
modemImei?: string;
|
||||
imsi: string;
|
||||
peer: string;
|
||||
direction: "inbound" | "outbound" | "received" | "sent";
|
||||
@@ -377,6 +379,7 @@ export interface SystemInfo {
|
||||
os?: string;
|
||||
architecture?: string;
|
||||
uptime?: string;
|
||||
developer?: boolean;
|
||||
}
|
||||
|
||||
export type Notice = {
|
||||
|
||||
Reference in New Issue
Block a user