Skip to content

Commit d8f04ba

Browse files
committed
feat(tunnel): forward host local ports to operators
Adds tunnel.* capabilities so the host can expose a loopback TCP port to whoever runs `handoff tunnel <connect-token>` on their own machine. tunnel.open is risky-gated; data shuttling rides over the existing bridge as base64 frames keyed by tunnel and stream id. Running handoff with no args now shows a menu that picks between hosting a session and connecting as the operator side of a tunnel.
1 parent 4abf7a3 commit d8f04ba

11 files changed

Lines changed: 1615 additions & 21 deletions

File tree

cmd/menu.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
package cmd
3+
4+
import (
5+
"bufio"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
// Menu is the interactive launcher shown when handoff.exe is run with no
14+
// subcommand. It lets the user pick between hosting a session and connecting
15+
// as the operator side of a tunnel. When stdin is not a terminal (e.g. piped),
16+
// we keep the prior behavior of falling through to a host session.
17+
func Menu(args []string) {
18+
if !stdinIsTerminal() {
19+
New(args)
20+
return
21+
}
22+
23+
reader := bufio.NewReader(os.Stdin)
24+
for {
25+
printMenu()
26+
choice, err := readChoice(reader)
27+
if err != nil {
28+
if err == io.EOF {
29+
return
30+
}
31+
fmt.Fprintln(os.Stderr, "could not read input:", err)
32+
return
33+
}
34+
switch choice {
35+
case "1", "h", "host":
36+
New(args)
37+
return
38+
case "2", "t", "tunnel", "connect":
39+
runTunnelPrompt(reader, args)
40+
return
41+
case "3", "q", "quit", "exit":
42+
return
43+
case "":
44+
// re-prompt
45+
default:
46+
fmt.Println("not a valid choice; type 1, 2, or 3.")
47+
}
48+
}
49+
}
50+
51+
func printMenu() {
52+
fmt.Println()
53+
fmt.Println("What would you like to do?")
54+
fmt.Println(" 1) Host a session (let someone help me with this computer)")
55+
fmt.Println(" 2) Connect to a tunnel (forward a port from someone else's computer)")
56+
fmt.Println(" 3) Quit")
57+
fmt.Print("Choose [1-3]: ")
58+
}
59+
60+
func readChoice(reader *bufio.Reader) (string, error) {
61+
line, err := reader.ReadString('\n')
62+
if err != nil && line == "" {
63+
return "", err
64+
}
65+
return strings.ToLower(strings.TrimSpace(line)), nil
66+
}
67+
68+
func runTunnelPrompt(reader *bufio.Reader, baseArgs []string) {
69+
fmt.Println()
70+
fmt.Println("Paste the connect token shown on the session page after the host")
71+
fmt.Println("approves the tunnel (looks like 'tk_AbCdEfGh').")
72+
fmt.Print("Connect token: ")
73+
tokenLine, err := reader.ReadString('\n')
74+
if err != nil {
75+
fmt.Fprintln(os.Stderr, "could not read token:", err)
76+
return
77+
}
78+
token := strings.TrimSpace(tokenLine)
79+
if token == "" {
80+
fmt.Println("no token entered; aborting.")
81+
return
82+
}
83+
84+
fmt.Print("Local port to bind on this computer [47800]: ")
85+
portLine, err := reader.ReadString('\n')
86+
if err != nil {
87+
fmt.Fprintln(os.Stderr, "could not read port:", err)
88+
return
89+
}
90+
port := 47800
91+
if v := strings.TrimSpace(portLine); v != "" {
92+
parsed, perr := strconv.Atoi(v)
93+
if perr != nil || parsed <= 0 || parsed > 65535 {
94+
fmt.Println("port must be a number between 1 and 65535.")
95+
return
96+
}
97+
port = parsed
98+
}
99+
100+
args := append([]string{token, "--local-port", strconv.Itoa(port)}, baseArgs...)
101+
Tunnel(args)
102+
}
103+
104+
func stdinIsTerminal() bool {
105+
fi, err := os.Stdin.Stat()
106+
if err != nil {
107+
return false
108+
}
109+
return (fi.Mode() & os.ModeCharDevice) != 0
110+
}

cmd/new.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,6 @@ func New(args []string) {
6363
}
6464
}()
6565

66-
// Dispatcher with all capabilities registered.
67-
router := dispatch.New()
68-
capabilities.RegisterAll(router)
69-
supportlog.Printf("capabilities registered count=%d", len(router.Kinds()))
70-
fmt.Printf("ready -- %d capabilities registered\n\n", len(router.Kinds()))
71-
7266
// Handle Ctrl+C.
7367
sig := make(chan os.Signal, 1)
7468
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
@@ -91,6 +85,13 @@ func New(args []string) {
9185
defer bridge.Close()
9286
supportlog.Printf("bridge connected sid=%s", sid)
9387

88+
// Dispatcher with all capabilities registered. The bridge is plumbed in so
89+
// tunnel handlers can push bytes back outside the command_result return path.
90+
router := dispatch.New()
91+
capabilities.RegisterAll(router, bridge)
92+
supportlog.Printf("capabilities registered count=%d", len(router.Kinds()))
93+
fmt.Printf("ready -- %d capabilities registered\n\n", len(router.Kinds()))
94+
9495
hostname, _ := os.Hostname()
9596
if err := bridge.SendHello(ctx, hostname, Version, router.Kinds()); err != nil {
9697
supportlog.Printf("hello failed sid=%s: %v", sid, err)

0 commit comments

Comments
 (0)