mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
* 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>
314 lines
8.9 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|