~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
471 lines · 13.4 KB Raw History
package gatewayapi

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"maps"
	"net/http"
	"os"
	"strings"
	"sync"

	"github.com/bluesky-social/indigo/atproto/identity"

	commonv1 "github.com/johnandersen777/socialweb-computer-kcp/api/common/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/component"
	contractnodeclaim "github.com/johnandersen777/socialweb-computer-kcp/internal/contract/nodeclaim"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gateway"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gateway/custody"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gateway/nodeclaim"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gatewayhttp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcpclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/kcp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/pinneddownloads"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/supervise"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/xrpcserver"
)

type Service struct {
	spec v1alpha1.GatewayAPISpec

	deps Deps

	log *slog.Logger

	workspace string

	stateDir string

	publicOrigin string

	audiences []string

	policyArgs map[string]any

	homeWorkspaces []string

	contractWorkspace string

	server *gateway.Server

	mu sync.Mutex

	oauth *gatewayhttp.OAuth

	surface *xrpcserver.Server

	nodeClaims *contractnodeclaim.Binding

	stopCh chan struct{}

	stopOnce *sync.Once

	ready bool

	readiness component.ReadySignal
}

func (s *Service) Start(ctx context.Context) error {
	log := s.log
	if s.stateDir == "" {
		return errors.New("gatewayapi: no state dir")
	}
	if err := os.MkdirAll(s.stateDir, 0o700); err != nil {
		return fmt.Errorf("gatewayapi: creating the state dir: %w", err)
	}
	if s.spec.NodesOnly {
		return s.startNodesOnly(ctx)
	}

	vault, err := s.resolveVault(ctx)
	if err != nil {
		return err
	}
	if vault == nil {
		return errors.New("gatewayapi: no sign-in backend")
	}
	signIn, err := custody.New(ctx, custody.Config{
		Endpoint:       vault.endpoint,
		Role:           DefaultAtprotoRole,
		RenewBelow:     DefaultRenewBelow,
		RenewIncrement: DefaultRenewIncrement,
		Network:        s.spec.Network,
	}, custody.Deps{TLS: s.deps.TLS, Log: log})
	if err != nil {
		return err
	}
	defer signIn.Close()
	tasks := supervise.New(ctx)
	defer func() { _ = tasks.Stop() }()

	pinned, err := s.loadDownloads()
	if err != nil {
		return err
	}
	handler, oauth, surface, err := s.buildHandler(ctx, signIn, pinned, tasks)
	if err != nil {
		return err
	}
	nodeClaims, err := s.buildNodeClaims(vault, pinned)
	if err != nil {
		if surface != nil {
			surface.Close()
		}
		return err
	}
	return s.serve(ctx, handler, oauth, surface, nodeClaims)
}

func (s *Service) startNodesOnly(ctx context.Context) error {
	pinned, err := s.loadDownloads()
	if err != nil {
		return err
	}
	nodeClaims, err := s.buildNodeClaims(&vaultRuntime{mount: strings.Trim(s.spec.NodeMarket.VaultMount, "/")}, pinned)
	if err != nil {
		return err
	}
	if nodeClaims == nil {
		return errors.New("gatewayapi: nodesOnly needs a nodeMarket")
	}
	mux := http.NewServeMux()
	gatewayhttp.NewDownloads(gatewayhttp.DownloadsOptions{Downloads: pinned, Log: s.log}).Register(mux)
	mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeAPINotFound(w) }))
	return s.serve(ctx, securityHeaders(mux), nil, nil, nodeClaims)
}

func (s *Service) serve(ctx context.Context, handler http.Handler, oauth *gatewayhttp.OAuth, surface *xrpcserver.Server, nodeClaims *contractnodeclaim.Binding) error {
	log := s.log
	stopCh := make(chan struct{})

	s.mu.Lock()
	if s.stopCh != nil {
		s.mu.Unlock()
		if surface != nil {
			surface.Close()
		}
		return errors.New("gatewayapi: the service already started")
	}
	if s.deps.Bus == nil {
		s.mu.Unlock()
		if surface != nil {
			surface.Close()
		}
		return errors.New("gatewayapi: the handler registry is not available")
	}
	key := component.Key{Workspace: s.workspace, Kind: Kind, Name: s.deps.Self.Name}
	if err := s.deps.Bus.RegisterHTTP(key, handler); err != nil {
		s.mu.Unlock()
		if surface != nil {
			surface.Close()
		}
		return err
	}
	s.oauth = oauth
	s.surface = surface
	s.nodeClaims = nodeClaims
	s.stopCh = stopCh
	s.stopOnce = &sync.Once{}
	s.ready = true
	s.mu.Unlock()

	if nodeClaims != nil {
		s.deps.NodeClaims.Bind(*nodeClaims)
	}
	log.Info("gatewayapi_registered", "handler", kcp.HandlerURL(s.workspace, Kind, s.deps.Self.Name), "network", networkName(s.spec.Network))
	s.readiness.Signal()

	select {
	case <-ctx.Done():
	case <-stopCh:
	}

	s.shutdown(ctx)
	s.finish()
	return nil
}

func (s *Service) Ready() component.Readiness {
	return &s.readiness
}

func (s *Service) Endpoints() []component.Endpoint {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.ready {
		return nil
	}
	return []component.Endpoint{
		{Name: "http", Protocol: component.ProtocolHTTP},
	}
}

func (s *Service) Stop(ctx context.Context) error {
	s.mu.Lock()
	stopCh := s.stopCh
	stopOnce := s.stopOnce
	s.mu.Unlock()
	if stopCh == nil || stopOnce == nil {
		return nil
	}
	stopOnce.Do(func() { close(stopCh) })
	s.shutdown(ctx)
	return nil
}

func (s *Service) OAuth() *gatewayhttp.OAuth {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.oauth
}

func (s *Service) buildHandler(ctx context.Context, signIn gatewayhttp.WebSignIn, pinned *pinneddownloads.Set, tasks *supervise.Group) (http.Handler, *gatewayhttp.OAuth, *xrpcserver.Server, error) {
	log := s.log
	oauth, err := gatewayhttp.NewOAuth(gatewayhttp.OAuthOptions{
		SignIn:        signIn,
		PublicOrigin:  s.publicOrigin,
		PostSignInURL: s.spec.PostSignInURL,
		Tasks:         tasks,
		Log:           log,
	})
	if err != nil {
		return nil, nil, nil, err
	}

	cluster, err := gatewayhttp.NewClusterAPI(gatewayhttp.ClusterOptions{
		Gateway: gatewayhttp.DirectClusterGateway{Server: s.server},
		Caller: func(ctx context.Context, r *http.Request) (gatewayhttp.ClusterCaller, error) {
			return oauth.RequestCaller(r)
		},
		Log: log,
	})
	if err != nil {
		return nil, nil, nil, err
	}
	downloads := gatewayhttp.NewDownloads(gatewayhttp.DownloadsOptions{Downloads: pinned, Log: log})

	surface, err := xrpcserver.New(ctx, xrpcserver.Options{
		Gateway:   s.server,
		Audiences: append([]string(nil), s.audiences...),
		Directory: directoryFor(plcURL(s.spec.Network)),
	})
	if err != nil {
		return nil, nil, nil, err
	}

	mux := http.NewServeMux()
	cluster.Register(mux)
	oauth.Register(mux)
	downloads.Register(mux)
	xrpcHandler := surface.Handler()
	mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasPrefix(r.URL.Path, xrpcserver.XRPCPrefix) {
			xrpcHandler.ServeHTTP(w, r)
			return
		}
		writeAPINotFound(w)
	}))
	return securityHeaders(mux), oauth, surface, nil
}

func (s *Service) loadDownloads() (*pinneddownloads.Set, error) {
	dir := s.downloadsDir()
	if dir == "" {
		return nil, nil
	}
	pinned, err := pinneddownloads.Load(dir)
	if err != nil {
		return nil, fmt.Errorf("gatewayapi: the downloads dir: %w", err)
	}
	if missing := pinned.Missing(s.spec.RequiredDownloads); len(missing) > 0 {
		return nil, fmt.Errorf("gatewayapi: required pinned downloads are missing from %s: %s", dir, strings.Join(missing, ", "))
	}
	return pinned, nil
}

func (s *Service) buildNodeClaims(vault *vaultRuntime, pinned *pinneddownloads.Set) (*contractnodeclaim.Binding, error) {
	nodeMarket := s.spec.NodeMarket
	if nodeMarket == nil {
		return nil, nil
	}
	log := s.log
	if s.deps.NodeClaims == nil {
		return nil, errors.New("gatewayapi: the node market needs the NodeClaim controller")
	}
	if vault == nil || vault.mount == "" {
		return nil, errors.New("gatewayapi: the node market needs a vault mount")
	}
	homes := append([]string(nil), s.homeWorkspaces...)
	indexWorkspace, indexNamespace := s.workspace+":"+contractnodeclaim.AgentWorkspace, contractnodeclaim.AgentNamespace
	if ref, err := kcpclient.ParseServiceAccountRef(os.Getenv("SOCIALWEB_GATEWAY_NODECLAIM_AGENT")); err == nil && ref != nil {
		indexWorkspace, indexNamespace = ref.Workspace, ref.Namespace
	} else if os.Getenv("SOCIALWEB_GATEWAY_NODECLAIM_AGENT") != "" {
		log.Warn("gatewayapi: the node claim agent ref did not parse", "ref", os.Getenv("SOCIALWEB_GATEWAY_NODECLAIM_AGENT"), "err", err)
	}
	log.Info("gatewayapi node market", "homes", homes, "indexWorkspace", indexWorkspace, "indexNamespace", indexNamespace,
		"contractWorkspace", s.contractWorkspace)
	cfg := nodeclaim.Config{VaultMount: vault.mount, VMReadyTimeoutSec: DefaultVMReadySec}
	if nodeMarket.NodePolicy != "" {
		args := map[string]any{}
		maps.Copy(args, s.policyArgs)
		cfg.Policy = &nodeclaim.Policy{Name: nodeMarket.NodePolicy, Args: args}
	}
	if nodeMarket.UserDataBaseFile != "" {
		raw, err := os.ReadFile(nodeMarket.UserDataBaseFile)
		if err != nil {
			return nil, fmt.Errorf("gatewayapi: reading the node user data base: %w", err)
		}
		cfg.UserDataBase = string(raw)
	}
	deps := nodeclaim.Deps{CAFileOf: nodeclaim.DefaultOpenBaoCAFile, Log: log}
	if dir := s.clusterCADir(); dir != "" {
		deps.CAFileOf = nodeclaim.PublishedOpenBaoCAFile(dir)
	}
	var downloads []pinneddownloads.PinnedDownload
	if pinned != nil {
		downloads = pinned.List()
	}
	meshPins, agentPins := gatewayhttp.MeshdPinsOf(downloads), gatewayhttp.NodeAgentPinsOf(downloads)
	if s.spec.Network != nil {
		if meshPins != nil {
			cfg.Meshd = &nodeclaim.Pin{Sha256: meshPins}
		}
		if agentPins != nil {
			cfg.NodeAgent = &nodeclaim.Pin{Sha256: agentPins}
		}
		if cfg.Meshd != nil || cfg.NodeAgent != nil {
			baseOf := s.cachedDownloadBase()
			deps.MeshdBase, deps.NodeAgentBase = baseOf, baseOf
		}
	} else if base := s.downloadBase(); base != "" {
		if meshPins != nil {
			cfg.Meshd = &nodeclaim.Pin{DownloadBase: base, Sha256: meshPins}
		}
		if agentPins != nil {
			cfg.NodeAgent = &nodeclaim.Pin{DownloadBase: base, Sha256: agentPins}
		}
	}
	mapper, err := nodeclaim.New(cfg, deps)
	if err != nil {
		return nil, fmt.Errorf("gatewayapi: %w", err)
	}
	return &contractnodeclaim.Binding{Owner: s.selfRef(), Mapper: mapper, Vault: s.vaultRef(), ContractWorkspace: s.contractWorkspace,
		IndexWorkspace: indexWorkspace, IndexNamespace: indexNamespace, Homes: homes, Workspace: s.workspace, Record: nodeMarket.InfraRecord.DeepCopy()}, nil
}

func (s *Service) cachedDownloadBase() func(context.Context) (string, bool) {
	var mu sync.Mutex
	base := ""
	return func(ctx context.Context) (string, bool) {
		mu.Lock()
		cached := base
		mu.Unlock()
		if cached != "" {
			return cached, true
		}
		resolved := strings.TrimRight(strings.TrimSpace(s.spec.PublicOrigin), "/")
		if resolved == "" {
			resolved = s.localDownloadBase(ctx)
		}
		if resolved == "" {
			return "", false
		}
		mu.Lock()
		base = resolved
		mu.Unlock()
		return resolved, true
	}
}

func (s *Service) selfRef() commonv1.ObjectRef {
	return commonv1.ObjectRef{Group: v1alpha1.GroupName, Kind: Kind, Name: s.deps.Self.Name, Workspace: s.workspace}
}

func (s *Service) shutdown(ctx context.Context) {
	s.mu.Lock()
	nodeClaims := s.nodeClaims
	s.mu.Unlock()
	if nodeClaims != nil {
		s.deps.NodeClaims.Unbind(nodeClaims.Owner)
	}
	s.mu.Lock()
	surface := s.surface
	s.mu.Unlock()
	if surface != nil {
		surface.Close()
	}
}

func (s *Service) finish() {
	s.mu.Lock()
	s.ready = false
	s.stopCh = nil
	s.stopOnce = nil
	s.nodeClaims = nil
	s.surface = nil
	s.mu.Unlock()
}

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Referrer-Policy", "no-referrer")
		w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
		if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/session" {
			w.Header().Set("Cache-Control", "no-store")
		}
		next.ServeHTTP(w, r)
	})
}

func writeAPINotFound(w http.ResponseWriter) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusNotFound)
	_, _ = w.Write([]byte(`{"error":"NotFound","message":"no such API route"}` + "\n"))
}

func networkName(network *v1alpha1.MarketNetworkValue) string {
	if network == nil {
		return ""
	}
	return network.PLCDirectoryURL
}

func plcURL(network *v1alpha1.MarketNetworkValue) string {
	if network == nil || network.PLCDirectoryURL == "" {
		return identity.DefaultPLCURL
	}
	return strings.TrimRight(network.PLCDirectoryURL, "/")
}

func directoryFor(raw string) identity.Directory {
	base := identity.DefaultDirectory()
	raw = strings.TrimRight(raw, "/")
	if raw == "" || raw == strings.TrimRight(identity.DefaultPLCURL, "/") {
		return base
	}
	cached, ok := base.(*identity.CacheDirectory)
	if !ok {
		return base
	}
	inner := cached.Inner
	if directory, ok := inner.(*identity.BaseDirectory); ok {
		inner = &identity.BaseDirectory{
			PLCURL:                 raw,
			PLCLimiter:             directory.PLCLimiter,
			DIDWebLimitFunc:        directory.DIDWebLimitFunc,
			HTTPClient:             directory.HTTPClient,
			PLCClient:              directory.PLCClient,
			TryAuthoritativeDNS:    directory.TryAuthoritativeDNS,
			SkipDNSDomainSuffixes:  directory.SkipDNSDomainSuffixes,
			FallbackDNSServers:     directory.FallbackDNSServers,
			SkipHandleVerification: directory.SkipHandleVerification,
			UserAgent:              directory.UserAgent,
		}
	}
	return identity.NewCacheDirectory(inner, DefaultDirectoryKeys, DefaultDirectoryTTL, DefaultDirectoryTTL, DefaultDirectoryTTL)
}