Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/config/example_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ ssh:
# 0s disables idle disconnects.
idle_timeout: 0s
security:
# pin prompts for every new host key; tofu trusts first-seen keys and
# still prompts when a known key changes.
host_key_policy: pin
accept_once_mode: pin
compat_persist_probes: false
Expand Down
8 changes: 4 additions & 4 deletions internal/connect/hostkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ func promptNewHostKey(prompt connector.HostKeyPrompt) connector.HostKeyAction {
fmt.Print(renderNewHostKeyPromptText(prompt))

options := []string{
"Reject (disconnect)",
"Accept once (this session only)",
"Accept always (add to known_hosts)",
"Reject (disconnect) [enter]",
"Accept once (this session only) [o]",
"Accept always (add to known_hosts) [a]",
}
idx, err := ui.SelectIndex("Host key verification", options, prompt.Stdin)
idx, err := ui.SelectIndexWithShortcuts("Host key verification", options, map[rune]int{'o': 1, 'a': 2}, prompt.Stdin)
if err != nil || idx < 0 {
return connector.HostKeyReject
}
Expand Down
15 changes: 14 additions & 1 deletion internal/connect/hostkey_prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ type scannedHostKey struct {
var negotiatedHostKeyRE = regexp.MustCompile(`(?m)Server host key: ([^\s]+) (SHA256:[A-Za-z0-9+/=]+)`)

func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs []string, cfg *config.Config, opts Options, changed bool, proxyEnv []string) (*connector.HostKeyPreparation, error) {
if opts.capture != nil && opts.capture.HostKeyPrompt == nil {
// TOFU trusts a host's first key without asking; a changed key still prompts.
tofu := !changed && hostKeyPolicyTOFU(cfg)
if opts.capture != nil && opts.capture.HostKeyPrompt == nil && !tofu {
return nil, fmt.Errorf("host %s requires interactive host-key verification", resolved.Hostname)
}
slog.Debug("preparing host key", "host", resolved.Hostname)
Expand All @@ -41,6 +43,13 @@ func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs
}
slog.Debug("host key scanned", "host", resolved.Hostname, "key_type", key.KeyType, "fingerprint", key.Fingerprint)
decision := func() (*connector.HostKeyPreparation, error) {
if tofu {
if err := appendKnownHosts(key.Line); err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "nssh: trusted new host key for %s (%s %s)\n", resolved.Hostname, key.KeyType, key.Fingerprint)
return nil, nil
}
promptFn := hostKeyPromptFunc()
if opts.capture != nil {
promptFn = opts.capture.HostKeyPrompt
Expand Down Expand Up @@ -87,6 +96,10 @@ func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs
return decision()
}

func hostKeyPolicyTOFU(cfg *config.Config) bool {
return cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.SSH.Security.HostKeyPolicy), "tofu")
}

func writeTemporaryKnownHosts(line, algorithm string) (*connector.HostKeyPreparation, error) {
file, err := os.CreateTemp("", "nssh-known-hosts-*")
if err != nil {
Expand Down
39 changes: 39 additions & 0 deletions internal/connect/hostkey_prepare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,42 @@ func TestRunHostKeyPreparationRejectsHostKey(t *testing.T) {
t.Fatalf("prep = %+v, want nil", prep)
}
}

func TestRunHostKeyPreparationTOFUTrustsNewKeyWithoutPrompt(t *testing.T) {
oldPrompt := hostKeyPromptFunc
oldScan := scanHostKeyFunc
defer func() {
hostKeyPromptFunc = oldPrompt
scanHostKeyFunc = oldScan
}()

home := t.TempDir()
t.Setenv("HOME", home)
line := "edge01 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBcLcMSBE8+TJuxrHFujWBrOCcXrl+/sTqONstg2Jcg7"
prompted := false
hostKeyPromptFunc = func() connector.HostKeyPromptFunc {
return func(connector.HostKeyPrompt) connector.HostKeyAction {
prompted = true
return connector.HostKeyReject
}
}
scanHostKeyFunc = func(context.Context, *ResolvedHost, []string, *config.Config, Options, []string) (scannedHostKey, error) {
return scannedHostKey{KeyType: "ssh-ed25519", Algorithm: "ssh-ed25519", Fingerprint: "SHA256:test", Line: line}, nil
}
cfg := config.DefaultConfig()
cfg.SSH.Security.HostKeyPolicy = "tofu"
resolved := &ResolvedHost{Hostname: "edge01", Port: 22}

prep, err := runHostKeyPreparation(context.Background(), resolved, nil, cfg, Options{}, false, nil)
if err != nil || prep != nil || prompted {
t.Fatalf("new key: prep=%v err=%v prompted=%v", prep, err, prompted)
}
data, err := os.ReadFile(filepath.Join(home, ".ssh", "known_hosts"))
if err != nil || string(data) != line+"\n" {
t.Fatalf("known_hosts = %q, err %v", data, err)
}

if _, err := runHostKeyPreparation(context.Background(), resolved, nil, cfg, Options{}, true, nil); err == nil || !prompted {
t.Fatalf("changed key: err=%v prompted=%v, want prompt and reject", err, prompted)
}
}
73 changes: 73 additions & 0 deletions internal/ui/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"errors"
"fmt"
"io"
"unicode"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
Expand Down Expand Up @@ -143,6 +145,77 @@ func SelectIndex(title string, options []string, input io.Reader) (int, error) {
return selected, nil
}

// SelectIndexWithShortcuts shows a selection list whose options can also be
// chosen with a single key. Enter selects the highlighted option, which starts
// on the first one. Filtering is disabled so letter keys stay shortcuts.
func SelectIndexWithShortcuts(title string, options []string, shortcuts map[rune]int, input io.Reader) (int, error) {
if len(options) == 0 {
return -1, nil
}

var selected int
huhOpts := make([]huh.Option[int], len(options))
for i, opt := range options {
huhOpts[i] = huh.NewOption(opt, i)
}
sel := huh.NewSelect[int]().
Title(title).
Options(huhOpts...).
Filtering(false).
Value(&selected)
form := huh.NewForm(huh.NewGroup(sel)).WithTheme(huhTheme())
form.SubmitCmd = tea.Quit
form.CancelCmd = tea.Interrupt

model := &shortcutSelect{form: form, shortcuts: shortcuts, chosen: -1}
teaOpts := []tea.ProgramOption{}
if input != nil {
teaOpts = append(teaOpts, tea.WithInput(input))
}
if _, err := tea.NewProgram(model, teaOpts...).Run(); err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return -1, huh.ErrUserAborted
}
return -1, err
}
if model.chosen >= 0 {
return model.chosen, nil
}
if form.State != huh.StateCompleted {
return -1, huh.ErrUserAborted
}
return selected, nil
}

type shortcutSelect struct {
form *huh.Form
shortcuts map[rune]int
chosen int
}

func (m *shortcutSelect) Init() tea.Cmd { return m.form.Init() }

func (m *shortcutSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok && key.Type == tea.KeyRunes && len(key.Runes) == 1 {
if idx, ok := m.shortcuts[unicode.ToLower(key.Runes[0])]; ok {
m.chosen = idx
return m, tea.Quit
}
}
model, cmd := m.form.Update(msg)
if form, ok := model.(*huh.Form); ok {
m.form = form
}
return m, cmd
}

func (m *shortcutSelect) View() string {
if m.chosen >= 0 || m.form.State != huh.StateNormal {
return ""
}
return m.form.View()
}

// Confirm shows a yes/no confirmation prompt.
// Returns the user's choice.
func Confirm(title string, defaultValue bool) (bool, error) {
Expand Down
25 changes: 25 additions & 0 deletions internal/ui/prompts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package ui

import (
"strings"
"testing"
)

func TestSelectIndexWithShortcuts(t *testing.T) {
options := []string{"Reject", "Accept once", "Accept always"}
shortcuts := map[rune]int{'o': 1, 'a': 2}
for input, want := range map[string]int{
"\r": 0, // enter selects the default first option
"o": 1,
"A": 2,
"j\r": 1, // arrow-style navigation still works
} {
got, err := SelectIndexWithShortcuts("Host key verification", options, shortcuts, strings.NewReader(input))
if err != nil {
t.Fatalf("input %q: %v", input, err)
}
if got != want {
t.Fatalf("input %q: got %d, want %d", input, got, want)
}
}
}
12 changes: 9 additions & 3 deletions skills/nssh/references/connections-logs-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,15 @@ ssh:
OpenSSH trust-on-first-use behavior. Password-backed setup scans the presented
key before using the target credential; a managed password-backed proxy may use
its own credential to reach the target during that scan. New-key prompts offer
reject, accept once, or accept always. Changed-key prompts label both acceptance
choices dangerous; accept-always removes the stale entry and writes the verified
replacement only after explicit confirmation.
reject, accept once, or accept always; Enter rejects, `o` accepts once, and `a`
accepts always. Changed-key prompts have no single-key shortcuts and label both
acceptance choices dangerous; accept-always removes the stale entry and writes
the verified replacement only after explicit confirmation.

`host_key_policy = "tofu"` trusts a host's first key without prompting: nssh
adds the scanned key to `~/.ssh/known_hosts` and prints its fingerprint. A
changed key still prompts. The preset also sets `accept_once_mode` to
`accept-new` and persists compatibility probes.

On recognized legacy SSH algorithm failures, nssh can select supported
algorithm floors and persist them under the owning provider YAML host
Expand Down
Loading