Members' etcd speaks TLS to peers and clients with a certificate from the cluster's authority and asks clients for one; Patroni reaches it with the member's certificate and serves its API over TLS. A running plain-HTTP cluster is moved with Patroni paused, so PostgreSQL serves throughout: Patroni's lease-free keys are carried over to the new etcd. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 lines
3.1 KiB
Go
117 lines
3.1 KiB
Go
package pgha
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Patroni's API answers over TLS once a member has its certificate, and
|
|
// over plain HTTP on a cluster set up before that, or while it is being
|
|
// moved. So every request is made over HTTPS first, verified against the
|
|
// cluster's authority, and over HTTP only when the member answers in plain
|
|
// HTTP — never when the certificate is wrong.
|
|
|
|
var (
|
|
restMu sync.RWMutex
|
|
restCA *x509.CertPool
|
|
)
|
|
|
|
// SetRESTCA is the cluster's authority, against which members' API
|
|
// certificates are verified: the control plane's ca.pem. Without one, the
|
|
// API is asked over plain HTTP.
|
|
func SetRESTCA(pemFile string) error {
|
|
b, err := os.ReadFile(pemFile) //nolint:gosec // the operator's own CA
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return setRESTCAPEM(b)
|
|
}
|
|
|
|
func setRESTCAPEM(b []byte) error {
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(b) {
|
|
return errors.New("no certificate in the authority's file")
|
|
}
|
|
restMu.Lock()
|
|
restCA = pool
|
|
restMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// restRequest asks one member's API.
|
|
func restRequest(ctx context.Context, host, method, path string, body []byte, password string,
|
|
timeout time.Duration,
|
|
) (*http.Response, error) {
|
|
restMu.RLock()
|
|
pool := restCA
|
|
restMu.RUnlock()
|
|
addr := net.JoinHostPort(host, strconv.Itoa(restPort))
|
|
try := func(scheme string, client *http.Client) (*http.Response, error) {
|
|
var r io.Reader
|
|
if body != nil {
|
|
r = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, scheme+"://"+addr+path, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if password != "" {
|
|
req.SetBasicAuth(RestUser, password)
|
|
}
|
|
return client.Do(req)
|
|
}
|
|
plain := &http.Client{Timeout: timeout}
|
|
if pool == nil {
|
|
return try("http", plain)
|
|
}
|
|
secure := &http.Client{Timeout: timeout, Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
|
}}
|
|
resp, err := try("https", secure)
|
|
if err != nil && speaksPlainHTTP(err) {
|
|
return try("http", plain)
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
// speaksPlainHTTP reports whether a TLS attempt failed because the other
|
|
// end answered in plain HTTP: a member whose API has no certificate yet.
|
|
func speaksPlainHTTP(err error) bool {
|
|
var rh tls.RecordHeaderError
|
|
return errors.As(err, &rh) || strings.Contains(err.Error(), "server gave HTTP response to HTTPS client")
|
|
}
|
|
|
|
// restJSON is restRequest for an answer that must be 200, read whole.
|
|
func restJSON(ctx context.Context, host, method, path string, body []byte, password string,
|
|
timeout time.Duration,
|
|
) ([]byte, error) {
|
|
resp, err := restRequest(ctx, host, method, path, body, password, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return b, fmt.Errorf("%s %s on %s: %s", method, path, host, resp.Status)
|
|
}
|
|
return b, nil
|
|
}
|