diff --git a/apps/kolab_guam/src/kolab_guam_session.hrl b/apps/kolab_guam/src/kolab_guam_session.hrl new file mode 100644 --- /dev/null +++ b/apps/kolab_guam/src/kolab_guam_session.hrl @@ -0,0 +1,4 @@ +-record(state, { listen_socket, socket = undefined, super_pid, tls_config = [], client_implicit_tls = false, client_tls_active = false, server_config = [], + rules_active = [], rules_deciding = [], imap_session = undefined, inflator = undefined, deflator = undefined, buffered_client_data = <<>>, + current_command_split = undefined, command_split_reset_trigger = reset_for_next_client_command }). + diff --git a/apps/kolab_guam/src/kolab_guam_session.erl b/apps/kolab_guam/src/kolab_guam_session.erl --- a/apps/kolab_guam/src/kolab_guam_session.erl +++ b/apps/kolab_guam/src/kolab_guam_session.erl @@ -25,10 +25,8 @@ %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -%% state record definition --record(state, { listen_socket, socket = undefined, super_pid, tls_config = [], client_implicit_tls = false, client_tls_active = false, server_config = [], - rules_active = [], rules_deciding = [], imap_session = undefined, inflator, deflator, buffered_client_data = <<>>, - current_command_split = undefined, command_split_reset_trigger = reset_for_next_client_command }). + +-include("kolab_guam_session.hrl"). %% public API start_link(SupervisorPID, ListenSocket, ImapConfig, ImplicitTLS, TLSConfig, Rules) -> gen_server:start_link(?MODULE, [SupervisorPID, ListenSocket, ImapConfig, ImplicitTLS, TLSConfig, Rules], []). @@ -99,7 +97,7 @@ %TODO: should we also support non-active rules doing imapy things here? { noreply, State#state{ rules_active = NewActiveRules } }; handle_info({ imap_server_response, Data }, #state{ socket = Socket, imap_session = ImapSession, client_tls_active = TLS, deflator = Deflator, rules_active = ActiveRules } = State) -> - %lager:debug("FROM SERVER: ~s", [Data]), + lager:debug("FROM SERVER: ~p", [Data]), { ModifiedData, CurrentlyActiveRules } = apply_ruleset_serverside(ImapSession, Data, ActiveRules), relay_response(Socket, postprocess_server_data(Deflator, ModifiedData), TLS), NewSplitCommand = update_split_command_state(ModifiedData, State), @@ -176,13 +174,44 @@ close_socket(true, _TLS, Socket) -> ssl:close(Socket); close_socket(_ImplicitTLS, _TLS, Socket) -> gen_tcp:close(Socket). -process_client_data(Socket, Data, #state{ rules_deciding = UndecidedRules, tls_config = TLSConfig, client_tls_active = TLS, rules_active = ActiveRules, socket = Socket, imap_session = ImapSession, inflator = Inflator, deflator = Deflator, server_config = ServerConfig, current_command_split = CurrentCommandSplit } = State) -> - %%TODO: multipacket input from clients + +read_line(Data) -> + case binary:match(Data, <<"\r\n">>) of + nomatch -> + {Data, <<>>}; + {SplitPos, SplitSize} -> + {binary:part(Data, 0, SplitPos + SplitSize), binary:part(Data, SplitPos + SplitSize, size(Data)-(SplitPos + SplitSize))} + end. + + +process_preprocessed_client_data(Socket, PreprocessData, #state{ command_split_reset_trigger = CurrentCommandSplitResetTrigger, current_command_split = CurrentCommandSplit } = State) -> + NewSplitCommand = case CurrentCommandSplitResetTrigger of + reset_for_next_client_command -> undefined; + _ -> CurrentCommandSplit + end, + + % TODO Maybe we should prepend buffered data here instead. + {Line, Remainder} = read_line(PreprocessData), + NewState = process_client_line(Line, Socket, State#state{ current_command_split = NewSplitCommand }), + + case Remainder of + <<>> -> NewState; + _ -> process_preprocessed_client_data(Socket, Remainder, NewState) + end. + + +% Client data is processed as it comes in, but split up by lines if more than one line comes in at a time. +% The processing interanly buffers data as necessary if it encounters incomplete lines. +process_client_data(Socket, Data, #state{ inflator = Inflator } = State) -> % TODO: refactor so starttls and compress commands can be made into rules PreprocessData = preprocess_client_data(Inflator, Data, State), - %lager:info("FROM CLIENT: ~s", [PreprocessData]), + lager:debug("FROM CLIENT: ~p", [PreprocessData]), + { noreply, process_preprocessed_client_data(Socket, PreprocessData, State) }. + + +process_client_line(Data, Socket, #state{ rules_deciding = UndecidedRules, tls_config = TLSConfig, client_tls_active = TLS, rules_active = ActiveRules, socket = Socket, imap_session = ImapSession, inflator = Inflator, deflator = Deflator, server_config = ServerConfig, current_command_split = CurrentCommandSplit } = State) -> { TLSActive, CurrentSocket, CurrentInflator, CurrentDeflator, CurrentUndecidedRules, CurrentActiveRules, DataToBuffer, SplitCommand, SplitResetTrigger } = - case check_for_transmission_change_commands(TLS, TLSConfig, PreprocessData, Deflator, Socket) of + case check_for_transmission_change_commands(TLS, TLSConfig, Data, Deflator, Socket) of { socket_upgraded, SSLSocket } -> %% if we have upgraded our socket, then do so to the backend if that hasn't happened auomatically case proplists:get_value(implicit_tls, ServerConfig, false) of @@ -194,46 +223,24 @@ eimap:compress(ImapSession), % TODO: make optional { TLS, Socket, NewInflator, NewDeflator, UndecidedRules, ActiveRules, <<>>, undefined, undefined }; nochange -> - %%lager:debug("... now applying rules"), - { ModifiedData, NewSplitCommand, NewSplitResetTrigger, NewUndecidedRules, NewActiveRules, PostAction } = apply_ruleset_clientside(ImapSession, Socket, PreprocessData, CurrentCommandSplit, UndecidedRules, ActiveRules), - %%lager:info("The modified data is: ~s", [ModifiedData]), - %lager:info("The post-processed data is: ~s", [PostProcessed]), + { ModifiedData, NewSplitCommand, NewSplitResetTrigger, NewUndecidedRules, NewActiveRules, PostAction } = apply_ruleset_clientside(ImapSession, Socket, Data, CurrentCommandSplit, UndecidedRules, ActiveRules), BufferThisData = case PostAction of perform_passthrough -> - %lager:info("sending (no buffer): ~s", [ModifiedData]), eimap:passthrough_data(ImapSession, ModifiedData), <<>>; buffer_data -> - % Originally Aaron uses Data here, but later on this buffer is assumed to be - % already decoded, so we do have to use PreprocessData here, I think. - case binary:matches(PreprocessData, <<"\r\n">>) of - [] -> - %lager:info("buffering: ~s", [PreprocessData]), - PreprocessData; - List -> - {FoundPos, _} = lists:last(List), - % I would like to have some binary:match for the last instead of the - % first occurrence; but I'm really inexperienced in erlang so I don't - % know how to solve this efficiently, so I'm using binary:matches with - % using the last element only - SplitPos = FoundPos + 2, - eimap:passthrough_data(ImapSession, binary:part(PreprocessData, 0, SplitPos)), - %lager:info("sending first part: ~s", [binary:part(PreprocessData, 0, SplitPos)] ), - %lager:info("buffering second part: ~s", [binary:part(PreprocessData, SplitPos, size(PreprocessData)-SplitPos)]), - binary:part(PreprocessData, SplitPos, size(PreprocessData)-SplitPos) - end + Data end, { TLS, Socket, Inflator, Deflator, NewUndecidedRules, NewActiveRules, BufferThisData, NewSplitCommand, NewSplitResetTrigger } end, set_socket_active(TLSActive, CurrentSocket), - %buffered_client_data is already in DataToBuffer via preprocess_client_data - { noreply, State#state{ rules_deciding = CurrentUndecidedRules, rules_active = CurrentActiveRules, + State#state{ rules_deciding = CurrentUndecidedRules, rules_active = CurrentActiveRules, socket = CurrentSocket, client_tls_active = TLSActive, inflator = CurrentInflator, deflator = CurrentDeflator, - buffered_client_data = <>, + buffered_client_data = DataToBuffer, current_command_split = SplitCommand, - command_split_reset_trigger = SplitResetTrigger } }. + command_split_reset_trigger = SplitResetTrigger }. preprocess_client_data(undefined, Data, #state{ buffered_client_data = Buffered }) -> <>; @@ -281,15 +288,11 @@ { PostAction, SplitCommand, SplitResetTrigger } = case CurrentCommandSplit of undefined -> - %We first have to check whether the command is an empty line. In such a case split_command_into_components would return an empty command, - %even though the command is complete. - case ClientData of - <<"\r\n">> -> { perform_passthrough, CurrentCommandSplit, reset_for_next_client_command }; - _ -> - case eimap_utils:split_command_into_components(ClientData) of - { _Tag, <<>>, <<>> } -> { buffer_data, undefined, reset_for_next_client_command }; - { _Tag, Command, _Data } = Split -> { perform_passthrough, Split, when_to_reset_split(Command) } - end + case eimap_utils:split_command_into_components(ClientData) of + % In case of an empty line ("\r\n") the line is complete (doesn't need to be buffered), but there is also no command or tag. + { <<>>, <<>>, <<>> } -> { perform_passthrough, CurrentCommandSplit, reset_for_next_client_command }; + { _Tag, <<>>, <<>> } -> { buffer_data, undefined, reset_for_next_client_command }; + { _Tag, Command, _Data } = Split -> { perform_passthrough, Split, when_to_reset_split(Command) } end; _ -> { perform_passthrough, CurrentCommandSplit, reset_for_next_client_command } end, diff --git a/apps/kolab_guam/test/kolab_guam_session_SUITE.erl b/apps/kolab_guam/test/kolab_guam_session_SUITE.erl new file mode 100644 --- /dev/null +++ b/apps/kolab_guam/test/kolab_guam_session_SUITE.erl @@ -0,0 +1,187 @@ +%% Copyright 2015 Kolab Systems AG (http://www.kolabsys.com) +%% +%% Christian Mollekopf (Kolab Systems) +%% +%% This program is free software: you can redistribute it and/or modify +%% it under the terms of the GNU General Public License as published by +%% the Free Software Foundation, either version 3 of the License, or +%% (at your option) any later version. +%% +%% This program is distributed in the hope that it will be useful, +%% but WITHOUT ANY WARRANTY; without even the implied warranty of +%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +%% GNU General Public License for more details. +%% +%% You should have received a copy of the GNU General Public License +%% along with this program. If not, see . + +-module(kolab_guam_session_SUITE). + +% easier than exporting by name +-compile(export_all). + +% required for common_test to work +-include_lib("common_test/include/ct.hrl"). +-include("../src/kolab_guam_session.hrl"). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% common test callbacks %% +%%%%%%%%%%%%%%%%%%%%%%%%%%% + +% Specify a list of all unit test functions +all() -> [ + kolab_guam_session_test + ]. + +% required, but can just return Config. this is a suite level setup function. +init_per_suite(Config) -> + Config. + +% required, but can just return Config. this is a suite level tear down function. +end_per_suite(Config) -> + Config. + +% optional, can do function level setup for all functions, +% or for individual functions by matching on TestCase. +init_per_testcase(_TestCase, Config) -> + Config. + +% optional, can do function level tear down for all functions, +% or for individual functions by matching on TestCase. +end_per_testcase(_TestCase, Config) -> + Config. + + +kolab_guam_session_test(_TestConfig) -> + %% setup boilerplate + ServerConfig = kolab_guam_sup:default_imap_server_config(), + { ok, ImapSession } = eimap:start_link(ServerConfig), + { ok, Socket } = gen_tcp:listen(9964, [ { reuseaddr, true }, {active, false}, inet6 ]), + + lager:start(), + lager:set_loglevel(lager_console_backend, debug), + + ActiveRules = [{ kolab_guam_rule_filter_groupware, kolab_guam_rule_filter_groupware:new({}) }], + + State = #state{ + socket = Socket, + server_config = ServerConfig, + imap_session = ImapSession + }, + + + % Run without rules + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = undefined, + command_split_reset_trigger = [] %FIXME ? + }} = kolab_guam_session:handle_info({tcp, Socket, <<"y1 ID (\"name\" \"Test\")\r\ny2 ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>}, State#state{ rules_deciding = [] }), + + + % Activate filtering + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y1">>,<<"ID">>,<<"(\"name\" \"Test\")">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + rules_active = [{kolab_guam_rule_filter_groupware, _}] + }} = kolab_guam_session:handle_info({tcp, Socket, <<"y1 ID (\"name\" \"Test\")\r\n">>}, State#state{ rules_deciding = ActiveRules }), + + + % Don't activate filtering + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y1">>,<<"ID">>,<<"(\"name\" \"Test/KOLAB\")">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + rules_active = [] + }} = kolab_guam_session:handle_info({tcp, Socket, <<"y1 ID (\"name\" \"Test/KOLAB\")\r\n">>}, State#state{ rules_deciding = ActiveRules }), + + + % Lone tag in a packet. Can/Could happen with iPhone apparently. (See commit 89f9dc93757c68032ed17f42838858bdfaefa408) + {noreply, #state{ buffered_client_data = <<"y1">>, rules_active = [] } = IntermediateState1} = kolab_guam_session:handle_info({tcp, Socket, <<"y1">>}, State#state{ rules_deciding = ActiveRules }), + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y2">>,<<"ENABLE">>,<<"QRESYNC">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + rules_active = [{kolab_guam_rule_filter_groupware, _}] + }} = kolab_guam_session:handle_info({tcp, Socket, <<" ID (\"name\" \"Test\")\r\ny2 ENABLE QRESYNC\r\n">>}, IntermediateState1), + + + % Lone tag in a packet, but with a \r\n before. See guam-0.9.2-stalling-client-buffer-and-split-command-handling.patch (This triggers the odd List buffering case) + {noreply, #state{ buffered_client_data = <<"y1 ">>, rules_active = [] } = IntermediateState2} = kolab_guam_session:handle_info({tcp, Socket, <<"\r\ny1 ">>}, State#state{ rules_deciding = ActiveRules }), + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y2">>,<<"ENABLE">>,<<"QRESYNC">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + rules_active = [{kolab_guam_rule_filter_groupware, _}] + }} = kolab_guam_session:handle_info({tcp, Socket, <<"ID (\"name\" \"Test\")\r\ny2 ENABLE QRESYNC\r\n">>}, IntermediateState2), + + + % Empty line (should be considered a complete command and should not be buffered) + {noreply, #state{ buffered_client_data = <<>>, rules_active = [] } = IntermediateState3} = kolab_guam_session:handle_info({tcp, Socket, <<"\r\n">>}, State#state{ rules_deciding = ActiveRules }), + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y1">>,<<"ID">>,<<"(\"name\" \"Test\")">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + rules_active = [{kolab_guam_rule_filter_groupware, _}] + }} = kolab_guam_session:handle_info({tcp, Socket, <<"y1 ID (\"name\" \"Test\")\r\n">>}, IntermediateState3), + + + % Activate with multi-line packet + {noreply, #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y3">>,<<"LIST">>,<<"\"\" \"%\" RETURN (SUBSCRIBED))">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + % Make sure that we have processed y3 in here (and don't stop processing at y1) + rules_active = [{kolab_guam_rule_filter_groupware, {state,undefined,<<"y3">>,true,<<>>,[<<"LIST">>,<<"list">>,<<"XLIST">>,<<"xlist">>,<<"LSUB">>,<<"lsub">>]}}] + % rules_active = [{kolab_guam_rule_filter_groupware, _}] + }} = kolab_guam_session:handle_info({tcp, Socket, <<"y1 ID (\"name\" \"Test\")\r\ny2 ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>}, State#state{ rules_deciding = ActiveRules }), + + + % Test various packet splits + PacketsSplits = [ + [<<"y1 ID (\"name\" \"Test\")\r\n">>, <<"y2 ENABLE QRESYNC\r\n">>, <<"y3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>], + [<<"y1 ID (\"name\" \"Test\")\r\ny2 ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>], + [<<"y1 ID (\"name\" \"Test\")">>, <<"\r\ny2">>, <<" ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>], + [<<"y1 ID (\"name\" \"Test\")">>, <<"\r\n">>, <<"y2 ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>], + [<<"y1">>, <<" ">>, <<"ID">>, <<" (\"name\" \"Test\")\r\n">>, <<"y2 ENABLE QRESYNC\r\ny3 LIST \"\" \"%\" RETURN (SUBSCRIBED))\r\n">>] + ], + + lists:foldl(fun(Packets, _) -> + try_packets(Packets, Socket, ServerConfig, State#state{ rules_deciding = ActiveRules }) + end, + undefined, + PacketsSplits), + + + lager:info("Done"). + + +try_packets(Packets, Socket, ServerConfig, State) -> + #state{ + server_config = ServerConfig, + buffered_client_data = <<>>, + current_command_split = {<<"y3">>,<<"LIST">>,<<"\"\" \"%\" RETURN (SUBSCRIBED))">>}, + command_split_reset_trigger = reset_for_next_client_command, + rules_deciding = [], + % Make sure that we have processed y3 in here (and don't stop processing at y1) + rules_active = [{kolab_guam_rule_filter_groupware, {state,undefined,<<"y3">>,true,<<>>,[<<"LIST">>,<<"list">>,<<"XLIST">>,<<"xlist">>,<<"LSUB">>,<<"lsub">>]}}] + } = lists:foldl(fun(Packet, _State) -> + {_, NewState} = kolab_guam_session:handle_info({tcp, Socket, Packet}, _State), + NewState + end, + State, + Packets).