~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
520 lines · 13.6 KB Raw History
package pds

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/atcrypto"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/bluesky-social/indigo/util/ssrf"
	"k8s.io/utils/ptr"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/oauth"
	atpds "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pds"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pds/files"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pds/plcsubmit"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/repo"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/bus"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/component"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/hostos"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/kcp"
)

const (
	shutdownTimeout  = 10 * time.Second
	directoryTimeout = 10 * time.Second
)

type Service struct {
	spec v1alpha1.PdsSpec
	deps Deps

	mode          atpds.Mode
	stateDir      string
	publicBaseURL string
	plcURL        string

	mu       sync.Mutex
	started  bool
	endpoint component.Endpoint
	key      component.Key
	baseURL  string
	server   *atpds.Server
	stopCh   chan struct{}
	stopOnce sync.Once

	readiness component.ReadySignal

	closeOnce    sync.Once
	repoStore    *repo.SQLiteStore
	accountStore *atpds.Store
	keyStore     atpds.RepoKeyStore
}

var _ component.Service = (*Service)(nil)

func New(spec v1alpha1.PdsSpec, deps Deps) (*Service, error) {
	s := &Service{
		spec:          spec,
		deps:          deps,
		mode:          atpds.ModeFull,
		stateDir:      strings.TrimSpace(deps.StateDir),
		publicBaseURL: strings.TrimRight(strings.TrimSpace(spec.PublicBaseURL), "/"),
		plcURL:        strings.TrimRight(strings.TrimSpace(spec.PlcURL), "/"),
	}
	if spec.Mode == v1alpha1.PdsModeRepo {
		s.mode = atpds.ModeRepo
	}
	if s.mode == atpds.ModeRepo && spec.OAuth != nil {
		return nil, errors.New("pds: oauth is not available in read-only repo mode")
	}
	if spec.Storage != nil {
		s.stateDir = cmp.Or(strings.TrimSpace(spec.Storage.DataDir), s.stateDir)
	}
	if s.plcURL == "" && spec.Network != nil {
		s.plcURL = strings.TrimRight(strings.TrimSpace(spec.Network.PLCDirectoryURL), "/")
	}
	if spec.RequireIngress && s.publicBaseURL != "" {
		if err := publicEndpoint(s.publicBaseURL); err != nil {
			return nil, err
		}
	}
	return s, nil
}

func (s *Service) componentPath(name string) string {
	if s.deps.StateDir == "" {
		return ""
	}
	return filepath.Join(s.deps.StateDir, name)
}

func (s *Service) serviceDID() string {
	if s.spec.Identity == nil {
		return ""
	}
	return strings.TrimSpace(s.spec.Identity.Did)
}

func (s *Service) alsoKnownAs() []string {
	if s.spec.Identity == nil {
		return nil
	}
	if handle := strings.TrimSpace(s.spec.Identity.Handle); handle != "" {
		return []string{"at://" + handle}
	}
	return nil
}

func (s *Service) serviceAuthAudiences() []string {
	if s.spec.ServiceAuth == nil {
		return nil
	}
	return append([]string(nil), s.spec.ServiceAuth.Audience...)
}

func publicEndpoint(raw string) error {
	u, err := url.Parse(raw)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return fmt.Errorf("pds: the ingress public URL %q is not a scheme, host and optional port", raw)
	}
	if !strings.EqualFold(u.Scheme, "https") {
		return fmt.Errorf("pds: the ingress public URL %q must be https", raw)
	}
	if u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
		return fmt.Errorf("pds: the ingress public URL %q must carry no userinfo, path, query or fragment", raw)
	}
	return nil
}

func (s *Service) Start(ctx context.Context) error {
	s.mu.Lock()
	if s.started {
		s.mu.Unlock()
		return errors.New("pds: the service is already started")
	}
	s.mu.Unlock()

	stateDir := s.stateDir
	if stateDir == "" {
		return errors.New("pds: a state directory is required")
	}
	if err := os.MkdirAll(stateDir, 0o700); err != nil {
		return fmt.Errorf("pds: creating the state directory: %w", err)
	}
	log := s.deps.Log
	var err error
	unlock, err := lockStateDir(stateDir)
	if err != nil {
		return err
	}
	defer unlock()
	var (
		repoStore    *repo.SQLiteStore
		accountStore *atpds.Store
	)
	success := false
	cleanup := func() {
		if accountStore != nil {
			_ = accountStore.Close()
		}
		if repoStore != nil {
			_ = repoStore.Close()
		}
	}
	defer func() {
		if !success {
			cleanup()
		}
	}()

	key := s.deps.Key()
	key.Kind = cmp.Or(key.Kind, Kind)
	key.Name = cmp.Or(key.Name, "default")
	logicalURL := "http://" + logicalHost(key)
	baseURL := cmp.Or(s.publicBaseURL, logicalURL)
	plcURL := s.plcURL
	if plcURL != "" && log != nil {
		log.Info("pds plc directory", "url", plcURL, "publicBaseURL", baseURL)
	}
	handlers := s.deps.Bus
	var plcClient *http.Client
	if plcURL != "" {
		if handlers != nil && strings.HasPrefix(strings.ToLower(plcURL), "http://") && !isLoopbackURL(plcURL) {
			plcClient = handlers.HTTPClient()
		} else {
			plcClient = s.deps.Egress.Client(directoryTimeout)
		}
	}

	repoStore, err = repo.NewSQLiteStore(ctx, filepath.Join(stateDir, "repos.db"))
	if err != nil {
		return fmt.Errorf("pds: opening the repo store: %w", err)
	}
	if s.mode == atpds.ModeFull {
		accountStore, err = atpds.NewStore(ctx, filepath.Join(stateDir, "accounts.db"))
		if err != nil {
			return fmt.Errorf("pds: opening the account store: %w", err)
		}
	}
	blobDir := filepath.Join(stateDir, "blobs")
	if s.spec.Storage != nil {
		blobDir = cmp.Or(strings.TrimSpace(s.spec.Storage.BlobsDir), blobDir)
	}
	blobStore, err := files.NewFileBlobStore(blobDir)
	if err != nil {
		return err
	}

	var sessionSecret []byte
	if s.mode == atpds.ModeFull {
		sessionSecret, err = files.SessionSecret(stateDir, s.componentPath(SessionSecretFile))
		if err != nil {
			return err
		}
	}

	keyStore, err := files.RepoKeyStore(s.componentPath(RepoKeyDir))
	if err != nil {
		return err
	}
	if s.mode == atpds.ModeFull && keyStore == nil {
		return errors.New("pds: full mode needs a repo key store (a state dir for repo-keys)")
	}

	directory := buildDirectory(plcURL, plcClient, handlers, s.deps.Egress)

	var submitter atpds.PLCSubmitter
	var rotation atcrypto.PrivateKey
	if s.mode == atpds.ModeFull && plcURL != "" {
		rotation, err = files.RotationKey(stateDir, s.componentPath(RotationKeyFile))
		if err != nil {
			return err
		}
		submitter, err = plcsubmit.New(plcURL, rotation, plcClient)
		if err != nil {
			return err
		}
	}
	if s.mode == atpds.ModeFull && submitter == nil && !s.spec.AllowExplicitDID {
		return errors.New("pds: full mode needs a PLC directory URL or submitter")
	}
	rotationDID := ""
	if rotation != nil {
		pub, err := rotation.PublicKey()
		if err != nil {
			return fmt.Errorf("pds: deriving the rotation key: %w", err)
		}
		rotationDID = pub.DIDKey()
	}

	oauthConfig, err := s.oauthConfig(stateDir, baseURL)
	if err != nil {
		return err
	}

	server, err := atpds.New(atpds.Config{
		Mode:                 s.mode,
		Repos:                repoStore,
		Store:                accountStore,
		KeyStore:             keyStore,
		Directory:            directory,
		PLC:                  submitter,
		Blobs:                blobStore,
		OAuth:                oauthConfig,
		ServiceDID:           s.serviceDID(),
		PublicBaseURL:        baseURL,
		AlsoKnownAs:          s.alsoKnownAs(),
		RotationKey:          rotationDID,
		ContactEmail:         strings.TrimSpace(s.spec.ContactEmail),
		SessionSecret:        sessionSecret,
		ServiceAuthAudiences: s.serviceAuthAudiences(),
		MaxBlobSize:          ptr.Deref(s.spec.MaxBlobSize, 0),
		MaxRequestBytes:      ptr.Deref(s.spec.MaxRequestBytes, 0),
		AllowExplicitDID:     s.spec.AllowExplicitDID,
	})
	if err != nil {
		return err
	}

	if handlers == nil {
		return errors.New("pds: no handler registry is available")
	}
	if err := handlers.RegisterHTTP(key, server.Handler()); err != nil {
		return fmt.Errorf("pds: registering the handler: %w", err)
	}
	stopCh := make(chan struct{})
	endpoint := component.Endpoint{
		Name:     "http",
		Protocol: component.ProtocolHTTP,
		URL:      logicalURL,
	}

	s.mu.Lock()
	s.started = true
	s.endpoint = endpoint
	s.key = key
	s.baseURL = logicalURL
	s.stopCh = stopCh
	s.stopOnce = sync.Once{}
	s.server = server
	s.repoStore = repoStore
	s.accountStore = accountStore
	s.keyStore = keyStore
	s.mu.Unlock()
	s.readiness.Signal()

	success = true
	syncCtx, cancelSync := context.WithCancel(ctx)
	var wg sync.WaitGroup
	if s.spec.RequireIngress && s.publicBaseURL != "" && s.mode == atpds.ModeFull && submitter != nil {
		wg.Go(func() { s.syncServiceEndpoints(syncCtx, accountStore, submitter, s.publicBaseURL) })
	}
	select {
	case <-ctx.Done():
	case <-stopCh:
	}
	cancelSync()
	wg.Wait()
	s.mu.Lock()
	s.started = false
	s.stopCh = nil
	s.mu.Unlock()
	return nil
}

func (s *Service) syncServiceEndpoints(ctx context.Context, store *atpds.Store, submitter atpds.PLCSubmitter, publicURL string) {
	if store == nil || submitter == nil || strings.TrimSpace(publicURL) == "" {
		return
	}
	dids, err := store.ListDIDs(ctx)
	if err != nil {
		return
	}
	for _, did := range dids {
		if !strings.HasPrefix(string(did), "did:plc:") {
			continue
		}
		if err := submitter.UpdateService(ctx, did, publicURL); err != nil && s.deps.Log != nil {
			s.deps.Log.Warn("syncing the PDS service endpoint", "did", did.String(), "err", err.Error())
		}
	}
}

func (s *Service) oauthConfig(stateDir, baseURL string) (*atpds.OAuthConfig, error) {
	if s.mode != atpds.ModeFull || s.spec.OAuth == nil {
		return nil, nil
	}
	opts := s.spec.OAuth
	signerKey, err := files.OAuthSigner(stateDir)
	if err != nil {
		return nil, err
	}
	serviceDID := s.serviceDID()
	if serviceDID == "" {
		parsed, err := url.Parse(baseURL)
		if err != nil || parsed.Host == "" {
			return nil, errors.New("pds: cannot derive the service DID")
		}
		serviceDID = "did:web:" + url.QueryEscape(parsed.Host)
	}
	signerDID, err := syntax.ParseDID(serviceDID)
	if err != nil {
		return nil, fmt.Errorf("pds: invalid service DID: %w", err)
	}
	issuer := cmp.Or(strings.TrimRight(strings.TrimSpace(opts.Issuer), "/"), baseURL)
	return &atpds.OAuthConfig{
		Issuer:                  issuer,
		Signer:                  oauth.Signer{DID: signerDID, Key: signerKey},
		AccessTokenTTL:          time.Duration(opts.AccessTokenTTLSeconds) * time.Second,
		RefreshTokenTTL:         time.Duration(opts.RefreshTokenTTLSeconds) * time.Second,
		DisableLocalhostClients: opts.DisableLocalhostClients || !opts.AllowLocalhostClients,
		Scopes:                  append([]string(nil), opts.Scopes...),
	}, nil
}

func buildDirectory(plcURL string, client *http.Client, handlers *bus.Bus, egress component.Egress) identity.Directory {
	if plcURL == "" {
		return identity.DefaultDirectory()
	}
	if handlers != nil && strings.HasPrefix(strings.ToLower(plcURL), "http://") && !isLoopbackURL(plcURL) {
		httpClient := handlers.HTTPClient()
		return &identity.BaseDirectory{
			PLCURL:     plcURL,
			HTTPClient: *httpClient,
			PLCClient:  httpClient,
		}
	}
	plcClient := client
	if plcClient == nil {
		plcClient = egress.Client(directoryTimeout)
	}
	return &identity.BaseDirectory{
		PLCURL: plcURL,
		HTTPClient: http.Client{
			Timeout:   directoryTimeout,
			Transport: ssrf.PublicOnlyTransport(),
		},
		PLCClient: plcClient,
	}
}

func isLoopbackURL(raw string) bool {
	u, err := url.Parse(raw)
	if err != nil {
		return false
	}
	host := u.Hostname()
	if strings.EqualFold(host, "localhost") {
		return true
	}
	ip := net.ParseIP(host)
	return ip != nil && ip.IsLoopback()
}

func logicalHost(key component.Key) string {
	kind := strings.ToLower(strings.TrimSpace(key.Kind))
	if kind == "" {
		return ""
	}
	if name := strings.ToLower(strings.TrimSpace(key.Name)); name != "" {
		return kind + "." + name
	}
	return kcp.ServiceFQDN(kind, "default", key.Workspace, kcp.DefaultServiceDomain)
}

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

func (s *Service) Endpoints() []component.Endpoint {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.started || s.endpoint.URL == "" {
		return nil
	}
	return []component.Endpoint{s.endpoint}
}

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

func (s *Service) closeResources() {
	s.closeOnce.Do(func() {
		if s.accountStore != nil {
			_ = s.accountStore.Close()
		}
		if s.repoStore != nil {
			_ = s.repoStore.Close()
		}
	})
}

func (s *Service) Server() *atpds.Server {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.started {
		return nil
	}
	return s.server
}

func (s *Service) KeyStore() atpds.RepoKeyStore {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.keyStore
}

func (s *Service) ServiceDID() string {
	server := s.Server()
	if server == nil {
		return ""
	}
	return server.ServiceDID()
}

func (s *Service) UpdateService(ctx context.Context, did syntax.DID, service string) error {
	server := s.Server()
	if server == nil {
		return errors.New("pds: the service is not started")
	}
	return server.UpdateService(ctx, did, service)
}

func lockStateDir(dir string) (func(), error) {
	f, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_RDWR|os.O_CREATE, 0o600)
	if err != nil {
		return nil, fmt.Errorf("pds: the state dir lock: %w", err)
	}
	if err := hostos.Lock(f, hostos.LockExclusive|hostos.LockNonBlocking); err != nil {
		_ = f.Close()
		return nil, fmt.Errorf("pds: the state dir %s is locked by another PDS: %w", dir, err)
	}
	return func() {
		_ = hostos.Unlock(f)
		_ = f.Close()
	}, nil
}