Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ac5a682
docs(config): document allowed_origins and websocket.idle_timeout_sec…
OliverWolffGIP Jul 21, 2026
fee2b91
docs(architecture): add gRPC trailers-only rejection mapping and WS/g…
OliverWolffGIP Jul 21, 2026
f96ee67
docs: add WS/gRPC ADRs, plan-05 rationale, LogRecords, and README sweep
OliverWolffGIP Jul 21, 2026
c7bb534
refactor(edge): remove orphaned JAX-RS GatewayExceptionMapper
OliverWolffGIP Jul 21, 2026
66605a1
docs(plan): record verify-green-while-errored finding (build-wrapper,…
OliverWolffGIP Jul 21, 2026
84be633
feat(integration-tests): in-repo Quarkus gRPC echo upstream (delivera…
OliverWolffGIP Jul 21, 2026
76ad275
test(integration-tests): unit-test the gRPC echo service (deliverable 9)
OliverWolffGIP Jul 21, 2026
223a51c
feat(gateway): add WebSocket configuration model and fail-closed vali…
OliverWolffGIP Jul 21, 2026
6b371b9
feat(gateway): serve WebSocket routes with opaque relay and Origin gate
OliverWolffGIP Jul 21, 2026
0c368d5
feat(gateway): add gRPC ProtocolProcessor with forced-h2 upstream, tr…
OliverWolffGIP Jul 21, 2026
d8ee7e6
docs(user): add operator guide for WebSocket and gRPC protocol routes
OliverWolffGIP Jul 21, 2026
21eeaa5
docs(development): add contributor guide for the ProtocolProcessor SP…
OliverWolffGIP Jul 21, 2026
800595d
test(integration-tests): WebSocket proxy IT + test routes (deliverabl…
OliverWolffGIP Jul 21, 2026
dabcc44
test(integration-tests): gRPC proxy IT + test routes (deliverable 11)
OliverWolffGIP Jul 21, 2026
675d76b
test(integration-tests): rework gRPC routes to bare-service-path mode…
OliverWolffGIP Jul 21, 2026
e7b7b7d
test(integration-tests): give the bearer gRPC route a distinct Secure…
OliverWolffGIP Jul 21, 2026
3ac1254
feat(benchmarks): add k6 WebSocket + gRPC aspects and APISIX parity r…
OliverWolffGIP Jul 21, 2026
95da672
test(benchmarks): assert ws + grpc aspects render in the comparison s…
OliverWolffGIP Jul 21, 2026
212c985
fix(integration-tests): skip javadoc jar for IT coordinator (delivera…
OliverWolffGIP Jul 21, 2026
fd49487
style: apply OpenRewrite pre-commit auto-fixes to plan-authored WS/gR…
OliverWolffGIP Jul 21, 2026
4ccbd8a
fix(integration-tests): give WS/gRPC endpoints a resolvable public au…
OliverWolffGIP Jul 21, 2026
d05cc76
fix(config): exempt gRPC routes from anchor-namespace containment
OliverWolffGIP Jul 21, 2026
cb4b0f9
fix(review): apply PR #88 review and sonar triage fixes
OliverWolffGIP Jul 21, 2026
cd3cc6f
fix(gateway): preserve stage-0 security headers on WebSocket handshak…
OliverWolffGIP Jul 21, 2026
f0206e3
fix(integration-tests): scope log chmod 0777 to a dedicated subdirectory
OliverWolffGIP Jul 21, 2026
195a2ec
chore(simplify): hoist RouteTable locals in RouteRuntimeAssemblerTest
OliverWolffGIP Jul 21, 2026
fbf4cdf
fix(gateway): honor route upstream.path in forward-URI reconstruction
OliverWolffGIP Jul 21, 2026
535244a
fix(review): apply round-2 review and sonar triage fixes
OliverWolffGIP Jul 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
* {@code ApiSheriff} prefix and a stable numeric identifier, so they are greppable and
* assertable. This catalogue's identifier ranges are disjoint from
* {@link de.cuioss.sheriff.api.config.ConfigLogMessages}'s (the boot-time configuration
* subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1-2} /
* {@code 100} / {@code 103-104} here vs {@code 2-3} / {@code 101-102} / {@code 200-201}
* subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} /
* {@code 100} / {@code 103-106} here vs {@code 2-3} / {@code 101-102} / {@code 200-201}
* there — never renumber one catalogue without checking the other for a collision.
* Security-relevant {@code WARN}s record only the failure <em>type</em> and route id —
* never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger
Expand All @@ -54,6 +54,13 @@ public static final class INFO {
.identifier(1)
.template("Route table compiled: %s route runtime(s) assembled")
.build();

/** A WebSocket upgrade was accepted and the opaque bidirectional relay was established. */
public static final LogRecord WEBSOCKET_RELAY_ESTABLISHED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(4)
.template("WebSocket relay established for route '%s'")
.build();
}

/**
Expand Down Expand Up @@ -85,5 +92,23 @@ public static final class WARN {
.identifier(104)
.template("Circuit breaker closed for upstream '%s'")
.build();

/**
* A WebSocket upgrade was rejected by the Origin allowlist (GW-09 / CSWSH). Records the
* route id and the rejection disposition ({@code absent} or {@code foreign}) only — the raw
* offending {@code Origin} value is never logged.
*/
public static final LogRecord WEBSOCKET_ORIGIN_REJECTED = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(105)
.template("WebSocket upgrade rejected on route '%s': %s origin")
.build();

/** An established WebSocket relay was reclaimed after exceeding its idle timeout. */
public static final LogRecord WEBSOCKET_IDLE_RECLAIM = LogRecordModel.builder()
.prefix(PREFIX)
.identifier(106)
.template("WebSocket relay on route '%s' reclaimed after idle timeout of %s seconds")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;


import de.cuioss.sheriff.api.config.model.AccessLevel;
Expand All @@ -43,6 +46,7 @@
import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
import de.cuioss.sheriff.api.config.model.UpstreamConfig;
import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
import de.cuioss.sheriff.api.config.model.WebSocketConfig;
import de.cuioss.tools.logging.CuiLogger;

/**
Expand All @@ -54,8 +58,10 @@
* {@code path_prefix} length (most specific first), and
* materializes each route's effective auth, effective {@code allowed_methods},
* effective {@code security_filter} / {@code security_headers}, effective retry
* / not-modified toggles, and the effective deny-by-default {@code forward}
* allowlist into a {@link ResolvedRoute}. The inheritance chains
* / not-modified toggles, the effective deny-by-default {@code forward}
* allowlist, and the effective upstream base path (the route-level
* {@code upstream.path} replacing the alias-derived base path when declared)
* into a {@link ResolvedRoute}. The inheritance chains
* (gateway defaults → anchor → endpoint → route, wholesale replacement at every
* step — ADR-0007) are resolved here, once, so the request pipeline never
* re-implements them and never consults an anchor. The effective posture of each
Expand All @@ -78,6 +84,9 @@ public final class RouteTableBuilder {
* display fallback for an absent anchor name / security-filter profile in {@link #logPosture}. */
private static final String NONE = "none";

/** The default {@code websocket.idle_timeout_seconds} applied when a WebSocket route omits it. */
private static final int DEFAULT_WEBSOCKET_IDLE_TIMEOUT_SECONDS = 300;

/**
* Builds the route table from the enabled endpoints and the resolved topology.
*
Expand Down Expand Up @@ -160,9 +169,15 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou
.flatMap(UpstreamConfig.NotModified::enabled)
.orElse(defaults.notModifiedEnabled());
ForwardConfig effectiveForward = route.forward().orElseGet(() -> ForwardConfig.builder().build());
Protocol protocol = route.protocol().orElse(Protocol.HTTP);
Set<String> allowedOrigins = effectiveAllowedOrigins(route);
Optional<Integer> idleTimeout = protocol == Protocol.WEBSOCKET
? Optional.of(route.websocket().flatMap(WebSocketConfig::idleTimeoutSeconds)
.orElse(DEFAULT_WEBSOCKET_IDLE_TIMEOUT_SECONDS))
: Optional.empty();
ResolvedRoute.ResolvedRouteBuilder builder = ResolvedRoute.builder()
.id(route.id())
.protocol(route.protocol().orElse(Protocol.HTTP))
.protocol(protocol)
.anchor(anchor.map(AnchorConfig::name))
.match(route.match())
.effectiveAuth(auth)
Expand All @@ -171,21 +186,50 @@ private static ResolvedRoute resolveRoute(GatewayConfig gateway, RouteConfig rou
.effectiveSecurityHeaders(securityHeaders)
.retryEnabled(retryEnabled)
.notModifiedEnabled(notModifiedEnabled)
.effectiveForward(effectiveForward);
.effectiveForward(effectiveForward)
.effectiveAllowedOrigins(allowedOrigins)
.effectiveWebSocketIdleTimeoutSeconds(idleTimeout);
// A route resolves to exactly one terminal action: an asset action (when the route
// declares an asset block) is materialized here; otherwise the route proxies to its
// endpoint upstream. ADR-0014: upstream XOR asset.
Optional<AssetConfig> asset = route.asset();
if (asset.isPresent()) {
builder.asset(Optional.of(resolveAsset(route, asset.get(), anchor, auth, topology)));
} else {
builder.upstream(Optional.of(upstream));
builder.upstream(Optional.of(applyRouteUpstreamPath(upstream, route)));
}
ResolvedRoute resolved = builder.build();
logPosture(resolved);
return resolved;
}

/**
* Materializes the route-level {@code upstream.path} into the route's effective upstream base
* path. A route that declares a non-blank {@code upstream.path} <em>replaces</em> the
* alias-derived base path with it (the bare-service-path routing model): the forward URI is
* then reconstructed as {@code stripTrailingSlash(upstream.path) + remainder-after-prefix} by
* {@link de.cuioss.sheriff.api.edge.DispatchStage#upstreamRequestUri}, so a gRPC route's
* {@code /{package}.{Service}} segment (and a benchmark route's {@code /anything/<aspect>}
* rewrite) reaches the upstream instead of being stripped. The alias host / port / scheme are
* carried through unchanged, so the client- and guard-sharing tuple
* ({@link de.cuioss.sheriff.api.edge.RouteRuntimeAssembler.UpstreamTarget}, keyed on
* scheme/host/port) is unaffected. A route without {@code upstream.path} keeps the
* alias-derived base path unchanged — the default proxy behavior.
*
* @param aliasUpstream the endpoint's alias-resolved upstream (shared across the endpoint's
* routes)
* @param route the route whose optional {@code upstream.path} overrides the base path
* @return the per-route upstream carrying the effective base path
*/
private static ResolvedUpstream applyRouteUpstreamPath(ResolvedUpstream aliasUpstream, RouteConfig route) {
return route.upstream()
.flatMap(UpstreamConfig::path)
.filter(path -> !path.isBlank())
.map(path -> new ResolvedUpstream(aliasUpstream.scheme(), aliasUpstream.host(),
aliasUpstream.port(), path))
.orElse(aliasUpstream);
}

/**
* Materializes a route's asset terminal action (ADR-0014). A {@code directory}
* source carries its configured root; an {@code upstream} source resolves its
Expand Down Expand Up @@ -280,6 +324,21 @@ private static List<HttpMethod> effectiveAllowedMethods(GatewayConfig gateway, E
return STANDARD_ALLOWED_METHODS;
}

/**
* The materialized WebSocket {@code allowed_origins} allowlist, lower-cased once at
* assembly for case-insensitive host matching (scheme and port are already
* case-insensitive). Iteration order is not significant — origin acceptance is an
* exact-membership test, so the set may be defensively re-copied downstream without
* any ordering guarantee. Empty for a route that declares no {@code websocket} block.
*/
private static Set<String> effectiveAllowedOrigins(RouteConfig route) {
Set<String> origins = new LinkedHashSet<>();
for (String origin : route.websocket().map(WebSocketConfig::allowedOrigins).orElseGet(List::of)) {
origins.add(origin.toLowerCase(Locale.ROOT));
}
return origins;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Signals a route-table assembly failure: an enabled endpoint whose alias does
* not resolve, or a route with no resolvable effective auth. Both are boot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import lombok.Builder;

Expand Down Expand Up @@ -64,6 +65,13 @@
* @param effectiveForward the materialized, deny-by-default {@code forward}
* allowlist consumed by stage 5; an empty
* {@link ForwardConfig} when the route declares none
* @param effectiveAllowedOrigins the materialized, lower-cased exact-match
* {@code Origin} allowlist for a WebSocket route,
* empty for a non-WebSocket route (meaningful only when
* {@code protocol} is {@link Protocol#WEBSOCKET})
* @param effectiveWebSocketIdleTimeoutSeconds the materialized idle timeout for a
* WebSocket route with the {@code 300}-second default
* applied, empty for a non-WebSocket route
* @author API Sheriff Team
* @since 1.0
*/
Expand All @@ -72,15 +80,16 @@ public record ResolvedRoute(String id, Protocol protocol, Optional<String> ancho
AuthConfig effectiveAuth, List<HttpMethod> effectiveAllowedMethods,
Optional<SecurityFilterConfig> effectiveSecurityFilter, Optional<SecurityHeadersConfig> effectiveSecurityHeaders,
boolean retryEnabled, boolean notModifiedEnabled, Optional<ResolvedUpstream> upstream, Optional<ResolvedAsset> asset,
ForwardConfig effectiveForward) {
ForwardConfig effectiveForward, Set<String> effectiveAllowedOrigins,
Optional<Integer> effectiveWebSocketIdleTimeoutSeconds) {

/**
* Canonical constructor requiring the mandatory components, defensively copying
* {@code effectiveAllowedMethods}, normalizing absent optionals, defaulting an
* absent {@code protocol} to {@link Protocol#HTTP}, defaulting an absent
* {@code effectiveForward} to a deny-by-default empty {@link ForwardConfig}, and
* enforcing the terminal-action invariant: exactly one of {@code upstream}
* (proxy) or {@code asset} resolves.
* {@code effectiveAllowedMethods} and {@code effectiveAllowedOrigins}, normalizing
* absent optionals, defaulting an absent {@code protocol} to {@link Protocol#HTTP},
* defaulting an absent {@code effectiveForward} to a deny-by-default empty
* {@link ForwardConfig}, and enforcing the terminal-action invariant: exactly one
* of {@code upstream} (proxy) or {@code asset} resolves.
*/
public ResolvedRoute {
Objects.requireNonNull(id, "id");
Expand All @@ -98,6 +107,9 @@ public record ResolvedRoute(String id, Protocol protocol, Optional<String> ancho
"route '" + id + "' must resolve exactly one terminal action (upstream XOR asset)");
}
effectiveForward = effectiveForward == null ? ForwardConfig.builder().build() : effectiveForward;
effectiveAllowedOrigins = effectiveAllowedOrigins == null ? Set.of() : Set.copyOf(effectiveAllowedOrigins);
effectiveWebSocketIdleTimeoutSeconds = Objects.requireNonNullElse(effectiveWebSocketIdleTimeoutSeconds,
Optional.empty());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@
* route carries at most one terminal action, so {@code asset}
* and {@code upstream} are mutually exclusive (ADR-0014)
* @param rateLimit the reserved rate-limit block, empty when omitted
* @param websocket the per-route WebSocket settings ({@code allowed_origins},
* {@code idle_timeout_seconds}), empty for non-WebSocket routes
* @author API Sheriff Team
* @since 1.0
*/
@Builder
public record RouteConfig(String id, Optional<Protocol> protocol, Optional<String> anchor, MatchConfig match,
Optional<AuthConfig> auth, Optional<SecurityFilterConfig> securityFilter, Optional<ForwardConfig> forward,
Optional<UpstreamConfig> upstream, Optional<AssetConfig> asset, Optional<RateLimitConfig> rateLimit) {
Optional<UpstreamConfig> upstream, Optional<AssetConfig> asset, Optional<RateLimitConfig> rateLimit,
Optional<WebSocketConfig> websocket) {

/**
* Canonical constructor requiring {@code id} and {@code match} and normalizing
Expand All @@ -67,5 +70,6 @@ public record RouteConfig(String id, Optional<Protocol> protocol, Optional<Strin
upstream = Objects.requireNonNullElse(upstream, Optional.empty());
asset = Objects.requireNonNullElse(asset, Optional.empty());
rateLimit = Objects.requireNonNullElse(rateLimit, Optional.empty());
websocket = Objects.requireNonNullElse(websocket, Optional.empty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.cuioss.sheriff.api.config.model;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

import lombok.Builder;

/**
* The per-route {@code websocket} block carried by a {@code protocol: websocket}
* route.
* <p>
* {@code allowedOrigins} is a fail-closed, deny-by-default allowlist of exact-match
* {@code Origin} strings (scheme + host + port, no wildcards); an absent or empty
* allowlist on a bearer WebSocket route rejects the upgrade at boot — there is no
* "any origin" default. Host matching is case-insensitive; the effective allowlist
* is lower-cased once, at route-table assembly. {@code idleTimeoutSeconds} bounds an
* established relay ("idle" = no frame in either direction, ping/pong counting as
* activity); it defaults to {@code 300} when absent, applied at resolution.
*
* @param allowedOrigins the exact-match, case-insensitive-host origin allowlist,
* empty when none is declared
* @param idleTimeoutSeconds the per-route idle timeout in seconds, empty when the
* default applies
* @author API Sheriff Team
* @since 1.0
*/
@Builder
public record WebSocketConfig(List<String> allowedOrigins, Optional<Integer> idleTimeoutSeconds) {

/**
* Canonical constructor defensively copying {@code allowedOrigins} into an
* unmodifiable copy, normalizing an absent list to empty and an absent
* {@code idleTimeoutSeconds} to {@link Optional#empty()}.
*/
public WebSocketConfig {
allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins);
idleTimeoutSeconds = Objects.requireNonNullElse(idleTimeoutSeconds, Optional.empty());
}
}
Loading
Loading