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,523 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user