summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimponic <loganhunt@simponic.xyz>2022-12-29 17:21:20 -0700
committerSimponic <loganhunt@simponic.xyz>2022-12-29 17:21:20 -0700
commit1a2bdccf124de6207899f59538cc0ed2efc97b5a (patch)
tree5b582023531c8df637881899ab64d5f5eddd7f3f
parent10bc34245e8e1e3ba63fb0720d3bcfb1119db921 (diff)
downloadchessh-1a2bdccf124de6207899f59538cc0ed2efc97b5a.tar.gz
chessh-1a2bdccf124de6207899f59538cc0ed2efc97b5a.zip
Add scalable nodes and user sessions
-rw-r--r--.env1
-rw-r--r--.env.example1
-rw-r--r--config/config.exs6
-rw-r--r--config/test.exs5
-rw-r--r--lib/chessh/application.ex16
-rw-r--r--lib/chessh/auth/password.ex4
-rw-r--r--lib/chessh/schema/node.ex24
-rw-r--r--lib/chessh/schema/player_session.ex34
-rw-r--r--lib/chessh/ssh/daemon.ex45
-rw-r--r--lib/chessh/ssh/server_key.ex3
-rw-r--r--mix.exs2
-rw-r--r--priv/repo/migrations/20221229225556_add_node.exs10
-rw-r--r--priv/repo/migrations/20221229225559_add_user_session.exs11
-rw-r--r--test/auth/password_test.exs8
-rw-r--r--test/ssh/ssh_auth_test-emacs-elixir-format.exs81
-rw-r--r--test/ssh/ssh_auth_test.exs27
16 files changed, 232 insertions, 46 deletions
diff --git a/.env b/.env
new file mode 100644
index 0000000..20bab31
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+NODE_ID=node-one
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d87dcc8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+NODE_ID=aUniqueString \ No newline at end of file
diff --git a/config/config.exs b/config/config.exs
index 2136a60..c919a33 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -3,10 +3,14 @@ import Config
config :chessh,
ecto_repos: [Chessh.Repo],
key_dir: Path.join(Path.dirname(__DIR__), "priv/keys"),
- max_password_attempts: 3,
port: 42_069,
max_sessions: 255
+config :chessh, RateLimits,
+ jail_timeout_ms: 5 * 60 * 1000,
+ jail_threshold: 15,
+ max_concurrent_user_sessions: 5
+
config :hammer,
backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]}
diff --git a/config/test.exs b/config/test.exs
index db11bb4..bdaf9f1 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,7 +1,8 @@
import Config
-config :hammer,
- backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]}
+config :chessh, RateLimits,
+ jail_timeout_ms: 1000,
+ jail_threshold: 2
config :chessh, Chessh.Repo,
database: "chessh-test",
diff --git a/lib/chessh/application.ex b/lib/chessh/application.ex
index 847dd98..4692489 100644
--- a/lib/chessh/application.ex
+++ b/lib/chessh/application.ex
@@ -1,9 +1,23 @@
defmodule Chessh.Application do
+ alias Chessh.{PlayerSession, Node}
use Application
+ def initialize_player_sessions_on_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.
+ node_id = System.fetch_env!("NODE_ID")
+ Node.boot(node_id)
+ PlayerSession.delete_all_on_node(node_id)
+ end
+
def start(_, _) do
children = [Chessh.Repo, Chessh.SSH.Daemon]
opts = [strategy: :one_for_one, name: Chessh.Supervisor]
- Supervisor.start_link(children, opts)
+
+ with {:ok, pid} <- Supervisor.start_link(children, opts) do
+ initialize_player_sessions_on_node()
+ {:ok, pid}
+ end
end
end
diff --git a/lib/chessh/auth/password.ex b/lib/chessh/auth/password.ex
index c3b03f5..ea2c8fc 100644
--- a/lib/chessh/auth/password.ex
+++ b/lib/chessh/auth/password.ex
@@ -2,8 +2,8 @@ defmodule Chessh.Auth.PasswordAuthenticator do
alias Chessh.{Player, Repo}
def authenticate(username, password) do
- case Repo.get_by(Player, username: String.Chars.to_string(username)) do
- x -> Player.valid_password?(x, String.Chars.to_string(password))
+ case Repo.get_by(Player, username: username) do
+ x -> Player.valid_password?(x, password)
end
end
end
diff --git a/lib/chessh/schema/node.ex b/lib/chessh/schema/node.ex
new file mode 100644
index 0000000..867a6e2
--- /dev/null
+++ b/lib/chessh/schema/node.ex
@@ -0,0 +1,24 @@
+defmodule Chessh.Node do
+ alias Chessh.Repo
+ import Ecto.Changeset
+ use Ecto.Schema
+
+ @primary_key {:id, :string, []}
+ schema "nodes" do
+ field(:last_start, :utc_datetime)
+ end
+
+ def changeset(node, attrs) do
+ node
+ |> cast(attrs, [:last_start])
+ end
+
+ def boot(node_id) do
+ case Repo.get(Chessh.Node, node_id) do
+ nil -> %Chessh.Node{id: node_id}
+ node -> node
+ end
+ |> Chessh.Node.changeset(%{last_start: DateTime.utc_now()})
+ |> Repo.insert_or_update()
+ end
+end
diff --git a/lib/chessh/schema/player_session.ex b/lib/chessh/schema/player_session.ex
new file mode 100644
index 0000000..84f15ee
--- /dev/null
+++ b/lib/chessh/schema/player_session.ex
@@ -0,0 +1,34 @@
+defmodule Chessh.PlayerSession do
+ alias Chessh.Repo
+ use Ecto.Schema
+ import Ecto.{Query, Changeset}
+
+ schema "player_sessions" do
+ field(:login, :utc_datetime)
+
+ belongs_to(:node, Chessh.Node, type: :string)
+ belongs_to(:player, Chessh.Player)
+ end
+
+ def changeset(player_session, attrs) do
+ player_session
+ |> cast(attrs, [:login])
+ end
+
+ def concurrent_sessions(player) do
+ Repo.aggregate(
+ from(p in Chessh.PlayerSession,
+ where: p.player_id == ^player.id
+ ),
+ :count
+ )
+ end
+
+ def delete_all_on_node(node_id) do
+ Repo.delete_all(
+ from(p in Chessh.PlayerSession,
+ where: p.node_id == ^node_id
+ )
+ )
+ end
+end
diff --git a/lib/chessh/ssh/daemon.ex b/lib/chessh/ssh/daemon.ex
index acb6bea..cd0d466 100644
--- a/lib/chessh/ssh/daemon.ex
+++ b/lib/chessh/ssh/daemon.ex
@@ -1,4 +1,5 @@
defmodule Chessh.SSH.Daemon do
+ alias Chessh.Auth.PasswordAuthenticator
use GenServer
def start_link(_) do
@@ -12,24 +13,39 @@ defmodule Chessh.SSH.Daemon do
{:ok, state}
end
- def pwd_authenticate(username, password, _address, attempts) do
- if Chessh.Auth.PasswordAuthenticator.authenticate(username, password) do
- true
- else
- newAttempts =
- case attempts do
- :undefined -> 0
- _ -> attempts
- end
-
- if Application.fetch_env!(:chessh, :max_password_attempts) <= newAttempts do
+ def pwd_authenticate(username, password) do
+ # TODO - check concurrent sessions
+ PasswordAuthenticator.authenticate(
+ String.Chars.to_string(username),
+ String.Chars.to_string(password)
+ )
+ end
+
+ def pwd_authenticate(username, password, inet) do
+ [jail_timeout_ms, jail_threshold] =
+ Application.get_env(:chessh, RateLimits)
+ |> Keyword.take([:jail_timeout_ms, :jail_threshold])
+ |> Keyword.values()
+
+ {ip, _port} = inet
+ rateId = "failed_password_attempts:#{Enum.join(Tuple.to_list(ip), ".")}"
+
+ case Hammer.check_rate(rateId, jail_timeout_ms, jail_threshold) do
+ {:allow, _count} ->
+ pwd_authenticate(username, password) ||
+ (fn ->
+ Hammer.check_rate_inc(rateId, jail_timeout_ms, jail_threshold, 1)
+ false
+ end).()
+
+ {:deny, _limit} ->
:disconnect
- else
- {false, newAttempts + 1}
- end
end
end
+ def pwd_authenticate(username, password, inet, _address),
+ do: pwd_authenticate(username, password, inet)
+
def handle_cast(:start, state) do
port = Application.fetch_env!(:chessh, :port)
key_dir = String.to_charlist(Application.fetch_env!(:chessh, :key_dir))
@@ -40,6 +56,7 @@ defmodule Chessh.SSH.Daemon do
system_dir: key_dir,
pwdfun: &pwd_authenticate/4,
key_cb: Chessh.SSH.ServerKey,
+ # disconnectfun:
id_string: :random,
subsystems: [],
parallel_login: true,
diff --git a/lib/chessh/ssh/server_key.ex b/lib/chessh/ssh/server_key.ex
index 1096e09..72a4fbb 100644
--- a/lib/chessh/ssh/server_key.ex
+++ b/lib/chessh/ssh/server_key.ex
@@ -1,8 +1,9 @@
defmodule Chessh.SSH.ServerKey do
+ alias Chessh.Auth.KeyAuthenticator
@behaviour :ssh_server_key_api
def is_auth_key(key, username, _daemon_options) do
- Chessh.Auth.KeyAuthenticator.authenticate(username, key)
+ KeyAuthenticator.authenticate(username, key)
end
def host_key(algorithm, daemon_options) do
diff --git a/mix.exs b/mix.exs
index 500d0b2..d35b519 100644
--- a/mix.exs
+++ b/mix.exs
@@ -32,7 +32,7 @@ defmodule Chessh.MixProject do
{:ecto_sql, "~> 3.9"},
{:postgrex, "~> 0.16.5"},
{:bcrypt_elixir, "~> 3.0"},
- {:hammer, "~> 6.0"}
+ {:hammer, "~> 6.1"}
]
end
diff --git a/priv/repo/migrations/20221229225556_add_node.exs b/priv/repo/migrations/20221229225556_add_node.exs
new file mode 100644
index 0000000..f8eace8
--- /dev/null
+++ b/priv/repo/migrations/20221229225556_add_node.exs
@@ -0,0 +1,10 @@
+defmodule Chessh.Repo.Migrations.AddNode do
+ use Ecto.Migration
+
+ def change do
+ create table(:nodes, primary_key: false) do
+ add(:id, :string, primary_key: true)
+ add(:last_start, :utc_datetime)
+ end
+ end
+end
diff --git a/priv/repo/migrations/20221229225559_add_user_session.exs b/priv/repo/migrations/20221229225559_add_user_session.exs
new file mode 100644
index 0000000..6f7a599
--- /dev/null
+++ b/priv/repo/migrations/20221229225559_add_user_session.exs
@@ -0,0 +1,11 @@
+defmodule Chessh.Repo.Migrations.AddUserSession do
+ use Ecto.Migration
+
+ def change do
+ create table(:player_sessions) do
+ add(:login, :utc_datetime)
+ add(:player_id, references(:players))
+ add(:node_id, references(:nodes, type: :string))
+ end
+ end
+end
diff --git a/test/auth/password_test.exs b/test/auth/password_test.exs
index 1516bdf..5e1b1d8 100644
--- a/test/auth/password_test.exs
+++ b/test/auth/password_test.exs
@@ -14,13 +14,13 @@ defmodule Chessh.Auth.PasswordAuthenticatorTest do
test "Password can authenticate a hashed password" do
assert Chessh.Auth.PasswordAuthenticator.authenticate(
- String.to_charlist(@valid_user.username),
- String.to_charlist(@valid_user.password)
+ @valid_user.username,
+ @valid_user.password
)
refute Chessh.Auth.PasswordAuthenticator.authenticate(
- String.to_charlist(@valid_user.username),
- String.to_charlist("a_bad_password")
+ @valid_user.username,
+ "a_bad_password"
)
end
end
diff --git a/test/ssh/ssh_auth_test-emacs-elixir-format.exs b/test/ssh/ssh_auth_test-emacs-elixir-format.exs
new file mode 100644
index 0000000..cb07259
--- /dev/null
+++ b/test/ssh/ssh_auth_test-emacs-elixir-format.exs
@@ -0,0 +1,81 @@
+defmodule Chessh.SSH.AuthTest do
+ use ExUnit.Case
+ alias Chessh.{Player, Repo, Key}
+
+ @localhost '127.0.0.1'
+ @key_name "The Gamer Machine"
+ @valid_user %{username: "logan", password: "password"}
+ @client_test_keys_dir Path.join(Application.compile_env!(:chessh, :key_dir), "client_keys")
+ @client_pub_key 'id_ed25519.pub'
+
+ setup_all do
+ case Ecto.Adapters.SQL.Sandbox.checkout(Repo) do
+ :ok -> nil
+ {:already, :owner} -> nil
+ end
+
+ Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
+
+ {:ok, player} = Repo.insert(Player.registration_changeset(%Player{}, @valid_user))
+
+ {:ok, key_text} = File.read(Path.join(@client_test_keys_dir, @client_pub_key))
+
+ {:ok, _key} =
+ Repo.insert(
+ Key.changeset(%Key{}, %{key: key_text, name: @key_name})
+ |> Ecto.Changeset.put_assoc(:player, player)
+ )
+
+ :ok
+ end
+
+ test "Password attempts are rate limited" do
+ assert :disconnect ==
+ Enum.reduce(
+ 1..Application.fetch_env!(:chessh, RateLimits, :jail_threshold),
+ fn _, _ ->
+ Chessh.SSH.Daemon.pwd_authenticate(
+ @valid_user.username,
+ 'wrong_password',
+ @localhost
+ ) do
+ end
+ )
+ end
+
+ test "INTEGRATION - Can ssh into daemon with password or public key" do
+ {:ok, sup} = Task.Supervisor.start_link()
+ test_pid = self()
+
+ Task.Supervisor.start_child(sup, fn ->
+ {:ok, _pid} =
+ :ssh.connect(@localhost, Application.fetch_env!(:chessh, :port),
+ user: String.to_charlist(@valid_user.username),
+ password: String.to_charlist(@valid_user.password),
+ auth_methods: 'password',
+ silently_accept_hosts: true
+ )
+
+ send(test_pid, :connected_via_password)
+ end)
+
+ Task.Supervisor.start_child(sup, fn ->
+ {:ok, _pid} =
+ :ssh.connect(@localhost, Application.fetch_env!(:chessh, :port),
+ user: String.to_charlist(@valid_user.username),
+ auth_methods: 'publickey',
+ silently_accept_hosts: true,
+ user_dir: String.to_charlist(@client_test_keys_dir)
+ )
+
+ send(test_pid, :connected_via_public_key)
+ end)
+
+ assert_receive(:connected_via_password, 500)
+ assert_receive(:connected_via_public_key, 500)
+ end
+
+ test "INTEGRATION - User cannot have more than specified concurrent sessions" do
+ :ok
+ end
+end
diff --git a/test/ssh/ssh_auth_test.exs b/test/ssh/ssh_auth_test.exs
index c3ced20..cb07259 100644
--- a/test/ssh/ssh_auth_test.exs
+++ b/test/ssh/ssh_auth_test.exs
@@ -29,26 +29,21 @@ defmodule Chessh.SSH.AuthTest do
:ok
end
- test "Fails to authenticate after configured max password attempt" do
+ test "Password attempts are rate limited" do
assert :disconnect ==
Enum.reduce(
- 1..Application.fetch_env!(:chessh, :max_password_attempts),
- %{attempts: 0},
- fn acc, _ ->
- case Chessh.SSH.Daemon.pwd_authenticate(
+ 1..Application.fetch_env!(:chessh, RateLimits, :jail_threshold),
+ fn _, _ ->
+ Chessh.SSH.Daemon.pwd_authenticate(
@valid_user.username,
'wrong_password',
- @localhost,
- acc
+ @localhost
) do
- {false, state} -> state
- x -> x
- end
end
)
end
- test "INTEGRATION TEST - Can ssh into daemon with password or public key" do
+ test "INTEGRATION - Can ssh into daemon with password or public key" do
{:ok, sup} = Task.Supervisor.start_link()
test_pid = self()
@@ -80,15 +75,7 @@ defmodule Chessh.SSH.AuthTest do
assert_receive(:connected_via_public_key, 500)
end
- test "Hosts are rate limited via password attempts" do
- :ok
- end
-
- test "Hosts are also rate limited with public keys" do
- :ok
- end
-
- test "User cannot have more than one current session" do
+ test "INTEGRATION - User cannot have more than specified concurrent sessions" do
:ok
end
end