diff --git a/Cargo.lock b/Cargo.lock index 03886e2a..9655b356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,10 @@ dependencies = [ "tokio-rustls", "tokio-test", "tokio-util", + "tonic", + "tonic-web", + "tower-layer", + "tower-service", "urlencoding", "webpki-roots 1.0.5", "xxhash-rust", @@ -4439,6 +4443,24 @@ dependencies = [ "tonic", ] +[[package]] +name = "tonic-web" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75214f6b6bd28c19aa752ac09fdf0eea546095670906c21fe3940e180a4c43f2" +dependencies = [ + "base64", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "pin-project", + "tokio-stream", + "tonic", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/dockertest/runner/Dockerfile b/dockertest/runner/Dockerfile index cedf5c7b..baabd534 100644 --- a/dockertest/runner/Dockerfile +++ b/dockertest/runner/Dockerfile @@ -30,6 +30,24 @@ RUN --mount=type=cache,sharing=private,target=/go/pkg/mod \ # Build grpcurl CGO_ENABLED=0 go build -o /usr/local/bin/grpcurl ./cmd/grpcurl +# Use Rust base image for building grpcweb-cli +FROM rust:trixie AS builder-grpcweb-cli + +# Set the working directory for grpcweb-cli +WORKDIR /usr/src/grpcweb-cli + +# Clone the grpcweb-cli Git repository +RUN git clone https://github.com/DorianNiemiecSVRJS/grpcweb-cli.git /usr/src/grpcweb-cli + +# Build grpcweb-cli +RUN --mount=type=cache,sharing=private,target=/usr/local/cargo/git \ + --mount=type=cache,sharing=private,target=/usr/local/cargo/registry \ + --mount=type=cache,sharing=private,target=/usr/src/grpcweb-cli/target \ + # Build grpcweb-cli + cargo build --release && \ + # Copy executables out of the cache + mkdir .dist && cp target/release/grpcweb-cli .dist + # Use Debian base image FROM debian:trixie @@ -47,3 +65,6 @@ COPY --from=builder-websocat /usr/src/websocat/.dist /usr/local/bin # Copy the built grpcurl COPY --from=builder-grpcurl /usr/local/bin/grpcurl /usr/local/bin + +# Copy the built grpcweb-cli +COPY --from=builder-grpcweb-cli /usr/src/grpcweb-cli/.dist /usr/local/bin diff --git a/dockertest/tests/grpcweb/backend/.dockerignore b/dockertest/tests/grpcweb/backend/.dockerignore new file mode 100644 index 00000000..80db1637 --- /dev/null +++ b/dockertest/tests/grpcweb/backend/.dockerignore @@ -0,0 +1,2 @@ +/node_modules/ +/Dockerfile diff --git a/dockertest/tests/grpcweb/backend/.gitignore b/dockertest/tests/grpcweb/backend/.gitignore new file mode 100644 index 00000000..793e78bb --- /dev/null +++ b/dockertest/tests/grpcweb/backend/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/hello.proto diff --git a/dockertest/tests/grpcweb/backend/Dockerfile b/dockertest/tests/grpcweb/backend/Dockerfile new file mode 100644 index 00000000..076bc0c1 --- /dev/null +++ b/dockertest/tests/grpcweb/backend/Dockerfile @@ -0,0 +1,17 @@ +# Use the official Node.js image +FROM node:22-alpine + +# Set the working directory +WORKDIR /app + +# Copy files into the container +COPY . . + +# Install dependencies +RUN npm install + +# Expose port 3000 +EXPOSE 3000 + +# Start the application +CMD ["node", "index.js"] diff --git a/dockertest/tests/grpcweb/backend/index.js b/dockertest/tests/grpcweb/backend/index.js new file mode 100644 index 00000000..74c6d5fd --- /dev/null +++ b/dockertest/tests/grpcweb/backend/index.js @@ -0,0 +1,60 @@ +// Based on https://github.com/grpc/grpc-node/blob/master/examples/helloworld/dynamic_codegen/greeter_server.js, +// with a custom .proto file path +/* + * + * Copyright 2015 gRPC authors. + * + * 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. + * + */ + +const PROTO_PATH = __dirname + "/hello.proto"; + +const grpc = require("@grpc/grpc-js"); +const protoLoader = require("@grpc/proto-loader"); +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}); +const hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld; + +/** + * Implements the SayHello RPC method. + */ +function sayHello(call, callback) { + callback(null, { message: "Hello " + call.request.name }); +} + +/** + * Starts an RPC server that receives requests for the Greeter service at the + * sample server port + */ +function main() { + var server = new grpc.Server(); + server.addService(hello_proto.Greeter.service, { sayHello: sayHello }); + server.bindAsync( + "0.0.0.0:50051", + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err != null) { + return console.error(err); + } + console.log(`gRPC listening on ${port}`); + }, + ); +} + +main(); diff --git a/dockertest/tests/grpcweb/backend/package-lock.json b/dockertest/tests/grpcweb/backend/package-lock.json new file mode 100644 index 00000000..a22ed917 --- /dev/null +++ b/dockertest/tests/grpcweb/backend/package-lock.json @@ -0,0 +1,346 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@grpc/grpc-js": "^1.14.1", + "@grpc/proto-loader": "^0.8.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/dockertest/tests/grpcweb/backend/package.json b/dockertest/tests/grpcweb/backend/package.json new file mode 100644 index 00000000..d6a1acce --- /dev/null +++ b/dockertest/tests/grpcweb/backend/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@grpc/grpc-js": "^1.14.1", + "@grpc/proto-loader": "^0.8.0" + } +} diff --git a/dockertest/tests/grpcweb/docker-compose.yml b/dockertest/tests/grpcweb/docker-compose.yml new file mode 100644 index 00000000..dd25b29b --- /dev/null +++ b/dockertest/tests/grpcweb/docker-compose.yml @@ -0,0 +1,21 @@ +services: + # The web server to test + ferron: + build: + context: ../../.. + dockerfile: Dockerfile.test + volumes: + - ./ferron.kdl:/etc/ferron.kdl + + # The backend server for testing + backend: + build: + context: ./backend + + # A container to run tests + test-runner: + build: + context: ../../runner + command: "tail -f /dev/null" + volumes: + - ./hello.proto:/tmp/hello.proto diff --git a/dockertest/tests/grpcweb/ferron.kdl b/dockertest/tests/grpcweb/ferron.kdl new file mode 100644 index 00000000..35bc3544 --- /dev/null +++ b/dockertest/tests/grpcweb/ferron.kdl @@ -0,0 +1,5 @@ +:80 { + grpcweb + proxy "http://backend:50051/" + proxy_http2_only +} diff --git a/dockertest/tests/grpcweb/hello.proto b/dockertest/tests/grpcweb/hello.proto new file mode 100644 index 00000000..1270ae0e --- /dev/null +++ b/dockertest/tests/grpcweb/hello.proto @@ -0,0 +1,39 @@ +// Copied from https://github.com/hyperium/tonic/blob/115b95ceedbd58186d6c461b0a7f12e10353dbfd/examples/proto/helloworld/helloworld.proto + +// Copyright 2015 gRPC authors. +// +// 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. + +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "io.grpc.examples.helloworld"; +option java_outer_classname = "HelloWorldProto"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/dockertest/tests/grpcweb/run.sh b/dockertest/tests/grpcweb/run.sh new file mode 100755 index 00000000..76454b21 --- /dev/null +++ b/dockertest/tests/grpcweb/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash +TEST_FAILED=0 +cp hello.proto backend/ +docker compose --progress quiet up --build -d > /dev/null || (echo "Failed to start containers for a test suite" >&2; exit 1) +cat test.sh | docker compose --progress quiet exec -T test-runner bash 2>&1 || TEST_FAILED=1 +docker compose --progress quiet kill -s SIGKILL > /dev/null || true +docker compose --progress quiet down -v > /dev/null || true +rm -f backend/hello.proto +if [ "$TEST_FAILED" -eq 1 ]; then + exit 1 +fi diff --git a/dockertest/tests/grpcweb/test.sh b/dockertest/tests/grpcweb/test.sh new file mode 100755 index 00000000..5fda8248 --- /dev/null +++ b/dockertest/tests/grpcweb/test.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +TEST_FAILED=0 + +# Wait for the backend server to start +for i in $(seq 1 3) +do + if [ "$i" -gt 1 ]; then + sleep 1 + fi + nc -z backend 50051 >/dev/null 2>&1 && break || true +done + +# Wait for the HTTP server to start +for i in $(seq 1 3) +do + if [ "$i" -gt 1 ]; then + sleep 1 + fi + nc -z ferron 80 >/dev/null 2>&1 && break || true +done + +TEST_RESULTS="$(grpcweb-cli --data '{"name": "Ferron"}' --include /tmp --proto /tmp/hello.proto --url http://ferron/helloworld.Greeter/SayHello | jq .message)" +TEST_EXPECTED='"Hello Ferron"' +TEST_EXIT_CODE=$? +if [ "$TEST_EXIT_CODE" -eq 0 ] && [ "$TEST_RESULTS" = "$TEST_EXPECTED" ]; then + echo "Basic gRPC-Web proxying test passed!" +else + echo "Basic gRPC-Web proxying test failed!" >&2 + echo " Exit code: $TEST_EXIT_CODE" >&2 + echo " Expected: $TEST_EXPECTED" >&2 + echo " Received: $TEST_RESULTS" >&2 + TEST_FAILED=1 +fi + +if [ "$TEST_FAILED" -eq 1 ]; then + exit 1 +fi diff --git a/docs/configuration-kdl.md b/docs/configuration-kdl.md index afe15901..d668efe7 100644 --- a/docs/configuration-kdl.md +++ b/docs/configuration-kdl.md @@ -535,7 +535,9 @@ example.com { - This directive specifies whether the reverse proxy uses HTTP/2 protocol (without HTTP/1.1 fallback) when connecting to backend servers. When the backend server is connected via HTTPS, the reverse proxy negotiates HTTP/2 during the TLS handshake. When the backend server is connected via HTTP, the reverse proxy uses HTTP/2 with prior knowledge. This directive can be used when proxying gRPC requests. Default: `proxy_http2_only #false` - `proxy_proxy_header ` (_rproxy_ module; Ferron 2.1.0 or newer) - This directive specifies the version of the PROXY protocol header to be sent to backend servers when acting as a reverse proxy. Supported versions are `"v1"` (PROXY protocol version 1) and `"v2"` (PROXY protocol version 2). If specified with `#null` value, no PROXY protocol header is sent. Default: `proxy_proxy_header #null` - +- `grpcweb [enable_grpcweb: bool]` (_grpcweb_ module; Ferron UNRELEASED or newer) + - This directive specifies whether to translate gRPC-Web requests into gRPC ones. Default: `grpcweb #false` + **Configuration example:** ```kdl diff --git a/docs/modules.md b/docs/modules.md index b46d5fcb..f56a0100 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -13,6 +13,7 @@ The following modules are built into Ferron and are enabled by default: - _fcgi_ - this module enables the support for connecting to FastCGI servers. - _fproxy_ - this module enables forward proxy functionality. - _fproxyauth_ (Ferron 2.4.0 and newer) - this module enables forward proxy authentication via HTTP Basic authentication. +- _grpcweb_ (Ferron UNRELEASED and newer) - this module enables support for translation of gRPC-Web data into gRPC. - _limit_ (Ferron 2.0.0 and newer) - this module enables rate limits. - _replace_ (Ferron 2.0.0 and newer) - this module enables replacement of strings in response bodies. - _rproxy_ - this module enables reverse proxy functionality. diff --git a/docs/use-cases/reverse-proxy.md b/docs/use-cases/reverse-proxy.md index a0947f30..2c9441a9 100644 --- a/docs/use-cases/reverse-proxy.md +++ b/docs/use-cases/reverse-proxy.md @@ -146,6 +146,19 @@ grpc.example.com { } ``` +## gRPC-Web translation + +Ferron supports translation of gRPC-Web requests into gRPC ones. This can be useful when you want to expose a gRPC service over HTTP/2, but your clients only support gRPC-Web. To configure Ferron for gRPC-Web translation, you can use this configuration: + +```kdl +// Example configuration with gRPC-Web translation. Replace "grpcweb.example.com" with your domain name. +grpcweb.example.com { + grpcweb // Translate gRPC-Web requests into gRPC ones + proxy "http://localhost:3000/" // Replace "http://localhost:3000" with the backend server URL + proxy_http2_only // Enables HTTP/2-only proxying to support gRPC proxying +} +``` + ## Example: Ferron multiplexing to several backend servers In this example, the `example.com` and `bar.example.com` domains point to a server running Ferron. diff --git a/ferron-build.yaml b/ferron-build.yaml index 0c00bc45..e28149b3 100644 --- a/ferron-build.yaml +++ b/ferron-build.yaml @@ -35,6 +35,9 @@ modules: - builtin: true cargo_feature: replace loader: ReplaceModuleLoader + - builtin: true + cargo_feature: grpcweb + loader: GrpcWebModuleLoader - builtin: true cargo_feature: rproxy loader: ReverseProxyModuleLoader diff --git a/ferron-load-modules/Cargo.toml b/ferron-load-modules/Cargo.toml index 23d7239b..1dca0f92 100644 --- a/ferron-load-modules/Cargo.toml +++ b/ferron-load-modules/Cargo.toml @@ -13,6 +13,7 @@ ferron-modules-builtin = { workspace = true, features = [ "fcgi", "fproxy", "fproxyauth", + "grpcweb", "limit", "replace", "rproxy", diff --git a/ferron-modules-builtin/Cargo.toml b/ferron-modules-builtin/Cargo.toml index c66d840b..95e31d4b 100644 --- a/ferron-modules-builtin/Cargo.toml +++ b/ferron-modules-builtin/Cargo.toml @@ -93,6 +93,12 @@ smallvec = { version = "1.15.0", features = [ "const_generics", ] } +# gRPC +tonic-web = { version = "0.14.2", optional = true } +tonic = { version = "0.14.2", optional = true, default-features = false } +tower-layer = { version = "0.3.3", optional = true } +tower-service = { version = "0.3.3", optional = true } + [dev-dependencies] tokio-test = "0.4.4" shiba = { workspace = true } @@ -106,6 +112,7 @@ default = [ "fcgi", "fproxy", "fproxyauth", + "grpcweb", "limit", "replace", "rproxy", @@ -121,6 +128,7 @@ default-tokio = [ "fcgi", "fproxy", "fproxyauth", + "grpcweb", "limit", "replace", "rproxy", @@ -135,6 +143,7 @@ fauth = ["rand", "connpool"] fcgi = ["tokio-util/codec", "httparse", "memchr"] fproxy = [] fproxyauth = [] +grpcweb = ["tonic-web", "tower-layer", "tower-service", "tonic"] limit = ["tokenbucket"] replace = ["memchr"] rproxy = ["rand", "ppp", "connpool"] diff --git a/ferron-modules-builtin/src/optional/grpcweb.rs b/ferron-modules-builtin/src/optional/grpcweb.rs new file mode 100644 index 00000000..a3280537 --- /dev/null +++ b/ferron-modules-builtin/src/optional/grpcweb.rs @@ -0,0 +1,272 @@ +use std::collections::HashSet; +use std::error::Error; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use ferron_common::get_entries_for_validation; +use http_body_util::combinators::BoxBody; +use http_body_util::BodyExt; +use hyper::{Request, Response}; +use tonic_web::{GrpcWebLayer, ResponseFuture}; +use tower_layer::Layer; +use tower_service::Service; + +use ferron_common::logging::ErrorLogger; +use ferron_common::modules::{Module, ModuleHandlers, ModuleLoader, ResponseData, SocketData}; +use ferron_common::{config::ServerConfiguration, util::ModuleCache}; + +/// A Tower service that sends the request and waits for a response +struct InnerWaitService { + tx: Option>>, + rx: Option>>>, +} + +impl Service> for InnerWaitService { + type Response = Response>; + type Error = anyhow::Error; + type Future = Pin> + Send + Sync>>; + + #[inline] + fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, request: Request) -> Self::Future { + if let Some(tx) = self.tx.take() { + let _ = tx.send(request); + } + let rx = self.rx.take(); + Box::pin(async move { + if let Some(rx) = rx { + rx.await.map_err(|_| anyhow::anyhow!("Response body missing")) + } else { + Err(anyhow::anyhow!("Response body missing")) + } + }) + } +} + +/// Unsafely sync Tonic body +struct SyncTonicBody { + body: Pin>, +} + +impl SyncTonicBody { + /// Create a new `SyncTonicBody` from a `tonic::body::Body`. + /// + /// ## Safety + /// This function is unsafe because it does not check if the inner body is `Sync`. + #[inline] + unsafe fn new(body: tonic::body::Body) -> Self { + Self { body: Box::pin(body) } + } +} + +impl hyper::body::Body for SyncTonicBody { + type Data = ::Data; + type Error = ::Error; + + #[inline] + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + Pin::new(&mut self.body).poll_frame(cx) + } + + #[inline] + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } + + #[inline] + fn size_hint(&self) -> hyper::body::SizeHint { + // Use default size hint, to avoid conflicts with gRPC + hyper::body::SizeHint::default() + } +} + +// Safety: There's a safety warning in the `SyncTonicBody` struct's constructor. +unsafe impl Sync for SyncTonicBody {} + +/// A gRPC-Web module loader +pub struct GrpcWebModuleLoader { + cache: ModuleCache, +} + +impl Default for GrpcWebModuleLoader { + fn default() -> Self { + Self::new() + } +} + +impl GrpcWebModuleLoader { + /// Creates a new module loader + pub fn new() -> Self { + Self { + cache: ModuleCache::new(vec![]), + } + } +} + +impl ModuleLoader for GrpcWebModuleLoader { + fn load_module( + &mut self, + config: &ServerConfiguration, + _global_config: Option<&ServerConfiguration>, + _secondary_runtime: &tokio::runtime::Runtime, + ) -> Result, Box> { + Ok( + self + .cache + .get_or_init::<_, Box>(config, move |_| { + Ok(Arc::new(GrpcWebModule { + layer: GrpcWebLayer::new(), + })) + })?, + ) + } + + fn get_requirements(&self) -> Vec<&'static str> { + vec!["grpcweb"] + } + + fn validate_configuration( + &self, + config: &ServerConfiguration, + used_properties: &mut HashSet, + ) -> Result<(), Box> { + if let Some(entries) = get_entries_for_validation!("grpcweb", config, used_properties) { + for entry in &entries.inner { + if entry.values.len() != 1 { + return Err(anyhow::anyhow!("The `grpcweb` configuration property must have exactly one value").into()); + } else if !entry.values[0].is_bool() { + return Err(anyhow::anyhow!("Invalid gRPC-Web translation enabling option").into()); + } + } + } + + Ok(()) + } +} + +/// A gRPC-Web module +struct GrpcWebModule { + layer: GrpcWebLayer, +} + +impl Module for GrpcWebModule { + fn get_module_handlers(&self) -> Box { + Box::new(GrpcWebModuleHandlers { + layer: self.layer.clone(), + service: None, + }) + } +} + +/// Handlers for the gRPC-Web module +#[allow(clippy::type_complexity)] +struct GrpcWebModuleHandlers { + layer: GrpcWebLayer, + service: Option<( + tokio::sync::oneshot::Sender>>, + ResponseFuture< + Pin>, anyhow::Error>> + Send + Sync>>, + >, + )>, +} + +#[async_trait(?Send)] +impl ModuleHandlers for GrpcWebModuleHandlers { + async fn request_handler( + &mut self, + request: Request>, + _config: &ServerConfiguration, + _socket_data: &SocketData, + _error_logger: &ErrorLogger, + ) -> Result> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let (request_tx, request_rx) = tokio::sync::oneshot::channel(); + let mut service = self.layer.layer(InnerWaitService { + tx: Some(request_tx), + rx: Some(rx), + }); + futures_util::future::poll_fn(|cx| { + tower_service::Service::>>::poll_ready(&mut service, cx) + }) + .await?; + let mut call_future = service.call(request); + ferron_common::runtime::select! { + biased; + + response = &mut call_future => { + let (response_parts, _) = response?.into_parts(); + Ok(ResponseData { + request: None, + response: None, + response_status: Some(response_parts.status), + response_headers: Some(response_parts.headers), + new_remote_address: None, + }) + } + request = request_rx => { + let request = request + .map_err(|_| anyhow::anyhow!("Failed to obtain the translated gRPC request"))?; + let mut request = request.map(|b| { + // Safety: the tonic::body::Body (which SyncTonicBody wraps) is wrapped around a BoxedBody, + // which is Send + Sync. + let wrapped_body = unsafe { SyncTonicBody::new(b) }; + wrapped_body + .map_err(|e| std::io::Error::other(format!("gRPC error: {e}"))) + .boxed() + }); + self.service = Some((tx, call_future)); + + // Remove the Content-Length header to avoid conflicts with gRPC + while request.headers_mut().remove(hyper::header::CONTENT_LENGTH).is_some() {} + + Ok(ResponseData { + request: Some(request), + response: None, + response_status: None, + response_headers: None, + new_remote_address: None, + }) + }, + } + } + + async fn response_modifying_handler( + &mut self, + response: Response>, + ) -> Result>, Box> { + if response + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_none_or(|value| !(value == "application/grpc" || value.starts_with("application/grpc+"))) + { + // If the response is not gRPC, don't turn it into gRPC-Web one + return Ok(response); + } + + if let Some((tx, call_future)) = self.service.take() { + tx.send(response).unwrap_or_default(); + let response = call_future.await?; + Ok(response.map(|b| { + // Safety: the tonic::body::Body (which SyncTonicBody wraps) is wrapped around a BoxedBody, + // which is Send + Sync. + let wrapped_body = unsafe { SyncTonicBody::new(b) }; + wrapped_body + .map_err(|e| std::io::Error::other(format!("gRPC error: {e}"))) + .boxed() + })) + } else { + Ok(response) + } + } +} diff --git a/ferron-modules-builtin/src/optional/mod.rs b/ferron-modules-builtin/src/optional/mod.rs index 73a08f84..cff65c82 100644 --- a/ferron-modules-builtin/src/optional/mod.rs +++ b/ferron-modules-builtin/src/optional/mod.rs @@ -12,6 +12,8 @@ mod fcgi; mod fproxy; #[cfg(feature = "fproxyauth")] mod fproxyauth; +#[cfg(feature = "grpcweb")] +mod grpcweb; #[cfg(feature = "limit")] mod limit; #[cfg(feature = "replace")] @@ -37,6 +39,8 @@ pub use fcgi::*; pub use fproxy::*; #[cfg(feature = "fproxyauth")] pub use fproxyauth::*; +#[cfg(feature = "grpcweb")] +pub use grpcweb::*; #[cfg(feature = "limit")] pub use limit::*; #[cfg(feature = "static")]