Skip to content

Commit

Permalink
[ASCII-2547] Make the GUI serve static files from FS + e2e test (#31187)
Browse files Browse the repository at this point in the history
  • Loading branch information
misteriaud authored Nov 28, 2024
1 parent bd7c058 commit dba148c
Show file tree
Hide file tree
Showing 8 changed files with 334 additions and 8 deletions.
21 changes: 16 additions & 5 deletions comp/core/gui/guiimpl/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"net/http"
"os"
"path"

"path/filepath"
"strconv"
"time"
Expand All @@ -26,6 +27,8 @@ import (
"github.com/dvsekhvalnov/jose2go/base64url"
"github.com/gorilla/mux"

securejoin "github.com/cyphar/filepath-securejoin"

api "github.com/DataDog/datadog-agent/comp/api/api/def"
"github.com/DataDog/datadog-agent/comp/collector/collector"
"github.com/DataDog/datadog-agent/comp/core/autodiscovery"
Expand All @@ -36,6 +39,7 @@ import (
"github.com/DataDog/datadog-agent/comp/core/status"

"github.com/DataDog/datadog-agent/pkg/api/security"
"github.com/DataDog/datadog-agent/pkg/config/setup"
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
"github.com/DataDog/datadog-agent/pkg/util/optional"
"github.com/DataDog/datadog-agent/pkg/util/system"
Expand All @@ -62,8 +66,8 @@ type gui struct {
startTimestamp int64
}

//go:embed views
var viewsFS embed.FS
//go:embed views/templates
var templatesFS embed.FS

// Payload struct is for the JSON messages received from a client POST request
type Payload struct {
Expand Down Expand Up @@ -198,7 +202,7 @@ func (g *gui) getIntentToken(w http.ResponseWriter, _ *http.Request) {
}

func renderIndexPage(w http.ResponseWriter, _ *http.Request) {
data, err := viewsFS.ReadFile("views/templates/index.tmpl")
data, err := templatesFS.ReadFile("views/templates/index.tmpl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
Expand Down Expand Up @@ -229,8 +233,15 @@ func renderIndexPage(w http.ResponseWriter, _ *http.Request) {
}

func serveAssets(w http.ResponseWriter, req *http.Request) {
path := path.Join("views", "private", req.URL.Path)
data, err := viewsFS.ReadFile(path)
staticFilePath := path.Join(setup.InstallPath, "bin", "agent", "dist", "views")

// checking against path traversal
path, err := securejoin.SecureJoin(staticFilePath, req.URL.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, err.Error(), http.StatusNotFound)
Expand Down
2 changes: 1 addition & 1 deletion comp/core/gui/guiimpl/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func renderError(name string) (string, error) {
func fillTemplate(w io.Writer, data Data, request string) error {
t := template.New(request + ".tmpl")
t.Funcs(fmap)
tmpl, err := viewsFS.ReadFile("views/templates/" + request + ".tmpl")
tmpl, err := templatesFS.ReadFile("views/templates/" + request + ".tmpl")
if err != nil {
return err
}
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion tasks/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def refresh_assets(_, build_tags, development=True, flavor=AgentFlavor.base.name
os.path.join(dist_folder, "conf.d/process_agent.yaml.default"),
)

shutil.copytree("./comp/core/gui/guiimpl/views", os.path.join(dist_folder, "views"), dirs_exist_ok=True)
shutil.copytree("./comp/core/gui/guiimpl/views/private", os.path.join(dist_folder, "views"), dirs_exist_ok=True)
if development:
shutil.copytree("./dev/dist/", dist_folder, dirs_exist_ok=True)

Expand Down
2 changes: 1 addition & 1 deletion test/new-e2e/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ require (
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/net v0.31.0
golang.org/x/oauth2 v0.22.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/text v0.20.0
Expand Down
174 changes: 174 additions & 0 deletions test/new-e2e/tests/agent-shared-components/gui/gui_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

package gui

import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"testing"

"net/http/cookiejar"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/html"

"github.com/DataDog/datadog-agent/test/new-e2e/pkg/components"
)

const (
agentAPIPort = 5001
guiPort = 5002
guiAPIEndpoint = "/agent/gui/intent"
)

// assertAgentsUseKey checks that all agents are using the given key.
func getGUIIntentToken(t *assert.CollectT, host *components.RemoteHost, authtoken string) string {
hostHTTPClient := host.NewHTTPClient()

apiEndpoint := &url.URL{
Scheme: "https",
Host: net.JoinHostPort("localhost", strconv.Itoa(agentAPIPort)),
Path: guiAPIEndpoint,
}

req, err := http.NewRequest(http.MethodGet, apiEndpoint.String(), nil)
require.NoErrorf(t, err, "failed to fetch API from %s", apiEndpoint.String())

req.Header.Set("Authorization", "Bearer "+authtoken)

resp, err := hostHTTPClient.Do(req)
require.NoErrorf(t, err, "failed to fetch intent token from %s", apiEndpoint.String())
defer resp.Body.Close()

require.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", apiEndpoint.String())

url, err := io.ReadAll(resp.Body)
require.NoErrorf(t, err, "failed to read response body from %s", apiEndpoint.String())

return string(url)
}

// assertGuiIsAvailable checks that the Agent GUI server is up and running.
func getGUIClient(t *assert.CollectT, host *components.RemoteHost, authtoken string) *http.Client {
intentToken := getGUIIntentToken(t, host, authtoken)

guiURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort("localhost", strconv.Itoa(guiPort)),
Path: "/auth",
RawQuery: url.Values{
"intent": {intentToken},
}.Encode(),
}

jar, err := cookiejar.New(&cookiejar.Options{})
require.NoError(t, err)

guiClient := host.NewHTTPClient()
guiClient.Jar = jar

// Make the GET request
resp, err := guiClient.Get(guiURL.String())
require.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
require.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", guiURL.String())
defer resp.Body.Close()

cookies := guiClient.Jar.Cookies(&guiURL)
assert.NotEmpty(t, cookies)
assert.Equal(t, cookies[0].Name, "accessToken", "GUI server didn't the accessToken cookie")

// Assert redirection to "/"
assert.Equal(t, fmt.Sprintf("http://%v", net.JoinHostPort("localhost", strconv.Itoa(guiPort)))+"/", resp.Request.URL.String(), "GUI auth endpoint didn't redirect to root endpoint")

return guiClient
}

func checkStaticFiles(t *testing.T, client *http.Client, host *components.RemoteHost, installPath string) {

var links []string
var traverse func(*html.Node)

guiURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort("localhost", strconv.Itoa(guiPort)),
Path: "/",
}

// Make the GET request
resp, err := client.Get(guiURL.String())
require.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
require.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", guiURL.String())
defer resp.Body.Close()

doc, err := html.Parse(resp.Body)
require.NoErrorf(t, err, "failed to parse HTML response from GUI at address %s", guiURL.String())

traverse = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "link":
for _, attr := range n.Attr {
if attr.Key == "href" {
links = append(links, attr.Val)
}
}
case "script":
for _, attr := range n.Attr {
if attr.Key == "src" {
links = append(links, attr.Val)
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}

traverse(doc)
for _, link := range links {
t.Logf("trying to reach asset %v", link)
fullLink := fmt.Sprintf("http://%v/%v", net.JoinHostPort("localhost", strconv.Itoa(guiPort)), link)
resp, err := client.Get(fullLink)
assert.NoErrorf(t, err, "failed to reach GUI asset at address %s", fullLink)
defer resp.Body.Close()
assert.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", fullLink)

body, err := io.ReadAll(resp.Body)
// We replace windows line break by linux so the tests pass on every OS
bodyContent := strings.Replace(string(body), "\r\n", "\n", -1)
assert.NoErrorf(t, err, "failed to read content of GUI asset at address %s", fullLink)

// retrieving the served file in the Agent insallation director, removing the "view/" prefix
expectedBody, err := host.ReadFile(path.Join(installPath, "bin", "agent", "dist", "views", strings.TrimLeft(link, "view/")))
// We replace windows line break by linux so the tests pass on every OS
expectedBodyContent := strings.Replace(string(expectedBody), "\r\n", "\n", -1)
assert.NoErrorf(t, err, "unable to retrieve file %v in the expected served files", link)

assert.Equalf(t, expectedBodyContent, bodyContent, "content of the file %v is not the same as expected", link)
}
}

func checkPingEndpoint(t *testing.T, client *http.Client) {
guiURL := url.URL{
Scheme: "http",
Host: net.JoinHostPort("localhost", strconv.Itoa(guiPort)),
Path: "/agent/ping",
}

// Make the GET request
resp, err := client.Post(guiURL.String(), "", nil)
require.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
require.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", guiURL.String())
defer resp.Body.Close()
}
69 changes: 69 additions & 0 deletions test/new-e2e/tests/agent-shared-components/gui/gui_nix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016-present Datadog, Inc.

package gui

import (
"fmt"
"net/http"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DataDog/test-infra-definitions/components/datadog/agentparams"

"github.com/DataDog/datadog-agent/test/new-e2e/pkg/e2e"
"github.com/DataDog/datadog-agent/test/new-e2e/pkg/environments"
awshost "github.com/DataDog/datadog-agent/test/new-e2e/pkg/environments/aws/host"
"github.com/DataDog/datadog-agent/test/new-e2e/pkg/utils/e2e/client/agentclientparams"
)

type guiLinuxSuite struct {
e2e.BaseSuite[environments.Host]
}

func TestGUILinuxSuite(t *testing.T) {
t.Parallel()
e2e.Run(t, &guiLinuxSuite{}, e2e.WithProvisioner(awshost.ProvisionerNoFakeIntake()))
}

func (v *guiLinuxSuite) TestGUI() {
authTokenFilePath := "/etc/datadog-agent/auth_token"

config := fmt.Sprintf(`auth_token_file_path: %v
cmd_port: %d
GUI_port: %d`, authTokenFilePath, agentAPIPort, guiPort)
// start the agent with that configuration
v.UpdateEnv(awshost.Provisioner(
awshost.WithAgentOptions(
agentparams.WithAgentConfig(config),
),
awshost.WithAgentClientOptions(
agentclientparams.WithAuthTokenPath(authTokenFilePath),
),
))

// get auth token
v.T().Log("Getting the authentication token")
authtokenContent := v.Env().RemoteHost.MustExecute("sudo cat " + authTokenFilePath)
authtoken := strings.TrimSpace(authtokenContent)

v.T().Log("Testing GUI authentication flow")

var guiClient *http.Client
// and check that the agents are using the new key
require.EventuallyWithT(v.T(), func(t *assert.CollectT) {
guiClient = getGUIClient(t, v.Env().RemoteHost, authtoken)
}, 30*time.Second, 5*time.Second)

v.T().Log("Testing GUI static file server")
checkStaticFiles(v.T(), guiClient, v.Env().RemoteHost, "/opt/datadog-agent")

v.T().Log("Testing GUI ping endpoint")
checkPingEndpoint(v.T(), guiClient)
}
Loading

0 comments on commit dba148c

Please sign in to comment.