IRC host cloaking + oper vhosts, topic persistence, and images feature

3a1226c · gmorell · 2026-06-27 17:40

67 files +3728 -68
Message
{commit_body(@commit)}

Files changed

modified README.md
+9 −2
@@ -35,8 +35,15 @@ services, IRC-over-TLS, and ACME cert provisioning baked in.
35 35 or `:`. A `JOIN` of anything else is refused with `403`.
36 36 * Channel modes: `+b/+e/+I` (ban / except / invite-exception masks
37 37 with glob matching), `+i` (invite-only), `+m` (moderated),
38 − `+n` (no external), `+k <key>`, `+l <limit>`. Mode views via
39 − `MODE #ch b` etc. ban-list-style numerics (367/348/346).
38 + `+n` (no external), `+k <key>`, `+l <limit>`, `+f`
39 + (flood-exempt — members of the channel bypass PRIVMSG/NOTICE
40 + flood control; founder-only to set/clear since it disables a
41 + built-in protection), and `+A` (auto-join — every connecting
42 + user is force-joined to the channel after registration;
43 + IRC-operator-only to set/clear, and only effective on a
44 + registered channel since the flag is read back from
45 + persistence at connect time). Mode views via `MODE #ch b` etc.
46 + ban-list-style numerics (367/348/346).
40 47 * ChanServ service with `REGISTER / DROP / INFO / OP / DEOP / HOP
41 48 / DEHOP / VOICE / DEVOICE / TRANSFER / HELP`. Service nicks are
42 49 configurable and reserved at the registry level.
modified config/config.exs
+26 −0
@@ -30,8 +30,28 @@ config :oh_my_ex_oh_ircd,
30 30 config :oh_my_ex_oh_ircd, :services,
31 31 chanserv_nick: "ChanServ",
32 32 scrobbleserv_nick: "ScrobbleServ",
33 + imgserv_nick: "ImgServ",
33 34 service_host: "services."
34 35
36 +# ImgServ image hosting. Images are stored on the local filesystem under
37 +# `upload_dir` and served from `/i/<filename>`. NOTE: in a multi-node
38 +# cluster this assumes a shared volume (NFS / PVC) mounted at `upload_dir`
39 +# on every node — otherwise an image uploaded on one node can't be served
40 +# or GC'd from another. Override `upload_dir` per environment in runtime.exs.
41 +config :oh_my_ex_oh_ircd, :imgserv,
42 + # Where image files live. Resolved at runtime to the app's priv/uploads
43 + # when unset; override with IMGSERV_UPLOAD_DIR (see runtime.exs).
44 + upload_dir: nil,
45 + # Max accepted upload size, bytes.
46 + max_bytes: 10_485_760,
47 + allowed_types: ~w(image/png image/jpeg image/gif image/webp),
48 + # Default retention window; channels may override (NULL = use this).
49 + retention_days: 30,
50 + # Per-user budget (MB) of pinned, GC-exempt images.
51 + pin_budget_mb: 100,
52 + # How often the retention sweep runs.
53 + gc_interval_ms: 3_600_000
54 +
35 55 # WebIRC LiveView toggle. When enabled, logged-in users can open
36 56 # /webirc to participate in the cluster from a browser tab.
37 57 config :oh_my_ex_oh_ircd, :webirc, enabled: false
@@ -115,6 +135,12 @@ config :oh_my_ex_oh_ircd, OhMyExOhIrcd.IRC.Listener,
115 135 # Default `false` so direct TCP keeps working. Flip via
116 136 # `IRC_PROXY_PROTOCOL=true` at runtime.
117 137 proxy_protocol: false,
138 + # When set to a base domain, client IPs are cloaked as
139 + # `<hash>.<cloak_host>` in prefixes/WHOIS; opers still see the real IP via
140 + # WHOIS 338, and oper-assigned vhosts override the cloak. nil disables
141 + # cloaking (real IP shown). Set via `IRC_CLOAK_HOST` at runtime; in prod
142 + # it defaults to `PHX_HOST`.
143 + cloak_host: nil,
118 144 motd: [
119 145 "Welcome to OhMyExOhIRC.",
120 146 "",
modified config/runtime.exs
+21 −0
@@ -40,6 +40,12 @@ if net = System.get_env("IRC_NETWORK_NAME") do
40 40 config :oh_my_ex_oh_ircd, OhMyExOhIrcd.IRC.Listener, network_name: net
41 41 end
42 42
43 +# Where ImgServ stores hosted image files. In a multi-node deployment this
44 +# should be a shared volume mounted at the same path on every node.
45 +if dir = System.get_env("IMGSERV_UPLOAD_DIR") do
46 + config :oh_my_ex_oh_ircd, :imgserv, upload_dir: dir
47 +end
48 +
43 49 # Expect a PROXY v1/v2 header on every connection. Required when the
44 50 # IRC pod is behind a TCP proxy (Traefik IngressRouteTCP, HAProxy, AWS
45 51 # NLB...) — otherwise every client looks like the proxy's IP.
@@ -48,6 +54,21 @@ if pp = System.get_env("IRC_PROXY_PROTOCOL") do
48 54 proxy_protocol: pp in ~w(1 true yes on)
49 55 end
50 56
57 +# Host cloaking base domain. Client IPs are shown as `<hash>.<cloak_host>`
58 +# to non-opers. Explicit `IRC_CLOAK_HOST` wins; otherwise prod defaults to
59 +# `PHX_HOST` so it's on out of the box. Disable with
60 +# `IRC_CLOAK_HOST=off` (or empty/false/none).
61 +cloak_host =
62 + case System.get_env("IRC_CLOAK_HOST") do
63 + nil -> if config_env() == :prod, do: System.get_env("PHX_HOST")
64 + v when v in ["", "off", "false", "none"] -> nil
65 + v -> v
66 + end
67 +
68 +if cloak_host do
69 + config :oh_my_ex_oh_ircd, OhMyExOhIrcd.IRC.Listener, cloak_host: cloak_host
70 +end
71 +
51 72 # Anonymous IRC access — default ON in prod releases (set
52 73 # IRC_ALLOW_ANONYMOUS=false to lock it down). Dev/test compile-time
53 74 # config wins outside prod.
modified config/test.exs
+4 −0
@@ -8,6 +8,10 @@ config :bcrypt_elixir, :log_rounds, 1
8 8 # Tests that exercise the invite gate can `Application.put_env/3` per-test.
9 9 config :oh_my_ex_oh_ircd, :registration_mode, :open
10 10
11 +# Don't run the ImgServ retention sweeper during tests — it would race the
12 +# SQL sandbox. Tests call `OhMyExOhIrcd.Images.gc/0` directly instead.
13 +config :oh_my_ex_oh_ircd, :imgserv, gc_enabled: false
14 +
11 15 # A static O-line so oper-command tests can `OPER testoper testpass`
12 16 # without provisioning a DB-flagged account. Hash is `testpass`.
13 17 config :oh_my_ex_oh_ircd, :opers, %{
modified lib/oh_my_ex_oh_ircd/accounts.ex
+21 −0
@@ -296,6 +296,27 @@ defmodule OhMyExOhIrcd.Accounts do
296 296 def oper?(%User{is_oper: true}), do: true
297 297 def oper?(_), do: false
298 298
299 + @doc """
300 + Set (or clear, with `nil`) a user's persistent vhost. Applied to their
301 + IRC prefix on the next connection; `OhMyExOhIrcd.IRC.Vhost.set_live/3`
302 + also pushes it to any currently-connected session. Returns the updated
303 + `%User{}` or `{:error, changeset}`.
304 + """
305 + def set_vhost(%User{} = user, vhost) when is_binary(vhost) or is_nil(vhost) do
306 + user |> User.vhost_changeset(vhost) |> Repo.update()
307 + end
308 +
309 + @doc """
310 + Set a persistent vhost by account email (for offline users). Returns
311 + `{:error, :not_found}` if no such account.
312 + """
313 + def set_vhost_by_email(email, vhost) when is_binary(email) do
314 + case get_user_by_email(email) do
315 + %User{} = user -> set_vhost(user, vhost)
316 + nil -> {:error, :not_found}
317 + end
318 + end
319 +
299 320 ## Invites (invite-only registration)
300 321
301 322 alias OhMyExOhIrcd.Accounts.Invite
modified lib/oh_my_ex_oh_ircd/accounts/user.ex
+21 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd/application.ex
+1 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd/channels.ex
+41 −10

Click to load diff…

added lib/oh_my_ex_oh_ircd/channels/channel_topic.ex
+33 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images.ex
+486 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images/channel_config.ex
+77 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images/garbage_collector.ex
+47 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images/image.ex
+50 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images/request_log.ex
+31 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd/images/token.ex
+78 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/channel.ex
+53 −27

Click to load diff…

added lib/oh_my_ex_oh_ircd/irc/cloak.ex
+53 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/connection.ex
+192 −11

Click to load diff…

added lib/oh_my_ex_oh_ircd/irc/img_serv.ex
+629 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/listener.ex
+4 −1

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/services.ex
+8 −3

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/stats.ex
+3 −1

Click to load diff…

modified lib/oh_my_ex_oh_ircd/irc/tls_listener.ex
+4 −1

Click to load diff…

added lib/oh_my_ex_oh_ircd/irc/vhost.ex
+48 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/controllers/image_controller.ex
+178 −0

Click to load diff…

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

Click to load diff…

modified lib/oh_my_ex_oh_ircd_web/live/user_live/channels.ex
+14 −6

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/live/user_live/img_serv.ex
+541 −0

Click to load diff…

added lib/oh_my_ex_oh_ircd_web/plugs/image_auth.ex
+32 −0

Click to load diff…

modified lib/oh_my_ex_oh_ircd_web/router.ex
+18 −0

Click to load diff…

added priv/repo/migrations/20260610000000_create_image_channel_configs.exs
+26 −0

Click to load diff…

added priv/repo/migrations/20260611000000_create_image_tokens.exs
+24 −0

Click to load diff…

added priv/repo/migrations/20260612000000_create_images.exs
+29 −0

Click to load diff…

added priv/repo/migrations/20260613000000_create_image_request_logs.exs
+16 −0

Click to load diff…

added priv/repo/migrations/20260627000000_add_vhost_to_users.exs
+12 −0

Click to load diff…

added priv/repo/migrations/20260627010000_create_channel_topics.exs
+22 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/images_test.exs
+152 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/auto_join_test.exs
+117 −0

Click to load diff…

modified test/oh_my_ex_oh_ircd/irc/channel_modes_test.exs
+2 −2

Click to load diff…

modified test/oh_my_ex_oh_ircd/irc/channel_persistence_test.exs
+41 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/cloak_test.exs
+34 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/img_serv_test.exs
+134 −0

Click to load diff…

added test/oh_my_ex_oh_ircd/irc/vhost_test.exs
+37 −0

Click to load diff…

added test/oh_my_ex_oh_ircd_web/controllers/image_controller_test.exs
+128 −0

Click to load diff…

added test/oh_my_ex_oh_ircd_web/live/user_live/img_serv_test.exs
+112 −0

Click to load diff…

Parents: ca5f84e