mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: move app URL parsing to its own package (#11651)
* chore: move app url parsing to it's own package
This commit is contained in:
@@ -21,8 +21,8 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
@@ -146,7 +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{
|
||||
appHost := appurl.ApplicationURL{
|
||||
Prefix: app.Prefix,
|
||||
AppSlugOrPort: app.AppSlugOrPort,
|
||||
AgentName: app.AgentName,
|
||||
@@ -370,7 +370,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
for _, app := range workspaceBuild.Resources[0].Agents[0].Apps {
|
||||
require.True(t, app.Subdomain)
|
||||
|
||||
appURL := httpapi.ApplicationURL{
|
||||
appURL := appurl.ApplicationURL{
|
||||
Prefix: "",
|
||||
// findProtoApp is needed as the order of apps returned from PG database
|
||||
// is not guaranteed.
|
||||
@@ -399,7 +399,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
manifest, err := agentClient.Manifest(appHostCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
appHost := httpapi.ApplicationURL{
|
||||
appHost := appurl.ApplicationURL{
|
||||
Prefix: "",
|
||||
AppSlugOrPort: "{{port}}",
|
||||
AgentName: proxyTestAgentName,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package appurl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var (
|
||||
// nameRegex is the same as our UsernameRegex without the ^ and $.
|
||||
nameRegex = "[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*"
|
||||
appURL = regexp.MustCompile(fmt.Sprintf(
|
||||
// {PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
|
||||
`^(?P<AppSlug>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
|
||||
nameRegex))
|
||||
|
||||
validHostnameLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
||||
)
|
||||
|
||||
// ApplicationURL is a parsed application URL hostname.
|
||||
type ApplicationURL struct {
|
||||
Prefix string
|
||||
AppSlugOrPort string
|
||||
AgentName string
|
||||
WorkspaceName string
|
||||
Username string
|
||||
}
|
||||
|
||||
// String returns the application URL hostname without scheme. You will likely
|
||||
// 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)
|
||||
_, _ = appURL.WriteString("--")
|
||||
_, _ = appURL.WriteString(a.WorkspaceName)
|
||||
_, _ = appURL.WriteString("--")
|
||||
_, _ = appURL.WriteString(a.Username)
|
||||
return appURL.String()
|
||||
}
|
||||
|
||||
// Path is a helper function to get the url path of the app if it is not served
|
||||
// on a subdomain. In practice this is not really used because we use the chi
|
||||
// `{variable}` syntax to extract these parts. For testing purposes and for
|
||||
// completeness of this package, we include it.
|
||||
func (a ApplicationURL) Path() string {
|
||||
return fmt.Sprintf("/@%s/%s.%s/apps/%s", a.Username, a.WorkspaceName, a.AgentName, a.AppSlugOrPort)
|
||||
}
|
||||
|
||||
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
|
||||
// the subdomain is not a valid application URL hostname, returns a non-nil
|
||||
// error. If the hostname is not a subdomain of the given base hostname, returns
|
||||
// a non-nil error.
|
||||
//
|
||||
// Subdomains should be in the form:
|
||||
//
|
||||
// ({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)
|
||||
}
|
||||
matchGroup := matches[0]
|
||||
|
||||
return ApplicationURL{
|
||||
Prefix: prefix,
|
||||
AppSlugOrPort: matchGroup[appURL.SubexpIndex("AppSlug")],
|
||||
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
|
||||
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
|
||||
Username: matchGroup[appURL.SubexpIndex("Username")],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HostnamesMatch returns true if the hostnames are equal, disregarding
|
||||
// capitalization, extra leading or trailing periods, and ports.
|
||||
func HostnamesMatch(a, b string) bool {
|
||||
a = strings.Trim(a, ".")
|
||||
b = strings.Trim(b, ".")
|
||||
|
||||
aHost, _, err := net.SplitHostPort(a)
|
||||
if err != nil {
|
||||
aHost = a
|
||||
}
|
||||
bHost, _, err := net.SplitHostPort(b)
|
||||
if err != nil {
|
||||
bHost = b
|
||||
}
|
||||
|
||||
return strings.EqualFold(aHost, bHost)
|
||||
}
|
||||
|
||||
// CompileHostnamePattern compiles a hostname pattern into a regular expression.
|
||||
// A hostname pattern is a string that may contain a single wildcard character
|
||||
// at the beginning. The wildcard character matches any number of hostname-safe
|
||||
// characters excluding periods. The pattern is case-insensitive.
|
||||
//
|
||||
// The supplied pattern:
|
||||
// - must not start or end with a period
|
||||
// - must contain exactly one asterisk at the beginning
|
||||
// - must not contain any other wildcard characters
|
||||
// - must not contain any other characters that are not hostname-safe (including
|
||||
// whitespace)
|
||||
// - must contain at least two hostname labels/segments (i.e. "foo" or "*" are
|
||||
// not valid patterns, but "foo.bar" and "*.bar" are).
|
||||
//
|
||||
// The returned regular expression will match an entire hostname with optional
|
||||
// trailing periods and whitespace. The first submatch will be the wildcard
|
||||
// match.
|
||||
func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.ToLower(pattern)
|
||||
if strings.Contains(pattern, "http:") || strings.Contains(pattern, "https:") {
|
||||
return nil, xerrors.Errorf("hostname pattern must not contain a scheme: %q", pattern)
|
||||
}
|
||||
if strings.Contains(pattern, ":") {
|
||||
return nil, xerrors.Errorf("hostname pattern must not contain a port: %q", pattern)
|
||||
}
|
||||
if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
|
||||
return nil, xerrors.Errorf("hostname pattern must not start or end with a period: %q", pattern)
|
||||
}
|
||||
if strings.Count(pattern, ".") < 1 {
|
||||
return nil, xerrors.Errorf("hostname pattern must contain at least two labels/segments: %q", pattern)
|
||||
}
|
||||
if strings.Count(pattern, "*") != 1 {
|
||||
return nil, xerrors.Errorf("hostname pattern must contain exactly one asterisk: %q", pattern)
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "*") {
|
||||
return nil, xerrors.Errorf("hostname pattern must only contain an asterisk at the beginning: %q", pattern)
|
||||
}
|
||||
for i, label := range strings.Split(pattern, ".") {
|
||||
if i == 0 {
|
||||
// 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) {
|
||||
return nil, xerrors.Errorf("hostname pattern contains invalid label %q: %q", label, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace periods with escaped periods.
|
||||
regexPattern := strings.ReplaceAll(pattern, ".", "\\.")
|
||||
|
||||
// Capture wildcard match.
|
||||
regexPattern = strings.Replace(regexPattern, "*", "([^.]+)", 1)
|
||||
|
||||
// Allow trailing period.
|
||||
regexPattern = regexPattern + "\\.?"
|
||||
|
||||
// Allow optional port number.
|
||||
regexPattern += "(:\\d+)?"
|
||||
|
||||
// Allow leading and trailing whitespace.
|
||||
regexPattern = `^\s*` + regexPattern + `\s*$`
|
||||
|
||||
return regexp.Compile(regexPattern)
|
||||
}
|
||||
|
||||
// ExecuteHostnamePattern executes a pattern generated by CompileHostnamePattern
|
||||
// and returns the wildcard match. If the pattern does not match the hostname,
|
||||
// returns false.
|
||||
func ExecuteHostnamePattern(pattern *regexp.Regexp, hostname string) (string, bool) {
|
||||
matches := pattern.FindStringSubmatch(hostname)
|
||||
if len(matches) < 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return matches[1], true
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package appurl_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
)
|
||||
|
||||
func TestApplicationURLString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
URL appurl.ApplicationURL
|
||||
Expected string
|
||||
}{
|
||||
{
|
||||
Name: "Empty",
|
||||
URL: appurl.ApplicationURL{},
|
||||
Expected: "------",
|
||||
},
|
||||
{
|
||||
Name: "AppName",
|
||||
URL: appurl.ApplicationURL{
|
||||
AppSlugOrPort: "app",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
Expected: "app--agent--workspace--user",
|
||||
},
|
||||
{
|
||||
Name: "Port",
|
||||
URL: appurl.ApplicationURL{
|
||||
AppSlugOrPort: "8080",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
Expected: "8080--agent--workspace--user",
|
||||
},
|
||||
{
|
||||
Name: "Prefix",
|
||||
URL: appurl.ApplicationURL{
|
||||
Prefix: "yolo---",
|
||||
AppSlugOrPort: "app",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
Expected: "yolo---app--agent--workspace--user",
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
Subdomain string
|
||||
Expected appurl.ApplicationURL
|
||||
ExpectedError string
|
||||
}{
|
||||
{
|
||||
Name: "Invalid_Empty",
|
||||
Subdomain: "test",
|
||||
Expected: appurl.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_Workspace.Agent--App",
|
||||
Subdomain: "workspace.agent--app",
|
||||
Expected: appurl.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_Workspace--App",
|
||||
Subdomain: "workspace--app",
|
||||
Expected: appurl.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_App--Workspace--User",
|
||||
Subdomain: "app--workspace--user",
|
||||
Expected: appurl.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
{
|
||||
Name: "Invalid_TooManyComponents",
|
||||
Subdomain: "1--2--3--4--5",
|
||||
Expected: appurl.ApplicationURL{},
|
||||
ExpectedError: "invalid application url format",
|
||||
},
|
||||
// Correct
|
||||
{
|
||||
Name: "AppName--Agent--Workspace--User",
|
||||
Subdomain: "app--agent--workspace--user",
|
||||
Expected: appurl.ApplicationURL{
|
||||
AppSlugOrPort: "app",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Port--Agent--Workspace--User",
|
||||
Subdomain: "8080--agent--workspace--user",
|
||||
Expected: appurl.ApplicationURL{
|
||||
AppSlugOrPort: "8080",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "HyphenatedNames",
|
||||
Subdomain: "app-slug--agent-name--workspace-name--user-name",
|
||||
Expected: appurl.ApplicationURL{
|
||||
AppSlugOrPort: "app-slug",
|
||||
AgentName: "agent-name",
|
||||
WorkspaceName: "workspace-name",
|
||||
Username: "user-name",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Prefix",
|
||||
Subdomain: "dean---was---here---app--agent--workspace--user",
|
||||
Expected: appurl.ApplicationURL{
|
||||
Prefix: "dean---was---here---",
|
||||
AppSlugOrPort: "app",
|
||||
AgentName: "agent",
|
||||
WorkspaceName: "workspace",
|
||||
Username: "user",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
c := c
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, err := appurl.ParseSubdomainAppURL(c.Subdomain)
|
||||
if c.ExpectedError == "" {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.Expected, app, "expected app")
|
||||
} else {
|
||||
require.ErrorContains(t, err, c.ExpectedError, "expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileHostnamePattern(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type matchCase struct {
|
||||
input string
|
||||
// empty string denotes no match
|
||||
match string
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
pattern string
|
||||
errorContains string
|
||||
// expectedRegex only needs to contain the inner part of the regex, not
|
||||
// the prefix and suffix checks.
|
||||
expectedRegex string
|
||||
matchCases []matchCase
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
name: "Invalid_ContainsHTTP",
|
||||
pattern: "http://*.hi.com",
|
||||
errorContains: "must not contain a scheme",
|
||||
},
|
||||
{
|
||||
name: "Invalid_ContainsHTTPS",
|
||||
pattern: "https://*.hi.com",
|
||||
errorContains: "must not contain a scheme",
|
||||
},
|
||||
{
|
||||
name: "Invalid_ContainsPort",
|
||||
pattern: "*.hi.com:8080",
|
||||
errorContains: "must not contain a port",
|
||||
},
|
||||
{
|
||||
name: "Invalid_StartPeriod",
|
||||
pattern: ".hi.com",
|
||||
errorContains: "must not start or end with a period",
|
||||
},
|
||||
{
|
||||
name: "Invalid_EndPeriod",
|
||||
pattern: "hi.com.",
|
||||
errorContains: "must not start or end with a period",
|
||||
},
|
||||
{
|
||||
name: "Invalid_Empty",
|
||||
pattern: "",
|
||||
errorContains: "must contain at least two labels",
|
||||
},
|
||||
{
|
||||
name: "Invalid_SingleLabel",
|
||||
pattern: "hi",
|
||||
errorContains: "must contain at least two labels",
|
||||
},
|
||||
{
|
||||
name: "Invalid_NoWildcard",
|
||||
pattern: "hi.com",
|
||||
errorContains: "must contain exactly one asterisk",
|
||||
},
|
||||
{
|
||||
name: "Invalid_MultipleWildcards",
|
||||
pattern: "**.hi.com",
|
||||
errorContains: "must contain exactly one asterisk",
|
||||
},
|
||||
{
|
||||
name: "Invalid_WildcardNotFirst",
|
||||
pattern: "hi.*.com",
|
||||
errorContains: "must only contain an asterisk at the beginning",
|
||||
},
|
||||
{
|
||||
name: "Invalid_BadLabel1",
|
||||
pattern: "*.h_i.com",
|
||||
errorContains: "contains invalid label",
|
||||
},
|
||||
{
|
||||
name: "Invalid_BadLabel2",
|
||||
pattern: "*.hi-.com",
|
||||
errorContains: "contains invalid label",
|
||||
},
|
||||
{
|
||||
name: "Invalid_BadLabel3",
|
||||
pattern: "*.-hi.com",
|
||||
errorContains: "contains invalid label",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Valid_Simple",
|
||||
pattern: "*.hi",
|
||||
expectedRegex: `([^.]+)\.hi`,
|
||||
matchCases: []matchCase{
|
||||
{
|
||||
input: "hi",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "hi.hi.hi",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi.",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: " abcd.hi. ",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi:8080",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-.hi",
|
||||
// Invalid subdomains still match the pattern because they
|
||||
// managed to make it to the webserver anyways.
|
||||
match: "ab__invalid__cd-",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid_MultiLevel",
|
||||
pattern: "*.hi.com",
|
||||
expectedRegex: `([^.]+)\.hi\.com`,
|
||||
matchCases: []matchCase{
|
||||
{
|
||||
input: "hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi.com",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-.hi.com",
|
||||
match: "ab__invalid__cd-",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid_WildcardSuffix1",
|
||||
pattern: `*a.hi.com`,
|
||||
expectedRegex: `([^.]+)a\.hi\.com`,
|
||||
matchCases: []matchCase{
|
||||
{
|
||||
input: "hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-.hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcda.hi.com",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-a.hi.com",
|
||||
match: "ab__invalid__cd-",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid_WildcardSuffix2",
|
||||
pattern: `*-test.hi.com`,
|
||||
expectedRegex: `([^.]+)-test\.hi\.com`,
|
||||
matchCases: []matchCase{
|
||||
{
|
||||
input: "hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcd.hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-.hi.com",
|
||||
match: "",
|
||||
},
|
||||
{
|
||||
input: "abcd-test.hi.com",
|
||||
match: "abcd",
|
||||
},
|
||||
{
|
||||
input: "ab__invalid__cd-test.hi.com",
|
||||
match: "ab__invalid__cd",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
regex, err := appurl.CompileHostnamePattern(c.pattern)
|
||||
if c.errorContains == "" {
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := `^\s*` + c.expectedRegex + `\.?(:\d+)?\s*$`
|
||||
require.Equal(t, expected, regex.String(), "generated regex does not match")
|
||||
|
||||
for i, m := range c.matchCases {
|
||||
m := m
|
||||
t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
match, ok := appurl.ExecuteHostnamePattern(regex, m.input)
|
||||
if m.match == "" {
|
||||
require.False(t, ok)
|
||||
} else {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, m.match, match)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, c.errorContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package appurl handles all parsing/validation/etc around application URLs.
|
||||
package appurl
|
||||
@@ -19,9 +19,9 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
@@ -751,7 +751,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
appHost := httpapi.ApplicationURL{
|
||||
appHost := appurl.ApplicationURL{
|
||||
Prefix: "",
|
||||
AppSlugOrPort: req.AppSlugOrPort,
|
||||
AgentName: req.AgentNameOrID,
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/site"
|
||||
)
|
||||
@@ -96,7 +97,7 @@ type Server struct {
|
||||
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
|
||||
Hostname string
|
||||
// HostnameRegex contains the regex version of Hostname as generated by
|
||||
// httpapi.CompileHostnamePattern(). It MUST be set if Hostname is set.
|
||||
// appurl.CompileHostnamePattern(). It MUST be set if Hostname is set.
|
||||
HostnameRegex *regexp.Regexp
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
@@ -329,7 +330,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
// 3. If the request hostname matches api.AccessURL then we pass on.
|
||||
// 5. We split the subdomain into the subdomain and the "rest". If there are no
|
||||
// periods in the hostname then we pass on.
|
||||
// 5. We parse the subdomain into a httpapi.ApplicationURL struct. If we
|
||||
// 5. We parse the subdomain into a appurl.ApplicationURL struct. If we
|
||||
// encounter an error:
|
||||
// a. If the "rest" does not match api.Hostname then we pass on;
|
||||
// b. Otherwise, we return a 400.
|
||||
@@ -428,43 +429,43 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler)
|
||||
|
||||
// parseHostname will return if a given request is attempting to access a
|
||||
// workspace app via a subdomain. If it is, the hostname of the request is parsed
|
||||
// into an httpapi.ApplicationURL and true is returned. If the request is not
|
||||
// into an appurl.ApplicationURL and true is returned. If the request is not
|
||||
// accessing a workspace app, then the next handler is called and false is
|
||||
// returned.
|
||||
func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
|
||||
func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (appurl.ApplicationURL, bool) {
|
||||
// Check if the hostname matches either of the access URLs. If it does, the
|
||||
// user was definitely trying to connect to the dashboard/API or a
|
||||
// path-based app.
|
||||
if httpapi.HostnamesMatch(s.DashboardURL.Hostname(), host) || httpapi.HostnamesMatch(s.AccessURL.Hostname(), host) {
|
||||
if appurl.HostnamesMatch(s.DashboardURL.Hostname(), host) || appurl.HostnamesMatch(s.AccessURL.Hostname(), host) {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
return appurl.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// If there are no periods in the hostname, then it can't be a valid
|
||||
// application URL.
|
||||
if !strings.Contains(host, ".") {
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
return appurl.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// Split the subdomain so we can parse the application details and verify it
|
||||
// matches the configured app hostname later.
|
||||
subdomain, ok := httpapi.ExecuteHostnamePattern(s.HostnameRegex, host)
|
||||
subdomain, ok := appurl.ExecuteHostnamePattern(s.HostnameRegex, host)
|
||||
if !ok {
|
||||
// Doesn't match the regex, so it's not a valid application URL.
|
||||
next.ServeHTTP(rw, r)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
return appurl.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// Check if the request is part of the deprecated logout flow. If so, we
|
||||
// just redirect to the main access URL.
|
||||
if subdomain == appLogoutHostname {
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusSeeOther)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
return appurl.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
// Parse the application URL from the subdomain.
|
||||
app, err := httpapi.ParseSubdomainAppURL(subdomain)
|
||||
app, err := appurl.ParseSubdomainAppURL(subdomain)
|
||||
if err != nil {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
@@ -473,7 +474,7 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
return appurl.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
return app, true
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
|
||||
return nil, xerrors.New("subdomain app hostname is required to generate subdomain app URL")
|
||||
}
|
||||
|
||||
appHost := httpapi.ApplicationURL{
|
||||
appHost := appurl.ApplicationURL{
|
||||
Prefix: r.AppRequest.Prefix,
|
||||
AppSlugOrPort: r.AppRequest.AppSlugOrPort,
|
||||
AgentName: r.AppRequest.AgentNameOrID,
|
||||
|
||||
Reference in New Issue
Block a user