Repository navigation
Expand file tree
/
Copy pathembedded_site.go
More file actions
131 lines (120 loc) · 5.83 KB
/
Copy pathembedded_site.go
File metadata and controls
131 lines (120 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// Copyright (c) 2026 Super Durable, Inc.
//
// Licensed under the Sustainable Use License 1.0.
// You may not use this file except in compliance with the License.
// See the LICENSE file in the repository root.
//
// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
package web
import (
"io/fs"
"log/slog"
"net/http"
"github.com/superdurable/dex/sdk-go/gen/dexpb"
"github.com/superdurable/dex/web/api"
)
// EmbeddedSiteConfig configures Dex Web pages that a host HTTP server serves for many Dex Servers.
// It is read once by NewEmbeddedSite.
type EmbeddedSiteConfig struct {
// FrameAncestors defaults empty, which forbids framing. It has the same meaning as Config.FrameAncestors.
FrameAncestors []string
// AllowedHosts defaults empty, which accepts every Host because the host server owns Host checks. When it has
// an entry, /api requests must name a loopback name, an IP address, or one of these hosts; an entry may carry
// a port, which is ignored. Other Hosts get HTTP 403.
AllowedHosts []string
}
// EmbeddedSiteTarget is the Dex Server that one request reaches. Dex Web keeps no part of it after the request.
type EmbeddedSiteTarget struct {
// FlowServiceClient is required and already carries the credentials for the Dex Server.
FlowServiceClient dexpb.FlowServiceClient
// BasePath is the URL path prefix of this Dex Web, such as "/dex-web/server-a"; ValidateBasePath lists its form.
BasePath string
// FlowRenderingDirectory defaults empty, meaning no Flow Definition Graph files; otherwise Dex Web reads the
// JSON files under it on every definition request.
//
// Dex Web also reads the definitions that Workers uploaded to the Dex Server through FlowServiceClient,
// so a target needs no directory for them. A Flow type that a file in this directory declares comes only
// from the directory. Changed in 1.45.0: Dex Web reads uploaded definitions.
FlowRenderingDirectory string
}
// EmbeddedSite serves Dex Web for a target chosen per request. It shares only immutable pages between targets.
type EmbeddedSite struct {
pages *sitePages
protection apiRequestProtection
}
// NewEmbeddedSite builds the shared pages from assets, whose "dist" directory holds the built SPA.
// It returns an error when cfg.FrameAncestors is invalid.
func NewEmbeddedSite(cfg *EmbeddedSiteConfig, assets fs.FS) (*EmbeddedSite, error) {
if cfg == nil {
panic("embedded Web config must not be nil")
}
pages, err := newSitePages(assets, cfg.FrameAncestors)
if err != nil {
return nil, err
}
return &EmbeddedSite{pages: pages, protection: newAPIRequestProtection(newAllowedHostsPolicy(cfg.AllowedHosts))}, nil
}
// SetFramingHeaders sets the Content-Security-Policy frame-ancestors and X-Frame-Options headers that
// FrameAncestors chooses, and removes an X-Frame-Options that the list does not allow. Call it before writing a
// Dex Web response that ServeTarget does not write, such as the default resolver error. Never call it for a
// page that the host writes, because it replaces the host's Content-Security-Policy.
func (site *EmbeddedSite) SetFramingHeaders(header http.Header) {
site.pages.framePolicy.apply(header)
}
// ServeTarget serves one request against target. Routes, caches, and readiness state live only for this request.
// The probes /healthz and /readyz are served under target.BasePath and report only this target. Every response
// carries the framing headers.
func (site *EmbeddedSite) ServeTarget(response http.ResponseWriter, request *http.Request, target EmbeddedSiteTarget) {
site.SetFramingHeaders(response.Header())
if target.FlowServiceClient == nil {
slog.Error("Dex Web target has no FlowService client")
api.WriteCodedError(response, http.StatusInternalServerError, "TARGET_INVALID", "Dex Web target is invalid")
return
}
basePath, err := ValidateBasePath(target.BasePath)
if err != nil {
slog.Error("Dex Web target has an invalid base path", "error", err)
api.WriteCodedError(response, http.StatusInternalServerError, "TARGET_INVALID", "Dex Web target is invalid")
return
}
flowDefinitions, err := newFlowDefinitionSources(target.FlowRenderingDirectory, target.FlowServiceClient)
if err != nil {
slog.Error("Dex Web target has an invalid Flow rendering directory", "error", err)
api.WriteCodedError(response, http.StatusInternalServerError, "TARGET_INVALID", "Dex Web target is invalid")
return
}
routes := site.pages.newTargetRouteMux(targetRoutes{
client: target.FlowServiceClient, flowDefinitions: flowDefinitions, basePath: basePath,
})
serveUnderBasePath(response, request, basePath, site.protection.wrap(refuseWorkerHealthProbes(routes)))
}
// refuseWorkerHealthProbes keeps callers from making the host process dial addresses inside its network.
func refuseWorkerHealthProbes(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/api/v2/worker-health" {
api.WriteCodedError(response, http.StatusNotFound, "WORKER_HEALTH_UNAVAILABLE", "Worker health probes are not available in embedded Dex Web")
return
}
next.ServeHTTP(response, request)
})
}
// IsMutatingAPIRequest reports whether a request may change Flows or Connector configuration.
//
// apiPath is the request path relative to the Dex Web base path, such as "/api/flows/stop". Every method other
// than GET, HEAD, and OPTIONS mutates, except these read-only searches and loads:
// POST /api/flows/search, POST /api/v2/search, and POST /api/blobs/load.
// An unknown path with an unsafe method counts as mutating.
//
// It returns true when the request needs write permission.
func IsMutatingAPIRequest(method string, apiPath string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
case http.MethodPost:
switch apiPath {
case "/api/flows/search", "/api/v2/search", "/api/blobs/load":
return false
}
}
return true
}