From 3066b3cbfad350ce206dbefdd4190b9a1852b6e4 Mon Sep 17 00:00:00 2001 From: Lonny Wong Date: Sun, 5 May 2024 11:02:56 +0800 Subject: [PATCH] support X11 forwarding #97 ForwardX11 ForwardX11Trusted ForwardX11Timeout --- README.md | 1 + tssh/agent.go | 26 +------ tssh/agent_windows.go | 4 +- tssh/args.go | 3 + tssh/args_test.go | 4 + tssh/cipher.go | 4 +- tssh/forward.go | 165 ++++++++++++++++++++++++++++++++++++++++++ tssh/forward_test.go | 20 +++++ tssh/login.go | 5 +- tssh/xauth.go | 110 ++++++++++++++++++++++++++++ 10 files changed, 315 insertions(+), 27 deletions(-) create mode 100644 tssh/xauth.go diff --git a/README.md b/README.md index 9202a22..5e932d7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ trzsz-ssh ( tssh ) works exactly like the openssh client. The following common f | Multiplexing | `ControlMaster` `ControlPath` `ControlPersist` | | Command | `RemoteCommand`, `LocalCommand`, `PermitLocalCommand` | | SSH Agent | `-a` `-A` `ForwardAgent` `IdentityAgent` `SSH_AUTH_SOCK` | +| X11 Forward | `-x` `-X` `-Y` `ForwardX11` `ForwardX11Trusted` `ForwardX11Timeout` | | Known Hosts | `UserKnownHostsFile` `GlobalKnownHostsFile` `StrictHostKeyChecking` | | Basic Login | `-l` `-p` `-i` `-F` `HostName` `Port` `User` `IdentityFile` `SendEnv` `SetEnv` | | Authentication | `PubkeyAuthentication` `PasswordAuthentication` `KbdInteractiveAuthentication` | diff --git a/tssh/agent.go b/tssh/agent.go index 8db859a..faa8894 100644 --- a/tssh/agent.go +++ b/tssh/agent.go @@ -1,5 +1,3 @@ -package tssh - /* MIT License @@ -24,10 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +package tssh + import ( "fmt" - "io" - "net" "os" "strings" "sync" @@ -119,25 +117,9 @@ func forwardToRemote(client *ssh.Client, addr string) error { func forwardAgentRequest(channel ssh.Channel, addr string) { conn, err := dialAgent(addr) if err != nil { + debug("ssh agent dial [%s] failed: %v", addr, err) return } - var wg sync.WaitGroup - wg.Add(2) - go func() { - _, _ = io.Copy(conn, channel) - if unixConn, ok := conn.(*net.UnixConn); ok { - _ = unixConn.CloseWrite() - } - wg.Done() - }() - go func() { - _, _ = io.Copy(channel, conn) - _ = channel.CloseWrite() - wg.Done() - }() - - wg.Wait() - conn.Close() - channel.Close() + forwardChannel(channel, conn) } diff --git a/tssh/agent_windows.go b/tssh/agent_windows.go index 95039ae..7e35581 100644 --- a/tssh/agent_windows.go +++ b/tssh/agent_windows.go @@ -1,5 +1,3 @@ -package tssh - /* MIT License @@ -24,6 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +package tssh + import ( "net" "time" diff --git a/tssh/args.go b/tssh/args.go index f9311c8..8039dba 100644 --- a/tssh/args.go +++ b/tssh/args.go @@ -71,6 +71,9 @@ type sshArgs struct { DynamicForward bindArgs `arg:"-D,--" placeholder:"[bind_addr:]port" help:"dynamic port forwarding ( socks5 proxy )"` LocalForward forwardArgs `arg:"-L,--" placeholder:"[bind_addr:]port:host:hostport" help:"local port forwarding"` RemoteForward forwardArgs `arg:"-R,--" placeholder:"[bind_addr:]port:host:hostport" help:"remote port forwarding"` + X11Untrusted bool `arg:"-X,--" help:"enables X11 forwarding"` + NoX11Forward bool `arg:"-x,--" help:"disables X11 forwarding"` + X11Trusted bool `arg:"-Y,--" help:"enables trusted X11 forwarding"` Reconnect bool `arg:"--reconnect" help:"reconnect when background(-f) process exits"` DragFile bool `arg:"--dragfile" help:"enable drag files and directories to upload"` TraceLog bool `arg:"--tracelog" help:"enable trzsz detect trace logs for debugging"` diff --git a/tssh/args_test.go b/tssh/args_test.go index 3ad0344..faea7db 100644 --- a/tssh/args_test.go +++ b/tssh/args_test.go @@ -61,6 +61,10 @@ func TestSshArgs(t *testing.T) { assertArgsEqual("-N", sshArgs{NoCommand: true}) assertArgsEqual("-gfN -T", sshArgs{Gateway: true, Background: true, NoCommand: true, DisableTTY: true}) + assertArgsEqual("-X", sshArgs{X11Untrusted: true}) + assertArgsEqual("-x", sshArgs{NoX11Forward: true}) + assertArgsEqual("-Y", sshArgs{X11Trusted: true}) + assertArgsEqual("-p1022", sshArgs{Port: 1022}) assertArgsEqual("-p 2049", sshArgs{Port: 2049}) assertArgsEqual("-luser", sshArgs{LoginName: "user"}) diff --git a/tssh/cipher.go b/tssh/cipher.go index 3a3cdcb..8cf88d8 100644 --- a/tssh/cipher.go +++ b/tssh/cipher.go @@ -1,5 +1,3 @@ -package tssh - /* MIT License @@ -24,6 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +package tssh + import ( "fmt" "regexp" diff --git a/tssh/forward.go b/tssh/forward.go index 3cf0fa3..ac9ba8f 100644 --- a/tssh/forward.go +++ b/tssh/forward.go @@ -439,3 +439,168 @@ func sshForward(client *ssh.Client, args *sshArgs, param *sshParam) error { return nil } + +type x11Request struct { + SingleConnection bool + AuthProtocol string + AuthCookie string + ScreenNumber uint32 +} + +func sshX11Forward(args *sshArgs, client *ssh.Client, session *ssh.Session) { + if args.NoX11Forward || !args.X11Untrusted && !args.X11Trusted && strings.ToLower(getOptionConfig(args, "ForwardX11")) != "yes" { + return + } + + display := os.Getenv("DISPLAY") + if display == "" { + warning("X11 forwarding is not working since environment variable DISPLAY is not set") + return + } + hostname, displayNumber := resolveDisplayEnv(display) + + trusted := false + if !args.X11Untrusted && (args.X11Trusted || strings.ToLower(getOptionConfig(args, "ForwardX11Trusted")) == "yes") { + trusted = true + } + + timeout := 1200 + if !trusted { + forwardX11Timeout := getOptionConfig(args, "ForwardX11Timeout") + if forwardX11Timeout != "" && strings.ToLower(forwardX11Timeout) != "none" { + seconds, err := convertSshTime(forwardX11Timeout) + if err != nil { + warning("invalid ForwardX11Timeout '%s': %v", forwardX11Timeout, err) + } else { + timeout = seconds + } + } + } + + cookie, proto, err := getXauthAndProto(display, trusted, timeout) + if err != nil { + warning("X11 forwarding get xauth failed: %v", err) + return + } + + payload := x11Request{ + SingleConnection: false, + AuthProtocol: proto, + AuthCookie: cookie, + ScreenNumber: 0, + } + ok, err := session.SendRequest("x11-req", true, ssh.Marshal(payload)) + if err != nil { + warning("X11 forwarding request failed: %v", err) + return + } + if !ok { + warning("X11 forwarding request denied") + return + } + + channels := client.HandleChannelOpen("x11") + if channels == nil { + warning("already have handler for x11") + return + } + go func() { + for ch := range channels { + channel, reqs, err := ch.Accept() + if err != nil { + continue + } + go ssh.DiscardRequests(reqs) + go func() { + serveX11(display, hostname, displayNumber, channel) + channel.Close() + }() + } + }() +} + +func resolveDisplayEnv(display string) (string, int) { + colon := strings.LastIndex(display, ":") + if colon < 0 { + return "", 0 + } + hostname := display[:colon] + display = display[colon+1:] + dot := strings.Index(display, ".") + if dot < 0 { + dot = len(display) + } + displayNumber, err := strconv.Atoi(display[:dot]) + if err != nil { + return "", 0 + } + return hostname, displayNumber +} + +func convertSshTime(time string) (int, error) { + total := 0 + seconds := 0 + for _, ch := range time { + switch { + case ch >= '0' && ch <= '9': + seconds = seconds*10 + int(ch-'0') + case ch == 's' || ch == 'S': + total += seconds + seconds = 0 + case ch == 'm' || ch == 'M': + total += seconds * 60 + seconds = 0 + case ch == 'h' || ch == 'H': + total += seconds * 60 * 60 + seconds = 0 + case ch == 'd' || ch == 'D': + total += seconds * 60 * 60 * 24 + seconds = 0 + case ch == 'w' || ch == 'W': + total += seconds * 60 * 60 * 24 * 7 + seconds = 0 + default: + return 0, fmt.Errorf("invalid char '%c'", ch) + } + } + return total + seconds, nil +} + +func serveX11(display, hostname string, displayNumber int, channel ssh.Channel) { + var err error + var conn net.Conn + if hostname != "" && !strings.HasPrefix(hostname, "/") { + conn, err = net.DialTimeout("tcp", joinHostPort(hostname, strconv.Itoa(6000+displayNumber)), time.Second) + } else if strings.HasPrefix(display, "/") { + conn, err = net.DialTimeout("unix", display, time.Second) + } else { + conn, err = net.DialTimeout("unix", fmt.Sprintf("/tmp/.X11-unix/X%d", displayNumber), time.Second) + } + if err != nil { + debug("X11 forwarding dial [%s] failed: %v", display, err) + return + } + + forwardChannel(channel, conn) +} + +func forwardChannel(channel ssh.Channel, conn net.Conn) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + _, _ = io.Copy(conn, channel) + if unixConn, ok := conn.(*net.UnixConn); ok { + _ = unixConn.CloseWrite() + } + wg.Done() + }() + go func() { + _, _ = io.Copy(channel, conn) + _ = channel.CloseWrite() + wg.Done() + }() + + wg.Wait() + conn.Close() + channel.Close() +} diff --git a/tssh/forward_test.go b/tssh/forward_test.go index 0bff3b8..f6ff36b 100644 --- a/tssh/forward_test.go +++ b/tssh/forward_test.go @@ -209,3 +209,23 @@ func TestParseForwardArg(t *testing.T) { assertArgError("127.0.0.1:8000:[::1]]:9000", "invalid forward specification: 127.0.0.1:8000:[::1]]:9000") assertArgError("127.0.0.1:8000:[:\t:1]:9000", "invalid forward specification: 127.0.0.1:8000:[:\t:1]:9000") } + +func TestConvertSshTime(t *testing.T) { + assert := assert.New(t) + assertTimeEqual := func(time string, expected int) { + t.Helper() + seconds, err := convertSshTime(time) + assert.Nil(err) + assert.Equal(expected, seconds) + } + assertTimeEqual("0", 0) + assertTimeEqual("0s", 0) + assertTimeEqual("0W", 0) + assertTimeEqual("1", 1) + assertTimeEqual("1S", 1) + assertTimeEqual("90m", 5400) + assertTimeEqual("1h30m", 5400) + assertTimeEqual("2d", 172800) + assertTimeEqual("1w", 604800) + assertTimeEqual("1W2d3h4m5", 788645) +} diff --git a/tssh/login.go b/tssh/login.go index b88b644..6a73b53 100644 --- a/tssh/login.go +++ b/tssh/login.go @@ -1268,9 +1268,12 @@ func sshLogin(args *sshArgs) (ss *sshSession, err error) { return } - // ssh agent forward if !control { + // ssh agent forward sshAgentForward(args, param, ss.client, ss.session) + + // x11 forward + sshX11Forward(args, ss.client, ss.session) } // not terminal or not tty diff --git a/tssh/xauth.go b/tssh/xauth.go new file mode 100644 index 0000000..dee9547 --- /dev/null +++ b/tssh/xauth.go @@ -0,0 +1,110 @@ +/* +MIT License + +Copyright (c) 2023-2024 The Trzsz SSH Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package tssh + +import ( + "bytes" + "crypto/rand" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +const kSshX11Proto = "MIT-MAGIC-COOKIE-1" + +func getXauthAndProto(display string, trusted bool, timeout int) (string, string, error) { + if !commandExists("xauth") { + debug("no xauth program") + return genFakeXauth(trusted) + } + + if strings.HasPrefix(display, "localhost:") { + display = "unix:" + display[10:] + } + + var listArgs []string + if !trusted { + file, err := os.CreateTemp("", "xauthfile_*") + if err != nil { + return "", "", fmt.Errorf("create xauth file failed: %v", err) + } + path := file.Name() + defer os.Remove(path) + genArgs := []string{"-f", path, "generate", display, kSshX11Proto, "untrusted"} + if timeout > 0 { + genArgs = append(genArgs, "timeout", strconv.Itoa(timeout)) + } + debug("xauth generate command: %v", genArgs) + if _, err := execXauthCommand(genArgs); err != nil { + return "", "", fmt.Errorf("xauth generate failed: %v", err) + } + listArgs = []string{"-f", path, "list", display} + } else { + listArgs = []string{"list", display} + } + + debug("xauth list command: %v", listArgs) + out, err := execXauthCommand(listArgs) + if err != nil { + return "", "", fmt.Errorf("xauth list failed: %v", err) + } + if out != "" { + tokens := strings.Fields(out) + if len(tokens) < 3 { + return "", "", fmt.Errorf("invalid xauth list output: %s", out) + } + return tokens[2], tokens[1], nil + } + + return genFakeXauth(trusted) +} + +func execXauthCommand(args []string) (string, error) { + cmd := exec.Command("xauth", args...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + if errBuf.Len() > 0 { + return "", fmt.Errorf("%v, %s", err, strings.TrimSpace(errBuf.String())) + } + return "", err + } + return strings.TrimSpace(outBuf.String()), nil +} + +func genFakeXauth(trusted bool) (string, string, error) { + if !trusted { + return "", "", fmt.Errorf("untrusted X11 forwarding setup failed since no xauth program") + } + cookie := make([]byte, 16) + if _, err := rand.Read(cookie); err != nil { + return "", "", fmt.Errorf("random cookie failed: %v", err) + } + warning("No xauth data; using fake authentication data for X11 forwarding.") + return fmt.Sprintf("%x", cookie), kSshX11Proto, nil +}