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
90 changes: 89 additions & 1 deletion apps/cli-go/internal/debug/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package debug

import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"errors"
"io"
"log"
"net"
"os"
"time"

"github.com/jackc/pgproto3/v2"
"github.com/jackc/pgx/v4"
Expand All @@ -16,23 +19,45 @@ import (

type Proxy struct {
dialContext func(ctx context.Context, network, addr string) (net.Conn, error)
errChan chan error
// tlsPlan is pgconn's TLS config per attempt, primary first. DialFunc walks it
// so sslmode=allow keeps its plaintext-then-TLS retry, and completes the
// handshake itself, leaving logged frames plaintext in memory but not on the wire.
tlsPlan []*tls.Config
attempts map[string]int
errChan chan error
}

func NewProxy() Proxy {
dialer := net.Dialer{}
return Proxy{
dialContext: dialer.DialContext,
attempts: map[string]int{},
errChan: make(chan error, 1),
}
}

func SetupPGX(config *pgx.ConnConfig) {
tlsPlan := []*tls.Config{config.TLSConfig}
for _, fallback := range config.Fallbacks {
if fallback.Host != config.Host {
break
}
// ConnectByUrl drops plaintext fallbacks once the primary is TLS, and it
// runs after this, so apply the same rule here or --debug reinstates them.
if config.TLSConfig != nil && fallback.TLSConfig == nil {
continue
}
tlsPlan = append(tlsPlan, fallback.TLSConfig)
Comment thread
7ttp marked this conversation as resolved.
}
proxy := Proxy{
dialContext: config.DialFunc,
tlsPlan: tlsPlan,
attempts: map[string]int{},
errChan: make(chan error, 1),
}
config.DialFunc = proxy.DialFunc
// pgx must not negotiate TLS again over the in-memory pipe; clearing these
// *without* DialFunc's handshake is what downgraded sslmode=require (#5872).
config.TLSConfig = nil
// pgconn reads Fallbacks independently of TLSConfig, so a restored sslmode=allow
// would still retry TLS through this plaintext proxy.
Expand All @@ -41,11 +66,37 @@ func SetupPGX(config *pgx.ConnConfig) {
}
}

// nextTLSConfig returns the TLS config for this attempt against addr. pgconn dials
// each plan entry once per resolved address, in plan order, so a per-address
// counter replays the same TLS/plaintext sequence it would have used itself.
func (p *Proxy) nextTLSConfig(addr string) *tls.Config {
if p.attempts == nil {
p.attempts = map[string]int{}
}
if len(p.tlsPlan) == 0 {
return nil
}
i := p.attempts[addr]
p.attempts[addr]++
// pgconn redials an addr for the target_session_attrs retry, so clamp rather
// than fall off the plan into plaintext.
if i >= len(p.tlsPlan) {
i = len(p.tlsPlan) - 1
}
return p.tlsPlan[i]
}

func (p *Proxy) DialFunc(ctx context.Context, network, addr string) (net.Conn, error) {
tlsConfig := p.nextTLSConfig(addr)
serverConn, err := p.dialContext(ctx, network, addr)
if err != nil {
return nil, err
}
if tlsConfig != nil {
if serverConn, err = startTLS(ctx, serverConn, tlsConfig); err != nil {
return nil, err
}
}

const bufSize = 1024 * 1024
ln := bufconn.Listen(bufSize)
Expand Down Expand Up @@ -78,6 +129,43 @@ func (p *Proxy) DialFunc(ctx context.Context, network, addr string) (net.Conn, e
return ln.DialContext(ctx)
}

// libpq's SSLRequest message code, PG_PROTOCOL(1234,5679).
const sslRequestCode = 80877103

// startTLS mirrors pgconn's own startTLS (pgconn@v1.14.3/pgconn.go:406-419), but
// handshakes eagerly so a refusal or bad certificate fails the dial rather than
// surfacing as a parse error inside the forwarding goroutines.
func startTLS(ctx context.Context, conn net.Conn, tlsConfig *tls.Config) (net.Conn, error) {
// pgconn only watches ctx once DialFunc returns, so bound the negotiation here
// or an endpoint that accepts TCP and never replies hangs the read forever.
if deadline, ok := ctx.Deadline(); ok {
if err := conn.SetDeadline(deadline); err != nil {
conn.Close()
return nil, err
}
defer func() { _ = conn.SetDeadline(time.Time{}) }()
}
if err := binary.Write(conn, binary.BigEndian, []int32{8, sslRequestCode}); err != nil {
conn.Close()
return nil, err
}
response := make([]byte, 1)
if _, err := io.ReadFull(conn, response); err != nil {
Comment thread
7ttp marked this conversation as resolved.
conn.Close()
return nil, err
}
if response[0] != 'S' {
conn.Close()
return nil, errors.New("server refused TLS connection")
}
tlsConn := tls.Client(conn, tlsConfig)
if err := tlsConn.HandshakeContext(ctx); err != nil {
conn.Close()
return nil, err
}
return tlsConn, nil
}

type Backend struct {
*pgproto3.Backend
logger *log.Logger
Expand Down
34 changes: 34 additions & 0 deletions apps/cli-go/internal/debug/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package debug

import (
"context"
"encoding/binary"
"io"
"net"
"testing"

"github.com/jackc/pgx/v4"
Expand All @@ -28,4 +31,35 @@ func TestPostgresProxy(t *testing.T) {
assert.NoError(t, err)
assert.NoError(t, proxy.Close(ctx))
})

t.Run("negotiates TLS itself instead of downgrading", func(t *testing.T) {
config, err := pgx.ParseConfig(postgresUrl + "?sslmode=require")
require.NoError(t, err)
require.NotNil(t, config.TLSConfig)
// Run test
SetupPGX(config)
assert.Nil(t, config.TLSConfig)
for _, fallback := range config.Fallbacks {
assert.Nil(t, fallback.TLSConfig)
}
// A server refusing TLS must fail the dial, not continue in plaintext.
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
request := make([]byte, 8)
if _, err := io.ReadFull(conn, request); err != nil {
return
}
assert.Equal(t, uint32(sslRequestCode), binary.BigEndian.Uint32(request[4:]))
_, _ = conn.Write([]byte("N"))
}()
_, err = config.DialFunc(context.Background(), "tcp", ln.Addr().String())
assert.ErrorContains(t, err, "server refused TLS connection")
})
}
7 changes: 2 additions & 5 deletions apps/cli-go/internal/gen/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/spf13/afero"
"github.com/spf13/viper"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/pkg/api"
)
Expand Down Expand Up @@ -192,10 +191,8 @@ func isRequireSSL(ctx context.Context, dbUrl string, options ...func(*pgx.ConnCo
}
return false, err
}
// SSL is not supported in debug mode
require := !viper.GetBool("DEBUG")
debugf("isRequireSSL result require_ssl=%t debug_mode=%t", require, viper.GetBool("DEBUG"))
return require, conn.Close(ctx)
debugf("isRequireSSL result require_ssl=true")
return true, conn.Close(ctx)
Comment thread
7ttp marked this conversation as resolved.
}

func IsSSLDebugEnabled() bool {
Expand Down
2 changes: 0 additions & 2 deletions apps/cli-go/internal/utils/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,6 @@ func SetConnectSuggestion(err error) {
"Make sure your local IP is allowed in Network Restrictions and Network Bans.\n%s/project/_/database/settings",
CurrentProfile.DashboardURL,
)
} else if strings.Contains(msg, "SSL connection is required") && viper.GetBool("DEBUG") {
CmdSuggestion = "SSL connection is not supported with --debug flag"
} else if strings.Contains(msg, "SCRAM exchange: Wrong password") || strings.Contains(msg, "failed SASL auth") {
// password authentication failed for user / invalid SCRAM server-final-message received from server
CmdSuggestion = SuggestEnvVar
Expand Down
4 changes: 3 additions & 1 deletion apps/cli-go/internal/utils/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ func TestSetConnectSuggestion(t *testing.T) {
suggestion: "",
},
{
// The debug proxy negotiates TLS itself, so --debug is no longer the
// reason a server rejects an unencrypted connection (#5872).
name: "ssl required with debug flag",
err: errors.New("SSL connection is required"),
debug: true,
suggestion: "SSL connection is not supported with --debug flag",
suggestion: "",
},
{
name: "wrong password via SCRAM",
Expand Down
4 changes: 1 addition & 3 deletions apps/cli/src/legacy/commands/gen/types/types.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { loadProjectConfig } from "@supabase/config";
import { ChildProcessSpawner } from "effect/unstable/process";
import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect";
import {
LegacyDebugFlag,
LegacyDnsResolverFlag,
LegacyNetworkIdFlag,
} from "../../../../shared/legacy/global-flags.ts";
Expand Down Expand Up @@ -192,7 +191,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le
const stdio = yield* Stdio.Stdio;
const networkId = yield* LegacyNetworkIdFlag;
const dnsResolver = yield* LegacyDnsResolverFlag;
const debug = yield* LegacyDebugFlag;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const rawArgs = yield* stdio.args;
const platformApi = yield* LegacyPlatformApiFactory;
Expand Down Expand Up @@ -426,7 +424,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le
}

const useTls = yield* sslProbe.requireSslForHost(target.probeHost, target.probePort);
if (useTls && !debug) {
if (useTls) {
env.push(`PG_META_DB_SSL_ROOT_CERT=${legacyRootCaBundle()}`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2883,7 +2883,10 @@ describe("legacy gen types", () => {
}),
);

it.live("omits the CA bundle env var in --debug mode even when TLS is supported", () =>
// Go's `GetRootCA` no longer special-cases `--debug`: `isRequireSSL` returns true
// on any successful probe (`types.go:194-195`), so the bundle is passed to pgmeta
// regardless of the flag.
it.live("passes the CA bundle env var in --debug mode when TLS is supported", () =>
Effect.tryPromise({
try: () =>
withSslProbeServer(async (port) => {
Expand All @@ -2903,7 +2906,7 @@ describe("legacy gen types", () => {
).pipe(Effect.provide(layer)),
);

expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(false);
expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true);
}, "S"),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}),
Expand Down
6 changes: 0 additions & 6 deletions apps/cli/src/legacy/shared/legacy-connect-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ export interface LegacyConnectSuggestionContext {
readonly dashboardUrl: string;
/** Active profile name (Go's `CurrentProfile.Name`). */
readonly profileName: string;
/** Whether `--debug` is set (Go's `viper.GetBool("DEBUG")`). */
readonly debug: boolean;
}

/**
Expand Down Expand Up @@ -366,10 +364,6 @@ export function legacyConnectSuggestion(
) {
return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`;
}
// SSL connection is required (only under --debug, which disables TLS).
if (text.includes("SSL connection is required") && ctx.debug) {
return "SSL connection is not supported with --debug flag";
}
// Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth";
// node-postgres surfaces the server's `28P01` "password authentication failed").
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ describe("legacyConnectSuggestion", () => {
const ctx = {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
} as const;

// The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address
Expand Down Expand Up @@ -318,12 +317,10 @@ describe("legacyConnectSuggestion", () => {
expect(legacyConnectSuggestion(err, ctx)).toBe(LEGACY_SUGGEST_ENV_VAR);
});

it("suggests the --debug SSL note only under --debug", () => {
// `ssl` comes from the DSN alone, so a server demanding it blames the DSN, not the flag.
it("does not blame --debug when the server demands SSL", () => {
const err = sqlError(new Error("SSL connection is required"));
expect(legacyConnectSuggestion(err, ctx)).toBeUndefined();
expect(legacyConnectSuggestion(err, { ...ctx, debug: true })).toBe(
"SSL connection is not supported with --debug flag",
);
});

it("maps an IPv6-only connectivity failure to the IPv6 pooler suggestion", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ describe("legacyDbConfigResolver (local + db-url)", () => {
suggestionContext: {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
},
});
expect(r.isLocal).toBe(true);
Expand Down Expand Up @@ -216,7 +215,6 @@ describe("legacyDbConfigResolver (local + db-url)", () => {
suggestionContext: {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
},
});
expect(r.isLocal).toBe(false);
Expand Down Expand Up @@ -510,7 +508,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => {
suggestionContext: {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
},
});
expect(r.ref).toEqual(Option.some(adHocRef));
Expand Down Expand Up @@ -668,7 +665,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => {
suggestionContext: {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
},
});
}
Expand Down Expand Up @@ -810,7 +806,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => {
suggestionContext: {
dashboardUrl: "https://supabase.com/dashboard",
profileName: "supabase",
debug: false,
},
});
}
Expand Down
3 changes: 1 addition & 2 deletions apps/cli/src/legacy/shared/legacy-db-config.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,12 @@ export const legacyDbConfigLayer = Layer.effect(
const path = yield* Path.Path;

// Profile context for the connect-failure suggestion (Go's `SetConnectSuggestion`
// reads the ambient `CurrentProfile` + `viper.GetBool("DEBUG")`). Snapshot it once
// reads the ambient `CurrentProfile`). Snapshot it once
// and attach it to every resolved connection so the driver layer can render Go's
// hint on a refused/auth/IPv6 connect error.
const suggestionContext: LegacyConnectSuggestionContext = {
dashboardUrl: cliConfig.dashboardUrl,
profileName: cliConfig.profile,
debug: yield* LegacyDebugFlag,
};

// Capture the ambient services the Management API stack needs, so the
Expand Down
Loading
Loading