~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
167 lines · 4.7 KB Raw History
package request

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/childproc"
)

const (
	KeyFile          = "id_ed25519"
	SSHPollInterval  = 5 * time.Second
	sshConnectTimout = "ConnectTimeout=10"
	ProxyShell       = "/bin/sh"
)

var ErrHostKeyMismatch = errors.New("request: the guest's ssh host key does not match the key it reported (StrictHostKeyChecking=yes)")

var hostKeyMismatch = regexp.MustCompile(`Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|No .* host key is known`)

var sshRefused = regexp.MustCompile(`Permission denied \(`)

func EnsureKey(ctx context.Context, dir string) (string, string, error) {
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return "", "", err
	}
	key := filepath.Join(dir, KeyFile)
	if _, err := os.Stat(key); errors.Is(err, os.ErrNotExist) {
		out, err := exec.CommandContext(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "socialweb-computer-run-request", "-f", key).CombinedOutput()
		if err != nil {
			return "", "", fmt.Errorf("request: ssh-keygen: %v: %s", err, strings.TrimSpace(string(out)))
		}
	}
	pub, err := os.ReadFile(key + ".pub")
	if err != nil {
		return "", "", err
	}
	return strings.TrimSpace(string(pub)), key, nil
}

type SSH struct {
	Dumbpipe     string
	ProxyCommand string
	Dir          string
}

type Target struct {
	Alias      string
	KnownHosts string
	Ticket     string
}

func (s SSH) Target(c *v1alpha1.ComputeContract) (Target, error) {
	k := c.Status.Contract
	if k == nil || (k.VMAddress == "" && s.ProxyCommand == "") {
		return Target{}, errors.New("request: the contract reports no guest address (onNetwork)")
	}
	if k.HostKeys == nil || len(k.HostKeys.Keys) == 0 {
		return Target{}, errors.New("request: the guest reported no ssh host keys; ssh never runs without them")
	}
	if k.Receipt == nil || k.Receipt.URI == "" {
		return Target{}, errors.New("request: the contract has no receipt")
	}
	ticket := ""
	if s.ProxyCommand == "" {
		var err error
		if ticket, err = Ticket(k.VMAddress); err != nil {
			return Target{}, err
		}
	}
	alias := HostKeyAlias(k.Receipt.URI)
	var b strings.Builder
	for _, h := range k.HostKeys.Keys {
		fmt.Fprintf(&b, "%s %s %s\n", alias, h.Type, h.PublicKey)
	}
	known := filepath.Join(s.Dir, "known_hosts."+alias)
	if err := os.WriteFile(known, []byte(b.String()), 0o600); err != nil {
		return Target{}, err
	}
	return Target{Alias: alias, KnownHosts: known, Ticket: ticket}, nil
}

func (s SSH) args(t Target) []string {
	proxy := s.ProxyCommand
	if proxy == "" {
		proxy = s.Dumbpipe + " connect " + t.Ticket
	}
	return []string{
		"-o", "ProxyCommand=" + proxy,
		"-o", "IdentityFile=" + filepath.Join(s.Dir, KeyFile),
		"-o", "IdentitiesOnly=yes",
		"-o", "StrictHostKeyChecking=yes",
		"-o", "UserKnownHostsFile=" + t.KnownHosts,
		"-o", "GlobalKnownHostsFile=/dev/null",
		"-o", "HostKeyAlias=" + t.Alias,
		"-o", "CheckHostIP=no",
		"-o", "LogLevel=ERROR",
	}
}

func sshCommand(ctx context.Context, args []string) *exec.Cmd {
	cmd := exec.CommandContext(ctx, "ssh", args...)
	cmd.Env = append(os.Environ(), "SHELL="+ProxyShell)
	return cmd
}

func (s SSH) WaitReady(ctx context.Context, t Target, timeout time.Duration, progress func(attempt int, err string)) error {
	deadline := time.Now().Add(timeout)
	for attempt := 1; ; attempt++ {
		args := append(s.args(t), "-o", "BatchMode=yes", "-o", sshConnectTimout, "root@"+t.Alias, "true")
		var stderr bytes.Buffer
		cmd := sshCommand(ctx, args)
		cmd.Stderr = childproc.FilterSecrets(&stderr)
		err := cmd.Run()
		if err == nil {
			return nil
		}
		msg := strings.TrimSpace(stderr.String())
		if hostKeyMismatch.MatchString(msg) {
			return fmt.Errorf("%w: %s", ErrHostKeyMismatch, msg)
		}
		if sshRefused.MatchString(msg) {
			return fmt.Errorf("request: ssh to %s was refused: %s", t.Alias, msg)
		}
		if progress != nil {
			progress(attempt, msg)
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("request: ssh to %s did not answer within %s: %s", t.Alias, timeout, msg)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(SSHPollInterval):
		}
	}
}

func (s SSH) Run(ctx context.Context, t Target, program string, interactive bool, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
	args := s.args(t)
	if interactive {
		args = append(args, "-tt", "root@"+t.Alias)
	} else {
		args = append(args, "root@"+t.Alias, program)
	}
	cmd := sshCommand(ctx, args)
	cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, stdout, childproc.FilterSecrets(stderr)
	err := cmd.Run()
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		return ee.ExitCode(), nil
	}
	if err != nil {
		return -1, err
	}
	return 0, nil
}