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
4 changes: 2 additions & 2 deletions cmd/gpu/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ image vmi-docker-* for a ready-made base.`,

// Phase 1: install toolkit + driver.
code, err := internalssh.RunScript(sshClient, gpuInstallScript(), nil, os.Stdout, os.Stderr)
sshClient.Close()
_ = sshClient.Close()
if err != nil {
return fmt.Errorf("install script: %w", err)
}
Expand Down Expand Up @@ -105,7 +105,7 @@ image vmi-docker-* for a ready-made base.`,
if err != nil {
return err
}
defer sshClient.Close()
defer func() { _ = sshClient.Close() }()

// Phase 4: install nvidia-utils + verify.
code, err = internalssh.RunScript(sshClient, gpuVerifyScript(), nil, os.Stdout, os.Stderr)
Expand Down
19 changes: 12 additions & 7 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ import (
var (
version = "dev"

flagProfile string
flagFormat string
flagNoInput bool
flagQuiet bool
flagVerbose bool
flagNoColor bool
flagYes bool
flagProfile string
flagFormat string
flagNoInput bool
flagQuiet bool
flagVerbose bool
flagNoColor bool
flagYes bool
flagSSHInsecure bool
)

// rootCmd is the base command.
Expand All @@ -61,6 +62,9 @@ var rootCmd = &cobra.Command{
if flagNoColor {
_ = os.Setenv(config.EnvNoColor, "1")
}
if flagSSHInsecure {
_ = os.Setenv(config.EnvSSHInsecure, "1")
}
},
}

Expand All @@ -79,6 +83,7 @@ func init() {
rootCmd.PersistentFlags().BoolVar(&flagQuiet, "quiet", false, "suppress non-essential output")
rootCmd.PersistentFlags().BoolVar(&flagNoColor, "no-color", false, "disable color output")
rootCmd.PersistentFlags().BoolVarP(&flagYes, "yes", "y", false, "skip confirmation prompts")
rootCmd.PersistentFlags().BoolVar(&flagSSHInsecure, "insecure", false, "disable SSH host-key verification (not recommended; for lab / throwaway VPS)")
rootCmd.PersistentFlags().Bool("no-headers", false, "hide table/CSV headers")
rootCmd.PersistentFlags().StringArray("filter", nil, "filter rows by key=value (repeatable)")
rootCmd.PersistentFlags().String("sort-by", "", "sort rows by field name")
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.1
require (
github.com/manifoldco/promptui v0.9.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
Expand All @@ -13,6 +14,5 @@ require (
require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.42.0 // indirect
)
8 changes: 8 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
EnvDebug = "CONOHA_DEBUG"
EnvYes = "CONOHA_YES"
EnvNoColor = "CONOHA_NO_COLOR"
EnvSSHInsecure = "CONOHA_SSH_INSECURE"
)

// EnvOr returns the environment variable value if set, otherwise the fallback.
Expand Down Expand Up @@ -46,3 +47,10 @@ func IsNoColor() bool {
_, noColor := os.LookupEnv("NO_COLOR")
return noColor
}

// IsSSHInsecure returns true when SSH host-key verification should be
// disabled (InsecureIgnoreHostKey). Set via --insecure flag or the env var.
// Default false — real known_hosts verification with TOFU fallback.
func IsSSHInsecure() bool {
return os.Getenv(EnvSSHInsecure) == "1" || os.Getenv(EnvSSHInsecure) == "true"
}
17 changes: 13 additions & 4 deletions internal/ssh/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import (
"time"

"golang.org/x/crypto/ssh"

configpkg "github.com/crowdy/conoha-cli/internal/config"
)

// ConnectConfig holds SSH connection parameters.
// ConnectConfig holds SSH connection parameters. Host-key verification is
// controlled globally via the --insecure flag / CONOHA_SSH_INSECURE env var
// (see configpkg.IsSSHInsecure); there is no per-call opt-out by design.
type ConnectConfig struct {
Host string // IP or hostname
Port string // default "22"
Expand All @@ -38,15 +42,20 @@ func Connect(cfg ConnectConfig) (*ssh.Client, error) {
return nil, fmt.Errorf("parse key %s: %w", cfg.KeyPath, err)
}

config := &ssh.ClientConfig{
hostKeyCB, err := HostKeyCallback(configpkg.IsSSHInsecure(), configpkg.IsNoInput())
if err != nil {
return nil, err
}

clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // personal VPS use
HostKeyCallback: hostKeyCB,
Timeout: 30 * time.Second,
}

addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
return ssh.Dial("tcp", addr, config)
return ssh.Dial("tcp", addr, clientCfg)
}

// RunScript uploads and executes a script on the remote server.
Expand Down
149 changes: 149 additions & 0 deletions internal/ssh/knownhosts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package ssh

import (
"bufio"
"fmt"
"net"
"os"
"path/filepath"
"strings"

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

// HostKeyCallback returns an ssh.HostKeyCallback that verifies the remote
// host key against ~/.ssh/known_hosts. On first connect to an unknown host
// it prompts the operator to accept and pin the key (TOFU). When noInput
// is true (CONOHA_NO_INPUT) or stdin is not a TTY, the connection fails
// rather than silently trusting.
//
// insecure=true returns the legacy InsecureIgnoreHostKey callback for lab
// and throwaway-VPS use; documented as the explicit opt-out for operators
// who knowingly want the old v0.1.x behavior back.
func HostKeyCallback(insecure, noInput bool) (ssh.HostKeyCallback, error) {
if insecure {
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // user-requested via --insecure
}

path, err := knownHostsPath()
if err != nil {
return nil, err
}

// knownhosts.New rejects a missing file. Create an empty one so the
// TOFU prompt path can append to it on first use.
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("creating %s dir: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("creating %s: %w", path, err)
}
_ = f.Close()
}

strict, err := knownhosts.New(path)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}

return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
if err := strict(hostname, remote, key); err == nil {
return nil
} else if kkErr, ok := err.(*knownhosts.KeyError); ok {
if len(kkErr.Want) > 0 {
// Key mismatch — never auto-accept; this is a MITM signal.
return &HostKeyMismatchError{Host: hostname, Path: path, Err: kkErr}
}
// Unknown host: TOFU prompt — only when stdin is genuinely
// interactive. A non-TTY stdin (CI, build script piping a
// heredoc, wrapper without --no-input) would otherwise let
// `yes\n` from an untrusted source silently trust the host.
if noInput || !term.IsTerminal(int(os.Stdin.Fd())) {
return fmt.Errorf("host %s not in %s and stdin is not interactive (no-input mode or non-TTY) — refusing to trust unknown host. Add manually with ssh-keyscan or use --insecure", hostname, path)
}
return promptAndPin(path, hostname, remote, key)
} else {
return err
}
}, nil
}

// HostKeyMismatchError is returned when the server presents a host key that
// disagrees with the one pinned in known_hosts. Deliberately distinct from a
// plain error so callers can print MITM-specific guidance.
type HostKeyMismatchError struct {
Host string
Path string
Err error
}

func (e *HostKeyMismatchError) Error() string {
return fmt.Sprintf(
"host key for %s has changed! This is either the server was rebuilt or a man-in-the-middle attack.\n"+
" Pinned in: %s\n"+
" Underlying: %v\n"+
"If you just rebuilt the VPS, run: ssh-keygen -R %s (removes the old pin, next connect re-pins).",
e.Host, e.Path, e.Err, e.Host)
}

func (e *HostKeyMismatchError) Unwrap() error { return e.Err }

// promptAndPin asks the user to accept the unknown key, then appends it to
// known_hosts in the canonical OpenSSH format.
func promptAndPin(path, hostname string, remote net.Addr, key ssh.PublicKey) error {
fp := ssh.FingerprintSHA256(key)
fmt.Fprintf(os.Stderr, "\nThe authenticity of host %q can't be established.\n", hostname)
fmt.Fprintf(os.Stderr, "%s key fingerprint is %s.\n", key.Type(), fp)
fmt.Fprint(os.Stderr, "Are you sure you want to continue connecting (yes/no)? ")

reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("reading prompt answer: %w", err)
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer != "yes" && answer != "y" {
return fmt.Errorf("host %s key rejected by user", hostname)
}

// Canonical line: "<addresses> <keytype> <base64 key>"
// knownhosts.Normalize returns host[:port] → host when port is 22.
addr := knownhosts.Normalize(hostname)
// Also include the numeric address so that later SSH sessions by IP
// (common in this CLI — we connect to IPs, not names) also match.
addrs := []string{addr}
if _, ok := remote.(*net.TCPAddr); ok {
if na := knownhosts.Normalize(remote.String()); na != addr {
addrs = append(addrs, na)
}
}
line = knownhosts.Line(addrs, key)

f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("opening %s for append: %w", path, err)
}
defer f.Close()
if _, err := f.WriteString(line + "\n"); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
fmt.Fprintf(os.Stderr, "Warning: Permanently added %q (%s) to the list of known hosts.\n", hostname, key.Type())
return nil
}

// knownHostsPath returns the path to the user's known_hosts file.
// Honors SSH_KNOWN_HOSTS override for tests and bespoke setups.
func knownHostsPath() (string, error) {
if p := os.Getenv("SSH_KNOWN_HOSTS"); p != "" {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving $HOME: %w", err)
}
return filepath.Join(home, ".ssh", "known_hosts"), nil
}
Loading
Loading