Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .release-notes/add-ssl-preferred.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Add SSLPreferred mode

`SSLPreferred` is a new SSL mode equivalent to PostgreSQL's `sslmode=prefer`. It attempts SSL negotiation when connecting and falls back to plaintext if the server refuses. A TLS handshake failure (server accepts but handshake fails) is a hard failure — the connection is not retried as plaintext.

Use `SSLPreferred` when you want encryption if available but don't want to fail when connecting to servers that don't support SSL:

```pony
use "ssl/net"

let sslctx = recover val
SSLContext
.> set_client_verify(false)
.> set_server_verify(false)
end

let session = Session(
ServerConnectInfo(auth, host, port, SSLPreferred(sslctx)),
DatabaseConnectInfo(user, password, database),
notify)
```

The existing `SSLRequired` mode is unchanged — it still aborts if the server refuses SSL.
34 changes: 18 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ Managed via `corral`.
`Session` actor is the main entry point. Constructor takes `ServerConnectInfo` (auth, host, service, ssl_mode) and `DatabaseConnectInfo` (user, password, database). Implements `lori.TCPConnectionActor` and `lori.ClientLifecycleEventReceiver`. The stored `ServerConnectInfo` is accessible via `server_connect_info()` for use by `_CancelSender`. State transitions via `_SessionState` interface with concrete states:

```
_SessionUnopened --connect (no SSL)--> _SessionConnected
_SessionUnopened --connect (SSL)--> _SessionSSLNegotiating
_SessionUnopened --fail--> _SessionClosed
_SessionSSLNegotiating --'S'+TLS ok--> _SessionConnected
_SessionSSLNegotiating --'N'/fail--> _SessionClosed
_SessionConnected --MD5 auth ok--> _SessionLoggedIn
_SessionConnected --MD5 auth fail--> _SessionClosed
_SessionConnected --SASL challenge--> _SessionSCRAMAuthenticating
_SessionSCRAMAuthenticating --auth ok--> _SessionLoggedIn
_SessionSCRAMAuthenticating --auth fail--> _SessionClosed
_SessionLoggedIn --close--> _SessionClosed
_SessionUnopened --connect (no SSL)--> _SessionConnected
_SessionUnopened --connect (SSLRequired/Preferred)--> _SessionSSLNegotiating
_SessionUnopened --fail--> _SessionClosed
_SessionSSLNegotiating --'S'+TLS ok--> _SessionConnected
_SessionSSLNegotiating --'N' (SSLRequired)--> _SessionClosed
_SessionSSLNegotiating --'N' (SSLPreferred)--> _SessionConnected (plaintext fallback)
_SessionSSLNegotiating --TLS fail--> _SessionClosed
_SessionConnected --MD5 auth ok--> _SessionLoggedIn
_SessionConnected --MD5 auth fail--> _SessionClosed
_SessionConnected --SASL challenge--> _SessionSCRAMAuthenticating
_SessionSCRAMAuthenticating --auth ok--> _SessionLoggedIn
_SessionSCRAMAuthenticating --auth fail--> _SessionClosed
_SessionLoggedIn --close--> _SessionClosed
```

State behavior is composed via a trait hierarchy that mixes in capabilities and defaults:
Expand All @@ -53,7 +55,7 @@ State behavior is composed via a trait hierarchy that mixes in capabilities and
- `_AuthenticableState` / `_NotAuthenticableState` — can/can't authenticate
- `_AuthenticatedState` / `_NotAuthenticated` — has/hasn't authenticated

`_SessionSSLNegotiating` is a standalone class (not using `_ConnectedState`) because it handles raw bytes — the server's SSL response is not a PostgreSQL protocol message, so `_ResponseParser` is not used. It mixes in `_NotConnectableState`, `_NotAuthenticableState`, and `_NotAuthenticated`.
`_SessionSSLNegotiating` is a standalone class (not using `_ConnectedState`) because it handles raw bytes — the server's SSL response is not a PostgreSQL protocol message, so `_ResponseParser` is not used. It mixes in `_NotConnectableState`, `_NotAuthenticableState`, and `_NotAuthenticated`. A `_fallback_on_refusal` field controls behavior when the server responds 'N': `true` for `SSLPreferred` (fall back to plaintext), `false` for `SSLRequired` (fire `pg_session_connection_failed`). TLS handshake failures always fire `pg_session_connection_failed` regardless of this flag.

`_SessionSCRAMAuthenticating` handles the multi-step SCRAM-SHA-256 exchange after `_SessionConnected` receives an AuthSASL challenge. It mixes in `_ConnectedState` (for `on_received`/TCP write) and `_NotAuthenticated`. Fields store the client nonce, client-first-bare, password, and expected server signature across the exchange steps.

Expand Down Expand Up @@ -114,7 +116,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
- `ClientQueryError` trait — `SessionNeverOpened`, `SessionClosed`, `SessionNotAuthenticated`, `DataError`
- `DatabaseConnectInfo` — val class grouping database authentication parameters (user, password, database). Passed to `Session.create()` alongside `ServerConnectInfo`.
- `ServerConnectInfo` — val class grouping connection parameters (auth, host, service, ssl_mode). Passed to `Session.create()` as the first parameter. Also used by `_CancelSender`.
- `SSLMode` — union type `(SSLDisabled | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLRequired` wraps an `SSLContext val` for TLS negotiation.
- `SSLMode` — union type `(SSLDisabled | SSLPreferred | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLPreferred` wraps an `SSLContext val` and attempts SSL with plaintext fallback on server refusal (`sslmode=prefer`). `SSLRequired` wraps an `SSLContext val` and aborts on server refusal.
- `ErrorResponseMessage` — full PostgreSQL error with all standard fields
- `AuthenticationFailureReason` = `(InvalidAuthenticationSpecification | InvalidPassword | ServerVerificationFailed | UnsupportedAuthenticationMethod)`

Expand All @@ -133,7 +135,7 @@ In `_RowsBuilder._field_to_type()`:

### Query Cancellation

`_CancelSender` actor — fire-and-forget actor that sends a `CancelRequest` on a separate TCP connection. PostgreSQL requires cancel requests on a different connection from the one executing the query. No response is expected on the cancel connection — the result (if any) arrives as an `ErrorResponse` on the original session connection. When the session uses `SSLRequired`, the cancel connection performs SSL negotiation before sending the CancelRequest — mirroring the main session's connection setup. If the server refuses SSL or the TLS handshake fails, the cancel is silently abandoned. Created by `_SessionLoggedIn.cancel()` using the session's `ServerConnectInfo`, `backend_pid`, and `backend_secret_key`. Design: [discussion #88](https://github.com/ponylang/postgres/discussions/88).
`_CancelSender` actor — fire-and-forget actor that sends a `CancelRequest` on a separate TCP connection. PostgreSQL requires cancel requests on a different connection from the one executing the query. No response is expected on the cancel connection — the result (if any) arrives as an `ErrorResponse` on the original session connection. When the session uses `SSLRequired` or `SSLPreferred`, the cancel connection performs SSL negotiation before sending the CancelRequest — mirroring the main session's connection setup. For `SSLRequired`, if the server refuses SSL or the TLS handshake fails, the cancel is silently abandoned. For `SSLPreferred`, server refusal falls back to a plaintext cancel; TLS handshake failure still silently abandons. Created by `_SessionLoggedIn.cancel()` using the session's `ServerConnectInfo`, `backend_pid`, and `backend_secret_key`. Design: [discussion #88](https://github.com/ponylang/postgres/discussions/88).

### Mort Primitives

Expand All @@ -145,11 +147,11 @@ Tests live in the main `postgres/` package (private test classes), organized acr

**Conventions**: `_test.pony` contains shared helpers (`_ConnectionTestConfiguration` for env vars, `_ConnectTestNotify`/`_AuthenticateTestNotify` reused by other files). `_test_response_parser.pony` contains `_Incoming*TestMessage` builder classes that construct raw protocol bytes for mock servers across all test files. `_test_mock_message_reader.pony` contains `_MockMessageReader` for extracting complete PostgreSQL frontend messages from TCP data in mock servers.

**Ports**: Mock server tests use ports in the 7669–7706 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it.
**Ports**: Mock server tests use ports in the 7669–7710 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it.

## Supported PostgreSQL Features

**SSL/TLS:** Optional SSL negotiation via `SSLRequired`. CVE-2021-23222 mitigated via `expect(1)` before SSLRequest. Design: [discussion #76](https://github.com/ponylang/postgres/discussions/76).
**SSL/TLS:** Optional SSL negotiation via `SSLRequired` (mandatory) or `SSLPreferred` (fallback to plaintext on server refusal). CVE-2021-23222 mitigated via `expect(1)` before SSLRequest. Design: [discussion #76](https://github.com/ponylang/postgres/discussions/76).

**Authentication:** MD5 password and SCRAM-SHA-256. No SCRAM-SHA-256-PLUS (channel binding), Kerberos, GSS, or certificate auth. Design: [discussion #83](https://github.com/ponylang/postgres/discussions/83).

Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Parameterized queries using `PreparedQuery`. Sends a query with typed parameters

Named prepared statements using `Session.prepare()` and `NamedPreparedQuery`. Prepares a statement once, executes it twice with different parameters, then cleans up with `Session.close_statement()`. Shows how to implement `PrepareReceiver` for prepare lifecycle callbacks.

## ssl-preferred-query

SSL-preferred query using `SSLPreferred`. Same workflow as `query` but with SSL negotiation that falls back to plaintext if the server refuses — equivalent to PostgreSQL's `sslmode=prefer`. Demonstrates the difference between `SSLPreferred` (best-effort encryption) and `SSLRequired` (mandatory encryption). Works with both SSL-enabled and non-SSL PostgreSQL servers.

## ssl-query

SSL-encrypted query using `SSLRequired`. Same workflow as `query` but with TLS negotiation enabled. Demonstrates how to create an `SSLContext`, wrap it in `SSLRequired`, and pass it to `Session`. Requires a PostgreSQL server configured to accept SSL connections.
Expand Down
117 changes: 117 additions & 0 deletions examples/ssl-preferred-query/ssl-preferred-query-example.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
SSL-preferred query using `SSLPreferred`. Attempts TLS negotiation when
connecting, but falls back to plaintext if the server refuses — equivalent to
PostgreSQL's `sslmode=prefer`. A TLS handshake failure (server accepts but
handshake fails) is NOT retried as plaintext.

Use `SSLPreferred` when you want encryption if available but don't want to
fail if the server doesn't support it. Use `SSLRequired` when encryption is
mandatory and you'd rather fail than connect without it.

Requires environment variables for server configuration. Works with both
SSL-enabled and non-SSL PostgreSQL servers.
"""
use "cli"
use "collections"
use "files"
use lori = "lori"
use "ssl/net"
// in your code this `use` statement would be:
// use "postgres"
use "../../postgres"

actor Main
new create(env: Env) =>
let server_info = ServerInfo(env.vars)

let sslctx = recover val
SSLContext
.> set_client_verify(false)
.> set_server_verify(false)
end

let auth = lori.TCPConnectAuth(env.root)
Client(auth, server_info, sslctx, env.out)

actor Client is (SessionStatusNotify & ResultReceiver)
let _session: Session
let _out: OutStream

new create(auth: lori.TCPConnectAuth, info: ServerInfo,
sslctx: SSLContext val, out: OutStream)
=>
_out = out
_session = Session(
ServerConnectInfo(auth, info.host, info.port, SSLPreferred(sslctx)),
DatabaseConnectInfo(info.username, info.password, info.database),
this)

be close() =>
_session.close()

be pg_session_connected(session: Session) =>
_out.print("Connected (SSL negotiation complete — may be encrypted or plaintext).")

be pg_session_connection_failed(session: Session) =>
_out.print("Connection failed.")

be pg_session_authenticated(session: Session) =>
_out.print("Authenticated.")
_out.print("Sending query....")
let q = SimpleQuery("SELECT 525600::text")
session.execute(q, this)

be pg_session_authentication_failed(
s: Session,
reason: AuthenticationFailureReason)
=>
_out.print("Failed to authenticate.")

be pg_query_result(session: Session, result: Result) =>
match result
| let r: ResultSet =>
_out.print("ResultSet (" + r.rows().size().string() + " rows):")
for row in r.rows().values() do
for field in row.fields.values() do
_out.write(field.name + "=")
match field.value
| let v: String => _out.print(v)
| let v: I16 => _out.print(v.string())
| let v: I32 => _out.print(v.string())
| let v: I64 => _out.print(v.string())
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
| None => _out.print("NULL")
end
end
end
| let r: RowModifying =>
_out.print(r.command() + " " + r.impacted().string() + " rows")
| let r: SimpleResult =>
_out.print("Query executed.")
end
close()

be pg_query_failed(session: Session, query: Query,
failure: (ErrorResponseMessage | ClientQueryError))
=>
_out.print("Query failed.")
close()

class val ServerInfo
let host: String
let port: String
let username: String
let password: String
let database: String

new val create(vars: (Array[String] val | None)) =>
let e = EnvVars(vars)
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
port = try e("POSTGRES_PORT")? else "5432" end
username = try e("POSTGRES_USERNAME")? else "postgres" end
password = try e("POSTGRES_PASSWORD")? else "postgres" end
database = try e("POSTGRES_DATABASE")? else "postgres" end
39 changes: 27 additions & 12 deletions postgres/_cancel_sender.pony
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei
connection — the result (if any) arrives as an ErrorResponse on the
original session connection.

When `SSLRequired` is active, performs SSL negotiation before sending the
CancelRequest — mirroring what the main Session connection does. If the
server refuses SSL or the TLS handshake fails, the cancel is silently
abandoned (fire-and-forget semantics).
When `SSLRequired` or `SSLPreferred` is active, performs SSL negotiation
before sending the CancelRequest — mirroring what the main Session
connection does. For `SSLRequired`, if the server refuses SSL or the TLS
handshake fails, the cancel is silently abandoned. For `SSLPreferred`, if
the server refuses SSL ('N'), the cancel proceeds over plaintext; TLS
handshake failure still silently abandons.
"""
var _tcp_connection: lori.TCPConnection = lori.TCPConnection.none()
let _process_id: I32
Expand All @@ -33,7 +35,7 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei
match _info.ssl_mode
| SSLDisabled =>
_send_cancel_and_close()
| let _: SSLRequired =>
| let _: (SSLRequired | SSLPreferred) =>
// CVE-2021-23222 mitigation: expect exactly 1 byte for SSL response.
try _tcp_connection.expect(1)? end
_tcp_connection.send(_FrontendMessage.ssl_request())
Expand All @@ -43,16 +45,29 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei
// Only called during SSL negotiation — server responds 'S' or 'N'.
try
if data(0)? == 'S' then
let ctx = match _info.ssl_mode
| let req: SSLRequired => req.ctx
| let pref: SSLPreferred => pref.ctx
else
_tcp_connection.close()
return
end
match _tcp_connection.start_tls(ctx, _info.host)
| None => None // Handshake started, wait for _on_tls_ready
| let _: lori.StartTLSError =>
_tcp_connection.close()
end
elseif data(0)? == 'N' then
match _info.ssl_mode
| let req: SSLRequired =>
match _tcp_connection.start_tls(req.ctx, _info.host)
| None => None // Handshake started, wait for _on_tls_ready
| let _: lori.StartTLSError =>
_tcp_connection.close()
end
| let _: SSLPreferred =>
// SSLPreferred: fall back to plaintext cancel
try _tcp_connection.expect(0)? end
_send_cancel_and_close()
else
// SSLRequired or unexpected: silently give up
_tcp_connection.close()
end
else
// 'N' or junk — server refused SSL, silently give up
_tcp_connection.close()
end
else
Expand Down
6 changes: 6 additions & 0 deletions postgres/_test.pony
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ actor \nodoc\ Main is TestList
test(_TestSSLAuthenticate)
test(_TestSSLQueryResults)
test(_TestSSLRefused)
test(_TestSSLPreferredFallback)
test(_TestSSLPreferredSuccess)
test(_TestSSLPreferredTLSFailure)
test(_TestSSLPreferredCancelFallback)
test(_TestSSLPreferredWithSSLServer)
test(_TestSSLPreferredWithPlainServer)
test(_TestFieldEqualityReflexive)
test(_TestFieldEqualityStructural)
test(_TestFieldEqualitySymmetric)
Expand Down
Loading