feat: allow prefixes at the beginning of subdomain app hostnames (#10150)

This commit is contained in:
Dean Sheather 2023-10-11 07:02:39 +11:00 committed by GitHub
parent f48bc33e00
commit e7d9b8d858
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 306 additions and 6 deletions

4
coderd/apidoc/docs.go generated
View File

@ -12182,6 +12182,10 @@ const docTemplate = `{
"description": "AgentNameOrID is not required if the workspace has only one agent.",
"type": "string"
},
"app_prefix": {
"description": "Prefix is the prefix of the subdomain app URL. Prefix should have a\ntrailing \"---\" if set.",
"type": "string"
},
"app_slug_or_port": {
"type": "string"
},

View File

@ -11129,6 +11129,10 @@
"description": "AgentNameOrID is not required if the workspace has only one agent.",
"type": "string"
},
"app_prefix": {
"description": "Prefix is the prefix of the subdomain app URL. Prefix should have a\ntrailing \"---\" if set.",
"type": "string"
},
"app_slug_or_port": {
"type": "string"
},

View File

@ -22,6 +22,7 @@ var (
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
Prefix string
AppSlugOrPort string
AgentName string
WorkspaceName string
@ -32,6 +33,7 @@ type ApplicationURL struct {
// want to append a period and the base hostname.
func (a ApplicationURL) String() string {
var appURL strings.Builder
_, _ = appURL.WriteString(a.Prefix)
_, _ = appURL.WriteString(a.AppSlugOrPort)
_, _ = appURL.WriteString("--")
_, _ = appURL.WriteString(a.AgentName)
@ -47,13 +49,34 @@ func (a ApplicationURL) String() string {
// error. If the hostname is not a subdomain of the given base hostname, returns
// a non-nil error.
//
// The base hostname should not include a scheme, leading asterisk or dot.
//
// Subdomains should be in the form:
//
// {PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// (eg. https://8080--main--dev--dean.hi.c8s.io)
// ({PREFIX}---)?{PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// e.g.
// https://8080--main--dev--dean.hi.c8s.io
// https://app--main--dev--dean.hi.c8s.io
// https://prefix---8080--main--dev--dean.hi.c8s.io
// https://prefix---app--main--dev--dean.hi.c8s.io
//
// The optional prefix is permitted to allow customers to put additional URL at
// the beginning of their application URL (i.e. if they want to simulate
// different subdomains on the same app/port).
//
// Prefix requires three hyphens at the end to separate it from the rest of the
// URL so we can add/remove segments in the future from the parsing logic.
//
// TODO(dean): make the agent name optional when using the app slug. This will
// reduce the character count for app URLs.
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
var (
prefixSegments = strings.Split(subdomain, "---")
prefix = ""
)
if len(prefixSegments) > 1 {
prefix = strings.Join(prefixSegments[:len(prefixSegments)-1], "---") + "---"
subdomain = prefixSegments[len(prefixSegments)-1]
}
matches := appURL.FindAllStringSubmatch(subdomain, -1)
if len(matches) == 0 {
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
@ -61,6 +84,7 @@ func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
matchGroup := matches[0]
return ApplicationURL{
Prefix: prefix,
AppSlugOrPort: matchGroup[appURL.SubexpIndex("AppSlug")],
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
@ -125,8 +149,12 @@ func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) {
}
for i, label := range strings.Split(pattern, ".") {
if i == 0 {
// We have to allow the asterisk to be a valid hostname label.
// We have to allow the asterisk to be a valid hostname label, so
// we strip the asterisk (which is only on the first one).
label = strings.TrimPrefix(label, "*")
// Put an "a" at the start to stand in for the asterisk in the regex
// test below. This makes `*.coder.com` become `a.coder.com` and
// `*--prod.coder.com` become `a--prod.coder.com`.
label = "a" + label
}
if !validHostnameLabelRegex.MatchString(label) {

View File

@ -42,6 +42,17 @@ func TestApplicationURLString(t *testing.T) {
},
Expected: "8080--agent--workspace--user",
},
{
Name: "Prefix",
URL: httpapi.ApplicationURL{
Prefix: "yolo---",
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
Expected: "yolo---app--agent--workspace--user",
},
}
for _, c := range testCases {
@ -123,6 +134,17 @@ func TestParseSubdomainAppURL(t *testing.T) {
Username: "user-name",
},
},
{
Name: "Prefix",
Subdomain: "dean---was---here---app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
Prefix: "dean---was---here---",
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
},
}
for _, c := range testCases {

View File

@ -1417,6 +1417,10 @@ func convertApps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent,
appSlug = dbApp.DisplayName
}
subdomainName = httpapi.ApplicationURL{
// We never generate URLs with a prefix. We only allow prefixes
// when parsing URLs from the hostname. Users that want this
// feature can write out their own URLs.
Prefix: "",
AppSlugOrPort: appSlug,
AgentName: agent.Name,
WorkspaceName: workspace.Name,

View File

@ -19,6 +19,7 @@ import (
"testing"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -552,6 +553,118 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
})
})
t.Run("WorkspaceAppsProxySubdomainHostnamePrefix/OK", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
// Try to load the owner app with a prefix.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
prefixedOwnerApp := appDetails.Apps.Owner
prefixedOwnerApp.Prefix = "some---prefix---"
u := appDetails.SubdomainAppURL(prefixedOwnerApp)
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, resp.Header.Get("X-Got-Host"), u.Host)
// Parse the returned signed token to verify that it contains the
// prefix.
var appTokenCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.SignedAppTokenCookie {
appTokenCookie = c
break
}
}
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
// Parse the JWT without verifying it (since we can't access the key
// from this test).
object, err := jose.ParseSigned(appTokenCookie.Value)
require.NoError(t, err)
require.Len(t, object.Signatures, 1)
// Parse the payload.
var tok workspaceapps.SignedToken
//nolint:gosec
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
require.NoError(t, err)
// Verify the prefix is in the token.
require.Equal(t, prefixedOwnerApp.Prefix, tok.Request.Prefix)
// Ensure the signed app token cookie is valid by making a request with
// it with no session token.
appTokenClient := appDetails.AppClient(t)
appTokenClient.SetSessionToken("")
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, resp.Header.Get("X-Got-Host"), u.Host)
})
t.Run("WorkspaceAppsProxySubdomainHostnamePrefix/Different", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
// Try to load the owner app with a prefix.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
prefixedOwnerApp := appDetails.Apps.Owner
t.Log(appDetails.SubdomainAppURL(prefixedOwnerApp))
prefixedOwnerApp.Prefix = "some---prefix---"
t.Log(appDetails.SubdomainAppURL(prefixedOwnerApp))
u := appDetails.SubdomainAppURL(prefixedOwnerApp)
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Find the cookie.
var appTokenCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.SignedAppTokenCookie {
appTokenCookie = c
break
}
}
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
// Ensure the signed app token cookie is valid only for the given prefix
// by making a request with it with no session token.
appTokenClient := appDetails.AppClient(t)
appTokenClient.SetSessionToken("")
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
prefixedOwnerApp.Prefix = "different---"
u = appDetails.SubdomainAppURL(prefixedOwnerApp)
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
require.NoError(t, err)
_ = resp.Body.Close()
require.NotEqual(t, http.StatusOK, resp.StatusCode)
})
// This test ensures that the subdomain handler does nothing if
// --app-hostname is not set by the admin.
t.Run("WorkspaceAppsProxySubdomainPassthrough", func(t *testing.T) {

View File

@ -25,6 +25,7 @@ import (
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
@ -88,7 +89,9 @@ type App struct {
AgentName string
AppSlugOrPort string
Query string
// Prefix should have ---.
Prefix string
Query string
}
// Details are the full test details returned from setupProxyTestWithFactory.
@ -143,6 +146,7 @@ func (d *Details) PathAppURL(app App) *url.URL {
// SubdomainAppURL returns the URL for the given subdomain app.
func (d *Details) SubdomainAppURL(app App) *url.URL {
appHost := httpapi.ApplicationURL{
Prefix: app.Prefix,
AppSlugOrPort: app.AppSlugOrPort,
AgentName: app.AgentName,
WorkspaceName: app.WorkspaceName,
@ -252,6 +256,7 @@ func appServer(t *testing.T, headers http.Header, isHTTPS bool) uint16 {
_, err := r.Cookie(codersdk.SessionTokenCookie)
assert.ErrorIs(t, err, http.ErrNoCookie)
w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
w.Header().Set("X-Got-Host", r.Host)
for name, values := range headers {
for _, value := range values {
w.Header().Add(name, value)
@ -290,6 +295,17 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
scheme = "https"
}
// Workspace name needs to be short to avoid hitting 62 char hostname
// segment limit.
workspaceName, err := cryptorand.String(6)
require.NoError(t, err)
workspaceName = "ws-" + workspaceName
workspaceMutators = append([]func(*codersdk.CreateWorkspaceRequest){
func(req *codersdk.CreateWorkspaceRequest) {
req.Name = workspaceName
},
}, workspaceMutators...)
appURL := fmt.Sprintf("%s://127.0.0.1:%d?%s", scheme, port, proxyTestAppQuery)
protoApps := []*proto.App{
{
@ -354,6 +370,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
require.True(t, app.Subdomain)
appURL := httpapi.ApplicationURL{
Prefix: "",
// findProtoApp is needed as the order of apps returned from PG database
// is not guaranteed.
AppSlugOrPort: findProtoApp(t, protoApps, app.Slug).Slug,
@ -382,6 +399,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
require.NoError(t, err)
appHost := httpapi.ApplicationURL{
Prefix: "",
AppSlugOrPort: "{{port}}",
AgentName: proxyTestAgentName,
WorkspaceName: workspace.Name,

View File

@ -752,6 +752,7 @@ func Test_ResolveRequest(t *testing.T) {
require.NoError(t, err)
appHost := httpapi.ApplicationURL{
Prefix: "",
AppSlugOrPort: req.AppSlugOrPort,
AgentName: req.AgentNameOrID,
WorkspaceName: req.WorkspaceNameOrID,

View File

@ -22,6 +22,7 @@ func WriteWorkspaceApp404(log slog.Logger, accessURL *url.URL, rw http.ResponseW
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
slog.F("agent_name_or_id", appReq.AgentNameOrID),
slog.F("app_slug_or_port", appReq.AppSlugOrPort),
slog.F("hostname_prefix", appReq.Prefix),
slog.F("warnings", warnings),
)
}
@ -48,6 +49,7 @@ func WriteWorkspaceApp500(log slog.Logger, accessURL *url.URL, rw http.ResponseW
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
slog.F("agent_name_or_id", appReq.AgentNameOrID),
slog.F("app_name_or_port", appReq.AppSlugOrPort),
slog.F("hostname_prefix", appReq.Prefix),
)
}
log.Warn(ctx,
@ -76,6 +78,7 @@ func WriteWorkspaceAppOffline(log slog.Logger, accessURL *url.URL, rw http.Respo
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
slog.F("agent_name_or_id", appReq.AgentNameOrID),
slog.F("app_slug_or_port", appReq.AppSlugOrPort),
slog.F("hostname_prefix", appReq.Prefix),
)
}

View File

@ -301,6 +301,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
AppRequest: Request{
AccessMethod: AccessMethodPath,
BasePath: basePath,
Prefix: "", // Prefix doesn't exist for path apps
UsernameOrID: chi.URLParam(r, "user"),
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
// We don't support port proxying on paths. The ResolveRequest method
@ -405,6 +406,7 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler)
AppRequest: Request{
AccessMethod: AccessMethodSubdomain,
BasePath: "/",
Prefix: app.Prefix,
UsernameOrID: app.Username,
WorkspaceNameOrID: app.WorkspaceName,
AgentNameOrID: app.AgentName,

View File

@ -64,6 +64,7 @@ func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
}
appHost := httpapi.ApplicationURL{
Prefix: r.AppRequest.Prefix,
AppSlugOrPort: r.AppRequest.AppSlugOrPort,
AgentName: r.AppRequest.AgentNameOrID,
WorkspaceName: r.AppRequest.WorkspaceNameOrID,
@ -83,6 +84,9 @@ type Request struct {
// for this particular app. For subdomain apps, this should be "/". This is
// used for setting the cookie path.
BasePath string `json:"base_path"`
// Prefix is the prefix of the subdomain app URL. Prefix should have a
// trailing "---" if set.
Prefix string `json:"app_prefix"`
// For the following fields, if the AccessMethod is AccessMethodTerminal,
// then only AgentNameOrID may be set and it must be a UUID. The other
@ -170,6 +174,13 @@ func (r Request) Validate() error {
return xerrors.New("app slug or port is required")
}
if r.Prefix != "" && r.AccessMethod != AccessMethodSubdomain {
return xerrors.New("prefix is only valid for subdomain apps")
}
if r.Prefix != "" && !strings.HasSuffix(r.Prefix, "---") {
return xerrors.New("prefix must have a trailing '---'")
}
return nil
}

View File

@ -153,6 +153,44 @@ func Test_RequestValidate(t *testing.T) {
},
errContains: "app slug or port is required",
},
{
name: "Prefix/OK",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "blah---",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
},
{
name: "Prefix/Invalid",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "blah", // no trailing ---
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
errContains: "prefix must have a trailing '---'",
},
{
name: "Prefix/NotAllowedPath",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
Prefix: "blah---",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
errContains: "prefix is only valid for subdomain apps",
},
{
name: "Terminal/OtherFields/UsernameOrID",
req: workspaceapps.Request{

View File

@ -50,6 +50,7 @@ func (t SignedToken) MatchesRequest(req Request) bool {
return t.AccessMethod == req.AccessMethod &&
tokenBasePath == reqBasePath &&
t.Prefix == req.Prefix &&
t.UsernameOrID == req.UsernameOrID &&
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
t.AgentNameOrID == req.AgentNameOrID &&

View File

@ -174,6 +174,54 @@ func Test_TokenMatchesRequest(t *testing.T) {
},
want: false,
},
{
name: "SamePrefix",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "dean-was--here---",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "dean--was--here---",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "quux",
},
},
want: false,
},
{
name: "DifferentPrefix",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "yolo--",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Prefix: "swag--",
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "quux",
},
},
want: false,
},
}
for _, c := range cases {

3
docs/api/schemas.md generated
View File

@ -7869,6 +7869,7 @@ _None_
"app_request": {
"access_method": "path",
"agent_name_or_id": "string",
"app_prefix": "string",
"app_slug_or_port": "string",
"base_path": "string",
"username_or_id": "string",
@ -7896,6 +7897,7 @@ _None_
{
"access_method": "path",
"agent_name_or_id": "string",
"app_prefix": "string",
"app_slug_or_port": "string",
"base_path": "string",
"username_or_id": "string",
@ -7909,6 +7911,7 @@ _None_
| ---------------------- | -------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `access_method` | [workspaceapps.AccessMethod](#workspaceappsaccessmethod) | false | | |
| `agent_name_or_id` | string | false | | Agent name or ID is not required if the workspace has only one agent. |
| `app_prefix` | string | false | | Prefix is the prefix of the subdomain app URL. Prefix should have a trailing "---" if set. |
| `app_slug_or_port` | string | false | | |
| `base_path` | string | false | | Base path of the app. For path apps, this is the path prefix in the router for this particular app. For subdomain apps, this should be "/". This is used for setting the cookie path. |
| `username_or_id` | string | false | | For the following fields, if the AccessMethod is AccessMethodTerminal, then only AgentNameOrID may be set and it must be a UUID. The other fields must be left blank. |