331 lines
8.9 KiB
Elixir
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
|