mkinvite crash

d2edb4a · gmorell · 2026-06-10 22:09

9 files +162 -26

Files changed

modified lib/oh_my_ex_oh_ircd/irc/connection.ex
+92 −26
@@ -960,7 +960,7 @@ defmodule OhMyExOhIrcd.IRC.Connection do
960 960 send_numeric(socket, state, "451", ["*", "You have not registered"])
961 961 state
962 962
963 − state.account && OhMyExOhIrcd.Accounts.oper?(state.account) ->
963 + account_is_oper?(state.account) ->
964 964 grant_oper(socket, state, "user:" <> state.account.email)
965 965
966 966 true ->
@@ -973,15 +973,25 @@ defmodule OhMyExOhIrcd.IRC.Connection do
973 973 end
974 974 end
975 975
976 − # OPER name password: the classic flow, against the static `:opers`
977 − # config map. Kept as a break-glass path that doesn't require a DB
978 − # connection — useful for bootstrap and disaster recovery.
976 + # OPER name password. Two ways to succeed:
977 + #
978 + # 1. DB account flag — if you've already SASL/PASS-authenticated as
979 + # an `is_oper` account, the typed password is irrelevant (the
980 + # login is the proof of identity). This is what makes the common
981 + # client `/oper` work: irssi/weechat/etc. *always* send a name +
982 + # password and never a bare OPER, so without this fallback the
983 + # DB-flag path would be unreachable from a normal client.
984 + # 2. Static `:opers` config map — the classic O-line flow. No DB
985 + # round-trip, so it doubles as bootstrap / disaster recovery.
979 986 defp dispatch(%Message{command: "OPER", params: [name, password | _]}, socket, state) do
980 987 cond do
981 988 not state.registered? ->
982 989 send_numeric(socket, state, "451", ["*", "You have not registered"])
983 990 state
984 991
992 + account_is_oper?(state.account) ->
993 + grant_oper(socket, state, "user:" <> state.account.email)
994 +
985 995 true ->
986 996 user_at_host = "#{state.user || state.nick}@#{state.host}"
987 997
@@ -1233,34 +1243,76 @@ defmodule OhMyExOhIrcd.IRC.Connection do
1233 1243 opts = if days, do: Keyword.put(opts, :expires_in_seconds, days * 24 * 3600), else: opts
1234 1244 opts = if state.account, do: Keyword.put(opts, :created_by, state.account), else: opts
1235 1245
1236 − case OhMyExOhIrcd.Accounts.create_invite(opts) do
1237 − {:ok, invite} ->
1238 − url = OhMyExOhIrcdWeb.Endpoint.url() <> "/users/register?invite=#{invite.token}"
1239 − send_server_notice(socket, state, "Invite token: #{invite.token}")
1240 − send_server_notice(socket, state, "Signup URL: #{url}")
1241 −
1242 − if invite.expires_at,
1243 − do: send_server_notice(socket, state, "Expires: #{invite.expires_at}")
1244 −
1245 − {:error, cs} ->
1246 − send_server_notice(socket, state, "Could not create invite: #{inspect(cs.errors)}")
1247 − end
1246 + # Guard the whole DB + URL path: an exception here would otherwise
1247 + # crash the ThousandIsland handler and drop the oper's connection.
1248 + # We'd much rather surface the error as a NOTICE and log it.
1249 + guard_invite_command(socket, state, "MKINVITE", fn ->
1250 + case OhMyExOhIrcd.Accounts.create_invite(opts) do
1251 + {:ok, invite} ->
1252 + url = signup_base_url() <> "/users/register?invite=#{invite.token}"
1253 + send_server_notice(socket, state, "Invite token: #{invite.token}")
1254 + send_server_notice(socket, state, "Signup URL: #{url}")
1255 +
1256 + if invite.expires_at,
1257 + do: send_server_notice(socket, state, "Expires: #{invite.expires_at}")
1258 +
1259 + {:error, cs} ->
1260 + send_server_notice(socket, state, "Could not create invite: #{inspect(cs.errors)}")
1261 + end
1262 + end)
1248 1263 end
1249 1264 end
1250 1265
1251 1266 defp list_invites_command(socket, state) do
1252 − case OhMyExOhIrcd.Accounts.list_active_invites() do
1253 − [] ->
1254 − send_server_notice(socket, state, "No active invites.")
1267 + guard_invite_command(socket, state, "LSINVITE", fn ->
1268 + case OhMyExOhIrcd.Accounts.list_active_invites() do
1269 + [] ->
1270 + send_server_notice(socket, state, "No active invites.")
1255 1271
1256 − invites ->
1257 − send_server_notice(socket, state, "Active invites (#{length(invites)}):")
1272 + invites ->
1273 + send_server_notice(socket, state, "Active invites (#{length(invites)}):")
1258 1274
1259 − Enum.each(invites, fn i ->
1260 − note = if i.note in [nil, ""], do: "", else: " — #{i.note}"
1261 − send_server_notice(socket, state, " #{i.token} expires=#{i.expires_at || "never"}#{note}")
1262 − end)
1263 − end
1275 + Enum.each(invites, fn i ->
1276 + note = if i.note in [nil, ""], do: "", else: " — #{i.note}"
1277 +
1278 + send_server_notice(
1279 + socket,
1280 + state,
1281 + " #{i.token} expires=#{i.expires_at || "never"}#{note}"
1282 + )
1283 + end)
1284 + end
1285 + end)
1286 + end
1287 +
1288 + # Run an invite command body, converting any crash into a logged error
1289 + # plus an error NOTICE — never a dropped connection.
1290 + defp guard_invite_command(socket, state, label, fun) do
1291 + fun.()
1292 + rescue
1293 + e ->
1294 + Logger.error("#{label} failed: #{Exception.format(:error, e, __STACKTRACE__)}")
1295 + send_server_notice(socket, state, "#{label} failed: #{Exception.message(e)} (see server log)")
1296 + catch
1297 + kind, reason ->
1298 + Logger.error("#{label} failed: #{inspect(kind)} #{inspect(reason)}")
1299 + send_server_notice(socket, state, "#{label} failed (see server log)")
1300 + end
1301 +
1302 + # Public signup base URL, derived from the Endpoint's `:url` config
1303 + # (host = PHX_HOST in prod) rather than the live `Endpoint.url/0`, so it
1304 + # doesn't depend on endpoint runtime state and matches the behaviour of
1305 + # `OhMyExOhIrcd.Release.create_invite/1`.
1306 + defp signup_base_url do
1307 + url =
1308 + Application.get_env(:oh_my_ex_oh_ircd, OhMyExOhIrcdWeb.Endpoint, [])
1309 + |> Keyword.get(:url, [])
1310 +
1311 + scheme = Keyword.get(url, :scheme, "http")
1312 + host = Keyword.get(url, :host, "localhost")
1313 + default = if scheme == "https", do: 443, else: 80
1314 + port = Keyword.get(url, :port, default)
1315 + if port == default, do: "#{scheme}://#{host}", else: "#{scheme}://#{host}:#{port}"
1264 1316 end
1265 1317
1266 1318 # `MKINVITE` argument parsing. No args → no expiry. A leading token, if
@@ -2063,6 +2115,20 @@ defmodule OhMyExOhIrcd.IRC.Connection do
2063 2115 %{state | operator?: true, oper_name: oper_name}
2064 2116 end
2065 2117
2118 + # Re-read the `is_oper` flag from the DB rather than trusting the
2119 + # `User` struct cached at SASL/PASS time. Otherwise a `grant_oper`
2120 + # issued *after* the client authenticated wouldn't take effect until
2121 + # the client reconnected (the cached struct would still say false).
2122 + defp account_is_oper?(nil), do: false
2123 +
2124 + defp account_is_oper?(%{id: id}) do
2125 + OhMyExOhIrcd.Accounts.oper?(OhMyExOhIrcd.Accounts.get_user!(id))
2126 + rescue
2127 + Ecto.NoResultsError -> false
2128 + end
2129 +
2130 + defp account_is_oper?(_), do: false
2131 +
2066 2132 defp schedule_idle_check(state) do
2067 2133 if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
2068 2134 ref = Process.send_after(self(), :check_idle, state.ping_interval_ms)
modified test/oh_my_ex_oh_ircd/irc/oper_invite_test.exs
+24 −0
@@ -12,6 +12,7 @@ defmodule OhMyExOhIrcd.IRC.OperInviteTest do
12 12 use OhMyExOhIrcd.DataCase, async: false
13 13
14 14 import OhMyExOhIrcd.IRCTestHelper
15 + import OhMyExOhIrcd.AccountsFixtures
15 16
16 17 alias OhMyExOhIrcd.Accounts
17 18
@@ -80,6 +81,29 @@ defmodule OhMyExOhIrcd.IRC.OperInviteTest do
80 81 refute text =~ "Invite token:"
81 82 end
82 83
84 + test "a SASL-authed DB-flagged oper mints an invite (created_by path)" do
85 + # Mirrors prod: the oper is authenticated (so state.account is set
86 + # and recorded as created_by) rather than a static O-line.
87 + password = "longenoughpass#{System.unique_integer([:positive])}"
88 + user = user_fixture()
89 + {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})
90 + {:ok, user} = Accounts.set_oper(user, true)
91 +
92 + port = start_listener()
93 + sock = register_sasl(port, unique_nick("op"), user.email, password)
94 + send_line(sock, "OPER")
95 + assert Enum.any?(drain(sock, 300), &String.contains?(&1, " 381 "))
96 +
97 + send_line(sock, "MKINVITE")
98 + text = notice_text(drain(sock, 400))
99 +
100 + assert text =~ "Invite token:"
101 + [_, token] = Regex.run(~r/Invite token: (\S+)/, text)
102 + invite = Accounts.valid_invite(token)
103 + assert invite
104 + assert invite.created_by_id == user.id
105 + end
106 +
83 107 test "a non-oper gets 481 and no invite is created" do
84 108 port = start_listener()
85 109 before = length(Accounts.list_active_invites())
modified test/oh_my_ex_oh_ircd/irc/oper_test.exs
+46 −0
@@ -136,6 +136,52 @@ defmodule OhMyExOhIrcd.IRC.OperTest do
136 136 assert drain_after_event(sock, ~r/ 464 /)
137 137 end
138 138
139 + test "OPER name+password promotes a flagged SASL user (clients always send a password)" do
140 + import OhMyExOhIrcd.AccountsFixtures
141 +
142 + alias OhMyExOhIrcd.Accounts
143 +
144 + password = "longenoughpass#{System.unique_integer([:positive])}"
145 + user = user_fixture()
146 + {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})
147 + {:ok, user} = Accounts.set_oper(user, true)
148 +
149 + port = start_listener()
150 + sock = register_sasl(port, unique_nick("authoper"), user.email, password)
151 + # irssi/weechat `/oper` always sends a name + (here irrelevant) password,
152 + # never a bare OPER — the flagged account still gets promoted.
153 + send_line(sock, "OPER whatever ignored-password")
154 +
155 + line = drain_after_event(sock, ~r/ 381 /)
156 + assert line, "expected RPL_YOUREOPER (381) via name+password for is_oper user"
157 + end
158 +
159 + test "an is_oper grant issued after authentication takes effect without reconnecting" do
160 + import OhMyExOhIrcd.AccountsFixtures
161 +
162 + alias OhMyExOhIrcd.Accounts
163 +
164 + password = "longenoughpass#{System.unique_integer([:positive])}"
165 + user = user_fixture()
166 + {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})
167 +
168 + port = start_listener()
169 + sock = register_sasl(port, unique_nick("lateoper"), user.email, password)
170 +
171 + # Not flagged yet — refused.
172 + send_line(sock, "OPER")
173 + assert drain_after_event(sock, ~r/ 464 /)
174 +
175 + # Flag the account mid-session (mirrors `Release.grant_oper`).
176 + {:ok, _} = Accounts.set_oper(user, true)
177 +
178 + # Same connection, no reconnect: the flag is re-read from the DB, so
179 + # the stale cached struct doesn't block promotion.
180 + send_line(sock, "OPER")
181 + assert drain_after_event(sock, ~r/ 381 /),
182 + "expected RPL_YOUREOPER (381) after a mid-session grant"
183 + end
184 +
139 185 test "WHOIS on an oper includes 313 RPL_WHOISOPERATOR" do
140 186 port = start_listener()
141 187 admin_nick = unique_nick("admin1")

Parents: 414662c