a73x

internal/cli/env.go

Ref:   Size: 3.9 KiB   History

// Package cli implements the eitri client binary: self-signed short-lived SSH
// certs with the tenant's own user CA, host verification pinned to eitri's
// host CA, sessions through the system ssh, and tenant CA registration. It is
// the compiled successor of the hack/ scripts and keeps their env contract.
// The crypto boundary is unchanged: eitri never sees a user private key.
package cli

import (
	"os"
	"path/filepath"
	"strings"
)

// Hosted-service defaults: unset and unconfigured, the CLI targets the hosted
// service at eitri.sh, so a fresh laptop needs no configuration to reach it.
// Self-hosters and tests point elsewhere by setting the matching environment
// variable or by running `eitri init`.
//
// defaultGate is the weaker of the two, and it belongs to defaultURL alone. A
// plane names its own gate on /me, so this address is only what a client
// assumes when nothing has told it better: no EITRI_GATE, no config, and either
// no credential to ask with or a server old enough to answer /me without
// ssh_gate. Against any OTHER plane that assumption is not a fallback but a
// wrong answer that looks right — a self-hoster's laptop hopping through
// eitri.sh's gate — so hostedPlane gates it.
const (
	defaultURL  = "https://console.eitri.sh"
	defaultGate = "gate.eitri.sh:2222"
)

// hostedPlane reports whether url is the hosted service, the one plane whose
// gate this binary knows without being told.
func hostedPlane(url string) bool {
	return strings.TrimSuffix(url, "/") == defaultURL
}

// Env is the client configuration, resolved from EITRI_* variables over the
// config file.
type Env struct {
	URL        string // server base URL (EITRI_URL, else config, else the hosted default)
	Gate       string // gate host[:port] (EITRI_GATE, else config, else the plane's own answer)
	CA         string // tenant user-CA private key path
	Tenant     string
	Key        string // user SSH private key path
	KnownHosts string // dedicated pin file — never the user's main known_hosts
}

// FromEnv resolves the client configuration: an EITRI_* variable when set, else
// the value `eitri init` wrote to the config file, else a default. It is the
// SINGLE door onto both — every consumer (ssh, ca, init alike) resolves through
// it, so each precedence rule is written in exactly one place.
//
// The environment sits above the file because it is the per-invocation
// override; the file sits above the defaults because it is the user's own
// deliberate answer. Gate is the one field with a rung below the file: left
// empty here, `eitri ssh` asks the plane for it (see resolvePlane) rather than
// assuming the hosted address.
//
// Tenant is optional at this layer — `eitri ssh` derives it from the credential
// via /me when nothing pins it, so the user never has to type it; a pinned
// tenant is the offline path and the way to disambiguate a user CA registered
// in more than one tenant. The path fields default under $HOME/.ssh.
func FromEnv() (Env, error) {
	cfg, err := ConfigFromDisk()
	if err != nil {
		return Env{}, err
	}
	e := Env{
		URL:        firstNonEmpty(os.Getenv("EITRI_URL"), cfg.URL, defaultURL),
		Gate:       firstNonEmpty(os.Getenv("EITRI_GATE"), cfg.Gate),
		CA:         firstNonEmpty(os.Getenv("EITRI_CA"), cfg.CA),
		Tenant:     firstNonEmpty(os.Getenv("EITRI_TENANT"), cfg.Tenant),
		Key:        firstNonEmpty(os.Getenv("EITRI_KEY"), cfg.Key),
		KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"),
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return Env{}, err
	}
	def := func(p *string, name string) {
		if *p == "" {
			*p = filepath.Join(home, ".ssh", name)
		}
	}
	def(&e.CA, "eitri_user_ca")
	def(&e.Key, "id_ed25519")
	def(&e.KnownHosts, "eitri_known_hosts")
	return e, nil
}

// firstNonEmpty returns the first set value — the shape every precedence chain
// in this package takes.
func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}