a73x

internal/cli/init.go

Ref:   Size: 16.4 KiB   History

package cli

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/crypto/ssh"
	"golang.org/x/term"

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

// prompter is init's dialogue with the user, over injected streams so the whole
// flow is drivable by a test. Every question defaults to the answer that
// touches nothing.
type prompter struct {
	buf *bufio.Reader
	raw io.Reader // the unbuffered source, for the terminal check in secret
	out io.Writer
}

func newPrompter(in io.Reader, out io.Writer) *prompter {
	return &prompter{buf: bufio.NewReader(in), raw: in, out: out}
}

func (p *prompter) sayf(format string, a ...any) {
	fmt.Fprintf(p.out, format+"\n", a...)
}

// confirm asks a yes/no question, defaulting to no: a step acts only on an
// explicit yes, so an empty line, an unrecognized answer, and EOF (a closed or
// piped stdin) all decline. Nothing in init happens by running out of input.
func (p *prompter) confirm(format string, a ...any) bool {
	fmt.Fprintf(p.out, "  "+format+" [y/N] ", a...)
	line, err := p.buf.ReadString('\n')
	if err != nil && line == "" {
		fmt.Fprintln(p.out)
		return false
	}
	switch strings.ToLower(strings.TrimSpace(line)) {
	case "y", "yes":
		return true
	default:
		return false
	}
}

// ask reads one line of free text, returning def for an empty answer.
func (p *prompter) ask(question, def string) string {
	if def != "" {
		fmt.Fprintf(p.out, "  %s [%s] ", question, def)
	} else {
		fmt.Fprintf(p.out, "  %s ", question)
	}
	line, _ := p.buf.ReadString('\n')
	return firstNonEmpty(strings.TrimSpace(line), def)
}

// secret reads a value that must not be echoed or logged. A real terminal has
// its echo turned off for the duration; anything else — a pipe, the tests —
// reads a plain line, there being no terminal to turn echo off on. The fd is
// only read directly while the buffer is empty, so nothing already read ahead
// can be lost.
func (p *prompter) secret(question string) (string, error) {
	fmt.Fprintf(p.out, "  %s ", question)
	if f, ok := p.raw.(*os.File); ok && p.buf.Buffered() == 0 && term.IsTerminal(int(f.Fd())) {
		b, err := term.ReadPassword(int(f.Fd()))
		fmt.Fprintln(p.out)
		return string(b), err
	}
	line, err := p.buf.ReadString('\n')
	if err != nil && line == "" {
		return "", err
	}
	return strings.TrimSpace(line), nil
}

// RunInit walks a laptop from nothing to a first shell in one command: prove a
// token, settle the tenant's SSH CA, and write the config every other command
// reads. Afterwards `eitri ssh <vm>` needs no environment at all — the config
// carries the plane and the tenant, the cert is signed locally, and the host-CA
// pin comes from a public endpoint.
//
// Every step prints what it would do and acts only on a yes, and every step
// reports "already done" when it already is: a second init against a settled
// laptop changes nothing. The steps that can go wrong quietly are the ones it
// is most careful about — a CA the tenant has not registered signs certs that
// are perfectly formed and refused by every guest, so init says so rather than
// generating a second key on top.
func RunInit(ctx context.Context, e Env, cfgPath, token string, in io.Reader, out io.Writer) error {
	p := newPrompter(in, out)
	cur, err := LoadConfig(cfgPath)
	if err != nil {
		return err
	}

	// initIdentity's only network call is the deliberately context-free
	// client.Me, so there is no ctx for it to forward.
	me, token, err := initIdentity(p, e, token) //nolint:contextcheck
	if err != nil {
		return err
	}
	caPath, err := initCA(ctx, p, e, token, me)
	if err != nil {
		return err
	}
	// The written gate is resolved in the order `eitri ssh` resolves it — a pin
	// here (EITRI_GATE, or the gate a previous run wrote) outranks the plane's
	// own answer, which fills only an unpinned one. Writing the plane's answer
	// over a pin would un-pin it in the file: the next session would silently
	// stop using the gate its user chose, and init would have been the thing
	// that changed it. initIdentity says so at the prompt when the two differ.
	//
	// A plane that named no gate and is not the hosted one leaves this empty,
	// which is the honest record: initIdentity has already said what to set, and
	// an unpinned gate makes `eitri ssh` say it again rather than send the
	// session through eitri.sh's gate to reach a guest that is nowhere near it.
	gate, _ := gateFor(firstNonEmpty(e.Gate, me.SSHGate), e.URL)
	next := Config{
		URL:    e.URL,
		Gate:   gate,
		Tenant: me.Tenant,
		CA:     caPath,
		Key:    e.Key,
	}
	if err := initConfig(p, cfgPath, cur, next); err != nil {
		return err
	}
	p.sayf("\nDone. `eitri ssh <vm>` from here — no environment needed.")
	return nil
}

// initIdentity proves the credential and learns what comes with it: the tenant
// it acts for, and the gate this plane's clients dial. The token is used and
// forgotten — never written to the config, never printed back.
func initIdentity(p *prompter, e Env, token string) (client.Me, string, error) {
	p.sayf("Step 1/3: prove your identity against %s", e.URL)
	source := "--token"
	if token == "" {
		token, source = os.Getenv("EITRI_TOKEN"), "EITRI_TOKEN"
	}
	if token == "" {
		p.sayf("  A personal access token, minted in the console under Settings →")
		p.sayf("  Personal access tokens. It is used once, to ask the plane who you")
		p.sayf("  are, and is not stored.")
		var err error
		if token, err = p.secret("token:"); err != nil {
			return client.Me{}, "", err
		}
		source = "the prompt"
		if token == "" {
			return client.Me{}, "", errors.New("no token — init needs one to learn your tenant")
		}
	} else {
		p.sayf("  Using the token from %s to call GET %s/api/v1/me.", source, e.URL)
		if !p.confirm("Ask the plane who this token is?") {
			return client.Me{}, "", errors.New("declined — init has nothing to go on without an identity")
		}
	}

	// 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 client.Me{}, "", fmt.Errorf("the token from %s did not authenticate against %s — a token belongs to the plane that minted it, so check this is the right one for this server: %w", source, e.URL, err)
	}
	if me.Tenant == "" {
		return client.Me{}, "", errors.New("that token resolves to no tenant")
	}
	p.sayf("  %s, tenant %s.", firstNonEmpty(me.Email, "signed in"), me.Tenant)
	// A gate that binds every interface with no ssh_gate_domain to name it does
	// not boot, so a plane answering with no ssh_gate has its gate switched off
	// or is old enough to answer without the field. Either way the hosted
	// address belongs to the hosted plane alone — anywhere else it is a
	// confidently wrong answer, so the remedy is named instead.
	switch {
	case e.Gate != "" && me.SSHGate != "" && e.Gate != me.SSHGate:
		// The pin wins (see RunInit), and a disagreement it silently won would
		// be the one thing here the user could not have noticed: two plausible
		// gates, one of them theirs, and no sign that the other exists.
		p.sayf("  %s pins %s; this plane names %s — keeping your pin.", gatePinnedBy(), e.Gate, me.SSHGate)
	case me.SSHGate != "":
		p.sayf("  This plane's SSH gate is %s.", me.SSHGate)
	case e.Gate != "":
		p.sayf("  This plane names no SSH gate; %s is set here and is kept.", e.Gate)
	case hostedPlane(e.URL):
		p.sayf("  This plane names no SSH gate, so the hosted %s is assumed.", defaultGate)
	default:
		p.sayf("  This plane names no SSH gate — its gate is off, or it predates")
		p.sayf("  the servers that always name one. `eitri ssh` cannot reach a")
		p.sayf("  guest without one: set ssh_gate_domain on the server, or")
		p.sayf("  EITRI_GATE here.")
	}
	return me, token, nil
}

// gatePinnedBy names where a pinned gate came from. FromEnv has already
// collapsed the two sources into one field because they rank the same; only
// this sentence needs to tell them apart, so that a user told their pin was
// kept knows which pin — the variable in this shell, or the file init is about
// to rewrite.
func gatePinnedBy() string {
	if os.Getenv("EITRI_GATE") != "" {
		return "EITRI_GATE"
	}
	return "the config file"
}

// initCA settles the tenant's SSH CA and returns the signing key `eitri ssh`
// should use. A guest trusts the CA set baked into it at create and nothing
// rewrites that set afterwards, so this is the step that decides whether the
// VMs made after it can ever be entered.
func initCA(ctx context.Context, p *prompter, e Env, token string, me client.Me) (string, error) {
	p.sayf("\nStep 2/3: the SSH CA for tenant %s", me.Tenant)
	c := &client.Client{BaseURL: e.URL, Token: token}
	registered, err := c.ListUserCAs(ctx, "")
	if err != nil {
		return "", fmt.Errorf("listing the CAs registered for %s: %w", me.Tenant, err)
	}
	byFP := make(map[string]client.UserCA, len(registered))
	for _, ca := range registered {
		byFP[ca.Fingerprint] = ca
	}
	if len(registered) > 0 {
		p.sayf("  %s has %d registered:", me.Tenant, len(registered))
		for _, ca := range registered {
			p.sayf("    %s  %s", ca.Fingerprint, firstNonEmpty(ca.Label, "(no label)"))
		}
	} else {
		p.sayf("  %s has none registered. Until it does, a VM created for it would", me.Tenant)
		p.sayf("  trust no certificate at all — which is why create refuses one.")
	}

	pub, caPath, err := localCA(p, e.CA)
	if err != nil {
		return "", err
	}
	if pub == nil {
		p.sayf("  Skipped — no signing key settled. `eitri ssh` cannot mint a cert until there is one.")
		return e.CA, nil
	}

	fp := ssh.FingerprintSHA256(pub)
	if ca, ok := byFP[fp]; ok {
		p.sayf("  Already done: %s is registered as %s.", fp, firstNonEmpty(ca.Label, "(no label)"))
		return caPath, nil
	}
	if len(registered) > 0 {
		// The trap this step exists for. The certs this key signs are perfectly
		// formed; every guest refuses them, and ssh reports only a denied public
		// key, which reads like a broken account rather than an unregistered CA.
		p.sayf("  The key at %s is %s — not one of the above.", caPath, fp)
		p.sayf("  Certs it signs would be refused by every guest of this tenant.")
	}
	if !p.confirm("Register this key's public half with %s?", me.Tenant) {
		p.sayf("  Skipped — nothing registered.")
		return caPath, nil
	}
	c.UserCALabel = p.ask("label for it:", keyID())
	if err := c.UploadUserCA(ctx, "", string(ssh.MarshalAuthorizedKey(pub))); err != nil {
		return "", err
	}
	p.sayf("  Registered %s as %q. eitri holds the public half only.", fp, c.UserCALabel)
	return caPath, nil
}

// localCA settles WHICH key on this machine signs for the tenant, in the order
// that touches least: the one already at the configured path, then one the user
// names, and only then a new one. Generating is last because a CA is an
// identity — making a second where one exists is exactly how a laptop ends up
// signing with a key the tenant has never heard of. A nil key means the user
// declined every offer.
func localCA(p *prompter, path string) (ssh.PublicKey, string, error) {
	pub, keyPath, err := caPublicKey(path)
	switch {
	case err == nil:
		return pub, keyPath, nil
	case errors.Is(err, errNoPrivateHalf):
		// A public half names the CA but cannot sign for it, so there is
		// nothing usable here: say which file is missing, then offer as if the
		// path had been empty.
		p.sayf("  %v.", err)
	case errors.Is(err, os.ErrNotExist):
		p.sayf("  There is no signing key at %s.", path)
	default:
		return nil, "", err
	}

	if p.confirm("Do you already have a CA key, elsewhere on this machine?") {
		// A path is typed, so it can be mistyped, and a rejected one is a
		// question still open rather than a run to abandon: say what is wrong
		// with it and ask again.
		for {
			named := p.ask("path to it (the private key, or its .pub):", "")
			if named == "" {
				return nil, "", errors.New("no path given")
			}
			pub, keyPath, err := caPublicKey(expandHome(named))
			if err == nil {
				return pub, keyPath, nil
			}
			p.sayf("  %v", err)
			if !p.confirm("Name a different path?") {
				return nil, "", err
			}
		}
	}

	p.sayf("  A new ed25519 CA would be written, and nothing else touched:")
	p.sayf("    %s      private, mode 0600 — eitri never sees this half", path)
	p.sayf("    %s.pub  public, mode 0644 — this is the half that gets registered", path)
	if !p.confirm("Generate it?") {
		return nil, path, nil
	}
	pub, err = newEd25519Key(path)
	if err != nil {
		return nil, "", err
	}
	p.sayf("  Wrote %s and %s.pub.", path, path)
	return pub, path, nil
}

// errNoPrivateHalf marks a .pub that names a CA this machine cannot sign for.
// It is a distinct condition from a missing file: the key the user pointed at
// is right there and readable, and only the half that matters is absent.
var errNoPrivateHalf = errors.New("the public half alone cannot sign")

// caPublicKey reads the public half of a CA at path, which a user may name
// either way round: the signing key (whose public half is derived, exactly as
// minting does) or the .pub beside it, which is what `eitri ca upload` takes.
// It returns the signing key's path alongside, since that is what the config
// records — `eitri ssh` signs with it, not with the .pub.
//
// Naming the .pub is the one way to settle a CA whose signing half was never
// checked for, so it is checked for here. A .pub is the copyable half — it is
// what gets pasted into the console and scp'd between laptops — and one that
// arrives alone reads exactly like one that did not: init would register the
// CA, write its path into the config, and hand every later `eitri ssh` a
// signing key that is not there, while every VM created afterwards bakes in
// trust for a CA nobody present can sign with.
func caPublicKey(path string) (ssh.PublicKey, string, error) {
	if strings.HasSuffix(path, ".pub") {
		raw, err := os.ReadFile(path)
		if err != nil {
			return nil, "", err
		}
		pub, _, _, _, perr := ssh.ParseAuthorizedKey(raw)
		if perr != nil {
			return nil, "", fmt.Errorf("%s: not an SSH public key: %w", path, perr)
		}
		key := strings.TrimSuffix(path, ".pub")
		// Loaded the way minting will load it, so a key that passes here is one
		// `eitri ssh` can actually use — including the passphrase-protected
		// case, which userPublicKey resolves without asking for the passphrase.
		if _, err := userPublicKey(key); err != nil {
			if errors.Is(err, os.ErrNotExist) {
				return nil, "", fmt.Errorf("%s: %w — the private key would need to be at %s", path, errNoPrivateHalf, key)
			}
			return nil, "", err
		}
		return pub, key, nil
	}
	pub, err := userPublicKey(path)
	if err != nil {
		return nil, "", err
	}
	return pub, path, nil
}

// initConfig writes the settings every other command reads. It shows the file's
// current values beside the ones this run would leave, writes only on a yes,
// and says "already done" when the two are the same.
func initConfig(p *prompter, path string, cur, next Config) error {
	p.sayf("\nStep 3/3: write %s", path)
	for _, l := range configLines(cur, next) {
		p.sayf("%s", l)
	}
	if cur == next {
		p.sayf("  Already done: the file says exactly this.")
		return nil
	}
	if cur.URL != "" && cur.URL != next.URL {
		p.sayf("  This moves the laptop to a different plane: %s → %s.", cur.URL, next.URL)
		p.sayf("  The tenant and CA above belong to the new one.")
	}
	if !p.confirm("Write it?") {
		p.sayf("  Skipped — the file is unchanged.")
		return nil
	}
	if err := SaveConfig(path, next); err != nil {
		return err
	}
	p.sayf("  Written.")
	return nil
}

// configLines renders next field by field, naming the value it replaces so the
// user confirms a diff rather than a wall of settings.
func configLines(cur, next Config) []string {
	fields := []struct{ name, was, now string }{
		{"url", cur.URL, next.URL},
		{"gate", cur.Gate, next.Gate},
		{"tenant", cur.Tenant, next.Tenant},
		{"ca", cur.CA, next.CA},
		{"key", cur.Key, next.Key},
	}
	lines := make([]string, 0, len(fields))
	for _, f := range fields {
		line := fmt.Sprintf("    %-7s %s", f.name, f.now)
		if f.was != "" && f.was != f.now {
			line += fmt.Sprintf("  (was %s)", f.was)
		}
		lines = append(lines, line)
	}
	return lines
}

// expandHome resolves a leading ~ in a path the user typed, which no shell
// expanded because it came in on a prompt rather than a command line.
func expandHome(path string) string {
	if path != "~" && !strings.HasPrefix(path, "~"+string(filepath.Separator)) {
		return path
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return path
	}
	return filepath.Join(home, strings.TrimPrefix(path, "~"))
}