1%% @author Couchbase <info@couchbase.com>
2%% @copyright 2014-2018 Couchbase, Inc.
3%%
4%% Licensed under the Apache License, Version 2.0 (the "License");
5%% you may not use this file except in compliance with the License.
6%% You may obtain a copy of the License at
7%%
8%%      http://www.apache.org/licenses/LICENSE-2.0
9%%
10%% Unless required by applicable law or agreed to in writing, software
11%% distributed under the License is distributed on an "AS IS" BASIS,
12%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13%% See the License for the specific language governing permissions and
14%% limitations under the License.
15%%
16-module(menelaus_web_cluster_logs).
17
18-include("cut.hrl").
19
20-export([handle_start_collect_logs/1,
21         handle_cancel_collect_logs/1,
22         handle_settings_log_redaction/1,
23         handle_settings_log_redaction_post/1]).
24
25handle_settings_log_redaction(Req) ->
26    menelaus_util:assert_is_enterprise(),
27    menelaus_util:assert_is_55(),
28
29    {value, Config} =
30        ns_config:search(ns_config:get(), log_redaction_default_cfg),
31    Level = proplists:get_value(redact_level, Config),
32    menelaus_util:reply_json(Req, {[{logRedactionLevel, Level}]}).
33
34handle_settings_log_redaction_post(Req) ->
35    menelaus_util:assert_is_enterprise(),
36    menelaus_util:assert_is_55(),
37
38    validator:handle(do_handle_settings_log_redaction_post_body(Req, _),
39                     Req, form, settings_log_redaction_post_validators()).
40
41settings_log_redaction_post_validators() ->
42    [validator:has_params(_),
43     validator:one_of(logRedactionLevel, [none, partial], _),
44     validator:convert(logRedactionLevel, fun list_to_atom/1, _),
45     validator:unsupported(_)].
46
47do_handle_settings_log_redaction_post_body(Req, Values) ->
48    Settings = [{redact_level, proplists:get_value(logRedactionLevel, Values)}],
49    ns_config:set(log_redaction_default_cfg, Settings),
50    ns_audit:modify_log_redaction_settings(Req, Settings),
51    handle_settings_log_redaction(Req).
52
53handle_start_collect_logs(Req) ->
54    Params = Req:parse_post(),
55
56    case parse_validate_collect_params(Params, ns_config:get()) of
57        {ok, Nodes, BaseURL, Options} ->
58            case cluster_logs_collection_task:preflight_base_url(BaseURL) of
59                ok ->
60                    case cluster_logs_sup:start_collect_logs(Nodes, BaseURL,
61                                                             Options) of
62                        ok ->
63                            ns_audit:start_log_collection(
64                              Req, Nodes, BaseURL,
65                              lists:keydelete(redact_salt_fun, 1, Options)),
66                            menelaus_util:reply_json(Req, [], 200);
67                        already_started ->
68                            menelaus_util:reply_json(
69                              Req, {struct,
70                                    [{'_', <<"Logs collection task is already "
71                                             "started">>}]}, 400)
72                    end;
73                {error, Message} ->
74                    menelaus_util:reply_json(Req, {struct, [{'_', Message}]},
75                                             400)
76            end;
77        {errors, RawErrors} ->
78            Errors = [begin
79                          {Field, Msg} = stringify_one_node_upload_error(E),
80                          {Field, iolist_to_binary(Msg)}
81                      end || E <- RawErrors],
82            menelaus_util:reply_json(Req, {struct, lists:flatten(Errors)}, 400)
83    end.
84
85%% we're merely best-effort-sync and we don't care about results
86handle_cancel_collect_logs(Req) ->
87    cluster_logs_sup:cancel_logs_collection(),
88    menelaus_util:reply_json(Req, []).
89
90stringify_one_node_upload_error({unknown_nodes, List}) ->
91    {nodes, io_lib:format("Unknown nodes: ~p", [List])};
92stringify_one_node_upload_error(missing_nodes) ->
93    {nodes, "Please select at least one node."};
94stringify_one_node_upload_error({empty, F}) ->
95    {F, "cannot be empty"};
96stringify_one_node_upload_error({malformed, customer}) ->
97    {customer, "must contain only [A-Za-z0-9._ -] and be no longer than 50 characters"};
98stringify_one_node_upload_error({malformed, ticket}) ->
99    {ticket, "must contain only [0-9] and be no longer than 7 characters"};
100stringify_one_node_upload_error(missing_customer) ->
101    {customer, "customer must be given if upload host or ticket is given"};
102stringify_one_node_upload_error(missing_upload) ->
103    {uploadHost, "upload host must be given if customer or ticket is given"};
104stringify_one_node_upload_error({cluster_too_old, log_redaction}) ->
105    {logRedactionLevel, "log redaction is not supported for this version of the cluster"};
106stringify_one_node_upload_error({not_enterprise, log_redaction}) ->
107    {logRedactionLevel, "log redaction is an enterprise only feature"};
108stringify_one_node_upload_error({unknown, log_redaction}) ->
109    {logRedactionLevel, "log redaction should be none or partial"};
110stringify_one_node_upload_error({salt_without_level, log_redaction}) ->
111    {logRedactionSalt, "log redaction level must be partial if salt is given"};
112stringify_one_node_upload_error({invalid_directory, R}) ->
113    {R, "Must be an absolute path"}.
114
115
116parse_nodes("*", Config) ->
117    [{ok, ns_node_disco:nodes_wanted(Config)}];
118parse_nodes(undefined, _) ->
119    [{error, missing_nodes}];
120parse_nodes(NodesParam, Config) ->
121    KnownNodes = sets:from_list([atom_to_list(N) || N <- ns_node_disco:nodes_wanted(Config)]),
122    Nodes = string:tokens(NodesParam, ","),
123    {_Good, Bad} = lists:partition(
124                    fun (N) ->
125                            sets:is_element(N, KnownNodes)
126                    end, Nodes),
127    case Bad of
128        [] ->
129            case Nodes of
130                [] -> [{error, missing_nodes}];
131                _ -> [{ok, lists:usort([list_to_atom(N) || N <- Nodes])}]
132            end;
133        _ ->
134            [{error, {unknown_nodes, Bad}}]
135    end.
136
137is_field_valid(customer, Customer) ->
138    re:run(Customer, <<"^[A-Za-z0-9._ -]*$">>) =/= nomatch andalso length(Customer) =< 50;
139is_field_valid(ticket, Ticket) ->
140    re:run(Ticket, <<"^[0-9]*$">>) =/= nomatch andalso length(Ticket) =< 7.
141
142parse_validate_upload_url(UploadHost0, Customer0, Ticket0) ->
143    UploadHost = misc:trim(UploadHost0),
144    Customer = misc:trim(Customer0),
145    Ticket = misc:trim(Ticket0),
146    E0 = [{error, {malformed, K}} || {K, V} <- [{customer, Customer},
147                                                {ticket, Ticket}],
148                                     not is_field_valid(K, V)],
149    E1 = [{error, {empty, K}} || {K, V} <- [{customer, Customer},
150                                            {uploadHost, UploadHost}],
151                                 V =:= ""],
152    BasicErrors = E0 ++ E1,
153    case BasicErrors =/= [] of
154        true ->
155            BasicErrors;
156        _ ->
157            Prefix = case UploadHost of
158                         "http://" ++ _ -> "";
159                         "https://" ++ _ -> "";
160                         _ -> "https://"
161                     end,
162            Suffix = case lists:reverse(UploadHost) of
163                         "/" ++ _ ->
164                             "";
165                         _ ->
166                             "/"
167                     end,
168            URLNoTicket = Prefix ++ UploadHost ++ Suffix
169                ++ mochiweb_util:quote_plus(Customer) ++ "/",
170            URL = case Ticket of
171                      [] ->
172                          URLNoTicket;
173                      _ ->
174                          URLNoTicket ++ mochiweb_util:quote_plus(Ticket) ++ "/"
175                  end,
176            [{ok, URL}]
177    end.
178
179parse_validate_collect_params(Params, Config) ->
180    NodesRV = parse_nodes(proplists:get_value("nodes", Params), Config),
181
182    UploadHost = proplists:get_value("uploadHost", Params),
183    Customer = proplists:get_value("customer", Params),
184    %% we handle no ticket or empty ticket the same
185    Ticket = proplists:get_value("ticket", Params, ""),
186
187    UploadProxy = case proplists:get_value("uploadProxy", Params) of
188                      undefined -> [];
189                      P -> [{upload_proxy, P}]
190                  end,
191    LogDir = case proplists:get_value("logDir", Params) of
192                 undefined -> [];
193                 Val -> case misc:is_absolute_path(Val) of
194                            true -> [{log_dir, Val}];
195                            false -> [{error, {invalid_directory, logDir}}]
196                        end
197             end,
198    TmpDir = case proplists:get_value("tmpDir", Params) of
199                 undefined -> [];
200                 Value -> case misc:is_absolute_path(Value) of
201                              true -> [{tmp_dir, Value}];
202                              false -> [{error, {invalid_directory, tmpDir}}]
203                          end
204             end,
205    RedactLevel =
206        case proplists:get_value("logRedactionLevel", Params) of
207            undefined ->
208                ns_config:read_key_fast(log_redaction_default_cfg, []);
209            N when N =:= "none"; N =:= "partial" ->
210                case cluster_compat_mode:is_enterprise() of
211                    true ->
212                        case cluster_compat_mode:is_cluster_55() of
213                            true ->
214                                [{redact_level, list_to_atom(N)}];
215                            false ->
216                                [{error, {cluster_too_old, log_redaction}}]
217                        end;
218                    false ->
219                        [{error, {not_enterprise, log_redaction}}]
220                end;
221            _ ->
222                [{error, {unknown, log_redaction}}]
223        end,
224    RedactSalt =
225        case RedactLevel of
226            [{redact_level, partial}] ->
227                case proplists:get_value("logRedactionSalt", Params) of
228                    undefined ->
229                        %% We override the user input here because we want to
230                        %% have a common salt for all the nodes for this log
231                        %% collection run.
232                        S = base64:encode_to_string(crypto:rand_bytes(32)),
233                        [{redact_salt_fun, ?cut(S)}];
234                    Salt ->
235                        [{redact_salt_fun, ?cut(Salt)}]
236                end;
237            _ ->
238                case proplists:get_value("logRedactionSalt", Params) of
239                    undefined ->
240                        [];
241                    _ ->
242                        [{error, {salt_without_level, log_redaction}}]
243                end
244        end,
245
246    MaybeUpload = case [F || {F, P} <- [{upload, UploadHost},
247                                        {customer, Customer}],
248                             P =:= undefined] of
249                      [_, _] ->
250                          case Ticket of
251                              "" ->
252                                  [{ok, false}];
253                              _ ->
254                                  [{error, missing_customer},
255                                   {error, missing_upload}]
256                          end;
257                      [] ->
258                          parse_validate_upload_url(UploadHost, Customer, Ticket);
259                      [upload] ->
260                          [{error, missing_upload}];
261                      [customer] ->
262                          [{error, missing_customer}]
263                  end,
264
265    BasicErrors = [E || {error, E} <- NodesRV ++ TmpDir ++ LogDir ++
266                                      UploadProxy ++ RedactLevel ++
267                                      RedactSalt ++ MaybeUpload],
268    case BasicErrors of
269        [] ->
270            [{ok, Nodes}] = NodesRV,
271            [{ok, Upload}] = MaybeUpload,
272            Options = RedactLevel ++ RedactSalt ++ TmpDir ++
273                      LogDir ++ UploadProxy,
274            {ok, Nodes, Upload, Options};
275        _ ->
276            {errors, BasicErrors}
277    end.
278