neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
mkinvite crash
d2edb4a · gmorell · 2026-06-10 22:09
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