diff options
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r-- | lib/irc/connection.ex | 347 |
1 files changed, 247 insertions, 100 deletions
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index f43fca7..052141c 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -11,10 +11,39 @@ defmodule Irc.Connection do @reply_timeout :timer.seconds(1) @cap_timeout :timer.seconds(15) @registration_timeout :timer.seconds(30) - @support_capabs ["sasl", "cap-notify"] + @connection_capabs ["sasl", "cap-notify"] @internal {:next_event, :internal, nil} - defstruct [:server, :nick, :modes, :capabs, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :isupport, :motd] + @moduledoc """ + Lightweight implementation of an IRC Connection handling only the basics: + + * the socket and reconnections, + * registration process, + * sasl authentication, + * capabilities negotiation, + * ping, + * nick and user mode changes. + + Events and lines are sent to calling PID. + + ## Usage + + The client is started using `start/4` or `start_link/4`, supplying a nickname, IRC host, and options `t:start_opt/0`: + + ```elixir + {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) + ``` + + Messages will be sent to your PID, see `t:message/0` for the possible types. + + You can send lines using `sendline/3`, and disconnect with `disconnect/3`. + + Status and information about the connection (`t:t/0`) can be requested with `info/1`. + + If you are looking for a more featured client, you probably want `Irc.Client`! + """ + + defstruct [:server, :nick, :modes, :capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd] @typedoc "Connection Information state" @type t :: %__MODULE__{ @@ -22,25 +51,24 @@ defmodule Irc.Connection do nick: String.t, capabs: list(), modes: String.t, - server_version: String.t, - server_umodes: String.t, - server_cmodes: String.t, - server_cmodes_with_params: String.t, - isupport: isupport(), + version: String.t, + umodes: String.t, + cmodes: String.t, + cmodes_with_params: String.t, + isupport: Irc.Parser.Isupport.t(), motd: [String.t], } - @typedoc "Server ISUPPORT" - @type isupport :: %{String.t() => true | nil | String.t | %{String.t() => any()}} - @typedoc "Messages sent to the owning process." @type message :: msg_up() | msg_down() | msg_error() - | msg_line() | msg_umodes() | msg_nick() + | msg_line() | msg_batch() | msg_umodes() | msg_nick() @typedoc "Connection is now up and registered." @type msg_up :: {:irc_conn_up, pid(), t()} @typedoc "Message: Received an IRC line." @type msg_line :: {:irc_conn_line, pid(), Line.t} + @typedoc "Message: Line batch (only if enabled, see `batch` in `t:start_opt/0`)." + @type msg_batch :: {:irc_conn_batch, pid(), type :: String.t(), type_params :: [String.t()], lines :: [Line.t]} @typedoc "Message: Connection user modes have changed." @type msg_umodes :: {:irc_conn_modes, pid(), changes :: String.t, previous_modes :: list(), modes :: list()} @typedoc "Message: Connection nickname have changed." @@ -58,61 +86,81 @@ defmodule Irc.Connection do | {:quit, reason :: String.t} | {:irc_error, reason :: String.t} + @typedoc """ Start options. * user: IRC user, * name: IRC real name, * pass: IRC server password, - * capabs: IRCv3 capabs to request (if available), - * port, * tls, - * reconnect, - * process, - * tcp_opts and tls_opts. + * port (default 6667, 6697 if tls), + * reconnect (automatic/backoff reconnection), + * [batch][batch] + * [sasl][sasl] options, see `t:start_opt_sasl/0`, + * [webirc][webirc] options, see `t:start_opt_webirc/0`, + * capabs: list to request, + * connect options (either `t:gen_tcp.connect_option/0` or `t:ssl.start_option/0`, depending on the value of the `tls`), + * owner process. + """ @type start_opt :: {:user, nil | String.t} | {:name, nil | String.t} | {:pass, nil | String.t} - | {:capabs, [String.t]} - | {:port, 6667 | Integer.t} + | {:port, 6667 | 6697 | Integer.t} | {:tls, false | true} - | {:inet, nil | 4 | 6} - | {:iface, nil | String.t} - | {:ip, nil | String.t} + | {:sts, start_opt_sts()} | {:reconnect, true | false} - | {:process, pid} - | {:tcp_opts, [:gen_tcp.connect_option]} - | {:tls_opts, [:ssl.connect_option]} + | {:sasl, start_opt_sasl()} + | {:batch, start_opt_batch()} + | {:echo, start_opt_echo()} + | {:webirc, nil | start_opt_webirc()} + | {:capabs, [String.t()]} + | {:connect_opts, [:gen_tcp.connect_option | :ssl.connect_option]} + | {:process, pid()} - @moduledoc """ - Lightweight implementation of an IRC Connection handling only the basics: + @typedoc """ + IRCv3 [Strict Transport Security]() - * the socket and reconnections, - * registration process, - * sasl authentication, - * capabilities negotiation, - * ping, - * nick and user mode changes. + Enabled by default. If a STS policy is enabled or known and the connection not secured, the client will automatically reconnect with tls. + """ + @type start_opt_sts :: true | false - Events and lines are sent to calling PID. + @typedoc """ + [SASL][sasl] Authentication options - ## Usage + [sasl]: https://ircv3.net/specs/extensions/sasl-3.1.html + """ + @type start_opt_sasl :: %{mechanism :: String.t => [mechanism_param() :: String.t]} - The client is started using `start/4` or `start_link/4`: + @typedoc """ + IRCv3 [Batch][batch] support. - ```elixir - {:ok, Pid} = Irc.Connection.start("ExIrcTest", "irc.random.sh", [tls: true, port: 6697], []) - ``` + Enabled by default. If negotiated, batched lines are sent with the `t:msg_batch/0` message. - Messages will be sent to your PID, see `t:message/0` for the possible types. + If the capability wasn't negotiated or disabled, individual `t:msg_line/0` messages will be sent. - You can send lines using `sendline/3`, and disconnect with `disconnect/3`. + [batch]: https://ircv3.net/specs/extensions/batch-3.2 + """ + @type start_opt_batch :: true | false - Status and information about the connection (`t:t/0`) can be requested with `info/1`. + @typedoc """ + IRCv3 [echo-message](https://ircv3.net/specs/extensions/echo-message-3.2) support. - If you are looking for a more featured client, you probably want `Irc.Client`! + Enabled by default. If negotiated, PRIVMSG and NOTICE sent by the connection will be echoed by the server. + """ + @type start_opt_echo :: true | false + + @typedoc """ + Send a [WEBIRC][webirc] command upon connection. + + ``` + {"hunter5", "web.irc.mynetwork", "some-user-hostname", "127.0.0.1", %{"secure" => true}} + ``` + + [webirc]: https://ircv3.net/specs/extensions/webirc """ + @type start_opt_webirc :: {password ::String.t, gateway :: String.t, hostname :: String.t, ip :: String.t, options :: Map.t} @type nick :: String.t @type host :: String.t @@ -187,6 +235,18 @@ defmodule Irc.Connection do result end + @spec capab?(t, String.t) :: boolean + @doc "Returns true if the capab `feature` has been negotiated for this connection." + def capab?(%__MODULE__{capabs: capabs}, feature) do + Enum.member?(capabs, feature) + end + + @spec supports?(String.t, t) :: boolean + @doc "Returns true if `feature` is reported as supported by the server (ISUPPORT)." + def supports?(%__MODULE__{isupport: isupport}, feature) do + Map.has_key?(isupport, String.upcase(feature)) + end + @doc "Flush all messages from the connection." def flush(pid) do receive do @@ -204,22 +264,23 @@ defmodule Irc.Connection do defmodule State do @moduledoc false defstruct [:args, - :backoff, :socket, :sockmodule, {:tries, 0}, + :backoff, :socket, :sockmodule, :process, :monitor, :nick, :server, - :server_version, - :server_umodes, - :server_cmodes, - :server_cmodes_with_params, + :version, + :umodes, + :cmodes, + :cmodes_with_params, :welcomed, + :sts, :port, :tls, + {:batches, %{}}, {:errored, false}, {:isupport, %{}}, {:modes, []}, {:server_capabs, []}, - {:req_capabs, []}, + {:client_capabs, []}, {:capabs, []}, - {:buffer, []}, {:motd, []}, {:welcome, []}, ] @@ -235,11 +296,31 @@ defmodule Irc.Connection do process = Keyword.get(args, :process, nil) monitor = Process.monitor(process) with \ - {:ok, processed_args} <- process_args(args), + {:ok, args} <- process_args(args), backoff <- :backoff.init(backoff_min, backoff_max) do + {tls, sts, port} = if !args.tls && args.sts do + case Irc.STS.lookup(args.host, args.port) do + {true, port} -> {true, true, port} + {false, port} -> {false, false, port} + end + else + {args.tls, false, args.port} + end + client_capabs = [Irc.Parser.capabs(), + @connection_capabs, + if(args.batch, do: ["batch"]), + if(args.sasl, do: ["sasl"]), + if(args.echo, do: ["echo-message"]), + args.capabs, + ] + |> Enum.filter(fn(i) -> i end) + |> List.flatten() + |> Enum.uniq() data = %State{ - args: processed_args, + args: args, + sts: sts, port: port, tls: tls, + client_capabs: client_capabs, process: process, monitor: monitor, backoff: backoff @@ -254,14 +335,14 @@ defmodule Irc.Connection do # TCP Open @doc false - def connect(:internal, _, data = %{args: %{tls: true}}) do + def connect(:internal, _, data = %{tls: true}) do {:next_state, :connect_tls, data, {:next_event, :internal, nil}} end def connect(:internal, _, data) do Logger.debug("#{inspect(data)} connecting...") - options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts - case :gen_tcp.connect(String.to_charlist(data.args.host), data.args.port, options) do + options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts + case :gen_tcp.connect(String.to_charlist(data.args.host), data.port, options) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:gen_tcp, socket}}} @@ -279,8 +360,8 @@ defmodule Irc.Connection do @doc false def connect_tls(:internal, nil, data) do - options = data.args.bind ++ data.args.inet ++ [{:active, true}, {:packet, :line}] ++ data.args.tcp_opts ++ data.args.tls_opts - case :ssl.connect(String.to_charlist(data.args.host), data.args.port, options) do + options = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts + case :ssl.connect(String.to_charlist(data.args.host), data.port, options) do {:ok, socket} -> {_, backoff} = :backoff.succeed(data.backoff) {:next_state, :connected, %State{data | backoff: backoff}, {:next_event, :internal, {:ssl, socket}}} @@ -295,13 +376,43 @@ defmodule Irc.Connection do @doc false def connected(:internal, {module, socket}, data) do - {:next_state, :cap_ls, %State{data | sockmodule: module, socket: socket}, @internal} + next = if Map.get(data.args, :webirc) do + :webirc + else + :cap_ls + end + + {:next_state, next, %State{data | sockmodule: module, socket: socket}, @internal} end def connected(type, content, data) do handle_common(:connected, type, content, data) end + # Registration: WEBIRC + # https://ircv3.net/specs/extensions/webirc + + @doc false + def webirc(:internal, _, data = %{args: args}) do + {password, gateway, hostname, ip, options} = Keyword.get(args, :webirc) + opt_string = Enum.map(options, fn({key, value}) -> + if value == true do + key + else + key <> "=" <> value + end + end) + |> Enum.join(" ") + case socksend(data, ['WEBIRC ', password, ' ', gateway, ' ', hostname, ' ', ip, ' :', options]) do + :ok -> {:next_state, :cap_ls, data, @internal} + error -> to_disconnected(error, data) + end + end + + def webirc(type, content, data) do + handle_common(:webirc, type, content, data) + end + # Registration: LIST CAPABS @doc false @@ -321,18 +432,15 @@ defmodule Irc.Connection do {:keep_state, %State{data | server_capabs: data.capabs ++ capabs}, {:state_timeout, @reply_timeout, nil}} l = %Line{command: "CAP", args: ["*", "LS", rest]} -> capabs = Enum.uniq(data.capabs ++ String.split(rest, " ")) - {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal} - # TODO: What do? + {:next_state, :cap_sts, %State{data | server_capabs: capabs}, @internal} l -> handle_common(:cap_ls, :info, msg, data) - # {:next_state, :registration, data, {:next_event, :internal, nil}} end end def cap_ls(:state_timeout, _, data) do - capabs = Enum.uniq(data.capabs) Logger.debug("State timeout") - {:next_state, :reg_pass, %State{data | server_capabs: capabs}, @internal} + {:next_state, :cap_sts, data, @internal} end def cap_ls(type, content, data) do @@ -340,6 +448,41 @@ defmodule Irc.Connection do handle_common(:cap_ls, type, content, data) end + # REGISTRATION: Check for STS + + @doc false + def cap_sts(:internal, _, data = %{args: %{sts: true}}) do + sts? = Enum.find(data.capabs, fn(capab) -> String.starts_with?(capab, "sts=") end) + if sts? do + "sts="<>rules = sts? + rules = Map.reduce(String.split(rules, ","), %{}, fn(k_v, acc) -> + [k, v] = String.split(k_v, "=") + Map.put(acc, k, v) + end) + port = Map.get(rules, "port") + duration = Map.get(rules, "duration") + Irc.STS.witness(data.args.host, data.args.port, port, Map.get(rules, "duration")) + if !data.tls && port && duration do + Logger.info("#{inspect data} STS advertised, reconnecting to tls port #{port}") + socksend(data, ['QUIT :STS, Reconnecting']) + data.sockmodule.close(data.socket) + {:next_state, :connect, %State{data | sts: true, port: port, tls: true, socket: nil, sockmodule: nil}, @internal} + else + {:next_state, :reg_pass, %State{data | sts: true}, @internal} + end + else + {:next_state, :reg_pass, data, @internal} + end + end + + def cap_sts(:internal, _, data) do + {:next_state, :reg_pass, data, @internal} + end + + def cap_sts(type, content, data) do + handle_common(:cap_sts, type, content, data) + end + # Registration: PASS @doc false @@ -432,8 +575,7 @@ defmodule Irc.Connection do end def cap_req(:internal, _, data) do - capabs = data.args.capabs ++ @support_capabs - req = Enum.filter(@support_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end) + req = Enum.filter(data.client_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end) |> Enum.uniq() |> Enum.join(" ") case socksend(data, ['CAP REQ :', String.to_charlist(req)]) do @@ -493,8 +635,8 @@ defmodule Irc.Connection do [] -> nil [c] -> c end - data = %State{data | welcome: [l | data.welcome], server_version: version, server_umodes: umodes, - server_cmodes: cmodes, server_cmodes_with_params: cmodes_with_params} + data = %State{data | welcome: [l | data.welcome], version: version, umodes: umodes, + cmodes: cmodes, cmodes_with_params: cmodes_with_params} {:keep_state, data, {:state_timeout, @reply_timeout, nil}} l = %Line{command: rpl_ISUPPORT(), args: [_ | isupport]} -> supports = Irc.Parser.Isupport.parse(isupport, data.isupport) @@ -548,7 +690,7 @@ defmodule Irc.Connection do defp to_info(data, state \\ nil) do data |> Map.from_struct() - |> Map.take([:nick, :modes, :server, :server_version, :server_umodes, :server_cmodes, :server_cmodes_with_params, :capabs, :isupport, :motd]) + |> Map.take([:nick, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd]) |> Map.put(:__struct__, __MODULE__) end @@ -652,6 +794,31 @@ defmodule Irc.Connection do {:keep_state, %State{data | nick: nick}} end + # -- BATCH + def process_line(%Line{command: "BATCH", args: ["+"<>batchid, type | args]}, data) do + batch = %{type: type, args: args, lines: []} + {:keep_state, %State{data | batches: Map.put(data.batches, batchid, batch)}} + end + + def process_line(%Line{command: "BATCH", args: ["-"<>batchid | args]}, data) do + %{type: type, args: args, lines: lines} = Map.get(data.batches, batchid) + if Keyword.get(data.args, :batch, true) do + send(data.process, {:irc_conn_batch, self(), type, args, Enum.reverse(lines)}) + else + for l <- Enum.reverse(lines), do: send(data.process, {:irc_conn_line, self(), l}) + end + {:keep_state, %State{data | batches: Map.drop(data.batches, batchid)}} + end + + def process_line(line = %Line{tags: %{"batch" => batch}}, data) do + if batch_data = Map.get(data.batches, batch) do + batch_data = Map.put(batch_data, :lines, [line | batch_data.lines]) + {:keep_state, %State{data | batches: Map.put(data.batches, batch, batch_data)}} + else + :ignore + end + end + def process_line(line, _) do :ignore end @@ -806,6 +973,9 @@ defmodule Irc.Connection do end # Send helpers. + defp socksend(data, line = %Line{}) do + socksend(data, [Line.encode(line, info(data))]) + end defp socksend(data = %State{sockmodule: module, socket: socket}, line) when is_list(line) do Logger.debug("#{inspect data} >>> #{inspect line}") module.send(socket, [line | [?\r, ?\n]]) @@ -830,15 +1000,10 @@ defmodule Irc.Connection do # Process args (in init) defp process_args(args) do host = Keyword.get(args, :host, nil) - port = Keyword.get(args, :port, nil) || 6667 tls = Keyword.get(args, :tls, nil) || false - inet = case Keyword.get(args, :inet, nil) do - 4 -> [:inet] - 6 -> [:inet6] - _ -> [] - end - ip = Keyword.get(args, :ip, nil) - ifaddr = Keyword.get(args, :ifaddr, nil) + sts = Keyword.get(args, :sts, nil) || true + default_port = if tls, do: 6697, else: 6667 + port = Keyword.get(args, :port, nil) || default_port connect_timeout = Keyword.get(args, :connect_timeout, @connect_timeout) nick = Keyword.get(args, :nick, nil) user = Keyword.get(args, :user, nick) || nick @@ -846,28 +1011,26 @@ defmodule Irc.Connection do pass = Keyword.get(args, :pass, nil) capabs = Keyword.get(args, :capabs, []) || [] reconnect = Keyword.get(args, :reconnect, true) - tcp_opts = Keyword.get(args, :tcp_opts, []) || [] - tls_opts = Keyword.get(args, :tls_opts, []) || [] + batch = Keyword.get(args, :batch, true) + connect_opts = Keyword.get(args, :connect_opts, []) || [] with \ {:host, {:ok, host}} <- {:host, parse_host(host)}, - {:port, {:ok, port}} <- {:port, parse_port(port)}, - {:bind, {:ok, bind}} <- {:bind, parse_bind(ip, ifaddr)} + {:port, {:ok, port}} <- {:port, parse_port(port)} do {:ok, %{ host: host, - port: port, - bind: bind, tls: tls, + sts: sts, + port: port, capabs: capabs, - inet: inet, nick: nick, user: user, name: name, pass: pass, reconnect: reconnect, - tcp_opts: tcp_opts, - tls_opts: tls_opts, + batch: batch, + connect_opts: connect_opts, connect_timeout: connect_timeout }} else @@ -885,22 +1048,6 @@ defmodule Irc.Connection do end defp parse_port(port), do: {:error, {:invalid_format, :port, inspect(port)}} - defp parse_bind(ip, _) when is_binary(ip) do - case :inet.parse_strict_address(String.to_charlist(ip)) do - {:ok, ip} -> {:ok, [{:ip, ip}]} - {:error, :einval} -> {:error, {:invalid_format, :ip, inspect(ip)}} - end - end - # TODO: Verify ifaddr is a valid interface - defp parse_bind(_, ifaddr) when is_binary(ifaddr) do - {:ok, [{:ifaddr, ifaddr}]} - end - defp parse_bind(nil, nil) do - {:ok, []} - end - defp parse_bind(ip, ifaddr) do - {:error, {:invalid_format, :bind, inspect([ip, ifaddr])}} - end defimpl Inspect, for: State do @moduledoc false |