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    // `PROXYSQL INTERNAL SESSION` is intercepted by ProxySQL on its client
159    // port (6033) and returns a single-column JSON row describing the
160    // proxy session; vanilla MySQL and MariaDB return SQL syntax error
161    // 1064.  We use `query_drop` so we don't have to model the response
162    // shape — any `Ok` indicates ProxySQL accepted the command.
163    //
164    // (`PROXYSQL VERSION` exists too but is only accepted on the admin
165    // port 6032, not on the client port we connect through.)
166    let proxysql_internal_accepted: bool = conn.query_drop("PROXYSQL INTERNAL SESSION").is_ok();
167    let version_comment: Option<String> =
168        conn.query_first("SELECT @@version_comment").unwrap_or(None);
169    let proxy_version: Option<String> = conn.query_first("SELECT @@proxy_version").unwrap_or(None);
170    // CONNECTION_ID() is server-side: comparing two consecutive calls on the
171    // same `Conn` detects transaction-mode multiplexers that hand each
172    // statement to a different backend connection.
173    let cid1: Option<u64> = conn.query_first("SELECT CONNECTION_ID()").unwrap_or(None);
174    let cid2: Option<u64> = conn.query_first("SELECT CONNECTION_ID()").unwrap_or(None);
175    let pair = match (cid1, cid2) {
176        (Some(a), Some(b)) => Some((a, b)),
177        _ => None,
178    };
179    classify_mysql_proxy(
180        proxysql_internal_accepted,
181        version_comment.as_deref(),
182        proxy_version.as_deref(),
183        pair,
184    )
185}
186
187/// Emit the one-time connect-time warning for a non-direct proxy kind.
188/// Centralized so the wording stays consistent across the three connect entry
189/// points (`from_pool`, `connect`, `connect_with_tls`).
190pub(super) fn warn_proxy_kind(kind: MysqlProxyKind) {
191    if let Some(msg) = kind.warn_message() {
192        log::warn!("{msg}");
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::{MysqlProxyKind, classify_mysql_proxy};
199
200    #[test]
201    fn proxy_classify_direct_when_no_signals() {
202        let kind = classify_mysql_proxy(
203            false,
204            Some("MySQL Community Server - GPL"),
205            None,
206            Some((42, 42)),
207        );
208        assert_eq!(kind, MysqlProxyKind::Direct);
209    }
210
211    #[test]
212    fn proxy_classify_direct_when_all_signals_missing() {
213        let kind = classify_mysql_proxy(false, None, None, None);
214        assert_eq!(kind, MysqlProxyKind::Direct);
215    }
216
217    #[test]
218    fn proxy_classify_proxysql_via_internal_command() {
219        let kind = classify_mysql_proxy(
220            true,
221            Some("MySQL Community Server - GPL"),
222            None,
223            Some((7, 7)),
224        );
225        assert_eq!(kind, MysqlProxyKind::ProxySql);
226    }
227
228    #[test]
229    fn proxy_classify_proxysql_via_banner() {
230        let kind = classify_mysql_proxy(
231            false,
232            Some("(ProxySQL) High Performance MySQL Proxy"),
233            None,
234            None,
235        );
236        assert_eq!(kind, MysqlProxyKind::ProxySql);
237    }
238
239    #[test]
240    fn proxy_classify_proxysql_via_banner_lowercase() {
241        let kind = classify_mysql_proxy(false, Some("(proxysql) hpmp"), None, None);
242        assert_eq!(kind, MysqlProxyKind::ProxySql);
243    }
244
245    #[test]
246    fn proxy_classify_proxysql_via_proxy_version_only() {
247        let kind = classify_mysql_proxy(
248            false,
249            Some("MySQL Community Server - GPL"),
250            Some("2.5.5-percona-1.1"),
251            Some((1, 1)),
252        );
253        assert_eq!(kind, MysqlProxyKind::ProxySql);
254    }
255
256    #[test]
257    fn proxy_classify_maxscale_via_banner() {
258        let kind = classify_mysql_proxy(
259            false,
260            Some("MariaDB MaxScale 22.08.4-ge6a8d35ec source distribution"),
261            None,
262            Some((1, 1)),
263        );
264        assert_eq!(kind, MysqlProxyKind::MaxScale);
265    }
266
267    #[test]
268    fn proxy_classify_proxysql_internal_takes_precedence_over_banner_maxscale() {
269        let kind = classify_mysql_proxy(
270            true,
271            Some("MariaDB MaxScale 22.08.4 source distribution"),
272            None,
273            None,
274        );
275        assert_eq!(kind, MysqlProxyKind::ProxySql);
276    }
277
278    #[test]
279    fn proxy_classify_proxysql_takes_precedence_over_maxscale_in_banner() {
280        let kind = classify_mysql_proxy(
281            false,
282            Some("ProxySQL bridging MaxScale upstream"),
283            None,
284            None,
285        );
286        assert_eq!(kind, MysqlProxyKind::ProxySql);
287    }
288
289    #[test]
290    fn proxy_classify_multiplexed_via_connection_id_drift() {
291        let kind = classify_mysql_proxy(
292            false,
293            Some("MySQL Community Server"),
294            None,
295            Some((100, 200)),
296        );
297        assert_eq!(kind, MysqlProxyKind::Multiplexed);
298    }
299
300    #[test]
301    fn proxy_classify_direct_when_connection_id_pair_missing() {
302        let kind = classify_mysql_proxy(false, Some("MySQL Community Server"), None, None);
303        assert_eq!(kind, MysqlProxyKind::Direct);
304    }
305
306    #[test]
307    fn proxy_classify_direct_when_connection_ids_match() {
308        let kind = classify_mysql_proxy(false, Some("MySQL Community Server"), None, Some((7, 7)));
309        assert_eq!(kind, MysqlProxyKind::Direct);
310    }
311
312    #[test]
313    fn proxy_classify_proxysql_pool_size_one_still_caught_by_banner() {
314        let kind = classify_mysql_proxy(false, Some("(ProxySQL) HPMP"), None, Some((5, 5)));
315        assert_eq!(kind, MysqlProxyKind::ProxySql);
316    }
317
318    #[test]
319    fn proxy_classify_proxysql_default_config_still_detected_via_internal() {
320        let kind = classify_mysql_proxy(
321            true,
322            Some("MySQL Community Server - GPL"),
323            None,
324            Some((42, 42)),
325        );
326        assert_eq!(
327            kind,
328            MysqlProxyKind::ProxySql,
329            "default-config ProxySQL must be detectable via PROXYSQL INTERNAL signal alone"
330        );
331    }
332
333    // ── Warning / label contract ────────────────────────────────────────
334
335    #[test]
336    fn proxy_kind_is_proxy_helper_matches_variants() {
337        assert!(!MysqlProxyKind::Direct.is_proxy());
338        assert!(MysqlProxyKind::ProxySql.is_proxy());
339        assert!(MysqlProxyKind::MaxScale.is_proxy());
340        assert!(MysqlProxyKind::Multiplexed.is_proxy());
341    }
342
343    #[test]
344    fn proxy_kind_direct_has_no_warning() {
345        assert!(MysqlProxyKind::Direct.warn_message().is_none());
346    }
347
348    #[test]
349    fn proxy_kind_non_direct_variants_have_warnings() {
350        for k in [
351            MysqlProxyKind::ProxySql,
352            MysqlProxyKind::MaxScale,
353            MysqlProxyKind::Multiplexed,
354        ] {
355            assert!(
356                k.warn_message().is_some(),
357                "{k:?} must emit a warning at connect time"
358            );
359        }
360    }
361
362    #[test]
363    fn proxy_kind_log_labels_are_stable_and_distinct() {
364        let labels = [
365            MysqlProxyKind::Direct.log_label(),
366            MysqlProxyKind::ProxySql.log_label(),
367            MysqlProxyKind::MaxScale.log_label(),
368            MysqlProxyKind::Multiplexed.log_label(),
369        ];
370        assert_eq!(
371            labels,
372            ["direct", "proxysql", "maxscale", "proxy-multiplexed"]
373        );
374    }
375}