DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, §ion); 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
|
||||
}
|
||||
@@ -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.`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, ¤tUser); 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("DNF90Launcher is available only on Windows.")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user