Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6d21b37
feat: add --app-hostname flag to coder server
deansheather Sep 19, 2022
b8a7a45
chore: add test for subdomain proxy passthrough
deansheather Sep 19, 2022
3b875b2
fixup! chore: add test for subdomain proxy passthrough
deansheather Sep 19, 2022
39e9b6f
chore: reorganize subdomain app handler
deansheather Sep 19, 2022
3a099ba
chore: add authorization check endpoint
deansheather Sep 20, 2022
25b8182
Merge branch 'main' into dean/app-tokens
deansheather Sep 20, 2022
e864f27
chore: improve proxy auth tests
deansheather Sep 20, 2022
b5d2be3
chore: refactor ExtractAPIKey to accept struct
deansheather Sep 20, 2022
d9b404d
feat: end-to-end workspace application authentication
deansheather Sep 21, 2022
da5f656
Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
312e0d5
fixup! Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
16bbcbe
feat: use a custom cookie name for devurls to avoid clashes
deansheather Sep 21, 2022
a172cd5
feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
d4986d2
fixup! feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
9b56f02
fixup! feat: /api/v2/applications/host endpoint, PR comments
deansheather Sep 21, 2022
d9186a8
chore: more pr comments
deansheather Sep 21, 2022
35962fc
Remove checkUserPermissions
kylecarbs Sep 21, 2022
b1436ec
fixup! Remove checkUserPermissions
deansheather Sep 21, 2022
11e985f
Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
496fde3
fixup! Merge branch 'main' into dean/app-tokens
deansheather Sep 21, 2022
3e30a9f
chore: more security stuff
deansheather Sep 21, 2022
11e6061
fixup! chore: more security stuff
deansheather Sep 21, 2022
cf70650
chore: more comments
deansheather Sep 21, 2022
6d66f55
Merge branch 'main' into dean/app-tokens
deansheather Sep 22, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add --app-hostname flag to coder server
  • Loading branch information
deansheather committed Sep 19, 2022
commit 6d21b37bc40d9fcef76068644c51a3d0d2b6e127
3 changes: 3 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
var (
accessURL string
address string
appHostname string
autobuildPollInterval time.Duration
derpServerEnabled bool
derpServerRegionID int
Expand Down Expand Up @@ -360,6 +361,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {

options := &coderd.Options{
AccessURL: accessURLParsed,
AppHostname: appHostname,
ICEServers: iceServers,
Logger: logger.Named("coderd"),
Database: databasefake.New(),
Expand Down Expand Up @@ -763,6 +765,7 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command {
"External URL to access your deployment. This must be accessible by all provisioned workspaces.")
cliflag.StringVarP(root.Flags(), &address, "address", "a", "CODER_ADDRESS", "127.0.0.1:3000",
"Bind address of the server.")
cliflag.StringVarP(root.Flags(), &appHostname, "app-hostname", "", "CODER_APP_HOSTNAME", "", `Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".`)
cliflag.StringVarP(root.Flags(), &derpConfigURL, "derp-config-url", "", "CODER_DERP_CONFIG_URL", "",
"URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/")
cliflag.StringVarP(root.Flags(), &derpConfigPath, "derp-config-path", "", "CODER_DERP_CONFIG_PATH", "",
Expand Down
9 changes: 6 additions & 3 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ import (
// Options are requires parameters for Coder to start.
type Options struct {
AccessURL *url.URL
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub
// AppHostname should be the wildcard hostname to use for workspace
// applications without the asterisk or leading dot. E.g. "apps.coder.com".
AppHostname string
Logger slog.Logger
Database database.Store
Pubsub database.Pubsub

// CacheDir is used for caching files served by the API.
CacheDir string
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
)

type Options struct {
AppHostname string
AWSCertificates awsidentity.Certificates
Authorizer rbac.Authorizer
AzureCertificates x509.VerifyOptions
Expand Down Expand Up @@ -220,6 +221,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
// agents are not marked as disconnected during slow tests.
AgentInactiveDisconnectTimeout: testutil.WaitShort,
AccessURL: serverURL,
AppHostname: options.AppHostname,
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
CacheDir: t.TempDir(),
Database: db,
Expand Down
44 changes: 29 additions & 15 deletions coderd/httpapi/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httpapi

import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -40,35 +41,31 @@ type ApplicationURL struct {
AgentName string
WorkspaceName string
Username string
// BaseHostname is the rest of the hostname minus the application URL part
// and the first dot.
BaseHostname string
}

// String returns the application URL hostname without scheme.
// 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 {
appNameOrPort := a.AppName
if a.Port != 0 {
appNameOrPort = strconv.Itoa(int(a.Port))
}

return fmt.Sprintf("%s--%s--%s--%s.%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username, a.BaseHostname)
return fmt.Sprintf("%s--%s--%s--%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username)
}

// ParseSubdomainAppURL parses an ApplicationURL from the given hostname. If
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
// the subdomain is not a valid application URL hostname, returns a non-nil
// error.
// error. If the hostname is not a subdomain of the given base hostname, returns
// a non-nil error.
//
// The base hostname should not include a scheme, leading asterisk or dot.
//
// Subdomains should be in the form:
//
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// (eg. http://8080--main--dev--dean.hi.c8s.io)
func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
subdomain, rest, err := SplitSubdomain(hostname)
if err != nil {
return ApplicationURL{}, xerrors.Errorf("split host domain %q: %w", hostname, err)
}

// (eg. https://8080--main--dev--dean.hi.c8s.io)
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
matches := appURL.FindAllStringSubmatch(subdomain, -1)
if len(matches) == 0 {
return ApplicationURL{}, xerrors.Errorf("invalid application url format: %q", subdomain)
Expand All @@ -82,7 +79,6 @@ func ParseSubdomainAppURL(hostname string) (ApplicationURL, error) {
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
Username: matchGroup[appURL.SubexpIndex("Username")],
BaseHostname: rest,
}, nil
}

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

return val, uint16(port)
}

// 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)
}
58 changes: 17 additions & 41 deletions coderd/httpapi/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "Empty",
URL: httpapi.ApplicationURL{},
Expected: "------.",
Expected: "------",
},
{
Name: "AppName",
Expand All @@ -100,9 +100,8 @@ func TestApplicationURLString(t *testing.T) {
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
Expected: "app--agent--workspace--user.coder.com",
Expected: "app--agent--workspace--user",
},
{
Name: "Port",
Expand All @@ -112,9 +111,8 @@ func TestApplicationURLString(t *testing.T) {
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
Expected: "8080--agent--workspace--user.coder.com",
Expected: "8080--agent--workspace--user",
},
{
Name: "Both",
Expand All @@ -124,10 +122,9 @@ func TestApplicationURLString(t *testing.T) {
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
// Prioritizes port over app name.
Expected: "8080--agent--workspace--user.coder.com",
Expected: "8080--agent--workspace--user",
},
}

Expand All @@ -145,93 +142,72 @@ func TestParseSubdomainAppURL(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Host string
Subdomain string
Expected httpapi.ApplicationURL
ExpectedError string
}{
{
Name: "Invalid_Split",
Host: "com",
Expected: httpapi.ApplicationURL{},
ExpectedError: "no subdomain",
},
{
Name: "Invalid_Empty",
Host: "example.com",
Subdomain: "test",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace.Agent--App",
Host: "workspace.agent--app.coder.com",
Subdomain: "workspace.agent--app",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_Workspace--App",
Host: "workspace--app.coder.com",
Subdomain: "workspace--app",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_App--Workspace--User",
Host: "app--workspace--user.coder.com",
Subdomain: "app--workspace--user",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
{
Name: "Invalid_TooManyComponents",
Host: "1--2--3--4--5.coder.com",
Subdomain: "1--2--3--4--5",
Expected: httpapi.ApplicationURL{},
ExpectedError: "invalid application url format",
},
// Correct
{
Name: "AppName--Agent--Workspace--User",
Host: "app--agent--workspace--user.coder.com",
Name: "AppName--Agent--Workspace--User",
Subdomain: "app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppName: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
},
{
Name: "Port--Agent--Workspace--User",
Host: "8080--agent--workspace--user.coder.com",
Name: "Port--Agent--Workspace--User",
Subdomain: "8080--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppName: "",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "coder.com",
},
},
{
Name: "DeepSubdomain",
Host: "app--agent--workspace--user.dev.dean-was-here.coder.com",
Expected: httpapi.ApplicationURL{
AppName: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
BaseHostname: "dev.dean-was-here.coder.com",
},
},
{
Name: "HyphenatedNames",
Host: "app-name--agent-name--workspace-name--user-name.coder.com",
Name: "HyphenatedNames",
Subdomain: "app-name--agent-name--workspace-name--user-name",
Expected: httpapi.ApplicationURL{
AppName: "app-name",
Port: 0,
AgentName: "agent-name",
WorkspaceName: "workspace-name",
Username: "user-name",
BaseHostname: "coder.com",
},
},
}
Expand All @@ -241,7 +217,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()

app, err := httpapi.ParseSubdomainAppURL(c.Host)
app, err := httpapi.ParseSubdomainAppURL(c.Subdomain)
if c.ExpectedError == "" {
require.NoError(t, err)
require.Equal(t, c.Expected, app, "expected app")
Expand Down
45 changes: 35 additions & 10 deletions coderd/workspaceapps.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,18 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()

if api.AppHostname == "" {
next.ServeHTTP(rw, r)
return
}

host := httpapi.RequestHost(r)
if host == "" {
if r.URL.Path == "/derp" {
// The /derp endpoint is used by wireguard clients to tunnel
// through coderd. For some reason these requests don't set
// a Host header properly sometimes (no idea how), which
// causes this path to get hit.
// a Host header properly sometimes in tests (no idea how),
// which causes this path to get hit.
next.ServeHTTP(rw, r)
return
}
Expand All @@ -72,18 +77,38 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
return
}

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

// Split the subdomain and verify it matches the configured app
// hostname.
subdomain, rest, err := httpapi.SplitSubdomain(host)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Could not split request Host header %q.", host),
Detail: err.Error(),
})
return
}
if !httpapi.HostnamesMatch(api.AppHostname, rest) {
httpapi.ResourceNotFound(rw)
return
}

app, err := httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not parse subdomain application URL.",
Detail: err.Error(),
})
return
}

workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName)
chiCtx := chi.RouteContext(ctx)
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey)
Expand Down
6 changes: 4 additions & 2 deletions coderd/workspaceapps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const (
proxyTestAppQuery = "query=true"
proxyTestAppBody = "hello world"
proxyTestFakeAppName = "fake"

proxyTestSubdomain = "test.coder.com"
)

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

client := coderdtest.New(t, &coderdtest.Options{
AppHostname: proxyTestSubdomain,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
Expand Down Expand Up @@ -252,8 +255,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
AgentName: proxyTestAgentName,
WorkspaceName: workspace.Name,
Username: me.Username,
BaseHostname: "test.coder.com",
}.String()
}.String() + "." + proxyTestSubdomain

actualPath := "/"
query := ""
Expand Down