-
Notifications
You must be signed in to change notification settings - Fork 954
feat: Add serving applications on subdomains and port-based proxying #3753
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
Merged
+895
−88
Merged
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
35ee5d6
chore: Add subdomain parser for applications
Emyrk a7e5bd6
Add basic router for applications using same codepath
Emyrk 03c697d
Merge remote-tracking branch 'origin/main' into stevenmasley/unnamed-…
Emyrk 3e30cdd
Handle ports as app names
Emyrk 8397306
Add comments
Emyrk f994ec3
Cleanup
Emyrk fda80b8
Linting
Emyrk 6b09a0f
Push cookies to subdomains on the access url as well
Emyrk 634cd2e
Fix unit test
Emyrk 82df6f1
Fix comment
Emyrk 49084e2
Reuse regex from validation
Emyrk 4696bf9
Export valid name regex
Emyrk b5d1f6a
Move to workspaceapps.go
Emyrk 0578588
Change app url name order
Emyrk 77d3452
Import order
Emyrk 931ecb2
Merge remote-tracking branch 'origin/main' into stevenmasley/unnamed-…
Emyrk 54f2bdd
Deleted duplicate code
Emyrk f1d7670
Rename subdomain handler
Emyrk 46e0900
Merge branch 'main' into stevenmasley/unnamed-apps
deansheather 56c1d00
Change the devurl syntax to app--agent--workspace--user
deansheather 25d776a
more devurls support stuff, everything should work now
deansheather f3c6645
devurls working + tests
deansheather 75c4713
Merge branch 'main' into stevenmasley/unnamed-apps
deansheather 1f8a1f0
Move stuff to httpapi
deansheather 2c7bcc1
fixup! Move stuff to httpapi
deansheather dc0d348
Merge branch 'main' into stevenmasley/unnamed-apps
deansheather 58653d4
kyle comments
deansheather 5321bef
fixup! kyle comments
deansheather ad53b42
fixup! kyle comments
deansheather File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
package coderd | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
"regexp" | ||
"strings" | ||
|
||
"github.com/coder/coder/coderd/httpmw" | ||
|
||
"github.com/go-chi/chi/v5" | ||
|
||
"golang.org/x/xerrors" | ||
) | ||
|
||
const ( | ||
// XForwardedHostHeader is a header used by proxies to indicate the | ||
// original host of the request. | ||
XForwardedHostHeader = "X-Forwarded-Host" | ||
) | ||
|
||
// ApplicationURL is a parsed application url into it's components | ||
type ApplicationURL struct { | ||
AppName string | ||
WorkspaceName string | ||
Agent string | ||
Username string | ||
Path string | ||
Domain string | ||
} | ||
|
||
func (api *API) handleSubdomain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { | ||
Emyrk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return func(next http.Handler) http.Handler { | ||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | ||
ctx := r.Context() | ||
app, err := ParseSubdomainAppURL(r) | ||
|
||
if err != nil { | ||
// Subdomain is not a valid application url. Pass through. | ||
// TODO: @emyrk we should probably catch invalid subdomains. Meaning | ||
// an invalid application should not route to the coderd. | ||
// To do this we would need to know the list of valid access urls | ||
// though? | ||
next.ServeHTTP(rw, r) | ||
return | ||
} | ||
|
||
workspaceAgentKey := app.WorkspaceName | ||
if app.Agent != "" { | ||
workspaceAgentKey += "." + app.Agent | ||
} | ||
chiCtx := chi.RouteContext(ctx) | ||
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey) | ||
chiCtx.URLParams.Add("user", app.Username) | ||
|
||
// Use the passed in app middlewares before passing to the proxy app. | ||
mws := chi.Middlewares(middlewares) | ||
mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | ||
workspace := httpmw.WorkspaceParam(r) | ||
agent := httpmw.WorkspaceAgentParam(r) | ||
|
||
api.proxyWorkspaceApplication(proxyApplication{ | ||
Workspace: workspace, | ||
Agent: agent, | ||
AppName: app.AppName, | ||
}, rw, r) | ||
})).ServeHTTP(rw, r.WithContext(ctx)) | ||
}) | ||
} | ||
} | ||
|
||
var ( | ||
nameRegex = `[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*` | ||
appURL = regexp.MustCompile(fmt.Sprintf( | ||
// {USERNAME}--{WORKSPACE_NAME}}--{{AGENT_NAME}}--{{PORT}} | ||
`^(?P<UserName>%[1]s)--(?P<WorkspaceName>%[1]s)(--(?P<AgentName>%[1]s))?--(?P<AppName>%[1]s)$`, | ||
Emyrk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
nameRegex)) | ||
) | ||
|
||
// ParseSubdomainAppURL parses an application from the subdomain of r's Host header. | ||
// If the application string is not valid, returns a non-nil error. | ||
// 1) {USERNAME}--{WORKSPACE_NAME}}--{{AGENT_NAME}}--{{PORT/AppName}} | ||
// (eg. http://admin--myenv--main--8080.cdrdeploy.c8s.io) | ||
func ParseSubdomainAppURL(r *http.Request) (ApplicationURL, error) { | ||
deansheather marked this conversation as resolved.
Show resolved
Hide resolved
|
||
host := RequestHost(r) | ||
if host == "" { | ||
return ApplicationURL{}, xerrors.Errorf("no host header") | ||
} | ||
|
||
subdomain, domain, err := SplitSubdomain(host) | ||
if err != nil { | ||
return ApplicationURL{}, xerrors.Errorf("split host domain: %w", err) | ||
} | ||
|
||
matches := appURL.FindAllStringSubmatch(subdomain, -1) | ||
if len(matches) == 0 { | ||
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain) | ||
} | ||
|
||
if len(matches) > 1 { | ||
return ApplicationURL{}, xerrors.Errorf("multiple matches (%d) for application url: %q", len(matches), subdomain) | ||
} | ||
matchGroup := matches[0] | ||
|
||
return ApplicationURL{ | ||
AppName: matchGroup[appURL.SubexpIndex("AppName")], | ||
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")], | ||
Agent: matchGroup[appURL.SubexpIndex("AgentName")], | ||
Username: matchGroup[appURL.SubexpIndex("UserName")], | ||
Path: r.URL.Path, | ||
Domain: domain, | ||
}, nil | ||
} | ||
|
||
// RequestHost returns the name of the host from the request. It prioritizes | ||
// 'X-Forwarded-Host' over r.Host since most requests are being proxied. | ||
func RequestHost(r *http.Request) string { | ||
host := r.Header.Get(XForwardedHostHeader) | ||
if host != "" { | ||
return host | ||
} | ||
|
||
return r.Host | ||
} | ||
|
||
// SplitSubdomain splits a subdomain from a domain. E.g.: | ||
// - "foo.bar.com" becomes "foo", "bar.com" | ||
// - "foo.bar.baz.com" becomes "foo", "bar.baz.com" | ||
// | ||
// An error is returned if the string doesn't contain a period. | ||
func SplitSubdomain(hostname string) (subdomain string, domain string, err error) { | ||
toks := strings.SplitN(hostname, ".", 2) | ||
if len(toks) < 2 { | ||
return "", "", xerrors.Errorf("no domain") | ||
} | ||
|
||
return toks[0], toks[1], nil | ||
} | ||
|
||
// applicationCookie is a helper function to copy the auth cookie to also | ||
// support subdomains. Until we support creating authentication cookies that can | ||
// only do application authentication, we will just reuse the original token. | ||
// This code should be temporary and be replaced with something that creates | ||
// a unique session_token. | ||
func (api *API) applicationCookie(authCookie *http.Cookie) *http.Cookie { | ||
appCookie := *authCookie | ||
// We only support setting this cookie on the access url subdomains. | ||
// This is to ensure we don't accidentally leak the auth cookie to subdomains | ||
// on another hostname. | ||
appCookie.Domain = "." + api.AccessURL.Hostname() | ||
return &appCookie | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
package coderd_test | ||
|
||
import ( | ||
"net/http/httptest" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/coder/coder/coderd" | ||
) | ||
|
||
func TestParseSubdomainAppURL(t *testing.T) { | ||
t.Parallel() | ||
testCases := []struct { | ||
Name string | ||
URL string | ||
Expected coderd.ApplicationURL | ||
ExpectedError string | ||
}{ | ||
{ | ||
Name: "Empty", | ||
URL: "https://example.com", | ||
Expected: coderd.ApplicationURL{}, | ||
ExpectedError: "invalid application url format", | ||
}, | ||
{ | ||
Name: "Workspace.Agent+App", | ||
URL: "https://workspace.agent--app.coder.com", | ||
Expected: coderd.ApplicationURL{}, | ||
ExpectedError: "invalid application url format", | ||
}, | ||
{ | ||
Name: "Workspace+App", | ||
URL: "https://workspace--app.coder.com", | ||
Expected: coderd.ApplicationURL{}, | ||
ExpectedError: "invalid application url format", | ||
}, | ||
// Correct | ||
{ | ||
Name: "User+Workspace+App", | ||
URL: "https://user--workspace--app.coder.com", | ||
Expected: coderd.ApplicationURL{ | ||
AppName: "app", | ||
WorkspaceName: "workspace", | ||
Agent: "", | ||
Username: "user", | ||
Path: "", | ||
Domain: "coder.com", | ||
}, | ||
}, | ||
{ | ||
Name: "User+Workspace+Port", | ||
URL: "https://user--workspace--8080.coder.com", | ||
Expected: coderd.ApplicationURL{ | ||
AppName: "8080", | ||
WorkspaceName: "workspace", | ||
Agent: "", | ||
Username: "user", | ||
Path: "", | ||
Domain: "coder.com", | ||
}, | ||
}, | ||
{ | ||
Name: "User+Workspace.Agent+App", | ||
URL: "https://user--workspace--agent--app.coder.com", | ||
Expected: coderd.ApplicationURL{ | ||
AppName: "app", | ||
WorkspaceName: "workspace", | ||
Agent: "agent", | ||
Username: "user", | ||
Path: "", | ||
Domain: "coder.com", | ||
}, | ||
}, | ||
{ | ||
Name: "User+Workspace.Agent+Port", | ||
URL: "https://user--workspace--agent--8080.coder.com", | ||
Expected: coderd.ApplicationURL{ | ||
AppName: "8080", | ||
WorkspaceName: "workspace", | ||
Agent: "agent", | ||
Username: "user", | ||
Path: "", | ||
Domain: "coder.com", | ||
}, | ||
}, | ||
{ | ||
Name: "HyphenatedNames", | ||
URL: "https://admin-user--workspace-thing--agent-thing--app-name.coder.com", | ||
Expected: coderd.ApplicationURL{ | ||
AppName: "app-name", | ||
WorkspaceName: "workspace-thing", | ||
Agent: "agent-thing", | ||
Username: "admin-user", | ||
Path: "", | ||
Domain: "coder.com", | ||
}, | ||
}, | ||
} | ||
|
||
for _, c := range testCases { | ||
c := c | ||
t.Run(c.Name, func(t *testing.T) { | ||
t.Parallel() | ||
r := httptest.NewRequest("GET", c.URL, nil) | ||
|
||
app, err := coderd.ParseSubdomainAppURL(r) | ||
if c.ExpectedError == "" { | ||
require.NoError(t, err) | ||
require.Equal(t, c.Expected, app, "expected app") | ||
} else { | ||
require.ErrorContains(t, err, c.ExpectedError, "expected error") | ||
} | ||
}) | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.