Skip to content

Commit 6d21b37

Browse files
committed
feat: add --app-hostname flag to coder server
1 parent 794b88f commit 6d21b37

File tree

7 files changed

+96
-71
lines changed

7 files changed

+96
-71
lines changed

cli/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
7575
var (
7676
accessURL string
7777
address string
78+
appHostname string
7879
autobuildPollInterval time.Duration
7980
derpServerEnabled bool
8081
derpServerRegionID int
@@ -360,6 +361,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
360361

361362
options := &coderd.Options{
362363
AccessURL: accessURLParsed,
364+
AppHostname: appHostname,
363365
ICEServers: iceServers,
364366
Logger: logger.Named("coderd"),
365367
Database: databasefake.New(),
@@ -763,6 +765,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
763765
"External URL to access your deployment. This must be accessible by all provisioned workspaces.")
764766
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000",
765767
"Bind address of the server.")
768+
cliflag.StringVarP(root.Flags(), &appHostname, "app-hostname", "", "CODER_APP_HOSTNAME", "", `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".`)
766769
cliflag.StringVarP(root.Flags(), &derpConfigURL, "derp-config-url", "", "CODER_DERP_CONFIG_URL", "",
767770
"URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/")
768771
cliflag.StringVarP(root.Flags(), &derpConfigPath, "derp-config-path", "", "CODER_DERP_CONFIG_PATH", "",

coderd/coderd.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ import (
4545
// Options are requires parameters for Coder to start.
4646
type Options struct {
4747
AccessURL *url.URL
48-
Logger slog.Logger
49-
Database database.Store
50-
Pubsub database.Pubsub
48+
// AppHostname should be the wildcard hostname to use for workspace
49+
// applications without the asterisk or leading dot. E.g. "apps.coder.com".
50+
AppHostname string
51+
Logger slog.Logger
52+
Database database.Store
53+
Pubsub database.Pubsub
5154

5255
// CacheDir is used for caching files served by the API.
5356
CacheDir string

coderd/coderdtest/coderdtest.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import (
6666
)
6767

6868
type Options struct {
69+
AppHostname string
6970
AWSCertificates awsidentity.Certificates
7071
Authorizer rbac.Authorizer
7172
AzureCertificates x509.VerifyOptions
@@ -220,6 +221,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
220221
// agents are not marked as disconnected during slow tests.
221222
AgentInactiveDisconnectTimeout: testutil.WaitShort,
222223
AccessURL: serverURL,
224+
AppHostname: options.AppHostname,
223225
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
224226
CacheDir: t.TempDir(),
225227
Database: db,

coderd/httpapi/url.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package httpapi
22

33
import (
44
"fmt"
5+
"net"
56
"regexp"
67
"strconv"
78
"strings"
@@ -40,35 +41,31 @@ type ApplicationURL struct {
4041
AgentName string
4142
WorkspaceName string
4243
Username string
43-
// BaseHostname is the rest of the hostname minus the application URL part
44-
// and the first dot.
45-
BaseHostname string
4644
}
4745

48-
// String returns the application URL hostname without scheme.
46+
// String returns the application URL hostname without scheme. You will likely
47+
// want to append a period and the base hostname.
4948
func (a ApplicationURL) String() string {
5049
appNameOrPort := a.AppName
5150
if a.Port != 0 {
5251
appNameOrPort = strconv.Itoa(int(a.Port))
5352
}
5453

55-
return fmt.Sprintf("%s--%s--%s--%s.%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username, a.BaseHostname)
54+
return fmt.Sprintf("%s--%s--%s--%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username)
5655
}
5756

58-
// ParseSubdomainAppURL parses an ApplicationURL from the given hostname. If
57+
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
5958
// the subdomain is not a valid application URL hostname, returns a non-nil
60-
// error.
59+
// error. If the hostname is not a subdomain of the given base hostname, returns
60+
// a non-nil error.
61+
//
62+
// The base hostname should not include a scheme, leading asterisk or dot.
6163
//
6264
// Subdomains should be in the form:
6365
//
6466
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
65-
// (eg. http://8080--main--dev--dean.hi.c8s.io)
66-
func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
67-
subdomain, rest, err := SplitSubdomain(hostname)
68-
if err != nil {
69-
return ApplicationURL{}, xerrors.Errorf("split host domain %q: %w", hostname, err)
70-
}
71-
67+
// (eg. https://8080--main--dev--dean.hi.c8s.io)
68+
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
7269
matches := appURL.FindAllStringSubmatch(subdomain, -1)
7370
if len(matches) == 0 {
7471
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
@@ -82,7 +79,6 @@ func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
8279
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
8380
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
8481
Username: matchGroup[appURL.SubexpIndex("Username")],
85-
BaseHostname: rest,
8682
}, nil
8783
}
8884

@@ -98,3 +94,21 @@ func AppNameOrPort(val string) (string, uint16) {
9894

9995
return val, uint16(port)
10096
}
97+
98+
// HostnamesMatch returns true if the hostnames are equal, disregarding
99+
// capitalization, extra leading or trailing periods, and ports.
100+
func HostnamesMatch(a, b string) bool {
101+
a = strings.Trim(a, ".")
102+
b = strings.Trim(b, ".")
103+
104+
aHost, _, err := net.SplitHostPort(a)
105+
if err != nil {
106+
aHost = a
107+
}
108+
bHost, _, err := net.SplitHostPort(b)
109+
if err != nil {
110+
bHost = b
111+
}
112+
113+
return strings.EqualFold(aHost, bHost)
114+
}

coderd/httpapi/url_test.go

Lines changed: 17 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func TestApplicationURLString(t *testing.T) {
9090
{
9191
Name: "Empty",
9292
URL: httpapi.ApplicationURL{},
93-
Expected: "------.",
93+
Expected: "------",
9494
},
9595
{
9696
Name: "AppName",
@@ -100,9 +100,8 @@ func TestApplicationURLString(t *testing.T) {
100100
AgentName: "agent",
101101
WorkspaceName: "workspace",
102102
Username: "user",
103-
BaseHostname: "coder.com",
104103
},
105-
Expected: "app--agent--workspace--user.coder.com",
104+
Expected: "app--agent--workspace--user",
106105
},
107106
{
108107
Name: "Port",
@@ -112,9 +111,8 @@ func TestApplicationURLString(t *testing.T) {
112111
AgentName: "agent",
113112
WorkspaceName: "workspace",
114113
Username: "user",
115-
BaseHostname: "coder.com",
116114
},
117-
Expected: "8080--agent--workspace--user.coder.com",
115+
Expected: "8080--agent--workspace--user",
118116
},
119117
{
120118
Name: "Both",
@@ -124,10 +122,9 @@ func TestApplicationURLString(t *testing.T) {
124122
AgentName: "agent",
125123
WorkspaceName: "workspace",
126124
Username: "user",
127-
BaseHostname: "coder.com",
128125
},
129126
// Prioritizes port over app name.
130-
Expected: "8080--agent--workspace--user.coder.com",
127+
Expected: "8080--agent--workspace--user",
131128
},
132129
}
133130

@@ -145,93 +142,72 @@ func TestParseSubdomainAppURL(t *testing.T) {
145142
t.Parallel()
146143
testCases := []struct {
147144
Name string
148-
Host string
145+
Subdomain string
149146
Expected httpapi.ApplicationURL
150147
ExpectedError string
151148
}{
152-
{
153-
Name: "Invalid_Split",
154-
Host: "com",
155-
Expected: httpapi.ApplicationURL{},
156-
ExpectedError: "no subdomain",
157-
},
158149
{
159150
Name: "Invalid_Empty",
160-
Host: "example.com",
151+
Subdomain: "test",
161152
Expected: httpapi.ApplicationURL{},
162153
ExpectedError: "invalid application url format",
163154
},
164155
{
165156
Name: "Invalid_Workspace.Agent--App",
166-
Host: "workspace.agent--app.coder.com",
157+
Subdomain: "workspace.agent--app",
167158
Expected: httpapi.ApplicationURL{},
168159
ExpectedError: "invalid application url format",
169160
},
170161
{
171162
Name: "Invalid_Workspace--App",
172-
Host: "workspace--app.coder.com",
163+
Subdomain: "workspace--app",
173164
Expected: httpapi.ApplicationURL{},
174165
ExpectedError: "invalid application url format",
175166
},
176167
{
177168
Name: "Invalid_App--Workspace--User",
178-
Host: "app--workspace--user.coder.com",
169+
Subdomain: "app--workspace--user",
179170
Expected: httpapi.ApplicationURL{},
180171
ExpectedError: "invalid application url format",
181172
},
182173
{
183174
Name: "Invalid_TooManyComponents",
184-
Host: "1--2--3--4--5.coder.com",
175+
Subdomain: "1--2--3--4--5",
185176
Expected: httpapi.ApplicationURL{},
186177
ExpectedError: "invalid application url format",
187178
},
188179
// Correct
189180
{
190-
Name: "AppName--Agent--Workspace--User",
191-
Host: "app--agent--workspace--user.coder.com",
181+
Name: "AppName--Agent--Workspace--User",
182+
Subdomain: "app--agent--workspace--user",
192183
Expected: httpapi.ApplicationURL{
193184
AppName: "app",
194185
Port: 0,
195186
AgentName: "agent",
196187
WorkspaceName: "workspace",
197188
Username: "user",
198-
BaseHostname: "coder.com",
199189
},
200190
},
201191
{
202-
Name: "Port--Agent--Workspace--User",
203-
Host: "8080--agent--workspace--user.coder.com",
192+
Name: "Port--Agent--Workspace--User",
193+
Subdomain: "8080--agent--workspace--user",
204194
Expected: httpapi.ApplicationURL{
205195
AppName: "",
206196
Port: 8080,
207197
AgentName: "agent",
208198
WorkspaceName: "workspace",
209199
Username: "user",
210-
BaseHostname: "coder.com",
211-
},
212-
},
213-
{
214-
Name: "DeepSubdomain",
215-
Host: "app--agent--workspace--user.dev.dean-was-here.coder.com",
216-
Expected: httpapi.ApplicationURL{
217-
AppName: "app",
218-
Port: 0,
219-
AgentName: "agent",
220-
WorkspaceName: "workspace",
221-
Username: "user",
222-
BaseHostname: "dev.dean-was-here.coder.com",
223200
},
224201
},
225202
{
226-
Name: "HyphenatedNames",
227-
Host: "app-name--agent-name--workspace-name--user-name.coder.com",
203+
Name: "HyphenatedNames",
204+
Subdomain: "app-name--agent-name--workspace-name--user-name",
228205
Expected: httpapi.ApplicationURL{
229206
AppName: "app-name",
230207
Port: 0,
231208
AgentName: "agent-name",
232209
WorkspaceName: "workspace-name",
233210
Username: "user-name",
234-
BaseHostname: "coder.com",
235211
},
236212
},
237213
}
@@ -241,7 +217,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
241217
t.Run(c.Name, func(t *testing.T) {
242218
t.Parallel()
243219

244-
app, err := httpapi.ParseSubdomainAppURL(c.Host)
220+
app, err := httpapi.ParseSubdomainAppURL(c.Subdomain)
245221
if c.ExpectedError == "" {
246222
require.NoError(t, err)
247223
require.Equal(t, c.Expected, app, "expected app")

coderd/workspaceapps.go

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,18 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
5555
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
5656
ctx := r.Context()
5757

58+
if api.AppHostname == "" {
59+
next.ServeHTTP(rw, r)
60+
return
61+
}
62+
5863
host := httpapi.RequestHost(r)
5964
if host == "" {
6065
if r.URL.Path == "/derp" {
6166
// The /derp endpoint is used by wireguard clients to tunnel
6267
// through coderd. For some reason these requests don't set
63-
// a Host header properly sometimes (no idea how), which
64-
// causes this path to get hit.
68+
// a Host header properly sometimes in tests (no idea how),
69+
// which causes this path to get hit.
6570
next.ServeHTTP(rw, r)
6671
return
6772
}
@@ -72,18 +77,38 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
7277
return
7378
}
7479

75-
app, err := httpapi.ParseSubdomainAppURL(host)
76-
if err != nil {
77-
// Subdomain is not a valid application url. Pass through to the
78-
// rest of the app.
79-
// TODO: @emyrk we should probably catch invalid subdomains. Meaning
80-
// an invalid application should not route to the coderd.
81-
// To do this we would need to know the list of valid access urls
82-
// though?
80+
// Check if the hostname matches the access URL.
81+
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
82+
// The user was definitely trying to connect to the
83+
// dashboard/API.
8384
next.ServeHTTP(rw, r)
8485
return
8586
}
8687

88+
// Split the subdomain and verify it matches the configured app
89+
// hostname.
90+
subdomain, rest, err := httpapi.SplitSubdomain(host)
91+
if err != nil {
92+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
93+
Message: fmt.Sprintf("Could not split request Host header %q.", host),
94+
Detail: err.Error(),
95+
})
96+
return
97+
}
98+
if !httpapi.HostnamesMatch(api.AppHostname, rest) {
99+
httpapi.ResourceNotFound(rw)
100+
return
101+
}
102+
103+
app, err := httpapi.ParseSubdomainAppURL(subdomain)
104+
if err != nil {
105+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
106+
Message: "Could not parse subdomain application URL.",
107+
Detail: err.Error(),
108+
})
109+
return
110+
}
111+
87112
workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName)
88113
chiCtx := chi.RouteContext(ctx)
89114
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey)

coderd/workspaceapps_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const (
3131
proxyTestAppQuery = "query=true"
3232
proxyTestAppBody = "hello world"
3333
proxyTestFakeAppName = "fake"
34+
35+
proxyTestSubdomain = "test.coder.com"
3436
)
3537

3638
// setupProxyTest creates a workspace with an agent and some apps. It returns a
@@ -58,6 +60,7 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
5860
require.True(t, ok)
5961

6062
client := coderdtest.New(t, &coderdtest.Options{
63+
AppHostname: proxyTestSubdomain,
6164
IncludeProvisionerDaemon: true,
6265
})
6366
user := coderdtest.CreateFirstUser(t, client)
@@ -252,8 +255,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
252255
AgentName: proxyTestAgentName,
253256
WorkspaceName: workspace.Name,
254257
Username: me.Username,
255-
BaseHostname: "test.coder.com",
256-
}.String()
258+
}.String() + "." + proxyTestSubdomain
257259

258260
actualPath := "/"
259261
query := ""

0 commit comments

Comments
 (0)