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, "~"))
}