From c77be7ec8fbbbd2b871efa3822be3b25edeb445d Mon Sep 17 00:00:00 2001 From: href Date: Sat, 9 Jan 2021 14:38:05 +0100 Subject: Old WIP commit - STS, ... --- README.md | 2 + config/config.exs | 30 +-- lib/irc/client/command/away.ex | 2 +- lib/irc/connection.ex | 443 +++++++++++++++++++++-------------------- lib/irc/connection_socket.ex | 100 ++++++++++ lib/irc/parser/capabs.ex | 32 +++ lib/irc/parser/line.ex | 20 +- lib/irc/sts.ex | 130 ++++++++---- 8 files changed, 472 insertions(+), 287 deletions(-) create mode 100644 lib/irc/connection_socket.ex create mode 100644 lib/irc/parser/capabs.ex diff --git a/README.md b/README.md index 4b81d33..57f19a4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Examples of usage: `Irc.Shout` (a simple connect-join-message-quit on Connection Future versions may include: bots, server parser, server-to-server connections, …. +**DO NOT USE, it's all moving parts and they'll move and shuffle and move for a long time before being a bit stable!** + ### Supported features The eventual goal is to support most of the modern IRC specifications. diff --git a/config/config.exs b/config/config.exs index 36bd1d8..eaeaf25 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,30 +1,4 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. use Mix.Config -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for -# third-party users, it should be done in your "mix.exs" file. - -# You can configure your application as: -# -# config :irc, key: :value -# -# and access this configuration in your application as: -# -# Application.get_env(:irc, :key) -# -# You can also configure a third-party app: -# -# config :logger, level: :info -# - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env()}.exs" +config :irc, + sts_store_file: {:priv, "sts_policies.dets"} diff --git a/lib/irc/client/command/away.ex b/lib/irc/client/command/away.ex index 3b6b38e..da8b5ef 100644 --- a/lib/irc/client/command/away.ex +++ b/lib/irc/client/command/away.ex @@ -15,7 +15,7 @@ defmodule Irc.Client.Command.Away do {:send, command} end - def hanle_line(%Line{command: "AWAY", source: source, args: args}, _) do + def handle_line(%Line{command: "AWAY", source: source, args: args}, _) do case args do [] -> {:event, {:away, source}} [message] -> {:event, {:away, {source, message}}} diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 052141c..7b82cea 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -1,7 +1,9 @@ defmodule Irc.Connection do @behaviour :gen_statem require Logger + alias Irc.ConnectionSocket alias Irc.Parser.Line + alias Irc.Parser require Irc.Parser.Numeric import Irc.Parser.Numeric @@ -20,9 +22,10 @@ defmodule Irc.Connection do * the socket and reconnections, * registration process, * sasl authentication, - * capabilities negotiation, + * capabilities negotiation/lifecycle, * ping, - * nick and user mode changes. + * nick and user mode changes, + * batches/echo. Events and lines are sent to calling PID. @@ -43,13 +46,35 @@ defmodule Irc.Connection do 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] + defstruct [:server, :host, :port, :tls, :sts, :nick, :modes, :capabs, :server_capabs, :version, :umodes, :cmodes, :cmodes_with_params, :isupport, :motd] - @typedoc "Connection Information state" + @typedoc """ + Connection Information state. Retrieve it with `info/1`. + + * `server`: reported server name, + * `host`: connection host, + * `port`: connection port, + * `tls`: connection using tls, + * `sts`: connection using sts, + * `nick`: current connection nickname, + * `capabs`: negotiated capabilities, + * `server_capabs`: server offerred capabilities (with their parameters if any), + * `modes`: connection modes, + * `version`: reported server version, + * `umodes`: user modes, + * `cmodes` and `cmodes_with_params`: channel modes, + * `isupport`: server ISUPPORT, see `Irc.Parser.Isupport`, + * `motd`: MOTD. + """ @type t :: %__MODULE__{ server: String.t, + host: String.t, + port: Integer.t, + tls: boolean, + sts: boolean, nick: String.t, - capabs: list(), + capabs: [String.t], + server_capabs: Irc.Parser.Capabs.t(), modes: String.t, version: String.t, umodes: String.t, @@ -86,6 +111,10 @@ defmodule Irc.Connection do | {:quit, reason :: String.t} | {:irc_error, reason :: String.t} + # TODO: Better batch / Echo-message / message id + # TODO: CAP lifecycle while connection is established + # TODO: SASL + # TODO: supports?/capab? by calling PID, more info to retrieve this way? @typedoc """ Start options. @@ -95,8 +124,9 @@ defmodule Irc.Connection do * pass: IRC server password, * tls, * port (default 6667, 6697 if tls), + * sts, see `t:start_opt_sts/0`, * reconnect (automatic/backoff reconnection), - * [batch][batch] + * [batch][batch] option, see `t:start_opt_batch/0`, * [sasl][sasl] options, see `t:start_opt_sasl/0`, * [webirc][webirc] options, see `t:start_opt_webirc/0`, * capabs: list to request, @@ -155,6 +185,7 @@ defmodule Irc.Connection do Send a [WEBIRC][webirc] command upon connection. ``` + {password, gateway, user_hostname, user_port, flags} {"hunter5", "web.irc.mynetwork", "some-user-hostname", "127.0.0.1", %{"secure" => true}} ``` @@ -238,10 +269,10 @@ defmodule Irc.Connection do @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) + Map.has_key?(capabs, feature) end - @spec supports?(String.t, t) :: boolean + @spec supports?(t, String.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)) @@ -261,10 +292,17 @@ defmodule Irc.Connection do end end + # + # --- STATE MACHINE + # + # * connect -> [reg_webirc -> cap_ls -> reg_nick -> reg_pass -> cap_req -> cap_wait_ack -> welcome -> motd] + # -> [disconnected, registered] + # * disconnected -> [connect] + defmodule State do @moduledoc false defstruct [:args, - :backoff, :socket, :sockmodule, + :backoff, :socket, :process, :monitor, :nick, :server, @@ -273,16 +311,19 @@ defmodule Irc.Connection do :cmodes, :cmodes_with_params, :welcomed, - :sts, :port, :tls, - {:batches, %{}}, - {:errored, false}, - {:isupport, %{}}, + :tls, + :cap_302, # If server supports 302 + :sts, # If an STS policy has been used + :port, # Actual connection port (may differ than args.port due to STS) + {:batches, %{}}, # Active batches + {:errored, false}, # TODO + {:isupport, %{}}, # Server ISUPPORT {:modes, []}, - {:server_capabs, []}, - {:client_capabs, []}, - {:capabs, []}, + {:server_capabs, %{}}, # Server advertised capabs + {:client_capabs, []}, # Client enabled capabs + {:capabs, []}, # Negotiated capabs {:motd, []}, - {:welcome, []}, + {:welcome, []}, # Registration log ] end @@ -291,21 +332,17 @@ defmodule Irc.Connection do @doc false def init([args]) do - backoff_min = Keyword.get(args, :backoff_min, @backoff_min) - backoff_max = Keyword.get(args, :backoff_max, @backoff_max) - process = Keyword.get(args, :process, nil) + backoff_min = Map.get(args, :backoff_min, @backoff_min) + backoff_max = Map.get(args, :backoff_max, @backoff_max) + process = Map.get(args, :process, nil) monitor = Process.monitor(process) with \ {: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} + {tls, sts, port} = case {args.sts, Irc.STS.lookup(args.host, args.port)} do + {true, {:ok, port}} -> {true, true, port} + _ -> {args.tls, false, args.port} end client_capabs = [Irc.Parser.capabs(), @connection_capabs, @@ -335,17 +372,13 @@ defmodule Irc.Connection do # TCP Open @doc false - 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 = [{:active, true}, {:packet, :line}] ++ data.args.connect_opts - case :gen_tcp.connect(String.to_charlist(data.args.host), data.port, options) do + case ConnectionSocket.connect(data.tls, 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}}} + {:next_state, :reg_webirc, %State{data | socket: socket, backoff: backoff}, @internal} error -> # TODO ERRORS to_disconnected(error, data) @@ -356,45 +389,11 @@ defmodule Irc.Connection do handle_common(:connect, type, content, data) end - # TLS Connect - - @doc false - def connect_tls(:internal, nil, data) 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}}} - error -> - to_disconnected(error, data) - end - end - - def connect_tls(type, content, data) do - handle_common(:connect_tls, type, content, data) - end - - @doc false - def connected(:internal, {module, socket}, data) do - 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) + def reg_webirc(:internal, _, data = %{args: %{webirc: {password, gateway, hostname, ip, options}}}) do opt_string = Enum.map(options, fn({key, value}) -> if value == true do key @@ -409,8 +408,12 @@ defmodule Irc.Connection do end end - def webirc(type, content, data) do - handle_common(:webirc, type, content, data) + def reg_webirc(:internal, _, data) do + {:next_state, :cap_ls, data, @internal} + end + + def reg_webirc(type, content, data) do + handle_common(:reg_webirc, type, content, data) end # Registration: LIST CAPABS @@ -420,61 +423,56 @@ defmodule Irc.Connection do case socksend(data, 'CAP LS 320') do :ok -> {:keep_state_and_data, {:state_timeout, @cap_timeout, nil}} error -> - Logger.error("Disconnected in socksend") + Logger.error("#{inspect data} - cap_ls: Disconnected in socksend") to_disconnected(error, data) end end - def cap_ls(:info, msg = {module, socket, line}, data) do - case Line.parse(line) do - l = %Line{command: "CAP", args: [_, "LS", "*", rest]} -> - capabs = String.split(rest, " ") - {: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, :cap_sts, %State{data | server_capabs: capabs}, @internal} - l -> - handle_common(:cap_ls, :info, msg, data) + # multiline capab + def cap_ls(:info, {_, _, %Line{command: "CAP", args: [_, "LS", "*", rest]}}, data) do + capabs = Map.merge(data.server_capabs, Parser.Capabs.parse(rest)) + {:keep_state, %State{data | server_capabs: capabs, cap_302: true}, {:state_timeout, @reply_timeout, nil}} + end + + # end of multiline or no multiline indicator + def cap_ls(:info, {_, _, %Line{command: "CAP", args: [_, "LS", rest]}}, data) do + capabs = Map.merge(data.server_capabs, Parser.Capabs.parse(rest)) + if data.cap_302 do + {:next_state, :cap_sts, %State{data | server_capabs: capabs}, @internal} + else + {:keep_state, %State{data | server_capabs: capabs}, {:state_timeout, @reply_timeout, nil}} end end + # state timeout - consider LS complete def cap_ls(:state_timeout, _, data) do - Logger.debug("State timeout") + Logger.debug("#{inspect data} - CAP LS State timeout") {:next_state, :cap_sts, data, @internal} end def cap_ls(type, content, data) do - Logger.debug("Handling common #{inspect({type,content,data})}") handle_common(:cap_ls, type, content, data) end # REGISTRATION: Check for STS + # STS is not already enabled on this connection, and a policy is advertised with a port: follow the policy and + # reconnect. @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 + def cap_sts(:internal, _, data = %{args: %{sts: true}, sts: false, port: current_port, + server_capabs: %{"sts" => policy = %{"port" => sts_port}}}) when current_port != sts_port do + socksend(data, Line.new("QUIT", ["Following STS policy."])) + ConnectionSocket.close(data.socket) + {:next_state, :connect, %State{data | sts: true, port: sts_port, tls: true, socket: nil}, @internal} + end + + # STS is enabled and we are over TLS, witness the policy. + def cap_sts(:internal, _, data = %{args: %{sts: true}, sts: true, tls: true, server_capabs: %{"sts" => policy}}) do + Irc.STS.witness(data.args.host, policy) + {:next_state, :reg_pass, data, @internal} end + # invalid conditions for sts, exit state def cap_sts(:internal, _, data) do {:next_state, :reg_pass, data, @internal} end @@ -487,7 +485,7 @@ defmodule Irc.Connection do @doc false def reg_pass(:internal, _, data = %State{args: %{pass: pass}}) when is_binary(pass) and pass != "" do - case socksend(data, ['PASS ', pass]) do + case socksend(data, Line.new("PASS", pass)) do :ok -> {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}} error -> @@ -499,14 +497,8 @@ defmodule Irc.Connection do {:next_state, :reg_nick, data, @internal} end - def reg_pass(:info, content = {_, _, line}, data) do - case Line.parse(line) do - %Line{command: err_PASSWDMISMATCH()} -> - {:next_state, :error, {:invalid_password, data.args.pass}} - line -> - #{:keep_state_and_data, [:postpone, {:state_timeout, @reply_timeout, nil}]} - handle_common(:reg_pass, :info, content, data) - end + def reg_pass(:info, {_, _, %Line{command: err_PASSWDMISMATCH()}}, data) do + {:next_state, :error, {:invalid_password, data.args.pass}} end def reg_pass(:state_timeout, _, data) do @@ -521,23 +513,19 @@ defmodule Irc.Connection do @doc false def reg_nick(:internal, nick, data) do - case socksend(data, ['NICK ', nick || data.args.nick]) do + case socksend(data, Line.new("NICK", nick || data.args.nick)) do :ok -> {:keep_state, %State{data | nick: nick}, {:state_timeout, @reply_timeout, nil}} error -> to_disconnected(error, data) end end - def reg_nick(:info, content = {_, _, line}, data) do - case Line.parse(line) do - %Line{command: cmd} when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION()] -> - new_nick = data.args.nick <> to_string(:random.uniform(9999)) - {:keep_state, data, {:next_event, :internal, new_nick}} - line = %Line{command: cmd} when cmd in [err_ERRONEUSNICKNAME(), err_NONICKNAMEGIVEN()] -> - {:next_state, :error, data, {:next_event, :internal, {:invalid_nickname, data.args.nick, line}}} - line -> - #{:keep_state_and_data, :postpone} - handle_common(:reg_nick, :info, content, data) - end + def reg_nick(:info, {_, _, %Line{command: cmd}}, data) when cmd in [err_NICKNAMEINUSE(), err_NICKCOLLISION() ]do + new_nick = data.args.nick <> to_string(:random.uniform(9999)) + {:keep_state, data, {:next_event, :internal, new_nick}} + end + + def reg_nick(:info, {_, _, line = %Line{command: cmd}}, data) when cmd in [err_ERRONEUSNICKNAME(), err_NONICKNAMEGIVEN()] do + {:next_state, :error, data, {:next_event, :internal, {:invalid_nickname, data.args.nick, line}}} end def reg_nick(:state_timeout, _, data) do @@ -552,8 +540,7 @@ defmodule Irc.Connection do @doc false def reg_user(:internal, _, data) do - user = List.flatten(['USER ', data.args.user, ' 0 * :', data.args.name]) - case socksend(data, user) do + case socksend(data, Line.new("USER", [data.args.user, "0", "*", data.args.name])) do :ok -> {:next_state, :cap_req, data, @internal} error -> @@ -561,24 +548,23 @@ defmodule Irc.Connection do end end - #def reg_user(:state_timeout, _, data) do - # {:next_state, :welcome, data, {:state_timeout, @registration_timeout, nil}} - #end - def reg_user(type, content, data) do handle_common(:reg_user, type, content, data) end + # --- Registration: CAP REQ + @doc false - def cap_req(:internal, _, data = %{server_capabs: []}) do + # No advertised server capabs, skip to WELCOME phase. + def cap_req(:internal, _, data = %{server_capabs: server_capabs}) when server_capabs == %{} do {:next_state, :welcome, data} end - def cap_req(:internal, _, data) do - req = Enum.filter(data.client_capabs, fn(capab) -> Enum.member?(data.server_capabs, capab) end) + def cap_req(:internal, _, data = %{client_capabs: client_capabs, server_capabs: server_capabs}) do + req = Enum.filter(client_capabs, fn(capab) -> Map.has_key?(server_capabs, capab) end) |> Enum.uniq() |> Enum.join(" ") - case socksend(data, ['CAP REQ :', String.to_charlist(req)]) do + case socksend(data, Line.new("CAP", args: ["REQ", req])) do :ok -> {:next_state, :cap_wait_ack, data} error -> to_disconnected(error, data) end @@ -588,23 +574,23 @@ defmodule Irc.Connection do handle_common(:cap_req, type, content, data) end + # --- Registration: CAP ACK/NACK + @doc false def cap_wait_ack(:enter, _, _) do {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}} end - # FIXME: Support multiline ACK/NACK - def cap_wait_ack(:info, content = {_, _, line}, data) do - case Line.parse(line) do - %Line{command: "CAP", args: [_, "ACK", capabs]} -> - capabs = String.split(capabs, " ") - {:keep_state, %State{data | capabs: capabs}, {:next_event, :internal, :end}} - %Line{command: "CAP", args: [_, "NACK", capabs]} -> - capabs = String.split(capabs, " ") - {:keep_state, %State{data | capabs: data.capabs -- capabs}, {:next_event, :internal, :end}} - line -> - handle_common(:cap_wait_ack, :info, content, data) - end + # FIXME: Support multiline ACK/NACK ??? + # TODO: VERIFY SPEC CONFORMITY + def cap_wait_ack(:info, {_, _, %Line{command: "CAP", args: [_, "ACK", capabs]}}, data) do + capabs = String.split(capabs, " ") + {:keep_state, %State{data | capabs: capabs}, {:next_event, :internal, :end}} + end + + def cap_wait_ack(:info, {_, _, %Line{command: "CAP", args: [_, "NACK", capabs]}}, data) do + capabs = String.split(capabs, " ") + {:keep_state, %State{data | capabs: data.capabs -- capabs}, {:next_event, :internal, :end}} end def cap_wait_ack(:internal, :end, data) do @@ -614,6 +600,10 @@ defmodule Irc.Connection do end end + def cap_wait_ack(type, message, data) do + handle_common(:cap_wait_ack, type, message, data) + end + # --- REGISTRATION: Welcome @doc false @@ -621,30 +611,30 @@ defmodule Irc.Connection do {:keep_state_and_data, {:state_timeout, @registration_timeout, nil}} end + def welcome(:info, {_, _, l = %Line{command: rpl_WELCOME(), source: source, args: [nick, _]}}, data) do + {:keep_state, %State{data | nick: nick, welcome: [l | data.welcome], server: source}, {:state_timeout, @reply_timeout, nil}} + end + @welcome_numeric [rpl_WELCOME, rpl_YOURHOST, rpl_CREATED, rpl_MYINFO, rpl_ISUPPORT] - def welcome(:info, content = {module, socket, line}, data) do - case Line.parse(line) do - l = %Line{command: err_NOMOTD()} -> - {:next_state, :registered, %State{data | motd: []}} - l = %Line{command: rpl_MOTDSTART()} -> - {:next_state, :motd, data, [:postpone]} - l = %Line{command: rpl_WELCOME(), source: source, args: [nick, _]} -> - {:keep_state, %State{data | nick: nick, welcome: [l | data.welcome], server: source}, {:state_timeout, @reply_timeout, nil}} - l = %Line{command: rpl_MYINFO(), source: source, args: [_, _, version, umodes, cmodes | rest]} -> - cmodes_with_params = case rest do - [] -> nil - [c] -> c - end - 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) - {:keep_state, %State{data | isupport: supports}, {:state_timeout, @reply_timeout, nil}} - l = %Line{command: cmd} -> - #{:keep_state, %State{data | welcome: [l | data.welcome]}, {:state_timeout, @reply_timeout, nil}} - handle_common(:welcome, :info, content, data) + + def welcome(:info, {_, _, l = %Line{command: rpl_MYINFO(), source: source, args: [_, _, version, umodes, cmodes | rest]}}, data) do + cmodes_with_params = case rest do + [] -> nil + [c] -> c end + 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}} + end + + def welcome(:info, {_, _, %Line{command: rpl_ISUPPORT(), args: [_ | isupport]}}, data) do + supports = Irc.Parser.Isupport.parse(isupport, data.isupport) + {:keep_state, %State{data | isupport: supports}, {:state_timeout, @reply_timeout, nil}} + end + + # No MOTD/Start of MOTD ends the welcome phase + def welcome(:info, {_, _, %Line{command: cmd}}, data) when cmd in [err_NOMOTD(), rpl_MOTDSTART()] do + {:next_state, :motd, data, [:postpone]} end def welcome(:state_timeout, _, data) do @@ -662,19 +652,20 @@ defmodule Irc.Connection do {:keep_state_and_data, {:state_timeout, @reply_timeout, nil}} end - def motd(:info, {_, _, line}, data) do - case Line.parse(line) do - %Line{command: rpl_MOTDSTART(), args: [_, line]} -> - {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}} - %Line{command: err_NOMOTD()} -> - {:next_state, :registered, %State{data | motd: []}} - %Line{command: rpl_MOTD(), args: [_, line]} -> - {:keep_state, %State{data | motd: [line | data.motd]}, {:state_timeout, @reply_timeout, nil}} - %Line{command: rpl_ENDOFMOTD(), args: [_, line]} -> - {:next_state, :registered, %State{data | motd: [line | data.motd]}} - line -> - {:keep_state_and_data, :postpone} - end + def motd(:info, {_, _, %Line{command: err_NOMOTD()}}, data) do + {:next_state, :registered, %State{data | motd: []}} + end + + def motd(:info, {_, _, %Line{command: rpl_MOTDSTART(), args: [_, line]}}, data) do + {:keep_state, %State{data | motd: [line]}, {:state_timeout, @reply_timeout, nil}} + end + + def motd(:info, {_, _, %Line{command: rpl_MOTD(), args: [_, line]}}, data) do + {:keep_state, %State{data | motd: [line | data.motd]}, {:state_timeout, @reply_timeout, nil}} + end + + def motd(:info, {_, _, %Line{command: rpl_ENDOFMOTD(), args: [_, line]}}, data) do + {:next_state, :registered, %State{data | motd: List.reverse([line | data.motd])}} end def motd(:state_timeout, _, data) do @@ -690,7 +681,8 @@ defmodule Irc.Connection do defp to_info(data, state \\ nil) do data |> Map.from_struct() - |> Map.take([:nick, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd]) + |> Map.take([:nick, :port, :tls, :sts, :modes, :server, :version, :umodes, :cmodes, :cmodes_with_params, :capabs, :isupport, :motd]) + |> Map.put(:host, data.args.host) |> Map.put(:__struct__, __MODULE__) end @@ -710,7 +702,6 @@ defmodule Irc.Connection do end def registered(:info, {_, _, line}, data) do - line = Line.parse(line) Logger.debug "#{inspect(data)} <<< #{inspect(line)}" case process_line(line, data) do :ignore -> @@ -843,8 +834,8 @@ defmodule Irc.Connection do {delay, backoff} = :backoff.fail(data.backoff) send(data.process, {:irc_conn_down, self(), error, delay}) data = if data.socket do - data.sockmodule.close(data.socket) - %State{data | socket: nil, sockmodule: nil} + ConnectionSocket.close(data.socket) + %State{data | socket: nil} else data end @@ -873,7 +864,7 @@ defmodule Irc.Connection do :keep_state_and_data end - def disconnected(:info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do + def disconnected(:info, {ConnectionSocket, _socket, :closed}, data) do :keep_state_and_data end @@ -898,15 +889,15 @@ defmodule Irc.Connection do def handle_common(_, {:call, ref}, {:disconnect, stop, quit}, data = %State{socket: socket}) do Logger.debug("#{inspect(data)} disconnecting on request (stop:#{inspect stop})") - if data.sockmodule && data.socket do + if data.socket do quit = if quit, do: quit, else: "" socksend(data, ['QUIT :', quit]) - data.sockmodule.close(socket) + ConnectionSocket.close(socket) end if stop do {:stop_and_reply, :normal, {:reply, ref, :ok}} else - {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, [ + {:next_state, :disconnected, %State{data | socket: nil}, [ {:reply, ref, :ok}, {:next_event, :internal, :disconnected}, ]} @@ -936,18 +927,16 @@ defmodule Irc.Connection do :keep_state_and_data end - def handle_common(_, :info, {_mod, _sock, line = [?P, ?I, ?N, ?G | _]}, data) do - line = Line.parse(line) - arg = Enum.join(line.args, " ") + def handle_common(_, :info, {_, _, %Line{command: "PING", args: args}}, data) do + arg = Enum.join(args, " ") case socksend(data, ['PONG :', arg]) do :ok -> :keep_state_and_data error -> to_disconnected(error, data) end end - def handle_common(_, :info, {_mod, _sock, line = [?E, ?R, ?R, ?O, ?R | _]}, data) do - line = Line.parse(line) - to_disconnected({:irc_error, Enum.join(line.args, " ")}, data) + def handle_common(_, :info, {_, _, %Line{command: "ERROR", args: args}}, data) do + to_disconnected({:irc_error, Enum.join(args, " ")}, data) end @@ -955,12 +944,29 @@ defmodule Irc.Connection do {:keep_state_and_data, :postpone} end - def handle_common(_, :info, {closed, socket}, data) when closed in [:tcp_closed, :ssl_closed] do - {:next_state, :disconnected, %State{data | socket: nil, sockmodule: nil}, @internal} + # TODO: SOCKET ERROR + def handle_common(_, :info, {ConnectionSocket, _socket, :closed}, data) do + Logger.error("#{inspect data} - socket closed") + {:next_state, :disconnected, %State{data | socket: nil}, @internal} + end + + # SOCKET DIED + def handle_common(_, :info, {:DOWN, ref, :process, socket_pid, reason}, data = %{socket: {ConnectionSocket,socket_pid,ref}}) do + Logger.error("#{inspect data} - Socket pid died #{inspect reason}") + {:next_state, :disconnected, %State{data | socket: nil}, @internal} + end + + # OWNER DIED + def handle_common(_, :info, {:DOWN, _, :process, pid, reason}, data = %{process: pid}) do + Logger.error("#{inspect data} - Owner pid died #{inspect reason}") + {:stop, :normal, data} end @doc false def terminate(reason, state, data) do + if state.socket do + ConnectionSocket.close(state.socket) + end unless data.errored do send(data.process, {:irc_conn_error, self(), :down}) end @@ -974,11 +980,11 @@ defmodule Irc.Connection do # Send helpers. defp socksend(data, line = %Line{}) do - socksend(data, [Line.encode(line, info(data))]) + socksend(data, [Line.encode(line, to_info(data))]) end - defp socksend(data = %State{sockmodule: module, socket: socket}, line) when is_list(line) do + defp socksend(data = %State{socket: socket}, line) when is_list(line) do Logger.debug("#{inspect data} >>> #{inspect line}") - module.send(socket, [line | [?\r, ?\n]]) + ConnectionSocket.send(socket, [line | [?\r, ?\n]]) end @@ -995,26 +1001,28 @@ defmodule Irc.Connection do |> Keyword.put_new(:nick, nick) |> Keyword.put_new(:host, host) |> Keyword.put_new(:process, self()) + |> Enum.into(Map.new) end # Process args (in init) defp process_args(args) do - host = Keyword.get(args, :host, nil) - tls = Keyword.get(args, :tls, nil) || false - sts = Keyword.get(args, :sts, nil) || true + host = Map.get(args, :host) + tls = Map.get(args, :tls) || false + sts = Map.get(args, :sts) || 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 - name = Keyword.get(args, :name, user) || user - pass = Keyword.get(args, :pass, nil) - capabs = Keyword.get(args, :capabs, []) || [] - reconnect = Keyword.get(args, :reconnect, true) - batch = Keyword.get(args, :batch, true) - connect_opts = Keyword.get(args, :connect_opts, []) || [] + port = Map.get(args, :port) || default_port + connect_timeout = Map.get(args, :connect_timeout, @connect_timeout) + nick = Map.get(args, :nick) + user = Map.get(args, :user) || nick + name = Map.get(args, :name) || user + pass = Map.get(args, :pass) + capabs = Map.get(args, :capabs) || [] + reconnect = Map.get(args, :reconnect, true) + batch = Map.get(args, :batch, true) + connect_opts = Map.get(args, :connect_opts) || [] with \ + {:nick, :ok} <- {:nick, verify_nickname(nick)}, {:host, {:ok, host}} <- {:host, parse_host(host)}, {:port, {:ok, port}} <- {:port, parse_port(port)} do @@ -1048,6 +1056,11 @@ defmodule Irc.Connection do end defp parse_port(port), do: {:error, {:invalid_format, :port, inspect(port)}} + defp verify_nickname(nick) when is_binary(nick) and byte_size(nick) > 1 do + :ok + end + + defp verify_nickname(nick), do: {:error, {:invalid_nickname, nick}} defimpl Inspect, for: State do @moduledoc false diff --git a/lib/irc/connection_socket.ex b/lib/irc/connection_socket.ex new file mode 100644 index 0000000..29c42c0 --- /dev/null +++ b/lib/irc/connection_socket.ex @@ -0,0 +1,100 @@ +defmodule Irc.ConnectionSocket do + use GenServer + require Logger + alias Irc.Parser.Line + + import Kernel, except: [send: 2] + + @moduledoc "Underlying wrapper that just parses IRC lines" + + @type error :: :todo + + @type t :: {ConnectionSocket, pid(), reference()} + + @spec connect(tls :: boolean, host :: String.t, port :: Integer.t, options :: Keyword.t) :: {:ok, t} | {:error, error} + def connect(tls, host, port, options) do + caller = self() + case GenServer.start(__MODULE__, [caller, tls, host, port, options]) do + {:ok, pid} -> + mon = Process.monitor(pid) + {:ok, {__MODULE__, pid, mon}} + error -> error + end + end + + @spec close(t) :: :ok + def close({__MODULE__, pid, mon}) do + case GenServer.call(pid, :close) do + :ok -> + Process.demonitor(mon, [:flush]) + error -> + error + end + end + + @spec send(t, iolist) :: :ok + def send({__MODULE__, pid, _}, line) do + GenServer.call(pid, {:send, line}) + end + + @doc false + def init([caller, tls, host, port, options]) do + Process.monitor(caller) + module = if tls, do: :ssl, else: :gen_tcp + state = %{caller: caller, module: module, host: host, port: port, options: options, socket: nil} + case module.connect(String.to_charlist(host), port, options) do + {:ok, socket} -> + {:ok, Map.put(state, :socket, socket)} + error -> + {:error, error} + end + end + + @doc false + def handle_call({:send, data}, {caller, _}, state = %{caller: caller, module: module, socket: socket}) do + {:reply, module.send(socket, data), state} + end + + def handle_call(:close, {caller, _}, state = %{caller: caller, module: module, socket: socket}) do + {:stop, :normal, module.close(socket), state = %{state | module: nil, socket: nil}} + end + + def handle_call(_, {caller, _}, state = %{caller: caller}) do + {:reply, {:error, :invalid_call}, state} + end + + def handle_call(_, _, state) do + {:noreply, state} + end + + @doc false + # Received a line from socket + def handle_info({module, socket, line}, state = %{caller: caller, module: module, socket: socket}) do + Kernel.send(caller, {__MODULE__, self(), Line.parse(line)}) + {:noreply, state} + end + + # Socket is closed + def handle_info({closed, socket}, state = %{socket: socket, caller: caller}) when closed in [:tcp_closed, :ssl_closed] do + Kernel.send(caller, {__MODULE__, self(), :closed}) + {:stop, :normal, state} + end + + # Socket error + # TODO: SSL errors + def handle_info({:tcp_error, socket, reason}, state = %{socket: socket, caller: caller}) do + Kernel.send(caller, {__MODULE__, self(), {:error, reason}}) + {:stop, :normal, state} + end + + # Caller is down + def handle_info({:DOWN, _, :process, caller, reason}, state = %{caller: caller, module: module, socket: socket}) do + module.close(socket) + {:stop, :normal, state} + end + + def terminate(reason, state) do + state + end + +end diff --git a/lib/irc/parser/capabs.ex b/lib/irc/parser/capabs.ex new file mode 100644 index 0000000..88bf017 --- /dev/null +++ b/lib/irc/parser/capabs.ex @@ -0,0 +1,32 @@ +defmodule Irc.Parser.Capabs do + @moduledoc "Helper to parse capability lists" + + @spec parse(String.t) :: %{capab :: String.t => true | args :: Map.t} + def parse(string) do + string + |> String.split(" ") + |> Enum.map(&parse_capab/1) + |> Enum.into(Map.new) + end + + defp parse_capab(string) do + case String.split(string, "=", parts: 2) do + [capab] -> + {capab, true} + [capab, args] -> + parse_capab_with_args(capab, args) + end + end + + defp parse_capab_with_args(capab, args) do + args = args + |> String.split(",") + |> Map.reduce(%{}, fn(k_v, acc) -> + [k, v] = String.split(k_v, "=") + Map.put(acc, k, v) + end) + {capab, args} + end + +end + diff --git a/lib/irc/parser/line.ex b/lib/irc/parser/line.ex index 17c381c..5aa5a9b 100644 --- a/lib/irc/parser/line.ex +++ b/lib/irc/parser/line.ex @@ -37,13 +37,25 @@ defmodule Irc.Parser.Line do |> Enum.join(" ") end - def new(command, args \\ [], tags \\ %{}) do + def new(command) do + new(command, []) + end + + def new(command, args) do + new(command, args, %{}) + end + + def new(command, arg, tags) when not is_list(arg) do + new(command, [arg], tags) + end + + def new(command, args, tags) do %__MODULE__{command: command, args: args, tags: tags, __private__: %{at: DateTime.utc_now()}} end @doc "Returns the line date (server time if sent, otherwise, parse time)" @spec at(t()) :: DateTime.t() - def at(%__MODULE__{__private__: %{at: at}, tags: %{"time" => server_time}}) do + def at(line = %__MODULE__{__private__: %{at: at}, tags: %{"time" => server_time}}) do case DateTime.from_iso8601(server_time) do {:ok, date} -> date _ -> at @@ -54,14 +66,14 @@ defmodule Irc.Parser.Line do @spec to?(t(), Irc.Connection.t() | Irc.Mask.t() | Irc.User.t()) :: boolean @doc "Returns true if the line is adressed to the connection." - def to?(%__MODULE__{args: [nick | _]}, %{__struct__: s, nick: nick}) when s in [Irc.Connection, Irc.Mask, Irc.User] do + def to?(line = %__MODULE__{args: [nick | _]}, target = %{__struct__: s, nick: nick}) when s in [Irc.Connection, Irc.Mask, Irc.User] do true end def to?(%__MODULE__{}, %{__struct__: s}) when s in [Irc.Connection, Irc.Mask, Irc.User], do: false @spec self?(t(), Irc.Connection.t() | Irc.Mask.t() | Irc.User.t()) :: boolean @doc "Returns true if the line source is the from the given connection/mask." - def self?(%__MODULE__{source: %Irc.Mask{nick: nick}}, %Irc.Connection{nick: nick}) do + def self?(line = %__MODULE__{source: %Irc.Mask{nick: nick}}, target = %Irc.Connection{nick: nick}) do true end def self?(%__MODULE__{source: mask = %Irc.Mask{}}, mask = %Irc.Mask{}) do diff --git a/lib/irc/sts.ex b/lib/irc/sts.ex index 9980f8c..b71d032 100644 --- a/lib/irc/sts.ex +++ b/lib/irc/sts.ex @@ -1,29 +1,45 @@ defmodule Irc.STS do + require Logger + use GenServer @moduledoc """ # STS Store. When a connection encounters a STS policy, it signals it using `witness/4`. The store will consider the policy valid indefinitely as long as - any connection are still alive for that host/port pair. Once all connections or the system stops, the latest policy expiration date will be computed. + any connection are still alive for that host. - By default, the store is not persistent. If you wish to enable persistance, set the `:irc, :sts_cache_file` app environment. + Once all connections or the system stops, the latest policy expiration date will be computed. + + The store is shared for all a given node/uses of the IRC app, but can be disabled per connection basis, by disabling STS. + + By default, the store is persisted to disk. If you wish to configure persistance, set the `:irc, :sts_store_file` app environment: + + * `nil` to disable, + * `{:priv, "filename.dets"}` to store in the irc app priv directory, + * `{:priv, app, "filename.dets"}` to store in another app priv directory, + * any file path as string. """ @ets __MODULE__.ETS - # tuple {{host,port}, tls_port, period, until | true, at} + # tuple {host, # hostname + # tls_port, # port to use + # period, # advertised period. use `until` instead + # until | true, # until which date the entry is valid. true if connections are already connected with the sts policy. + # at # last witness time + # } @doc "Lookup a STS entry" - @spec lookup(host :: String.t(), port :: Integer.t()) :: {enabled :: boolean, port :: Integer.t()} - def lookup(host,port) do + @spec lookup(host :: String.t()) :: {:ok, port :: Integer.t()} | nil + def lookup(host) do with \ - [{_, port, period, until}] <- :ets.lookup(@ets, {host,port}), + [{_, port, period, until, _}] <- :ets.lookup(@ets, host), true <- verify_validity(period, until) do - {true, port} + {:ok, port} else - [] -> {false, port} + [] -> nil false -> - GenServer.cast(__MODULE__, {:expired, {host,port}}) - {false, port} + GenServer.cast(__MODULE__, {:expired, host}) + nil end end @@ -33,33 +49,42 @@ defmodule Irc.STS do The STS cache will consider the policy as infinitely valid as long as the calling PID is alive, or signal a new `witness/4`. """ - def witness(host, port, sts_port, period) do - GenServer.call(__MODULE__, {:witness, host, port, sts_port, period, self()}) - end - - @doc """ - Revoke a STS policy. This is the same as calling `witness/4` with sts_port = nil and period = nil. - """ - def revoke(host, port) do - GenServer.call(__MODULE__, {:witness, {host,port,nil,nil,self()}}) + def witness(host, policy) do + GenServer.call(__MODULE__, {:witness, host, policy, self()}) end @doc "Returns all entries in the STS store" def all() do - fold = fn(el, acc) -> [el | acc] end - :ets.foldl(fold, @ets, []) + fold = fn({host, port, period, until, at}, acc) -> + policy = %{port: port, period: period, until: until, at: at} + Map.put(acc, host, policy) + end + :ets.foldl(fold, @ets, Map.new) end - def start_link() do - GenServer.start_link(__MODULE__, [], [name: __MODULE__]) + def start_link(args) do + GenServer.start_link(__MODULE__, args, [name: __MODULE__]) end def init(_) do ets = :ets.new(@ets, [:named_table, :protected]) - cache_file = Application.get_env(:irc, :sts_cache_file) - dets = if cache_file do - {:ok, dets} = :dets.open_file(cache_file) + store_file = parse_env_store_file(Application.get_env(:irc, :sts_store_file, {:priv, "sts_policies.dets"})) + dets = if store_file do + {:ok, dets} = :dets.open_file(store_file, []) true = :ets.from_dets(ets, dets) + # Fix possible stale entries by using their last witness known time + fold = fn + ({key, _tls_port, period, true, at}, acc) -> + until = at |> DateTime.add(period) + [{key, until} | acc] + (_, acc) -> acc + end + for {key, until} <- :ets.foldl(fold, [], @ets) do + :ets.update_element(@ets, key, {4, until}) + end + :ets.to_dets(@ets, dets) + :dets.sync(dets) + dets end {:ok, %{ets: ets, dets: dets, map: %{}}} end @@ -82,25 +107,25 @@ defmodule Irc.STS do end # Duration 0 -- Policy is removed - def handle_call({:witness, host, port, _tls_port, period, pid}, from, state) when period in ["0", 0, nil] do - state = remove({host,port}, state) - {:reply, :ok, state, {:handle_continue, {:remove, {host,port}}}} + def handle_call({:witness, host, %{"duration" => period}, pid}, from, state) when period in ["0", 0, nil] do + state = remove(host, state) + {:reply, :ok, state, {:handle_continue, {:remove, host}}} end # Witnessed policy. # As long as caller PID is alive, consider the policy always valid - def handle_call({:witness, host, port, tls_port, period, pid}, _, state) do - entry = {{host,port}, tls_port, period, true} + def handle_call({:witness, host, %{"port" => tls_port, "duration" => duration}, pid}, _, state) do + entry = {host, tls_port, duration, true, DateTime.utc_now()} :ets.insert(@ets, entry) mon = Process.monitor(pid) - state = %{state | map: Map.put(state.map, pid, {mon,{host,port}})} + state = %{state | map: Map.put(state.map, pid, {mon,host})} {:reply, :ok, state, {:handle_continue, {:write, entry}}} end # Caller side encountered an expired policy, check and remove it. def handle_cast({:expired, key}, state) do {state, continue} = case :ets.lookup(@ets, key) do - [{_, _, period, until}] -> + [{_, _, period, until, _at}] -> if !verify_validity(period, until) do {remove(key, state), {:remove, key}} else @@ -117,13 +142,18 @@ defmodule Irc.STS do others = Enum.filter(state.map, fn({p, {_,k}}) -> k == key && p != pid end) state = %{state | map: Map.delete(state.map, pid)} if key && Enum.empty?(others) do - case :ets.lookup(@ets, key) do - [{key, tls_port, period, until}] -> - until = DateTime.utc_now() |> DateTime.add(period) - entry = {key, tls_port, period, until} + case {Enum.empty?(others), :ets.lookup(@ets, key)} do + {last?, [{key, tls_port, period, until, at}]} -> + now = DateTime.utc_now() + until = if last? do + DateTime.add(now, period) + else + true + end + entry = {key, tls_port, period, until, now} :ets.insert(@ets, entry) {:noreply, state, {:handle_continue, {:write, entry}}} - [] -> + {_, []} -> {:noreply, state} end else @@ -135,7 +165,7 @@ defmodule Irc.STS do def terminate(_, state) do if state.dets do fold = fn - ({key, _tls_port, period, true}, acc) -> + ({key, _tls_port, period, true, _}, acc) -> until = DateTime.utc_now() |> DateTime.add(period) [{key, until} | acc] (_, acc) -> acc @@ -166,4 +196,26 @@ defmodule Irc.STS do DateTime.utc_now() >= until end + defp parse_env_store_file({:priv, file}) do + parse_env_store_file({:priv, :irc, file}) + end + + defp parse_env_store_file({:priv, app, file}) do + :code.priv_dir(app) ++ '/' ++ String.to_charlist(file) + end + + defp parse_env_store_file(string) when is_binary(string) do + String.to_charlist(string) + end + + defp parse_env_store_file(nil) do + Logger.info "Irc.STS: Permanent cache NOT ENABLED" + nil + end + + defp parse_env_store_file(_invalid) do + Logger.error "Irc.STS: Invalid cache file configuration, permanent store NOT ENABLED" + nil + end + end -- cgit v1.2.3