Skip to content

Commit

Permalink
Beginnings of S3 request proxying
Browse files Browse the repository at this point in the history
Closes #10
  • Loading branch information
Randgalt committed May 16, 2024
1 parent 56ad313 commit 7e98ecf
Show file tree
Hide file tree
Showing 11 changed files with 278 additions and 23 deletions.
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ Proxy for S3
To run testing server:

```shell
./mvnw package
./mvnw -pl trino-s3-proxy exec:java -Dexec.classpathScope=test -Dexec.mainClass=io.trino.s3.proxy.server.TestingTrinoS3ProxyServer
./mvnw clean package
./mvnw -pl trino-s3-proxy exec:java -Dexec.classpathScope=test -Dexec.mainClass=io.trino.s3.proxy.server.TestingTrinoS3ProxyServer <fake access key> <fake secret key> <real S3 access key> <real S3 secret key>
```

Make note of the logged `Endpoint`

To test out proxied APIs:

First, set up AWS CLI:

```shell
> aws configure
AWS Access Key ID [None]: enter the fake access key
AWS Secret Access Key [None]: enter the fake secret key
Default region name [None]: <enter>
Default output format [None]: <enter>
```

Now, make calls. E.g.

```shell
aws --endpoint-url http://<endpoint>/api/v1/s3Proxy/s3 s3 ls s3://<s3bucket>/<dir>
```
5 changes: 5 additions & 0 deletions trino-s3-proxy/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
<artifactId>event</artifactId>
</dependency>

<dependency>
<groupId>io.airlift</groupId>
<artifactId>http-client</artifactId>
</dependency>

<dependency>
<groupId>io.airlift</groupId>
<artifactId>http-server</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 io.trino.s3.proxy.server;

import io.airlift.http.client.HttpStatus;
import io.airlift.http.client.Request;
import io.airlift.http.client.Response;
import io.airlift.http.client.ResponseHandler;
import jakarta.ws.rs.WebApplicationException;

import static io.airlift.http.client.ResponseHandlerUtils.propagate;
import static io.airlift.http.client.ResponseHandlerUtils.readResponseBytes;
import static java.util.Objects.requireNonNull;

public class BytesResponseHandler
implements ResponseHandler<BytesResponseHandler.BytesResponse, RuntimeException>
{
@Override
public BytesResponse handleException(Request request, Exception exception)
throws RuntimeException
{
throw propagate(request, exception);
}

public record BytesResponse(Response response, byte[] entity)
{
public BytesResponse
{
requireNonNull(response, "response is null");
requireNonNull(entity, "entity is null");
}
}

@Override
public BytesResponse handle(Request request, Response response)
throws RuntimeException
{
if (HttpStatus.familyForStatusCode(response.getStatusCode()) != HttpStatus.Family.SUCCESSFUL) {
// TODO
throw new WebApplicationException(response.getStatusCode());
}

byte[] bytes = readResponseBytes(request, response);

return new BytesResponse(response, bytes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@

import static java.util.Objects.requireNonNull;

public record Credentials(CredentialsEntry emulated)
public record Credentials(CredentialsEntry emulated, CredentialsEntry real)
{
public Credentials
{
requireNonNull(emulated, "emulated is null");
requireNonNull(real, "real is null");
}

public record CredentialsEntry(String accessKey, String secretKey)
Expand Down
117 changes: 117 additions & 0 deletions trino-s3-proxy/src/main/java/io/trino/s3/proxy/server/ProxyClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 io.trino.s3.proxy.server;

import com.google.inject.BindingAnnotation;
import com.google.inject.Inject;
import io.airlift.http.client.HeaderName;
import io.airlift.http.client.HttpClient;
import io.airlift.http.client.Request;
import io.trino.s3.proxy.server.BytesResponseHandler.BytesResponse;
import io.trino.s3.proxy.server.Credentials.CredentialsEntry;
import io.trino.s3.proxy.server.SigningController.Scope;
import io.trino.s3.proxy.server.rest.TrinoS3ProxyRestConstants;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.glassfish.jersey.server.ContainerRequest;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.net.URI;
import java.util.Map;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import static java.util.Objects.requireNonNull;

public class ProxyClient
{
private final HttpClient httpClient;
private final SigningController signingController;
private final CredentialsController credentialsController;

@Retention(RUNTIME)
@Target({FIELD, PARAMETER, METHOD})
@BindingAnnotation
public @interface ForProxyClient {}

@Inject
public ProxyClient(@ForProxyClient HttpClient httpClient, SigningController signingController, CredentialsController credentialsController)
{
this.httpClient = requireNonNull(httpClient, "httpClient is null");
this.signingController = requireNonNull(signingController, "signingController is null");
this.credentialsController = requireNonNull(credentialsController, "credentialsController is null");
}

public Response proxyRequest(ContainerRequest request, String bucket)
{
String fullPath = "/" + request.getPath(false);
if (!fullPath.startsWith(TrinoS3ProxyRestConstants.S3_PATH)) {
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
fullPath = fullPath.substring(TrinoS3ProxyRestConstants.S3_PATH.length());
if (fullPath.startsWith("/" + bucket)) {
fullPath = fullPath.substring(("/" + bucket).length());
}

Scope scope = Scope.fromHeaders(request.getHeaders()).orElseThrow(() -> new WebApplicationException(Response.Status.BAD_REQUEST));

String host = buildHost(bucket, scope.region());
URI uri = request.getUriInfo().getRequestUriBuilder()
.host(host)
.port(-1)
.scheme("https")
.replacePath(fullPath)
.build();

Request.Builder requestBuilder = new Request.Builder()
.setMethod(request.getMethod())
.setUri(uri)
.setFollowRedirects(true);

// TODO
CredentialsEntry credentials = credentialsController.credentials(scope.accessKey()).orElseThrow().real();

MultivaluedMap<String, String> requestHeaders = new MultivaluedHashMap<>(request.getRequestHeaders());
requestHeaders.remove("X-Amz-Security-Token");
requestHeaders.putSingle("Host", host);

Map<String, String> signedRequestHeaders = signingController.signedRequestHeaders(scope, credentials, request.getMethod(), requestHeaders, uri.getRawPath(), uri.getRawQuery());
signedRequestHeaders.forEach(requestBuilder::addHeader);

requestHeaders.forEach((name, values) -> {
if (!signedRequestHeaders.containsKey(name)) {
values.forEach(value -> requestBuilder.addHeader(name, value));
}
});

BytesResponse bytesResponse = httpClient.execute(requestBuilder.build(), new BytesResponseHandler());
Response.ResponseBuilder responseBuilder = Response.status(bytesResponse.response().getStatusCode()).entity(bytesResponse.entity());
bytesResponse.response().getHeaders()
.keySet()
.stream()
.map(HeaderName::toString)
.forEach(name -> bytesResponse.response().getHeaders(name).forEach(value -> responseBuilder.header(name, value)));
return responseBuilder.build();
}

private String buildHost(String bucket, String region)
{
return "%s.s3.%s.amazonaws.com".formatted(bucket, region);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import com.google.common.base.Splitter;
import com.google.inject.Inject;
import io.trino.s3.proxy.server.Credentials.CredentialsEntry;
import io.trino.s3.proxy.server.minio.Signer;
import io.trino.s3.proxy.server.minio.emulation.MinioRequest;
import io.trino.s3.proxy.server.minio.emulation.MinioUrl;
Expand Down Expand Up @@ -80,25 +81,24 @@ public static Optional<Scope> fromHeaders(MultivaluedMap<String, String> request
public boolean validateRequest(String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery)
{
return Scope.fromHeaders(requestHeaders).map(scope -> {
Map<String, String> signedRequestHeaders = signedRequestHeaders(scope, method, requestHeaders, encodedPath, encodedQuery);
// TODO
CredentialsEntry credentials = credentialsController.credentials(scope.accessKey).orElseThrow().emulated();
Map<String, String> signedRequestHeaders = signedRequestHeaders(scope, credentials, method, requestHeaders, encodedPath, encodedQuery);
String requestAuthorization = signedRequestHeaders.get("Authorization");
return scope.authorization.equals(requestAuthorization);
}).orElse(false);
}

public Map<String, String> signedRequestHeaders(Scope scope, String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery)
public Map<String, String> signedRequestHeaders(Scope scope, CredentialsEntry credentials, String method, MultivaluedMap<String, String> requestHeaders, String encodedPath, String encodedQuery)
{
// TODO
Credentials credentials = credentialsController.credentials(scope.accessKey).orElseThrow();

MinioUrl minioUrl = MinioUrl.build(encodedPath, encodedQuery);
MinioRequest minioRequest = MinioRequest.build(requestHeaders, method, minioUrl);

// TODO
String sha256 = minioRequest.headerValue("x-amz-content-sha256").orElseThrow();

try {
return Signer.signV4S3(minioRequest, scope.region, scope.accessKey, credentials.emulated().secretKey(), sha256).headers();
return Signer.signV4S3(minioRequest, scope.region, credentials.accessKey(), credentials.secretKey(), sha256).headers();
}
catch (NoSuchAlgorithmException | InvalidKeyException e) {
// TODO
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,28 @@
import com.google.inject.Scopes;
import io.trino.s3.proxy.server.rest.TrinoS3ProxyResource;

import static io.airlift.http.client.HttpClientBinder.httpClientBinder;
import static io.airlift.jaxrs.JaxrsBinder.jaxrsBinder;

public class TrinoS3ProxyServerModule
implements Module
{
@Override
public void configure(Binder binder)
public final void configure(Binder binder)
{
jaxrsBinder(binder).bind(TrinoS3ProxyResource.class);

binder.bind(SigningController.class).in(Scopes.SINGLETON);

// TODO config, etc.
httpClientBinder(binder).bindHttpClient("ProxyClient", ProxyClient.ForProxyClient.class);
binder.bind(ProxyClient.class).in(Scopes.SINGLETON);

moduleSpecificBinding(binder);
}

protected void moduleSpecificBinding(Binder binder)
{
// default does nothing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package io.trino.s3.proxy.server.rest;

import com.google.inject.Inject;
import io.trino.s3.proxy.server.ProxyClient;
import io.trino.s3.proxy.server.SigningController;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
Expand All @@ -34,11 +35,13 @@
public class TrinoS3ProxyResource
{
private final SigningController signingController;
private final ProxyClient proxyClient;

@Inject
public TrinoS3ProxyResource(SigningController signingController)
public TrinoS3ProxyResource(SigningController signingController, ProxyClient proxyClient)
{
this.signingController = requireNonNull(signingController, "signingController is null");
this.proxyClient = requireNonNull(proxyClient, "proxyClient is null");
}

@GET
Expand All @@ -56,7 +59,7 @@ public Response s3Get(@Context ContainerRequest request)
public Response s3Get(@Context ContainerRequest request, @PathParam("bucket") String bucket)
{
validateRequest(request);
return Response.ok().build();
return proxyClient.proxyRequest(request, bucket);
}

@HEAD
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

public class TestSigningController
{
private static final Credentials CREDENTIALS = new Credentials(new CredentialsEntry("THIS_IS_AN_ACCESS_KEY", "THIS_IS_A_SECRET_KEY"));
private static final Credentials CREDENTIALS = new Credentials(new CredentialsEntry("THIS_IS_AN_ACCESS_KEY", "THIS_IS_A_SECRET_KEY"), new CredentialsEntry("dummy", "dummy"));

private final CredentialsController credentialsController = new CredentialsController()
{
Expand Down Expand Up @@ -57,7 +57,7 @@ public void testRootLs()
requestHeaders.putSingle("User-Agent", "aws-cli/2.15.16 Python/3.11.7 Darwin/22.6.0 source/x86_64 prompt/off command/s3.ls");
requestHeaders.putSingle("Accept-Encoding", "identity");

Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), "GET", requestHeaders, "/", "");
Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), CREDENTIALS.emulated(), "GET", requestHeaders, "/", "");

assertThat(signedHeaders).contains(Map.entry("Authorization", "AWS4-HMAC-SHA256 Credential=THIS_IS_AN_ACCESS_KEY/20240516/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=9a19c251bf4e1533174e80da59fa57c65b3149b611ec9a4104f6944767c25704"));
}
Expand All @@ -76,7 +76,7 @@ public void testBucketLs()
requestHeaders.putSingle("User-Agent", "aws-cli/2.15.16 Python/3.11.7 Darwin/22.6.0 source/x86_64 prompt/off command/s3.ls");
requestHeaders.putSingle("Accept-Encoding", "identity");

Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), "GET", requestHeaders, "/mybucket", "list-type=2&prefix=foo%2Fbar&delimiter=%2F&encoding-type=url");
Map<String, String> signedHeaders = signingController.signedRequestHeaders(new Scope("dummy", "THIS_IS_AN_ACCESS_KEY", "us-east-1"), CREDENTIALS.emulated(), "GET", requestHeaders, "/mybucket", "list-type=2&prefix=foo%2Fbar&delimiter=%2F&encoding-type=url");

assertThat(signedHeaders).contains(Map.entry("Authorization", "AWS4-HMAC-SHA256 Credential=THIS_IS_AN_ACCESS_KEY/20240516/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=222d7b7fcd4d5560c944e8fecd9424ee3915d131c3ad9e000d65db93e87946c4"));
}
Expand Down
Loading

0 comments on commit 7e98ecf

Please sign in to comment.