mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 11:23:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed8ac9fdf | ||
|
|
1100f20dc5 | ||
|
|
c9656ea3fa | ||
|
|
cb44348a76 | ||
|
|
794dd1177e | ||
|
|
400f08c6c7 | ||
|
|
1380ffb419 | ||
|
|
45b15b245f | ||
|
|
2780dd96de | ||
|
|
2922d6a275 | ||
|
|
288e856fdb | ||
|
|
f17d925c4c | ||
|
|
296f963885 | ||
|
|
1b9546a73d |
+4
-6
@@ -1,6 +1,4 @@
|
||||
# Copy this file to .env and fill in real values before `docker compose up -d`.
|
||||
# .env is gitignored; .env.example is tracked as a template.
|
||||
|
||||
# Admin password for the web UI. REQUIRED — the server refuses to start safely
|
||||
# without it once exposed. Pick a strong password.
|
||||
VOCAT_ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
# VoCat no longer stores administrator credentials in .env.
|
||||
# Initialize a new Docker database with the bootstrap-admin command documented
|
||||
# at the top of docker-compose.yml. Keep this file only for optional, non-secret
|
||||
# Compose substitutions added by an operator.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -15,7 +15,50 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
arch: arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.platform }} smoke image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
push: false
|
||||
tags: vocat-smoke:${{ matrix.arch }}
|
||||
build-args: |
|
||||
VERSION=0.0.0-smoke
|
||||
BUILD_TIME=${{ github.event.repository.updated_at }}
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Verify ${{ matrix.platform }} runtime and smart-card stack
|
||||
run: |
|
||||
docker run --rm --platform '${{ matrix.platform }}' \
|
||||
vocat-smoke:${{ matrix.arch }} version
|
||||
docker run --rm --platform '${{ matrix.platform }}' \
|
||||
--entrypoint /bin/sh vocat-smoke:${{ matrix.arch }} -c \
|
||||
'command -v pcscd && test -d /usr/lib/pcsc/drivers'
|
||||
|
||||
build-and-push:
|
||||
needs: smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -93,6 +93,9 @@ jobs:
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist
|
||||
- name: Set up QEMU for ARM64 runtime smoke test
|
||||
if: matrix.goarch == 'arm64'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Build ${{ matrix.target }}
|
||||
env:
|
||||
GOOS: linux
|
||||
@@ -110,6 +113,14 @@ jobs:
|
||||
-o "$OUTPUT" \
|
||||
./cmd/vocat
|
||||
chmod 0755 "$OUTPUT"
|
||||
if readelf -l "$OUTPUT" | grep -q 'Requesting program interpreter'; then
|
||||
echo "ERROR: $OUTPUT unexpectedly requires a dynamic loader" >&2
|
||||
readelf -l "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "${{ matrix.goarch }}" in
|
||||
amd64|arm64) "$OUTPUT" version ;;
|
||||
esac
|
||||
- name: Upload ${{ matrix.target }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
+9
-4
@@ -36,7 +36,7 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \
|
||||
|
||||
# ---- Stage 3: minimal runtime ----
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
RUN apk add --no-cache ca-certificates ccid pcsc-lite tzdata && \
|
||||
addgroup -S -g 1000 vocat && \
|
||||
adduser -S -D -H -u 1000 -G vocat vocat
|
||||
|
||||
@@ -44,14 +44,19 @@ RUN mkdir -p /opt/vocat/bin /opt/vocat/data && \
|
||||
chown -R vocat:vocat /opt/vocat
|
||||
|
||||
COPY --from=go-builder /out/vocat /opt/vocat/bin/vocat
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/vocat-entrypoint
|
||||
|
||||
# Symlink into /usr/local/bin so `docker exec <ctr> vocat ...` finds it via $PATH.
|
||||
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat
|
||||
RUN ln -s /opt/vocat/bin/vocat /usr/local/bin/vocat && \
|
||||
chmod 0755 /usr/local/bin/vocat-entrypoint
|
||||
|
||||
USER vocat
|
||||
# Hardware access and the bundled pcscd daemon require root inside the
|
||||
# container. The container already needs host networking and privileged device
|
||||
# access for modem, QMI, IPsec, and hot-plug support.
|
||||
USER root
|
||||
VOLUME ["/opt/vocat/data"]
|
||||
EXPOSE 7575
|
||||
ENV VOCAT_ADDR=0.0.0.0:7575 \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
|
||||
ENTRYPOINT ["/opt/vocat/bin/vocat"]
|
||||
ENTRYPOINT ["/usr/local/bin/vocat-entrypoint"]
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<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)
|
||||
**English** | [العربية](docs/README.ar.md) | [简体中文](docs/README.zh-CN.md) | [繁體中文](docs/README.zh-TW.md) | [Français](docs/README.fr.md) | [Русский](docs/README.ru.md) | [Español](docs/README.es.md) | [日本語](docs/README.ja.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.
|
||||
|
||||
@@ -46,7 +46,7 @@ The backend is written in Go, the interface is built with React and TypeScript,
|
||||
| Card policy | ICCID-based WiFi Calling and flight-mode behavior with immediate policy application. |
|
||||
| Proxy routing | Upstream SOCKS routing, device bindings, country rules, TCP reachability checks, and UDP Associate checks for WiFi Calling data paths. |
|
||||
| Notifications | New inbound SMS forwarding through Telegram, Bark, email, Pushplus, and signed webhooks. Each SMS is delivered as an individual notification. |
|
||||
| Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, SMS sending, timed dialing with automatic hang-up, call status, answer, and hang-up commands. Sensitive actions require administrator confirmation. |
|
||||
| Telegram bot | Device status, installed-profile listing and switching, WiFi Calling controls, and SMS sending. Sensitive actions require administrator confirmation. |
|
||||
| Operations | Authentication, CSRF protection, access policies, audit events, live logs, log retention, health checks, responsive layout, dark mode, and English/Chinese application UI. |
|
||||
| Distribution | Static Linux binaries, systemd installation script, self-update with SHA-256 verification, Docker image, GHCR publishing, and GitHub Actions release builds. |
|
||||
|
||||
@@ -65,10 +65,24 @@ Available features depend on the module firmware, USB composition, SIM/eSIM capa
|
||||
|
||||
### One-click Linux installation
|
||||
|
||||
As root (including OpenWrt/Kwrt, where `sudo` is normally absent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
From a normal user on a distribution with sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Check the host's VoWiFi/XFRM prerequisites without installing VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
@@ -76,6 +90,12 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS requires Linux XFRM/IPsec. On OpenWrt/Kwrt the installer attempts
|
||||
to install matching `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC and SHA1 packages from the firmware's own feed.
|
||||
If matching kernel modules are unavailable, use a firmware that includes them;
|
||||
never force-install kmods built for a different kernel.
|
||||
|
||||
The installer:
|
||||
|
||||
- detects `amd64`, `386`, `arm64`, `aarch64`, or `armv7`;
|
||||
@@ -110,9 +130,11 @@ 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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
@@ -129,13 +151,20 @@ continue seeing USB hot-plug events, run Vocat in hardware-access mode:
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
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 \
|
||||
@@ -146,18 +175,28 @@ Open `http://<server-address>:7575` after the container starts. Host networking
|
||||
is required so QMI network interfaces remain visible to Vocat, while privileged
|
||||
device access is required for serial ports, QMI control nodes, TUN interfaces,
|
||||
network configuration, and devices added after the container starts. The
|
||||
`/dev` bind mount makes new `ttyUSB*`, `ttyACM*`, and `cdc-wdm*` nodes visible
|
||||
without recreating the container.
|
||||
`/dev` bind mount makes new `ttyUSB*`, `ttyACM*`, `cdc-wdm*`, and MHI
|
||||
`wwan*` nodes visible without recreating the container.
|
||||
|
||||
This mode intentionally gives Vocat broad access to the host's devices and
|
||||
network stack. Use it only on a trusted Linux host. The automatic discovery
|
||||
currently identifies supported Quectel USB modems (USB vendor ID `2c7c`), not
|
||||
arbitrary modem brands. Mapping only individual nodes with `--device`, such as
|
||||
`/dev/ttyUSB2` and `/dev/cdc-wdm0`, limits the container to those fixed nodes
|
||||
and does not provide complete multi-device or hot-plug discovery.
|
||||
identifies supported Quectel USB modems (USB vendor ID `2c7c`) and PCIe/MHI
|
||||
modems exposed through the Linux WWAN subsystem; it does not identify arbitrary
|
||||
modem layouts. Mapping only individual nodes with `--device`, such as
|
||||
`/dev/ttyUSB2`, `/dev/cdc-wdm0`, or `/dev/wwan0qmi0`, limits the container to
|
||||
those fixed nodes and does not provide complete multi-device or hot-plug discovery.
|
||||
|
||||
The GHCR image is published for `linux/amd64` and `linux/arm64`.
|
||||
|
||||
### USB SIM readers
|
||||
|
||||
USB SIM readers use the Linux PC/SC service. The one-click installer installs
|
||||
and starts `pcscd` plus the CCID driver automatically on supported package
|
||||
managers. On Debian/Ubuntu, the equivalent manual setup is
|
||||
`apt install pcscd libccid`. If USB sees a CCID reader but PC/SC is unavailable,
|
||||
VoCat keeps the reader visible in the add-device dialog and reports the missing
|
||||
service or driver instead of silently hiding it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applies `VOCAT_*` environment variables. Environment variables take precedence.
|
||||
@@ -166,8 +205,6 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | HTTP listen address. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | SQLite database path. |
|
||||
| `VOCAT_ADMIN_USERNAME` | `admin` | Initial administrator username. |
|
||||
| `VOCAT_ADMIN_PASSWORD` | `admin` | Initial administrator password. Change it before exposing the service. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Authentication session lifetime. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marks session cookies as secure when HTTPS is used. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. |
|
||||
@@ -175,6 +212,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie
|
||||
| `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. |
|
||||
|
||||
Administrator credentials are stored only in SQLite. Initialize an empty
|
||||
database once with `vocat bootstrap-admin`; environment variables and JSON
|
||||
configuration cannot set or overwrite the administrator username or password.
|
||||
|
||||
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.
|
||||
|
||||
## Telegram bot
|
||||
@@ -187,13 +228,9 @@ When Telegram notifications are enabled and both Chat ID and Admin ID are config
|
||||
/switch <device> <iccid>
|
||||
/wfc <device> <status|on|off|reconnect>
|
||||
/sms <device> <number> <message>
|
||||
/call <device> <number> <seconds>
|
||||
/calls <device>
|
||||
/answer <device>
|
||||
/hangup <device>
|
||||
```
|
||||
|
||||
Profile switching, SMS submission, and dialing use one-time confirmation buttons. Timed dialing performs the modem call action and automatically hangs up after 1–600 seconds; it does not capture or process call audio. The bot does not expose eSIM download, delete, or rename commands.
|
||||
Profile switching and SMS submission use one-time confirmation buttons. The bot does not expose eSIM download, delete, or rename commands.
|
||||
|
||||
## Updating
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func runBootstrapAdmin(args []string) error {
|
||||
flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
databasePath := flags.String("database", "/opt/vocat/data/vocat.db", "database path")
|
||||
username := flags.String("username", "admin", "administrator username")
|
||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
||||
}
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 12 and 1024 characters")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
return errors.New("bootstrap username must contain between 1 and 64 characters without control whitespace")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
database, err := store.Open(ctx, strings.TrimSpace(*databasePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
service, err := auth.New(database, auth.Options{SessionTTL: 24 * time.Hour})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := service.EnsureAdminIfMissing(ctx, adminUsername, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
fmt.Println("created")
|
||||
} else {
|
||||
fmt.Println("exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestBootstrapAdminOnlyInitializesAnEmptyDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
withBootstrapStdin(t, "first-secure-password\n", func() {
|
||||
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
withBootstrapStdin(t, "second-secure-password\n", func() {
|
||||
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
database, err := store.Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
service, err := auth.New(database, auth.Options{SessionTTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Login(context.Background(), "admin", "first-secure-password"); err != nil {
|
||||
t.Fatalf("initial password was overwritten: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withBootstrapStdin(t *testing.T, input string, action func()) {
|
||||
t.Helper()
|
||||
original := os.Stdin
|
||||
file, err := os.CreateTemp(t.TempDir(), "stdin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdin = file
|
||||
t.Cleanup(func() { os.Stdin = original; _ = file.Close() })
|
||||
action()
|
||||
os.Stdin = original
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
// The modem, PC/SC reader, XFRM policies and listener are host resources,
|
||||
// not database resources. Lock per OS user so a diagnostic instance using a
|
||||
// different VOCAT_DATABASE_PATH cannot silently steal the same AT port from
|
||||
// the managed service. Prefer /run because systemd's PrivateTmp would
|
||||
// otherwise hide the managed service's lock from a manually started process.
|
||||
// The UID-specific directory still permits intentionally isolated users to
|
||||
// operate independently; development hosts without writable /run fall back
|
||||
// to TempDir.
|
||||
uid := os.Geteuid()
|
||||
directory := filepath.Join("/run", fmt.Sprintf("vocat-%d", uid))
|
||||
if uid == 0 {
|
||||
directory = "/run/vocat"
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
directory = os.TempDir()
|
||||
}
|
||||
path := filepath.Join(directory, "vocat-server.lock")
|
||||
fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open server instance lock: %w", err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), path)
|
||||
if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
|
||||
return nil, errors.New("another vocat server already controls this host's modem resources")
|
||||
}
|
||||
return nil, fmt.Errorf("lock server instance: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerInstanceLockRejectsSecondProcess(t *testing.T) {
|
||||
firstDatabase := filepath.Join(t.TempDir(), "vocat.db")
|
||||
first, err := lockServerInstance(firstDatabase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer first.Close()
|
||||
secondDatabase := filepath.Join(t.TempDir(), "other.db")
|
||||
second, err := lockServerInstance(secondDatabase)
|
||||
if second != nil {
|
||||
second.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "already controls this host") {
|
||||
t.Fatalf("second lock error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func lockServerInstance(databasePath string) (*os.File, error) {
|
||||
return os.OpenFile(filepath.Join(filepath.Dir(databasePath), ".vocat.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
}
|
||||
+84
-19
@@ -89,6 +89,13 @@ func main() {
|
||||
logger.Error("develop failed", "error", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
case "bootstrap-admin":
|
||||
// Installer-only command. The password is read from stdin so it never
|
||||
// appears in argv, an environment file, or process listings.
|
||||
if err := runBootstrapAdmin(rest); err != nil {
|
||||
logger.Error("bootstrap admin failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
printUsage(os.Stdout)
|
||||
default:
|
||||
@@ -112,12 +119,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
if cfg.UsesDefaultCredentials() {
|
||||
logger.Warn(
|
||||
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
|
||||
)
|
||||
instanceLock, err := lockServerInstance(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer instanceLock.Close()
|
||||
startupContext, cancelStartup := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancelStartup()
|
||||
|
||||
@@ -177,12 +183,11 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authService.EnsureAdmin(
|
||||
startupContext,
|
||||
cfg.AdminUsername,
|
||||
cfg.AdminPassword,
|
||||
); err != nil {
|
||||
return err
|
||||
if _, adminErr := database.CurrentAdmin(startupContext); adminErr != nil {
|
||||
if errors.Is(adminErr, store.ErrNotFound) {
|
||||
return errors.New("administrator is not initialized; run vocat bootstrap-admin before starting the service")
|
||||
}
|
||||
return fmt.Errorf("read administrator: %w", adminErr)
|
||||
}
|
||||
|
||||
cardReaders := pcsc.New()
|
||||
@@ -617,12 +622,18 @@ func configureVoWiFiRuntime(
|
||||
}
|
||||
if deviceConfig.VoWiFiEnabled {
|
||||
if entry, mapErr := mapper.Get(deviceConfig.ID); mapErr == nil {
|
||||
flightContext, cancelFlight := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, flightErr := deviceManager.SetFlight(flightContext, entry.ID, true)
|
||||
cancelFlight()
|
||||
flightErr := protectVoWiFiStartupRadio(ctx, deviceManager, entry.ID)
|
||||
if flightErr != nil {
|
||||
_ = manager.Close(context.Background())
|
||||
return nil, fmt.Errorf("protect device %q before VoWiFi startup: %w", deviceConfig.ID, flightErr)
|
||||
// A modem can be temporarily unavailable while OpenWrt/procd is
|
||||
// restarting the service (notably after loading XFRM modules). Do
|
||||
// not take the Web/API service down with it: the orchestrator below
|
||||
// remains fail-closed and its runtime manager retries until CFUN=4
|
||||
// can be established.
|
||||
logger.Warn(
|
||||
"VoWiFi startup radio protection deferred to automatic retry",
|
||||
"device_id", deviceConfig.ID,
|
||||
"error", flightErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil {
|
||||
@@ -634,6 +645,56 @@ func configureVoWiFiRuntime(
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
const (
|
||||
vowifiStartupRadioAttempts = 3
|
||||
vowifiStartupRadioDelay = time.Second
|
||||
)
|
||||
|
||||
type flightModeSetter interface {
|
||||
SetFlight(context.Context, string, bool) (device.FlightResult, error)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadio(ctx context.Context, manager flightModeSetter, physicalID string) error {
|
||||
return protectVoWiFiStartupRadioWithRetry(
|
||||
ctx,
|
||||
manager,
|
||||
physicalID,
|
||||
vowifiStartupRadioAttempts,
|
||||
vowifiStartupRadioDelay,
|
||||
)
|
||||
}
|
||||
|
||||
func protectVoWiFiStartupRadioWithRetry(
|
||||
ctx context.Context,
|
||||
manager flightModeSetter,
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
_, lastErr = manager.SetFlight(flightContext, physicalID, true)
|
||||
cancel()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt+1 == attempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return errors.Join(lastErr, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type vowifiDeviceAdapter interface {
|
||||
vowifi.SIMIdentityReader
|
||||
vowifi.AKAProvider
|
||||
@@ -654,9 +715,13 @@ func newVoWiFiOrchestrator(
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
// The userspace SWu data plane currently carries the protected P-CSCF
|
||||
// signalling path over TCP.
|
||||
// The userspace SWu data plane carries protected P-CSCF signalling over
|
||||
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
|
||||
Transport: "tcp",
|
||||
TransportByPLMN: map[string]string{
|
||||
"23410": "udp",
|
||||
"234010": "udp",
|
||||
},
|
||||
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
|
||||
// this service-centre number for manual SMS setup.
|
||||
SMSCenter: "+447785016005",
|
||||
@@ -877,7 +942,7 @@ func pollDeviceSnapshots(
|
||||
var refreshGroup sync.WaitGroup
|
||||
refreshSlots := make(chan struct{}, 4)
|
||||
for _, entry := range entries {
|
||||
if !entry.Discovered {
|
||||
if !entry.Discovered || entry.Candidate.DiscoveryIssue != "" {
|
||||
continue
|
||||
}
|
||||
entry := entry
|
||||
|
||||
+32
-29
@@ -22,9 +22,8 @@ import (
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// envFilePath carries non-secret service settings such as the Web listen port.
|
||||
// Administrator credentials live exclusively in the database.
|
||||
const envFilePath = "/etc/vocat/env"
|
||||
|
||||
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
||||
@@ -51,8 +50,8 @@ const uiPreferencesSettingKey = "ui.preferences"
|
||||
// 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.
|
||||
// and pins VOCAT_DATABASE_PATH to the install default, without overriding any
|
||||
// value the operator already exported. Legacy credential entries are ignored.
|
||||
func loadMenuEnv() {
|
||||
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
||||
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
||||
@@ -68,6 +67,9 @@ func loadMenuEnv() {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
if key == "VOCAT_ADMIN_USERNAME" || key == "VOCAT_ADMIN_PASSWORD" || key == "VOCAT_ADMIN_PASSWORD_B64" {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
if _, ok := os.LookupEnv(key); !ok {
|
||||
_ = os.Setenv(key, val)
|
||||
@@ -126,7 +128,7 @@ func runMenu(logger *slog.Logger) error {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "2":
|
||||
if err := menuChangePassword(reader, menu, logger); err != nil {
|
||||
if err := menuChangePassword(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
@@ -191,7 +193,7 @@ func loadMenuLanguage() (string, error) {
|
||||
return "en", nil
|
||||
}
|
||||
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
@@ -209,6 +211,10 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
||||
}
|
||||
admin, err := database.CurrentAdmin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
|
||||
fmt.Print(m.currentPassword())
|
||||
currentPw, err := readPasswordMasked()
|
||||
@@ -229,19 +235,12 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
|
||||
if newPw != confirmPw {
|
||||
return errPasswordsDiffer
|
||||
}
|
||||
if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
|
||||
if err := authService.ChangePassword(ctx, admin.Username, currentPw, newPw); err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
return errCurrentWrong
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
||||
}
|
||||
// Persist the new plaintext to the env file so the next EnsureAdmin (on
|
||||
// restart) agrees with the hash we just wrote to the DB. Without this the
|
||||
// restart reverts the password to whatever the env file still holds.
|
||||
if err := rewriteEnvPassword(newPw); err != nil {
|
||||
logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
|
||||
return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
|
||||
}
|
||||
fmt.Println(m.passwordChanged())
|
||||
return nil
|
||||
}
|
||||
@@ -258,20 +257,15 @@ func readPasswordMasked() (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
|
||||
// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
|
||||
// the temp file lives in the same directory so os.Rename stays on one
|
||||
// filesystem.
|
||||
func rewriteEnvPassword(newPassword string) error {
|
||||
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
||||
}
|
||||
|
||||
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
||||
// write is atomic and rejects line breaks so one setting cannot inject another.
|
||||
func rewriteEnvValue(path, name, value string) error {
|
||||
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return errors.New("invalid environment setting")
|
||||
}
|
||||
if strings.HasPrefix(name, "VOCAT_ADMIN_") {
|
||||
return errors.New("administrator credentials cannot be stored in the environment file")
|
||||
}
|
||||
key := name + "="
|
||||
var lines []string
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
@@ -282,12 +276,17 @@ func rewriteEnvValue(path, name, value string) error {
|
||||
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "VOCAT_ADMIN_USERNAME=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD_B64=") {
|
||||
lines[i] = ""
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, key) {
|
||||
lines[i] = key + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
lines = compactNonEmptyLines(lines)
|
||||
if !replaced {
|
||||
lines = append(lines, key+value)
|
||||
}
|
||||
@@ -298,6 +297,16 @@ func rewriteEnvValue(path, name, value string) error {
|
||||
return writeEnvFileAtomic(path, []byte(content))
|
||||
}
|
||||
|
||||
func compactNonEmptyLines(lines []string) []string {
|
||||
result := lines[:0]
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeEnvFileAtomic(path string, content []byte) error {
|
||||
dirIndex := strings.LastIndexAny(path, "/\\")
|
||||
if dirIndex < 0 {
|
||||
@@ -562,7 +571,6 @@ var (
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errMenuPortWrite = errors.New("menu: write Web port")
|
||||
errInvalidWebPort = errors.New("menu: invalid Web port")
|
||||
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
||||
@@ -702,11 +710,6 @@ func (m *menu) errorPrefix(err error) string {
|
||||
return "Auth service error."
|
||||
}
|
||||
return "认证服务错误。"
|
||||
case errors.Is(err, errMenuEnvWrite):
|
||||
if m.lang == "en" {
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
||||
case errors.Is(err, errInvalidWebPort):
|
||||
if m.lang == "en" {
|
||||
return "Invalid port. Enter a number from 1 to 65535."
|
||||
|
||||
@@ -53,12 +53,15 @@ func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(content)
|
||||
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
if strings.Contains(got, "VOCAT_ADMIN_PASSWORD") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
t.Fatalf("rewritten env = %q", got)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
|
||||
t.Fatal("environment line injection was accepted")
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADMIN_PASSWORD", "changed-password"); err == nil {
|
||||
t.Fatal("administrator credential was accepted for the environment file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/device"
|
||||
)
|
||||
|
||||
type startupFlightSetter struct {
|
||||
errors []error
|
||||
calls int
|
||||
id string
|
||||
}
|
||||
|
||||
func (setter *startupFlightSetter) SetFlight(
|
||||
_ context.Context,
|
||||
id string,
|
||||
enabled bool,
|
||||
) (device.FlightResult, error) {
|
||||
setter.calls++
|
||||
setter.id = id
|
||||
if !enabled {
|
||||
return device.FlightResult{}, errors.New("expected flight mode to be enabled")
|
||||
}
|
||||
if setter.calls <= len(setter.errors) {
|
||||
return device.FlightResult{}, setter.errors[setter.calls-1]
|
||||
}
|
||||
return device.FlightResult{CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioRetriesTransientFailure(t *testing.T) {
|
||||
transient := errors.New("modem is reopening")
|
||||
setter := &startupFlightSetter{errors: []error{transient, transient}}
|
||||
if err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 3, 0,
|
||||
); err != nil {
|
||||
t.Fatalf("protect startup radio: %v", err)
|
||||
}
|
||||
if setter.calls != 3 || setter.id != "quectel-1" {
|
||||
t.Fatalf("SetFlight calls = %d, id = %q", setter.calls, setter.id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectVoWiFiStartupRadioReturnsLastFailure(t *testing.T) {
|
||||
first := errors.New("first")
|
||||
last := errors.New("last")
|
||||
setter := &startupFlightSetter{errors: []error{first, last}}
|
||||
err := protectVoWiFiStartupRadioWithRetry(
|
||||
context.Background(), setter, "quectel-1", 2, 0,
|
||||
)
|
||||
if !errors.Is(err, last) || setter.calls != 2 {
|
||||
t.Fatalf("protect startup radio = %v after %d calls", err, setter.calls)
|
||||
}
|
||||
}
|
||||
+19
-16
@@ -1,9 +1,12 @@
|
||||
# VoCat Docker Compose deployment.
|
||||
#
|
||||
# First-time setup:
|
||||
# cp .env.example .env # then edit VOCAT_ADMIN_PASSWORD
|
||||
# docker compose pull # fetch the prebuilt GHCR image
|
||||
# docker compose up -d # start
|
||||
# First-time setup (password is read from stdin and stored only in SQLite):
|
||||
# docker compose pull
|
||||
# read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
# printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker compose run --rm -T \
|
||||
# --entrypoint /opt/vocat/bin/vocat vocat bootstrap-admin
|
||||
# unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
# docker compose up -d
|
||||
#
|
||||
# Build locally from this repo instead of using the GHCR image:
|
||||
# docker compose up -d --build
|
||||
@@ -35,26 +38,26 @@ services:
|
||||
# reachable on the host IP without explicit mapping.
|
||||
network_mode: host
|
||||
|
||||
# VoWiFi / eSIM / IMS paths need raw sockets (IPsec, netlink). The systemd
|
||||
# unit grants CAP_NET_ADMIN + CAP_NET_RAW; mirror that here.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
# Modem/QMI/USB-reader hot-plug uses dynamic character devices. Privileged
|
||||
# mode mirrors the documented hardware-access docker run command and also
|
||||
# supplies the raw-socket/netlink permissions needed by VoWiFi/IPsec.
|
||||
privileged: true
|
||||
user: "0:0"
|
||||
|
||||
environment:
|
||||
# Marks the process as containerized: the web UI then advertises
|
||||
# "pull new image" instead of attempting an in-place binary update.
|
||||
VOCAT_CONTAINER: docker
|
||||
# VOCAT_ADDR / VOCAT_DATABASE_PATH are set in the Dockerfile; override
|
||||
# only if you want non-default values. Sensitive values come from .env.
|
||||
VOCAT_ADMIN_PASSWORD: ${VOCAT_ADMIN_PASSWORD:?set VOCAT_ADMIN_PASSWORD in .env}
|
||||
# VOCAT_ADDR / VOCAT_DATABASE_PATH are set in the Dockerfile. Admin
|
||||
# credentials are stored only in SQLite and are not process environment.
|
||||
|
||||
volumes:
|
||||
# SQLite database + persistent state. Named volume (not a bind mount)
|
||||
# because the container runs as uid 1000 (vocat) while a bind-mounted
|
||||
# host dir would be root-owned and unwritable. Docker gives the named
|
||||
# volume the image's uid 1000 ownership automatically.
|
||||
# SQLite database + persistent state.
|
||||
- vocat-data:/opt/vocat/data
|
||||
# Required for modem, MHI/WWAN and PC/SC USB-reader discovery, including
|
||||
# devices added after the container starts.
|
||||
- /dev:/dev
|
||||
- /sys:/sys:ro
|
||||
|
||||
volumes:
|
||||
vocat-data:
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
<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_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">
|
||||
<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) | **العربية** | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat هي لوحة تحكم ويب مفتوحة المصدر ومجموعة أدوات هندسية لمودمات Quectel الخلوية من فئة EC20/EC25. تجمع في خدمة واحدة مكتفية ذاتيًا بين اكتشاف المودم، وحالة الراديو المباشرة، وطرفيات AT وUSSD، والرسائل القصيرة SMS، وWiFi Calling، وإدارة eSIM، واختيار الشبكة، والتوجيه عبر البروكسي، والإشعارات، وسجلات التدقيق، وأتمتة الإصدارات.
|
||||
|
||||
الواجهة الخلفية مكتوبة بلغة Go، والواجهة مبنية باستخدام React وTypeScript، وتُضمَّن واجهة الإنتاج الأمامية داخل الملف الثنائي لـ Go. يحتوي ملف تنفيذي واحد على تطبيق الويب ويستخدم 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، وقائمة الملفات الشخصية المثبتة، وعمليات التمكين/التعطيل/التبديل، وعمليات التنزيل وإعادة التسمية والحذف عندما تدعمها البطاقة. |
|
||||
| سياسة البطاقة | سلوك WiFi Calling ووضع الطيران بناءً على ICCID مع تطبيق فوري للسياسة. |
|
||||
| التوجيه عبر البروكسي | توجيه SOCKS صاعد، ربط الأجهزة، قواعد الدول، فحوصات الوصول عبر TCP، وفحوصات UDP Associate لمسارات بيانات WiFi Calling. |
|
||||
| الإشعارات | إعادة توجيه الرسائل القصيرة الواردة الجديدة عبر Telegram وBark والبريد الإلكتروني وPushplus وwebhooks الموقّعة. يتم تسليم كل رسالة كإشعار منفصل. |
|
||||
| بوت Telegram | حالة الجهاز، قائمة الملفات الشخصية المثبتة وتبديلها، ضوابط WiFi Calling، وإرسال الرسائل القصيرة. تتطلب الإجراءات الحساسة تأكيد المسؤول. |
|
||||
| العمليات | المصادقة، الحماية من CSRF، سياسات الوصول، أحداث التدقيق، السجلات المباشرة، الاحتفاظ بالسجلات، فحوصات الصحة، تخطيط متجاوب، الوضع الداكن، وواجهة مستخدم بالإنجليزية/الصينية. |
|
||||
| التوزيع | ملفات Linux الثنائية الثابتة، سكربت تثبيت systemd، تحديث ذاتي مع التحقق من SHA-256، صورة Docker، النشر إلى GHCR، وبنى إصدارات GitHub Actions. |
|
||||
|
||||
## الأجهزة المدعومة
|
||||
|
||||
يستهدف Vocat وحدات Quectel المبنية على Qualcomm والتي توفر واجهات AT وQMI والمنفذ التسلسلي وشبكة USB المتوافقة، بما في ذلك:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- عائلة Quectel EG25
|
||||
- وحدات EG600 المتوافقة وذات الصلة
|
||||
|
||||
تعتمد الميزات المتاحة على برنامج الوحدة الثابت (firmware)، وتكوين USB، وقدرات SIM/eSIM، وتعريفات المضيف، والشبكة اللاسلكية، وإعدادات المشغّل.
|
||||
|
||||
## التثبيت
|
||||
|
||||
### تثبيت Linux بنقرة واحدة
|
||||
|
||||
بصفتك root (بما في ذلك OpenWrt/Kwrt، حيث يكون `sudo` غير موجود عادةً):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
من مستخدم عادي على توزيعة تحتوي على sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
تحقق من متطلبات VoWiFi/XFRM على المضيف دون تثبيت VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
تثبيت إصدار محدد:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
يتطلب VoWiFi IMS وجود Linux XFRM/IPsec. على OpenWrt/Kwrt يحاول المثبّت
|
||||
تثبيت الحزم المطابقة `ip-full` و`kmod-ipsec` و`kmod-ipsec4/6`
|
||||
و`kmod-crypto-authenc` وAES-CBC وSHA1 من مستودع البرنامج الثابت نفسه.
|
||||
إذا لم تكن وحدات النواة المطابقة متاحة، فاستخدم برنامجًا ثابتًا يتضمنها؛
|
||||
ولا تفرض أبدًا تثبيت kmods مبنية لنواة مختلفة.
|
||||
|
||||
المثبّت:
|
||||
|
||||
- يكتشف `amd64` أو `386` أو `arm64` أو `aarch64` أو `armv7`؛
|
||||
- ينزّل الملف الثنائي المطابق من GitHub Release؛
|
||||
- يتحقق منه مقابل `SHA256SUMS`؛
|
||||
- يثبّت Vocat في `/opt/vocat`؛
|
||||
- ينشئ خدمة systemd محصّنة بوصول الأجهزة والشبكة الذي يتطلبه Vocat؛
|
||||
- يخزّن إعدادات وقت التشغيل في `/etc/vocat/env`؛
|
||||
- يولّد كلمة مرور مسؤول أولية عشوائية عند التثبيت الأول.
|
||||
|
||||
بعد التثبيت، افتح:
|
||||
|
||||
```text
|
||||
http://<عنوان-الخادم>:7575
|
||||
```
|
||||
|
||||
### التثبيت اليدوي للملف الثنائي
|
||||
|
||||
نزّل الملف الثنائي المطابق و`SHA256SUMS` من GitHub Releases:
|
||||
|
||||
| المنصة | ملف الإصدار |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32-بت | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| 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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
يشغّل هذا الأمر اليدوي Vocat في المقدمة. استخدم `vocat serve` حتى
|
||||
يبدأ العملية الخادم مباشرةً؛ إن تشغيل `vocat` دون وسائط بصفتك root
|
||||
على TTY يفتح بدلاً من ذلك قائمة الإدارة التفاعلية. استخدم المثبّت بنقرة
|
||||
واحدة عند الحاجة إلى خدمة systemd مُدارة وإعادة تشغيل تلقائية.
|
||||
|
||||
### Docker
|
||||
|
||||
لمضيف Linux الذي يجب أن يكتشف كل مودم Quectel مدعوم متصل ويواصل
|
||||
رؤية أحداث التوصيل الساخن لـ USB، شغّل Vocat في وضع الوصول إلى الأجهزة:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-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 `2c7c`)، وليس على ماركات مودم عشوائية.
|
||||
إن تركيب العقد الفردية فقط باستخدام `--device`، مثل `/dev/ttyUSB2` و`/dev/cdc-wdm0`،
|
||||
يحصر الحاوية في تلك العقد الثابتة ولا يوفر اكتشافًا كاملاً متعدد الأجهزة أو بالتوصيل الساخن.
|
||||
|
||||
تُنشر صورة GHCR لـ `linux/amd64` و`linux/arm64`.
|
||||
|
||||
## الإعدادات
|
||||
|
||||
يقرأ Vocat ملف إعدادات JSON اختياريًا من `VOCAT_CONFIG`، ثم يطبق متغيرات البيئة `VOCAT_*`. متغيرات البيئة لها الأولوية.
|
||||
|
||||
| متغير البيئة | الافتراضي | الوصف |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | عنوان الاستماع HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | مسار قاعدة بيانات SQLite. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | مدة صلاحية جلسة المصادقة. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | يضع علامة آمنة على ملفات تعريف ارتباط الجلسة عند استخدام HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | مهلة الإيقاف السلس. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | الحد الأقصى لحجم جسم طلب API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | مستودع GitHub الموثوق الذي يستخدمه المحدّث الذاتي، بصيغة `owner/name`. |
|
||||
| `GITHUB_TOKEN` | فارغ | رمز GitHub اختياري للمستودعات الخاصة أو حدود API أعلى. |
|
||||
|
||||
لا تخزّن رموز Telegram، أو كلمات مرور SMTP، أو أسرار webhook، أو بيانات اعتماد SIM، أو بيانات خاصة أخرى في المستودع. قم بإعدادها عبر إعدادات التطبيق أو ملفات البيئة المحمية.
|
||||
|
||||
## بوت Telegram
|
||||
|
||||
عند تفعيل إشعارات Telegram وإعداد كلٍّ من Chat ID وAdmin ID، يدعم البوت:
|
||||
|
||||
```text
|
||||
/status [الجهاز]
|
||||
/esim <الجهاز>
|
||||
/switch <الجهاز> <iccid>
|
||||
/wfc <الجهاز> <status|on|off|reconnect>
|
||||
/sms <الجهاز> <الرقم> <الرسالة>
|
||||
```
|
||||
|
||||
تستخدم عمليتا تبديل الملفات الشخصية وإرسال الرسائل القصيرة أزرار تأكيد لمرة واحدة. لا يعرض البوت أوامر تنزيل أو حذف أو إعادة تسمية eSIM.
|
||||
|
||||
## التحديث
|
||||
|
||||
تحقق من وجود GitHub Release أحدث:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
ثبّت أحدث إصدار:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
ينزّل المحدّث الملف الثنائي المطابق لبنية Linux الحالية، ويتحقق منه باستخدام `SHA256SUMS` المنشور، ويستبدل الملف التنفيذي بشكل ذري، ويعيد تشغيل خدمة systemd `vocat` عند توفرها.
|
||||
|
||||
لتثبيتات 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` و`aarch64` و`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 والإشعارات وخادم الويب المضمّن
|
||||
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؛ وقد يرفضها الجهاز أو الملف الشخصي أو الشبكة أو المشغّل مع ذلك.
|
||||
|
||||
## المساهمة
|
||||
|
||||
نرحّب بالمسائل (issues) وطلبات السحب (pull requests). حافظ على التغييرات مركّزة، وأضِف الاختبارات حيثما أمكن، وتجنّب إيداع بيانات الاعتماد أو بيانات المشتركين، ووثّق بوضوح السلوك الخاص بالأجهزة.
|
||||
|
||||
قبل إرسال تغيير:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## شكر وتقدير
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — مجتمع مكرّس للخوادم
|
||||
- [Linux.do](https://linux.do) — مجتمع تقني ملهم
|
||||
- [iniwex5](https://github.com/iniwex5) — إرشادات الأسلوب والوظائف
|
||||
|
||||
## ادعُني إلى فنجان قهوة
|
||||
|
||||
| الشبكة | العنوان |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## الرخصة
|
||||
|
||||
انظر [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,343 @@
|
||||
<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_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">
|
||||
<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) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | **Español** | [日本語](README.ja.md)
|
||||
|
||||
Vocat es un panel de control web de código abierto y un conjunto de herramientas de ingeniería para módems celulares Quectel de clase EC20/EC25. Combina, en un único servicio autocontenido, el descubrimiento de módems, el estado de radio en vivo, terminales AT y USSD, SMS, WiFi Calling, gestión de eSIM, selección de red, enrutamiento por proxy, notificaciones, registros de auditoría y automatización de versiones.
|
||||
|
||||
El backend está escrito en Go, la interfaz está construida con React y TypeScript, y el frontend de producción está incrustado en el binario de Go. Un único ejecutable contiene la aplicación web y utiliza SQLite para el estado persistente.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
| Área | Lo que proporciona Vocat |
|
||||
| --- | --- |
|
||||
| Gestión de dispositivos | Descubrimiento serie/USB automático, soporte para múltiples módems, nombres de dispositivo amigables, actualizaciones en vivo de la vista general, reinicio del módulo, modo avión y controles del modo de red USB. |
|
||||
| Radio y red | Estado de registro, operador, métricas de señal, RSRP/RSRQ/SINR, modo de red, banda, canal, búsqueda de operadores y selección de red automática o manual. |
|
||||
| AT y USSD | Terminal AT interactivo, historial de comandos, respuestas sin procesar del módem, flujos de inicio/continuación/cancelación de USSD y reporte claro de errores del módem. |
|
||||
| SMS | Envío directo de SMS celulares e IMS, sincronización entrante, manejo multiparte, informes de entrega, historial de conversaciones, estado de no leído, marcas de tiempo y estado de entrega por mensaje. |
|
||||
| WiFi Calling | Establecimiento de túnel IKEv2/ePDG, autenticación EAP-AKA, registro IMS, SMS IMS, controles de reconexión, diagnósticos de estado y enrutamiento por dispositivo. |
|
||||
| eSIM y eUICC | Descubrimiento de eUICC, EID e información de producción, metadatos de certificados, inventario multi-eUICC, lista de perfiles instalados, operaciones de habilitar/deshabilitar/cambiar, y operaciones de descarga, renombrado y eliminación cuando la tarjeta lo admite. |
|
||||
| Política de tarjeta | Comportamiento de WiFi Calling y modo avión basado en ICCID con aplicación inmediata de la política. |
|
||||
| Enrutamiento por proxy | Enrutamiento SOCKS ascendente, vinculaciones de dispositivos, reglas por país, comprobaciones de accesibilidad TCP y comprobaciones UDP Associate para las rutas de datos de WiFi Calling. |
|
||||
| Notificaciones | Reenvío de nuevos SMS entrantes a través de Telegram, Bark, correo electrónico, Pushplus y webhooks firmados. Cada SMS se entrega como una notificación individual. |
|
||||
| Bot de Telegram | Estado del dispositivo, lista y cambio de perfiles instalados, controles de WiFi Calling y envío de SMS. Las acciones sensibles requieren confirmación del administrador. |
|
||||
| Operaciones | Autenticación, protección CSRF, políticas de acceso, eventos de auditoría, registros en vivo, retención de registros, comprobaciones de salud, diseño adaptable, modo oscuro e interfaz de usuario en inglés/chino. |
|
||||
| Distribución | Binarios estáticos de Linux, script de instalación systemd, autoactualización con verificación SHA-256, imagen Docker, publicación en GHCR y compilaciones de versión de GitHub Actions. |
|
||||
|
||||
## Hardware compatible
|
||||
|
||||
Vocat está dirigido a módulos Quectel basados en Qualcomm que exponen interfaces AT, QMI, serie y de red USB compatibles, incluyendo:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Familia Quectel EG25
|
||||
- Módulos EG600 compatibles y relacionados
|
||||
|
||||
Las funciones disponibles dependen del firmware del módulo, la composición USB, las capacidades SIM/eSIM, los controladores del host, la red de radio y la configuración del operador.
|
||||
|
||||
## Instalación
|
||||
|
||||
### Instalación en Linux con un clic
|
||||
|
||||
Como root (incluyendo OpenWrt/Kwrt, donde `sudo` normalmente está ausente):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Desde un usuario normal en una distribución con sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Comprobar los prerrequisitos de VoWiFi/XFRM del host sin instalar VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Instalar una versión específica:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS requiere Linux XFRM/IPsec. En OpenWrt/Kwrt el instalador intenta
|
||||
instalar los paquetes coincidentes `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC y SHA1 desde el propio feed del firmware.
|
||||
Si no hay módulos de kernel coincidentes disponibles, use un firmware que los incluya;
|
||||
nunca fuerce la instalación de kmods compilados para un kernel diferente.
|
||||
|
||||
El instalador:
|
||||
|
||||
- detecta `amd64`, `386`, `arm64`, `aarch64` o `armv7`;
|
||||
- descarga el binario de GitHub Release correspondiente;
|
||||
- lo verifica contra `SHA256SUMS`;
|
||||
- instala Vocat en `/opt/vocat`;
|
||||
- crea un servicio systemd reforzado con el acceso a hardware y red requerido por Vocat;
|
||||
- almacena la configuración en tiempo de ejecución en `/etc/vocat/env`;
|
||||
- genera una contraseña de administrador inicial aleatoria en la primera instalación.
|
||||
|
||||
Después de la instalación, abra:
|
||||
|
||||
```text
|
||||
http://<dirección-del-servidor>:7575
|
||||
```
|
||||
|
||||
### Instalación manual del binario
|
||||
|
||||
Descargue el binario correspondiente y `SHA256SUMS` desde GitHub Releases:
|
||||
|
||||
| Plataforma | Archivo de versión |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 bits | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Verifíquelo e instálelo:
|
||||
|
||||
```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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Este comando manual ejecuta Vocat en primer plano. Use `vocat serve` para que el
|
||||
proceso inicie el servidor directamente; ejecutar `vocat` sin argumentos como root
|
||||
en un TTY abre en su lugar el menú de gestión interactivo. Use el instalador de
|
||||
un clic cuando se requiera un servicio systemd gestionado y reinicio automático.
|
||||
|
||||
### Docker
|
||||
|
||||
Para un host Linux que debe descubrir cada módem Quectel compatible conectado y
|
||||
seguir viendo los eventos de conexión en caliente USB, ejecute Vocat en modo de acceso a hardware:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Abra `http://<dirección-del-servidor>:7575` después de que el contenedor se inicie. La red del host
|
||||
es necesaria para que las interfaces de red QMI permanezcan visibles para Vocat, mientras que el
|
||||
acceso privilegiado a dispositivos es necesario para los puertos serie, los nodos de control QMI,
|
||||
las interfaces TUN, la configuración de red y los dispositivos añadidos después de que el contenedor
|
||||
se inicie. El montaje bind de `/dev` hace visibles los nuevos nodos `ttyUSB*`, `ttyACM*` y `cdc-wdm*`
|
||||
sin recrear el contenedor.
|
||||
|
||||
Este modo otorga intencionadamente a Vocat un amplio acceso a los dispositivos y a la pila de red
|
||||
del host. Úselo solo en un host Linux de confianza. El descubrimiento automático identifica
|
||||
actualmente los módems USB Quectel compatibles (ID de fabricante USB `2c7c`), no marcas de módems
|
||||
arbitrarias. Mapear solo nodos individuales con `--device`, como `/dev/ttyUSB2` y `/dev/cdc-wdm0`,
|
||||
limita el contenedor a esos nodos fijos y no proporciona un descubrimiento completo de múltiples
|
||||
dispositivos o de conexión en caliente.
|
||||
|
||||
La imagen GHCR se publica para `linux/amd64` y `linux/arm64`.
|
||||
|
||||
## Configuración
|
||||
|
||||
Vocat lee un archivo de configuración JSON opcional desde `VOCAT_CONFIG` y luego aplica las variables de entorno `VOCAT_*`. Las variables de entorno tienen prioridad.
|
||||
|
||||
| Variable de entorno | Predeterminado | Descripción |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Dirección de escucha HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Ruta de la base de datos SQLite. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Duración de la sesión de autenticación. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marca las cookies de sesión como seguras cuando se usa HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Tiempo de espera de apagado ordenado. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Tamaño máximo del cuerpo de solicitud de la API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Repositorio de GitHub de confianza usado por el autoactualizador, en formato `owner/name`. |
|
||||
| `GITHUB_TOKEN` | vacío | Token de GitHub opcional para repositorios privados o límites de API más altos. |
|
||||
|
||||
No almacene tokens de Telegram, contraseñas SMTP, secretos de webhook, credenciales SIM u otros datos privados en el repositorio. Configúrelos a través de los ajustes de la aplicación o archivos de entorno protegidos.
|
||||
|
||||
## Bot de Telegram
|
||||
|
||||
Cuando las notificaciones de Telegram están habilitadas y tanto el Chat ID como el Admin ID están configurados, el bot admite:
|
||||
|
||||
```text
|
||||
/status [dispositivo]
|
||||
/esim <dispositivo>
|
||||
/switch <dispositivo> <iccid>
|
||||
/wfc <dispositivo> <status|on|off|reconnect>
|
||||
/sms <dispositivo> <número> <mensaje>
|
||||
```
|
||||
|
||||
El cambio de perfil y el envío de SMS usan botones de confirmación de un solo uso. El bot no expone comandos de descarga, eliminación o renombrado de eSIM.
|
||||
|
||||
## Actualización
|
||||
|
||||
Comprobar si hay una GitHub Release más reciente:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Instalar la última versión:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
El actualizador descarga el binario que coincide con la arquitectura Linux actual, lo verifica con el `SHA256SUMS` publicado, reemplaza el ejecutable de forma atómica y reinicia el servicio systemd `vocat` cuando está disponible.
|
||||
|
||||
Para instalaciones Docker:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Recrear el contenedor después de descargar la nueva imagen.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
Requisitos:
|
||||
|
||||
- Go 1.25 o más reciente
|
||||
- Node.js 20 o más reciente
|
||||
- npm
|
||||
|
||||
Ejecutar el servidor de desarrollo del frontend:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Compilar el frontend incrustado e iniciar el backend:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
Ejecutar todas las pruebas:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Compilar un binario de producción:
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## Automatización de versiones
|
||||
|
||||
Hacer push de una etiqueta de versión inicia dos flujos de trabajo de GitHub Actions:
|
||||
|
||||
- `release-binaries` compila y publica los binarios `amd64`, `386`, `arm64`, `aarch64` y `armv7` más `SHA256SUMS`.
|
||||
- `docker` compila y publica una imagen multiarquitectura en GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```text
|
||||
cmd/vocat/ Punto de entrada de la aplicación y CLI
|
||||
internal/device/ Descubrimiento de módems y control de dispositivos
|
||||
internal/modem/ Sesión AT y manejo de respuestas
|
||||
internal/server/ API HTTP, notificaciones y servidor web incrustado
|
||||
internal/store/ Persistencia SQLite
|
||||
internal/update/ Autoactualizador de GitHub Release
|
||||
internal/vowifi/ Runtime de IKE, EAP-AKA, IMS y WiFi Calling
|
||||
scripts/install.sh Instalador y actualizador de Linux
|
||||
web/src/ Frontend en React y TypeScript
|
||||
.github/workflows/ Automatización de versiones de binarios y Docker
|
||||
```
|
||||
|
||||
## Uso responsable
|
||||
|
||||
Las operaciones con módems celulares y eSIM pueden afectar el servicio del abonado, los perfiles almacenados, el registro de red y el estado del hardware. Mantenga copias de seguridad, revise con cuidado las acciones destructivas y use el software solo en entornos legales donde tenga permiso para operar el hardware y los recursos de red conectados.
|
||||
|
||||
Vocat no elude la autenticación del operador, la política de red, la seguridad del hardware ni los requisitos de confianza de eSIM. El soporte de una operación significa que Vocat puede solicitarla al módem o al eUICC; el dispositivo, el perfil, la red o el operador aún pueden rechazarla.
|
||||
|
||||
## Contribuir
|
||||
|
||||
Las incidencias y pull requests son bienvenidas. Mantenga los cambios enfocados, incluya pruebas cuando sea práctico, evite confirmar credenciales o datos de abonados, y documente claramente el comportamiento específico del hardware.
|
||||
|
||||
Antes de enviar un cambio:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Agradecimientos
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — Una comunidad dedicada a los servidores
|
||||
- [Linux.do](https://linux.do) — Una comunidad tecnológica inspiradora
|
||||
- [iniwex5](https://github.com/iniwex5) — Guías de estilo y funcionalidad
|
||||
|
||||
## Invítame a un café
|
||||
|
||||
| Red | Dirección |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Licencia
|
||||
|
||||
Consulte [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,343 @@
|
||||
<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_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">
|
||||
<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) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | **Français** | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat est un panneau de contrôle web open-source et une boîte à outils d'ingénierie pour les modems cellulaires Quectel de classe EC20/EC25. Il réunit, dans un service autonome unique, la découverte de modems, l'état radio en direct, les terminaux AT et USSD, les SMS, la WiFi Calling, la gestion eSIM, la sélection de réseau, le routage par proxy, les notifications, les journaux d'audit et l'automatisation des versions.
|
||||
|
||||
Le backend est écrit en Go, l'interface est construite avec React et TypeScript, et le frontend de production est intégré dans le binaire Go. Un seul exécutable contient l'application web et utilise SQLite pour l'état persistant.
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
| Domaine | Ce que Vocat fournit |
|
||||
| --- | --- |
|
||||
| Gestion des appareils | Découverte série/USB automatique, prise en charge de plusieurs modems, noms d'appareils conviviaux, mises à jour en direct de la vue d'ensemble, redémarrage du module, mode avion et contrôles du mode réseau USB. |
|
||||
| Radio et réseau | État d'enregistrement, opérateur, métriques de signal, RSRP/RSRQ/SINR, mode réseau, bande, canal, recherche d'opérateurs et sélection de réseau automatique ou manuelle. |
|
||||
| AT et USSD | Terminal AT interactif, historique des commandes, réponses brutes du modem, flux de démarrage/poursuite/annulation USSD et rapport d'erreurs modem clair. |
|
||||
| SMS | Envoi direct de SMS cellulaires et IMS, synchronisation entrante, gestion des messages multiparties, rapports de livraison, historique des conversations, état non lu, horodatages et statut de livraison par message. |
|
||||
| WiFi Calling | Établissement de tunnel IKEv2/ePDG, authentification EAP-AKA, enregistrement IMS, SMS IMS, contrôles de reconnexion, diagnostics d'état et routage par appareil. |
|
||||
| eSIM et eUICC | Découverte eUICC, EID et informations de production, métadonnées de certificat, inventaire multi-eUICC, liste des profils installés, opérations d'activation/désactivation/commutation, ainsi que téléchargement, renommage et suppression lorsque la carte le permet. |
|
||||
| Politique de carte | Comportement WiFi Calling et mode avion basé sur l'ICCID avec application immédiate de la politique. |
|
||||
| Routage par proxy | Routage SOCKS amont, liaisons d'appareils, règles par pays, vérifications d'accessibilité TCP et vérifications UDP Associate pour les chemins de données WiFi Calling. |
|
||||
| Notifications | Transfert des nouveaux SMS entrants via Telegram, Bark, e-mail, Pushplus et webhooks signés. Chaque SMS est livré comme une notification individuelle. |
|
||||
| Bot Telegram | État de l'appareil, liste et commutation des profils installés, contrôles WiFi Calling et envoi de SMS. Les actions sensibles nécessitent une confirmation de l'administrateur. |
|
||||
| Exploitation | Authentification, protection CSRF, politiques d'accès, événements d'audit, journaux en direct, rétention des journaux, vérifications de santé, mise en page réactive, mode sombre et interface utilisateur en anglais/chinois. |
|
||||
| Distribution | Binaires Linux statiques, script d'installation systemd, auto-mise à jour avec vérification SHA-256, image Docker, publication GHCR et builds de version GitHub Actions. |
|
||||
|
||||
## Matériel pris en charge
|
||||
|
||||
Vocat cible les modules Quectel à base Qualcomm qui exposent des interfaces AT, QMI, série et réseau USB compatibles, notamment :
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Famille Quectel EG25
|
||||
- Modules EG600 compatibles et apparentés
|
||||
|
||||
Les fonctionnalités disponibles dépendent du firmware du module, de la composition USB, des capacités SIM/eSIM, des pilotes hôtes, du réseau radio et de la configuration de l'opérateur.
|
||||
|
||||
## Installation
|
||||
|
||||
### Installation Linux en un clic
|
||||
|
||||
En tant que root (y compris OpenWrt/Kwrt, où `sudo` est normalement absent) :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Depuis un utilisateur normal sur une distribution disposant de sudo :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Vérifier les prérequis VoWiFi/XFRM de l'hôte sans installer VoCat :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Installer une version spécifique :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS nécessite Linux XFRM/IPsec. Sur OpenWrt/Kwrt, le programme d'installation tente
|
||||
d'installer les paquets correspondants `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC et SHA1 depuis le dépôt du firmware lui-même.
|
||||
Si des modules noyau correspondants ne sont pas disponibles, utilisez un firmware qui les inclut ;
|
||||
ne forcez jamais l'installation de kmods compilés pour un noyau différent.
|
||||
|
||||
Le programme d'installation :
|
||||
|
||||
- détecte `amd64`, `386`, `arm64`, `aarch64` ou `armv7` ;
|
||||
- télécharge le binaire GitHub Release correspondant ;
|
||||
- le vérifie par rapport à `SHA256SUMS` ;
|
||||
- installe Vocat sous `/opt/vocat` ;
|
||||
- crée un service systemd renforcé disposant des accès matériel et réseau requis par Vocat ;
|
||||
- stocke la configuration d'exécution dans `/etc/vocat/env` ;
|
||||
- génère un mot de passe administrateur initial aléatoire lors de la première installation.
|
||||
|
||||
Après l'installation, ouvrez :
|
||||
|
||||
```text
|
||||
http://<adresse-du-serveur>:7575
|
||||
```
|
||||
|
||||
### Installation manuelle du binaire
|
||||
|
||||
Téléchargez le binaire correspondant et `SHA256SUMS` depuis GitHub Releases :
|
||||
|
||||
| Plateforme | Fichier de version |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 bits | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| Linux ARMv7 | `vocat-linux-armv7` |
|
||||
|
||||
Vérifiez-le et installez-le :
|
||||
|
||||
```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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Cette commande manuelle exécute Vocat au premier plan. Utilisez `vocat serve` pour que le
|
||||
processus démarre directement le serveur ; exécuter `vocat` sans argument en tant que root
|
||||
sur un TTY ouvre plutôt le menu de gestion interactif. Utilisez le programme d'installation
|
||||
en un clic lorsqu'un service systemd géré et un redémarrage automatique sont requis.
|
||||
|
||||
### Docker
|
||||
|
||||
Pour un hôte Linux qui doit découvrir chaque modem Quectel pris en charge connecté et
|
||||
continuer à voir les événements de branchement à chaud USB, exécutez Vocat en mode d'accès matériel :
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
-v /dev:/dev \
|
||||
-v /sys:/sys:ro \
|
||||
ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Ouvrez `http://<adresse-du-serveur>:7575` après le démarrage du conteneur. Le réseau de l'hôte
|
||||
est requis pour que les interfaces réseau QMI restent visibles par Vocat, tandis que l'accès
|
||||
privilégié aux périphériques est requis pour les ports série, les nœuds de contrôle QMI, les
|
||||
interfaces TUN, la configuration réseau et les périphériques ajoutés après le démarrage du
|
||||
conteneur. Le montage bind de `/dev` rend les nouveaux nœuds `ttyUSB*`, `ttyACM*` et `cdc-wdm*`
|
||||
visibles sans recréer le conteneur.
|
||||
|
||||
Ce mode donne intentionnellement à Vocat un large accès aux périphériques et à la pile réseau
|
||||
de l'hôte. Ne l'utilisez que sur un hôte Linux de confiance. La découverte automatique
|
||||
identifie actuellement les modems USB Quectel pris en charge (ID fabricant USB `2c7c`), et non
|
||||
des marques de modems arbitraires. Le mappage de nœuds individuels uniquement avec `--device`,
|
||||
comme `/dev/ttyUSB2` et `/dev/cdc-wdm0`, limite le conteneur à ces nœuds fixes et ne fournit
|
||||
pas une découverte multi-périphériques ou à chaud complète.
|
||||
|
||||
L'image GHCR est publiée pour `linux/amd64` et `linux/arm64`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Vocat lit un fichier de configuration JSON optionnel depuis `VOCAT_CONFIG`, puis applique les variables d'environnement `VOCAT_*`. Les variables d'environnement ont la priorité.
|
||||
|
||||
| Variable d'environnement | Par défaut | Description |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Adresse d'écoute HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Chemin de la base de données SQLite. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Durée de vie de la session d'authentification. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Marque les cookies de session comme sécurisés lorsque HTTPS est utilisé. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Délai d'arrêt gracieux. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Taille maximale du corps de requête API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Dépôt GitHub de confiance utilisé par l'auto-updater, au format `owner/name`. |
|
||||
| `GITHUB_TOKEN` | vide | Jeton GitHub optionnel pour les dépôts privés ou des limites d'API plus élevées. |
|
||||
|
||||
Ne stockez pas de jetons Telegram, mots de passe SMTP, secrets de webhook, identifiants SIM ou autres données privées dans le dépôt. Configurez-les via les paramètres de l'application ou des fichiers d'environnement protégés.
|
||||
|
||||
## Bot Telegram
|
||||
|
||||
Lorsque les notifications Telegram sont activées et que le Chat ID et l'Admin ID sont configurés, le bot prend en charge :
|
||||
|
||||
```text
|
||||
/status [appareil]
|
||||
/esim <appareil>
|
||||
/switch <appareil> <iccid>
|
||||
/wfc <appareil> <status|on|off|reconnect>
|
||||
/sms <appareil> <numéro> <message>
|
||||
```
|
||||
|
||||
La commutation de profil et l'envoi de SMS utilisent des boutons de confirmation à usage unique. Le bot n'expose pas les commandes de téléchargement, de suppression ou de renommage eSIM.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
Vérifier l'existence d'une GitHub Release plus récente :
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Installer la dernière version :
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
L'updater télécharge le binaire correspondant à l'architecture Linux actuelle, le vérifie avec le `SHA256SUMS` publié, remplace l'exécutable de manière atomique et redémarre le service systemd `vocat` lorsqu'il est disponible.
|
||||
|
||||
Pour les installations Docker :
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
```
|
||||
|
||||
Recréez le conteneur après avoir tiré la nouvelle image.
|
||||
|
||||
## Développement
|
||||
|
||||
Prérequis :
|
||||
|
||||
- Go 1.25 ou plus récent
|
||||
- Node.js 20 ou plus récent
|
||||
- npm
|
||||
|
||||
Lancer le serveur de développement frontend :
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Construire le frontend intégré et démarrer le backend :
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run build
|
||||
cd ..
|
||||
go run ./cmd/vocat
|
||||
```
|
||||
|
||||
Exécuter tous les tests :
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Construire un binaire de production :
|
||||
|
||||
```bash
|
||||
go build -trimpath -ldflags "-s -w" -o vocat ./cmd/vocat
|
||||
```
|
||||
|
||||
## Automatisation des versions
|
||||
|
||||
Pousser un tag de version déclenche deux workflows GitHub Actions :
|
||||
|
||||
- `release-binaries` construit et publie les binaires `amd64`, `386`, `arm64`, `aarch64` et `armv7` ainsi que `SHA256SUMS`.
|
||||
- `docker` construit et publie une image multi-architecture vers GitHub Container Registry.
|
||||
|
||||
```bash
|
||||
git tag v0.2.0
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```text
|
||||
cmd/vocat/ Point d'entrée de l'application et CLI
|
||||
internal/device/ Découverte de modems et contrôle des appareils
|
||||
internal/modem/ Session AT et gestion des réponses
|
||||
internal/server/ API HTTP, notifications et serveur web intégré
|
||||
internal/store/ Persistance SQLite
|
||||
internal/update/ Auto-updater GitHub Release
|
||||
internal/vowifi/ Runtime IKE, EAP-AKA, IMS et WiFi Calling
|
||||
scripts/install.sh Installeur et updater Linux
|
||||
web/src/ Frontend React et TypeScript
|
||||
.github/workflows/ Automatisation des versions binaires et Docker
|
||||
```
|
||||
|
||||
## Utilisation responsable
|
||||
|
||||
Les opérations sur les modems cellulaires et les eSIM peuvent affecter le service de l'abonné, les profils stockés, l'enregistrement réseau et l'état du matériel. Effectuez des sauvegardes, examinez attentivement les actions destructrices et n'utilisez le logiciel que dans des environnements légaux où vous êtes autorisé à exploiter le matériel et les ressources réseau connectés.
|
||||
|
||||
Vocat ne contourne ni l'authentification de l'opérateur, ni la politique réseau, ni la sécurité matérielle, ni les exigences de confiance eSIM. La prise en charge d'une opération signifie que Vocat peut la demander au modem ou à l'eUICC ; l'appareil, le profil, le réseau ou l'opérateur peut toujours la refuser.
|
||||
|
||||
## Contribution
|
||||
|
||||
Les issues et pull requests sont les bienvenues. Gardez des changements ciblés, incluez des tests lorsque c'est possible, évitez de committer des identifiants ou des données d'abonnés, et documentez clairement les comportements spécifiques au matériel.
|
||||
|
||||
Avant de soumettre un changement :
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
## Remerciements
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — Une communauté dédiée aux serveurs
|
||||
- [Linux.do](https://linux.do) — Une communauté technologique inspirante
|
||||
- [iniwex5](https://github.com/iniwex5) — Directives de style et de fonctionnalité
|
||||
|
||||
## Offrez-moi un café
|
||||
|
||||
| Réseau | Adresse |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Licence
|
||||
|
||||
Voir [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,325 @@
|
||||
<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_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">
|
||||
<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) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | **日本語**
|
||||
|
||||
Vocat は、Quectel EC20/EC25 クラスのセルラーモデム向けのオープンソース Web コントロールパネル兼エンジニアリングツールキットです。モデムの検出、ライブの無線ステータス、AT / USSD ターミナル、SMS、WiFi Calling、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 の開始/継続/キャンセルフロー、明確なモデムエラーレポート。 |
|
||||
| SMS | セルラーおよび IMS SMS の直接送信、受信同期、マルチパート処理、配信レポート、会話履歴、未読状態、タイムスタンプ、メッセージごとの配信ステータス。 |
|
||||
| WiFi Calling | IKEv2/ePDG トンネルの確立、EAP-AKA 認証、IMS 登録、IMS SMS、再接続制御、ステータス診断、デバイスごとのルーティング。 |
|
||||
| eSIM と eUICC | eUICC の検出、EID と製造情報、証明書メタデータ、複数 eUICC のインベントリ、インストール済みプロファイルの一覧、有効化/無効化/切り替え操作、およびカードが対応している場合のダウンロード、名前変更、削除操作。 |
|
||||
| カードポリシー | ICCID ベースの WiFi Calling および機内モードの動作で、ポリシーが即時に適用されます。 |
|
||||
| プロキシルーティング | アップストリーム SOCKS ルーティング、デバイスバインディング、国別ルール、TCP 到達性チェック、WiFi Calling データパス向けの UDP Associate チェック。 |
|
||||
| 通知 | Telegram、Bark、メール、Pushplus、署名付き Webhook を介した新着 SMS の転送。各 SMS は個別の通知として配信されます。 |
|
||||
| Telegram ボット | デバイスステータス、インストール済みプロファイルの一覧と切り替え、WiFi Calling 制御、SMS 送信。機密性の高い操作には管理者の確認が必要です。 |
|
||||
| 運用 | 認証、CSRF 保護、アクセスポリシー、監査イベント、ライブログ、ログ保持、ヘルスチェック、レスポンシブレイアウト、ダークモード、英語/中国語のアプリケーション UI。 |
|
||||
| 配布 | 静的 Linux バイナリ、systemd インストールスクリプト、SHA-256 検証付きの自己更新、Docker イメージ、GHCR 公開、GitHub Actions リリースビルド。 |
|
||||
|
||||
## 対応ハードウェア
|
||||
|
||||
Vocat は、互換性のある AT、QMI、シリアル、USB ネットワークインターフェースを公開する Qualcomm ベースの Quectel モジュールを対象としています。対象には以下が含まれます:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Quectel EG25 ファミリー
|
||||
- 互換性のある EG600 および関連モジュール
|
||||
|
||||
利用可能な機能は、モジュールのファームウェア、USB 構成、SIM/eSIM の機能、ホストドライバー、無線ネットワーク、キャリア設定によって異なります。
|
||||
|
||||
## インストール
|
||||
|
||||
### ワンクリック Linux インストール
|
||||
|
||||
root として(`sudo` が通常存在しない OpenWrt/Kwrt を含む):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
sudo を持つディストリビューションの一般ユーザーから:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
VoCat をインストールせずに、ホストの VoWiFi/XFRM 前提条件を確認する:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
特定のバージョンをインストールする:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS には Linux XFRM/IPsec が必要です。OpenWrt/Kwrt では、インストーラーはファームウェア自身のフィードから、一致する `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC、SHA1 パッケージのインストールを試みます。一致するカーネルモジュールが利用できない場合は、それらを含むファームウェアを使用してください。別のカーネル向けにビルドされた kmod を強制的にインストールしてはいけません。
|
||||
|
||||
インストーラーは次を行います:
|
||||
|
||||
- `amd64`、`386`、`arm64`、`aarch64`、`armv7` を検出します;
|
||||
- 一致する GitHub Release バイナリをダウンロードします;
|
||||
- `SHA256SUMS` と照合して検証します;
|
||||
- Vocat を `/opt/vocat` にインストールします;
|
||||
- Vocat が必要とするハードウェアおよびネットワークアクセスを持つ強化された systemd サービスを作成します;
|
||||
- 実行時設定を `/etc/vocat/env` に保存します;
|
||||
- 初回インストール時にランダムな初期管理者パスワードを生成します。
|
||||
|
||||
インストール後、次を開きます:
|
||||
|
||||
```text
|
||||
http://<サーバーアドレス>:7575
|
||||
```
|
||||
|
||||
### 手動バイナリインストール
|
||||
|
||||
一致するバイナリと `SHA256SUMS` を GitHub Releases からダウンロードします:
|
||||
|
||||
| プラットフォーム | リリースファイル |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32 ビット | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| 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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
この手動コマンドは Vocat をフォアグラウンドで実行します。プロセスがサーバーを直接起動するように `vocat serve` を使用してください。TTY で root として引数なしで `vocat` を実行すると、代わりに対話型管理メニューが開きます。管理対象の systemd サービスと自動再起動が必要な場合は、ワンクリックインストーラーを使用してください。
|
||||
|
||||
### Docker
|
||||
|
||||
接続されているすべてのサポート対象 Quectel モデムを検出し、USB ホットプラグイベントを継続的に認識する必要がある Linux ホストでは、Vocat をハードウェアアクセスモードで実行します:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-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_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` | 空 | プライベートリポジトリやより高い API レート制限のためのオプションの GitHub トークン。 |
|
||||
|
||||
Telegram トークン、SMTP パスワード、Webhook シークレット、SIM 認証情報、その他のプライベートデータをリポジトリに保存しないでください。アプリケーション設定または保護された環境ファイルを通じて設定してください。
|
||||
|
||||
## Telegram ボット
|
||||
|
||||
Telegram 通知が有効で、Chat ID と Admin ID の両方が設定されている場合、ボットは以下をサポートします:
|
||||
|
||||
```text
|
||||
/status [デバイス]
|
||||
/esim <デバイス>
|
||||
/switch <デバイス> <iccid>
|
||||
/wfc <デバイス> <status|on|off|reconnect>
|
||||
/sms <デバイス> <番号> <メッセージ>
|
||||
```
|
||||
|
||||
プロファイルの切り替えと SMS の送信には、ワンタイム確認ボタンが使用されます。ボットは 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
|
||||
```
|
||||
|
||||
## リリース自動化
|
||||
|
||||
バージョンタグをプッシュすると、2 つの GitHub Actions ワークフローが開始されます:
|
||||
|
||||
- `release-binaries` は `amd64`、`386`、`arm64`、`aarch64`、`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) — スタイルと機能のガイドライン
|
||||
|
||||
## コーヒーをおごってください
|
||||
|
||||
| ネットワーク | アドレス |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## ライセンス
|
||||
|
||||
[LICENSE](../LICENSE) を参照してください。
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -0,0 +1,342 @@
|
||||
<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_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">
|
||||
<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) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | **Русский** | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat — это веб-панель управления с открытым исходным кодом и набор инженерных инструментов для сотовых модемов Quectel класса EC20/EC25. Она объединяет в одном автономном сервисе обнаружение модемов, состояние радиосвязи в реальном времени, терминалы AT и USSD, SMS, WiFi Calling, управление eSIM, выбор сети, маршрутизацию через прокси, уведомления, журналы аудита и автоматизацию релизов.
|
||||
|
||||
Бэкенд написан на Go, интерфейс построен на React и TypeScript, а производственный фронтенд встроен в бинарный файл Go. Один исполняемый файл содержит веб-приложение и использует 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 и понятные сообщения об ошибках модема. |
|
||||
| SMS | Прямая отправка сотовых и IMS SMS, входящая синхронизация, обработка составных сообщений, отчёты о доставке, история диалогов, статус непрочитанных, метки времени и статус доставки каждого сообщения. |
|
||||
| WiFi Calling | Установка туннеля IKEv2/ePDG, аутентификация EAP-AKA, регистрация IMS, IMS SMS, управление переподключением, диагностика состояния и маршрутизация по устройствам. |
|
||||
| eSIM и eUICC | Обнаружение eUICC, EID и производственная информация, метаданные сертификатов, инвентарь нескольких eUICC, список установленных профилей, операции включения/отключения/переключения, а также загрузка, переименование и удаление при поддержке картой. |
|
||||
| Политика карты | Поведение WiFi Calling и авиарежима на основе ICCID с немедленным применением политики. |
|
||||
| Маршрутизация через прокси | Восходящая маршрутизация SOCKS, привязки устройств, правила по странам, проверки доступности TCP и проверки UDP Associate для путей передачи данных WiFi Calling. |
|
||||
| Уведомления | Пересылка новых входящих SMS через Telegram, Bark, электронную почту, Pushplus и подписанные вебхуки. Каждое SMS доставляется как отдельное уведомление. |
|
||||
| Telegram-бот | Статус устройства, список и переключение установленных профилей, управление WiFi Calling и отправка SMS. Чувствительные действия требуют подтверждения администратора. |
|
||||
| Эксплуатация | Аутентификация, защита CSRF, политики доступа, события аудита, журналы в реальном времени, хранение журналов, проверки работоспособности, адаптивная вёрстка, тёмный режим и интерфейс на английском/китайском. |
|
||||
| Дистрибуция | Статические бинарные файлы Linux, скрипт установки systemd, самообновление с проверкой SHA-256, образ Docker, публикация в GHCR и сборки релизов GitHub Actions. |
|
||||
|
||||
## Поддерживаемое оборудование
|
||||
|
||||
Vocat ориентирован на модули Quectel на базе Qualcomm, которые предоставляют совместимые интерфейсы AT, QMI, последовательный порт и USB-сеть, включая:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Семейство Quectel EG25
|
||||
- Совместимые модули EG600 и родственные
|
||||
|
||||
Доступные функции зависят от прошивки модуля, конфигурации USB, возможностей SIM/eSIM, драйверов хоста, радиосети и настроек оператора.
|
||||
|
||||
## Установка
|
||||
|
||||
### Установка в Linux одной командой
|
||||
|
||||
От имени root (включая OpenWrt/Kwrt, где `sudo` обычно отсутствует):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
От обычного пользователя в дистрибутиве с sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Проверить предварительные требования VoWiFi/XFRM на хосте без установки VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
Установить конкретную версию:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS требует Linux XFRM/IPsec. В OpenWrt/Kwrt установщик пытается
|
||||
установить соответствующие пакеты `ip-full`, `kmod-ipsec`, `kmod-ipsec4/6`,
|
||||
`kmod-crypto-authenc`, AES-CBC и SHA1 из собственного репозитория прошивки.
|
||||
Если соответствующие модули ядра недоступны, используйте прошивку, которая их включает;
|
||||
никогда не устанавливайте принудительно kmod, собранные для другого ядра.
|
||||
|
||||
Установщик:
|
||||
|
||||
- определяет `amd64`, `386`, `arm64`, `aarch64` или `armv7`;
|
||||
- загружает соответствующий бинарный файл GitHub Release;
|
||||
- проверяет его по `SHA256SUMS`;
|
||||
- устанавливает Vocat в `/opt/vocat`;
|
||||
- создаёт усиленный сервис systemd с доступом к оборудованию и сети, необходимым Vocat;
|
||||
- хранит конфигурацию времени выполнения в `/etc/vocat/env`;
|
||||
- генерирует случайный начальный пароль администратора при первой установке.
|
||||
|
||||
После установки откройте:
|
||||
|
||||
```text
|
||||
http://<адрес-сервера>:7575
|
||||
```
|
||||
|
||||
### Ручная установка бинарного файла
|
||||
|
||||
Загрузите соответствующий бинарный файл и `SHA256SUMS` из GitHub Releases:
|
||||
|
||||
| Платформа | Файл релиза |
|
||||
| --- | --- |
|
||||
| Linux x86-64 | `vocat-linux-amd64` |
|
||||
| Linux x86 32-бит | `vocat-linux-386` |
|
||||
| Linux ARM64 | `vocat-linux-arm64` |
|
||||
| Linux AArch64 | `vocat-linux-aarch64` |
|
||||
| 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
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
Эта ручная команда запускает Vocat в переднем плане. Используйте `vocat serve`, чтобы
|
||||
процесс сразу запустил сервер; запуск `vocat` без аргументов от имени root
|
||||
в TTY вместо этого открывает интерактивное меню управления. Используйте установку
|
||||
одной командой, когда требуется управляемый сервис systemd и автоматический перезапуск.
|
||||
|
||||
### Docker
|
||||
|
||||
Для хоста Linux, который должен обнаруживать каждый подключённый поддерживаемый модем Quectel и
|
||||
продолжать видеть события горячего подключения USB, запустите Vocat в режиме доступа к оборудованию:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "Admin password: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-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. Автоматическое обнаружение
|
||||
в настоящее время определяет поддерживаемые USB-модемы Quectel (USB vendor ID `2c7c`), а не
|
||||
произвольные марки модемов. Монтирование только отдельных узлов с помощью `--device`, таких как
|
||||
`/dev/ttyUSB2` и `/dev/cdc-wdm0`, ограничивает контейнер этими фиксированными узлами и не
|
||||
обеспечивает полное обнаружение нескольких устройств или горячего подключения.
|
||||
|
||||
Образ GHCR публикуется для `linux/amd64` и `linux/arm64`.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Vocat читает необязательный JSON-файл конфигурации из `VOCAT_CONFIG`, затем применяет переменные окружения `VOCAT_*`. Переменные окружения имеют приоритет.
|
||||
|
||||
| Переменная окружения | По умолчанию | Описание |
|
||||
| --- | --- | --- |
|
||||
| `VOCAT_ADDR` | `0.0.0.0:7575` | Адрес прослушивания HTTP. |
|
||||
| `VOCAT_DATABASE_PATH` | `./data/vocat.db` | Путь к базе данных SQLite. |
|
||||
| `VOCAT_SESSION_TTL` | `24h` | Время жизни сессии аутентификации. |
|
||||
| `VOCAT_SECURE_COOKIES` | `false` | Помечает cookie сессии как безопасные при использовании HTTPS. |
|
||||
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Тайм-аут корректного завершения работы. |
|
||||
| `VOCAT_MAX_REQUEST_BODY_BYTES` | `1048576` | Максимальный размер тела запроса API. |
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | Доверенный репозиторий GitHub, используемый самообновлятором, в формате `owner/name`. |
|
||||
| `GITHUB_TOKEN` | пусто | Необязательный токен GitHub для приватных репозиториев или более высоких лимитов API. |
|
||||
|
||||
Не храните токены Telegram, пароли SMTP, секреты вебхуков, учётные данные SIM или другие приватные данные в репозитории. Настраивайте их через параметры приложения или защищённые файлы окружения.
|
||||
|
||||
## Telegram-бот
|
||||
|
||||
Когда уведомления Telegram включены и настроены Chat ID и Admin ID, бот поддерживает:
|
||||
|
||||
```text
|
||||
/status [устройство]
|
||||
/esim <устройство>
|
||||
/switch <устройство> <iccid>
|
||||
/wfc <устройство> <status|on|off|reconnect>
|
||||
/sms <устройство> <номер> <сообщение>
|
||||
```
|
||||
|
||||
Переключение профилей и отправка SMS используют одноразовые кнопки подтверждения. Бот не предоставляет команды загрузки, удаления или переименования eSIM.
|
||||
|
||||
## Обновление
|
||||
|
||||
Проверить наличие более нового GitHub Release:
|
||||
|
||||
```bash
|
||||
vocat update --check --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Установить последний релиз:
|
||||
|
||||
```bash
|
||||
sudo vocat update --repo MengMengCode/VoCat
|
||||
```
|
||||
|
||||
Обновлятор загружает бинарный файл, соответствующий текущей архитектуре Linux, проверяет его по опубликованному `SHA256SUMS`, атомарно заменяет исполняемый файл и перезапускает сервис systemd `vocat`, когда он доступен.
|
||||
|
||||
Для установок 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`, `aarch64` и `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, уведомления и встроенный веб-сервер
|
||||
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; устройство, профиль, сеть или оператор всё равно могут её отклонить.
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Мы приветствуем issues и 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) — Руководства по стилю и функциональности
|
||||
|
||||
## Угостите меня кофе
|
||||
|
||||
| Сеть | Адрес |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## Лицензия
|
||||
|
||||
См. [LICENSE](../LICENSE).
|
||||
|
||||
[](https://meteor-history.com)
|
||||
+40
-11
@@ -22,7 +22,7 @@
|
||||
<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) | **简体中文**
|
||||
[English](../README.md) | [العربية](README.ar.md) | **简体中文** | [繁體中文](README.zh-TW.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制面板与工程工具套件。它在一个自包含的服务中整合了模组发现、实时射频状态、AT 与 USSD 终端、短信、WiFi Calling(WiFi 通话)、eSIM 管理、网络选择、代理路由、通知、审计日志以及发布自动化。
|
||||
|
||||
@@ -46,7 +46,7 @@ Vocat 是一款面向 Quectel EC20/EC25 系列蜂窝模组的开源 Web 控制
|
||||
| 卡策略 | 基于 ICCID 的 WiFi Calling 与飞行模式行为,策略即时应用。 |
|
||||
| 代理路由 | 上游 SOCKS 路由、设备绑定、国家规则、TCP 可达性检查以及面向 WiFi Calling 数据路径的 UDP Associate 检查。 |
|
||||
| 通知 | 通过 Telegram、Bark、邮件、Pushplus 以及签名 Webhook 转发新入站短信,每条短信单独推送。 |
|
||||
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制、短信发送、定时拨号并自动挂断、通话状态、接听与挂断命令。敏感操作需要管理员确认。 |
|
||||
| Telegram 机器人 | 设备状态、已安装配置文件列表与切换、WiFi Calling 控制以及短信发送。敏感操作需要管理员确认。 |
|
||||
| 运维 | 鉴权、CSRF 防护、访问策略、审计事件、实时日志、日志留存、健康检查、响应式布局、深色模式以及中英文应用界面。 |
|
||||
| 分发 | 静态 Linux 二进制、systemd 安装脚本、带 SHA-256 校验的自更新、Docker 镜像、GHCR 发布以及 GitHub Actions 发布构建。 |
|
||||
|
||||
@@ -65,10 +65,24 @@ Vocat 面向基于高通芯片、并暴露兼容 AT、QMI、串口与 USB 网络
|
||||
|
||||
### Linux 一键安装
|
||||
|
||||
已是 root(包括默认没有 `sudo` 的 OpenWrt/Kwrt):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
普通 Linux 用户且系统装有 sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
只检查 VoWiFi/XFRM 环境,不安装 VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
安装指定版本:
|
||||
|
||||
```bash
|
||||
@@ -76,6 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/i
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS 必须使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安装脚本会从当前固件自己的软件源尝试安装严格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 组件。若软件源没有与当前内核匹配的模块,必须更换包含这些组件的固件,禁止强装其他内核版本的 kmod。
|
||||
|
||||
安装程序会:
|
||||
|
||||
- 检测 `amd64`、`386`、`arm64` 或 `armv7` 架构;
|
||||
@@ -109,9 +125,11 @@ http://<服务器地址>:7575
|
||||
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
|
||||
read -rsp "管理员密码: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
VOCAT_ADMIN_PASSWORD=change-this-password \
|
||||
/opt/vocat/bin/vocat serve
|
||||
```
|
||||
|
||||
@@ -124,13 +142,20 @@ sudo env \
|
||||
```bash
|
||||
docker pull ghcr.io/mengmengcode/vocat:latest
|
||||
|
||||
read -rsp "管理员密码: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
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 \
|
||||
@@ -143,6 +168,13 @@ docker run -d \
|
||||
|
||||
GHCR 镜像发布为 `linux/amd64` 与 `linux/arm64`。
|
||||
|
||||
### USB SIM 读卡器
|
||||
|
||||
USB SIM 读卡器通过 Linux PC/SC 服务访问。一键安装脚本会在支持的软件包管理器上
|
||||
自动安装并启动 `pcscd` 和 CCID 驱动;Debian/Ubuntu 手动安装命令为
|
||||
`apt install pcscd libccid`。如果 USB 已识别 CCID 读卡器但 PC/SC 尚未就绪,
|
||||
VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服务或驱动,不再静默隐藏。
|
||||
|
||||
## 配置
|
||||
|
||||
Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*` 环境变量。环境变量优先级更高。
|
||||
@@ -151,8 +183,6 @@ 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` | 优雅关闭超时时间。 |
|
||||
@@ -160,6 +190,9 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
| `VOCAT_REPO` | `MengMengCode/VoCat` | 自更新器使用的受信任 GitHub 仓库,格式为 `owner/name`。 |
|
||||
| `GITHUB_TOKEN` | 空 | 可选的 GitHub token,用于私有仓库或更高的 API 限额。 |
|
||||
|
||||
管理员账号和密码只保存在 SQLite 数据库中。空数据库需要执行一次
|
||||
`vocat bootstrap-admin` 完成初始化;环境变量和 JSON 配置都不能设置或覆盖管理员凭据。
|
||||
|
||||
请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。
|
||||
|
||||
## Telegram 机器人
|
||||
@@ -172,13 +205,9 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_*
|
||||
/switch <设备> <iccid>
|
||||
/wfc <设备> <status|on|off|reconnect>
|
||||
/sms <设备> <号码> <内容>
|
||||
/call <设备> <号码> <秒数>
|
||||
/calls <设备>
|
||||
/answer <设备>
|
||||
/hangup <设备>
|
||||
```
|
||||
|
||||
配置文件切换、短信提交与拨号使用一次性确认按钮。定时拨号会执行模组拨号动作,并在 1–600 秒后自动挂断;不会捕获或处理通话音频。机器人不暴露 eSIM 下载、删除或重命名命令。
|
||||
配置文件切换与短信提交使用一次性确认按钮。机器人不暴露 eSIM 下载、删除或重命名命令。
|
||||
|
||||
## 更新
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<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_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">
|
||||
<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) | [العربية](README.ar.md) | [简体中文](README.zh-CN.md) | **繁體中文** | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [日本語](README.ja.md)
|
||||
|
||||
Vocat 是一款面向 Quectel EC20/EC25 系列行動通訊模組的開源 Web 控制面板與工程工具套件。它在單一自包含的服務中整合了模組探索、即時射頻狀態、AT 與 USSD 終端、簡訊、WiFi Calling(WiFi 通話)、eSIM 管理、網路選擇、代理路由、通知、稽核日誌以及發佈自動化。
|
||||
|
||||
後端使用 Go 撰寫,介面採用 React 與 TypeScript 建構,生產環境前端被嵌入進 Go 二進位檔中。單一可執行檔即包含完整的 Web 應用,並使用 SQLite 進行持久化儲存。
|
||||
|
||||
<p align="center">
|
||||
<img src="../img/image.png">
|
||||
<img src="../img/image-1.png">
|
||||
</p>
|
||||
|
||||
## 功能
|
||||
|
||||
| 領域 | Vocat 提供的能力 |
|
||||
| --- | --- |
|
||||
| 裝置管理 | 自動序列埠/USB 探索、多模組支援、裝置友善名稱、概覽即時更新、模組重新啟動、飛航模式以及 USB 網路卡模式控制。 |
|
||||
| 射頻與網路 | 註冊狀態、電信業者、訊號指標、RSRP/RSRQ/SINR、網路模式、頻段、通道、電信業者掃描以及自動/手動選網。 |
|
||||
| AT 與 USSD | 互動式 AT 終端、指令歷史、原始模組回應、USSD 發起/繼續/取消流程以及清晰的模組錯誤回報。 |
|
||||
| 簡訊 | 行動通訊與 IMS 簡訊直接傳送、接收同步、長簡訊合併、送達報告、對話歷史、未讀狀態、時間戳以及逐則訊息的送達狀態。 |
|
||||
| WiFi Calling | IKEv2/ePDG 隧道建立、EAP-AKA 驗證、IMS 註冊、IMS 簡訊、重新連線控制、狀態診斷以及依裝置路由。 |
|
||||
| eSIM 與 eUICC | eUICC 探索、EID 與生產資訊、憑證中繼資料、多 eUICC 清單、已安裝設定檔列表、啟用/停用/切換操作,以及在卡片支援時進行下載、重新命名與刪除。 |
|
||||
| 卡片策略 | 基於 ICCID 的 WiFi Calling 與飛航模式行為,策略即時套用。 |
|
||||
| 代理路由 | 上游 SOCKS 路由、裝置綁定、國家規則、TCP 可達性檢查以及面向 WiFi Calling 資料路徑的 UDP Associate 檢查。 |
|
||||
| 通知 | 透過 Telegram、Bark、電子郵件、Pushplus 以及簽章 Webhook 轉發新接收簡訊,每則簡訊個別推送。 |
|
||||
| Telegram 機器人 | 裝置狀態、已安裝設定檔列表與切換、WiFi Calling 控制以及簡訊傳送。敏感操作需要管理員確認。 |
|
||||
| 維運 | 驗證、CSRF 防護、存取策略、稽核事件、即時日誌、日誌保留、健康檢查、響應式版面、深色模式以及中英文應用介面。 |
|
||||
| 發佈 | 靜態 Linux 二進位檔、systemd 安裝腳本、具 SHA-256 校驗的自我更新、Docker 映像、GHCR 發佈以及 GitHub Actions 發佈建置。 |
|
||||
|
||||
## 支援的硬體
|
||||
|
||||
Vocat 面向基於高通晶片、並暴露相容 AT、QMI、序列埠與 USB 網路介面的 Quectel 模組,包括:
|
||||
|
||||
- Quectel EC20
|
||||
- Quectel EC25
|
||||
- Quectel EG25 系列
|
||||
- 相容的 EG600 及相關模組
|
||||
|
||||
可用功能取決於模組韌體、USB 複合裝置配置、SIM/eSIM 能力、主機驅動、無線網路以及電信業者配置。
|
||||
|
||||
## 安裝
|
||||
|
||||
### Linux 一鍵安裝
|
||||
|
||||
已是 root(包括預設沒有 `sudo` 的 OpenWrt/Kwrt):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
一般 Linux 使用者且系統裝有 sudo:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | sudo bash
|
||||
```
|
||||
|
||||
只檢查 VoWiFi/XFRM 環境,不安裝 VoCat:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh | bash -s -- --check-env
|
||||
```
|
||||
|
||||
安裝指定版本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/VoCat/master/scripts/install.sh -o install.sh
|
||||
sudo bash install.sh 0.0.2
|
||||
```
|
||||
|
||||
VoWiFi IMS 必須使用 Linux XFRM/IPsec。OpenWrt/Kwrt 上安裝腳本會從目前韌體自己的軟體源嘗試安裝嚴格匹配的 `ip-full`、`kmod-ipsec`、`kmod-ipsec4/6`、`kmod-crypto-authenc`、AES-CBC 和 SHA1 元件。若軟體源沒有與目前核心匹配的模組,必須更換包含這些元件的韌體,禁止強裝其他核心版本的 kmod。
|
||||
|
||||
安裝程式會:
|
||||
|
||||
- 偵測 `amd64`、`386`、`arm64`、`aarch64` 或 `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 AArch64 | `vocat-linux-aarch64` |
|
||||
| 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
|
||||
read -rsp "管理員密碼: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | sudo /opt/vocat/bin/vocat bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
sudo env \
|
||||
VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db \
|
||||
/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
|
||||
|
||||
read -rsp "管理員密碼: " VOCAT_BOOTSTRAP_PASSWORD; echo
|
||||
printf '%s\n' "$VOCAT_BOOTSTRAP_PASSWORD" | docker run --rm -i \
|
||||
--user 0:0 \
|
||||
-v vocat-data:/opt/vocat/data \
|
||||
--entrypoint /opt/vocat/bin/vocat \
|
||||
ghcr.io/mengmengcode/vocat:latest bootstrap-admin
|
||||
unset VOCAT_BOOTSTRAP_PASSWORD
|
||||
|
||||
docker run -d \
|
||||
--name vocat \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
--privileged \
|
||||
--user 0:0 \
|
||||
-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_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 <裝置> <號碼> <內容>
|
||||
```
|
||||
|
||||
設定檔切換與簡訊提交使用一次性確認按鈕。機器人不暴露 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`、`aarch64` 與 `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) — 風格與功能指南
|
||||
|
||||
## 請我喝杯咖啡
|
||||
|
||||
| 網路 | 位址 |
|
||||
| ------- | ------- |
|
||||
| USDT-TRON (TRC20) | `TQQAbboBoU8h5xX4YCA1rqWJU2WjK3seSg` |
|
||||
| USDT-BSC (BEP20) | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
| USDT-Polygon | `0xdbfcd4a462550d6ff06d09cbd89026c6b145d9c4` |
|
||||
|
||||
## 授權條款
|
||||
|
||||
參見 [LICENSE](../LICENSE)。
|
||||
|
||||
[](https://meteor-history.com)
|
||||
@@ -3,19 +3,17 @@ module vocat
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0
|
||||
github.com/coder/websocket v1.8.15
|
||||
go.bug.st/serial v1.6.4
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.34.0
|
||||
golang.org/x/term v0.43.0
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/creack/goselect v0.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0=
|
||||
github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
|
||||
@@ -8,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -26,8 +22,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
@@ -37,8 +33,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -102,6 +102,22 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureAdminIfMissing initializes the administrator only for a new database.
|
||||
// Once an administrator exists, the database is the sole credential source;
|
||||
// process configuration must never overwrite a password changed through the UI
|
||||
// or CLI on a later restart.
|
||||
func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, password string) (bool, error) {
|
||||
if _, err := s.store.CurrentAdmin(ctx); err == nil {
|
||||
return false, nil
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return false, fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
|
||||
@@ -96,3 +96,24 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
|
||||
t.Fatalf("login with new password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
if err := service.ChangePassword(ctx, "admin", "correct-password", "changed-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.EnsureAdminIfMissing(ctx, "admin", "stale-config-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("existing administrator was reported as newly created")
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", "changed-password"); err != nil {
|
||||
t.Fatalf("database password was overwritten: %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", "stale-config-password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("stale configured password became active: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ const maxConfigBytes = 1 << 20
|
||||
type Config struct {
|
||||
Address string
|
||||
DatabasePath string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
SessionTTL time.Duration
|
||||
SecureCookies bool
|
||||
ShutdownTimeout time.Duration
|
||||
@@ -28,25 +26,25 @@ type Config struct {
|
||||
}
|
||||
|
||||
type fileConfig struct {
|
||||
Address *string `json:"address"`
|
||||
DatabasePath *string `json:"database_path"`
|
||||
AdminUsername *string `json:"admin_username"`
|
||||
AdminPassword *string `json:"admin_password"`
|
||||
Address *string `json:"address"`
|
||||
DatabasePath *string `json:"database_path"`
|
||||
// Retain the legacy keys only so upgrades do not reject an existing config
|
||||
// file. They are deliberately ignored: administrator credentials are read
|
||||
// exclusively from SQLite.
|
||||
LegacyAdminUsername *string `json:"admin_username"`
|
||||
LegacyAdminPassword *string `json:"admin_password"`
|
||||
SessionTTL *string `json:"session_ttl"`
|
||||
SecureCookies *bool `json:"secure_cookies"`
|
||||
ShutdownTimeout *string `json:"shutdown_timeout"`
|
||||
MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
|
||||
}
|
||||
|
||||
// Default returns a configuration suitable for a first local deployment.
|
||||
// Operators should replace the bootstrap password through
|
||||
// VOCAT_ADMIN_PASSWORD before exposing the service.
|
||||
// Default returns the non-secret process configuration. Administrator
|
||||
// credentials are initialized separately and stored only in SQLite.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Address: "0.0.0.0:7575",
|
||||
DatabasePath: "./data/vocat.db",
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "admin",
|
||||
SessionTTL: 24 * time.Hour,
|
||||
SecureCookies: false,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
@@ -116,12 +114,6 @@ func applyFile(cfg *Config, values fileConfig) error {
|
||||
if values.DatabasePath != nil {
|
||||
cfg.DatabasePath = *values.DatabasePath
|
||||
}
|
||||
if values.AdminUsername != nil {
|
||||
cfg.AdminUsername = *values.AdminUsername
|
||||
}
|
||||
if values.AdminPassword != nil {
|
||||
cfg.AdminPassword = *values.AdminPassword
|
||||
}
|
||||
if values.SessionTTL != nil {
|
||||
duration, err := time.ParseDuration(*values.SessionTTL)
|
||||
if err != nil {
|
||||
@@ -154,8 +146,6 @@ func applyEnvironment(cfg *Config) error {
|
||||
|
||||
applyString("VOCAT_ADDR", &cfg.Address)
|
||||
applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
|
||||
applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername)
|
||||
applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword)
|
||||
|
||||
if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
|
||||
duration, err := time.ParseDuration(value)
|
||||
@@ -203,16 +193,6 @@ func (cfg Config) Validate() error {
|
||||
if strings.TrimSpace(cfg.DatabasePath) == "" {
|
||||
return errors.New("database_path must not be empty")
|
||||
}
|
||||
username := strings.TrimSpace(cfg.AdminUsername)
|
||||
if username == "" || len(username) > 64 {
|
||||
return errors.New("admin_username must contain between 1 and 64 characters")
|
||||
}
|
||||
if strings.ContainsAny(username, "\r\n\t") {
|
||||
return errors.New("admin_username must not contain control whitespace")
|
||||
}
|
||||
if cfg.AdminPassword == "" {
|
||||
return errors.New("admin_password must not be empty")
|
||||
}
|
||||
if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
|
||||
return errors.New("session_ttl must be between 5m and 720h")
|
||||
}
|
||||
@@ -224,9 +204,3 @@ func (cfg Config) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsesDefaultCredentials reports whether the documented bootstrap credentials
|
||||
// are still active.
|
||||
func (cfg Config) UsesDefaultCredentials() bool {
|
||||
return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ var configEnvironment = []string{
|
||||
"VOCAT_DATABASE_PATH",
|
||||
"VOCAT_ADMIN_USERNAME",
|
||||
"VOCAT_ADMIN_PASSWORD",
|
||||
"VOCAT_ADMIN_PASSWORD_B64",
|
||||
"VOCAT_SESSION_TTL",
|
||||
"VOCAT_SECURE_COOKIES",
|
||||
"VOCAT_SHUTDOWN_TIMEOUT",
|
||||
@@ -39,9 +40,6 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Address != "0.0.0.0:7575" {
|
||||
t.Fatalf("Address = %q", cfg.Address)
|
||||
}
|
||||
if !cfg.UsesDefaultCredentials() {
|
||||
t.Fatal("expected bootstrap credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
@@ -50,8 +48,6 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"address": "127.0.0.1:8000",
|
||||
"database_path": "/tmp/from-file.db",
|
||||
"admin_username": "operator",
|
||||
"admin_password": "from-file",
|
||||
"session_ttl": "2h",
|
||||
"secure_cookies": false,
|
||||
"shutdown_timeout": "12s",
|
||||
@@ -72,7 +68,7 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
if cfg.Address != "0.0.0.0:9000" || !cfg.SecureCookies {
|
||||
t.Fatalf("environment override not applied: %+v", cfg)
|
||||
}
|
||||
if cfg.AdminUsername != "operator" || cfg.SessionTTL != 2*time.Hour {
|
||||
if cfg.SessionTTL != 2*time.Hour {
|
||||
t.Fatalf("file values not applied: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +86,24 @@ func TestLoadRejectsUnknownJSONField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresLegacyAdministratorConfiguration(t *testing.T) {
|
||||
clearConfigEnvironment(t)
|
||||
path := filepath.Join(t.TempDir(), "vocat.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"admin_username": "legacy-admin",
|
||||
"admin_password": "legacy-password"
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VOCAT_CONFIG", path)
|
||||
t.Setenv("VOCAT_ADMIN_USERNAME", "environment-admin")
|
||||
t.Setenv("VOCAT_ADMIN_PASSWORD", "environment-password")
|
||||
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load() rejected ignored legacy credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidEnvironment(t *testing.T) {
|
||||
clearConfigEnvironment(t)
|
||||
t.Setenv("VOCAT_SESSION_TTL", "tomorrow")
|
||||
|
||||
@@ -27,9 +27,9 @@ const (
|
||||
DeviceLimitSettingKey = "developer.device_limit"
|
||||
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
||||
DefaultDeviceLimit = 5
|
||||
MaxDeviceLimit = 128
|
||||
MaxDeviceLimit = 10
|
||||
DefaultSMSHourlyLimit = 10
|
||||
MaxSMSHourlyLimit = 1000
|
||||
MaxSMSHourlyLimit = 20
|
||||
)
|
||||
|
||||
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
@@ -43,9 +43,12 @@ func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxDeviceLimit {
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
|
||||
return DefaultDeviceLimit
|
||||
}
|
||||
if document.Limit > MaxDeviceLimit {
|
||||
return MaxDeviceLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
@@ -70,9 +73,12 @@ func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
|
||||
var document struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit {
|
||||
if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 {
|
||||
return DefaultSMSHourlyLimit
|
||||
}
|
||||
if document.Limit > MaxSMSHourlyLimit {
|
||||
return MaxSMSHourlyLimit
|
||||
}
|
||||
return document.Limit
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := SetDeviceLimit(ctx, database, 24); err != nil {
|
||||
if err := SetDeviceLimit(ctx, database, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
|
||||
if err := SetSMSHourlyLimit(ctx, database, 18); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
||||
@@ -92,10 +92,34 @@ func TestSetSMSHourlyLimitValidatesRange(t *testing.T) {
|
||||
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
|
||||
t.Fatal("out-of-range SMS hourly limit was accepted")
|
||||
}
|
||||
if err := SetSMSHourlyLimit(ctx, database, 25); err != nil {
|
||||
if err := SetSMSHourlyLimit(ctx, database, 15); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
if got := SMSHourlyLimit(ctx, database); got != 15 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredLimitsAboveHardMaximumAreClamped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
for key, limit := range map[string]int{
|
||||
DeviceLimitSettingKey: 99,
|
||||
SMSHourlyLimitKey: 99,
|
||||
} {
|
||||
value, _ := json.Marshal(map[string]int{"limit": limit})
|
||||
if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: key, Value: value}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := DeviceLimit(ctx, database, true); got != MaxDeviceLimit {
|
||||
t.Fatalf("device limit = %d, want %d", got, MaxDeviceLimit)
|
||||
}
|
||||
if got := SMSHourlyLimit(ctx, database); got != MaxSMSHourlyLimit {
|
||||
t.Fatalf("SMS hourly limit = %d, want %d", got, MaxSMSHourlyLimit)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-9
@@ -246,6 +246,15 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return channel, nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == 0 && errors.Is(err, errNoLogicalChannel) &&
|
||||
manager.releaseStaleEuiccChannel(ctx, id) {
|
||||
// EC20 firmware exposes only one MANAGE CHANNEL slot. A canceled or
|
||||
// interrupted APDU transaction can leave channel 1 allocated, after
|
||||
// which every eSIM page load returns 6A81 until reboot. Closing the
|
||||
// orphan while holding the shared UICC transaction lock makes the
|
||||
// operation self-healing without disturbing an active AKA exchange.
|
||||
continue
|
||||
}
|
||||
if !isTransientEuiccCME(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -262,6 +271,11 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (manager *Manager) releaseStaleEuiccChannel(ctx context.Context, id string) bool {
|
||||
_, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x80, 0x01, 0x00})
|
||||
return err == nil && sw == 0x9000
|
||||
}
|
||||
|
||||
func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) {
|
||||
return manager.openEuiccOnceAID(ctx, id, isdRAID)
|
||||
}
|
||||
@@ -601,8 +615,8 @@ func validProfileICCID(iccid string) bool {
|
||||
|
||||
// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo.
|
||||
func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
if cached, ok := manager.cachedESIMInfo(id); ok {
|
||||
return cached, nil
|
||||
@@ -642,14 +656,14 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
manager.lockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex))
|
||||
if err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -676,7 +690,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// detached reset is safe in either case and prevents an uncertain switch
|
||||
// from leaving the modem's SIM cache unusable.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
// A transport SW 9000 only means the APDU reached the eUICC. The real outcome
|
||||
@@ -685,11 +699,11 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
result, ok := enableProfileResult(payload)
|
||||
if !ok {
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload)))
|
||||
}
|
||||
if err := enableProfileResponseError(byte(result), payload); err != nil {
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
return err
|
||||
}
|
||||
manager.markCachedProfileEnabled(id, iccid)
|
||||
@@ -697,7 +711,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
|
||||
// a detached recovery so it survives an HTTP disconnect, but keep this API
|
||||
// call pending until the live modem ICCID proves that the switch took effect.
|
||||
manager.startProfileSwitchRecovery(id)
|
||||
manager.esimMu.Unlock()
|
||||
manager.unlockESIM()
|
||||
|
||||
verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager))
|
||||
defer cancelVerify()
|
||||
|
||||
@@ -69,8 +69,8 @@ func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHe
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, para
|
||||
}
|
||||
}
|
||||
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
report("preflight", "正在检查 eUICC 剩余空间...", 10)
|
||||
channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex))
|
||||
@@ -228,8 +228,8 @@ type EsimChipInfo struct {
|
||||
// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for
|
||||
// the chip header. It takes the eSIM lock like the other card ops.
|
||||
func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
|
||||
var lastErr error
|
||||
for _, aid := range manager.discoverEuiccAIDs(ctx, id) {
|
||||
@@ -286,8 +286,8 @@ func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string)
|
||||
// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo,
|
||||
// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued.
|
||||
func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if manager.esimRecoveryActive(id) {
|
||||
return nil, errESIMRecovering
|
||||
}
|
||||
|
||||
@@ -295,8 +295,8 @@ func (channel *euiccChannel) deliverPendingNotifications(ctx context.Context) er
|
||||
// ESIMNotifications returns the notifications retained across every eUICC
|
||||
// storage exposed by the physical card.
|
||||
func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]EsimNotification, error) {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -331,8 +331,8 @@ func (manager *Manager) ESIMNotifications(ctx context.Context, id string) ([]Esi
|
||||
// ESIMRetryNotification sends one retained notification and removes it from the
|
||||
// eUICC only after the receiver returns the SGP.22 success acknowledgement.
|
||||
func (manager *Manager) ESIMRetryNotification(ctx context.Context, id, aidHex string, sequenceNumber uint64) error {
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickna
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manager.esimMu.Lock()
|
||||
defer manager.esimMu.Unlock()
|
||||
manager.lockESIM()
|
||||
defer manager.unlockESIM()
|
||||
if err := manager.waitForESIMRecovery(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -294,6 +294,46 @@ func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEuiccRecoversOrphanedSingleLogicalChannel(t *testing.T) {
|
||||
client := &transcriptClient{steps: []clientStep{
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"006A81"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070000001"`,
|
||||
response: okResponse(`+CSIM: 6,"019000"`),
|
||||
},
|
||||
{
|
||||
command: fmt.Sprintf(
|
||||
`AT+CSIM=42,"01A4040010%s"`,
|
||||
isdRAID,
|
||||
),
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
{
|
||||
command: `AT+CSIM=10,"0070800100"`,
|
||||
response: okResponse(`+CSIM: 4,"9000"`),
|
||||
},
|
||||
}}
|
||||
manager, id := newStartedTestManager(t, client)
|
||||
|
||||
manager.lockESIM()
|
||||
channel, err := manager.openEuiccAID(context.Background(), id, isdRAID)
|
||||
if err == nil {
|
||||
channel.close(context.Background())
|
||||
}
|
||||
manager.unlockESIM()
|
||||
if err != nil {
|
||||
t.Fatalf("open eUICC after orphaned channel: %v", err)
|
||||
}
|
||||
client.assertDone(t)
|
||||
}
|
||||
|
||||
func TestWaitForESIMRecovery(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Options struct {
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
uiccMu sync.Mutex // serializes all multi-command UICC/APDU transactions
|
||||
esimMu sync.Mutex // serializes eSIM card access (list/switch/download)
|
||||
esimRecoveryMu sync.Mutex
|
||||
esimRecoveries map[string]chan struct{}
|
||||
@@ -42,6 +43,23 @@ type Manager struct {
|
||||
ussdSessions map[string]ussdSession
|
||||
}
|
||||
|
||||
// LockUICC and UnlockUICC allow another in-process UICC client (currently the
|
||||
// VoWiFi AKA adapter) to share the same transaction boundary as eSIM ES10.
|
||||
// Individual AT commands are already serialized per modem, but a logical-
|
||||
// channel transaction spans several commands and must not be interleaved.
|
||||
func (manager *Manager) LockUICC() { manager.uiccMu.Lock() }
|
||||
func (manager *Manager) UnlockUICC() { manager.uiccMu.Unlock() }
|
||||
|
||||
func (manager *Manager) lockESIM() {
|
||||
manager.esimMu.Lock()
|
||||
manager.uiccMu.Lock()
|
||||
}
|
||||
|
||||
func (manager *Manager) unlockESIM() {
|
||||
manager.uiccMu.Unlock()
|
||||
manager.esimMu.Unlock()
|
||||
}
|
||||
|
||||
// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or
|
||||
// Cancel can be routed back to the right modem. The modem owns the actual
|
||||
// network session; this map only records which device a session id belongs to.
|
||||
@@ -163,6 +181,7 @@ func (manager *Manager) Discover(ctx context.Context) ([]Device, error) {
|
||||
ReaderName: reader.Name, USBPath: reader.USBPath,
|
||||
VendorID: reader.VendorID, ProductID: reader.ProductID,
|
||||
Manufacturer: reader.Manufacturer, Product: reader.Product,
|
||||
DiscoveryIssue: reader.DiscoveryIssue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+169
-2
@@ -38,9 +38,10 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
entries, err := os.ReadDir(usbRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
entries = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
}
|
||||
|
||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||
@@ -131,10 +132,176 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
result = append(result, state.candidate)
|
||||
}
|
||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, wwanCandidates...)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type discoveredWWANDevice struct {
|
||||
index string
|
||||
ports []Port
|
||||
qmiNames []string
|
||||
sysPath string
|
||||
}
|
||||
|
||||
// discoverWWAN covers PCIe/MHI modems exposed through Linux's wwan subsystem,
|
||||
// for example /dev/wwan0at0 and /dev/wwan0qmi0. These devices do not appear on
|
||||
// the USB bus and therefore need a separate discovery path.
|
||||
func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error) {
|
||||
classRoot := filepath.Join(d.SysRoot, "class", "wwan")
|
||||
classEntries, err := os.ReadDir(classRoot)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("discover PCIe/MHI WWAN devices: %w", err)
|
||||
}
|
||||
classEntries = nil
|
||||
}
|
||||
|
||||
// Normal kernels expose these ports in /sys/class/wwan. Also inspect /dev
|
||||
// because some downstream MHI packages create the character devices but do
|
||||
// not populate the class directory in the host namespace/container.
|
||||
portNames := make(map[string]struct{})
|
||||
for _, entry := range classEntries {
|
||||
portNames[entry.Name()] = struct{}{}
|
||||
}
|
||||
if devEntries, devErr := os.ReadDir(d.DevRoot); devErr == nil {
|
||||
for _, entry := range devEntries {
|
||||
if _, _, _, ok := parseWWANPortName(entry.Name()); ok {
|
||||
portNames[entry.Name()] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(devErr) {
|
||||
return nil, fmt.Errorf("inspect WWAN device nodes: %w", devErr)
|
||||
}
|
||||
names := make([]string, 0, len(portNames))
|
||||
for name := range portNames {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
groups := make(map[string]*discoveredWWANDevice)
|
||||
for _, name := range names {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, kind, portIndex, ok := parseWWANPortName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
group := groups[index]
|
||||
if group == nil {
|
||||
group = &discoveredWWANDevice{index: index}
|
||||
groups[index] = group
|
||||
}
|
||||
classPath := filepath.Join(classRoot, name)
|
||||
if resolved, resolveErr := filepath.EvalSymlinks(classPath); resolveErr == nil {
|
||||
group.sysPath = filepath.Dir(resolved)
|
||||
}
|
||||
switch kind {
|
||||
case "at":
|
||||
group.ports = append(group.ports, Port{
|
||||
Path: filepath.Join(d.DevRoot, name), Name: name,
|
||||
InterfaceNumber: portIndex, Role: PortRoleAT,
|
||||
})
|
||||
case "qmi":
|
||||
group.qmiNames = append(group.qmiNames, name)
|
||||
}
|
||||
}
|
||||
result := make([]Candidate, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
sort.Slice(group.ports, func(i, j int) bool {
|
||||
return group.ports[i].InterfaceNumber < group.ports[j].InterfaceNumber
|
||||
})
|
||||
sort.Strings(group.qmiNames)
|
||||
if len(group.ports) == 0 && len(group.qmiNames) == 0 {
|
||||
continue
|
||||
}
|
||||
if group.sysPath == "" {
|
||||
group.sysPath = filepath.Join(classRoot, "wwan"+group.index)
|
||||
}
|
||||
vendorID, productID := readPCIIdentity(group.sysPath, d.SysRoot)
|
||||
manufacturer := ""
|
||||
if vendorID == "17cb" {
|
||||
manufacturer = "Qualcomm"
|
||||
}
|
||||
candidate := Candidate{
|
||||
HardwareKind: "wwan", ID: "mhi-wwan" + group.index,
|
||||
VendorID: vendorID, ProductID: productID, Manufacturer: manufacturer,
|
||||
Product: "PCIe/MHI WWAN modem", USBPath: group.sysPath,
|
||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||
}
|
||||
if len(group.ports) > 0 {
|
||||
candidate.ATPort = group.ports[0]
|
||||
}
|
||||
if len(group.qmiNames) > 0 {
|
||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||
}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||
if !strings.HasPrefix(name, "wwan") {
|
||||
return "", "", 0, false
|
||||
}
|
||||
rest := strings.TrimPrefix(name, "wwan")
|
||||
cut := 0
|
||||
for cut < len(rest) && rest[cut] >= '0' && rest[cut] <= '9' {
|
||||
cut++
|
||||
}
|
||||
if cut == 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
index, rest = rest[:cut], rest[cut:]
|
||||
for _, candidateKind := range []string{"at", "qmi"} {
|
||||
if !strings.HasPrefix(rest, candidateKind) {
|
||||
continue
|
||||
}
|
||||
numberText := strings.TrimPrefix(rest, candidateKind)
|
||||
number, err := strconv.Atoi(numberText)
|
||||
if err != nil || number < 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
return index, candidateKind, number, true
|
||||
}
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
func selectWWANNetworkInterface(sysRoot, index string) string {
|
||||
exact := "wwan" + index
|
||||
if _, err := os.Stat(filepath.Join(sysRoot, "class", "net", exact)); err == nil {
|
||||
return exact
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(sysRoot, "class", "net"))
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), exact) {
|
||||
return entry.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readPCIIdentity(path, sysRoot string) (vendorID, productID string) {
|
||||
root := filepath.Clean(sysRoot)
|
||||
for current := filepath.Clean(path); current != "." && current != string(filepath.Separator); current = filepath.Dir(current) {
|
||||
vendor := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "vendor"))), "0x")
|
||||
device := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "device"))), "0x")
|
||||
if vendor != "" && device != "" {
|
||||
return vendor, device
|
||||
}
|
||||
if current == root {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func parseUSBInterfaceName(name string) (int, bool) {
|
||||
_, suffix, ok := strings.Cut(name, ":")
|
||||
if !ok {
|
||||
|
||||
@@ -235,6 +235,80 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
wwanRoot := filepath.Join(sysRoot, "class", "wwan")
|
||||
for _, name := range []string{"wwan0at1", "wwan0qmi0", "wwan0at0"} {
|
||||
mustMkdir(t, filepath.Join(wwanRoot, name))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(sysRoot, "class", "net", "wwan0"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %#v, want one MHI device", candidates)
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||
t.Fatalf("identity = %#v", candidate)
|
||||
}
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if candidate.NetworkInterface != "wwan0" {
|
||||
t.Fatalf("network interface = %q", candidate.NetworkInterface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
for _, name := range []string{"wwan2at0", "wwan2qmi0"} {
|
||||
mustWrite(t, filepath.Join(devRoot, name), "")
|
||||
}
|
||||
mustMkdir(t, filepath.Join(sysRoot, "class", "net", "wwan2"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %#v, want one WWAN device", candidates)
|
||||
}
|
||||
if candidates[0].ATPort.Path != filepath.Join(devRoot, "wwan2at0") ||
|
||||
candidates[0].QMIControl != filepath.Join(devRoot, "wwan2qmi0") ||
|
||||
candidates[0].NetworkInterface != "wwan2" {
|
||||
t.Fatalf("candidate = %#v", candidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWWANPortName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, index, kind string
|
||||
port int
|
||||
ok bool
|
||||
}{
|
||||
{"wwan0at0", "0", "at", 0, true},
|
||||
{"wwan12qmi3", "12", "qmi", 3, true},
|
||||
{"wwan0", "", "", 0, false},
|
||||
{"wwanXat0", "", "", 0, false},
|
||||
{"cdc-wdm0", "", "", 0, false},
|
||||
} {
|
||||
index, kind, port, ok := parseWWANPortName(test.name)
|
||||
if index != test.index || kind != test.kind || port != test.port || ok != test.ok {
|
||||
t.Fatalf("parseWWANPortName(%q) = %q, %q, %d, %v", test.name, index, kind, port, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Dir(path))
|
||||
|
||||
@@ -57,6 +57,7 @@ type Candidate struct {
|
||||
Ports []Port `json:"ports"`
|
||||
QMIControl string `json:"qmiControl,omitempty"`
|
||||
NetworkInterface string `json:"networkInterface,omitempty"`
|
||||
DiscoveryIssue string `json:"discoveryIssue,omitempty"`
|
||||
}
|
||||
|
||||
func (c Candidate) HasATPort() bool {
|
||||
|
||||
+98
-121
@@ -5,89 +5,94 @@ package pcsc
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ElMostafaIdrassi/goscard"
|
||||
"time"
|
||||
)
|
||||
|
||||
type nativeBackend struct {
|
||||
initializeOnce sync.Once
|
||||
initializeErr error
|
||||
}
|
||||
type nativeBackend struct{ sysRoot string }
|
||||
|
||||
func newNativeBackend() Backend { return &nativeBackend{} }
|
||||
func newNativeBackend() Backend { return &nativeBackend{sysRoot: "/sys"} }
|
||||
|
||||
func (backend *nativeBackend) initialize() error {
|
||||
backend.initializeOnce.Do(func() {
|
||||
if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil {
|
||||
backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable)
|
||||
func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
||||
paths := []string{strings.TrimSpace(os.Getenv("PCSCLITE_CSOCK_NAME")), "/run/pcscd/pcscd.comm", "/var/run/pcscd/pcscd.comm"}
|
||||
var failures []error
|
||||
seen := make(map[string]bool)
|
||||
for _, path := range paths {
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
})
|
||||
return backend.initializeErr
|
||||
seen[path] = true
|
||||
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", path)
|
||||
if err != nil {
|
||||
failures = append(failures, err)
|
||||
continue
|
||||
}
|
||||
client, err := establishPCSCD(ctx, conn)
|
||||
if err == nil {
|
||||
return client, nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
failures = append(failures, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
if len(physical) > 0 {
|
||||
for index := range physical {
|
||||
physical[index].DiscoveryIssue = "pcsc_service_unavailable"
|
||||
}
|
||||
return physical, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := backend.initialize(); err != nil {
|
||||
defer client.closeContext(context.Background())
|
||||
states, err := client.readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable)
|
||||
}
|
||||
defer cardContext.Release()
|
||||
names, _, err := cardContext.ListReaders(nil)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "no readers") {
|
||||
return []Reader{}, nil
|
||||
readers := make([]Reader, 0, len(states))
|
||||
for _, state := range states {
|
||||
reader := Reader{
|
||||
Name: state.name,
|
||||
CardPresent: state.state&pcscCardPresent != 0,
|
||||
ATR: strings.ToUpper(hex.EncodeToString(state.atr)),
|
||||
}
|
||||
return nil, fmt.Errorf("pcsc: list readers: %w", err)
|
||||
}
|
||||
presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil)
|
||||
present := make(map[string]string, len(presentNames))
|
||||
for index, name := range presentNames {
|
||||
atr := ""
|
||||
if index < len(atrs) {
|
||||
atr = atrs[index]
|
||||
}
|
||||
present[name] = atr
|
||||
}
|
||||
readers := make([]Reader, 0, len(names))
|
||||
for _, name := range names {
|
||||
reader := Reader{Name: name}
|
||||
reader.ATR, reader.CardPresent = present[name]
|
||||
if path, ok := backend.readerUSBPath(cardContext, name); ok {
|
||||
if path, ok := backend.readerUSBPath(ctx, client, state.name); ok {
|
||||
reader.USBPath = path
|
||||
reader.VendorID = readSysfsText(path, "idVendor")
|
||||
reader.ProductID = readSysfsText(path, "idProduct")
|
||||
reader.Manufacturer = readSysfsText(path, "manufacturer")
|
||||
reader.Product = readSysfsText(path, "product")
|
||||
reader.VendorID = backend.readSysfsText(path, "idVendor")
|
||||
reader.ProductID = backend.readSysfsText(path, "idProduct")
|
||||
reader.Manufacturer = backend.readSysfsText(path, "manufacturer")
|
||||
reader.Product = backend.readSysfsText(path, "product")
|
||||
} else {
|
||||
reader.USBPath = "pcsc:" + name
|
||||
reader.USBPath = "pcsc:" + state.name
|
||||
}
|
||||
if reader.Product == "" {
|
||||
reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00"))
|
||||
reader.Product = strings.TrimSpace(strings.TrimSuffix(state.name, " 00 00"))
|
||||
}
|
||||
readers = append(readers, reader)
|
||||
}
|
||||
return readers, nil
|
||||
return mergePCSCAndUSBReaders(readers, physical), nil
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) {
|
||||
card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
|
||||
func (backend *nativeBackend) readerUSBPath(ctx context.Context, client *pcscdClient, name string) (string, bool) {
|
||||
card, _, err := client.connect(ctx, name, pcscShareDirect, 0)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer card.Disconnect(goscard.SCardLeaveCard)
|
||||
attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID)
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
defer client.simpleCardCommand(context.Background(), pcscCmdDisconnect, card, &disposition)
|
||||
attribute, err := client.getAttrib(ctx, card, pcscAttrChannelID)
|
||||
if err != nil || len(attribute) < 4 {
|
||||
return "", false
|
||||
}
|
||||
@@ -95,8 +100,9 @@ func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name st
|
||||
if channel>>16 != 0x0020 {
|
||||
return "", false
|
||||
}
|
||||
bus, device := int((channel>>8)&0xFF), int(channel&0xFF)
|
||||
entries, err := os.ReadDir("/sys/bus/usb/devices")
|
||||
bus, device := int((channel>>8)&0xff), int(channel&0xff)
|
||||
usbRoot := filepath.Join(backend.sysRoot, "bus", "usb", "devices")
|
||||
entries, err := os.ReadDir(usbRoot)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -104,7 +110,7 @@ func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name st
|
||||
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
|
||||
path := filepath.Join(usbRoot, entry.Name())
|
||||
entryBus, busErr := readSysfsInt(path, "busnum")
|
||||
entryDevice, deviceErr := readSysfsInt(path, "devnum")
|
||||
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
|
||||
@@ -115,9 +121,6 @@ func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name st
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readers, err := backend.Readers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -126,66 +129,55 @@ func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card
|
||||
if !ok {
|
||||
return nil, ErrReaderNotFound
|
||||
}
|
||||
if reader.DiscoveryIssue != "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnavailable, reader.DiscoveryIssue)
|
||||
}
|
||||
if !reader.CardPresent {
|
||||
return nil, ErrNoCard
|
||||
}
|
||||
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: create context", ErrUnavailable)
|
||||
return nil, err
|
||||
}
|
||||
card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
|
||||
handle, protocol, err := client.connect(ctx, reader.Name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
cardContext.Release()
|
||||
return nil, fmt.Errorf("pcsc: connect reader: %w", err)
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if _, err := card.BeginTransaction(); err != nil {
|
||||
card.Disconnect(goscard.SCardLeaveCard)
|
||||
cardContext.Release()
|
||||
if err := client.simpleCardCommand(ctx, pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
_ = client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition)
|
||||
_ = client.closeContext(context.Background())
|
||||
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
|
||||
}
|
||||
return &nativeCard{context: &cardContext, card: &card}, nil
|
||||
return &nativeCard{client: client, handle: handle, protocol: protocol}, nil
|
||||
}
|
||||
|
||||
type nativeCard struct {
|
||||
context *goscard.Context
|
||||
card *goscard.Card
|
||||
closed bool
|
||||
client *pcscdClient
|
||||
handle int32
|
||||
protocol uint32
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.card == nil || card.closed {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return card.transmit(ctx, append([]byte(nil), command...), 0)
|
||||
}
|
||||
|
||||
// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need
|
||||
// to observe 61xx themselves because GET RESPONSE must target their logical
|
||||
// channel rather than the basic channel.
|
||||
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
|
||||
if card == nil || card.card == nil || card.closed {
|
||||
if card == nil || card.client == nil || card.closed {
|
||||
return nil, 0, errors.New("pcsc: card session is closed")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pci := goscard.SCardIoRequestT0
|
||||
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
|
||||
pci = goscard.SCardIoRequestT1
|
||||
}
|
||||
response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil)
|
||||
response, err := card.client.transmit(ctx, card.handle, card.protocol, append([]byte(nil), command...))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
last := len(response) - 2
|
||||
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
|
||||
}
|
||||
@@ -194,69 +186,54 @@ func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int)
|
||||
if depth > 8 {
|
||||
return nil, 0, errors.New("pcsc: too many APDU continuations")
|
||||
}
|
||||
pci := goscard.SCardIoRequestT0
|
||||
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
|
||||
pci = goscard.SCardIoRequestT1
|
||||
}
|
||||
response, _, err := card.card.Transmit(&pci, command, nil)
|
||||
data, status, err := card.TransmitRaw(ctx, command)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(response) < 2 {
|
||||
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
|
||||
}
|
||||
data := append([]byte(nil), response[:len(response)-2]...)
|
||||
sw1, sw2 := response[len(response)-2], response[len(response)-1]
|
||||
if sw1 == 0x6C && len(command) >= 5 {
|
||||
sw1, sw2 := byte(status>>8), byte(status)
|
||||
if sw1 == 0x6c && len(command) >= 5 {
|
||||
retry := append([]byte(nil), command...)
|
||||
retry[len(retry)-1] = sw2
|
||||
return card.transmit(ctx, retry, depth+1)
|
||||
}
|
||||
if sw1 == 0x61 || sw1 == 0x9F {
|
||||
more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1)
|
||||
if sw1 == 0x61 || sw1 == 0x9f {
|
||||
more, sw, err := card.transmit(ctx, []byte{0x00, 0xc0, 0x00, 0x00, sw2}, depth+1)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return append(data, more...), sw, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return data, uint16(sw1)<<8 | uint16(sw2), nil
|
||||
return data, status, ctx.Err()
|
||||
}
|
||||
|
||||
func (card *nativeCard) Close() error {
|
||||
return card.close(goscard.SCardLeaveCard)
|
||||
}
|
||||
func (card *nativeCard) Close() error { return card.close(pcscLeaveCard) }
|
||||
|
||||
func (card *nativeCard) CloseWithReset() error {
|
||||
return card.close(goscard.SCardResetCard)
|
||||
}
|
||||
func (card *nativeCard) CloseWithReset() error { return card.close(pcscResetCard) }
|
||||
|
||||
func (card *nativeCard) close(disposition goscard.SCardDisposition) error {
|
||||
func (card *nativeCard) close(disposition uint32) error {
|
||||
if card == nil || card.closed {
|
||||
return nil
|
||||
}
|
||||
card.closed = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result []error
|
||||
if card.card != nil {
|
||||
if _, err := card.card.EndTransaction(disposition); err != nil {
|
||||
if card.client != nil {
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdEndTransaction, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
if _, err := card.card.Disconnect(disposition); err != nil {
|
||||
if err := card.client.simpleCardCommand(ctx, pcscCmdDisconnect, card.handle, &disposition); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
if card.context != nil {
|
||||
if _, err := card.context.Release(); err != nil {
|
||||
if err := card.client.closeContext(ctx); err != nil {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(result...)
|
||||
}
|
||||
|
||||
func readSysfsText(usbPath, name string) string {
|
||||
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
|
||||
func (backend *nativeBackend) readSysfsText(usbPath, name string) string {
|
||||
value, err := os.ReadFile(filepath.Join(backend.sysRoot, "bus", "usb", "devices", usbPath, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pcsc-lite exposes a small, versioned protocol over its local Unix socket.
|
||||
// Speaking that protocol directly keeps VoCat's Linux binaries fully static;
|
||||
// loading libpcsclite through dlopen would pull a glibc interpreter into an
|
||||
// otherwise CGO-free build and make it unusable on musl-based routers.
|
||||
const (
|
||||
pcscProtocolMajor = 4
|
||||
pcscProtocolCurrentMinor = 6
|
||||
pcscProtocolOldestMinor = 4
|
||||
|
||||
pcscCmdEstablishContext = 0x01
|
||||
pcscCmdReleaseContext = 0x02
|
||||
pcscCmdConnect = 0x04
|
||||
pcscCmdDisconnect = 0x06
|
||||
pcscCmdBeginTransaction = 0x07
|
||||
pcscCmdEndTransaction = 0x08
|
||||
pcscCmdTransmit = 0x09
|
||||
pcscCmdGetAttrib = 0x0f
|
||||
pcscCmdVersion = 0x11
|
||||
pcscCmdGetReadersState = 0x12
|
||||
|
||||
pcscScopeSystem = 0x0002
|
||||
pcscProtocolT0 = 0x0001
|
||||
pcscProtocolT1 = 0x0002
|
||||
pcscProtocolAny = pcscProtocolT0 | pcscProtocolT1
|
||||
pcscShareShared = 0x0002
|
||||
pcscShareDirect = 0x0003
|
||||
pcscLeaveCard = 0x0000
|
||||
pcscResetCard = 0x0001
|
||||
pcscCardPresent = 0x0004
|
||||
pcscAttrChannelID = 0x00020110
|
||||
pcscMaxReaderName = 128
|
||||
pcscMaxATR = 33
|
||||
pcscMaxReaders = 16
|
||||
pcscReaderStateSize = 184
|
||||
pcscGetSetBodySize = 280
|
||||
pcscMaxAttribute = 264
|
||||
pcscMaxAPDUResponse = 65548
|
||||
pcscDefaultIOTimeout = 30 * time.Second
|
||||
pcscSuccess = uint32(0)
|
||||
pcscNoSmartcard = uint32(0x8010000c)
|
||||
pcscNoService = uint32(0x8010001d)
|
||||
pcscServiceStopped = uint32(0x8010001e)
|
||||
pcscNoReaders = uint32(0x8010002e)
|
||||
)
|
||||
|
||||
type pcscdClient struct {
|
||||
conn net.Conn
|
||||
contextID uint32
|
||||
serverMinor int32
|
||||
}
|
||||
|
||||
type pcscdReaderState struct {
|
||||
name string
|
||||
state uint32
|
||||
atr []byte
|
||||
protocol uint32
|
||||
}
|
||||
|
||||
func establishPCSCD(ctx context.Context, conn net.Conn) (*pcscdClient, error) {
|
||||
client := &pcscdClient{conn: conn}
|
||||
version := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], pcscProtocolCurrentMinor)
|
||||
for {
|
||||
if err := client.exchange(ctx, pcscCmdVersion, version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
major := int32(binary.LittleEndian.Uint32(version[0:4]))
|
||||
client.serverMinor = int32(binary.LittleEndian.Uint32(version[4:8]))
|
||||
rv := binary.LittleEndian.Uint32(version[8:12])
|
||||
if rv == pcscSuccess {
|
||||
break
|
||||
}
|
||||
if rv != pcscServiceStopped || major != pcscProtocolMajor || client.serverMinor < pcscProtocolOldestMinor || client.serverMinor >= pcscProtocolCurrentMinor {
|
||||
return nil, pcscError("negotiate protocol", rv)
|
||||
}
|
||||
// pcsc-lite answers a newer client's first probe with its own
|
||||
// compatible minor version. Retry on the same connection with that
|
||||
// value, matching libpcsclite's official fallback behavior.
|
||||
binary.LittleEndian.PutUint32(version[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(version[4:8], uint32(client.serverMinor))
|
||||
binary.LittleEndian.PutUint32(version[8:12], pcscSuccess)
|
||||
}
|
||||
if client.serverMinor < pcscProtocolOldestMinor {
|
||||
return nil, fmt.Errorf("pcsc: unsupported pcscd protocol %d.%d", pcscProtocolMajor, client.serverMinor)
|
||||
}
|
||||
body := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscScopeSystem)
|
||||
if err := client.exchange(ctx, pcscCmdEstablishContext, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[8:12]); rv != pcscSuccess {
|
||||
return nil, pcscError("establish context", rv)
|
||||
}
|
||||
client.contextID = binary.LittleEndian.Uint32(body[4:8])
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) exchange(ctx context.Context, command uint32, body []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return fmt.Errorf("pcsc: send command %02x: %w", command, err)
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: send command body %02x: %w", command, err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return fmt.Errorf("pcsc: receive command %02x: %w", command, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) send(ctx context.Context, command uint32, body, extra []byte) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return errors.New("pcsc: pcscd connection is closed")
|
||||
}
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[0:4], uint32(len(body)))
|
||||
binary.LittleEndian.PutUint32(header[4:8], command)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(client.conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(client.conn, extra)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) setDeadline(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(pcscDefaultIOTimeout)
|
||||
if value, ok := ctx.Deadline(); ok && value.Before(deadline) {
|
||||
deadline = value
|
||||
}
|
||||
return client.conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
func (client *pcscdClient) readers(ctx context.Context) ([]pcscdReaderState, error) {
|
||||
if err := client.setDeadline(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(header[4:8], pcscCmdGetReadersState)
|
||||
if err := writeAll(client.conn, header); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: request reader states: %w", err)
|
||||
}
|
||||
raw := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
if _, err := io.ReadFull(client.conn, raw); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: read reader states: %w", err)
|
||||
}
|
||||
result := make([]pcscdReaderState, 0, pcscMaxReaders)
|
||||
for offset := 0; offset < len(raw); offset += pcscReaderStateSize {
|
||||
state := raw[offset : offset+pcscReaderStateSize]
|
||||
name := cString(state[:pcscMaxReaderName])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
atrLen := int(binary.LittleEndian.Uint32(state[176:180]))
|
||||
if atrLen < 0 || atrLen > pcscMaxATR {
|
||||
atrLen = 0
|
||||
}
|
||||
result = append(result, pcscdReaderState{
|
||||
name: name,
|
||||
state: binary.LittleEndian.Uint32(state[132:136]),
|
||||
atr: append([]byte(nil), state[140:140+atrLen]...),
|
||||
protocol: binary.LittleEndian.Uint32(state[180:184]),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) connect(ctx context.Context, reader string, share, protocols uint32) (int32, uint32, error) {
|
||||
if len(reader) >= pcscMaxReaderName {
|
||||
return 0, 0, errors.New("pcsc: reader name is too long")
|
||||
}
|
||||
body := make([]byte, 152)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
copy(body[4:132], reader)
|
||||
binary.LittleEndian.PutUint32(body[132:136], share)
|
||||
binary.LittleEndian.PutUint32(body[136:140], protocols)
|
||||
if err := client.exchange(ctx, pcscCmdConnect, body); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[148:152]); rv != pcscSuccess {
|
||||
return 0, 0, pcscError("connect reader", rv)
|
||||
}
|
||||
return int32(binary.LittleEndian.Uint32(body[140:144])), binary.LittleEndian.Uint32(body[144:148]), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) simpleCardCommand(ctx context.Context, command uint32, card int32, disposition *uint32) error {
|
||||
size := 8
|
||||
if disposition != nil {
|
||||
size = 12
|
||||
}
|
||||
body := make([]byte, size)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
if disposition != nil {
|
||||
binary.LittleEndian.PutUint32(body[4:8], *disposition)
|
||||
}
|
||||
if err := client.exchange(ctx, command, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[size-4:]); rv != pcscSuccess {
|
||||
return pcscError("card command", rv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) transmit(ctx context.Context, card int32, protocol uint32, command []byte) ([]byte, error) {
|
||||
body := make([]byte, 32)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], protocol)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 8)
|
||||
binary.LittleEndian.PutUint32(body[12:16], uint32(len(command)))
|
||||
binary.LittleEndian.PutUint32(body[16:20], pcscProtocolAny)
|
||||
binary.LittleEndian.PutUint32(body[20:24], 8)
|
||||
binary.LittleEndian.PutUint32(body[24:28], pcscMaxAPDUResponse)
|
||||
if err := client.send(ctx, pcscCmdTransmit, body, command); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: transmit APDU: %w", err)
|
||||
}
|
||||
if _, err := io.ReadFull(client.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU result: %w", err)
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[28:32]); rv != pcscSuccess {
|
||||
return nil, pcscError("transmit APDU", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[24:28])
|
||||
if length > pcscMaxAPDUResponse {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized APDU")
|
||||
}
|
||||
response := make([]byte, length)
|
||||
if _, err := io.ReadFull(client.conn, response); err != nil {
|
||||
return nil, fmt.Errorf("pcsc: receive APDU: %w", err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) getAttrib(ctx context.Context, card int32, attribute uint32) ([]byte, error) {
|
||||
body := make([]byte, pcscGetSetBodySize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], uint32(card))
|
||||
binary.LittleEndian.PutUint32(body[4:8], attribute)
|
||||
binary.LittleEndian.PutUint32(body[272:276], pcscMaxAttribute)
|
||||
if err := client.exchange(ctx, pcscCmdGetAttrib, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rv := binary.LittleEndian.Uint32(body[276:280]); rv != pcscSuccess {
|
||||
return nil, pcscError("get reader attribute", rv)
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(body[272:276])
|
||||
if length > pcscMaxAttribute {
|
||||
return nil, errors.New("pcsc: pcscd returned an oversized attribute")
|
||||
}
|
||||
return append([]byte(nil), body[8:8+length]...), nil
|
||||
}
|
||||
|
||||
func (client *pcscdClient) closeContext(ctx context.Context) error {
|
||||
if client == nil || client.conn == nil {
|
||||
return nil
|
||||
}
|
||||
body := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(body[0:4], client.contextID)
|
||||
err := client.exchange(ctx, pcscCmdReleaseContext, body)
|
||||
if err == nil {
|
||||
if rv := binary.LittleEndian.Uint32(body[4:8]); rv != pcscSuccess {
|
||||
err = pcscError("release context", rv)
|
||||
}
|
||||
}
|
||||
closeErr := client.conn.Close()
|
||||
client.conn = nil
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
|
||||
func pcscError(operation string, code uint32) error {
|
||||
switch code {
|
||||
case pcscNoSmartcard:
|
||||
return ErrNoCard
|
||||
case pcscNoService, pcscServiceStopped:
|
||||
return fmt.Errorf("%w: %s failed with PC/SC status %08X", ErrUnavailable, operation, code)
|
||||
case pcscNoReaders:
|
||||
return ErrReaderNotFound
|
||||
default:
|
||||
return fmt.Errorf("pcsc: %s failed with status %08X", operation, code)
|
||||
}
|
||||
}
|
||||
|
||||
func cString(value []byte) string {
|
||||
for index, current := range value {
|
||||
if current == 0 {
|
||||
return string(value[:index])
|
||||
}
|
||||
}
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func writeAll(writer io.Writer, value []byte) error {
|
||||
for len(value) > 0 {
|
||||
written, err := writer.Write(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written == 0 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
value = value[written:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPCSCDClientLifecycleAndTransmit(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer serverConn.Close()
|
||||
serverDone <- servePCSCDTestSession(serverConn)
|
||||
}()
|
||||
|
||||
client, err := establishPCSCD(context.Background(), clientConn)
|
||||
if err != nil {
|
||||
t.Fatalf("establishPCSCD: %v", err)
|
||||
}
|
||||
states, err := client.readers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("readers: %v", err)
|
||||
}
|
||||
if len(states) != 1 || states[0].name != "VoCat Test Reader 00 00" || states[0].state&pcscCardPresent == 0 {
|
||||
t.Fatalf("states = %#v", states)
|
||||
}
|
||||
handle, protocol, err := client.connect(context.Background(), states[0].name, pcscShareShared, pcscProtocolAny)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if handle != 42 || protocol != pcscProtocolT1 {
|
||||
t.Fatalf("handle/protocol = %d/%d", handle, protocol)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdBeginTransaction, handle, nil); err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
response, err := client.transmit(context.Background(), handle, protocol, []byte{0x00, 0xa4, 0x00, 0x00})
|
||||
if err != nil {
|
||||
t.Fatalf("transmit: %v", err)
|
||||
}
|
||||
if !bytes.Equal(response, []byte{0x62, 0x02, 0x90, 0x00}) {
|
||||
t.Fatalf("response = %x", response)
|
||||
}
|
||||
disposition := uint32(pcscLeaveCard)
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdEndTransaction, handle, &disposition); err != nil {
|
||||
t.Fatalf("end: %v", err)
|
||||
}
|
||||
if err := client.simpleCardCommand(context.Background(), pcscCmdDisconnect, handle, &disposition); err != nil {
|
||||
t.Fatalf("disconnect: %v", err)
|
||||
}
|
||||
if err := client.closeContext(context.Background()); err != nil {
|
||||
t.Fatalf("close context: %v", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("fake pcscd: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func servePCSCDTestSession(conn net.Conn) error {
|
||||
for {
|
||||
header := make([]byte, 8)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return err
|
||||
}
|
||||
size := binary.LittleEndian.Uint32(header[0:4])
|
||||
command := binary.LittleEndian.Uint32(header[4:8])
|
||||
body := make([]byte, size)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case pcscCmdVersion:
|
||||
binary.LittleEndian.PutUint32(body[0:4], pcscProtocolMajor)
|
||||
binary.LittleEndian.PutUint32(body[4:8], pcscProtocolCurrentMinor)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdEstablishContext:
|
||||
binary.LittleEndian.PutUint32(body[4:8], 7)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdGetReadersState:
|
||||
states := make([]byte, pcscMaxReaders*pcscReaderStateSize)
|
||||
copy(states, "VoCat Test Reader 00 00")
|
||||
binary.LittleEndian.PutUint32(states[132:136], pcscCardPresent)
|
||||
copy(states[140:143], []byte{0x3b, 0x00, 0x00})
|
||||
binary.LittleEndian.PutUint32(states[176:180], 3)
|
||||
binary.LittleEndian.PutUint32(states[180:184], pcscProtocolT1)
|
||||
if err := writeAll(conn, states); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdConnect:
|
||||
binary.LittleEndian.PutUint32(body[140:144], 42)
|
||||
binary.LittleEndian.PutUint32(body[144:148], pcscProtocolT1)
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdBeginTransaction, pcscCmdEndTransaction, pcscCmdDisconnect:
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdTransmit:
|
||||
commandBody := make([]byte, binary.LittleEndian.Uint32(body[12:16]))
|
||||
if _, err := io.ReadFull(conn, commandBody); err != nil {
|
||||
return err
|
||||
}
|
||||
response := []byte{0x62, 0x02, 0x90, 0x00}
|
||||
binary.LittleEndian.PutUint32(body[24:28], uint32(len(response)))
|
||||
if err := writeAll(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAll(conn, response); err != nil {
|
||||
return err
|
||||
}
|
||||
case pcscCmdReleaseContext:
|
||||
return writeAll(conn, body)
|
||||
default:
|
||||
return errors.New("unexpected fake pcscd command")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func (service *Service) Snapshot(ctx context.Context, selector Selector, pin str
|
||||
return Snapshot{}, ErrReaderNotFound
|
||||
}
|
||||
result := Snapshot{Reader: reader}
|
||||
if reader.DiscoveryIssue != "" {
|
||||
return result, fmt.Errorf("%w: %s", ErrUnavailable, reader.DiscoveryIssue)
|
||||
}
|
||||
if !reader.CardPresent {
|
||||
return result, ErrNoCard
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ type scriptedCard struct {
|
||||
calls [][]byte
|
||||
}
|
||||
|
||||
type unavailableReaderBackend struct{}
|
||||
|
||||
func (unavailableReaderBackend) Readers(context.Context) ([]Reader, error) {
|
||||
return []Reader{{Name: "ACR38", USBPath: "2-1", DiscoveryIssue: "pcsc_service_unavailable"}}, nil
|
||||
}
|
||||
|
||||
func (unavailableReaderBackend) Open(context.Context, Selector) (Card, error) {
|
||||
return nil, errors.New("Open must not be called for a diagnostic-only reader")
|
||||
}
|
||||
|
||||
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
|
||||
card.calls = append(card.calls, append([]byte(nil), command...))
|
||||
if len(card.replies) == 0 {
|
||||
@@ -82,3 +92,11 @@ func TestDeviceIDUsesStableUSBPath(t *testing.T) {
|
||||
t.Fatalf("device IDs = %q, %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRejectsPhysicalReaderUntilPCSCDIsReady(t *testing.T) {
|
||||
service := NewWithBackend(unavailableReaderBackend{})
|
||||
snapshot, err := service.Snapshot(context.Background(), Selector{USBPath: "2-1"}, "")
|
||||
if !errors.Is(err, ErrUnavailable) || snapshot.Reader.USBPath != "2-1" {
|
||||
t.Fatalf("Snapshot() = %#v, %v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ type Reader struct {
|
||||
Product string
|
||||
CardPresent bool
|
||||
ATR string
|
||||
// DiscoveryIssue is set when USB sees a smart-card reader but pcscd cannot
|
||||
// expose it yet. Keeping the physical reader visible lets the UI explain the
|
||||
// missing service/driver instead of silently showing an empty device list.
|
||||
DiscoveryIssue string
|
||||
}
|
||||
|
||||
type Selector struct {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const usbSmartCardInterfaceClass = "0b"
|
||||
|
||||
// discoverUSBSmartCardReaders finds physical USB CCID interfaces directly in
|
||||
// sysfs. It is a diagnostic fallback; APDU access still goes through pcscd.
|
||||
func discoverUSBSmartCardReaders(sysRoot, issue string) []Reader {
|
||||
usbRoot := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices")
|
||||
entries, err := os.ReadDir(usbRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
deviceNames := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
class := strings.ToLower(readTrimmedFile(filepath.Join(usbRoot, name, "bInterfaceClass")))
|
||||
if deviceName, ok := smartCardUSBDeviceName(name, class); ok {
|
||||
deviceNames[deviceName] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]Reader, 0, len(deviceNames))
|
||||
for deviceName := range deviceNames {
|
||||
path := filepath.Join(usbRoot, deviceName)
|
||||
vendorID := strings.ToLower(readTrimmedFile(filepath.Join(path, "idVendor")))
|
||||
productID := strings.ToLower(readTrimmedFile(filepath.Join(path, "idProduct")))
|
||||
if vendorID == "" || productID == "" {
|
||||
continue
|
||||
}
|
||||
product := readTrimmedFile(filepath.Join(path, "product"))
|
||||
if product == "" {
|
||||
product = fmt.Sprintf("USB smart card reader %s:%s", vendorID, productID)
|
||||
}
|
||||
result = append(result, Reader{
|
||||
Name: product, USBPath: deviceName,
|
||||
VendorID: vendorID, ProductID: productID,
|
||||
Manufacturer: readTrimmedFile(filepath.Join(path, "manufacturer")),
|
||||
Product: product, DiscoveryIssue: issue,
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].USBPath < result[j].USBPath })
|
||||
return result
|
||||
}
|
||||
|
||||
func smartCardUSBDeviceName(interfaceName, class string) (string, bool) {
|
||||
deviceName, _, interfaceEntry := strings.Cut(interfaceName, ":")
|
||||
return deviceName, interfaceEntry && deviceName != "" && strings.EqualFold(strings.TrimSpace(class), usbSmartCardInterfaceClass)
|
||||
}
|
||||
|
||||
func readTrimmedFile(path string) string {
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(value))
|
||||
}
|
||||
|
||||
func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
|
||||
if len(readers) == 1 && len(physical) == 1 && strings.HasPrefix(readers[0].USBPath, "pcsc:") {
|
||||
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
||||
return readers
|
||||
}
|
||||
seen := make(map[string]bool, len(readers))
|
||||
for i := range readers {
|
||||
seen[readers[i].USBPath] = true
|
||||
for _, usbReader := range physical {
|
||||
if readers[i].USBPath == usbReader.USBPath {
|
||||
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, usbReader := range physical {
|
||||
if !seen[usbReader.USBPath] {
|
||||
readers = append(readers, usbReader)
|
||||
}
|
||||
}
|
||||
return readers
|
||||
}
|
||||
|
||||
func enrichPCSCReader(reader, physical Reader) Reader {
|
||||
reader.USBPath = physical.USBPath
|
||||
reader.VendorID = physical.VendorID
|
||||
reader.ProductID = physical.ProductID
|
||||
reader.Manufacturer = physical.Manufacturer
|
||||
if reader.Product == "" {
|
||||
reader.Product = physical.Product
|
||||
}
|
||||
reader.DiscoveryIssue = ""
|
||||
return reader
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pcsc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiscoverUSBSmartCardReadersFindsACR38(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows filenames cannot represent Linux sysfs interface names containing a colon")
|
||||
}
|
||||
root := t.TempDir()
|
||||
usbRoot := filepath.Join(root, "bus", "usb", "devices")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "idVendor"), "072f\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "idProduct"), "90cc\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "manufacturer"), "Advanced Card Systems\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "2-1", "product"), "ACR38 SmartCard Reader\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceClass"), "0b\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "3-1", "idVendor"), "2c7c\n")
|
||||
writeUSBTestFile(t, filepath.Join(usbRoot, "3-1:1.0", "bInterfaceClass"), "ff\n")
|
||||
|
||||
readers := discoverUSBSmartCardReaders(root, "pcsc_service_unavailable")
|
||||
if len(readers) != 1 {
|
||||
t.Fatalf("readers = %#v", readers)
|
||||
}
|
||||
reader := readers[0]
|
||||
if reader.USBPath != "2-1" || reader.VendorID != "072f" || reader.ProductID != "90cc" || reader.DiscoveryIssue != "pcsc_service_unavailable" {
|
||||
t.Fatalf("reader = %#v", reader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartCardUSBDeviceName(t *testing.T) {
|
||||
if name, ok := smartCardUSBDeviceName("2-1:1.0", "0B"); !ok || name != "2-1" {
|
||||
t.Fatalf("smartCardUSBDeviceName() = %q, %v", name, ok)
|
||||
}
|
||||
if _, ok := smartCardUSBDeviceName("2-1:1.0", "ff"); ok {
|
||||
t.Fatal("vendor-specific interface was accepted as CCID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) {
|
||||
readers := mergePCSCAndUSBReaders(
|
||||
[]Reader{{Name: "ACS ACR38 00 00", USBPath: "pcsc:ACS ACR38 00 00", CardPresent: true}},
|
||||
[]Reader{{Name: "ACR38", USBPath: "2-1", VendorID: "072f", ProductID: "90cc", DiscoveryIssue: "pcsc_driver_missing"}},
|
||||
)
|
||||
if len(readers) != 1 || readers[0].USBPath != "2-1" || readers[0].DiscoveryIssue != "" || !readers[0].CardPresent {
|
||||
t.Fatalf("readers = %#v", readers)
|
||||
}
|
||||
}
|
||||
|
||||
func writeUSBTestFile(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(value), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,13 @@ func (scheduler *automaticTaskScheduler) run() {
|
||||
}
|
||||
|
||||
func (scheduler *automaticTaskScheduler) claim() {
|
||||
runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
var runs []store.AutomaticTaskRun
|
||||
var err error
|
||||
if scheduler.server.developerActive(scheduler.ctx) {
|
||||
runs, err = scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
} else {
|
||||
runs, err = scheduler.server.store.ClaimDueAvailableAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50)
|
||||
}
|
||||
if err != nil {
|
||||
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
||||
return
|
||||
@@ -127,6 +133,11 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
if err := validateAutomaticTaskAvailability(scheduler.server.developerActive(scheduler.ctx), task.TaskType, task.Environment); err != nil {
|
||||
run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
return
|
||||
}
|
||||
run.Status, run.StartedAt = "running", time.Now().UTC()
|
||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||
var output string
|
||||
@@ -175,6 +186,9 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
||||
}
|
||||
|
||||
func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) {
|
||||
if err := validateAutomaticTaskAvailability(s.developerActive(ctx), task.TaskType, task.Environment); err != nil {
|
||||
return "", automaticTaskExecutionError{err: err, retryable: false}
|
||||
}
|
||||
progress("正在检查设备和 eSIM Profile")
|
||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
|
||||
if err != nil {
|
||||
@@ -359,9 +373,6 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
||||
return err
|
||||
}
|
||||
if task.TaskType == "public_ip" {
|
||||
if !s.developerActive(ctx) {
|
||||
return errors.New("roaming public IP tasks require developer mode")
|
||||
}
|
||||
progress("已注册蜂窝网络,正在建立数据连接")
|
||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
|
||||
return fmt.Errorf("start roaming data: %w", err)
|
||||
@@ -659,6 +670,15 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.developerActive(r.Context()) {
|
||||
visible := tasks[:0]
|
||||
for _, task := range tasks {
|
||||
if validateAutomaticTaskAvailability(false, task.TaskType, task.Environment) == nil {
|
||||
visible = append(visible, task)
|
||||
}
|
||||
}
|
||||
tasks = visible
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
|
||||
case http.MethodPost:
|
||||
task, err := s.decodeAutomaticTask(r, 0)
|
||||
@@ -711,7 +731,14 @@ func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request)
|
||||
query := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
var runs []store.AutomaticTaskRun
|
||||
var total int
|
||||
var err error
|
||||
if s.developerActive(r.Context()) {
|
||||
runs, total, err = s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
} else {
|
||||
runs, total, err = s.store.ListAvailableAutomaticTaskRunsPaginated(r.Context(), limit, offset)
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
return
|
||||
@@ -741,6 +768,10 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateAutomaticTaskAvailability(s.developerActive(r.Context()), task.TaskType, task.Environment); err != nil {
|
||||
writeError(w, http.StatusNotFound, "task_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, err)
|
||||
@@ -789,6 +820,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
if request.TaskType == "public_ip" && request.Environment != "cellular" {
|
||||
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
|
||||
}
|
||||
if err := validateAutomaticTaskAvailability(s.developerActive(r.Context()), request.TaskType, request.Environment); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
|
||||
return store.AutomaticTask{}, err
|
||||
}
|
||||
@@ -831,6 +865,13 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func validateAutomaticTaskAvailability(available bool, taskType, environment string) error {
|
||||
if !available && (taskType == "public_ip" || environment == "cellular") {
|
||||
return errors.New("unsupported task type or environment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
|
||||
if config.DeviceType != store.DeviceTypeUSBSIMReader {
|
||||
return nil
|
||||
|
||||
@@ -39,6 +39,26 @@ func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticTaskAvailabilityHidesRestrictedPaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
available bool
|
||||
taskType string
|
||||
environment string
|
||||
wantError bool
|
||||
}{
|
||||
{false, "sms", "vowifi", false},
|
||||
{false, "call", "vowifi", false},
|
||||
{false, "sms", "cellular", true},
|
||||
{false, "public_ip", "cellular", true},
|
||||
{true, "public_ip", "cellular", false},
|
||||
} {
|
||||
err := validateAutomaticTaskAvailability(test.available, test.taskType, test.environment)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("availability(%v, %q, %q) = %v", test.available, test.taskType, test.environment, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
|
||||
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
|
||||
if automaticSMSRetrySafe(unsafe) {
|
||||
|
||||
@@ -39,14 +39,14 @@ func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`))
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":17}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleDeveloperSettings(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
||||
if got := developer.SMSHourlyLimit(ctx, database); got != 17 {
|
||||
t.Fatalf("SMS hourly limit = %d, want 17", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
"usb_path": candidate.USBPath,
|
||||
"vendor_id": parseHexID(candidate.VendorID),
|
||||
"product_id": parseHexID(candidate.ProductID),
|
||||
"driver_name": "",
|
||||
"driver_name": candidate.Product,
|
||||
"at_ports": atPorts,
|
||||
"at_port": candidate.ATPort.OpenPath(),
|
||||
"imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
|
||||
@@ -387,7 +387,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
|
||||
"network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""),
|
||||
"configured": configuredID != "",
|
||||
"configured_id": configuredID,
|
||||
"degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(),
|
||||
"degraded": candidate.DiscoveryIssue != "" || (candidate.HardwareKind != "pcsc" && !candidate.HasATPort()),
|
||||
"discovery_issue": candidate.DiscoveryIssue,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}})
|
||||
|
||||
@@ -99,6 +99,16 @@ func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
return s.claimDueAutomaticTasks(ctx, now, limit, false)
|
||||
}
|
||||
|
||||
// ClaimDueAvailableAutomaticTasks excludes task types and environments that
|
||||
// are not exposed in the standard product surface.
|
||||
func (s *Store) ClaimDueAvailableAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) {
|
||||
return s.claimDueAutomaticTasks(ctx, now, limit, true)
|
||||
}
|
||||
|
||||
func (s *Store) claimDueAutomaticTasks(ctx context.Context, now time.Time, limit int, availableOnly bool) ([]AutomaticTaskRun, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -107,8 +117,12 @@ func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
availability := ""
|
||||
if availableOnly {
|
||||
availability = " AND task_type <> 'public_ip' AND environment <> 'cellular'"
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
|
||||
WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
WHERE enabled = 1 AND next_run_at <= ?`+availability+` ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -236,6 +250,19 @@ func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]Automat
|
||||
// the total run count, so the UI can page through the full history instead of
|
||||
// a fixed recent window.
|
||||
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
||||
return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, "")
|
||||
}
|
||||
|
||||
// ListAvailableAutomaticTaskRunsPaginated omits history belonging to task
|
||||
// types and environments that are not exposed in the standard product surface.
|
||||
func (s *Store) ListAvailableAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
||||
const where = ` WHERE task_id IN (
|
||||
SELECT id FROM automatic_tasks WHERE task_type <> 'public_ip' AND environment <> 'cellular'
|
||||
)`
|
||||
return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, where)
|
||||
}
|
||||
|
||||
func (s *Store) listAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int, where string) ([]AutomaticTaskRun, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
@@ -246,10 +273,10 @@ func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offse
|
||||
offset = 0
|
||||
}
|
||||
total := 0
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`+where).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count automatic task runs: %w", err)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -120,6 +120,54 @@ func TestListAutomaticTaskRunsPaginated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableAutomaticTasksExcludeRestrictedTaskAndRunHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-availability.db"))
|
||||
mustSaveDevice(t, database, "ec20", "EC20")
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
save := func(name, taskType, environment string) AutomaticTask {
|
||||
t.Helper()
|
||||
task, err := database.SaveAutomaticTask(ctx, AutomaticTask{
|
||||
Name: name, Enabled: true, DeviceID: "ec20", ProfileICCID: "one",
|
||||
TaskType: taskType, Environment: environment, IntervalDays: 1,
|
||||
StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai",
|
||||
Payload: []byte(`{"phone":"10086","message":"test"}`), NextRunAt: now.Add(-time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
visible := save("visible", "sms", "vowifi")
|
||||
hidden := save("hidden", "public_ip", "cellular")
|
||||
for _, task := range []AutomaticTask{visible, hidden} {
|
||||
if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runs, total, err := database.ListAvailableAutomaticTaskRunsPaginated(ctx, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(runs) != 1 || runs[0].TaskID != visible.ID {
|
||||
t.Fatalf("available history total=%d runs=%+v", total, runs)
|
||||
}
|
||||
claimed, err := database.ClaimDueAvailableAutomaticTasks(ctx, now, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(claimed) != 1 || claimed[0].TaskID != visible.ID {
|
||||
t.Fatalf("available claims = %+v", claimed)
|
||||
}
|
||||
storedHidden, err := database.AutomaticTask(ctx, hidden.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedHidden.NextRunAt.After(now) {
|
||||
t.Fatalf("restricted task schedule advanced unexpectedly: %v", storedHidden.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
|
||||
|
||||
@@ -184,6 +184,10 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
cleanup()
|
||||
return fmt.Errorf("update: chmod temp binary: %w", err)
|
||||
}
|
||||
if err := validateExecutable(ctx, tmpPath); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := backupAndReplace(opts.Target, tmpPath); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
@@ -202,10 +206,25 @@ func applyUpdate(ctx context.Context, logger *slog.Logger, opts Options, release
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExecutable catches incompatible architectures and missing dynamic
|
||||
// loaders before the working installation is touched. A valid checksum alone
|
||||
// cannot detect those packaging errors.
|
||||
func validateExecutable(ctx context.Context, path string) error {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(checkCtx, path, "version").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: downloaded binary cannot run on this host: %w (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(string(output)), "vocat") {
|
||||
return fmt.Errorf("update: downloaded binary returned an unexpected version response: %q", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupAndReplace renames the current binary aside, then moves the verified
|
||||
// temp file into place. Both renames are atomic on the same filesystem. On
|
||||
// Linux the kernel holds the running binary's inode, so replacing it mid-flight
|
||||
// is safe.
|
||||
// temp file into place. Both renames are atomic on the same filesystem. The
|
||||
// previous working binary is retained for service-level or manual rollback.
|
||||
func backupAndReplace(target, tmp string) error {
|
||||
backup := target + ".previous"
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
@@ -221,16 +240,21 @@ func backupAndReplace(target, tmp string) error {
|
||||
}
|
||||
return fmt.Errorf("update: move new binary into place: %w", err)
|
||||
}
|
||||
_ = os.Remove(backup)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartService restarts the vocat systemd unit. If systemctl is unavailable
|
||||
// (non-systemd hosts, containers), it returns an error the caller surfaces as
|
||||
// a non-fatal warning.
|
||||
// RestartService supports both systemd hosts and OpenWrt/procd routers.
|
||||
func RestartService(logger *slog.Logger) error {
|
||||
if _, err := os.Stat("/etc/init.d/vocat"); err == nil {
|
||||
cmd := exec.Command("/etc/init.d/vocat", "restart")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
logger.Warn("OpenWrt service restart failed", "error", err, "output", string(out))
|
||||
return fmt.Errorf("restart OpenWrt vocat service: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return fmt.Errorf("systemctl not found in PATH")
|
||||
return fmt.Errorf("neither /etc/init.d/vocat nor systemctl is available")
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateExecutableRejectsNonExecutableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "not-vocat")
|
||||
if err := os.WriteFile(path, []byte("not an executable"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateExecutable(context.Background(), path); err == nil {
|
||||
t.Fatal("validateExecutable accepted invalid file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupAndReplaceRetainsPreviousBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Linux replacement behavior")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
target := filepath.Join(directory, "vocat")
|
||||
replacement := filepath.Join(directory, "replacement")
|
||||
if err := os.WriteFile(target, []byte("old"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, []byte("new"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := backupAndReplace(target, replacement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := os.ReadFile(target + ".previous")
|
||||
if err != nil {
|
||||
t.Fatalf("read retained backup: %v", err)
|
||||
}
|
||||
if string(old) != "old" {
|
||||
t.Fatalf("backup = %q", old)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ type EC20SensitiveATExecutor interface {
|
||||
ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error)
|
||||
}
|
||||
|
||||
type EC20UICCLocker interface {
|
||||
LockUICC()
|
||||
UnlockUICC()
|
||||
}
|
||||
|
||||
type EC20AdapterOptions struct {
|
||||
// PureAirplanePolicy reports the independent user policy. The adapter only
|
||||
// changes the transactional CFUN projection used by VoWiFi and never
|
||||
@@ -339,6 +344,10 @@ func (adapter *EC20Adapter) CheckReady(
|
||||
// cannot insert an APDU between a 61xx response and GET RESPONSE.
|
||||
adapter.apduMu.Lock()
|
||||
defer adapter.apduMu.Unlock()
|
||||
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
|
||||
locker.LockUICC()
|
||||
defer locker.UnlockUICC()
|
||||
}
|
||||
|
||||
aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID)
|
||||
if err != nil {
|
||||
@@ -402,6 +411,10 @@ func (adapter *EC20Adapter) Authenticate(
|
||||
|
||||
adapter.apduMu.Lock()
|
||||
defer adapter.apduMu.Unlock()
|
||||
if locker, ok := adapter.executor.(EC20UICCLocker); ok {
|
||||
locker.LockUICC()
|
||||
defer locker.UnlockUICC()
|
||||
}
|
||||
|
||||
apdu := buildUSIMAuthenticateAPDU(challenge)
|
||||
var raw []byte
|
||||
|
||||
@@ -38,6 +38,7 @@ type Config struct {
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
@@ -106,6 +107,19 @@ func normalizeConfig(config Config) (Config, error) {
|
||||
if config.Transport != "" && config.Transport != "udp" && config.Transport != "tcp" {
|
||||
return Config{}, fmt.Errorf("ims: unsupported SIP transport %q", config.Transport)
|
||||
}
|
||||
transportByPLMN := make(map[string]string, len(config.TransportByPLMN))
|
||||
for plmn, transport := range config.TransportByPLMN {
|
||||
plmn = strings.TrimSpace(plmn)
|
||||
transport = strings.ToLower(strings.TrimSpace(transport))
|
||||
if !digitsBetween(plmn, 5, 6) {
|
||||
return Config{}, fmt.Errorf("ims: invalid transport override PLMN %q", plmn)
|
||||
}
|
||||
if transport != "udp" && transport != "tcp" {
|
||||
return Config{}, fmt.Errorf("ims: unsupported SIP transport %q for PLMN %s", transport, plmn)
|
||||
}
|
||||
transportByPLMN[plmn] = transport
|
||||
}
|
||||
config.TransportByPLMN = transportByPLMN
|
||||
if strings.TrimSpace(config.UserAgent) == "" {
|
||||
config.UserAgent = "vocat/1"
|
||||
}
|
||||
@@ -185,7 +199,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||
}
|
||||
transport := provider.config.Transport
|
||||
transport := transportForIdentity(provider.config, request.Identity)
|
||||
if transport == "" {
|
||||
transport = transportHint
|
||||
}
|
||||
@@ -227,6 +241,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
return transport
|
||||
}
|
||||
return config.Transport
|
||||
}
|
||||
|
||||
type identitySet struct {
|
||||
domain string
|
||||
private string
|
||||
|
||||
@@ -26,6 +26,53 @@ func (evidenceTunnel) Close(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTransportForIdentityUsesPLMNOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := Config{
|
||||
Transport: "tcp",
|
||||
TransportByPLMN: map[string]string{
|
||||
"23410": "udp",
|
||||
"234010": "udp",
|
||||
},
|
||||
}
|
||||
|
||||
if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}); got != "udp" {
|
||||
t.Fatalf("PLMN 234-10 transport = %q, want udp", got)
|
||||
}
|
||||
if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "010"}); got != "udp" {
|
||||
t.Fatalf("zero-padded PLMN 234-010 transport = %q, want udp", got)
|
||||
}
|
||||
if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}); got != "tcp" {
|
||||
t.Fatalf("non-overridden transport = %q, want tcp", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := Config{
|
||||
TransportByPLMN: map[string]string{
|
||||
"31001": "udp",
|
||||
"310001": "tcp",
|
||||
"31000": "udp",
|
||||
"310000": "tcp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "01", want: "udp"},
|
||||
{mnc: "001", want: "tcp"},
|
||||
{mnc: "00", want: "udp"},
|
||||
{mnc: "000", want: "tcp"},
|
||||
} {
|
||||
if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "310", HomeMNC: test.mnc}); got != test.want {
|
||||
t.Errorf("PLMN 310-%s transport = %q, want %q", test.mnc, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
|
||||
@@ -28,8 +28,9 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required")
|
||||
ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations")
|
||||
ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required")
|
||||
ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations")
|
||||
errIncompleteSecurityOffer = errors.New("ims: incomplete ipsec-3gpp security offer")
|
||||
)
|
||||
|
||||
// IPSecSAConfig is the complete, evidence-derived 3GPP transport-mode SA set.
|
||||
@@ -186,6 +187,9 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit
|
||||
if err != nil {
|
||||
name := strings.ToLower(strings.TrimSpace(strings.SplitN(item, ";", 2)[0]))
|
||||
if name == "ipsec-3gpp" {
|
||||
if errors.Is(err, errIncompleteSecurityOffer) {
|
||||
continue
|
||||
}
|
||||
return securityAgreement{}, fmt.Errorf(
|
||||
"ims: malformed ipsec-3gpp Security-Server: %w",
|
||||
err,
|
||||
@@ -263,6 +267,19 @@ func parseSecurityMechanism(value string) (securityMechanism, error) {
|
||||
if value := parameters["ealg"]; value != "" {
|
||||
mechanism.encryption = strings.ToLower(value)
|
||||
}
|
||||
saParameterKeys := []string{"spi-c", "spi-s", "port-c", "port-s"}
|
||||
presentSAParameters := 0
|
||||
for _, key := range saParameterKeys {
|
||||
if parameters[key] != "" {
|
||||
presentSAParameters++
|
||||
}
|
||||
}
|
||||
if presentSAParameters == 0 {
|
||||
return securityMechanism{}, errIncompleteSecurityOffer
|
||||
}
|
||||
if presentSAParameters != len(saParameterKeys) {
|
||||
return securityMechanism{}, errors.New("ims: partially specified Security-Server SA parameters")
|
||||
}
|
||||
var err error
|
||||
if mechanism.spiClient, err = decimalUint32(parameters["spi-c"]); err != nil {
|
||||
return securityMechanism{}, err
|
||||
|
||||
@@ -84,6 +84,14 @@ func runIPCommand(ctx context.Context, command string, operation xfrmOperation)
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
if strings.Contains(strings.ToLower(message), "protocol not supported") ||
|
||||
strings.Contains(strings.ToLower(message), "operation not supported") {
|
||||
return fmt.Errorf(
|
||||
"%s: host kernel lacks XFRM/IPsec support; install matching kmod-ipsec and kmod-ipsec4/6 (OpenWrt), or enable CONFIG_XFRM_USER and ESP in the kernel: %s",
|
||||
operation.description,
|
||||
message,
|
||||
)
|
||||
}
|
||||
// Operation descriptions contain no SPI keys or subscriber identity.
|
||||
return fmt.Errorf("%s: %s", operation.description, message)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunIPCommandExplainsMissingKernelXFRM(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
command := directory + "/ip"
|
||||
script := "#!/bin/sh\necho 'Cannot open netlink socket: Protocol not supported' >&2\nexit 1\n"
|
||||
if err := os.WriteFile(command, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runIPCommand(context.Background(), command, xfrmOperation{description: "test state"})
|
||||
if err == nil || !strings.Contains(err.Error(), "kmod-ipsec") || !strings.Contains(err.Error(), "CONFIG_XFRM_USER") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxIPSecInstallerLifecycle(t *testing.T) {
|
||||
if os.Getenv("VOCAT_NETNS_TEST") != "1" {
|
||||
t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace")
|
||||
|
||||
@@ -36,6 +36,31 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
spiServer: 1002,
|
||||
portClient: 40666,
|
||||
portServer: 55610,
|
||||
}
|
||||
incomplete := []string{
|
||||
"ipsec-3gpp;q=0.100;alg=hmac-md5-96;mod=trans",
|
||||
"ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;ealg=des-ede3-cbc;mod=trans",
|
||||
"ipsec-3gpp;q=0.300;alg=hmac-sha-1-96;ealg=aes-cbc;mod=trans",
|
||||
}
|
||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
values := []string{strings.Join(append(incomplete, selected), ", ")}
|
||||
|
||||
agreement, err := parseSecurityAgreement(values, proposal)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSecurityAgreement() error = %v", err)
|
||||
}
|
||||
if agreement.selected.spiClient != 2001 || agreement.selected.spiServer != 2002 {
|
||||
t.Fatalf("selected mechanism = %#v", agreement.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecurityAgreementFailsClosed(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
@@ -73,6 +98,12 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) {
|
||||
valid + ", ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;alg=hmac-sha-1-96",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partially specified SA parameters poison otherwise valid list",
|
||||
values: []string{
|
||||
valid + ", ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;spi-c=3001",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no offer",
|
||||
values: nil,
|
||||
|
||||
@@ -24,6 +24,23 @@ type ATMapper struct {
|
||||
Devices ATDeviceController
|
||||
}
|
||||
|
||||
type uiccLocker interface {
|
||||
LockUICC()
|
||||
UnlockUICC()
|
||||
}
|
||||
|
||||
func (mapper ATMapper) LockUICC() {
|
||||
if locker, ok := mapper.Devices.(uiccLocker); ok {
|
||||
locker.LockUICC()
|
||||
}
|
||||
}
|
||||
|
||||
func (mapper ATMapper) UnlockUICC() {
|
||||
if locker, ok := mapper.Devices.(uiccLocker); ok {
|
||||
locker.UnlockUICC()
|
||||
}
|
||||
}
|
||||
|
||||
func (mapper ATMapper) Get(configuredID string) (device.Device, error) {
|
||||
physicalID, err := mapper.resolve(context.Background(), configuredID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# pcscd daemonizes after startup. Keep failure non-fatal so modem-only
|
||||
# deployments remain usable and the UI can report a reader diagnostic.
|
||||
if [ "$(id -u)" = "0" ] && command -v pcscd >/dev/null 2>&1; then
|
||||
mkdir -p /run/pcscd
|
||||
pcscd || echo "warning: pcscd failed to start; USB SIM readers may be unavailable" >&2
|
||||
fi
|
||||
|
||||
exec /opt/vocat/bin/vocat "$@"
|
||||
+295
-28
@@ -1,20 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# vocat install / update script for binary + systemd deployments.
|
||||
# vocat install / update script for systemd and OpenWrt/procd deployments.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install.sh [version] # install a specific version
|
||||
# sudo bash install.sh # install latest release
|
||||
# sudo bash install.sh --force # reinstall even at the same version
|
||||
# curl -fsSL <raw url> | sudo bash # one-liner (latest)
|
||||
# bash install.sh [version] # run directly when already root
|
||||
# sudo bash install.sh [version] # run through sudo as a normal user
|
||||
# bash install.sh --check-env # check VoWiFi host prerequisites
|
||||
#
|
||||
# Behavior:
|
||||
# - Prompts for script language (中文 / English) as soon as it runs.
|
||||
# - If the installed version equals the target version, does nothing (unless --force).
|
||||
# - On first install, generates a random 32-char admin password, writes it to
|
||||
# /etc/vocat/env (0600, loaded by the systemd unit), and prints it ONCE.
|
||||
# - On update, preserves the existing env file and credentials.
|
||||
# - (Re)writes the systemd unit and restarts the service.
|
||||
# - On first install, generates a random 32-char admin password, initializes
|
||||
# it directly in SQLite through stdin, and prints it ONCE.
|
||||
# - Administrator credentials are never stored in /etc/vocat/env.
|
||||
# - Verifies Linux XFRM/IPsec support required by IMS; on OpenWrt it tries
|
||||
# the matching opkg packages first.
|
||||
# - (Re)writes a systemd or OpenWrt/procd service and restarts it.
|
||||
#
|
||||
# Published script: must contain no secrets, IPs, or passwords.
|
||||
|
||||
@@ -31,6 +32,7 @@ LINK_PATH="/usr/local/bin/vocat"
|
||||
ENV_DIR="/etc/vocat"
|
||||
ENV_FILE="${ENV_DIR}/env"
|
||||
UNIT_PATH="/etc/systemd/system/vocat.service"
|
||||
OPENWRT_INIT_PATH="/etc/init.d/vocat"
|
||||
|
||||
# --- Language ----------------------------------------------------------------
|
||||
LANG_CHOICE=""
|
||||
@@ -76,14 +78,44 @@ die() {
|
||||
|
||||
prompt_language
|
||||
|
||||
# BusyBox/OpenWrt images often omit coreutils' install(1). Provide the small
|
||||
# subset used by this script so the same installer works on router firmware.
|
||||
if ! command -v install >/dev/null 2>&1; then
|
||||
install() {
|
||||
if [ "${1:-}" = "-d" ]; then
|
||||
shift
|
||||
local mode="0755"
|
||||
if [ "${1:-}" = "-m" ]; then
|
||||
mode="$2"
|
||||
shift 2
|
||||
fi
|
||||
mkdir -p "$@"
|
||||
chmod "$mode" "$@"
|
||||
return
|
||||
fi
|
||||
local mode="0755"
|
||||
if [ "${1:-}" = "-m" ]; then
|
||||
mode="$2"
|
||||
shift 2
|
||||
fi
|
||||
[ "$#" -eq 2 ] || return 2
|
||||
cp "$1" "$2"
|
||||
chmod "$mode" "$2"
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Parse args --------------------------------------------------------------
|
||||
FORCE=0
|
||||
CHECK_ENV=0
|
||||
SKIP_VOWIFI_CHECK="${VOCAT_SKIP_VOWIFI_CHECK:-0}"
|
||||
TARGET_VERSION=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--force) FORCE=1 ;;
|
||||
--check-env) CHECK_ENV=1 ;;
|
||||
--skip-vowifi-check) SKIP_VOWIFI_CHECK=1 ;;
|
||||
-h|--help)
|
||||
msg "用法: sudo bash install.sh [--force] [版本]" "Usage: sudo bash install.sh [--force] [version]"
|
||||
msg "用法: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [版本]" "Usage: bash install.sh [--force] [--check-env] [--skip-vowifi-check] [version]"
|
||||
exit 0
|
||||
;;
|
||||
*) TARGET_VERSION="${arg#v}" ;;
|
||||
@@ -110,6 +142,131 @@ resolve_target_version() {
|
||||
TARGET_VERSION="${tag#v}"
|
||||
}
|
||||
|
||||
# --- Host prerequisites ------------------------------------------------------
|
||||
is_openwrt() {
|
||||
[ -f /etc/openwrt_release ] || [ -x /sbin/procd ]
|
||||
}
|
||||
|
||||
xfrm_works() {
|
||||
command -v ip >/dev/null 2>&1 && ip xfrm state list >/dev/null 2>&1
|
||||
}
|
||||
|
||||
opkg_has_package() {
|
||||
opkg list "$1" 2>/dev/null | grep -q "^$1 -"
|
||||
}
|
||||
|
||||
install_openwrt_vowifi_packages() {
|
||||
msg "正在检查 OpenWrt/Kwrt 的 VoWiFi 内核组件..." "Checking OpenWrt/Kwrt VoWiFi kernel components..."
|
||||
opkg update >/dev/null 2>&1 || msg \
|
||||
"警告:opkg 软件源更新失败,将使用现有索引继续检查。" \
|
||||
"Warning: opkg feed update failed; checking the existing index."
|
||||
|
||||
local packages=""
|
||||
local package
|
||||
for package in \
|
||||
ip-full \
|
||||
kmod-ipsec kmod-ipsec4 kmod-ipsec6 \
|
||||
kmod-crypto-authenc kmod-crypto-cbc kmod-crypto-aes \
|
||||
kmod-crypto-hmac kmod-crypto-sha1; do
|
||||
if opkg_has_package "$package"; then
|
||||
packages="$packages $package"
|
||||
fi
|
||||
done
|
||||
if [ -n "$packages" ]; then
|
||||
# Kernel packages must come from this firmware's own feed. opkg checks
|
||||
# the kernel ABI and refuses mismatched modules; never bypass that check.
|
||||
# shellcheck disable=SC2086
|
||||
opkg install $packages >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
install_linux_ip_tool() {
|
||||
command -v ip >/dev/null 2>&1 && return 0
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y iproute2
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y iproute
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y iproute
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
pacman -Sy --noconfirm iproute2
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache iproute2
|
||||
fi
|
||||
}
|
||||
|
||||
install_pcsc_support() {
|
||||
msg "正在检查 USB SIM 读卡器的 PC/SC 运行环境..." "Checking the PC/SC environment for USB SIM readers..."
|
||||
local installed=0
|
||||
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
local packages=""
|
||||
opkg_has_package pcscd && packages="$packages pcscd"
|
||||
opkg_has_package ccid && packages="$packages ccid"
|
||||
if [ -n "$packages" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
||||
fi
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
if apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y pcscd libccid; then
|
||||
installed=1
|
||||
fi
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y pcsc-lite pcsc-lite-ccid && installed=1 || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y pcsc-lite pcsc-lite-ccid && installed=1 || true
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
pacman -Sy --noconfirm pcsclite ccid && installed=1 || true
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache pcsc-lite ccid && installed=1 || true
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now pcscd.socket >/dev/null 2>&1 || \
|
||||
systemctl restart pcscd >/dev/null 2>&1 || true
|
||||
elif [ -x /etc/init.d/pcscd ]; then
|
||||
/etc/init.d/pcscd enable >/dev/null 2>&1 || true
|
||||
/etc/init.d/pcscd restart >/dev/null 2>&1 || /etc/init.d/pcscd start >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v pcscd >/dev/null 2>&1 || [ "$installed" -eq 1 ]; then
|
||||
msg "USB SIM 读卡器 PC/SC 环境已就绪。" "USB SIM reader PC/SC environment is ready."
|
||||
else
|
||||
msg \
|
||||
"警告:未能自动安装 pcscd/CCID 驱动;系统仍会显示读卡器并给出修复提示。" \
|
||||
"Warning: pcscd/CCID could not be installed automatically; VoCat will still show the reader with a remediation hint."
|
||||
fi
|
||||
}
|
||||
|
||||
check_vowifi_environment() {
|
||||
if [ "$SKIP_VOWIFI_CHECK" = "1" ]; then
|
||||
msg \
|
||||
"已跳过 VoWiFi 内核环境检查;IMS 通话和短信可能不可用。" \
|
||||
"Skipped the VoWiFi kernel check; IMS calls and SMS may not work."
|
||||
return
|
||||
fi
|
||||
|
||||
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||
# Install the crypto algorithms even when NETLINK_XFRM already works;
|
||||
# some minimal images provide xfrm_user but omit AES-CBC/authenc.
|
||||
install_openwrt_vowifi_packages
|
||||
elif ! xfrm_works; then
|
||||
install_linux_ip_tool
|
||||
fi
|
||||
if xfrm_works; then
|
||||
msg "VoWiFi XFRM/IPsec 环境安装并验证成功。" "VoWiFi XFRM/IPsec environment installed and verified."
|
||||
return
|
||||
fi
|
||||
|
||||
if is_openwrt; then
|
||||
die \
|
||||
"当前 OpenWrt/Kwrt 内核 $(uname -r) 不支持 NETLINK_XFRM,且软件源没有匹配的 kmod-ipsec。请使用包含 kmod-ipsec、kmod-ipsec4、kmod-ipsec6、kmod-crypto-authenc、kmod-crypto-cbc、kmod-crypto-aes 和 kmod-crypto-sha1 的同版本固件;严禁安装其他内核版本的 kmod。仅使用非 VoWiFi 功能时可加 --skip-vowifi-check。" \
|
||||
"The OpenWrt/Kwrt kernel $(uname -r) lacks NETLINK_XFRM and its feed has no matching kmod-ipsec. Use a firmware built with matching kmod-ipsec, kmod-ipsec4/6, crypto-authenc, CBC, AES and SHA1 modules. Never force kmods from another kernel. Use --skip-vowifi-check only for non-VoWiFi operation."
|
||||
fi
|
||||
die \
|
||||
"当前 Linux 内核不支持 XFRM/IPsec,VoWiFi IMS 无法工作。请启用 CONFIG_XFRM、CONFIG_XFRM_USER、CONFIG_INET_ESP、CONFIG_INET6_ESP、AES-CBC 和 HMAC-SHA1。" \
|
||||
"This Linux kernel lacks XFRM/IPsec required by VoWiFi IMS. Enable CONFIG_XFRM, CONFIG_XFRM_USER, CONFIG_INET_ESP, CONFIG_INET6_ESP, AES-CBC and HMAC-SHA1."
|
||||
}
|
||||
|
||||
# --- Skip if already installed at the same version ---------------------------
|
||||
skip_if_equal() {
|
||||
[ -x "$BINARY_PATH" ] || return 0
|
||||
@@ -159,6 +316,10 @@ download_and_verify() {
|
||||
[ -n "$expected" ] || die "SHA256SUMS 中找不到 $asset 的校验行。" "$asset not found in SHA256SUMS."
|
||||
actual=$(sha256sum "${VOCAT_TMP}/vocat" | awk '{print $1}')
|
||||
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
||||
chmod 0755 "${VOCAT_TMP}/vocat"
|
||||
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
||||
"Downloaded binary cannot run on this system; keeping the installed version." \
|
||||
"The downloaded binary cannot run on this host; the installed version was not changed."
|
||||
}
|
||||
|
||||
# --- Install binary ----------------------------------------------------------
|
||||
@@ -179,21 +340,32 @@ ensure_data_dir() {
|
||||
chown -R root:root /opt/vocat
|
||||
}
|
||||
|
||||
# --- Env file (first install only) -------------------------------------------
|
||||
# Generates a random 32-char secret, stores it in the 0600 env file, and flags
|
||||
# FIRST_INSTALL so we can print the secret once at the end.
|
||||
# --- Administrator bootstrap and non-secret environment ---------------------
|
||||
FIRST_INSTALL=0
|
||||
setup_env() {
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
return
|
||||
fi
|
||||
install -d -m 0755 "$ENV_DIR"
|
||||
local secret
|
||||
INITIAL_ADMIN_PASSWORD=""
|
||||
|
||||
bootstrap_admin() {
|
||||
local secret result
|
||||
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"
|
||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
||||
result=$(printf '%s\n' "$secret" | "$BINARY_PATH" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die "Failed to initialize the administrator." "Failed to initialize the administrator."
|
||||
if [ "$result" = "created" ]; then
|
||||
FIRST_INSTALL=1
|
||||
INITIAL_ADMIN_PASSWORD="$secret"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_env() {
|
||||
install -d -m 0755 "$ENV_DIR"
|
||||
local temporary="${ENV_FILE}.new.$$"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
grep -Ev '^VOCAT_ADMIN_(USERNAME|PASSWORD|PASSWORD_B64)=' "$ENV_FILE" > "$temporary" || true
|
||||
else
|
||||
: > "$temporary"
|
||||
fi
|
||||
mv -f "$temporary" "$ENV_FILE"
|
||||
chmod 0600 "$ENV_FILE"
|
||||
FIRST_INSTALL=1
|
||||
}
|
||||
|
||||
# --- systemd unit ------------------------------------------------------------
|
||||
@@ -218,6 +390,8 @@ 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
|
||||
RuntimeDirectory=vocat
|
||||
RuntimeDirectoryMode=0755
|
||||
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
|
||||
@@ -246,11 +420,98 @@ EOF
|
||||
chmod 0644 "$UNIT_PATH"
|
||||
}
|
||||
|
||||
write_openwrt_init() {
|
||||
cat > "$OPENWRT_INIT_PATH" <<'EOF'
|
||||
#!/bin/sh /etc/rc.common
|
||||
START=95
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
PROCD_TERM_TIMEOUT=40
|
||||
PROGRAM=/opt/vocat/bin/vocat
|
||||
ENV_FILE=/etc/vocat/env
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROGRAM" serve
|
||||
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
if [ -r "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r name value; do
|
||||
case "$name" in VOCAT_*) procd_append_param env "$name=$value" ;; esac
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
service_triggers() { procd_add_reload_trigger vocat; }
|
||||
EOF
|
||||
chmod 0755 "$OPENWRT_INIT_PATH"
|
||||
}
|
||||
|
||||
write_service() {
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
write_unit
|
||||
return
|
||||
fi
|
||||
if [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; then
|
||||
write_openwrt_init
|
||||
return
|
||||
fi
|
||||
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
|
||||
}
|
||||
|
||||
enable_and_start() {
|
||||
if [ -x "$OPENWRT_INIT_PATH" ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ]; }; then
|
||||
"$OPENWRT_INIT_PATH" enable
|
||||
# Stop explicitly before restart. Some procd/rc.common variants return
|
||||
# from restart while the previous process is still inside its bounded
|
||||
# VoWiFi cleanup, so the replacement can race the host-wide instance
|
||||
# lock and enter a respawn cycle.
|
||||
"$OPENWRT_INIT_PATH" stop || true
|
||||
local stop_attempt
|
||||
for stop_attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do
|
||||
if ! "$OPENWRT_INIT_PATH" running; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if "$OPENWRT_INIT_PATH" restart; then
|
||||
# Modems may need several seconds to release and reopen their AT
|
||||
# port after procd stops the previous process. Require consecutive
|
||||
# healthy observations so a short-lived respawn is not mistaken for
|
||||
# a successful upgrade.
|
||||
local attempt stable
|
||||
stable=0
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
|
||||
sleep 1
|
||||
if "$OPENWRT_INIT_PATH" running; then
|
||||
stable=$((stable + 1))
|
||||
if [ "$stable" -ge 3 ]; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
else
|
||||
stable=0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||
"$OPENWRT_INIT_PATH" restart || true
|
||||
fi
|
||||
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable vocat
|
||||
if systemctl restart vocat; then
|
||||
return
|
||||
local attempt
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if systemctl is-active --quiet vocat; then
|
||||
rm -f "${BINARY_PATH}.bak"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
if [ -e "${BINARY_PATH}.bak" ]; then
|
||||
msg "新版本启动失败,正在恢复旧二进制。" "The new version failed to start; restoring the previous binary."
|
||||
@@ -261,27 +522,33 @@ enable_and_start() {
|
||||
}
|
||||
|
||||
# --- Main --------------------------------------------------------------------
|
||||
resolve_target_version
|
||||
detect_arch
|
||||
install_pcsc_support
|
||||
check_vowifi_environment
|
||||
if [ "$CHECK_ENV" -eq 1 ]; then
|
||||
msg "VoCat 运行环境检查完成。" "VoCat host environment check completed."
|
||||
exit 0
|
||||
fi
|
||||
resolve_target_version
|
||||
skip_if_equal
|
||||
download_and_verify
|
||||
install_binary
|
||||
ensure_data_dir
|
||||
bootstrap_admin
|
||||
setup_env
|
||||
write_unit
|
||||
write_service
|
||||
enable_and_start
|
||||
|
||||
if [ "$FIRST_INSTALL" -eq 1 ]; then
|
||||
secret=$(grep -E '^VOCAT_ADMIN_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)
|
||||
echo
|
||||
msg "================ 安装完成 ================" "================ Install complete ================"
|
||||
msg "首次安装已生成管理员初始密码 (仅显示一次):" "First-install admin password (shown once):"
|
||||
echo
|
||||
echo " $secret"
|
||||
echo " $INITIAL_ADMIN_PASSWORD"
|
||||
echo
|
||||
msg "用户名为 admin。请立即记录此密码。" "Username is admin. Record this password now."
|
||||
msg "登录后或运行以下命令修改密码:" "Change it via the web UI or run:"
|
||||
echo " sudo vocat menu"
|
||||
echo " vocat menu"
|
||||
msg "==========================================" "=============================================="
|
||||
else
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
START=95
|
||||
STOP=10
|
||||
USE_PROCD=1
|
||||
PROCD_TERM_TIMEOUT=40
|
||||
|
||||
PROGRAM=/opt/vocat/bin/vocat
|
||||
ENV_FILE=/etc/vocat/env
|
||||
|
||||
start_service() {
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROGRAM" serve
|
||||
procd_set_param env VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
|
||||
if [ -r "$ENV_FILE" ]; then
|
||||
while IFS='=' read -r name value; do
|
||||
case "$name" in
|
||||
VOCAT_*) procd_append_param env "$name=$value" ;;
|
||||
esac
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger vocat
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
|
||||
<Field label={t("IMEI 绑定")}>
|
||||
<Input value={addConfig.modemImei} disabled placeholder={t("自动识别(从发现设备填充)")} />
|
||||
</Field>
|
||||
<Field label={t("USB 路径")}>
|
||||
<Field label={t("硬件路径")}>
|
||||
<Input value={addConfig.usbPath} disabled />
|
||||
</Field>
|
||||
<Field label={t("网卡接口")}>
|
||||
|
||||
@@ -27,9 +27,10 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
const [operatorOpen, setOperatorOpen] = useState(false);
|
||||
const { device } = props;
|
||||
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
|
||||
const showNetworkDetails = !!device.developerEnabled && !wifiCallingOnly;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
|
||||
<div className={`grid grid-cols-1 gap-4 ${showNetworkDetails ? "lg:grid-cols-3" : "lg:grid-cols-2"}`}>
|
||||
<div className="ui-panel-muted p-4">
|
||||
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
|
||||
@@ -45,7 +46,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
e911Starting={props.e911Starting}
|
||||
onSetupE911={props.onSetupE911}
|
||||
/>
|
||||
{!wifiCallingOnly ? <OverviewNetworkPanel
|
||||
{showNetworkDetails ? <OverviewNetworkPanel
|
||||
device={device}
|
||||
trafficMinuteRx={props.trafficMinuteRx}
|
||||
trafficMinuteTx={props.trafficMinuteTx}
|
||||
|
||||
@@ -18,6 +18,12 @@ export function DiscoveredDeviceRow({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const degraded = !!device.degraded;
|
||||
const displayName = device.readerName || device.driverName || device.netInterface || device.controlPath || t("未知设备");
|
||||
const discoveryMessage = device.discoveryIssue === "pcsc_service_unavailable"
|
||||
? t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。")
|
||||
: device.discoveryIssue === "pcsc_driver_missing"
|
||||
? t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。")
|
||||
: "";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -31,15 +37,15 @@ export function DiscoveredDeviceRow({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-bold text-gray-800">
|
||||
<span>
|
||||
{device.netInterface || "--"} · {device.driverName || "--"}
|
||||
</span>
|
||||
<span>{displayName}</span>
|
||||
<Tag type={isQmi ? "success" : "warning"}>{modeLabel}</Tag>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">
|
||||
{device.controlPath} · AT: {device.atPort || "--"} · IMEI: {device.imei || "--"} · USB: {device.usbPath || "--"}
|
||||
{device.hardwareKind === "pcsc"
|
||||
? `USB: ${device.usbPath || "--"} · VID:PID ${device.vendorId.toString(16).padStart(4, "0")}:${device.productId.toString(16).padStart(4, "0")}`
|
||||
: `${device.controlPath} · AT: ${device.atPort || "--"} · IMEI: ${device.imei || "--"} · USB: ${device.usbPath || "--"}`}
|
||||
</div>
|
||||
{degraded ? <div className="mt-1 text-xs text-amber-700">{t("未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。")}</div> : null}
|
||||
{degraded ? <div className="mt-1 text-xs text-amber-700">{discoveryMessage || t("未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。")}</div> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,11 +77,7 @@ export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx,
|
||||
: "";
|
||||
|
||||
if (!developerActive) {
|
||||
return (
|
||||
<div className="ui-panel-muted p-4">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AddRegular, DeleteRegular } from "@fluentui/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, apiMessage } from "../../api";
|
||||
import { api } from "../../api";
|
||||
import type { DeviceListItem, DeviceProxyBinding, EsimOverview, ProfileProxyCandidate, UpstreamProxy } from "../../types";
|
||||
import { Button, EmptyState, Modal, Tag, message } from "../ui";
|
||||
import { Button, EmptyState, Modal, Tag } from "../ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface DeviceBindingsDialogProps {
|
||||
@@ -29,7 +29,10 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const [candidates, setCandidates] = useState<ProfileProxyCandidate[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const proxyName = proxy?.name || proxy?.id || "";
|
||||
const deviceKey = devices.map((device) => device.id).sort().join("|");
|
||||
const deviceKey = devices
|
||||
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const current = useMemo(
|
||||
() => bindings.filter((item) => item.upstreamProxyId === proxy?.id),
|
||||
[bindings, proxy?.id],
|
||||
@@ -50,13 +53,29 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
let active = true;
|
||||
setLoadingProfiles(true);
|
||||
Promise.allSettled(devices.map(async (device) => {
|
||||
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
|
||||
return (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({
|
||||
deviceId: device.id,
|
||||
iccid: String(profile.iccid || "").trim(),
|
||||
profileName: profileLabel(profile),
|
||||
stateText: profile.stateText,
|
||||
}))).filter((profile) => profile.iccid);
|
||||
const currentICCID = String(device.modem?.iccid || "").trim();
|
||||
let installed: ProfileProxyCandidate[] = [];
|
||||
try {
|
||||
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
|
||||
installed = (data.profiles || []).flatMap((group) => (group.profiles || []).map((profile) => ({
|
||||
deviceId: device.id,
|
||||
iccid: String(profile.iccid || "").trim(),
|
||||
profileName: profileLabel(profile),
|
||||
stateText: profile.stateText,
|
||||
}))).filter((profile) => profile.iccid);
|
||||
} catch {
|
||||
// A traditional SIM and some readers do not expose an eSIM profile
|
||||
// inventory. Their live ICCID is still a valid VoWiFi route key.
|
||||
}
|
||||
if (currentICCID && !installed.some((profile) => profile.iccid === currentICCID)) {
|
||||
installed.push({
|
||||
deviceId: device.id,
|
||||
iccid: currentICCID,
|
||||
profileName: t("当前 SIM 卡"),
|
||||
stateText: t("当前使用中"),
|
||||
});
|
||||
}
|
||||
return installed;
|
||||
})).then((results) => {
|
||||
if (!active) return;
|
||||
const unique = new Map<string, ProfileProxyCandidate>();
|
||||
@@ -65,8 +84,6 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
for (const profile of result.value) if (!unique.has(profile.iccid)) unique.set(profile.iccid, profile);
|
||||
}
|
||||
setCandidates(Array.from(unique.values()).sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.profileName.localeCompare(b.profileName)));
|
||||
}).catch((error) => {
|
||||
if (active) message.error(apiMessage(error) || t("读取 eSIM Profile 失败"));
|
||||
}).finally(() => {
|
||||
if (active) setLoadingProfiles(false);
|
||||
});
|
||||
@@ -94,13 +111,13 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const toggleAll = () => setSelected(allSelected ? [] : selectable);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 Profile 绑定") : t("Profile 绑定")} — ${proxyName}`} width="max-w-5xl">
|
||||
<Modal open={open} onClose={onClose} title={`${adding ? t("添加 SIM / Profile 绑定") : t("SIM / Profile 绑定")} — ${proxyName}`} width="max-w-5xl">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
|
||||
{t("VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。")}
|
||||
{t("VoWiFi 会按当前 ICCID 选择代理。实体 SIM 和 eSIM Profile 都可以绑定;同一 ICCID 只能绑定一个代理。")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-gray-500">{adding ? t("从设备已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 Profile")}`}</div>
|
||||
<div className="text-xs text-gray-500">{adding ? t("从当前 SIM 卡和已安装的 eSIM Profile 中选择") : `${current.length} ${t("个 SIM / Profile")}`}</div>
|
||||
<div className="flex gap-2">
|
||||
{adding ? (
|
||||
<Button size="small" onClick={() => { setAdding(false); setSelected([]); }}>{t("返回绑定列表")}</Button>
|
||||
@@ -130,7 +147,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
<th className="w-12 px-4 py-3"><input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={selectable.length === 0 || busy} aria-label={t("全选")} /></th>
|
||||
<th className="px-4 py-3">{t("设备 ID")}</th>
|
||||
<th className="px-4 py-3">ICCID</th>
|
||||
<th className="px-4 py-3">{t("Profile 名称")}</th>
|
||||
<th className="px-4 py-3">{t("SIM / Profile")}</th>
|
||||
{adding ? <th className="px-4 py-3">{t("状态")}</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -155,7 +172,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
</tbody>
|
||||
</table>
|
||||
{loadingProfiles ? <div className="px-6 py-12 text-center text-sm text-gray-400">{t("读取 Profile 中...")}</div> : null}
|
||||
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 eSIM Profile") : t("尚未绑定 Profile")} subtitle={adding ? t("请确认设备在线且支持 eSIM Profile 列表读取。") : t("点击添加,从设备 Profile 列表中选择。")}/>: null}
|
||||
{!loadingProfiles && rows.length === 0 ? <EmptyState title={adding ? t("没有可显示的 SIM / Profile") : t("尚未绑定 SIM / Profile")} subtitle={adding ? t("请确认设备在线并已读取到 SIM 卡 ICCID。") : t("点击添加,从 SIM / Profile 列表中选择。")}/>: null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<th className="px-4 py-3">{t("地址")}</th>
|
||||
<th className="px-4 py-3">{t("鉴权")}</th>
|
||||
<th className="px-4 py-3">{t("状态")}</th>
|
||||
<th className="px-4 py-3">{t("Profile 绑定")}</th>
|
||||
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
|
||||
<th className="px-4 py-3 text-right">{t("操作")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -53,12 +53,12 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
|
||||
<DesktopRegular className="text-[14px]" />
|
||||
<span>{row.bindingCount} {t("个 Profile")}</span>
|
||||
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
|
||||
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
|
||||
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
|
||||
<GlobeRegular className="mb-3 text-4xl" />
|
||||
<div className="text-sm">{t("暂无上游代理")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定需要使用它的 eSIM Profile;未绑定 Profile 默认直连。")}</div>
|
||||
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DeviceQuotaCard({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={value?.maxDeviceLimit ?? 128}
|
||||
max={value?.maxDeviceLimit ?? 10}
|
||||
value={Number.isFinite(limit) ? limit : ""}
|
||||
disabled={loading || saving}
|
||||
onChange={(event) => onLimitChange(Number(event.target.value))}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SMSRateLimitCard({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={value?.maxSmsHourlyLimit ?? 1000}
|
||||
max={value?.maxSmsHourlyLimit ?? 20}
|
||||
value={Number.isFinite(limit) ? limit : ""}
|
||||
disabled={loading || saving}
|
||||
onChange={(event) => onLimitChange(Number(event.target.value))}
|
||||
@@ -46,8 +46,8 @@ export function SMSRateLimitCard({
|
||||
/>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{zh
|
||||
? `采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。关闭开发者模式后恢复为 ${value?.defaultSmsHourlyLimit ?? 10} 条/小时。`
|
||||
: `Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited. Disabling developer mode restores ${value?.defaultSmsHourlyLimit ?? 10} messages/hour.`}
|
||||
? "采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。"
|
||||
: "Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited."}
|
||||
</p>
|
||||
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
|
||||
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
|
||||
|
||||
+22
-1
@@ -5,6 +5,10 @@
|
||||
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
|
||||
*/
|
||||
export const EN_DICT: Record<string, string> = {
|
||||
"未知设备": "Unknown device",
|
||||
"系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。": "The USB card reader was found, but the PC/SC service is not running. Install and start pcscd, then scan again.",
|
||||
"系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。": "The USB card reader was found, but its PC/SC driver is not loaded. Install libccid or the vendor driver, then scan again.",
|
||||
硬件路径: "Hardware Path",
|
||||
"USB SIM 读卡器(仅 WiFi Calling)": "USB SIM Reader (WiFi Calling only)",
|
||||
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
|
||||
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
|
||||
@@ -83,6 +87,22 @@ export const EN_DICT: Record<string, string> = {
|
||||
"设备绑定": "Device Bindings",
|
||||
"Profile 绑定": "Profile Bindings",
|
||||
"添加 Profile 绑定": "Add Profile Bindings",
|
||||
"SIM / Profile 绑定": "SIM / Profile Bindings",
|
||||
"添加 SIM / Profile 绑定": "Add SIM / Profile Bindings",
|
||||
"VoWiFi 会按当前 ICCID 选择代理。实体 SIM 和 eSIM Profile 都可以绑定;同一 ICCID 只能绑定一个代理。":
|
||||
"VoWiFi selects its proxy by the active ICCID. Both physical SIMs and eSIM profiles can be bound, and each ICCID can use only one proxy.",
|
||||
"从当前 SIM 卡和已安装的 eSIM Profile 中选择": "Select from the current SIM and installed eSIM profiles",
|
||||
"个 SIM / Profile": "SIMs / profiles",
|
||||
"当前 SIM 卡": "Current SIM",
|
||||
"当前使用中": "Currently active",
|
||||
"SIM / Profile": "SIM / Profile",
|
||||
"没有可显示的 SIM / Profile": "No SIMs or profiles to display",
|
||||
"尚未绑定 SIM / Profile": "No SIMs or profiles bound",
|
||||
"请确认设备在线并已读取到 SIM 卡 ICCID。": "Make sure the device is online and its SIM ICCID has been read.",
|
||||
"点击添加,从 SIM / Profile 列表中选择。": "Click Add and select from the SIM / profile list.",
|
||||
"管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定": "Manage VoWiFi upstream proxies and physical SIM / eSIM profile bindings",
|
||||
"点击“新增代理”创建 SOCKS5 上游代理,再按 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。":
|
||||
"Create a SOCKS5 upstream proxy, then bind a physical SIM or eSIM profile by ICCID. Unbound SIMs use a direct connection.",
|
||||
"VoWiFi 会按当前 ICCID 选择代理。同一 ICCID 只能绑定一个代理,一个代理可以绑定多台设备上的多个 Profile。":
|
||||
"VoWiFi selects its proxy by the active ICCID. An ICCID can use only one proxy, while one proxy can serve profiles across multiple devices.",
|
||||
"从设备已安装的 eSIM Profile 中选择": "Select from eSIM profiles installed on the devices",
|
||||
@@ -157,6 +177,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
短信检测: "SMS Test",
|
||||
自动任务: "Automatic Tasks",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
添加任务: "Add Task",
|
||||
"设备 / Profile": "Device / Profile",
|
||||
执行环境: "Environment",
|
||||
@@ -193,7 +214,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
任务类型: "Task Type",
|
||||
开启漫游流量并获取一次公网IP: "Enable roaming data and get the public IP once",
|
||||
"基站直连(自动选网)": "Cellular (automatic network selection)",
|
||||
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface. Developer mode is required.",
|
||||
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface.",
|
||||
首次执行日期: "First Run Date",
|
||||
执行时间: "Run Time",
|
||||
执行周期: "Interval",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SendClockRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { api, apiMessage } from "../api";
|
||||
import type { DeviceListItem, DevicesResponse } from "../types";
|
||||
import type { DeviceListItem, DevicesResponse, SystemInfo } from "../types";
|
||||
import type { EsimProfileGroup } from "../components/devices/types";
|
||||
import {
|
||||
Button,
|
||||
@@ -140,6 +140,7 @@ export default function AutomaticTasksPage() {
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
const [runsPageSize, setRunsPageSize] = useState(20);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [advancedTasksAvailable, setAdvancedTasksAvailable] = useState(false);
|
||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
@@ -155,12 +156,14 @@ export default function AutomaticTasksPage() {
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
try {
|
||||
const [taskData, deviceData] = await Promise.all([
|
||||
const [taskData, deviceData, systemInfo] = await Promise.all([
|
||||
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
api<SystemInfo>("/system/info"),
|
||||
]);
|
||||
setTasks(taskData.tasks || []);
|
||||
setDevices(deviceData.devices || []);
|
||||
setAdvancedTasksAvailable(!!systemInfo.developer);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
@@ -272,6 +275,9 @@ export default function AutomaticTasksPage() {
|
||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
||||
}
|
||||
if (!advancedTasksAvailable && (next.taskType === "public_ip" || next.environment === "cellular")) {
|
||||
next = { ...next, taskType: "sms", environment: "vowifi" };
|
||||
}
|
||||
setForm(next);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
@@ -282,7 +288,7 @@ export default function AutomaticTasksPage() {
|
||||
setForm((current) => ({
|
||||
...current, deviceId, profileIccid: "", profileAid: "",
|
||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||
environment: reader ? "vowifi" : current.environment,
|
||||
environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment,
|
||||
}));
|
||||
void loadProfiles(deviceId);
|
||||
}
|
||||
@@ -293,7 +299,7 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
|
||||
function chooseTaskType(taskType: TaskType) {
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
|
||||
if ((!advancedTasksAvailable || deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader") && taskType === "public_ip") return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
taskType,
|
||||
@@ -308,6 +314,7 @@ export default function AutomaticTasksPage() {
|
||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
||||
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
||||
}
|
||||
if (!advancedTasksAvailable && (form.environment !== "vowifi" || form.taskType === "public_ip")) return;
|
||||
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
|
||||
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
||||
setSaving(true);
|
||||
@@ -393,9 +400,9 @@ export default function AutomaticTasksPage() {
|
||||
const taskTypeOptions = [
|
||||
{ value: "sms", label: t("发送短信") },
|
||||
{ value: "call", label: t("拨打电话并自动挂断") },
|
||||
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
|
||||
...(advancedTasksAvailable && !selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
|
||||
];
|
||||
const environmentOptions = selectedTaskDeviceIsReader
|
||||
const environmentOptions = selectedTaskDeviceIsReader || !advancedTasksAvailable
|
||||
? [{ value: "vowifi", label: "VoWiFi" }]
|
||||
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
|
||||
|
||||
@@ -403,7 +410,9 @@ export default function AutomaticTasksPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("自动任务")}
|
||||
subtitle={t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")}
|
||||
subtitle={advancedTasksAvailable
|
||||
? t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||
: t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
||||
/>
|
||||
|
||||
@@ -475,8 +484,8 @@ export default function AutomaticTasksPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
{runsTotal > 0 ? (
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
{runsTotal > 0 ? (
|
||||
<div className="border-t border-gray-100 px-5 py-3 dark:border-white/10">
|
||||
<Pagination
|
||||
page={runsPage}
|
||||
@@ -501,7 +510,7 @@ export default function AutomaticTasksPage() {
|
||||
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
|
||||
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
|
||||
{form.taskType === "sms" ? <div className="md:col-span-2"><label className={fieldLabel}>{t("短信内容")}</label><Textarea rows={4} value={form.message} onChange={(event) => setForm({ ...form, message: event.target.value })} /></div> : null}
|
||||
{form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。")}</div> : null}
|
||||
{advancedTasksAvailable && form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。")}</div> : null}
|
||||
|
||||
<div><label className={fieldLabel}>{t("首次执行日期")}</label><Input type="date" value={form.startDate} onChange={(event) => setForm({ ...form, startDate: event.target.value })} /></div>
|
||||
<div><label className={fieldLabel}>{t("执行时间")}</label><Input type="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
|
||||
|
||||
@@ -367,6 +367,14 @@ export default function DevicesPage() {
|
||||
}, [loadDiscovered]);
|
||||
|
||||
const selectDiscovered = useCallback((d: DiscoveredDevice) => {
|
||||
if (d.discoveryIssue === "pcsc_service_unavailable") {
|
||||
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 服务未运行;请安装并启动 pcscd 后重新扫描。"));
|
||||
return;
|
||||
}
|
||||
if (d.discoveryIssue === "pcsc_driver_missing") {
|
||||
message.warning(t("系统已发现 USB 读卡器,但 PC/SC 驱动未加载;请安装 libccid 或厂商驱动后重新扫描。"));
|
||||
return;
|
||||
}
|
||||
if (d.degraded) {
|
||||
message.warning(t("无法读取该设备 IMEI(可能控制口挂死),请执行 AT!RESET 或切换组态后重试"));
|
||||
return;
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function ProxyPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title={t("代理管理")}
|
||||
subtitle={t("管理 VoWiFi 上游代理和 eSIM Profile 绑定")}
|
||||
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
|
||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
|
||||
/>
|
||||
<UpstreamSection
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function SettingsPage() {
|
||||
}, [lang]);
|
||||
|
||||
const onSaveDeviceLimit = useCallback(async () => {
|
||||
const maximum = developerSettings?.maxDeviceLimit ?? 128;
|
||||
const maximum = developerSettings?.maxDeviceLimit ?? 10;
|
||||
if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) {
|
||||
message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`);
|
||||
return;
|
||||
@@ -196,7 +196,7 @@ export default function SettingsPage() {
|
||||
}, [developerSettings, deviceLimit, lang]);
|
||||
|
||||
const onSaveSMSHourlyLimit = useCallback(async () => {
|
||||
const maximum = developerSettings?.maxSmsHourlyLimit ?? 1000;
|
||||
const maximum = developerSettings?.maxSmsHourlyLimit ?? 20;
|
||||
if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) {
|
||||
message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`);
|
||||
return;
|
||||
|
||||
@@ -185,6 +185,7 @@ export interface DiscoveredDevice {
|
||||
configured: boolean;
|
||||
configuredId?: string;
|
||||
degraded?: boolean;
|
||||
discoveryIssue?: "pcsc_service_unavailable" | "pcsc_driver_missing" | string;
|
||||
usbnetMode?: number | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user