Files
odinsea-elixir/lib/odinsea/login/client.ex
2026-02-25 12:26:26 -07:00

331 lines
8.9 KiB
Elixir

defmodule Odinsea.Login.Client do
@moduledoc """
Client connection handler for the login server.
Manages the login session state and packet encryption/decryption.
"""
use GenServer, restart: :temporary
require Logger
alias Odinsea.Net.Packet.In
alias Odinsea.Net.Opcodes
alias Odinsea.Net.PacketLogger
alias Odinsea.Login.Packets
alias Odinsea.Net.Cipher.ClientCrypto
alias Odinsea.Util.BitTools
defstruct [
:socket,
:ip,
:state,
:account_id,
:account_name,
:character_id,
:world,
:channel,
:logged_in,
:login_attempts,
:second_password,
:gender,
:is_gm,
:hardware_info,
:crypto,
:handshake_complete,
# === NEW FIELDS - Critical Priority ===
:created_at, # Session creation time (for session timeout)
:last_alive_ack, # Last pong received timestamp
:server_transition, # Boolean - migrating between servers
:macs, # [String.t()] - MAC addresses for ban checking
:character_slots, # integer() - Max chars per world (default 3)
# === NEW FIELDS - Medium Priority ===
:birthday, # integer() - YYMMDD format for PIN/SPW verification
:monitored, # boolean() - GM monitoring flag
:tempban, # DateTime.t() | nil - Temporary ban info
:chat_mute, # boolean() - Chat restriction
buffer: <<>>,
character_ids: []
]
def start_link(socket) do
GenServer.start_link(__MODULE__, socket)
end
@impl true
def init(socket) do
{:ok, {ip, _port}} = :inet.peername(socket)
ip_string = format_ip(ip)
Logger.info("Login client connected from #{ip_string}")
# Generate IVs for encryption (4 bytes each)
send_iv = :crypto.strong_rand_bytes(4)
recv_iv = :crypto.strong_rand_bytes(4)
# Create crypto context
crypto = ClientCrypto.new_from_ivs(112, send_iv, recv_iv)
# Get current timestamp for session tracking
current_time = System.system_time(:millisecond)
state = %__MODULE__{
socket: socket,
ip: ip_string,
state: :connected,
account_id: nil,
account_name: nil,
character_id: nil,
world: nil,
channel: nil,
logged_in: false,
login_attempts: 0,
second_password: nil,
gender: 0,
is_gm: false,
hardware_info: nil,
crypto: crypto,
handshake_complete: false,
buffer: <<>>,
character_ids: [],
# === NEW FIELDS INITIALIZATION ===
created_at: current_time,
last_alive_ack: current_time,
server_transition: false,
macs: [],
character_slots: 3,
birthday: nil,
monitored: false,
tempban: nil,
chat_mute: false
}
# Send hello packet (handshake) - unencrypted
send_hello_packet(state, send_iv, recv_iv)
# Start receiving packets
send(self(), :receive)
{:ok, state}
end
@impl true
def handle_info(:receive, %{socket: socket} = state) do
case :gen_tcp.recv(socket, 0, 30_000) do
{:ok, data} ->
# Append to buffer and process all complete packets
new_state = %{state | buffer: state.buffer <> data}
new_state = process_buffer(new_state)
send(self(), :receive)
{:noreply, new_state}
{:error, :closed} ->
Logger.info("Login client disconnected: #{state.ip}")
{:stop, :normal, state}
{:error, reason} ->
Logger.warning("Login client error: #{inspect(reason)}")
{:stop, :normal, state}
end
end
@impl true
def handle_info({:disconnect, reason}, state) do
Logger.info("Disconnecting client #{state.ip}: #{inspect(reason)}")
{:stop, :normal, state}
end
@impl true
def terminate(_reason, state) do
if state.socket do
:gen_tcp.close(state.socket)
end
:ok
end
# Process all complete packets from the TCP buffer
defp process_buffer(state) do
case extract_packet(state.buffer, state.crypto) do
{:ok, payload, remaining} ->
# Decrypt the payload (AES then Shanda) and morph recv IV
{updated_crypto, decrypted} = ClientCrypto.decrypt(state.crypto, payload)
state = %{state |
buffer: remaining,
crypto: updated_crypto,
handshake_complete: true
}
state = process_decrypted_packet(decrypted, state)
# Try to process more packets from the buffer
process_buffer(state)
{:need_more, _} ->
state
{:error, reason} ->
Logger.error("Packet error from #{state.ip}: #{inspect(reason)}")
send(self(), {:disconnect, reason})
state
end
end
# Extract a complete encrypted packet from the buffer using the 4-byte header
defp extract_packet(buffer, _crypto) when byte_size(buffer) < 4 do
{:need_more, buffer}
end
defp extract_packet(buffer, crypto) do
<<raw_seq::little-16, raw_len::little-16, rest::binary>> = buffer
# Validate header against current recv IV
if not ClientCrypto.decode_header_valid?(crypto, raw_seq) do
Logger.warning(
"Invalid packet header: raw_seq=#{raw_seq} (0x#{Integer.to_string(raw_seq, 16)}), " <>
"expected version check failed"
)
{:error, :invalid_header}
else
# Decode actual packet length from header
packet_len = ClientCrypto.decode_header_len(crypto, raw_seq, raw_len)
cond do
packet_len < 2 ->
{:error, :invalid_length_small}
packet_len > 65535 ->
{:error, :invalid_length_large}
byte_size(rest) < packet_len ->
# Incomplete packet - wait for more data (don't consume header)
{:need_more, buffer}
true ->
# Extract the encrypted payload and keep remainder
payload = binary_part(rest, 0, packet_len)
remaining = binary_part(rest, packet_len, byte_size(rest) - packet_len)
{:ok, payload, remaining}
end
end
end
defp process_decrypted_packet(decrypted_data, state) do
packet = In.new(decrypted_data)
# Read opcode (first 2 bytes)
case In.decode_short(packet) do
{opcode, packet} ->
# Extract remaining data (after opcode) for logging
remaining_data = binary_part(packet.data, packet.index, packet.length - packet.index)
# Log the decrypted packet
context = %{
ip: state.ip,
server_type: :login
}
PacketLogger.log_client_packet(opcode, remaining_data, context)
dispatch_packet(opcode, packet, state)
:error ->
Logger.warning("Failed to read packet opcode from #{state.ip}")
state
end
end
defp dispatch_packet(opcode, packet, state) do
alias Odinsea.Net.Processor
case Processor.handle(opcode, packet, state, :login) do
{:ok, new_state} ->
new_state
{:error, reason, new_state} ->
Logger.error("Packet processing error: #{inspect(reason)}")
new_state
{:disconnect, reason} ->
Logger.warning("Client disconnected: #{inspect(reason)}")
send(self(), {:disconnect, reason})
state
end
end
defp format_ip({a, b, c, d}) do
"#{a}.#{b}.#{c}.#{d}"
end
defp format_ip({a, b, c, d, e, f, g, h}) do
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
end
defp send_hello_packet(state, send_iv, recv_iv) do
# Get maple version from config
maple_version = 112
# Build hello packet
hello_packet = Packets.get_hello(maple_version, send_iv, recv_iv)
# Log the hello packet
context = %{ip: state.ip, server_type: :login}
PacketLogger.log_raw_packet("loopback", "HELLO", hello_packet, context)
# Send the hello packet (it already includes the length header)
case :gen_tcp.send(state.socket, hello_packet) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to send hello packet: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Sends a packet to the client with proper encryption.
"""
def send_packet(client_pid, packet_data) when is_pid(client_pid) do
GenServer.call(client_pid, {:send_packet, packet_data})
end
@impl true
def handle_call({:send_packet, packet_data}, _from, state) do
case encrypt_and_send(packet_data, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
defp encrypt_and_send(data, state) do
# Encrypt the data (Shanda then AES) and morph send IV
{updated_crypto, encrypted, header} = ClientCrypto.encrypt(state.crypto, data)
# Combine header and encrypted payload
full_packet = header <> encrypted
# Log the outgoing packet
context = %{ip: state.ip, server_type: :login}
PacketLogger.log_server_packet("SERVER", data, context)
# Send the packet
case :gen_tcp.send(state.socket, full_packet) do
:ok ->
{:ok, %{state | crypto: updated_crypto}}
{:error, reason} ->
Logger.error("Failed to send packet: #{inspect(reason)}")
{:error, reason}
end
end
end