Rust in Everything
基于 sqlx 0.9.0

连接选项与建立连接

主线位置:连接池主线的 connect 那一步。池子没有空闲连接、又没到 max_connections 时,调 PgConnectOptions::connect,也就是本页的 PgConnection::establish;PgConnection::connect(url) 直接走的也是它。查询主线上用到的每一条 PgConnection,都是从这里造出来的。本页讲两件事:选项从哪来,以及从 TCP 连上到 ReadyForQuery 之间线上发生了什么。

sequenceDiagram
  participant C as PgConnection
  participant S as Postgres
  C->>S: SSLRequest(prefer 时)
  S-->>C: 一个字节 N
  C->>S: StartupMessage
  S-->>C: AuthenticationSASL
  C->>S: SASLInitialResponse
  S-->>C: SASLContinue 盐和次数
  C->>S: SASLResponse 带证明
  S-->>C: SASLFinal 服务端签名
  S-->>C: AuthenticationOk
  S-->>C: ParameterStatus 若干
  S-->>C: BackendKeyData
  S-->>C: ReadyForQuery

PgConnectOptions 的字段都在 options/mod.rs:主机、端口、Unix socket 目录、用户名、密码、库名、ssl_mode 和三个证书、statement_cache_capacity、application_name、日志设置、extra_float_digits、options(启动参数)。设置字段的 pub fn 都是按值收 self 再返回 Self 的 builder,另有八个 get_* 方法读回其中一部分(主机、端口、socket、用户、库、ssl_mode、application_name、options),没有读密码的。

选项从哪来

第一层:环境变量

new_without_pgpass 是一切的起点,它只读环境变量:

    pub fn new_without_pgpass() -> Self {
        let port = var("PGPORT")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(5432);

        let host = var("PGHOSTADDR")
            .ok()
            .or_else(|| var("PGHOST").ok())
            .unwrap_or_else(|| default_host(port));

        let username = if let Ok(username) = var("PGUSER") {
            username
        } else if let Ok(username) = whoami::username() {
            username
        } else {
            // keep the same fallback as previous version
            "unknown".to_string()
        };

        let database = var("PGDATABASE").ok();

        PgConnectOptions {
            port,
            host,
            socket: None,
            username,
            password: var("PGPASSWORD").ok(),
            // ...
            ssl_mode: var("PGSSLMODE")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or_default(),
            statement_cache_capacity: 100,
            application_name: var("PGAPPNAME").ok(),
            extra_float_digits: Some("2".into()),
            log_settings: Default::default(),
            options: var("PGOPTIONS").ok(),
        }
    }
字段环境变量没有时
hostPGHOSTADDR,其次 PGHOSTdefault_host:依次看 /var/run/postgresql、/private/tmp、/tmp 下有没有 .s.PGSQL.<端口>,有就用那个目录,否则 localhost
portPGPORT5432
usernamePGUSER当前操作系统用户名
passwordPGPASSWORD无
databasePGDATABASE无(服务端按用户名选库)
ssl_modePGSSLMODEPrefer
三个证书PGSSLROOTCERT、PGSSLCERT、PGSSLKEY无
application_namePGAPPNAME无
optionsPGOPTIONS无

statement_cache_capacity 和 extra_float_digits 没有环境变量。环境变量的值解析失败(PGPORT=abc、PGSSLMODE=xyz)时一声不吭地用默认值。PgConnectOptions::new() 和 Default 是 new_without_pgpass().apply_pgpass()。

第二层:URL

"postgres://...".parse() 走 parse_from_url,它的第一行是:

        let mut options = Self::new_without_pgpass();

也就是说,URL 不是从空白开始,而是覆盖在环境变量之上:URL 里写了的字段用 URL 的,没写的仍然是环境变量给的。路径是库名,用户名和密码做百分号解码,主机名以 /(URL 里写成 %2F)开头时当作 Unix socket 目录。查询参数认 sslmode、sslrootcert、sslcert、sslkey、statement-cache-capacity、host、hostaddr、port、dbname、user、password、application_name、options,以及 options[键]=值 这种写法。其他参数只打一条 tracing::warn!:

                _ => tracing::warn!(%key, %value, "ignoring unrecognized connect parameter"),

libpq 的 connect_timeout 就落在这里。sqlx 的连接超时归连接池管(acquire_timeout,见 PoolOptions 与默认值)。

options[search_path]=pg1 和 builder 方法 options 一样,拼成 -c search_path=pg1,空格和反斜杠按 libpq 的规则转义。它最后作为启动参数 options 发给服务端,服务端当作命令行参数处理,效果等于连接一建立就 SET。

第三层:passfile

parse_from_url 的最后一步是 apply_pgpass(),只在前两层都没给密码时才去读文件:

    pub(crate) fn apply_pgpass(mut self) -> Self {
        if self.password.is_none() {
            self.password = pgpass::load_password(
                &self.host,
                self.port,
                &self.username,
                self.database.as_deref(),
            );
        }

        self
    }

load_password 先看 PGPASSFILE 指的文件,再看 ~/.pgpass(Windows 是 %APPDATA%\postgresql\pgpass.conf),逐行按 主机:端口:库:用户:密码 匹配,* 是通配。只有在 Linux 上才检查文件权限,组或其他人可读(mode & 0o77 != 0)就忽略整个文件并打一条警告;macOS 上不查。

所以密码的优先级是:URL(或 .password())> PGPASSWORD > passfile。

实验:同一个 URL,三种环境

URL 里不写密码,带一个 sqlx 不认识的 connect_timeout(src/bin/options_src.rs,M2 Max、macOS、rustc 1.99.0、Postgres 16.14):

use sqlx::postgres::PgConnectOptions;
use sqlx::{ConnectOptions, Connection, PgConnection};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
  tracing_subscriber::fmt()
    .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
    .without_time()
    .with_ansi(false)
    .init();

  // 1. what new() picks up from the environment
  let o = PgConnectOptions::new();
  println!(
    "new(): host={} port={} user={} db={:?} ssl={:?}",
    o.get_host(),
    o.get_port(),
    o.get_username(),
    o.get_database(),
    o.get_ssl_mode()
  );

  // 2. URL: host starting with '/' (percent-encoded) means a Unix socket directory
  let o: PgConnectOptions = "postgres://%2Fvar%2Frun%2Fpostgresql/lab".parse()?;
  println!("socket url: host={} socket={:?}", o.get_host(), o.get_socket());

  // 3. query parameters, including one sqlx does not know
  let o: PgConnectOptions =
    "postgres://lab@192.168.64.106/lab?sslmode=disable&application_name=pg1&options[search_path]=pg1&connect_timeout=5"
      .parse()?;
  println!("options = {:?}", o.get_options());
  println!("to_url_lossy = {}", o.to_url_lossy());

  // 4. no password in the URL: the password comes from PGPASSWORD / the passfile, or nowhere
  match PgConnection::connect_with(&o).await {
    Ok(c) => {
      println!("connected without a password in the URL");
      c.close().await?;
    }
    Err(e) => println!("connect error: {e}"),
  }
  Ok(())
}

先清掉所有 PG* 变量、把 HOME 指到不存在的目录,再给一个 PGPASSFILE(内容是一行 192.168.64.106:5432:lab:lab:lab,权限 600):

== A:没有任何 PG* 变量
new(): host=localhost port=5432 user=alice db=None ssl=Prefer
socket url: host=localhost socket=Some("/var/run/postgresql")
 WARN sqlx_postgres::options::parse: ignoring unrecognized connect parameter key=connect_timeout value=5
options = Some("-c search_path=pg1")
to_url_lossy = postgres://lab@192.168.64.106:5432/lab?sslmode=disable&statement-cache-capacity=100
connect error: error returned from database: password authentication failed for user "lab" at line 331
== B:只加 PGPASSFILE
to_url_lossy = postgres://lab:lab@192.168.64.106:5432/lab?sslmode=disable&statement-cache-capacity=100
connected without a password in the URL
  • A 里 new() 的主机是 localhost:本机三个候选目录下都没有 socket 文件。用户名是操作系统用户 hal,库名是 None。
  • %2Fvar%2Frun%2Fpostgresql 进了 socket 字段,host 仍是环境给的默认值;连接时 fetch_socket 拼出 <目录>/.s.PGSQL.<端口>,有它就走 connect_uds,不再看 host。
  • 没有密码时 sqlx 照样做 SCRAM(拿空串当密码),服务端回 28P01。错误后面的 at line 331 是服务端 C 源码的行号,见错误。
  • B 里 passfile 补上了密码,to_url_lossy 里出现了 lab:lab。

to_url_lossy 名副其实:build_url 只写回用户、密码、主机、端口、库、sslmode、三个证书和 statement-cache-capacity,application_name 和 options 都丢了;密码却是明文写回去的,别把它打进日志。

❗IMPORTANT
动脑筋:环境里有一个错的 PGPASSWORD,passfile 里有对的密码

同一个程序,环境同 B,再加上 PGPASSWORD=postgres(不是 lab 的密码)。to_url_lossy 那一行打印什么,能不能连上?

establish:一个循环收完握手

PgConnection::establish 先 PgStream::connect(建 TCP 或 Unix socket,按 sslmode 决定要不要升级成 TLS,最后包一层读写缓冲),再写 StartupMessage:

        let mut params = vec![
            // Sets the display format for date and time values,
            // as well as the rules for interpreting ambiguous date input values.
            ("DateStyle", "ISO, MDY"),
            // Sets the client-side encoding (character set).
            // <https://www.postgresql.org/docs/devel/multibyte.html#MULTIBYTE-CHARSET-SUPPORTED>
            ("client_encoding", "UTF8"),
            // Sets the time zone for displaying and interpreting time stamps.
            ("TimeZone", "UTC"),
        ];

        if let Some(ref extra_float_digits) = options.extra_float_digits {
            params.push(("extra_float_digits", extra_float_digits));
        }

        if let Some(ref application_name) = options.application_name {
            params.push(("application_name", application_name));
        }

        if let Some(ref options) = options.options {
            params.push(("options", options));
        }

        stream.write(Startup {
            username: Some(&options.username),
            database: options.database.as_deref(),
            params: &params,
        })?;

        stream.flush().await?;

前三个参数是写死的。sqlx 自己要解析服务端发来的文本:简单查询协议的结果全是文本格式,日期要按 ISO 的格式读,字符串要是 UTF-8,timestamptz 的文本要带固定时区。连接建好以后再 SET DateStyle,sqlx 并不跟着变,后果见消息与读写缓冲的实验。

extra_float_digits 默认是 "2",控制文本格式下浮点数输出多少位。它的 setter 的文档注释却写着「SQLx sets this value to 3 by default」。

📝NOTE
冷知识:extra_float_digits 从 3 改成了 2

最早 sqlx 在启动参数里发的是 extra_float_digits=3。issue #801(「Issues with redshift」)报告「sqlx sends at the beginning of a pg session extra_float_digits = 3. This is not supported by redshift and will cause the connection to fail」;后来一个想加 URL 参数的 PR(#2708,未合并)贴出了 Redshift 的原话:「3 is outside the valid range for parameter "extra_float_digits" (-15 .. 2)」。PR #2832(「Update extra_float_digits default to 2 instead of 3」,首次发布于 0.7.3)只改了一行,就是上面 new_without_pgpass 里的 Some("2".into())。对 Postgres 本身 2 和 3 没有区别:在实验库上分别 set extra_float_digits 为 0、1、2、3 再查 (0.1::float8 + 0.2::float8)::text,0 得到 0.3,1、2、3 都是 0.30000000000000004。那段文档注释没跟着改,到 0.9.0 还写着 3。

写完 StartupMessage,establish 进入一个循环,每次 recv 一条后端消息:

        loop {
            let message = stream.recv().await?;
            match message.format {
                BackendMessageFormat::Authentication => match message.decode()? {
                    Authentication::Ok => {
                        // the authentication exchange is successfully completed
                        // do nothing; no more information is required to continue
                    }

                    Authentication::CleartextPassword => {
                        // ...
                        stream
                            .send(Password::Cleartext(
                                options.password.as_deref().unwrap_or_default(),
                            ))
                            .await?;
                    }

                    Authentication::Md5Password(body) => {
                        // ...
                        stream
                            .send(Password::Md5 {
                                username: &options.username,
                                password: options.password.as_deref().unwrap_or_default(),
                                salt: body.salt,
                            })
                            .await?;
                    }

                    Authentication::Sasl(body) => {
                        sasl::authenticate(&mut stream, options, body).await?;
                    }

                    method => {
                        return Err(err_protocol!(
                            "unsupported authentication method: {:?}",
                            method
                        ));
                    }
                },

                BackendMessageFormat::BackendKeyData => {
                    // ...
                    let data: BackendKeyData = message.decode()?;

                    process_id = data.process_id;
                    secret_key = data.secret_key;
                }

                BackendMessageFormat::ReadyForQuery => {
                    // start-up is completed. The frontend can now issue commands
                    transaction_status = message.decode::<ReadyForQuery>()?.transaction_status;

                    break;
                }
                // ...
            }
        }

认证方式只支持明文、MD5 和 SASL(GSSAPI、SSPI 等报 unsupported authentication method)。明文和 MD5 都是一条 PasswordMessage 了事,SASL 交给 sasl::authenticate,下一节讲。

循环里看不到 ParameterStatus:它和 NoticeResponse 一样在 recv 内部就被处理掉了,不会到这里(见消息与读写缓冲)。BackendKeyData 里的进程号和密钥存进了 PgConnectionInner,但这两个字段标着 #[allow(dead_code)],注释说「used to send cancel requests」,整个 sqlx-postgres 里却没有发送 CancelRequest 的代码。sqlx 不会主动取消服务端正在跑的查询,这件事在扩展查询协议与语句缓存里会再碰到。

收到 ReadyForQuery 循环结束,PgConnectionInner 才被构造出来:语句 id 从 sqlx_s_1 开始,语句缓存按 statement_cache_capacity 建好,pending_ready_for_query_count 是 0。PgStream 那边的注释解释了为什么要把握手放在 PgStream 上做:「to uphold the invariant where an instantiated [PgConnection] is a valid connection to postgres」。拿到 PgConnection 就意味着握手已经完成。

实验:抓一次握手

一次查询的全过程那页的代理在 StartupMessage 之后就只认「类型字节 + 长度」。这里复制一份,加了几样:SSLRequest 的单字节回复、认证消息的子类型、SASL 的载荷(长的 nonce 和证明只留前 8 个字符,括号里是原长度)、StartupMessage 和 ParameterStatus 的内容、ErrorResponse / NoticeResponse 的 S、C、M 三个字段。实验 crate 的 lib.rs 里有一个帮手函数,后面两页也用它:

pub const UPSTREAM: &str = "192.168.64.106:5432";

pub fn direct() -> anyhow::Result<PgConnectOptions> {
  Ok(std::env::var("DATABASE_URL")?.parse::<PgConnectOptions>()?.options([("search_path", "pg1")]))
}

// Start a proxy and return options pointing at it (TLS off unless the caller changes it).
pub async fn via_proxy() -> anyhow::Result<(proxy::Proxy, PgConnectOptions)> {
  let p = proxy::start(UPSTREAM).await?;
  let opts = direct()?.host("127.0.0.1").port(p.port).ssl_mode(PgSslMode::Disable);
  Ok((p, opts))
}

src/bin/startup.rs 先直连查一下服务端怎么存密码,再经代理用 Prefer 和 Require 各连一次:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
  // how is the password stored on the server?
  let mut c = PgConnection::connect_with(&direct()?).await?;
  let enc: String = sqlx::query_scalar("show password_encryption").fetch_one(&mut c).await?;
  let stored: String = sqlx::query_scalar("select left(rolpassword, 14) from pg_authid where rolname = current_user")
    .fetch_one(&mut c)
    .await?;
  println!("password_encryption = {enc}, stored = {stored}...");
  c.close().await?;

  let (_p, opts) = via_proxy().await?;
  for mode in [PgSslMode::Prefer, PgSslMode::Require] {
    println!("--- ssl_mode = {mode:?} ---");
    let r = PgConnection::connect_with(&opts.clone().ssl_mode(mode).application_name("pg1-startup")).await;
    settle().await;
    match r {
      Ok(conn) => {
        println!("connected, server_version_num = {:?}", conn.server_version_num());
        conn.close().await?;
        settle().await;
      }
      Err(e) => println!("error: {e}"),
    }
  }
  Ok(())
}
password_encryption = scram-sha-256, stored = SCRAM-SHA-256$...
--- ssl_mode = Prefer ---
  [conn 1] C->S SSLRequest
  [conn 1] S->C SSL 回复 'N'
  [conn 1] C->S StartupMessage(v3.0 user=lab database=lab DateStyle=ISO, MDY client_encoding=UTF8 TimeZone=UTC extra_float_digits=2 application_name=pg1-startup options=-c search_path=pg1)
  [conn 1] S->C AuthenticationSASL(SCRAM-SHA-256)
  [conn 1] C->S SASLInitialResponse(SCRAM-SHA-256, "n,,n=lab,r=xX`TIl…(96)")
  [conn 1] S->C AuthenticationSASLContinue("r=xX`TIl…(120),s=pmdfF7…(26),i=4096")
  [conn 1] C->S SASLResponse("c=biws,r=xX`TIl…(120),p=3L6fHM…(46)")
  [conn 1] S->C AuthenticationSASLFinal("v=bh2DwO…(46)"), AuthenticationOk, ParameterStatus(in_hot_standby=off), ParameterStatus(integer_datetimes=on), ParameterStatus(TimeZone=UTC), ParameterStatus(IntervalStyle=postgres), ParameterStatus(is_superuser=on), ParameterStatus(application_name=pg1-startup), ParameterStatus(default_transaction_read_only=off), ParameterStatus(scram_iterations=4096), ParameterStatus(DateStyle=ISO, MDY), ParameterStatus(standard_conforming_strings=on), ParameterStatus(session_authorization=lab), ParameterStatus(client_encoding=UTF8), ParameterStatus(server_version=16.14), ParameterStatus(server_encoding=UTF8), BackendKeyData, ReadyForQuery(I)
connected, server_version_num = Some(160014)
  [conn 1] C->S Terminate
--- ssl_mode = Require ---
  [conn 2] C->S SSLRequest
  [conn 2] S->C SSL 回复 'N'
error: error occurred while attempting to establish a TLS connection: server does not support TLS
  • 服务端的 password_encryption 是 scram-sha-256,lab 的密码以 SCRAM-SHA-256$ 开头存着,所以服务端提出的认证方式是 SASL,并且只列了 SCRAM-SHA-256 一种。
  • 从 StartupMessage 到 ReadyForQuery 是三个来回:启动、SASL 第一步、SASL 第二步。最后一次读里,服务端把签名、AuthenticationOk、14 条 ParameterStatus、BackendKeyData、ReadyForQuery 一口气发了过来。
  • server_version=16.14 被 recv 截下来解析成 server_version_num() 的 160014。
  • Require 遇到 N 直接报错,StartupMessage 都没发。

SCRAM-SHA-256:两个来回

sasl::authenticate 照 RFC 5802 一步步来,变量名和 RFC 里的公式一一对应:

  1. 在服务端列出的机制里找 SCRAM-SHA-256 或 SCRAM-SHA-256-PLUS,都没有就报 unsupported SASL authentication mechanisms。
  2. 发 SASLInitialResponse:n,,n=<用户名>,r=<客户端 nonce>。开头的 n,, 是 GS2 头,意思是「客户端不支持通道绑定」,所以即使服务端提供了 -PLUS,sqlx 也用 plus: false 走普通的 SCRAM-SHA-256。gen_nonce 生成 64 到 127 个可打印 ASCII 字符(排除逗号);上面那次是 94 个,r= 加起来 96。
  3. 服务端回 SASLContinue:r= 是客户端 nonce 后面接上服务端的 24 个字符(120 = 96 + 24),s= 是 base64 的盐,i=4096 是迭代次数。
  4. 客户端算 SaltedPassword = Hi(密码, 盐, 4096),再由它派生 ClientKey、StoredKey、ClientSignature,ClientProof = ClientKey XOR ClientSignature,发 SASLResponse:c=biws,r=<完整 nonce>,p=<证明>。biws 是 n,, 的 base64。
  5. 服务端回 SASLFinal,v= 是服务端签名。客户端用 ServerKey 算出同一个签名比对:
    // authentication is only considered valid if this verification passes
    mac.verify_slice(&data.verifier).map_err(Error::protocol)?;

最后这步是 SCRAM 比 MD5 强的地方:不只服务端验证了客户端,客户端也验证了服务端确实知道这个密码,冒充的服务端过不了这一关。实验库里 lab 的 rolpassword 形如 SCRAM-SHA-256$4096:<盐>$<StoredKey>:<ServerKey>,服务端手里没有密码本身。

Hi 就是 PBKDF2-HMAC-SHA256,4096 次 HMAC 是纯 CPU 计算。hi 每算 5 轮让出一次:

    for i in 1..iter_count {
        mac.update(u.as_slice());
        u = mac.finalize_reset().into_bytes();
        hi = hi.iter().zip(u.iter()).map(|(&a, &b)| a ^ b).collect();

        // For large iteration counts, this process can take a long time and block the event loop.
        // It was measured as taking ~50ms for 4096 iterations (the default) on a developer machine.
        // If we want to yield every 10-100us (as generally advised for tokio), then we can yield
        // every 5 iterations which should be every ~50us.
        if i % 5 == 0 {
            rt::yield_now().await;
        }
    }

在 async 里做长时间计算会卡住整个 worker,见 tokio 卷的在 async 里阻塞。

📝NOTE
冷知识:PR 标题说 blocking executor,合进去的是 yield

0.9.0 之前,hi 是普通的同步函数,4096 轮一口气算完。issue #4005(「PostgreSQL SCRAM-SHA-256 auth causes event loop blocking」)用火焰图看到大量时间花在 sha2::sha256::compress256 上,而且是在 Future::poll 里,单独跑这段代码「can take ~50ms on my machine (MacBook Pro M1)」。修它的 PR #4006(首次发布于 0.9.0)起初是「a PoC of offloading the SHA256 HMAC generation into a blocking executor」,合并时的提交说明却是「Yield while salting password for PG SASL」:「we yield every few iterations of the hmac-sha256」,也就是上面的 i % 5 == 0。PR 后来改名为「add yield points to SHA256 calculation」,CHANGELOG 里却留着旧标题「run SHA256 in a blocking executor」。现在的做法不占用阻塞线程池,计算量也没变,只是被 yield_now 切成了八百多段(4096 / 5)。

TLS:SSLRequest 和 sslmode

TLS 是在 StartupMessage 之前、同一条 TCP 连接上协商的。maybe_upgrade:

    match options.ssl_mode {
        // FIXME: Implement ALLOW
        PgSslMode::Allow | PgSslMode::Disable => return Ok(Box::new(socket)),

        PgSslMode::Prefer => {
            if !tls::available() {
                return Ok(Box::new(socket));
            }

            // try upgrade, but its okay if we fail
            if !request_upgrade(&mut socket, options).await? {
                return Ok(Box::new(socket));
            }
        }

        PgSslMode::Require | PgSslMode::VerifyFull | PgSslMode::VerifyCa => {
            tls::error_if_unavailable()?;

            if !request_upgrade(&mut socket, options).await? {
                // upgrade failed, die
                return Err(Error::Tls("server does not support TLS".into()));
            }
        }
    }

    let accept_invalid_certs = !matches!(
        options.ssl_mode,
        PgSslMode::VerifyCa | PgSslMode::VerifyFull
    );
    let accept_invalid_hostnames = !matches!(options.ssl_mode, PgSslMode::VerifyFull);

request_upgrade 往裸 socket 写 8 个字节的 SSLRequest(长度 8,请求码 80877103,高 16 位是 1234、低 16 位是 5679),读回一个字节:S 就在这条 socket 上做 TLS 握手,N 就返回 false。这个字节没有类型和长度,也不经过读写缓冲,这一步发生在 BufferedSocket 包上去之前。实验里 Prefer 收到 N 之后,StartupMessage 就在同一条 TCP 连接上明文发了出去,代理看到的是同一个 [conn 1]。

sslmode发 SSLRequest服务端回 N证书校验
disable不发——
allow不发(FIXME: Implement ALLOW,等同 disable)——
prefer(默认)编译时开了 TLS 才发明文继续不校验
require发报错不校验
verify-ca发报错校验证书链,不校验主机名
verify-full发报错证书链和主机名都校验

「不校验」是真的什么都不查:rustls 后端在 accept_invalid_certs 为真时装的是一个 DummyTlsVerifier(tls_rustls.rs)。prefer 只在服务端回 N 时退回明文;回了 S 但握手失败,连接直接报错,不会再明文重试。这个实验环境的服务端没开 TLS,表里后三列的握手行为是读源码得到的,没有实测。

📝NOTE
没有蠢问题

问: PgSslMode::Require 的文档写着「If a root CA file is present, verify the connection in the same way as if VerifyCa was specified」,那我配了 sslrootcert 再用 require,是不是就校验了?

答: 0.9.0 不会。accept_invalid_certs 只看 ssl_mode 是不是 VerifyCa / VerifyFull,不看有没有根证书;Require 加根证书照样走 DummyTlsVerifier,根证书根本不会被读。这句文档描述的是 libpq 的行为,sqlx 没有实现。要校验,就明确写 verify-ca 或 verify-full。

Unix socket 走的是同一个 MaybeUpgradeTls:PgStream::connect 只是在 connect_uds 和 connect_tcp 之间选一个,代码不区分两者,sslmode 是 prefer 时在 Unix socket 上同样先发 SSLRequest。TCP 那一侧在 tokio 下就是 tokio::net::TcpStream::connect((host, port)),按主机名解析出的地址逐个试;没有连接超时,超时由连接池的 acquire_timeout 兜底。

动脑筋答案

== C:B 的环境再加 PGPASSWORD=postgres
to_url_lossy = postgres://lab:postgres@192.168.64.106:5432/lab?sslmode=disable&statement-cache-capacity=100
connect error: error returned from database: password authentication failed for user "lab" at line 331
  • URL 没写密码,parse_from_url 起点的 new_without_pgpass 已经从 PGPASSWORD 拿到了 postgres,URL 没有覆盖它。
  • apply_pgpass 只在 password.is_none() 时读文件,这时密码已经有了,passfile 里正确的 lab 根本没被读。
  • 于是用错的密码做 SCRAM,服务端拒绝。写这个实验的机器上,shell 里正好有一组给别的库用的 PGPASSWORD、PGUSER、PGHOST,第一次跑时就踩到了:URL 里没写的东西,都会被环境悄悄补上。

小结

  • 选项分三层:new_without_pgpass 读 PG* 环境变量,URL 覆盖在它上面,最后只在还没有密码时读 passfile。密码优先级是 URL > PGPASSWORD > passfile;不认识的 URL 参数只打一条警告。
  • establish 写一条 StartupMessage(固定带 DateStyle=ISO, MDY、client_encoding=UTF8、TimeZone=UTC、extra_float_digits=2),然后在一个循环里处理认证、BackendKeyData,到 ReadyForQuery 为止;ParameterStatus 在 recv 里就被截走了。
  • SCRAM-SHA-256 是两个来回,不做通道绑定,最后校验服务端签名;4096 轮 HMAC 每 5 轮 yield_now 一次(0.9.0 起)。
  • sslmode:allow 等于 disable;prefer 收到 N 就在同一条连接上明文继续;prefer 和 require 都不校验证书,配了根证书也一样,要校验得用 verify-ca / verify-full。
  • BackendKeyData 存了但没用,sqlx 不发 CancelRequest。

💬 关联讨论

发起讨论
加载中...