feat: 实现配置校验与运维命令及优雅停机

This commit is contained in:
Nixevol
2026-09-30 06:47:16 +08:00
parent 22c56d1f35
commit d777a1e3d3
17 changed files with 1053 additions and 26 deletions
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"strings"
"git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
const (
adminPasswordLen = 20
minAdminPasswordLen = 12
adminPasswordChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
)
func cmdAdmin(args []string) error {
if len(args) < 1 {
return errors.New("usage: nixmsg admin <init|set-password>")
}
switch args[0] {
case "init":
return cmdAdminInit(args[1:])
case "set-password":
return cmdAdminSetPassword(args[1:])
default:
return fmt.Errorf("unknown admin command: %s", args[0])
}
}
func cmdAdminInit(_ []string) error {
cfg, err := loadAndValidateConfig()
if err != nil {
return err
}
ctx := context.Background()
db, err := store.Open(cfg.DataDir, cfg.SQLiteSynchronous)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
ok, err := store.HasAdminPassword(ctx, db.Write)
if err != nil {
return err
}
if ok {
return errors.New("admin already initialized; use admin set-password")
}
password, err := generateAdminPassword(adminPasswordLen)
if err != nil {
return err
}
phc, err := auth.HashPassword(password)
if err != nil {
return err
}
if err := store.SetAdminPasswordHash(ctx, db.Write, phc); err != nil {
return err
}
// 密码只打印到终端一次,不进日志。
fmt.Printf("admin password: %s\n", password)
return nil
}
func cmdAdminSetPassword(args []string) error {
password, err := parseSetPasswordArgs(args, os.Stdin)
if err != nil {
return err
}
if vErr := validateAdminPassword(password); vErr != nil {
return vErr
}
cfg, err := loadAndValidateConfig()
if err != nil {
return err
}
ctx := context.Background()
db, err := store.Open(cfg.DataDir, cfg.SQLiteSynchronous)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
phc, err := auth.HashPassword(password)
if err != nil {
return err
}
if err := store.SetAdminPasswordHash(ctx, db.Write, phc); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "admin password updated")
return nil
}
func parseSetPasswordArgs(args []string, stdin io.Reader) (string, error) {
var password string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--password":
if i+1 >= len(args) {
return "", errors.New("usage: nixmsg admin set-password --password <password>")
}
password = args[i+1]
i++
default:
if strings.HasPrefix(args[i], "-") {
return "", fmt.Errorf("unknown flag: %s", args[i])
}
if password != "" {
return "", errors.New("usage: nixmsg admin set-password --password <password>")
}
password = args[i]
}
}
if password == "" {
b, err := io.ReadAll(io.LimitReader(stdin, 4096))
if err != nil {
return "", err
}
password = strings.TrimSpace(string(b))
}
if password == "" {
return "", errors.New("password required (pass --password or stdin)")
}
return password, nil
}
func validateAdminPassword(password string) error {
if len(password) < minAdminPasswordLen {
return fmt.Errorf("admin password must be at least %d characters", minAdminPasswordLen)
}
return nil
}
func generateAdminPassword(n int) (string, error) {
buf := make([]byte, n)
charset := []byte(adminPasswordChars)
for i := 0; i < n; {
var b [1]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
if int(b[0]) >= 256-(256%len(charset)) {
continue
}
buf[i] = charset[int(b[0])%len(charset)]
i++
}
return string(buf), nil
}
func loadAndValidateConfig() (config.Config, error) {
cfgPath := config.PathFromEnv()
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, err
}
if err := cfg.Validate(); err != nil {
return config.Config{}, err
}
return cfg, nil
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func cmdBackup(args []string) error {
outPath, err := parseBackupArgs(args)
if err != nil {
return err
}
cfg, err := loadAndValidateConfig()
if err != nil {
return err
}
if dir := filepath.Dir(outPath); dir != "" && dir != "." {
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
return fmt.Errorf("mkdir backup dir: %w", mkErr)
}
}
absOut, err := filepath.Abs(outPath)
if err != nil {
return err
}
ctx := context.Background()
// 对运行中的库:单独打开写连接执行 VACUUM INTO(可与 serve 并存,WAL 下安全)。
write, err := store.OpenWriter(cfg.DataDir, cfg.SQLiteSynchronous)
if err != nil {
return err
}
defer func() { _ = write.Close() }()
if err := store.VacuumInto(ctx, write, filepath.ToSlash(absOut)); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "backup written to %s\n", absOut)
return nil
}
func parseBackupArgs(args []string) (string, error) {
var out string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--out":
if i+1 >= len(args) {
return "", errors.New("usage: nixmsg backup --out <file.db>")
}
out = args[i+1]
i++
default:
return "", fmt.Errorf("unknown argument: %s", args[i])
}
}
if out == "" {
return "", errors.New("usage: nixmsg backup --out <file.db>")
}
return out, nil
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"fmt"
"os"
"git.asio.asia/nixevol/NixMsg/internal/config"
)
func cmdCheckConfig(args []string) error {
path := config.PathFromEnv()
if len(args) > 0 {
path = args[0]
}
cfg, err := config.Load(path)
if err != nil {
return err
}
if err := cfg.Validate(); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "config ok")
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func TestCheckConfigRejectsLargeBody(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yaml")
body := "listen: \":0\"\ndata_dir: \"" + filepath.ToSlash(dir) + "\"\nlimits:\n max_body_bytes: 262145\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("NIXMSG_CONFIG", path)
err := cmdCheckConfig(nil)
if err == nil || !strings.Contains(err.Error(), "max_body_bytes") {
t.Fatalf("want max_body_bytes error, got %v", err)
}
}
func TestCheckConfigOK(t *testing.T) {
dir := t.TempDir()
path := writeTestConfig(t, dir)
t.Setenv("NIXMSG_CONFIG", path)
if err := cmdCheckConfig(nil); err != nil {
t.Fatal(err)
}
}
func TestAdminInitOnceAndSetPassword(t *testing.T) {
dir := t.TempDir()
path := writeTestConfig(t, dir)
t.Setenv("NIXMSG_CONFIG", path)
var out bytes.Buffer
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
errInit := cmdAdminInit(nil)
_ = w.Close()
os.Stdout = old
_, _ = out.ReadFrom(r)
_ = r.Close()
if errInit != nil {
t.Fatal(errInit)
}
text := out.String()
if !strings.Contains(text, "admin password:") {
t.Fatalf("password not printed: %q", text)
}
pass := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "admin password:"))
if len(pass) != 20 {
t.Fatalf("password len=%d value=%q", len(pass), pass)
}
if err2 := cmdAdminInit(nil); err2 == nil || !strings.Contains(err2.Error(), "already initialized") {
t.Fatalf("want already initialized, got %v", err2)
}
if err2 := cmdAdminSetPassword([]string{"--password", "short"}); err2 == nil {
t.Fatal("expected short password error")
}
if err2 := cmdAdminSetPassword([]string{"--password", "long-enough-password"}); err2 != nil {
t.Fatal(err2)
}
db, openErr := store.Open(dir, "FULL")
if openErr != nil {
t.Fatal(openErr)
}
defer func() { _ = db.Close() }()
ok, hasErr := store.HasAdminPassword(context.Background(), db.Write)
if hasErr != nil || !ok {
t.Fatalf("has admin: ok=%v err=%v", ok, hasErr)
}
}
func TestBackupVacuumInto(t *testing.T) {
dir := t.TempDir()
path := writeTestConfig(t, dir)
t.Setenv("NIXMSG_CONFIG", path)
initAdminForTest(t, dir)
out := filepath.Join(dir, "backup", "copy.db")
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
t.Fatal(err)
}
if err := cmdBackup([]string{"--out", out}); err != nil {
t.Fatal(err)
}
st, err := os.Stat(out)
if err != nil {
t.Fatal(err)
}
if st.Size() == 0 {
t.Fatal("backup empty")
}
}
func TestHealthcheck(t *testing.T) {
dir := t.TempDir()
cfgPath := writeTestConfig(t, dir)
initAdminForTest(t, dir)
t.Setenv("NIXMSG_CONFIG", cfgPath)
cfg, err := loadAndValidateConfig()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() { errCh <- runServe(ctx, cfg) }()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if b, readErr := os.ReadFile(filepath.Join(dir, "listen.addr")); readErr == nil && strings.TrimSpace(string(b)) != "" {
break
}
time.Sleep(20 * time.Millisecond)
}
if err := cmdHealthcheck(nil); err != nil {
cancel()
<-errCh
t.Fatal(err)
}
cancel()
<-errCh
}
func TestParseSetPasswordArgs(t *testing.T) {
t.Parallel()
pass, err := parseSetPasswordArgs([]string{"--password", "abcdefghijkl"}, nil)
if err != nil || pass != "abcdefghijkl" {
t.Fatalf("pass=%q err=%v", pass, err)
}
pass, err = parseSetPasswordArgs([]string{"twelvechars!!"}, nil)
if err != nil || pass != "twelvechars!!" {
t.Fatalf("pass=%q err=%v", pass, err)
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func cmdHealthcheck(_ []string) error {
cfg, err := loadAndValidateConfig()
if err != nil {
return err
}
addrBytes, err := os.ReadFile(filepath.Join(cfg.DataDir, "listen.addr"))
if err != nil {
return fmt.Errorf("read listen.addr: %w", err)
}
addr := strings.TrimSpace(string(addrBytes))
if addr == "" {
return fmt.Errorf("listen.addr is empty")
}
url := "http://" + addr + "/healthz"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthz status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
+16 -4
View File
@@ -10,16 +10,28 @@ func main() {
fmt.Fprintln(os.Stderr, "usage: nixmsg <command>")
os.Exit(2)
}
var err error
switch os.Args[1] {
case "version":
cmdVersion(os.Args[2:])
case "serve":
if err := cmdServe(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = cmdServe(os.Args[2:])
// P-WIRE-BEGIN
case "admin":
err = cmdAdmin(os.Args[2:])
case "backup":
err = cmdBackup(os.Args[2:])
case "check-config":
err = cmdCheckConfig(os.Args[2:])
case "healthcheck":
err = cmdHealthcheck(os.Args[2:])
// P-WIRE-END
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+55 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -23,6 +25,12 @@ func cmdServe(_ []string) error {
if err != nil {
return err
}
// P-WIRE-BEGIN
if err := cfg.Validate(); err != nil {
return err
}
setupJSONLogger(cfg.Log)
// P-WIRE-END
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return runServe(ctx, cfg)
@@ -41,6 +49,16 @@ func runServe(ctx context.Context, cfg config.Config) error {
}
defer func() { _ = db.Close() }()
// P-WIRE-BEGIN
ok, err := store.HasAdminPassword(ctx, db.Write)
if err != nil {
return err
}
if !ok {
return errors.New("admin password not initialized; run: nixmsg admin init")
}
// P-WIRE-END
// 启动恢复入口已挂上(假实现为空操作);M 线替换 message.Service 后生效。
if recoverErr := deps.Messages.RecoverOnStart(ctx); recoverErr != nil {
return fmt.Errorf("message recover: %w", recoverErr)
@@ -62,6 +80,17 @@ func runServe(ctx context.Context, cfg config.Config) error {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// P-WIRE-BEGIN
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if readyErr := db.Ready(r.Context()); readyErr != nil {
slog.Error("readyz failed", "err", readyErr)
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// P-WIRE-END
// 确保前端资源被链接进二进制;完整静态托管由后续任务完善。
_ = web.Dist()
@@ -87,9 +116,17 @@ func runServe(ctx context.Context, cfg config.Config) error {
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// P-WIRE-BEGIN
// 先停止接受新连接,再等写队列最多 10 秒,然后断开并退出。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer drainCancel()
if drainErr := db.Queue.Drain(drainCtx); drainErr != nil && !errors.Is(drainErr, context.DeadlineExceeded) {
slog.Error("write queue drain", "err", drainErr)
}
// P-WIRE-END
serveErr := <-errCh
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
@@ -107,3 +144,20 @@ func writeListenAddr(dataDir, addr string) error {
path := filepath.Join(dataDir, "listen.addr")
return os.WriteFile(path, []byte(addr+"\n"), 0o644)
}
// P-WIRE-BEGIN
func setupJSONLogger(cfg config.LogConfig) {
level := slog.LevelInfo
switch strings.ToLower(strings.TrimSpace(cfg.Level)) {
case "debug":
level = slog.LevelDebug
case "warn", "warning":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(h))
}
// P-WIRE-END
+60 -7
View File
@@ -10,23 +10,51 @@ import (
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func writeTestConfig(t *testing.T, dataDir string) string {
t.Helper()
cfgPath := filepath.Join(dataDir, "config.yaml")
cfgYAML := []byte("listen: \"127.0.0.1:0\"\ndata_dir: \"" + filepath.ToSlash(dataDir) + "\"\nlog:\n level: error\n")
if err := os.WriteFile(cfgPath, cfgYAML, 0o644); err != nil {
t.Fatal(err)
}
return cfgPath
}
func initAdminForTest(t *testing.T, dataDir string) {
t.Helper()
db, err := store.Open(dataDir, "FULL")
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
phc, err := auth.HashPassword("test-admin-password-xx")
if err != nil {
t.Fatal(err)
}
if err := store.SetAdminPasswordHash(context.Background(), db.Write, phc); err != nil {
t.Fatal(err)
}
}
func TestServeHealthzAndListenAddr(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfgPath := filepath.Join(dataDir, "config.yaml")
cfgYAML := []byte("listen: \"127.0.0.1:0\"\ndata_dir: \"" + filepath.ToSlash(dataDir) + "\"\n")
if err := os.WriteFile(cfgPath, cfgYAML, 0o644); err != nil {
t.Fatal(err)
}
cfgPath := writeTestConfig(t, dataDir)
initAdminForTest(t, dataDir)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
if vErr := cfg.Validate(); vErr != nil {
t.Fatal(vErr)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -37,7 +65,7 @@ func TestServeHealthzAndListenAddr(t *testing.T) {
}()
var addr string
deadline := time.Now().Add(5 * time.Second)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
b, readErr := os.ReadFile(filepath.Join(dataDir, "listen.addr"))
if readErr == nil {
@@ -65,6 +93,15 @@ func TestServeHealthzAndListenAddr(t *testing.T) {
t.Fatalf("unexpected body: %q", body)
}
ready, err := http.Get("http://" + addr + "/readyz")
if err != nil {
t.Fatalf("readyz: %v", err)
}
defer func() { _ = ready.Body.Close() }()
if ready.StatusCode != http.StatusOK {
t.Fatalf("readyz status=%d", ready.StatusCode)
}
if _, err := os.Stat(filepath.Join(dataDir, "nixmsg.db")); err != nil {
t.Fatalf("db missing: %v", err)
}
@@ -75,7 +112,23 @@ func TestServeHealthzAndListenAddr(t *testing.T) {
if err != nil {
t.Fatalf("serve exit: %v", err)
}
case <-time.After(5 * time.Second):
case <-time.After(10 * time.Second):
t.Fatal("serve did not stop")
}
}
func TestServeRejectsWithoutAdmin(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
cfgPath := writeTestConfig(t, dataDir)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = runServe(ctx, cfg)
if err == nil || !strings.Contains(err.Error(), "admin password") {
t.Fatalf("want admin init error, got %v", err)
}
}