diff --git a/src/avatar.gleam b/src/avatar.gleam index 37b40ee..f84c287 100644 --- a/src/avatar.gleam +++ b/src/avatar.gleam @@ -1,54 +1,75 @@ -import gleam/bit_array -import gleam/bytes_builder +import cipher +import client.{type Client, Client} +import gleam/erlang import gleam/erlang/process import gleam/int import gleam/io -import gleam/option.{None} +import gleam/option import gleam/otp/actor -import gleam/result -import gleam/string -import glisten.{Packet, User} +import glisten -const port = 4000 +// TODO: Make this configurable. The defaults in UO's login.cfg specify 4 login +// servers running on two different ports (7775 and 7776). +const port = 7775 + +// Number of login servers * pool size = login server concurrency +const pool_size = 10 + +type Connection = + glisten.Connection(Client) + +type Message = + glisten.Message(Client) pub fn main() { let assert Ok(_) = - glisten.handler(fn(_conn) { #(Nil, None) }, fn(msg, state, conn) { - case msg { - Packet(bit_array) -> { - let assert Ok(info) = glisten.get_client_info(conn) - let ip = glisten.ip_address_to_string(info.ip_address) - let ip_and_port = ip <> ":" <> int.to_string(info.port) - let bytes = bit_array.inspect(bit_array) - let n = bit_array.byte_size(bit_array) - let text = - bit_array.to_string(bit_array) - |> result.unwrap("(error)") - |> string.trim() - - io.println( - ip_and_port - <> ": " - <> text - <> " (" - <> int.to_string(n) - <> " bytes)\n\t" - <> bytes, - ) - - let assert Ok(_) = - glisten.send(conn, bytes_builder.from_string("ok\n")) - - actor.continue(state) - } - User(_user_message) -> { - todo - } - } - }) + glisten.handler(on_init, on_message) + |> glisten.with_pool_size(pool_size) + |> glisten.with_close(on_close) |> glisten.serve(port) io.println("Listening on port " <> int.to_string(port)) process.sleep_forever() } + +fn on_init(connection: Connection) { + let client = Client(connection, <<>>, client.Unauthenticated, cipher.nil()) + + io.println("new connection from " <> client.inspect_socket(client)) + + #(client, option.None) +} + +fn on_message(message: Message, client: Client, conn: Connection) { + // This assertion is safe because on_init doesn't return a selector, so there + // won't ever be any User-type messages. + let assert glisten.Packet(data) = message + + // TODO: check data size and halt if > max_packet_size + + // TODO: check client state – no need to store more data if the client is + // about to be disconnected. + + // Clients often send partial or multiple commands at one time, so here all + // that needs to be done is append the incoming data to the client's buffer, + // then wait for the next read. + let new_client = Client(..client, conn: conn, buf: <>) + io.println(client.inspect(new_client)) + + case client.do_work(new_client) { + Ok(client) -> actor.continue(client) + Error(err) -> { + io.println( + client.inspect_socket(client) <> ": error! " <> erlang.format(err), + ) + actor.Stop(process.Normal) + } + } +} + +fn on_close(client: Client) { + // The connection has already been closed, so writes will fail in here. + io.println(client.inspect(client) <> ": connection closed.") + // TODO: flag client as disconnected? +} diff --git a/src/client.gleam b/src/client.gleam new file mode 100644 index 0000000..7f8e69e --- /dev/null +++ b/src/client.gleam @@ -0,0 +1,365 @@ +import cipher.{type PlainText, CipherText, PlainText} +import gleam/bit_array +import gleam/bool +import gleam/bytes_tree.{type BytesTree} +import gleam/erlang +import gleam/int +import gleam/io +import gleam/list +import gleam/result +import gleam/string +import gleam/yielder +import glisten +import glisten/socket +import glisten/tcp + +pub type Client { + Client( + conn: glisten.Connection(Client), + buf: BitArray, + state: State, + cipher: cipher.Cipher, + ) +} + +pub type State { + Disconnected + Unauthenticated + Authenticating + Authenticated + PickingGameServer + NeedsRelay(GameServer) + Relayed +} + +pub const max_packet_size = 0xF000 + +pub type ReadError { + TooSmall + TooLarge + SocketError(socket.SocketReason) +} + +fn read(client: Client, size: Int) -> Result(#(Client, PlainText), ReadError) { + // size ≤ 0 -> error + use <- bool.guard(size <= 0, return: Error(TooSmall)) + // size > max_packet_size -> error + use <- bool.guard(size > max_packet_size, return: Error(TooLarge)) + + let buf_size = bit_array.byte_size(client.buf) + + case size { + n if n == max_packet_size -> todo as "Implement variable-length packets" + n if n <= buf_size -> { + // Slice off n bytes from head of client.buf and decrypt and return them: + io.println( + inspect_socket(client) + <> ": reading " + <> int.to_string(size) + <> "/" + <> int.to_string(buf_size) + <> " bytes (from buffer)", + ) + dump_buf(client) + + // TODO: remove assert? I think Gleam isn't sure there're at least n bytes + // in client.buf, but this is certain from the enclosing pattern. + let assert <> = client.buf + let #(cipher, plaintext) = cipher.decrypt(client.cipher, CipherText(data)) + let new_client = Client(..client, cipher: cipher, buf: new_buf) + dump_buf(new_client) + Ok(#(new_client, plaintext)) + } + n if n > buf_size -> { + // More bytes have been requested than are buffered; read more into + // client.buf from the socket, then call read(client, size) again: + io.println( + inspect_socket(client) + <> ": need " + <> int.to_string(n - buf_size) + <> " more bytes; reading from socket...", + ) + case tcp.receive(client.conn.socket, 0) { + Ok(data) -> { + let new_buf = <> + read(Client(..client, buf: new_buf), size) + } + Error(reason) -> { + io.debug(reason) + Error(SocketError(reason)) + } + } + } + _ -> todo as "is this reachable?" + } +} + +fn write(client: Client, plaintext: PlainText) -> Result(Client, ClientError) { + let #(new_cipher, ciphertext) = cipher.encrypt(client.cipher, plaintext) + let bytes = bytes_tree.from_bit_array(ciphertext.bits) + let new_client = Client(..client, cipher: new_cipher) + + case tcp.send(client.conn.socket, bytes) { + Ok(_) -> Ok(new_client) + Error(reason) -> Error(WriteError(SocketError2(reason))) + } +} + +pub type WriteError { + InvalidState + SocketError2(socket.SocketReason) + // TODO: rename me +} + +pub type ClientError { + NotConnected + ReadError(ReadError) + WriteError(WriteError) + UnexpectedCommand(String) +} + +pub fn do_work(client: Client) -> Result(Client, ClientError) { + io.println(inspect(client) <> ": do_work") + + case client.state { + Disconnected -> Error(NotConnected) + // TODO: make these names more betterer + Unauthenticated -> handle_login_seed(client) + Authenticating -> handle_login_request(client) + Authenticated -> send_game_server_list(client) + PickingGameServer -> handle_select_server(client) + NeedsRelay(game_server) -> relay_to_game_server(client, game_server) + Relayed -> todo + } +} + +fn handle_login_seed(client: Client) -> Result(Client, ClientError) { + io.println(inspect_socket(client) <> ": authenticating...") + dump_buf(client) + + // receive 0xEF Login Seed (21), unencrypted + case read(client, 21) { + Ok(#(new_client, plaintext)) -> { + case plaintext.bits { + << + 0xEF, + seed:big-int-size(32), + major:big-int-size(32), + minor:big-int-size(32), + revision:big-int-size(32), + prototype:big-int-size(32), + >> -> { + io.println( + inspect_socket(new_client) + <> ": got 0xEF Login Seed (21), constructing login cipher", + ) + // TODO: remove assertions + let assert Ok(version) = + cipher.new_version(major, minor, revision, prototype) + io.println( + inspect_socket(new_client) <> ": " <> erlang.format(version), + ) + let assert Ok(seed) = cipher.new_seed(seed) + io.println(inspect_socket(new_client) <> ": " <> erlang.format(seed)) + let assert Ok(new_cipher) = cipher.login(seed, version) + let new_client = + Client(..new_client, state: Authenticating, cipher: new_cipher) + // TODO: hacky because currently client actors only move forward when + // receiving new data (via glisten's on_message handler). fix this by + // making reads happen in the actor, so we're in full control of when + // client state needs to change. + do_work(new_client) + } + _ -> todo + } + } + Error(err) -> { + io.println(inspect_socket(client) <> ": read error!") + Error(ReadError(err)) + } + _ -> todo + } +} + +fn handle_login_request(client: Client) -> Result(Client, ClientError) { + io.println(inspect_socket(client) <> ": derping...") + dump_buf(client) + + // receive 0x80 Login Request (62), encrypted + let assert Ok(#(new_client, plaintext)) = read(client, 62) + io.println("decrypted Login Request: " <> string.inspect(plaintext.bits)) + + // TODO: authenticate the client. + // 1. credential match + // 2. IP-in-use check + // 3. ban check + + let new_client = Client(..new_client, state: Authenticated) + // TODO: hacky because currently client actors only move forward when + // receiving new data (via glisten's on_message handler). fix this by + // making reads happen in the actor, so we're in full control of when + // client state needs to change. + do_work(new_client) +} + +// TODO: client expects that a single byte identifies a time zone. Figure out +// the map of tzdata canonical zones to client-compatible bytes. +pub type TimeZone { + AmericaDetroit +} + +pub type IPv4 = + #(Int, Int, Int, Int) + +fn reversed_ip(ip: IPv4) -> Int { + // (192 << 24) | (168 << 16) | (68 << 8) | 58 + // but, reversed... for some reason. + int.bitwise_shift_left(ip.3, 24) + |> int.bitwise_or(int.bitwise_shift_left(ip.2, 16)) + |> int.bitwise_or(int.bitwise_shift_left(ip.1, 8)) + |> int.bitwise_or(ip.0) + |> io.debug +} + +pub type GameServer { + GameServer(name: String, time_zone: TimeZone, ip: IPv4, port: Int) +} + +fn encode_game_server(game_server: GameServer) -> BytesTree { + // server name must be exactly 32 bytes (padded with null bytes). + let name = + game_server.name + |> string.pad_end(32, "\u{0000}") + |> string.slice(0, 32) + io.debug(name) + + bytes_tree.new() + |> bytes_tree.append_string(name) + // TODO: percent full: + |> bytes_tree.append(<<10>>) + // TODO: time zone: + |> bytes_tree.append(<<1>>) + |> bytes_tree.append(<>) +} + +type Packet { + /// 0xA8 Game Server List + GameServerList(servers: List(GameServer)) + + /// 0x8C Connect to Game Server + ConnectToGameServer(server: GameServer) +} + +// TODO: fetch from ... somehere. +const game_servers = [ + GameServer("Test Shard 1", AmericaDetroit, #(192, 168, 68, 58), 7775), +] + +// TODO: https://docs.polserver.com/packets/index.php?Packet=0xA8 +const system_info_flag = 0xCC + +/// Encode a packet to a byte tree. +fn encode_packet(packet: Packet) -> BytesTree { + let bytes = case packet { + GameServerList(servers) -> { + let count = list.length(servers) + let server_list = + list.index_fold(servers, bytes_tree.new(), fn(bytes, server, i) { + bytes + |> bytes_tree.append(<>) + |> bytes_tree.append_tree(encode_game_server(server)) + }) + let length = 6 + bytes_tree.byte_size(server_list) + + bytes_tree.new() + |> bytes_tree.append(<<0xA8, length:16, system_info_flag:8, count:16>>) + |> bytes_tree.append_tree(server_list) + } + ConnectToGameServer(game_server) -> { + bytes_tree.new() + |> bytes_tree.append(<< + 0x8C, + reversed_ip(game_server.ip):32, + game_server.port:16, + 0:32, + >>) + } + } + + io.println(bit_array.inspect(bytes_tree.to_bit_array(bytes))) + + bytes +} + +fn send_game_server_list(client: Client) -> Result(Client, ClientError) { + io.println(inspect_socket(client) <> ": sending game server list...") + dump_buf(client) + + let packet = GameServerList(game_servers) + let data = PlainText(encode_packet(packet) |> bytes_tree.to_bit_array) + + let assert Ok(new_client) = write(client, data) + do_work(Client(..new_client, state: PickingGameServer)) +} + +fn handle_select_server(client: Client) -> Result(Client, ClientError) { + io.println(inspect_socket(client) <> ": waiting on server selection") + // TODO: this will hang if client has sent 0xD9 Client Info + let assert Ok(#(new_client, plaintext)) = read(client, 3) + + // plaintext could be either 0xD9 Client Info or 0xA0 Select Server + case plaintext.bits { + <<0xA0, shard_index:16>> -> { + let game_server = + yielder.from_list(game_servers) + |> yielder.at(shard_index) + |> result.lazy_unwrap(fn() { + panic as "handle_select_server: index out of range" + }) + io.debug(#(shard_index, game_server)) + do_work(Client(..new_client, state: NeedsRelay(game_server))) + } + <<0xD9, _:bits>> -> todo + _ -> todo + } +} + +fn relay_to_game_server(client: Client, server: GameServer) -> Result(Client, ClientError) { + io.println(inspect_socket(client) <> ": relaying to " <> server.name) + + let packet = ConnectToGameServer(server) + let data = PlainText(encode_packet(packet) |> bytes_tree.to_bit_array) + let assert Ok(new_client) = write(client, data) + + Ok(Client(..new_client, state: Relayed)) +} + +pub fn inspect_socket(client: Client) -> String { + glisten.get_client_info(client.conn) + |> result.map(fn(info) { + let ip = glisten.ip_address_to_string(info.ip_address) + let port = int.to_string(info.port) + + ip <> ":" <> port + }) + |> result.unwrap("?") +} + +fn dump_buf(client: Client) { + io.println("\t" <> bit_array.inspect(client.buf)) + Nil +} + +pub fn inspect(client: Client) -> String { + let socket = inspect_socket(client) + let string = socket <> ": " <> string.inspect(client.state) + + case client.buf { + <<>> -> string <> " (0 bytes)" + _ -> { + let buf = bit_array.inspect(client.buf) + let size = bit_array.byte_size(client.buf) + string <> " (" <> int.to_string(size) <> " bytes)\n\t" <> buf + } + } +}