mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: add agent endpoint for querying file system (#16736)
Closes https://github.com/coder/internal/issues/382
This commit is contained in:
@@ -41,6 +41,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
r.Get("/api/v0/containers", ch.ServeHTTP)
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
|
||||
|
||||
func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var query LSRequest
|
||||
if !httpapi.Read(ctx, rw, r, &query) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := listFiles(query)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
status = http.StatusForbidden
|
||||
default:
|
||||
}
|
||||
httpapi.Write(ctx, rw, status, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func listFiles(query LSRequest) (LSResponse, error) {
|
||||
var fullPath []string
|
||||
switch query.Relativity {
|
||||
case LSRelativityHome:
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
fullPath = []string{home}
|
||||
case LSRelativityRoot:
|
||||
if runtime.GOOS == "windows" {
|
||||
if len(query.Path) == 0 {
|
||||
return listDrives()
|
||||
}
|
||||
if !WindowsDriveRegex.MatchString(query.Path[0]) {
|
||||
return LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
|
||||
}
|
||||
} else {
|
||||
fullPath = []string{"/"}
|
||||
}
|
||||
default:
|
||||
return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
|
||||
}
|
||||
|
||||
fullPath = append(fullPath, query.Path...)
|
||||
fullPathRelative := filepath.Join(fullPath...)
|
||||
absolutePathString, err := filepath.Abs(fullPathRelative)
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
|
||||
}
|
||||
|
||||
f, err := os.Open(absolutePathString)
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
|
||||
}
|
||||
|
||||
if !stat.IsDir() {
|
||||
return LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
|
||||
}
|
||||
|
||||
// `contents` may be partially populated even if the operation fails midway.
|
||||
contents, _ := f.ReadDir(-1)
|
||||
respContents := make([]LSFile, 0, len(contents))
|
||||
for _, file := range contents {
|
||||
respContents = append(respContents, LSFile{
|
||||
Name: file.Name(),
|
||||
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
|
||||
IsDir: file.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
absolutePath := pathToArray(absolutePathString)
|
||||
|
||||
return LSResponse{
|
||||
AbsolutePath: absolutePath,
|
||||
AbsolutePathString: absolutePathString,
|
||||
Contents: respContents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func listDrives() (LSResponse, error) {
|
||||
partitionStats, err := disk.Partitions(true)
|
||||
if err != nil {
|
||||
return LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
|
||||
}
|
||||
contents := make([]LSFile, 0, len(partitionStats))
|
||||
for _, a := range partitionStats {
|
||||
// Drive letters on Windows have a trailing separator as part of their name.
|
||||
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
|
||||
name := a.Mountpoint + string(os.PathSeparator)
|
||||
contents = append(contents, LSFile{
|
||||
Name: name,
|
||||
AbsolutePathString: name,
|
||||
IsDir: true,
|
||||
})
|
||||
}
|
||||
|
||||
return LSResponse{
|
||||
AbsolutePath: []string{},
|
||||
AbsolutePathString: "",
|
||||
Contents: contents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pathToArray(path string) []string {
|
||||
out := strings.FieldsFunc(path, func(r rune) bool {
|
||||
return r == os.PathSeparator
|
||||
})
|
||||
// Drive letters on Windows have a trailing separator as part of their name.
|
||||
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
|
||||
if runtime.GOOS == "windows" && len(out) > 0 {
|
||||
out[0] += string(os.PathSeparator)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type LSRequest struct {
|
||||
// e.g. [], ["repos", "coder"],
|
||||
Path []string `json:"path"`
|
||||
// Whether the supplied path is relative to the user's home directory,
|
||||
// or the root directory.
|
||||
Relativity LSRelativity `json:"relativity"`
|
||||
}
|
||||
|
||||
type LSResponse struct {
|
||||
AbsolutePath []string `json:"absolute_path"`
|
||||
// Returned so clients can display the full path to the user, and
|
||||
// copy it to configure file sync
|
||||
// e.g. Windows: "C:\\Users\\coder"
|
||||
// Linux: "/home/coder"
|
||||
AbsolutePathString string `json:"absolute_path_string"`
|
||||
Contents []LSFile `json:"contents"`
|
||||
}
|
||||
|
||||
type LSFile struct {
|
||||
Name string `json:"name"`
|
||||
// e.g. "C:\\Users\\coder\\hello.txt"
|
||||
// "/home/coder/hello.txt"
|
||||
AbsolutePathString string `json:"absolute_path_string"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
type LSRelativity string
|
||||
|
||||
const (
|
||||
LSRelativityRoot LSRelativity = "root"
|
||||
LSRelativityHome LSRelativity = "home"
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListFilesNonExistentDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
query := LSRequest{
|
||||
Path: []string{"idontexist"},
|
||||
Relativity: LSRelativityHome,
|
||||
}
|
||||
_, err := listFiles(query)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestListFilesPermissionDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating an unreadable-by-user directory is non-trivial on Windows")
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
reposDir := filepath.Join(tmpDir, "repos")
|
||||
err = os.Mkdir(reposDir, 0o000)
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, err := filepath.Rel(home, reposDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
query := LSRequest{
|
||||
Path: pathToArray(rel),
|
||||
Relativity: LSRelativityHome,
|
||||
}
|
||||
_, err = listFiles(query)
|
||||
require.ErrorIs(t, err, os.ErrPermission)
|
||||
}
|
||||
|
||||
func TestListFilesNotADirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
filePath := filepath.Join(tmpDir, "file.txt")
|
||||
err = os.WriteFile(filePath, []byte("content"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, err := filepath.Rel(home, filePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
query := LSRequest{
|
||||
Path: pathToArray(rel),
|
||||
Relativity: LSRelativityHome,
|
||||
}
|
||||
_, err = listFiles(query)
|
||||
require.ErrorContains(t, err, "is not a directory")
|
||||
}
|
||||
|
||||
func TestListFilesSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc := []struct {
|
||||
name string
|
||||
baseFunc func(t *testing.T) string
|
||||
relativity LSRelativity
|
||||
}{
|
||||
{
|
||||
name: "home",
|
||||
baseFunc: func(t *testing.T) string {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
return home
|
||||
},
|
||||
relativity: LSRelativityHome,
|
||||
},
|
||||
{
|
||||
name: "root",
|
||||
baseFunc: func(*testing.T) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ""
|
||||
}
|
||||
return "/"
|
||||
},
|
||||
relativity: LSRelativityRoot,
|
||||
},
|
||||
}
|
||||
|
||||
// nolint:paralleltest // Not since Go v1.22.
|
||||
for _, tc := range tc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := tc.baseFunc(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
reposDir := filepath.Join(tmpDir, "repos")
|
||||
err := os.Mkdir(reposDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
downloadsDir := filepath.Join(tmpDir, "Downloads")
|
||||
err = os.Mkdir(downloadsDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
textFile := filepath.Join(tmpDir, "file.txt")
|
||||
err = os.WriteFile(textFile, []byte("content"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
var queryComponents []string
|
||||
// We can't get an absolute path relative to empty string on Windows.
|
||||
if runtime.GOOS == "windows" && base == "" {
|
||||
queryComponents = pathToArray(tmpDir)
|
||||
} else {
|
||||
rel, err := filepath.Rel(base, tmpDir)
|
||||
require.NoError(t, err)
|
||||
queryComponents = pathToArray(rel)
|
||||
}
|
||||
|
||||
query := LSRequest{
|
||||
Path: queryComponents,
|
||||
Relativity: tc.relativity,
|
||||
}
|
||||
resp, err := listFiles(query)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tmpDir, resp.AbsolutePathString)
|
||||
require.ElementsMatch(t, []LSFile{
|
||||
{
|
||||
Name: "repos",
|
||||
AbsolutePathString: reposDir,
|
||||
IsDir: true,
|
||||
},
|
||||
{
|
||||
Name: "Downloads",
|
||||
AbsolutePathString: downloadsDir,
|
||||
IsDir: true,
|
||||
},
|
||||
{
|
||||
Name: "file.txt",
|
||||
AbsolutePathString: textFile,
|
||||
IsDir: false,
|
||||
},
|
||||
}, resp.Contents)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesListDrives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("skipping test on non-Windows OS")
|
||||
}
|
||||
|
||||
query := LSRequest{
|
||||
Path: []string{},
|
||||
Relativity: LSRelativityRoot,
|
||||
}
|
||||
resp, err := listFiles(query)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, resp.Contents, LSFile{
|
||||
Name: "C:\\",
|
||||
AbsolutePathString: "C:\\",
|
||||
IsDir: true,
|
||||
})
|
||||
|
||||
query = LSRequest{
|
||||
Path: []string{"C:\\"},
|
||||
Relativity: LSRelativityRoot,
|
||||
}
|
||||
resp, err = listFiles(query)
|
||||
require.NoError(t, err)
|
||||
|
||||
query = LSRequest{
|
||||
Path: resp.AbsolutePath,
|
||||
Relativity: LSRelativityRoot,
|
||||
}
|
||||
resp, err = listFiles(query)
|
||||
require.NoError(t, err)
|
||||
// System directory should always exist
|
||||
require.Contains(t, resp.Contents, LSFile{
|
||||
Name: "Windows",
|
||||
AbsolutePathString: "C:\\Windows",
|
||||
IsDir: true,
|
||||
})
|
||||
|
||||
query = LSRequest{
|
||||
// Network drives are not supported.
|
||||
Path: []string{"\\sshfs\\work"},
|
||||
Relativity: LSRelativityRoot,
|
||||
}
|
||||
resp, err = listFiles(query)
|
||||
require.ErrorContains(t, err, "drive")
|
||||
}
|
||||
@@ -164,6 +164,7 @@ require (
|
||||
github.com/prometheus/common v0.62.0
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.21
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.2
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/spf13/afero v1.12.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
@@ -285,7 +286,7 @@ require (
|
||||
github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect
|
||||
github.com/ebitengine/purego v0.6.0-alpha.5 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/elastic/go-windows v1.0.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
|
||||
@@ -301,8 +301,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg=
|
||||
github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds=
|
||||
github.com/ebitengine/purego v0.6.0-alpha.5 h1:EYID3JOAdmQ4SNZYJHu9V6IqOeRQDBYxqKAg9PyoHFY=
|
||||
github.com/ebitengine/purego v0.6.0-alpha.5/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elastic/go-sysinfo v1.15.0 h1:54pRFlAYUlVNQ2HbXzLVZlV+fxS7Eax49stzg95M4Xw=
|
||||
github.com/elastic/go-sysinfo v1.15.0/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk=
|
||||
github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY=
|
||||
@@ -825,6 +825,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
|
||||
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
|
||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
|
||||
Reference in New Issue
Block a user