Building an Erlang chat server with Comet – Part 3

You can see a live demo of the following tutorial here.

Overview

In the last part, we implemented the first version of our chat server. Now that the framework is down, we can start adding some more features. You can find the source for this tutorial here

UPDATE 7/2/11: The source code is also available here: https://github.com/chrismoos/erl_chat_tutorial. You can checkout the 0.2 tag.

In this part, we will add the following features:

Rate Limiting

This feature will *help* in stopping people from spamming the chat room. It isn't very complex, but it will give you the idea of what rate limiting can do. The algorithm for rate limiting is very simple, if a user sends too many messages in a certain interval, stop them from sending. At the top of chat_room.erl I've added the following:

-define(RATE_MSG_INTERVAL, 3).
-define(RATE_MSG_COUNT, 10).

I've just randomly chosen these values for now, and it means if you try to send more than 10 messages in 3 seconds, you will be rate limited. The check for rate limiting will go in the chat_message function:

handle_cast({chat_message, {Sess, Msg}}, State) when is_list(Msg) and (length(Msg) > 0) ->
    case get_session(Sess, State) of
        {error, not_found} -> {noreply, State};
        {ok, #client_state{nick=Nick,id=ID} = C} ->
            CleanMsg = chat_util:unicode_clean(lists:sublist(Msg, 256)),
            {NewC, NewState} = update_client(C, State),
            case handle_command(NewC, CleanMsg, NewState) of
                {ok, S1} -> {noreply, S1};
                _ ->
                    case should_rate_limit(NewC, NewState) of
                        {yes, {_, S2}} ->
                            send_system_msg(NewC, "You are sending messages to fast. Please wait a few seconds and try again."),
                            {noreply, S2};
                        {no, {RateClient, S2}} ->
                            chat_postoffice:broadcast_mail({msg, {chat_msg, {Nick, CleanMsg}}}, [ID]),
                            chat_postoffice:send_mail(ID, {msg, {sent_chat_msg, {Nick, CleanMsg}}}),
                            {_, S3} = update_client(RateClient#client_state{last_msg=now()}, S2),
                            {noreply, S3}
                    end
            end
    end;

We've just added a check that calls should_rate_limit and based on what it returns, the user is rate limited(and we notify them), or they are not, and the message can be sent. Also, you may notice the handle_command function, we will discuss this in a little.

To implement rate limiting, I've updated our client's state with last_msg and msg_count:

-record(client_state, {
id, nick, host,last_action,admin=false,last_msg=never,msg_count=0
}).

Finally, the implementation of should_rate_limit. This function implements our simple algorithm.

should_rate_limit(#client_state{last_msg=never} = C, State) -> 
    NewState = update_client(C#client_state{last_msg=now(),msg_count=1}, State),
    {no, NewState};
should_rate_limit(#client_state{last_msg=LastMsg,msg_count=Count} = C, State) ->
    RateSecs = calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(now())) - calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(LastMsg)),
    case {RateSecs =< ?RATE_MSG_INTERVAL, (Count + 1) > ?RATE_MSG_COUNT} of
        {true, true} -> {yes, update_client(C#client_state{msg_count=Count}, State)};
        {false, _} -> {no, update_client(C#client_state{msg_count=0}, State)};
        _ -> {no, update_client(C#client_state{msg_count=Count+1}, State)}
    end;
should_rate_limit(_, State) -> {no, State}.

Admin Control

This feature allows the administrator exercise some control over the chat server, with features such as kicking/banning users. The admin features are only protected by a password. We define the admin password as so:

-define(ADMIN_PASSWORD, "demopass").

It is probably a good idea to change this password if you are running it on a public server.

I have updated the client state to contain another field, called admin. When a user joins, this value defaults to false. To become an admin, (and to change a client's state to admin=true), the user must authenticate. To handle authentication, and various other commands, we will use an IRC style /command param1 param2 param3..paramN. If you want to add spaces for a parameter, surround it with quotes.

You probably noticed in the previous section that we added a handle_command check in the chat message function. This will allow us to parse and handle any possible commands. It looks like the following:

handle_command(Client, Msg, State) ->
    case re:run(Msg, "^/([A-Za-z0-9]+) ?(.+){0,1}", [{capture, all_but_first, list}]) of
        {match, [Cmd]} -> process_command(Cmd, [], Client, State);
        {match, [Cmd, ParamStr]} -> process_command(Cmd, get_params(ParamStr), Client, State);
        _ -> {error, no_command}
    end.

It takes a chat message, and tries to parse out a command. If it is valid, we then process the command further. process_command takes the command (value after the slash), and a list of parameters (along with the client state, and server state). Using Erlang's pattern matching, we can easily handle any command we want by just matching the string. For now, process_command handles two commands, /help, and /auth.

process_command("help", _, Client, State) ->
    send_system_msg(Client, "Commands: (/auth password)"),
    {ok, State};

% Generic admin handler.
process_command("admin", Args, #client_state{admin=true} = Client, State) -> process_admin_command(Args, Client, State);
    
% Authenticate an administrator    
process_command("auth", [?ADMIN_PASSWORD], Client, State) -> 
    send_system_msg(Client, "You are now authenticated as an administrator. Type /admin help for commands."),
    case Client#client_state.admin of
        false -> chat_postoffice:broadcast_mail({msg, {admin_logged_in, Client#client_state.nick}}, []);
        _ -> ok
    end,
    {_, NewState} = update_client(Client#client_state{admin=true}, State),
    {ok, NewState}; 
process_command("auth", _, Client, State) -> send_system_msg(Client, "Invalid adminstrator password."), {ok, State};  
 
% Unknown command. 
process_command(_, _, Client, State) -> 
    send_system_msg(Client, "Invalid command or format."),
    {ok, State}.

/help just prints out help. All users can access this command. The auth command is what will give us administrator access. Using pattern matching, we can check to see if the correct password was entered. If it was, we update the client state and notify all the other users that this user has became an admin.

Once a user is authenticated, the Generic Admin Handler function will match, and the process_admin_command function will be called.

Admin Commands

Now that the user is logged in, we want to add some admin functionality. In this tutorial, we add the following commands:

Kicking a user

To kick a user, the admin will specify a user, and a reason. The code is:

% Kick a user
process_admin_command(["kick", User, Reason], #client_state{id=MySess} = Client, State) ->
    case get_user_by_nick(User, State) of
        {error, not_found} -> send_system_msg(Client, "Unable to kick: user not found.");
        {ok, #client_state{id=Sess} = C} when Sess /= MySess -> 
            send_system_msg(C, "You have been kicked from the chat room. Reason: " ++ Reason),
            chat_room:leave(Sess, "kicked: " ++ Reason);
        _ -> send_system_msg(Client, "Sorry, you can't kick yourself!")
    end,
    {ok, State};

First, we lookup the user. If the user exists, we *make* them leave the chat room, and specify the reason. That's it.

Banning a user

This is a little more complicated, as we want to persist the ban between server restarts. To do this, we use a DETS table, which is basically ETS(Erlang's in memory database), persisted to disk. Erlang stores the database in a file. We use the file name "bans.dets". The table is created/opened in our chat_room init function:

init(_Args) -> 
    process_flag(trap_exit, true),
    {ok, BansTable} = dets:open_file("bans.dets", []),
    _ConnTable = ets:new(conns, [named_table]),
    timer:apply_after(?CHECK_IDLE_TIME, ?MODULE, find_idle_clients, []),
    {ok, #state{bans_table=BansTable}}.

Each item in the bans_table will be defined by the following record:

-record(user_ban, {
host,last_nick,reason,until
}).

Okay, now for the fun part. First, let's see the function to ban a user:

{% raw %}
% Ban a user
process_admin_command(["ban", User, Reason, Seconds], #client_state{id=MySess} = Client, State) ->
    case {get_user_by_nick(User, State), string:to_integer(Seconds)} of
        {_, {error, _}} -> send_system_msg(Client, "Unable to ban: invalid time specified.");
        {{error, not_found}, _} -> send_system_msg(Client, "Unable to ban: user not found.");
        {{ok, #client_state{id=Sess,host=Host} = C}, {Secs, _}} when Sess /= MySess -> 
            {Mega, S, Micro} = now(),
            BanTime = {Mega, S + Secs, Micro},
            BanTimeStr = httpd_util:rfc1123_date(calendar:now_to_local_time(BanTime)),
            send_system_msg(C, "You have been banned from the chat room until " ++ BanTimeStr ++ ". Reason: " ++ Reason),
            chat_room:leave(Sess, "banned: " ++ Reason),
            dets:insert(State#state.bans_table, #user_ban{host=Host,last_nick=User,reason=Reason,until=BanTime});
        _ -> send_system_msg(Client, "Sorry, you can't ban yourself!")
    end,
    {ok, State};

This function looks up the user, and if the user exists, we let them know that they have been banned(and disconnect them) and store an item in the DETS table describing the ban.

Now that the user has been disconnected, we need to make sure they can't login until the ban is up. This check is implemented in our join function.

{% raw %}
handle_call({join, {Nick, Host}}, _From, #state{clients=Clients} = State) when is_list(Nick) ->
    case {is_banned(Host,State), can_connect(Host), validate_nick(Nick, State)} of
        {{yes, Until}, _, _} -> {reply, {error, {banned, Until}}, State};
        {_, no, _} -> {reply, {error, too_many_conns}, State};
        {_, _, {error, Reason}} -> {reply, {error, Reason}, State};
        {_, {yes, NumConns}, {ok, ValidNick}} ->
            Session = get_unique_session(State),
            case chat_postoffice:create_mailbox(Session) of
                ok -> 
                    update_host_conns(Host, NumConns + 1),
                    chat_postoffice:broadcast_mail({msg, {user_joined_room, ValidNick}}, [Session]),
                    Client = #client_state{id=Session,nick=ValidNick,host=Host,last_action=now()},
                    {reply, {ok, Session}, State#state{clients=[Client | Clients]}};
                {error, _} -> {reply, {error, not_available}, State}
            end
    end;

We have updated this function to call the function, is_banned, which determines if a user is indeed banned. If they are, we return an error, and the message is displayed to the user.

The ban function simply looks up a host/ip address in the bans table, and determines if it is still active.

is_banned(Host, #state{bans_table=Table}) ->
    Now = now(),
    case qlc:e(qlc:q([Ban || Ban <- dets:table(Table), Ban#user_ban.host == Host])) of
        [] -> no;
        [#user_ban{until=Until} | _] when Until >= Now -> {yes, Until};
        _ -> no
    end.

Connection Limiting

Finally, we've added a simple check in the join function, called can_connect, which checks to see if a user has the maximum connections allowed open. This is defined as:

% Max connections per host
-define(MAX_CONNECTIONS, 3).

The number of connections for a host is stored in an ETS table. We don't need to persist this because if the server crashes, all connections will be gone anyway.

When a user joins, we update the connections table.

% Updates a host in connections ETS table
update_host_conns(Host, Conns) -> ets:insert(conns, {Host, Conns}).

When a user leaves, we update the connections table.

host_disconnected(Host) ->
    case ets:lookup(conns, Host) of
        [] -> ok;
        [{_, Num}] when is_integer(Num) -> ets:insert(conns, {Host, Num - 1});
        _ -> ets:insert(conns, {Host, 0})
    end.

Conclusion

Now we have a more functional server, but it still needs lots of work(that's for you to decide). The source is available here. Try it out locally, by doing:

wget http://tech9computers.com/erl_chat-0.2.tar.gz
tar -xvzf erl_chat-0.2.tar.gz
cd erl_chat-0.2
make erl_chat

Then visit http://localhost:8000 to try it out

If anyone has any comments/suggestions/requests please let me know, I'd be happy to get some feedback on this. And also, you can try a demo here, although don't think that demopass will get you admin control :).


comments powered by Disqus