Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ASCII-2547] Make the GUI serve static files from FS + e2e test #31187

Merged
merged 9 commits into from
Nov 28, 2024
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)
Fixed Show fixed Hide fixed
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 @@ -352,7 +352,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
183 changes: 183 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,183 @@
// 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"
"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.TestingT, host *components.RemoteHost, authtoken string) string {
if h, ok := t.(testing.TB); ok {
h.Helper()
}

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)
assert.NoErrorf(t, err, "failed to fetch API from %s", apiEndpoint.String())
misteriaud marked this conversation as resolved.
Show resolved Hide resolved

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

assert.NoErrorf(t, err, "failed to create request for %s", apiEndpoint.String())

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

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

url, err := io.ReadAll(resp.Body)
assert.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.TestingT, host *components.RemoteHost, authtoken string) *http.Client {
if h, ok := t.(testing.TB); ok {
h.Helper()
}

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{})
assert.NoError(t, err)

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

// Make the GET request
resp, err := guiClient.Get(guiURL.String())
assert.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
assert.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())
assert.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
assert.Equalf(t, http.StatusOK, resp.StatusCode, "unexpected status code for %s", guiURL.String())
defer resp.Body.Close()

doc, err := html.Parse(resp.Body)
assert.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)
assert.NoErrorf(t, err, "failed to reach GUI at address %s", guiURL.String())
assert.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)
ogaca-dd marked this conversation as resolved.
Show resolved Hide resolved
}, 1*time.Minute, 10*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
Loading