~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
504 lines · 12.3 KB Raw History
package sshtransport

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/gatewayhttp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpjson"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/ttlcache"
)

const (
	DevicePairStartNSID = "dev.cocore.devicePair.start"

	DevicePairPollNSID = "dev.cocore.devicePair.poll"

	CocoreConfigPathVM = "/root/.pi/agent/cocore-config.json"

	DefaultCocorePairingTTL = 10 * time.Minute
)

type CocoreToken struct {
	Token string `json:"token"`

	APIBase string `json:"apiBase,omitempty"`

	AccountDID string `json:"accountDid,omitempty"`

	PairedAt string `json:"pairedAt"`
}

type CocoreTokenStore interface {
	Get(ctx context.Context, did string) (*CocoreToken, error)

	Set(ctx context.Context, did string, token *CocoreToken) error
}

type FileCocoreTokenStore struct {
	Path string

	Log *slog.Logger

	mu sync.Mutex

	loaded bool

	tokens map[string]CocoreToken
}

func NewFileCocoreTokenStore(path string, log *slog.Logger) *FileCocoreTokenStore {
	if log == nil {
		log = slog.Default()
	}
	return &FileCocoreTokenStore{Path: path, Log: log, tokens: map[string]CocoreToken{}}
}

func (s *FileCocoreTokenStore) loadLocked() error {
	if s.loaded {
		return nil
	}
	if s.Path == "" {
		return errors.New("sshtransport: no cocore token store path")
	}
	raw, err := os.ReadFile(s.Path)
	if errors.Is(err, os.ErrNotExist) {
		s.loaded = true
		return nil
	}
	if err != nil {
		return err
	}
	if err := json.Unmarshal(raw, &s.tokens); err != nil {
		quarantine := s.Path + ".corrupt-" + strconv.FormatInt(time.Now().Unix(), 10)
		if renameErr := os.Rename(s.Path, quarantine); renameErr != nil {
			return err
		}
		s.Log.Error("cocore_token_store_corrupt", "path", s.Path, "quarantine", quarantine, "err", err)
		s.tokens = map[string]CocoreToken{}
	}
	s.loaded = true
	return nil
}

func (s *FileCocoreTokenStore) Get(ctx context.Context, did string) (*CocoreToken, error) {
	_ = ctx
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := s.loadLocked(); err != nil {
		return nil, err
	}
	token, ok := s.tokens[did]
	if !ok {
		return nil, nil
	}
	return &token, nil
}

func (s *FileCocoreTokenStore) Set(ctx context.Context, did string, token *CocoreToken) error {
	_ = ctx
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := s.loadLocked(); err != nil {
		return err
	}
	s.tokens[did] = *token
	raw, err := json.Marshal(s.tokens)
	if err != nil {
		return err
	}
	dir := filepath.Dir(s.Path)
	if dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o700); err != nil {
			return err
		}
	}
	temp := s.Path + ".tmp-" + randomHex(6)
	if err := os.WriteFile(temp, raw, 0o600); err != nil {
		return err
	}
	if err := os.Rename(temp, s.Path); err != nil {
		os.Remove(temp)
		return err
	}
	return nil
}

type CocorePairingStarted struct {
	PairID string `json:"pairId"`

	UserCode string `json:"userCode"`

	VerificationURI string `json:"verificationUri"`

	IntervalSecs int `json:"intervalSecs"`
}

type CocorePairingOptions struct {
	APIBaseURL string

	Tokens CocoreTokenStore

	AppName string

	AppDID string

	KeyName string

	ReturnURL string

	TTL time.Duration

	Client *http.Client

	Log *slog.Logger
}

type pendingPair struct {
	did string

	deviceID string
}

type CocorePairing struct {
	opts CocorePairingOptions

	base string

	log *slog.Logger

	pending *ttlcache.Cache[string, pendingPair]
}

func NewCocorePairing(opts CocorePairingOptions) (*CocorePairing, error) {
	if opts.APIBaseURL == "" {
		return nil, errors.New("sshtransport: cocore pairing has no API base URL")
	}
	if opts.Tokens == nil {
		return nil, errors.New("sshtransport: cocore pairing has no token store")
	}
	opts.TTL = opt.Positive(opts.TTL, DefaultCocorePairingTTL)
	if opts.Log == nil {
		opts.Log = slog.Default()
	}
	return &CocorePairing{
		opts:    opts,
		base:    strings.TrimRight(opts.APIBaseURL, "/"),
		log:     opts.Log,
		pending: ttlcache.New[string, pendingPair](ttlcache.Options{}),
	}, nil
}

func (p *CocorePairing) client() *http.Client {
	if p.opts.Client != nil {
		return p.opts.Client
	}
	return &http.Client{Timeout: 30 * time.Second}
}

func (p *CocorePairing) do(ctx context.Context, method, target string, body any) (map[string]any, int, error) {
	var reader io.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return nil, 0, err
		}
		reader = bytes.NewReader(raw)
	}
	req, err := http.NewRequestWithContext(ctx, method, target, reader)
	if err != nil {
		return nil, 0, err
	}
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := p.client().Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return nil, resp.StatusCode, err
	}
	var out map[string]any
	if len(bytes.TrimSpace(raw)) > 0 {
		_ = json.Unmarshal(raw, &out)
	}
	return out, resp.StatusCode, nil
}

func (p *CocorePairing) Start(ctx context.Context, did string) (CocorePairingStarted, error) {
	body, status, err := p.do(ctx, http.MethodPost, p.base+"/xrpc/"+DevicePairStartNSID, map[string]any{
		"appName":   p.opts.AppName,
		"appDid":    p.opts.AppDID,
		"keyName":   p.opts.KeyName,
		"returnUrl": p.opts.ReturnURL,
	})
	if err != nil {
		return CocorePairingStarted{}, fmt.Errorf("cocore pairing could not start: %w", err)
	}
	if status < 200 || status > 299 || body == nil {
		detail := ""
		if body != nil {
			if message, ok := body["message"].(string); ok && message != "" {
				detail = message
			} else if code, ok := body["error"].(string); ok && code != "" {
				detail = code
			}
		}
		if detail == "" {
			detail = "HTTP " + strconv.Itoa(status)
		}
		return CocorePairingStarted{}, errors.New("cocore pairing could not start: " + detail)
	}
	deviceID, _ := body["deviceId"].(string)
	userCode, _ := body["userCode"].(string)
	verificationURI, _ := body["verificationUri"].(string)
	if deviceID == "" || verificationURI == "" {
		return CocorePairingStarted{}, errors.New("cocore pairing response carried no deviceId or verificationUri")
	}
	expires := p.opts.TTL
	if seconds, ok := numberValue(body["expiresInSecs"]); ok && seconds > 0 {
		expires = time.Duration(seconds * float64(time.Second))
	}
	interval := 5
	if seconds, ok := numberValue(body["pollIntervalSecs"]); ok && seconds > 0 {
		interval = int(seconds)
	}
	pairID := randomHex(16)
	p.pending.SetTTL(pairID, pendingPair{did: did, deviceID: deviceID}, expires)
	p.log.Info("cocore_pairing_started", "did", did, "hasUserCode", userCode != "")
	return CocorePairingStarted{
		PairID: pairID, UserCode: userCode, VerificationURI: verificationURI, IntervalSecs: interval,
	}, nil
}

func (p *CocorePairing) Poll(ctx context.Context, pairID string) (string, error) {
	entry, ok := p.pending.Get(pairID)
	if !ok {
		return "expired", nil
	}
	target := p.base + "/xrpc/" + DevicePairPollNSID + "?deviceId=" + url.QueryEscape(entry.deviceID)
	body, status, err := p.do(ctx, http.MethodGet, target, nil)
	if err != nil {
		p.log.Warn("cocore_pairing_poll_failed", "err", err)
		return "pending", nil
	}
	if status == http.StatusNotFound || status == http.StatusGone {
		p.pending.Delete(pairID)
		return "expired", nil
	}
	if status < 200 || status > 299 || body == nil {
		p.log.Warn("cocore_pairing_poll_failed", "status", status)
		return "pending", nil
	}
	state, _ := body["status"].(string)
	switch state {
	case "denied":
		p.pending.Delete(pairID)
		p.log.Info("cocore_pairing_denied", "did", entry.did)
		return "denied", nil
	case "expired":
		p.pending.Delete(pairID)
		return "expired", nil
	}
	session, _ := body["session"].(map[string]any)
	if session == nil {
		return "pending", nil
	}
	token, ok := sessionKey(session)
	if !ok {
		keys := make([]string, 0, len(session))
		for key := range session {
			keys = append(keys, key)
		}
		p.log.Warn("cocore_pairing_no_key_in_session", "keys", strings.Join(keys, ","))
		return "pending", nil
	}
	stored := &CocoreToken{
		Token:    token,
		PairedAt: time.Now().UTC().Format(time.RFC3339Nano),
	}
	if apiBase, ok := session["apiBase"].(string); ok {
		stored.APIBase = apiBase
	}
	if did, ok := session["did"].(string); ok {
		stored.AccountDID = did
	}
	if err := p.opts.Tokens.Set(ctx, entry.did, stored); err != nil {
		return "", err
	}
	p.pending.Delete(pairID)
	p.log.Info("cocore_paired", "did", entry.did)
	return "complete", nil
}

func (p *CocorePairing) Token(ctx context.Context, did string) (*CocoreToken, error) {
	return p.opts.Tokens.Get(ctx, did)
}

func sessionKey(session map[string]any) (string, bool) {
	for _, name := range []string{"apiKey", "token", "secret", "key"} {
		if value, ok := session[name].(string); ok && value != "" {
			return value, true
		}
	}
	return "", false
}

func numberValue(value any) (float64, bool) {
	switch v := value.(type) {
	case float64:
		return v, true
	case json.Number:
		n, err := v.Float64()
		return n, err == nil
	case int:
		return float64(v), true
	default:
		return 0, false
	}
}

type GuestSecret struct {
	Path string

	Value string
}

func CocoreSecretEntry(token string) GuestSecret {
	raw, _ := json.Marshal(map[string]string{"apiKey": token})
	return GuestSecret{Path: CocoreConfigPathVM, Value: string(raw)}
}

type CocoreOptions struct {
	Pairing *CocorePairing

	Auth Authenticator

	Log *slog.Logger
}

type Cocore struct {
	opts CocoreOptions

	log *slog.Logger
}

func NewCocoreAPI(opts CocoreOptions) (*Cocore, error) {
	if opts.Pairing == nil {
		return nil, errors.New("sshtransport: cocore API has no pairing")
	}
	if opts.Auth == nil {
		return nil, errors.New("sshtransport: cocore API has no authenticator")
	}
	if opts.Log == nil {
		opts.Log = slog.Default()
	}
	return &Cocore{opts: opts, log: opts.Log}, nil
}

func (c *Cocore) Register(mux *http.ServeMux) {
	mux.HandleFunc("POST /cocore/status", c.status)
	mux.HandleFunc("POST /cocore/pair/start", c.start)
	mux.HandleFunc("POST /cocore/pair/poll", c.poll)
}

func (c *Cocore) Handler() http.Handler {
	mux := http.NewServeMux()
	c.Register(mux)
	return mux
}

func (c *Cocore) caller(w http.ResponseWriter, r *http.Request) (*gatewayhttp.WebCaller, bool) {
	who, err := c.opts.Auth.Authenticate(r)
	if err != nil {
		c.log.Error("cocore_identify_failed", "err", err)
		httpjson.Write(w, http.StatusServiceUnavailable, map[string]any{"error": "the vault is not answering"})
		return nil, false
	}
	if who == nil {
		httpjson.Write(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized"})
		return nil, false
	}
	return who, true
}

func (c *Cocore) status(w http.ResponseWriter, r *http.Request) {
	who, ok := c.caller(w, r)
	if !ok {
		return
	}
	token, err := c.opts.Pairing.Token(r.Context(), who.DID)
	if err != nil {
		c.log.Error("cocore_status_failed", "did", who.DID, "err", err)
		httpjson.Write(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
		return
	}
	var accountDID any
	var pairedAt any
	if token != nil {
		if token.AccountDID != "" {
			accountDID = token.AccountDID
		}
		pairedAt = token.PairedAt
	}
	httpjson.Write(w, http.StatusOK, struct {
		Paired     bool `json:"paired"`
		AccountDID any  `json:"accountDid"`
		PairedAt   any  `json:"pairedAt"`
	}{token != nil, accountDID, pairedAt})

}

func (c *Cocore) start(w http.ResponseWriter, r *http.Request) {
	who, ok := c.caller(w, r)
	if !ok {
		return
	}
	started, err := c.opts.Pairing.Start(r.Context(), who.DID)
	if err != nil {
		c.log.Error("cocore_pairing_start_failed", "did", who.DID, "err", err)
		httpjson.Write(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	httpjson.Write(w, http.StatusOK, started)
}

func (c *Cocore) poll(w http.ResponseWriter, r *http.Request) {
	var body struct {
		PairID any `json:"pairId"`
	}
	raw, err := io.ReadAll(io.LimitReader(r.Body, 8*1024))
	if err == nil {
		_ = json.Unmarshal(raw, &body)
	}
	pairID, _ := body.PairID.(string)
	if pairID == "" {
		httpjson.Write(w, http.StatusBadRequest, map[string]any{"error": "expected a pairId"})
		return
	}
	status, err := c.opts.Pairing.Poll(r.Context(), pairID)
	if err != nil {
		c.log.Error("cocore_pairing_poll_failed", "err", err)
		httpjson.Write(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
		return
	}
	httpjson.Write(w, http.StatusOK, map[string]any{"status": status})
}