summaryrefslogtreecommitdiff
path: root/lib/irc/connection.ex
diff options
context:
space:
mode:
Diffstat (limited to 'lib/irc/connection.ex')
-rw-r--r--lib/irc/connection.ex347
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