DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+664
View File
@@ -0,0 +1,664 @@
package main
import (
"bufio"
"context"
"database/sql"
"errors"
"flag"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
mysql "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
const (
localLoginAccountTable = "dnf_login_accounts"
localAccountStateLive = "active"
)
type localLoginAccount struct {
AccountID string
Username string
State string
CreatedAt time.Time
LastLogin sql.NullTime
ActiveNow bool
}
func (c *controller) account(ctx context.Context, args []string) error {
if len(args) == 0 {
printAccountUsage(c.stdout)
return flag.ErrHelp
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "register":
fs := newFlagSet("account register", c.stderr)
username := fs.String("username", "", "login username")
passwordStdin := fs.Bool("password-stdin", false, "read one password line from stdin")
keepDatabase := fs.Bool("keep-database", false, "leave MySQL running after the command")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
if !*passwordStdin {
return errors.New("account registration requires --password-stdin")
}
password, err := readLocalAccountPassword(c.stdin)
if err != nil {
return err
}
return c.registerLocalLoginAccount(
ctx,
*username,
password,
*keepDatabase,
)
case "login":
fs := newFlagSet("account login", c.stderr)
username := fs.String("username", "", "login username")
passwordStdin := fs.Bool("password-stdin", false, "read one password line from stdin")
keepDatabase := fs.Bool("keep-database", false, "leave MySQL running after the command")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
if !*passwordStdin {
return errors.New("account login requires --password-stdin")
}
password, err := readLocalAccountPassword(c.stdin)
if err != nil {
return err
}
return c.loginLocalAccount(
ctx,
*username,
password,
*keepDatabase,
)
case "list":
fs := newFlagSet("account list", c.stderr)
keepDatabase := fs.Bool("keep-database", false, "leave MySQL running after the command")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
return c.listLocalLoginAccounts(ctx, *keepDatabase)
case "bind-client":
fs := newFlagSet("account bind-client", c.stderr)
username := fs.String("username", "", "login username")
pid := fs.Int("pid", 0, "Windows client process ID")
passwordStdin := fs.Bool("password-stdin", false, "read one password line from stdin")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
if !*passwordStdin {
return errors.New("account bind-client requires --password-stdin")
}
password, err := readLocalAccountPassword(c.stdin)
if err != nil {
return err
}
return c.bindRemoteClientAccount(ctx, *username, password, *pid)
default:
fmt.Fprintf(c.stderr, "unknown account command %q\n\n", args[0])
printAccountUsage(c.stderr)
return flag.ErrHelp
}
}
func printAccountUsage(w io.Writer) {
fmt.Fprintln(w, `DNF90 local account commands
Usage:
DNF90Control.exe account register --username NAME --password-stdin
DNF90Control.exe account login --username NAME --password-stdin
DNF90Control account bind-client --username NAME --pid PID --password-stdin
DNF90Control.exe account list
Passwords are accepted only through stdin. The Windows launcher uses these
commands without exposing a password in the process command line or logs.`)
}
func (c *controller) bindRemoteClientAccount(ctx context.Context, username, password string, pid int) error {
if pid < 0 {
return errors.New("client PID must not be negative")
}
clientIP, err := remoteClientIP(os.Getenv("SSH_CONNECTION"))
if err != nil {
return err
}
cfg, err := loadInstance(c.paths)
if err != nil {
return err
}
if _, found, err := c.loadManagedProcess(cfg.InstallationID, true); err != nil {
return err
} else if !found {
return errors.New("DNF90 server is not running")
}
accountID, err := c.authenticateRunningLocalAccount(ctx, cfg, username, password)
if err != nil {
return err
}
if err := c.registerClientAccountSource(ctx, cfg, pid, accountID, clientIP); err != nil {
return fmt.Errorf("bind remote client PID %d: %w", pid, err)
}
fmt.Fprintf(c.stdout, "Remote client account bound: username=%s client_ip=%s pid=%d\n", strings.ToLower(strings.TrimSpace(username)), clientIP, pid)
return nil
}
func remoteClientIP(sshConnection string) (string, error) {
fields := strings.Fields(sshConnection)
if len(fields) != 4 {
return "", errors.New("remote client IP is unavailable; bind-client must run through SSH")
}
value := fields[0]
address, err := netip.ParseAddr(value)
if err != nil {
return "", errors.New("remote client IP is invalid")
}
address = address.Unmap()
if !address.Is4() || !address.IsPrivate() || address.IsLoopback() {
return "", errors.New("remote client IP must be a private non-loopback IPv4 address")
}
return address.String(), nil
}
func (c *controller) registerLocalLoginAccount(
ctx context.Context,
username string,
password string,
keepDatabase bool,
) error {
username, err := normalizeLocalUsername(username)
if err != nil {
return err
}
if err := validateLocalPassword(password); err != nil {
return err
}
passwordHash, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
if err != nil {
return fmt.Errorf("hash local account password: %w", err)
}
cfg, err := loadInstance(c.paths)
if err != nil {
return err
}
return c.withLocalLoginDatabase(
ctx,
cfg,
keepDatabase,
func(db *sql.DB) error {
var existing int
err := db.QueryRowContext(
ctx,
"SELECT 1 FROM "+localLoginAccountTable+" WHERE username = ?",
username,
).Scan(&existing)
switch {
case err == nil:
return errors.New("this username is already registered")
case !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("check local username: %w", err)
}
var accountCount int
if err := db.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM "+localLoginAccountTable,
).Scan(&accountCount); err != nil {
return fmt.Errorf("count local login accounts: %w", err)
}
accountID := cfg.Server.AccountID
if accountCount > 0 {
accountID, err = randomCredential(defaultAccountIDPrefix)
if err != nil {
return err
}
}
now := time.Now().UTC()
_, err = db.ExecContext(
ctx,
"INSERT INTO "+localLoginAccountTable+
" (account_id, username, password_hash, state, created_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, ?)",
accountID,
username,
passwordHash,
localAccountStateLive,
now,
now,
)
if err != nil {
var mysqlErr *mysql.MySQLError
if (errors.As(err, &mysqlErr) && mysqlErr.Number == 1062) ||
strings.Contains(strings.ToLower(err.Error()), "unique constraint") {
return errors.New("this username is already registered")
}
return fmt.Errorf("register local account: %w", err)
}
fmt.Fprintln(c.stdout, "Account registered:", username)
return nil
},
)
}
func (c *controller) loginLocalAccount(
ctx context.Context,
username string,
password string,
keepDatabase bool,
) error {
username, err := normalizeLocalUsername(username)
if err != nil {
return err
}
if err := validateLocalPassword(password); err != nil {
return err
}
cfg, err := loadInstance(c.paths)
if err != nil {
return err
}
return c.withLocalLoginDatabase(
ctx,
cfg,
keepDatabase,
func(db *sql.DB) error {
var accountID string
var passwordHash []byte
var state string
err := db.QueryRowContext(
ctx,
"SELECT account_id, password_hash, state FROM "+
localLoginAccountTable+" WHERE username = ?",
username,
).Scan(&accountID, &passwordHash, &state)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("username or password is incorrect")
}
if err != nil {
return fmt.Errorf("load local login account: %w", err)
}
if bcrypt.CompareHashAndPassword(passwordHash, []byte(password)) != nil {
return errors.New("username or password is incorrect")
}
if state != localAccountStateLive {
return errors.New("this account is disabled")
}
if accountID != cfg.Server.AccountID {
if err := c.requireLocalServerStopped(cfg); err != nil {
return err
}
if err := writeActiveLocalAccountID(c.paths, accountID); err != nil {
return err
}
}
if _, err := db.ExecContext(
ctx,
"UPDATE "+localLoginAccountTable+
" SET last_login_at = ?, updated_at = ? WHERE account_id = ?",
time.Now().UTC(),
time.Now().UTC(),
accountID,
); err != nil {
if accountID != cfg.Server.AccountID {
rollbackErr := writeActiveLocalAccountID(
c.paths,
cfg.Server.AccountID,
)
return errors.Join(
fmt.Errorf("record local account login: %w", err),
rollbackErr,
)
}
return fmt.Errorf("record local account login: %w", err)
}
fmt.Fprintln(c.stdout, "Account login successful:", username)
return nil
},
)
}
// authenticateRunningLocalAccount validates credentials without changing the
// instance-wide fallback account. It is used for per-process launches while
// the server and other accounts remain online.
func (c *controller) authenticateRunningLocalAccount(
ctx context.Context,
cfg instanceConfig,
username string,
password string,
) (string, error) {
username, err := normalizeLocalUsername(username)
if err != nil {
return "", err
}
if err := validateLocalPassword(password); err != nil {
return "", err
}
var accountID string
err = c.withLocalLoginDatabase(
ctx,
cfg,
true,
func(db *sql.DB) error {
var passwordHash []byte
var state string
err := db.QueryRowContext(
ctx,
"SELECT account_id, password_hash, state FROM "+
localLoginAccountTable+" WHERE username = ?",
username,
).Scan(&accountID, &passwordHash, &state)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("username or password is incorrect")
}
if err != nil {
return fmt.Errorf("load local login account: %w", err)
}
if bcrypt.CompareHashAndPassword(passwordHash, []byte(password)) != nil {
return errors.New("username or password is incorrect")
}
if state != localAccountStateLive {
return errors.New("this account is disabled")
}
if _, err := db.ExecContext(
ctx,
"UPDATE "+localLoginAccountTable+
" SET last_login_at = ?, updated_at = ? WHERE account_id = ?",
time.Now().UTC(),
time.Now().UTC(),
accountID,
); err != nil {
return fmt.Errorf("record local account login: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return "", errors.New("authenticated account has no account ID")
}
return accountID, nil
}
func (c *controller) listLocalLoginAccounts(
ctx context.Context,
keepDatabase bool,
) error {
cfg, err := loadInstance(c.paths)
if err != nil {
return err
}
return c.withLocalLoginDatabase(
ctx,
cfg,
keepDatabase,
func(db *sql.DB) error {
rows, err := db.QueryContext(
ctx,
"SELECT account_id, username, state, created_at, last_login_at "+
"FROM "+localLoginAccountTable+" ORDER BY created_at, account_id",
)
if err != nil {
return fmt.Errorf("query local login accounts: %w", err)
}
defer rows.Close()
fmt.Fprintln(c.stdout, "Local login accounts:")
accountCount := 0
for rows.Next() {
var account localLoginAccount
if err := rows.Scan(
&account.AccountID,
&account.Username,
&account.State,
&account.CreatedAt,
&account.LastLogin,
); err != nil {
return fmt.Errorf("scan local login account: %w", err)
}
marker := " "
if account.AccountID == cfg.Server.AccountID {
marker = "*"
}
fmt.Fprintf(
c.stdout,
"%s %s account_id=%s state=%s\n",
marker,
account.Username,
account.AccountID,
account.State,
)
accountCount++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("read local login accounts: %w", err)
}
if accountCount == 0 {
fmt.Fprintln(c.stdout, "(none; register through LOGIN.bat)")
} else {
fmt.Fprintln(c.stdout, "* active account")
}
return nil
},
)
}
func (c *controller) requireLocalServerStopped(cfg instanceConfig) error {
state, found, err := c.loadManagedProcess(cfg.InstallationID, true)
if err != nil {
return err
}
if found {
return fmt.Errorf(
"DNF90 is running as PID %d; stop it before switching accounts",
state.PID,
)
}
return nil
}
func (c *controller) withLocalLoginDatabase(
ctx context.Context,
cfg instanceConfig,
keepDatabase bool,
run func(*sql.DB) error,
) (resultErr error) {
if cfg.Database.Mode == "sqlite" {
databasePath := filepath.Join(c.paths.runtimeRoot, filepath.FromSlash(cfg.Database.Path))
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
return fmt.Errorf("create SQLite data directory: %w", err)
}
db, err := sql.Open("sqlite", databasePath)
if err != nil {
return fmt.Errorf("open local SQLite login database: %w", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
for _, pragma := range []string{
`PRAGMA journal_mode = WAL`,
`PRAGMA synchronous = FULL`,
`PRAGMA busy_timeout = 5000`,
} {
if _, err := db.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf("configure local SQLite login database: %w", err)
}
}
if err := ensureLocalLoginAccountSchema(ctx, db, "sqlite"); err != nil {
return err
}
return run(db)
}
if cfg.Database.Mode == databaseModePortable {
mysqlState, mysqlRunning, err := c.loadManagedMySQLProcess(cfg, true)
if err != nil {
return err
}
if mysqlRunning {
if err := validateRunningDatabaseConfig(c.paths, cfg, mysqlState); err != nil {
return err
}
} else if err := generateRuntimeConfigs(c.paths, cfg); err != nil {
return err
}
} else if err := generateRuntimeConfigs(c.paths, cfg); err != nil {
return err
}
started, err := c.startDatabase(ctx, cfg)
if err != nil {
if started {
return errors.Join(err, c.rollbackDatabase(cfg))
}
return err
}
if started && !keepDatabase {
defer func() {
resultErr = errors.Join(resultErr, c.rollbackDatabase(cfg))
}()
}
db, err := openPortableRootDB(
ctx,
cfg,
cfg.Database.Password,
cfg.Database.Name,
)
if err != nil {
return fmt.Errorf("open local login database: %w", err)
}
defer db.Close()
if cfg.Database.Mode == databaseModePortable {
if err := verifyConnectedPortableMySQLIdentity(
ctx,
db,
c.paths,
cfg.Database.Port,
); err != nil {
return err
}
} else if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("connect external MySQL login database: %w", err)
}
if err := ensureLocalLoginAccountSchema(ctx, db, "mysql"); err != nil {
return err
}
return run(db)
}
func ensureLocalLoginAccountSchema(ctx context.Context, db *sql.DB, driver string) error {
schema := `CREATE TABLE IF NOT EXISTS dnf_login_accounts (
account_id VARCHAR(128) NOT NULL,
username VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
state VARCHAR(32) NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
last_login_at DATETIME(6) NULL,
PRIMARY KEY (account_id),
UNIQUE KEY uk_dnf_login_accounts_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
if driver == "sqlite" {
schema = `CREATE TABLE IF NOT EXISTS dnf_login_accounts (
account_id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
last_login_at DATETIME NULL
)`
}
_, err := db.ExecContext(ctx, schema)
if err != nil {
return fmt.Errorf("ensure local login account schema: %w", err)
}
return nil
}
func normalizeLocalUsername(username string) (string, error) {
username = strings.ToLower(strings.TrimSpace(username))
count := utf8.RuneCountInString(username)
if !utf8.ValidString(username) || count < 3 || count > 32 {
return "", errors.New("username must contain 3 to 32 characters")
}
for _, character := range username {
if unicode.IsLetter(character) ||
unicode.IsDigit(character) ||
character == '_' ||
character == '-' ||
character == '.' {
continue
}
return "", errors.New(
"username may contain only letters, numbers, '_', '-', or '.'",
)
}
return username, nil
}
func validateLocalPassword(password string) error {
if !utf8.ValidString(password) {
return errors.New("password must be valid UTF-8")
}
if len(password) < 8 || len(password) > 72 {
return errors.New("password must contain 8 to 72 UTF-8 bytes")
}
for _, character := range password {
if unicode.IsControl(character) {
return errors.New("password must not contain control characters")
}
}
return nil
}
func readLocalAccountPassword(r io.Reader) (string, error) {
if r == nil {
return "", errors.New("password input is unavailable")
}
reader := bufio.NewReader(io.LimitReader(r, 4096))
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("read password from stdin: %w", err)
}
password := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
if password == "" {
return "", errors.New("password from stdin is empty")
}
return password, nil
}
func writeActiveLocalAccountID(paths projectPaths, accountID string) error {
accountID = strings.TrimSpace(accountID)
if accountID == "" ||
len(accountID) > 128 ||
!safeValuePattern.MatchString(accountID) {
return errors.New("active account id is invalid")
}
cfg, err := decodeInstance(paths.instance)
if err != nil {
return err
}
cfg.Server.AccountID = accountID
data, err := marshalInstance(cfg)
if err != nil {
return err
}
if err := writeFile(paths.instance, data, 0o600); err != nil {
return fmt.Errorf("write active local account: %w", err)
}
return nil
}
@@ -0,0 +1,144 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestRemoteClientIPUsesSSHConnectionSource(t *testing.T) {
got, err := remoteClientIP("10.66.0.23 52144 10.66.0.1 22")
if err != nil {
t.Fatal(err)
}
if got != "10.66.0.23" {
t.Fatalf("remote IP=%q", got)
}
}
func TestRemoteClientIPRejectsUntrustedAddresses(t *testing.T) {
for _, value := range []string{"127.0.0.1", "203.0.113.8", "2001:db8::1"} {
if _, err := remoteClientIP(value + " 52144 10.66.0.1 22"); err == nil {
t.Fatalf("remoteClientIP(%q) succeeded", value)
}
}
}
func TestNormalizeLocalUsername(t *testing.T) {
tests := map[string]string{
" Alice ": "alice",
"测试账号": "测试账号",
"user-1": "user-1",
}
for input, want := range tests {
got, err := normalizeLocalUsername(input)
if err != nil {
t.Fatalf("normalizeLocalUsername(%q): %v", input, err)
}
if got != want {
t.Fatalf("normalizeLocalUsername(%q) = %q, want %q", input, got, want)
}
}
for _, invalid := range []string{"ab", "bad name", "bad/name", strings.Repeat("a", 33)} {
if _, err := normalizeLocalUsername(invalid); err == nil {
t.Fatalf("normalizeLocalUsername(%q) unexpectedly succeeded", invalid)
}
}
}
func TestSQLiteLocalAccountRegistrationAndAuthentication(t *testing.T) {
root := t.TempDir()
paths := newProjectPaths(root)
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
cfg.Database = databaseConfig{Mode: "sqlite", Path: "data/dnf90.db", Name: "dnf_local"}
data, err := marshalInstance(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.instance, data, 0o600); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
controller := newController(paths, &stdout, &stdout)
ctx := context.Background()
if err := controller.registerLocalLoginAccount(ctx, "Alice", "correct-password", false); err != nil {
t.Fatalf("register SQLite account: %v", err)
}
accountID, err := controller.authenticateRunningLocalAccount(ctx, cfg, "alice", "correct-password")
if err != nil {
t.Fatalf("authenticate SQLite account: %v", err)
}
if accountID != cfg.Server.AccountID {
t.Fatalf("authenticated account = %q, want %q", accountID, cfg.Server.AccountID)
}
if _, err := controller.authenticateRunningLocalAccount(ctx, cfg, "alice", "wrong-password"); err == nil {
t.Fatal("wrong SQLite password was accepted")
}
if !isRegularFile(filepath.Join(paths.runtimeRoot, filepath.FromSlash(cfg.Database.Path))) {
t.Fatal("SQLite database file was not created")
}
}
func TestLocalPasswordUsesBcryptAndRejectsUnsafeLengths(t *testing.T) {
password := "correct-password"
if err := validateLocalPassword(password); err != nil {
t.Fatal(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil {
t.Fatal(err)
}
if err := bcrypt.CompareHashAndPassword(hash, []byte("wrong-password")); err == nil {
t.Fatal("wrong password matched bcrypt hash")
}
for _, invalid := range []string{"short", strings.Repeat("x", 73), "bad\npassword"} {
if err := validateLocalPassword(invalid); err == nil {
t.Fatalf("validateLocalPassword(%q) unexpectedly succeeded", invalid)
}
}
}
func TestWriteActiveLocalAccountIDPreservesOtherInstanceFields(t *testing.T) {
root := t.TempDir()
paths := newProjectPaths(root)
if err := os.MkdirAll(filepath.Dir(paths.instance), 0o755); err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
cfg.Server.AdvertiseIP = autoDetectAdvertiseIP
data, err := marshalInstance(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.instance, data, 0o600); err != nil {
t.Fatal(err)
}
if err := writeActiveLocalAccountID(paths, "dnf:account-two"); err != nil {
t.Fatal(err)
}
updated, err := decodeInstance(paths.instance)
if err != nil {
t.Fatal(err)
}
if updated.Server.AccountID != "dnf:account-two" {
t.Fatalf("active account = %q", updated.Server.AccountID)
}
if updated.Server.AdvertiseIP != autoDetectAdvertiseIP {
t.Fatalf("advertise ip changed to %q", updated.Server.AdvertiseIP)
}
if updated.Database != cfg.Database || updated.Protocol != cfg.Protocol {
t.Fatal("active account update changed unrelated instance configuration")
}
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
func validateAssets(paths projectPaths, out io.Writer) error {
var manifest assetManifest
if err := readStrictJSON(paths.assetManifest, &manifest); err != nil {
return fmt.Errorf("asset manifest: %w", err)
}
if manifest.SchemaVersion != 1 {
return fmt.Errorf("asset manifest schemaVersion must be 1")
}
for _, entry := range manifest.Files {
if !entry.Required {
continue
}
relative, err := safeManifestPath(entry.Path)
if err != nil {
return err
}
absolute := filepath.Join(paths.runtimeRoot, relative)
info, err := os.Stat(absolute)
if err != nil {
return fmt.Errorf("required runtime asset is missing: %s", absolute)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("runtime asset is not a regular file: %s", absolute)
}
if isCustomPVFAsset(relative) {
// Script.pvf is intentionally operator-editable. The runtime still
// requires a safe, regular file at the profile-owned path; parsing is
// performed by the server/doctor afterwards. Do not pin its size or
// hash to the distributed asset manifest.
fmt.Fprintln(out, "Asset OK (custom PVF accepted):", filepath.ToSlash(relative))
continue
}
if info.Size() != entry.Size {
return fmt.Errorf(
"runtime asset size mismatch: %s (got %d, expected %d)",
filepath.ToSlash(relative),
info.Size(),
entry.Size,
)
}
digest, err := fileSHA256(absolute)
if err != nil {
return err
}
if !strings.EqualFold(digest, strings.TrimSpace(entry.SHA256)) {
return fmt.Errorf("runtime asset SHA256 mismatch: %s", filepath.ToSlash(relative))
}
fmt.Fprintln(out, "Asset OK:", filepath.ToSlash(relative))
}
return nil
}
func isCustomPVFAsset(relative string) bool {
return strings.EqualFold(filepath.ToSlash(filepath.Clean(relative)), "data/dnf/Script.pvf")
}
func validateClient(paths projectPaths, cfg instanceConfig, override string, out io.Writer) (string, string, error) {
directory := strings.TrimSpace(override)
if directory == "" {
directory = strings.TrimSpace(cfg.Client.Directory)
}
if directory == "" {
return "", "", fmt.Errorf(
"client directory is not configured; set client.directory in runtime/config/instance.json or pass --client-directory",
)
}
if !filepath.IsAbs(directory) {
directory = filepath.Join(paths.projectRoot, directory)
}
clientRoot, err := filepath.Abs(directory)
if err != nil {
return "", "", fmt.Errorf("resolve client directory: %w", err)
}
info, err := os.Stat(clientRoot)
if err != nil || !info.IsDir() {
return "", "", fmt.Errorf("client directory does not exist: %s", clientRoot)
}
var manifest clientManifest
if err := readStrictJSON(paths.clientManifest, &manifest); err != nil {
return "", "", fmt.Errorf("client compatibility manifest: %w", err)
}
if manifest.SchemaVersion != 1 {
return "", "", fmt.Errorf("client compatibility manifest schemaVersion must be 1")
}
if manifest.Profile != cfg.Protocol.Profile {
return "", "", fmt.Errorf(
"client compatibility profile %q does not match instance profile %q",
manifest.Profile,
cfg.Protocol.Profile,
)
}
var clientExecutable string
for _, entry := range manifest.Files {
var match string
for _, name := range entry.Names {
if name == "" || filepath.Base(name) != name {
return "", "", fmt.Errorf("unsafe client manifest filename %q", name)
}
candidate := filepath.Join(clientRoot, name)
if isRegularFile(candidate) {
match = candidate
break
}
}
if match == "" {
if entry.Required {
return "", "", fmt.Errorf(
"client file is missing; expected one of: %s",
strings.Join(entry.Names, ", "),
)
}
continue
}
fmt.Fprintln(out, "Client file OK (existence only):", match)
base := strings.ToLower(filepath.Base(match))
if base == "dnf.exe" || base == "nopack.exe" {
clientExecutable = match
}
}
if clientExecutable == "" {
return "", "", fmt.Errorf("DNF.exe or NoPack.exe was not found")
}
fmt.Fprintln(out, "OK: client profile", manifest.Profile)
return clientRoot, clientExecutable, nil
}
func writeAssetState(paths projectPaths) error {
state := assetState{GeneratedAt: time.Now().UTC()}
relatives := []string{
filepath.Join("data", "dnf", "Script.pvf"),
filepath.Join("data", "dnf", "channel_info.etc"),
filepath.Join("bin", platformExecutableName("DNF90Server")),
}
if runtime.GOOS == "windows" {
relatives = append(relatives, filepath.Join("mysql", "server", "bin", "mysqld.exe"))
}
for _, relative := range relatives {
absolute := filepath.Join(paths.runtimeRoot, relative)
info, err := os.Stat(absolute)
if err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("stat asset-state file %s: %w", absolute, err)
}
if !info.Mode().IsRegular() {
continue
}
digest, err := fileSHA256(absolute)
if err != nil {
return err
}
state.Files = append(state.Files, assetStateRecord{
Path: filepath.ToSlash(relative),
Size: info.Size(),
SHA256: strings.ToUpper(digest),
})
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode asset state: %w", err)
}
return writeFile(filepath.Join(paths.runtimeState, "asset-state.json"), append(data, '\n'), 0o600)
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open %s for SHA256: %w", path, err)
}
defer func() { _ = file.Close() }()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("hash %s: %w", path, err)
}
return strings.ToUpper(hex.EncodeToString(hash.Sum(nil))), nil
}
func safeManifestPath(path string) (string, error) {
path = filepath.FromSlash(strings.TrimSpace(path))
if path == "" || filepath.IsAbs(path) || escapesRoot(path) {
return "", fmt.Errorf("unsafe manifest path %q", path)
}
return filepath.Clean(path), nil
}
func readStrictJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return fmt.Errorf("decode %s: multiple JSON values", path)
}
return fmt.Errorf("decode %s: %w", path, err)
}
return nil
}
+335
View File
@@ -0,0 +1,335 @@
package main
import (
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
const binaryInstallSchemaVersion = 1
type resolvedBinaryInstallEntry struct {
state binaryInstallStateEntry
destination string
temporary string
backup string
}
func installBuiltBinarySet(paths projectPaths, targets []builtBinary) error {
if len(targets) == 0 {
return errors.New("binary installation set is empty")
}
if isRegularFile(paths.binaryInstallState) {
if err := recoverBuiltBinarySet(paths); err != nil {
return fmt.Errorf("recover previous binary installation: %w", err)
}
}
state := binaryInstallState{
SchemaVersion: binaryInstallSchemaVersion,
Entries: make([]binaryInstallStateEntry, 0, len(targets)),
}
seenDestinations := make(map[string]struct{}, len(targets))
for index, target := range targets {
if !isRegularFile(target.temp) {
return fmt.Errorf("built DNF90 %s binary is missing: %s", target.label, target.temp)
}
if err := os.MkdirAll(filepath.Dir(target.destination), 0o755); err != nil {
return err
}
if !sameDirectoryPath(filepath.Dir(target.destination), paths.runtimeBin) ||
!sameDirectoryPath(filepath.Dir(target.temp), paths.runtimeBin) {
return fmt.Errorf("DNF90 %s binary installation escaped runtime/bin", target.label)
}
destinationName := filepath.Base(target.destination)
temporaryName := filepath.Base(target.temp)
destinationKey := strings.ToLower(destinationName)
if _, exists := seenDestinations[destinationKey]; exists {
return fmt.Errorf("duplicate DNF90 binary installation target: %s", destinationName)
}
seenDestinations[destinationKey] = struct{}{}
candidateSHA256, err := fileSHA256(target.temp)
if err != nil {
return err
}
entry := binaryInstallStateEntry{
Destination: destinationName,
Temporary: temporaryName,
Backup: destinationName + ".previous-" + strconv.Itoa(os.Getpid()) + "-" + strconv.Itoa(index),
CandidateSHA256: candidateSHA256,
}
destinationInfo, err := os.Stat(target.destination)
switch {
case err == nil:
if !destinationInfo.Mode().IsRegular() {
return fmt.Errorf("DNF90 %s destination is not a regular file: %s", target.label, target.destination)
}
entry.HadOriginal = true
entry.OriginalSHA256, err = fileSHA256(target.destination)
if err != nil {
return err
}
case os.IsNotExist(err):
default:
return fmt.Errorf("inspect DNF90 %s destination: %w", target.label, err)
}
backupPath := filepath.Join(paths.runtimeBin, entry.Backup)
if _, err := os.Lstat(backupPath); err == nil {
return fmt.Errorf("binary backup path already exists: %s", backupPath)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect binary backup path %s: %w", backupPath, err)
}
state.Entries = append(state.Entries, entry)
}
if err := writeJSON(paths.binaryInstallState, state, 0o600); err != nil {
return fmt.Errorf("record binary installation transaction: %w", err)
}
var mutationErr error
for index, target := range targets {
entry := state.Entries[index]
backupPath := filepath.Join(paths.runtimeBin, entry.Backup)
if entry.HadOriginal {
if err := os.Rename(target.destination, backupPath); err != nil {
mutationErr = fmt.Errorf("preserve current DNF90 %s binary: %w", target.label, err)
break
}
}
if err := os.Rename(target.temp, target.destination); err != nil {
mutationErr = fmt.Errorf("install built DNF90 %s binary: %w", target.label, err)
break
}
}
if mutationErr != nil {
return errors.Join(mutationErr, recoverBuiltBinarySet(paths))
}
if err := recoverBuiltBinarySet(paths); err != nil {
return fmt.Errorf("finalize binary installation transaction: %w", err)
}
return nil
}
func recoverBuiltBinarySet(paths projectPaths) error {
var state binaryInstallState
if err := readStrictJSON(paths.binaryInstallState, &state); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
entries, err := resolveBinaryInstallState(paths, state)
if err != nil {
return err
}
allCandidates := true
for _, entry := range entries {
exists, digest, err := regularFileDigest(entry.destination)
if err != nil {
return err
}
if !exists || !strings.EqualFold(digest, entry.state.CandidateSHA256) {
allCandidates = false
break
}
}
if allCandidates {
for _, entry := range entries {
if err := removeKnownBinaryArtifact(
entry.backup,
entry.state.HadOriginal,
entry.state.OriginalSHA256,
); err != nil {
return err
}
if err := removeKnownBinaryArtifact(
entry.temporary,
true,
entry.state.CandidateSHA256,
); err != nil {
return err
}
}
return removeBinaryInstallState(paths.binaryInstallState)
}
for _, entry := range entries {
destinationExists, destinationDigest, err := regularFileDigest(entry.destination)
if err != nil {
return err
}
backupExists, backupDigest, err := regularFileDigest(entry.backup)
if err != nil {
return err
}
if entry.state.HadOriginal {
switch {
case destinationExists && strings.EqualFold(destinationDigest, entry.state.OriginalSHA256):
if backupExists {
if !strings.EqualFold(backupDigest, entry.state.OriginalSHA256) {
return fmt.Errorf("refusing to remove unrecognized binary backup: %s", entry.backup)
}
if err := os.Remove(entry.backup); err != nil {
return fmt.Errorf("remove redundant binary backup %s: %w", entry.backup, err)
}
}
default:
if !backupExists || !strings.EqualFold(backupDigest, entry.state.OriginalSHA256) {
return fmt.Errorf("cannot safely restore original binary %s", entry.destination)
}
if destinationExists {
if !strings.EqualFold(destinationDigest, entry.state.CandidateSHA256) {
return fmt.Errorf("refusing to replace unrecognized binary: %s", entry.destination)
}
if err := os.Remove(entry.destination); err != nil {
return fmt.Errorf("remove interrupted candidate %s: %w", entry.destination, err)
}
}
if err := os.Rename(entry.backup, entry.destination); err != nil {
return fmt.Errorf("restore original binary %s: %w", entry.destination, err)
}
}
} else {
if backupExists {
return fmt.Errorf("unexpected backup for newly installed binary: %s", entry.backup)
}
if destinationExists {
if !strings.EqualFold(destinationDigest, entry.state.CandidateSHA256) {
return fmt.Errorf("refusing to remove unrecognized binary: %s", entry.destination)
}
if err := os.Remove(entry.destination); err != nil {
return fmt.Errorf("remove interrupted new binary %s: %w", entry.destination, err)
}
}
}
if err := removeKnownBinaryArtifact(
entry.temporary,
true,
entry.state.CandidateSHA256,
); err != nil {
return err
}
}
for _, entry := range entries {
exists, digest, err := regularFileDigest(entry.destination)
if err != nil {
return err
}
if entry.state.HadOriginal {
if !exists || !strings.EqualFold(digest, entry.state.OriginalSHA256) {
return fmt.Errorf("binary rollback verification failed: %s", entry.destination)
}
} else if exists {
return fmt.Errorf("new binary remained after rollback: %s", entry.destination)
}
}
return removeBinaryInstallState(paths.binaryInstallState)
}
func resolveBinaryInstallState(
paths projectPaths,
state binaryInstallState,
) ([]resolvedBinaryInstallEntry, error) {
if state.SchemaVersion != binaryInstallSchemaVersion {
return nil, fmt.Errorf("unsupported binary installation state schema %d", state.SchemaVersion)
}
if len(state.Entries) == 0 || len(state.Entries) > 16 {
return nil, fmt.Errorf("invalid binary installation entry count %d", len(state.Entries))
}
seen := make(map[string]struct{}, len(state.Entries)*3)
resolved := make([]resolvedBinaryInstallEntry, 0, len(state.Entries))
for _, entry := range state.Entries {
if !validSHA256(entry.CandidateSHA256) {
return nil, fmt.Errorf("invalid candidate SHA256 for %q", entry.Destination)
}
if entry.HadOriginal != validSHA256(entry.OriginalSHA256) {
return nil, fmt.Errorf("invalid original SHA256 for %q", entry.Destination)
}
pathsByName := make([]string, 0, 3)
for _, name := range []string{entry.Destination, entry.Temporary, entry.Backup} {
cleaned, err := safeManifestPath(name)
if err != nil || filepath.Base(cleaned) != cleaned {
return nil, fmt.Errorf("unsafe binary installation filename %q", name)
}
key := strings.ToLower(cleaned)
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("duplicate binary installation filename %q", cleaned)
}
seen[key] = struct{}{}
absolute := filepath.Join(paths.runtimeBin, cleaned)
if !pathInside(paths.runtimeBin, absolute) {
return nil, fmt.Errorf("binary installation filename escaped runtime/bin: %q", cleaned)
}
pathsByName = append(pathsByName, absolute)
}
resolved = append(resolved, resolvedBinaryInstallEntry{
state: entry,
destination: pathsByName[0],
temporary: pathsByName[1],
backup: pathsByName[2],
})
}
return resolved, nil
}
func regularFileDigest(path string) (bool, string, error) {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return false, "", nil
}
if err != nil {
return false, "", fmt.Errorf("inspect binary transaction path %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return false, "", fmt.Errorf("binary transaction path is not a regular file: %s", path)
}
digest, err := fileSHA256(path)
if err != nil {
return false, "", err
}
return true, digest, nil
}
func removeKnownBinaryArtifact(path string, allowed bool, expectedSHA256 string) error {
exists, digest, err := regularFileDigest(path)
if err != nil {
return err
}
if !exists {
return nil
}
if !allowed || !strings.EqualFold(digest, expectedSHA256) {
return fmt.Errorf("refusing to remove unrecognized binary transaction file: %s", path)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("remove binary transaction file %s: %w", path, err)
}
return nil
}
func removeBinaryInstallState(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove completed binary installation state: %w", err)
}
return nil
}
func sameDirectoryPath(left, right string) bool {
leftAbsolute, leftErr := filepath.Abs(left)
rightAbsolute, rightErr := filepath.Abs(right)
return leftErr == nil &&
rightErr == nil &&
strings.EqualFold(filepath.Clean(leftAbsolute), filepath.Clean(rightAbsolute))
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
@@ -0,0 +1,192 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestRecoverBuiltBinarySetRollsBackInterruptedSet(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := os.MkdirAll(paths.runtimeBin, 0o755); err != nil {
t.Fatal(err)
}
server := filepath.Join(paths.runtimeBin, "DNF90Server.exe")
doctor := filepath.Join(paths.runtimeBin, "DNF90Doctor.exe")
serverTemp := server + ".build-test"
doctorTemp := doctor + ".build-test"
writeTestBinary(t, server, "old-server")
writeTestBinary(t, doctor, "old-doctor")
writeTestBinary(t, serverTemp, "new-server")
writeTestBinary(t, doctorTemp, "new-doctor")
state := testBinaryInstallState(t, paths, []builtBinary{
{destination: server, temp: serverTemp, label: "server"},
{destination: doctor, temp: doctorTemp, label: "doctor"},
})
serverBackup := filepath.Join(paths.runtimeBin, state.Entries[0].Backup)
if err := os.Rename(server, serverBackup); err != nil {
t.Fatal(err)
}
if err := os.Rename(serverTemp, server); err != nil {
t.Fatal(err)
}
if err := writeJSON(paths.binaryInstallState, state, 0o600); err != nil {
t.Fatal(err)
}
if err := recoverBuiltBinarySet(paths); err != nil {
t.Fatalf("recoverBuiltBinarySet() error = %v", err)
}
assertTestBinary(t, server, "old-server")
assertTestBinary(t, doctor, "old-doctor")
for _, path := range []string{
serverTemp,
doctorTemp,
serverBackup,
paths.binaryInstallState,
} {
if _, err := os.Lstat(path); !os.IsNotExist(err) {
t.Fatalf("transaction artifact remains: %s", path)
}
}
}
func TestRecoverBuiltBinarySetCommitsCompleteSet(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := os.MkdirAll(paths.runtimeBin, 0o755); err != nil {
t.Fatal(err)
}
server := filepath.Join(paths.runtimeBin, "DNF90Server.exe")
doctor := filepath.Join(paths.runtimeBin, "DNF90Doctor.exe")
serverTemp := server + ".build-test"
doctorTemp := doctor + ".build-test"
writeTestBinary(t, server, "old-server")
writeTestBinary(t, doctor, "old-doctor")
writeTestBinary(t, serverTemp, "new-server")
writeTestBinary(t, doctorTemp, "new-doctor")
state := testBinaryInstallState(t, paths, []builtBinary{
{destination: server, temp: serverTemp, label: "server"},
{destination: doctor, temp: doctorTemp, label: "doctor"},
})
for index, path := range []string{server, doctor} {
backup := filepath.Join(paths.runtimeBin, state.Entries[index].Backup)
if err := os.Rename(path, backup); err != nil {
t.Fatal(err)
}
temp := []string{serverTemp, doctorTemp}[index]
if err := os.Rename(temp, path); err != nil {
t.Fatal(err)
}
}
if err := writeJSON(paths.binaryInstallState, state, 0o600); err != nil {
t.Fatal(err)
}
if err := recoverBuiltBinarySet(paths); err != nil {
t.Fatalf("recoverBuiltBinarySet() error = %v", err)
}
assertTestBinary(t, server, "new-server")
assertTestBinary(t, doctor, "new-doctor")
for _, entry := range state.Entries {
if _, err := os.Lstat(filepath.Join(paths.runtimeBin, entry.Backup)); !os.IsNotExist(err) {
t.Fatalf("binary backup remains: %s", entry.Backup)
}
}
if _, err := os.Lstat(paths.binaryInstallState); !os.IsNotExist(err) {
t.Fatal("binary installation state remains")
}
}
func TestRecoverBuiltBinarySetRefusesUnknownDestination(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := os.MkdirAll(paths.runtimeBin, 0o755); err != nil {
t.Fatal(err)
}
server := filepath.Join(paths.runtimeBin, "DNF90Server.exe")
serverTemp := server + ".build-test"
writeTestBinary(t, server, "old-server")
writeTestBinary(t, serverTemp, "new-server")
state := testBinaryInstallState(t, paths, []builtBinary{
{destination: server, temp: serverTemp, label: "server"},
})
backup := filepath.Join(paths.runtimeBin, state.Entries[0].Backup)
if err := os.Rename(server, backup); err != nil {
t.Fatal(err)
}
writeTestBinary(t, server, "unknown-user-file")
if err := writeJSON(paths.binaryInstallState, state, 0o600); err != nil {
t.Fatal(err)
}
if err := recoverBuiltBinarySet(paths); err == nil {
t.Fatal("recoverBuiltBinarySet() succeeded with an unknown destination")
}
assertTestBinary(t, server, "unknown-user-file")
assertTestBinary(t, backup, "old-server")
}
func TestResolveBinaryInstallStateRejectsEscapingPath(t *testing.T) {
paths := newProjectPaths(t.TempDir())
_, err := resolveBinaryInstallState(paths, binaryInstallState{
SchemaVersion: binaryInstallSchemaVersion,
Entries: []binaryInstallStateEntry{{
Destination: `..\outside.exe`,
Temporary: "candidate.exe",
Backup: "backup.exe",
CandidateSHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}},
})
if err == nil {
t.Fatal("resolveBinaryInstallState() accepted a path escape")
}
}
func testBinaryInstallState(
t *testing.T,
paths projectPaths,
targets []builtBinary,
) binaryInstallState {
t.Helper()
state := binaryInstallState{
SchemaVersion: binaryInstallSchemaVersion,
Entries: make([]binaryInstallStateEntry, 0, len(targets)),
}
for index, target := range targets {
originalSHA256, err := fileSHA256(target.destination)
if err != nil {
t.Fatal(err)
}
candidateSHA256, err := fileSHA256(target.temp)
if err != nil {
t.Fatal(err)
}
state.Entries = append(state.Entries, binaryInstallStateEntry{
Destination: filepath.Base(target.destination),
Temporary: filepath.Base(target.temp),
Backup: filepath.Base(target.destination) + ".previous-test-" + string(rune('0'+index)),
HadOriginal: true,
OriginalSHA256: originalSHA256,
CandidateSHA256: candidateSHA256,
})
}
return state
}
func writeTestBinary(t *testing.T, path, value string) {
t.Helper()
if err := os.WriteFile(path, []byte(value), 0o755); err != nil {
t.Fatal(err)
}
}
func assertTestBinary(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != want {
t.Fatalf("%s = %q, want %q", path, data, want)
}
}
@@ -0,0 +1,47 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
func (c *controller) configureClient(directory string) error {
directory = strings.TrimSpace(directory)
directory = strings.TrimSpace(strings.Trim(directory, `"`))
if directory == "" {
return errors.New("client directory is required")
}
if _, err := loadInstance(c.paths); err != nil {
return err
}
clientRoot, err := filepath.Abs(directory)
if err != nil {
return fmt.Errorf("resolve client directory: %w", err)
}
info, err := os.Stat(clientRoot)
if err != nil || !info.IsDir() {
return fmt.Errorf("client directory does not exist: %s", clientRoot)
}
if !isRegularFile(filepath.Join(clientRoot, "DNF.exe")) &&
!isRegularFile(filepath.Join(clientRoot, "NoPack.exe")) {
return fmt.Errorf("DNF.exe or NoPack.exe was not found in client directory: %s", clientRoot)
}
cfg, err := decodeInstance(c.paths.instance)
if err != nil {
return err
}
cfg.Client.Directory = filepath.Clean(clientRoot)
data, err := marshalInstance(cfg)
if err != nil {
return err
}
if err := writeFile(c.paths.instance, data, 0o600); err != nil {
return err
}
fmt.Fprintln(c.stdout, "Client directory configured:", cfg.Client.Directory)
return nil
}
@@ -0,0 +1,72 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
// TestClientManifestPinsShippedPatchHashes keeps runtime/assets/client-compatibility.json
// honest about the two DLLs this repository builds.
//
// This controller validates client files by existence only, so a stale hash
// here costs it nothing and would never be noticed locally. DNF90Hub (another
// repository) is the consumer that compares them, and it treats any mismatch —
// including an absent hash, which compares against the empty string — as a
// permanent failure: its "verify and repair client files" action copies the
// file, reports success, and the very next check fails again on the same
// expectation. That is a defect the server side can only cause and only fix,
// so it is pinned from this side.
//
// Commercial client files (DNF.exe / ijl15*.dll) are deliberately not asserted:
// they are not built here and are not in the repository.
func TestClientManifestPinsShippedPatchHashes(t *testing.T) {
t.Setenv("DNF90_PROJECT_ROOT", "")
paths, err := discoverPaths()
if err != nil {
t.Fatalf("discoverPaths() error = %v", err)
}
var manifest clientManifest
if err := readStrictJSON(paths.clientManifest, &manifest); err != nil {
t.Fatalf("read client manifest: %v", err)
}
pinned := map[string]string{}
for _, entry := range manifest.Files {
for _, name := range entry.Names {
pinned[strings.ToLower(name)] = entry.SHA256
}
}
for _, name := range []string{"90CN.dll", "90CNLua.dll"} {
declared, found := pinned[strings.ToLower(name)]
if !found {
t.Fatalf("client manifest does not declare %s", name)
}
if strings.TrimSpace(declared) == "" {
t.Fatalf(
"client manifest has no sha256 for %s; DNF90Hub compares this value and an "+
"empty one makes its client repair loop forever",
name,
)
}
shipped := filepath.Join(paths.runtimeRoot, "patch", name)
data, err := os.ReadFile(shipped)
if err != nil {
t.Fatalf("read shipped %s: %v", name, err)
}
sum := sha256.Sum256(data)
actual := hex.EncodeToString(sum[:])
if !strings.EqualFold(actual, declared) {
t.Fatalf(
"client manifest sha256 for %s is stale: manifest=%s runtime/patch=%s; "+
"update runtime/assets/client-compatibility.json after rebuilding the client patch",
name, strings.ToUpper(declared), strings.ToUpper(actual),
)
}
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "context"
func acquireControllerLock(context.Context, string) (func(), error) {
return func() {}, nil
}
@@ -0,0 +1,112 @@
//go:build windows
package main
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"golang.org/x/sys/windows"
"longheng.io/server/internal/platform/wineenv"
)
func acquireControllerLock(ctx context.Context, projectRoot string) (func(), error) {
if wineenv.Detected() {
return acquireWineControllerLock(ctx, projectRoot)
}
lockDirectory := filepath.Join(projectRoot, "runtime", "state")
if err := os.MkdirAll(lockDirectory, 0o700); err != nil {
return nil, fmt.Errorf("create controller lock directory: %w", err)
}
lockPath := filepath.Join(lockDirectory, "control.lock")
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("open controller lock file: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := &windows.Overlapped{}
flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
for {
err = windows.LockFileEx(handle, flags, 0, 1, 0, overlapped)
if err == nil {
var once sync.Once
return func() {
once.Do(func() {
_ = windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
_ = file.Close()
})
}, nil
}
if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
_ = file.Close()
return nil, fmt.Errorf("lock controller state: %w", err)
}
select {
case <-ctx.Done():
_ = file.Close()
return nil, fmt.Errorf("wait for another DNF90 command: %w", ctx.Err())
case <-time.After(250 * time.Millisecond):
}
}
}
func acquireWineControllerLock(ctx context.Context, projectRoot string) (func(), error) {
// Win32 mutex ownership belongs to an OS thread, not a goroutine. Keep the
// controller's CLI goroutine on the acquiring thread until the deferred
// release callback runs.
runtime.LockOSThread()
identity := strings.ToLower(filepath.Clean(projectRoot))
digest := sha256.Sum256([]byte(identity))
name, err := windows.UTF16PtrFromString(fmt.Sprintf(
`DNF90Control-%x`,
digest[:16],
))
if err != nil {
runtime.UnlockOSThread()
return nil, fmt.Errorf("create Wine controller mutex name: %w", err)
}
mutex, err := windows.CreateMutex(nil, false, name)
if err != nil {
runtime.UnlockOSThread()
return nil, fmt.Errorf("create Wine controller mutex: %w", err)
}
for {
event, waitErr := windows.WaitForSingleObject(mutex, 250)
if waitErr != nil {
_ = windows.CloseHandle(mutex)
runtime.UnlockOSThread()
return nil, fmt.Errorf("wait for Wine controller mutex: %w", waitErr)
}
switch event {
case windows.WAIT_OBJECT_0, windows.WAIT_ABANDONED:
var once sync.Once
return func() {
once.Do(func() {
_ = windows.ReleaseMutex(mutex)
_ = windows.CloseHandle(mutex)
runtime.UnlockOSThread()
})
}, nil
case uint32(windows.WAIT_TIMEOUT):
select {
case <-ctx.Done():
_ = windows.CloseHandle(mutex)
runtime.UnlockOSThread()
return nil, fmt.Errorf("wait for another DNF90 command: %w", ctx.Err())
default:
}
default:
_ = windows.CloseHandle(mutex)
runtime.UnlockOSThread()
return nil, fmt.Errorf("unexpected Wine controller mutex wait state 0x%x", event)
}
}
}
@@ -0,0 +1,25 @@
//go:build windows
package main
import (
"context"
"testing"
"time"
)
func TestWineControllerMutexCanBeReleasedAndReacquired(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
root := t.TempDir()
firstRelease, err := acquireWineControllerLock(ctx, root)
if err != nil {
t.Fatal(err)
}
firstRelease()
secondRelease, err := acquireWineControllerLock(ctx, root)
if err != nil {
t.Fatal(err)
}
secondRelease()
}
+540
View File
@@ -0,0 +1,540 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
func generateRuntimeConfigs(paths projectPaths, cfg instanceConfig) error {
if err := paths.ensureDirectories(); err != nil {
return err
}
channelData, err := os.ReadFile(paths.channelAsset)
if err != nil {
return fmt.Errorf("read bundled channel_info.etc: %w", err)
}
if err := writeFile(filepath.Join(paths.runtimeData, "channel_info.etc"), channelData, 0o644); err != nil {
return err
}
adminPort, err := listenPort(cfg.Server.AdminListen)
if err != nil {
return err
}
channelPort, err := listenPort(cfg.Server.ChannelListen)
if err != nil {
return err
}
serviceConfig := renderServiceConfig(cfg, adminPort, channelPort)
logicConfig := renderLogicConfig(cfg)
planConfig, err := renderServerGroupPlan(cfg)
if err != nil {
return err
}
if err := writeFile(filepath.Join(paths.runtimeConfigs, "dnfbridge.toml"), []byte(serviceConfig), 0o600); err != nil {
return err
}
if err := writeFile(filepath.Join(paths.runtimeConfigs, "dnf", "logic.toml"), []byte(logicConfig), 0o600); err != nil {
return err
}
if err := writeFile(filepath.Join(paths.runtimeConfigs, "servergroup", "plan.json"), planConfig, 0o600); err != nil {
return err
}
if cfg.Database.Mode == "portable" {
if err := writeFile(paths.mysqlConfig, []byte(renderMySQLConfig(paths, cfg)), 0o600); err != nil {
return err
}
}
return nil
}
func validateRunningDatabaseConfig(
paths projectPaths,
cfg instanceConfig,
state processState,
) error {
expected := map[string][]byte{
paths.mysqlConfig: []byte(renderMySQLConfig(paths, cfg)),
filepath.Join(
paths.runtimeConfigs,
"dnf",
"logic.toml",
): []byte(renderLogicConfig(cfg)),
}
for configPath, expectedData := range expected {
currentData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf(
"portable MySQL is running but its active runtime configuration cannot be verified; run STOP.bat before changing configuration: %w",
err,
)
}
if !bytes.Equal(currentData, expectedData) {
return fmt.Errorf(
"portable MySQL is running and database configuration has changed; run STOP.bat before applying the new configuration",
)
}
}
currentSHA256, err := databaseRuntimeConfigSHA256(paths)
if err != nil {
return err
}
if state.RuntimeConfigSHA256 != "" &&
!strings.EqualFold(currentSHA256, state.RuntimeConfigSHA256) {
return fmt.Errorf(
"portable MySQL is running but its runtime configuration files changed after launch; run STOP.bat before applying configuration changes",
)
}
return nil
}
func databaseRuntimeConfigSHA256(paths projectPaths) (string, error) {
configPaths := []struct {
name string
path string
}{
{name: "mysql.ini", path: paths.mysqlConfig},
{
name: "dnf/logic.toml",
path: filepath.Join(paths.runtimeConfigs, "dnf", "logic.toml"),
},
}
hash := sha256.New()
for _, config := range configPaths {
data, err := os.ReadFile(config.path)
if err != nil {
return "", fmt.Errorf("read database runtime configuration: %w", err)
}
_, _ = hash.Write([]byte(config.name))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write(data)
_, _ = hash.Write([]byte{0})
}
return fmt.Sprintf("%X", hash.Sum(nil)), nil
}
func desiredServerRuntimeConfigSHA256(cfg instanceConfig) (string, error) {
adminPort, err := listenPort(cfg.Server.AdminListen)
if err != nil {
return "", err
}
channelPort, err := listenPort(cfg.Server.ChannelListen)
if err != nil {
return "", err
}
plan, err := renderServerGroupPlan(cfg)
if err != nil {
return "", err
}
identity, err := json.Marshal(struct {
Server serverConfig `json:"server"`
Database databaseConfig `json:"database"`
Game gameConfig `json:"game"`
Protocol protocolConfig `json:"protocol"`
}{
Server: cfg.Server,
Database: cfg.Database,
Game: cfg.Game,
Protocol: cfg.Protocol,
})
if err != nil {
return "", fmt.Errorf("encode server runtime identity: %w", err)
}
return digestNamedRuntimeConfig([]namedRuntimeConfig{
{name: "dnfbridge.toml", data: []byte(renderServiceConfig(cfg, adminPort, channelPort))},
{name: "dnf/logic.toml", data: []byte(renderLogicConfig(cfg))},
{name: "servergroup/plan.json", data: plan},
{name: "runtime-identity.json", data: identity},
}), nil
}
func currentServerRuntimeConfigSHA256(
paths projectPaths,
cfg instanceConfig,
) (string, error) {
files := []struct {
name string
path string
}{
{name: "dnfbridge.toml", path: filepath.Join(paths.runtimeConfigs, "dnfbridge.toml")},
{name: "dnf/logic.toml", path: filepath.Join(paths.runtimeConfigs, "dnf", "logic.toml")},
{
name: "servergroup/plan.json",
path: filepath.Join(paths.runtimeConfigs, "servergroup", "plan.json"),
},
}
configs := make([]namedRuntimeConfig, 0, len(files)+1)
for _, file := range files {
data, err := os.ReadFile(file.path)
if err != nil {
return "", fmt.Errorf("read active server runtime configuration: %w", err)
}
configs = append(configs, namedRuntimeConfig{name: file.name, data: data})
}
identity, err := json.Marshal(struct {
Server serverConfig `json:"server"`
Database databaseConfig `json:"database"`
Game gameConfig `json:"game"`
Protocol protocolConfig `json:"protocol"`
}{
Server: cfg.Server,
Database: cfg.Database,
Game: cfg.Game,
Protocol: cfg.Protocol,
})
if err != nil {
return "", fmt.Errorf("encode active server runtime identity: %w", err)
}
configs = append(
configs,
namedRuntimeConfig{name: "runtime-identity.json", data: identity},
)
return digestNamedRuntimeConfig(configs), nil
}
type namedRuntimeConfig struct {
name string
data []byte
}
func digestNamedRuntimeConfig(configs []namedRuntimeConfig) string {
hash := sha256.New()
for _, config := range configs {
_, _ = hash.Write([]byte(config.name))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write(config.data)
_, _ = hash.Write([]byte{0})
}
return fmt.Sprintf("%X", hash.Sum(nil))
}
func renderServiceConfig(cfg instanceConfig, adminPort, channelPort int) string {
publicAddress := cfg.Server.AdvertiseIP + ":" + strconv.Itoa(channelPort)
privateAddress := "127.0.0.1:" + strconv.Itoa(adminPort)
return fmt.Sprintf(`[service]
name = "dnfbridge"
node_id = "dnfbridge-local-1"
environment = "local"
public_addr = %s
private_addr = %s
[cluster]
id = "local"
gid = 2
virtual_gid = 2
shard_id = %s
zone = "local"
version = "local"
data_version = "local"
[admin]
listen = %s
token = %s
[metrics]
prometheus_enabled = true
require_admin_token = false
[pvf]
enabled = true
path = %s
max_bytes = %d
preload_chunks = false
chunk_cache_bytes = 100663296
text_cache_bytes = 67108864
[server_group]
enabled = true
store_kind = "file"
plan_file = "configs/servergroup/plan.json"
[topology]
enabled = false
[idempotency]
kind = "memory"
[registry]
kind = "memory"
[bus]
kind = "memory"
[cache]
kind = "memory"
[lock]
kind = "memory"
[presence]
kind = "memory"
[worker]
size = 8
queue = 256
[profile_store]
store_kind = "memory"
`,
tomlString(publicAddress),
tomlString(privateAddress),
cfg.Game.ShardID,
tomlString(cfg.Server.AdminListen),
tomlString(cfg.Server.AdminToken),
tomlString(filepath.ToSlash(cfg.Game.PVFPath)),
cfg.Game.PVFMaxBytes,
)
}
func renderLogicConfig(cfg instanceConfig) string {
if cfg.Database.Mode == "sqlite" {
return fmt.Sprintf(`[repository]
enabled = true
driver = "sqlite"
sqlite_path = %s
redis_enabled = false
`, tomlString(filepath.ToSlash(cfg.Database.Path)))
}
// interpolateParams 让驱动在客户端内联参数,一条语句就是一次往返,而不是
// Prepare + Execute + Close。上游 2026-09-24 的 general log 抓包显示:一趟副本
// 里每条语句都在付这三次往返。仓储层只把结果扫描进带类型的落点
// (int64/string/NullString/time.Time),所以内联隐含的文本协议返回同样的值。
//
// 移植上游 89b6ea4。⚠️ 这份 DSN 由 runtime\bin\DNF90Control.exe 渲染进
// runtime\configs\dnf\logic.toml,那个 exe 未纳入 git —— 改完必须重编它并
// 重新生成配置,否则这一行不会生效。
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&interpolateParams=true",
cfg.Database.User,
cfg.Database.Password,
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Name,
)
return fmt.Sprintf(`[repository]
enabled = true
driver = "mysql"
mysql_dsn = %s
mysql_max_open_conns = 32
mysql_max_idle_conns = 8
mysql_conn_max_lifetime_seconds = 300
shard_id = %s
table_prefix = "dnf"
server_group_plan_file = "configs/servergroup/plan.json"
auto_create_schema = true
csharp_legacy_schema = true
create_databases = false
`,
tomlString(dsn),
tomlString(cfg.Game.ShardID),
)
}
func renderServerGroupPlan(cfg instanceConfig) ([]byte, error) {
plan := serverGroupPlan{
Version: "dnf90-local-v1",
Shards: []serverGroupShard{{
ID: cfg.Game.ShardID,
GroupID: "logic-local-1",
State: "open",
OpenAt: "2026-01-01T00:00:00Z",
PublicOpenAt: "2026-01-01T00:00:00Z",
}},
Groups: []serverGroup{{
ID: "logic-local-1",
Service: "logic",
MemberID: "logic-local-1",
State: "open",
}},
Routes: []serverGroupRoute{{
Feature: "dnf_repository",
ShardID: cfg.Game.ShardID,
GroupID: "logic-local-1",
State: "open",
Meta: map[string]string{
"dnf.repository.write_database": cfg.Database.Name,
"dnf.repository.read_database": cfg.Database.Name,
},
}},
}
data, err := json.MarshalIndent(plan, "", " ")
if err != nil {
return nil, fmt.Errorf("encode server-group plan: %w", err)
}
return append(data, '\n'), nil
}
func renderMySQLConfig(paths projectPaths, cfg instanceConfig) string {
return fmt.Sprintf(`[mysqld]
basedir=%s
datadir=%s
port=%d
bind-address=127.0.0.1
mysqlx=0
skip-log-bin
local-infile=OFF
symbolic-links=0
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
max-connections=128
innodb-flush-log-at-trx-commit=1
log-error=%s
pid-file=%s
[client]
host=127.0.0.1
port=%d
protocol=TCP
default-character-set=utf8mb4
`,
mysqlOptionValue(paths.mysqlServer),
mysqlOptionValue(paths.mysqlData),
cfg.Database.Port,
mysqlOptionValue(filepath.Join(paths.runtimeLogs, "mysql-error.log")),
mysqlOptionValue(paths.mysqlPIDFile),
cfg.Database.Port,
)
}
func mysqlOptionValue(value string) string {
value = filepath.ToSlash(value)
replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\r", "", "\n", "")
return `"` + replacer.Replace(value) + `"`
}
func runtimeEnvironment(cfg instanceConfig) []string {
values := map[string]string{
"LONGHENG_DNF_CONFIG": "configs/dnf/logic.toml",
"DNFBRIDGE_SERVER_IP": cfg.Server.AdvertiseIP,
"DNFBRIDGE_CHANNEL_LISTEN": cfg.Server.ChannelListen,
"DNFBRIDGE_GAME_LISTEN_HOST": cfg.Server.AdvertiseIP,
"DNFBRIDGE_PARTY_UDP_RELAY_ENABLED": strconv.FormatBool(cfg.Server.PartyUDPRelayEnabled),
"DNFBRIDGE_PARTY_UDP_RELAY_PORT_START": strconv.Itoa(cfg.Server.PartyUDPRelayPortStart),
"DNFBRIDGE_PARTY_UDP_RELAY_PORT_COUNT": strconv.Itoa(cfg.Server.PartyUDPRelayPortCount),
"DNFBRIDGE_DUNGEON_ROOM_PARTY_SLOT_OFF": strconv.FormatBool(cfg.Server.DungeonRoomPartySlotOff),
"DNFBRIDGE_RAID_LUKE_WEEKLY_LIMIT_ENFORCED": strconv.FormatBool(cfg.Server.RaidLukeWeeklyLimitEnforced),
"DNFBRIDGE_CHANNEL_INFO_PATH": filepath.ToSlash(cfg.Game.ChannelInfoPath),
"DNFBRIDGE_CHANNEL_INFO_BODY_MODE": "latest",
"DNFBRIDGE_DUNGEON_BATCH_PICKUP_ENABLED": strconv.FormatBool(dungeonBatchPickupEnabled(cfg)),
"DNFBRIDGE_PVF_PATH": filepath.ToSlash(cfg.Game.PVFPath),
"DNFBRIDGE_ACCOUNT_ID": cfg.Server.AccountID,
"DNFBRIDGE_PACKET_LOG": strconv.FormatBool(cfg.Server.PacketLog),
"DNFBRIDGE_PACKET_LOG_PATH": "logs/packet_log.txt",
"DNFBRIDGE_DEBUG_MODE": strconv.FormatBool(cfg.Server.DebugMode),
"DNFBRIDGE_DEBUG_STRIKE_ON_SKILL_USE": strconv.FormatBool(cfg.Server.DebugStrikeOnSkillUse),
"DNFBRIDGE_ROBOT_ENABLED": strconv.FormatBool(cfg.Server.robotsEnabled()),
"DNFBRIDGE_GAME_INITIAL_MODE": "notice",
"DNFBRIDGE_GAME_PRE_BOOTSTRAP": "none",
"DNFBRIDGE_GAME_POST_BOOTSTRAP": "none",
"DNFBRIDGE_GAME_UPPER_HEADER": cfg.Protocol.GameUpperHeader,
"DNFBRIDGE_GAME_UPPER_BODY_CODEC": cfg.Protocol.GameUpperBodyCodec,
"DNFBRIDGE_GAME_UPPER_CLIENT_BODY_CODEC": cfg.Protocol.GameUpperClientBodyCodec,
"DNFBRIDGE_GAME_OUTER_TOKEN": cfg.Protocol.GameOuterToken,
"DNFBRIDGE_DPROTO_MODE": "legacy_patch",
"DNFBRIDGE_CHANNEL_SERVER_INDEX": strconv.Itoa(cfg.Protocol.ChannelServerIndex),
"DNFBRIDGE_CHANNEL_ADVERTISE_SERVER_INDEX": strconv.Itoa(cfg.Protocol.ChannelAdvertiseServerIndex),
"DNFBRIDGE_REGION_ID": strconv.Itoa(cfg.Protocol.RegionID),
"DNF_SCRIPT_VERSION": "61",
}
// 包日志的加密与轮转参数。cleanRuntimeBaseEnvironment 会清掉所有继承的
// DNFBRIDGE_*/DNF_*/DFO_* 变量,因此这里必须显式透传,否则运维在启动器环境里
// 设置了也不生效。未设置时不写入——服务端加密用的是编译期固化公钥,
// 默认 100 MB 一片、保留 7 天。
for _, key := range []string{
"DNFBRIDGE_LOG_PUBLIC_KEY_FILE",
"DNFBRIDGE_LOG_PRIVATE_KEY_FILE",
"DNFBRIDGE_LOG_MAX_BYTES",
"DNFBRIDGE_LOG_RETENTION_DAYS",
} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
values[key] = value
}
}
// The launcher binding is emitted only when it is configured, so an
// untouched deployment keeps the exact environment it had before the feature
// existed. The keys travel as a comma separated list because each one is a
// single-line base64 blob.
if binding := cfg.LauncherBinding; binding != nil {
if mode := normalizeLauncherBindingMode(binding.Mode); mode != launcherBindingModeOff {
values["DNFBRIDGE_LAUNCHER_LID_MODE"] = mode
values["DNFBRIDGE_LAUNCHER_LID_PUBLIC_KEYS"] = strings.Join(
normalizeLauncherBindingKeys(binding.PublicKeys), ",")
values["DNFBRIDGE_LAUNCHER_LID_ALLOW_WIREGUARD"] = strconv.FormatBool(binding.AllowWireGuard)
}
}
// The controller normally removes every inherited DNF variable so runtime
// behavior is generated only from instance.json. This single opt-in is a
// local-debug deployment migration for one exact unstarted raid owned by
// the process being replaced; production profiles can never inherit it.
if cfg.Server.DebugMode {
if seed := strings.TrimSpace(os.Getenv("DNFBRIDGE_RAID_CREATED_RECOVERY_SEED_BASE64")); seed != "" {
values["DNFBRIDGE_RAID_CREATED_RECOVERY_SEED_BASE64"] = seed
}
}
return mergeEnvironment(cleanRuntimeBaseEnvironment(os.Environ()), values)
}
func dungeonBatchPickupEnabled(cfg instanceConfig) bool {
return cfg.Game.DungeonBatchPickupEnabled == nil || *cfg.Game.DungeonBatchPickupEnabled
}
func cleanRuntimeBaseEnvironment(base []string) []string {
result := make([]string, 0, len(base))
for _, entry := range base {
key, _, ok := strings.Cut(entry, "=")
if !ok {
continue
}
normalized := strings.ToUpper(strings.TrimSpace(key))
if normalized == "LONGHENG_DNF_CONFIG" ||
strings.HasPrefix(normalized, "DNFBRIDGE_") ||
strings.HasPrefix(normalized, "DNF_") ||
strings.HasPrefix(normalized, "DFO_") {
continue
}
result = append(result, entry)
}
return result
}
func mergeEnvironment(base []string, overrides map[string]string) []string {
merged := make(map[string]string, len(base)+len(overrides))
names := make(map[string]string, len(base)+len(overrides))
for _, entry := range base {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
normalized := strings.ToUpper(key)
merged[normalized] = value
names[normalized] = key
}
for key, value := range overrides {
normalized := strings.ToUpper(key)
merged[normalized] = value
names[normalized] = key
}
result := make([]string, 0, len(merged))
for normalized, value := range merged {
result = append(result, names[normalized]+"="+value)
}
return result
}
func tomlString(value string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\r", `\r`,
"\n", `\n`,
"\t", `\t`,
)
return `"` + replacer.Replace(value) + `"`
}
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
)
// 控制程序启动服务端时用的是 cmd.Env = runtimeEnvironment(cfg):它先把继承环境里的
// DNFBRIDGE_* / DNF_* / DFO_* / LONGHENG_DNF_CONFIG 全部剔除,再按 instance.json 拼一份。
//
// 这个测试把两件事钉死(都是排查"环境变量为什么没生效"时最容易踩的):
// 1. DNFBRIDGE_* 覆盖项在正常启动路径下**不会**传给服务端(例如 DNFBRIDGE_RECORD_DIR:
// 想改记录目录必须用直接运行 DNF90Server.exe 的方式,或用 instance.json)。
// 2. 不匹配这些前缀的变量会原样保留(限购窗口的 DNF90_PURCHASE_LIMIT_CLOCK_OFFSET_HOURS
// 就走这条路——名字里是 DNF90_ 而不是 DNF_,只差一个字符,容易被误删)。
func TestRuntimeEnvironmentScrubsBridgeOverridesButKeepsOthers(t *testing.T) {
t.Setenv("DNFBRIDGE_RECORD_DIR", `D:\somewhere\records`)
t.Setenv("DNFBRIDGE_PACKET_LOG_PATH", `logs\other.txt`)
t.Setenv("DNF_PACKET_LOG", "true")
t.Setenv("DFO_DEBUG", "1")
t.Setenv("DNF90_PURCHASE_LIMIT_CLOCK_OFFSET_HOURS", "200")
t.Setenv("PATH", `C:\Windows\System32`)
env := runtimeEnvironment(instanceConfig{})
lookup := func(key string) (string, bool) {
for _, entry := range env {
name, value, ok := strings.Cut(entry, "=")
if ok && strings.EqualFold(name, key) {
return value, true
}
}
return "", false
}
for _, scrubbed := range []string{"DNFBRIDGE_RECORD_DIR", "DNF_PACKET_LOG", "DFO_DEBUG"} {
if value, found := lookup(scrubbed); found {
t.Fatalf("%s 不该被传给服务端,实际值=%q(继承环境必须被 instance.json 覆盖)", scrubbed, value)
}
}
// 控制器自己会设置的同名项:保留的是它推导出来的值,继承值必须被顶掉。
if got, found := lookup("DNFBRIDGE_PACKET_LOG_PATH"); !found || got != "logs/packet_log.txt" {
t.Fatalf("DNFBRIDGE_PACKET_LOG_PATH=%q found=%t want logs/packet_log.txt(继承的 logs\\other.txt 必须被覆盖)", got, found)
}
value, found := lookup("DNF90_PURCHASE_LIMIT_CLOCK_OFFSET_HOURS")
if !found || value != "200" {
t.Fatalf("限购窗口时间旅行开关没传下去: found=%t value=%q", found, value)
}
if _, found := lookup("PATH"); !found {
t.Fatal("基础环境(PATH 之类)应当保留")
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"context"
"errors"
"fmt"
"runtime"
"strings"
)
const (
databaseModeSQLite = "sqlite"
databaseModePortable = "portable"
databaseModeMySQL = "mysql"
)
func (c *controller) database(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("database subcommand is required: select")
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "select":
fs := newFlagSet("database select", c.stderr)
mode := fs.String("mode", "", "database mode: sqlite or mysql")
prepare := fs.Bool("prepare", false, "extract the bundled portable MySQL files after selecting MySQL")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
return c.selectDatabaseMode(ctx, *mode, *prepare)
case "configure-mysql":
fs := newFlagSet("database configure-mysql", c.stderr)
port := fs.Int("port", 3306, "loopback MySQL TCP port")
name := fs.String("name", "dnf_local", "MySQL database name")
passwordStdin := fs.Bool("password-stdin", false, "read the MySQL root password from stdin")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
if !*passwordStdin {
return errors.New("database configure-mysql requires --password-stdin")
}
password, err := readLocalAccountPassword(c.stdin)
if err != nil {
return err
}
return c.configureExternalMySQL(ctx, *port, *name, password)
default:
return fmt.Errorf("unknown database subcommand %q", args[0])
}
}
func normalizeDatabaseMode(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case databaseModeSQLite, "sqliet":
return databaseModeSQLite, nil
case databaseModePortable:
return databaseModePortable, nil
case databaseModeMySQL:
if runtime.GOOS == "windows" {
return databaseModePortable, nil
}
return databaseModeMySQL, nil
default:
return "", fmt.Errorf("database mode must be sqlite or mysql, got %q", value)
}
}
// selectDatabaseMode is the single mode-switch boundary used by the Windows
// launcher and installer. It preserves installation identity, account
// namespace, client directory and all protocol settings while changing only
// the repository profile. A live server is stopped before an actual mode
// change so generated repository configuration can never disagree with the
// process that owns the game ports.
func (c *controller) selectDatabaseMode(
ctx context.Context,
requested string,
prepare bool,
) error {
targetMode, err := normalizeDatabaseMode(requested)
if err != nil {
return err
}
current, err := loadInstance(c.paths)
if err != nil {
return err
}
raw, err := decodeInstance(c.paths.instance)
if err != nil {
return err
}
if current.Database.Mode != targetMode {
_, serverRunning, inspectErr := c.loadManagedProcess(current.InstallationID, true)
if inspectErr != nil {
return inspectErr
}
// Portable MySQL can remain alive even when the game server is down, so
// always run the portable stop path before leaving that mode. SQLite
// needs a checkpoint only when its server was actually running.
if serverRunning || current.Database.Mode == databaseModePortable {
if err := c.stop(ctx, false); err != nil {
return fmt.Errorf("stop current database profile before switching: %w", err)
}
}
}
raw.Database.Mode = targetMode
switch targetMode {
case databaseModeSQLite:
if strings.TrimSpace(raw.Database.Path) == "" {
raw.Database.Path = "data/dnf90.db"
}
case databaseModePortable:
raw.Database.Host = "127.0.0.1"
raw.Database.Port = 13306
if strings.TrimSpace(raw.Database.Name) == "" {
raw.Database.Name = "dnf_local"
}
raw.Database.User = "root"
if strings.TrimSpace(raw.Database.Password) == "" || raw.Database.Password == "AUTO_GENERATE" {
raw.Database.Password, err = randomCredential("db_")
if err != nil {
return err
}
}
case databaseModeMySQL:
raw.Database.Host = "127.0.0.1"
raw.Database.Port = 3306
if strings.TrimSpace(raw.Database.Name) == "" {
raw.Database.Name = "dnf_local"
}
raw.Database.User = "root"
if strings.TrimSpace(raw.Database.Password) == "" || raw.Database.Password == "AUTO_GENERATE" {
raw.Database.Password, err = randomCredential("db_")
if err != nil {
return err
}
}
}
validated := raw
if strings.EqualFold(strings.TrimSpace(validated.Server.AdvertiseIP), autoDetectAdvertiseIP) {
validated.Server.AdvertiseIP = current.Server.AdvertiseIP
}
if err := validateInstance(validated); err != nil {
return fmt.Errorf("validate selected database profile: %w", err)
}
data, err := marshalInstance(raw)
if err != nil {
return err
}
if err := writeFile(c.paths.instance, data, 0o600); err != nil {
return err
}
if prepare && targetMode == databaseModePortable {
if err := preparePortableMySQL(ctx, c.paths, c.stdout); err != nil {
return fmt.Errorf("install bundled portable MySQL: %w", err)
}
}
label := "SQLite"
if targetMode == databaseModePortable || targetMode == databaseModeMySQL {
label = "MySQL"
}
fmt.Fprintf(c.stdout, "Database profile selected: %s\n", label)
return nil
}
func (c *controller) configureExternalMySQL(
ctx context.Context,
port int,
name string,
password string,
) error {
if runtime.GOOS == "windows" {
return errors.New("external system MySQL configuration is available only in the Linux release")
}
cfg, err := loadInstance(c.paths)
if err != nil {
return err
}
if _, found, err := c.loadManagedProcess(cfg.InstallationID, true); err != nil {
return err
} else if found {
return errors.New("stop DNF90 before changing the MySQL profile")
}
raw, err := decodeInstance(c.paths.instance)
if err != nil {
return err
}
raw.Database.Mode = databaseModeMySQL
raw.Database.Host = "127.0.0.1"
raw.Database.Port = port
raw.Database.Name = strings.TrimSpace(name)
raw.Database.User = "root"
raw.Database.Password = password
validated := raw
if strings.EqualFold(strings.TrimSpace(validated.Server.AdvertiseIP), autoDetectAdvertiseIP) {
validated.Server.AdvertiseIP = cfg.Server.AdvertiseIP
}
if err := validateInstance(validated); err != nil {
return err
}
if err := ensureExternalMySQLDatabase(ctx, validated); err != nil {
return err
}
data, err := marshalInstance(raw)
if err != nil {
return err
}
if err := writeFile(c.paths.instance, data, 0o600); err != nil {
return err
}
fmt.Fprintf(c.stdout, "External loopback MySQL configured: 127.0.0.1:%d/%s\n", port, raw.Database.Name)
return nil
}
@@ -0,0 +1,105 @@
package main
import (
"bytes"
"context"
"os"
"testing"
)
func TestSelectDatabaseModePreservesInstallationAndClient(t *testing.T) {
root := t.TempDir()
paths := newProjectPaths(root)
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
cfg.Database = databaseConfig{
Mode: databaseModeSQLite,
Path: "data/dnf90.db",
Name: "dnf_local",
}
cfg.Client.Directory = `D:\Games\DNF`
data, err := marshalInstance(cfg)
if err != nil {
t.Fatal(err)
}
if err := writeFile(paths.instance, data, 0o600); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
controller := newController(paths, &output, &output)
if err := controller.selectDatabaseMode(context.Background(), "mysql", false); err != nil {
t.Fatalf("select MySQL: %v", err)
}
selected, err := decodeInstance(paths.instance)
if err != nil {
t.Fatal(err)
}
if selected.Database.Mode != databaseModePortable ||
selected.Database.Host != "127.0.0.1" ||
selected.Database.Port != 13306 ||
selected.Database.User != "root" ||
selected.Database.Password == "" {
t.Fatalf("portable database profile = %+v", selected.Database)
}
if selected.InstallationID != cfg.InstallationID ||
selected.Server.AccountID != cfg.Server.AccountID ||
selected.Client.Directory != cfg.Client.Directory {
t.Fatalf("mode switch changed installation identity: %+v", selected)
}
password := selected.Database.Password
if err := controller.selectDatabaseMode(context.Background(), "sqlite", false); err != nil {
t.Fatalf("select SQLite: %v", err)
}
selected, err = decodeInstance(paths.instance)
if err != nil {
t.Fatal(err)
}
if selected.Database.Mode != databaseModeSQLite || selected.Database.Path != "data/dnf90.db" {
t.Fatalf("SQLite database profile = %+v", selected.Database)
}
if selected.Database.Password != password {
t.Fatal("portable MySQL credential was not retained for switching back")
}
}
func TestNormalizeDatabaseModeAcceptsPublishedNames(t *testing.T) {
for input, want := range map[string]string{
"SQLite": databaseModeSQLite,
"SQLiet": databaseModeSQLite,
"MySQL": databaseModePortable,
} {
got, err := normalizeDatabaseMode(input)
if err != nil || got != want {
t.Fatalf("normalizeDatabaseMode(%q) = %q, %v", input, got, err)
}
}
if _, err := normalizeDatabaseMode("unknown"); err == nil {
t.Fatal("unknown database mode was accepted")
}
}
func TestSelectDatabaseModeRejectsUnknownModeWithoutWriting(t *testing.T) {
root := t.TempDir()
paths := newProjectPaths(root)
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths.instance, []byte("sentinel"), 0o600); err != nil {
t.Fatal(err)
}
controller := newController(paths, &bytes.Buffer{}, &bytes.Buffer{})
if err := controller.selectDatabaseMode(context.Background(), "unknown", false); err == nil {
t.Fatal("unknown database mode was accepted")
}
data, err := os.ReadFile(paths.instance)
if err != nil {
t.Fatal(err)
}
if string(data) != "sentinel" {
t.Fatalf("invalid selection changed instance.json: %q", data)
}
}
@@ -0,0 +1,77 @@
package main
import (
"os"
"path/filepath"
"longheng.io/server/internal/platform/logcrypt"
"longheng.io/server/internal/platform/logkey"
)
// 服务端 stdout/stderr 自 2026-09-30 起**落盘即密文**(公钥信封)。
//
// 这两份日志曾是最大的明文来源(17 小时 101 MB),内容含客户端 IP、admin 请求路径、
// 角色名与聊天摘要——正是"服务端要分发出去"时最不该外泄的现场信息。
//
// 密钥:收件人公钥**固化在二进制里**(internal/platform/logkey),因此运维不需要
// 部署任何密钥文件;只有显式设了 DNFBRIDGE_LOG_PUBLIC_KEY_FILE 才用它覆盖(轮换/测试)。
// 能解开日志的私钥只在运营本机,分发包里没有。
// serverLogStream 是接给 cmd.Stdout / cmd.Stderr 的加密写入器。
//
// 刻意不在这里 Close:os/exec 会起一个拷贝协程,在子进程存活期间持续调用 Write,
// 提前关闭会让服务端输出全部丢失。每次 start 保留一对句柄到本进程退出,
// 相比服务端自身的运行时长可以忽略。
type serverLogStream struct {
path string
file *os.File
sink *logcrypt.StreamSink
}
// openEncryptedLogStream 打开一个加密日志流并写入容器头。
func openEncryptedLogStream(path string) (*serverLogStream, error) {
recipient, err := logkey.ResolveRecipient()
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
sink, err := logcrypt.NewStreamSink(recipient, file, nil)
if err != nil {
_ = file.Close()
return nil, err
}
return &serverLogStream{path: path, file: file, sink: sink}, nil
}
// Write 实现 io.Writer。
func (s *serverLogStream) Write(p []byte) (int, error) { return s.sink.Write(p) }
// Path 返回容器文件路径。
func (s *serverLogStream) Path() string {
if s == nil {
return ""
}
return s.path
}
// Err 返回第一次写入失败的错误(用于停服时留痕)。
func (s *serverLogStream) Err() error {
if s == nil {
return nil
}
return s.sink.Err()
}
// Close 关闭底层文件。只有在确认子进程已经退出之后才可调用。
func (s *serverLogStream) Close() error {
if s == nil || s.file == nil {
return nil
}
return s.file.Close()
}
@@ -0,0 +1,49 @@
package main
import (
"context"
"database/sql"
"fmt"
"net"
"strconv"
mysql "github.com/go-sql-driver/mysql"
)
func ensureExternalMySQLDatabase(ctx context.Context, cfg instanceConfig) error {
if cfg.Database.Mode != databaseModeMySQL {
return fmt.Errorf("external MySQL setup requires database.mode=mysql")
}
serverConfig := mysql.NewConfig()
serverConfig.User = cfg.Database.User
serverConfig.Passwd = cfg.Database.Password
serverConfig.Net = "tcp"
serverConfig.Addr = net.JoinHostPort(cfg.Database.Host, strconv.Itoa(cfg.Database.Port))
serverConfig.ParseTime = true
serverConfig.Collation = "utf8mb4_unicode_ci"
db, err := sql.Open("mysql", serverConfig.FormatDSN())
if err != nil {
return fmt.Errorf("open external MySQL: %w", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("external MySQL is not reachable on loopback: %w", err)
}
if _, err := db.ExecContext(
ctx,
"CREATE DATABASE IF NOT EXISTS `"+cfg.Database.Name+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
); err != nil {
return fmt.Errorf("create external MySQL database %s: %w", cfg.Database.Name, err)
}
selectedConfig := serverConfig.Clone()
selectedConfig.DBName = cfg.Database.Name
selected, err := sql.Open("mysql", selectedConfig.FormatDSN())
if err != nil {
return fmt.Errorf("open external MySQL database %s: %w", cfg.Database.Name, err)
}
defer selected.Close()
if err := selected.PingContext(ctx); err != nil {
return fmt.Errorf("select external MySQL database %s: %w", cfg.Database.Name, err)
}
return nil
}
+763
View File
@@ -0,0 +1,763 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"longheng.io/server/internal/platform/wineenv"
)
const (
autoDetectAdvertiseIP = "AUTO_DETECT"
defaultAccountIDPrefix = "dnf:"
defaultPartyUDPRelayPortStart = 30000
defaultPartyUDPRelayPortCount = 512
)
var (
safeValuePattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
sqlIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
digitsPattern = regexp.MustCompile(`^[0-9]+$`)
hexTokenPattern = regexp.MustCompile(`^[0-9A-Fa-f]{8,32}$`)
)
func loadInstance(paths projectPaths) (instanceConfig, error) {
if err := paths.ensureDirectories(); err != nil {
return instanceConfig{}, err
}
if !isRegularFile(paths.instance) {
data, err := os.ReadFile(paths.instanceExample)
if err != nil {
return instanceConfig{}, fmt.Errorf("read instance template: %w", err)
}
if err := writeFile(paths.instance, data, 0o600); err != nil {
return instanceConfig{}, err
}
fmt.Println("Created runtime config:", paths.instance)
}
cfg, droppedLegacy, err := decodeInstanceMigratingLegacyFields(paths.instance)
if err != nil {
return instanceConfig{}, err
}
changed := false
if len(droppedLegacy) > 0 {
fmt.Println(
"Removing instance.json settings this release does not implement:",
strings.Join(droppedLegacy, ", "),
)
changed = true
}
if cfg.InstallationID == "" || cfg.InstallationID == "AUTO_GENERATE" {
installationID, err := randomCredential("inst_")
if err != nil {
return instanceConfig{}, err
}
cfg.InstallationID = installationID
changed = true
}
if cfg.Server.AdminToken == "AUTO_GENERATE" {
token, err := randomCredential("adm_")
if err != nil {
return instanceConfig{}, err
}
cfg.Server.AdminToken = token
changed = true
}
if strings.EqualFold(strings.TrimSpace(cfg.Server.AccountID), "AUTO_GENERATE") {
accountID, err := randomCredential(defaultAccountIDPrefix)
if err != nil {
return instanceConfig{}, err
}
cfg.Server.AccountID = accountID
changed = true
}
if cfg.Database.Password == "AUTO_GENERATE" {
password, err := randomCredential("db_")
if err != nil {
return instanceConfig{}, err
}
cfg.Database.Password = password
changed = true
}
if cfg.Database.Mode == "docker" {
cfg.Database.Mode = "portable"
changed = true
}
if cfg.Database.Mode == "sqlite" && strings.TrimSpace(cfg.Database.Path) == "" {
cfg.Database.Path = "data/dnf90.db"
changed = true
}
if len(cfg.LegacyRedis) != 0 {
cfg.LegacyRedis = nil
changed = true
}
if cfg.Server.RobotEnabled == nil {
enabled := false
cfg.Server.RobotEnabled = &enabled
changed = true
}
if cfg.Server.PartyUDPRelayPortStart == 0 {
cfg.Server.PartyUDPRelayPortStart = defaultPartyUDPRelayPortStart
changed = true
}
if cfg.Server.PartyUDPRelayPortCount == 0 {
cfg.Server.PartyUDPRelayPortCount = defaultPartyUDPRelayPortCount
changed = true
}
// Preserve the pre-switch behavior for existing installations. A pointer is
// used in the JSON model so an omitted legacy field is distinct from the
// operator explicitly choosing false.
if cfg.Game.DungeonBatchPickupEnabled == nil {
enabled := true
cfg.Game.DungeonBatchPickupEnabled = &enabled
changed = true
}
// regionId=0 与 advertise=0 是本部署客户端的必需取值,不做"补默认值"改写;
// 详见下面 protocol 段校验处的说明。
resolved := cfg
configuredAdvertiseIP := strings.TrimSpace(cfg.Server.AdvertiseIP)
autoDetect := strings.EqualFold(configuredAdvertiseIP, autoDetectAdvertiseIP)
if autoDetect {
// An explicit 127.0.0.1 is a deliberate operator/hub choice and is left
// alone; only AUTO_DETECT reaches the detector.
advertiseIP, loopbackFallback, err := detectPrivateLANIPv4()
if err != nil {
return instanceConfig{}, fmt.Errorf(
"auto-detect server.advertiseIp: %w; set it explicitly to an IPv4 assigned to this computer",
err,
)
}
resolved.Server.AdvertiseIP = advertiseIP
if loopbackFallback {
fmt.Println(
"No private LAN IPv4 was found on this computer; advertising the 90cn game channels on",
advertiseIP+".",
)
fmt.Println(
"Only this computer can reach the server in that state: other computers on the LAN and",
"party P2P cannot connect until a private LAN adapter is available. Connect a LAN or",
"Wi-Fi adapter and run start again, or set server.advertiseIp explicitly.",
)
} else {
fmt.Println("Using local LAN IPv4 for 90cn game channels:", advertiseIP)
}
} else {
resolved.Server.AdvertiseIP = configuredAdvertiseIP
if cfg.Server.AdvertiseIP != configuredAdvertiseIP {
cfg.Server.AdvertiseIP = configuredAdvertiseIP
changed = true
}
}
if err := validateInstance(resolved); err != nil {
return instanceConfig{}, err
}
if changed {
data, err := marshalInstance(cfg)
if err != nil {
return instanceConfig{}, err
}
if err := writeFile(paths.instance, data, 0o600); err != nil {
return instanceConfig{}, err
}
fmt.Println("Generated private local credentials in runtime/config/instance.json")
}
return resolved, nil
}
// legacyInstanceFields are instance.json keys earlier one-click trees wrote for
// features this release does not implement. They are dropped on load with an
// explicit note instead of being rejected by the strict decoder: runtime/config/
// is outside the launcher's update whitelist, so an installation carrying one
// could never be repaired by an update, only by hand-deleting instance.json.
// Every other unknown field still fails decoding, so a typo in a hand-edited
// file is never silently ignored.
var legacyInstanceFields = []struct {
section string
keys []string
}{
{section: "server", keys: []string{"partyUdpRelay"}},
{section: "game", keys: []string{
"enabledChannelIds",
"mainQuestContinuityExperience",
"deleteExpiredItems",
"pvfItemExpirationEnabled",
}},
}
// dropLegacyInstanceFields removes exactly the known legacy keys and reports
// which ones were present, as "section.key". Input it cannot parse is returned
// untouched so the strict decoder reports the real syntax problem.
func dropLegacyInstanceFields(data []byte) ([]byte, []string) {
var document map[string]json.RawMessage
if err := json.Unmarshal(data, &document); err != nil {
return data, nil
}
var dropped []string
for _, legacy := range legacyInstanceFields {
raw, found := document[legacy.section]
if !found {
continue
}
var section map[string]json.RawMessage
if err := json.Unmarshal(raw, &section); err != nil {
continue
}
sectionChanged := false
for _, key := range legacy.keys {
if _, present := section[key]; !present {
continue
}
delete(section, key)
dropped = append(dropped, legacy.section+"."+key)
sectionChanged = true
}
if !sectionChanged {
continue
}
encoded, err := json.Marshal(section)
if err != nil {
return data, nil
}
document[legacy.section] = encoded
}
if len(dropped) == 0 {
return data, nil
}
encoded, err := json.Marshal(document)
if err != nil {
return data, nil
}
return encoded, dropped
}
func decodeInstance(path string) (instanceConfig, error) {
cfg, _, err := decodeInstanceMigratingLegacyFields(path)
return cfg, err
}
func decodeInstanceMigratingLegacyFields(path string) (instanceConfig, []string, error) {
data, err := os.ReadFile(path)
if err != nil {
return instanceConfig{}, nil, fmt.Errorf("read instance %s: %w", path, err)
}
data, dropped := dropLegacyInstanceFields(data)
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var cfg instanceConfig
if err := decoder.Decode(&cfg); err != nil {
return instanceConfig{}, nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return instanceConfig{}, nil, fmt.Errorf("invalid JSON in %s: multiple values", path)
}
return instanceConfig{}, nil, fmt.Errorf("invalid JSON in %s: %w", path, err)
}
return cfg, dropped, nil
}
func randomCredential(prefix string) (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate private credential: %w", err)
}
return prefix + hex.EncodeToString(raw[:]), nil
}
func marshalInstance(cfg instanceConfig) ([]byte, error) {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return nil, fmt.Errorf("encode instance configuration: %w", err)
}
return append(data, '\n'), nil
}
func validateInstance(cfg instanceConfig) error {
if cfg.SchemaVersion != 1 {
return errorsf("instance.json schemaVersion must be 1")
}
if err := validateSafeValue("installationId", cfg.InstallationID); err != nil {
return err
}
if cfg.Mode != "local-single-account" {
return errorsf("this release supports only mode=local-single-account")
}
ip := net.ParseIP(strings.TrimSpace(cfg.Server.AdvertiseIP))
if ip == nil || ip.To4() == nil {
return errorsf("server.advertiseIp must be an IPv4 address")
}
// A private LAN IPv4 is the required shape: party P2P and any second
// computer depend on it. 127.0.0.1 is the single documented exception for a
// host with no LAN adapter at all, where the local client is the only peer.
if ip.IsLoopback() {
if !ip.Equal(net.IPv4(127, 0, 0, 1)) {
return errorsf("server.advertiseIp loopback must be 127.0.0.1")
}
} else if !ip.IsPrivate() {
return errorsf("90cn game channels require server.advertiseIp to be a private LAN IPv4 address or 127.0.0.1, not a public address")
}
if cfg.Server.PartyUDPRelayEnabled {
relayPortStart := cfg.Server.PartyUDPRelayPortStart
if relayPortStart == 0 {
relayPortStart = defaultPartyUDPRelayPortStart
}
relayPortCount := cfg.Server.PartyUDPRelayPortCount
if relayPortCount == 0 {
relayPortCount = defaultPartyUDPRelayPortCount
}
if relayPortStart < 1 || relayPortStart > 65535 {
return errorsf("server.partyUdpRelayPortStart must be between 1 and 65535")
}
if relayPortCount < 12 || relayPortCount > 1024 ||
relayPortStart+relayPortCount-1 > 65535 {
return errorsf("server.partyUdpRelayPortCount must reserve 12-1024 UDP ports within the valid port range")
}
}
if err := validateListenAddress("server.channelListen", cfg.Server.ChannelListen, false); err != nil {
return err
}
if err := validateLoopbackListenAddress("server.channelListen", cfg.Server.ChannelListen); err != nil {
return err
}
if err := validateListenAddress("server.adminListen", cfg.Server.AdminListen, false); err != nil {
return err
}
if err := validateLoopbackListenAddress("server.adminListen", cfg.Server.AdminListen); err != nil {
return err
}
if err := validateSafeValue("server.adminToken", cfg.Server.AdminToken); err != nil {
return err
}
accountID := strings.TrimSpace(cfg.Server.AccountID)
if accountID == "" {
return errorsf("server.accountId is required")
}
if accountID == "AUTO_GENERATE" ||
len(accountID) > 128 ||
!safeValuePattern.MatchString(accountID) {
return errorsf("server.accountId must be a safe account id with at most 128 bytes")
}
switch cfg.Database.Mode {
case "sqlite":
path := strings.TrimSpace(cfg.Database.Path)
if path == "" || filepath.IsAbs(path) || escapesRoot(path) {
return errorsf("database.path must be a non-empty path inside runtime")
}
case "portable", "mysql":
if strings.TrimSpace(cfg.Database.Host) == "" {
return errorsf("database.host is required")
}
if cfg.Database.Port < 1 || cfg.Database.Port > 65535 {
return errorsf("database.port must be between 1 and 65535")
}
if !sqlIdentifierPattern.MatchString(cfg.Database.Name) {
return errorsf("database.name must be a safe SQL identifier")
}
if err := validateSafeValue("database.user", cfg.Database.User); err != nil {
return err
}
if cfg.Database.User != "root" {
return errorsf("database.user must be root, as required by this local profile")
}
if err := validateSafeValue("database.password", cfg.Database.Password); err != nil {
return err
}
if cfg.Database.Host != "127.0.0.1" && cfg.Database.Host != "localhost" {
return errorsf("local-single-account requires database.host=127.0.0.1 or localhost")
}
default:
return errorsf("database.mode must be sqlite, mysql, or portable")
}
if !digitsPattern.MatchString(cfg.Game.ShardID) {
return errorsf("game.shardId must contain only digits")
}
if cfg.Game.PVFMaxBytes == 0 || cfg.Game.PVFMaxBytes < -1 {
return errorsf("game.pvfMaxBytes must be positive or -1 to disable the size limit")
}
for _, value := range []string{cfg.Game.PVFPath, cfg.Game.ChannelInfoPath} {
if strings.TrimSpace(value) == "" || filepath.IsAbs(value) {
return errorsf("game asset paths must be non-empty paths relative to runtime")
}
if escapesRoot(value) {
return errorsf("game asset paths must stay inside runtime")
}
}
if filepath.ToSlash(cfg.Game.PVFPath) != "data/dnf/Script.pvf" ||
filepath.ToSlash(cfg.Game.ChannelInfoPath) != "data/dnf/channel_info.etc" {
return errorsf("the 90cn profile locks game asset paths to data/dnf/Script.pvf and data/dnf/channel_info.etc")
}
if cfg.Protocol.Profile != "90cn-decode-bypass-v1" {
return errorsf("protocol.profile must be 90cn-decode-bypass-v1")
}
if cfg.Protocol.GameUpperHeader != "server16" {
return errorsf("protocol.gameUpperHeader must be server16 for this client profile")
}
if cfg.Protocol.GameUpperBodyCodec != "plaintext" ||
cfg.Protocol.GameUpperClientBodyCodec != "plaintext" {
return errorsf("protocol body codecs must both be plaintext for this client profile")
}
if !hexTokenPattern.MatchString(cfg.Protocol.GameOuterToken) ||
!strings.EqualFold(cfg.Protocol.GameOuterToken, "de509f65e9ccaae621cb7278fc2b8e6c") {
return errorsf("protocol.gameOuterToken does not match the 90cn compatibility profile")
}
if cfg.Protocol.ChannelServerIndex != 1 {
return errorsf("protocol.channelServerIndex must remain 1 for the current client profile")
}
// 本部署所用的客户端补丁在 ProxyChannelCategoryInsert 里按
// "serverID == 0" 判定自动连接候选频道(见 client-patch\90CN.cpp 的
// channel auto-connect candidate captured)。因此这里必须允许
// regionId=0 / channelAdvertiseServerIndex=0:一旦被自动改成 1,
// 频道目录会通告 server=1,客户端永远捕获不到候选频道,表现就是
// "自动频道连接失败,连接至可登录的频道"。
if cfg.Protocol.RegionID < 0 || cfg.Protocol.RegionID >= 250 {
return errorsf("protocol.regionId must be between 0 and 249 for the current client")
}
if cfg.Protocol.ChannelAdvertiseServerIndex != cfg.Protocol.RegionID {
return errorsf("protocol.channelAdvertiseServerIndex must match protocol.regionId so guild and social region identities stay aligned")
}
regionName := strings.TrimSpace(cfg.Protocol.RegionName)
if utf8.RuneCountInString(regionName) > 24 {
return errorsf("protocol.regionName must contain at most 24 characters")
}
for _, character := range regionName {
if unicode.IsControl(character) {
return errorsf("protocol.regionName must not contain control characters")
}
}
if cfg.Client.InitialGamePort != 0 {
return errorsf("client.initialGamePort must be 0 for the 90cn dynamic channel profile")
}
if !cfg.Client.HookCreate {
return errorsf("client.hookCreate must be true for this client profile")
}
if strings.TrimSpace(cfg.Build.GoExecutable) == "" {
return errorsf("build.goExecutable is required")
}
return nil
}
type lanIPv4Candidate struct {
Address string
InterfaceName string
InterfaceIndex int
}
// loopbackAdvertiseIP is the sole documented exception to the private-LAN rule.
// A host with no outbound route at all (fully offline, or carrying only virtual
// host-only adapters) still has to be playable by the client running on it.
const loopbackAdvertiseIP = "127.0.0.1"
// detectPrivateLANIPv4 resolves AUTO_DETECT. It returns the chosen address and
// whether the loopback fallback had to be used; callers must surface that
// second return value to the operator, because loopback silently rules out
// LAN play and party P2P.
func detectPrivateLANIPv4() (string, bool, error) {
preferred := preferredOutboundIPv4()
interfaces, err := net.Interfaces()
if err != nil {
if wineenv.Detected() && isPrivateLANIPv4(preferred) {
return preferred.To4().String(), false, nil
}
return "", false, fmt.Errorf("enumerate network interfaces: %w", err)
}
candidates := make([]lanIPv4Candidate, 0, len(interfaces))
for _, networkInterface := range interfaces {
if networkInterface.Flags&net.FlagUp == 0 ||
networkInterface.Flags&net.FlagLoopback != 0 {
continue
}
addresses, err := networkInterface.Addrs()
if err != nil {
continue
}
for _, address := range addresses {
var ip net.IP
switch value := address.(type) {
case *net.IPNet:
ip = value.IP
case *net.IPAddr:
ip = value.IP
default:
host, _, splitErr := net.SplitHostPort(address.String())
if splitErr == nil {
ip = net.ParseIP(host)
}
}
if !isPrivateLANIPv4(ip) {
continue
}
candidates = append(candidates, lanIPv4Candidate{
Address: ip.To4().String(),
InterfaceName: networkInterface.Name,
InterfaceIndex: networkInterface.Index,
})
}
}
candidates = appendWineRouteCandidate(candidates, preferred, wineenv.Detected())
address, loopbackFallback := chooseAdvertiseIPv4(candidates, preferred)
return address, loopbackFallback, nil
}
// chooseAdvertiseIPv4 keeps the private LAN IPv4 as the strongly preferred
// answer and only degrades to loopback when there is not a single usable
// candidate, so a machine that is merely offline still starts instead of
// failing hard.
func chooseAdvertiseIPv4(candidates []lanIPv4Candidate, preferred net.IP) (string, bool) {
address, err := choosePrivateLANIPv4(candidates, preferred)
if err != nil {
return loopbackAdvertiseIP, true
}
return address, false
}
func appendWineRouteCandidate(
candidates []lanIPv4Candidate,
preferred net.IP,
underWine bool,
) []lanIPv4Candidate {
if !underWine || !isPrivateLANIPv4(preferred) {
return candidates
}
// Wine's GetAdaptersAddresses view can omit the Unix host interface even
// though a socket route lookup returns a bindable private source address.
// Feed that source address through the normal private-IP selector instead
// of weakening validation or falling back to wildcard/loopback binding.
return append(candidates, lanIPv4Candidate{
Address: preferred.To4().String(),
InterfaceName: "Wine default route",
InterfaceIndex: int(^uint(0) >> 1),
})
}
func preferredOutboundIPv4() net.IP {
// UDP connect performs only a local route lookup; it sends no application data.
connection, err := net.DialUDP(
"udp4",
nil,
&net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 9},
)
if err != nil {
return nil
}
defer connection.Close()
local, ok := connection.LocalAddr().(*net.UDPAddr)
if !ok {
return nil
}
return local.IP.To4()
}
func choosePrivateLANIPv4(candidates []lanIPv4Candidate, preferred net.IP) (string, error) {
unique := make(map[string]lanIPv4Candidate, len(candidates))
for _, candidate := range candidates {
ip := net.ParseIP(strings.TrimSpace(candidate.Address))
if !isPrivateLANIPv4(ip) {
continue
}
candidate.Address = ip.To4().String()
existing, found := unique[candidate.Address]
if !found || candidate.InterfaceIndex < existing.InterfaceIndex {
unique[candidate.Address] = candidate
}
}
if preferred = preferred.To4(); isPrivateLANIPv4(preferred) {
if candidate, found := unique[preferred.String()]; found &&
!likelyVirtualInterface(candidate.InterfaceName) {
return preferred.String(), nil
}
}
valid := make([]lanIPv4Candidate, 0, len(unique))
for _, candidate := range unique {
valid = append(valid, candidate)
}
sort.Slice(valid, func(left, right int) bool {
leftVirtual := likelyVirtualInterface(valid[left].InterfaceName)
rightVirtual := likelyVirtualInterface(valid[right].InterfaceName)
if leftVirtual != rightVirtual {
return !leftVirtual
}
leftRank := privateLANSubnetRank(net.ParseIP(valid[left].Address))
rightRank := privateLANSubnetRank(net.ParseIP(valid[right].Address))
if leftRank != rightRank {
return leftRank < rightRank
}
if valid[left].InterfaceIndex != valid[right].InterfaceIndex {
return valid[left].InterfaceIndex < valid[right].InterfaceIndex
}
return valid[left].Address < valid[right].Address
})
if len(valid) == 0 {
return "", errors.New("no active private LAN IPv4 address was found")
}
return valid[0].Address, nil
}
func isPrivateLANIPv4(ip net.IP) bool {
return ip != nil && ip.To4() != nil && !ip.IsLoopback() && ip.IsPrivate()
}
func likelyVirtualInterface(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
for _, marker := range []string{
"virtual",
"vethernet",
"hyper-v",
"vmware",
"virtualbox",
"vbox",
"docker",
"wsl",
"tailscale",
"zerotier",
"wireguard",
"wintun",
"openvpn",
"hamachi",
"clash",
"sing-box",
"loopback",
"npcap",
"tap",
"tun",
} {
if strings.Contains(name, marker) {
return true
}
}
return false
}
func privateLANSubnetRank(ip net.IP) int {
ip = ip.To4()
if ip == nil {
return 3
}
switch {
case ip[0] == 192 && ip[1] == 168:
return 0
case ip[0] == 10:
return 1
default:
return 2
}
}
func validateListenAddress(name, value string, requireHost bool) error {
value = strings.TrimSpace(value)
if value == "" {
return errorsf("%s is required", name)
}
separator := strings.LastIndex(value, ":")
if separator < 0 {
return errorsf("%s must be host:port or :port", name)
}
if requireHost && separator == 0 {
return errorsf("%s must include a host", name)
}
port, err := strconv.Atoi(value[separator+1:])
if err != nil || port < 1 || port > 65535 {
return errorsf("%s has an invalid TCP port", name)
}
return nil
}
func validateLoopbackListenAddress(name, value string) error {
host, _, err := net.SplitHostPort(strings.TrimSpace(value))
if err != nil {
return errorsf("%s must use the form 127.0.0.1:port", name)
}
if host != "127.0.0.1" {
return errorsf("local-single-account requires %s to bind 127.0.0.1", name)
}
return nil
}
func validateSafeValue(name, value string) error {
if strings.TrimSpace(value) == "" || !safeValuePattern.MatchString(value) {
return errorsf("%s may contain only letters, digits, dot, underscore, colon, and dash", name)
}
return nil
}
func listenPort(address string) (int, error) {
separator := strings.LastIndex(strings.TrimSpace(address), ":")
if separator < 0 {
return 0, fmt.Errorf("address %q has no port", address)
}
port, err := strconv.Atoi(address[separator+1:])
if err != nil || port < 1 || port > 65535 {
return 0, fmt.Errorf("address %q has an invalid port", address)
}
return port, nil
}
func escapesRoot(path string) bool {
cleaned := filepath.Clean(path)
return cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator))
}
func errorsf(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
func writeFile(path string, data []byte, mode os.FileMode) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o755); err != nil {
return fmt.Errorf("create directory for %s: %w", path, err)
}
temp, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("create temporary file for %s: %w", path, err)
}
tempPath := temp.Name()
installed := false
defer func() {
_ = temp.Close()
if !installed {
_ = os.Remove(tempPath)
}
}()
if err := temp.Chmod(mode); err != nil {
return fmt.Errorf("set temporary file mode for %s: %w", path, err)
}
written, err := temp.Write(data)
if err != nil {
return fmt.Errorf("write temporary file for %s: %w", path, err)
}
if written != len(data) {
return fmt.Errorf(
"write temporary file for %s: wrote %d of %d bytes",
path,
written,
len(data),
)
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("flush temporary file for %s: %w", path, err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary file for %s: %w", path, err)
}
if err := replaceFileAtomic(tempPath, path); err != nil {
return fmt.Errorf("replace %s atomically: %w", path, err)
}
installed = true
return nil
}
@@ -0,0 +1,209 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Launcher identity binding modes, kept in step with the dnfbridge constants of
// the same names. They are duplicated here rather than imported because the
// controller is the config generator and must not depend on the protocol
// service just to spell three strings.
const (
launcherBindingModeOff = "off"
launcherBindingModeAudit = "audit"
launcherBindingModeEnforce = "enforce"
)
// launcherIdentityPrivateKeyFile is where keygen writes the launcher's half.
// The name says "private" because the file is the whole identity: whoever holds
// it can connect to this server, and the server package must never ship it.
const launcherIdentityPrivateKeyFile = "launcher_identity_private.pem"
func normalizeLauncherBindingMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case launcherBindingModeAudit, "log":
return launcherBindingModeAudit
case launcherBindingModeEnforce, "on", "true", "required":
return launcherBindingModeEnforce
default:
return launcherBindingModeOff
}
}
func normalizeLauncherBindingKeys(keys []string) []string {
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
normalized = append(normalized, trimmed)
}
}
return normalized
}
// launcherIdentity generates the deployment key pair, stores the public half in
// instance.json, and writes the private half where the launcher build picks it
// up. Only the public half ever reaches the server, which is what stops a
// distributed server package from minting its own proof.
func (c *controller) launcherIdentity(_ context.Context, args []string) error {
if len(args) == 0 {
return errors.New("launcher-identity subcommand is required: keygen or status")
}
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "keygen":
fs := newFlagSet("launcher-identity keygen", c.stderr)
mode := fs.String("mode", launcherBindingModeAudit,
"binding mode to write: off, audit or enforce")
out := fs.String("out", "", "directory for the private key file (default runtime/config)")
force := fs.Bool("force", false, "replace an existing key pair")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
return c.generateLauncherIdentity(*mode, *out, *force)
case "status":
fs := newFlagSet("launcher-identity status", c.stderr)
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
return c.launcherIdentityStatus()
default:
return fmt.Errorf("unknown launcher-identity subcommand %q; use keygen or status", args[0])
}
}
func (c *controller) generateLauncherIdentity(mode string, out string, force bool) error {
normalized := normalizeLauncherBindingMode(mode)
if normalized == launcherBindingModeOff && !strings.EqualFold(strings.TrimSpace(mode), launcherBindingModeOff) {
return fmt.Errorf("mode must be one of %s, %s or %s", launcherBindingModeOff, launcherBindingModeAudit, launcherBindingModeEnforce)
}
// loadInstance applies the same migrations and AUTO_DETECT resolution the
// server will see, so this writes back a config that is already valid rather
// than re-validating a raw file that still says AUTO_DETECT.
current, err := loadInstance(c.paths)
if err != nil {
return err
}
if current.LauncherBinding != nil && !force {
if existing := normalizeLauncherBindingKeys(current.LauncherBinding.PublicKeys); len(existing) > 0 {
return fmt.Errorf("instance.json already pins %d launcher public key(s); pass --force to rotate them", len(existing))
}
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate launcher identity key: %w", err)
}
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return fmt.Errorf("encode launcher public key: %w", err)
}
publicEncoded := base64.StdEncoding.EncodeToString(publicDER)
privateDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return fmt.Errorf("encode launcher private key: %w", err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER})
targetDir := strings.TrimSpace(out)
if targetDir == "" {
targetDir = c.paths.runtimeConfig
}
if err := os.MkdirAll(targetDir, 0o700); err != nil {
return fmt.Errorf("create private key directory: %w", err)
}
privatePath := filepath.Join(targetDir, launcherIdentityPrivateKeyFile)
if err := writeFile(privatePath, privatePEM, 0o600); err != nil {
return err
}
allowWireGuard := current.LauncherBinding != nil && current.LauncherBinding.AllowWireGuard
// The key is kept even in off mode: parking the binding must not throw away
// the identity the distributed launcher already carries.
current.LauncherBinding = &launcherBindingConfig{
Mode: normalized,
PublicKeys: []string{publicEncoded},
AllowWireGuard: allowWireGuard,
}
if err := validateInstance(current); err != nil {
return err
}
data, err := marshalInstance(current)
if err != nil {
return err
}
if err := writeFile(c.paths.instance, data, 0o600); err != nil {
return err
}
fmt.Fprintf(c.stdout, "Launcher identity created.\n")
fmt.Fprintf(c.stdout, " binding mode : %s\n", normalized)
fmt.Fprintf(c.stdout, " public key : %s\n", publicEncoded)
fmt.Fprintf(c.stdout, " private key : %s\n", privatePath)
fmt.Fprintf(c.stdout, " instance : %s\n\n", c.paths.instance)
fmt.Fprint(c.stdout, launcherIdentityInstructions(normalized, publicEncoded))
return nil
}
// launcherIdentityInstructions is the operator-facing half of keygen. It spells
// out where the private key has to end up, because a key that is generated but
// not installed looks exactly like a working setup until the first connection is
// refused.
func launcherIdentityInstructions(mode string, publicEncoded string) string {
_ = publicEncoded
var builder strings.Builder
builder.WriteString("Next steps:\n")
builder.WriteString(" 1. Put the private key where the launcher reads it: copy it next to the launcher\n")
builder.WriteString(" exe (or into %APPDATA%\\DNFLogin) as launcher_identity.key, or paste its\n")
builder.WriteString(" base64 into EmbeddedPrivateKeyBase64 in Services\\LauncherIdentity.cs.\n")
builder.WriteString(" 2. Rebuild 90CN.dll from client-patch so the in-band LANA frame carries the lease.\n")
builder.WriteString(" 3. Rebuild and redistribute the launcher, then restart the server.\n")
if mode == launcherBindingModeEnforce {
builder.WriteString("\nMode is enforce: every game connection now needs a lease signed by this key.\n")
builder.WriteString("Run with --mode audit first if the launcher or the client patch is not deployed yet.\n")
}
return builder.String()
}
func (c *controller) launcherIdentityStatus() error {
current, err := loadInstance(c.paths)
if err != nil {
return err
}
binding := current.LauncherBinding
mode := launcherBindingModeOff
keys := []string(nil)
allowWireGuard := false
if binding != nil {
mode = normalizeLauncherBindingMode(binding.Mode)
keys = normalizeLauncherBindingKeys(binding.PublicKeys)
allowWireGuard = binding.AllowWireGuard
}
fmt.Fprintf(c.stdout, "launcher binding mode : %s\n", mode)
fmt.Fprintf(c.stdout, "pinned public keys : %d\n", len(keys))
for index, key := range keys {
fmt.Fprintf(c.stdout, " [%d] %s\n", index, key)
}
fmt.Fprintf(c.stdout, "allow WireGuard bind : %t\n", allowWireGuard)
privatePath := filepath.Join(c.paths.runtimeConfig, launcherIdentityPrivateKeyFile)
if isRegularFile(privatePath) {
fmt.Fprintf(c.stdout, "private key on disk : %s\n", privatePath)
} else {
fmt.Fprintf(c.stdout, "private key on disk : (none at %s)\n", privatePath)
}
if mode != launcherBindingModeOff && len(keys) == 0 {
fmt.Fprintln(c.stderr, "WARNING: a mode is set but no public key is pinned; the server will refuse to start.")
}
return nil
}
+187
View File
@@ -0,0 +1,187 @@
// Command control is the native DNF90 local deployment controller.
//
// It intentionally does not invoke PowerShell. All generated runtime files,
// process ownership checks, portable MySQL lifecycle, and client compatibility
// checks are implemented in this command.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
const commandTimeout = 10 * time.Minute
func main() {
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
}
func runCLI(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
printUsage(stdout)
return 0
}
paths, err := discoverPaths()
if err != nil {
fmt.Fprintln(stderr, "FAILED:", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
releaseLock, err := acquireControllerLock(ctx, paths.projectRoot)
if err != nil {
fmt.Fprintln(stderr, "FAILED:", err)
return 1
}
defer releaseLock()
if err := recoverBuiltBinarySet(paths); err != nil {
fmt.Fprintln(stderr, "FAILED: recover interrupted binary installation:", err)
return 1
}
controller := newController(paths, stdout, stderr)
var runErr error
switch strings.ToLower(strings.TrimSpace(args[0])) {
case "start":
fs := newFlagSet("start", stderr)
rebuild := fs.Bool("rebuild", false, "rebuild server and doctor before starting")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.start(ctx, *rebuild)
}
case "stop":
fs := newFlagSet("stop", stderr)
keepDatabase := fs.Bool("keep-database", false, "leave the bundled MySQL process running")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.stop(ctx, *keepDatabase)
}
case "status":
fs := newFlagSet("status", stderr)
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.status(ctx)
}
case "build":
fs := newFlagSet("build", stderr)
force := fs.Bool("force", true, "rebuild even when binaries already exist")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.build(ctx, *force)
}
case "check":
fs := newFlagSet("check", stderr)
skipDatabase := fs.Bool("skip-database", false, "skip MySQL connectivity validation")
skipPorts := fs.Bool("skip-ports", false, "skip listener availability validation")
checkClient := fs.Bool("client", false, "require and validate the configured client directory")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.check(ctx, checkOptions{
skipDatabase: *skipDatabase,
skipPorts: *skipPorts,
checkClient: *checkClient,
})
}
case "configure-client":
fs := newFlagSet("configure-client", stderr)
clientDirectory := fs.String("directory", "", "DNF client directory to save in instance.json")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
runErr = controller.configureClient(*clientDirectory)
}
case "database":
runErr = controller.database(ctx, args[1:])
case "network":
runErr = controller.network(ctx, args[1:])
case "launch-client":
fs := newFlagSet("launch-client", stderr)
clientDirectory := fs.String("client-directory", "", "override client.directory from instance.json")
multiInstance := fs.Bool("multi-instance", false, "launch an additional client using the audited current-EXE instance compatibility")
username := fs.String("username", "", "authenticate and bind this client to a local account")
passwordStdin := fs.Bool("password-stdin", false, "read one password line from stdin")
if runErr = parseFlags(fs, args[1:]); runErr == nil {
password := ""
if strings.TrimSpace(*username) != "" {
if !*passwordStdin {
runErr = errors.New("launch-client with --username requires --password-stdin")
} else {
password, runErr = readLocalAccountPassword(controller.stdin)
}
} else if *passwordStdin {
runErr = errors.New("--password-stdin requires --username")
}
if runErr == nil {
runErr = controller.launchClient(
ctx,
*clientDirectory,
*multiInstance,
*username,
password,
)
}
}
case "account":
runErr = controller.account(ctx, args[1:])
case "launcher-identity":
runErr = controller.launcherIdentity(ctx, args[1:])
default:
fmt.Fprintf(stderr, "unknown command %q\n\n", args[0])
printUsage(stderr)
return 2
}
if runErr != nil {
if errors.Is(runErr, flag.ErrHelp) {
return 0
}
fmt.Fprintln(stderr, "FAILED:", runErr)
return 1
}
return 0
}
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
return fs
}
func parseFlags(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
}
return nil
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, `DNF90 native server controller
Usage:
DNF90Control start [--rebuild]
DNF90Control stop [--keep-database]
DNF90Control status
DNF90Control build [--force=true]
DNF90Control check [--skip-database] [--skip-ports] [--client]
DNF90Control configure-client --directory PATH
DNF90Control database select --mode sqlite|mysql [--prepare]
DNF90Control database configure-mysql --password-stdin [--port 3306] [--name dnf_local]
DNF90Control network set --advertise-ip PRIVATE_IP [--region-id N] [--region-name NAME]
DNF90Control launch-client [--client-directory PATH] [--multi-instance] [--username NAME --password-stdin]
DNF90Control account register --username NAME --password-stdin
DNF90Control account login --username NAME --password-stdin
DNF90Control account bind-client --username NAME --pid PID --password-stdin
DNF90Control account list
DNF90Control launcher-identity keygen [--mode off|audit|enforce] [--out DIR] [--force]
DNF90Control launcher-identity status
Project discovery starts at the current directory and executable directory.
Set DNF90_PROJECT_ROOT to override it.`)
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"encoding/json"
"time"
)
type instanceConfig struct {
SchemaVersion int `json:"schemaVersion"`
InstallationID string `json:"installationId"`
Mode string `json:"mode"`
Server serverConfig `json:"server"`
Database databaseConfig `json:"database"`
LegacyRedis json.RawMessage `json:"redis,omitempty"`
Game gameConfig `json:"game"`
Protocol protocolConfig `json:"protocol"`
Build buildConfig `json:"build"`
Client clientConfig `json:"client"`
// LauncherBinding pins which launcher may open a game connection. It is a
// pointer so an installation that never runs keygen keeps an instance.json
// byte-identical to the one it had before the feature existed; other tools
// in this project read the same file, some of them strictly. The public keys
// are the only half of the identity this server ever holds.
LauncherBinding *launcherBindingConfig `json:"launcherBinding,omitempty"`
}
// launcherBindingConfig mirrors DNFBRIDGE_LAUNCHER_LID_* in dnfbridge.
type launcherBindingConfig struct {
// Mode is off, audit or enforce. Audit verifies and logs but still serves
// the legacy paths, which is how a rollout is observed before it rejects.
Mode string `json:"mode,omitempty"`
// PublicKeys are base64 DER SubjectPublicKeyInfo blobs (ECDSA P-256). Each
// entry is one authorized launcher build; several are allowed so a rotation
// or an edition split does not require a flag day.
PublicKeys []string `json:"publicKeys,omitempty"`
// AllowWireGuard keeps the SSH-attested private-source-IP binding reachable
// while enforcement is on. It is off by default because that path is a
// second, weaker identity that an enforcing deployment usually means to
// retire.
AllowWireGuard bool `json:"allowWireGuard,omitempty"`
}
type serverConfig struct {
AdvertiseIP string `json:"advertiseIp"`
ChannelListen string `json:"channelListen"`
AdminListen string `json:"adminListen"`
AdminToken string `json:"adminToken"`
AccountID string `json:"accountId"`
PacketLog bool `json:"packetLog"`
DebugMode bool `json:"debugMode"`
DebugStrikeOnSkillUse bool `json:"debugStrikeOnSkillUse"`
RobotEnabled *bool `json:"robotEnabled,omitempty"`
PartyUDPRelayEnabled bool `json:"partyUdpRelayEnabled"`
PartyUDPRelayPortStart int `json:"partyUdpRelayPortStart"`
PartyUDPRelayPortCount int `json:"partyUdpRelayPortCount"`
// DungeonRoomPartySlotOff disables the dungeon-room op9 party slot table
// (client host arbitration). Optional; default false keeps the fix on.
DungeonRoomPartySlotOff bool `json:"dungeonRoomPartySlotOff,omitempty"`
// RaidLukeWeeklyLimitEnforced turns Luke's [WEEKLY ENTER COUNT] from an
// observation into a refusal. Optional; default false, because the weekly
// reset node is still unconfirmed and enforcing a wrong node locks players
// out of a raid they are entitled to enter.
RaidLukeWeeklyLimitEnforced bool `json:"raidLukeWeeklyLimitEnforced,omitempty"`
}
func (cfg serverConfig) robotsEnabled() bool {
return cfg.RobotEnabled != nil && *cfg.RobotEnabled
}
type databaseConfig struct {
Mode string `json:"mode"`
Path string `json:"path,omitempty"`
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
}
type gameConfig struct {
ShardID string `json:"shardId"`
PVFPath string `json:"pvfPath"`
PVFMaxBytes int64 `json:"pvfMaxBytes"`
ChannelInfoPath string `json:"channelInfoPath"`
DungeonBatchPickupEnabled *bool `json:"dungeonBatchPickupEnabled,omitempty"`
}
type protocolConfig struct {
Profile string `json:"profile"`
GameUpperHeader string `json:"gameUpperHeader"`
GameUpperBodyCodec string `json:"gameUpperBodyCodec"`
GameUpperClientBodyCodec string `json:"gameUpperClientBodyCodec"`
GameOuterToken string `json:"gameOuterToken"`
ChannelServerIndex int `json:"channelServerIndex"`
ChannelAdvertiseServerIndex int `json:"channelAdvertiseServerIndex"`
RegionID int `json:"regionId"`
RegionName string `json:"regionName"`
}
type buildConfig struct {
GoExecutable string `json:"goExecutable"`
}
type clientConfig struct {
Directory string `json:"directory"`
InitialGamePort int `json:"initialGamePort"`
HookCreate bool `json:"hookCreate"`
}
type assetManifest struct {
SchemaVersion int `json:"schemaVersion"`
Files []assetManifestEntry `json:"files"`
}
type assetManifestEntry struct {
Path string `json:"path"`
Required bool `json:"required"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type clientManifest struct {
SchemaVersion int `json:"schemaVersion"`
Profile string `json:"profile"`
Files []clientManifestEntry `json:"files"`
}
// clientManifestEntry describes one required client file. SHA256 is optional
// here: this controller validates client files by existence only, on purpose,
// because an operator's DNF.exe legitimately varies between regional builds.
// The field still has to be declared and preserved, because the launcher
// (DNF90Hub, another repository) reads the same file and decides "matches" by
// comparing this hash. An entry with no hash is a permanent mismatch there:
// the launcher's repair copies the file, reports success, and the next check
// fails again on the empty expectation. Dropping the field also contradicts
// REBUILD_CLIENT_PATCH.bat, which tells the operator to update the 90CN.dll
// hash here after every client-patch rebuild.
type clientManifestEntry struct {
Names []string `json:"names"`
Required bool `json:"required"`
SHA256 string `json:"sha256"`
}
type processState struct {
PID int `json:"pid"`
StartedAt time.Time `json:"startedAt"`
ProcessCreatedAt time.Time `json:"processCreatedAt"`
Executable string `json:"executable"`
ExecutableSHA256 string `json:"executableSha256"`
InstallationID string `json:"installationId"`
ServicePort int `json:"servicePort,omitempty"`
RuntimeConfigSHA256 string `json:"runtimeConfigSha256,omitempty"`
LauncherPID int `json:"launcherPid,omitempty"`
LauncherCreatedAt time.Time `json:"launcherCreatedAt,omitempty"`
DatabaseHost string `json:"databaseHost,omitempty"`
DatabaseName string `json:"databaseName,omitempty"`
DatabaseUser string `json:"databaseUser,omitempty"`
DatabasePassword string `json:"databasePassword,omitempty"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}
type assetState struct {
GeneratedAt time.Time `json:"generatedAt"`
Files []assetStateRecord `json:"files"`
}
type assetStateRecord struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type portableMySQLManifest struct {
SchemaVersion int `json:"schemaVersion"`
Product string `json:"product"`
Version string `json:"version"`
Platform string `json:"platform"`
SourceURL string `json:"sourceUrl"`
Archive portableMySQLArchive `json:"archive"`
Files []portableMySQLManifestFile `json:"files"`
}
type portableMySQLArchive struct {
Path string `json:"path"`
Root string `json:"root"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type portableMySQLManifestFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
type appLocalRuntimeManifest struct {
SchemaVersion int `json:"schemaVersion"`
Product string `json:"product"`
Version string `json:"version"`
Platform string `json:"platform"`
SourceURL string `json:"sourceUrl"`
Files []portableMySQLManifestFile `json:"files"`
}
type portableMySQLInstallState struct {
SchemaVersion int `json:"schemaVersion"`
Product string `json:"product"`
Version string `json:"version"`
Platform string `json:"platform"`
ArchiveSHA256 string `json:"archiveSha256"`
InstalledAt time.Time `json:"installedAt"`
}
type portableMySQLDataState struct {
SchemaVersion int `json:"schemaVersion"`
InstallationID string `json:"installationId"`
Phase string `json:"phase"`
DataDirectory string `json:"dataDirectory"`
MysqldSHA256 string `json:"mysqldSha256"`
AutoCNFSHA256 string `json:"autoCnfSha256,omitempty"`
Database string `json:"database,omitempty"`
InitializedAt time.Time `json:"initializedAt"`
ReadyAt time.Time `json:"readyAt,omitempty"`
}
type binaryInstallState struct {
SchemaVersion int `json:"schemaVersion"`
Entries []binaryInstallStateEntry `json:"entries"`
}
type binaryInstallStateEntry struct {
Destination string `json:"destination"`
Temporary string `json:"temporary"`
Backup string `json:"backup"`
HadOriginal bool `json:"hadOriginal"`
OriginalSHA256 string `json:"originalSha256,omitempty"`
CandidateSHA256 string `json:"candidateSha256"`
}
type serverGroupPlan struct {
Version string `json:"version"`
Shards []serverGroupShard `json:"shards"`
Groups []serverGroup `json:"groups"`
Routes []serverGroupRoute `json:"routes"`
}
type serverGroupShard struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
State string `json:"state"`
OpenAt string `json:"open_at"`
PublicOpenAt string `json:"public_open_at"`
}
type serverGroup struct {
ID string `json:"id"`
Service string `json:"service"`
MemberID string `json:"member_id"`
State string `json:"state"`
}
type serverGroupRoute struct {
Feature string `json:"feature"`
ShardID string `json:"shard_id"`
GroupID string `json:"group_id"`
State string `json:"state"`
Meta map[string]string `json:"meta"`
}
type checkOptions struct {
skipDatabase bool
skipPorts bool
checkClient bool
}
+388
View File
@@ -0,0 +1,388 @@
package main
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
func preparePortableMySQL(
ctx context.Context,
paths projectPaths,
out io.Writer,
) error {
manifest, err := loadPortableMySQLManifest(paths)
if err != nil {
return err
}
if isRegularFile(paths.mysqlInstallState) {
if err := validatePortableMySQLFiles(paths.mysqlServer, manifest.Files); err == nil {
if err := installAppLocalVCRuntime(paths, paths.mysqlServer); err != nil {
return err
}
if err := verifyPortableMySQLExecutable(
ctx,
paths.mysqlServer,
manifest.Version,
); err != nil {
return err
}
fmt.Fprintf(out, "Bundled MySQL %s: verified\n", manifest.Version)
return nil
}
}
if info, err := os.Stat(paths.mysqlServer); err == nil {
if !info.IsDir() {
return fmt.Errorf("portable MySQL target is not a directory: %s", paths.mysqlServer)
}
if err := validatePortableMySQLFiles(paths.mysqlServer, manifest.Files); err != nil {
return fmt.Errorf(
"existing portable MySQL directory failed verification and was not overwritten: %w",
err,
)
}
if err := installAppLocalVCRuntime(paths, paths.mysqlServer); err != nil {
return err
}
if err := verifyPortableMySQLExecutable(
ctx,
paths.mysqlServer,
manifest.Version,
); err != nil {
return err
}
state := portableMySQLInstallState{
SchemaVersion: 1,
Product: manifest.Product,
Version: manifest.Version,
Platform: manifest.Platform,
ArchiveSHA256: strings.ToUpper(manifest.Archive.SHA256),
InstalledAt: time.Now().UTC(),
}
return writeJSON(paths.mysqlInstallState, state, 0o600)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect portable MySQL target: %w", err)
}
archiveRelative, err := safeManifestPath(manifest.Archive.Path)
if err != nil {
return fmt.Errorf("portable MySQL archive path: %w", err)
}
archivePath := filepath.Join(paths.projectRoot, archiveRelative)
info, err := os.Stat(archivePath)
if err != nil {
return fmt.Errorf("bundled MySQL archive is missing: %s", archivePath)
}
if !info.Mode().IsRegular() || info.Size() != manifest.Archive.Size {
return fmt.Errorf("bundled MySQL archive size mismatch: %s", archivePath)
}
digest, err := fileSHA256(archivePath)
if err != nil {
return err
}
if !strings.EqualFold(digest, manifest.Archive.SHA256) {
return fmt.Errorf("bundled MySQL archive SHA256 mismatch: %s", archivePath)
}
staging := filepath.Join(
paths.mysqlRoot,
"server.extracting-"+strconv.Itoa(os.Getpid()),
)
if err := safeRemoveRuntimeTree(paths.runtimeRoot, staging); err != nil {
return err
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("create portable MySQL extraction directory: %w", err)
}
installed := false
defer func() {
if !installed {
_ = safeRemoveRuntimeTree(paths.runtimeRoot, staging)
}
}()
fmt.Fprintf(out, "Extracting bundled MySQL %s (first start only)...\n", manifest.Version)
if err := extractPortableMySQLArchive(
archivePath,
manifest.Archive.Root,
staging,
); err != nil {
return err
}
if err := validatePortableMySQLFiles(staging, manifest.Files); err != nil {
return err
}
if err := installAppLocalVCRuntime(paths, staging); err != nil {
return err
}
if err := verifyPortableMySQLExecutable(ctx, staging, manifest.Version); err != nil {
return err
}
state := portableMySQLInstallState{
SchemaVersion: 1,
Product: manifest.Product,
Version: manifest.Version,
Platform: manifest.Platform,
ArchiveSHA256: strings.ToUpper(manifest.Archive.SHA256),
InstalledAt: time.Now().UTC(),
}
if err := writeJSON(
filepath.Join(staging, ".dnf90-install.json"),
state,
0o600,
); err != nil {
return err
}
if err := os.Rename(staging, paths.mysqlServer); err != nil {
return fmt.Errorf("install extracted portable MySQL: %w", err)
}
installed = true
fmt.Fprintf(out, "Bundled MySQL %s extracted and verified.\n", manifest.Version)
return nil
}
func installAppLocalVCRuntime(paths projectPaths, mysqlServer string) error {
if runtime.GOOS != "windows" || runtime.GOARCH != "amd64" {
return fmt.Errorf(
"bundled MySQL requires Windows x64; current runtime is %s/%s",
runtime.GOOS,
runtime.GOARCH,
)
}
var manifest appLocalRuntimeManifest
if err := readStrictJSON(paths.vcRuntimeManifest, &manifest); err != nil {
return fmt.Errorf("VC++ app-local manifest: %w", err)
}
if manifest.SchemaVersion != 1 ||
!strings.EqualFold(manifest.Platform, "winx64") ||
strings.TrimSpace(manifest.Version) == "" ||
len(manifest.Files) == 0 {
return errors.New("VC++ app-local manifest is invalid")
}
destinationRoot := filepath.Join(mysqlServer, "bin")
for _, entry := range manifest.Files {
relative, err := safeManifestPath(entry.Path)
if err != nil || filepath.Dir(relative) != "." {
return fmt.Errorf("unsafe VC++ app-local filename %q", entry.Path)
}
source := filepath.Join(paths.vcRuntimeRoot, relative)
if err := validatePortableMySQLFiles(
paths.vcRuntimeRoot,
[]portableMySQLManifestFile{entry},
); err != nil {
return fmt.Errorf("bundled VC++ app-local source: %w", err)
}
destination := filepath.Join(destinationRoot, relative)
if isRegularFile(destination) {
if err := validatePortableMySQLFiles(
destinationRoot,
[]portableMySQLManifestFile{entry},
); err != nil {
return fmt.Errorf(
"existing MySQL app-local runtime failed verification and was not overwritten: %w",
err,
)
}
continue
}
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read bundled VC++ app-local file: %w", err)
}
if err := writeFile(destination, data, 0o644); err != nil {
return fmt.Errorf("install VC++ app-local file: %w", err)
}
if err := validatePortableMySQLFiles(
destinationRoot,
[]portableMySQLManifestFile{entry},
); err != nil {
return fmt.Errorf("verify installed VC++ app-local file: %w", err)
}
}
return nil
}
func verifyPortableMySQLExecutable(
ctx context.Context,
mysqlServer string,
expectedVersion string,
) error {
executable := filepath.Join(mysqlServer, "bin", "mysqld.exe")
cmd := exec.CommandContext(ctx, executable, "--version")
cmd.Dir = mysqlServer
configureBackgroundProcess(cmd)
var output strings.Builder
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
return fmt.Errorf(
"bundled MySQL executable check failed: %w: %s",
err,
strings.TrimSpace(output.String()),
)
}
if !strings.Contains(output.String(), "Ver "+strings.TrimSpace(expectedVersion)) {
return fmt.Errorf(
"bundled MySQL executable reported an unexpected version: %s",
strings.TrimSpace(output.String()),
)
}
return nil
}
func loadPortableMySQLManifest(paths projectPaths) (portableMySQLManifest, error) {
var manifest portableMySQLManifest
if err := readStrictJSON(paths.mysqlManifest, &manifest); err != nil {
return manifest, fmt.Errorf("portable MySQL manifest: %w", err)
}
if manifest.SchemaVersion != 1 {
return manifest, errors.New("portable MySQL manifest schemaVersion must be 1")
}
if strings.TrimSpace(manifest.Product) == "" ||
strings.TrimSpace(manifest.Version) == "" ||
!strings.EqualFold(strings.TrimSpace(manifest.Platform), "winx64") {
return manifest, errors.New("portable MySQL manifest identity is invalid")
}
if manifest.Archive.Size <= 0 ||
strings.TrimSpace(manifest.Archive.SHA256) == "" ||
strings.TrimSpace(manifest.Archive.Root) == "" {
return manifest, errors.New("portable MySQL archive manifest is incomplete")
}
if len(manifest.Files) == 0 {
return manifest, errors.New("portable MySQL file manifest is empty")
}
return manifest, nil
}
func validatePortableMySQLFiles(
root string,
files []portableMySQLManifestFile,
) error {
for _, entry := range files {
relative, err := safeManifestPath(entry.Path)
if err != nil {
return fmt.Errorf("portable MySQL file path: %w", err)
}
absolute := filepath.Join(root, relative)
info, err := os.Stat(absolute)
if err != nil {
return fmt.Errorf("portable MySQL file is missing: %s", absolute)
}
if !info.Mode().IsRegular() || info.Size() != entry.Size {
return fmt.Errorf("portable MySQL file size mismatch: %s", absolute)
}
digest, err := fileSHA256(absolute)
if err != nil {
return err
}
if !strings.EqualFold(digest, entry.SHA256) {
return fmt.Errorf("portable MySQL file SHA256 mismatch: %s", absolute)
}
}
return nil
}
func extractPortableMySQLArchive(archivePath, archiveRoot, destination string) error {
archiveRoot = strings.Trim(strings.TrimSpace(filepath.ToSlash(archiveRoot)), "/")
if archiveRoot == "" || strings.Contains(archiveRoot, "/") ||
archiveRoot == "." || archiveRoot == ".." {
return fmt.Errorf("unsafe portable MySQL archive root %q", archiveRoot)
}
reader, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open bundled MySQL archive: %w", err)
}
defer func() { _ = reader.Close() }()
prefix := archiveRoot + "/"
foundFile := false
for _, entry := range reader.File {
name := strings.ReplaceAll(entry.Name, `\`, "/")
clean := path.Clean(name)
if clean == archiveRoot {
continue
}
if path.IsAbs(clean) || clean == "." || clean == ".." ||
strings.HasPrefix(clean, "../") || !strings.HasPrefix(clean, prefix) {
return fmt.Errorf("unsafe path in bundled MySQL archive: %q", entry.Name)
}
relative := strings.TrimPrefix(clean, prefix)
target := filepath.Join(destination, filepath.FromSlash(relative))
if !pathInside(destination, target) {
return fmt.Errorf("bundled MySQL archive entry escapes target: %q", entry.Name)
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("create bundled MySQL directory: %w", err)
}
continue
}
if entry.Mode()&os.ModeSymlink != 0 || !entry.Mode().IsRegular() {
return fmt.Errorf("unsupported bundled MySQL archive entry: %q", entry.Name)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create bundled MySQL parent directory: %w", err)
}
source, err := entry.Open()
if err != nil {
return fmt.Errorf("open bundled MySQL archive entry %q: %w", entry.Name, err)
}
destinationFile, err := os.OpenFile(
target,
os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
0o644,
)
if err != nil {
_ = source.Close()
return fmt.Errorf("create bundled MySQL file %q: %w", target, err)
}
_, copyErr := io.Copy(destinationFile, source)
closeErr := errors.Join(destinationFile.Close(), source.Close())
if copyErr != nil || closeErr != nil {
return fmt.Errorf(
"extract bundled MySQL file %q: %w",
entry.Name,
errors.Join(copyErr, closeErr),
)
}
foundFile = true
}
if !foundFile {
return errors.New("bundled MySQL archive contained no files under its declared root")
}
return nil
}
func safeRemoveRuntimeTree(runtimeRoot, target string) error {
if !pathInside(runtimeRoot, target) {
return fmt.Errorf("refusing to remove path outside runtime: %s", target)
}
if err := os.RemoveAll(target); err != nil {
return fmt.Errorf("remove temporary runtime path %s: %w", target, err)
}
return nil
}
func pathInside(root, target string) bool {
rootAbs, rootErr := filepath.Abs(root)
targetAbs, targetErr := filepath.Abs(target)
if rootErr != nil || targetErr != nil {
return false
}
relative, err := filepath.Rel(rootAbs, targetAbs)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
return relative != ".." &&
!strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
@@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"strings"
)
func (c *controller) network(_ context.Context, args []string) error {
if len(args) == 0 || !strings.EqualFold(strings.TrimSpace(args[0]), "set") {
return errors.New("network subcommand is required: set")
}
fs := newFlagSet("network set", c.stderr)
advertiseIP := fs.String("advertise-ip", "", "RFC1918 address assigned to this server")
regionID := fs.Int("region-id", 0, "optional region id (1-249)")
regionName := fs.String("region-name", "", "optional region display name")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
return c.configureNetwork(*advertiseIP, *regionID, *regionName)
}
func (c *controller) configureNetwork(advertiseIP string, regionID int, regionName string) error {
advertiseIP = strings.TrimSpace(advertiseIP)
ip := net.ParseIP(advertiseIP)
if ip == nil || ip.To4() == nil || !ip.IsPrivate() || ip.IsLoopback() {
return errors.New("advertise-ip must be a non-loopback RFC1918 IPv4 address, such as a WireGuard 10.x address")
}
current, err := loadInstance(c.paths)
if err != nil {
return err
}
if state, found, err := c.loadManagedProcess(current.InstallationID, true); err != nil {
return err
} else if found {
return fmt.Errorf("DNF90 is running as PID %d; stop it before changing the network profile", state.PID)
}
raw, err := decodeInstance(c.paths.instance)
if err != nil {
return err
}
raw.Server.AdvertiseIP = advertiseIP
if regionID != 0 {
raw.Protocol.RegionID = regionID
raw.Protocol.ChannelAdvertiseServerIndex = regionID
}
if strings.TrimSpace(regionName) != "" {
raw.Protocol.RegionName = strings.TrimSpace(regionName)
}
if err := validateInstance(raw); err != nil {
return err
}
data, err := marshalInstance(raw)
if err != nil {
return err
}
if err := writeFile(c.paths.instance, data, 0o600); err != nil {
return err
}
fmt.Fprintf(c.stdout, "Private game address configured: %s (region %d %s)\n", advertiseIP, raw.Protocol.RegionID, raw.Protocol.RegionName)
return nil
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
type projectPaths struct {
projectRoot string
goServerRoot string
runtimeRoot string
runtimeBin string
runtimeConfig string
runtimeConfigs string
runtimeData string
runtimeLogs string
runtimeState string
instance string
instanceExample string
assetManifest string
clientManifest string
mysqlManifest string
vcRuntimeManifest string
vcRuntimeRoot string
channelAsset string
serverExe string
doctorExe string
launcherExe string
controlExe string
processState string
binaryInstallState string
mysqlRoot string
mysqlServer string
mysqlData string
mysqlConfig string
mysqlPIDFile string
mysqldExe string
mysqlProcessState string
mysqlInstallState string
mysqlDataState string
mysqlLegacyDataState string
}
func discoverPaths() (projectPaths, error) {
if configured := strings.TrimSpace(os.Getenv("DNF90_PROJECT_ROOT")); configured != "" {
root, err := filepath.Abs(configured)
if err != nil {
return projectPaths{}, fmt.Errorf("resolve DNF90_PROJECT_ROOT: %w", err)
}
if isProjectRoot(root) {
return newProjectPaths(root), nil
}
return projectPaths{}, fmt.Errorf("DNF90_PROJECT_ROOT is not a DNF90 project: %s", root)
}
var starts []string
if cwd, err := os.Getwd(); err == nil {
starts = append(starts, cwd)
}
if exe, err := os.Executable(); err == nil {
starts = append(starts, filepath.Dir(exe))
}
seen := make(map[string]struct{})
for _, start := range starts {
current, err := filepath.Abs(start)
if err != nil {
continue
}
for {
key := strings.ToLower(filepath.Clean(current))
if _, ok := seen[key]; !ok {
seen[key] = struct{}{}
if isProjectRoot(current) {
return newProjectPaths(current), nil
}
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
}
return projectPaths{}, errors.New("cannot find DNF90 project root; set DNF90_PROJECT_ROOT")
}
// isProjectRoot recognises every shape this controller ships in. The instance
// template is the mandatory first anchor so an unrelated directory that merely
// happens to contain a runtime/ folder is never mistaken for a project root;
// one of the three second anchors must then confirm which shape it is.
func isProjectRoot(root string) bool {
if !isRegularFile(filepath.Join(root, "runtime", "templates", "instance.example.json")) {
return false
}
// Runtime-only installations distributed by the hub/launcher carry neither
// the Go module nor RELEASE_INFO.json: the hub's update whitelist may only
// write runtime/{bin,assets,templates,patch}/ plus changlog.md, so a release
// marker in the root can never reach such a tree. The asset manifest is the
// marker those installations do have, and it is one of the very files the
// hub itself requires before it will run this controller at all.
if isRegularFile(filepath.Join(root, "runtime", "assets", "manifest.json")) {
return true
}
if isRegularFile(filepath.Join(root, "go-server", "go.mod")) {
return true
}
// Binary-only external releases intentionally contain no source tree.
return isRegularFile(filepath.Join(root, "RELEASE_INFO.json")) &&
isRegularFile(filepath.Join(root, "runtime", "bin", platformExecutableName("DNF90Control")))
}
// hasGoServerSource reports whether this installation carries the go-server
// module. Runtime-only installations do not, and every code path that invokes
// the Go toolchain must check this first instead of failing inside a go command
// with a module error that says nothing about the real cause.
func hasGoServerSource(paths projectPaths) bool {
return isRegularFile(filepath.Join(paths.goServerRoot, "go.mod"))
}
func platformExecutableName(base string) string {
if runtime.GOOS == "windows" {
return base + ".exe"
}
return base
}
func newProjectPaths(root string) projectPaths {
root = filepath.Clean(root)
runtimeRoot := filepath.Join(root, "runtime")
return projectPaths{
projectRoot: root,
goServerRoot: filepath.Join(root, "go-server"),
runtimeRoot: runtimeRoot,
runtimeBin: filepath.Join(runtimeRoot, "bin"),
runtimeConfig: filepath.Join(runtimeRoot, "config"),
runtimeConfigs: filepath.Join(runtimeRoot, "configs"),
runtimeData: filepath.Join(runtimeRoot, "data", "dnf"),
runtimeLogs: filepath.Join(runtimeRoot, "logs"),
runtimeState: filepath.Join(runtimeRoot, "state"),
instance: filepath.Join(runtimeRoot, "config", "instance.json"),
instanceExample: filepath.Join(runtimeRoot, "templates", "instance.example.json"),
assetManifest: filepath.Join(runtimeRoot, "assets", "manifest.json"),
clientManifest: filepath.Join(runtimeRoot, "assets", "client-compatibility.json"),
mysqlManifest: filepath.Join(runtimeRoot, "assets", "mysql-portable.json"),
vcRuntimeManifest: filepath.Join(runtimeRoot, "assets", "vcruntime-app-local.json"),
vcRuntimeRoot: filepath.Join(runtimeRoot, "vendor", "vcruntime", "x64"),
channelAsset: filepath.Join(runtimeRoot, "assets", "channel_info.etc"),
serverExe: filepath.Join(runtimeRoot, "bin", platformExecutableName("DNF90Server")),
doctorExe: filepath.Join(runtimeRoot, "bin", platformExecutableName("DNF90Doctor")),
launcherExe: filepath.Join(runtimeRoot, "bin", platformExecutableName("DNF90Launcher")),
controlExe: filepath.Join(runtimeRoot, "bin", platformExecutableName("DNF90Control")),
processState: filepath.Join(runtimeRoot, "state", "server-process.json"),
binaryInstallState: filepath.Join(runtimeRoot, "state", "binary-install.json"),
mysqlRoot: filepath.Join(runtimeRoot, "mysql"),
mysqlServer: filepath.Join(runtimeRoot, "mysql", "server"),
mysqlData: filepath.Join(runtimeRoot, "mysql", "data"),
mysqlConfig: filepath.Join(runtimeRoot, "config", "mysql.ini"),
mysqlPIDFile: filepath.Join(runtimeRoot, "state", "mysql.pid"),
mysqldExe: filepath.Join(runtimeRoot, "mysql", "server", "bin", "mysqld.exe"),
mysqlProcessState: filepath.Join(runtimeRoot, "state", "mysql-process.json"),
mysqlInstallState: filepath.Join(runtimeRoot, "mysql", "server", ".dnf90-install.json"),
mysqlDataState: filepath.Join(runtimeRoot, "mysql", "data-state.json"),
mysqlLegacyDataState: filepath.Join(runtimeRoot, "mysql", "data", ".dnf90-data.json"),
}
}
func (p projectPaths) ensureDirectories() error {
for _, path := range []string{
p.runtimeRoot,
p.runtimeBin,
p.runtimeConfig,
p.runtimeConfigs,
filepath.Join(p.runtimeConfigs, "dnf"),
filepath.Join(p.runtimeConfigs, "servergroup"),
p.runtimeData,
p.runtimeLogs,
p.runtimeState,
p.mysqlRoot,
} {
if err := os.MkdirAll(path, 0o755); err != nil {
return fmt.Errorf("create runtime directory %s: %w", path, err)
}
}
return nil
}
func isRegularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,384 @@
package main
import (
"archive/zip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestExtractPortableMySQLArchiveRejectsPathTraversal(t *testing.T) {
root := t.TempDir()
archivePath := filepath.Join(root, "mysql.zip")
writePortableMySQLTestZIP(t, archivePath, []portableMySQLTestZIPEntry{
{name: "mysql-test/bin/mysqld.exe", data: []byte("fixture")},
{name: "mysql-test/../../escaped.txt", data: []byte("escape")},
})
destination := filepath.Join(root, "destination")
err := extractPortableMySQLArchive(archivePath, "mysql-test", destination)
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
t.Fatalf("extractPortableMySQLArchive() error = %v, want unsafe path rejection", err)
}
if _, statErr := os.Stat(filepath.Join(root, "escaped.txt")); !os.IsNotExist(statErr) {
t.Fatalf("path traversal created an outside file; stat error = %v", statErr)
}
}
func TestExtractAndValidatePortableMySQLArchive(t *testing.T) {
root := t.TempDir()
archivePath := filepath.Join(root, "mysql.zip")
files := []portableMySQLTestZIPEntry{
{name: "mysql-test/bin/mysqld.exe", data: []byte("mysqld-fixture")},
{name: "mysql-test/LICENSE", data: []byte("license-fixture")},
}
writePortableMySQLTestZIP(t, archivePath, files)
destination := filepath.Join(root, "destination")
if err := extractPortableMySQLArchive(
archivePath,
"mysql-test",
destination,
); err != nil {
t.Fatalf("extractPortableMySQLArchive() error = %v", err)
}
manifest := make([]portableMySQLManifestFile, 0, len(files))
for _, file := range files {
relative := strings.TrimPrefix(file.name, "mysql-test/")
manifest = append(manifest, portableMySQLManifestFile{
Path: relative,
Size: int64(len(file.data)),
SHA256: portableMySQLTestSHA256(file.data),
})
got, err := os.ReadFile(filepath.Join(destination, filepath.FromSlash(relative)))
if err != nil {
t.Fatalf("read extracted file %q: %v", relative, err)
}
if string(got) != string(file.data) {
t.Fatalf("extracted file %q = %q, want %q", relative, got, file.data)
}
}
if err := validatePortableMySQLFiles(destination, manifest); err != nil {
t.Fatalf("validatePortableMySQLFiles() error = %v", err)
}
mysqldPath := filepath.Join(destination, "bin", "mysqld.exe")
tampered, err := os.ReadFile(mysqldPath)
if err != nil {
t.Fatal(err)
}
tampered[0] ^= 0xff
if err := os.WriteFile(mysqldPath, tampered, 0o644); err != nil {
t.Fatal(err)
}
err = validatePortableMySQLFiles(destination, manifest)
if err == nil || !strings.Contains(err.Error(), "SHA256 mismatch") {
t.Fatalf("validatePortableMySQLFiles(tampered) error = %v, want SHA256 mismatch", err)
}
}
func TestEnsurePortableMySQLDataRejectsUnownedNonEmptyDirectory(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
if err := writeFile(paths.mysqldExe, []byte("mysqld-fixture"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(paths.mysqlData, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(paths.mysqlData, "unowned.dat"),
[]byte("do not adopt"),
0o600,
); err != nil {
t.Fatal(err)
}
control := newController(paths, io.Discard, io.Discard)
_, err := control.ensurePortableMySQLData(context.Background(), validTestInstance())
if err == nil || !strings.Contains(err.Error(), "non-empty") ||
!strings.Contains(err.Error(), "ownership state") {
t.Fatalf(
"ensurePortableMySQLData() error = %v, want non-empty ownership-state rejection",
err,
)
}
if isRegularFile(paths.mysqlDataState) {
t.Fatal("rejected unowned data directory unexpectedly gained ownership state")
}
}
func TestEnsurePortableMySQLDataValidatesReadyOwnershipAndHash(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
if err := writeFile(paths.mysqldExe, []byte("mysqld-fixture"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(paths.mysqlData, "mysql"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"mysql.ibd", "auto.cnf"} {
if err := os.WriteFile(
filepath.Join(paths.mysqlData, name),
[]byte("initialized-fixture"),
0o600,
); err != nil {
t.Fatal(err)
}
}
mysqldSHA256, err := fileSHA256(paths.mysqldExe)
if err != nil {
t.Fatal(err)
}
autoCNFSHA256, err := fileSHA256(filepath.Join(paths.mysqlData, "auto.cnf"))
if err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
readyState := portableMySQLDataState{
SchemaVersion: 1,
InstallationID: cfg.InstallationID,
Phase: portableMySQLPhaseReady,
DataDirectory: portableMySQLDataRelative,
MysqldSHA256: mysqldSHA256,
AutoCNFSHA256: autoCNFSHA256,
Database: cfg.Database.Name,
InitializedAt: time.Now().Add(-time.Minute).UTC(),
ReadyAt: time.Now().UTC(),
}
writeReadyState := func(t *testing.T, state portableMySQLDataState) {
t.Helper()
if err := writeJSON(paths.mysqlDataState, state, 0o600); err != nil {
t.Fatal(err)
}
}
writeReadyState(t, readyState)
control := newController(paths, io.Discard, io.Discard)
got, err := control.ensurePortableMySQLData(context.Background(), cfg)
if err != nil {
t.Fatalf("ensurePortableMySQLData(ready) error = %v", err)
}
if got.Phase != portableMySQLPhaseReady ||
got.InstallationID != cfg.InstallationID ||
!strings.EqualFold(got.MysqldSHA256, mysqldSHA256) {
t.Fatalf("ensurePortableMySQLData(ready) state = %+v", got)
}
tests := []struct {
name string
mutate func(*portableMySQLDataState)
want []string
}{
{
// A replaced instance.json is the only one of these an operator
// causes, so the message has to name both installation IDs and the
// way out; a launcher user sees this text and nothing else.
name: "installation ID",
mutate: func(state *portableMySQLDataState) {
state.InstallationID = "inst_other"
},
want: []string{
`belongs to installation "inst_other"`,
`now says "` + cfg.InstallationID + `"`,
"Restore the instance.json this database was created with",
"discards every character in it",
},
},
{
name: "mysqld hash",
mutate: func(state *portableMySQLDataState) {
state.MysqldSHA256 = strings.Repeat("0", 64)
},
want: []string{"written by a different mysqld build"},
},
{
name: "ready database",
mutate: func(state *portableMySQLDataState) {
state.Database = "other_database"
},
want: []string{"ready state does not match"},
},
{
name: "ready timestamp",
mutate: func(state *portableMySQLDataState) {
state.ReadyAt = time.Time{}
},
want: []string{"ready state does not match"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invalid := readyState
test.mutate(&invalid)
writeReadyState(t, invalid)
_, err := control.ensurePortableMySQLData(context.Background(), cfg)
if err == nil {
t.Fatalf("ensurePortableMySQLData() error = nil, want failure")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf(
"ensurePortableMySQLData() error = %v, want substring %q",
err,
want,
)
}
}
})
}
}
func TestPortableTemplateAndLogicConfigExcludeRedisAndDocker(t *testing.T) {
t.Setenv("DNF90_PROJECT_ROOT", "")
paths, err := discoverPaths()
if err != nil {
t.Fatalf("discoverPaths() error = %v", err)
}
templateData, err := os.ReadFile(paths.instanceExample)
if err != nil {
t.Fatal(err)
}
template := strings.ToLower(string(templateData))
for _, forbidden := range []string{"redis", "docker"} {
if strings.Contains(template, forbidden) {
t.Fatalf("instance template contains forbidden dependency %q", forbidden)
}
}
if !strings.Contains(template, `"advertiseip": "auto_detect"`) {
t.Fatal("instance template must auto-detect the private LAN IPv4 used by 90cn game channels")
}
logic := strings.ToLower(renderLogicConfig(validTestInstance()))
if strings.Contains(logic, "redis") {
t.Fatalf("logic config unexpectedly contains Redis configuration:\n%s", logic)
}
if !strings.Contains(logic, "mysql_dsn") {
t.Fatalf("logic config is missing the MySQL repository DSN:\n%s", logic)
}
}
func TestReadPortableMySQLServerUUID(t *testing.T) {
autoCNF := filepath.Join(t.TempDir(), "auto.cnf")
if err := os.WriteFile(
autoCNF,
[]byte("[auto]\r\nserver-uuid=12345678-1234-4abc-9def-1234567890ab\r\n"),
0o600,
); err != nil {
t.Fatal(err)
}
got, err := readPortableMySQLServerUUID(autoCNF)
if err != nil {
t.Fatalf("readPortableMySQLServerUUID() error = %v", err)
}
if got != "12345678-1234-4abc-9def-1234567890ab" {
t.Fatalf("readPortableMySQLServerUUID() = %q", got)
}
if err := os.WriteFile(
autoCNF,
[]byte("[auto]\nserver-uuid=not-a-uuid\n"),
0o600,
); err != nil {
t.Fatal(err)
}
if _, err := readPortableMySQLServerUUID(autoCNF); err == nil {
t.Fatal("invalid server UUID was accepted")
}
}
func TestDatabaseRuntimeConfigSHA256DetectsCredentialDrift(t *testing.T) {
paths := newProjectPaths(t.TempDir())
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
if err := writeFile(
paths.mysqlConfig,
[]byte(renderMySQLConfig(paths, cfg)),
0o600,
); err != nil {
t.Fatal(err)
}
if err := writeFile(
filepath.Join(paths.runtimeConfigs, "dnf", "logic.toml"),
[]byte(renderLogicConfig(cfg)),
0o600,
); err != nil {
t.Fatal(err)
}
before, err := databaseRuntimeConfigSHA256(paths)
if err != nil {
t.Fatal(err)
}
cfg.Database.Password = "db_changed1234"
if err := writeFile(
filepath.Join(paths.runtimeConfigs, "dnf", "logic.toml"),
[]byte(renderLogicConfig(cfg)),
0o600,
); err != nil {
t.Fatal(err)
}
after, err := databaseRuntimeConfigSHA256(paths)
if err != nil {
t.Fatal(err)
}
if strings.EqualFold(before, after) {
t.Fatal("database runtime configuration digest ignored credential drift")
}
}
type portableMySQLTestZIPEntry struct {
name string
data []byte
}
func writePortableMySQLTestZIP(
t *testing.T,
archivePath string,
entries []portableMySQLTestZIPEntry,
) {
t.Helper()
file, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
writer := zip.NewWriter(file)
for _, entry := range entries {
target, err := writer.Create(entry.name)
if err != nil {
_ = writer.Close()
_ = file.Close()
t.Fatal(err)
}
if _, err := target.Write(entry.data); err != nil {
_ = writer.Close()
_ = file.Close()
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
_ = file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
func portableMySQLTestSHA256(data []byte) string {
digest := sha256.Sum256(data)
return fmt.Sprintf("%X", digest[:])
}
+159
View File
@@ -0,0 +1,159 @@
//go:build !windows
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
type liveProcess struct {
running bool
executable string
createdAt time.Time
}
func inspectProcess(pid int) (liveProcess, error) {
if runtime.GOOS != "linux" {
return liveProcess{}, fmt.Errorf("PID executable verification is unsupported on %s (PID %d)", runtime.GOOS, pid)
}
if pid <= 0 {
return liveProcess{}, fmt.Errorf("invalid PID %d", pid)
}
stat, err := readLinuxProcessStat(pid)
if errors.Is(err, os.ErrNotExist) {
return liveProcess{}, nil
}
if err != nil {
return liveProcess{}, err
}
if stat.state == "Z" || stat.state == "X" {
return liveProcess{}, nil
}
executable, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if errors.Is(err, os.ErrNotExist) {
return liveProcess{}, nil
}
if err != nil {
return liveProcess{}, fmt.Errorf("read PID %d executable: %w", pid, err)
}
executable, err = filepath.Abs(executable)
if err != nil {
return liveProcess{}, fmt.Errorf("resolve PID %d executable: %w", pid, err)
}
return liveProcess{
running: true,
executable: filepath.Clean(executable),
createdAt: linuxProcessIdentity(stat.startTicks),
}, nil
}
func forceTerminateProcess(pid int, expectedExecutable string, expectedCreatedAt time.Time) error {
live, err := inspectProcess(pid)
if err != nil {
return err
}
if !live.running {
return nil
}
if !sameExecutable(live.executable, expectedExecutable) || !live.createdAt.Equal(expectedCreatedAt) {
return fmt.Errorf("refusing to terminate PID %d because its verified identity changed", pid)
}
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("find PID %d: %w", pid, err)
}
if err := process.Signal(syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("terminate PID %d: %w", pid, err)
}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
current, inspectErr := inspectProcess(pid)
if inspectErr != nil {
return inspectErr
}
if !current.running {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("PID %d did not exit after SIGKILL", pid)
}
func processParentPID(pid int) (int, error) {
stat, err := readLinuxProcessStat(pid)
if err != nil {
return 0, err
}
return stat.parentPID, nil
}
func configureServerProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
func configureBackgroundProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
func configureClientProcess(*exec.Cmd) {}
func sameExecutable(actual, expected string) bool {
actualAbs, actualErr := filepath.Abs(actual)
expectedAbs, expectedErr := filepath.Abs(expected)
if actualErr != nil || expectedErr != nil {
return false
}
return filepath.Clean(actualAbs) == filepath.Clean(expectedAbs)
}
type linuxProcessStat struct {
state string
parentPID int
startTicks uint64
}
func readLinuxProcessStat(pid int) (linuxProcessStat, error) {
path := fmt.Sprintf("/proc/%d/stat", pid)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return linuxProcessStat{}, os.ErrNotExist
}
return linuxProcessStat{}, fmt.Errorf("read %s: %w", path, err)
}
text := strings.TrimSpace(string(data))
closing := strings.LastIndexByte(text, ')')
if closing < 0 || closing+2 >= len(text) {
return linuxProcessStat{}, fmt.Errorf("parse %s: malformed process stat", path)
}
fields := strings.Fields(text[closing+1:])
// fields starts at proc(5) field 3 (state). Linux field 22 is starttime.
if len(fields) <= 19 {
return linuxProcessStat{}, fmt.Errorf("parse %s: process stat has %d trailing fields", path, len(fields))
}
parentPID, err := strconv.Atoi(fields[1])
if err != nil {
return linuxProcessStat{}, fmt.Errorf("parse %s parent PID: %w", path, err)
}
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return linuxProcessStat{}, fmt.Errorf("parse %s start identity: %w", path, err)
}
return linuxProcessStat{state: fields[0], parentPID: parentPID, startTicks: startTicks}, nil
}
// A Linux /proc start-tick value is already unique for a PID over the lifetime
// of one boot. Store it in time.Time so the existing cross-platform process
// state can compare an exact, stable identity without relying on clock rounding.
func linuxProcessIdentity(startTicks uint64) time.Time {
return time.Unix(0, int64(startTicks)).UTC()
}
@@ -0,0 +1,349 @@
//go:build windows
package main
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"longheng.io/server/internal/platform/wineenv"
)
type liveProcess struct {
running bool
executable string
createdAt time.Time
}
func inspectProcess(pid int) (liveProcess, error) {
if pid <= 0 {
return liveProcess{}, fmt.Errorf("invalid PID %d", pid)
}
handle, exists, err := openProcessForControl(
pid,
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE,
)
if err != nil {
return liveProcess{}, fmt.Errorf("open PID %d: %w", pid, err)
}
if !exists {
return liveProcess{}, nil
}
defer func() { _ = windows.CloseHandle(handle) }()
event, err := windows.WaitForSingleObject(handle, 0)
if err != nil {
return liveProcess{}, fmt.Errorf("query PID %d state: %w", pid, err)
}
if event == windows.WAIT_OBJECT_0 {
return liveProcess{}, nil
}
if event != uint32(windows.WAIT_TIMEOUT) {
return liveProcess{}, fmt.Errorf("unexpected wait state 0x%x for PID %d", event, pid)
}
executable, err := processExecutablePath(handle)
if err != nil {
if exited, waitErr := processHandleExitedWithin(handle, 1_000); waitErr == nil && exited {
return liveProcess{}, nil
}
return liveProcess{}, fmt.Errorf("query PID %d executable: %w", pid, err)
}
var creationTime windows.Filetime
var exitTime windows.Filetime
var kernelTime windows.Filetime
var userTime windows.Filetime
if err := windows.GetProcessTimes(
handle,
&creationTime,
&exitTime,
&kernelTime,
&userTime,
); err != nil {
if exited, waitErr := processHandleExitedWithin(handle, 1_000); waitErr == nil && exited {
return liveProcess{}, nil
}
return liveProcess{}, fmt.Errorf("query PID %d creation time: %w", pid, err)
}
return liveProcess{
running: true,
executable: executable,
createdAt: time.Unix(0, creationTime.Nanoseconds()).UTC(),
}, nil
}
func forceTerminateProcess(pid int, expectedExecutable string, expectedCreatedAt time.Time) error {
handle, exists, err := openProcessForControl(
pid,
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE,
)
if err != nil {
return fmt.Errorf("open PID %d for termination: %w", pid, err)
}
if !exists {
return nil
}
defer func() { _ = windows.CloseHandle(handle) }()
actual, err := processExecutablePath(handle)
if err != nil {
if exited, waitErr := processHandleExitedWithin(handle, 1_000); waitErr == nil && exited {
return nil
}
return fmt.Errorf("query PID %d executable before termination: %w", pid, err)
}
if !sameExecutable(actual, expectedExecutable) {
return fmt.Errorf(
"refusing to terminate PID %d: executable %s does not match %s",
pid,
actual,
expectedExecutable,
)
}
if !expectedCreatedAt.IsZero() {
var creationTime windows.Filetime
var exitTime windows.Filetime
var kernelTime windows.Filetime
var userTime windows.Filetime
if err := windows.GetProcessTimes(
handle,
&creationTime,
&exitTime,
&kernelTime,
&userTime,
); err != nil {
if exited, waitErr := processHandleExitedWithin(handle, 1_000); waitErr == nil && exited {
return nil
}
return fmt.Errorf("query PID %d creation time before termination: %w", pid, err)
}
actualCreatedAt := time.Unix(0, creationTime.Nanoseconds()).UTC()
if !actualCreatedAt.Equal(expectedCreatedAt) {
return fmt.Errorf(
"refusing to terminate PID %d: creation time %s does not match %s",
pid,
actualCreatedAt.Format(time.RFC3339Nano),
expectedCreatedAt.Format(time.RFC3339Nano),
)
}
}
if err := windows.TerminateProcess(handle, 1); err != nil {
if exited, waitErr := processHandleExitedWithin(handle, 1_000); waitErr == nil && exited {
return nil
}
return fmt.Errorf("terminate PID %d: %w", pid, err)
}
event, err := windows.WaitForSingleObject(handle, 10_000)
if err != nil {
return fmt.Errorf("wait for terminated PID %d: %w", pid, err)
}
if event != windows.WAIT_OBJECT_0 {
return fmt.Errorf(
"terminated PID %d did not exit within 10 seconds (wait state 0x%x)",
pid,
event,
)
}
return nil
}
func processExecutablePath(handle windows.Handle) (string, error) {
buffer := make([]uint16, windows.MAX_LONG_PATH)
if wineenv.Detected() {
// Wine implements QueryFullProcessImageName from the server handle's
// process identity, while PSAPI module enumeration may require VM_READ
// or fail for a process backed by a Unix loader. Prefer the limited-right
// API so START/STATUS/STOP retain executable ownership checks.
if path, err := queryFullProcessImageName(handle, buffer); err == nil {
return path, nil
}
}
// QueryFullProcessImageName returned one trailing non-path code unit for the
// managed server on this Windows build. The primary module path is the same
// authoritative executable identity and is NUL-terminated by this API.
if err := windows.GetModuleFileNameEx(handle, 0, &buffer[0], uint32(len(buffer))); err == nil {
if path := windows.UTF16ToString(buffer); path != "" {
return path, nil
}
}
return queryFullProcessImageName(handle, buffer)
}
func queryFullProcessImageName(handle windows.Handle, buffer []uint16) (string, error) {
if len(buffer) == 0 {
return "", errors.New("process executable path buffer is empty")
}
size := uint32(len(buffer))
if err := windows.QueryFullProcessImageName(handle, 0, &buffer[0], &size); err != nil {
return "", err
}
path := windows.UTF16ToString(buffer[:size])
if path == "" {
return "", errors.New("empty process executable path")
}
return path, nil
}
func processHandleExited(handle windows.Handle) (bool, error) {
return processHandleExitedWithin(handle, 0)
}
func processHandleExitedWithin(
handle windows.Handle,
timeoutMilliseconds uint32,
) (bool, error) {
event, err := windows.WaitForSingleObject(handle, timeoutMilliseconds)
if err != nil {
return false, err
}
switch event {
case windows.WAIT_OBJECT_0:
return true, nil
case uint32(windows.WAIT_TIMEOUT):
return false, nil
default:
return false, fmt.Errorf("unexpected process wait state 0x%x", event)
}
}
func processParentPID(pid int) (int, error) {
if pid <= 0 {
return 0, fmt.Errorf("invalid PID %d", pid)
}
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0, fmt.Errorf("snapshot processes for PID %d: %w", pid, err)
}
defer func() { _ = windows.CloseHandle(snapshot) }()
var entry windows.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
if err := windows.Process32First(snapshot, &entry); err != nil {
return 0, fmt.Errorf("read process snapshot for PID %d: %w", pid, err)
}
for {
if int(entry.ProcessID) == pid {
return int(entry.ParentProcessID), nil
}
if err := windows.Process32Next(snapshot, &entry); err != nil {
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
break
}
return 0, fmt.Errorf("scan process snapshot for PID %d: %w", pid, err)
}
}
return 0, fmt.Errorf("PID %d was not found in the process snapshot", pid)
}
func processExistsInSnapshot(pid int) (bool, error) {
if pid <= 0 {
return false, fmt.Errorf("invalid PID %d", pid)
}
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return false, fmt.Errorf("snapshot processes for PID %d: %w", pid, err)
}
defer func() { _ = windows.CloseHandle(snapshot) }()
var entry windows.ProcessEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
if err := windows.Process32First(snapshot, &entry); err != nil {
return false, fmt.Errorf("read process snapshot for PID %d: %w", pid, err)
}
for {
if int(entry.ProcessID) == pid {
return true, nil
}
if err := windows.Process32Next(snapshot, &entry); err != nil {
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return false, nil
}
return false, fmt.Errorf("scan process snapshot for PID %d: %w", pid, err)
}
}
}
func openProcessForControl(
pid int,
desiredAccess uint32,
) (windows.Handle, bool, error) {
const (
attempts = 21
retryWait = 25 * time.Millisecond
)
for attempt := 0; attempt < attempts; attempt++ {
access := desiredAccess | windows.PROCESS_QUERY_INFORMATION | windows.PROCESS_VM_READ
if wineenv.Detected() {
// QueryFullProcessImageName and GetProcessTimes need only the limited
// rights already requested by callers. Wine can reject VM_READ for a
// perfectly controllable process, so do not turn that optional PSAPI
// capability into a lifecycle failure.
access = desiredAccess
}
handle, err := windows.OpenProcess(
access,
false,
uint32(pid),
)
if err == nil {
return handle, true, nil
}
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return 0, false, nil
}
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return 0, false, err
}
exists, snapshotErr := processExistsInSnapshot(pid)
if snapshotErr != nil {
return 0, false, errors.Join(err, snapshotErr)
}
if !exists {
return 0, false, nil
}
if attempt+1 < attempts {
time.Sleep(retryWait)
continue
}
return 0, true, err
}
panic("unreachable")
}
func configureServerProcess(cmd *exec.Cmd) {
configureBackgroundProcess(cmd)
}
func configureBackgroundProcess(cmd *exec.Cmd) {
creationFlags := uint32(windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW)
if wineenv.Detected() {
// Older Wine releases reject CREATE_NO_WINDOW for some PE console
// processes. Redirected stdout/stderr already keeps managed processes
// non-interactive, so the process-group boundary is sufficient here.
creationFlags = windows.CREATE_NEW_PROCESS_GROUP
}
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: creationFlags,
}
}
func configureClientProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP,
}
}
func sameExecutable(actual, expected string) bool {
actualAbs, actualErr := filepath.Abs(actual)
expectedAbs, expectedErr := filepath.Abs(expected)
if actualErr != nil || expectedErr != nil {
return false
}
return strings.EqualFold(filepath.Clean(actualAbs), filepath.Clean(expectedAbs))
}
@@ -0,0 +1,103 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"golang.org/x/sys/windows"
)
func TestProcessExistsInSnapshot(t *testing.T) {
exists, err := processExistsInSnapshot(os.Getpid())
if err != nil {
t.Fatalf("processExistsInSnapshot(current) error = %v", err)
}
if !exists {
t.Fatal("current process is absent from the process snapshot")
}
exists, err = processExistsInSnapshot(int(^uint32(0)))
if err != nil {
t.Fatalf("processExistsInSnapshot(impossible) error = %v", err)
}
if exists {
t.Fatal("impossible PID appears in the process snapshot")
}
}
func TestProcessExecutablePathUsesCurrentMainModule(t *testing.T) {
handle, err := windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ,
false,
uint32(os.Getpid()),
)
if err != nil {
t.Fatalf("open current process: %v", err)
}
defer func() { _ = windows.CloseHandle(handle) }()
got, err := processExecutablePath(handle)
if err != nil {
t.Fatalf("processExecutablePath(current) error = %v", err)
}
want, err := os.Executable()
if err != nil {
t.Fatalf("current executable path: %v", err)
}
if !sameExecutable(got, want) {
t.Fatalf("process executable = %q, want %q", got, want)
}
}
func TestManagedServerExecutableMatchesWindowsReplacementRename(t *testing.T) {
directory := t.TempDir()
expected := filepath.Join(directory, "DNF90Server.exe")
renamed := expected + "~"
content := []byte("verified managed server image")
if err := os.WriteFile(expected, content, 0o600); err != nil {
t.Fatalf("write expected executable: %v", err)
}
if err := os.WriteFile(renamed, content, 0o600); err != nil {
t.Fatalf("write renamed executable: %v", err)
}
digest, err := fileSHA256(renamed)
if err != nil {
t.Fatalf("hash renamed executable: %v", err)
}
matched, err := managedServerExecutableMatches(renamed, expected, digest)
if err != nil || !matched {
t.Fatalf("renamed executable match = %t, %v; want true, nil", matched, err)
}
matched, err = managedServerExecutableMatches(expected, expected, digest)
if err != nil || !matched {
t.Fatalf("exact executable match = %t, %v; want true, nil", matched, err)
}
for _, test := range []struct {
name string
actual string
digest string
}{
{name: "wrong digest", actual: renamed, digest: "00"},
{name: "empty digest", actual: renamed, digest: ""},
{name: "backup suffix", actual: expected + ".previous-good", digest: digest},
} {
t.Run(test.name, func(t *testing.T) {
if test.name == "backup suffix" {
if err := os.WriteFile(test.actual, content, 0o600); err != nil {
t.Fatalf("write backup executable: %v", err)
}
}
matched, err := managedServerExecutableMatches(test.actual, expected, test.digest)
if err != nil {
t.Fatalf("managed executable check: %v", err)
}
if matched {
t.Fatal("unexpected managed executable match")
}
})
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "os"
func replaceFileAtomic(source, destination string) error {
return os.Rename(source, destination)
}
@@ -0,0 +1,21 @@
//go:build windows
package main
import "golang.org/x/sys/windows"
func replaceFileAtomic(source, destination string) error {
sourceUTF16, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationUTF16, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
return windows.MoveFileEx(
sourceUTF16,
destinationUTF16,
windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH,
)
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func sqliteSavePath(paths projectPaths, cfg instanceConfig) (string, error) {
relative := filepath.Clean(filepath.FromSlash(strings.TrimSpace(cfg.Database.Path)))
if relative == "." || filepath.IsAbs(relative) || escapesRoot(relative) {
return "", fmt.Errorf("invalid SQLite save path %q", cfg.Database.Path)
}
absolute, err := filepath.Abs(filepath.Join(paths.runtimeRoot, relative))
if err != nil {
return "", fmt.Errorf("resolve SQLite save path: %w", err)
}
if !pathInside(paths.runtimeRoot, absolute) {
return "", fmt.Errorf("SQLite save path escapes runtime: %s", absolute)
}
return absolute, nil
}
func finalizeSQLiteSave(ctx context.Context, paths projectPaths, cfg instanceConfig) (string, error) {
path, err := sqliteSavePath(paths, cfg)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("open SQLite save %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("SQLite save is not a regular file: %s", path)
}
if ctx == nil {
ctx = context.Background()
}
checkpointCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
database, err := sql.Open("sqlite", path)
if err != nil {
return "", fmt.Errorf("open SQLite save %s: %w", path, err)
}
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
defer database.Close()
for _, pragma := range []string{
`PRAGMA busy_timeout = 5000`,
`PRAGMA synchronous = FULL`,
} {
if _, err := database.ExecContext(checkpointCtx, pragma); err != nil {
return "", fmt.Errorf("configure SQLite save finalization: %w", err)
}
}
var busy, logFrames, checkpointedFrames int
if err := database.QueryRowContext(checkpointCtx, `PRAGMA wal_checkpoint(TRUNCATE)`).Scan(
&busy,
&logFrames,
&checkpointedFrames,
); err != nil {
return "", fmt.Errorf("checkpoint SQLite save: %w", err)
}
if busy != 0 {
return "", fmt.Errorf(
"SQLite save checkpoint remained busy (log_frames=%d checkpointed_frames=%d)",
logFrames,
checkpointedFrames,
)
}
var integrity string
if err := database.QueryRowContext(checkpointCtx, `PRAGMA integrity_check(1)`).Scan(&integrity); err != nil {
return "", fmt.Errorf("check SQLite save integrity: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(integrity), "ok") {
return "", fmt.Errorf("SQLite save integrity check returned %q", integrity)
}
if err := database.Close(); err != nil {
return "", fmt.Errorf("close SQLite save after checkpoint: %w", err)
}
if walInfo, statErr := os.Stat(path + "-wal"); statErr == nil && walInfo.Size() > 0 {
return "", fmt.Errorf("SQLite WAL still contains %d bytes after checkpoint: %s", walInfo.Size(), path+"-wal")
} else if statErr != nil && !os.IsNotExist(statErr) {
return "", fmt.Errorf("inspect SQLite WAL after checkpoint: %w", statErr)
}
return path, nil
}
@@ -0,0 +1,73 @@
package main
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
)
func TestFinalizeSQLiteSaveCheckpointsAndVerifiesDatabase(t *testing.T) {
root := t.TempDir()
paths := newProjectPaths(root)
if err := paths.ensureDirectories(); err != nil {
t.Fatal(err)
}
cfg := validTestInstance()
cfg.Database = databaseConfig{Mode: "sqlite", Path: "data/dnf90.db", Name: "dnf_local"}
path := filepath.Join(paths.runtimeRoot, "data", "dnf90.db")
database, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`PRAGMA journal_mode=WAL`); err != nil {
database.Close()
t.Fatal(err)
}
if _, err := database.Exec(`CREATE TABLE save_test (value TEXT NOT NULL)`); err != nil {
database.Close()
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO save_test (value) VALUES ('latest')`); err != nil {
database.Close()
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
gotPath, err := finalizeSQLiteSave(context.Background(), paths, cfg)
if err != nil {
t.Fatal(err)
}
if gotPath != path {
t.Fatalf("save path = %q, want %q", gotPath, path)
}
if walInfo, err := os.Stat(path + "-wal"); err == nil && walInfo.Size() > 0 {
t.Fatalf("WAL still contains %d bytes", walInfo.Size())
} else if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
verified, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer verified.Close()
var value string
if err := verified.QueryRow(`SELECT value FROM save_test`).Scan(&value); err != nil {
t.Fatal(err)
}
if value != "latest" {
t.Fatalf("saved value = %q, want latest", value)
}
}
func TestSQLiteSavePathRejectsEscape(t *testing.T) {
paths := newProjectPaths(t.TempDir())
cfg := validTestInstance()
cfg.Database = databaseConfig{Mode: "sqlite", Path: "../outside.db", Name: "dnf_local"}
if _, err := sqliteSavePath(paths, cfg); err == nil {
t.Fatal("escaping SQLite save path was accepted")
}
}
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"context"
"longheng.io/server/cmd/server/internal/runner"
dnf90app "longheng.io/server/internal/app/dnf90"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.RunContext(ctx, dnf90app.ServiceName, dnf90app.DefaultConfigPath, dnf90app.ComponentsWithShutdown(cancel))
}
+369
View File
@@ -0,0 +1,369 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
sqliterepo "longheng.io/server/internal/modules/dnf/repository/sqlite"
appkit "longheng.io/server/internal/platform/app"
platformconfig "longheng.io/server/internal/platform/config"
"longheng.io/server/internal/platform/servergroup"
logicdnf "longheng.io/server/internal/services/logic/dnf"
)
type options struct {
root string
servicePath string
logicPath string
channelPath string
channelAddr string
gameHost string
timeout time.Duration
skipDatabase bool
skipPorts bool
expectListen bool
}
func main() {
var opts options
flag.StringVar(&opts.root, "root", ".", "runtime working directory")
flag.StringVar(&opts.servicePath, "config", "configs/dnfbridge.toml", "service config path, relative to root")
flag.StringVar(&opts.logicPath, "logic-config", "configs/dnf/logic.toml", "repository config path, relative to root")
flag.StringVar(&opts.channelPath, "channel-info", "data/dnf/channel_info.etc", "channel_info.etc path, relative to root")
flag.StringVar(&opts.channelAddr, "channel-listen", ":7001", "channel TCP listen address")
flag.StringVar(&opts.gameHost, "game-listen-host", "", "host used by all game TCP listeners")
flag.DurationVar(&opts.timeout, "timeout", 5*time.Second, "database and network check timeout")
flag.BoolVar(&opts.skipDatabase, "skip-database", false, "skip MySQL connectivity check")
flag.BoolVar(&opts.skipPorts, "skip-ports", false, "skip TCP listen-port availability checks")
flag.BoolVar(&opts.expectListen, "expect-listening", false, "require server TCP ports to be accepting connections")
flag.Parse()
if err := run(opts); err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(1)
}
fmt.Println("OK: DNF90 runtime preflight passed")
}
func run(opts options) error {
root, err := filepath.Abs(strings.TrimSpace(opts.root))
if err != nil {
return fmt.Errorf("resolve runtime root: %w", err)
}
if err := os.Chdir(root); err != nil {
return fmt.Errorf("enter runtime root %s: %w", root, err)
}
fmt.Println("runtime:", root)
serviceCfg, err := platformconfig.Load(opts.servicePath, "dnfbridge")
if err != nil {
return fmt.Errorf("service config %s: %w", opts.servicePath, err)
}
fmt.Println("service config: valid")
env := &appkit.Env{Config: serviceCfg}
logicCfg, err := logicdnf.LoadConfigForEnv(opts.logicPath, env)
if err != nil {
return fmt.Errorf("repository config %s: %w", opts.logicPath, err)
}
fmt.Println("repository config: valid")
if err := checkFile(serviceCfg.PVF.Path, "Script.pvf", serviceCfg.PVF.MaxBytes); err != nil {
return err
}
index, err := channelinfo.LoadFile(opts.channelPath)
if err != nil {
return fmt.Errorf("channel info %s: %w", opts.channelPath, err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{
ServerID: dnfenum.LoginChannelServerIndex,
GamePortBase: dnfenum.GamePortBase,
})
if err != nil {
return fmt.Errorf("build channel catalog: %w", err)
}
ports := catalog.GamePorts()
fmt.Printf("channels: %d, game ports: %s\n", len(catalog.Channels()), joinInts(ports))
if logicCfg.Repository.Driver == "mysql" {
planPath := strings.TrimSpace(logicCfg.Repository.ServerGroupPlanFile)
if planPath == "" {
planPath = strings.TrimSpace(serviceCfg.ServerGroup.PlanFile)
}
dbPlan, err := loadDatabasePlan(planPath, logicCfg.Repository.ShardID)
if err != nil {
return err
}
if len(dbPlan.WriteDatabases) != 1 || len(dbPlan.ReadDatabases) != 1 ||
dbPlan.WriteDatabases[0] != dbPlan.ReadDatabases[0] {
return fmt.Errorf(
"local deployment requires exactly one shared read/write database; got read=%v write=%v",
dbPlan.ReadDatabases,
dbPlan.WriteDatabases,
)
}
fmt.Printf("database route: read=%s write=%s\n",
strings.Join(dbPlan.ReadDatabases, ","),
strings.Join(dbPlan.WriteDatabases, ","))
if !opts.skipDatabase {
schemaReady, err := checkDatabase(
logicCfg.Repository.MySQLDSN,
dbPlan.WriteDatabases[0],
logicCfg.Repository.TablePrefix,
logicCfg.Repository.AutoCreateSchema,
opts.timeout,
)
if err != nil {
return err
}
fmt.Println("mysql: reachable")
if schemaReady {
fmt.Println("mysql schema: ready")
} else {
fmt.Println("mysql schema: pending first-start auto creation")
}
}
} else if !opts.skipDatabase {
if err := checkSQLiteDatabase(logicCfg.Repository.SQLitePath, opts.timeout); err != nil {
return err
}
fmt.Println("sqlite: reachable, writable, and schema ready")
}
if logicCfg.Repository.RedisEnabled {
if err := checkTCP(logicCfg.Repository.RedisAddress, opts.timeout); err != nil {
return fmt.Errorf("redis %s: %w", logicCfg.Repository.RedisAddress, err)
}
fmt.Println("redis: reachable")
}
if !opts.skipPorts {
addresses := make([]string, 0, len(ports)+2)
addresses = append(addresses, opts.channelAddr, serviceCfg.Admin.Listen)
for _, port := range ports {
addresses = append(addresses, net.JoinHostPort(strings.Trim(opts.gameHost, "[]"), strconv.Itoa(port)))
}
if opts.expectListen {
if err := checkListeningAddresses(addresses, opts.timeout); err != nil {
return err
}
fmt.Printf("listen ports: accepting connections (%d checked)\n", len(addresses))
} else {
if err := checkListenAddresses(addresses); err != nil {
return err
}
fmt.Printf("listen ports: available (%d checked)\n", len(addresses))
}
}
return nil
}
func checkSQLiteDatabase(path string, timeout time.Duration) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("sqlite path is empty")
}
database, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open sqlite: %w", err)
}
defer database.Close()
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
component := sqliterepo.NewComponent(database, path)
if err := component.Start(ctx); err != nil {
return fmt.Errorf("start sqlite repository: %w", err)
}
defer component.Stop(context.Background())
group, ok := component.Group()
if !ok {
return fmt.Errorf("sqlite repository group is unavailable")
}
if err := group.Check(ctx); err != nil {
return fmt.Errorf("check sqlite repository: %w", err)
}
return nil
}
func checkFile(path, label string, maxBytes int64) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("%s path is empty", label)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s %s: %w", label, path, err)
}
if !info.Mode().IsRegular() || info.Size() == 0 {
return fmt.Errorf("%s %s is not a non-empty regular file", label, path)
}
if maxBytes > 0 && info.Size() > maxBytes {
return fmt.Errorf("%s %s is %d bytes, over configured max %d", label, path, info.Size(), maxBytes)
}
fmt.Printf("%s: %s (%d bytes)\n", label, path, info.Size())
return nil
}
func loadDatabasePlan(path, shardID string) (dnfrepo.DatabasePlan, error) {
if strings.TrimSpace(path) == "" {
return dnfrepo.DatabasePlan{}, fmt.Errorf("server-group plan path is empty")
}
store, err := servergroup.NewFileStore(path)
if err != nil {
return dnfrepo.DatabasePlan{}, fmt.Errorf("server-group plan %s: %w", path, err)
}
plan, err := store.Load(context.Background())
if err != nil {
return dnfrepo.DatabasePlan{}, fmt.Errorf("load server-group plan %s: %w", path, err)
}
manager, err := servergroup.New(plan)
if err != nil {
return dnfrepo.DatabasePlan{}, fmt.Errorf("validate server-group plan %s: %w", path, err)
}
dbPlan, err := dnfrepo.ResolveDatabasePlan(context.Background(), manager, shardID)
if err != nil {
return dnfrepo.DatabasePlan{}, fmt.Errorf("resolve database route: %w", err)
}
return dbPlan, nil
}
func checkDatabase(dsn, expectedDatabase, tablePrefix string, autoCreate bool, timeout time.Duration) (bool, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return false, fmt.Errorf("open mysql: %w", err)
}
defer func() { _ = db.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return false, fmt.Errorf("mysql is not reachable: %w", err)
}
var selectedDatabase string
var currentUser string
if err := db.QueryRowContext(ctx, "SELECT DATABASE(), CURRENT_USER()").Scan(&selectedDatabase, &currentUser); err != nil {
return false, fmt.Errorf("query mysql identity: %w", err)
}
if selectedDatabase != expectedDatabase {
return false, fmt.Errorf("mysql DSN selects database %q, route requires %q", selectedDatabase, expectedDatabase)
}
if !strings.HasPrefix(strings.ToLower(currentUser), "root@") {
return false, fmt.Errorf("mysql account must be root, server reports %q", currentUser)
}
if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE dnf90_preflight_write (id INT NOT NULL)"); err != nil {
return false, fmt.Errorf("mysql root account cannot create a temporary table: %w", err)
}
if _, err := db.ExecContext(ctx, "DROP TEMPORARY TABLE dnf90_preflight_write"); err != nil {
return false, fmt.Errorf("drop mysql preflight temporary table: %w", err)
}
tablePrefix = strings.TrimSpace(tablePrefix)
if tablePrefix == "" {
tablePrefix = "dnf"
}
var tableCount int
if err := db.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ? AND table_name = ?",
expectedDatabase,
tablePrefix+"_accounts",
).Scan(&tableCount); err != nil {
return false, fmt.Errorf("inspect mysql schema: %w", err)
}
if tableCount == 0 && !autoCreate {
return false, fmt.Errorf("mysql schema is missing and repository auto_create_schema is disabled")
}
return tableCount > 0, nil
}
func checkTCP(address string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return err
}
return conn.Close()
}
func checkListenAddresses(addresses []string) error {
seen := make(map[string]struct{}, len(addresses))
listeners := make([]net.Listener, 0, len(addresses))
defer func() {
for _, listener := range listeners {
_ = listener.Close()
}
}()
for _, address := range addresses {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("listen address %s is unavailable: %w", address, err)
}
listeners = append(listeners, listener)
}
return nil
}
func checkListeningAddresses(addresses []string, timeout time.Duration) error {
seen := make(map[string]struct{}, len(addresses))
for _, address := range addresses {
address = dialAddress(address)
if address == "" {
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return fmt.Errorf("listen address %s is not accepting connections: %w", address, err)
}
_ = conn.Close()
}
return nil
}
func dialAddress(address string) string {
address = strings.TrimSpace(address)
if address == "" {
return ""
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
host = strings.Trim(host, "[]")
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
func joinInts(values []int) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.Itoa(value))
}
return strings.Join(parts, ",")
}
@@ -0,0 +1,33 @@
package runner
import (
"context"
"flag"
"log"
"os"
appkit "longheng.io/server/internal/platform/app"
"longheng.io/server/internal/platform/i18n"
runtimekit "longheng.io/server/internal/runtime/bootstrap"
)
type Factory = appkit.Builder
func Run(serviceName, defaultConfigPath string, factory Factory) {
if err := appkit.RunManifestFromFlags(serviceName, defaultConfigPath, runtimekit.Manifest(factory, i18n.ConfigureApp)); err != nil {
log.Fatal(err)
}
}
func RunContext(ctx context.Context, serviceName, defaultConfigPath string, factory Factory) {
if err := appkit.RunManifestFromFlagSet(
ctx,
flag.CommandLine,
os.Args[1:],
serviceName,
defaultConfigPath,
runtimekit.Manifest(factory, i18n.ConfigureApp),
); err != nil {
log.Fatal(err)
}
}
@@ -0,0 +1,294 @@
//go:build windows
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const gwOwner = 4
var (
procEnumWindows = user32.NewProc("EnumWindows")
procDestroyWindow = user32.NewProc("DestroyWindow")
procGetWindow = user32.NewProc("GetWindow")
procGetWindowThreadProcessID = user32.NewProc("GetWindowThreadProcessId")
procIsWindow = user32.NewProc("IsWindow")
procIsWindowVisible = user32.NewProc("IsWindowVisible")
clientWindowEnumCallback = windows.NewCallback(enumClientWindow)
clientWindowEnumMu sync.Mutex
clientWindowEnumCurrent *clientWindowEnumState
)
type hiddenClientWindow struct {
handle uintptr
processID uint32
executable string
createdAt time.Time
}
type clientWindowEnumState struct {
executables map[string]struct{}
hidden []hiddenClientWindow
err error
}
type launcherInstance struct {
Client struct {
Directory string `json:"directory"`
} `json:"client"`
}
func hideConfiguredClientWindows() (int, error) {
executables, err := configuredClientExecutables(
globalLauncher.projectRoot,
)
if err != nil {
return 0, err
}
hidden, err := hideVisibleWindowsForExecutables(executables)
if err != nil {
return 0, err
}
globalLauncher.mu.Lock()
defer globalLauncher.mu.Unlock()
known := make(map[[2]uintptr]struct{}, len(globalLauncher.hidden))
for _, entry := range globalLauncher.hidden {
known[[2]uintptr{entry.handle, uintptr(entry.processID)}] = struct{}{}
}
added := 0
for _, entry := range hidden {
key := [2]uintptr{entry.handle, uintptr(entry.processID)}
if _, exists := known[key]; exists {
continue
}
globalLauncher.hidden = append(globalLauncher.hidden, entry)
known[key] = struct{}{}
added++
}
if added == 0 && len(globalLauncher.hidden) == 0 {
return 0, errors.New(
"没有找到正在显示的 DNF.exe 或 NoPack.exe 游戏窗口",
)
}
return added, nil
}
func showHiddenClientWindows() (int, error) {
globalLauncher.mu.Lock()
hidden := append([]hiddenClientWindow(nil), globalLauncher.hidden...)
globalLauncher.mu.Unlock()
if len(hidden) == 0 {
return 0, errors.New("本次登录器运行期间没有隐藏过游戏窗口")
}
restored, remaining, err := restoreHiddenWindows(hidden)
globalLauncher.mu.Lock()
globalLauncher.hidden = remaining
globalLauncher.mu.Unlock()
return restored, err
}
func restoreAllHiddenClientWindows() {
globalLauncher.mu.Lock()
hidden := append([]hiddenClientWindow(nil), globalLauncher.hidden...)
globalLauncher.hidden = nil
globalLauncher.mu.Unlock()
_, _, _ = restoreHiddenWindows(hidden)
}
func configuredClientExecutables(projectRoot string) ([]string, error) {
configPath := filepath.Join(
projectRoot,
"runtime",
"config",
"instance.json",
)
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("读取客户端配置失败: %w", err)
}
var cfg launcherInstance
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析客户端配置失败: %w", err)
}
clientRoot := strings.TrimSpace(cfg.Client.Directory)
if clientRoot == "" {
return nil, errors.New(
"请先在 instance.json 中配置 client.directory",
)
}
if !filepath.IsAbs(clientRoot) {
return nil, errors.New("client.directory 必须是绝对路径")
}
var executables []string
for _, name := range []string{"DNF.exe", "NoPack.exe"} {
candidate := filepath.Join(clientRoot, name)
if regularFile(candidate) {
executables = append(executables, filepath.Clean(candidate))
}
}
if len(executables) == 0 {
return nil, errors.New(
"配置的客户端目录中没有 DNF.exe 或 NoPack.exe",
)
}
return executables, nil
}
func hideVisibleWindowsForExecutables(
executables []string,
) ([]hiddenClientWindow, error) {
state := &clientWindowEnumState{
executables: make(map[string]struct{}, len(executables)),
}
for _, executable := range executables {
state.executables[normalizeExecutablePath(executable)] = struct{}{}
}
clientWindowEnumMu.Lock()
clientWindowEnumCurrent = state
result, _, callErr := procEnumWindows.Call(clientWindowEnumCallback, 0)
clientWindowEnumCurrent = nil
clientWindowEnumMu.Unlock()
if result == 0 && state.err == nil {
return nil, fmt.Errorf("枚举游戏窗口失败: %w", callErr)
}
if state.err != nil {
return state.hidden, state.err
}
return state.hidden, nil
}
func enumClientWindow(window uintptr, _ uintptr) uintptr {
state := clientWindowEnumCurrent
if state == nil {
return 0
}
visible, _, _ := procIsWindowVisible.Call(window)
if visible == 0 {
return 1
}
owner, _, _ := procGetWindow.Call(window, gwOwner)
if owner != 0 {
return 1
}
processID, executable, createdAt, err := windowProcessIdentity(window)
if err != nil {
return 1
}
if _, matches := state.executables[normalizeExecutablePath(executable)]; !matches {
return 1
}
procShowWindow.Call(window, 0)
visible, _, _ = procIsWindowVisible.Call(window)
if visible != 0 {
state.err = errors.New("隐藏游戏窗口失败")
return 0
}
state.hidden = append(state.hidden, hiddenClientWindow{
handle: window,
processID: processID,
executable: executable,
createdAt: createdAt,
})
return 1
}
func restoreHiddenWindows(
hidden []hiddenClientWindow,
) (restored int, remaining []hiddenClientWindow, resultErr error) {
for _, entry := range hidden {
exists, _, _ := procIsWindow.Call(entry.handle)
if exists == 0 {
continue
}
processID, executable, createdAt, err := windowProcessIdentity(
entry.handle,
)
if err != nil ||
processID != entry.processID ||
!sameExecutablePath(executable, entry.executable) ||
!createdAt.Equal(entry.createdAt) {
continue
}
procShowWindow.Call(entry.handle, swRestore)
visible, _, _ := procIsWindowVisible.Call(entry.handle)
if visible == 0 {
remaining = append(remaining, entry)
resultErr = errors.Join(
resultErr,
errors.New("显示游戏窗口失败"),
)
continue
}
restored++
}
return restored, remaining, resultErr
}
func windowProcessIdentity(
window uintptr,
) (processID uint32, executable string, createdAt time.Time, resultErr error) {
procGetWindowThreadProcessID.Call(
window,
uintptr(unsafe.Pointer(&processID)),
)
if processID == 0 {
return 0, "", time.Time{}, errors.New("窗口没有所属进程")
}
handle, err := windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION,
false,
processID,
)
if err != nil {
return 0, "", time.Time{}, err
}
defer func() {
resultErr = errors.Join(resultErr, windows.CloseHandle(handle))
}()
buffer := make([]uint16, windows.MAX_LONG_PATH)
size := uint32(len(buffer))
if err := windows.QueryFullProcessImageName(
handle,
0,
&buffer[0],
&size,
); err != nil {
return 0, "", time.Time{}, err
}
var creation windows.Filetime
var exit windows.Filetime
var kernel windows.Filetime
var user windows.Filetime
if err := windows.GetProcessTimes(
handle,
&creation,
&exit,
&kernel,
&user,
); err != nil {
return 0, "", time.Time{}, err
}
return processID,
windows.UTF16ToString(buffer[:size]),
time.Unix(0, creation.Nanoseconds()).UTC(),
nil
}
func sameExecutablePath(left string, right string) bool {
return normalizeExecutablePath(left) == normalizeExecutablePath(right)
}
func normalizeExecutablePath(path string) string {
return strings.ToLower(filepath.Clean(strings.TrimSpace(path)))
}
@@ -0,0 +1,135 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
"unsafe"
)
func TestConfiguredClientExecutables(t *testing.T) {
root := t.TempDir()
clientRoot := filepath.Join(root, "client")
if err := os.MkdirAll(clientRoot, 0o755); err != nil {
t.Fatal(err)
}
dnfPath := filepath.Join(clientRoot, "DNF.exe")
if err := os.WriteFile(dnfPath, []byte("fixture"), 0o600); err != nil {
t.Fatal(err)
}
configRoot := filepath.Join(root, "runtime", "config")
if err := os.MkdirAll(configRoot, 0o755); err != nil {
t.Fatal(err)
}
config := []byte(
"{\"client\":{\"directory\":" +
quoteJSON(clientRoot) +
"}}",
)
if err := os.WriteFile(
filepath.Join(configRoot, "instance.json"),
config,
0o600,
); err != nil {
t.Fatal(err)
}
executables, err := configuredClientExecutables(root)
if err != nil {
t.Fatal(err)
}
if len(executables) != 1 ||
!sameExecutablePath(executables[0], dnfPath) {
t.Fatalf("configured executables = %q", executables)
}
}
func TestHideAndRestoreOwnedTopLevelWindow(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
window, _, callErr := procCreateWindowExW.Call(
0,
uintptr(unsafe.Pointer(utf16Ptr("STATIC"))),
uintptr(unsafe.Pointer(utf16Ptr("DNF90 launcher window test"))),
wsOverlapped|wsCaption,
0,
0,
200,
100,
0,
0,
0,
0,
)
if window == 0 {
t.Fatalf("create test window: %v", callErr)
}
defer procDestroyWindow.Call(window)
procShowWindow.Call(window, swShow)
procUpdateWindow.Call(window)
hidden, err := hideVisibleWindowsForExecutables([]string{executable})
if err != nil {
t.Fatal(err)
}
found := false
for _, entry := range hidden {
if entry.handle == window {
found = true
break
}
}
if !found {
t.Fatal("test window was not hidden")
}
if !waitForWindowVisibility(window, false) {
t.Fatal("test window is still visible")
}
restored, remaining, err := restoreHiddenWindows(hidden)
if err != nil {
t.Fatal(err)
}
if restored < 1 || len(remaining) != 0 {
t.Fatalf(
"restore result = %d restored, %d remaining",
restored,
len(remaining),
)
}
if !waitForWindowVisibility(window, true) {
t.Fatal("test window was not restored")
}
}
func waitForWindowVisibility(window uintptr, wantVisible bool) bool {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
visible, _, _ := procIsWindowVisible.Call(window)
if (visible != 0) == wantVisible {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
func quoteJSON(value string) string {
quoted := "\""
for _, character := range value {
switch character {
case '\\', '"':
quoted += "\\" + string(character)
default:
quoted += string(character)
}
}
return quoted + "\""
}
@@ -0,0 +1,145 @@
//go:build windows
package main
import (
"crypto/sha256"
"errors"
"fmt"
"path/filepath"
"runtime"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
credentialTypeGeneric = 1
credentialPersistLocalHost = 2
errorNotFound = syscall.Errno(1168)
)
var (
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
procCredWriteW = advapi32.NewProc("CredWriteW")
procCredReadW = advapi32.NewProc("CredReadW")
procCredDelete = advapi32.NewProc("CredDeleteW")
procCredFree = advapi32.NewProc("CredFree")
)
type windowsCredential struct {
Flags uint32
Type uint32
TargetName *uint16
Comment *uint16
LastWritten windows.Filetime
CredentialBlobSize uint32
CredentialBlob *byte
Persist uint32
AttributeCount uint32
Attributes uintptr
TargetAlias *uint16
UserName *uint16
}
func credentialTarget(projectRoot string) string {
normalized := strings.ToLower(filepath.Clean(projectRoot))
digest := sha256.Sum256([]byte(normalized))
return fmt.Sprintf("DNF90/LocalLogin/%x", digest[:16])
}
func writeCredential(target string, username string, password string) error {
targetPointer, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
usernamePointer, err := windows.UTF16PtrFromString(username)
if err != nil {
return err
}
commentPointer, _ := windows.UTF16PtrFromString(
"DNF90 本地登录器保存的账号密码",
)
blob := []byte(password)
var blobPointer *byte
if len(blob) > 0 {
blobPointer = &blob[0]
}
credential := windowsCredential{
Type: credentialTypeGeneric,
TargetName: targetPointer,
Comment: commentPointer,
CredentialBlobSize: uint32(len(blob)),
CredentialBlob: blobPointer,
Persist: credentialPersistLocalHost,
UserName: usernamePointer,
}
result, _, callErr := procCredWriteW.Call(
uintptr(unsafe.Pointer(&credential)),
0,
)
runtime.KeepAlive(blob)
runtime.KeepAlive(targetPointer)
runtime.KeepAlive(usernamePointer)
if result == 0 {
return fmt.Errorf("写入 Windows 凭据失败: %w", callErr)
}
return nil
}
func readCredential(target string) (
username string,
password string,
found bool,
err error,
) {
targetPointer, err := windows.UTF16PtrFromString(target)
if err != nil {
return "", "", false, err
}
var credential *windowsCredential
result, _, callErr := procCredReadW.Call(
uintptr(unsafe.Pointer(targetPointer)),
credentialTypeGeneric,
0,
uintptr(unsafe.Pointer(&credential)),
)
if result == 0 {
if errors.Is(callErr, errorNotFound) {
return "", "", false, nil
}
return "", "", false, fmt.Errorf("读取 Windows 凭据失败: %w", callErr)
}
defer procCredFree.Call(uintptr(unsafe.Pointer(credential)))
if credential == nil || credential.UserName == nil {
return "", "", false, errors.New("Windows 凭据内容无效")
}
username = windows.UTF16PtrToString(credential.UserName)
if credential.CredentialBlobSize > 0 &&
credential.CredentialBlob != nil {
blob := unsafe.Slice(
credential.CredentialBlob,
int(credential.CredentialBlobSize),
)
password = string(blob)
}
return username, password, true, nil
}
func deleteCredential(target string) error {
targetPointer, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
result, _, callErr := procCredDelete.Call(
uintptr(unsafe.Pointer(targetPointer)),
credentialTypeGeneric,
0,
)
if result == 0 && !errors.Is(callErr, errorNotFound) {
return fmt.Errorf("删除 Windows 凭据失败: %w", callErr)
}
return nil
}
@@ -0,0 +1,46 @@
//go:build windows
package main
import "testing"
func TestWindowsCredentialRoundTrip(t *testing.T) {
target := credentialTarget(t.TempDir())
t.Cleanup(func() {
_ = deleteCredential(target)
})
const (
username = "credential-test-user"
password = "credential-test-password"
)
if err := writeCredential(target, username, password); err != nil {
t.Fatal(err)
}
gotUsername, gotPassword, found, err := readCredential(target)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("written Windows credential was not found")
}
if gotUsername != username || gotPassword != password {
t.Fatalf(
"credential = (%q, %q), want (%q, %q)",
gotUsername,
gotPassword,
username,
password,
)
}
if err := deleteCredential(target); err != nil {
t.Fatal(err)
}
_, _, found, err = readCredential(target)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("deleted Windows credential is still present")
}
}
@@ -0,0 +1,98 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"longheng.io/server/internal/platform/wineenv"
)
const swShowNormal = 1
var (
shell32 = windows.NewLazySystemDLL("shell32.dll")
procShellExecuteW = shell32.NewProc("ShellExecuteW")
)
// ensureLauncherElevation keeps the BAT entrypoint simple while still
// allowing CreateProcess to start DNF clients that Windows marks as requiring
// elevation. ShellExecuteW with the runas verb is the native UAC boundary; the
// original unelevated launcher exits after the elevated copy is accepted.
func ensureLauncherElevation(args []string) (bool, error) {
// Wine has no native Windows UAC boundary. Some versions report a
// non-elevated token but either reject or recursively relaunch the "runas"
// verb. The Wine process already has the Unix user's filesystem rights, so
// continue with those rights and let the normal path/file checks enforce the
// project boundary.
if wineenv.Detected() {
return false, nil
}
var token windows.Token
if err := windows.OpenProcessToken(
windows.CurrentProcess(),
windows.TOKEN_QUERY,
&token,
); err != nil {
return false, fmt.Errorf("检查管理员权限失败:%w", err)
}
defer token.Close()
if token.IsElevated() {
return false, nil
}
executable, err := os.Executable()
if err != nil {
return false, fmt.Errorf("定位登录器失败:%w", err)
}
executable, err = filepath.Abs(executable)
if err != nil {
return false, fmt.Errorf("解析登录器路径失败:%w", err)
}
workingDirectory := filepath.Dir(executable)
verb, err := windows.UTF16PtrFromString("runas")
if err != nil {
return false, err
}
file, err := windows.UTF16PtrFromString(executable)
if err != nil {
return false, err
}
directory, err := windows.UTF16PtrFromString(workingDirectory)
if err != nil {
return false, err
}
var parameters *uint16
if len(args) != 0 {
escaped := make([]string, 0, len(args))
for _, arg := range args {
escaped = append(escaped, syscall.EscapeArg(arg))
}
parameters, err = windows.UTF16PtrFromString(strings.Join(escaped, " "))
if err != nil {
return false, err
}
}
result, _, callErr := procShellExecuteW.Call(
uintptr(unsafe.Pointer(parameters)),
uintptr(unsafe.Pointer(verb)),
uintptr(unsafe.Pointer(file)),
0,
uintptr(unsafe.Pointer(directory)),
swShowNormal,
)
if result <= 32 {
if callErr != nil && callErr != windows.ERROR_SUCCESS {
return false, fmt.Errorf("无法取得管理员权限(请在UAC窗口中选择“是”):%w", callErr)
}
return false, fmt.Errorf("无法取得管理员权限(ShellExecute返回%d),请右键“一键登录.bat”选择“以管理员身份运行”", result)
}
return true, nil
}
@@ -0,0 +1,56 @@
//go:build windows
package main
import (
"context"
"errors"
"reflect"
"testing"
)
func TestRunGMToolLaunchTaskStartsServerBeforeTool(t *testing.T) {
originalControl := launcherRunControl
originalStart := launcherStartGMTool
defer func() {
launcherRunControl = originalControl
launcherStartGMTool = originalStart
}()
var calls []string
launcherRunControl = func(_ context.Context, args []string, password string) (string, error) {
if password != "" {
t.Fatalf("unexpected password %q", password)
}
calls = append(calls, "control:"+args[0])
return "", nil
}
launcherStartGMTool = func() error {
calls = append(calls, "gm")
return nil
}
result := runGMToolLaunchTask()
if !result.success || !reflect.DeepEqual(calls, []string{"control:start", "gm"}) {
t.Fatalf("result=%+v calls=%v", result, calls)
}
}
func TestRunGMToolLaunchTaskDoesNotOpenToolWhenServerStartFails(t *testing.T) {
originalControl := launcherRunControl
originalStart := launcherStartGMTool
defer func() {
launcherRunControl = originalControl
launcherStartGMTool = originalStart
}()
started := false
launcherRunControl = func(context.Context, []string, string) (string, error) {
return "server failed", errors.New("exit status 1")
}
launcherStartGMTool = func() error {
started = true
return nil
}
result := runGMToolLaunchTask()
if result.success || started {
t.Fatalf("result=%+v started=%t", result, started)
}
}
@@ -0,0 +1,116 @@
package main
import (
"context"
"reflect"
"testing"
)
func TestAuthenticatedClientLaunchDoesNotStopOrSwitchServerAccount(t *testing.T) {
original := launcherRunControl
t.Cleanup(func() {
launcherRunControl = original
})
type call struct {
args []string
stdin string
}
var calls []call
launcherRunControl = func(
_ context.Context,
args []string,
stdin string,
) (string, error) {
calls = append(calls, call{
args: append([]string(nil), args...),
stdin: stdin,
})
return "", nil
}
if _, err := runAuthenticatedClientLaunch(
context.Background(),
"second-account",
"second-password",
); err != nil {
t.Fatalf("runAuthenticatedClientLaunch: %v", err)
}
want := []call{
{args: []string{"start"}},
{
args: []string{
"launch-client",
"--multi-instance",
"--username",
"second-account",
"--password-stdin",
},
stdin: "second-password",
},
}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("control calls = %#v, want %#v", calls, want)
}
}
func TestPrepareLauncherDatabaseRunsBeforeServerStart(t *testing.T) {
originalRunControl := launcherRunControl
originalMode := globalLauncher.databaseMode
t.Cleanup(func() {
launcherRunControl = originalRunControl
globalLauncher.databaseMode = originalMode
})
type call struct {
args []string
stdin string
}
var calls []call
launcherRunControl = func(
_ context.Context,
args []string,
stdin string,
) (string, error) {
calls = append(calls, call{args: append([]string(nil), args...), stdin: stdin})
return "", nil
}
globalLauncher.databaseMode = "mysql"
result := runLauncherTask(true, "test", "test123456", false, "unused")
if !result.success {
t.Fatalf("login task failed: %+v", result)
}
want := []call{
{args: []string{"database", "select", "--mode", "mysql"}},
{args: []string{"start"}},
{
args: []string{
"launch-client", "--multi-instance", "--username", "test", "--password-stdin",
},
stdin: "test123456",
},
}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("control calls = %#v, want %#v", calls, want)
}
}
func TestParseLauncherDatabaseMode(t *testing.T) {
for _, test := range []struct {
args []string
want string
}{
{args: nil, want: ""},
{args: []string{"--database", "MySQL"}, want: "mysql"},
{args: []string{"--database", "SQLiet"}, want: "sqlite"},
} {
got, err := parseLauncherDatabaseMode(test.args)
if err != nil || got != test.want {
t.Fatalf("parseLauncherDatabaseMode(%v) = %q, %v", test.args, got, err)
}
}
if _, err := parseLauncherDatabaseMode([]string{"--database", "bad"}); err == nil {
t.Fatal("invalid database mode was accepted")
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "fmt"
func main() {
fmt.Println("DNF90Launcher is available only on Windows.")
}
+945
View File
@@ -0,0 +1,945 @@
//go:build windows
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const (
windowClassName = "DNF90LocalLoginWindow"
windowTitle = "DNF90 本地登录器"
wmCreate = 0x0001
wmDestroy = 0x0002
wmClose = 0x0010
wmCommand = 0x0111
wmSetFont = 0x0030
wmGetTextLen = 0x000E
wmAppResult = 0x8001
bmGetCheck = 0x00F0
bmSetCheck = 0x00F1
bstChecked = 1
bnClicked = 0
swShow = 5
swMinimize = 6
swRestore = 9
colorWindow = 5
defaultGUIFont = 17
idcArrow = 32512
wsOverlapped = 0x00000000
wsCaption = 0x00C00000
wsSysMenu = 0x00080000
wsMinimizeBox = 0x00020000
wsChild = 0x40000000
wsVisible = 0x10000000
wsTabStop = 0x00010000
wsClipChild = 0x02000000
wsExClientEdge = 0x00000200
esAutoHScroll = 0x0080
esPassword = 0x0020
bsAutoCheck = 0x0003
bsDefPush = 0x0001
ssCenter = 0x0001
controlUsername = 1001
controlPassword = 1002
controlRemember = 1003
controlRegister = 1004
controlLogin = 1005
controlUsername2 = 1006
controlPassword2 = 1007
controlRegister2 = 1008
controlLogin2 = 1009
controlGMTool = 1010
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
procRegisterClassExW = user32.NewProc("RegisterClassExW")
procCreateWindowExW = user32.NewProc("CreateWindowExW")
procDefWindowProcW = user32.NewProc("DefWindowProcW")
procShowWindow = user32.NewProc("ShowWindow")
procUpdateWindow = user32.NewProc("UpdateWindow")
procGetMessageW = user32.NewProc("GetMessageW")
procIsDialogMessageW = user32.NewProc("IsDialogMessageW")
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessageW = user32.NewProc("DispatchMessageW")
procPostQuitMessage = user32.NewProc("PostQuitMessage")
procPostMessageW = user32.NewProc("PostMessageW")
procSendMessageW = user32.NewProc("SendMessageW")
procSetWindowTextW = user32.NewProc("SetWindowTextW")
procGetWindowTextW = user32.NewProc("GetWindowTextW")
procEnableWindow = user32.NewProc("EnableWindow")
procMessageBoxW = user32.NewProc("MessageBoxW")
procLoadCursorW = user32.NewProc("LoadCursorW")
procSetProcessDPIAware = user32.NewProc("SetProcessDPIAware")
procGetStockObject = gdi32.NewProc("GetStockObject")
procGetModuleHandleW = kernel32.NewProc("GetModuleHandleW")
globalLauncher launcherState
windowProcedureCallback = windows.NewCallback(windowProcedure)
)
type point struct {
X int32
Y int32
}
type windowMessage struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Point point
Private uint32
}
type windowClassEx struct {
Size uint32
Style uint32
WndProc uintptr
ClassExtra int32
WindowExtra int32
Instance uintptr
Icon uintptr
Cursor uintptr
Background uintptr
MenuName *uint16
ClassName *uint16
SmallIcon uintptr
}
type taskResult struct {
message string
success bool
minimize bool
clearPassword bool
credentialSlot int
}
type launcherState struct {
mu sync.Mutex
projectRoot string
controlExe string
databaseMode string
credential [2]string
window uintptr
username [2]uintptr
password [2]uintptr
remember uintptr
register [2]uintptr
login [2]uintptr
gmTool uintptr
status uintptr
busy bool
result taskResult
hidden []hiddenClientWindow
}
func main() {
runtime.LockOSThread()
databaseMode, err := parseLauncherDatabaseMode(os.Args[1:])
if err != nil {
messageBox(0, err.Error(), windowTitle)
return
}
relaunched, err := ensureLauncherElevation(os.Args[1:])
if err != nil {
messageBox(0, err.Error(), windowTitle)
return
}
if relaunched {
return
}
projectRoot, controlExe, err := discoverLauncherProject()
if err != nil {
messageBox(0, err.Error(), windowTitle)
return
}
globalLauncher.projectRoot = projectRoot
globalLauncher.controlExe = controlExe
globalLauncher.databaseMode = databaseMode
globalLauncher.credential[0] = credentialTarget(projectRoot)
globalLauncher.credential[1] = credentialTarget(projectRoot) + "/Slot2"
if err := runWindow(); err != nil {
messageBox(0, err.Error(), windowTitle)
}
}
func runWindow() error {
_, _, _ = procSetProcessDPIAware.Call()
instance, _, callErr := procGetModuleHandleW.Call(0)
if instance == 0 {
return fmt.Errorf("获取程序模块失败: %v", callErr)
}
cursor, _, _ := procLoadCursorW.Call(0, idcArrow)
className := utf16Ptr(windowClassName)
class := windowClassEx{
Size: uint32(unsafe.Sizeof(windowClassEx{})),
WndProc: windowProcedureCallback,
Instance: instance,
Cursor: cursor,
Background: colorWindow + 1,
ClassName: className,
}
atom, _, callErr := procRegisterClassExW.Call(
uintptr(unsafe.Pointer(&class)),
)
if atom == 0 {
return fmt.Errorf("注册登录窗口失败: %v", callErr)
}
window, _, callErr := procCreateWindowExW.Call(
0,
uintptr(unsafe.Pointer(className)),
uintptr(unsafe.Pointer(utf16Ptr(windowTitle))),
wsOverlapped|wsCaption|wsSysMenu|wsMinimizeBox|wsClipChild,
uintptr(uint32(0x80000000)),
uintptr(uint32(0x80000000)),
520,
620,
0,
0,
instance,
0,
)
if window == 0 {
return fmt.Errorf("创建登录窗口失败: %v", callErr)
}
globalLauncher.window = window
procShowWindow.Call(window, swShow)
procUpdateWindow.Call(window)
var message windowMessage
for {
result, _, err := procGetMessageW.Call(
uintptr(unsafe.Pointer(&message)),
0,
0,
0,
)
if int32(result) == -1 {
return fmt.Errorf("读取窗口消息失败: %v", err)
}
if result == 0 {
return nil
}
if handled, _, _ := procIsDialogMessageW.Call(
window,
uintptr(unsafe.Pointer(&message)),
); handled != 0 {
continue
}
procTranslateMessage.Call(uintptr(unsafe.Pointer(&message)))
procDispatchMessageW.Call(uintptr(unsafe.Pointer(&message)))
}
}
func windowProcedure(
window uintptr,
message uint32,
wParam uintptr,
lParam uintptr,
) uintptr {
switch message {
case wmCreate:
createLauncherControls(window)
return 0
case wmCommand:
controlID := uint16(wParam & 0xFFFF)
notification := uint16((wParam >> 16) & 0xFFFF)
if notification == bnClicked {
switch controlID {
case controlRegister:
beginLauncherTask(0, false)
case controlLogin:
beginLauncherTask(0, true)
case controlRegister2:
beginLauncherTask(1, false)
case controlLogin2:
beginLauncherTask(1, true)
case controlGMTool:
beginGMToolTask()
}
}
return 0
case wmAppResult:
finishLauncherTask()
return 0
case wmClose:
globalLauncher.mu.Lock()
busy := globalLauncher.busy
globalLauncher.mu.Unlock()
if busy {
setWindowText(
globalLauncher.status,
"当前操作尚未完成,请完成后再关闭登录器。",
)
return 0
}
restoreAllHiddenClientWindows()
procDestroyWindow.Call(window)
return 0
case wmDestroy:
procPostQuitMessage.Call(0)
return 0
default:
result, _, _ := procDefWindowProcW.Call(window, uintptr(message), wParam, lParam)
return result
}
}
func createLauncherControls(window uintptr) {
title := createControl(
0,
"STATIC",
"DNF90 本地账号登录",
wsChild|wsVisible|ssCenter,
40,
20,
430,
34,
window,
0,
)
subtitle := createControl(
0,
"STATIC",
"两组账号可分别注册和登录;登录不会重启已在线服务",
wsChild|wsVisible|ssCenter,
40,
55,
430,
22,
window,
0,
)
usernameLabel1 := createControl(
0,
"STATIC",
"账号 1",
wsChild|wsVisible,
70,
95,
75,
24,
window,
0,
)
globalLauncher.username[0] = createControl(
wsExClientEdge,
"EDIT",
"",
wsChild|wsVisible|wsTabStop|esAutoHScroll,
145,
89,
285,
30,
window,
controlUsername,
)
passwordLabel1 := createControl(
0,
"STATIC",
"密码 1",
wsChild|wsVisible,
70,
136,
75,
24,
window,
0,
)
globalLauncher.password[0] = createControl(
wsExClientEdge,
"EDIT",
"",
wsChild|wsVisible|wsTabStop|esAutoHScroll|esPassword,
145,
130,
285,
30,
window,
controlPassword,
)
usernameLabel2 := createControl(
0,
"STATIC",
"账号 2",
wsChild|wsVisible,
70,
236,
75,
24,
window,
0,
)
globalLauncher.username[1] = createControl(
wsExClientEdge,
"EDIT",
"",
wsChild|wsVisible|wsTabStop|esAutoHScroll,
145,
230,
285,
30,
window,
controlUsername2,
)
passwordLabel2 := createControl(
0,
"STATIC",
"密码 2",
wsChild|wsVisible,
70,
277,
75,
24,
window,
0,
)
globalLauncher.password[1] = createControl(
wsExClientEdge,
"EDIT",
"",
wsChild|wsVisible|wsTabStop|esAutoHScroll|esPassword,
145,
271,
285,
30,
window,
controlPassword2,
)
globalLauncher.remember = createControl(
0,
"BUTTON",
"记住两组账号密码(Windows 凭据管理器)",
wsChild|wsVisible|wsTabStop|bsAutoCheck,
145,
365,
285,
26,
window,
controlRemember,
)
globalLauncher.register[0] = createControl(
0,
"BUTTON",
"注册账号 1",
wsChild|wsVisible|wsTabStop,
95,
170,
140,
38,
window,
controlRegister,
)
globalLauncher.login[0] = createControl(
0,
"BUTTON",
"登录账号 1",
wsChild|wsVisible|wsTabStop,
270,
170,
165,
38,
window,
controlLogin,
)
globalLauncher.register[1] = createControl(
0,
"BUTTON",
"注册账号 2",
wsChild|wsVisible|wsTabStop,
95,
311,
140,
38,
window,
controlRegister2,
)
globalLauncher.login[1] = createControl(
0,
"BUTTON",
"登录账号 2",
wsChild|wsVisible|wsTabStop|bsDefPush,
270,
311,
165,
38,
window,
controlLogin2,
)
globalLauncher.gmTool = createControl(
0,
"BUTTON",
"打开 GM 工具",
wsChild|wsVisible|wsTabStop,
145,
405,
285,
38,
window,
controlGMTool,
)
globalLauncher.status = createControl(
0,
"STATIC",
"请输入账号和密码;首次使用请先注册对应账号。",
wsChild|wsVisible|ssCenter,
45,
462,
420,
65,
window,
0,
)
font, _, _ := procGetStockObject.Call(defaultGUIFont)
for _, control := range []uintptr{
title,
subtitle,
usernameLabel1,
globalLauncher.username[0],
passwordLabel1,
globalLauncher.password[0],
usernameLabel2,
globalLauncher.username[1],
passwordLabel2,
globalLauncher.password[1],
globalLauncher.remember,
globalLauncher.register[0],
globalLauncher.login[0],
globalLauncher.register[1],
globalLauncher.login[1],
globalLauncher.gmTool,
globalLauncher.status,
} {
procSendMessageW.Call(control, wmSetFont, font, 1)
}
remembered := false
for slot := range globalLauncher.credential {
username, password, found, err := readCredential(
globalLauncher.credential[slot],
)
if err != nil || !found {
continue
}
setWindowText(globalLauncher.username[slot], username)
setWindowText(globalLauncher.password[slot], password)
remembered = true
}
if remembered {
procSendMessageW.Call(globalLauncher.remember, bmSetCheck, bstChecked, 0)
}
}
func beginLauncherTask(slot int, login bool) {
if slot < 0 || slot >= len(globalLauncher.username) {
return
}
username := getWindowText(globalLauncher.username[slot])
password := getWindowText(globalLauncher.password[slot])
if strings.TrimSpace(username) == "" || password == "" {
setWindowText(
globalLauncher.status,
fmt.Sprintf("账号 %d 和密码 %d 不能为空。", slot+1, slot+1),
)
return
}
status := fmt.Sprintf("正在注册账号 %d,请稍候……", slot+1)
if login {
status = fmt.Sprintf("正在验证账号 %d 并启动游戏,请稍候……", slot+1)
}
remember := isButtonChecked(globalLauncher.remember)
beginBackgroundTask(status, func() taskResult {
result := runLauncherTask(
login,
username,
password,
remember,
globalLauncher.credential[slot],
)
result.clearPassword = result.success
result.credentialSlot = slot
return result
})
}
func beginGMToolTask() {
beginBackgroundTask("正在启动服务端并打开 GM 工具,请稍候……", runGMToolLaunchTask)
}
func beginBackgroundTask(
status string,
run func() taskResult,
) {
globalLauncher.mu.Lock()
if globalLauncher.busy {
globalLauncher.mu.Unlock()
return
}
globalLauncher.busy = true
globalLauncher.mu.Unlock()
enableLauncherButtons(false)
setWindowText(globalLauncher.status, status)
go func() {
result := run()
globalLauncher.mu.Lock()
globalLauncher.result = result
globalLauncher.mu.Unlock()
procPostMessageW.Call(globalLauncher.window, wmAppResult, 0, 0)
}()
}
func runLauncherTask(
login bool,
username string,
password string,
remember bool,
credentialTarget string,
) taskResult {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if output, err := prepareLauncherDatabase(ctx); err != nil {
return taskResult{message: summarizeControlError(output, err)}
}
if !login {
output, err := runControl(
ctx,
[]string{
"account",
"register",
"--username",
username,
"--password-stdin",
},
password,
)
if err != nil {
return taskResult{message: summarizeControlError(output, err)}
}
if remember {
if err := writeCredential(
credentialTarget,
username,
password,
); err != nil {
return taskResult{
success: true,
message: "账号注册成功,但保存登录信息失败:" + err.Error(),
}
}
}
return taskResult{
success: true,
message: "账号注册成功。现在可以点击“登录并启动游戏”。",
}
}
output, err := runAuthenticatedClientLaunch(ctx, username, password)
if err != nil {
return taskResult{message: summarizeControlError(output, err)}
}
if remember {
if err := writeCredential(
credentialTarget,
username,
password,
); err != nil {
return taskResult{
success: true,
minimize: true,
message: "游戏已启动,但保存登录信息失败:" + err.Error(),
}
}
} else if err := deleteCredential(credentialTarget); err != nil {
return taskResult{
success: true,
minimize: true,
message: "游戏已启动,但清除旧登录信息失败:" + err.Error(),
}
}
return taskResult{
success: true,
minimize: true,
message: "登录成功,游戏已启动。",
}
}
func finishLauncherTask() {
globalLauncher.mu.Lock()
result := globalLauncher.result
globalLauncher.result = taskResult{}
globalLauncher.busy = false
globalLauncher.mu.Unlock()
enableLauncherButtons(true)
setWindowText(globalLauncher.status, result.message)
if result.clearPassword &&
result.credentialSlot >= 0 &&
result.credentialSlot < len(globalLauncher.password) &&
!isButtonChecked(globalLauncher.remember) {
setWindowText(globalLauncher.password[result.credentialSlot], "")
}
if result.minimize {
procShowWindow.Call(globalLauncher.window, swMinimize)
}
}
var (
launcherRunControl = runControl
launcherStartGMTool = startGMTool
)
func runGMToolLaunchTask() taskResult {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if output, err := prepareLauncherDatabase(ctx); err != nil {
return taskResult{message: summarizeControlError(output, err)}
}
output, err := launcherRunControl(ctx, []string{"start"}, "")
if err != nil {
return taskResult{message: summarizeControlError(output, err)}
}
if err := launcherStartGMTool(); err != nil {
return taskResult{message: err.Error()}
}
return taskResult{success: true, message: "GM 工具已打开;服务端已就绪。"}
}
func parseLauncherDatabaseMode(args []string) (string, error) {
mode := ""
for index := 0; index < len(args); index++ {
switch strings.ToLower(strings.TrimSpace(args[index])) {
case "--database":
index++
if index >= len(args) {
return "", errors.New("--database requires mysql or sqlite")
}
switch strings.ToLower(strings.TrimSpace(args[index])) {
case "mysql", "portable":
mode = "mysql"
case "sqlite", "sqliet":
mode = "sqlite"
default:
return "", fmt.Errorf("unsupported database mode %q; use mysql or sqlite", args[index])
}
default:
return "", fmt.Errorf("unsupported launcher argument %q", args[index])
}
}
return mode, nil
}
func prepareLauncherDatabase(ctx context.Context) (string, error) {
mode := strings.TrimSpace(globalLauncher.databaseMode)
if mode == "" {
return "", nil
}
return launcherRunControl(
ctx,
[]string{"database", "select", "--mode", mode},
"",
)
}
func startGMTool() error {
path := filepath.Join(globalLauncher.projectRoot, "runtime", "bin", "DNF90GMTool.exe")
if !regularFile(path) {
return fmt.Errorf("找不到 GM 工具:%s,请重新解压完整发布包", path)
}
command := exec.Command(path)
command.Dir = globalLauncher.projectRoot
command.Env = append(
os.Environ(),
"DNF90_INSTANCE_CONFIG="+filepath.Join(globalLauncher.projectRoot, "runtime", "config", "instance.json"),
)
if err := command.Start(); err != nil {
return fmt.Errorf("启动 GM 工具失败:%w", err)
}
return command.Process.Release()
}
func runAuthenticatedClientLaunch(
ctx context.Context,
username string,
password string,
) (string, error) {
output, err := launcherRunControl(ctx, []string{"start"}, "")
if err != nil {
return output, err
}
return launcherRunControl(
ctx,
[]string{
"launch-client",
"--multi-instance",
"--username",
username,
"--password-stdin",
},
password,
)
}
func runControl(
ctx context.Context,
args []string,
password string,
) (string, error) {
command := exec.CommandContext(ctx, globalLauncher.controlExe, args...)
command.Dir = globalLauncher.projectRoot
command.SysProcAttr = &windows.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW,
}
if password != "" {
command.Stdin = strings.NewReader(password + "\n")
}
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
err := command.Run()
return output.String(), err
}
func discoverLauncherProject() (string, string, error) {
executable, err := os.Executable()
if err != nil {
return "", "", fmt.Errorf("获取登录器路径失败: %w", err)
}
current := filepath.Dir(executable)
for {
control := filepath.Join(current, "runtime", "bin", "DNF90Control.exe")
template := filepath.Join(
current,
"deploy",
"templates",
"instance.example.json",
)
if regularFile(control) && regularFile(template) {
return current, control, nil
}
if strings.EqualFold(filepath.Base(current), "bin") {
control = filepath.Join(current, "DNF90Control.exe")
root := filepath.Clean(filepath.Join(current, "..", ".."))
template = filepath.Join(
root,
"deploy",
"templates",
"instance.example.json",
)
if regularFile(control) && regularFile(template) {
return root, control, nil
}
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
return "", "", errors.New(
"找不到 DNF90Control.exe 或发布目录,请把登录器保留在 runtime\\bin",
)
}
func regularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
func createControl(
exStyle uintptr,
className string,
text string,
style uintptr,
x int32,
y int32,
width int32,
height int32,
parent uintptr,
id uint16,
) uintptr {
control, _, _ := procCreateWindowExW.Call(
exStyle,
uintptr(unsafe.Pointer(utf16Ptr(className))),
uintptr(unsafe.Pointer(utf16Ptr(text))),
style,
uintptr(uint32(x)),
uintptr(uint32(y)),
uintptr(uint32(width)),
uintptr(uint32(height)),
parent,
uintptr(id),
0,
0,
)
return control
}
func setWindowText(window uintptr, value string) {
procSetWindowTextW.Call(
window,
uintptr(unsafe.Pointer(utf16Ptr(value))),
)
}
func getWindowText(window uintptr) string {
length, _, _ := procSendMessageW.Call(window, wmGetTextLen, 0, 0)
buffer := make([]uint16, int(length)+1)
procGetWindowTextW.Call(
window,
uintptr(unsafe.Pointer(&buffer[0])),
uintptr(len(buffer)),
)
return windows.UTF16ToString(buffer)
}
func enableLauncherButtons(enabled bool) {
value := uintptr(0)
if enabled {
value = 1
}
for slot := range globalLauncher.register {
procEnableWindow.Call(globalLauncher.register[slot], value)
procEnableWindow.Call(globalLauncher.login[slot], value)
}
procEnableWindow.Call(globalLauncher.gmTool, value)
}
func isButtonChecked(button uintptr) bool {
result, _, _ := procSendMessageW.Call(button, bmGetCheck, 0, 0)
return result == bstChecked
}
func messageBox(window uintptr, text string, title string) {
procMessageBoxW.Call(
window,
uintptr(unsafe.Pointer(utf16Ptr(text))),
uintptr(unsafe.Pointer(utf16Ptr(title))),
0x10,
)
}
func utf16Ptr(value string) *uint16 {
pointer, _ := windows.UTF16PtrFromString(value)
return pointer
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"strings"
"unicode"
"unicode/utf8"
)
func summarizeControlError(output string, commandErr error) string {
output = strings.TrimSpace(output)
if output == "" && commandErr != nil {
output = commandErr.Error()
}
output = strings.ReplaceAll(output, "\r", " ")
output = strings.ReplaceAll(output, "\n", " ")
output = strings.Join(strings.Fields(output), " ")
if localized := localizeControlError(output); localized != "" {
return localized
}
return "操作失败,请检查服务配置和运行日志。"
}
func localizeControlError(detail string) string {
lower := strings.ToLower(detail)
switch {
case strings.Contains(lower, "this username is already registered"):
return "该账号已注册,请直接登录或更换账号名。"
case strings.Contains(lower, "username or password is incorrect"):
return "账号或密码错误,请重新输入。"
case strings.Contains(lower, "this account is disabled"):
return "该账号已被停用。"
case strings.Contains(lower, "username must contain 3 to 32 characters"):
return "账号长度必须为 3 到 32 个字符。"
case strings.Contains(lower, "username may contain only"):
return "账号只能使用字母、数字、下划线、短横线或句点。"
case strings.Contains(lower, "password must contain 8 to 72"):
return "密码长度必须为 8 到 72 个 UTF-8 字节。"
case strings.Contains(lower, "password must be valid utf-8"):
return "密码包含无效字符,请重新输入。"
case strings.Contains(lower, "password must not contain control characters"):
return "密码不能包含控制字符。"
case strings.Contains(lower, "client directory is not configured"):
return "尚未配置游戏客户端目录,请检查 runtime/config/instance.json 中的 client.directory。"
case strings.Contains(lower, "client directory does not exist:"):
return "游戏客户端目录不存在:" + detailAfter(detail, "client directory does not exist:")
case strings.Contains(lower, "client file is missing; expected one of:"):
return "游戏客户端文件缺失,需要以下文件之一:" + detailAfter(detail, "client file is missing; expected one of:")
case strings.Contains(lower, "dnf.exe or nopack.exe was not found"):
return "游戏客户端目录中找不到 DNF.exe 或 NoPack.exe。"
case strings.Contains(lower, "dnf90 server is not running"), strings.Contains(lower, "server is not running"):
return "本地游戏服务尚未启动,请先启动服务。"
case strings.Contains(lower, "dnf90 server is not ready"), strings.Contains(lower, "server is not ready"):
return "本地游戏服务尚未就绪,请稍后重试。"
case strings.Contains(lower, "requires elevation"),
strings.Contains(lower, "requested operation requires elevation"),
strings.Contains(lower, "error 740"):
return "游戏客户端要求管理员权限。请关闭本登录器,再右键“一键登录.bat”选择“以管理员身份运行”。"
case strings.Contains(lower, "virus or potentially unwanted software"),
strings.Contains(lower, "operation did not complete successfully because the file contains a virus"),
strings.Contains(lower, "error 225"):
return "游戏客户端被Windows安全中心或杀毒软件拦截。请检查保护历史记录,确认文件可信后恢复,并把DNF客户端目录加入排除项。"
case strings.Contains(lower, "access is denied"), strings.Contains(lower, "permission denied"):
return "Windows拒绝启动游戏客户端。请以管理员身份运行“一键登录.bat”,并检查杀毒软件是否隔离了DNF.exe或补丁DLL。"
case strings.Contains(lower, "not a valid win32 application"),
strings.Contains(lower, "exec format error"),
strings.Contains(lower, "error 193"):
return "游戏客户端EXE格式无效或文件已损坏,请换用完整的23.4.15.0客户端。"
case strings.Contains(lower, "start client:"):
return "启动游戏客户端失败。Windows返回:" + detailAfter(detail, "start client:")
case strings.Contains(lower, "timed out"), strings.Contains(lower, "deadline exceeded"):
return "操作超时,请检查本地服务状态后重试。"
case (strings.Contains(lower, "connection refused") || strings.Contains(lower, "actively refused")) && !strings.Contains(lower, "mysql"):
return "本地服务连接失败,请确认服务已经启动。"
case strings.Contains(lower, "additional client pid") && strings.Contains(lower, "exited"):
return "游戏客户端启动后提前退出,请检查客户端目录中的 DNF.exe、补丁 DLL 和安全软件拦截记录。"
case strings.Contains(lower, "mysql is not reachable"),
strings.Contains(lower, "mysql schema"),
strings.Contains(lower, "database initialization failed"),
strings.Contains(lower, "database connection failed"):
return "数据库服务异常,请检查 MySQL 状态和运行日志。"
case strings.Contains(lower, "preflight failed"), strings.Contains(lower, "runtime asset"), strings.Contains(lower, "manifest"):
return "启动前检查失败,请检查运行文件和配置。"
case strings.Contains(lower, "cannot find"), strings.Contains(lower, "not found"), strings.Contains(lower, "system cannot find the file"):
return "所需程序或文件不存在,请检查安装目录。"
}
if containsChinese(detail) {
const maxCharacters = 280
if utf8.RuneCountInString(detail) > maxCharacters {
runes := []rune(detail)
detail = string(runes[len(runes)-maxCharacters:])
}
return "操作失败:" + detail
}
return ""
}
func detailAfter(detail, marker string) string {
index := strings.Index(strings.ToLower(detail), strings.ToLower(marker))
if index < 0 {
return ""
}
return strings.TrimSpace(detail[index+len(marker):])
}
func containsChinese(value string) bool {
for _, character := range value {
if unicode.Is(unicode.Han, character) {
return true
}
}
return false
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"errors"
"strings"
"testing"
"unicode/utf8"
)
func TestSummarizeControlErrorUsesBoundedSingleLine(t *testing.T) {
got := summarizeControlError(
"first line\r\n"+strings.Repeat("错误", 200),
errors.New("exit status 1"),
)
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("summary contains a newline: %q", got)
}
if utf8.RuneCountInString(got) > 290 {
t.Fatalf("summary is not bounded: %d runes", utf8.RuneCountInString(got))
}
}
func TestSummarizeControlErrorFallsBackToCommandError(t *testing.T) {
got := summarizeControlError("", errors.New("exit status 1"))
if got != "操作失败,请检查服务配置和运行日志。" {
t.Fatalf("summary = %q", got)
}
}
func TestSummarizeControlErrorLocalizesCommonLauncherFailures(t *testing.T) {
tests := []struct {
name string
output string
want string
}{
{name: "bad credentials", output: "FAILED: username or password is incorrect", want: "账号或密码错误,请重新输入。"},
{name: "duplicate account", output: "FAILED: this username is already registered", want: "该账号已注册,请直接登录或更换账号名。"},
{name: "missing client DLL", output: "FAILED: client file is missing; expected one of: 90CN.dll", want: "游戏客户端文件缺失,需要以下文件之一:90CN.dll"},
{name: "service not ready", output: "FAILED: DNF90 server is not ready; run control start first", want: "本地游戏服务尚未就绪,请稍后重试。"},
{name: "timeout", output: "FAILED: context deadline exceeded", want: "操作超时,请检查本地服务状态后重试。"},
{name: "client needs elevation", output: "FAILED: start client: The requested operation requires elevation.", want: "游戏客户端要求管理员权限。请关闭本登录器,再右键“一键登录.bat”选择“以管理员身份运行”。"},
{name: "client blocked by antivirus", output: "FAILED: start client: Operation did not complete successfully because the file contains a virus or potentially unwanted software.", want: "游戏客户端被Windows安全中心或杀毒软件拦截。请检查保护历史记录,确认文件可信后恢复,并把DNF客户端目录加入排除项。"},
{name: "client access denied", output: "FAILED: start client: Access is denied.", want: "Windows拒绝启动游戏客户端。请以管理员身份运行“一键登录.bat”,并检查杀毒软件是否隔离了DNF.exe或补丁DLL。"},
{name: "client invalid executable", output: "FAILED: start client: %1 is not a valid Win32 application.", want: "游戏客户端EXE格式无效或文件已损坏,请换用完整的23.4.15.0客户端。"},
{name: "client other start error", output: "FAILED: start client: The system cannot execute the specified program.", want: "启动游戏客户端失败。Windows返回:The system cannot execute the specified program."},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := summarizeControlError(test.output, errors.New("exit status 1")); got != test.want {
t.Fatalf("summary = %q, want %q", got, test.want)
}
})
}
}
func TestSummarizeControlErrorDoesNotMislabelClientExitAsDatabaseFailure(t *testing.T) {
output := "Database: portable root@127.0.0.1:13306/dnf_local FAILED: additional client PID 1234 exited during the 5-second startup check"
got := summarizeControlError(output, errors.New("exit status 1"))
if !strings.Contains(got, "\u63d0\u524d\u9000\u51fa") {
t.Fatalf("client exit summary = %q", got)
}
if strings.Contains(got, "\u6570\u636e\u5e93") {
t.Fatalf("client exit was mislabeled as database failure: %q", got)
}
}
func TestSummarizeControlErrorRecognizesRealMySQLFailure(t *testing.T) {
got := summarizeControlError("FAILED: mysql is not reachable: connection refused", errors.New("exit status 1"))
if !strings.Contains(got, "\u6570\u636e\u5e93") {
t.Fatalf("mysql summary = %q", got)
}
}
+689
View File
@@ -0,0 +1,689 @@
// Command logtool 是 dnfbridge 加密日志的运维侧工具(运营本机使用)。
//
// 服务端自 2026-09-30 起**所有日志落盘即密文**,采用公钥信封:服务端只带收件人**公钥**,
// 私钥只在你本机。因此排障流程是:把日志目录拉回本机 → 用 logtool 解密检索。
//
// 生成密钥对(只做一次;私钥绝不放上服务器、绝不进分发包):
//
// logtool keygen -o D:\dnf-keys
// → D:\dnf-keys\log-public.key 随服务端包分发
// → D:\dnf-keys\log-private.key 只留本机
//
// 读日志(把服务器上的 runtime\logs 整个拉下来即可,工具会递归找全部密文分片):
//
// logtool list -dir logs -key D:\dnf-keys\log-private.key
// logtool verify -dir logs -key ...
// logtool tail -n 200 -dir logs -key ...
// logtool grep "monster-death-blocked" -dir logs -key ... # 内存解密,不落明文
// logtool grep -i "conflicting" -n -dir logs -key ...
// logtool decrypt <分片> -o out -key ... # 会落明文,用完请删
//
// 服务端里的公钥是编译期固化的(服务端使用方无感,不需要任何密钥文件),
// 换机器/换版本前先确认手上私钥配对成功:
//
// logtool keycheck -key D:\dnf-keys\log-private.key
// logtool keycheck -key ... -dir logs # 顺便列出历史分片各由哪把钥匙加密
//
// 私钥来源:-key 参数 > DNFBRIDGE_LOG_PRIVATE_KEY_FILE 环境变量 > keys/log-private.key。
package main
import (
"bufio"
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"longheng.io/server/internal/platform/logcrypt"
"longheng.io/server/internal/platform/logkey"
)
const usage = `logtool - read dnfbridge encrypted logs (public-key envelope)
usage:
logtool keygen -o DIR [--force]
logtool keycheck [-key PRIVATE_KEY] [-dir DIR|FILE]
logtool list [-dir DIR|FILE] [-key PRIVATE_KEY]
logtool verify [-dir DIR|FILE] [-key PRIVATE_KEY]
logtool tail [-n N] [-dir DIR|FILE] [-key PRIVATE_KEY]
logtool grep [-i] [-n] [-dir DIR|FILE] [-key PRIVATE_KEY] PATTERN
logtool decrypt [-o OUTDIR] [-key PRIVATE_KEY] FILE...
notes:
* -dir 可以是目录(递归扫描全部 *.enc 分片)或单个分片文件。
* 选项写在被检索模式前后都可以:grep "x" -dir logs 与 grep -dir logs "x" 等价。
模式本身以 - 开头时用 -- 分隔,例如 grep -- "-dir" -dir logs。
* grep / tail / verify 全部在内存中解密,不向磁盘写任何明文。
* decrypt 会写出明文文件,仅在你确实需要拿文本工具二次处理时使用。
* keycheck 用来确认"手上私钥"与"服务端版本里固化的公钥"配对,避免拉回日志才发现解不开。
* 私钥丢失 = 全部历史日志永久不可读;请单独备份,且不要放进分发包。
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
command := os.Args[1]
args := os.Args[2:]
switch command {
case "keygen":
os.Exit(runKeygen(args))
case "keycheck":
os.Exit(runKeycheck(args))
case "list":
os.Exit(runList(args))
case "tail":
os.Exit(runTail(args))
case "grep":
os.Exit(runGrep(args))
case "verify":
os.Exit(runVerify(args))
case "decrypt":
os.Exit(runDecrypt(args))
case "-h", "--help", "help":
fmt.Fprint(os.Stdout, usage)
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", command, usage)
os.Exit(2)
}
}
func newFlagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
return fs
}
// parseInterspersed 解析允许"位置参数在前、选项在后"的命令行。
// 标准库 flag 遇到第一个非选项参数就停止解析,而 `logtool grep PATTERN -dir logs`
// 这种写法(也是本工具文档里的示例)会让 -dir/-key 被当成模式的一部分,
// 于是悄悄退回默认路径。这里先把选项及其取值整体挪到前面再交给 flag 包。
// valueFlags 必须登记所有"要吞掉下一个 token"的选项;布尔选项不需要登记。
func parseInterspersed(fs *flag.FlagSet, args []string, valueFlags ...string) error {
takesValue := make(map[string]bool, len(valueFlags))
for _, name := range valueFlags {
takesValue[name] = true
}
return fs.Parse(permuteFlags(args, takesValue))
}
func permuteFlags(args []string, takesValue map[string]bool) []string {
flags := make([]string, 0, len(args))
positional := make([]string, 0, len(args))
separated := false // 原命令行是否出现过 --:出现过就必须把分隔符一起搬到新位置
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" { // 显式分隔符:其后一律视为位置参数(模式本身以 - 开头时用)
positional = append(positional, args[i+1:]...)
separated = true
break
}
if !isFlagToken(arg) {
positional = append(positional, arg)
continue
}
flags = append(flags, arg)
hasInlineValue := strings.ContainsRune(arg, '=')
if takesValue[flagTokenName(arg)] && !hasInlineValue && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
if separated {
// 分隔符必须留着:否则搬到后面的 "-dir" 这类位置参数会被 flag 包重新当成选项。
flags = append(flags, "--")
}
return append(flags, positional...)
}
func isFlagToken(arg string) bool {
return len(arg) > 1 && arg[0] == '-' && arg != "-"
}
func flagTokenName(arg string) string {
name := strings.TrimLeft(arg, "-")
if idx := strings.IndexByte(name, '='); idx >= 0 {
name = name[:idx]
}
return name
}
// runKeygen 生成密钥对。这是**唯一**生成密钥的入口:服务端从不自动生成。
func runKeygen(args []string) int {
fs := newFlagSet("keygen")
out := fs.String("o", "", "output directory (required)")
force := fs.Bool("force", false, "overwrite existing key files")
if err := parseInterspersed(fs, args, "o"); err != nil {
return 2
}
if strings.TrimSpace(*out) == "" {
fmt.Fprintln(os.Stderr, "keygen requires -o DIR")
return 2
}
publicPath, privatePath, keyID, err := logcrypt.WriteKeyPair(*out, *force)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf("key id : %s\n", keyID)
fmt.Printf("public key : %s <- 随服务端包分发(只放公钥)\n", publicPath)
fmt.Printf("private key : %s <- 只留本机,切勿放进分发包\n", privatePath)
fmt.Println()
fmt.Println("服务端放置:把公钥放到 <runtime>\\keys\\log-public.key(或用 DNFBRIDGE_LOG_PUBLIC_KEY_FILE 指定)。")
fmt.Println("你自己的服务器如需 GM 记录查询本机回读,可另放私钥;分发包里绝不放。")
return 0
}
// resolveKey 依次尝试 -key、DNFBRIDGE_LOG_PRIVATE_KEY_FILE、默认相对路径。
func resolveKey(explicit string) ([]byte, error) {
key, _, err := resolveKeyPath(explicit)
return key, err
}
// resolveKeyPath 与 resolveKey 等价,另外回报命中的私钥路径,供 keycheck 显示。
func resolveKeyPath(explicit string) ([]byte, string, error) {
candidates := keyCandidates(explicit)
var lastErr error
for _, path := range candidates {
key, err := logcrypt.LoadPrivateKey(path)
if err == nil {
return key, path, nil
}
lastErr = fmt.Errorf("%s: %w", path, err)
}
return nil, "", fmt.Errorf("no usable private key (tried %s): %w", strings.Join(candidates, ", "), lastErr)
}
func keyCandidates(explicit string) []string {
candidates := make([]string, 0, 3)
if strings.TrimSpace(explicit) != "" {
candidates = append(candidates, explicit)
}
if fromEnv := strings.TrimSpace(os.Getenv("DNFBRIDGE_LOG_PRIVATE_KEY_FILE")); fromEnv != "" {
candidates = append(candidates, fromEnv)
}
return append(candidates, filepath.Join("keys", logcrypt.PrivateKeyFileName))
}
// keyHeaderProbeBytes 是探测分片 keyID 时读取的文件头长度:容器头不到 128 字节,
// 读 256 字节足够,不必把上百 MB 的分片整个读进内存。
const keyHeaderProbeBytes = 256
// runKeycheck 回答运维最怕踩的两个问题:
// 1. 我手上这把私钥,和这个服务端版本里固化的公钥是同一对吗?(不是的话,日志拉回来也解不开)
// 2. 手上这堆历史分片,分别是被哪把钥匙加密的?(轮换过密钥时一眼看出来)
func runKeycheck(args []string) int {
fs := newFlagSet("keycheck")
dir := fs.String("dir", "", "optional: log directory, to list which key encrypted the history")
keyFile := fs.String("key", "", "private key file")
if err := parseInterspersed(fs, args, "dir", "key"); err != nil {
return 2
}
recipient, err := logkey.Recipient()
if err != nil {
fmt.Fprintf(os.Stderr, "baked-in public key is unusable: %v\n", err)
return 1
}
bakedKeyID := recipient.KeyID()
fmt.Printf("服务端固化公钥 : %s\n", logkey.PublicKeyBase64)
fmt.Printf("固化 keyID : %s\n", bakedKeyID)
key, keyPath, keyErr := resolveKeyPath(*keyFile)
if keyErr != nil {
fmt.Fprintln(os.Stderr, keyErr)
return 1
}
keyID, idErr := logcrypt.PrivateKeyKeyID(key)
if idErr != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", keyPath, idErr)
return 1
}
fmt.Printf("私钥 : %s\n", keyPath)
fmt.Printf("私钥 keyID : %s\n", keyID)
status := 0
if keyID == bakedKeyID {
fmt.Println("配对结果 : MATCH —— 这把私钥能解开本版本服务端写出的全部日志。")
} else {
fmt.Println("配对结果 : MISMATCH —— 不是同一对密钥,日志拉回来也解不开。")
status = 1
}
if strings.TrimSpace(*dir) == "" {
return status
}
target, targetErr := resolveTarget(*dir)
if targetErr != nil {
fmt.Fprintln(os.Stderr, targetErr)
return 1
}
files, listErr := collectContainers(target)
if listErr != nil {
fmt.Fprintln(os.Stderr, listErr)
return 1
}
perKey := map[string]int{}
other := 0
for _, file := range files {
head, readErr := readFileHead(file, keyHeaderProbeBytes)
if readErr != nil {
other++
continue
}
id, idErr := logcrypt.ContainerKeyID(head)
if idErr != nil {
other++
continue
}
perKey[id]++
}
fmt.Printf("\n历史分片 : %d 个(%s)\n", len(files), target)
if len(perKey) == 0 && other == 0 {
fmt.Println(" 目录下没有 *.enc 分片")
}
for _, id := range sortedKeys(perKey) {
note := " <= 与本版本固化公钥不同,需要那一版对应的私钥"
if id == bakedKeyID {
note = " <= 本版本固化公钥"
}
fmt.Printf(" key-id=%s %5d 个分片%s\n", id, perKey[id], note)
}
if other > 0 {
fmt.Printf(" (另有 %d 个文件读不到或不是 logcrypt 容器)\n", other)
}
return status
}
func sortedKeys(counts map[string]int) []string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// readFileHead 只读文件开头一段,用于探测容器头。
func readFileHead(path string, limit int) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
buf := make([]byte, limit)
n, readErr := io.ReadFull(file, buf)
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
return nil, readErr
}
return buf[:n], nil
}
// collectContainers 收集待处理的密文分片:目标可以是文件或目录(目录递归)。
func collectContainers(target string) ([]string, error) {
trimmed := strings.TrimSpace(target)
if trimmed == "" {
return nil, fmt.Errorf("no log directory given (use -dir)")
}
info, err := os.Stat(trimmed)
if err != nil {
return nil, err
}
if !info.IsDir() {
return []string{trimmed}, nil
}
var out []string
walkErr := filepath.WalkDir(trimmed, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil // 单个文件不可读不该中断整次扫描
}
if entry.IsDir() {
return nil
}
if strings.HasSuffix(entry.Name(), ".enc") {
out = append(out, path)
}
return nil
})
if walkErr != nil {
return nil, walkErr
}
// 目录名是 YYYYMMDD,字典序即时间序;同目录内再按文件名排序。
sort.Strings(out)
if len(out) == 0 {
return nil, fmt.Errorf("no encrypted log containers under %s", trimmed)
}
return out, nil
}
func resolveTarget(explicit string) (string, error) {
if strings.TrimSpace(explicit) != "" {
return explicit, nil
}
fallback := filepath.Join("logs", "packet")
if _, err := os.Stat(fallback); err == nil {
return fallback, nil
}
if _, err := os.Stat("logs"); err == nil {
return "logs", nil
}
return "", fmt.Errorf("cannot locate the encrypted log directory; pass -dir")
}
// eachPlainLine 逐个分片解密并按行回调。一次只持有一个分片,避免把整棵日志树读进内存。
func eachPlainLine(target string, key []byte, fn func(file string, line string) bool) error {
files, err := collectContainers(target)
if err != nil {
return err
}
for _, file := range files {
raw, readErr := os.ReadFile(file)
if readErr != nil {
return readErr
}
plain, _, truncated, decErr := logcrypt.DecryptAll(key, raw)
if decErr != nil {
return fmt.Errorf("%s: %w", file, decErr)
}
if truncated {
fmt.Fprintf(os.Stderr, "warning: %s has a truncated trailing record; earlier records are intact\n", filepath.Base(file))
}
scanner := bufio.NewScanner(strings.NewReader(string(plain)))
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for scanner.Scan() {
if !fn(file, scanner.Text()) {
return nil
}
}
}
return nil
}
func runList(args []string) int {
fs := newFlagSet("list")
dir := fs.String("dir", "", "log directory or single container")
keyFile := fs.String("key", "", "private key file")
if err := parseInterspersed(fs, args, "dir", "key"); err != nil {
return 2
}
if _, err := resolveKey(*keyFile); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
target, err := resolveTarget(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
files, err := collectContainers(target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
// 按通道分组显示,便于确认"每一类都被加密了"。
groups := map[string][]string{}
var total int64
for _, file := range files {
relative, relErr := filepath.Rel(target, file)
if relErr != nil {
relative = file
}
group := strings.Split(filepath.ToSlash(relative), "/")[0]
if group == filepath.Base(relative) {
group = "(root)"
}
groups[group] = append(groups[group], file)
if info, statErr := os.Stat(file); statErr == nil {
total += info.Size()
}
}
names := make([]string, 0, len(groups))
for name := range groups {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
var groupBytes int64
for _, file := range groups[name] {
if info, statErr := os.Stat(file); statErr == nil {
groupBytes += info.Size()
}
}
fmt.Printf("%-12s %3d file(s) %9.2f MB\n", name, len(groups[name]), float64(groupBytes)/(1<<20))
for _, file := range groups[name] {
info, _ := os.Stat(file)
size := int64(0)
if info != nil {
size = info.Size()
}
fmt.Printf(" %-56s %8.2f MB\n", filepath.Base(filepath.Dir(file))+"/"+filepath.Base(file), float64(size)/(1<<20))
}
}
fmt.Printf("\ntotal %d container(s), %.2f MB\n", len(files), float64(total)/(1<<20))
return 0
}
func runTail(args []string) int {
fs := newFlagSet("tail")
dir := fs.String("dir", "", "log directory or single container")
keyFile := fs.String("key", "", "private key file")
count := fs.Int("n", 100, "number of lines")
if err := parseInterspersed(fs, args, "dir", "key", "n"); err != nil {
return 2
}
key, err := resolveKey(*keyFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
target, err := resolveTarget(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
ring := make([]string, 0, *count)
if err := eachPlainLine(target, key, func(_ string, line string) bool {
if len(ring) == *count {
copy(ring, ring[1:])
ring[len(ring)-1] = line
return true
}
ring = append(ring, line)
return true
}); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
out := bufio.NewWriter(os.Stdout)
defer func() { _ = out.Flush() }()
for _, line := range ring {
fmt.Fprintln(out, line)
}
return 0
}
func runGrep(args []string) int {
fs := newFlagSet("grep")
dir := fs.String("dir", "", "log directory or single container")
keyFile := fs.String("key", "", "private key file")
ignoreCase := fs.Bool("i", false, "case-insensitive")
lineNumbers := fs.Bool("n", false, "show line numbers")
showFile := fs.Bool("f", false, "prefix each line with its container name")
if err := parseInterspersed(fs, args, "dir", "key"); err != nil {
return 2
}
rest := fs.Args()
if len(rest) == 0 {
fmt.Fprintln(os.Stderr, "grep requires a PATTERN")
return 2
}
needle := strings.Join(rest, " ")
if *ignoreCase {
needle = strings.ToLower(needle)
}
key, err := resolveKey(*keyFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
target, err := resolveTarget(*dir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
out := bufio.NewWriter(os.Stdout)
defer func() { _ = out.Flush() }()
lineNo := 0
matches := 0
if err := eachPlainLine(target, key, func(file string, line string) bool {
lineNo++
haystack := line
if *ignoreCase {
haystack = strings.ToLower(haystack)
}
if !strings.Contains(haystack, needle) {
return true
}
matches++
prefix := ""
if *showFile {
prefix = filepath.Base(file) + ":"
}
if *lineNumbers {
fmt.Fprintf(out, "%s%d:%s\n", prefix, lineNo, line)
} else {
fmt.Fprintf(out, "%s%s\n", prefix, line)
}
return true
}); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Fprintf(os.Stderr, "\n%d matching line(s)\n", matches)
if matches == 0 {
return 1
}
return 0
}
func runVerify(args []string) int {
fs := newFlagSet("verify")
dir := fs.String("dir", "", "log directory or single container")
keyFile := fs.String("key", "", "private key file")
if err := parseInterspersed(fs, args, "dir", "key"); err != nil {
return 2
}
key, err := resolveKey(*keyFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
targets := fs.Args()
if len(targets) == 0 {
resolved, resolveErr := resolveTarget(*dir)
if resolveErr != nil {
fmt.Fprintln(os.Stderr, resolveErr)
return 1
}
files, listErr := collectContainers(resolved)
if listErr != nil {
fmt.Fprintln(os.Stderr, listErr)
return 1
}
targets = files
}
bad := 0
for _, file := range targets {
raw, readErr := os.ReadFile(file)
if readErr != nil {
fmt.Printf("FAIL %s: %v\n", file, readErr)
bad++
continue
}
keyID, idErr := logcrypt.ContainerKeyID(raw)
if idErr != nil {
fmt.Printf("FAIL %s: %v\n", file, idErr)
bad++
continue
}
plain, records, truncated, decErr := logcrypt.DecryptAll(key, raw)
if decErr != nil {
fmt.Printf("FAIL %-58s key-id=%s: %v\n", filepath.Base(file), keyID, decErr)
bad++
continue
}
status := "ok"
if truncated {
status = "truncated-tail"
}
fmt.Printf("%-15s %-58s key-id=%s records=%-7d bytes=%d\n",
status, filepath.Base(file), keyID, records, len(plain))
}
if bad > 0 {
fmt.Fprintf(os.Stderr, "\n%d file(s) failed\n", bad)
return 1
}
return 0
}
func runDecrypt(args []string) int {
fs := newFlagSet("decrypt")
keyFile := fs.String("key", "", "private key file")
outDir := fs.String("o", "", "output directory for plaintext (required)")
if err := parseInterspersed(fs, args, "key", "o"); err != nil {
return 2
}
targets := fs.Args()
if len(targets) == 0 || strings.TrimSpace(*outDir) == "" {
fmt.Fprintln(os.Stderr, "decrypt requires FILE... and -o DIR")
fmt.Fprintln(os.Stderr, "warning: this writes PLAINTEXT to disk; prefer `logtool grep`")
return 2
}
key, err := resolveKey(*keyFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := os.MkdirAll(*outDir, 0o750); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
bad := 0
for _, file := range targets {
raw, readErr := os.ReadFile(file)
if readErr != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", file, readErr)
bad++
continue
}
plain, records, truncated, decErr := logcrypt.DecryptAll(key, raw)
if decErr != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", file, decErr)
bad++
continue
}
name := strings.TrimSuffix(filepath.Base(file), ".enc")
if name == filepath.Base(file) {
name += ".txt"
}
destination := filepath.Join(*outDir, name)
if writeErr := os.WriteFile(destination, plain, 0o600); writeErr != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", destination, writeErr)
bad++
continue
}
note := ""
if truncated {
note = " (trailing record dropped as truncated)"
}
fmt.Printf("%s -> %s records=%d bytes=%d%s\n", filepath.Base(file), destination, records, len(plain), note)
}
if bad > 0 {
return 1
}
return 0
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"flag"
"reflect"
"testing"
)
// grepFlags 复刻 runGrep 的选项表,用来验证"模式在前、选项在后"也能被正确解析。
func grepFlags() (fs *flag.FlagSet, dir, keyFile *string, ignoreCase, lineNumbers, showFile *bool) {
fs = newFlagSet("grep-test")
dir = fs.String("dir", "", "")
keyFile = fs.String("key", "", "")
ignoreCase = fs.Bool("i", false, "")
lineNumbers = fs.Bool("n", false, "")
showFile = fs.Bool("f", false, "")
return fs, dir, keyFile, ignoreCase, lineNumbers, showFile
}
// 这是真实踩过的坑:标准库 flag 遇到第一个非选项参数就停止解析,
// 于是文档里的 `logtool grep PATTERN -dir logs -key K` 会让 -dir/-key 变成模式的一部分,
// 悄悄退回默认路径(表现为"读了错误的密钥/目录",而不是报错)。
func TestGrepAcceptsOptionsAfterPattern(t *testing.T) {
fs, dir, keyFile, _, lineNumbers, _ := grepFlags()
err := parseInterspersed(fs, []string{"monster-death-blocked", "-dir", `D:\logs`, "-n", "-key", `D:\k\p.key`}, "dir", "key")
if err != nil {
t.Fatalf("parse: %v", err)
}
if *dir != `D:\logs` {
t.Errorf("dir = %q, want D:\\logs", *dir)
}
if *keyFile != `D:\k\p.key` {
t.Errorf("key = %q, want D:\\k\\p.key", *keyFile)
}
if !*lineNumbers {
t.Error("-n after the pattern was not honoured")
}
if want := []string{"monster-death-blocked"}; !reflect.DeepEqual(fs.Args(), want) {
t.Errorf("pattern = %v, want %v", fs.Args(), want)
}
}
func TestGrepAcceptsOptionsBeforePattern(t *testing.T) {
fs, dir, keyFile, _, _, _ := grepFlags()
err := parseInterspersed(fs, []string{"-dir", "logs", "-key", "k", "key=name"}, "dir", "key")
if err != nil {
t.Fatalf("parse: %v", err)
}
if *dir != "logs" || *keyFile != "k" {
t.Fatalf("dir=%q key=%q", *dir, *keyFile)
}
if want := []string{"key=name"}; !reflect.DeepEqual(fs.Args(), want) {
t.Errorf("pattern = %v, want %v", fs.Args(), want)
}
}
// 模式里含空格时按位置参数原样传下来(runGrep 再拼回去),不能被当成选项。
func TestGrepKeepsMultiTokenPattern(t *testing.T) {
fs, _, _, _, _, _ := grepFlags()
if err := parseInterspersed(fs, []string{"-dir", "logs", "owner", "not", "ready"}, "dir", "key"); err != nil {
t.Fatalf("parse: %v", err)
}
if want := []string{"owner", "not", "ready"}; !reflect.DeepEqual(fs.Args(), want) {
t.Errorf("pattern = %v, want %v", fs.Args(), want)
}
}
// -- 之后一律是位置参数:想搜 "-dir" 这种以短横线开头的字面量时必须能搜,
// 而且分隔符本身不能被吞掉(否则搬后面的位置参数又会被 flag 包当成选项)。
func TestGrepSeparatorTreatsRestAsPattern(t *testing.T) {
fs, dir, _, _, _, _ := grepFlags()
if err := parseInterspersed(fs, []string{"-dir", "logs", "--", "-n", "-dir"}, "dir", "key"); err != nil {
t.Fatalf("parse: %v", err)
}
if *dir != "logs" {
t.Errorf("dir = %q, want logs", *dir)
}
if want := []string{"-n", "-dir"}; !reflect.DeepEqual(fs.Args(), want) {
t.Errorf("pattern = %v, want %v", fs.Args(), want)
}
}
func TestGrepInlineFlagValueStillParsed(t *testing.T) {
fs, dir, _, _, _, _ := grepFlags()
if err := parseInterspersed(fs, []string{"needle", "-dir=logs"}, "dir", "key"); err != nil {
t.Fatalf("parse: %v", err)
}
if *dir != "logs" {
t.Errorf("dir = %q, want logs", *dir)
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "fmt"
func main() {
fmt.Println("DNF90PublicClient is a Windows client connector")
}
@@ -0,0 +1,281 @@
//go:build windows
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const clientArgument = "99?127.0.0.1?7001?0?de509f65e9ccaae621cb7278fc2b8e6c?01?1?0?0?0?0?1?9n2b1c8r3w7y?0?0?19847"
var (
sshTargetPattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,255}$`)
remotePathPattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]{1,511}$`)
)
type connectorConfig struct {
SchemaVersion int `json:"schemaVersion"`
ClientDirectory string `json:"clientDirectory"`
SSHTarget string `json:"sshTarget"`
RemoteControlPath string `json:"remoteControlPath"`
RegionID int `json:"regionId"`
RegionName string `json:"regionName"`
MultiInstance bool `json:"multiInstance"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "连接失败:", err)
fmt.Fprintln(os.Stderr, "按回车键退出。")
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
os.Exit(1)
}
}
func run() error {
configPath := flag.String("config", "", "connector configuration JSON")
flag.Parse()
path, err := resolveConfigPath(*configPath)
if err != nil {
return err
}
cfg, err := loadConnectorConfig(path)
if err != nil {
return err
}
ssh, err := exec.LookPath("ssh.exe")
if err != nil {
return errors.New("找不到 Windows OpenSSH 客户端 ssh.exe;请在 Windows 可选功能中安装 OpenSSH 客户端")
}
tunnel, err := ensureTunnel(ssh, cfg.SSHTarget)
if err != nil {
return err
}
success := false
defer func() {
if !success && tunnel != nil {
_ = tunnel.Process.Kill()
_, _ = tunnel.Process.Wait()
}
}()
username, password, err := readCredentials()
if err != nil {
return err
}
executable, err := findClientExecutable(cfg.ClientDirectory)
if err != nil {
return err
}
// Bind the WireGuard source address before DNF can open a game socket. The
// remote Windows PID is not a Linux process identity, so zero is a valid
// pre-launch audit value for this route.
if err := bindRemoteAccount(ssh, cfg, username, password, 0); err != nil {
return err
}
client := exec.Command(executable, clientArgument)
client.Dir = cfg.ClientDirectory
multi := "0"
if cfg.MultiInstance {
multi = "1"
}
client.Env = mergeEnvironment(os.Environ(), map[string]string{
"DNF_HOOK_CREATE": "1",
"DNF_MULTI_CLIENT": multi,
"DNF_REGION_ID": strconv.Itoa(cfg.RegionID),
"DNF_REGION_NAME": cfg.RegionName,
})
client.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
if err := client.Start(); err != nil {
return fmt.Errorf("启动游戏客户端:%w", err)
}
pid := client.Process.Pid
password = ""
if err := client.Process.Release(); err != nil {
return fmt.Errorf("释放游戏进程句柄:%w", err)
}
if tunnel != nil {
// The stock client may hand its window to a child process shortly after
// launch, so the connector cannot reliably wait on the initial PID. Keep
// this key-authenticated loopback forward alive after the connector exits;
// a later connector invocation reuses the listener. It carries only the
// local channel port, never the admin endpoint or MySQL.
if err := tunnel.Process.Release(); err != nil {
return fmt.Errorf("保留 SSH 频道转发:%w", err)
}
}
success = true
fmt.Printf("登录已提交:账号=%s,客户端 PID=%d。\n", username, pid)
return nil
}
func resolveConfigPath(explicit string) (string, error) {
if strings.TrimSpace(explicit) != "" {
return filepath.Abs(explicit)
}
executable, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(executable), "public-client.json"), nil
}
func loadConnectorConfig(path string) (connectorConfig, error) {
var cfg connectorConfig
data, err := os.ReadFile(path)
if err != nil {
return cfg, fmt.Errorf("读取公网客户端配置 %s:%w", path, err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&cfg); err != nil {
return cfg, fmt.Errorf("解析公网客户端配置:%w", err)
}
if cfg.SchemaVersion != 1 {
return cfg, errors.New("public-client.json 的 schemaVersion 必须是 1")
}
cfg.ClientDirectory = strings.TrimSpace(cfg.ClientDirectory)
cfg.SSHTarget = strings.TrimSpace(cfg.SSHTarget)
cfg.RemoteControlPath = strings.TrimSpace(cfg.RemoteControlPath)
cfg.RegionName = strings.TrimSpace(cfg.RegionName)
if cfg.ClientDirectory == "" {
return cfg, errors.New("请先在 public-client.json 填写 clientDirectory")
}
if !sshTargetPattern.MatchString(cfg.SSHTarget) {
return cfg, errors.New("sshTarget 格式无效,应为 user@公网地址或 SSH 配置别名")
}
if !remotePathPattern.MatchString(cfg.RemoteControlPath) {
return cfg, errors.New("remoteControlPath 必须是无空格的 Linux 绝对路径")
}
if cfg.RegionID < 1 || cfg.RegionID >= 250 {
return cfg, errors.New("regionId 必须在 1 到 249 之间")
}
return cfg, nil
}
func ensureTunnel(ssh, target string) (*exec.Cmd, error) {
if connection, err := net.DialTimeout("tcp", "127.0.0.1:7001", 300*time.Millisecond); err == nil {
_ = connection.Close()
return nil, nil
}
cmd := exec.Command(ssh,
"-N", "-T", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=30", "-L", "127.0.0.1:7001:127.0.0.1:7001", target,
)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 SSH 转发:%w", err)
}
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if connection, err := net.DialTimeout("tcp", "127.0.0.1:7001", 300*time.Millisecond); err == nil {
_ = connection.Close()
return cmd, nil
}
time.Sleep(200 * time.Millisecond)
}
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
return nil, fmt.Errorf("SSH 转发未就绪:%s", strings.TrimSpace(stderr.String()))
}
func readCredentials() (string, string, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print("账号:")
username, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", "", err
}
username = strings.TrimSpace(username)
if username == "" {
return "", "", errors.New("账号不能为空")
}
fmt.Print("密码:")
handle, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
if err != nil {
return "", "", err
}
var mode uint32
if err := windows.GetConsoleMode(handle, &mode); err != nil {
return "", "", err
}
if err := windows.SetConsoleMode(handle, mode&^windows.ENABLE_ECHO_INPUT); err != nil {
return "", "", err
}
password, readErr := reader.ReadString('\n')
restoreErr := windows.SetConsoleMode(handle, mode)
fmt.Println()
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", "", readErr
}
if restoreErr != nil {
return "", "", restoreErr
}
password = strings.TrimSuffix(strings.TrimSuffix(password, "\n"), "\r")
if password == "" {
return "", "", errors.New("密码不能为空")
}
return username, password, nil
}
func findClientExecutable(directory string) (string, error) {
directory, err := filepath.Abs(directory)
if err != nil {
return "", err
}
for _, name := range []string{"DNF.exe", "NoPack.exe"} {
path := filepath.Join(directory, name)
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
return path, nil
}
}
return "", fmt.Errorf("客户端目录中没有 DNF.exe 或 NoPack.exe:%s", directory)
}
func bindRemoteAccount(ssh string, cfg connectorConfig, username, password string, pid int) error {
cmd := exec.Command(ssh, "-T", "-o", "BatchMode=yes", cfg.SSHTarget,
cfg.RemoteControlPath, "account", "bind-client", "--username", username,
"--pid", strconv.Itoa(pid), "--password-stdin",
)
cmd.Stdin = strings.NewReader(password + "\n")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("服务端账号校验失败:%s", strings.TrimSpace(string(output)))
}
return nil
}
func mergeEnvironment(base []string, overrides map[string]string) []string {
result := make([]string, 0, len(base)+len(overrides))
for _, entry := range base {
key, _, found := strings.Cut(entry, "=")
if found {
if _, overridden := overrides[strings.ToUpper(key)]; overridden {
continue
}
}
result = append(result, entry)
}
for key, value := range overrides {
result = append(result, key+"="+value)
}
return result
}
+604
View File
@@ -0,0 +1,604 @@
package main
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
mysqldriver "github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
mysqlrepo "longheng.io/server/internal/modules/dnf/repository/mysql"
sqliterepo "longheng.io/server/internal/modules/dnf/repository/sqlite"
)
const (
bundleSchemaVersion = 1
loginAccountTable = "dnf_login_accounts"
)
var sqlIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type options struct {
root string
output string
accountID string
characterID string
username string
passwordIn bool
}
type instanceFile struct {
Database struct {
Mode string `json:"mode"`
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
} `json:"database"`
Game struct {
ShardID string `json:"shardId"`
} `json:"game"`
}
type bundleInfo struct {
SchemaVersion int `json:"schemaVersion"`
GeneratedAt time.Time `json:"generatedAt"`
Username string `json:"username"`
AccountID string `json:"accountId"`
CharacterID string `json:"characterId"`
CharacterName string `json:"characterName"`
CharacterLevel int `json:"characterLevel"`
AccountCount int `json:"accountCount"`
CharacterCount int `json:"characterCount"`
RecordCount int `json:"recordCount"`
}
type txDatabase struct {
tx *sql.Tx
}
func (d txDatabase) ExecContext(context.Context, string, ...any) (sql.Result, error) {
return nil, errors.New("save export transaction is read-only")
}
func (d txDatabase) QueryContext(ctx context.Context, query string, args ...any) (mysqlrepo.SQLRows, error) {
return d.tx.QueryContext(ctx, query, args...)
}
func (d txDatabase) QueryRowContext(ctx context.Context, query string, args ...any) mysqlrepo.SQLRow {
return d.tx.QueryRowContext(ctx, query, args...)
}
func (d txDatabase) PingContext(ctx context.Context) error {
var value int
return d.tx.QueryRowContext(ctx, "SELECT 1").Scan(&value)
}
type recordLoader[T any] interface {
Load(context.Context, string) (T, bool, error)
}
func main() {
var opts options
flag.StringVar(&opts.root, "root", "", "DNF90 project root")
flag.StringVar(&opts.output, "output", "", "output bundle directory under runtime/tmp")
flag.StringVar(&opts.accountID, "account-id", "", "source account ID")
flag.StringVar(&opts.characterID, "character-id", "", "source character ID")
flag.StringVar(&opts.username, "username", "", "new public test username")
flag.BoolVar(&opts.passwordIn, "password-stdin", false, "read the new public test password from stdin")
flag.Parse()
if err := run(context.Background(), opts, os.Stdin); err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(1)
}
}
func run(ctx context.Context, opts options, stdin io.Reader) error {
root, err := resolveRoot(opts.root)
if err != nil {
return err
}
accountID := strings.TrimSpace(opts.accountID)
characterID := strings.TrimSpace(opts.characterID)
if accountID == "" || characterID == "" {
return errors.New("account-id and character-id are required")
}
username, err := normalizeUsername(opts.username)
if err != nil {
return err
}
if !opts.passwordIn {
return errors.New("password-stdin is required")
}
password, err := readPassword(stdin)
if err != nil {
return err
}
if err := validatePassword(password); err != nil {
return err
}
output, err := resolveOutput(root, opts.output)
if err != nil {
return err
}
if _, err := os.Stat(output); err == nil {
return fmt.Errorf("output already exists: %s", output)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect output %s: %w", output, err)
}
cfg, err := readInstance(filepath.Join(root, "runtime", "config", "instance.json"))
if err != nil {
return err
}
state, character, err := exportState(ctx, cfg, accountID, characterID)
if err != nil {
return err
}
tmpRoot := filepath.Join(root, "runtime", "tmp")
if err := os.MkdirAll(tmpRoot, 0o755); err != nil {
return fmt.Errorf("create runtime tmp: %w", err)
}
stage, err := os.MkdirTemp(tmpRoot, ".save-bundle-")
if err != nil {
return fmt.Errorf("create save bundle staging directory: %w", err)
}
defer os.RemoveAll(stage)
databasePath := filepath.Join(stage, "dnf90.db")
if err := writeSQLiteBundle(ctx, databasePath, state, accountID, username, password); err != nil {
return err
}
info := bundleInfo{
SchemaVersion: bundleSchemaVersion,
GeneratedAt: time.Now().UTC(),
Username: username,
AccountID: accountID,
CharacterID: characterID,
CharacterName: character.Name,
CharacterLevel: character.Level,
AccountCount: len(state.Accounts),
CharacterCount: len(state.Characters),
RecordCount: stateRecordCount(state),
}
if err := writeJSON(filepath.Join(stage, "save-info.json"), info); err != nil {
return err
}
if err := os.WriteFile(
filepath.Join(stage, "guide.txt"),
[]byte(buildGuide(info, password)),
0o600,
); err != nil {
return fmt.Errorf("write save guide: %w", err)
}
if err := os.Rename(stage, output); err != nil {
return fmt.Errorf("publish save bundle: %w", err)
}
fmt.Println("Save bundle created.")
fmt.Println("Directory:", output)
fmt.Printf("Character: %s (level %d, id %s)\n", character.Name, character.Level, character.CharacterID)
fmt.Printf("Repository records: %d; login accounts: 1\n", info.RecordCount)
fmt.Println("Password was written only to guide.txt and was not printed.")
return nil
}
func resolveRoot(configured string) (string, error) {
configured = strings.TrimSpace(configured)
if configured == "" {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
configured = filepath.Join(cwd, "..")
}
root, err := filepath.Abs(configured)
if err != nil {
return "", fmt.Errorf("resolve project root: %w", err)
}
if _, err := os.Stat(filepath.Join(root, "go-server", "go.mod")); err != nil {
return "", fmt.Errorf("not a DNF90 project root: %s", root)
}
return filepath.Clean(root), nil
}
func resolveOutput(root, configured string) (string, error) {
configured = strings.TrimSpace(configured)
if configured == "" {
return "", errors.New("output is required")
}
if !filepath.IsAbs(configured) {
configured = filepath.Join(root, configured)
}
output, err := filepath.Abs(configured)
if err != nil {
return "", fmt.Errorf("resolve output: %w", err)
}
tmpRoot := filepath.Join(root, "runtime", "tmp")
rel, err := filepath.Rel(tmpRoot, output)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("output must be a child directory of %s", tmpRoot)
}
return filepath.Clean(output), nil
}
func readInstance(path string) (instanceFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return instanceFile{}, fmt.Errorf("read instance configuration: %w", err)
}
var cfg instanceFile
if err := json.Unmarshal(data, &cfg); err != nil {
return instanceFile{}, fmt.Errorf("decode instance configuration: %w", err)
}
if cfg.Database.Mode != "portable" {
return instanceFile{}, fmt.Errorf("source database mode must be portable MySQL, got %q", cfg.Database.Mode)
}
if cfg.Database.Host != "127.0.0.1" && cfg.Database.Host != "localhost" {
return instanceFile{}, errors.New("save export accepts only loopback MySQL")
}
if cfg.Database.Port < 1 || cfg.Database.Port > 65535 ||
!sqlIdentifierPattern.MatchString(cfg.Database.Name) {
return instanceFile{}, errors.New("source database configuration is invalid")
}
return cfg, nil
}
func exportState(ctx context.Context, cfg instanceFile, accountID, characterID string) (memory.State, dnfrepo.CharacterRecord, error) {
driverCfg := mysqldriver.Config{
User: cfg.Database.User,
Passwd: cfg.Database.Password,
Net: "tcp",
Addr: net.JoinHostPort(cfg.Database.Host, strconv.Itoa(cfg.Database.Port)),
DBName: cfg.Database.Name,
ParseTime: true,
Loc: time.UTC,
Timeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
}
database, err := sql.Open("mysql", driverCfg.FormatDSN())
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, fmt.Errorf("open source MySQL: %w", err)
}
defer database.Close()
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
tx, err := database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, fmt.Errorf("begin consistent save snapshot: %w", err)
}
defer tx.Rollback()
group, err := mysqlrepo.NewMySQLGroup(txDatabase{tx: tx}, mysqlrepo.MySQLGroupOptions{
DatabasePlan: dnfrepo.DatabasePlan{
ShardID: strings.TrimSpace(cfg.Game.ShardID),
GroupID: "release-save-export",
WriteDatabases: []string{cfg.Database.Name},
ReadDatabases: []string{cfg.Database.Name},
},
TablePrefix: "dnf",
})
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
account, err := loadRequired(ctx, group.Account, accountID, "account")
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
character, err := loadRequired(ctx, group.Character, characterID, "character")
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if character.AccountID != accountID {
return memory.State{}, dnfrepo.CharacterRecord{}, errors.New("selected character does not belong to selected account")
}
if character.Level != 90 {
return memory.State{}, dnfrepo.CharacterRecord{}, fmt.Errorf("selected character level is %d, want 90", character.Level)
}
if character.Stats["delete_flag"] != 0 {
return memory.State{}, dnfrepo.CharacterRecord{}, errors.New("selected character is deleted")
}
character.Slot = 0
state := memory.State{
Version: memory.StateVersion,
Accounts: []dnfrepo.AccountRecord{account},
Characters: []dnfrepo.CharacterRecord{character},
}
if err := appendOptional(ctx, group.AccountInventory, accountID, "account inventory", &state.AccountInventories); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Inventory, characterID, "inventory", &state.Inventories); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Equipment, characterID, "equipment", &state.Equipment); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Pet, characterID, "pet", &state.Pets); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Quest, characterID, "quest", &state.Quests); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Skill, characterID, "skill", &state.Skills); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.DungeonPermission, characterID, "dungeon permission", &state.DungeonPermissions); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.HellPartyPermission, characterID, "hell-party permission", &state.HellPartyPermissions); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.Mailbox, characterID, "mailbox", &state.Mailboxes); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
if err := appendOptional(ctx, group.EpicBook, accountID, "epic book", &state.EpicBooks); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
scopes, err := listKeys(ctx, tx, "dnf_settings", "scope")
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
accountPrefix := "account:" + accountID + ":"
characterPrefix := "character:" + characterID + ":"
for _, scope := range scopes {
if !strings.HasPrefix(scope, accountPrefix) && !strings.HasPrefix(scope, characterPrefix) {
continue
}
if err := appendOptional(ctx, group.Settings, scope, "settings", &state.Settings); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
}
templateIDs, err := listKeys(ctx, tx, "dnf_packet_templates", "template_id")
if err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
for _, templateID := range templateIDs {
if err := appendOptional(ctx, group.PacketTemplate, templateID, "packet template", &state.PacketTemplates); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, err
}
}
if _, err := memory.NewRuntimeFromState(ctx, state); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, fmt.Errorf("validate exported state: %w", err)
}
if err := tx.Commit(); err != nil {
return memory.State{}, dnfrepo.CharacterRecord{}, fmt.Errorf("finish consistent save snapshot: %w", err)
}
return state, character, nil
}
func loadRequired[T any](ctx context.Context, loader recordLoader[T], key, label string) (T, error) {
record, found, err := loader.Load(ctx, key)
if err != nil {
var zero T
return zero, fmt.Errorf("load %s %q: %w", label, key, err)
}
if !found {
var zero T
return zero, fmt.Errorf("%s %q was not found", label, key)
}
return record, nil
}
func appendOptional[T any](ctx context.Context, loader recordLoader[T], key, label string, target *[]T) error {
record, found, err := loader.Load(ctx, key)
if err != nil {
return fmt.Errorf("load %s %q: %w", label, key, err)
}
if found {
*target = append(*target, record)
}
return nil
}
func listKeys(ctx context.Context, tx *sql.Tx, table, column string) ([]string, error) {
if !sqlIdentifierPattern.MatchString(table) || !sqlIdentifierPattern.MatchString(column) {
return nil, errors.New("unsafe source table or column")
}
rows, err := tx.QueryContext(ctx, "SELECT `"+column+"` FROM `"+table+"` ORDER BY `"+column+"`")
if err != nil {
return nil, fmt.Errorf("list %s keys: %w", table, err)
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("scan %s key: %w", table, err)
}
keys = append(keys, key)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate %s keys: %w", table, err)
}
return keys, nil
}
func writeSQLiteBundle(ctx context.Context, path string, state memory.State, accountID, username, password string) error {
database, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open target SQLite: %w", err)
}
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
closed := false
defer func() {
if !closed {
database.Close()
}
}()
if err := sqliterepo.ReplaceState(ctx, database, state); err != nil {
return err
}
if _, err := database.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS dnf_login_accounts (
account_id TEXT NOT NULL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
last_login_at DATETIME NULL
)`); err != nil {
return fmt.Errorf("create target login schema: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash target test password: %w", err)
}
now := time.Now().UTC()
if _, err := database.ExecContext(ctx, `INSERT INTO dnf_login_accounts
(account_id, username, password_hash, state, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?)`, accountID, username, hash, now, now); err != nil {
return fmt.Errorf("insert target test account: %w", err)
}
if _, err := database.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
return fmt.Errorf("checkpoint target SQLite: %w", err)
}
var journalMode string
if err := database.QueryRowContext(ctx, `PRAGMA journal_mode = DELETE`).Scan(&journalMode); err != nil {
return fmt.Errorf("finalize target SQLite journal: %w", err)
}
if err := database.Close(); err != nil {
return fmt.Errorf("close target SQLite: %w", err)
}
closed = true
return verifySQLiteBundle(ctx, path, state, accountID, username, password)
}
func verifySQLiteBundle(ctx context.Context, path string, state memory.State, accountID, username, password string) error {
database, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer database.Close()
database.SetMaxOpenConns(1)
var integrity string
if err := database.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil {
return fmt.Errorf("check target SQLite integrity: %w", err)
}
if integrity != "ok" {
return fmt.Errorf("target SQLite integrity check returned %q", integrity)
}
var repositoryCount int
if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM dnf_repository_records`).Scan(&repositoryCount); err != nil {
return err
}
if repositoryCount != stateRecordCount(state) {
return fmt.Errorf("target repository record count=%d want=%d", repositoryCount, stateRecordCount(state))
}
var storedAccountID string
var passwordHash []byte
if err := database.QueryRowContext(ctx, `SELECT account_id, password_hash FROM dnf_login_accounts WHERE username = ?`, username).Scan(&storedAccountID, &passwordHash); err != nil {
return fmt.Errorf("verify target test account: %w", err)
}
if storedAccountID != accountID || bcrypt.CompareHashAndPassword(passwordHash, []byte(password)) != nil {
return errors.New("target test account verification failed")
}
return nil
}
func stateRecordCount(state memory.State) int {
return len(state.Accounts) + len(state.AccountInventories) + len(state.Characters) +
len(state.Inventories) + len(state.Equipment) + len(state.Pets) +
len(state.Quests) + len(state.Skills) + len(state.DungeonPermissions) +
len(state.HellPartyPermissions) + len(state.PacketTemplates) + len(state.Settings) +
len(state.Mailboxes) + len(state.EpicBooks)
}
func writeJSON(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
func buildGuide(info bundleInfo, password string) string {
return fmt.Sprintf(`【本存档版先看】内置测试账号与存档
==================================
本发布包已经带一个测试存档,不需要注册,直接在“一键登录.bat”中登录:
账号:%s
密码:%s
角色:%s(%d 级)
请只填写第 1 组账号并点击“登录账号 1”。不要用“注册账号”覆盖测试账号。
下面通用说明里的“首次注册”步骤只适用于不带存档的空白版本,本包请跳过注册。
这个账号密码会随发布包公开,仅用于本地一键端测试,不要用于其他网站或正式服务。
`, info.Username, password, info.CharacterName, info.CharacterLevel)
}
func normalizeUsername(username string) (string, error) {
username = strings.ToLower(strings.TrimSpace(username))
count := utf8.RuneCountInString(username)
if !utf8.ValidString(username) || count < 3 || count > 32 {
return "", errors.New("username must contain 3 to 32 characters")
}
for _, character := range username {
if unicode.IsLetter(character) || unicode.IsDigit(character) ||
character == '_' || character == '-' || character == '.' {
continue
}
return "", errors.New("username contains unsupported characters")
}
return username, nil
}
func validatePassword(password string) error {
if !utf8.ValidString(password) || len(password) < 8 || len(password) > 72 {
return errors.New("password must contain 8 to 72 UTF-8 bytes")
}
for _, character := range password {
if unicode.IsControl(character) {
return errors.New("password must not contain control characters")
}
}
return nil
}
func readPassword(reader io.Reader) (string, error) {
if reader == nil {
return "", errors.New("password input is unavailable")
}
line, err := bufio.NewReader(io.LimitReader(reader, 4096)).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("read password: %w", err)
}
password := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
password = strings.TrimPrefix(password, "\ufeff")
if password == "" {
return "", errors.New("password is empty")
}
return password, nil
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"strings"
"testing"
)
func TestReadPasswordStripsWindowsPipelineBOM(t *testing.T) {
password, err := readPassword(strings.NewReader("\ufefftest123456\r\n"))
if err != nil {
t.Fatal(err)
}
if password != "test123456" {
t.Fatalf("password = %q", password)
}
}
func TestBuildGuideIncludesPublishedTestCredentials(t *testing.T) {
guide := buildGuide(bundleInfo{
Username: "test",
CharacterName: "Test",
CharacterLevel: 90,
}, "test123456")
for _, want := range []string{"账号:test", "密码:test123456", "角色:Test(90 级)"} {
if !strings.Contains(guide, want) {
t.Fatalf("guide is missing %q: %s", want, guide)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
// Command updatemanifest assembles the launcher update package for one release.
//
// It collects the fixed set of files a shipped installation refreshes between
// full packages, copies each one into a flat staging directory under a
// collision-free asset name, and writes server-update.json describing them. A
// GitHub release then carries the staging directory's contents verbatim, and the
// launcher resolves every download from the manifest's own directory.
//
// The manifest is schema version 2: entries carry an install path relative to
// the project root plus the flat release asset name, because GitHub release
// assets have no directories. PVF, MySQL and the commercial client are never
// part of an update package.
package main
import (
"crypto/sha256"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
const manifestName = "server-update.json"
// updateFiles is the complete update payload, deliberately fixed: adding a path
// here is a compatibility decision, not a packaging detail. Paths are relative
// to the project root, forward slashes.
//
// The install side (DNF90Hub 的 update.go,另一个仓库) 只接受落在
// runtime/{bin,assets,templates,patch}/ 之内的路径,加上 changlog.md 这个单文件白
// 名单,超出的一律以"清单中的安装路径不合法"拒收整个更新包。updatePathWhitelist
// 就是那份名单的副本,TestUpdateFilesStayInsideTheHubWhitelist 逐条比对。
//
// 这些路径同时也是安装位置,必须和 cmd/server/control/paths.go 解析的位置一致——
// 否则文件会被装到没人读的地方,留下一份看着像权威、实际陈旧的副本。
var updateFiles = []string{
"runtime/bin/DNF90Control.exe",
"runtime/bin/DNF90Doctor.exe",
"runtime/bin/DNF90Launcher.exe",
"runtime/bin/DNF90Server.exe",
"runtime/assets/channel_info.etc",
"runtime/assets/client-compatibility.json",
"runtime/assets/manifest.json",
"runtime/assets/mysql-portable.json",
"runtime/assets/vcruntime-app-local.json",
"runtime/templates/instance.example.json",
"runtime/patch/90CN.dll",
"runtime/patch/90CNLua.dll",
"changlog.md",
}
// updatePathWhitelist mirrors DNF90Hub 的 updatePathPrefixes / updateExactPaths。
// 它不参与打包,只供测试比对——一旦这边加了对面不认的路径,测试先失败,而不是等到
// 玩家的启动器拒收整个更新包。
var updatePathWhitelist = struct {
Prefixes []string
Exact []string
}{
Prefixes: []string{"runtime/bin/", "runtime/assets/", "runtime/templates/", "runtime/patch/"},
Exact: []string{"changlog.md"},
}
type updateManifest struct {
SchemaVersion int `json:"schemaVersion"`
Tag string `json:"tag"`
Commit string `json:"commit"`
Platform string `json:"platform"`
GoVersion string `json:"goVersion"`
Files []updateEntry `json:"files"`
}
type updateEntry struct {
Path string `json:"path"`
Asset string `json:"asset"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
func main() {
root := flag.String("root", "..", "project root holding runtime/ and changlog.md")
out := flag.String("out", "", "staging directory receiving the flat assets and the manifest")
tag := flag.String("tag", "", "release tag, e.g. v1.0.0")
commit := flag.String("commit", "", "commit the binaries were built from")
flag.Parse()
if err := run(*root, *out, *tag, *commit); err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(1)
}
}
func run(root, out, tag, commit string) error {
if strings.TrimSpace(out) == "" {
return fmt.Errorf("-out is required")
}
if strings.TrimSpace(tag) == "" {
return fmt.Errorf("-tag is required")
}
absRoot, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("resolve root: %w", err)
}
entries, err := collectUpdateFiles(absRoot)
if err != nil {
return err
}
if err := os.MkdirAll(out, 0o755); err != nil {
return fmt.Errorf("create staging directory: %w", err)
}
for _, entry := range entries {
source := filepath.Join(absRoot, filepath.FromSlash(entry.Path))
if err := copyFile(source, filepath.Join(out, entry.Asset)); err != nil {
return err
}
}
manifest := updateManifest{
SchemaVersion: 2,
Tag: tag,
Commit: commit,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
GoVersion: runtime.Version(),
Files: entries,
}
if err := writeUpdateManifest(filepath.Join(out, manifestName), manifest); err != nil {
return err
}
fmt.Printf("update package staged: %d files + %s in %s\n", len(entries), manifestName, out)
return nil
}
func collectUpdateFiles(root string) ([]updateEntry, error) {
entries := make([]updateEntry, 0, len(updateFiles))
seenAssets := map[string]string{}
for _, path := range updateFiles {
asset := assetNameFor(path)
if previous, taken := seenAssets[asset]; taken {
return nil, fmt.Errorf("asset name collision: %s and %s both map to %s", previous, path, asset)
}
seenAssets[asset] = path
if asset == manifestName {
return nil, fmt.Errorf("update file %s collides with the manifest asset %s", path, manifestName)
}
full := filepath.Join(root, filepath.FromSlash(path))
info, err := os.Stat(full)
if err != nil {
return nil, fmt.Errorf("update file is missing: %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("update file is not a regular file: %s", path)
}
digest, err := fileSHA256(full)
if err != nil {
return nil, err
}
entries = append(entries, updateEntry{
Path: path,
Asset: asset,
Size: info.Size(),
SHA256: digest,
})
}
return entries, nil
}
// assetNameFor flattens an install path into a release asset name. GitHub
// release assets have no directories, so the separator becomes a double
// underscore; the mapping is never inverted — the manifest records both sides.
func assetNameFor(path string) string {
return strings.ReplaceAll(path, "/", "__")
}
// writeUpdateManifest writes BOM-free UTF-8: the launcher parses this as JSON,
// which rejects a byte order mark.
func writeUpdateManifest(path string, manifest updateManifest) error {
encoded, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
return nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
digest := sha256.New()
if _, err := io.Copy(digest, file); err != nil {
return "", fmt.Errorf("hash %s: %w", path, err)
}
return strings.ToUpper(fmt.Sprintf("%x", digest.Sum(nil))), nil
}
func copyFile(source, destination string) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(destination)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return fmt.Errorf("copy %s: %w", filepath.Base(source), err)
}
return out.Close()
}
@@ -0,0 +1,159 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeFixture(t *testing.T, root string, relative string, body string) {
t.Helper()
full := filepath.Join(root, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRunStagesEveryFileAndWritesTheManifest(t *testing.T) {
root := t.TempDir()
for _, path := range updateFiles {
writeFixture(t, root, path, "content of "+path)
}
out := filepath.Join(t.TempDir(), "dist")
if err := run(root, out, "v1.2.3", "abcdef0123456789"); err != nil {
t.Fatalf("run: %v", err)
}
raw, err := os.ReadFile(filepath.Join(out, manifestName))
if err != nil {
t.Fatalf("manifest was not written: %v", err)
}
if raw[0] == 0xEF {
t.Fatal("manifest carries a byte order mark")
}
var manifest updateManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatalf("manifest does not parse: %v", err)
}
if manifest.SchemaVersion != 2 {
t.Fatalf("schemaVersion = %d, want 2", manifest.SchemaVersion)
}
if manifest.Tag != "v1.2.3" || manifest.Commit != "abcdef0123456789" {
t.Fatalf("tag/commit = %q/%q", manifest.Tag, manifest.Commit)
}
if len(manifest.Files) != len(updateFiles) {
t.Fatalf("manifest has %d files, want %d", len(manifest.Files), len(updateFiles))
}
seen := map[string]bool{}
for _, entry := range manifest.Files {
if seen[entry.Asset] {
t.Fatalf("duplicate asset name %s", entry.Asset)
}
seen[entry.Asset] = true
if strings.ContainsAny(entry.Asset, `/\`) {
t.Fatalf("asset name %s carries a separator", entry.Asset)
}
staged, err := os.Stat(filepath.Join(out, entry.Asset))
if err != nil {
t.Fatalf("asset %s was not staged: %v", entry.Asset, err)
}
if staged.Size() != entry.Size {
t.Fatalf("asset %s staged %d bytes, manifest says %d", entry.Asset, staged.Size(), entry.Size)
}
if len(entry.SHA256) != 64 || entry.SHA256 != strings.ToUpper(entry.SHA256) {
t.Fatalf("asset %s hash %q is not uppercase SHA256", entry.Asset, entry.SHA256)
}
}
}
func TestRunRefusesAMissingFile(t *testing.T) {
root := t.TempDir()
for _, path := range updateFiles[:len(updateFiles)-1] {
writeFixture(t, root, path, "x")
}
err := run(root, filepath.Join(t.TempDir(), "dist"), "v0.0.1", "c0ffee")
if err == nil || !strings.Contains(err.Error(), updateFiles[len(updateFiles)-1]) {
t.Fatalf("err = %v, want the missing file named", err)
}
}
func TestRunRequiresTagAndOut(t *testing.T) {
if err := run(t.TempDir(), "", "v1.0.0", "c"); err == nil {
t.Fatal("an empty -out was accepted")
}
if err := run(t.TempDir(), t.TempDir(), "", "c"); err == nil {
t.Fatal("an empty -tag was accepted")
}
}
func TestAssetNamesCannotCollide(t *testing.T) {
entries, err := func() ([]updateEntry, error) {
root := t.TempDir()
for _, path := range updateFiles {
writeFixture(t, root, path, "x")
}
return collectUpdateFiles(root)
}()
if err != nil {
t.Fatalf("collect: %v", err)
}
for _, entry := range entries {
if entry.Asset == manifestName {
t.Fatalf("asset %s collides with the manifest name", entry.Asset)
}
}
}
// The install side lives in another repository (DNF90Hub 的 update.go) and rejects
// the whole update package when any manifest entry falls outside its fixed
// whitelist. A path added here that it does not accept would not fail any build —
// it would fail on players' machines, at install time, with "清单中的安装路径不合法".
// This test is the only thing standing between those two facts.
func TestUpdateFilesStayInsideTheHubWhitelist(t *testing.T) {
for _, path := range updateFiles {
allowed := false
for _, exact := range updatePathWhitelist.Exact {
if path == exact {
allowed = true
break
}
}
for _, prefix := range updatePathWhitelist.Prefixes {
if strings.HasPrefix(path, prefix) {
allowed = true
break
}
}
if !allowed {
t.Errorf("update path %q is outside the install-side whitelist %v (+%v); "+
"DNF90Hub would reject the entire package. Widen its whitelist and ship that first.",
path, updatePathWhitelist.Prefixes, updatePathWhitelist.Exact)
}
}
}
// Forward slashes only, no traversal, no rooting — the same shape the install side
// validates. Keeping it here means a bad path is caught by go test, not by a
// player's failed update.
func TestUpdateFilesAreCleanRelativePaths(t *testing.T) {
for _, path := range updateFiles {
if path == "" || strings.Contains(path, `\`) {
t.Errorf("update path %q must use forward slashes", path)
}
if strings.HasPrefix(path, "/") || strings.Contains(path, ":") {
t.Errorf("update path %q must be relative", path)
}
for _, part := range strings.Split(path, "/") {
if part == "" || part == "." || part == ".." {
t.Errorf("update path %q has an empty or traversing segment", path)
}
}
}
}