Files
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00

620 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// channel.go 负责 DNF bridge 频道服 7001 的握手、兼容包解析和频道目录响应。
package dnfbridge
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"net"
"os"
"strings"
"syscall"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type channelSession struct {
conn net.Conn
connID string
packetSeq uint64
seq uint16
scriptSent bool
closeAfterBootstrapDirectory bool
}
// Channel bootstrap can pause while the current client loads the downloaded
// script, initializes the patch DLLs, or is scanned by endpoint security.
// Eight seconds was too aggressive on cold/low-end release machines and made
// the native client fall back to its generic "connecting server" retry UI.
const channelReadIdleTimeout = 30 * time.Second
func (s *Service) handleChannelConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
session := &channelSession{conn: conn, connID: s.nextPacketConnID("channel")}
reader := bufio.NewReader(conn)
s.logInfo("dnfbridge channel connection accepted",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
s.logPacketEvent("channel-connect",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
defer s.logPacketEvent("channel-close",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
setChannelReadDeadline(conn)
if err := s.acceptDofLoginPreface(reader, session); err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
for {
setChannelReadDeadline(conn)
handled, err := s.handleLegacyChannelRequest(reader, session)
if err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
if handled {
if session.closeAfterBootstrapDirectory {
s.logPacketEvent("channel-bootstrap-directory-committed",
"conn_id", session.connID,
"remote", session.conn.RemoteAddr().String())
return
}
continue
}
setChannelReadDeadline(conn)
packet, err := s.readChannelPacket(reader, session)
if err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
if err := s.handleChannelPacket(session, packet); err != nil {
s.logWarn("dnfbridge handle channel packet failed", "msg_id", packet.Header.MsgID, "error", err)
return
}
}
}
func setChannelReadDeadline(conn net.Conn) {
_ = conn.SetReadDeadline(time.Now().Add(channelReadIdleTimeout))
}
func (s *Service) acceptDofLoginPreface(reader *bufio.Reader, session *channelSession) error {
header, err := reader.Peek(dnfproto.LegacyChannelHeaderSize)
if err != nil {
return err
}
if !dnfproto.IsDofLoginPrefacePrefix(header) {
return nil
}
raw := make([]byte, dnfproto.DofLoginPrefaceSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return err
}
s.logChannelPacket(session, "RECV", "channel-dof-preface", raw)
ack := dnfproto.BuildDofLoginAck(s.aesKey())
s.logChannelPacket(session, "SEND", "channel-dof-ack", ack)
_, err = session.conn.Write(ack)
return err
}
func (s *Service) handleLegacyChannelRequest(reader *bufio.Reader, session *channelSession) (bool, error) {
header, err := reader.Peek(dnfproto.LegacyChannelHeaderSize)
if err != nil {
return false, err
}
if dnfproto.IsDofChannelProbePrefix(header) {
raw := make([]byte, dnfproto.LegacyChannelHeaderSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return false, err
}
s.logChannelPacket(session, "RECV", "channel-dof-get-script", raw)
return true, s.sendLegacyScript(session)
}
if !dnfproto.IsDofAskChannelPrefix(header) {
return false, nil
}
raw := make([]byte, dnfproto.DofAskChannelSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return false, err
}
s.logChannelPacket(session, "RECV", "channel-dof-ask", raw)
bootstrap := session.scriptSent
body, err := s.buildChannelInfoBodyFor(dnfproto.LegacyPayload(raw), bootstrap)
if err != nil {
return true, err
}
packet := dnfproto.BuildLegacyChannelPacket(dnfenum.LegacyMsgChannelInfo, body)
s.logChannelPacket(session, "SEND", "channel-dof-info", packet, "msg_id", dnfenum.LegacyMsgChannelInfo)
if _, err := session.conn.Write(packet); err != nil {
return true, err
}
s.logPacketEvent("channel-dof-info-sent",
"conn_id", session.connID,
"remote", session.conn.RemoteAddr().String(),
"local", session.conn.LocalAddr().String(),
"bootstrap", bootstrap)
session.closeAfterBootstrapDirectory = bootstrap
return true, nil
}
func (s *Service) sendLegacyScript(session *channelSession) error {
body, err := s.buildScriptBody()
if err != nil {
return err
}
packet := dnfproto.BuildLegacyChannelPacket(dnfenum.LegacyMsgDofScript, body)
s.logChannelPacket(session, "SEND", "channel-dof-script", packet, "msg_id", dnfenum.LegacyMsgDofScript)
if _, err := session.conn.Write(packet); err != nil {
return err
}
session.scriptSent = true
return nil
}
func (s *Service) readChannelPacket(reader io.Reader, session *channelSession) (dnfproto.ChannelPacket, error) {
header := make([]byte, dnfproto.ChannelHeaderSize)
if _, err := io.ReadFull(reader, header); err != nil {
return dnfproto.ChannelPacket{}, err
}
if packet, ok, err := s.tryReadMixedLegacyChannelPacket(reader, header, session); ok || err != nil {
return packet, err
}
length := binary.LittleEndian.Uint32(header[3:7])
maxPacketBytes := s.options.maxPacketBytes
if maxPacketBytes <= 0 {
maxPacketBytes = defaultMaxPacketBytes
}
if length < dnfproto.ChannelHeaderSize || int(length) > maxPacketBytes {
s.logChannelPacket(session, "RECV", "channel-invalid-header", header)
return dnfproto.ChannelPacket{}, dnfproto.ErrPacketLength
}
packet := make([]byte, int(length))
copy(packet, header)
if _, err := io.ReadFull(reader, packet[dnfproto.ChannelHeaderSize:]); err != nil {
return dnfproto.ChannelPacket{}, err
}
parsed, err := dnfproto.ParseChannelPacket(packet)
if err != nil {
s.logChannelPacket(session, "RECV", "channel-invalid", packet)
return dnfproto.ChannelPacket{}, err
}
s.logChannelPacket(session, "RECV", "channel", packet,
"msg_id", parsed.Header.MsgID,
"header_seq", parsed.Header.Seq,
"body_len", len(parsed.Body))
return parsed, nil
}
func (s *Service) tryReadMixedLegacyChannelPacket(reader io.Reader, header []byte, session *channelSession) (dnfproto.ChannelPacket, bool, error) {
if len(header) < dnfproto.ChannelHeaderSize || header[0] == dnfproto.DefaultChannelClassification {
return dnfproto.ChannelPacket{}, false, nil
}
bodyLen := int(binary.BigEndian.Uint16(header[0:2]))
msgID := binary.LittleEndian.Uint16(header[2:4])
total := 4 + bodyLen
if !isMixedLegacyChannelMsg(msgID) || total < dnfproto.ChannelHeaderSize {
return dnfproto.ChannelPacket{}, false, nil
}
maxPacketBytes := s.options.maxPacketBytes
if maxPacketBytes <= 0 {
maxPacketBytes = defaultMaxPacketBytes
}
if total > maxPacketBytes {
s.logChannelPacket(session, "RECV", "channel-mixed-legacy-invalid", header, "msg_id", msgID)
return dnfproto.ChannelPacket{}, true, dnfproto.ErrPacketLength
}
raw := make([]byte, total)
copy(raw, header)
if total > dnfproto.ChannelHeaderSize {
if _, err := io.ReadFull(reader, raw[dnfproto.ChannelHeaderSize:]); err != nil {
return dnfproto.ChannelPacket{}, true, err
}
}
body := append([]byte(nil), raw[4:]...)
s.logChannelPacket(session, "RECV", "channel-mixed-legacy", raw,
"msg_id", msgID,
"body_len", len(body))
return dnfproto.ChannelPacket{
Header: dnfproto.ChannelHeader{
Classification: dnfproto.DefaultChannelClassification,
MsgID: msgID,
Length: uint32(total),
Seq: mixedLegacySequence(body),
},
Body: body,
}, true, nil
}
func isMixedLegacyChannelMsg(msgID uint16) bool {
switch dnfenum.ChannelMsg(msgID) {
case dnfenum.MsgCSConnect, dnfenum.MsgCSAskChannelInfoNew:
return true
default:
return false
}
}
func mixedLegacySequence(body []byte) uint16 {
for _, value := range body {
if value != 0 {
return uint16(value)
}
}
return 1
}
func (s *Service) logChannelReadError(ctx context.Context, conn net.Conn, err error) {
if !isQuietChannelReadError(err) && ctx.Err() == nil {
s.logWarn("dnfbridge read channel packet failed", "remote", conn.RemoteAddr().String(), "error", err)
}
}
func isQuietChannelReadError(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET)
}
func (s *Service) handleChannelPacket(session *channelSession, packet dnfproto.ChannelPacket) error {
switch dnfenum.ChannelMsg(packet.Header.MsgID) {
case dnfenum.MsgCSUpdateChannelInfo:
body, err := s.buildChannelUpdateBody(packet.Body)
if err != nil {
return err
}
return s.sendChannelClass(session, uint16(dnfenum.MsgSCAskChannelInfo), body, packet.Header.Classification)
case dnfenum.MsgCSConnect:
return s.sendChannel(session, uint16(dnfenum.MsgSCConnect), s.buildConnectBody())
case dnfenum.MsgCSCheckScript:
body, err := s.buildScriptVersionBody()
if err != nil {
return err
}
return s.sendChannel(session, uint16(dnfenum.MsgSCCheckScript), body)
case dnfenum.MsgCSGetScript:
return s.sendChannelScript(session)
case dnfenum.MsgCSAskChannelInfoNew:
if !session.scriptSent {
if err := s.sendChannelScript(session); err != nil {
return err
}
}
body, err := s.buildChannelInfoBodyFor(packet.Body, true)
if err != nil {
return err
}
return s.sendChannel(session, uint16(dnfenum.MsgSCAskChannelInfoNew), body)
default:
return nil
}
}
// sendChannelScript 用最新频道包下发 channel_info.etc,只影响客户端频道静态表加载,不写玩家状态。
func (s *Service) sendChannelScript(session *channelSession) error {
body, err := s.buildScriptBody()
if err != nil {
return err
}
if err := s.sendChannel(session, uint16(dnfenum.MsgSCGetScript), body); err != nil {
return err
}
session.scriptSent = true
return nil
}
func (s *Service) sendChannel(session *channelSession, msgID uint16, body []byte) error {
return s.sendChannelClass(session, msgID, body, dnfproto.DefaultChannelClassification)
}
func (s *Service) sendChannelClass(session *channelSession, msgID uint16, body []byte, class byte) error {
packet, err := dnfproto.BuildChannelPacket(msgID, body, session.seq, dnfproto.DefaultChannelClassification)
if err != nil {
return err
}
if class != 0 && class != dnfproto.DefaultChannelClassification {
packet, err = dnfproto.BuildChannelPacket(msgID, body, session.seq, class)
if err != nil {
return err
}
}
session.seq++
s.logChannelPacket(session, "SEND", "channel", packet,
"msg_id", msgID,
"header_seq", session.seq-1,
"body_len", len(body))
_, err = session.conn.Write(packet)
return err
}
func (s *Service) logChannelPacket(session *channelSession, direction string, kind string, data []byte, fields ...any) {
if session == nil {
s.logPacket(direction, kind, data, fields...)
return
}
session.packetSeq++
args := make([]any, 0, len(fields)+4)
args = append(args, "conn_id", session.connID, "pkt_seq", session.packetSeq)
args = append(args, fields...)
s.logPacket(direction, kind, data, args...)
}
func (s *Service) buildConnectBody() []byte {
key := s.aesKey()
body := make([]byte, 36)
copy(body[4:], []byte(key))
return body
}
func (s *Service) buildScriptVersionBody() ([]byte, error) {
raw := make([]byte, 20)
copy(raw[4:], []byte(s.options.scriptVersion))
return encryptChannelData(raw, s.aesKey(), false)
}
func (s *Service) buildScriptBody() ([]byte, error) {
script := s.currentChannelScript()
if len(script) == 0 {
raw, err := osReadFile(s.options.channelInfoFile)
if err != nil {
return nil, err
}
script, err = s.buildChannelScriptFiltered(raw, s.loadDisabledChannelIDs())
if err != nil {
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
}
return encryptChannelData(script, s.aesKey(), true)
}
func (s *Service) buildChannelUpdateBody(_ []byte) ([]byte, error) {
channel, err := s.loginChannel()
if err != nil {
return nil, err
}
var writer packetWriter
writer.writeUint32(0)
writer.buffer.Write(fixedASCII(s.options.serverIP, 16))
writer.writeInt32(channel.Port)
writer.writeInt32(channel.MaxUsers)
writer.writeInt32(channel.CurrentUsers)
return writer.bytes(), nil
}
// currentSpecialAreaChannelType 特殊区域频道类型。
//
// 线上 channel_info.etc 里 121(ancient 远古)与 171(deathtower 绝望之塔)都是 type 22。
// 服主口径(2026-09-30):任何时刻必须至少有一个 22 类型频道处于开启状态——运维工具的关闭守卫
// (channelstore.checkDisableTargets)会拒绝关掉最后一个,服务端这边只告警,让手改表的人也能看到。
//
// 注意别用 dnfenum.DeathTowerChannelType(=11):那份 ETC 里 type 11 是普通城镇频道,
// 这个常量错位的问题在 channelcatalog.isResidentChannel 的注释里已经记过一次。
const currentSpecialAreaChannelType = 22
func catalogHasSpecialAreaChannel(catalog *channelcatalog.Catalog) bool {
if catalog == nil {
return false
}
for _, channel := range catalog.Channels() {
if int(channel.Type) == currentSpecialAreaChannelType {
return true
}
}
return false
}
// currentChannelTableWarnings 返回"表能跑、但玩家侧或运维约定会受影响"的告警。
// 启动与热重载共用;includeDefaultTown=false 时跳过"缺默认城镇频道"那条
// (严格模式已经把它当硬错误拒绝了,不该再重复告警一次)。
func currentChannelTableWarnings(catalog *channelcatalog.Catalog, includeDefaultTown bool) []string {
if catalog == nil {
return nil
}
warnings := make([]string, 0, 2)
if includeDefaultTown {
if _, ok := catalog.Channel(dnfenum.DefaultGameChannelID); !ok {
warnings = append(warnings, fmt.Sprintf(
"频道表没有默认城镇频道 %d:登录时客户端只会自动拨它记住的那个频道号,"+
"记住的不是表里这些频道的玩家会停在选频道界面(没有手动入口)",
dnfenum.DefaultGameChannelID))
}
}
if !catalogHasSpecialAreaChannel(catalog) {
warnings = append(warnings, fmt.Sprintf(
"频道表没有任何特殊区域频道(type %d):线上默认是 121 远古 / 171 绝望之塔,"+
"关闭它们的守卫要求至少留一个开启",
currentSpecialAreaChannelType))
}
return warnings
}
// loginChannel 选出 game class0/op1 首包与"刷新频道信息"回包要广告的那个频道端点。
//
// 顺序:默认城镇频道 11 → 引导频道 19 → 目录里第一个能承载城镇 actor 的频道。
// 最后这档是给"运维只开少数频道(甚至只开特殊区域频道)"用的:原来只认 11/19,
// 两个都不在表里就返回 error —— 而 buildChannelUpdateBody 的错误会让
// handleChannelPacket 失败、进而把 7001 那条频道连接关掉(见 handleChannelConn 的错误分支)。
func (s *Service) loginChannel() (channelcatalog.Channel, error) {
catalog := s.currentCatalog()
if catalog == nil {
return channelcatalog.Channel{}, channelcatalog.ErrEmpty
}
if channel, ok := catalog.Channel(dnfenum.DefaultGameChannelID); ok {
return channel, nil
}
if channel, ok := catalog.Channel(dnfenum.BootstrapChannelID); ok {
return channel, nil
}
for _, channel := range catalog.Channels() {
if channelFitsTownActorOwner(channel.ID) {
return channel, nil
}
}
return channelcatalog.Channel{}, fmt.Errorf(
"%w: resident channels %d and %d are missing and no catalog channel fits the town actor owner",
channelcatalog.ErrEmpty,
dnfenum.DefaultGameChannelID,
dnfenum.BootstrapChannelID,
)
}
func (s *Service) buildChannelInfoBody(request []byte) ([]byte, error) {
return s.buildChannelInfoBodyFor(request, false)
}
func (s *Service) buildChannelInfoBodyFor(request []byte, bootstrap bool) ([]byte, error) {
catalog := s.currentCatalog()
if catalog == nil {
return nil, channelcatalog.ErrEmpty
}
group := readNullASCII(request, 0, 20)
channels := s.channelsForRequestMode(group, bootstrap)
firstChannel := 0
lastChannel := 0
if len(channels) > 0 {
firstChannel = channels[0].ID
lastChannel = channels[len(channels)-1].ID
}
s.logPacketEvent("channel-advertise",
"requested_group", group,
"source_server", s.channelServerID(),
"advertise_server", s.channelAdvertiseID(),
"body_mode", s.channelInfoBodyMode(),
"bootstrap", bootstrap,
"count", len(channels),
"first_channel", firstChannel,
"last_channel", lastChannel)
var writer packetWriter
s.writeChannelInfoPrefix(&writer, len(channels))
s.writeChannelInfoEntries(&writer, channels)
return encryptChannelData(writer.bytes(), s.aesKey(), true)
}
func (s *Service) writeChannelInfoPrefix(writer *packetWriter, count int) {
// IDA/MCP 复核 sub_1CA2440:最新客户端按 server_count/server_id/channel_count 解析 0x12 明文头。
// 旧 C# 的 2 字节保留位前缀已确认会把客户端解析游标带偏,不能再作为当前桥接输出。
writer.writeUint32(1)
writer.writeUint32(uint32(s.channelAdvertiseID()))
writer.writeUint32(uint32(count))
}
func (s *Service) writeChannelInfoEntries(writer *packetWriter, channels []channelcatalog.Channel) {
for _, channel := range channels {
writer.buffer.Write(fixedASCII("#"+channel.Name, 20))
writer.writeInt32(channel.MaxUsers)
writer.writeInt32(channel.CurrentUsers)
writer.buffer.Write(fixedASCII(s.options.serverIP, 16))
writer.writeInt32(channel.Port)
}
}
func (s *Service) channelsForRequest(group string) []channelcatalog.Channel {
return s.channelsForRequestMode(group, false)
}
func (s *Service) channelsForRequestMode(group string, bootstrap bool) []channelcatalog.Channel {
catalog := s.currentCatalog()
if catalog == nil {
return nil
}
var channels []channelcatalog.Channel
if bootstrap {
channels = catalog.FilterForBootstrap(group)
} else {
channels = catalog.FilterForRequest(group)
}
channels = s.advertisableChannels(channels, group, bootstrap)
if len(channels) == 0 && isDefaultChannelGroup(group) {
if channel, ok := catalog.ForPort(dnfenum.GamePortBase + dnfenum.DefaultGameChannelID); ok {
return []channelcatalog.Channel{channel}
}
}
return channels
}
// advertisableChannels removes every channel the current client cannot own a
// town actor in. A published channel is selectable, and selecting one above the
// u8 town-actor-owner range produced a session that entered the game with an
// uncommitted owner. Refusing to publish the channel is what makes that state
// unreachable, because the client derives the owner byte from the advertised id
// and the server cannot renumber it on the wire without lying about which
// channel the player is in -- the ids have to be in range in channel_info.etc.
//
// The dropped ids are logged rather than silently trimmed: a shipped
// channel_info.etc that loses entries here must be visible in the log.
func (s *Service) advertisableChannels(
channels []channelcatalog.Channel,
group string,
bootstrap bool,
) []channelcatalog.Channel {
dropped := make([]int, 0)
for _, channel := range channels {
if !channelFitsTownActorOwner(channel.ID) {
dropped = append(dropped, channel.ID)
}
}
if len(dropped) == 0 {
return channels
}
kept := make([]channelcatalog.Channel, 0, len(channels)-len(dropped))
for _, channel := range channels {
if channelFitsTownActorOwner(channel.ID) {
kept = append(kept, channel)
}
}
s.logPacketEvent("channel-advertise-out-of-range-dropped",
"requested_group", group,
"bootstrap", bootstrap,
"kept", len(kept),
"dropped", len(dropped),
"dropped_channels", dropped,
"limit", math.MaxUint8,
"reason", "channel_id_cannot_fit_current_exe_u8_town_actor_owner")
return kept
}
func isDefaultChannelGroup(group string) bool {
group = strings.TrimSpace(strings.Trim(group, "`"))
group = strings.TrimPrefix(group, "[")
group = strings.TrimSuffix(group, "]")
group = strings.ToLower(strings.TrimSpace(group))
return group == "" || group == dnfenum.GroupCain
}
func readNullASCII(data []byte, offset int, size int) string {
if offset >= len(data) || size <= 0 {
return ""
}
limit := offset + size
if limit > len(data) {
limit = len(data)
}
end := offset
for end < limit && data[end] != 0 {
end++
}
return strings.TrimSpace(string(data[offset:end]))
}
func osReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自部署配置,DNF bridge 只读取 channel_info.etc 活配置。
if err != nil {
return nil, fmt.Errorf("read channel info script: %w", err)
}
return data, nil
}