Admin: "Seen on this network", a persisted connection history

e1821ef · Gabriel Morell · 2026-10-27 17:46

28 files +732 -8
Message
{commit_body(@commit)}

Files changed

modified config/config.exs
+9 −0
@@ -56,6 +56,15 @@ config :oh_my_ex_oh_ircd, :imgserv,
56 56 # /webirc to participate in the cluster from a browser tab.
57 57 config :oh_my_ex_oh_ircd, :webirc, enabled: false
58 58
59 +# "Seen on this network" history (operator console). Anonymous sessions'
60 +# rows are deleted once not seen for `unregistered_retention_days`; rows
61 +# for registered accounts are kept. The sweep runs on every node.
62 +config :oh_my_ex_oh_ircd, :sightings,
63 + enabled: true,
64 + prune_enabled: true,
65 + unregistered_retention_days: 90,
66 + prune_interval_ms: 6 * 3_600_000
67 +
59 68 # IRC operators (OPER command). Each entry's password is a bcrypt hash:
60 69 #
61 70 # iex> Bcrypt.hash_pwd_salt("yourpassword")
modified config/test.exs
+5 −0
@@ -12,6 +12,11 @@ config :oh_my_ex_oh_ircd, :registration_mode, :open
12 12 # SQL sandbox. Tests call `OhMyExOhIrcd.Images.gc/0` directly instead.
13 13 config :oh_my_ex_oh_ircd, :imgserv, gc_enabled: false
14 14
15 +# Connections don't record sightings and the pruner doesn't sweep in tests
16 +# (the writer would race the SQL sandbox); tests that cover them enable
17 +# them or call `OhMyExOhIrcd.Sightings` directly.
18 +config :oh_my_ex_oh_ircd, :sightings, enabled: false, prune_enabled: false
19 +
15 20 # A static O-line so oper-command tests can `OPER testoper testpass`
16 21 # without provisioning a DB-flagged account. Hash is `testpass`.
17 22 config :oh_my_ex_oh_ircd, :opers, %{
modified lib/oh_my_ex_oh_ircd/application.ex
+2 −0
@@ -27,6 +27,8 @@ defmodule OhMyExOhIrcd.Application do
27 27 OhMyExOhIrcd.Cluster.HordeSupervisor,
28 28 OhMyExOhIrcd.Cluster.NodeListener,
29 29 OhMyExOhIrcd.Images.GarbageCollector,
30 + OhMyExOhIrcd.Sightings.Writer,
31 + OhMyExOhIrcd.Sightings.Pruner,
30 32 OhMyExOhIrcd.IRC.Services,
31 33 OhMyExOhIrcd.IRC.Listener,
32 34 endpoint_child(),
modified lib/oh_my_ex_oh_ircd/irc/connection.ex
+27 −3
@@ -83,7 +83,9 @@ defmodule OhMyExOhIrcd.IRC.Connection do
83 83 operator?: false,
84 84 oper_name: nil,
85 85 # `+w` user-mode: receive WALLOPS broadcasts.
86 − wallops?: false
86 + wallops?: false,
87 + # The client's QUIT reason, kept for the disconnect sighting.
88 + quit_reason: nil
87 89 end
88 90
89 91 alias OhMyExOhIrcd.IRC.Stats
@@ -438,6 +440,8 @@ defmodule OhMyExOhIrcd.IRC.Connection do
438 440 if state.ping_timer, do: Process.cancel_timer(state.ping_timer)
439 441
440 442 if state.registered? do
443 + record_sighting(state, %{quit_reason: state.quit_reason || "Connection closed"})
444 +
441 445 # Leave every joined channel cleanly.
442 446 Enum.each(state.channels, fn ch -> Channel.part(ch, state.nick, "Connection closed") end)
443 447
@@ -686,7 +690,13 @@ defmodule OhMyExOhIrcd.IRC.Connection do
686 690 end
687 691
688 692 send_msg(socket, Message.build("NICK", [nick], old_prefix))
689 − %{state | nick: nick}
693 +
694 + # An anonymous user's history is per nick: the old one was seen
695 + # until now too.
696 + if is_nil(state.account), do: record_sighting(state)
697 + state = %{state | nick: nick}
698 + record_sighting(state)
699 + state
690 700
691 701 true ->
692 702 state = %{state | nick: nick}
@@ -708,7 +718,7 @@ defmodule OhMyExOhIrcd.IRC.Connection do
708 718
709 719 send_msg(socket, Message.build("ERROR", ["Closing link: #{state.host} (#{reason})"]))
710 720 ThousandIsland.Socket.shutdown(socket, :write)
711 − state
721 + %{state | quit_reason: reason}
712 722 end
713 723
714 724 defp dispatch(%Message{command: "JOIN", params: [chans | rest]}, socket, state) do
@@ -1668,6 +1678,7 @@ defmodule OhMyExOhIrcd.IRC.Connection do
1668 1678 {:ok, _} ->
1669 1679 state = %{state | registered?: true}
1670 1680 state = apply_account_vhost(state)
1681 + record_sighting(state)
1671 1682 send_welcome(socket, state)
1672 1683 OhMyExOhIrcd.IRC.Broadcast.subscribe_all()
1673 1684 state = schedule_idle_check(state)
@@ -1689,6 +1700,19 @@ defmodule OhMyExOhIrcd.IRC.Connection do
1689 1700
1690 1701 defp apply_account_vhost(state), do: state
1691 1702
1703 + # "Seen on this network" history. Handed to Sightings.Writer, so the
1704 + # database is never on this connection's path.
1705 + defp record_sighting(state, extra \\ %{}) do
1706 + %{
1707 + nick: state.nick,
1708 + user_id: state.account && state.account.id,
1709 + host: state.host,
1710 + real_ip: state.real_host
1711 + }
1712 + |> Map.merge(extra)
1713 + |> OhMyExOhIrcd.Sightings.record_async()
1714 + end
1715 +
1692 1716 defp swap_user_registration(old, new) do
1693 1717 Horde.Registry.unregister(HordeRegistry, {:user, downcase(old)})
1694 1718
added lib/oh_my_ex_oh_ircd/sightings.ex
+183 −0
@@ -0,0 +1,183 @@
1 +defmodule OhMyExOhIrcd.Sightings do
2 + @moduledoc """
3 + Persisted "seen on this network" history, for the operator console.
4 +
5 + `Connection` reports three events — registration complete, nick change
6 + and disconnect — through `record_async/1`, which hands the write to
7 + `Sightings.Writer` so the connection never waits on (or crashes from)
8 + the database. Each event upserts one row:
9 +
10 + * authenticated to an account: keyed by `user_id`, so an account is
11 + one row whatever nick it last used;
12 + * anonymous: keyed by the downcased nick, where `user_id` is NULL.
13 +
14 + `first_seen_at` is kept from the first sighting; `last_seen_at` only
15 + moves forward. Unregistered rows not seen for
16 + `unregistered_retention_days` (default 90) are deleted by `prune/0`,
17 + which `Sightings.Pruner` runs on every node — a plain DELETE, so
18 + concurrent runs are harmless.
19 + """
20 +
21 + import Ecto.Query, warn: false
22 +
23 + alias OhMyExOhIrcd.Repo
24 + alias OhMyExOhIrcd.Sightings.{Sighting, Writer}
25 +
26 + @default_retention_days 90
27 + @default_list_limit 500
28 +
29 + @type event :: %{
30 + required(:nick) => String.t(),
31 + optional(:user_id) => integer() | nil,
32 + optional(:host) => String.t() | nil,
33 + optional(:real_ip) => String.t() | nil,
34 + optional(:quit_reason) => String.t() | nil,
35 + optional(:at) => DateTime.t()
36 + }
37 +
38 + ## Config
39 +
40 + defp env, do: Application.get_env(:oh_my_ex_oh_ircd, :sightings, [])
41 +
42 + @doc "Whether connections report sightings (off in the test env)."
43 + def enabled?, do: Keyword.get(env(), :enabled, true)
44 +
45 + @doc "Whether `Sightings.Pruner` sweeps (off in the test env)."
46 + def prune_enabled?, do: Keyword.get(env(), :prune_enabled, true)
47 +
48 + def prune_interval_ms, do: Keyword.get(env(), :prune_interval_ms, 6 * 3_600_000)
49 +
50 + def unregistered_retention_days,
51 + do: Keyword.get(env(), :unregistered_retention_days, @default_retention_days)
52 +
53 + def list_limit, do: @default_list_limit
54 +
55 + ## Recording
56 +
57 + @doc """
58 + Queue a sighting for `Sightings.Writer`. Never raises and never blocks
59 + the caller; a no-op when disabled or for service nicks.
60 + """
61 + @spec record_async(event()) :: :ok
62 + def record_async(%{nick: nick} = event) when is_binary(nick) do
63 + if enabled?() and not service_nick?(nick) do
64 + Writer.record(Map.put_new(event, :at, now()))
65 + end
66 +
67 + :ok
68 + rescue
69 + _ -> :ok
70 + end
71 +
72 + def record_async(_), do: :ok
73 +
74 + @doc """
75 + Upsert a sighting now. Returns `{:ok, %Sighting{}}`, `:skipped` for a
76 + service nick, or `{:error, reason}`.
77 + """
78 + @spec record(event()) :: {:ok, Sighting.t()} | :skipped | {:error, term()}
79 + def record(%{nick: nick} = event) when is_binary(nick) do
80 + if service_nick?(nick) do
81 + :skipped
82 + else
83 + at = event |> Map.get(:at, now()) |> DateTime.truncate(:second)
84 + user_id = Map.get(event, :user_id)
85 +
86 + row = %Sighting{
87 + nick_key: String.downcase(nick),
88 + nick: nick,
89 + user_id: user_id,
90 + host: Map.get(event, :host),
91 + real_ip: Map.get(event, :real_ip),
92 + first_seen_at: at,
93 + last_seen_at: at,
94 + last_quit_reason: truncate(Map.get(event, :quit_reason)),
95 + inserted_at: at,
96 + updated_at: at
97 + }
98 +
99 + Repo.insert(row,
100 + on_conflict: upsert_query(),
101 + conflict_target: conflict_target(user_id),
102 + returning: true
103 + )
104 + end
105 + end
106 +
107 + # Keep first_seen_at; never move last_seen_at backwards (writes from two
108 + # nodes can land out of order); keep a known IP / quit reason when the
109 + # new event doesn't carry one.
110 + defp upsert_query do
111 + from s in Sighting,
112 + update: [
113 + set: [
114 + nick: fragment("EXCLUDED.nick"),
115 + nick_key: fragment("EXCLUDED.nick_key"),
116 + host: fragment("COALESCE(EXCLUDED.host, ?)", s.host),
117 + real_ip: fragment("COALESCE(EXCLUDED.real_ip, ?)", s.real_ip),
118 + last_seen_at: fragment("GREATEST(?, EXCLUDED.last_seen_at)", s.last_seen_at),
119 + last_quit_reason:
120 + fragment("COALESCE(EXCLUDED.last_quit_reason, ?)", s.last_quit_reason),
121 + updated_at: fragment("EXCLUDED.updated_at")
122 + ]
123 + ]
124 + end
125 +
126 + defp conflict_target(nil), do: {:unsafe_fragment, "(nick_key) WHERE user_id IS NULL"}
127 + defp conflict_target(_user_id), do: [:user_id]
128 +
129 + defp truncate(nil), do: nil
130 + defp truncate(reason) when is_binary(reason), do: String.slice(reason, 0, 255)
131 +
132 + @doc "ChanServ, ScrobbleServ, ImgServ — never recorded."
133 + def service_nick?(nick) when is_binary(nick) do
134 + String.downcase(nick) in [
135 + String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick()),
136 + String.downcase(OhMyExOhIrcd.IRC.ScrobbleServ.nick()),
137 + String.downcase(OhMyExOhIrcd.IRC.ImgServ.nick())
138 + ]
139 + end
140 +
141 + ## Reading
142 +
143 + @doc """
144 + The most recently seen rows, newest first, with `:user` preloaded.
145 + `filter` is `:all`, `:registered` or `:unregistered`.
146 + """
147 + @spec list_recent(:all | :registered | :unregistered, pos_integer()) :: [Sighting.t()]
148 + def list_recent(filter \\ :all, limit \\ @default_list_limit) do
149 + Sighting
150 + |> filter_kind(filter)
151 + |> order_by([s], desc: s.last_seen_at, desc: s.id)
152 + |> limit(^limit)
153 + |> preload(:user)
154 + |> Repo.all()
155 + end
156 +
157 + @doc "How many rows match `filter` (to say when the list is capped)."
158 + @spec count(:all | :registered | :unregistered) :: non_neg_integer()
159 + def count(filter \\ :all), do: Sighting |> filter_kind(filter) |> Repo.aggregate(:count)
160 +
161 + defp filter_kind(query, :registered), do: where(query, [s], not is_nil(s.user_id))
162 + defp filter_kind(query, :unregistered), do: where(query, [s], is_nil(s.user_id))
163 + defp filter_kind(query, _all), do: query
164 +
165 + ## Expiry
166 +
167 + @doc """
168 + Delete unregistered (user_id NULL) rows last seen more than
169 + `unregistered_retention_days` ago. Account rows are kept. Returns the
170 + number deleted.
171 + """
172 + @spec prune(DateTime.t()) :: non_neg_integer()
173 + def prune(now \\ now()) do
174 + cutoff = DateTime.add(now, -unregistered_retention_days() * 86_400, :second)
175 +
176 + {n, _} =
177 + Repo.delete_all(from s in Sighting, where: is_nil(s.user_id) and s.last_seen_at < ^cutoff)
178 +
179 + n
180 + end
181 +
182 + defp now, do: DateTime.utc_now() |> DateTime.truncate(:second)
183 +end
added lib/oh_my_ex_oh_ircd/sightings/pruner.ex
+45 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/sightings/sighting.ex
+26 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/sightings/writer.ex
+37 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd_web/live/admin_live.ex
+163 −4

Click to load diff…

added priv/repo/migrations/20261027173749_create_user_sightings.exs
+36 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/sightings_connection_test.exs
+48 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/sightings_test.exs
+114 −0

Click to load diff…

modified test/oh_my_ex_oh_ircd_web/live/admin_live_test.exs
+37 −1

Click to load diff…

Parents: d434ccb