Files
worker/internal/workercluster/transport.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

288 строки
9.1 KiB
Go

package workercluster
import (
"bytes"
"crypto/subtle"
"encoding/base64"
"fmt"
"io"
"log"
"net"
"net/http"
"sync"
"time"
rafthttp "github.com/CanonicalLtd/raft-http"
)
// HTTPCreds is the basic-auth credential pair workers use to gate the
// rafthttp endpoint. The values come from distworker.HTTPConfig.Login
// and distworker.HTTPConfig.Password (env vars WORKER_LOGIN and
// WORKER_PASSWORD).
type HTTPCreds struct {
Login string
Password string
}
// IsConfigured returns true when both login and password are set.
// NewTransport and NewHandler refuse to start otherwise.
func (c HTTPCreds) IsConfigured() bool {
return c.Login != "" && c.Password != ""
}
// AuthDial returns a rafthttp.Dial function that wraps the inner
// connection so the Authorization header is injected on every HTTP
// request the rafthttp library writes over it.
//
// rafthttp builds its own http.Request without Authorization (GET for
// the stream upgrade, POST/DELETE for membership changes). It then
// writes the request to the net.Conn returned by Dial. We can't inject
// at the request layer; we have to do it at the connection layer.
//
// Implementation: the wrapper buffers the first Write, looks for the
// end-of-headers marker (\r\n\r\n), inserts an Authorization header
// just before it, then forwards the augmented buffer plus any further
// writes to the inner conn.
//
// TODO(security): replace with a rafthttp fork that supports an
// outbound Authorization header or use NewDialTLS with mTLS client
// certs once the worker identity model in plan section 7.1 lands.
func AuthDial(inner rafthttp.Dial, creds HTTPCreds) rafthttp.Dial {
if !creds.IsConfigured() {
panic("workercluster: AuthDial requires both login and password")
}
return func(addr string, timeout time.Duration) (net.Conn, error) {
conn, err := inner(addr, timeout)
if err != nil {
return nil, err
}
return &authInjectingConn{
Conn: conn,
auth: "Basic " + base64.StdEncoding.EncodeToString([]byte(creds.Login+":"+creds.Password)),
}, nil
}
}
// authInjectingConn wraps a net.Conn and rewrites the first HTTP
// request written to it so it carries an Authorization header.
//
// State machine:
//
// - injected = false: incoming bytes are appended to buf until we see
// the end-of-headers marker (\r\n\r\n).
// - once we see \r\n\r\n, we insert the Authorization header just
// before it, drain the buffer to the inner conn, and switch to
// passthrough.
// - if too much data arrives without a header terminator (e.g. a
// very large POST body), we forward as-is; the auth handler will
// reject the request.
// - subsequent writes pass through unchanged.
//
// This is sufficient for rafthttp: every HTTP request it writes is a
// self-contained, single-shot request over a fresh connection.
type authInjectingConn struct {
net.Conn
auth string
mu sync.Mutex
buf []byte
injected bool
}
func (a *authInjectingConn) Write(p []byte) (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
if !a.injected {
a.buf = append(a.buf, p...)
if len(a.buf) > maxAuthHeaderBuffer {
// Too much data before we saw the header terminator;
// bail out and forward as-is. The request will be
// rejected by the auth handler on the other side,
// which is the correct failure mode.
a.injected = true
if _, err := a.Conn.Write(a.buf); err != nil {
return 0, err
}
a.buf = nil
return len(p), nil
}
if idx := bytes.Index(a.buf, []byte("\r\n\r\n")); idx >= 0 {
// Split around the header terminator.
head := a.buf[:idx]
rest := a.buf[idx:]
newBuf := make([]byte, 0, len(a.buf)+len(a.auth)+32)
newBuf = append(newBuf, head...)
newBuf = append(newBuf, []byte("\r\nAuthorization: ")...)
newBuf = append(newBuf, []byte(a.auth)...)
newBuf = append(newBuf, rest...)
a.buf = newBuf
a.injected = true
n, err := a.Conn.Write(a.buf)
a.buf = nil
return n, err
}
return len(p), nil
}
return a.Conn.Write(p)
}
// maxAuthHeaderBuffer caps the bytes we'll buffer waiting for the
// header terminator. 64 KiB is well past any reasonable rafthttp
// request and large enough to absorb the headers + a small body.
const maxAuthHeaderBuffer = 64 * 1024
// NewAuthHandler wraps an inner rafthttp.Handler with an HTTP basic-auth
// check. Requests without matching credentials are rejected with 401
// before the rafthttp path runs.
//
// The auth check uses crypto/subtle.ConstantTimeCompare to avoid timing
// leaks on the credential comparison.
func NewAuthHandler(inner *rafthttp.Handler, creds HTTPCreds, logger *log.Logger) http.Handler {
if logger == nil {
logger = log.Default()
}
if !creds.IsConfigured() {
// We panic on construction rather than at request time so a
// misconfigured worker fails fast at startup.
panic("workercluster: NewAuthHandler requires both login and password")
}
expectedUser := []byte(creds.Login)
expectedPass := []byte(creds.Password)
return &authHandler{inner: inner, expectedUser: expectedUser, expectedPass: expectedPass, logger: logger}
}
type authHandler struct {
inner *rafthttp.Handler
expectedUser []byte
expectedPass []byte
logger *log.Logger
}
func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok ||
subtle.ConstantTimeCompare([]byte(user), a.expectedUser) != 1 ||
subtle.ConstantTimeCompare([]byte(pass), a.expectedPass) != 1 {
a.logger.Printf("[WARN] raft-http: rejected %s %s from %s: bad credentials",
r.Method, r.URL.Path, r.RemoteAddr)
w.Header().Set("WWW-Authenticate", `Basic realm="raft"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
a.inner.ServeHTTP(w, r)
}
// Unwrap exposes the inner rafthttp.Handler so callers like
// unwrapRafthttpHandler can find it.
func (a *authHandler) Unwrap() http.Handler { return a.inner }
// NewTransport builds a rafthttp Layer + NetworkTransport pair bound to
// the given listener. The returned Layer is ready to hand to
// raft.NewNetworkTransport. Close must be called on shutdown to drain
// the Layer's HTTP handler.
//
// dial is the rafthttp.Dial used to connect to peers; if nil, the
// rafthttp.NewDialTCP default is used. handler is an http.Handler that
// owns the rafthttp endpoint; production callers pass the auth wrapper
// from NewAuthHandler. The inbound listener is started by the caller
// because the listener needs to be running before peers can dial in.
func NewTransport(
raftPath string,
listener net.Listener,
handler http.Handler,
dial rafthttp.Dial,
logOutput io.Writer,
) (*rafthttp.Layer, *http.Server, error) {
if raftPath == "" {
raftPath = "/raft"
}
if listener == nil {
return nil, nil, fmt.Errorf("workercluster: listener is required")
}
if handler == nil {
return nil, nil, fmt.Errorf("workercluster: handler is required")
}
if dial == nil {
dial = rafthttp.NewDialTCP()
}
logger := log.New(logOutput, "[raft-http] ", log.LstdFlags)
realHandler, ok := unwrapRafthttpHandler(handler)
if !ok {
return nil, nil, fmt.Errorf("workercluster: handler must wrap a *rafthttp.Handler")
}
layer := rafthttp.NewLayerWithLogger(raftPath, listener.Addr(), realHandler, dial, logger)
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
return layer, server, nil
}
// unwrapRafthttpHandler walks a chain of http.Handler wrappers and
// returns the *rafthttp.Handler at the bottom. Production wraps it in
// NewAuthHandler; tests can wrap it in additional middleware.
func unwrapRafthttpHandler(h http.Handler) (*rafthttp.Handler, bool) {
for {
switch v := h.(type) {
case *rafthttp.Handler:
return v, true
case interface{ Unwrap() http.Handler }:
h = v.Unwrap()
default:
return nil, false
}
}
}
// CheckBasicAuth is a small helper used by transport_test.go to confirm
// the auth wrapper rejects bad creds and accepts good ones without
// needing the rafthttp Library state.
func CheckBasicAuth(h http.Handler, r *http.Request, login, password string) bool {
r.Header.Set("Authorization", basicAuthHeader(login, password))
rec := &recordingResponseWriter{header: http.Header{}}
h.ServeHTTP(rec, r)
return rec.status == http.StatusOK
}
// basicAuthHeader returns the value of an HTTP Basic Authorization
// header for the given user/password pair. Exported for tests; production
// code uses Go's r.BasicAuth() helper.
func basicAuthHeader(user, pass string) string {
const prefix = "Basic "
value := user + ":" + pass
return prefix + base64Encode(value)
}
// base64Encode is a tiny indirection so tests do not import encoding/base64
// directly; the production call sites use Go's standard library.
func base64Encode(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// recordingResponseWriter is a minimal http.ResponseWriter for tests.
type recordingResponseWriter struct {
header http.Header
body []byte
status int
}
func (w *recordingResponseWriter) Header() http.Header { return w.header }
func (w *recordingResponseWriter) Write(b []byte) (int, error) {
w.body = append(w.body, b...)
if w.status == 0 {
w.status = http.StatusOK
}
return len(b), nil
}
func (w *recordingResponseWriter) WriteHeader(status int) {
w.status = status
}