fix: persist admin credentials in database and discover MHI modems

This commit is contained in:
MengMengCode
2026-08-13 10:46:12 +08:00
parent c63c765d14
commit 0c380c1e07
24 changed files with 619 additions and 154 deletions
+4 -6
View File
@@ -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.
+22 -10
View File
@@ -130,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
```
@@ -149,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 \
@@ -166,15 +175,16 @@ 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`.
@@ -186,8 +196,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. |
@@ -195,6 +203,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
+60
View File
@@ -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
}
+57
View File
@@ -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
}
+12 -12
View File
@@ -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:
@@ -117,12 +124,6 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
defer instanceLock.Close()
if cfg.UsesDefaultCredentials() {
logger.Warn(
"default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service",
)
}
startupContext, cancelStartup := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelStartup()
@@ -182,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()
+32 -29
View File
@@ -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."
+4 -1
View File
@@ -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) {
+9 -7
View File
@@ -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
@@ -45,9 +48,8 @@ services:
# 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)
+11 -4
View File
@@ -130,9 +130,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 "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
```
@@ -149,13 +151,20 @@ sudo env \
```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 \
@@ -184,8 +193,6 @@ Quectel USB المدعومة (معرّف الشركة المصنعة USB `2c7c`)
| --- | --- | --- |
| `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. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | مهلة الإيقاف السلس. |
+11 -4
View File
@@ -130,9 +130,11 @@ Verifíquelo e instálelo:
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
```
@@ -149,13 +151,20 @@ seguir viendo los eventos de conexión en caliente USB, ejecute Vocat en modo de
```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 \
@@ -186,8 +195,6 @@ Vocat lee un archivo de configuración JSON opcional desde `VOCAT_CONFIG` y lueg
| --- | --- | --- |
| `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_ADMIN_USERNAME` | `admin` | Nombre de usuario administrador inicial. |
| `VOCAT_ADMIN_PASSWORD` | `admin` | Contraseña de administrador inicial. Cámbiela antes de exponer el servicio. |
| `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. |
+11 -4
View File
@@ -130,9 +130,11 @@ Vérifiez-le et installez-le :
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
```
@@ -149,13 +151,20 @@ continuer à voir les événements de branchement à chaud USB, exécutez 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 \
-e VOCAT_ADMIN_PASSWORD=change-this-password \
-v vocat-data:/opt/vocat/data \
-v /dev:/dev \
-v /sys:/sys:ro \
@@ -186,8 +195,6 @@ Vocat lit un fichier de configuration JSON optionnel depuis `VOCAT_CONFIG`, puis
| --- | --- | --- |
| `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_ADMIN_USERNAME` | `admin` | Nom d'utilisateur administrateur initial. |
| `VOCAT_ADMIN_PASSWORD` | `admin` | Mot de passe administrateur initial. Modifiez-le avant d'exposer le service. |
| `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. |
+11 -4
View File
@@ -126,9 +126,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 "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
```
@@ -141,13 +143,20 @@ sudo env \
```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 \
@@ -168,8 +177,6 @@ Vocat は `VOCAT_CONFIG` からオプションの JSON 設定ファイルを読
| --- | --- | --- |
| `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` | グレースフルシャットダウンのタイムアウト。 |
+11 -4
View File
@@ -130,9 +130,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 "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
```
@@ -149,13 +151,20 @@ sudo env \
```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 \
@@ -185,8 +194,6 @@ Vocat читает необязательный JSON-файл конфигура
| --- | --- | --- |
| `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` | Помечает cookie сессии как безопасные при использовании HTTPS. |
| `VOCAT_SHUTDOWN_TIMEOUT` | `10s` | Тайм-аут корректного завершения работы. |
+14 -4
View File
@@ -125,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
```
@@ -140,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 \
@@ -167,8 +176,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` | 优雅关闭超时时间。 |
@@ -176,6 +183,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 机器人
+11 -4
View File
@@ -126,9 +126,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
```
@@ -141,13 +143,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 \
@@ -168,8 +177,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` | 優雅關閉逾時時間。 |
+16
View File
@@ -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) {
+21
View File
@@ -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)
}
}
+9 -35
View File
@@ -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"
}
+20 -6
View File
@@ -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")
+169 -2
View File
@@ -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 {
+74
View File
@@ -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))
+28 -17
View File
@@ -10,9 +10,9 @@
# 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.
# - 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.
@@ -298,21 +298,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 ------------------------------------------------------------
@@ -480,17 +491,17 @@ skip_if_equal
download_and_verify
install_binary
ensure_data_dir
bootstrap_admin
setup_env
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:"
@@ -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("网卡接口")}>
+1
View File
@@ -5,6 +5,7 @@
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
*/
export const EN_DICT: Record<string, string> = {
: "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",