fix being able to add ops irl

6de4808 · gmorell · 2026-06-10 21:15

4 files +249 -1

Files changed

modified README.md
+123 −0
@@ -86,6 +86,129 @@ mix phx.server # web UI on http://localhost:4666, IRC on 6667
86 86 Visit <http://localhost:4666/users/register> to make a Phoenix account
87 87 if you want to test SASL auth or register channels.
88 88
89 +## Inviting users (registration tokens)
90 +
91 +When `registration_mode` is `:invite_only` (the **default in prod**),
92 +`/users/register` is gated — a visitor needs a single-use invite token
93 +to create an account. Tokens are minted out-of-band and shared as a URL.
94 +
95 +Mint one with the `mix invites.create` task:
96 +
97 +```sh
98 +# Simplest — never expires, no note:
99 +mix invites.create
100 +
101 +# With a note and a 7-day expiry, against a specific public host:
102 +mix invites.create --note "for alice" --expires-in-days 7 \
103 + --base-url https://irc.skyjeep.club
104 +```
105 +
106 +It prints the token and the full signup link, e.g.:
107 +
108 +```
109 +Token: k3mfO0Yb9...
110 +URL: https://irc.skyjeep.club/users/register?invite=k3mfO0Yb9...
111 +```
112 +
113 +Send that URL to the person you're inviting — visiting it pre-fills the
114 +gate and lets them register. The token is **single-use** (consumed when
115 +the account is created) and optionally **time-limited**; a consumed or
116 +expired token falls back to the locked gate. The `--base-url` defaults
117 +to `https://$PHX_HOST` (or `http://localhost:4666` if unset), so set it
118 +explicitly when generating links for a different host.
119 +
120 +List the outstanding (unconsumed) tokens with:
121 +
122 +```sh
123 +mix invites.list
124 +```
125 +
126 +**In prod (a release, no Mix):** the `mix` tasks aren't available, so use
127 +the `OhMyExOhIrcd.Release` helpers via `eval` instead — they start the
128 +Repo on demand and print to your terminal:
129 +
130 +```sh
131 +bin/oh_my_ex_oh_ircd eval \
132 + 'OhMyExOhIrcd.Release.create_invite(note: "for alice", expires_in_days: 7)'
133 +bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.list_invites()'
134 +```
135 +
136 +(Use `eval`, not `rpc` — `rpc` runs on the live node, so its output would
137 +go to the server's console, not yours. The signup URL is built from the
138 +Endpoint's `:url` host, i.e. `PHX_HOST`.)
139 +
140 +Registration mode itself is set from the `REGISTRATION_MODE` env var in
141 +`runtime.exs` (`open` for free signup, anything else → `invite_only`).
142 +With `:open`, the gate is off and invite tokens are unnecessary.
143 +
144 +## Creating an IRC operator
145 +
146 +There are two ways to become an IRC operator (network staff who can
147 +`KILL`, `WALLOPS`, etc.). The account-flag path is the normal one; the
148 +static config map is a DB-free break-glass fallback.
149 +
150 +### Account flag (recommended)
151 +
152 +Flag an existing Phoenix account as an oper with the `mix opers.grant`
153 +task, keyed by email:
154 +
155 +```sh
156 +mix opers.grant alice@example.com # set the is_oper bit
157 +mix opers.list # show flagged accounts
158 +mix opers.revoke alice@example.com # take it away
159 +```
160 +
161 +In prod (a release, no Mix), use the `OhMyExOhIrcd.Release` equivalents
162 +via `eval`:
163 +
164 +```sh
165 +bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.grant_oper("alice@example.com")'
166 +bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.list_opers()'
167 +bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.revoke_oper("alice@example.com")'
168 +```
169 +
170 +That user then connects, **SASL-authenticates** (so the server knows
171 +which account they are), and sends a bare `OPER` with no arguments:
172 +
173 +```sh
174 +/oper
175 +```
176 +
177 +The server checks the `is_oper` bit on their authenticated account and,
178 +if set, grants operator status. No password is exchanged over IRC — the
179 +SASL login is the proof of identity.
180 +
181 +### Static O-line (break-glass)
182 +
183 +The classic `OPER <name> <password>` flow works against a static
184 +`:opers` config map with no DB round-trip — handy for bootstrap and
185 +disaster recovery. Add an entry (key = oper login name):
186 +
187 +```elixir
188 +# config/dev.exs, or env-specific / runtime config in prod
189 +config :oh_my_ex_oh_ircd, :opers, %{
190 + "admin" => %{
191 + # iex> Bcrypt.hash_pwd_salt("super-secret-passphrase")
192 + password_hash: "$2b$12$7ZFGtxWehSNu6j2qcqAcL.hlDG/trb1KaAq2v1lxLfzLRLMfnZrDW",
193 + host_mask: "*@*" # IRC glob on the connecting client's user@host
194 + }
195 +}
196 +```
197 +
198 +`host_mask` is matched case-insensitively against the connection's
199 +`user@host`; `"*@*"` allows opers from anywhere, tighten it (e.g.
200 +`"*@10.0.0.*"`) to pin staff to known hosts. This map is config, not an
201 +env var, so changes need a redeploy / restart. Then:
202 +
203 +```sh
204 +/oper admin super-secret-passphrase
205 +```
206 +
207 +Either path, on success you get `381 You are now an IRC operator` plus a
208 +`MODE +o` on your nick. Failure numerics: `491` (host doesn't match
209 +`host_mask`), `464` (wrong password, or account not flagged), `461`
210 +(missing parameters).
211 +
89 212 ## TLS / ACME modes
90 213
91 214 `:tls_mode` is a **compile-time** flag in `config/config.exs` (or set
modified lib/oh_my_ex_oh_ircd/release.ex
+126 −1
@@ -1,7 +1,17 @@
1 1 defmodule OhMyExOhIrcd.Release do
2 2 @moduledoc """
3 − Used for executing DB release tasks when run in production without Mix
3 + Used for executing release tasks when run in production without Mix
4 4 installed.
5 +
6 + The `mix invites.*` / `mix opers.*` tasks are dev-only (Mix is not
7 + shipped in the release). These functions are their prod equivalents —
8 + run them with `eval` so the Repo is started on demand and output lands
9 + in your terminal:
10 +
11 + bin/oh_my_ex_oh_ircd eval 'OhMyExOhIrcd.Release.create_invite(note: "for alice")'
12 +
13 + Prefer `eval` over `rpc` here: `rpc` runs on the live node, so any
14 + printed output goes to the *server's* console, not yours.
5 15 """
6 16 @app :oh_my_ex_oh_ircd
7 17
@@ -18,6 +28,121 @@ defmodule OhMyExOhIrcd.Release do
18 28 {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 29 end
20 30
31 + ## Registration invites — prod equivalents of `mix invites.*`.
32 +
33 + @doc """
34 + Mint a single-use registration invite and print the token + signup URL.
35 +
36 + bin/oh_my_ex_oh_ircd eval \
37 + 'OhMyExOhIrcd.Release.create_invite(note: "for alice", expires_in_days: 7)'
38 +
39 + Accepts `:note` and `:expires_in_days` (mirrors `mix invites.create`).
40 + """
41 + def create_invite(opts \\ []) do
42 + opts =
43 + case Keyword.pop(opts, :expires_in_days) do
44 + {nil, rest} -> rest
45 + {days, rest} -> Keyword.put(rest, :expires_in_seconds, days * 24 * 3600)
46 + end
47 +
48 + with_started_repo(fn ->
49 + case OhMyExOhIrcd.Accounts.create_invite(opts) do
50 + {:ok, invite} ->
51 + IO.puts("Token: #{invite.token}")
52 + IO.puts("URL: #{base_url()}/users/register?invite=#{invite.token}")
53 + if invite.expires_at, do: IO.puts("Expires: #{invite.expires_at}")
54 + :ok
55 +
56 + {:error, cs} ->
57 + IO.puts("Couldn't create invite: #{inspect(cs.errors)}")
58 + {:error, cs}
59 + end
60 + end)
61 + end
62 +
63 + @doc "List active (unconsumed) invites. Prod equivalent of `mix invites.list`."
64 + def list_invites do
65 + with_started_repo(fn ->
66 + case OhMyExOhIrcd.Accounts.list_active_invites() do
67 + [] ->
68 + IO.puts("(no active invites)")
69 +
70 + invites ->
71 + Enum.each(invites, fn i ->
72 + IO.puts(
73 + "#{String.slice(i.token, 0, 8)}… #{i.note || ""} expires=#{i.expires_at || "never"}"
74 + )
75 + end)
76 + end
77 + end)
78 + end
79 +
80 + ## IRC operators — prod equivalents of `mix opers.*`.
81 +
82 + @doc "Flag an account (by email) as an IRC operator. See `mix opers.grant`."
83 + def grant_oper(email), do: set_oper(email, true)
84 +
85 + @doc "Clear an account's IRC-operator flag. See `mix opers.revoke`."
86 + def revoke_oper(email), do: set_oper(email, false)
87 +
88 + @doc "List accounts flagged as IRC operators. See `mix opers.list`."
89 + def list_opers do
90 + import Ecto.Query
91 +
92 + with_started_repo(fn ->
93 + OhMyExOhIrcd.Repo.all(
94 + from u in OhMyExOhIrcd.Accounts.User,
95 + where: u.is_oper == true,
96 + order_by: [asc: u.email]
97 + )
98 + |> case do
99 + [] -> IO.puts("(no IRC operators flagged)")
100 + users -> Enum.each(users, &IO.puts(&1.email))
101 + end
102 + end)
103 + end
104 +
105 + defp set_oper(email, flag) do
106 + with_started_repo(fn ->
107 + case OhMyExOhIrcd.Accounts.get_user_by_email(email) do
108 + nil ->
109 + IO.puts("No account with email #{email}")
110 + {:error, :not_found}
111 +
112 + user ->
113 + case OhMyExOhIrcd.Accounts.set_oper(user, flag) do
114 + {:ok, updated} ->
115 + IO.puts("#{updated.email}: is_oper=#{updated.is_oper}")
116 + :ok
117 +
118 + {:error, cs} ->
119 + IO.puts("Update failed: #{inspect(cs.errors)}")
120 + {:error, cs}
121 + end
122 + end
123 + end)
124 + end
125 +
126 + # Build the public base URL from the Endpoint's `:url` config without
127 + # starting the Endpoint process (we run under `eval`, where it isn't up).
128 + defp base_url do
129 + url = Application.get_env(@app, OhMyExOhIrcdWeb.Endpoint, []) |> Keyword.get(:url, [])
130 + scheme = Keyword.get(url, :scheme, "http")
131 + host = Keyword.get(url, :host, "localhost")
132 + port = Keyword.get(url, :port, if(scheme == "https", do: 443, else: 80))
133 + default = if scheme == "https", do: 443, else: 80
134 + if port == default, do: "#{scheme}://#{host}", else: "#{scheme}://#{host}:#{port}"
135 + end
136 +
137 + # Start just the Repo (and its deps) for the duration of `fun`, then
138 + # shut it back down — the same trick `migrate/0` uses, so these helpers
139 + # work under `eval` on a node that isn't running the full app.
140 + defp with_started_repo(fun) do
141 + load_app()
142 + {:ok, result, _apps} = Ecto.Migrator.with_repo(OhMyExOhIrcd.Repo, fn _repo -> fun.() end)
143 + result
144 + end
145 +
21 146 defp repos do
22 147 Application.fetch_env!(@app, :ecto_repos)
23 148 end

Parents: bbe48e1