Skip to content

Commit 9ab437d

Browse files
Emyrkdeansheather
andauthored
feat: Add serving applications on subdomains and port-based proxying (#3753)
Co-authored-by: Dean Sheather <dean@deansheather.com>
1 parent 99a7a8d commit 9ab437d

File tree

16 files changed

+894
-87
lines changed

16 files changed

+894
-87
lines changed

cli/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
688688

689689
cmd.Println("Waiting for WebSocket connections to close...")
690690
_ = coderAPI.Close()
691-
cmd.Println("Done wainting for WebSocket connections")
691+
cmd.Println("Done waiting for WebSocket connections")
692692

693693
// Close tunnel after we no longer have in-flight connections.
694694
if tunnel {

coderd/coderd.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ func New(options *Options) *API {
160160
httpmw.Recover(api.Logger),
161161
httpmw.Logger(api.Logger),
162162
httpmw.Prometheus(options.PrometheusRegistry),
163+
// handleSubdomainApplications checks if the first subdomain is a valid
164+
// app URL. If it is, it will serve that application.
165+
api.handleSubdomainApplications(
166+
// Middleware to impose on the served application.
167+
httpmw.RateLimitPerMinute(options.APIRateLimit),
168+
httpmw.UseLoginURL(func() *url.URL {
169+
if options.AccessURL == nil {
170+
return nil
171+
}
172+
173+
u := *options.AccessURL
174+
u.Path = "/login"
175+
return &u
176+
}()),
177+
// This should extract the application specific API key when we
178+
// implement a scoped token.
179+
httpmw.ExtractAPIKey(options.Database, oauthConfigs, true),
180+
httpmw.ExtractUserParam(api.Database),
181+
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
182+
),
163183
// Build-Version is helpful for debugging.
164184
func(next http.Handler) http.Handler {
165185
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

coderd/coderdtest/coderdtest.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,12 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
182182
srv.Start()
183183
t.Cleanup(srv.Close)
184184

185+
tcpAddr, ok := srv.Listener.Addr().(*net.TCPAddr)
186+
require.True(t, ok)
187+
185188
serverURL, err := url.Parse(srv.URL)
186189
require.NoError(t, err)
190+
serverURL.Host = fmt.Sprintf("localhost:%d", tcpAddr.Port)
187191

188192
derpPort, err := strconv.Atoi(serverURL.Port())
189193
require.NoError(t, err)

coderd/httpapi/url.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package httpapi
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
9+
"golang.org/x/xerrors"
10+
)
11+
12+
var (
13+
// Remove the "starts with" and "ends with" regex components.
14+
nameRegex = strings.Trim(UsernameValidRegex.String(), "^$")
15+
appURL = regexp.MustCompile(fmt.Sprintf(
16+
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
17+
`^(?P<AppName>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
18+
nameRegex))
19+
)
20+
21+
// SplitSubdomain splits a subdomain from the rest of the hostname. E.g.:
22+
// - "foo.bar.com" becomes "foo", "bar.com"
23+
// - "foo.bar.baz.com" becomes "foo", "bar.baz.com"
24+
//
25+
// An error is returned if the string doesn't contain a period.
26+
func SplitSubdomain(hostname string) (subdomain string, rest string, err error) {
27+
toks := strings.SplitN(hostname, ".", 2)
28+
if len(toks) < 2 {
29+
return "", "", xerrors.New("no subdomain")
30+
}
31+
32+
return toks[0], toks[1], nil
33+
}
34+
35+
// ApplicationURL is a parsed application URL hostname.
36+
type ApplicationURL struct {
37+
// Only one of AppName or Port will be set.
38+
AppName string
39+
Port uint16
40+
AgentName string
41+
WorkspaceName string
42+
Username string
43+
// BaseHostname is the rest of the hostname minus the application URL part
44+
// and the first dot.
45+
BaseHostname string
46+
}
47+
48+
// String returns the application URL hostname without scheme.
49+
func (a ApplicationURL) String() string {
50+
appNameOrPort := a.AppName
51+
if a.Port != 0 {
52+
appNameOrPort = strconv.Itoa(int(a.Port))
53+
}
54+
55+
return fmt.Sprintf("%s--%s--%s--%s.%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username, a.BaseHostname)
56+
}
57+
58+
// ParseSubdomainAppURL parses an ApplicationURL from the given hostname. If
59+
// the subdomain is not a valid application URL hostname, returns a non-nil
60+
// error.
61+
//
62+
// Subdomains should be in the form:
63+
//
64+
// {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+
72+
matches := appURL.FindAllStringSubmatch(subdomain, -1)
73+
if len(matches) == 0 {
74+
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
75+
}
76+
matchGroup := matches[0]
77+
78+
appName, port := AppNameOrPort(matchGroup[appURL.SubexpIndex("AppName")])
79+
return ApplicationURL{
80+
AppName: appName,
81+
Port: port,
82+
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
83+
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
84+
Username: matchGroup[appURL.SubexpIndex("Username")],
85+
BaseHostname: rest,
86+
}, nil
87+
}
88+
89+
// AppNameOrPort takes a string and returns either the input string or a port
90+
// number.
91+
func AppNameOrPort(val string) (string, uint16) {
92+
port, err := strconv.ParseUint(val, 10, 16)
93+
if err != nil || port == 0 {
94+
port = 0
95+
} else {
96+
val = ""
97+
}
98+
99+
return val, uint16(port)
100+
}

0 commit comments

Comments
 (0)