Files
llmgw/internal/pgha/rest.go
T
Ilker ManapandClaude Opus 5.5 191c65e3db Put etcd and Patroni's API on TLS, and move a running cluster there in maintenance mode
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>
2026-10-04 12:07:17 +02:00

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
}