tsnet: add opt-in SSH support (Server.ListenSSH)
This adds tsnet.Server.ListenSSH which, if the SSH feature is linked, returns a net.Listener whose Accept yields *tailssh.Session values (as net.Conn). This lets tsnet apps accept incoming SSH connections to implement custom TUI applications. Basic apps can use net.Conn directly (Read/Write/Close). Rich apps import ssh/tailssh and type-assert for peer identity, PTY, signals, etc. If feature/ssh isn't imported, ListenSSH returns an error. Includes a demo guess-the-number game in tsnet/example/ssh-game. Updates tailscale/corp#37839 Change-Id: I4e7c3c96afb030cdf4da8f2d8b2253820628129a Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
c9333854fb
commit
3e34e721e8
@@ -0,0 +1,13 @@
|
||||
<!-- README.md auto-generated by misc/genreadme; DO NOT EDIT. (or remove this line) -->
|
||||
|
||||
# ssh-game
|
||||
|
||||
The ssh-game server demonstrates how to use tsnet's ListenSSH to build a custom SSH application. It runs a simple "guess the number" game.
|
||||
|
||||
Usage:
|
||||
|
||||
go run ./tsnet/example/ssh-game
|
||||
|
||||
Then from another Tailscale node:
|
||||
|
||||
ssh -p 2222 <hostname>
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
|
||||
|
||||
// The ssh-game server demonstrates how to use tsnet's ListenSSH to build
|
||||
// a custom SSH application. It runs a simple "guess the number" game.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tsnet/example/ssh-game
|
||||
//
|
||||
// Then from another Tailscale node:
|
||||
//
|
||||
// ssh -p 2222 <hostname>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
_ "tailscale.com/feature/ssh"
|
||||
"tailscale.com/ssh/tailssh"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s := &tsnet.Server{
|
||||
Hostname: "ssh-game",
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
ln, err := s.ListenSSH(":2222")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
log.Println("Listening on :2222")
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go handleGame(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGame(c net.Conn) {
|
||||
sess, ok := c.(*tailssh.Session)
|
||||
if !ok {
|
||||
fmt.Fprintf(c, "unexpected connection type\n")
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
defer sess.Exit(0)
|
||||
|
||||
target := rand.IntN(100) + 1
|
||||
scanner := bufio.NewScanner(sess)
|
||||
|
||||
fmt.Fprintf(sess, "Welcome, %s from %s!\r\n",
|
||||
sess.UserProfile().LoginName,
|
||||
sess.Peer().ComputedName())
|
||||
fmt.Fprintf(sess, "I'm thinking of a number between 1 and 100.\r\n")
|
||||
fmt.Fprintf(sess, "Can you guess it?\r\n\r\n")
|
||||
|
||||
for attempts := 1; ; attempts++ {
|
||||
fmt.Fprintf(sess, "Your guess: ")
|
||||
if !scanner.Scan() {
|
||||
return
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
var guess int
|
||||
if _, err := fmt.Sscanf(line, "%d", &guess); err != nil {
|
||||
fmt.Fprintf(sess, "Please enter a number.\r\n")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case guess < target:
|
||||
fmt.Fprintf(sess, "Higher!\r\n")
|
||||
case guess > target:
|
||||
fmt.Fprintf(sess, "Lower!\r\n")
|
||||
default:
|
||||
fmt.Fprintf(sess, "Correct! You got it in %d attempts.\r\n", attempts)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || plan9
|
||||
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
_ "tailscale.com/feature/ssh"
|
||||
"tailscale.com/ssh/tailssh"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
// TestListenSSH starts two tsnet nodes on a test tailnet, has one listen
|
||||
// for SSH via ListenSSH, and has the other connect using the Go
|
||||
// x/crypto/ssh client. The server verifies the command string and echoes
|
||||
// back the connecting peer's login name, verifying that WhoIs and
|
||||
// Peer/UserProfile work end-to-end.
|
||||
func TestListenSSH(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL, _ := startControl(t)
|
||||
srvNode, srvIP, _ := startServer(t, ctx, controlURL, "sshsrv")
|
||||
clientNode, clientIP, _ := startServer(t, ctx, controlURL, "sshclient")
|
||||
|
||||
// Listen for SSH on srvNode.
|
||||
ln, err := srvNode.ListenSSH(":22")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
// Server goroutine: verify the command, then write the peer's login name back.
|
||||
srvErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
srvErrCh <- err
|
||||
return
|
||||
}
|
||||
sess := conn.(*tailssh.Session)
|
||||
defer sess.Exit(0)
|
||||
if got := sess.RawCommand(); got != "test-whoami" {
|
||||
srvErrCh <- fmt.Errorf("server got command %q, want %q", got, "test-whoami")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sess, "%s\n", sess.UserProfile().LoginName)
|
||||
srvErrCh <- nil
|
||||
}()
|
||||
|
||||
// Wait until srvNode knows about clientNode so WhoIs succeeds when the
|
||||
// SSH connection arrives.
|
||||
if err := tstest.WaitFor(30*time.Second, func() error {
|
||||
lc, err := srvNode.LocalClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := lc.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, peer := range st.Peer {
|
||||
for _, ip := range peer.TailscaleIPs {
|
||||
if ip == clientIP {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.New("clientNode not yet in srvNode's netmap")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Dial srvNode's SSH listener from clientNode's Tailscale network.
|
||||
addr := net.JoinHostPort(srvIP.String(), "22")
|
||||
tcpConn, err := clientNode.Dial(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// gliderssh defaults to NoClientAuth when no auth handler is registered,
|
||||
// so no Auth methods are needed.
|
||||
sshConn, chans, reqs, err := gossh.NewClientConn(tcpConn, addr, &gossh.ClientConfig{
|
||||
User: "test",
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sshClient := gossh.NewClient(sshConn, chans, reqs)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := session.Output("test-whoami")
|
||||
if err != nil {
|
||||
t.Fatalf("session.Output: %v", err)
|
||||
}
|
||||
|
||||
loginName := strings.TrimSpace(string(out))
|
||||
if loginName == "" {
|
||||
t.Error("SSH server returned empty login name; WhoIs or Peer/UserProfile may be broken")
|
||||
}
|
||||
t.Logf("peer login name from SSH server: %q", loginName)
|
||||
|
||||
if err := <-srvErrCh; err != nil {
|
||||
t.Errorf("SSH server goroutine: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1264,6 +1264,33 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) {
|
||||
return s.listen(network, addr, listenOnTailnet)
|
||||
}
|
||||
|
||||
// ListenSSH listens on the Tailscale network for SSH connections at the given
|
||||
// addr (e.g. ":2222"). The returned listener's Accept method yields net.Conn
|
||||
// values that are actually *tailssh.Session, providing access to the
|
||||
// connecting peer's Tailscale identity, PTY information, signals, and more.
|
||||
//
|
||||
// Basic applications can use the returned connections as plain net.Conn
|
||||
// (Read/Write/Close). Applications that need richer SSH semantics should
|
||||
// type-assert to *tailssh.Session.
|
||||
//
|
||||
// SSH support must be linked into the binary by importing
|
||||
// _ "tailscale.com/feature/ssh". Without that import, ListenSSH returns an
|
||||
// error.
|
||||
//
|
||||
// If s has not been started yet, it will be started.
|
||||
func (s *Server) ListenSSH(addr string) (net.Listener, error) {
|
||||
rawLn, err := s.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshLn, err := s.lb.ListenSSH(rawLn, s.logf)
|
||||
if err != nil {
|
||||
rawLn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return sshLn, nil
|
||||
}
|
||||
|
||||
// ListenPacket announces on the Tailscale network.
|
||||
//
|
||||
// The network must be "udp", "udp4" or "udp6". The addr must be of the form
|
||||
|
||||
Reference in New Issue
Block a user