Erlang ssh server #1
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
NODE_ID=aUniqueString
|
@ -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]}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
24
lib/chessh/schema/node.ex
Normal file
24
lib/chessh/schema/node.ex
Normal file
@ -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
|
34
lib/chessh/schema/player_session.ex
Normal file
34
lib/chessh/schema/player_session.ex
Normal file
@ -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
|
@ -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
|
||||
def pwd_authenticate(username, password) do
|
||||
# TODO - check concurrent sessions
|
||||
PasswordAuthenticator.authenticate(
|
||||
String.Chars.to_string(username),
|
||||
String.Chars.to_string(password)
|
||||
)
|
||||
end
|
||||
|
||||
if Application.fetch_env!(:chessh, :max_password_attempts) <= newAttempts do
|
||||
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,
|
||||
|
@ -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
|
||||
|
2
mix.exs
2
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
|
||||
|
||||
|
10
priv/repo/migrations/20221229225556_add_node.exs
Normal file
10
priv/repo/migrations/20221229225556_add_node.exs
Normal file
@ -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
|
11
priv/repo/migrations/20221229225559_add_user_session.exs
Normal file
11
priv/repo/migrations/20221229225559_add_user_session.exs
Normal file
@ -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
|
@ -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
|
||||
|
81
test/ssh/ssh_auth_test-emacs-elixir-format.exs
Normal file
81
test/ssh/ssh_auth_test-emacs-elixir-format.exs
Normal file
@ -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
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user