a73x

internal/cli/sshcmd.go

Ref:   Size: 6.9 KiB   History

package cli

import (
	"context"
	"fmt"
	"net"
	"os"
	"os/exec"
	"os/user"
	"strings"
	"syscall"

	"github.com/a73x/eitri/internal/guest"
	"github.com/a73x/eitri/internal/names"
	"github.com/a73x/eitri/internal/server/api/client"
)

// SSHArgv builds the system-ssh invocation for a session to a VM. The connect
// name sent on the wire is ALWAYS the namespaced <tenant>.<vm>: a VM's host cert
// carries exactly one principal, its <tenant>.<vm> name, and the final hop
// verifies the dialed name against that principal under strict checking, so a
// bare name would fail host verification (the gate resolves bare names, but the
// VM's cert does not). e.Tenant is resolved before this call (RunSSH derives it
// from the credential when unset), so the user still never types their tenant.
// The gate hop rides an explicit ProxyCommand — NOT -J — because command-line
// -o options reach only the final hop; both hops must verify the presented
// host certificate against the pinned eitri CA with strict checking. The
// ProxyCommand value is run by the user's shell, so embedded paths are
// single-quoted (spaced $HOME paths are normal on macOS). The vm name reaches
// the shell via ssh's %h expansion; modern OpenSSH rejects hostnames with
// shell metacharacters itself — that hardening, not this code, is what blocks
// injection there. This argv shape is load-bearing; change it only with the
// table tests.
func SSHArgv(e Env, vm string, extra []string) []string {
	gateHost, gatePort, err := net.SplitHostPort(e.Gate)
	if err != nil {
		gateHost, gatePort = e.Gate, "22"
	}
	proxy := fmt.Sprintf(
		"ssh -W %%h:%%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -i %s -p %s %s@%s",
		shq(e.KnownHosts), shq(e.Key), gatePort, guest.LoginUser, gateHost)
	argv := []string{
		"ssh",
		"-o", "ProxyCommand=" + proxy,
		"-o", "StrictHostKeyChecking=yes",
		"-o", "UserKnownHostsFile=" + e.KnownHosts,
		"-i", e.Key,
		guest.LoginUser + "@" + names.ConnectName(e.Tenant, vm),
	}
	return append(argv, extra...)
}

// resolvePlane fills the two things a session needs that nothing on this
// machine has to carry: the tenant the connect name is namespaced under, and
// the gate it is dialed through. Either can be pinned — EITRI_TENANT /
// EITRI_GATE, or the config file `eitri init` wrote — and whatever is still
// missing comes from the credential's /me, which answers both. A connect name
// is half identity and half plane, so one probe settles it, and the plane names
// its own gate rather than the client assuming one.
//
// The hosted gate address survives only as the last rung, and only against the
// hosted plane (see gateFor). The connect name must be the namespaced
// <tenant>.<vm> (the VM host cert's one principal, see SSHArgv), so the tenant
// is always needed; deriving it is what means the user never has to know or
// type it, and with neither a tenant nor a token the name cannot be built at
// all.
//
// The probe happens only when something is still missing after everything this
// machine can answer alone. A token in the environment is a credential, not an
// instruction to use it: holding one must never cost a round trip that not
// holding one would have skipped, or `eitri ssh` breaks offline for exactly the
// users who are best set up.
func resolvePlane(e Env) (Env, error) {
	if e.Tenant != "" {
		if e.Gate != "" {
			return e, nil
		}
		// A pinned tenant leaves only the gate, and against the hosted plane
		// that is knowledge this binary already has (see gateFor). Anywhere
		// else the answer has to be asked for, so fall through.
		if gate, err := gateFor("", e.URL); err == nil {
			e.Gate = gate
			return e, nil
		}
	}
	token := os.Getenv("EITRI_TOKEN")
	if token == "" {
		if e.Tenant == "" {
			return e, fmt.Errorf("run 'eitri init', or set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your tenant")
		}
		// The tenant is pinned, the gate is not, and there is no credential to
		// ask the plane with: gateFor's refusal names the two settings that fix
		// it, and is the whole answer here.
		_, err := gateFor("", e.URL)
		return e, err
	}
	// client.Me is deliberately context-free — its own do-timeout bounds this
	// one synchronous probe (see the method's doc), so there is no ctx to thread.
	me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me()
	if err != nil {
		return e, fmt.Errorf("resolving your tenant and gate from the token: %w", err)
	}
	if e.Tenant == "" {
		if me.Tenant == "" {
			return e, fmt.Errorf("your token resolves to no tenant")
		}
		e.Tenant = me.Tenant
	}
	if e.Gate == "" {
		gate, gerr := gateFor(me.SSHGate, e.URL)
		if gerr != nil {
			return e, gerr
		}
		e.Gate = gate
	}
	return e, nil
}

// gateFor settles the gate from whatever has already named one — a pin here or
// the plane's own answer on /me, whichever the caller ranks first — else the
// hosted address, and that last rung only when the plane IS the hosted one.
// Against a self-hosted plane the hosted gate is not a weak default but a
// confidently wrong one: the session would hop through eitri.sh to reach a VM
// that lives nowhere near it, and fail as a host-key refusal rather than as the
// missing configuration it is. So it says which configuration is missing, on
// the server side and on this one.
func gateFor(named, url string) (string, error) {
	if named != "" {
		return named, nil
	}
	if hostedPlane(url) {
		return defaultGate, nil
	}
	return "", fmt.Errorf("%s names no SSH gate, and only the hosted plane has one this client can assume — set EITRI_GATE to the gate's host:port, or set ssh_gate_domain on the server so it names its own", url)
}

// shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via
// the user's shell).
func shq(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// keyID labels minted certs user@host, matching the scripts' -I value.
func keyID() string {
	name := "eitri"
	if u, err := user.Current(); err == nil && u.Username != "" {
		name = u.Username
	}
	host, _ := os.Hostname()
	return name + "@" + host
}

// RunSSH prepares credentials (keypair, cert, host-CA pin) and replaces this
// process with the system ssh. Exec (not a child process) so the TTY, signals,
// and exit code belong to ssh itself.
func RunSSH(ctx context.Context, e Env, vm string, extra []string) error {
	// resolvePlane's only network call is the deliberately context-free
	// client.Me, so there is no ctx for it to forward.
	e, err := resolvePlane(e) //nolint:contextcheck
	if err != nil {
		return err
	}
	if err := EnsureKeypair(e.Key); err != nil {
		return err
	}
	if err := MintCert(e.CA, e.Key, keyID()); err != nil {
		return err
	}
	if err := WriteHostCAPin(ctx, e.URL, e.KnownHosts); err != nil {
		return err
	}
	sshPath, err := exec.LookPath("ssh")
	if err != nil {
		return fmt.Errorf("ssh not found on PATH — install an OpenSSH client")
	}
	return syscall.Exec(sshPath, SSHArgv(e, vm, extra), os.Environ())
}