blob: 15cd16a64e05590417ec5556fb6bb69d197d9950 [file] [log] [blame]
// Copyright 2022 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package tunnel
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"os/exec"
"strconv"
"strings"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
)
const (
ffxAddRemoteTargetMessage = `To make your local device visible over the tunnel, run the following command on your remote workstation: ffx target add \"\[::1\]:8022\"`
sshControlPath = "~/.ssh/control-fuchsia-tunnel"
maxCleanupAttempts = 5
// DebugLoggingSSHConfig is a string for adding debugging to the ssh config.
DebugLoggingSSHConfig = `# Add additional log levels to be added to fssh's debug logs.
LogLevel DEBUG2`
// DefaultSSHConfigTemplate is the contents of the default SSH
// configuration file used by fssh if no other SSH config is specified.
DefaultSSHConfigTemplate = `
Include /etc/ssh/ssh_config
# This SSH config is only used for the specified host.
Host {{.Remote}}
HostName {{.Remote}}
# We want ipv6 binds for the port forwards
AddressFamily inet6
# This config sets the ControlPath to a value specific to this tool.
# This prevents collisions with other SSH connections and so this tool
# can manage a connection seperately from the user's connection. We
# intentionally do not use %h/%p in the control path because
# there can only be one forwarding session at a time (due to the local
# forward of {{.RepoPort}}).
ControlPath {{.TunnelPath}}
# "ControlMaster auto" enables multiple SSH connections to use the same
# tunnel/TCP connection. Enabling this option means that SSH connections
# can be multiplexed over the initial SSH connection created by this
# tool. The 'auto' value means that the initial connction will be
# created as the "master" connection if no connection is found based
# on the control path. Subsuquent sessions will use the initial
# connection.
ControlMaster auto
# Disable pseudo-tty allocation for screen based programs over the SSH tunnel.
RequestTTY no
# Request to a package server on the local host are forwarded to the remote
# host.
LocalForward *:{{.RepoPort}} localhost:{{.RepoPort}}
# Requests from the remote to ssh to localhost:8022 will be forwarded to the
# target.
RemoteForward 8022 [{{.DeviceIP}}]:22
# zxdb & fidlcat requests from the remote to 2345 are forwarded to the target.
RemoteForward 2345 [{{.DeviceIP}}]:2345
# libassistant debug requests from the remote to 8007 are forwarded to the
# target.
RemoteForward 8007 [{{.DeviceIP}}]:8007
# CasAgent setup server requests on ports 8008 and 8443 on the remote are
# forwarded to the corresponding target port.
RemoteForward 8008 [{{.DeviceIP}}]:8008
RemoteForward 8443 [{{.DeviceIP}}]:8443
# SL4F requests to port 9080 on the remote are forwarded to target port 80.
RemoteForward 9080 [{{.DeviceIP}}]:80
# UMA log requests to port 8888 on the remote are forwarded to target port 8888.
RemoteForward 8888 [{{.DeviceIP}}]:8888
# Fastboot over TCP
RemoteForward 5554 [{{.DeviceIP}}]:5554
{{range .TunnelPorts}}
RemoteForward {{.}} [{{$.DeviceIP}}]:{{.}}
{{end}}
# Tear down the connection if port forwarding fails.
ExitOnForwardFailure yes
# Keep a blank line at the end.
`
)
var (
attemptCount = 0
// UsedPorts is a set of port numbers which are used by the default SSH configuration.
UsedPorts = map[int]struct{}{
2345: {},
5554: {},
8007: {},
8008: {},
8022: {},
8443: {},
8888: {},
9080: {},
}
// ExecCommand exposes exec.Command as a variable so it can be mocked.
ExecCommand = exec.Command
)
// Cmd creates a command to create a tunnel between a remote and local device
// for the purposes of building and developing Fuchsia packages.
//
// Example bash command equivalent:
//
// ```
// ssh \
// -S ~/.ssh/control-fuchsia-tunnel
// -o ControlMaster=auto
// -t -M -t -6
// -L \*:8083:localhost:8083
// -R 8022:[192.168.1.1]:22
// -R 2345:[192.168.1.1]:2345
// -R 8443:[192.168.1.1]:8443
// -R 9080:[192.168.1.1]:80
// -R 8888:[192.168.1.1]:8888
// -o ExitOnForwardFailure=yes
// matthewcarroll@matt.c.googlers.com
// ```
func Cmd(sshPath string, sshConfigPath string, remote string) (*exec.Cmd, error) {
if sshPath == "" {
var err error
sshPath, err = findSSH()
if err != nil {
return &exec.Cmd{}, err
}
}
args := []string{
// specify which SSH config file to use.
"-F",
sshConfigPath,
remote,
// Ensure the connection doesn't exit because there is no traffic.
"-n",
"echo",
"Tunnel is established",
"&&",
"echo",
ffxAddRemoteTargetMessage,
"&&",
"sleep",
"infinity",
}
return ExecCommand(sshPath, args...), nil
}
// CleanupTunnel cleans up the SSH tunnel by exiting the Multiplexed
// session.
// Returns string of cleanup steps, intended for testing
// error if one is encountered.
func CleanupTunnel(ctx context.Context, sshPath string, remote string) (string, error) {
if sshPath == "" {
var err error
sshPath, err = findSSH()
if err != nil {
return "", err
}
}
// Don't use the SSH config here, since we're trying to exit it.
args := []string{
remote,
"-O",
"exit",
"-S",
sshControlPath,
}
// Do any clean up on the remote side before exiting.
// There is a chance of infinite recursion if the
// sshd session on the remote is not killed, so
// use a counter to keep the looping under maxCleanupAttempts.
attemptCount = 0
result := cleanupRemoteForwarding(ctx, sshPath, remote)
// Use the mockable exec.command to run this command so it is testable.
resultBytes, err := ExecCommand(sshPath, args...).CombinedOutput()
result += string(resultBytes)
// If the error is "Control socket connect...: No such file or directory" ignore it.
if err != nil {
if strings.Contains(result, "Control socket connect") && strings.Contains(result, "No such file or directory") {
logger.Debugf(ctx, "Ignoring non-error cleaning up %v: %s", err, result)
result = ""
err = nil
}
}
return result, err
}
// cleanupRemoteForwarding checks the remote host for a process listening on
// the SSH port forwarded to the device. If found, we speculatively attempt to clean
// it up.
//
// Returns output string for testing, or error if ssh cannot be found.
func cleanupRemoteForwarding(ctx context.Context, sshPath string, remote string) string {
var (
err error
result string
)
if sshPath == "" {
sshPath, err = findSSH()
if err != nil {
logger.Warningf(ctx, "Cannot find ssh: %v", err)
return ""
}
}
if isTunnelAlreadyOpen(ctx, sshPath, remote) {
result = fmt.Sprintf("Existing port forwarding found on %s, Cleaning up sshd sessions remotely.\n",
remote)
cleanupRemoteSSHd(ctx, sshPath, remote)
}
return result
}
func isTunnelAlreadyOpen(ctx context.Context, sshPath string, remote string) bool {
var exitError *exec.ExitError
// Look for 8022 being in LISTENING state.
args := []string{
remote,
"ss -ln | grep :8022",
}
output, err := ExecCommand(sshPath, args...).Output()
if err != nil {
if errors.As(err, &exitError) {
if len(exitError.Stderr) > 0 {
message := string(exitError.Stderr)
logger.Errorf(ctx, "%v returned %v: %s", args, exitError, message)
return false
}
} else {
logger.Debugf(ctx, "%v returned %v", args, err)
return false
}
}
if len(output) > 0 {
logger.Debugf(ctx, "Found existing forwarding on %s: %s", remote, string(output))
return true
}
return false
}
// Cleanup the remote sshd processes.
// These processes are started by SSHd to listen for the forwarded port.
// Since they are started by the system, the actual PID of processes
// is not reported by ss or other network tools without using sudo,
// which we want to avoid using.
//
// The next best is to look for sshd sessions not using tty. This avoids
// killing any interactive sessions that also exist.
//
// Returns output string for test inspection.
func cleanupRemoteSSHd(ctx context.Context, sshPath string, remote string) string {
var exitError *exec.ExitError
// Get the sshd processes that do not have a tty.
args := []string{
remote,
"ps `pgrep -u $USER sshd` | grep notty",
}
output, err := ExecCommand(sshPath, args...).Output()
if err != nil {
if errors.As(err, &exitError) {
if len(exitError.Stderr) > 0 {
message := string(exitError.Stderr)
logger.Debugf(ctx, "%v returned %v: %s", args, exitError, message)
return fmt.Sprintf("%v: %s", exitError, message)
}
} else {
logger.Debugf(ctx, "%v returned %v", args, err)
return ""
}
}
// Split the output into lines, ignoring blank lines.
nonEmptyLineSplitter := func(c rune) bool {
return c == '\n'
}
// There can be sshd instances that are notty and not handling
// the tunnel. so kill 1 sshd instance and then recurse.
// This is a little slower than killing all of them, but
// is less disruptive to other ssh instances such as vscode.
lines := strings.FieldsFunc(string(output), nonEmptyLineSplitter)
logger.Debugf(ctx, "ps respose is %v", lines)
if len(lines) != 0 {
fields := strings.Fields(lines[0])
if fields[0] != "" {
args = []string{
remote,
"kill",
"-9",
fields[0],
}
output, err := ExecCommand(sshPath, args...).Output()
if err != nil {
if errors.As(err, &exitError) {
if len(exitError.Stderr) > 0 {
message := string(exitError.Stderr)
logger.Warningf(ctx, "%v returned %v: %v", args, exitError, message)
}
} else {
logger.Warningf(ctx, "%v returned %v", args, err)
}
}
logger.Debugf(ctx, "%v returned %v", args, output)
// Since we are recursing based on the state of the remote computer,
// there is no way to guarantee the recursion is finite, so limit
// the looping to maxCleanupAttempts.
if isTunnelAlreadyOpen(ctx, sshPath, remote) {
if attemptCount < maxCleanupAttempts {
attemptCount++
return cleanupRemoteForwarding(ctx, sshPath, remote)
} else {
logger.Warningf(ctx, "Attempted to cleanup existing tunnel %d times without success. Giving up.", maxCleanupAttempts)
}
}
}
}
return ""
}
// findSSH finds a executable with the name `ssh` in the directories specified
// by the PATH environment variable.
func findSSH() (string, error) {
path, err := exec.LookPath("ssh")
if err != nil {
return "", err
}
return path, nil
}
// GenerateSSHConfig generates a default SSH config file based on the
// specified template `tpl`, `remote`, `deviceIP`, and `tunnelPorts`.
func GenerateSSHConfig(tpl string, remote string, deviceIP string, repoPort int, tunnelPorts []int, verbose bool) ([]byte, error) {
filteredTunnelPorts := []int{}
badTunnelPorts := []string{}
if repoPort < 1024 {
badTunnelPorts = append(badTunnelPorts, strconv.Itoa(repoPort))
}
for _, port := range tunnelPorts {
if port < 1024 {
badTunnelPorts = append(badTunnelPorts, strconv.Itoa(port))
continue
}
if port == repoPort {
continue
}
if _, used := UsedPorts[port]; !used {
filteredTunnelPorts = append(filteredTunnelPorts, port)
}
}
if len(badTunnelPorts) > 0 {
return []byte{}, fmt.Errorf("Cannot create SSH config with protected ports: %s", strings.Join(badTunnelPorts, ", "))
}
data := struct {
Remote string
DeviceIP string
RepoPort int
TunnelPath string
TunnelPorts []int
}{
remote,
deviceIP,
repoPort,
sshControlPath,
filteredTunnelPorts,
}
tmpl, err := template.New("sshconfig").Parse(tpl)
if err != nil {
err = fmt.Errorf("Could not create SSH config template: %v", err)
return []byte{}, err
}
buf := &bytes.Buffer{}
if err = tmpl.Execute(buf, data); err != nil {
err = fmt.Errorf("Could not execute SSH config template: %v", err)
return []byte{}, err
}
sshConfig := buf.Bytes()
if len(sshConfig) < 1 {
return sshConfig, fmt.Errorf("empty SSH config generated")
}
if verbose {
sshConfig = append(sshConfig, []byte(DebugLoggingSSHConfig)...)
}
return sshConfig, nil
}