mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Implements the chatd stabilization RFC. Combines: - https://github.com/coder/coder/pull/25908 - https://github.com/coder/coder/pull/25923 - https://github.com/coder/coder/pull/26109 - https://github.com/coder/coder/pull/26110 - https://github.com/coder/coder/pull/26111 - https://github.com/coder/coder/pull/26112
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
osschatd "github.com/coder/coder/v2/coderd/x/chatd"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
// RelaySourceHeader marks replica-relayed stream requests.
|
|
const RelaySourceHeader = "X-Coder-Relay-Source-Replica"
|
|
|
|
const (
|
|
authorizationHeader = "Authorization"
|
|
cookieHeader = "Cookie"
|
|
)
|
|
|
|
// RelayDialError wraps a failed relay handshake. HTTPStatus is 0
|
|
// when the failure happened before a response.
|
|
type RelayDialError struct {
|
|
HTTPStatus int
|
|
Err error
|
|
}
|
|
|
|
func (e *RelayDialError) Error() string { return e.Err.Error() }
|
|
func (e *RelayDialError) Unwrap() error { return e.Err }
|
|
|
|
// IsUnrecoverable reports whether retrying with the same captured
|
|
// session token is futile.
|
|
func (e *RelayDialError) IsUnrecoverable() bool {
|
|
return e.HTTPStatus == http.StatusUnauthorized ||
|
|
e.HTTPStatus == http.StatusForbidden
|
|
}
|
|
|
|
// StreamPartsDialerConfig holds dependencies for multi-replica stream parts.
|
|
type StreamPartsDialerConfig struct {
|
|
ResolveReplicaAddress func(context.Context, uuid.UUID) (string, bool)
|
|
ReplicaHTTPClient *http.Client
|
|
ReplicaIDFn func() uuid.UUID
|
|
DialerFn func(context.Context, osschatd.StreamPartsDialInput) (osschatd.StreamPartsSession, error)
|
|
}
|
|
|
|
// NewStreamPartsDialer returns a dialer for the owning replica's parts endpoint.
|
|
func NewStreamPartsDialer(cfg StreamPartsDialerConfig) osschatd.StreamPartsDialer {
|
|
return func(ctx context.Context, input osschatd.StreamPartsDialInput) (osschatd.StreamPartsSession, error) {
|
|
if cfg.DialerFn != nil {
|
|
return cfg.DialerFn(ctx, input)
|
|
}
|
|
return dialRelayParts(ctx, input, cfg)
|
|
}
|
|
}
|
|
|
|
func dialRelayParts(
|
|
ctx context.Context,
|
|
input osschatd.StreamPartsDialInput,
|
|
cfg StreamPartsDialerConfig,
|
|
) (osschatd.StreamPartsSession, error) {
|
|
if cfg.ResolveReplicaAddress == nil {
|
|
return nil, &RelayDialError{Err: xerrors.New("dial relay stream parts: resolver not configured")}
|
|
}
|
|
address, ok := cfg.ResolveReplicaAddress(ctx, input.WorkerID)
|
|
if !ok {
|
|
return nil, &RelayDialError{Err: xerrors.New("dial relay stream parts: worker replica not found")}
|
|
}
|
|
wsURL, err := buildRelayURL(address, input.ChatID)
|
|
if err != nil {
|
|
return nil, &RelayDialError{Err: xerrors.Errorf("dial relay stream parts: %w", err)}
|
|
}
|
|
|
|
if cfg.ReplicaIDFn == nil {
|
|
return nil, &RelayDialError{Err: xerrors.New("dial relay stream parts: replica ID function not configured")}
|
|
}
|
|
replicaID := cfg.ReplicaIDFn()
|
|
if replicaID == uuid.Nil {
|
|
return nil, &RelayDialError{Err: xerrors.New("dial relay stream parts: replica ID is nil")}
|
|
}
|
|
headers := make(http.Header, 2)
|
|
headers.Set(codersdk.SessionTokenHeader, extractSessionToken(input.RequestHeader))
|
|
headers.Set(RelaySourceHeader, replicaID.String())
|
|
|
|
conn, resp, dialErr := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
|
HTTPClient: cfg.ReplicaHTTPClient,
|
|
HTTPHeader: headers,
|
|
CompressionMode: websocket.CompressionDisabled,
|
|
})
|
|
status := 0
|
|
if resp != nil {
|
|
status = resp.StatusCode
|
|
if dialErr != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|
|
if dialErr != nil {
|
|
return nil, &RelayDialError{
|
|
HTTPStatus: status,
|
|
Err: xerrors.Errorf("dial relay stream parts: %w", dialErr),
|
|
}
|
|
}
|
|
conn.SetReadLimit(1 << 22)
|
|
return osschatd.NewStreamPartsJSONSession(ctx, conn), nil
|
|
}
|
|
|
|
// buildRelayURL builds the websocket URL for the chat stream parts endpoint on
|
|
// a peer replica. It maps http(s) schemes to ws(s).
|
|
func buildRelayURL(address string, chatID uuid.UUID) (string, error) {
|
|
u, err := url.Parse(address)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("parse relay address %q: %w", address, err)
|
|
}
|
|
switch u.Scheme {
|
|
case "http":
|
|
u.Scheme = "ws"
|
|
case "https":
|
|
u.Scheme = "wss"
|
|
case "ws", "wss":
|
|
default:
|
|
return "", xerrors.Errorf("unsupported relay address scheme %q", u.Scheme)
|
|
}
|
|
u.Path = "/api/experimental/chats/" + chatID.String() + "/stream/parts"
|
|
u.RawQuery = ""
|
|
return u.String(), nil
|
|
}
|
|
|
|
// extractSessionToken returns the session token carried by the given request
|
|
// headers. It mirrors the priority order used by apiKeyMiddleware: cookie,
|
|
// then Coder-Session-Token header, then Authorization: Bearer header.
|
|
func extractSessionToken(header http.Header) string {
|
|
if header == nil {
|
|
return ""
|
|
}
|
|
if raw := header.Get(cookieHeader); raw != "" {
|
|
r := &http.Request{Header: http.Header{cookieHeader: {raw}}}
|
|
if c, err := r.Cookie(codersdk.SessionTokenCookie); err == nil && c.Value != "" {
|
|
return c.Value
|
|
}
|
|
}
|
|
if v := header.Get(codersdk.SessionTokenHeader); v != "" {
|
|
return v
|
|
}
|
|
if v := header.Get(authorizationHeader); len(v) > 7 && strings.EqualFold(v[:7], "bearer ") {
|
|
return strings.TrimSpace(v[7:])
|
|
}
|
|
return ""
|
|
}
|