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:
Steven Masley 2024-01-17 10:41:42 -06:00 committed by GitHub
parent 1aee8da4b6
commit b246f08d84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 165 additions and 133 deletions

View File

@ -59,6 +59,28 @@ func (i *Validator[T]) Type() string {
return i.Value.Type()
}
func (i *Validator[T]) MarshalYAML() (interface{}, error) {
m, ok := any(i.Value).(yaml.Marshaler)
if !ok {
return i.Value, nil
}
return m.MarshalYAML()
}
func (i *Validator[T]) UnmarshalYAML(n *yaml.Node) error {
return n.Decode(i.Value)
}
func (i *Validator[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(i.Value)
}
func (i *Validator[T]) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, i.Value)
}
func (i *Validator[T]) Underlying() pflag.Value { return i.Value }
// values.go contains a standard set of value types that can be used as
// Option Values.
@ -378,6 +400,7 @@ func (s *Struct[T]) String() string {
return string(byt)
}
// nolint:revive
func (s *Struct[T]) MarshalYAML() (interface{}, error) {
var n yaml.Node
err := n.Encode(s.Value)
@ -387,6 +410,7 @@ func (s *Struct[T]) MarshalYAML() (interface{}, error) {
return n, nil
}
// nolint:revive
func (s *Struct[T]) UnmarshalYAML(n *yaml.Node) error {
// HACK: for compatibility with flags, we use nil slices instead of empty
// slices. In most cases, nil slices and empty slices are treated
@ -403,10 +427,12 @@ func (s *Struct[T]) Type() string {
return fmt.Sprintf("struct[%T]", s.Value)
}
// nolint:revive
func (s *Struct[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(s.Value)
}
// nolint:revive
func (s *Struct[T]) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &s.Value)
}

View File

@ -6,6 +6,7 @@ import (
"strings"
"github.com/mitchellh/go-wordwrap"
"github.com/spf13/pflag"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
@ -74,13 +75,16 @@ func (optSet *OptionSet) MarshalYAML() (any, error) {
Value: opt.YAML,
HeadComment: comment,
}
_, isValidator := opt.Value.(interface{ Underlying() pflag.Value })
var valueNode yaml.Node
if opt.Value == nil {
valueNode = yaml.Node{
Kind: yaml.ScalarNode,
Value: "null",
}
} else if m, ok := opt.Value.(yaml.Marshaler); ok {
} else if m, ok := opt.Value.(yaml.Marshaler); ok && !isValidator {
// Validators do a wrap, and should be handled by the else statement.
v, err := m.MarshalYAML()
if err != nil {
return nil, xerrors.Errorf(

View File

@ -53,8 +53,6 @@ import (
"gopkg.in/yaml.v3"
"tailscale.com/tailcfg"
"github.com/coder/pretty"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/buildinfo"
@ -75,7 +73,6 @@ import (
"github.com/coder/coder/v2/coderd/devtunnel"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/oauthpki"
"github.com/coder/coder/v2/coderd/prometheusmetrics"
@ -89,6 +86,7 @@ import (
"github.com/coder/coder/v2/coderd/util/slice"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"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/drpc"
"github.com/coder/coder/v2/cryptorand"
@ -99,6 +97,7 @@ import (
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/pretty"
"github.com/coder/retry"
"github.com/coder/wgtunnel/tunnelsdk"
)
@ -434,11 +433,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
if vals.WildcardAccessURL.String() == "" {
// Suffixed wildcard access URL.
u, err := url.Parse(fmt.Sprintf("*--%s", tunnel.URL.Hostname()))
wu := fmt.Sprintf("*--%s", tunnel.URL.Hostname())
err = vals.WildcardAccessURL.Set(wu)
if err != nil {
return xerrors.Errorf("parse wildcard url: %w", err)
return xerrors.Errorf("set wildcard access url %q: %w", wu, err)
}
vals.WildcardAccessURL = clibase.URL(*u)
}
}
@ -513,7 +512,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
appHostname := vals.WildcardAccessURL.String()
var appHostnameRegex *regexp.Regexp
if appHostname != "" {
appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname)
appHostnameRegex, err = appurl.CompileHostnamePattern(appHostname)
if err != nil {
return xerrors.Errorf("parse wildcard access URL %q: %w", appHostname, err)
}

View File

@ -29,6 +29,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@ -1552,6 +1553,18 @@ func TestServer(t *testing.T) {
// ValueSource is not going to be correct on the `want`, so just
// match that field.
wantConfig.Options[i].ValueSource = gotConfig.Options[i].ValueSource
// If there is a wrapped value with a validator, unwrap it.
// The underlying doesn't compare well since it compares go pointers,
// and not the actual value.
if validator, isValidator := wantConfig.Options[i].Value.(interface{ Underlying() pflag.Value }); isValidator {
wantConfig.Options[i].Value = validator.Underlying()
}
if validator, isValidator := gotConfig.Options[i].Value.(interface{ Underlying() pflag.Value }); isValidator {
gotConfig.Options[i].Value = validator.Underlying()
}
assert.Equal(
t, wantConfig.Options[i],
gotConfig.Options[i],

View File

@ -167,7 +167,7 @@ NETWORKING OPTIONS:
--secure-auth-cookie bool, $CODER_SECURE_AUTH_COOKIE
Controls if the 'Secure' property is set on browser session cookies.
--wildcard-access-url url, $CODER_WILDCARD_ACCESS_URL
--wildcard-access-url string, $CODER_WILDCARD_ACCESS_URL
Specifies the wildcard hostname to use for workspace applications in
the form "*.example.com".

View File

@ -4,8 +4,8 @@ networking:
accessURL:
# Specifies the wildcard hostname to use for workspace applications in the form
# "*.example.com".
# (default: <unset>, type: url)
wildcardAccessURL:
# (default: <unset>, type: string)
wildcardAccessURL: ""
# Specifies the custom docs URL.
# (default: <unset>, type: url)
docsURL:

View File

@ -20,7 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
)
@ -108,7 +108,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
return nil, xerrors.Errorf("fetching workspace agent data: %w", err)
}
appHost := httpapi.ApplicationURL{
appHost := appurl.ApplicationURL{
AppSlugOrPort: "{{port}}",
AgentName: workspaceAgent.Name,
WorkspaceName: workspace.Name,

2
coderd/apidoc/docs.go generated
View File

@ -9061,7 +9061,7 @@ const docTemplate = `{
"type": "string"
},
"wildcard_access_url": {
"$ref": "#/definitions/clibase.URL"
"type": "string"
},
"write_config": {
"type": "boolean"

View File

@ -8111,7 +8111,7 @@
"type": "string"
},
"wildcard_access_url": {
"$ref": "#/definitions/clibase.URL"
"type": "string"
},
"write_config": {
"type": "boolean"

View File

@ -96,7 +96,7 @@ type Options struct {
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
AppHostname string
// AppHostnameRegex contains the regex version of options.AppHostname as
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
// generated by appurl.CompileHostnamePattern(). It MUST be set if
// options.AppHostname is set.
AppHostnameRegex *regexp.Regexp
Logger slog.Logger

View File

@ -62,7 +62,6 @@ import (
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/gitsshkey"
"github.com/coder/coder/v2/coderd/healthcheck"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/schedule"
@ -71,6 +70,7 @@ import (
"github.com/coder/coder/v2/coderd/updatecheck"
"github.com/coder/coder/v2/coderd/util/ptr"
"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/codersdk/drpc"
@ -372,7 +372,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
var appHostnameRegex *regexp.Regexp
if options.AppHostname != "" {
var err error
appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname)
appHostnameRegex, err = appurl.CompileHostnamePattern(options.AppHostname)
require.NoError(t, err)
}

View File

@ -14,9 +14,9 @@ import (
"tailscale.com/tailcfg"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/parameter"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/tailnet"
@ -381,7 +381,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa
if appSlug == "" {
appSlug = dbApp.DisplayName
}
return httpapi.ApplicationURL{
return appurl.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.

View File

@ -21,10 +21,10 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/regosql"
"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/provisionersdk"
)
@ -4566,11 +4566,11 @@ func (q *FakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, params data
// Compile the app hostname regex. This is slow sadly.
if params.AllowWildcardHostname {
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
wildcardRegexp, err := appurl.CompileHostnamePattern(proxy.WildcardHostname)
if err != nil {
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
}
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, params.Hostname); ok {
if _, ok := appurl.ExecuteHostnamePattern(wildcardRegexp, params.Hostname); ok {
return proxy, nil
}
}

View File

@ -7,7 +7,7 @@ import (
"github.com/go-chi/cors"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
)
const (
@ -44,18 +44,18 @@ func Cors(allowAll bool, origins ...string) func(next http.Handler) http.Handler
})
}
func WorkspaceAppCors(regex *regexp.Regexp, app httpapi.ApplicationURL) func(next http.Handler) http.Handler {
func WorkspaceAppCors(regex *regexp.Regexp, app appurl.ApplicationURL) func(next http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowOriginFunc: func(r *http.Request, rawOrigin string) bool {
origin, err := url.Parse(rawOrigin)
if rawOrigin == "" || origin.Host == "" || err != nil {
return false
}
subdomain, ok := httpapi.ExecuteHostnamePattern(regex, origin.Host)
subdomain, ok := appurl.ExecuteHostnamePattern(regex, origin.Host)
if !ok {
return false
}
originApp, err := httpapi.ParseSubdomainAppURL(subdomain)
originApp, err := appurl.ParseSubdomainAppURL(subdomain)
if err != nil {
return false
}

View File

@ -7,14 +7,14 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
)
func TestWorkspaceAppCors(t *testing.T) {
t.Parallel()
regex, err := httpapi.CompileHostnamePattern("*--apps.dev.coder.com")
regex, err := appurl.CompileHostnamePattern("*--apps.dev.coder.com")
require.NoError(t, err)
methods := []string{
@ -30,13 +30,13 @@ func TestWorkspaceAppCors(t *testing.T) {
tests := []struct {
name string
origin string
app httpapi.ApplicationURL
app appurl.ApplicationURL
allowed bool
}{
{
name: "Self",
origin: "https://3000--agent--ws--user--apps.dev.coder.com",
app: httpapi.ApplicationURL{
app: appurl.ApplicationURL{
AppSlugOrPort: "3000",
AgentName: "agent",
WorkspaceName: "ws",
@ -47,7 +47,7 @@ func TestWorkspaceAppCors(t *testing.T) {
{
name: "SameWorkspace",
origin: "https://8000--agent--ws--user--apps.dev.coder.com",
app: httpapi.ApplicationURL{
app: appurl.ApplicationURL{
AppSlugOrPort: "3000",
AgentName: "agent",
WorkspaceName: "ws",
@ -58,7 +58,7 @@ func TestWorkspaceAppCors(t *testing.T) {
{
name: "SameUser",
origin: "https://8000--agent2--ws2--user--apps.dev.coder.com",
app: httpapi.ApplicationURL{
app: appurl.ApplicationURL{
AppSlugOrPort: "3000",
AgentName: "agent",
WorkspaceName: "ws",
@ -69,7 +69,7 @@ func TestWorkspaceAppCors(t *testing.T) {
{
name: "DifferentOriginOwner",
origin: "https://3000--agent--ws--user2--apps.dev.coder.com",
app: httpapi.ApplicationURL{
app: appurl.ApplicationURL{
AppSlugOrPort: "3000",
AgentName: "agent",
WorkspaceName: "ws",
@ -80,7 +80,7 @@ func TestWorkspaceAppCors(t *testing.T) {
{
name: "DifferentHostOwner",
origin: "https://3000--agent--ws--user--apps.dev.coder.com",
app: httpapi.ApplicationURL{
app: appurl.ApplicationURL{
AppSlugOrPort: "3000",
AgentName: "agent",
WorkspaceName: "ws",

View File

@ -19,6 +19,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
)
@ -169,7 +170,7 @@ func (api *API) ValidWorkspaceAppHostname(ctx context.Context, host string, opts
}
if opts.AllowPrimaryWildcard && api.AppHostnameRegex != nil {
_, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
_, ok := appurl.ExecuteHostnamePattern(api.AppHostnameRegex, host)
if ok {
// Force the redirect URI to have the same scheme as the access URL
// for security purposes.

View File

@ -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,

View File

@ -1,4 +1,4 @@
package httpapi
package appurl
import (
"fmt"
@ -10,8 +10,8 @@ import (
)
var (
// Remove the "starts with" and "ends with" regex components.
nameRegex = strings.Trim(UsernameValidRegex.String(), "^$")
// 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)$`,
@ -44,6 +44,14 @@ func (a ApplicationURL) String() string {
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

View File

@ -1,4 +1,4 @@
package httpapi_test
package appurl_test
import (
"fmt"
@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
)
func TestApplicationURLString(t *testing.T) {
@ -14,17 +14,17 @@ func TestApplicationURLString(t *testing.T) {
testCases := []struct {
Name string
URL httpapi.ApplicationURL
URL appurl.ApplicationURL
Expected string
}{
{
Name: "Empty",
URL: httpapi.ApplicationURL{},
URL: appurl.ApplicationURL{},
Expected: "------",
},
{
Name: "AppName",
URL: httpapi.ApplicationURL{
URL: appurl.ApplicationURL{
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
@ -34,7 +34,7 @@ func TestApplicationURLString(t *testing.T) {
},
{
Name: "Port",
URL: httpapi.ApplicationURL{
URL: appurl.ApplicationURL{
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
@ -44,7 +44,7 @@ func TestApplicationURLString(t *testing.T) {
},
{
Name: "Prefix",
URL: httpapi.ApplicationURL{
URL: appurl.ApplicationURL{
Prefix: "yolo---",
AppSlugOrPort: "app",
AgentName: "agent",
@ -70,44 +70,44 @@ func TestParseSubdomainAppURL(t *testing.T) {
testCases := []struct {
Name string
Subdomain string
Expected httpapi.ApplicationURL
Expected appurl.ApplicationURL
ExpectedError string
}{
{
Name: "Invalid_Empty",
Subdomain: "test",
Expected: httpapi.ApplicationURL{},
Expected: appurl.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace.Agent--App",
Subdomain: "workspace.agent--app",
Expected: httpapi.ApplicationURL{},
Expected: appurl.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace--App",
Subdomain: "workspace--app",
Expected: httpapi.ApplicationURL{},
Expected: appurl.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_App--Workspace--User",
Subdomain: "app--workspace--user",
Expected: httpapi.ApplicationURL{},
Expected: appurl.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_TooManyComponents",
Subdomain: "1--2--3--4--5",
Expected: httpapi.ApplicationURL{},
Expected: appurl.ApplicationURL{},
ExpectedError: "invalid application url format",
},
// Correct
{
Name: "AppName--Agent--Workspace--User",
Subdomain: "app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
Expected: appurl.ApplicationURL{
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
@ -117,7 +117,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
{
Name: "Port--Agent--Workspace--User",
Subdomain: "8080--agent--workspace--user",
Expected: httpapi.ApplicationURL{
Expected: appurl.ApplicationURL{
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
@ -127,7 +127,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
{
Name: "HyphenatedNames",
Subdomain: "app-slug--agent-name--workspace-name--user-name",
Expected: httpapi.ApplicationURL{
Expected: appurl.ApplicationURL{
AppSlugOrPort: "app-slug",
AgentName: "agent-name",
WorkspaceName: "workspace-name",
@ -137,7 +137,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
{
Name: "Prefix",
Subdomain: "dean---was---here---app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
Expected: appurl.ApplicationURL{
Prefix: "dean---was---here---",
AppSlugOrPort: "app",
AgentName: "agent",
@ -152,7 +152,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
app, err := httpapi.ParseSubdomainAppURL(c.Subdomain)
app, err := appurl.ParseSubdomainAppURL(c.Subdomain)
if c.ExpectedError == "" {
require.NoError(t, err)
require.Equal(t, c.Expected, app, "expected app")
@ -370,7 +370,7 @@ func TestCompileHostnamePattern(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
regex, err := httpapi.CompileHostnamePattern(c.pattern)
regex, err := appurl.CompileHostnamePattern(c.pattern)
if c.errorContains == "" {
require.NoError(t, err)
@ -382,7 +382,7 @@ func TestCompileHostnamePattern(t *testing.T) {
t.Run(fmt.Sprintf("MatchCase%d", i), func(t *testing.T) {
t.Parallel()
match, ok := httpapi.ExecuteHostnamePattern(regex, m.input)
match, ok := appurl.ExecuteHostnamePattern(regex, m.input)
if m.match == "" {
require.False(t, ok)
} else {

View File

@ -0,0 +1,2 @@
// Package appurl handles all parsing/validation/etc around application URLs.
package appurl

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
)
// Entitlement represents whether a feature is licensed.
@ -132,11 +133,11 @@ func (c *Client) Entitlements(ctx context.Context) (Entitlements, error) {
// DeploymentValues is the central configuration values the coder server.
type DeploymentValues struct {
Verbose clibase.Bool `json:"verbose,omitempty"`
AccessURL clibase.URL `json:"access_url,omitempty"`
WildcardAccessURL clibase.URL `json:"wildcard_access_url,omitempty"`
DocsURL clibase.URL `json:"docs_url,omitempty"`
RedirectToAccessURL clibase.Bool `json:"redirect_to_access_url,omitempty"`
Verbose clibase.Bool `json:"verbose,omitempty"`
AccessURL clibase.URL `json:"access_url,omitempty"`
WildcardAccessURL clibase.String `json:"wildcard_access_url,omitempty"`
DocsURL clibase.URL `json:"docs_url,omitempty"`
RedirectToAccessURL clibase.Bool `json:"redirect_to_access_url,omitempty"`
// HTTPAddress is a string because it may be set to zero to disable.
HTTPAddress clibase.String `json:"http_address,omitempty" typescript:",notnull"`
AutobuildPollInterval clibase.Duration `json:"autobuild_poll_interval,omitempty"`
@ -611,7 +612,19 @@ when required by your organization's security policy.`,
Description: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
Flag: "wildcard-access-url",
Env: "CODER_WILDCARD_ACCESS_URL",
Value: &c.WildcardAccessURL,
// Do not use a clibase.URL here. We are intentionally omitting the
// scheme part of the url (https://), so the standard url parsing
// will yield unexpected results.
//
// We have a validation function to ensure the wildcard url is correct,
// so use that instead.
Value: clibase.Validate(&c.WildcardAccessURL, func(value *clibase.String) error {
if value.Value() == "" {
return nil
}
_, err := appurl.CompileHostnamePattern(value.Value())
return err
}),
Group: &deploymentGroupNetworking,
YAML: "wildcardAccessURL",
Annotations: clibase.Annotations{}.Mark(annotationExternalProxies, "true"),

14
docs/api/general.md generated
View File

@ -401,19 +401,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"verbose": true,
"web_terminal_renderer": "string",
"wgtunnel_host": "string",
"wildcard_access_url": {
"forceQuery": true,
"fragment": "string",
"host": "string",
"omitHost": true,
"opaque": "string",
"path": "string",
"rawFragment": "string",
"rawPath": "string",
"rawQuery": "string",
"scheme": "string",
"user": {}
},
"wildcard_access_url": "string",
"write_config": true
},
"options": [

30
docs/api/schemas.md generated
View File

@ -2375,19 +2375,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"verbose": true,
"web_terminal_renderer": "string",
"wgtunnel_host": "string",
"wildcard_access_url": {
"forceQuery": true,
"fragment": "string",
"host": "string",
"omitHost": true,
"opaque": "string",
"path": "string",
"rawFragment": "string",
"rawPath": "string",
"rawQuery": "string",
"scheme": "string",
"user": {}
},
"wildcard_access_url": "string",
"write_config": true
},
"options": [
@ -2753,19 +2741,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"verbose": true,
"web_terminal_renderer": "string",
"wgtunnel_host": "string",
"wildcard_access_url": {
"forceQuery": true,
"fragment": "string",
"host": "string",
"omitHost": true,
"opaque": "string",
"path": "string",
"rawFragment": "string",
"rawPath": "string",
"rawQuery": "string",
"scheme": "string",
"user": {}
},
"wildcard_access_url": "string",
"write_config": true
}
```
@ -2829,7 +2805,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `verbose` | boolean | false | | |
| `web_terminal_renderer` | string | false | | |
| `wgtunnel_host` | string | false | | |
| `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | |
| `wildcard_access_url` | string | false | | |
| `write_config` | boolean | false | | |
## codersdk.DisplayApp

2
docs/cli/server.md generated
View File

@ -1088,7 +1088,7 @@ The renderer to use when opening a web terminal. Valid values are 'canvas', 'web
| | |
| ----------- | ----------------------------------------- |
| Type | <code>url</code> |
| Type | <code>string</code> |
| Environment | <code>$CODER_WILDCARD_ACCESS_URL</code> |
| YAML | <code>networking.wildcardAccessURL</code> |

View File

@ -26,8 +26,8 @@ import (
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/wsproxy"
)
@ -208,7 +208,7 @@ func (r *RootCmd) proxyServer() *clibase.Cmd {
var appHostnameRegex *regexp.Regexp
appHostname := cfg.WildcardAccessURL.String()
if appHostname != "" {
appHostnameRegex, err = httpapi.CompileHostnamePattern(appHostname)
appHostnameRegex, err = appurl.CompileHostnamePattern(appHostname)
if err != nil {
return xerrors.Errorf("parse wildcard access URL %q: %w", appHostname, err)
}

View File

@ -168,7 +168,7 @@ NETWORKING OPTIONS:
--secure-auth-cookie bool, $CODER_SECURE_AUTH_COOKIE
Controls if the 'Secure' property is set on browser session cookies.
--wildcard-access-url url, $CODER_WILDCARD_ACCESS_URL
--wildcard-access-url string, $CODER_WILDCARD_ACCESS_URL
Specifies the wildcard hostname to use for workspace applications in
the form "*.example.com".

View File

@ -19,7 +19,7 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/wsproxy"
@ -99,7 +99,7 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie
var appHostnameRegex *regexp.Regexp
if options.AppHostname != "" {
var err error
appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname)
appHostnameRegex, err = appurl.CompileHostnamePattern(options.AppHostname)
require.NoError(t, err)
}

View File

@ -26,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/telemetry"
"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/cryptorand"
"github.com/coder/coder/v2/enterprise/coderd/proxyhealth"
@ -591,7 +592,7 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
}
if req.WildcardHostname != "" {
if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
if _, err := appurl.CompileHostnamePattern(req.WildcardHostname); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Wildcard URL is invalid.",
Detail: err.Error(),

View File

@ -59,7 +59,7 @@ type Options struct {
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
AppHostname string
// AppHostnameRegex contains the regex version of options.AppHostname as
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
// generated by appurl.CompileHostnamePattern(). It MUST be set if
// options.AppHostname is set.
AppHostnameRegex *regexp.Regexp