summaryrefslogtreecommitdiffhomepage
path: root/tsnet
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2026-03-11 01:18:45 +0000
committerBrad Fitzpatrick <bradfitz@tailscale.com>2026-03-10 18:37:50 -0700
commitac63d82bcc254e6ded4f57a746e5028f6f4fd590 (patch)
treefe07d979f68ca76b3dcd188f4120f9f240bd6ccf /tsnet
parentf905871fb1b10ae7c75c5850b04e18b7bea09b36 (diff)
downloadtailscale-bradfitz/ssh_tsnet.tar.xz
tailscale-bradfitz/ssh_tsnet.zip
tsnet: add opt-in SSH supportbradfitz/ssh_tsnet
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. Change-Id: I4e7c3c96afb030cdf4da8f2d8b2253820628129a Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Diffstat (limited to 'tsnet')
-rw-r--r--tsnet/example/ssh-game/ssh-game.go91
-rw-r--r--tsnet/tsnet.go27
2 files changed, 118 insertions, 0 deletions
diff --git a/tsnet/example/ssh-game/ssh-game.go b/tsnet/example/ssh-game/ssh-game.go
new file mode 100644
index 000000000..a1b07e50c
--- /dev/null
+++ b/tsnet/example/ssh-game/ssh-game.go
@@ -0,0 +1,91 @@
+// Copyright (c) Tailscale Inc & contributors
+// SPDX-License-Identifier: BSD-3-Clause
+
+// 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)
+
+ peer := sess.PeerIdentity()
+ target := rand.IntN(100) + 1
+ scanner := bufio.NewScanner(sess)
+
+ fmt.Fprintf(sess, "Welcome, %s from %s!\r\n",
+ peer.UserProfile.LoginName,
+ peer.Node.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
+ }
+ }
+}
diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go
index 4a116cf34..57cd5a004 100644
--- a/tsnet/tsnet.go
+++ b/tsnet/tsnet.go
@@ -1074,6 +1074,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