diff --git a/README.md b/README.md index 33a6379..c03b0b8 100644 --- a/README.md +++ b/README.md @@ -234,3 +234,9 @@ the repository. ## License Apache-2.0. See [LICENSE](LICENSE). + +## HTTP CORS + +Use `.httpCors(new HttpCors(List.of("https://dashboard.example")))` on `CreateTunnelOptions.builder()`. The full `HttpCors` constructor accepts allowed origins, methods, request headers, exposed response headers, `allowCredentials`, and `maxAge` in seconds. Existing constructors remain available. + +CORS requires a published HTTP tunnel. Allowed origins must be explicit HTTP(S) origins without paths or wildcards. Omitted method and request-header lists use GET/HEAD/POST and Authorization/Content-Type; credentials are disabled and max age is zero by default. An absent policy preserves upstream CORS. Engine answers valid preflights before request authentication, but every actual request still requires its usual authorization. No filesystem or upstream access is granted by a preflight. Deploy an Engine supporting protocol patch 1.4.6 before using this SDK version; the new Engine remains compatible with supported older clients. diff --git a/src/main/java/io/rstream/ControlChannel.java b/src/main/java/io/rstream/ControlChannel.java index 8e11923..c8564fb 100644 --- a/src/main/java/io/rstream/ControlChannel.java +++ b/src/main/java/io/rstream/ControlChannel.java @@ -502,6 +502,12 @@ private ScheduledFuture operationTimeout( } private static TunnelProperties normalizeBytestreamOptions(CreateTunnelOptions options) { + if (options.httpCors() != null + && (Boolean.FALSE.equals(options.publish()) + || options.protocol() != null && options.protocol() != TunnelProtocol.HTTP)) { + throw new RstreamException( + "CORS requires a published HTTP tunnel.", "ERR_RSTREAM_INVALID_TUNNEL_OPTIONS"); + } if (options.httpVersion() == HttpVersion.H3) { throw new UnsupportedFeatureException( "HTTP/3 tunnels require datagram support, which rstream-java does not support.", @@ -568,6 +574,7 @@ private static TunnelProperties normalizeBytestreamOptions(CreateTunnelOptions o .tlsCiphers(options.tlsCiphers()) .mtlsAuth(options.mtlsAuth()) .httpVersion(options.httpVersion()) + .httpCors(options.httpCors()) .tokenAuth(tokenAuth) .rstreamAuth(rstreamAuth) .challengeMode(challengeMode) diff --git a/src/main/java/io/rstream/CreateTunnelOptions.java b/src/main/java/io/rstream/CreateTunnelOptions.java index 5a48885..440c936 100644 --- a/src/main/java/io/rstream/CreateTunnelOptions.java +++ b/src/main/java/io/rstream/CreateTunnelOptions.java @@ -25,7 +25,8 @@ public record CreateTunnelOptions( Integer port, Boolean upstreamTls, Boolean allowCrossRegionRouting, - TunnelAuth auth) { + TunnelAuth auth, + HttpCors httpCors) { public CreateTunnelOptions { labels = labels == null ? Map.of() : Map.copyOf(labels); geoIp = geoIp == null ? List.of() : List.copyOf(geoIp); @@ -34,6 +35,54 @@ public record CreateTunnelOptions( tlsCiphers = tlsCiphers == null ? List.of() : List.copyOf(tlsCiphers); } + /** Preserves the constructor used before HTTP CORS was introduced. */ + public CreateTunnelOptions( + String name, + TunnelType type, + Boolean publish, + TunnelProtocol protocol, + Map labels, + List geoIp, + List trustedIps, + TlsMode tlsMode, + List tlsAlpns, + String tlsMinVersion, + List tlsCiphers, + Boolean mtlsAuth, + HttpVersion httpVersion, + Boolean tokenAuth, + Boolean rstreamAuth, + Boolean challengeMode, + String hostname, + Integer port, + Boolean upstreamTls, + Boolean allowCrossRegionRouting, + TunnelAuth auth) { + this( + name, + type, + publish, + protocol, + labels, + geoIp, + trustedIps, + tlsMode, + tlsAlpns, + tlsMinVersion, + tlsCiphers, + mtlsAuth, + httpVersion, + tokenAuth, + rstreamAuth, + challengeMode, + hostname, + port, + upstreamTls, + allowCrossRegionRouting, + auth, + null); + } + public static Builder builder() { return new Builder(); } @@ -43,6 +92,7 @@ public static CreateTunnelOptions defaults() { } public static final class Builder { + private HttpCors httpCors; private String name; private TunnelType type; private Boolean publish; @@ -170,6 +220,11 @@ public Builder auth(TunnelAuth auth) { return this; } + public Builder httpCors(HttpCors httpCors) { + this.httpCors = httpCors; + return this; + } + public CreateTunnelOptions build() { return new CreateTunnelOptions( name, @@ -192,7 +247,8 @@ public CreateTunnelOptions build() { port, upstreamTls, allowCrossRegionRouting, - auth); + auth, + httpCors); } } } diff --git a/src/main/java/io/rstream/HttpCors.java b/src/main/java/io/rstream/HttpCors.java new file mode 100644 index 0000000..9a041e1 --- /dev/null +++ b/src/main/java/io/rstream/HttpCors.java @@ -0,0 +1,23 @@ +package io.rstream; + +import java.util.List; + +/** Browser access policy for a published HTTP tunnel; maxAge is in seconds. */ +public record HttpCors( + List allowedOrigins, + List allowedMethods, + List allowedHeaders, + List exposedHeaders, + boolean allowCredentials, + long maxAge) { + public HttpCors { + allowedOrigins = List.copyOf(allowedOrigins); + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + allowedHeaders = allowedHeaders == null ? List.of() : List.copyOf(allowedHeaders); + exposedHeaders = exposedHeaders == null ? List.of() : List.copyOf(exposedHeaders); + } + + public HttpCors(List allowedOrigins) { + this(allowedOrigins, List.of(), List.of(), List.of(), false, 0); + } +} diff --git a/src/main/java/io/rstream/Protocol.java b/src/main/java/io/rstream/Protocol.java index 8a49a58..97cd5bc 100644 --- a/src/main/java/io/rstream/Protocol.java +++ b/src/main/java/io/rstream/Protocol.java @@ -155,6 +155,17 @@ static Rstream.TunnelProperties tunnelPropertiesToPb(TunnelProperties properties builder.setDatagramGuaranteedDelivery(boolValue(properties.datagramGuaranteedDelivery())); if (properties.allowCrossRegionRouting() != null) builder.setAllowCrossRegionRouting(boolValue(properties.allowCrossRegionRouting())); + if (properties.httpCors() != null) { + var config = properties.httpCors(); + builder.setHttpCors( + Rstream.HttpCors.newBuilder() + .addAllAllowedOrigins(config.allowedOrigins()) + .addAllAllowedMethods(config.allowedMethods()) + .addAllAllowedHeaders(config.allowedHeaders()) + .addAllExposedHeaders(config.exposedHeaders()) + .setAllowCredentials(config.allowCredentials()) + .setMaxAge(config.maxAge())); + } return builder.build(); } @@ -189,7 +200,18 @@ static TunnelProperties tunnelPropertiesFromPb(Rstream.TunnelProperties properti wrapperBool( properties.hasDatagramGuaranteedDelivery(), properties.getDatagramGuaranteedDelivery()), wrapperBool( - properties.hasAllowCrossRegionRouting(), properties.getAllowCrossRegionRouting())); + properties.hasAllowCrossRegionRouting(), properties.getAllowCrossRegionRouting()), + properties.hasHttpCors() ? httpCorsFromPb(properties.getHttpCors()) : null); + } + + private static HttpCors httpCorsFromPb(Rstream.HttpCors config) { + return new HttpCors( + config.getAllowedOriginsList(), + config.getAllowedMethodsList(), + config.getAllowedHeadersList(), + config.getExposedHeadersList(), + config.getAllowCredentials(), + config.getMaxAge()); } static ServerDetails serverDetailsFromPb(Rstream.ServerDetails details) { diff --git a/src/main/java/io/rstream/TunnelProperties.java b/src/main/java/io/rstream/TunnelProperties.java index 199bb7a..86da33b 100644 --- a/src/main/java/io/rstream/TunnelProperties.java +++ b/src/main/java/io/rstream/TunnelProperties.java @@ -30,7 +30,8 @@ public record TunnelProperties( Integer port, Boolean upstreamTls, Boolean datagramGuaranteedDelivery, - Boolean allowCrossRegionRouting) { + Boolean allowCrossRegionRouting, + HttpCors httpCors) { public TunnelProperties { labels = labels == null ? Map.of() : Map.copyOf(labels); geoIp = geoIp == null ? List.of() : List.copyOf(geoIp); @@ -39,11 +40,68 @@ public record TunnelProperties( tlsCiphers = tlsCiphers == null ? List.of() : List.copyOf(tlsCiphers); } + /** Preserves the constructor used before HTTP CORS was introduced. */ + public TunnelProperties( + String id, + Instant creationDate, + String name, + TunnelType type, + Boolean publish, + TunnelProtocol protocol, + Map labels, + List geoIp, + List trustedIps, + String host, + TlsMode tlsMode, + List tlsAlpns, + String tlsMinVersion, + List tlsCiphers, + Boolean mtlsAuth, + HttpVersion httpVersion, + Boolean httpUseTls, + Boolean tokenAuth, + Boolean rstreamAuth, + Boolean challengeMode, + String hostname, + Integer port, + Boolean upstreamTls, + Boolean datagramGuaranteedDelivery, + Boolean allowCrossRegionRouting) { + this( + id, + creationDate, + name, + type, + publish, + protocol, + labels, + geoIp, + trustedIps, + host, + tlsMode, + tlsAlpns, + tlsMinVersion, + tlsCiphers, + mtlsAuth, + httpVersion, + httpUseTls, + tokenAuth, + rstreamAuth, + challengeMode, + hostname, + port, + upstreamTls, + datagramGuaranteedDelivery, + allowCrossRegionRouting, + null); + } + public static Builder builder() { return new Builder(); } public static final class Builder { + private HttpCors httpCors; private String id; private Instant creationDate; private String name; @@ -195,6 +253,11 @@ public Builder allowCrossRegionRouting(Boolean allowCrossRegionRouting) { return this; } + public Builder httpCors(HttpCors httpCors) { + this.httpCors = httpCors; + return this; + } + public TunnelProperties build() { return new TunnelProperties( id, @@ -221,7 +284,8 @@ public TunnelProperties build() { port, upstreamTls, datagramGuaranteedDelivery, - allowCrossRegionRouting); + allowCrossRegionRouting, + httpCors); } } } diff --git a/src/main/proto/rstream.proto b/src/main/proto/rstream.proto index 84aa301..0e098cf 100644 --- a/src/main/proto/rstream.proto +++ b/src/main/proto/rstream.proto @@ -14,7 +14,7 @@ extend google.protobuf.FieldOptions { string access = 51234; } -option (protocol_version) = "1.4.5"; +option (protocol_version) = "1.4.6"; package rstream.io_rstrm.protobuf; @@ -64,6 +64,7 @@ enum ErrorCode { // 7000-7999: Runtime capacity exhausted ERROR_CODE_CAPACITY_EXHAUSTED = 7000; + ERROR_CODE_RESOURCE_CONFLICT = 8000; // 9000-9999: Internal errors ERROR_CODE_INTERNAL = 9000; @@ -102,6 +103,16 @@ message ServerDetails { // Tunnel message structure used for creating and managing tunnels +// Optional browser access policy for a published HTTP tunnel. +message HttpCors { + repeated string allowed_origins = 1; + repeated string allowed_methods = 2; + repeated string allowed_headers = 3; + repeated string exposed_headers = 4; + bool allow_credentials = 5; + int64 max_age = 6; +} + message TunnelProperties { google.protobuf.StringValue id = 1 [(access) = "read-only"]; google.protobuf.Timestamp creation_date = 2 [(access) = "read-only"]; @@ -129,6 +140,7 @@ message TunnelProperties { google.protobuf.BoolValue upstream_tls = 24 [(access) = "read-write"]; google.protobuf.BoolValue datagram_guaranteed_delivery = 25 [(access) = "read-write"]; google.protobuf.BoolValue allow_cross_region_routing = 26 [(access) = "read-write"]; + HttpCors http_cors = 27 [(access) = "read-write"]; } // When a client opens a new control channel to the server diff --git a/src/test/java/io/rstream/HttpCorsTest.java b/src/test/java/io/rstream/HttpCorsTest.java new file mode 100644 index 0000000..5c5c209 --- /dev/null +++ b/src/test/java/io/rstream/HttpCorsTest.java @@ -0,0 +1,44 @@ +package io.rstream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import rstream.io_rstrm.protobuf.Rstream; + +final class HttpCorsTest { + @Test + void policyRoundTripsThroughWire() throws Exception { + var config = + new HttpCors( + List.of("https://dashboard.example"), + List.of("GET", "PUT"), + List.of("Authorization"), + List.of("ETag"), + true, + 600); + var properties = TunnelProperties.builder().httpCors(config).build(); + var bytes = Protocol.tunnelPropertiesToPb(properties).toByteArray(); + assertThat(Protocol.tunnelPropertiesFromPb(Rstream.TunnelProperties.parseFrom(bytes))) + .isEqualTo(properties); + assertThat(Protocol.tunnelPropertiesToPb(TunnelProperties.builder().build()).hasHttpCors()) + .isFalse(); + assertThat( + Protocol.tunnelPropertiesFromPb(Rstream.TunnelProperties.getDefaultInstance()) + .httpCors()) + .isNull(); + assertThat(CreateTunnelOptions.builder().httpCors(config).build().httpCors()).isEqualTo(config); + } + + @Test + void policyOwnsItsLists() { + var origins = new ArrayList<>(List.of("https://dashboard.example")); + var config = new HttpCors(origins); + origins.clear(); + assertThat(config.allowedOrigins()).containsExactly("https://dashboard.example"); + assertThatThrownBy(() -> config.allowedOrigins().clear()) + .isInstanceOf(UnsupportedOperationException.class); + } +}