mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-21 07:13:43 +08:00
fix: persist admin credentials in database and discover MHI modems
This commit is contained in:
+4
-6
@@ -1,6 +1,4 @@
|
||||
# Copy this file to .env and fill in real values before `docker compose up -d`.
|
||||
# .env is gitignored; .env.example is tracked as a template.
|
||||
|
||||
# Admin password for the web UI. REQUIRED — the server refuses to start safely
|
||||
# without it once exposed. Pick a strong password.
|
||||
VOCAT_ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||
# VoCat no longer stores administrator credentials in .env.
|
||||
# Initialize a new Docker database with the bootstrap-admin command documented
|
||||
# at the top of docker-compose.yml. Keep this file only for optional, non-secret
|
||||
# Compose substitutions added by an operator.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func runBootstrapAdmin(args []string) error {
|
||||
flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
databasePath := flags.String("database", "/opt/vocat/data/vocat.db", "database path")
|
||||
username := flags.String("username", "admin", "administrator username")
|
||||
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
|
||||
return errors.New("usage: vocat bootstrap-admin [--database path] [--username name]")
|
||||
}
|
||||
reader := bufio.NewReader(io.LimitReader(os.Stdin, 2049))
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
|
||||
if len(password) < 12 || len(password) > 1024 {
|
||||
return errors.New("bootstrap password must contain between 12 and 1024 characters")
|
||||
}
|
||||
adminUsername := strings.TrimSpace(*username)
|
||||
if len(adminUsername) < 1 || len(adminUsername) > 64 || strings.ContainsAny(adminUsername, "\r\n\t") {
|
||||
return errors.New("bootstrap username must contain between 1 and 64 characters without control whitespace")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
database, err := store.Open(ctx, strings.TrimSpace(*databasePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
service, err := auth.New(database, auth.Options{SessionTTL: 24 * time.Hour})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err := service.EnsureAdminIfMissing(ctx, adminUsername, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created {
|
||||
fmt.Println("created")
|
||||
} else {
|
||||
fmt.Println("exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vocat/internal/auth"
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
func TestBootstrapAdminOnlyInitializesAnEmptyDatabase(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "vocat.db")
|
||||
withBootstrapStdin(t, "first-secure-password\n", func() {
|
||||
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
withBootstrapStdin(t, "second-secure-password\n", func() {
|
||||
if err := runBootstrapAdmin([]string{"--database", path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
database, err := store.Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
service, err := auth.New(database, auth.Options{SessionTTL: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Login(context.Background(), "admin", "first-secure-password"); err != nil {
|
||||
t.Fatalf("initial password was overwritten: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withBootstrapStdin(t *testing.T, input string, action func()) {
|
||||
t.Helper()
|
||||
original := os.Stdin
|
||||
file, err := os.CreateTemp(t.TempDir(), "stdin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdin = file
|
||||
t.Cleanup(func() { os.Stdin = original; _ = file.Close() })
|
||||
action()
|
||||
os.Stdin = original
|
||||
}
|
||||
+12
-12
@@ -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
@@ -22,9 +22,8 @@ import (
|
||||
"vocat/internal/update"
|
||||
)
|
||||
|
||||
// envFilePath is the systemd EnvironmentFile that carries VOCAT_ADMIN_PASSWORD.
|
||||
// EnsureAdmin reseeds the DB from it on every start, so change-password must
|
||||
// rewrite it or the next restart reverts the password.
|
||||
// envFilePath carries non-secret service settings such as the Web listen port.
|
||||
// Administrator credentials live exclusively in the database.
|
||||
const envFilePath = "/etc/vocat/env"
|
||||
|
||||
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
|
||||
@@ -51,8 +50,8 @@ const uiPreferencesSettingKey = "ui.preferences"
|
||||
// rc) and VOCAT_DATABASE_PATH is unset, so config.Load() would resolve a
|
||||
// CWD-relative ./data/vocat.db — a different, empty database than
|
||||
// /opt/vocat/data/vocat.db the service uses. This loads the installed env file
|
||||
// for VOCAT_ADMIN_PASSWORD and pins VOCAT_DATABASE_PATH to the install default,
|
||||
// without overriding any value the operator already exported.
|
||||
// and pins VOCAT_DATABASE_PATH to the install default, without overriding any
|
||||
// value the operator already exported. Legacy credential entries are ignored.
|
||||
func loadMenuEnv() {
|
||||
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
|
||||
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
|
||||
@@ -68,6 +67,9 @@ func loadMenuEnv() {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
if key == "VOCAT_ADMIN_USERNAME" || key == "VOCAT_ADMIN_PASSWORD" || key == "VOCAT_ADMIN_PASSWORD_B64" {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
if _, ok := os.LookupEnv(key); !ok {
|
||||
_ = os.Setenv(key, val)
|
||||
@@ -126,7 +128,7 @@ func runMenu(logger *slog.Logger) error {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "2":
|
||||
if err := menuChangePassword(reader, menu, logger); err != nil {
|
||||
if err := menuChangePassword(reader, menu); err != nil {
|
||||
fmt.Println(menu.errorPrefix(err))
|
||||
}
|
||||
case "3":
|
||||
@@ -191,7 +193,7 @@ func loadMenuLanguage() (string, error) {
|
||||
return "en", nil
|
||||
}
|
||||
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) error {
|
||||
func menuChangePassword(reader *bufio.Reader, m *menu) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuConfig, err)
|
||||
@@ -209,6 +211,10 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
||||
}
|
||||
admin, err := database.CurrentAdmin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", errMenuStore, err)
|
||||
}
|
||||
|
||||
fmt.Print(m.currentPassword())
|
||||
currentPw, err := readPasswordMasked()
|
||||
@@ -229,19 +235,12 @@ func menuChangePassword(reader *bufio.Reader, m *menu, logger *slog.Logger) erro
|
||||
if newPw != confirmPw {
|
||||
return errPasswordsDiffer
|
||||
}
|
||||
if err := authService.ChangePassword(ctx, cfg.AdminUsername, currentPw, newPw); err != nil {
|
||||
if err := authService.ChangePassword(ctx, admin.Username, currentPw, newPw); err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
return errCurrentWrong
|
||||
}
|
||||
return fmt.Errorf("%w: %v", errMenuAuth, err)
|
||||
}
|
||||
// Persist the new plaintext to the env file so the next EnsureAdmin (on
|
||||
// restart) agrees with the hash we just wrote to the DB. Without this the
|
||||
// restart reverts the password to whatever the env file still holds.
|
||||
if err := rewriteEnvPassword(newPw); err != nil {
|
||||
logger.Error("menu: password changed in DB but env file rewrite failed; restart will revert", "error", err)
|
||||
return fmt.Errorf("%w: %v", errMenuEnvWrite, err)
|
||||
}
|
||||
fmt.Println(m.passwordChanged())
|
||||
return nil
|
||||
}
|
||||
@@ -258,20 +257,15 @@ func readPasswordMasked() (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// rewriteEnvPassword replaces (or appends) the VOCAT_ADMIN_PASSWORD line in the
|
||||
// systemd EnvironmentFile and keeps the file 0600. The replacement is atomic:
|
||||
// the temp file lives in the same directory so os.Rename stays on one
|
||||
// filesystem.
|
||||
func rewriteEnvPassword(newPassword string) error {
|
||||
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
|
||||
}
|
||||
|
||||
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
|
||||
// write is atomic and rejects line breaks so one setting cannot inject another.
|
||||
func rewriteEnvValue(path, name, value string) error {
|
||||
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
||||
return errors.New("invalid environment setting")
|
||||
}
|
||||
if strings.HasPrefix(name, "VOCAT_ADMIN_") {
|
||||
return errors.New("administrator credentials cannot be stored in the environment file")
|
||||
}
|
||||
key := name + "="
|
||||
var lines []string
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
@@ -282,12 +276,17 @@ func rewriteEnvValue(path, name, value string) error {
|
||||
|
||||
replaced := false
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "VOCAT_ADMIN_USERNAME=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD=") || strings.HasPrefix(line, "VOCAT_ADMIN_PASSWORD_B64=") {
|
||||
lines[i] = ""
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, key) {
|
||||
lines[i] = key + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
lines = compactNonEmptyLines(lines)
|
||||
if !replaced {
|
||||
lines = append(lines, key+value)
|
||||
}
|
||||
@@ -298,6 +297,16 @@ func rewriteEnvValue(path, name, value string) error {
|
||||
return writeEnvFileAtomic(path, []byte(content))
|
||||
}
|
||||
|
||||
func compactNonEmptyLines(lines []string) []string {
|
||||
result := lines[:0]
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeEnvFileAtomic(path string, content []byte) error {
|
||||
dirIndex := strings.LastIndexAny(path, "/\\")
|
||||
if dirIndex < 0 {
|
||||
@@ -562,7 +571,6 @@ var (
|
||||
errMenuConfig = errors.New("menu: load configuration")
|
||||
errMenuStore = errors.New("menu: open database")
|
||||
errMenuAuth = errors.New("menu: auth service")
|
||||
errMenuEnvWrite = errors.New("menu: write env file")
|
||||
errMenuPortWrite = errors.New("menu: write Web port")
|
||||
errInvalidWebPort = errors.New("menu: invalid Web port")
|
||||
errWebPortUnavailable = errors.New("menu: Web port unavailable")
|
||||
@@ -702,11 +710,6 @@ func (m *menu) errorPrefix(err error) string {
|
||||
return "Auth service error."
|
||||
}
|
||||
return "认证服务错误。"
|
||||
case errors.Is(err, errMenuEnvWrite):
|
||||
if m.lang == "en" {
|
||||
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
|
||||
}
|
||||
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
|
||||
case errors.Is(err, errInvalidWebPort):
|
||||
if m.lang == "en" {
|
||||
return "Invalid port. Enter a number from 1 to 65535."
|
||||
|
||||
@@ -53,12 +53,15 @@ func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(content)
|
||||
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
if strings.Contains(got, "VOCAT_ADMIN_PASSWORD") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
|
||||
t.Fatalf("rewritten env = %q", got)
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
|
||||
t.Fatal("environment line injection was accepted")
|
||||
}
|
||||
if err := rewriteEnvValue(path, "VOCAT_ADMIN_PASSWORD", "changed-password"); err == nil {
|
||||
t.Fatal("administrator credential was accepted for the environment file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
|
||||
|
||||
+9
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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` | 優雅關閉逾時時間。 |
|
||||
|
||||
@@ -102,6 +102,22 @@ func (s *Service) EnsureAdmin(ctx context.Context, username string, password str
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureAdminIfMissing initializes the administrator only for a new database.
|
||||
// Once an administrator exists, the database is the sole credential source;
|
||||
// process configuration must never overwrite a password changed through the UI
|
||||
// or CLI on a later restart.
|
||||
func (s *Service) EnsureAdminIfMissing(ctx context.Context, username string, password string) (bool, error) {
|
||||
if _, err := s.store.CurrentAdmin(ctx); err == nil {
|
||||
return false, nil
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return false, fmt.Errorf("auth: read configured admin: %w", err)
|
||||
}
|
||||
if err := s.EnsureAdmin(ctx, username, password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) {
|
||||
admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
|
||||
@@ -96,3 +96,24 @@ func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) {
|
||||
t.Fatalf("login with new password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminIfMissingDoesNotOverwriteChangedPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newTestService(t)
|
||||
if err := service.ChangePassword(ctx, "admin", "correct-password", "changed-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := service.EnsureAdminIfMissing(ctx, "admin", "stale-config-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Fatal("existing administrator was reported as newly created")
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", "changed-password"); err != nil {
|
||||
t.Fatalf("database password was overwritten: %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "admin", "stale-config-password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("stale configured password became active: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ const maxConfigBytes = 1 << 20
|
||||
type Config struct {
|
||||
Address string
|
||||
DatabasePath string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
SessionTTL time.Duration
|
||||
SecureCookies bool
|
||||
ShutdownTimeout time.Duration
|
||||
@@ -28,25 +26,25 @@ type Config struct {
|
||||
}
|
||||
|
||||
type fileConfig struct {
|
||||
Address *string `json:"address"`
|
||||
DatabasePath *string `json:"database_path"`
|
||||
AdminUsername *string `json:"admin_username"`
|
||||
AdminPassword *string `json:"admin_password"`
|
||||
Address *string `json:"address"`
|
||||
DatabasePath *string `json:"database_path"`
|
||||
// Retain the legacy keys only so upgrades do not reject an existing config
|
||||
// file. They are deliberately ignored: administrator credentials are read
|
||||
// exclusively from SQLite.
|
||||
LegacyAdminUsername *string `json:"admin_username"`
|
||||
LegacyAdminPassword *string `json:"admin_password"`
|
||||
SessionTTL *string `json:"session_ttl"`
|
||||
SecureCookies *bool `json:"secure_cookies"`
|
||||
ShutdownTimeout *string `json:"shutdown_timeout"`
|
||||
MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
|
||||
}
|
||||
|
||||
// Default returns a configuration suitable for a first local deployment.
|
||||
// Operators should replace the bootstrap password through
|
||||
// VOCAT_ADMIN_PASSWORD before exposing the service.
|
||||
// Default returns the non-secret process configuration. Administrator
|
||||
// credentials are initialized separately and stored only in SQLite.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Address: "0.0.0.0:7575",
|
||||
DatabasePath: "./data/vocat.db",
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "admin",
|
||||
SessionTTL: 24 * time.Hour,
|
||||
SecureCookies: false,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
@@ -116,12 +114,6 @@ func applyFile(cfg *Config, values fileConfig) error {
|
||||
if values.DatabasePath != nil {
|
||||
cfg.DatabasePath = *values.DatabasePath
|
||||
}
|
||||
if values.AdminUsername != nil {
|
||||
cfg.AdminUsername = *values.AdminUsername
|
||||
}
|
||||
if values.AdminPassword != nil {
|
||||
cfg.AdminPassword = *values.AdminPassword
|
||||
}
|
||||
if values.SessionTTL != nil {
|
||||
duration, err := time.ParseDuration(*values.SessionTTL)
|
||||
if err != nil {
|
||||
@@ -154,8 +146,6 @@ func applyEnvironment(cfg *Config) error {
|
||||
|
||||
applyString("VOCAT_ADDR", &cfg.Address)
|
||||
applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
|
||||
applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername)
|
||||
applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword)
|
||||
|
||||
if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
|
||||
duration, err := time.ParseDuration(value)
|
||||
@@ -203,16 +193,6 @@ func (cfg Config) Validate() error {
|
||||
if strings.TrimSpace(cfg.DatabasePath) == "" {
|
||||
return errors.New("database_path must not be empty")
|
||||
}
|
||||
username := strings.TrimSpace(cfg.AdminUsername)
|
||||
if username == "" || len(username) > 64 {
|
||||
return errors.New("admin_username must contain between 1 and 64 characters")
|
||||
}
|
||||
if strings.ContainsAny(username, "\r\n\t") {
|
||||
return errors.New("admin_username must not contain control whitespace")
|
||||
}
|
||||
if cfg.AdminPassword == "" {
|
||||
return errors.New("admin_password must not be empty")
|
||||
}
|
||||
if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
|
||||
return errors.New("session_ttl must be between 5m and 720h")
|
||||
}
|
||||
@@ -224,9 +204,3 @@ func (cfg Config) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsesDefaultCredentials reports whether the documented bootstrap credentials
|
||||
// are still active.
|
||||
func (cfg Config) UsesDefaultCredentials() bool {
|
||||
return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ var configEnvironment = []string{
|
||||
"VOCAT_DATABASE_PATH",
|
||||
"VOCAT_ADMIN_USERNAME",
|
||||
"VOCAT_ADMIN_PASSWORD",
|
||||
"VOCAT_ADMIN_PASSWORD_B64",
|
||||
"VOCAT_SESSION_TTL",
|
||||
"VOCAT_SECURE_COOKIES",
|
||||
"VOCAT_SHUTDOWN_TIMEOUT",
|
||||
@@ -39,9 +40,6 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Address != "0.0.0.0:7575" {
|
||||
t.Fatalf("Address = %q", cfg.Address)
|
||||
}
|
||||
if !cfg.UsesDefaultCredentials() {
|
||||
t.Fatal("expected bootstrap credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
@@ -50,8 +48,6 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"address": "127.0.0.1:8000",
|
||||
"database_path": "/tmp/from-file.db",
|
||||
"admin_username": "operator",
|
||||
"admin_password": "from-file",
|
||||
"session_ttl": "2h",
|
||||
"secure_cookies": false,
|
||||
"shutdown_timeout": "12s",
|
||||
@@ -72,7 +68,7 @@ func TestLoadFileThenEnvironmentOverride(t *testing.T) {
|
||||
if cfg.Address != "0.0.0.0:9000" || !cfg.SecureCookies {
|
||||
t.Fatalf("environment override not applied: %+v", cfg)
|
||||
}
|
||||
if cfg.AdminUsername != "operator" || cfg.SessionTTL != 2*time.Hour {
|
||||
if cfg.SessionTTL != 2*time.Hour {
|
||||
t.Fatalf("file values not applied: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +86,24 @@ func TestLoadRejectsUnknownJSONField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresLegacyAdministratorConfiguration(t *testing.T) {
|
||||
clearConfigEnvironment(t)
|
||||
path := filepath.Join(t.TempDir(), "vocat.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"admin_username": "legacy-admin",
|
||||
"admin_password": "legacy-password"
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VOCAT_CONFIG", path)
|
||||
t.Setenv("VOCAT_ADMIN_USERNAME", "environment-admin")
|
||||
t.Setenv("VOCAT_ADMIN_PASSWORD", "environment-password")
|
||||
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load() rejected ignored legacy credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidEnvironment(t *testing.T) {
|
||||
clearConfigEnvironment(t)
|
||||
t.Setenv("VOCAT_SESSION_TTL", "tomorrow")
|
||||
|
||||
+169
-2
@@ -38,9 +38,10 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
entries, err := os.ReadDir(usbRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
entries = nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("discover Quectel USB devices: %w", err)
|
||||
}
|
||||
|
||||
aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id"))
|
||||
@@ -131,10 +132,176 @@ func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) {
|
||||
state.candidate.ATPort = selectATPort(state.candidate.Ports)
|
||||
result = append(result, state.candidate)
|
||||
}
|
||||
wwanCandidates, err := d.discoverWWAN(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, wwanCandidates...)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type discoveredWWANDevice struct {
|
||||
index string
|
||||
ports []Port
|
||||
qmiNames []string
|
||||
sysPath string
|
||||
}
|
||||
|
||||
// discoverWWAN covers PCIe/MHI modems exposed through Linux's wwan subsystem,
|
||||
// for example /dev/wwan0at0 and /dev/wwan0qmi0. These devices do not appear on
|
||||
// the USB bus and therefore need a separate discovery path.
|
||||
func (d *SysFSDiscoverer) discoverWWAN(ctx context.Context) ([]Candidate, error) {
|
||||
classRoot := filepath.Join(d.SysRoot, "class", "wwan")
|
||||
classEntries, err := os.ReadDir(classRoot)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("discover PCIe/MHI WWAN devices: %w", err)
|
||||
}
|
||||
classEntries = nil
|
||||
}
|
||||
|
||||
// Normal kernels expose these ports in /sys/class/wwan. Also inspect /dev
|
||||
// because some downstream MHI packages create the character devices but do
|
||||
// not populate the class directory in the host namespace/container.
|
||||
portNames := make(map[string]struct{})
|
||||
for _, entry := range classEntries {
|
||||
portNames[entry.Name()] = struct{}{}
|
||||
}
|
||||
if devEntries, devErr := os.ReadDir(d.DevRoot); devErr == nil {
|
||||
for _, entry := range devEntries {
|
||||
if _, _, _, ok := parseWWANPortName(entry.Name()); ok {
|
||||
portNames[entry.Name()] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(devErr) {
|
||||
return nil, fmt.Errorf("inspect WWAN device nodes: %w", devErr)
|
||||
}
|
||||
names := make([]string, 0, len(portNames))
|
||||
for name := range portNames {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
groups := make(map[string]*discoveredWWANDevice)
|
||||
for _, name := range names {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, kind, portIndex, ok := parseWWANPortName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
group := groups[index]
|
||||
if group == nil {
|
||||
group = &discoveredWWANDevice{index: index}
|
||||
groups[index] = group
|
||||
}
|
||||
classPath := filepath.Join(classRoot, name)
|
||||
if resolved, resolveErr := filepath.EvalSymlinks(classPath); resolveErr == nil {
|
||||
group.sysPath = filepath.Dir(resolved)
|
||||
}
|
||||
switch kind {
|
||||
case "at":
|
||||
group.ports = append(group.ports, Port{
|
||||
Path: filepath.Join(d.DevRoot, name), Name: name,
|
||||
InterfaceNumber: portIndex, Role: PortRoleAT,
|
||||
})
|
||||
case "qmi":
|
||||
group.qmiNames = append(group.qmiNames, name)
|
||||
}
|
||||
}
|
||||
result := make([]Candidate, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
sort.Slice(group.ports, func(i, j int) bool {
|
||||
return group.ports[i].InterfaceNumber < group.ports[j].InterfaceNumber
|
||||
})
|
||||
sort.Strings(group.qmiNames)
|
||||
if len(group.ports) == 0 && len(group.qmiNames) == 0 {
|
||||
continue
|
||||
}
|
||||
if group.sysPath == "" {
|
||||
group.sysPath = filepath.Join(classRoot, "wwan"+group.index)
|
||||
}
|
||||
vendorID, productID := readPCIIdentity(group.sysPath, d.SysRoot)
|
||||
manufacturer := ""
|
||||
if vendorID == "17cb" {
|
||||
manufacturer = "Qualcomm"
|
||||
}
|
||||
candidate := Candidate{
|
||||
HardwareKind: "wwan", ID: "mhi-wwan" + group.index,
|
||||
VendorID: vendorID, ProductID: productID, Manufacturer: manufacturer,
|
||||
Product: "PCIe/MHI WWAN modem", USBPath: group.sysPath,
|
||||
Ports: group.ports, NetworkInterface: selectWWANNetworkInterface(d.SysRoot, group.index),
|
||||
}
|
||||
if len(group.ports) > 0 {
|
||||
candidate.ATPort = group.ports[0]
|
||||
}
|
||||
if len(group.qmiNames) > 0 {
|
||||
candidate.QMIControl = filepath.Join(d.DevRoot, group.qmiNames[0])
|
||||
}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseWWANPortName(name string) (index, kind string, portIndex int, ok bool) {
|
||||
if !strings.HasPrefix(name, "wwan") {
|
||||
return "", "", 0, false
|
||||
}
|
||||
rest := strings.TrimPrefix(name, "wwan")
|
||||
cut := 0
|
||||
for cut < len(rest) && rest[cut] >= '0' && rest[cut] <= '9' {
|
||||
cut++
|
||||
}
|
||||
if cut == 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
index, rest = rest[:cut], rest[cut:]
|
||||
for _, candidateKind := range []string{"at", "qmi"} {
|
||||
if !strings.HasPrefix(rest, candidateKind) {
|
||||
continue
|
||||
}
|
||||
numberText := strings.TrimPrefix(rest, candidateKind)
|
||||
number, err := strconv.Atoi(numberText)
|
||||
if err != nil || number < 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
return index, candidateKind, number, true
|
||||
}
|
||||
return "", "", 0, false
|
||||
}
|
||||
|
||||
func selectWWANNetworkInterface(sysRoot, index string) string {
|
||||
exact := "wwan" + index
|
||||
if _, err := os.Stat(filepath.Join(sysRoot, "class", "net", exact)); err == nil {
|
||||
return exact
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(sysRoot, "class", "net"))
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), exact) {
|
||||
return entry.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readPCIIdentity(path, sysRoot string) (vendorID, productID string) {
|
||||
root := filepath.Clean(sysRoot)
|
||||
for current := filepath.Clean(path); current != "." && current != string(filepath.Separator); current = filepath.Dir(current) {
|
||||
vendor := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "vendor"))), "0x")
|
||||
device := strings.TrimPrefix(strings.ToLower(readTrimmed(filepath.Join(current, "device"))), "0x")
|
||||
if vendor != "" && device != "" {
|
||||
return vendor, device
|
||||
}
|
||||
if current == root {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func parseUSBInterfaceName(name string) (int, bool) {
|
||||
_, suffix, ok := strings.Cut(name, ":")
|
||||
if !ok {
|
||||
|
||||
@@ -235,6 +235,80 @@ func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsPCIeMHIWWANWithoutUSBBus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
wwanRoot := filepath.Join(sysRoot, "class", "wwan")
|
||||
for _, name := range []string{"wwan0at1", "wwan0qmi0", "wwan0at0"} {
|
||||
mustMkdir(t, filepath.Join(wwanRoot, name))
|
||||
}
|
||||
mustMkdir(t, filepath.Join(sysRoot, "class", "net", "wwan0"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %#v, want one MHI device", candidates)
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.ID != "mhi-wwan0" || candidate.HardwareKind != "wwan" {
|
||||
t.Fatalf("identity = %#v", candidate)
|
||||
}
|
||||
if candidate.ATPort.Path != filepath.Join(devRoot, "wwan0at0") || candidate.ATPort.Role != PortRoleAT {
|
||||
t.Fatalf("AT port = %#v", candidate.ATPort)
|
||||
}
|
||||
if candidate.QMIControl != filepath.Join(devRoot, "wwan0qmi0") {
|
||||
t.Fatalf("QMI control = %q", candidate.QMIControl)
|
||||
}
|
||||
if candidate.NetworkInterface != "wwan0" {
|
||||
t.Fatalf("network interface = %q", candidate.NetworkInterface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysFSDiscoveryFindsWWANFromDevNodesWithoutClassDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sysRoot := filepath.Join(root, "sys")
|
||||
devRoot := filepath.Join(root, "dev")
|
||||
for _, name := range []string{"wwan2at0", "wwan2qmi0"} {
|
||||
mustWrite(t, filepath.Join(devRoot, name), "")
|
||||
}
|
||||
mustMkdir(t, filepath.Join(sysRoot, "class", "net", "wwan2"))
|
||||
|
||||
candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("candidates = %#v, want one WWAN device", candidates)
|
||||
}
|
||||
if candidates[0].ATPort.Path != filepath.Join(devRoot, "wwan2at0") ||
|
||||
candidates[0].QMIControl != filepath.Join(devRoot, "wwan2qmi0") ||
|
||||
candidates[0].NetworkInterface != "wwan2" {
|
||||
t.Fatalf("candidate = %#v", candidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWWANPortName(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, index, kind string
|
||||
port int
|
||||
ok bool
|
||||
}{
|
||||
{"wwan0at0", "0", "at", 0, true},
|
||||
{"wwan12qmi3", "12", "qmi", 3, true},
|
||||
{"wwan0", "", "", 0, false},
|
||||
{"wwanXat0", "", "", 0, false},
|
||||
{"cdc-wdm0", "", "", 0, false},
|
||||
} {
|
||||
index, kind, port, ok := parseWWANPortName(test.name)
|
||||
if index != test.index || kind != test.kind || port != test.port || ok != test.ok {
|
||||
t.Fatalf("parseWWANPortName(%q) = %q, %q, %d, %v", test.name, index, kind, port, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
mustMkdir(t, filepath.Dir(path))
|
||||
|
||||
+28
-17
@@ -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("网卡接口")}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user