neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
proxy protocol
e3999f1 · gmorell · 2026-05-21 04:06
Files changed
modified
config/config.exs
+7
−0
@@ -107,6 +107,13 @@ config :oh_my_ex_oh_ircd, OhMyExOhIrcd.IRC.Listener,
| 107 | 107 | # before the connection is killed. The bucket drains at 1 sec/sec. |
| 108 | 108 | flood_threshold_seconds: 20, |
| 109 | 109 | flood_penalty_default: 1.0, |
| 110 | + # When `true`, the listener expects each connection to start with a | |
| 111 | + # PROXY v1 or v2 header (Traefik / HAProxy / AWS NLB-style). The real | |
| 112 | + # client IP from the header replaces what `peername/0` reports — i.e. | |
| 113 | + # `state.host` (used in WHOIS, ban masks, K-lines, flood IDs). | |
| 114 | + # Default `false` so direct TCP keeps working. Flip via | |
| 115 | + # `IRC_PROXY_PROTOCOL=true` at runtime. | |
| 116 | + proxy_protocol: false, | |
| 110 | 117 | motd: [ |
| 111 | 118 | "Welcome to OhMyExOhIRC.", |
| 112 | 119 | "", |
modified
config/runtime.exs
+8
−0
@@ -40,6 +40,14 @@ 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 | +# Expect a PROXY v1/v2 header on every connection. Required when the | |
| 44 | +# IRC pod is behind a TCP proxy (Traefik IngressRouteTCP, HAProxy, AWS | |
| 45 | +# NLB...) — otherwise every client looks like the proxy's IP. | |
| 46 | +if pp = System.get_env("IRC_PROXY_PROTOCOL") do | |
| 47 | + config :oh_my_ex_oh_ircd, OhMyExOhIrcd.IRC.Listener, | |
| 48 | + proxy_protocol: pp in ~w(1 true yes on) | |
| 49 | +end | |
| 50 | + | |
| 43 | 51 | # Anonymous IRC access — default ON in prod releases (set |
| 44 | 52 | # IRC_ALLOW_ANONYMOUS=false to lock it down). Dev/test compile-time |
| 45 | 53 | # config wins outside prod. |
modified
deploy/argo/neiam-ircd/app.yml
+8
−0
@@ -54,6 +54,10 @@ spec:
| 54 | 54 | - { name: IRC_TLS_PORT, value: "6697" } |
| 55 | 55 | - { name: IRC_TLS_CERT_PATH, value: "/etc/tls/tls.crt" } |
| 56 | 56 | - { name: IRC_TLS_KEY_PATH, value: "/etc/tls/tls.key" } |
| 57 | + # PROXY v2 from Traefik — required so we see the real client IP | |
| 58 | + # instead of Traefik's pod IP. Matched by the IngressRouteTCP | |
| 59 | + # service `proxyProtocol.version: 2` setting below. | |
| 60 | + - { name: IRC_PROXY_PROTOCOL, value: "true" } | |
| 57 | 61 | |
| 58 | 62 | # BEAM cluster |
| 59 | 63 | - { name: CLUSTER_STRATEGY, value: "kubernetes" } |
@@ -189,6 +193,8 @@ spec:
| 189 | 193 | services: |
| 190 | 194 | - name: ircd-irc |
| 191 | 195 | port: 6667 |
| 196 | + proxyProtocol: | |
| 197 | + version: 2 | |
| 192 | 198 | --- |
| 193 | 199 | # Traefik TLS-passthrough for IRC-over-TLS. Termination happens inside |
| 194 | 200 | # the IRC pod using the cert-manager-issued cert. Add to Traefik: |
@@ -208,5 +214,7 @@ spec:
| 208 | 214 | services: |
| 209 | 215 | - name: ircd-irc |
| 210 | 216 | port: 6697 |
| 217 | + proxyProtocol: | |
| 218 | + version: 2 | |
| 211 | 219 | tls: |
| 212 | 220 | passthrough: true |
modified
lib/oh_my_ex_oh_ircd/irc/connection.ex
+33
−3
@@ -82,12 +82,12 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 82 | 82 | @impl ThousandIsland.Handler |
| 83 | 83 | def handle_connection(socket, opts) do |
| 84 | 84 | {:ok, {ip, _port}} = ThousandIsland.Socket.peername(socket) |
| 85 | − host = ip |> :inet.ntoa() |> List.to_string() | |
| 85 | + peer_host = ip |> :inet.ntoa() |> List.to_string() | |
| 86 | 86 | |
| 87 | 87 | now = System.system_time(:second) |
| 88 | 88 | |
| 89 | 89 | state = %State{ |
| 90 | − host: host, | |
| 90 | + host: peer_host, | |
| 91 | 91 | server_name: opts.server_name, |
| 92 | 92 | network_name: opts.network_name, |
| 93 | 93 | allow_anonymous: Map.get(opts, :allow_anonymous, false), |
@@ -102,7 +102,37 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | Phoenix.PubSub.subscribe(@pubsub, OhMyExOhIrcd.Cluster.NodeListener.topic()) |
| 105 | − {:continue, state} | |
| 105 | + | |
| 106 | + # If `proxy_protocol: true` is set on the listener, we expect Traefik / | |
| 107 | + # HAProxy / etc. to inject a PROXY v1/v2 header as the first bytes of | |
| 108 | + # every connection. Replace `state.host` with the real client IP and | |
| 109 | + # stash any extra bytes back into the data buffer so handle_data sees | |
| 110 | + # them on its first invocation. | |
| 111 | + if Map.get(opts, :proxy_protocol, false) do | |
| 112 | + handle_proxy_header(socket, state) | |
| 113 | + else | |
| 114 | + {:continue, state} | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 118 | + defp handle_proxy_header(socket, state) do | |
| 119 | + case OhMyExOhIrcd.IRC.Proxy.read_header(socket, 5_000) do | |
| 120 | + {:ok, real_ip, leftover} -> | |
| 121 | + {:continue, %{state | host: real_ip, buffer: leftover}} | |
| 122 | + | |
| 123 | + :no_header -> | |
| 124 | + # No header arrived. With proxy_protocol enabled this is most | |
| 125 | + # likely a direct (non-proxied) connection — common during health | |
| 126 | + # checks. Fail closed so we don't surface a misleading peer IP. | |
| 127 | + require Logger | |
| 128 | + Logger.warning("PROXY protocol enabled but first bytes weren't a header; dropping") | |
| 129 | + {:close, state} | |
| 130 | + | |
| 131 | + {:error, reason} -> | |
| 132 | + require Logger | |
| 133 | + Logger.warning("PROXY header parse failed: #{inspect(reason)}; dropping") | |
| 134 | + {:close, state} | |
| 135 | + end | |
| 106 | 136 | end |
| 107 | 137 | |
| 108 | 138 | @impl ThousandIsland.Handler |
modified
lib/oh_my_ex_oh_ircd/irc/listener.ex
+2
−1
@@ -23,7 +23,8 @@ defmodule OhMyExOhIrcd.IRC.Listener do
| 23 | 23 | ping_interval_ms: Keyword.get(cfg, :ping_interval_ms, 120_000), |
| 24 | 24 | ping_timeout_ms: Keyword.get(cfg, :ping_timeout_ms, 60_000), |
| 25 | 25 | flood_threshold_seconds: Keyword.get(cfg, :flood_threshold_seconds, 10), |
| 26 | − flood_penalty_default: Keyword.get(cfg, :flood_penalty_default, 1.0) | |
| 26 | + flood_penalty_default: Keyword.get(cfg, :flood_penalty_default, 1.0), | |
| 27 | + proxy_protocol: Keyword.get(cfg, :proxy_protocol, false) | |
| 27 | 28 | } |
| 28 | 29 | ) |
| 29 | 30 | end |
added
lib/oh_my_ex_oh_ircd/irc/proxy.ex
+117
−0
@@ -0,0 +1,117 @@
| 1 | +defmodule OhMyExOhIrcd.IRC.Proxy do | |
| 2 | + @moduledoc """ | |
| 3 | + Minimal PROXY-protocol v1 + v2 parser. | |
| 4 | + | |
| 5 | + When we sit behind a TCP proxy (Traefik, HAProxy, an ELB with PROXY | |
| 6 | + enabled, ...) the proxy injects a small header at the start of every | |
| 7 | + TCP connection containing the original client IP/port. Without | |
| 8 | + parsing that header the IRC pod sees the proxy's pod IP for every | |
| 9 | + user — useless for WHOIS, ban masks, K-lines, and rate limiting. | |
| 10 | + | |
| 11 | + Returned by `read_header/2`: | |
| 12 | + | |
| 13 | + {:ok, src_ip_string, leftover_bytes} | |
| 14 | + -- parsed cleanly; any bytes already read past the header are | |
| 15 | + -- handed back so the caller can replay them into the normal | |
| 16 | + -- handler buffer. | |
| 17 | + | |
| 18 | + :no_header | |
| 19 | + -- the bytes that arrived don't look like a PROXY header at all. | |
| 20 | + -- Up to the caller whether to fail closed or treat as a | |
| 21 | + -- direct connection. | |
| 22 | + | |
| 23 | + {:error, reason} | |
| 24 | + -- the bytes look like an attempt at PROXY but parsing failed | |
| 25 | + -- (malformed, truncated, ...). Caller should drop. | |
| 26 | + """ | |
| 27 | + | |
| 28 | + @v2_sig <<0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A>> | |
| 29 | + | |
| 30 | + @doc """ | |
| 31 | + Reads the PROXY header from `socket` synchronously (up to ~107 bytes | |
| 32 | + for v1 / variable for v2) and returns the parsed client IP plus any | |
| 33 | + bytes already read past the end of the header. | |
| 34 | + """ | |
| 35 | + @spec read_header(any(), pos_integer()) :: | |
| 36 | + {:ok, String.t(), binary()} | :no_header | {:error, term()} | |
| 37 | + def read_header(socket, timeout_ms \\ 2_000) do | |
| 38 | + # Peek at the first byte(s) to decide v1 vs v2. recv/3 returns whatever | |
| 39 | + # ThousandIsland's transport has buffered; we may get more than the | |
| 40 | + # header in one call, that's fine — we hand the leftover back. | |
| 41 | + case ThousandIsland.Socket.recv(socket, 0, timeout_ms) do | |
| 42 | + {:ok, data} -> parse(data) | |
| 43 | + {:error, reason} -> {:error, reason} | |
| 44 | + end | |
| 45 | + end | |
| 46 | + | |
| 47 | + @doc """ | |
| 48 | + Parse a PROXY header from an in-memory binary. Public so it can be | |
| 49 | + unit-tested without a real socket. | |
| 50 | + """ | |
| 51 | + @spec parse(binary()) :: {:ok, String.t(), binary()} | :no_header | {:error, term()} | |
| 52 | + def parse(<<@v2_sig, rest::binary>>), do: parse_v2(rest) | |
| 53 | + def parse("PROXY " <> rest), do: parse_v1(rest) | |
| 54 | + def parse(_), do: :no_header | |
| 55 | + | |
| 56 | + ## v1 (text) | |
| 57 | + | |
| 58 | + defp parse_v1(rest) do | |
| 59 | + case :binary.split(rest, "\r\n", []) do | |
| 60 | + [header, leftover] -> | |
| 61 | + case String.split(header, " ") do | |
| 62 | + ["TCP4", src_ip, _dst, _sp, _dp] -> {:ok, src_ip, leftover} | |
| 63 | + ["TCP6", src_ip, _dst, _sp, _dp] -> {:ok, src_ip, leftover} | |
| 64 | + ["UNKNOWN" | _] -> {:ok, "unknown", leftover} | |
| 65 | + _ -> {:error, :malformed_v1} | |
| 66 | + end | |
| 67 | + | |
| 68 | + _ -> | |
| 69 | + {:error, :truncated_v1} | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + ## v2 (binary) | |
| 74 | + | |
| 75 | + defp parse_v2(<<ver_cmd, fam_proto, addr_len::16-big, rest::binary>>) do | |
| 76 | + version = Bitwise.bsr(ver_cmd, 4) | |
| 77 | + command = Bitwise.band(ver_cmd, 0x0F) | |
| 78 | + | |
| 79 | + cond do | |
| 80 | + version != 0x2 -> | |
| 81 | + {:error, :bad_v2_version} | |
| 82 | + | |
| 83 | + byte_size(rest) < addr_len -> | |
| 84 | + {:error, :truncated_v2} | |
| 85 | + | |
| 86 | + true -> | |
| 87 | + <<addrs::binary-size(addr_len), leftover::binary>> = rest | |
| 88 | + # command == 0x00 → LOCAL (proxy's own healthcheck) — no real client | |
| 89 | + case command do | |
| 90 | + 0x0 -> {:ok, "local-healthcheck", leftover} | |
| 91 | + 0x1 -> {:ok, extract_v2_src(fam_proto, addrs), leftover} | |
| 92 | + _ -> {:error, :unknown_v2_command} | |
| 93 | + end | |
| 94 | + end | |
| 95 | + end | |
| 96 | + | |
| 97 | + defp parse_v2(_), do: {:error, :truncated_v2_header} | |
| 98 | + | |
| 99 | + # fam_proto: high nibble = family (1=IPv4, 2=IPv6, 3=UNIX), low nibble = proto. | |
| 100 | + defp extract_v2_src(0x11, <<a, b, c, d, _da, _db, _dc, _dd, _sp::16, _dp::16, _::binary>>), | |
| 101 | + do: "#{a}.#{b}.#{c}.#{d}" | |
| 102 | + | |
| 103 | + defp extract_v2_src(0x12, <<a, b, c, d, _::binary>>), do: "#{a}.#{b}.#{c}.#{d}" | |
| 104 | + | |
| 105 | + defp extract_v2_src(0x21, <<src::binary-size(16), _dst::binary-size(16), _sp::16, _dp::16, _::binary>>), | |
| 106 | + do: ipv6_to_string(src) | |
| 107 | + | |
| 108 | + defp extract_v2_src(0x22, <<src::binary-size(16), _::binary>>), do: ipv6_to_string(src) | |
| 109 | + | |
| 110 | + defp extract_v2_src(_, _), do: "unknown" | |
| 111 | + | |
| 112 | + defp ipv6_to_string(<<a::16, b::16, c::16, d::16, e::16, f::16, g::16, h::16>>) do | |
| 113 | + [a, b, c, d, e, f, g, h] | |
| 114 | + |> Enum.map_join(":", &Integer.to_string(&1, 16)) | |
| 115 | + |> String.downcase() | |
| 116 | + end | |
| 117 | +end |
modified
lib/oh_my_ex_oh_ircd/irc/tls_listener.ex
+2
−1
@@ -70,7 +70,8 @@ defmodule OhMyExOhIrcd.IRC.TLSListener do
| 70 | 70 | ping_interval_ms: Keyword.get(listener_cfg, :ping_interval_ms, 120_000), |
| 71 | 71 | ping_timeout_ms: Keyword.get(listener_cfg, :ping_timeout_ms, 60_000), |
| 72 | 72 | flood_threshold_seconds: Keyword.get(listener_cfg, :flood_threshold_seconds, 10), |
| 73 | − flood_penalty_default: Keyword.get(listener_cfg, :flood_penalty_default, 1.0) | |
| 73 | + flood_penalty_default: Keyword.get(listener_cfg, :flood_penalty_default, 1.0), | |
| 74 | + proxy_protocol: Keyword.get(listener_cfg, :proxy_protocol, false) | |
| 74 | 75 | } |
| 75 | 76 | ) |
| 76 | 77 | end |
modified
lib/oh_my_ex_oh_ircd_web/live/home_live.ex
+26
−8
@@ -29,6 +29,11 @@ defmodule OhMyExOhIrcdWeb.HomeLive do
| 29 | 29 | tls_enabled? = Keyword.get(tls_cfg, :enabled, false) |
| 30 | 30 | tls_port = Keyword.get(tls_cfg, :port, 6697) |
| 31 | 31 | |
| 32 | + # Prefer the TLS port in the snippets when it's available — irssi / | |
| 33 | + # weechat just need a flag flip. The plain port is still shown in the | |
| 34 | + # status badge above the snippets so power users can pick. | |
| 35 | + snippet_port = if tls_enabled?, do: tls_port, else: port | |
| 36 | + | |
| 32 | 37 | socket |
| 33 | 38 | |> assign(:server_name, info.name) |
| 34 | 39 | |> assign(:network_name, info.network) |
@@ -40,23 +45,29 @@ defmodule OhMyExOhIrcdWeb.HomeLive do
| 40 | 45 | |> assign(:channel_count, Stats.channel_count()) |
| 41 | 46 | |> assign(:user_count, Stats.user_count()) |
| 42 | 47 | |> assign(:node_count, 1 + length(Node.list())) |
| 43 | − |> assign(:irssi_snippet, irssi_snippet(info.network, info.name, port)) | |
| 44 | − |> assign(:weechat_snippet, weechat_snippet(info.name, port)) | |
| 48 | + |> assign(:irssi_snippet, irssi_snippet(info.network, info.name, snippet_port, tls_enabled?)) | |
| 49 | + |> assign(:weechat_snippet, weechat_snippet(info.name, snippet_port, tls_enabled?)) | |
| 45 | 50 | |> assign(:pass_snippet, pass_snippet()) |
| 46 | 51 | end |
| 47 | 52 | |
| 48 | − defp irssi_snippet(network, host, port) do | |
| 53 | + defp irssi_snippet(network, host, port, tls?) do | |
| 54 | + tls_flag = if tls?, do: "-tls -tls_verify ", else: "" | |
| 55 | + | |
| 49 | 56 | """ |
| 50 | 57 | /network add -sasl_mechanism PLAIN -sasl_username you@example.com -sasl_password yourpassword #{network} |
| 51 | − /server add -auto -network #{network} #{host} #{port} | |
| 58 | + /server add -auto #{tls_flag}-network #{network} #{host} #{port} | |
| 52 | 59 | /connect #{network}\ |
| 53 | 60 | """ |
| 54 | 61 | end |
| 55 | 62 | |
| 56 | − defp weechat_snippet(host, port) do | |
| 63 | + defp weechat_snippet(host, port, tls?) do | |
| 64 | + sep = if tls?, do: "/", else: "/" | |
| 65 | + suffix = if tls?, do: " -ssl", else: "" | |
| 66 | + tls_verify_line = if tls?, do: "/set irc.server.omx.ssl_verify on\n", else: "" | |
| 67 | + | |
| 57 | 68 | """ |
| 58 | − /server add omx #{host}/#{port} | |
| 59 | − /set irc.server.omx.sasl_mechanism plain | |
| 69 | + /server add omx #{host}#{sep}#{port}#{suffix} | |
| 70 | + #{tls_verify_line}/set irc.server.omx.sasl_mechanism plain | |
| 60 | 71 | /set irc.server.omx.sasl_username you@example.com |
| 61 | 72 | /set irc.server.omx.sasl_password yourpassword |
| 62 | 73 | /connect omx\ |
@@ -128,7 +139,14 @@ defmodule OhMyExOhIrcdWeb.HomeLive do
| 128 | 139 | </div> |
| 129 | 140 | |
| 130 | 141 | <details class="rounded-box border border-base-300 bg-base-200 p-4 text-sm" open> |
| 131 | − <summary class="cursor-pointer font-semibold">SASL PLAIN (recommended)</summary> | |
| 142 | + <summary class="cursor-pointer font-semibold"> | |
| 143 | + SASL PLAIN (recommended) — | |
| 144 | + <%= if @tls_enabled do %> | |
| 145 | + connects over TLS on {@tls_port} | |
| 146 | + <% else %> | |
| 147 | + plain port {@port} | |
| 148 | + <% end %> | |
| 149 | + </summary> | |
| 132 | 150 | <div class="mt-3 space-y-3"> |
| 133 | 151 | <div> |
| 134 | 152 | <p class="text-xs uppercase tracking-wider text-base-content/60 mb-1">irssi</p> |
added
test/oh_my_ex_oh_ircd/irc/proxy_test.exs
+61
−0
@@ -0,0 +1,61 @@
| 1 | +defmodule OhMyExOhIrcd.IRC.ProxyTest do | |
| 2 | + use ExUnit.Case, async: true | |
| 3 | + | |
| 4 | + alias OhMyExOhIrcd.IRC.Proxy | |
| 5 | + | |
| 6 | + @v2_sig <<0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A>> | |
| 7 | + | |
| 8 | + describe "v1" do | |
| 9 | + test "parses a TCP4 header and returns leftover" do | |
| 10 | + blob = "PROXY TCP4 198.51.100.7 10.0.0.1 56324 6697\r\nNICK alice\r\n" | |
| 11 | + assert {:ok, "198.51.100.7", leftover} = Proxy.parse(blob) | |
| 12 | + assert leftover == "NICK alice\r\n" | |
| 13 | + end | |
| 14 | + | |
| 15 | + test "TCP6" do | |
| 16 | + blob = "PROXY TCP6 2001:db8::1 fd00::2 1234 6667\r\n" | |
| 17 | + assert {:ok, "2001:db8::1", ""} = Proxy.parse(blob) | |
| 18 | + end | |
| 19 | + | |
| 20 | + test "UNKNOWN" do | |
| 21 | + blob = "PROXY UNKNOWN\r\n" | |
| 22 | + assert {:ok, "unknown", ""} = Proxy.parse(blob) | |
| 23 | + end | |
| 24 | + | |
| 25 | + test "truncated v1 errors" do | |
| 26 | + assert {:error, :truncated_v1} = Proxy.parse("PROXY TCP4 1.2.3.4 5.6.7.8 1 2") | |
| 27 | + end | |
| 28 | + end | |
| 29 | + | |
| 30 | + describe "v2" do | |
| 31 | + test "parses an IPv4 TCP header" do | |
| 32 | + addr = | |
| 33 | + <<198::8, 51::8, 100::8, 7::8, 10::8, 0::8, 0::8, 1::8, 56_324::16-big, 6697::16-big>> | |
| 34 | + | |
| 35 | + header = @v2_sig <> <<0x21>> <> <<0x11>> <> <<byte_size(addr)::16-big>> <> addr | |
| 36 | + blob = header <> "NICK alice\r\n" | |
| 37 | + | |
| 38 | + assert {:ok, "198.51.100.7", "NICK alice\r\n"} = Proxy.parse(blob) | |
| 39 | + end | |
| 40 | + | |
| 41 | + test "LOCAL command (healthcheck) is reported with a synthetic IP" do | |
| 42 | + header = @v2_sig <> <<0x20>> <> <<0x00>> <> <<0::16-big>> | |
| 43 | + assert {:ok, "local-healthcheck", ""} = Proxy.parse(header) | |
| 44 | + end | |
| 45 | + | |
| 46 | + test "truncated v2 errors" do | |
| 47 | + header = @v2_sig <> <<0x21>> <> <<0x11>> <> <<100::16-big>> <> "\x01\x02\x03" | |
| 48 | + assert {:error, :truncated_v2} = Proxy.parse(header) | |
| 49 | + end | |
| 50 | + end | |
| 51 | + | |
| 52 | + describe "fall-through" do | |
| 53 | + test "non-PROXY data returns :no_header" do | |
| 54 | + assert :no_header = Proxy.parse("NICK foo\r\n") | |
| 55 | + end | |
| 56 | + | |
| 57 | + test "PROXY without space returns :no_header" do | |
| 58 | + assert :no_header = Proxy.parse("PROXYish") | |
| 59 | + end | |
| 60 | + end | |
| 61 | +end |
Parents: 1bba52e