diff --git a/lib/chessh/application.ex b/lib/chessh/application.ex index 4692489..4b03169 100644 --- a/lib/chessh/application.ex +++ b/lib/chessh/application.ex @@ -2,10 +2,13 @@ defmodule Chessh.Application do alias Chessh.{PlayerSession, Node} use Application - def initialize_player_sessions_on_node() do + def initialize_node() do # If we have more than one node running the ssh daemon, we'd want to ensure # this is restarting after every potential crash. Otherwise the player sessions # on the node would hang. + + # User session also need to be cleaned up after the node exits the pool for + # the same reason. node_id = System.fetch_env!("NODE_ID") Node.boot(node_id) PlayerSession.delete_all_on_node(node_id) @@ -16,7 +19,7 @@ defmodule Chessh.Application do opts = [strategy: :one_for_one, name: Chessh.Supervisor] with {:ok, pid} <- Supervisor.start_link(children, opts) do - initialize_player_sessions_on_node() + initialize_node() {:ok, pid} end end diff --git a/lib/chessh/client.ex b/lib/chessh/client.ex new file mode 100644 index 0000000..e69de29 diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex index ce3fc1f..57803cb 100644 --- a/lib/chessh/schema/player_session.ex +++ b/lib/chessh/schema/player_session.ex @@ -55,17 +55,22 @@ defmodule Chessh.PlayerSession do auth_fn.(player) && PlayerSession.concurrent_sessions(player) < max_sessions - Repo.insert(%PlayerSession{ - login: DateTime.utc_now(), - node_id: System.fetch_env!("NODE_ID"), - player: player, - # TODO: This PID may be wrong - need to determine if this PID is shared with disconnectfun - process: Utils.pid_to_str(self()) - }) + if authed do + Logger.debug( + "Creating session for player #{username} on node #{System.fetch_env!("NODE_ID")} with process #{inspect(self())}" + ) - player - |> Player.authentications_changeset(%{authentications: player.authentications + 1}) - |> Repo.update() + Repo.insert(%PlayerSession{ + login: DateTime.utc_now(), + node_id: System.fetch_env!("NODE_ID"), + player: player, + process: Utils.pid_to_str(self()) + }) + + player + |> Player.authentications_changeset(%{authentications: player.authentications + 1}) + |> Repo.update() + end send(self(), {:authed, authed}) end diff --git a/lib/chessh/ssh/cli.ex b/lib/chessh/ssh/cli.ex deleted file mode 100644 index 71d789b..0000000 --- a/lib/chessh/ssh/cli.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Chessh.SSH.Cli do - @behaviour :ssh_server_channel - - def init(_args) do - {:ok, %{}} - end - - def handle_msg(_message, state) do - {:ok, state} - end - - def handle_ssh_msg( - {:ssh_cm, _connection_handler, {:exit_signal, channel_id, _signal, _err, _lang}}, - state - ) do - {:stop, channel_id, state} - end - - def handle_ssh_msg(_message, state) do - {:ok, state} - end - - def terminate(_reason, _state) do - :ok - end -end diff --git a/lib/chessh/ssh/client.ex b/lib/chessh/ssh/client.ex new file mode 100644 index 0000000..35893f1 --- /dev/null +++ b/lib/chessh/ssh/client.ex @@ -0,0 +1,16 @@ +defmodule Chessh.SSH.Client do + alias Chessh.SSH.Client + require Logger + + use GenServer + + # TODO: tui_state_stack is like [:menu, :player_settings, :change_password] or [:menu, {:game, game_id}, {:game_chat, game_id}] + + defstruct [:tui_pid, :width, :height, :player_id, :tui_state_stack] + + @impl true + def init([tui_pid, width, height] = args) do + Logger.debug("#{inspect(args)}") + {:ok, %Client{tui_pid: tui_pid, width: width, height: height}} + end +end diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex index b341833..24ad259 100644 --- a/lib/chessh/ssh/daemon.ex +++ b/lib/chessh/ssh/daemon.ex @@ -61,7 +61,7 @@ defmodule Chessh.SSH.Daemon do system_dir: key_dir, pwdfun: &pwd_authenticate/4, key_cb: Chessh.SSH.ServerKey, - ssh_cli: {Chessh.SSH.Cli, []}, + ssh_cli: {Chessh.SSH.Tui, []}, # connectfun: &on_connect/3, disconnectfun: &on_disconnect/1, id_string: :random, @@ -82,23 +82,6 @@ defmodule Chessh.SSH.Daemon do def handle_info(_, state), do: {:noreply, state} - # defp on_connect(username, _inet, _method) do - # Logger.debug("#{inspect(self())} connected and is authenticated as #{username}") - # - # case Repo.get_by(Player, username: String.Chars.to_string(username)) do - # nil -> - # nil - # - # player -> - # Repo.insert(%PlayerSession{ - # login: DateTime.utc_now(), - # node_id: System.fetch_env!("NODE_ID"), - # player: player, - # process: pid_to_str(self()) - # }) - # end - # end - defp on_disconnect(_reason) do Logger.debug("#{inspect(self())} disconnected") diff --git a/lib/chessh/ssh/tui.ex b/lib/chessh/ssh/tui.ex new file mode 100644 index 0000000..78360a9 --- /dev/null +++ b/lib/chessh/ssh/tui.ex @@ -0,0 +1,163 @@ +defmodule Chessh.SSH.Tui do + require Logger + + @behaviour :ssh_server_channel + + def init(opts) do + Logger.debug("#{inspect(opts)}") + + {:ok, + %{ + channel: nil, + cm: nil, + pty: %{term: nil, width: nil, height: nil, pixwidth: nil, pixheight: nil, modes: nil}, + shell: false, + client_pid: nil + }} + end + + @spec handle_msg(any, any) :: + :ok + | {:ok, atom | %{:channel => any, :cm => any, optional(any) => any}} + | {:stop, any, %{:channel => any, :client_pid => any, optional(any) => any}} + def handle_msg({:ssh_channel_up, channel_id, connection_handler}, state) do + Logger.debug( + "SSH CHANNEL UP #{inspect(connection_handler)} #{inspect(:ssh.connection_info(connection_handler))}" + ) + + {:ok, %{state | channel: channel_id, cm: connection_handler}} + end + + def handle_msg({:EXIT, client_pid, _reason}, %{client_pid: client_pid} = state) do + {:stop, state.channel, state} + end + + ### commands we expect from the client ### + def handle_msg({:send_data, data}, state) do + Logger.debug("DATA SENT #{inspect(data)}") + :ssh_connection.send(state.cm, state.channel, data) + {:ok, state} + end + + ### catch all for what we haven't seen ### + def handle_msg(msg, term) do + Logger.debug("Unknown msg #{inspect(msg)}, #{inspect(term)}") + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:data, _channel_id, _type, data}}, + state + ) do + Logger.debug("DATA #{inspect(data)}") + send(state.client_pid, {:data, data}) + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, + {:pty, channel_id, want_reply?, {term, width, height, pixwidth, pixheight, modes} = _pty}}, + state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) + + {:ok, + %{ + state + | pty: %{ + term: term, + width: width, + height: height, + pixwidth: pixwidth, + pixheight: pixheight, + modes: modes + } + }} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, {:env, channel_id, want_reply?, var, value}}, + state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :failure, channel_id) + Logger.debug("ENV #{var} = #{value}") + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, + {:window_change, _channel_id, width, height, pixwidth, pixheight}}, + state + ) do + Logger.debug("WINDOW CHANGE") + # SSHnakes.Client.resize(state.client_pid, width, height) + + {:ok, + %{ + state + | pty: %{ + state.pty + | width: width, + height: height, + pixwidth: pixwidth, + pixheight: pixheight + } + }} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}}, + state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) + + {:ok, client_pid} = + GenServer.start_link(Chessh.SSH.Client, [self(), state.pty.width, state.pty.height]) + + {:ok, %{state | client_pid: client_pid, shell: true}} + end + + def handle_ssh_msg( + {:ssh_cm, connection_handler, {:exec, channel_id, want_reply?, cmd}}, + state + ) do + :ssh_connection.reply_request(connection_handler, want_reply?, :success, channel_id) + Logger.debug("EXEC #{cmd}") + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:eof, _channel_id}}, + state + ) do + Logger.debug("EOF") + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:signal, _channel_id, signal}}, + state + ) do + Logger.debug("SIGNAL #{signal}") + {:ok, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:exit_signal, channel_id, signal, err, lang}}, + state + ) do + Logger.debug("EXIT SIGNAL #{signal} #{err} #{lang}") + {:stop, channel_id, state} + end + + def handle_ssh_msg( + {:ssh_cm, _connection_handler, {:exit_STATUS, channel_id, status}}, + state + ) do + Logger.debug("EXIT STATUS #{status}") + {:stop, channel_id, state} + end + + def terminate(_reason, _state) do + :ok + end +end diff --git a/mix.exs b/mix.exs index 441fb98..e77428a 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ defmodule Chessh.MixProject do defp aliases do [ - test: ["ecto.create --quiet", "ecto.migrate", "test"] + test: ["ecto.create --quiet", "ecto.migrate", "test --seed 0"] ] end end diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..13d4389 --- /dev/null +++ b/test/README.md @@ -0,0 +1,3 @@ +TODO: +- [ ] When password changed, remove all sessions +- [ ] When session removed, ssh connection closed diff --git a/test/ssh/ssh_auth_test.exs b/test/ssh/ssh_auth_test.exs index 92c4b22..b6711c7 100644 --- a/test/ssh/ssh_auth_test.exs +++ b/test/ssh/ssh_auth_test.exs @@ -34,6 +34,20 @@ defmodule Chessh.SSH.AuthTest do Process.sleep(1_000) end + def send_ssh_connection_to_pid(parent, auth_method) do + send( + parent, + {:attempted, + :ssh.connect(@localhost, Application.fetch_env!(:chessh, :port), + user: String.to_charlist(@valid_user.username), + password: String.to_charlist(@valid_user.password), + auth_methods: auth_method, + silently_accept_hosts: true, + user_dir: String.to_charlist(@client_test_keys_dir) + )} + ) + end + test "Password attempts are rate limited" do jail_attempt_threshold = Application.get_env(:chessh, RateLimits) @@ -88,7 +102,7 @@ defmodule Chessh.SSH.AuthTest do cleanup() end - test "INTEGRATION - Player cannot have more than specified concurrent sessions" do + test "INTEGRATION - Player cannot have more than specified concurrent sessions which are tracked by successful authentications and disconnections" do max_concurrent_user_sessions = Application.get_env(:chessh, RateLimits) |> Keyword.get(:max_concurrent_user_sessions) @@ -99,29 +113,22 @@ defmodule Chessh.SSH.AuthTest do test_pid = self() Enum.reduce(0..(max_concurrent_user_sessions + 1), fn i, _ -> - Task.Supervisor.start_child(sup, fn -> - case :ssh.connect(@localhost, Application.fetch_env!(:chessh, :port), - user: String.to_charlist(@valid_user.username), - password: String.to_charlist(@valid_user.password), - auth_methods: if(rem(i, 2) == 0, do: 'publickey', else: 'password'), - silently_accept_hosts: true, - user_dir: String.to_charlist(@client_test_keys_dir) - ) do - {:ok, conn} -> - send( - test_pid, - {:attempted, {:ok, conn}} - ) - - x -> - send(test_pid, {:attempted, x}) + Task.Supervisor.start_child( + sup, + fn -> + send_ssh_connection_to_pid( + test_pid, + if(rem(i, 2) == 0, do: 'publickey', else: 'password') + ) end - end) + ) end) - Enum.reduce(0..max_concurrent_user_sessions, fn _, _ -> - assert_receive({:attempted, {:ok, _conn}}, 2000) - end) + conns = + Enum.map(1..max_concurrent_user_sessions, fn _ -> + assert_receive({:attempted, {:ok, conn}}, 2_000) + conn + end) assert_receive( {:attempted, {:error, 'Unable to connect using the available authentication methods'}}, @@ -133,6 +140,10 @@ defmodule Chessh.SSH.AuthTest do :timer.sleep(100) assert PlayerSession.concurrent_sessions(player) == max_concurrent_user_sessions + Enum.map(conns, fn conn -> :ssh.close(conn) end) + :timer.sleep(100) + assert PlayerSession.concurrent_sessions(player) == 0 + cleanup() end end