feat: Add serving applications on subdomains and port-based proxying (#3753)

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Steven Masley
2022-09-13 13:31:33 -04:00
committed by GitHub
parent 99a7a8dd22
commit 9ab437d6e2
16 changed files with 895 additions and 88 deletions

100
coderd/httpapi/url.go Normal file
View File

@ -0,0 +1,100 @@
package httpapi
import (
"fmt"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
)
var (
// Remove the "starts with" and "ends with" regex components.
nameRegex = strings.Trim(UsernameValidRegex.String(), "^$")
appURL = regexp.MustCompile(fmt.Sprintf(
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppName>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
nameRegex))
)
// SplitSubdomain splits a subdomain from the rest of the hostname. 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, rest string, err error) {
toks := strings.SplitN(hostname, ".", 2)
if len(toks) < 2 {
return "", "", xerrors.New("no subdomain")
}
return toks[0], toks[1], nil
}
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
// Only one of AppName or Port will be set.
AppName string
Port uint16
AgentName string
WorkspaceName string
Username string
// BaseHostname is the rest of the hostname minus the application URL part
// and the first dot.
BaseHostname string
}
// String returns the application URL hostname without scheme.
func (a ApplicationURL) String() string {
appNameOrPort := a.AppName
if a.Port != 0 {
appNameOrPort = strconv.Itoa(int(a.Port))
}
return fmt.Sprintf("%s--%s--%s--%s.%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username, a.BaseHostname)
}
// ParseSubdomainAppURL parses an ApplicationURL from the given hostname. If
// the subdomain is not a valid application URL hostname, returns a non-nil
// error.
//
// Subdomains should be in the form:
//
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// (eg. http://8080--main--dev--dean.hi.c8s.io)
func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
subdomain, rest, err := SplitSubdomain(hostname)
if err != nil {
return ApplicationURL{}, xerrors.Errorf("split host domain %q: %w", hostname, err)
}
matches := appURL.FindAllStringSubmatch(subdomain, -1)
if len(matches) == 0 {
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
}
matchGroup := matches[0]
appName, port := AppNameOrPort(matchGroup[appURL.SubexpIndex("AppName")])
return ApplicationURL{
AppName: appName,
Port: port,
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
Username: matchGroup[appURL.SubexpIndex("Username")],
BaseHostname: rest,
}, nil
}
// AppNameOrPort takes a string and returns either the input string or a port
// number.
func AppNameOrPort(val string) (string, uint16) {
port, err := strconv.ParseUint(val, 10, 16)
if err != nil || port == 0 {
port = 0
} else {
val = ""
}
return val, uint16(port)
}

253
coderd/httpapi/url_test.go Normal file
View File

@ -0,0 +1,253 @@
package httpapi_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/httpapi"
)
func TestSplitSubdomain(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Host string
ExpectedSubdomain string
ExpectedRest string
ExpectedErr string
}{
{
Name: "Empty",
Host: "",
ExpectedSubdomain: "",
ExpectedRest: "",
ExpectedErr: "no subdomain",
},
{
Name: "NoSubdomain",
Host: "com",
ExpectedSubdomain: "",
ExpectedRest: "",
ExpectedErr: "no subdomain",
},
{
Name: "Domain",
Host: "coder.com",
ExpectedSubdomain: "coder",
ExpectedRest: "com",
ExpectedErr: "",
},
{
Name: "Subdomain",
Host: "subdomain.coder.com",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com",
ExpectedErr: "",
},
{
Name: "DoubleSubdomain",
Host: "subdomain1.subdomain2.coder.com",
ExpectedSubdomain: "subdomain1",
ExpectedRest: "subdomain2.coder.com",
ExpectedErr: "",
},
{
Name: "WithPort",
Host: "subdomain.coder.com:8080",
ExpectedSubdomain: "subdomain",
ExpectedRest: "coder.com:8080",
ExpectedErr: "",
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
subdomain, rest, err := httpapi.SplitSubdomain(c.Host)
if c.ExpectedErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), c.ExpectedErr)
} else {
require.NoError(t, err)
}
require.Equal(t, c.ExpectedSubdomain, subdomain)
require.Equal(t, c.ExpectedRest, rest)
})
}
}
func TestApplicationURLString(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
URL httpapi.ApplicationURL
Expected string
}{
{
Name: "Empty",
URL: httpapi.ApplicationURL{},
Expected: "------.",
},
{
Name: "AppName",
URL: httpapi.ApplicationURL{
AppName: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
Expected: "app--agent--workspace--user.coder.com",
},
{
Name: "Port",
URL: httpapi.ApplicationURL{
AppName: "",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
Expected: "8080--agent--workspace--user.coder.com",
},
{
Name: "Both",
URL: httpapi.ApplicationURL{
AppName: "app",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
// Prioritizes port over app name.
Expected: "8080--agent--workspace--user.coder.com",
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.Expected, c.URL.String())
})
}
}
func TestParseSubdomainAppURL(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Host string
Expected httpapi.ApplicationURL
ExpectedError string
}{
{
Name: "Invalid_Split",
Host: "com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "no subdomain",
},
{
Name: "Invalid_Empty",
Host: "example.com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace.Agent--App",
Host: "workspace.agent--app.coder.com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace--App",
Host: "workspace--app.coder.com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_App--Workspace--User",
Host: "app--workspace--user.coder.com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_TooManyComponents",
Host: "1--2--3--4--5.coder.com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
// Correct
{
Name: "AppName--Agent--Workspace--User",
Host: "app--agent--workspace--user.coder.com",
Expected: httpapi.ApplicationURL{
AppName: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
},
{
Name: "Port--Agent--Workspace--User",
Host: "8080--agent--workspace--user.coder.com",
Expected: httpapi.ApplicationURL{
AppName: "",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
},
{
Name: "DeepSubdomain",
Host: "app--agent--workspace--user.dev.dean-was-here.coder.com",
Expected: httpapi.ApplicationURL{
AppName: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "dev.dean-was-here.coder.com",
},
},
{
Name: "HyphenatedNames",
Host: "app-name--agent-name--workspace-name--user-name.coder.com",
Expected: httpapi.ApplicationURL{
AppName: "app-name",
Port: 0,
AgentName: "agent-name",
WorkspaceName: "workspace-name",
Username: "user-name",
BaseHostname: "coder.com",
},
},
}
for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
app, err := httpapi.ParseSubdomainAppURL(c.Host)
if c.ExpectedError == "" {
require.NoError(t, err)
require.Equal(t, c.Expected, app, "expected app")
} else {
require.ErrorContains(t, err, c.ExpectedError, "expected error")
}
})
}
}

View File

@ -8,8 +8,8 @@ import (
)
var (
usernameValid = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$")
usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*")
UsernameValidRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$")
usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*")
)
// UsernameValid returns whether the input string is a valid username.
@ -20,7 +20,7 @@ func UsernameValid(str string) bool {
if len(str) < 1 {
return false
}
return usernameValid.MatchString(str)
return UsernameValidRegex.MatchString(str)
}
// UsernameFrom returns a best-effort username from the provided string.