Files
coder/cli/vscodeipc/vscodeipc.go
Kyle Carberry e61234f260 feat: Add vscodeipc subcommand for VS Code Extension (#5326)
* Add extio

* feat: Add `vscodeipc` subcommand for VS Code Extension

This enables the VS Code extension to communicate with a Coder client.
The extension will download the slim binary from `/bin/*` for the
respective client architecture and OS, then execute `coder vscodeipc`
for the connecting workspace.

* Add authentication header, improve comments, and add tests for the CLI

* Update cli/vscodeipc_test.go

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>

* Update cli/vscodeipc_test.go

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>

* Update cli/vscodeipc/vscodeipc_test.go

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>

* Fix requested changes

* Fix IPC tests

* Fix shell execution

* Fix nix flake

* Silence usage

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2022-12-18 17:50:06 -06:00

314 lines
8.9 KiB
Go

package vscodeipc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"tailscale.com/tailcfg"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
const AuthHeader = "Coder-IPC-Token"
// New creates a VS Code IPC client that can be used to communicate with workspaces.
//
// Creating this IPC was required instead of using SSH, because we're unable to get
// connection information to display in the bottom-bar when using SSH. It's possible
// we could jank around this (maybe by using a temporary SSH host), but that's not
// ideal.
//
// This persists a single workspace connection, and lets you execute commands, check
// for network information, and forward ports.
//
// The VS Code extension is located at https://github.com/coder/vscode-coder. The
// extension downloads the slim binary from `/bin/*` and executes `coder vscodeipc`
// which calls this function. This API must maintain backward compatibility with
// the extension to support prior versions of Coder.
func New(ctx context.Context, client *codersdk.Client, agentID uuid.UUID, options *codersdk.DialWorkspaceAgentOptions) (http.Handler, io.Closer, error) {
if options == nil {
options = &codersdk.DialWorkspaceAgentOptions{}
}
// We need this to track upload and download!
options.EnableTrafficStats = true
agentConn, err := client.DialWorkspaceAgent(ctx, agentID, options)
if err != nil {
return nil, nil, err
}
api := &api{
agentConn: agentConn,
}
r := chi.NewRouter()
// This is to prevent unauthorized clients on the same machine from executing
// requests on behalf of the workspace.
r.Use(sessionTokenMiddleware(client.SessionToken()))
r.Route("/v1", func(r chi.Router) {
r.Get("/port/{port}", api.port)
r.Get("/network", api.network)
r.Post("/execute", api.execute)
})
return r, api, nil
}
type api struct {
agentConn *codersdk.AgentConn
sshClient *ssh.Client
sshClientErr error
sshClientOnce sync.Once
lastNetwork time.Time
}
func (api *api) Close() error {
if api.sshClient != nil {
api.sshClient.Close()
}
return api.agentConn.Close()
}
type NetworkResponse struct {
P2P bool `json:"p2p"`
Latency float64 `json:"latency"`
PreferredDERP string `json:"preferred_derp"`
DERPLatency map[string]float64 `json:"derp_latency"`
UploadBytesSec int64 `json:"upload_bytes_sec"`
DownloadBytesSec int64 `json:"download_bytes_sec"`
}
// port accepts an HTTP request to dial a port on the workspace agent.
// It uses an HTTP connection upgrade to transfer the connection to TCP.
func (api *api) port(w http.ResponseWriter, r *http.Request) {
port, err := strconv.Atoi(chi.URLParam(r, "port"))
if err != nil {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "Port must be an integer!",
})
return
}
remoteConn, err := api.agentConn.DialContext(r.Context(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
httpapi.InternalServerError(w, err)
return
}
defer remoteConn.Close()
// Upgrade an switch to TCP!
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Upgrade", "tcp")
w.WriteHeader(http.StatusSwitchingProtocols)
hijacker, ok := w.(http.Hijacker)
if !ok {
httpapi.InternalServerError(w, xerrors.Errorf("unable to hijack connection: %T", w))
return
}
localConn, brw, err := hijacker.Hijack()
if err != nil {
httpapi.InternalServerError(w, err)
return
}
defer localConn.Close()
_ = brw.Flush()
agent.Bicopy(r.Context(), localConn, remoteConn)
}
// network returns network information about the workspace.
func (api *api) network(w http.ResponseWriter, r *http.Request) {
// Ping the workspace agent to get the latency.
latency, p2p, err := api.agentConn.Ping(r.Context())
if err != nil {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to ping the workspace agent.",
Detail: err.Error(),
})
return
}
node := api.agentConn.Node()
derpMap := api.agentConn.DERPMap()
derpLatency := map[string]float64{}
// Convert DERP region IDs to friendly names for display in the UI.
for rawRegion, latency := range node.DERPLatency {
regionParts := strings.SplitN(rawRegion, "-", 2)
regionID, err := strconv.Atoi(regionParts[0])
if err != nil {
continue
}
region, found := derpMap.Regions[regionID]
if !found {
// It's possible that a workspace agent is using an old DERPMap
// and reports regions that do not exist. If that's the case,
// report the region as unknown!
region = &tailcfg.DERPRegion{
RegionID: regionID,
RegionName: fmt.Sprintf("Unnamed %d", regionID),
}
}
// Convert the microseconds to milliseconds.
derpLatency[region.RegionName] = latency * 1000
}
totalRx := uint64(0)
totalTx := uint64(0)
for _, stat := range api.agentConn.ExtractTrafficStats() {
totalRx += stat.RxBytes
totalTx += stat.TxBytes
}
// Tracking the time since last request is required because
// ExtractTrafficStats() resets its counters after each call.
dur := time.Since(api.lastNetwork)
uploadSecs := float64(totalTx) / dur.Seconds()
downloadSecs := float64(totalRx) / dur.Seconds()
api.lastNetwork = time.Now()
httpapi.Write(r.Context(), w, http.StatusOK, NetworkResponse{
P2P: p2p,
Latency: float64(latency.Microseconds()) / 1000,
PreferredDERP: derpMap.Regions[node.PreferredDERP].RegionName,
DERPLatency: derpLatency,
UploadBytesSec: int64(uploadSecs),
DownloadBytesSec: int64(downloadSecs),
})
}
type ExecuteRequest struct {
Command string `json:"command"`
Stdin string `json:"stdin"`
}
type ExecuteResponse struct {
Data string `json:"data"`
ExitCode *int `json:"exit_code"`
}
// execute runs the command provided, streams the output back, and returns the exit code.
func (api *api) execute(w http.ResponseWriter, r *http.Request) {
var req ExecuteRequest
if !httpapi.Read(r.Context(), w, r, &req) {
return
}
api.sshClientOnce.Do(func() {
// The SSH client is lazily created because it's not needed for
// all requests. It's only needed for the execute endpoint.
//
// It's alright if this fails on the first execution, because
// a new instance of this API is created for each remote SSH request.
api.sshClient, api.sshClientErr = api.agentConn.SSHClient(context.Background())
})
if api.sshClientErr != nil {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create SSH client.",
Detail: api.sshClientErr.Error(),
})
return
}
session, err := api.sshClient.NewSession()
if err != nil {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create SSH session.",
Detail: err.Error(),
})
return
}
defer session.Close()
f, ok := w.(http.Flusher)
if !ok {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("http.ResponseWriter is not http.Flusher: %T", w),
})
return
}
execWriter := &execWriter{w, f}
session.Stdout = execWriter
session.Stderr = execWriter
session.Stdin = strings.NewReader(req.Stdin)
err = session.Start(req.Command)
if err != nil {
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to start SSH session.",
Detail: err.Error(),
})
return
}
err = session.Wait()
writeExit := func(exitCode int) {
data, _ := json.Marshal(&ExecuteResponse{
ExitCode: &exitCode,
})
_, _ = w.Write(data)
f.Flush()
}
if err != nil {
var exitError *ssh.ExitError
if errors.As(err, &exitError) {
writeExit(exitError.ExitStatus())
return
}
}
writeExit(0)
}
type execWriter struct {
w http.ResponseWriter
f http.Flusher
}
func (e *execWriter) Write(data []byte) (int, error) {
js, err := json.Marshal(&ExecuteResponse{
Data: string(data),
})
if err != nil {
return 0, err
}
_, err = e.w.Write(js)
if err != nil {
return 0, err
}
e.f.Flush()
return len(data), nil
}
func sessionTokenMiddleware(sessionToken string) func(h http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get(AuthHeader)
if token == "" {
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("A session token must be provided in the `%s` header.", AuthHeader),
})
return
}
if token != sessionToken {
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
Message: "The session token provided doesn't match the one used to create the client.",
})
return
}
w.Header().Set("Access-Control-Allow-Origin", "*")
h.ServeHTTP(w, r)
})
}
}