Skip to content

Commit ec04552

Browse files
committed
Check workspace proxy hostnames for subdomain apps
1 parent 68c3bb1 commit ec04552

File tree

4 files changed

+170
-3
lines changed

4 files changed

+170
-3
lines changed

coderd/coderd.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import (
5151
"github.com/coder/coder/coderd/httpmw"
5252
"github.com/coder/coder/coderd/metricscache"
5353
"github.com/coder/coder/coderd/provisionerdserver"
54+
"github.com/coder/coder/coderd/proxycache"
5455
"github.com/coder/coder/coderd/rbac"
5556
"github.com/coder/coder/coderd/schedule"
5657
"github.com/coder/coder/coderd/telemetry"
@@ -272,6 +273,9 @@ func New(options *Options) *API {
272273
},
273274
)
274275

276+
ctx, cancel := context.WithCancel(context.Background())
277+
proxyCache := proxycache.New(ctx, options.Logger.Named("proxy_cache"), options.Database, time.Minute*5)
278+
275279
staticHandler := site.Handler(site.FS(), binFS, binHashes)
276280
// Static file handler must be wrapped with HSTS handler if the
277281
// StrictTransportSecurityAge is set. We only need to set this header on
@@ -284,7 +288,6 @@ func New(options *Options) *API {
284288
}
285289

286290
r := chi.NewRouter()
287-
ctx, cancel := context.WithCancel(context.Background())
288291
api := &API{
289292
ctx: ctx,
290293
cancel: cancel,
@@ -308,6 +311,7 @@ func New(options *Options) *API {
308311
options.AppSecurityKey,
309312
),
310313
metricsCache: metricsCache,
314+
ProxyCache: proxyCache,
311315
Auditor: atomic.Pointer[audit.Auditor]{},
312316
TemplateScheduleStore: options.TemplateScheduleStore,
313317
Experiments: experiments,
@@ -816,7 +820,8 @@ type API struct {
816820
workspaceAgentCache *wsconncache.Cache
817821
updateChecker *updatecheck.Checker
818822
WorkspaceAppsProvider workspaceapps.SignedTokenProvider
819-
workspaceAppServer *workspaceapps.Server
823+
workspaceAppServer *workspaceapps.Server
824+
ProxyCache *proxycache.Cache
820825

821826
// Experiments contains the list of experiments currently enabled.
822827
// This is used to gate features that are not yet ready for production.

coderd/proxycache/cache.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package proxycache
2+
3+
import (
4+
"context"
5+
"regexp"
6+
"runtime/pprof"
7+
"sync"
8+
"time"
9+
10+
"github.com/coder/coder/coderd/database/dbauthz"
11+
12+
"github.com/coder/coder/coderd/httpapi"
13+
14+
"cdr.dev/slog"
15+
"github.com/coder/coder/coderd/database"
16+
)
17+
18+
// Cache is used to cache workspace proxies to prevent having to do a database
19+
// call each time the list of workspace proxies is required. Workspace proxies
20+
// are very infrequently updated, so this cache should rarely change.
21+
//
22+
// The accessor functions on the cache are intended to optimize the hot path routes
23+
// in the API. Meaning, this cache can implement the specific logic required to
24+
// using the cache in the API, instead of just returning the slice of proxies.
25+
type Cache struct {
26+
db database.Store
27+
log slog.Logger
28+
interval time.Duration
29+
30+
// ctx controls the lifecycle of the cache.
31+
ctx context.Context
32+
cancel func()
33+
34+
// Data
35+
mu sync.RWMutex
36+
// cachedValues is the list of workspace proxies that are currently cached.
37+
// This is the raw data from the database.
38+
cachedValues []database.WorkspaceProxy
39+
// cachedPatterns is a map of the workspace proxy patterns to their compiled
40+
// regular expressions.
41+
cachedPatterns map[string]*regexp.Regexp
42+
}
43+
44+
func New(ctx context.Context, log slog.Logger, db database.Store, interval time.Duration) *Cache {
45+
if interval == 0 {
46+
interval = 5 * time.Minute
47+
}
48+
ctx, cancel := context.WithCancel(ctx)
49+
c := &Cache{
50+
ctx: ctx,
51+
db: db,
52+
log: log,
53+
cancel: cancel,
54+
interval: interval,
55+
56+
cachedPatterns: map[string]*regexp.Regexp{},
57+
}
58+
return c
59+
}
60+
61+
// ExecuteHostnamePattern is used to determine if a given hostname matches
62+
// any of the workspace proxy patterns. If it does, the subdomain for the app
63+
// is returned. If it does not, an empty string is returned with 'false'.
64+
func (c *Cache) ExecuteHostnamePattern(host string) (string, bool) {
65+
c.mu.RLock()
66+
defer c.mu.RUnlock()
67+
68+
for _, rg := range c.cachedPatterns {
69+
sub, ok := httpapi.ExecuteHostnamePattern(rg, host)
70+
if ok {
71+
return sub, ok
72+
}
73+
}
74+
return "", false
75+
}
76+
77+
func (c *Cache) run() {
78+
// Load the initial cache.
79+
c.updateCache()
80+
ticker := time.NewTicker(c.interval)
81+
pprof.Do(c.ctx, pprof.Labels("service", "proxy-cache"), func(ctx context.Context) {
82+
for {
83+
select {
84+
case <-ticker.C:
85+
c.updateCache()
86+
case <-c.ctx.Done():
87+
return
88+
}
89+
}
90+
})
91+
}
92+
93+
// ForceUpdate can be called externally to force an update of the cache.
94+
// The regular update interval will still be used.
95+
func (c *Cache) ForceUpdate() {
96+
c.updateCache()
97+
}
98+
99+
// updateCache is used to update the cache with the latest values from the database.
100+
func (c *Cache) updateCache() {
101+
c.mu.Lock()
102+
defer c.mu.Unlock()
103+
104+
proxies, err := c.db.GetWorkspaceProxies(dbauthz.AsSystemRestricted(c.ctx))
105+
if err != nil {
106+
c.log.Error(c.ctx, "failed to get workspace proxies", slog.Error(err))
107+
return
108+
}
109+
110+
c.cachedValues = proxies
111+
112+
keep := make(map[string]struct{})
113+
for _, p := range proxies {
114+
if p.WildcardHostname == "" {
115+
// It is possible some moons do not support subdomain apps.
116+
continue
117+
}
118+
119+
keep[p.WildcardHostname] = struct{}{}
120+
if _, ok := c.cachedPatterns[p.WildcardHostname]; ok {
121+
// pattern is already cached
122+
continue
123+
}
124+
125+
rg, err := httpapi.CompileHostnamePattern(p.WildcardHostname)
126+
if err != nil {
127+
c.log.Error(c.ctx, "failed to compile workspace proxy pattern",
128+
slog.Error(err),
129+
slog.F("proxy_id", p.ID),
130+
slog.F("proxy_name", p.Name),
131+
slog.F("proxy_hostname", p.WildcardHostname),
132+
)
133+
continue
134+
}
135+
c.cachedPatterns[p.WildcardHostname] = rg
136+
}
137+
138+
// Remove any excess patterns
139+
for k := range c.cachedPatterns {
140+
if _, ok := keep[k]; !ok {
141+
delete(c.cachedPatterns, k)
142+
}
143+
}
144+
}
145+
146+
func (c *Cache) Close() {
147+
c.cancel()
148+
}

coderd/workspaceapps.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
8383

8484
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
8585
// valid app subdomain.
86-
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
86+
subdomain, ok := api.executeHostnamePattern(u.Host)
8787
if !ok {
8888
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
8989
Message: "The redirect_uri query parameter must be a valid app subdomain.",
@@ -141,3 +141,14 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
141141
u.RawQuery = q.Encode()
142142
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
143143
}
144+
145+
// executeHostnamePattern will check if a hostname is a valid subdomain based
146+
// app. First it checks the primary's hostname, then checks if the hostname
147+
// is valid for any workspace proxy domain.
148+
func (api *API) executeHostnamePattern(hostname string) (string, bool) {
149+
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, hostname)
150+
if ok {
151+
return subdomain, true
152+
}
153+
return api.ProxyCache.ExecuteHostnamePattern(hostname)
154+
}

enterprise/coderd/workspaceproxy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
100100
Proxy: convertProxy(proxy),
101101
ProxyToken: fullToken,
102102
})
103+
104+
// Force update the proxy cache to ensure the new proxy is available.
105+
api.AGPL.ProxyCache.ForceUpdate()
103106
}
104107

105108
// nolint:revive

0 commit comments

Comments
 (0)