| 1 |  | %%% eturnal STUN/TURN server. | 
| 2 |  | %%% | 
| 3 |  | %%% Copyright (c) 2020-2025 Holger Weiss <holger@zedat.fu-berlin.de>. | 
| 4 |  | %%% Copyright (c) 2020-2025 ProcessOne, SARL. | 
| 5 |  | %%% All rights reserved. | 
| 6 |  | %%% | 
| 7 |  | %%% Licensed under the Apache License, Version 2.0 (the "License"); | 
| 8 |  | %%% you may not use this file except in compliance with the License. | 
| 9 |  | %%% You may obtain a copy of the License at | 
| 10 |  | %%% | 
| 11 |  | %%%     http://www.apache.org/licenses/LICENSE-2.0 | 
| 12 |  | %%% | 
| 13 |  | %%% Unless required by applicable law or agreed to in writing, software | 
| 14 |  | %%% distributed under the License is distributed on an "AS IS" BASIS, | 
| 15 |  | %%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
| 16 |  | %%% See the License for the specific language governing permissions and | 
| 17 |  | %%% limitations under the License. | 
| 18 |  |  | 
| 19 |  | -module(eturnal_ctl). | 
| 20 |  | -export([get_credentials/2, | 
| 21 |  |          get_password/1, | 
| 22 |  |          get_sessions/0, | 
| 23 |  |          get_sessions/1, | 
| 24 |  |          get_status/0, | 
| 25 |  |          get_info/0, | 
| 26 |  |          get_version/0, | 
| 27 |  |          get_loglevel/0, | 
| 28 |  |          set_loglevel/1, | 
| 29 |  |          disconnect/1, | 
| 30 |  |          reload/0]). | 
| 31 |  |  | 
| 32 |  | -include_lib("kernel/include/logger.hrl"). | 
| 33 |  | -include("eturnal.hrl"). | 
| 34 |  |  | 
| 35 |  | -type sock_mod() :: gen_udp | gen_tcp | fast_tls. | 
| 36 |  | -type addr() :: inet:ip_address(). | 
| 37 |  | -type addr_port() :: {inet:ip_address(), inet:port_number()}. | 
| 38 |  |  | 
| 39 |  | -record(session, | 
| 40 |  |         {pid :: pid(), | 
| 41 |  |          sid :: binary(), | 
| 42 |  |          user :: binary(), | 
| 43 |  |          sock_mod :: sock_mod(), | 
| 44 |  |          client_addr :: addr_port(), | 
| 45 |  |          relay_addr :: addr_port(), | 
| 46 |  |          perm_addrs :: [addr()], | 
| 47 |  |          peer_addrs :: [addr_port()], | 
| 48 |  |          sent_bytes :: non_neg_integer(), | 
| 49 |  |          sent_pkts :: non_neg_integer(), | 
| 50 |  |          rcvd_bytes :: non_neg_integer(), | 
| 51 |  |          rcvd_pkts :: non_neg_integer(), | 
| 52 |  |          start_time :: integer()}). | 
| 53 |  |  | 
| 54 |  | -type session() :: #session{}. | 
| 55 |  |  | 
| 56 |  | %% API. | 
| 57 |  |  | 
| 58 |  | -spec get_credentials(term(), term()) -> {ok, string()} | {error, string()}. | 
| 59 |  | get_credentials(Expiry, Suffix) -> | 
| 60 | 10 |     ?LOG_DEBUG("Handling API call: get_credentials(~p, ~p)", [Expiry, Suffix]), | 
| 61 | 10 |     try make_username(Expiry, Suffix) of | 
| 62 |  |         Username -> | 
| 63 | 6 |             case call({get_password, Username}) of | 
| 64 |  |                 {ok, Password} -> | 
| 65 | 6 |                     Credentials = format_credentials(Username, Password), | 
| 66 | 6 |                     {ok, unicode:characters_to_list(Credentials)}; | 
| 67 |  |                 {error, no_credentials} -> | 
| 68 | :-( |                     {error, "No shared secret and no credentials"}; | 
| 69 |  |                 {error, timeout} -> | 
| 70 | :-( |                     {error, "Querying eturnal timed out"} | 
| 71 |  |             end | 
| 72 |  |     catch _:badarg -> | 
| 73 | 4 |             ?LOG_DEBUG("Invalid argument(s): ~p:~p", [Expiry, Suffix]), | 
| 74 | 4 |             {error, "Invalid expiry or suffix"} | 
| 75 |  |     end. | 
| 76 |  |  | 
| 77 |  | -spec get_password(term()) -> {ok, string()} | {error, string()}. | 
| 78 |  | get_password(Username0) -> | 
| 79 | 9 |     ?LOG_DEBUG("Handling API call: get_password(~p)", [Username0]), | 
| 80 | 9 |     try unicode:characters_to_binary(Username0) of | 
| 81 |  |         Username when is_binary(Username) -> | 
| 82 | 7 |             case call({get_password, Username}) of | 
| 83 |  |                 {ok, Password} -> | 
| 84 | 6 |                     {ok, unicode:characters_to_list(Password)}; | 
| 85 |  |                 {error, no_credentials} -> | 
| 86 | 1 |                     {error, "No shared secret and no credentials"}; | 
| 87 |  |                 {error, timeout} -> | 
| 88 | :-( |                     {error, "Querying eturnal timed out"} | 
| 89 |  |             end; | 
| 90 |  |         {_, _, _} -> | 
| 91 | :-( |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 92 | :-( |             {error, "User name must be specified as a string"} | 
| 93 |  |     catch _:badarg -> | 
| 94 | 2 |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 95 | 2 |             {error, "User name must be specified as a string"} | 
| 96 |  |     end. | 
| 97 |  |  | 
| 98 |  | -spec get_sessions() -> {ok, string()} | {error, string()}. | 
| 99 |  | get_sessions() -> | 
| 100 | 1 |     ?LOG_DEBUG("Handling API call: get_sessions()"), | 
| 101 | 1 |     case query_all_sessions() of | 
| 102 |  |         [_ | _] = Sessions -> | 
| 103 | :-( |             {ok, unicode:characters_to_list(format_sessions(Sessions))}; | 
| 104 |  |         [] -> | 
| 105 | 1 |             {ok, "No active TURN sessions"} | 
| 106 |  |     end. | 
| 107 |  |  | 
| 108 |  | -spec get_sessions(term()) -> {ok, string()} | {error, string()}. | 
| 109 |  | get_sessions(Username0) -> | 
| 110 | 3 |     ?LOG_DEBUG("Handling API call: get_sessions(~p)", [Username0]), | 
| 111 | 3 |     try unicode:characters_to_binary(Username0) of | 
| 112 |  |         Username when is_binary(Username) -> | 
| 113 | 2 |             case query_user_sessions(Username) of | 
| 114 |  |                 [_ | _] = Sessions -> | 
| 115 | :-( |                     {ok, unicode:characters_to_list(format_sessions(Sessions))}; | 
| 116 |  |                 [] -> | 
| 117 | 2 |                     {ok, "No active TURN sessions"} | 
| 118 |  |             end; | 
| 119 |  |         {_, _, _} -> | 
| 120 | :-( |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 121 | :-( |             {error, "User name must be specified as a string"} | 
| 122 |  |     catch _:badarg -> | 
| 123 | 1 |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 124 | 1 |             {error, "User name must be specified as a string"} | 
| 125 |  |     end. | 
| 126 |  |  | 
| 127 |  | -spec get_status() -> {ok, string()} | {error, string()}. | 
| 128 |  | get_status() -> | 
| 129 | 1 |     ?LOG_DEBUG("Handling API call: get_status()"), | 
| 130 | 1 |     case call(get_status) of | 
| 131 |  |         ok -> | 
| 132 | 1 |             {ok, "eturnal is running"}; | 
| 133 |  |         {error, timeout} -> | 
| 134 | :-( |             {error, "Querying eturnal timed out"} | 
| 135 |  |     end. | 
| 136 |  |  | 
| 137 |  | -spec get_info() -> {ok, string()} | {error, string()}. | 
| 138 |  | get_info() -> | 
| 139 | 1 |     ?LOG_DEBUG("Handling API call: get_info()"), | 
| 140 | 1 |     case call(get_info) of | 
| 141 |  |         {ok, Info} -> | 
| 142 | 1 |             {ok, unicode:characters_to_list(format_info(Info))}; | 
| 143 |  |         {error, timeout} -> | 
| 144 | :-( |             {error, "Querying eturnal timed out"} | 
| 145 |  |     end. | 
| 146 |  |  | 
| 147 |  | -spec get_version() -> {ok, string()} | {error, string()}. | 
| 148 |  | get_version() -> | 
| 149 | 1 |     ?LOG_DEBUG("Handling API call: get_version()"), | 
| 150 | 1 |     case call(get_version) of | 
| 151 |  |         {ok, Version} -> | 
| 152 | 1 |             {ok, unicode:characters_to_list(Version)}; | 
| 153 |  |         {error, timeout} -> | 
| 154 | :-( |             {error, "Querying eturnal timed out"} | 
| 155 |  |     end. | 
| 156 |  |  | 
| 157 |  | -spec get_loglevel() -> {ok, string()} | {error, string()}. | 
| 158 |  | get_loglevel() -> | 
| 159 | 2 |     ?LOG_DEBUG("Handling API call: get_loglevel()"), | 
| 160 | 2 |     case call(get_loglevel) of | 
| 161 |  |         {ok, Level} -> | 
| 162 | 2 |             {ok, atom_to_list(Level)}; | 
| 163 |  |         {error, timeout} -> | 
| 164 | :-( |             {error, "Querying eturnal timed out"} | 
| 165 |  |     end. | 
| 166 |  |  | 
| 167 |  | -spec set_loglevel(term()) -> ok | {error, string()}. | 
| 168 |  | set_loglevel(Level) when is_atom(Level) -> | 
| 169 | 2 |     ?LOG_DEBUG("Handling API call: set_loglevel(~s)", [Level]), | 
| 170 | 2 |     case eturnal_logger:is_valid_level(Level) of | 
| 171 |  |         true -> | 
| 172 | 1 |             case call({set_loglevel, Level}) of | 
| 173 |  |                 ok -> | 
| 174 | 1 |                     ok; | 
| 175 |  |                 {error, timeout} -> | 
| 176 | :-( |                     {error, "Querying eturnal timed out"} | 
| 177 |  |             end; | 
| 178 |  |         false -> | 
| 179 | 1 |             ?LOG_DEBUG("Invalid log level: ~s", [Level]), | 
| 180 | 1 |             {error, "Not a valid log level: " ++ atom_to_list(Level)} | 
| 181 |  |     end; | 
| 182 |  | set_loglevel(Level) -> | 
| 183 | :-( |     ?LOG_DEBUG("Invalid API call: set_loglevel(~p)", [Level]), | 
| 184 | :-( |     {error, "Log level must be specified as an atom"}. | 
| 185 |  |  | 
| 186 |  | -spec disconnect(term()) -> {ok, string()} | {error, string()}. | 
| 187 |  | disconnect(Username0) -> | 
| 188 | 2 |     ?LOG_DEBUG("Handling API call: disconnect(~p)", [Username0]), | 
| 189 | 2 |     try unicode:characters_to_binary(Username0) of | 
| 190 |  |         Username when is_binary(Username) -> | 
| 191 | 1 |             N = disconnect_user(Username), | 
| 192 | 1 |             Msg = io_lib:format("Disconnected ~B TURN session(s)", [N]), | 
| 193 | 1 |             {ok, unicode:characters_to_list(Msg)}; | 
| 194 |  |         {_, _, _} -> | 
| 195 | :-( |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 196 | :-( |             {error, "User name must be specified as a string"} | 
| 197 |  |     catch _:badarg -> | 
| 198 | 1 |             ?LOG_DEBUG("Cannot convert user name to binary: ~p", [Username0]), | 
| 199 | 1 |             {error, "User name must be specified as a string"} | 
| 200 |  |     end. | 
| 201 |  |  | 
| 202 |  | -spec reload() -> ok | {error, string()}. | 
| 203 |  | reload() -> | 
| 204 | 2 |     ?LOG_DEBUG("Handling API call: reload()"), | 
| 205 | 2 |     case call(reload) of | 
| 206 |  |         ok -> | 
| 207 | 2 |             ok; | 
| 208 |  |         {error, timeout} -> | 
| 209 | :-( |             {error, "Querying eturnal timed out"}; | 
| 210 |  |         {error, Reason} -> | 
| 211 | :-( |             {error, conf:format_error(Reason)} | 
| 212 |  |     end. | 
| 213 |  |  | 
| 214 |  | %% Internal functions. | 
| 215 |  |  | 
| 216 |  | -spec make_username(string(), string()) -> binary(). | 
| 217 |  | make_username(Expiry0, Suffix) -> | 
| 218 | 10 |     Expiry = try string:trim(Expiry0) of | 
| 219 |  |                  Trimmed -> | 
| 220 | 7 |                      Trimmed | 
| 221 |  |              catch _:function_clause -> | 
| 222 | 3 |                      erlang:error(badarg) | 
| 223 |  |              end, | 
| 224 | 7 |     try calendar:rfc3339_to_system_time(Expiry) of | 
| 225 |  |         Time -> | 
| 226 | 1 |             username_from_timestamp(Time, Suffix) | 
| 227 |  |     catch | 
| 228 |  |         _:function_clause -> | 
| 229 | 6 |             username_from_expiry(Expiry, Suffix); | 
| 230 |  |         _:{badmatch, _} -> % Erlang/OTP < 28.0. | 
| 231 | :-( |             username_from_expiry(Expiry, Suffix); | 
| 232 |  |         _:badarg -> % Erlang/OTP < 21.3. | 
| 233 | :-( |             username_from_expiry(Expiry, Suffix) | 
| 234 |  |     end. | 
| 235 |  |  | 
| 236 |  | -spec username_from_timestamp(integer(), string()) -> binary(). | 
| 237 |  | username_from_timestamp(Time, [])  -> | 
| 238 | 1 |     integer_to_binary(Time); | 
| 239 |  | username_from_timestamp(Time, Suffix) -> | 
| 240 | 5 |     Username = io_lib:format("~B:~s", [Time, Suffix]), | 
| 241 | 5 |     unicode:characters_to_binary(Username). | 
| 242 |  |  | 
| 243 |  | -spec username_from_expiry(string(), string()) -> binary(). | 
| 244 |  | username_from_expiry(Expiry0, Suffix) -> | 
| 245 | 6 |     case {unicode:characters_to_binary(Expiry0), | 
| 246 |  |           io_lib:printable_unicode_list(Suffix)} of | 
| 247 |  |         {Expiry, true} when is_binary(Expiry) -> | 
| 248 | 6 |             Time = erlang:system_time(second) + parse_expiry(Expiry), | 
| 249 | 5 |             username_from_timestamp(Time, Suffix); | 
| 250 |  |         {_, _} -> | 
| 251 | :-( |             erlang:error(badarg) | 
| 252 |  |     end. | 
| 253 |  |  | 
| 254 |  | -spec parse_expiry(binary()) -> pos_integer(). | 
| 255 |  | parse_expiry(Expiry) -> | 
| 256 | 6 |     case string:to_integer(Expiry) of | 
| 257 |  |         {N, <<>>} when is_integer(N), N > 0 -> | 
| 258 | 1 |             N; | 
| 259 |  |         {N, <<"s">>} when is_integer(N), N > 0 -> | 
| 260 | 1 |             N; | 
| 261 |  |         {N, <<"m">>} when is_integer(N), N > 0 -> | 
| 262 | 1 |             N * 60; | 
| 263 |  |         {N, <<"h">>} when is_integer(N), N > 0 -> | 
| 264 | 1 |             N * 3600; | 
| 265 |  |         {N, <<"d">>} when is_integer(N), N > 0 -> | 
| 266 | 1 |             N * 86400; | 
| 267 |  |         {_, _} -> | 
| 268 | 1 |             erlang:error(badarg) | 
| 269 |  |     end. | 
| 270 |  |  | 
| 271 |  | -spec filter_sessions(fun((session()) -> boolean())) -> [session()]. | 
| 272 |  | filter_sessions(Pred) -> | 
| 273 | 4 |     lists:filtermap( | 
| 274 |  |       fun({_, PID, worker, _}) -> | 
| 275 | :-( |               try query_state(PID) of | 
| 276 |  |                   State -> | 
| 277 | :-( |                       Session = #session{ | 
| 278 |  |                                    pid = PID, | 
| 279 |  |                                    sid = element(27, State), | 
| 280 |  |                                    user = element(6, State), | 
| 281 |  |                                    sock_mod = element(2, State), | 
| 282 |  |                                    client_addr = element(4, State), | 
| 283 |  |                                    relay_addr = element(18, State), | 
| 284 |  |                                    perm_addrs = maps:keys(element(12, State)), | 
| 285 |  |                                    peer_addrs = maps:keys(element(10, State)), | 
| 286 |  |                                    sent_bytes = element(32, State), | 
| 287 |  |                                    sent_pkts = element(33, State), | 
| 288 |  |                                    rcvd_bytes = element(30, State), | 
| 289 |  |                                    rcvd_pkts = element(31, State), | 
| 290 |  |                                    start_time = element(34, State)}, | 
| 291 | :-( |                       case Pred(Session) of | 
| 292 |  |                           true -> | 
| 293 | :-( |                               {true, Session}; | 
| 294 |  |                           false -> | 
| 295 | :-( |                               false | 
| 296 |  |                       end | 
| 297 |  |               catch exit:{Reason, _} when Reason =:= noproc; | 
| 298 |  |                                           Reason =:= normal; | 
| 299 |  |                                           Reason =:= shutdown; | 
| 300 |  |                                           Reason =:= killed; | 
| 301 |  |                                           Reason =:= timeout -> | 
| 302 | :-( |                       ?LOG_DEBUG("Cannot query TURN session ~p: ~s", | 
| 303 | :-( |                                  [PID, Reason]), | 
| 304 | :-( |                       false | 
| 305 |  |               end | 
| 306 |  |       end, supervisor:which_children(turn_tmp_sup)). | 
| 307 |  |  | 
| 308 |  | -spec query_user_sessions(binary()) -> [session()]. | 
| 309 |  | query_user_sessions(Username) -> | 
| 310 | 3 |     Pred = fun(#session{user = User}) when User =:= Username -> | 
| 311 | :-( |                    true; | 
| 312 |  |               (#session{user = User}) -> % Match 1256900400:Username. | 
| 313 | :-( |                    case binary:split(User, <<$:>>) of | 
| 314 |  |                        [_Expiration, Username] -> | 
| 315 | :-( |                            true; | 
| 316 |  |                        _ -> | 
| 317 | :-( |                            false | 
| 318 |  |                    end | 
| 319 |  |            end, | 
| 320 | 3 |     filter_sessions(Pred). | 
| 321 |  |  | 
| 322 |  | -spec query_all_sessions() -> [session()]. | 
| 323 |  | query_all_sessions() -> | 
| 324 | 1 |     filter_sessions(fun(_Session) -> true end). | 
| 325 |  |  | 
| 326 |  | -spec query_state(pid()) -> tuple(). | 
| 327 |  | query_state(PID) -> % Until we add a proper API to 'stun'. | 
| 328 | :-( |     {value, State} = lists:search( | 
| 329 |  |                        fun(E) -> | 
| 330 | :-( |                                is_tuple(E) andalso element(1, E) =:= state | 
| 331 |  |                        end, sys:get_state(PID)), | 
| 332 | :-( |     State. | 
| 333 |  |  | 
| 334 |  | -spec disconnect_user(binary()) -> non_neg_integer(). | 
| 335 |  | disconnect_user(User) -> | 
| 336 | 1 |     lists:foldl(fun(#session{user = U, pid = PID, sid = SID}, N) -> | 
| 337 | :-( |                         ?LOG_DEBUG("Disconnecting session ~s of ~s", [SID, U]), | 
| 338 | :-( |                         _ = supervisor:terminate_child(turn_tmp_sup, PID), | 
| 339 | :-( |                         N + 1 | 
| 340 |  |                 end, 0, query_user_sessions(User)). | 
| 341 |  |  | 
| 342 |  | -spec format_sessions([session()]) -> io_lib:chars(). | 
| 343 |  | format_sessions(Sessions) -> | 
| 344 | :-( |     H = io_lib:format("~B active TURN sessions:", [length(Sessions)]), | 
| 345 | :-( |     T = lists:map( | 
| 346 |  |           fun(#session{user = User, | 
| 347 |  |                        sock_mod = SockMod, | 
| 348 |  |                        client_addr = ClientAddr, | 
| 349 |  |                        relay_addr = RelayAddr, | 
| 350 |  |                        perm_addrs = PermAddrs, | 
| 351 |  |                        peer_addrs = PeerAddrs, | 
| 352 |  |                        sent_bytes = SentBytes, | 
| 353 |  |                        sent_pkts = SentPkts, | 
| 354 |  |                        rcvd_bytes = RcvdBytes, | 
| 355 |  |                        rcvd_pkts = RcvdPkts, | 
| 356 |  |                        start_time = StartTime}) -> | 
| 357 | :-( |                   Duration0 = erlang:monotonic_time() - StartTime, | 
| 358 | :-( |                   Duration = erlang:convert_time_unit( | 
| 359 |  |                                Duration0, native, second), | 
| 360 | :-( |                   Transport = format_transport(SockMod), | 
| 361 | :-( |                   Client = eturnal_misc:addr_to_str(ClientAddr), | 
| 362 | :-( |                   Relay = eturnal_misc:addr_to_str(RelayAddr), | 
| 363 | :-( |                   Peers = format_addrs(PeerAddrs), | 
| 364 | :-( |                   Perms = format_addrs(PermAddrs), | 
| 365 | :-( |                   io_lib:format( | 
| 366 |  |                     "-- TURN session of ~ts --~s" | 
| 367 |  |                     "          Client: ~s (~s)~s" | 
| 368 |  |                     "           Relay: ~s (UDP)~s" | 
| 369 |  |                     "   Permission(s): ~s~s" | 
| 370 |  |                     "         Peer(s): ~s~s" | 
| 371 |  |                     "            Sent: ~B KiB (~B packets)~s" | 
| 372 |  |                     "        Received: ~B KiB (~B packets)~s" | 
| 373 |  |                     "     Running for: ~B seconds", | 
| 374 |  |                     [User, nl(), | 
| 375 |  |                      Client, Transport, nl(), | 
| 376 |  |                      Relay, nl(), | 
| 377 |  |                      Perms, nl(), | 
| 378 |  |                      Peers, nl(), | 
| 379 |  |                      round(SentBytes / 1024), SentPkts, nl(), | 
| 380 |  |                      round(RcvdBytes / 1024), RcvdPkts, nl(), | 
| 381 |  |                      Duration]) | 
| 382 |  |           end, Sessions), | 
| 383 | :-( |     lists:join([nl(), nl()], [H | T]). | 
| 384 |  |  | 
| 385 |  | -spec format_info(eturnal_node_info()) -> io_lib:chars(). | 
| 386 |  | format_info(#eturnal_node_info{ | 
| 387 |  |                eturnal_vsn = EturnalVsn, | 
| 388 |  |                otp_vsn = {OtpVsn, ErtsVsn}, | 
| 389 |  |                uptime = Uptime, | 
| 390 |  |                num_sessions = Sessions, | 
| 391 |  |                num_processes = Procs, | 
| 392 |  |                num_reductions = Reductions, | 
| 393 |  |                total_queue_len = QueueLen, | 
| 394 |  |                total_memory = Memory}) -> | 
| 395 | 1 |     MiB = round(Memory / 1024 / 1024), | 
| 396 | 1 |     Seconds = erlang:convert_time_unit(Uptime, millisecond, second), | 
| 397 | 1 |     {Ds, {Hs, Ms, Ss}} = calendar:seconds_to_daystime(Seconds), | 
| 398 | 1 |     io_lib:format( | 
| 399 |  |       "eturnal ~s on Erlang/OTP ~s (ERTS ~s)~s" | 
| 400 |  |       "Uptime: ~B days, ~B hours, ~B minutes, ~B seconds~s" | 
| 401 |  |       "Active TURN sessions: ~B~s" | 
| 402 |  |       "Processes: ~B~s" | 
| 403 |  |       "Total length of run queues: ~B~s" | 
| 404 |  |       "Total CPU usage (reductions): ~B~s" | 
| 405 |  |       "Allocated memory (MiB): ~B", | 
| 406 |  |       [EturnalVsn, OtpVsn, ErtsVsn, nl(), | 
| 407 |  |        Ds, Hs, Ms, Ss, nl(), | 
| 408 |  |        Sessions, nl(), | 
| 409 |  |        Procs, nl(), | 
| 410 |  |        QueueLen, nl(), | 
| 411 |  |        Reductions, nl(), | 
| 412 |  |        MiB]). | 
| 413 |  |  | 
| 414 |  | -spec format_transport(sock_mod()) -> binary(). | 
| 415 |  | format_transport(gen_udp) -> | 
| 416 | :-( |     <<"UDP">>; | 
| 417 |  | format_transport(gen_tcp) -> | 
| 418 | :-( |     <<"TCP">>; | 
| 419 |  | format_transport(fast_tls) -> | 
| 420 | :-( |     <<"TLS">>. | 
| 421 |  |  | 
| 422 |  | -spec format_addrs([addr() | addr_port()]) -> iodata(). | 
| 423 |  | format_addrs([]) -> | 
| 424 | :-( |     <<"none">>; | 
| 425 |  | format_addrs(PeerAddrs) -> | 
| 426 | :-( |     [lists:join(", ", lists:map(fun eturnal_misc:addr_to_str/1, PeerAddrs)), | 
| 427 |  |      <<" (UDP)">>]. | 
| 428 |  |  | 
| 429 |  | -spec format_credentials(binary(), binary()) -> iodata(). | 
| 430 |  | format_credentials(Username, Password) -> | 
| 431 | 6 |     io_lib:format("Username: ~s~s" | 
| 432 |  |                   "Password: ~s", | 
| 433 |  |                   [Username, nl(), | 
| 434 |  |                    Password]). | 
| 435 |  |  | 
| 436 |  | -spec nl() -> string(). | 
| 437 |  | nl() -> | 
| 438 | 12 |     [$~, $n]. % Let the caller convert "~n"s to actual newline characters. | 
| 439 |  |  | 
| 440 |  | -spec call(term()) -> ok | {ok | error, term()}. | 
| 441 |  | call(Request) -> | 
| 442 | 21 |     try gen_server:call(eturnal, Request, timer:minutes(1)) of | 
| 443 |  |         ok -> | 
| 444 | 4 |             ?LOG_DEBUG("eturnal call (~p) returned ok", [Request]), | 
| 445 | 4 |             ok; | 
| 446 |  |         {ok, _Value} = Result -> | 
| 447 | 16 |             ?LOG_DEBUG("eturnal call (~p) returned ~p", [Request, Result]), | 
| 448 | 16 |             Result; | 
| 449 |  |         {error, _Reason} = Err -> | 
| 450 | 1 |             ?LOG_DEBUG("eturnal call (~p) returned ~p", [Request, Err]), | 
| 451 | 1 |             Err | 
| 452 |  |     catch exit:{timeout, _} -> | 
| 453 | :-( |             ?LOG_DEBUG("eturnal call (~p) timed out", [Request]), | 
| 454 | :-( |             {error, timeout} | 
| 455 |  |     end. |