diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex index 57803cb..8ca338c 100644 --- a/lib/chessh/schema/player_session.ex +++ b/lib/chessh/schema/player_session.ex @@ -34,7 +34,7 @@ defmodule Chessh.PlayerSession do ) end - def player_within_concurrent_sessions_and_satisfies(username, auth_fn) do + def update_sessions_and_player_satisfies(username, auth_fn) do max_sessions = Application.get_env(:chessh, RateLimits) |> Keyword.get(:max_concurrent_user_sessions) @@ -51,9 +51,7 @@ defmodule Chessh.PlayerSession do send(self(), {:authed, false}) player -> - authed = - auth_fn.(player) && - PlayerSession.concurrent_sessions(player) < max_sessions + authed = auth_fn.(player) if authed do Logger.debug( @@ -67,6 +65,29 @@ defmodule Chessh.PlayerSession do process: Utils.pid_to_str(self()) }) + concurrent_sessions = PlayerSession.concurrent_sessions(player) + + if concurrent_sessions > max_sessions do + expired_sessions = + Repo.all( + from(p in PlayerSession, + select: p.id, + order_by: [asc: :login], + limit: ^(concurrent_sessions - max_sessions) + ) + ) + + Logger.debug( + "Player #{player.username} has #{length(expired_sessions)} expired sessions - attempting to close them" + ) + + Enum.map(expired_sessions, fn session_id -> + :syn.publish(:player_sessions, {:session, session_id}, :session_closed) + end) + + Repo.delete_all(from(p in PlayerSession, where: p.id in ^expired_sessions)) + end + player |> Player.authentications_changeset(%{authentications: player.authentications + 1}) |> Repo.update() diff --git a/lib/chessh/ssh/client.ex b/lib/chessh/ssh/client.ex index 35893f1..eba188f 100644 --- a/lib/chessh/ssh/client.ex +++ b/lib/chessh/ssh/client.ex @@ -1,16 +1,27 @@ defmodule Chessh.SSH.Client do - alias Chessh.SSH.Client + alias IO.ANSI require Logger use GenServer - # TODO: tui_state_stack is like [:menu, :player_settings, :change_password] or [:menu, {:game, game_id}, {:game_chat, game_id}] + @default_message [ + ANSI.clear(), + ANSI.reset(), + ANSI.home(), + ["Hello, world"] + ] - defstruct [:tui_pid, :width, :height, :player_id, :tui_state_stack] + defmodule State do + defstruct tui_pid: nil, + width: nil, + height: nil, + player_session: nil, + state_statck: [] + end @impl true - def init([tui_pid, width, height] = args) do - Logger.debug("#{inspect(args)}") - {:ok, %Client{tui_pid: tui_pid, width: width, height: height}} + def init([%State{tui_pid: tui_pid} = state]) do + send(tui_pid, {:send_data, @default_message}) + {:ok, state} end end diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex index 24ad259..1748e9e 100644 --- a/lib/chessh/ssh/daemon.ex +++ b/lib/chessh/ssh/daemon.ex @@ -1,6 +1,8 @@ defmodule Chessh.SSH.Daemon do alias Chessh.{Repo, PlayerSession, Utils} alias Chessh.Auth.PasswordAuthenticator + alias Chessh.SSH.{ServerKey, Tui} + use GenServer import Ecto.Query @@ -30,24 +32,30 @@ defmodule Chessh.SSH.Daemon do String.Chars.to_string(password) ) do false -> + Logger.debug( + "#{username} on bucket #{rateId} got their password wrong, or they don't exist! Point at them and laugh!!!!" + ) + case Hammer.check_rate_inc(rateId, jail_timeout_ms, jail_attempt_threshold, 1) do {:allow, _count} -> + Logger.debug("Bucket #{rateId} can continue to brute force though") false {:deny, _limit} -> + Logger.debug("Bucket #{rateId} ran out of password attempts") :disconnect end x -> - if PlayerSession.player_within_concurrent_sessions_and_satisfies(username, fn _player -> - x - end), - do: true, - else: :disconnect + PlayerSession.update_sessions_and_player_satisfies(username, fn _player -> + x + end) + + x end end - def pwd_authenticate(username, password, inet, _address), + def pwd_authenticate(username, password, inet, _state), do: pwd_authenticate(username, password, inet) def handle_cast(:start, state) do @@ -57,19 +65,19 @@ defmodule Chessh.SSH.Daemon do case :ssh.daemon( port, - # shell: fn _username, _peer -> Process.sleep(5000) end, system_dir: key_dir, pwdfun: &pwd_authenticate/4, - key_cb: Chessh.SSH.ServerKey, - ssh_cli: {Chessh.SSH.Tui, []}, - # connectfun: &on_connect/3, + key_cb: ServerKey, + ssh_cli: {Tui, [%Tui.State{}]}, disconnectfun: &on_disconnect/1, id_string: :random, - subsystems: [], parallel_login: true, - max_sessions: max_sessions + max_sessions: max_sessions, + subsystems: [] ) do {:ok, pid} -> + Logger.info("SSH server started on port #{port}, on #{inspect(pid)}") + Process.link(pid) {:noreply, %{state | pid: pid}, :hibernate} diff --git a/lib/chessh/ssh/server_key.ex b/lib/chessh/ssh/server_key.ex index 5252624..eae9577 100644 --- a/lib/chessh/ssh/server_key.ex +++ b/lib/chessh/ssh/server_key.ex @@ -5,9 +5,9 @@ defmodule Chessh.SSH.ServerKey do @behaviour :ssh_server_key_api def is_auth_key(key, username, _daemon_options) do - PlayerSession.player_within_concurrent_sessions_and_satisfies( + PlayerSession.update_sessions_and_player_satisfies( username, - &KeyAuthenticator.authenticate(&1, key) + fn player -> KeyAuthenticator.authenticate(player, key) end ) end diff --git a/lib/chessh/ssh/tui.ex b/lib/chessh/ssh/tui.ex index 78360a9..c0fc910 100644 --- a/lib/chessh/ssh/tui.ex +++ b/lib/chessh/ssh/tui.ex @@ -1,45 +1,90 @@ defmodule Chessh.SSH.Tui do + alias Chessh.{Repo, PlayerSession, Utils, Player} + alias Chessh.SSH.Client + + alias IO.ANSI + require Logger @behaviour :ssh_server_channel + @session_closed_message [ + ANSI.clear(), + ["This session has been closed"] + ] - 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 - }} + defmodule State do + defstruct channel_id: nil, + width: nil, + height: nil, + client_pid: nil, + connection_ref: nil, + player_session: 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))}" - ) + def init([%State{} = init_state]) do + :syn.add_node_to_scopes([:player_sessions]) + {:ok, init_state} + end - {:ok, %{state | channel: channel_id, cm: connection_handler}} + def handle_msg({:ssh_channel_up, channel_id, connection_ref}, state) do + Logger.debug("SSH channel up #{inspect(:ssh.connection_info(connection_ref))}") + + connected_player = + :ssh.connection_info(connection_ref) + |> Keyword.fetch!(:user) + |> String.Chars.to_string() + + case Repo.get_by(Player, username: connected_player) do + nil -> + Logger.error("Killing channel #{channel_id} - auth'd user does not exist") + {:stop, channel_id, state} + + player -> + case Repo.get_by(PlayerSession, + node_id: System.fetch_env!("NODE_ID"), + process: Utils.pid_to_str(connection_ref), + player_id: player.id + ) do + nil -> + Logger.error("Killing channel #{channel_id} - session does not exist") + {:stop, channel_id, state} + + session -> + Logger.debug("Subscribing to session #{session.id}") + :syn.join(:player_sessions, {:session, session.id}, self()) + + {:ok, + %{ + state + | channel_id: channel_id, + connection_ref: connection_ref, + player_session: session + }} + end + end 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) + def handle_msg( + {:send_data, data}, + %{connection_ref: connection_ref, channel_id: channel_id} = state + ) do + Logger.debug("Data was sent to TUI process #{inspect(data)}") + :ssh_connection.send(connection_ref, channel_id, data) {:ok, state} end - ### catch all for what we haven't seen ### + def handle_msg( + :session_closed, + %{connection_ref: connection_ref, channel_id: channel_id} = state + ) do + :ssh_connection.send(connection_ref, channel_id, @session_closed_message) + {:stop, channel_id, state} + end + def handle_msg(msg, term) do Logger.debug("Unknown msg #{inspect(msg)}, #{inspect(term)}") end @@ -49,28 +94,23 @@ defmodule Chessh.SSH.Tui do state ) do Logger.debug("DATA #{inspect(data)}") - send(state.client_pid, {:data, 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}}, + {:pty, channel_id, want_reply?, {_term, width, height, _pixwidth, _pixheight, _opts}}}, state ) do + Logger.debug("#{inspect(state.player_session)} has requested a PTY") :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 - } + | width: width, + height: height }} end @@ -85,35 +125,37 @@ defmodule Chessh.SSH.Tui do def handle_ssh_msg( {:ssh_cm, _connection_handler, - {:window_change, _channel_id, width, height, pixwidth, pixheight}}, + {:window_change, _channel_id, width, height, _pixwidth, _pixheight}}, state ) do Logger.debug("WINDOW CHANGE") - # SSHnakes.Client.resize(state.client_pid, width, height) - + # Chessh.SSH.Client.resize(state.client_pid, width, height) {:ok, %{ state - | pty: %{ - state.pty - | width: width, - height: height, - pixwidth: pixwidth, - pixheight: pixheight - } + | width: width, + height: height }} end def handle_ssh_msg( {:ssh_cm, connection_handler, {:shell, channel_id, want_reply?}}, - state + %{width: width, height: height, player_session: player_session} = state ) do + Logger.debug("Session #{player_session.id} requested shell") :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]) + GenServer.start_link(Client, [ + %Client.State{ + tui_pid: self(), + width: width, + player_session: player_session, + height: height + } + ]) - {:ok, %{state | client_pid: client_pid, shell: true}} + {:ok, %{state | client_pid: client_pid}} end def handle_ssh_msg( @@ -157,6 +199,14 @@ defmodule Chessh.SSH.Tui do {:stop, channel_id, state} end + def handle_ssh_msg( + msg, + %{channel_id: channel_id} = state + ) do + Logger.debug("UNKOWN MESSAGE #{inspect(msg)}") + {:stop, channel_id, state} + end + def terminate(_reason, _state) do :ok end diff --git a/mix.exs b/mix.exs index e77428a..6066333 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,7 @@ defmodule Chessh.MixProject do def application do [ mod: {Chessh.Application, []}, - extra_applications: [:logger, :crypto, :ssh] + extra_applications: [:logger, :crypto, :syn, :ssh] ] end @@ -32,7 +32,8 @@ defmodule Chessh.MixProject do {:ecto_sql, "~> 3.9"}, {:postgrex, "~> 0.16.5"}, {:bcrypt_elixir, "~> 3.0"}, - {:hammer, "~> 6.1"} + {:hammer, "~> 6.1"}, + {:syn, "~> 3.3"} ] end diff --git a/mix.lock b/mix.lock index e5bd298..c376f97 100644 --- a/mix.lock +++ b/mix.lock @@ -13,5 +13,6 @@ "hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, + "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, } diff --git a/test/ssh/ssh_auth_test.exs b/test/ssh/ssh_auth_test.exs index b6711c7..03063ed 100644 --- a/test/ssh/ssh_auth_test.exs +++ b/test/ssh/ssh_auth_test.exs @@ -43,7 +43,8 @@ defmodule Chessh.SSH.AuthTest do password: String.to_charlist(@valid_user.password), auth_methods: auth_method, silently_accept_hosts: true, - user_dir: String.to_charlist(@client_test_keys_dir) + user_dir: String.to_charlist(@client_test_keys_dir), + disconnectfun: fn _reason -> send(parent, {:disconnected, self()}) end )} ) end @@ -66,6 +67,20 @@ defmodule Chessh.SSH.AuthTest do ) end + test "Player authentications are increased after a successful authentication" do + player_before = Repo.get_by(Player, username: @valid_user.username) + + Chessh.SSH.Daemon.pwd_authenticate( + @valid_user.username, + @valid_user.password, + @localhost_inet + ) + + player_after = Repo.get_by(Player, username: @valid_user.username) + + assert(player_after.authentications - player_before.authentications == 1) + end + test "INTEGRATION - Can ssh into daemon with password or public key" do {:ok, sup} = Task.Supervisor.start_link() test_pid = self() @@ -83,6 +98,8 @@ defmodule Chessh.SSH.AuthTest do send(test_pid, :connected_via_password) end) + assert_receive(:connected_via_password, 2_000) + Task.Supervisor.start_child(sup, fn -> {:ok, conn} = :ssh.connect(@localhost, Application.fetch_env!(:chessh, :port), @@ -96,13 +113,12 @@ defmodule Chessh.SSH.AuthTest do send(test_pid, :connected_via_public_key) end) - assert_receive(:connected_via_password, 2_000) assert_receive(:connected_via_public_key, 2_000) cleanup() end - test "INTEGRATION - Player cannot have more than specified concurrent sessions which are tracked by successful authentications and disconnections" do + test "INTEGRATION - SSH Sessions are closed once player has 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) @@ -130,10 +146,7 @@ defmodule Chessh.SSH.AuthTest do conn end) - assert_receive( - {:attempted, {:error, 'Unable to connect using the available authentication methods'}}, - 2000 - ) + assert_receive({:disconnected, _conn}, 2_000) # Give it time to send back the disconnection payload after session was opened # but over threshold