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

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

524 lines
21 KiB
Go

// launcher_auth.go implements the external game-connection authentication
// gate (launcher ticket -> loopback launcher verifier). Local PID /
// WireGuard / Wine fast paths never reach this file: handleGameConn only
// enters the gate when launcherAuthEnabled is set AND no local binding won.
package dnfbridge
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/netip"
"strings"
"time"
)
// Launcher auth wire protocol. Frame layout:
//
// [4B magic "LANA"][1B version][2B body len BE][body]
//
// Server challenge body: server_public_ip(4B BE) + random_nonce(16B).
// Version 1 client auth body: ticket(32B hex, 64 ASCII) + machine_id(<=128 ASCII, optional).
//
// Version 2 exists only to carry the launcher identity lease. It keeps the
// version 1 prefix byte-for-byte so the ticket and machine_id parsing is shared,
// and appends a length-suffixed lease:
//
// machine_id(rest) + lease(lease_len ASCII bytes) + lease_len(2B BE)
//
// The length is deliberately last. machine_id is the only field whose length is
// not known up front, so putting the trailer's own length at the very end lets
// the reader recover every field by working backwards, without adding a
// machine_id length field that would invalidate the version 1 layout.
//
// The server advertises which version it wants in the challenge. A client that
// cannot answer version 2 (an older 90CN.dll) still works against a version 1
// challenge, which is what audit mode sends, so a rollout can be observed
// before it starts rejecting connections.
const (
launcherAuthFrameMagic = "LANA"
launcherAuthFrameVersion = 1
launcherAuthFrameVersionLease = 2
// launcherAuthFrameVersionS2C 只可能是**客户端**回的版本:它表示"我看懂了你
// challenge 里的 S2C 标记,本连接可以加密回包"。body 布局与 version 2 完全一致。
//
// 为什么用"客户端回一个更高的版本号"来确认能力:版本号是这条握手里唯一的
// 客户端→服务端信号位,而 challenge body 只要加一个字节就会让**旧 DLL** 忽略它
// (旧 DLL 只校验 body 长度区间 20..1024,且完全不读 body 内容),所以:
// · 旧服务端(body 20 字节)→ 新客户端看不到标记 → 回 v2 → 明文(不炸)
// · 新服务端+旧客户端 → 客户端忽略标记、回 v2 → 服务端按 v2 处理 → 明文(不炸)
// · 新服务端+新客户端 → 回 v3 → 加密
// 三种组合都不会把"没准备好解密的一端"推进密文里。
launcherAuthFrameVersionS2C = 3
launcherAuthHeaderLen = 4 + 1 + 2
launcherAuthTicketHexLen = 64 // 32 random bytes, ASCII hex
launcherAuthMaxMachineID = 128
launcherAuthNonceLen = 16
launcherAuthChallengeBody = 4 + launcherAuthNonceLen // server_public_ip + nonce
launcherAuthChallengeS2CFlag = 1 // 尾部 1 字节:请求 S2C 加密
launcherAuthMaxBodyLen = launcherAuthTicketHexLen + launcherAuthMaxMachineID
launcherAuthMaxVerifyBody = 32 << 10
launcherAuthLeaseTrailerLen = 2
launcherAuthMaxBodyLenLease = launcherAuthMaxBodyLen + launcherAuthLeaseTrailerLen + launcherIdentityMaxLeaseBytes
)
var launcherAuthMagicBytes = []byte(launcherAuthFrameMagic)
var (
errLauncherAuthFrameInvalid = errors.New("dnfbridge launcher auth frame invalid")
errLauncherAuthRejected = errors.New("dnfbridge launcher auth rejected")
errLauncherAuthTimeout = errors.New("dnfbridge launcher auth timeout")
errLauncherAuthVerifyError = errors.New("dnfbridge launcher auth verify error")
)
type launcherAuthVerifyRequest struct {
Ticket string `json:"ticket"`
MachineID string `json:"machine_id,omitempty"`
RemoteIP string `json:"remote_ip,omitempty"`
ChannelID int `json:"channel_id"`
ServerNonce string `json:"server_nonce"`
OnlineCount int `json:"online_count,omitempty"`
}
type launcherAuthVerifyResponse struct {
OK bool `json:"ok"`
AccountID string `json:"account_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
func newLauncherAuthVerifyClient(opts options) *http.Client {
if !opts.launcherAuthEnabled {
return nil
}
timeout := opts.launcherAuthTimeout
if timeout <= 0 {
timeout = defaultLauncherAuthTimeout
}
return &http.Client{Timeout: timeout}
}
// runLauncherAuthGate performs the full external auth handshake for one game
// connection: challenge frame -> auth frame -> loopback verifier -> bind.
// A nil return means the session now carries a launcher-issued accountID and
// the regular DNF protocol flow may continue unchanged.
func (s *Service) runLauncherAuthGate(ctx context.Context, session *gameSession) error {
timeout := s.options.launcherAuthTimeout
if timeout <= 0 {
timeout = defaultLauncherAuthTimeout
}
verifyURL := strings.TrimSpace(s.options.launcherAuthVerifyURL)
if verifyURL == "" {
verifyURL = defaultLauncherAuthVerifyURL
}
logEvent := func(event string, extra ...any) {
args := []any{
"conn_id", session.connID,
"remote", session.conn.RemoteAddr().String(),
"local", session.conn.LocalAddr().String(),
"channel_id", session.channel.ID,
"channel_type", session.channel.Type,
"game_port", session.channel.Port,
}
args = append(args, extra...)
s.logPacketEvent(event, args...)
}
logEvent("game-launcher-auth-start",
"verify_url", verifyURL,
"timeout_ms", timeout.Milliseconds(),
"lid_mode", s.launcherIdentity.modeName())
nonce := make([]byte, launcherAuthNonceLen)
if _, err := rand.Read(nonce); err != nil {
logEvent("game-launcher-auth-rejected", "reason", "nonce-generation-failed", "error", err)
s.logWarn("dnfbridge launcher auth nonce generation failed",
"remote", session.conn.RemoteAddr().String(), "error", err)
return errLauncherAuthRejected
}
// The challenge version is the server's capability statement. Version 2 asks
// for a launcher identity lease as well as the ticket; version 1 is the
// original frame that an older 90CN.dll can still answer. Only enforce mode
// raises the requirement, so audit mode can be observed on live traffic
// without breaking a client that predates the lease.
challengeVersion := byte(launcherAuthFrameVersion)
if s.launcherIdentity.enforcing() {
challengeVersion = launcherAuthFrameVersionLease
}
// S2C 回包加密:challenge body 末尾多一个标记字节,请求客户端在回复里确认能力。
// 只有 on 模式才带标记(shadow 只记账,不带;off 完全不变)。
//
// **顺序很重要**:加解密器必须在发 challenge 之前就建好,建不出来就干脆不发标记——
// 否则客户端已按 version 3 确认"我会解密",而服务端还在发明文,结果就是全乱码。
preparedS2CCipher, wantS2CCipher := s.prepareGameS2CCipher(session, nonce, logEvent)
serverIP := serverIPBytesForLauncherChallenge(s.options.serverIP)
challenge := buildLauncherAuthChallenge(
serverIP[:],
nonce,
challengeVersion,
wantS2CCipher,
)
if err := session.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
logEvent("game-launcher-auth-rejected", "reason", "challenge-deadline-failed", "error", err)
return errLauncherAuthRejected
}
if err := writeLauncherAuthFull(session.conn, challenge); err != nil {
logEvent("game-launcher-auth-rejected", "reason", "challenge-write-failed", "error", err)
s.logWarn("dnfbridge launcher auth challenge write failed",
"remote", session.conn.RemoteAddr().String(), "error", err)
return errLauncherAuthRejected
}
_ = session.conn.SetWriteDeadline(time.Time{})
if err := session.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
logEvent("game-launcher-auth-rejected", "reason", "auth-deadline-failed", "error", err)
return errLauncherAuthRejected
}
frame, readErr := readLauncherAuthFrame(session.conn)
_ = session.conn.SetReadDeadline(time.Time{})
if readErr != nil {
var netErr net.Error
switch {
case errors.As(readErr, &netErr) && netErr.Timeout():
logEvent("game-launcher-auth-timeout", "error", readErr)
s.logWarn("dnfbridge launcher auth frame timed out",
"remote", session.conn.RemoteAddr().String(), "error", readErr)
return errLauncherAuthTimeout
case errors.Is(readErr, errLauncherAuthFrameInvalid):
logEvent("game-launcher-auth-frame-invalid", "error", readErr)
s.logWarn("dnfbridge launcher auth frame invalid",
"remote", session.conn.RemoteAddr().String(), "error", readErr)
return errLauncherAuthFrameInvalid
default:
logEvent("game-launcher-auth-rejected", "reason", "auth-frame-read-failed", "error", readErr)
return errLauncherAuthRejected
}
}
ticket := frame.Ticket
machineID := frame.MachineID
// S2C 回包加密的能力协商结果。客户端回 version 3 = "我读到了标记、我会解密",
// 于是这条连接的回包 body 用 LANA challenge 里那 16 字节随机数派生密钥加密。
// 没回 v3(旧 DLL、或本地直连没走这个门)就保持明文——**绝不猜**。
s.negotiateGameS2CCipher(session, frame, preparedS2CCipher, logEvent)
// Launcher identity check. Enforce mode needs a valid lease before the ticket
// is even looked at, so an attacker who steals a ticket still cannot open a
// game connection; audit mode records the verdict and carries on.
leaseAccountID, leaseErr := s.applyLauncherIdentityLease(frame, logEvent)
if s.launcherIdentity.enforcing() && leaseErr != nil {
// frame_version is the single most useful field here: 1 means the client
// answered with the original frame because its 90CN.dll predates the
// lease, which is a deployment that was not finished rather than an
// attacker.
logEvent("game-launcher-auth-rejected",
"reason", "launcher-identity", "frame_version", frame.Version, "error", leaseErr)
s.logWarn("dnfbridge launcher identity rejected a game connection",
"remote", session.conn.RemoteAddr().String(),
"frame_version", frame.Version, "error", leaseErr)
return errLauncherAuthRejected
}
if s.launcherIdentity.enabled() && leaseErr == nil && leaseAccountID != "" {
// A ticketless lease names its account directly. That is the shape a
// deployment without an ops-tool ticket endpoint uses, and the account
// came out of a signed statement, so it needs no loopback verify.
session.accountID = leaseAccountID
session.machineID = machineID
if ip, err := netip.ParseAddr(remoteHostOf(session)); err == nil {
session.accountSourceIP = ip.Unmap()
}
// 同上:票据摘要即外网的客户端实例身份。
session.loginIdentity = currentLoginIdentityDigest(ticket)
logEvent("game-launcher-auth-ok", "account_id", session.accountID, "source", "launcher-identity-lease",
"ticket_digest", session.loginIdentity,
"machine_id_digest", currentLoginIdentityDigest(machineID))
return nil
}
if leaseErr != nil && s.launcherIdentity.enabled() {
logEvent("game-launcher-auth-lid-audit", "error", leaseErr)
}
remoteHost := remoteHostOf(session)
requestBody, err := json.Marshal(launcherAuthVerifyRequest{
Ticket: ticket,
MachineID: machineID,
RemoteIP: remoteHost,
ChannelID: session.channel.ID,
ServerNonce: hex.EncodeToString(nonce),
OnlineCount: s.countOnlineAccounts(),
})
if err != nil {
logEvent("game-launcher-auth-verify-error", "reason", "request-encode-failed", "error", err)
return errLauncherAuthVerifyError
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, bytes.NewReader(requestBody))
if err != nil {
logEvent("game-launcher-auth-verify-error", "reason", "request-build-failed", "error", err)
return errLauncherAuthVerifyError
}
req.Header.Set("Content-Type", "application/json")
if token := strings.TrimSpace(s.options.launcherAuthInternalToken); token != "" {
req.Header.Set("X-Internal-Token", token)
}
client := s.launcherAuthHTTPClient
if client == nil {
client = &http.Client{Timeout: timeout}
}
resp, err := client.Do(req)
if err != nil {
logEvent("game-launcher-auth-verify-error", "reason", "verify-request-failed", "error", err)
s.logWarn("dnfbridge launcher auth verify request failed",
"remote", session.conn.RemoteAddr().String(), "error", err)
return errLauncherAuthVerifyError
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logEvent("game-launcher-auth-verify-error", "reason", "verify-http-status", "status", resp.StatusCode)
return errLauncherAuthVerifyError
}
var verification launcherAuthVerifyResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, launcherAuthMaxVerifyBody)).Decode(&verification); err != nil {
logEvent("game-launcher-auth-verify-error", "reason", "verify-decode-failed", "error", err)
return errLauncherAuthVerifyError
}
if !verification.OK || strings.TrimSpace(verification.AccountID) == "" {
logEvent("game-launcher-auth-rejected", "reason", verification.Reason)
s.logWarn("dnfbridge launcher auth rejected by verifier",
"remote", session.conn.RemoteAddr().String(),
"reason", verification.Reason)
return errLauncherAuthRejected
}
session.accountID = strings.TrimSpace(verification.AccountID)
session.machineID = machineID
if ip, err := netip.ParseAddr(remoteHost); err == nil {
session.accountSourceIP = ip.Unmap()
}
// 票据摘要就是外网的"客户端实例"身份。
//
// 依据运维工具源码 internal/store/store.go:716 Login —— 每次
// POST /launcher/v1/login 才 INSERT 一行 launcher_sessions 并签发一张 32 字节随机票;
// :777 VerifyTicket 只按 ticket_hash 查表校验有效期,**不消费、不删除**;
// gm_events.go:98 的心跳只顺延 expires_at,不改 ticket_hash。
//
// 因此:同一个登录器登录期间,该客户端的上百条连接(启动时的全频道探测、换频道、
// 返回选角、攻坚探测)共用同一张票 → 摘要相同 → 不会被顶号误踢;
// 两次登录器登录(双开登 A 与登 B)是两张不同的票 → 摘要不同 → 顶号成立。
//
// 绝不用 IP 或机器码:两者在"同一台机器双开两个账号"时都相同,分不开。
session.loginIdentity = currentLoginIdentityDigest(ticket)
logEvent("game-launcher-auth-ok", "account_id", session.accountID,
"ticket_digest", session.loginIdentity,
"machine_id_digest", currentLoginIdentityDigest(machineID))
return nil
}
// serverIPBytesForLauncherChallenge returns the information-only public IPv4
// carried inside the challenge body. The launcher verifier does not rely on
// it for ticket decisions (the real peer address is supplied separately), so
// non-IPv4 server addresses degrade to 0.0.0.0 instead of failing the gate.
func serverIPBytesForLauncherChallenge(serverIP string) [4]byte {
var zero [4]byte
ip := net.ParseIP(strings.TrimSpace(serverIP))
if ip == nil {
return zero
}
if v4 := ip.To4(); v4 != nil {
return [4]byte{v4[0], v4[1], v4[2], v4[3]}
}
return zero
}
// buildLauncherAuthChallenge 组装服务端 challenge 帧。
//
// 布局:`"LANA" | version(1) | bodyLen(2B BE) | server_ip(4B) | nonce(16B) [| s2cFlag(1B)]`
//
// withS2CFlag 只在 S2C 回包加密为 on 时置位。多出来的那 1 个字节对**旧客户端是透明的**:
// 它只校验 body 长度落在 20..1024 且完全不读 body 内容,因此旧 DLL 照样握手成功、
// 回答 version 2,于是服务端按明文处理——不会把没准备好的客户端推进密文。
func buildLauncherAuthChallenge(serverIP []byte, nonce []byte, version byte, withS2CFlag bool) []byte {
bodyLen := launcherAuthChallengeBody
if withS2CFlag {
bodyLen += launcherAuthChallengeS2CFlag
}
challenge := make([]byte, 0, launcherAuthHeaderLen+bodyLen)
challenge = append(challenge, launcherAuthMagicBytes...)
challenge = append(challenge, version)
challenge = binary.BigEndian.AppendUint16(challenge, uint16(bodyLen))
challenge = append(challenge, serverIP...)
challenge = append(challenge, nonce...)
if withS2CFlag {
challenge = append(challenge, 1)
}
return challenge
}
// launcherAuthFrame is one decoded client auth frame. Lease is empty for the
// original version 1 frame and for a version 2 frame whose client had no
// launcher identity lease to attach.
type launcherAuthFrame struct {
Version byte
Ticket string
MachineID string
Lease string
LeaseParsed launcherIdentityLease
}
// readLauncherAuthFrame decodes either frame version. The ticket and machine_id
// prefix is byte-identical across versions, so only the version 2 trailer needs
// separate handling. An unknown version is rejected rather than guessed at,
// because guessing would let a future frame be read as its predecessor.
func readLauncherAuthFrame(conn net.Conn) (launcherAuthFrame, error) {
var frame launcherAuthFrame
header := make([]byte, launcherAuthHeaderLen)
if err := readLauncherAuthFull(conn, header); err != nil {
return frame, err
}
if !bytes.Equal(header[:4], launcherAuthMagicBytes) {
return frame, errLauncherAuthFrameInvalid
}
frame.Version = header[4]
switch frame.Version {
case launcherAuthFrameVersion, launcherAuthFrameVersionLease, launcherAuthFrameVersionS2C:
default:
return frame, errLauncherAuthFrameInvalid
}
bodyLen := int(binary.BigEndian.Uint16(header[5:7]))
maxBodyLen := launcherAuthMaxBodyLen
if frame.Version != launcherAuthFrameVersion {
// version 2 与 version 3 的 body 布局完全一致:version 3 只是"客户端读到了
// S2C 标记"的确认位,不改变 ticket/machine_id/lease 的解析。
maxBodyLen = launcherAuthMaxBodyLenLease
}
if bodyLen < launcherAuthTicketHexLen || bodyLen > maxBodyLen {
return frame, errLauncherAuthFrameInvalid
}
body := make([]byte, bodyLen)
if err := readLauncherAuthFull(conn, body); err != nil {
return frame, err
}
ticket := body[:launcherAuthTicketHexLen]
if _, err := hex.DecodeString(string(ticket)); err != nil {
return frame, errLauncherAuthFrameInvalid
}
trailer := body[launcherAuthTicketHexLen:]
machineEnd := len(trailer)
if frame.Version != launcherAuthFrameVersion {
if len(trailer) < launcherAuthLeaseTrailerLen {
return frame, errLauncherAuthFrameInvalid
}
// The lease length sits at the very end of the body, so machine_id is
// whatever precedes the lease. That is what makes the variable-length
// machine_id field unambiguous to parse.
lengthAt := len(trailer) - launcherAuthLeaseTrailerLen
leaseLen := int(binary.BigEndian.Uint16(trailer[lengthAt:]))
if leaseLen > lengthAt {
return frame, errLauncherAuthFrameInvalid
}
if leaseLen > 0 {
frame.Lease = string(trailer[lengthAt-leaseLen : lengthAt])
}
// A zero-length lease is legal: a version 1 capable client that was
// challenged by a version 2 server still reports its build, and enforce
// mode then rejects it with a precise reason instead of a frame error.
machineEnd = lengthAt - leaseLen
}
machine := trailer[:machineEnd]
for _, b := range machine {
if b > 0x7f {
return frame, errLauncherAuthFrameInvalid
}
}
frame.Ticket = string(ticket)
frame.MachineID = string(machine)
if frame.Lease != "" {
parsed, err := parseLauncherIdentityLease(frame.Lease)
if err != nil {
return frame, errLauncherAuthFrameInvalid
}
frame.LeaseParsed = parsed
}
return frame, nil
}
// remoteHostOf returns the peer address without its port, which is the form the
// loopback verifier and the ban filters expect.
func remoteHostOf(session *gameSession) string {
if session == nil || session.conn == nil || session.conn.RemoteAddr() == nil {
return ""
}
remoteHost := session.conn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(remoteHost); err == nil {
return host
}
return remoteHost
}
// applyLauncherIdentityLease verifies the lease attached to an in-band LANA
// frame. It returns the account the lease names directly when the lease carries
// one (the ticketless shape) and an empty string when the lease is bound to a
// ticket that still has to be verified. The returned error is the verification
// verdict and is never fatal on its own: the caller decides, because audit mode
// records the same verdict without rejecting.
func (s *Service) applyLauncherIdentityLease(
frame launcherAuthFrame,
logEvent func(string, ...any),
) (string, error) {
if !s.launcherIdentity.enabled() {
return "", errLauncherIdentityDisabled
}
if frame.Lease == "" {
return "", errLauncherIdentityMalformed
}
lease := frame.LeaseParsed
if err := s.launcherIdentity.verifyRemoteLease(frame.Ticket, lease, time.Now()); err != nil {
return "", err
}
leaseAccountID, _ := lease.leaseAccountID()
if leaseAccountID == "" {
logEvent("game-launcher-auth-lid-verified", "subject", "ticket")
return "", nil
}
logEvent("game-launcher-auth-lid-verified", "subject", "account")
return leaseAccountID, nil
}
func readLauncherAuthFull(conn net.Conn, buffer []byte) error {
for written := 0; written < len(buffer); {
n, err := conn.Read(buffer[written:])
written += n
if err != nil {
if errors.Is(err, io.EOF) && written == len(buffer) {
return nil
}
if errors.Is(err, io.EOF) {
return io.ErrUnexpectedEOF
}
return err
}
if n == 0 {
return io.ErrNoProgress
}
}
return nil
}
func writeLauncherAuthFull(conn net.Conn, payload []byte) error {
for written := 0; written < len(payload); {
n, err := conn.Write(payload[written:])
written += n
if err != nil {
return err
}
if n == 0 {
return io.ErrNoProgress
}
}
return nil
}