~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
213 lines · 7.4 KB Raw History
package sshserver

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	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/bus"
	contractmarket "github.com/johnandersen777/socialweb-computer-kcp/internal/contract/market"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/policy"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gateway/custody"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/gatewayhttp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcplibs/common/ref"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcplibs/impl/kcpstore"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/supervise"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/policyengine/runs"
)

const (
	VaultClient = "ssh-gateway"

	SignInWorkflow = "bidder-tangled-vouch"

	atprotoMount = "atproto"

	renewBelow = 30 * time.Minute

	renewIncrement = time.Hour

	signInPolicyTimeout = 30 * time.Second
)

var errNoOperator = errors.New("this ssh gateway names no operator, so its policy admits no account")

type webSignIn struct {
	endpoint custody.Endpoint

	handler http.Handler

	signIn *custody.SignIn

	auth *gatewayhttp.RequestAuth

	oauth *gatewayhttp.OAuth

	tasks *supervise.Group
}

func (w *webSignIn) close() {
	if w == nil {
		return
	}
	_ = w.tasks.Stop()
	w.signIn.Close()
}

func (s *Service) publicOrigin() string {
	return strings.TrimRight(strings.TrimSpace(s.spec.PublicOrigin), "/")
}

func webSignInMode(spec v1alpha1.SshServerSpec, ownerSessions int) bool {
	return spec.VaultRef != nil && strings.TrimSpace(spec.PublicOrigin) != "" && ownerSessions == 0
}

func (s *Service) openSignIn(ctx context.Context) (*webSignIn, error) {
	if !webSignInMode(s.spec, len(s.ownerSessions)) {
		return nil, nil
	}
	origin := s.publicOrigin()
	endpoint, err := s.vaultEndpoint(ctx)
	if err != nil {
		return nil, err
	}
	signIn, err := custody.New(ctx, custody.Config{Endpoint: endpoint, Role: custody.DefaultRole, RenewBelow: renewBelow,
		RenewIncrement: renewIncrement, Network: s.spec.Network}, custody.Deps{TLS: s.tls, Log: s.log})
	if err != nil {
		return nil, err
	}
	tasks := supervise.New(ctx)
	web := &webSignIn{endpoint: endpoint, signIn: signIn, tasks: tasks}
	if web.auth, err = gatewayhttp.NewRequestAuth(gatewayhttp.RequestAuthOptions{SignIn: signIn, PublicOrigin: origin, Log: s.log}); err != nil {
		web.close()
		return nil, err
	}
	if web.oauth, err = gatewayhttp.NewOAuth(gatewayhttp.OAuthOptions{SignIn: signIn, Auth: web.auth, PublicOrigin: origin,
		PostSignInURL: origin + "/", OnSignedIn: s.signedIn, OnSignedOut: s.signedOut, Tasks: tasks, Log: s.log}); err != nil {
		web.close()
		return nil, err
	}
	web.handler = web.oauth.Handler()
	return web, nil
}

func (s *Service) vaultEndpoint(ctx context.Context) (custody.Endpoint, error) {
	vref := s.spec.VaultRef
	if s.deps.KCP == nil {
		return custody.Endpoint{}, errors.New("sshserver: no kcp client for the vault")
	}
	workspace := cmp.Or(vref.Workspace, s.workspace())
	client, err := s.deps.KCP.Client(workspace)
	if err != nil {
		return custody.Endpoint{}, fmt.Errorf("sshserver: kcp client for %s: %w", workspace, err)
	}
	restConfig := client.RESTConfig()
	if restConfig == nil || restConfig.Host == "" {
		return custody.Endpoint{}, fmt.Errorf("sshserver: the kcp client for %s has no rest config", workspace)
	}
	store, err := kcpstore.New(kcpstore.Options{Host: restConfig.Host, RestConfig: restConfig})
	if err != nil {
		return custody.Endpoint{}, err
	}
	server, err := kcpstore.OfClusterScoped[v1alpha1.OpenBaoServer](store, v1alpha1.OpenBaoServerGVR).Get(ctx, ref.New(workspace, "", vref.Name))
	if err != nil {
		return custody.Endpoint{}, fmt.Errorf("sshserver: reading OpenBaoServer %s/%s: %w", workspace, vref.Name, err)
	}
	endpoint, _, err := custody.EndpointFor(server.Status, VaultClient, atprotoMount)
	if err != nil {
		return custody.Endpoint{}, fmt.Errorf("sshserver: OpenBaoServer %s/%s: %w", workspace, vref.Name, err)
	}
	return endpoint, nil
}

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

func (s *Service) sessionOwners(ctx context.Context) (contractmarket.SessionOwnerIntents, error) {
	if s.deps.Bus == nil {
		return nil, errors.New("sshserver: no bus")
	}
	self := s.self()
	return bus.Resolve[contractmarket.SessionOwnerIntents](ctx, s.deps.Bus, self, self, bus.Capability{Name: contractmarket.CapabilitySessionOwners})
}

func (s *Service) policyRuns() *runs.Client {
	handlers := s.deps.Bus
	self := s.self()
	return &runs.Client{
		Resolve: func(ctx context.Context, engine commonv1.ObjectRef, workflow string) (policy.Runner, error) {
			return bus.Resolve[policy.Runner](ctx, handlers, self, engine, bus.Capability{Name: policy.CapabilityRun, Scope: []string{workflow}})
		},
		Owner:   policy.Owner{APIVersion: v1alpha1.GroupVersion.String(), Kind: Kind, Name: s.deps.Self.Name, UID: string(s.deps.UID)},
		Engine:  runs.EngineRef(s.spec.PolicyEngineRef, s.workspace()),
		Role:    "sshserver",
		Prefix:  "sshserver-policy",
		Timeout: signInPolicyTimeout,
		Parser:  runs.Strict,
		Now:     time.Now,
	}
}

func (s *Service) admit(ctx context.Context, did string) error {
	operator := strings.TrimSpace(s.spec.OperatorDid)
	if operator == "" {
		return errNoOperator
	}
	verdict := s.policyRuns().Run(ctx, runs.Request{WorkflowRef: SignInWorkflow, Input: runs.Input{Perspective: "bidder", Mode: runs.ModeScope,
		SelfDid: operator, SubjectDid: did, CounterpartyDid: did}})
	if !verdict.Allow {
		return fmt.Errorf("the %s policy of %s does not admit %s: %s", SignInWorkflow, operator, did, verdict.Reason)
	}
	return nil
}

func (s *Service) signedIn(ctx context.Context, user *gatewayhttp.WebUser) error {
	if user == nil || strings.TrimSpace(user.DID) == "" {
		return errors.New("sshserver: the sign-in names no DID")
	}
	if err := s.admit(ctx, user.DID); err != nil {
		s.log.Info("sshserver_sign_in_refused", "did", user.DID, "reason", err.Error())
		return err
	}
	owners, err := s.sessionOwners(ctx)
	if err != nil {
		return fmt.Errorf("sshserver: the session owners handle: %w", err)
	}
	if err := owners.EnsureSessionOwner(ctx, user.DID); err != nil {
		s.log.Warn("sshserver_sign_in_unrecorded", "did", user.DID, "err", err.Error())
		return err
	}
	s.log.Info("sshserver_account_signed_in", "did", user.DID)
	return nil
}

func (s *Service) signedOut(ctx context.Context, user *gatewayhttp.WebUser) error {
	if user == nil || strings.TrimSpace(user.DID) == "" {
		return nil
	}
	owners, err := s.sessionOwners(ctx)
	if err != nil {
		return fmt.Errorf("sshserver: the session owners handle: %w", err)
	}
	if err := owners.DeleteSessionOwner(ctx, user.DID); err != nil {
		return err
	}
	s.log.Info("sshserver_account_signed_out", "did", user.DID)
	return nil
}

func (s *Service) page(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'")
	w.Header().Set("X-Frame-Options", "DENY")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	_, _ = w.Write([]byte(signInPage))
}