diff --git a/.gitignore b/.gitignore index 28f8248ea..337ab67da 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ venv*/ .coverage coverage.xml htmlcov/ +rest_gateway/*.tar\.gz /.env .envrc .vscode diff --git a/VERSION.in b/VERSION.in index d3827e75a..9f8e9b69a 100644 --- a/VERSION.in +++ b/VERSION.in @@ -1 +1 @@ -1.0 +1.0 \ No newline at end of file diff --git a/proto/comment.proto b/proto/comment.proto index 5538aa00d..ee0c36aac 100644 --- a/proto/comment.proto +++ b/proto/comment.proto @@ -5,6 +5,8 @@ package comment; option java_package = "com.imageworks.spcue.grpc.comment"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + // The Comment class contains comment data for any entity that supports // commenting. Currently these are [Job] and [Host]. diff --git a/proto/criterion.proto b/proto/criterion.proto index 079f3dc2a..b6f77a41a 100644 --- a/proto/criterion.proto +++ b/proto/criterion.proto @@ -4,6 +4,7 @@ package criterion; option java_package = "com.imageworks.spcue.grpc.criterion"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; // -------- Primary Message Types --------] diff --git a/proto/cue.proto b/proto/cue.proto index d1c2fe33d..ce5aba553 100644 --- a/proto/cue.proto +++ b/proto/cue.proto @@ -5,6 +5,8 @@ package cue; option java_package = "com.imageworks.spcue.grpc.cue"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + // Core System level objects and methods diff --git a/proto/department.proto b/proto/department.proto index 59fb98690..dd2af3183 100644 --- a/proto/department.proto +++ b/proto/department.proto @@ -5,6 +5,8 @@ package department; option java_package = "com.imageworks.spcue.grpc.department"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + import "task.proto"; // Departments diff --git a/proto/depend.proto b/proto/depend.proto index 70220968a..edbbe58f6 100644 --- a/proto/depend.proto +++ b/proto/depend.proto @@ -5,6 +5,7 @@ package depend; option java_package = "com.imageworks.spcue.grpc.depend"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; // -------- Services -------- diff --git a/proto/facility.proto b/proto/facility.proto index ae7f3d4b8..1bce07627 100644 --- a/proto/facility.proto +++ b/proto/facility.proto @@ -7,6 +7,7 @@ option java_multiple_files = true; import "host.proto"; import "subscription.proto"; +option go_package = "opencue_gateway/gen/go"; // -------- Services -------- diff --git a/proto/filter.proto b/proto/filter.proto index 184097782..7425ff1bc 100644 --- a/proto/filter.proto +++ b/proto/filter.proto @@ -7,6 +7,7 @@ option java_multiple_files = true; import "job.proto"; +option go_package = "opencue_gateway/gen/go"; // -------- Services -------- diff --git a/proto/host.proto b/proto/host.proto index 381d46b20..0aa41cbb6 100644 --- a/proto/host.proto +++ b/proto/host.proto @@ -10,6 +10,7 @@ import "criterion.proto"; import "job.proto"; import "renderPartition.proto"; +option go_package = "opencue_gateway/gen/go"; // -------- Services --------] diff --git a/proto/job.proto b/proto/job.proto index 2149148b6..0962d7896 100644 --- a/proto/job.proto +++ b/proto/job.proto @@ -5,6 +5,8 @@ package job; option java_package = "com.imageworks.spcue.grpc.job"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + import "comment.proto"; import "depend.proto"; import "limit.proto"; diff --git a/proto/limit.proto b/proto/limit.proto index 28a30cecb..ddc2cc108 100644 --- a/proto/limit.proto +++ b/proto/limit.proto @@ -5,6 +5,8 @@ package limit; option java_package = "com.imageworks.spcue.grpc.limit"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + // The Limit class contains data that allows users to place limits // on the maximum number of running frames for a certain type of layer. diff --git a/proto/renderPartition.proto b/proto/renderPartition.proto index c8553ed3c..29c64e9c2 100644 --- a/proto/renderPartition.proto +++ b/proto/renderPartition.proto @@ -5,6 +5,7 @@ package renderPartition; option java_package = "com.imageworks.spcue.grpc.renderpartition"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; // -------- Services --------] diff --git a/proto/report.proto b/proto/report.proto index f9c56d5f4..dd1fdf701 100644 --- a/proto/report.proto +++ b/proto/report.proto @@ -5,6 +5,8 @@ package report; option java_package = "com.imageworks.spcue.grpc.report"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + import "host.proto"; // Interface to handle RQD pings. diff --git a/proto/rqd.proto b/proto/rqd.proto index f6e0d8790..1ff75a4be 100644 --- a/proto/rqd.proto +++ b/proto/rqd.proto @@ -5,6 +5,8 @@ package rqd; option java_package = "com.imageworks.spcue.grpc.rqd"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + import "report.proto"; // Interface for issuing commands to an RQD instance. diff --git a/proto/service.proto b/proto/service.proto index 57110bdaf..2c0a74774 100644 --- a/proto/service.proto +++ b/proto/service.proto @@ -5,6 +5,7 @@ package service; option java_package = "com.imageworks.spcue.grpc.service"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; // Interface for managing Service objects diff --git a/proto/show.proto b/proto/show.proto index f0fbf34b4..8f2134b1f 100644 --- a/proto/show.proto +++ b/proto/show.proto @@ -5,6 +5,8 @@ package show; option java_package = "com.imageworks.spcue.grpc.show"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + import "department.proto"; import "filter.proto"; import "host.proto"; diff --git a/proto/subscription.proto b/proto/subscription.proto index 8ac100e7e..399cc1d91 100644 --- a/proto/subscription.proto +++ b/proto/subscription.proto @@ -5,6 +5,8 @@ package subscription; option java_package = "com.imageworks.spcue.grpc.subscription"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + // Subscriptions // A subscription is what a show sets up when they want to use hosts in // an allocation. diff --git a/proto/task.proto b/proto/task.proto index 647a937e5..23fc55783 100644 --- a/proto/task.proto +++ b/proto/task.proto @@ -5,6 +5,8 @@ package task; option java_package = "com.imageworks.spcue.grpc.task"; option java_multiple_files = true; +option go_package = "opencue_gateway/gen/go"; + // Tasks // Tasks are shot priorities for a specific dept diff --git a/rest_gateway/.gitignore b/rest_gateway/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/rest_gateway/.gitignore @@ -0,0 +1 @@ +.env diff --git a/rest_gateway/Dockerfile b/rest_gateway/Dockerfile new file mode 100644 index 000000000..c70b966c8 --- /dev/null +++ b/rest_gateway/Dockerfile @@ -0,0 +1,54 @@ +FROM centos7.6-go1.21:latest AS build + +RUN yum install -y \ + git \ + protobuf3-compiler \ + && yum clean all +WORKDIR /app +ENV PATH=$PATH:/root/go/bin:/opt/protobuf3/usr/bin/ + +# Copy all of the staged files (protos plus go source) +COPY ./proto /app/proto +COPY ./rest_gateway/opencue_gateway /app/opencue_gateway +# COPY ./lib /app/lib + + +WORKDIR /app/opencue_gateway +RUN go mod init opencue_gateway && go mod tidy + +RUN go install \ + github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \ + github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \ + github.com/golang-jwt/jwt/v5 \ + google.golang.org/protobuf/cmd/protoc-gen-go \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc + +# Generate go grpc code +RUN mkdir -p gen/go && \ + protoc -I ../proto/ \ + --go_out ./gen/go/ \ + --go_opt paths=source_relative \ + --go-grpc_out ./gen/go/ \ + --go-grpc_opt paths=source_relative \ + ../proto/*.proto + +# Generate grpc-gateway handlers +RUN protoc -I ../proto/ --grpc-gateway_out ./gen/go \ + --grpc-gateway_opt paths=source_relative \ + --grpc-gateway_opt generate_unbound_methods=true \ + ../proto/*.proto + +# Uncomment this to run go tests +# RUN go test -v + +# Build project +RUN go build -o grpc_gateway main.go + +FROM centos-7.6.1810:latest +COPY --from=build /app/opencue_gateway/grpc_gateway /app/ + +# Ensure logs folder is created and has correct permissions +RUN mkdir -p /logs && chmod 755 /logs + +EXPOSE 8448 +ENTRYPOINT ["/app/grpc_gateway"] diff --git a/rest_gateway/README.md b/rest_gateway/README.md new file mode 100644 index 000000000..ca78b0e2b --- /dev/null +++ b/rest_gateway/README.md @@ -0,0 +1,320 @@ +# Opencue Rest Gateway + +A gateway to provide a REST endpoint to the Opencue gRPC API. + + +## Contents + +1. [Introduction](#introduction) +2. [How does it work](#how-does-it-work) +3. [REST interface](#rest-interface) + - [Example: Getting a show](#example-getting-a-show) + - [Example: Getting frames for a job](#example-getting-frames-for-a-job) +4. [Authentication](#authentication) + - [What are JSON Web Tokens?](#what-are-json-web-tokens) + - [JSON Web Tokens in a web system and the Rest Gateway](#json-web-tokens-in-a-web-system-and-the-rest-gateway) + - [Web system](#web-system) + - [Rest Gateway](#rest-gateway) +5. [Rest Gateway unit testing](#rest-gateway-unit-testing) + + +## Introduction + +The Opencue Rest Gateway is a crucial component that bridges the gap between Opencue's high-performance gRPC API and the widespread, web-friendly RESTful interface. Designed with scalability and integration flexibility in mind, this gateway allows developers and systems to interact with Opencue through standard HTTP methods, making it easier to integrate Opencue into a variety of environments, including web applications, automation scripts, and third-party services. + +By leveraging REST, the Opencue Rest Gateway opens up Opencue's advanced rendering and job management capabilities to a broader audience, enabling access through familiar, widely-adopted web technologies. This gateway not only simplifies interaction with Opencue but also ensures that all communications are secure, thanks to its implementation of [JSON Web Token (JWT)](https://jwt.io/) authentication. + +This documentation provides detailed instructions on how to set up and use the Opencue Rest Gateway, including configuration tips, examples of API calls, and insights into the security mechanisms that protect your data and operations. + +Go back to [Contents](#contents). + + +## How Does It Work + +The Opencue Rest Gateway operates as a translator and secure access point between the RESTful world and the gRPC services provided by Opencue. Built on top of Go and the [grpc-gateway project](https://github.com/grpc-ecosystem/grpc-gateway), the gateway automatically converts Opencue's protocol buffer (proto) definitions into REST endpoints. + +Here’s a step-by-step breakdown of how it works: + +1. **Request Conversion**: When a client sends an HTTP request to the gateway, the request is matched against the predefined RESTful routes generated from the proto files. The gateway then converts this HTTP request into the corresponding gRPC call. + +2. **gRPC Communication**: The converted request is sent to the appropriate Opencue gRPC service, where it is processed just like any other gRPC request. + +3. **Response Handling**: After the gRPC service processes the request, the response is returned to the gateway, which then converts the gRPC response into a JSON format suitable for HTTP. + +4. **Security Enforcement**: Before any request is processed, the gateway enforces security by requiring a JSON Web Token (JWT) in the `Authorization header`. This token is validated to ensure that the request is authenticated and authorized to access the requested resources. + +5. **Final Response**: The formatted JSON response is sent back to the client via HTTP, completing the request-response cycle. + +This seamless conversion and security process allows the Opencue Rest Gateway to provide a robust, secure, and user-friendly interface to Opencue's gRPC services, making it accessible to a wide range of clients and applications. + +**Note:** In the examples below, the REST gateway is available at OPENCUE_REST_GATEWAY_URL. Remember to replace OPENCUE_REST_GATEWAY_URL with the appropriate URL. + +Go back to [Contents](#contents). + + +## REST interface + +All service RPC calls are accessible via the REST interface: + + * **HTTP method:** POST + * **URI path:** Built from the service's name and method: `//` (e.g., `/show.ShowInterface/FindShow`) + * **Authorization header:** Must include a JWT token as the bearer. + ```json + headers: { + "Authorization": `Bearer ${jwtToken}`, + }, + ``` + * **HTTP body:** A JSON object with the request data. + ```proto + message ShowFindShowRequest { + string name = 1; + } + ``` + Becomes: + ```json + { + "name": "value for name" + } + ``` + * **HTTP response:** A JSON object with the formatted response. + +Go back to [Contents](#contents). + + +### Example: Getting a show + +Given the following proto definition in `show.proto`: + +```proto +service ShowInterface { + // Find a show with the specified name. + rpc FindShow(ShowFindShowRequest) returns (ShowFindShowResponse); +} + +message ShowFindShowRequest { + string name = 1; +} +message ShowFindShowResponse { + Show show = 1; +} +message Show { + string id = 1; + string name = 2; + float default_min_cores = 3; + float default_max_cores = 4; + string comment_email = 5; + bool booking_enabled = 6; + bool dispatch_enabled = 7; + bool active = 8; + ShowStats show_stats = 9; + float default_min_gpus = 10; + float default_max_gpus = 11; +} +``` + +You can send a request to the REST gateway running on `OPENCUE_REST_GATEWAY_URL`: + +```bash +curl -i -H "Authorization: Bearer jwtToken" -X POST OPENCUE_REST_GATEWAY_URL/show.ShowInterface/FindShow -d '{"name": "testshow"}` +``` + +The response might look like this: + +```bash +HTTP/1.1 200 OK +Content-Type: application/json +Grpc-Metadata-Content-Type: application/grpc +Grpc-Metadata-Grpc-Accept-Encoding: gzip +Date: Tue, 12 Dec 2023 18:05:18 GMT +Content-Length: 501 + +{"show":{"id":"00000000-0000-0000-0000-000000000000","name":"testshow","defaultMinCores":1,"defaultMaxCores":10,"commentEmail":"middle-tier@company.com","bookingEnabled":true,"dispatchEnabled":true,"active":true,"showStats":{"runningFrames":75,"deadFrames":14,"pendingFrames":1814,"pendingJobs":175,"createdJobCount":"2353643","createdFrameCount":"10344702","renderedFrameCount":"9733366","failedFrameCount":"1096394","reservedCores":252,"reservedGpus":0},"defaultMinGpus":100,"defaultMaxGpus":100000}} +``` + +Go back to [Contents](#contents). + + +### Example: Getting frames for a job + +Given the following proto definition in `job.proto`: + +```proto +service JobInterface { + // Returns all frame objects that match FrameSearchCriteria + rpc GetFrames(JobGetFramesRequest) returns (JobGetFramesResponse); +} + +message JobGetFramesRequest { + Job job = 1; + FrameSearchCriteria req = 2; +} + +message Job { + string id = 1; + JobState state = 2; + string name = 3; + string shot = 4; + string show = 5; + string user = 6; + string group = 7; + string facility = 8; + string os = 9; + oneof uid_optional { + int32 uid = 10; + } + int32 priority = 11; + float min_cores = 12; + float max_cores = 13; + string log_dir = 14; + bool is_paused = 15; + bool has_comment = 16; + bool auto_eat = 17; + int32 start_time = 18; + int32 stop_time = 19; + JobStats job_stats = 20; + float min_gpus = 21; + float max_gpus = 22; +} + +// Object for frame searching +message FrameSearchCriteria { + repeated string ids = 1; + repeated string frames = 2; + repeated string layers = 3; + FrameStateSeq states = 4; + string frame_range = 5; + string memory_range = 6; + string duration_range = 7; + int32 page = 8; + int32 limit = 9; + int32 change_date = 10; + int32 max_results = 11; + int32 offset = 12; + bool include_finished = 13; +} + +message JobGetFramesResponse { + FrameSeq frames = 1; +} + +// A sequence of Frames +message FrameSeq { + repeated Frame frames = 1; +} +``` + +You can send a request to the REST gateway running on `OPENCUE_REST_GATEWAY_URL`: + +**Note:** It is important to include 'page' and 'limit' when getting frames for a job. + +```bash +curl -i -H "Authorization: Bearer jwtToken" -X POST OPENCUE_REST_GATEWAY_URL/job.JobInterface/GetFrames -d '{"job":{"id":"00000000-0000-0000-0000-000000000001", "req": {"include_finished":true,"page":1,"limit":100}}' +``` + +The response might look like this: + +```bash +HTTP/1.1 200 OK +content-type: application/json +grpc-metadata-content-type: application/grpc +grpc-metadata-grpc-accept-encoding: gzip +date: Tue, 13 Feb 2024 17:15:49 GMT +transfer-encoding: chunked +set-cookie: 01234567890123456789012345678901234567890123456789012345678901234; path=/; HttpOnly + + +{"frames":{"frames":[{"id":"00000000-0000-0000-0000-000000000002", "name":"0001-bty_tp_3d_123456", "layerName":"bty_tp_3d_123456", "number":1, "state":"WAITING", "retryCount":0, "exitStatus":-1, "dispatchOrder":0, "startTime":0, "stopTime":0, "maxRss":"0", "usedMemory":"0", "reservedMemory":"0", "reservedGpuMemory":"0", "lastResource":"/0.00/0", "checkpointState":"DISABLED", "checkpointCount":0, "totalCoreTime":0, "lluTime":1707842141, "totalGpuTime":0, "maxGpuMemory":"0", "usedGpuMemory":"0", "frameStateDisplayOverride":null}, {"id":"00000000-0000-0000-0000-000000000003", "name":"0002-bty_tp_3d_123456", "layerName":"bty_tp_3d_123456", "number":2, "state":"WAITING", "retryCount":0, "exitStatus":-1, "dispatchOrder":1, "startTime":0, "stopTime":0, "maxRss":"0", "usedMemory":"0", "reservedMemory":"0", "reservedGpuMemory":"0", "lastResource":"/0.00/0", "checkpointState":"DISABLED", "checkpointCount":0, "totalCoreTime":0, "lluTime":1707842141, "totalGpuTime":0, "maxGpuMemory":"0", "usedGpuMemory":"0", "frameStateDisplayOverride":null}, {"id":"00000000-0000-0000-0000-000000000004", "name":"0003-bty_tp_3d_083540", "layerName":"bty_tp_3d_123456", "number":3, "state":"WAITING", "retryCount":0, "exitStatus":-1, "dispatchOrder":2, "startTime":0, "stopTime":0, "maxRss":"0", "usedMemory":"0", "reservedMemory":"0", "reservedGpuMemory":"0", "lastResource":"/0.00/0", "checkpointState":"DISABLED", "checkpointCount":0, "totalCoreTime":0, "lluTime":1707842141, "totalGpuTime":0, "maxGpuMemory":"0", "usedGpuMemory":"0", "frameStateDisplayOverride":null}, {"id":"00000000-0000-0000-0000-000000000005", "name":"0004-bty_tp_3d_083540", "layerName":"bty_tp_3d_123456", "number":4, "state":"WAITING", "retryCount":0, "exitStatus":-1, "dispatchOrder":3, "startTime":0, "stopTime":0, "maxRss":"0", "usedMemory":"0", "reservedMemory":"0", "reservedGpuMemory":"0", "lastResource":"/0.00/0", "checkpointState":"DISABLED", "checkpointCount":0, "totalCoreTime":0, "lluTime":1707842141, "totalGpuTime":0, "maxGpuMemory":"0", "usedGpuMemory":"0", "frameStateDisplayOverride":null}, {"id":"00000000-0000-0000-0000-000000000006", "name":"0005-bty_tp_3d_083540", "layerName":"bty_tp_3d_123456", "number":5, "state":"WAITING", "retryCount":0, "exitStatus":-1, "dispatchOrder":4, "startTime":0, "stopTime":0, "maxRss":"0", "usedMemory":"0", "reservedMemory":"0", "reservedGpuMemory":"0", "lastResource":"/0.00/0", "checkpointState":"DISABLED", "checkpointCount":0, "totalCoreTime":0, "lluTime":1707842141, "totalGpuTime":0, "maxGpuMemory":"0", "usedGpuMemory":"0", "frameStateDisplayOverride":null}]}} +``` + +Go back to [Contents](#contents). + + +## Authentication + +Go back to [Contents](#contents). + + +### What are JSON Web Tokens? + +The gRPC REST gateway implements JSON Web Tokens (JWT) for authentication. JWTs are a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object and are digitally signed for verification and security. In the gRPC REST gateway, all JWTs are signed using a secret. A JWT consists of the following three parts separated by dots: + +1. **Header:** Contains the type of token (usually `JWT`) and the signing algorithm (like `SHA256`) + +- Example: + +```json +{ + "alg": "HS256", + "typ": "JWT" +} +``` + +2. **Payload:** Contains the claims, which are statements about an entity (user) and additional data. + +- Example: + +```json +{ + "sub": "user-id-123", + "role": "admin", + "iat": 1609459200, // Example timestamp (Jan 1, 2021) + "exp": 1609462800 // Example expiration (1 hour later) +} + +``` +3. **Signature:** Created from the encoded header, the encoded payload, a secret, the algorithm specified in the header, and signed. + +- The signature also verifies that the original message was not altered and can verify that the sender of the JWT is who they say they are (when signed with a private key). + +- Example: + +``` +HMACSHA256( + base64UrlEncode(header) + "." + + base64UrlEncode(payload), + secret +) +``` +Together, these three parts form a token like `xxxxx.yyyyy.zzzzz`, which is three Base64-URL strings separated by dots that can be passed in HTML environments. + +Go back to [Contents](#contents). + + +### JSON Web Tokens in a web system and the Rest Gateway + +In a web system and Rest Gateway, the secret for the JWT must be defined and match. In Rest Gateway, the secret is defined as an environment variable called `JWT_AUTH_SECRET`. + +Go back to [Contents](#contents). + + +#### Web system + +When a web system accesses the gRPC REST gateway, `fetchObjectFromRestGateway()` will be called, which initializes a JWT with an expiration time (e.g. 1 hour). This JWT is then passed on every request to the gRPC REST gateway as the authorization bearer in the header. If this JWT is successfully authenticated by the Rest Gateway, the gRPC endpoint will be reached. If the JWT is invalid, an error will be returned, and the gRPC endpoint will not be reached. + +Go back to [Contents](#contents). + + +#### Rest Gateway + +When the gRPC REST gateway receives a request, it must first verify and authenticate it using middleware (`jwtMiddleware()`). The following requirements are checked before the gRPC REST gateway complies with the request: +- The request contains an `Authorization header` with a `Bearer token`. +- The token's signing method is Hash-based message authentication code (or HMAC). +- The token is valid. +- The token is not expired. +- The token's secret matches the Rest Gateway's secret. + +Go back to [Contents](#contents). + + +## Rest Gateway unit testing + +Unit tests for the gRPC REST gateway can be run with `go test`. To run the Rest Gateway unit testing using the Dockerfile, uncomment `RUN go test -v` in the Dockerfile. + +Unit tests currently cover the following cases for `jwtMiddleware` (used for authentication): + +- Valid tokens +- Missing tokens +- Invalid tokens +- Expired tokens + +Go back to [Contents](#contents). diff --git a/rest_gateway/opencue_gateway/gateway.go b/rest_gateway/opencue_gateway/gateway.go new file mode 100644 index 000000000..86d3c81ce --- /dev/null +++ b/rest_gateway/opencue_gateway/gateway.go @@ -0,0 +1,8 @@ +package tools + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway" + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2" + _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc" + _ "google.golang.org/protobuf/cmd/protoc-gen-go" +) \ No newline at end of file diff --git a/rest_gateway/opencue_gateway/main.go b/rest_gateway/opencue_gateway/main.go new file mode 100644 index 000000000..974b04a7d --- /dev/null +++ b/rest_gateway/opencue_gateway/main.go @@ -0,0 +1,158 @@ +package main +import ( + "context" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/grpclog" + + gw "opencue_gateway/gen/go" // Update + ) + +func getEnv(key string) string { + // Return the value of the environment variable if it's found + if value, ok := os.LookupEnv(key); ok { + return value + } else { + // If the environment variable is not found, output an error and exit the program + log.Fatal(fmt.Sprintf("Error: environment variable '%v' not found", key)) + } + return "" +} + +// Parse and validate the JWT token string +func validateJWTToken(tokenString string, jwtSecret []byte) (*jwt.Token, error) { + return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Ensure that the token's signing method is HMAC + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + errorString := fmt.Sprintf("Unexpected signing method: %v", token.Header["alg"]) + log.Printf(errorString) + return nil, fmt.Errorf(errorString) + } + // Return the secret key for validation + return jwtSecret, nil + }) +} + +// Middleware to handle token authorization +func jwtMiddleware(next http.Handler, jwtSecret []byte) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the authorization header and return 401 if there is no header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + errorString := "Authorization header required" + log.Printf(errorString) + http.Error(w, errorString, http.StatusUnauthorized) + return + } + + // Get the token from the header and validate it + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + token, err := validateJWTToken(tokenString, jwtSecret) + if err!=nil { + errorString := fmt.Sprintf("Token validation error: %v", err) + log.Printf(errorString) + http.Error(w, errorString, http.StatusUnauthorized) + return + } + if !token.Valid { + errorString := "Invalid token" + log.Printf(errorString) + http.Error(w, errorString, http.StatusUnauthorized) + return + } + + // If token is valid, pass it to the next handler + next.ServeHTTP(w, r) + }) +} + +func run() error { + grpcServerEndpoint := getEnv("CUEBOT_ENDPOINT") + port := getEnv("REST_PORT") + jwtSecret := []byte(getEnv("JWT_AUTH_SECRET")) + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Register gRPC server endpoint + // Note: Make sure the gRPC server is running properly and accessible + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + // show.proto + errShow := gw.RegisterShowInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errShow != nil { + return errShow + } + + errFrame := gw.RegisterFrameInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errFrame != nil { + return errFrame + } + errGroup := gw.RegisterGroupInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errGroup != nil { + return errGroup + } + errJob := gw.RegisterJobInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errJob != nil { + return errJob + } + errLayer := gw.RegisterLayerInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errLayer != nil { + return errLayer + } + + // host.proto + errDeed := gw.RegisterDeedInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errDeed != nil { + return errDeed + } + errHost := gw.RegisterHostInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errHost != nil { + return errHost + } + errOwner := gw.RegisterOwnerInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errOwner != nil { + return errOwner + } + errProc := gw.RegisterProcInterfaceHandlerFromEndpoint(ctx, mux, grpcServerEndpoint, opts) + if errProc != nil { + return errProc + } + + // Create a new HTTP ServeMux with middleware jwtMiddleware to protect the mux + httpMux := http.NewServeMux() + httpMux.Handle("/", jwtMiddleware(mux, jwtSecret)) + + // Start HTTP server (and proxy calls to gRPC server endpoint) + return http.ListenAndServe(":" + port, httpMux) +} + +func main() { + // Set up file to capture all log outputs + f, err := os.OpenFile("/logs/opencue_gateway.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) + if err != nil { + log.Fatal(err) + } + // Enable output to both Stdout and the log file + mw := io.MultiWriter(os.Stdout, f) + defer f.Close() + log.SetOutput(mw) + + flag.Parse() + + if err := run(); err != nil { + grpclog.Fatal(err) + } +} \ No newline at end of file diff --git a/rest_gateway/opencue_gateway/main_test.go b/rest_gateway/opencue_gateway/main_test.go new file mode 100644 index 000000000..18878dec8 --- /dev/null +++ b/rest_gateway/opencue_gateway/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" +) + +func TestJwtMiddleware(t *testing.T) { + jwtSecret := []byte("test_secret") + + // Set up a sample handler to use with the middleware + sampleHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Create a test server with the middleware + ts := httptest.NewServer(jwtMiddleware(sampleHandler, jwtSecret)) + defer ts.Close() + + t.Run("Valid Token", func(t *testing.T) { + // Generate a valid token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "test_user", + "exp": time.Now().Add(time.Hour).Unix(), + }) + tokenString, err := token.SignedString(jwtSecret) + assert.NoError(t, err) + + // Create a request with the valid token + req, err := http.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenString) + + // Perform the request + res, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("Missing Token", func(t *testing.T) { + // Create a request without a token + req, err := http.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) + + // Perform the request + res, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + + t.Run("Invalid Token", func(t *testing.T) { + // Create a request with an invalid token + req, err := http.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer invalid_token") + + // Perform the request + res, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) + + t.Run("Expired Token", func(t *testing.T) { + // Generate an expired token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "test_user", + "exp": time.Now().Add(-time.Hour).Unix(), + }) + tokenString, err := token.SignedString(jwtSecret) + assert.NoError(t, err) + + // Create a request with the expired token + req, err := http.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+tokenString) + + // Perform the request + res, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, res.StatusCode) + }) +} diff --git a/rest_gateway/opencue_gateway/tools/gateway.go b/rest_gateway/opencue_gateway/tools/gateway.go new file mode 100644 index 000000000..86d3c81ce --- /dev/null +++ b/rest_gateway/opencue_gateway/tools/gateway.go @@ -0,0 +1,8 @@ +package tools + +import ( + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway" + _ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2" + _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc" + _ "google.golang.org/protobuf/cmd/protoc-gen-go" +) \ No newline at end of file