package main
import (
"context"
"fmt"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/pkg/browser"
"go.coder.com/flog"
"golang.org/x/xerrors"
)
const codeServerPath = "~/.cache/sshcode/sshcode-server"
const (
sshDirectory = "~/.ssh"
sshDirectoryUnsafeModeMask = 0022
sshControlPath = sshDirectory + "/control-%h-%p-%r"
)
type options struct {
skipSync bool
syncBack bool
noOpen bool
reuseConnection bool
bindAddr string
remotePort string
sshFlags string
uploadCodeServer string
}
func sshCode(host, dir string, o options) error {
host, extraSSHFlags, err := parseHost(host)
if err != nil {
return xerrors.Errorf("failed to parse host IP: %w", err)
}
if extraSSHFlags != "" {
o.sshFlags = strings.Join([]string{extraSSHFlags, o.sshFlags}, " ")
}
o.bindAddr, err = parseBindAddr(o.bindAddr)
if err != nil {
return xerrors.Errorf("failed to parse bind address: %w", err)
}
if o.remotePort == "" {
o.remotePort, err = randomPort()
}
if err != nil {
return xerrors.Errorf("failed to find available remote port: %w", err)
}
// Check the SSH directory's permissions and warn the user if it is not safe.
o.reuseConnection = checkSSHDirectory(sshDirectory, o.reuseConnection)
// Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication
// only happens on the initial connection.
if o.reuseConnection {
flog.Info("starting SSH master connection...")
newSSHFlags, cancel, err := startSSHMaster(o.sshFlags, sshControlPath, host)
defer cancel()
if err != nil {
flog.Error("failed to start SSH master connection: %v", err)
o.reuseConnection = false
} else {
o.sshFlags = newSSHFlags
}
}
// Upload local code-server or download code-server from CI server.
if o.uploadCodeServer != "" {
flog.Info("uploading local code-server binary...")
err = copyCodeServerBinary(o.sshFlags, host, o.uploadCodeServer, codeServerPath)
if err != nil {
return xerrors.Errorf("failed to upload local code-server binary to remote server: %w", err)
}
sshCmdStr :=
fmt.Sprintf("ssh %v %v 'chmod +x %v'",
o.sshFlags, host, codeServerPath,
)
sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
err = sshCmd.Run()
if err != nil {
return xerrors.Errorf("failed to make code-server binary executable:\n---ssh cmd---\n%s: %w",
sshCmdStr,
err,
)
}
} else {
flog.Info("ensuring code-server is updated...")
dlScript := downloadScript(codeServerPath)
// Downloads the latest code-server and allows it to be executed.
sshCmdStr := fmt.Sprintf("ssh %v %v '/usr/bin/env bash -l'", o.sshFlags, host)
sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
sshCmd.Stdin = strings.NewReader(dlScript)
err = sshCmd.Run()
if err != nil {
return xerrors.Errorf("failed to update code-server:\n---ssh cmd---\n%s"+
"\n---download script---\n%s: %w",
sshCmdStr,
dlScript,
err,
)
}
}
if !o.skipSync {
start := time.Now()
flog.Info("syncing settings")
err = syncUserSettings(o.sshFlags, host, false)
if err != nil {
return xerrors.Errorf("failed to sync settings: %w", err)
}
flog.Info("synced settings in %s", time.Since(start))
flog.Info("syncing extensions")
err = syncExtensions(o.sshFlags, host, false)
if err != nil {
return xerrors.Errorf("failed to sync extensions: %w", err)
}
flog.Info("synced extensions in %s", time.Since(start))
}
flog.Info("starting code-server...")
flog.Info("Tunneling remote port %v to %v", o.remotePort, o.bindAddr)
sshCmdStr :=
fmt.Sprintf("ssh -tt -q -L %v:localhost:%v %v %v '%v %v --host 127.0.0.1 --auth none --port=%v'",
o.bindAddr, o.remotePort, o.sshFlags, host, codeServerPath, dir, o.remotePort,
)
// Starts code-server and forwards the remote port.
sshCmd := exec.Command("sh", "-l", "-c", sshCmdStr)
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
err = sshCmd.Start()
if err != nil {
return xerrors.Errorf("failed to start code-server: %w", err)
}
url := fmt.Sprintf("http://%s", o.bindAddr)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := http.Client{
Timeout: time.Second * 3,
}
for {
if ctx.Err() != nil {
return xerrors.Errorf("code-server didn't start in time: %w", ctx.Err())
}
// Waits for code-server to be available before opening the browser.
resp, err := client.Get(url)
if err != nil {
continue
}
resp.Body.Close()
break
}
ctx, cancel = context.WithCancel(context.Background())
if !o.noOpen {
openBrowser(url)
}
go func() {
defer cancel()
sshCmd.Wait()
}()
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt)
select {
case