~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
249 lines · 5.9 KB Raw History
package relay

import (
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/ingress"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/market"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/guestcp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/host"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/pds"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/hostport"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
)

const (
	DefaultTokenLifetime = 5 * time.Minute

	maxResponseBody = 8 << 20

	retryFirst = time.Second

	retryMax = time.Minute
)

type Config struct {
	Label string

	KeyFile string

	IngressProxyHost string

	TLS bool

	TokenLifetime time.Duration
}

type Deps struct {
	Handler http.Handler

	Subscribe func(subscriptionID, nsid string, params, headers map[string]string, onEvent func(json.RawMessage), onData func([]byte)) (func(), error)

	ServiceAuth pds.ServiceAuth

	DID func() string

	Clock host.Clock

	Log *slog.Logger
}

type Relay struct {
	cfg Config

	deps Deps

	log *slog.Logger

	clock host.Clock

	mu sync.Mutex

	subscriber *ingress.Subscriber

	closed bool
}

var _ guestcp.Relay = (*Relay)(nil)

func New(cfg Config, deps Deps) (*Relay, error) {
	cfg.IngressProxyHost = strings.TrimSpace(cfg.IngressProxyHost)
	switch {
	case strings.TrimSpace(cfg.KeyFile) == "":
		return nil, errors.New("guestcp: the relay names no keypair and there is no state dir")
	case cfg.IngressProxyHost == "":
		return nil, errors.New("guestcp: the relay names no ingress proxy host")
	case deps.Handler == nil:
		return nil, errors.New("guestcp: the relay has no handler")
	case deps.ServiceAuth == nil:
		return nil, errors.New("guestcp: the relay has no service-auth token source")
	case deps.DID == nil:
		return nil, errors.New("guestcp: the relay has no account DID source")
	}
	cfg.TokenLifetime = opt.Positive(cfg.TokenLifetime, DefaultTokenLifetime)
	log := deps.Log
	if log == nil {
		log = slog.New(slog.DiscardHandler)
	}
	clock := deps.Clock
	if clock == nil {
		clock = systemClock{}
	}
	return &Relay{cfg: cfg, deps: deps, log: log, clock: clock}, nil
}

func (r *Relay) Run(ctx context.Context, connected func()) error {
	signer, err := market.LoadOrCreateSignerFile(r.cfg.KeyFile)
	if err != nil {
		return err
	}
	did, err := signer.PublicDID()
	if err != nil {
		return err
	}
	delay := retryFirst
	for {
		subscriber, err := ingress.NewSubscriber(ctx, ingress.SubscriberConfig{
			Label:               r.cfg.Label,
			Keypair:             signerKeypair{signer: signer, did: did},
			GetServiceAuthToken: r.serviceAuth,
			IngressProxyHost:    r.cfg.IngressProxyHost,
			TLS:                 r.cfg.TLS,
			HandleRequest:       r.forward,

			DirectSubscriptionHandler: r.deps.Subscribe,
		})
		if err == nil {
			if !r.hold(subscriber) {
				subscriber.Close()
				return nil
			}
			if connected != nil {
				connected()
			}
			<-ctx.Done()
			return nil
		}
		if ctx.Err() != nil {
			return nil
		}
		r.log.Warn("the guest relay did not connect; retrying", "err", err, "retry", delay)
		select {
		case <-ctx.Done():
			return nil
		case <-r.clock.After(delay):
		}
		delay = min(delay*2, retryMax)
	}
}

func (r *Relay) hold(subscriber *ingress.Subscriber) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.closed {
		return false
	}
	r.subscriber = subscriber
	return true
}

func (r *Relay) Close() {
	r.mu.Lock()
	subscriber := r.subscriber
	r.subscriber = nil
	r.closed = true
	r.mu.Unlock()
	if subscriber != nil {
		subscriber.Close()
	}
}

func (r *Relay) URL() string {
	r.mu.Lock()
	subscriber := r.subscriber
	r.mu.Unlock()
	if subscriber == nil {
		return ""
	}
	host := r.cfg.IngressProxyHost
	scheme := "https"
	if !r.cfg.TLS && (strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "localhost")) {
		scheme = "http"
	}
	return scheme + "://" + subscriber.Subdomain() + "." + host
}

func (r *Relay) serviceAuth(ctx context.Context, nsid string) (string, error) {
	did := strings.TrimSpace(r.deps.DID())
	if did == "" {
		return "", errors.New("guestcp: the relay account DID is not known yet")
	}
	audience := "did:web:" + hostport.URLHostname(r.cfg.IngressProxyHost)
	return r.deps.ServiceAuth.ServiceAuth(ctx, did, audience, nsid, r.clock.Now().Add(r.cfg.TokenLifetime))
}

func (r *Relay) forward(ctx context.Context, request *ingress.Request) (*ingress.Response, error) {
	if request == nil {
		return nil, errors.New("guestcp: the relay request is empty")
	}
	path := cmp.Or(request.Path, "/")
	if !strings.HasPrefix(path, "/") {
		path = "/" + path
	}
	if len(request.Params) > 0 {
		values := url.Values{}
		for key, value := range request.Params {
			values.Set(key, value)
		}
		path += "?" + values.Encode()
	}
	body := request.RawBody
	if body == "" && len(request.Body) > 0 {
		body = string(request.Body)
	}
	req, err := http.NewRequestWithContext(ctx, request.Method, "http://relay.local"+path, strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	for key, value := range request.Headers {
		req.Header.Set(key, value)
	}
	recorder := httptest.NewRecorder()
	r.deps.Handler.ServeHTTP(recorder, req)
	result := recorder.Result()
	defer result.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(result.Body, maxResponseBody))
	if err != nil {
		return nil, err
	}
	return &ingress.Response{Status: result.StatusCode, Body: json.RawMessage(raw), ContentType: result.Header.Get("Content-Type")}, nil
}

type signerKeypair struct {
	signer *market.Signer

	did string
}

func (k signerKeypair) DID() string { return k.did }

func (k signerKeypair) Sign(data []byte) ([]byte, error) {
	return market.SignBytes(data, k.signer.PrivateKey)
}

type systemClock struct{}

func (systemClock) Now() time.Time { return time.Now() }

func (systemClock) After(d time.Duration) <-chan time.Time { return time.After(d) }