Skip to main content

rivet/source/mysql/
proxy.rs

1//! MySQL connection-proxy classifier — distinguishes direct connections from
2//! ProxySQL / MaxScale / generic multiplexers.
3//!
4//! Runs once at connect time so the operator gets a one-line warning when a
5//! proxy is in front of the database (session vars and temporary state may
6//! not survive multiplexing).  See [`classify_mysql_proxy`] for the
7//! detection precedence; it is a pure function and exhaustively
8//! unit-tested in this file.  The I/O wrapper [`detect_mysql_proxy_kind`]
9//! collects the live signals and delegates.
10
11use mysql::Pool;
12use mysql::prelude::*;
13
14/// What the MySQL connection is actually talking to.
15///
16/// Used to:
17/// - decide which warning (if any) to print at connect time,
18/// - tag the `executing query (connection=...)` debug log so operators can
19///   distinguish direct vs proxied traffic when reading logs after the fact.
20///
21/// Detection happens once at connect time via [`detect_mysql_proxy_kind`].
22///
23/// `pub` for integration-test reachability via `MysqlSource::proxy_kind()`;
24/// same "no external API contract" disclaimer applies as for the rest of
25/// `rivet::source::mysql::*`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MysqlProxyKind {
28    /// Direct connection to a MySQL server — no proxy detected.
29    Direct,
30    /// ProxySQL: detected via either the `@@version_comment` signature or
31    /// `@@proxy_version` (a ProxySQL-only system variable).
32    ProxySql,
33    /// MariaDB MaxScale: detected via `@@version_comment` containing
34    /// "maxscale".  MaxScale's `readwritesplit` and `readconnroute` routers
35    /// do *not* multiplex by default but they can still rewrite or block
36    /// queries — worth surfacing to the operator.
37    MaxScale,
38    /// An unknown transaction-mode multiplexer: detected because
39    /// `CONNECTION_ID()` returned different values across two consecutive
40    /// queries on the same `mysql::Conn`.  This catches in-house balancers,
41    /// HAProxy-with-MySQL-mode setups, and ProxySQL/MaxScale instances that
42    /// hide their banner.
43    ///
44    /// False negatives are possible when the proxy's backend pool_size is 1
45    /// (the same physical backend is always reused).
46    Multiplexed,
47}
48
49impl MysqlProxyKind {
50    /// True for any non-direct connection (`is_proxy() == false` only for
51    /// [`MysqlProxyKind::Direct`]).
52    ///
53    /// `#[allow(dead_code)]` because the binary compilation unit (which
54    /// re-declares `mod source`) does not reference this; the lib + tests do.
55    /// Same pattern as `MysqlSource::from_pool`.
56    #[allow(dead_code)]
57    pub fn is_proxy(self) -> bool {
58        !matches!(self, MysqlProxyKind::Direct)
59    }
60
61    /// Stable label for the `executing query (connection=...)` debug log.
62    /// Keep this terse and stable: external log parsers grep on these strings.
63    pub fn log_label(self) -> &'static str {
64        match self {
65            MysqlProxyKind::Direct => "direct",
66            MysqlProxyKind::ProxySql => "proxysql",
67            MysqlProxyKind::MaxScale => "maxscale",
68            MysqlProxyKind::Multiplexed => "proxy-multiplexed",
69        }
70    }
71
72    /// One-time warning emitted at connect time.  Returns `None` for
73    /// [`MysqlProxyKind::Direct`] (the common case, no warning needed).
74    fn warn_message(self) -> Option<&'static str> {
75        match self {
76            MysqlProxyKind::Direct => None,
77            MysqlProxyKind::ProxySql => Some(
78                "MySQL proxy multiplexer detected (ProxySQL) — session variables \
79                 set per-connection may not survive multiplexing; use direct connections \
80                 for production exports",
81            ),
82            MysqlProxyKind::MaxScale => Some(
83                "MySQL proxy detected (MaxScale) — queries may be rewritten or routed; \
84                 verify SQL is accepted by the active MaxScale router (readwritesplit, \
85                 readconnroute) and that session timeouts apply on the backend",
86            ),
87            MysqlProxyKind::Multiplexed => Some(
88                "MySQL connection multiplexing detected (CONNECTION_ID() differs across \
89                 queries) — session variables and temporary state may not persist across \
90                 statements; use direct connections for production exports",
91            ),
92        }
93    }
94}
95
96/// Pure classifier for proxy detection signals.  Kept separate from
97/// [`detect_mysql_proxy_kind`] so it can be exhaustively unit-tested without a
98/// live MySQL.  See [`MysqlProxyKind`] for the meaning of each variant.
99///
100/// Precedence is intentional:
101///
102/// 1. `PROXYSQL INTERNAL SESSION` accepted as a query (ProxySQL intercepts
103///    this command on its client port; vanilla MySQL returns a syntax
104///    error). This is the strongest signal because ProxySQL by default
105///    forwards `@@version_comment`, `VERSION()`, and `@@version` straight
106///    through to the backend, so a configured-as-default ProxySQL is
107///    invisible to banner checks.
108/// 2. Explicit banner match in `@@version_comment` (ProxySQL > MaxScale).
109///    Catches ProxySQL builds with `server_version` overridden to advertise
110///    "ProxySQL" in `VERSION()`, and MaxScale which puts "MaxScale" in the
111///    banner.
112/// 3. `@@proxy_version` presence (ProxySQL-only system variable when
113///    `mysql_query_rules` is configured to expose it).
114/// 4. `CONNECTION_ID()` differing across two queries (generic multiplexing
115///    fallback — catches HAProxy MySQL mode, custom balancers, etc.).
116///
117/// Banner-before-CONNECTION_ID order matters: a ProxySQL behind a
118/// `transaction_persistent` user (which keeps the same backend conn) would
119/// fool the CONNECTION_ID check, so the specific signal wins.
120fn classify_mysql_proxy(
121    proxysql_internal_accepted: bool,
122    version_comment: Option<&str>,
123    proxy_version: Option<&str>,
124    connection_id_pair: Option<(u64, u64)>,
125) -> MysqlProxyKind {
126    if proxysql_internal_accepted {
127        return MysqlProxyKind::ProxySql;
128    }
129    if let Some(v) = version_comment {
130        let l = v.to_ascii_lowercase();
131        if l.contains("proxysql") {
132            return MysqlProxyKind::ProxySql;
133        }
134        if l.contains("maxscale") {
135            return MysqlProxyKind::MaxScale;
136        }
137    }
138    if proxy_version.is_some() {
139        return MysqlProxyKind::ProxySql;
140    }
141    if let Some((a, b)) = connection_id_pair
142        && a != b
143    {
144        return MysqlProxyKind::Multiplexed;
145    }
146    MysqlProxyKind::Direct
147}
148
149/// I/O wrapper around [`classify_mysql_proxy`]: collects the detection
150/// signals from a live connection and returns the classification.  On any
151/// connection failure returns [`MysqlProxyKind::Direct`] — detection is
152/// best-effort and must never break a real export.
153pub(super) fn detect_mysql_proxy_kind(pool: &Pool) -> MysqlProxyKind {
154    let mut conn = match pool.get_conn() {
155        Ok(c) => c,
156        Err(_) => return MysqlProxyKind::Direct,
157    };
158    detect_proxy_on_conn(&mut conn)
159}
160
161/// The probe + classify on an already-open connection — shared by the batch pool
162/// path and the CDC path (which holds a bare `Conn`, not a `Pool`). A proxy that
163/// shows up here cannot carry the binlog/replication protocol CDC needs.
164pub(super) fn detect_proxy_on_conn<C: Queryable>(conn: &mut C) -> MysqlProxyKind {
165    // `PROXYSQL INTERNAL SESSION` is intercepted by ProxySQL on its client
166    // port (6033) and returns a single-column JSON row describing the
167    // proxy session; vanilla MySQL and MariaDB return SQL syntax error
168    // 1064.  We use `query_drop` so we don't have to model the response
169    // shape — any `Ok` indicates ProxySQL accepted the command.
170    //
171    // (`PROXYSQL VERSION` exists too but is only accepted on the admin
172    // port 6032, not on the client port we connect through.)
173    let proxysql_internal_accepted: bool = conn.query_drop("PROXYSQL INTERNAL SESSION").is_ok();
174    let version_comment: Option<String> =
175        conn.query_first("SELECT @@version_comment").unwrap_or(None);
176    let proxy_version: Option<String> = conn.query_first("SELECT @@proxy_version").unwrap_or(None);
177    // CONNECTION_ID() is server-side: comparing two consecutive calls on the
178    // same `Conn` detects transaction-mode multiplexers that hand each
179    // statement to a different backend connection.
180    let cid1: Option<u64> = conn.query_first("SELECT CONNECTION_ID()").unwrap_or(None);
181    let cid2: Option<u64> = conn.query_first("SELECT CONNECTION_ID()").unwrap_or(None);
182    let pair = match (cid1, cid2) {
183        (Some(a), Some(b)) => Some((a, b)),
184        _ => None,
185    };
186    classify_mysql_proxy(
187        proxysql_internal_accepted,
188        version_comment.as_deref(),
189        proxy_version.as_deref(),
190        pair,
191    )
192}
193
194/// Emit the one-time connect-time warning for a non-direct proxy kind.
195/// Centralized so the wording stays consistent across the three connect entry
196/// points (`from_pool`, `connect`, `connect_with_tls`).
197pub(super) fn warn_proxy_kind(kind: MysqlProxyKind) {
198    if let Some(msg) = kind.warn_message() {
199        log::warn!("{msg}");
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::{MysqlProxyKind, classify_mysql_proxy};
206
207    #[test]
208    fn proxy_classify_direct_when_no_signals() {
209        let kind = classify_mysql_proxy(
210            false,
211            Some("MySQL Community Server - GPL"),
212            None,
213            Some((42, 42)),
214        );
215        assert_eq!(kind, MysqlProxyKind::Direct);
216    }
217
218    #[test]
219    fn proxy_classify_direct_when_all_signals_missing() {
220        let kind = classify_mysql_proxy(false, None, None, None);
221        assert_eq!(kind, MysqlProxyKind::Direct);
222    }
223
224    #[test]
225    fn proxy_classify_proxysql_via_internal_command() {
226        let kind = classify_mysql_proxy(
227            true,
228            Some("MySQL Community Server - GPL"),
229            None,
230            Some((7, 7)),
231        );
232        assert_eq!(kind, MysqlProxyKind::ProxySql);
233    }
234
235    #[test]
236    fn proxy_classify_proxysql_via_banner() {
237        let kind = classify_mysql_proxy(
238            false,
239            Some("(ProxySQL) High Performance MySQL Proxy"),
240            None,
241            None,
242        );
243        assert_eq!(kind, MysqlProxyKind::ProxySql);
244    }
245
246    #[test]
247    fn proxy_classify_proxysql_via_banner_lowercase() {
248        let kind = classify_mysql_proxy(false, Some("(proxysql) hpmp"), None, None);
249        assert_eq!(kind, MysqlProxyKind::ProxySql);
250    }
251
252    #[test]
253    fn proxy_classify_proxysql_via_proxy_version_only() {
254        let kind = classify_mysql_proxy(
255            false,
256            Some("MySQL Community Server - GPL"),
257            Some("2.5.5-percona-1.1"),
258            Some((1, 1)),
259        );
260        assert_eq!(kind, MysqlProxyKind::ProxySql);
261    }
262
263    #[test]
264    fn proxy_classify_maxscale_via_banner() {
265        let kind = classify_mysql_proxy(
266            false,
267            Some("MariaDB MaxScale 22.08.4-ge6a8d35ec source distribution"),
268            None,
269            Some((1, 1)),
270        );
271        assert_eq!(kind, MysqlProxyKind::MaxScale);
272    }
273
274    #[test]
275    fn proxy_classify_proxysql_internal_takes_precedence_over_banner_maxscale() {
276        let kind = classify_mysql_proxy(
277            true,
278            Some("MariaDB MaxScale 22.08.4 source distribution"),
279            None,
280            None,
281        );
282        assert_eq!(kind, MysqlProxyKind::ProxySql);
283    }
284
285    #[test]
286    fn proxy_classify_proxysql_takes_precedence_over_maxscale_in_banner() {
287        let kind = classify_mysql_proxy(
288            false,
289            Some("ProxySQL bridging MaxScale upstream"),
290            None,
291            None,
292        );
293        assert_eq!(kind, MysqlProxyKind::ProxySql);
294    }
295
296    #[test]
297    fn proxy_classify_multiplexed_via_connection_id_drift() {
298        let kind = classify_mysql_proxy(
299            false,
300            Some("MySQL Community Server"),
301            None,
302            Some((100, 200)),
303        );
304        assert_eq!(kind, MysqlProxyKind::Multiplexed);
305    }
306
307    #[test]
308    fn proxy_classify_direct_when_connection_id_pair_missing() {
309        let kind = classify_mysql_proxy(false, Some("MySQL Community Server"), None, None);
310        assert_eq!(kind, MysqlProxyKind::Direct);
311    }
312
313    #[test]
314    fn proxy_classify_direct_when_connection_ids_match() {
315        let kind = classify_mysql_proxy(false, Some("MySQL Community Server"), None, Some((7, 7)));
316        assert_eq!(kind, MysqlProxyKind::Direct);
317    }
318
319    #[test]
320    fn proxy_classify_proxysql_pool_size_one_still_caught_by_banner() {
321        let kind = classify_mysql_proxy(false, Some("(ProxySQL) HPMP"), None, Some((5, 5)));
322        assert_eq!(kind, MysqlProxyKind::ProxySql);
323    }
324
325    #[test]
326    fn proxy_classify_proxysql_default_config_still_detected_via_internal() {
327        let kind = classify_mysql_proxy(
328            true,
329            Some("MySQL Community Server - GPL"),
330            None,
331            Some((42, 42)),
332        );
333        assert_eq!(
334            kind,
335            MysqlProxyKind::ProxySql,
336            "default-config ProxySQL must be detectable via PROXYSQL INTERNAL signal alone"
337        );
338    }
339
340    // ── Warning / label contract ────────────────────────────────────────
341
342    #[test]
343    fn proxy_kind_is_proxy_helper_matches_variants() {
344        assert!(!MysqlProxyKind::Direct.is_proxy());
345        assert!(MysqlProxyKind::ProxySql.is_proxy());
346        assert!(MysqlProxyKind::MaxScale.is_proxy());
347        assert!(MysqlProxyKind::Multiplexed.is_proxy());
348    }
349
350    #[test]
351    fn proxy_kind_direct_has_no_warning() {
352        assert!(MysqlProxyKind::Direct.warn_message().is_none());
353    }
354
355    #[test]
356    fn proxy_kind_non_direct_variants_have_warnings() {
357        for k in [
358            MysqlProxyKind::ProxySql,
359            MysqlProxyKind::MaxScale,
360            MysqlProxyKind::Multiplexed,
361        ] {
362            assert!(
363                k.warn_message().is_some(),
364                "{k:?} must emit a warning at connect time"
365            );
366        }
367    }
368
369    #[test]
370    fn proxy_kind_log_labels_are_stable_and_distinct() {
371        let labels = [
372            MysqlProxyKind::Direct.log_label(),
373            MysqlProxyKind::ProxySql.log_label(),
374            MysqlProxyKind::MaxScale.log_label(),
375            MysqlProxyKind::Multiplexed.log_label(),
376        ];
377        assert_eq!(
378            labels,
379            ["direct", "proxysql", "maxscale", "proxy-multiplexed"]
380        );
381    }
382}