Skip to main content

Embed Dex Web in a Go server

View as Markdown

Since 1.43. A host application can serve Dex Web from its own Go HTTP server. The Go module github.com/superdurable/dex/web/embedded returns one http.Handler that serves Dex Web for many Dex Servers. The host mounts it under a path prefix on its own origin, such as /dex-web/SERVER_ID/. For each request, the handler asks the host which Dex Server the request reaches. The host keeps its own sign-in, roles, and pages around Dex Web.

Use dex-web instead when one Dex Web per Dex Server behind a reverse proxy is enough.

Install​

go get github.com/superdurable/dex/web/embedded@v1.43.0

Each web/vX.Y.Z release publishes the module as web/embedded/vX.Y.Z. The module contains the built UI, so the host needs no Node.js build. It requires Go 1.26 or later and a released github.com/superdurable/dex/sdk-go version, which its go.mod names. Keep the host's sdk-go requirement at that version or later.

Mount the handler​

This host serves two Dex Servers at /dex-web/server-a/ and /dex-web/server-b/. It creates one gRPC connection per Dex Server at startup, adds each server's credential to its connection, and closes the connections at shutdown.

Go

type dexServer struct {
client dexpb.FlowServiceClient
token string
}

func main() {
servers := map[string]dexServer{}
for serverID, address := range map[string]string{
"server-a": "dex-a.internal.example:8801",
"server-b": "dex-b.internal.example:8801",
} {
token := loadToken(serverID)
connection, err := grpc.NewClient(address,
grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")),
grpc.WithUnaryInterceptor(bearerToken(token)))
if err != nil {

loadToken, currentUser, and requireSignIn stand for the host's own credential store, session lookup, and sign-in middleware. Dex Web calls only unary FlowService RPCs.

Targets​

ResolveTarget runs once for every request. It returns a Target:

FieldMeaning
FlowServiceClientRequired. The FlowService client of the Dex Server. It must already carry the credential.
BasePathRequired. The path prefix of this Dex Server's Dex Web, such as /dex-web/server-a. It follows the base path rules.
FlowRenderingDirectoryOptional. A directory of Flow Definition Graph JSON files for this Dex Server, read on every definition request, like --flow-rendering-dir. Since 1.45, Dex Web also reads the definitions uploaded to the Dex Server through FlowServiceClient, so uploaded definitions need no directory. A Flow type in the directory wins. See Definition sources.

The handler serves the pages, assets, /api routes, /healthz, and /readyz under BasePath. /readyz checks only the resolved Dex Server. A request outside BasePath gets HTTP 404, and so does a path with an empty, ., or .. segment. A missing client or an invalid BasePath is a host error and gets HTTP 500.

Return one of these errors, or an error that wraps one, to choose the response:

ErrorResponse
ErrTargetNotFoundHTTP 404 TARGET_NOT_FOUND
ErrTargetForbiddenHTTP 403 TARGET_FORBIDDEN
ErrTargetUnavailableHTTP 503 TARGET_UNAVAILABLE
Any other errorHTTP 500 TARGET_RESOLUTION_FAILED

The response body never contains the error text. The handler logs an unrecognized error through log/slog. Set WriteResolveError in Config to write the response yourself, such as a sign-in redirect. The handler does not add its framing headers to that response.

Credentials​

The host owns every client. It creates, caches, and closes them, and the handler never closes one or keeps it after the request. Add the credential with grpc.WithPerRPCCredentials or a client interceptor. Each RPC receives a context derived from the HTTP request context, so a credential that depends on the signed-in user can read a value that host middleware stored in that context. The credential never reaches the browser.

Security​

The handler does no authentication and no authorization. Anyone whose request reaches it acts with the credential of the client that ResolveTarget returns.

  • Authenticate every request before the handler runs.
  • Return a client only for a Dex Server the caller may use.
  • Refuse a mutating request from a read-only caller. IsMutatingRequest tells whether a request needs write permission. The table below lists the routes.
  • Mount Dex Web on the host's own origin. A mutating /api request must be same-origin, by the browser's Sec-Fetch-Site header or, without it, an Origin that matches the Host. It must also send Content-Type: application/json. A cross-site page cannot forge one, even when the host signs users in with cookies. Same-site requests from another origin, such as a sibling subdomain, are refused too.
  • Dex Web shows Flow inputs, outputs, Attributes, and Worker stack traces. Grant viewing only to people who may see that data.

Mutating routes​

Every GET route only reads. These routes, relative to BasePath, change Flows or depend on write access:

RouteAction
POST /api/flows/stopStop a run.
POST /api/flows/time-travelReset a run to an earlier point.
PUT /api/flows/attributesWrite an Attribute.
POST /api/flows/channels/messagesPublish a Channel message.
DELETE /api/flows/channels/messagesDelete a pending Channel message.
PUT /api/flows/config/historyChange a run's history window.
POST /api/v2/startStart a Flow.
PATCH /api/v2/displayEdit a displayed Attribute.
POST /api/v2/actionsInvoke an RPC.
POST /api/v2/agent-sessions/startStart a Dex Agents session.
POST /api/v2/agent-sessions/messagesSend a message to a Dex Agents session.
POST /api/v2/agent-sessions/settingsChange a Dex Agents session's settings.

These POST routes only read: /api/flows/search, /api/v2/search, and /api/blobs/load. IsMutatingRequest treats every other request with a method other than GET, HEAD, or OPTIONS as mutating, including routes added by later releases.

Host and framing checks​

Config.AllowedHosts is empty by default, because the host's ingress owns Host checks. With entries, it turns on the Host check for /api requests, without a bind address.

Config.FrameAncestors works like --frame-ancestors. By default no page may frame Dex Web. Pass 'self' to let the host's own pages show it in a frame. The handler sets the framing headers on every response that Dex Web writes, including redirects and the default resolver errors. These headers replace a Content-Security-Policy that host middleware set earlier. With a list, the handler also removes the host's X-Frame-Options header from these responses.

The handler leaves every header unchanged when WriteResolveError writes the response. The host's error page owns its own Content-Security-Policy and X-Frame-Options, so a sign-in page that host middleware protects stays protected, even when FrameAncestors allows framing.

Browser storage​

Dex Web stores recent and saved Flow searches in the browser's local storage. Under a base path, each key ends with @ and the base path, such as dex-web-recent-queries@/dex-web/server-a. Dex Servers served on one origin therefore never see each other's searches. At the root, Dex Web keeps the original key names. It does not copy searches stored at the root into a base path. Display preferences, such as the theme, time zone, page size, and panel widths, apply to every base path on the origin.

What the handler does not do​

  • It keeps no state between Dex Servers. Each request gets its own routes, definition reader, and server engine lookup, and two handlers share nothing.
  • Local Connector configuration is off. Its /api/v2/connector- routes return HTTP 404 CONNECTOR_SETUP_UNAVAILABLE, and the UI hides Connectors.
  • POST /api/v2/worker-health, which makes Dex Web dial a Worker address that the request names, returns HTTP 404 WORKER_HEALTH_UNAVAILABLE, so a caller cannot probe the host's network. The UI does not use it.
  • It ignores forwarded headers such as X-Forwarded-Prefix. The base path comes only from ResolveTarget.
  • It never uploads Flow definitions. The browser's /api routes only read them, with ListFlowDefinitions and GetFlowDefinitions, so the client needs read access to Flow definitions. Since 1.45.