From 0c380c1e071b47d82aeadee7a019522008625f1d Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:46:12 +0800 Subject: [PATCH] fix: persist admin credentials in database and discover MHI modems --- .env.example | 10 +- README.md | 32 +++- cmd/vocat/bootstrap_admin.go | 60 ++++++ cmd/vocat/bootstrap_admin_test.go | 57 ++++++ cmd/vocat/main.go | 24 +-- cmd/vocat/menu.go | 61 ++++--- cmd/vocat/menu_test.go | 5 +- docker-compose.yml | 16 +- docs/README.ar.md | 15 +- docs/README.es.md | 15 +- docs/README.fr.md | 15 +- docs/README.ja.md | 15 +- docs/README.ru.md | 15 +- docs/README.zh-CN.md | 18 +- docs/README.zh-TW.md | 15 +- internal/auth/service.go | 16 ++ internal/auth/service_test.go | 21 +++ internal/config/config.go | 44 +---- internal/config/config_test.go | 26 ++- internal/modem/discovery.go | 171 +++++++++++++++++- internal/modem/discovery_test.go | 74 ++++++++ scripts/install.sh | 45 +++-- .../components/devices/DeviceAddDialog.tsx | 2 +- web/src/lib/i18n-en.ts | 1 + 24 files changed, 619 insertions(+), 154 deletions(-) create mode 100644 cmd/vocat/bootstrap_admin.go create mode 100644 cmd/vocat/bootstrap_admin_test.go diff --git a/.env.example b/.env.example index f52f6fb..c17d11e 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 23c99bd..4203152 100644 --- a/README.md +++ b/README.md @@ -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://: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 diff --git a/cmd/vocat/bootstrap_admin.go b/cmd/vocat/bootstrap_admin.go new file mode 100644 index 0000000..eed6ac8 --- /dev/null +++ b/cmd/vocat/bootstrap_admin.go @@ -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 +} diff --git a/cmd/vocat/bootstrap_admin_test.go b/cmd/vocat/bootstrap_admin_test.go new file mode 100644 index 0000000..76acdce --- /dev/null +++ b/cmd/vocat/bootstrap_admin_test.go @@ -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 +} diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 6900f74..4cd51fe 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -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() diff --git a/cmd/vocat/menu.go b/cmd/vocat/menu.go index 0ee70f9..d4746a8 100644 --- a/cmd/vocat/menu.go +++ b/cmd/vocat/menu.go @@ -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." diff --git a/cmd/vocat/menu_test.go b/cmd/vocat/menu_test.go index 4c4d741..2cf3d40 100644 --- a/cmd/vocat/menu_test.go +++ b/cmd/vocat/menu_test.go @@ -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) { diff --git a/docker-compose.yml b/docker-compose.yml index 9ac9d02..1484e02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docs/README.ar.md b/docs/README.ar.md index a6c26d3..d632d83 100644 --- a/docs/README.ar.md +++ b/docs/README.ar.md @@ -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` | مهلة الإيقاف السلس. | diff --git a/docs/README.es.md b/docs/README.es.md index 2b9a887..bbfef05 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -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. | diff --git a/docs/README.fr.md b/docs/README.fr.md index 2c17943..65fbb1b 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -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. | diff --git a/docs/README.ja.md b/docs/README.ja.md index 3864332..497766b 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -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` | グレースフルシャットダウンのタイムアウト。 | diff --git a/docs/README.ru.md b/docs/README.ru.md index 944415f..1e97140 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -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` | Тайм-аут корректного завершения работы. | diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index c7ab927..2f572ee 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -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 机器人 diff --git a/docs/README.zh-TW.md b/docs/README.zh-TW.md index ec896e4..758a465 100644 --- a/docs/README.zh-TW.md +++ b/docs/README.zh-TW.md @@ -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` | 優雅關閉逾時時間。 | diff --git a/internal/auth/service.go b/internal/auth/service.go index a917691..7b587e7 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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) { diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index 411d897..b804a2e 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a17a422..ce3c820 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c4cae8..a54426b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go index 69d8671..33aa786 100644 --- a/internal/modem/discovery.go +++ b/internal/modem/discovery.go @@ -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 { diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go index 0dd1c72..69c4ebe 100644 --- a/internal/modem/discovery_test.go +++ b/internal/modem/discovery_test.go @@ -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)) diff --git a/scripts/install.sh b/scripts/install.sh index 92c688f..c505a77 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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:" diff --git a/web/src/components/devices/DeviceAddDialog.tsx b/web/src/components/devices/DeviceAddDialog.tsx index f991a4e..f6d40b8 100644 --- a/web/src/components/devices/DeviceAddDialog.tsx +++ b/web/src/components/devices/DeviceAddDialog.tsx @@ -150,7 +150,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) { - + diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index bcd9fe7..83c1c03 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -5,6 +5,7 @@ * 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。 */ export const EN_DICT: Record = { + 硬件路径: "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",