proxy protocol

e3999f1 · gmorell · 2026-05-21 04:06

21 files +264 -13

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