tests/gateway_test.go
Rama master · réplica de lectura del repositorio original.
package main
import (
"bufio"
"context"
"encoding/json"
"io"
"maps"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/api/requests"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/pkg/uuid"
"github.com/shellhub-io/shellhub/tests/environment"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
tcexec "github.com/testcontainers/testcontainers-go/exec"
"golang.org/x/net/websocket"
)
const (
forgedAddress = "203.0.113.9"
declaredAddress = "203.0.113.7"
mcpInitialize = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}`
)
var withoutRedirects = &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
type fetched struct {
status int
header http.Header
body string
}
func fetch(t *testing.T, client *http.Client, target, host string, header http.Header) fetched {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
require.NoError(t, err)
if host != "" {
req.Host = host
}
maps.Copy(req.Header, header)
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return fetched{status: resp.StatusCode, header: resp.Header, body: string(body)}
}
func gatewayHost(t *testing.T, compose *environment.DockerCompose) string {
t.Helper()
base, err := url.Parse(compose.BaseURL())
require.NoError(t, err)
return base.Host
}
func probePath() string {
return "/api/e2e/" + uuid.Generate()
}
func rawExchange(t *testing.T, address, preamble, path string) string {
t.Helper()
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", address)
require.NoError(t, err)
defer func() { _ = conn.Close() }()
require.NoError(t, conn.SetDeadline(wallClock.Now().Add(30*time.Second)))
_, err = io.WriteString(conn, preamble+"GET "+path+" HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
require.NoError(t, err)
status, err := bufio.NewReader(conn).ReadString('\n')
require.NoError(t, err)
return strings.TrimSpace(status)
}
func proxyPreamble(source string) string {
return "PROXY TCP4 " + source + " 127.0.0.1 40000 80\r\n"
}
func execInServer(t *testing.T, compose *environment.DockerCompose, script string) string {
t.Helper()
_, output, err := compose.Service(environment.ServiceServer).Exec(t.Context(), []string{"sh", "-c", script}, tcexec.Multiplexed())
require.NoError(t, err)
out, err := io.ReadAll(output)
require.NoError(t, err)
return string(out)
}
var logField = regexp.MustCompile(`(?:^|\s)([a-z_]+)=("(?:[^"\\]|\\.)*"|\S*)`)
func logFields(line string) map[string]string {
fields := make(map[string]string)
for _, match := range logField.FindAllStringSubmatch(line, -1) {
fields[match[1]] = strings.Trim(match[2], `"`)
}
return fields
}
func requestLogged(t *testing.T, compose *environment.DockerCompose, mark int, path string) map[string]string {
t.Helper()
return logFields(compose.AwaitServerLogLine(t, mark, "uri="+path+" "))
}
// TestGateway drives the community gateway as it ships, through the HTTP port it publishes and,
// where the test needs to know the address a request comes from, from inside its own container.
// The API reports the address and request ID the gateway forwards in its request log, which is
// where these cases read them.
func TestGateway(t *testing.T) {
t.Parallel()
ctx := context.Background()
compose := newSSHEnvironment(t, ctx, legacyNamespace)
host := gatewayHost(t, compose)
port := strings.TrimPrefix(host, "localhost:")
console := fetch(t, withoutRedirects, compose.BaseURL()+"/", "", nil)
require.Equal(t, http.StatusOK, console.status)
require.Contains(t, strings.ToLower(console.body), "<!doctype html>")
t.Run("the MCP endpoint reaches the API", func(t *testing.T) {
key := compose.CreateAPIKey(t, &requests.CreateAPIKey{Name: "mcp", ExpiresAt: -1, OptRole: authorizer.RoleObserver})
mcp := func() *http.Request {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, compose.BaseURL()+"/mcp", strings.NewReader(mcpInitialize))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
return req
}
anonymous, err := http.DefaultClient.Do(mcp())
require.NoError(t, err)
_ = anonymous.Body.Close()
assert.Equal(t, http.StatusUnauthorized, anonymous.StatusCode)
authenticated := mcp()
authenticated.Header.Set("X-API-Key", key.Key)
resp, err := http.DefaultClient.Do(authenticated)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
initialized := struct {
Result struct {
ServerInfo struct {
Name string `json:"name"`
} `json:"serverInfo"`
} `json:"result"`
}{}
require.NoError(t, json.Unmarshal(body, &initialized), string(body))
assert.Equal(t, "shellhub", initialized.Result.ServerInfo.Name)
})
t.Run("the admin API is not routed in the community edition", func(t *testing.T) {
admin := fetch(t, withoutRedirects, compose.BaseURL()+"/admin/api/users", "", nil)
assert.Equal(t, http.StatusOK, admin.status)
assert.Equal(t, strings.TrimSpace(console.body), strings.TrimSpace(admin.body), "the console answered, not the API")
})
t.Run("an unknown path serves the console", func(t *testing.T) {
deepLink := fetch(t, withoutRedirects, compose.BaseURL()+"/devices/e2e/"+uuid.Generate(), "", nil)
assert.Equal(t, http.StatusOK, deepLink.status)
assert.Contains(t, deepLink.header.Get("Content-Type"), "text/html")
assert.Equal(t, strings.TrimSpace(console.body), strings.TrimSpace(deepLink.body))
})
t.Run("the health check endpoint reaches the API", func(t *testing.T) {
mark := compose.ServerLogMark(t)
probe := uuid.Generate()
health := fetch(t, withoutRedirects, compose.BaseURL()+"/healthcheck?probe="+probe, "", nil)
assert.Equal(t, http.StatusOK, health.status)
assert.Empty(t, health.body)
compose.AwaitServerLogLine(t, mark, "status=200", probe)
})
t.Run("the server container answers the gateway healthcheck", func(t *testing.T) {
inspected, err := compose.Service(environment.ServiceServer).Inspect(t.Context())
require.NoError(t, err)
require.NotNil(t, inspected.State.Health)
assert.Equal(t, "healthy", string(inspected.State.Health.Status))
assert.Equal(t, "ok", execInServer(t, compose, "wget -q -O- http://healthcheck.internal/healthz"))
})
t.Run("the install script and its kickstart alias are served uncached and name the gateway's address", func(t *testing.T) {
kickstart := fetch(t, withoutRedirects, compose.BaseURL()+"/kickstart.sh", "", nil)
install := fetch(t, withoutRedirects, compose.BaseURL()+"/install.sh", "", nil)
require.Equal(t, http.StatusOK, install.status, install.body)
require.Equal(t, http.StatusOK, kickstart.status, kickstart.body)
assert.Equal(t, "no-store", install.header.Get("Cache-Control"))
assert.Equal(t, "no-store", kickstart.header.Get("Cache-Control"))
assert.True(t, strings.HasPrefix(kickstart.body, "#!"), "the kickstart script is a script")
assert.Contains(t, kickstart.body, "SERVER_ADDRESS='http://"+host+"'")
assert.Equal(t, install.body, kickstart.body, "kickstart.sh is the install script under its other name")
})
t.Run("a request for a name the gateway does not serve is answered 404", func(t *testing.T) {
unknown := fetch(t, withoutRedirects, compose.BaseURL()+"/info", "unknown.example", nil)
assert.Equal(t, http.StatusNotFound, unknown.status)
assert.Empty(t, unknown.body)
})
t.Run("a request under /internal is answered 404 rather than reaching the console", func(t *testing.T) {
internal := fetch(t, withoutRedirects, compose.BaseURL()+"/internal/whatever", "", nil)
assert.Equal(t, http.StatusNotFound, internal.status)
})
t.Run("plain HTTP is served without a redirect when TLS is off", func(t *testing.T) {
plain := fetch(t, withoutRedirects, compose.BaseURL()+"/info", "", nil)
assert.Equal(t, http.StatusOK, plain.status)
assert.Empty(t, plain.header.Get("Location"))
})
t.Run("a PROXY preamble is not read when the PROXY protocol is off", func(t *testing.T) {
mark := compose.ServerLogMark(t)
path := probePath()
assert.Equal(t, "HTTP/1.1 400 Bad Request", rawExchange(t, host, proxyPreamble(declaredAddress), path))
control := probePath()
rawExchange(t, host, "", control)
requestLogged(t, compose, mark, control)
assert.NotContains(t, compose.ServerLogSince(t, mark), path, "the request after the preamble reached the API")
})
t.Run("X-Real-IP carries the address the client connected from", func(t *testing.T) {
mark := compose.ServerLogMark(t)
path := probePath()
execInServer(t, compose, "wget -q -O- --header 'X-Real-IP: "+forgedAddress+"' http://localhost"+path)
remote := net.ParseIP(requestLogged(t, compose, mark, path)["remote_ip"])
require.NotNil(t, remote)
assert.True(t, remote.IsLoopback(), "the API attributed the request to %s, not to the loopback client", remote)
})
t.Run("X-Request-ID is generated for every request", func(t *testing.T) {
mark := compose.ServerLogMark(t)
first, second := probePath(), probePath()
forged := http.Header{"X-Request-Id": {"forged-request-id"}}
fetch(t, withoutRedirects, compose.BaseURL()+first, "", forged)
fetch(t, withoutRedirects, compose.BaseURL()+second, "", forged)
firstID := requestLogged(t, compose, mark, first)["id"]
secondID := requestLogged(t, compose, mark, second)["id"]
assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, firstID)
assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, secondID)
assert.NotEqual(t, firstID, secondID)
})
t.Run("X-Forwarded-Port names the port the client addressed", func(t *testing.T) {
apiEndpoint := func(host string) string {
info := fetch(t, withoutRedirects, compose.BaseURL()+"/info", host, nil)
require.Equal(t, http.StatusOK, info.status, info.body)
decoded := struct {
Endpoints struct {
API string `json:"api"`
} `json:"endpoints"`
}{}
require.NoError(t, json.Unmarshal([]byte(info.body), &decoded))
return decoded.Endpoints.API
}
assert.Equal(t, "localhost:"+port, apiEndpoint(""), "the port in Host is the one the client addressed")
assert.Equal(t, "localhost:80", apiEndpoint("localhost"), "a Host without a port is answered with the port the gateway listens on")
})
t.Run("a web terminal runs a command through the gateway", func(t *testing.T) {
_, device := startAcceptedAgent(t, ctx, compose)
conn, _ := dialWebTerminal(t, ctx, compose, device)
require.NoError(t, websocket.JSON.Send(conn, map[string]any{"kind": 1, "data": "echo $((40+2))gateway\n"}))
require.NoError(t, conn.SetReadDeadline(wallClock.Now().Add(30*time.Second)))
var output strings.Builder
for !strings.Contains(output.String(), "42gateway") {
var frame []byte
require.NoError(t, websocket.Message.Receive(conn, &frame), "the terminal closed having written %q", output.String())
output.Write(frame)
}
})
t.Run("a plain GET to an agent tunnel endpoint is answered with a single 400", func(t *testing.T) {
device := authDevice(t, compose, newDeviceAuthRequest(t, "plain-get", "02:00:00:00:27:01"))
endpoints := []struct {
transport string
path string
}{
{transport: "v1", path: "/ssh/connection"},
{transport: "v2", path: "/agent/connection"},
}
for _, endpoint := range endpoints {
t.Run(endpoint.transport, func(t *testing.T) {
plain := fetch(t, withoutRedirects, compose.BaseURL()+endpoint.path, "", deviceTunnelHeader(device))
assert.Equal(t, http.StatusBadRequest, plain.status)
assert.Equal(t, "Bad Request\n", plain.body)
})
}
})
t.Run("a plain GET to the reverse dial pickup of a waiting dial is answered with a single 400", func(t *testing.T) {
device := authDevice(t, compose, newDeviceAuthRequest(t, "plain-pickup", "02:00:00:00:27:02"))
config, err := websocket.NewConfig(strings.Replace(compose.BaseURL(), "http", "ws", 1)+"/ssh/connection", compose.BaseURL())
require.NoError(t, err)
config.Header = deviceTunnelHeader(device)
tunnel, err := config.DialContext(t.Context())
require.NoError(t, err)
t.Cleanup(func() { _ = tunnel.Close() })
tunnel.PayloadType = websocket.BinaryFrame
compose.AwaitDeviceOnline(t, device.UID)
closed := make(chan struct{})
go func() {
defer close(closed)
_, _ = compose.R(context.WithoutCancel(t.Context())).
SetBody(map[string]string{"device": device.UID}).
Post("/api/sessions/" + uuid.Generate() + "/close")
}()
pickup := awaitRevdialConnReady(t, tunnel)
plain := fetch(t, withoutRedirects, compose.BaseURL()+pickup, "", nil)
assert.Equal(t, http.StatusBadRequest, plain.status)
assert.Equal(t, "Bad Request\n", plain.body)
failed, err := json.Marshal(revdialMessage{Command: "pickup-failed", ConnPath: pickup, Err: "e2e"})
require.NoError(t, err)
_, err = tunnel.Write(append(failed, '\n'))
require.NoError(t, err)
select {
case <-closed:
case <-time.After(30 * time.Second):
t.Fatal("the dial the pickup left waiting never ended")
}
})
}
type revdialMessage struct {
Command string `json:"command,omitempty"`
ConnPath string `json:"connPath,omitempty"`
Err string `json:"err,omitempty"`
}
func deviceTunnelHeader(device *models.DeviceAuthResponse) http.Header {
return http.Header{
"Authorization": {"Bearer " + device.Token},
"X-Device-Uid": {device.UID},
"X-Tenant-Id": {device.TenantID},
}
}
func awaitRevdialConnReady(t *testing.T, tunnel *websocket.Conn) string {
t.Helper()
require.NoError(t, tunnel.SetReadDeadline(wallClock.Now().Add(30*time.Second)))
messages := bufio.NewReader(tunnel)
for {
line, err := messages.ReadBytes('\n')
require.NoError(t, err, "the tunnel closed before the server dialed through it")
var message revdialMessage
require.NoError(t, json.Unmarshal(line, &message), string(line))
if message.Command == "conn-ready" {
return message.ConnPath
}
}
}