From 8fd83e636c62f814f998c629fa89d60190fc5306 Mon Sep 17 00:00:00 2001 From: Jeff Putsch Date: Fri, 17 Jul 2026 16:24:24 -0700 Subject: [PATCH 1/2] feat: add native SSH tunnel support Allows godap to transparently forward LDAP connections through an SSH jump host without requiring manual port forwarding. New packages: - pkg/debug/log.go: file-based debug logger, no-op by default, activated via debug.Init(path). Keeps SSH lifecycle logs off the TUI screen. - pkg/ssh/tunnel.go: SSH tunnel implementation supporting password, key, and agent auth. Listens on 127.0.0.1:0 and forwards to the remote LDAP host:port via bidirectional io.Copy. Unknown host keys are surfaced as HostKeyUnknownError for TUI-friendly handling. - pkg/ssh/tunnel_test.go: unit tests covering password auth, key file auth, end-to-end data forwarding, Close() behavior, and bad host error. Modified files: - godap.go: adds CLI flags --ssh-host, --ssh-port, --ssh-user, --ssh-auth, --ssh-password, --ssh-key, --ssh-key-passphrase, --ssh-ignore-host-key, and --debug-log. A non-empty --ssh-host implicitly enables the tunnel. - tui/main.go: integrates tunnel lifecycle into setupLDAPConn() and reconnectLdap(); adds showHostKeyModal() for unknown host key guidance; extends openConfigForm() with a third SSH tunnel panel section. - go.mod: promotes golang.org/x/crypto from indirect to direct dependency. Example: godap --ssh-host jumpbox.corp --ssh-user admin --ssh-auth key --ssh-key ~/.ssh/id_rsa ldap.internal --- go.mod | 8 +- godap.go | 27 +++ pkg/debug/log.go | 38 ++++ pkg/ssh/tunnel.go | 246 ++++++++++++++++++++++++ pkg/ssh/tunnel_test.go | 422 +++++++++++++++++++++++++++++++++++++++++ tui/main.go | 192 +++++++++++++++++-- tui/main_test.go | 41 ++++ 7 files changed, 958 insertions(+), 16 deletions(-) create mode 100644 pkg/debug/log.go create mode 100644 pkg/ssh/tunnel.go create mode 100644 pkg/ssh/tunnel_test.go diff --git a/go.mod b/go.mod index b056619..55ec2a4 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,12 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95 github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 + golang.org/x/crypto v0.21.0 + golang.org/x/term v0.18.0 golang.org/x/text v0.14.0 h12.io/socks v1.0.3 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( @@ -29,12 +33,8 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/net v0.22.0 // indirect golang.org/x/sys v0.18.0 // indirect - golang.org/x/term v0.18.0 // indirect - software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect ) replace github.com/jcmturner/gokrb5/v8 => github.com/Macmod/gokrb5/v8 v8.4.5-0.20240428143821-ea9a660f0f44 diff --git a/godap.go b/godap.go index c2933b3..47147f8 100644 --- a/godap.go +++ b/godap.go @@ -3,7 +3,9 @@ package main import ( "fmt" "log" + "os" + "github.com/Macmod/godap/v2/pkg/debug" "github.com/Macmod/godap/v2/tui" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -94,6 +96,20 @@ func main() { } } + // A non-empty --ssh-host implicitly enables the tunnel. + if tui.SSHTunnelHost != "" { + tui.SSHTunnelEnabled = true + } + + // Initialize debug log if requested. + if tui.DebugLogPath != "" { + if err := debug.Init(tui.DebugLogPath); err != nil { + log.Printf("Warning: could not open debug log %q: %v", tui.DebugLogPath, err) + } else { + defer debug.Close() + } + } + tui.SetupApp() }, } @@ -132,6 +148,17 @@ func main() { rootCmd.Flags().StringVarP(&tui.ExportDir, "exportdir", "", "data", "Custom directory to save godap exports taken with Ctrl+S") rootCmd.Flags().StringVarP(&tui.BackendFlavor, "backend", "b", "msad", "LDAP backend flavor (msad, basic or auto)") + // SSH tunnel flags + rootCmd.Flags().StringVar(&tui.SSHTunnelHost, "ssh-host", "", "SSH tunnel host (also enables the tunnel when non-empty)") + rootCmd.Flags().IntVar(&tui.SSHTunnelPort, "ssh-port", 22, "SSH tunnel port") + rootCmd.Flags().StringVar(&tui.SSHTunnelUser, "ssh-user", os.Getenv("USER"), "SSH tunnel username") + rootCmd.Flags().StringVar(&tui.SSHTunnelAuthMethod, "ssh-auth", "password", "SSH auth method: password, key, or agent") + rootCmd.Flags().StringVar(&tui.SSHTunnelPassword, "ssh-password", "", "SSH tunnel password") + rootCmd.Flags().StringVar(&tui.SSHTunnelKeyFile, "ssh-key", "", "Path to SSH private key file") + rootCmd.Flags().StringVar(&tui.SSHTunnelKeyPassphrase, "ssh-key-passphrase", "", "Passphrase for SSH private key") + rootCmd.Flags().BoolVar(&tui.SSHTunnelInsecure, "ssh-ignore-host-key", false, "Skip SSH host key verification (insecure)") + rootCmd.Flags().StringVar(&tui.DebugLogPath, "debug-log", "", "Path to debug log file") + versionCmd := &cobra.Command{ Use: "version", Short: "Print the version number of the application", diff --git a/pkg/debug/log.go b/pkg/debug/log.go new file mode 100644 index 0000000..c87de6c --- /dev/null +++ b/pkg/debug/log.go @@ -0,0 +1,38 @@ +package debug + +import ( + "fmt" + "log" + "os" +) + +var logger *log.Logger +var logFile *os.File + +// Init opens path for append-write and installs the logger. +func Init(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + logFile = f + logger = log.New(f, "", log.Ldate|log.Ltime|log.Lmicroseconds) + return nil +} + +// Log writes a timestamped line to the debug log if Init was called. +func Log(format string, args ...any) { + if logger == nil { + return + } + logger.Output(2, fmt.Sprintf(format, args...)) +} + +// Close flushes and closes the underlying log file. +func Close() { + if logFile != nil { + logFile.Close() + logFile = nil + logger = nil + } +} diff --git a/pkg/ssh/tunnel.go b/pkg/ssh/tunnel.go new file mode 100644 index 0000000..c541170 --- /dev/null +++ b/pkg/ssh/tunnel.go @@ -0,0 +1,246 @@ +package sshtunnel + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +// Config holds all parameters needed to establish an SSH tunnel. +type Config struct { + Host string + Port int // 0 means use 22 + User string + AuthMethod string // "password", "key", or "agent" + Password string + KeyFile string + KeyPassphrase string + InsecureIgnoreHostKey bool + // HostKeyCallback overrides the default known_hosts check when non-nil. + // Primarily used for testing (pass ssh.InsecureIgnoreHostKey()). + HostKeyCallback ssh.HostKeyCallback +} + +// HostKeyUnknownError is returned when the SSH server's host key is not +// present in known_hosts and InsecureIgnoreHostKey is false. +type HostKeyUnknownError struct { + Host string +} + +func (e *HostKeyUnknownError) Error() string { + return fmt.Sprintf( + "unknown SSH host key for %q — run: ssh-keyscan %s >> ~/.ssh/known_hosts or use --ssh-ignore-host-key", + e.Host, e.Host, + ) +} + +// Tunnel forwards TCP connections from a local listener through an SSH client +// to a remote endpoint. +type Tunnel struct { + config Config + client *ssh.Client + listener net.Listener + wg sync.WaitGroup + done chan struct{} + remoteHost string + remotePort int +} + +// New establishes an SSH connection and begins listening on a random local port. +// Connections to the local port are forwarded through the SSH client to +// remoteHost:remotePort. +func New(cfg Config, remoteHost string, remotePort int) (*Tunnel, error) { + port := cfg.Port + if port == 0 { + port = 22 + } + + hostKeyCallback, err := buildHostKeyCallback(cfg) + if err != nil { + return nil, err + } + + authMethods, err := buildAuthMethods(cfg) + if err != nil { + return nil, err + } + + clientCfg := &ssh.ClientConfig{ + User: cfg.User, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + } + + addr := fmt.Sprintf("%s:%d", cfg.Host, port) + client, err := ssh.Dial("tcp", addr, clientCfg) + if err != nil { + return nil, err + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + client.Close() + return nil, err + } + + t := &Tunnel{ + config: cfg, + client: client, + listener: listener, + done: make(chan struct{}), + remoteHost: remoteHost, + remotePort: remotePort, + } + + go t.accept() + return t, nil +} + +// LocalAddr returns the local address the tunnel is listening on (e.g. "127.0.0.1:54321"). +func (t *Tunnel) LocalAddr() string { + return t.listener.Addr().String() +} + +// LocalPort returns the local port the tunnel is listening on. +func (t *Tunnel) LocalPort() int { + return t.listener.Addr().(*net.TCPAddr).Port +} + +// Close shuts down the tunnel, waiting for all in-flight forwarded connections +// to finish. Safe to call multiple times. +func (t *Tunnel) Close() error { + select { + case <-t.done: + return nil // already closed + default: + close(t.done) + } + t.listener.Close() + t.client.Close() + t.wg.Wait() + return nil +} + +func (t *Tunnel) accept() { + for { + conn, err := t.listener.Accept() + if err != nil { + select { + case <-t.done: + return // expected shutdown + default: + return // unexpected error + } + } + t.wg.Add(1) + go t.forward(conn) + } +} + +func (t *Tunnel) forward(localConn net.Conn) { + defer t.wg.Done() + defer localConn.Close() + + remoteAddr := fmt.Sprintf("%s:%d", t.remoteHost, t.remotePort) + remoteConn, err := t.client.Dial("tcp", remoteAddr) + if err != nil { + return + } + defer remoteConn.Close() + + // Bidirectional copy: wait for one direction to finish. + // Deferred Close() calls on both conns unblock the other goroutine. + done := make(chan struct{}, 2) + go func() { + io.Copy(remoteConn, localConn) //nolint:errcheck + done <- struct{}{} + }() + go func() { + io.Copy(localConn, remoteConn) //nolint:errcheck + done <- struct{}{} + }() + <-done +} + +func buildHostKeyCallback(cfg Config) (ssh.HostKeyCallback, error) { + if cfg.InsecureIgnoreHostKey { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec + } + + if cfg.HostKeyCallback != nil { + return cfg.HostKeyCallback, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec + } + + knownHostsFile := filepath.Join(home, ".ssh", "known_hosts") + if _, err := os.Stat(knownHostsFile); os.IsNotExist(err) { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec + } + + khCallback, err := knownhosts.New(knownHostsFile) + if err != nil { + return nil, fmt.Errorf("failed to load known_hosts: %w", err) + } + + // Wrap the knownhosts callback to translate unknown-host errors into + // HostKeyUnknownError so callers can show a helpful message. + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := khCallback(hostname, remote, key) + if err == nil { + return nil + } + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { + // Unknown host (not a mismatch — mismatch propagates raw) + return &HostKeyUnknownError{Host: cfg.Host} + } + return err + }, nil +} + +func buildAuthMethods(cfg Config) ([]ssh.AuthMethod, error) { + switch cfg.AuthMethod { + case "key": + keyData, err := os.ReadFile(cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to read SSH key file %q: %w", cfg.KeyFile, err) + } + var signer ssh.Signer + if cfg.KeyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(cfg.KeyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey(keyData) + } + if err != nil { + return nil, fmt.Errorf("failed to parse SSH key: %w", err) + } + return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil + + case "agent": + sockPath := os.Getenv("SSH_AUTH_SOCK") + if sockPath == "" { + return nil, fmt.Errorf("SSH_AUTH_SOCK not set; cannot use agent auth") + } + conn, err := net.Dial("unix", sockPath) + if err != nil { + return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) + } + agentClient := agent.NewClient(conn) + return []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}, nil + + default: // "password" + return []ssh.AuthMethod{ssh.Password(cfg.Password)}, nil + } +} diff --git a/pkg/ssh/tunnel_test.go b/pkg/ssh/tunnel_test.go new file mode 100644 index 0000000..2f2f146 --- /dev/null +++ b/pkg/ssh/tunnel_test.go @@ -0,0 +1,422 @@ +package sshtunnel_test + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/binary" + "encoding/pem" + "fmt" + "io" + "net" + "os" + "strconv" + "testing" + + gossh "golang.org/x/crypto/ssh" + + sshtunnel "github.com/Macmod/godap/v2/pkg/ssh" +) + +// generateTestSigner creates an RSA host/user key for use in tests. +func generateTestSigner(t *testing.T) gossh.Signer { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + signer, err := gossh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + return signer +} + +// startTestSSHServer starts a minimal in-process SSH server that accepts +// password auth for user/password and forwards direct-tcpip channels. +// Returns the server address and a cleanup function. +func startTestSSHServer(t *testing.T, user, password string) (addr string, cleanup func()) { + t.Helper() + + hostKey := generateTestSigner(t) + + serverConfig := &gossh.ServerConfig{ + PasswordCallback: func(conn gossh.ConnMetadata, pw []byte) (*gossh.Permissions, error) { + if conn.User() == user && string(pw) == password { + return &gossh.Permissions{}, nil + } + return nil, fmt.Errorf("invalid credentials") + }, + } + serverConfig.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestSSHConn(conn, serverConfig) + } + }() + + return ln.Addr().String(), func() { ln.Close() } +} + +// startTestSSHServerWithKeyAuth starts an SSH server that only accepts +// public key authentication for the given user. +func startTestSSHServerWithKeyAuth(t *testing.T, user string, authorizedKey gossh.PublicKey) (addr string, cleanup func()) { + t.Helper() + + hostKey := generateTestSigner(t) + authKeyBytes := authorizedKey.Marshal() + + serverConfig := &gossh.ServerConfig{ + PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) { + if conn.User() == user && string(key.Marshal()) == string(authKeyBytes) { + return &gossh.Permissions{}, nil + } + return nil, fmt.Errorf("unauthorized key") + }, + } + serverConfig.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleTestSSHConn(conn, serverConfig) + } + }() + + return ln.Addr().String(), func() { ln.Close() } +} + +// directTCPIPPayload is the wire format of a direct-tcpip channel request. +type directTCPIPPayload struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 +} + +func handleTestSSHConn(conn net.Conn, cfg *gossh.ServerConfig) { + sshConn, chans, reqs, err := gossh.NewServerConn(conn, cfg) + if err != nil { + return + } + defer sshConn.Close() + go gossh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "direct-tcpip" { + newChan.Reject(gossh.UnknownChannelType, "only direct-tcpip supported") + continue + } + + // Parse the forwarding target from the channel extra data. + data := newChan.ExtraData() + // Manual parse: string(destAddr), uint32(destPort), string(origAddr), uint32(origPort) + destAddr, rest, ok := parseSSHString(data) + if !ok { + newChan.Reject(gossh.Prohibited, "bad payload") + continue + } + if len(rest) < 4 { + newChan.Reject(gossh.Prohibited, "bad payload") + continue + } + destPort := binary.BigEndian.Uint32(rest[:4]) + + ch, _, err := newChan.Accept() + if err != nil { + continue + } + + go func(ch gossh.Channel, dest string, port uint32) { + defer ch.Close() + target, err := net.Dial("tcp", net.JoinHostPort(dest, strconv.FormatUint(uint64(port), 10))) + if err != nil { + return + } + defer target.Close() + done := make(chan struct{}, 2) + go func() { io.Copy(target, ch); done <- struct{}{} }() //nolint:errcheck + go func() { io.Copy(ch, target); done <- struct{}{} }() //nolint:errcheck + <-done + }(ch, destAddr, destPort) + } +} + +// parseSSHString parses a length-prefixed SSH string from b. +func parseSSHString(b []byte) (s string, rest []byte, ok bool) { + if len(b) < 4 { + return "", nil, false + } + n := int(binary.BigEndian.Uint32(b[:4])) + if len(b) < 4+n { + return "", nil, false + } + return string(b[4 : 4+n]), b[4+n:], true +} + +// splitHostPort splits an address and returns host and port as int. +func splitHostPort(t *testing.T, addr string) (host string, port int) { + t.Helper() + h, p, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + n, err := strconv.Atoi(p) + if err != nil { + t.Fatal(err) + } + return h, n +} + +// TestPasswordAuth verifies that the tunnel can be established with password auth. +func TestPasswordAuth(t *testing.T) { + sshAddr, cleanup := startTestSSHServer(t, "testuser", "testpass") + defer cleanup() + + host, port := splitHostPort(t, sshAddr) + + // We don't need a real LDAP target — just use any listening port. + // The tunnel should be created successfully even if no connections flow. + dummyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer dummyLn.Close() + dummyPort := dummyLn.Addr().(*net.TCPAddr).Port + + tun, err := sshtunnel.New(sshtunnel.Config{ + Host: host, + Port: port, + User: "testuser", + AuthMethod: "password", + Password: "testpass", + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + }, "127.0.0.1", dummyPort) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer tun.Close() + + if tun.LocalPort() == 0 { + t.Error("expected non-zero local port") + } +} + +// TestKeyAuth verifies that the tunnel can be established with key-based auth. +func TestKeyAuth(t *testing.T) { + userSigner := generateTestSigner(t) + sshAddr, cleanup := startTestSSHServerWithKeyAuth(t, "keyuser", userSigner.PublicKey()) + defer cleanup() + + host, port := splitHostPort(t, sshAddr) + + // Write the private key to a temp file. + privKeyPEM := gossh.MarshalAuthorizedKey(userSigner.PublicKey()) + // We need the private key in PEM format; regenerate from scratch using crypto/rsa. + rsaPriv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + rsaSigner, err := gossh.NewSignerFromKey(rsaPriv) + if err != nil { + t.Fatal(err) + } + _ = privKeyPEM // discard the public key bytes above + + // Register the RSA signer's public key with the server. + sshAddr2, cleanup2 := startTestSSHServerWithKeyAuth(t, "keyuser2", rsaSigner.PublicKey()) + defer cleanup2() + + host2, port2 := splitHostPort(t, sshAddr2) + + // Marshal private key to PEM using x/crypto. + privBytes := gossh.MarshalAuthorizedKey(rsaSigner.PublicKey()) // public, not private + _ = privBytes + // Use the HostKeyCallback injection path instead of a key file to avoid + // writing PEM encoding logic in the test. + dummyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer dummyLn.Close() + dummyPort := dummyLn.Addr().(*net.TCPAddr).Port + + // Dial directly using the signer to verify the auth path works end-to-end. + // We pass an InMemoryKey via a custom dialer approach via HostKeyCallback. + // Since we can't inject the auth method directly, verify via normal Dial. + clientCfg := &gossh.ClientConfig{ + User: "keyuser2", + Auth: []gossh.AuthMethod{gossh.PublicKeys(rsaSigner)}, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + } + client, err := gossh.Dial("tcp", fmt.Sprintf("%s:%d", host2, port2), clientCfg) + if err != nil { + t.Fatalf("direct key auth dial failed: %v", err) + } + client.Close() + + // Also verify the tunnel package handles key files correctly via a temp file. + t.Run("key file", func(t *testing.T) { + keyFile := writePrivateKeyFile(t, rsaPriv) + sshAddr3, cleanup3 := startTestSSHServerWithKeyAuth(t, "keyuser3", rsaSigner.PublicKey()) + defer cleanup3() + host3, port3 := splitHostPort(t, sshAddr3) + + tun, err := sshtunnel.New(sshtunnel.Config{ + Host: host3, + Port: port3, + User: "keyuser3", + AuthMethod: "key", + KeyFile: keyFile, + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + }, "127.0.0.1", dummyPort) + if err != nil { + t.Fatalf("New() with key file failed: %v", err) + } + tun.Close() + }) + + // Silence unused variable warning for host/port from first test + _ = host + _ = port +} + +// writePrivateKeyFile writes an RSA private key to a temp file in OpenSSH PEM format. +func writePrivateKeyFile(t *testing.T, priv *rsa.PrivateKey) string { + t.Helper() + pemBlock, err := gossh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatal(err) + } + pemBytes := pem.EncodeToMemory(pemBlock) + path := t.TempDir() + "/id_rsa" + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + t.Fatal(err) + } + return path +} + +// TestTunnelForwarding verifies that data flows correctly through the tunnel. +func TestTunnelForwarding(t *testing.T) { + sshAddr, cleanup := startTestSSHServer(t, "u", "p") + defer cleanup() + + // Start an in-process echo server. + echoLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer echoLn.Close() + go func() { + for { + c, err := echoLn.Accept() + if err != nil { + return + } + go io.Copy(c, c) //nolint:errcheck + } + }() + + host, sshPort := splitHostPort(t, sshAddr) + echoPort := echoLn.Addr().(*net.TCPAddr).Port + + tun, err := sshtunnel.New(sshtunnel.Config{ + Host: host, + Port: sshPort, + User: "u", + AuthMethod: "password", + Password: "p", + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + }, "127.0.0.1", echoPort) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer tun.Close() + + conn, err := net.Dial("tcp", tun.LocalAddr()) + if err != nil { + t.Fatalf("Dial local tunnel addr failed: %v", err) + } + defer conn.Close() + + msg := []byte("hello tunnel") + if _, err := conn.Write(msg); err != nil { + t.Fatal(err) + } + buf := make([]byte, len(msg)) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("ReadFull failed: %v", err) + } + if string(buf) != string(msg) { + t.Errorf("echo got %q, want %q", buf, msg) + } +} + +// TestCloseStopsAccepting verifies that after Close(), the local address +// is no longer accepting new connections. +func TestCloseStopsAccepting(t *testing.T) { + sshAddr, cleanup := startTestSSHServer(t, "u", "p") + defer cleanup() + + dummyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer dummyLn.Close() + + host, port := splitHostPort(t, sshAddr) + + tun, err := sshtunnel.New(sshtunnel.Config{ + Host: host, + Port: port, + User: "u", + AuthMethod: "password", + Password: "p", + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + }, "127.0.0.1", dummyLn.Addr().(*net.TCPAddr).Port) + if err != nil { + t.Fatal(err) + } + + localAddr := tun.LocalAddr() + tun.Close() + + conn, err := net.Dial("tcp", localAddr) + if err == nil { + conn.Close() + t.Error("expected Dial to fail after Close(), but it succeeded") + } +} + +// TestBadSSHHost verifies that New() returns an error when the SSH server +// address is unreachable. +func TestBadSSHHost(t *testing.T) { + _, err := sshtunnel.New(sshtunnel.Config{ + Host: "127.0.0.1", + Port: 1, // no server listening here + User: "u", + AuthMethod: "password", + Password: "p", + HostKeyCallback: gossh.InsecureIgnoreHostKey(), //nolint:gosec + }, "127.0.0.1", 22) + if err == nil { + t.Error("expected error for unreachable SSH host, got nil") + } +} diff --git a/tui/main.go b/tui/main.go index 72473c0..4303cf9 100644 --- a/tui/main.go +++ b/tui/main.go @@ -4,6 +4,7 @@ import ( "bufio" "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "log" @@ -14,7 +15,9 @@ import ( "strings" "time" + "github.com/Macmod/godap/v2/pkg/debug" "github.com/Macmod/godap/v2/pkg/ldaputils" + sshtunnel "github.com/Macmod/godap/v2/pkg/ssh" "github.com/gdamore/tcell/v2" "github.com/go-ldap/ldap/v3" "github.com/rivo/tview" @@ -65,6 +68,18 @@ var ( AuthType int ExportDir string + // SSH tunnel settings + SSHTunnelEnabled bool + SSHTunnelHost string + SSHTunnelPort int + SSHTunnelUser string + SSHTunnelAuthMethod string + SSHTunnelPassword string + SSHTunnelKeyFile string + SSHTunnelKeyPassphrase string + SSHTunnelInsecure bool + DebugLogPath string + page int ) @@ -83,9 +98,10 @@ var ( sortAttrsFlagPanel *tview.TextView deletedFlagPanel *tview.TextView - tlsConfig *tls.Config - lc = &ldaputils.LDAPConn{} - err error + tlsConfig *tls.Config + lc = &ldaputils.LDAPConn{} + err error + activeTunnel *sshtunnel.Tunnel ) type GodapPage struct { @@ -272,7 +288,13 @@ func upgradeStartTLS() { func reconnectLdap() { go app.QueueUpdateDraw(func() { - setupLDAPConn() + connErr := setupLDAPConn() + if connErr != nil { + var hkErr *sshtunnel.HostKeyUnknownError + if errors.As(connErr, &hkErr) { + showHostKeyModal(hkErr.Host) + } + } }) } @@ -397,6 +419,43 @@ func openConfigForm() { configForm.GetFormItemByLabel("Auth Type").(*tview.DropDown). SetCurrentOption(AuthType) + // SSH tunnel form + sshForm := NewXForm() + sshPortStr := "" + if SSHTunnelPort != 0 { + sshPortStr = strconv.Itoa(SSHTunnelPort) + } + sshAuthIdx := 0 + switch SSHTunnelAuthMethod { + case "key": + sshAuthIdx = 1 + case "agent": + sshAuthIdx = 2 + } + sshForm. + AddInputField("SSH Host", SSHTunnelHost, 20, nil, nil). + AddInputField("SSH Port", sshPortStr, 8, nil, nil). + AddInputField("SSH User", SSHTunnelUser, 20, nil, nil). + AddDropDown("SSH Auth", []string{"password", "key", "agent"}, sshAuthIdx, nil). + AddPasswordField("SSH Password", SSHTunnelPassword, 20, '*', nil). + AddInputField("SSH Key File", SSHTunnelKeyFile, 30, nil, nil). + AddPasswordField("SSH Key Passphrase", SSHTunnelKeyPassphrase, 20, '*', nil). + AddCheckbox("Ignore Host Key", SSHTunnelInsecure, nil) + + emptySSHBox := tview.NewBox() + sshSection := tview.NewPages(). + AddPage("ssh-off", emptySSHBox, true, !SSHTunnelEnabled). + AddPage("ssh-on", sshForm, true, SSHTunnelEnabled) + + // Add SSH Tunnel checkbox to configForm (before buttons) + configForm.AddCheckbox("SSH Tunnel", SSHTunnelEnabled, func(checked bool) { + if checked { + sshSection.SwitchToPage("ssh-on") + } else { + sshSection.SwitchToPage("ssh-off") + } + }) + configForm. AddButton("Go Back", func() { app.SetRoot(appPanel, true).SetFocus(currentFocus) @@ -438,18 +497,34 @@ func openConfigForm() { AuthType = authTypeField + // Update SSH tunnel settings + SSHTunnelEnabled = configForm.GetFormItemByLabel("SSH Tunnel").(*tview.Checkbox).IsChecked() + SSHTunnelHost = sshForm.GetFormItemByLabel("SSH Host").(*tview.InputField).GetText() + sshPort, _ := validateSSHPort(sshForm.GetFormItemByLabel("SSH Port").(*tview.InputField).GetText()) + SSHTunnelPort = sshPort + SSHTunnelUser = sshForm.GetFormItemByLabel("SSH User").(*tview.InputField).GetText() + _, sshAuthMethod := sshForm.GetFormItemByLabel("SSH Auth").(*tview.DropDown).GetCurrentOption() + SSHTunnelAuthMethod = sshAuthMethod + SSHTunnelPassword = sshForm.GetFormItemByLabel("SSH Password").(*tview.InputField).GetText() + SSHTunnelKeyFile = sshForm.GetFormItemByLabel("SSH Key File").(*tview.InputField).GetText() + SSHTunnelKeyPassphrase = sshForm.GetFormItemByLabel("SSH Key Passphrase").(*tview.InputField).GetText() + SSHTunnelInsecure = sshForm.GetFormItemByLabel("Ignore Host Key").(*tview.Checkbox).IsChecked() + app.SetRoot(appPanel, true).SetFocus(currentFocus) reconnectLdap() }) - // Create configPanel container for both forms - configPanel := tview.NewFlex(). + // Top row: connection settings + auth pages side by side + topRow := tview.NewFlex(). AddItem(configForm, 0, 1, true). AddItem(authPages, 0, 1, false) - configPanel.SetBorder(true).SetTitle("Connection Configuration") + // Outer panel: top row stacked above SSH section + configPanel := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(topRow, 0, 2, true). + AddItem(sshSection, 0, 1, false) - //assignFormTheme(credsForm) + configPanel.SetBorder(true).SetTitle("Connection Configuration") configPanel.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { @@ -458,9 +533,16 @@ func openConfigForm() { } if event.Key() == tcell.KeyTab { - if app.GetFocus() == configForm { + switch app.GetFocus() { + case configForm: app.SetFocus(authPages) - } else { + case authPages: + if SSHTunnelEnabled { + app.SetFocus(sshForm) + } else { + app.SetFocus(configForm) + } + default: app.SetFocus(configForm) } return nil @@ -510,6 +592,49 @@ func appPanelKeyHandler(event *tcell.EventKey) *tcell.EventKey { return event } +// validateSSHPort parses s as an SSH port number. +// An empty string returns (0, nil). An out-of-range or non-numeric value returns an error. +func validateSSHPort(s string) (int, error) { + if s == "" { + return 0, nil + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 || n > 65535 { + return 0, fmt.Errorf("invalid SSH port: %q", s) + } + return n, nil +} + +// isSSHTunnelFieldVisible reports whether SSH tunnel fields should be shown in the config form. +func isSSHTunnelFieldVisible() bool { + return SSHTunnelEnabled +} + +// showHostKeyModal displays a modal explaining that the SSH host key is unknown, +// with instructions for adding it to known_hosts. +func showHostKeyModal(host string) { + modal := tview.NewModal(). + SetText(fmt.Sprintf( + "Unknown SSH host key for: %s\n\n"+ + "To add it to known_hosts, run:\n"+ + " ssh-keyscan %s >> ~/.ssh/known_hosts\n\n"+ + "Or restart godap with --ssh-ignore-host-key\n\n"+ + "Press any key to dismiss.", + host, host, + )). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + app.SetRoot(appPanel, true) + }) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + app.SetRoot(appPanel, true) + return nil + }) + + app.SetRoot(modal, true).SetFocus(modal) +} + func readFileOrStdin(filename string, promptIfTerm string) (string, error) { if filename == "-" { return readPass(promptIfTerm), nil @@ -596,12 +721,48 @@ func setupLDAPConn() error { tlsConfig.Certificates = []tls.Certificate{cert} } + // SSH tunnel lifecycle — close old tunnel before creating a new one. + if activeTunnel != nil { + activeTunnel.Close() + activeTunnel = nil + } + + effectiveLdapServer := LdapServer + effectiveLdapPort := LdapPort + + if SSHTunnelEnabled && SSHTunnelHost != "" { + port := SSHTunnelPort + if port == 0 { + port = 22 + } + t, tunnelErr := sshtunnel.New(sshtunnel.Config{ + Host: SSHTunnelHost, + Port: port, + User: SSHTunnelUser, + AuthMethod: SSHTunnelAuthMethod, + Password: SSHTunnelPassword, + KeyFile: SSHTunnelKeyFile, + KeyPassphrase: SSHTunnelKeyPassphrase, + InsecureIgnoreHostKey: SSHTunnelInsecure, + }, LdapServer, LdapPort) + if tunnelErr != nil { + debug.Log("SSH tunnel failed: %v", tunnelErr) + updateLog(fmt.Sprint(tunnelErr), "red") + updateStateBox(statusPanel, false) + return tunnelErr + } + debug.Log("SSH tunnel established on %s", t.LocalAddr()) + activeTunnel = t + effectiveLdapServer = "127.0.0.1" + effectiveLdapPort = t.LocalPort() + } + var proxyConn net.Conn = nil var err error if SocksServer != "" { proxyDial := socks.Dial(SocksServer) - proxyConn, err = proxyDial("tcp", fmt.Sprintf("%s:%s", LdapServer, strconv.Itoa(LdapPort))) + proxyConn, err = proxyDial("tcp", fmt.Sprintf("%s:%s", effectiveLdapServer, strconv.Itoa(effectiveLdapPort))) if err != nil { app.Stop() log.Fatal(fmt.Sprint(err)) @@ -612,7 +773,7 @@ func setupLDAPConn() error { var newLc *ldaputils.LDAPConn newLc, err = ldaputils.NewLDAPConn( - LdapServer, LdapPort, + effectiveLdapServer, effectiveLdapPort, Ldaps, tlsConfig, PagingSize, RootDN, proxyConn, ) @@ -782,6 +943,13 @@ func SetupApp() { err := setupLDAPConn() if err != nil { + var hkErr *sshtunnel.HostKeyUnknownError + if errors.As(err, &hkErr) { + log.Fatalf( + "Unknown SSH host key for %s\nRun: ssh-keyscan %s >> ~/.ssh/known_hosts\nOr use: --ssh-ignore-host-key", + hkErr.Host, hkErr.Host, + ) + } log.Fatal(err) } diff --git a/tui/main_test.go b/tui/main_test.go index e542c8a..baa84b9 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -4,6 +4,47 @@ import ( "testing" ) +func TestValidateSSHPort(t *testing.T) { + tests := []struct { + input string + want int + wantErr bool + }{ + {"", 0, false}, + {"22", 22, false}, + {"65535", 65535, false}, + {"1", 1, false}, + {"0", 0, true}, + {"-1", 0, true}, + {"65536", 0, true}, + {"abc", 0, true}, + {"22.5", 0, true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := validateSSHPort(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("validateSSHPort(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("validateSSHPort(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestIsSSHTunnelFieldVisible(t *testing.T) { + SSHTunnelEnabled = false + if isSSHTunnelFieldVisible() { + t.Error("expected false when SSHTunnelEnabled=false") + } + SSHTunnelEnabled = true + if !isSSHTunnelFieldVisible() { + t.Error("expected true when SSHTunnelEnabled=true") + } + SSHTunnelEnabled = false // restore +} + func TestSetupTimeFormat(t *testing.T) { tests := []struct { name string From f243b6761f0aef9071404115b13870f62337f210 Mon Sep 17 00:00:00 2001 From: Jeff Putsch Date: Fri, 17 Jul 2026 16:35:33 -0700 Subject: [PATCH 2/2] feat: LDAP/SSH password from env vars, prompting, and SSH passfile/agent flags - GODAP_PASSWD env var sets LDAP password (overridden by --password/--passfile) - GODAP_SSH_PASSWORD env var sets SSH tunnel password (overridden by --ssh-password/--ssh-passfile) - Interactive LDAP password prompt when -u is given but no password method is set - New --ssh-passfile flag reads SSH password from a file or stdin (- to prompt) - New --ssh-agent flag selects SSH agent auth; SSH auth method is now inferred from flags (--ssh-agent, --ssh-key, --ssh-password/--ssh-passfile) with conflict detection; --ssh-auth kept for backwards compat and TUI config form - TUI SSH config form adds passfile option to SSH Auth dropdown and a password file field Co-Authored-By: Claude Sonnet 4.6 --- godap.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++-- tui/main.go | 32 ++++++++++++++++++------- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/godap.go b/godap.go index 47147f8..48a09c4 100644 --- a/godap.go +++ b/godap.go @@ -4,14 +4,17 @@ import ( "fmt" "log" "os" + "strings" "github.com/Macmod/godap/v2/pkg/debug" "github.com/Macmod/godap/v2/tui" "github.com/spf13/cobra" "github.com/spf13/pflag" + "golang.org/x/term" ) var acceptableAuthFlagSets = []map[string]bool{ + {"username": true}, {"username": true, "password": true}, {"username": true, "passfile": true}, {"username": true, "hash": true}, @@ -80,12 +83,71 @@ func main() { Short: "A complete TUI for LDAP.", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - err := validateFlagSet(cmd) + // Apply GODAP_PASSWD env var when no explicit password flag was provided. + if !cmd.Flags().Changed("password") && !cmd.Flags().Changed("passfile") { + if envPw := os.Getenv("GODAP_PASSWD"); envPw != "" { + tui.LdapPassword = envPw + } + } + + // Apply GODAP_SSH_PASSWORD env var when no explicit SSH password flag was provided. + if !cmd.Flags().Changed("ssh-password") && !cmd.Flags().Changed("ssh-passfile") { + if envPw := os.Getenv("GODAP_SSH_PASSWORD"); envPw != "" { + tui.SSHTunnelPassword = envPw + } + } + // --ssh-passfile: read password from file or prompt on "-". + if cmd.Flags().Changed("ssh-passfile") { + pw, err := tui.ReadFileOrStdin(tui.SSHTunnelPasswordFile, "SSH Password: ") + if err != nil { + log.Fatalf("Failed to read SSH password file: %v", err) + } + tui.SSHTunnelPassword = strings.TrimSpace(pw) + } + + // Infer SSH auth method from flags; explicit --ssh-auth is honoured only as a fallback. + sshAgentSet := tui.SSHTunnelAgentAuth + sshKeySet := cmd.Flags().Changed("ssh-key") + sshPassSet := tui.SSHTunnelPassword != "" + switch { + case sshAgentSet && sshKeySet: + log.Fatal("Conflicting SSH auth flags: --ssh-agent and --ssh-key cannot both be set") + case sshAgentSet && sshPassSet: + log.Fatal("Conflicting SSH auth flags: --ssh-agent and --ssh-password/--ssh-passfile cannot both be set") + case sshKeySet && sshPassSet: + log.Fatal("Conflicting SSH auth flags: --ssh-key and --ssh-password/--ssh-passfile cannot both be set") + case sshAgentSet: + tui.SSHTunnelAuthMethod = "agent" + case sshKeySet: + tui.SSHTunnelAuthMethod = "key" + case sshPassSet: + tui.SSHTunnelAuthMethod = "password" + } + + err := validateFlagSet(cmd) if err != nil { log.Fatalf(fmt.Sprint(err)) } + // Prompt for LDAP password when username is set but no password method was provided. + if tui.LdapUsername != "" && + tui.LdapPassword == "" && + tui.LdapPasswordFile == "" && + tui.NtlmHash == "" && + tui.NtlmHashFile == "" && + !tui.Kerberos && + tui.CertFile == "" && + tui.PfxFile == "" { + fmt.Print("LDAP Password: ") + passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + log.Fatalf("Failed to read password: %v", err) + } + tui.LdapPassword = string(passwordBytes) + } + tui.LdapServer = args[0] if tui.LdapPort == 0 { @@ -152,8 +214,10 @@ func main() { rootCmd.Flags().StringVar(&tui.SSHTunnelHost, "ssh-host", "", "SSH tunnel host (also enables the tunnel when non-empty)") rootCmd.Flags().IntVar(&tui.SSHTunnelPort, "ssh-port", 22, "SSH tunnel port") rootCmd.Flags().StringVar(&tui.SSHTunnelUser, "ssh-user", os.Getenv("USER"), "SSH tunnel username") - rootCmd.Flags().StringVar(&tui.SSHTunnelAuthMethod, "ssh-auth", "password", "SSH auth method: password, key, or agent") + rootCmd.Flags().StringVar(&tui.SSHTunnelAuthMethod, "ssh-auth", "password", "SSH auth method: password, key, or agent (deprecated: inferred automatically from other flags)") rootCmd.Flags().StringVar(&tui.SSHTunnelPassword, "ssh-password", "", "SSH tunnel password") + rootCmd.Flags().StringVar(&tui.SSHTunnelPasswordFile, "ssh-passfile", "", "Path to a file containing the SSH tunnel password (or - for stdin)") + rootCmd.Flags().BoolVar(&tui.SSHTunnelAgentAuth, "ssh-agent", false, "Use SSH agent for tunnel authentication") rootCmd.Flags().StringVar(&tui.SSHTunnelKeyFile, "ssh-key", "", "Path to SSH private key file") rootCmd.Flags().StringVar(&tui.SSHTunnelKeyPassphrase, "ssh-key-passphrase", "", "Passphrase for SSH private key") rootCmd.Flags().BoolVar(&tui.SSHTunnelInsecure, "ssh-ignore-host-key", false, "Skip SSH host key verification (insecure)") diff --git a/tui/main.go b/tui/main.go index 4303cf9..47741e8 100644 --- a/tui/main.go +++ b/tui/main.go @@ -75,6 +75,8 @@ var ( SSHTunnelUser string SSHTunnelAuthMethod string SSHTunnelPassword string + SSHTunnelPasswordFile string + SSHTunnelAgentAuth bool SSHTunnelKeyFile string SSHTunnelKeyPassphrase string SSHTunnelInsecure bool @@ -427,17 +429,20 @@ func openConfigForm() { } sshAuthIdx := 0 switch SSHTunnelAuthMethod { - case "key": + case "passfile": sshAuthIdx = 1 - case "agent": + case "key": sshAuthIdx = 2 + case "agent": + sshAuthIdx = 3 } sshForm. AddInputField("SSH Host", SSHTunnelHost, 20, nil, nil). AddInputField("SSH Port", sshPortStr, 8, nil, nil). AddInputField("SSH User", SSHTunnelUser, 20, nil, nil). - AddDropDown("SSH Auth", []string{"password", "key", "agent"}, sshAuthIdx, nil). + AddDropDown("SSH Auth", []string{"password", "passfile", "key", "agent"}, sshAuthIdx, nil). AddPasswordField("SSH Password", SSHTunnelPassword, 20, '*', nil). + AddInputField("SSH Password File", SSHTunnelPasswordFile, 30, nil, nil). AddInputField("SSH Key File", SSHTunnelKeyFile, 30, nil, nil). AddPasswordField("SSH Key Passphrase", SSHTunnelKeyPassphrase, 20, '*', nil). AddCheckbox("Ignore Host Key", SSHTunnelInsecure, nil) @@ -504,8 +509,19 @@ func openConfigForm() { SSHTunnelPort = sshPort SSHTunnelUser = sshForm.GetFormItemByLabel("SSH User").(*tview.InputField).GetText() _, sshAuthMethod := sshForm.GetFormItemByLabel("SSH Auth").(*tview.DropDown).GetCurrentOption() - SSHTunnelAuthMethod = sshAuthMethod - SSHTunnelPassword = sshForm.GetFormItemByLabel("SSH Password").(*tview.InputField).GetText() + SSHTunnelPasswordFile = sshForm.GetFormItemByLabel("SSH Password File").(*tview.InputField).GetText() + if sshAuthMethod == "passfile" { + pw, err := ReadFileOrStdin(SSHTunnelPasswordFile, "SSH Password: ") + if err != nil { + updateLog(fmt.Sprintf("Failed to read SSH password file: %v", err), "red") + return + } + SSHTunnelPassword = strings.TrimSpace(pw) + SSHTunnelAuthMethod = "password" + } else { + SSHTunnelAuthMethod = sshAuthMethod + SSHTunnelPassword = sshForm.GetFormItemByLabel("SSH Password").(*tview.InputField).GetText() + } SSHTunnelKeyFile = sshForm.GetFormItemByLabel("SSH Key File").(*tview.InputField).GetText() SSHTunnelKeyPassphrase = sshForm.GetFormItemByLabel("SSH Key Passphrase").(*tview.InputField).GetText() SSHTunnelInsecure = sshForm.GetFormItemByLabel("Ignore Host Key").(*tview.Checkbox).IsChecked() @@ -635,7 +651,7 @@ func showHostKeyModal(host string) { app.SetRoot(modal, true).SetFocus(modal) } -func readFileOrStdin(filename string, promptIfTerm string) (string, error) { +func ReadFileOrStdin(filename string, promptIfTerm string) (string, error) { if filename == "-" { return readPass(promptIfTerm), nil } @@ -669,7 +685,7 @@ func setupLDAPConn() error { if AuthType == 0 { currentLdapPassword = strings.TrimSpace(LdapPassword) } else if AuthType == 1 { - pw, err = readFileOrStdin(LdapPasswordFile, "Password: ") + pw, err = ReadFileOrStdin(LdapPasswordFile, "Password: ") if err != nil { app.Stop() @@ -679,7 +695,7 @@ func setupLDAPConn() error { } else if AuthType == 2 { currentNtlmHash = strings.TrimSpace(NtlmHash) } else if AuthType == 3 { - hash, err = readFileOrStdin(NtlmHashFile, "NTLM hash: ") + hash, err = ReadFileOrStdin(NtlmHashFile, "NTLM hash: ") if err != nil { app.Stop()