Skip to content

Commit 6bb1a34

Browse files
authored
fix: allow ports in wildcard url configuration (#11657)
* fix: allow ports in wildcard url configuration This just forwards the port to the ui that generates urls. Our existing parsing + regex already supported ports for subdomain app requests.
1 parent 1f0e6ba commit 6bb1a34

File tree

12 files changed

+203
-31
lines changed

12 files changed

+203
-31
lines changed

coderd/agentapi/manifest.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package agentapi
33
import (
44
"context"
55
"database/sql"
6-
"fmt"
76
"net/url"
87
"strings"
98
"sync/atomic"
@@ -108,19 +107,14 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
108107
return nil, xerrors.Errorf("fetching workspace agent data: %w", err)
109108
}
110109

111-
appHost := appurl.ApplicationURL{
110+
appSlug := appurl.ApplicationURL{
112111
AppSlugOrPort: "{{port}}",
113112
AgentName: workspaceAgent.Name,
114113
WorkspaceName: workspace.Name,
115114
Username: owner.Username,
116115
}
117-
vscodeProxyURI := a.AccessURL.Scheme + "://" + strings.ReplaceAll(a.AppHostname, "*", appHost.String())
118-
if a.AppHostname == "" {
119-
vscodeProxyURI += a.AccessURL.Hostname()
120-
}
121-
if a.AccessURL.Port() != "" {
122-
vscodeProxyURI += fmt.Sprintf(":%s", a.AccessURL.Port())
123-
}
116+
117+
vscodeProxyURI := vscodeProxyURI(appSlug, a.AccessURL, a.AppHostname)
124118

125119
var gitAuthConfigs uint32
126120
for _, cfg := range a.ExternalAuthConfigs {
@@ -155,6 +149,17 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
155149
}, nil
156150
}
157151

152+
func vscodeProxyURI(app appurl.ApplicationURL, accessURL *url.URL, appHost string) string {
153+
// This will handle the ports from the accessURL or appHost.
154+
appHost = appurl.SubdomainAppHost(appHost, accessURL)
155+
// If there is no appHost, then we want to use the access url as the proxy uri.
156+
if appHost == "" {
157+
appHost = accessURL.Host
158+
}
159+
// Return the url with a scheme and any wildcards replaced with the app slug.
160+
return accessURL.Scheme + "://" + strings.ReplaceAll(appHost, "*", app.String())
161+
}
162+
158163
func dbAgentMetadataToProtoDescription(metadata []database.WorkspaceAgentMetadatum) []*agentproto.WorkspaceAgentMetadata_Description {
159164
ret := make([]*agentproto.WorkspaceAgentMetadata_Description, len(metadata))
160165
for i, metadatum := range metadata {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package agentapi
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
11+
)
12+
13+
func Test_vscodeProxyURI(t *testing.T) {
14+
t.Parallel()
15+
16+
coderAccessURL, err := url.Parse("https://coder.com")
17+
require.NoError(t, err)
18+
19+
accessURLWithPort, err := url.Parse("https://coder.com:8080")
20+
require.NoError(t, err)
21+
22+
basicApp := appurl.ApplicationURL{
23+
Prefix: "prefix",
24+
AppSlugOrPort: "slug",
25+
AgentName: "agent",
26+
WorkspaceName: "workspace",
27+
Username: "user",
28+
}
29+
30+
cases := []struct {
31+
Name string
32+
App appurl.ApplicationURL
33+
AccessURL *url.URL
34+
AppHostname string
35+
Expected string
36+
}{
37+
{
38+
// No hostname proxies through the access url.
39+
Name: "NoHostname",
40+
AccessURL: coderAccessURL,
41+
AppHostname: "",
42+
App: basicApp,
43+
Expected: coderAccessURL.String(),
44+
},
45+
{
46+
Name: "NoHostnameAccessURLPort",
47+
AccessURL: accessURLWithPort,
48+
AppHostname: "",
49+
App: basicApp,
50+
Expected: accessURLWithPort.String(),
51+
},
52+
{
53+
Name: "Hostname",
54+
AccessURL: coderAccessURL,
55+
AppHostname: "*.apps.coder.com",
56+
App: basicApp,
57+
Expected: fmt.Sprintf("https://%s.apps.coder.com", basicApp.String()),
58+
},
59+
{
60+
Name: "HostnameWithAccessURLPort",
61+
AccessURL: accessURLWithPort,
62+
AppHostname: "*.apps.coder.com",
63+
App: basicApp,
64+
Expected: fmt.Sprintf("https://%s.apps.coder.com:%s", basicApp.String(), accessURLWithPort.Port()),
65+
},
66+
{
67+
Name: "HostnameWithPort",
68+
AccessURL: coderAccessURL,
69+
AppHostname: "*.apps.coder.com:4444",
70+
App: basicApp,
71+
Expected: fmt.Sprintf("https://%s.apps.coder.com:%s", basicApp.String(), "4444"),
72+
},
73+
{
74+
// Port from hostname takes precedence over access url port.
75+
Name: "HostnameWithPortAccessURLWithPort",
76+
AccessURL: accessURLWithPort,
77+
AppHostname: "*.apps.coder.com:4444",
78+
App: basicApp,
79+
Expected: fmt.Sprintf("https://%s.apps.coder.com:%s", basicApp.String(), "4444"),
80+
},
81+
}
82+
83+
for _, c := range cases {
84+
c := c
85+
t.Run(c.Name, func(t *testing.T) {
86+
t.Parallel()
87+
88+
require.NotNilf(t, c.AccessURL, "AccessURL is required")
89+
90+
output := vscodeProxyURI(c.App, c.AccessURL, c.AppHostname)
91+
require.Equal(t, c.Expected, output)
92+
})
93+
}
94+
}

coderd/coderd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ type Options struct {
9393
// AppHostname should be the wildcard hostname to use for workspace
9494
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
9595
// It will use the same scheme and port number as the access URL.
96-
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
96+
// E.g. "*.apps.coder.com" or "*-apps.coder.com" or "*.apps.coder.com:8080".
9797
AppHostname string
9898
// AppHostnameRegex contains the regex version of options.AppHostname as
9999
// generated by appurl.CompileHostnamePattern(). It MUST be set if

coderd/workspaceapps.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package coderd
33
import (
44
"context"
55
"database/sql"
6-
"fmt"
76
"net/http"
87
"net/url"
98
"strings"
@@ -32,13 +31,8 @@ import (
3231
// @Router /applications/host [get]
3332
// @Deprecated use api/v2/regions and see the primary proxy.
3433
func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
35-
host := api.AppHostname
36-
if host != "" && api.AccessURL.Port() != "" {
37-
host += fmt.Sprintf(":%s", api.AccessURL.Port())
38-
}
39-
4034
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AppHostResponse{
41-
Host: host,
35+
Host: appurl.SubdomainAppHost(api.AppHostname, api.AccessURL),
4236
})
4337
}
4438

coderd/workspaceapps/apptest/apptest.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,38 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
963963
require.Equal(t, http.StatusOK, resp.StatusCode)
964964
})
965965

966+
t.Run("WildcardPortOK", func(t *testing.T) {
967+
t.Parallel()
968+
969+
// Manually specifying a port should override the access url port on
970+
// the app host.
971+
appDetails := setupProxyTest(t, &DeploymentOptions{
972+
// Just throw both the wsproxy and primary to same url.
973+
AppHost: "*.test.coder.com:4444",
974+
PrimaryAppHost: "*.test.coder.com:4444",
975+
})
976+
977+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
978+
defer cancel()
979+
980+
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
981+
t.Logf("url: %s", u)
982+
require.Equal(t, "4444", u.Port(), "port should be 4444")
983+
984+
// Assert the api response the UI uses has the port.
985+
apphost, err := appDetails.SDKClient.AppHost(ctx)
986+
require.NoError(t, err)
987+
require.Equal(t, "*.test.coder.com:4444", apphost.Host, "apphost has port")
988+
989+
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
990+
require.NoError(t, err)
991+
defer resp.Body.Close()
992+
body, err := io.ReadAll(resp.Body)
993+
require.NoError(t, err)
994+
require.Equal(t, proxyTestAppBody, string(body))
995+
require.Equal(t, http.StatusOK, resp.StatusCode)
996+
})
997+
966998
t.Run("SuffixWildcardNotMatch", func(t *testing.T) {
967999
t.Parallel()
9681000

coderd/workspaceapps/apptest/setup.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const (
4747
// DeploymentOptions are the options for creating a *Deployment with a
4848
// DeploymentFactory.
4949
type DeploymentOptions struct {
50+
PrimaryAppHost string
5051
AppHost string
5152
DisablePathApps bool
5253
DisableSubdomainApps bool
@@ -407,7 +408,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
407408
Username: me.Username,
408409
}
409410
proxyURL := "http://" + appHost.String() + strings.ReplaceAll(primaryAppHost.Host, "*", "")
410-
require.Equal(t, proxyURL, manifest.VSCodePortProxyURI)
411+
require.Equal(t, manifest.VSCodePortProxyURI, proxyURL)
411412
}
412413
agentCloser := agent.New(agent.Options{
413414
Client: agentClient,

coderd/workspaceapps/appurl/appurl.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package appurl
33
import (
44
"fmt"
55
"net"
6+
"net/url"
67
"regexp"
78
"strings"
89

@@ -20,6 +21,36 @@ var (
2021
validHostnameLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
2122
)
2223

24+
// SubdomainAppHost returns the URL of the apphost for subdomain based apps.
25+
// It will omit the scheme.
26+
//
27+
// Arguments:
28+
// apphost: Expected to contain a wildcard, example: "*.coder.com"
29+
// accessURL: The access url for the deployment.
30+
//
31+
// Returns:
32+
// 'apphost:port'
33+
//
34+
// For backwards compatibility and for "accessurl=localhost:0" purposes, we need
35+
// to use the port from the accessurl if the apphost doesn't have a port.
36+
// If the user specifies a port in the apphost, we will use that port instead.
37+
func SubdomainAppHost(apphost string, accessURL *url.URL) string {
38+
if apphost == "" {
39+
return ""
40+
}
41+
42+
if apphost != "" && accessURL.Port() != "" {
43+
// This should always parse if we prepend a scheme. We should add
44+
// the access url port if the apphost doesn't have a port specified.
45+
appHostU, err := url.Parse(fmt.Sprintf("https://%s", apphost))
46+
if err != nil || (err == nil && appHostU.Port() == "") {
47+
apphost += fmt.Sprintf(":%s", accessURL.Port())
48+
}
49+
}
50+
51+
return apphost
52+
}
53+
2354
// ApplicationURL is a parsed application URL hostname.
2455
type ApplicationURL struct {
2556
Prefix string
@@ -140,9 +171,7 @@ func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) {
140171
if strings.Contains(pattern, "http:") || strings.Contains(pattern, "https:") {
141172
return nil, xerrors.Errorf("hostname pattern must not contain a scheme: %q", pattern)
142173
}
143-
if strings.Contains(pattern, ":") {
144-
return nil, xerrors.Errorf("hostname pattern must not contain a port: %q", pattern)
145-
}
174+
146175
if strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
147176
return nil, xerrors.Errorf("hostname pattern must not start or end with a period: %q", pattern)
148177
}
@@ -155,6 +184,16 @@ func CompileHostnamePattern(pattern string) (*regexp.Regexp, error) {
155184
if !strings.HasPrefix(pattern, "*") {
156185
return nil, xerrors.Errorf("hostname pattern must only contain an asterisk at the beginning: %q", pattern)
157186
}
187+
188+
// If there is a hostname:port, we only care about the hostname. For hostname
189+
// pattern reasons, we do not actually care what port the client is requesting.
190+
// Any port provided here is used for generating urls for the ui, not for
191+
// validation.
192+
hostname, _, err := net.SplitHostPort(pattern)
193+
if err == nil {
194+
pattern = hostname
195+
}
196+
158197
for i, label := range strings.Split(pattern, ".") {
159198
if i == 0 {
160199
// We have to allow the asterisk to be a valid hostname label, so

coderd/workspaceapps/appurl/appurl_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,11 +193,6 @@ func TestCompileHostnamePattern(t *testing.T) {
193193
pattern: "https://*.hi.com",
194194
errorContains: "must not contain a scheme",
195195
},
196-
{
197-
name: "Invalid_ContainsPort",
198-
pattern: "*.hi.com:8080",
199-
errorContains: "must not contain a port",
200-
},
201196
{
202197
name: "Invalid_StartPeriod",
203198
pattern: ".hi.com",
@@ -249,6 +244,13 @@ func TestCompileHostnamePattern(t *testing.T) {
249244
errorContains: "contains invalid label",
250245
},
251246

247+
{
248+
name: "Valid_ContainsPort",
249+
pattern: "*.hi.com:8080",
250+
// Although a port is provided, the regex already matches any port.
251+
// So it is ignored for validation purposes.
252+
expectedRegex: `([^.]+)\.hi\.com`,
253+
},
252254
{
253255
name: "Valid_Simple",
254256
pattern: "*.hi",

coderd/workspaceproxies.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/coder/coder/v2/coderd/database"
1212
"github.com/coder/coder/v2/coderd/database/dbauthz"
1313
"github.com/coder/coder/v2/coderd/httpapi"
14+
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
1415
"github.com/coder/coder/v2/codersdk"
1516
)
1617

@@ -43,7 +44,7 @@ func (api *API) PrimaryRegion(ctx context.Context) (codersdk.Region, error) {
4344
IconURL: proxy.IconUrl,
4445
Healthy: true,
4546
PathAppURL: api.AccessURL.String(),
46-
WildcardHostname: api.AppHostname,
47+
WildcardHostname: appurl.SubdomainAppHost(api.AppHostname, api.AccessURL),
4748
}, nil
4849
}
4950

coderd/workspaceproxies_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd_test
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/google/uuid"
@@ -44,7 +45,7 @@ func TestRegions(t *testing.T) {
4445
require.NotEmpty(t, regions[0].IconURL)
4546
require.True(t, regions[0].Healthy)
4647
require.Equal(t, client.URL.String(), regions[0].PathAppURL)
47-
require.Equal(t, appHostname, regions[0].WildcardHostname)
48+
require.Equal(t, fmt.Sprintf("%s:%s", appHostname, client.URL.Port()), regions[0].WildcardHostname)
4849

4950
// Ensure the primary region ID is constant.
5051
regions2, err := client.Regions(ctx)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy