neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
fix being able to add ops irl
6de4808 · gmorell · 2026-06-10 21:15
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