Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions src/main/java/io/rstream/ControlChannel.java
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,12 @@ private <T> 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.",
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 58 additions & 2 deletions src/main/java/io/rstream/CreateTunnelOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String, String> labels,
List<String> geoIp,
List<String> trustedIps,
TlsMode tlsMode,
List<String> tlsAlpns,
String tlsMinVersion,
List<String> 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();
}
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -192,7 +247,8 @@ public CreateTunnelOptions build() {
port,
upstreamTls,
allowCrossRegionRouting,
auth);
auth,
httpCors);
}
}
}
23 changes: 23 additions & 0 deletions src/main/java/io/rstream/HttpCors.java
Original file line number Diff line number Diff line change
@@ -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<String> allowedOrigins,
List<String> allowedMethods,
List<String> allowedHeaders,
List<String> 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<String> allowedOrigins) {
this(allowedOrigins, List.of(), List.of(), List.of(), false, 0);
}
}
24 changes: 23 additions & 1 deletion src/main/java/io/rstream/Protocol.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand Down
68 changes: 66 additions & 2 deletions src/main/java/io/rstream/TunnelProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String, String> labels,
List<String> geoIp,
List<String> trustedIps,
String host,
TlsMode tlsMode,
List<String> tlsAlpns,
String tlsMinVersion,
List<String> 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;
Expand Down Expand Up @@ -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,
Expand All @@ -221,7 +284,8 @@ public TunnelProperties build() {
port,
upstreamTls,
datagramGuaranteedDelivery,
allowCrossRegionRouting);
allowCrossRegionRouting,
httpCors);
}
}
}
14 changes: 13 additions & 1 deletion src/main/proto/rstream.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions src/test/java/io/rstream/HttpCorsTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading