mirror of https://github.com/grpc/grpc.git
Merge pull request #15130 from jiangtaoli2016/fake_handshaker
Add fake ALTS handshaker server (bazel only)pull/15145/head
commit
27fd07b12b
4 changed files with 579 additions and 0 deletions
@ -0,0 +1,47 @@ |
||||
# Copyright 2018 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. |
||||
|
||||
licenses(["notice"]) # Apache v2 |
||||
|
||||
load("//bazel:grpc_build_system.bzl", "grpc_proto_library", "grpc_cc_binary", "grpc_package") |
||||
|
||||
grpc_package(name = "test/core/tsi/alts/fake_handshaker", visibility = "public") |
||||
|
||||
grpc_proto_library( |
||||
name = "transport_security_common_proto", |
||||
srcs = ["transport_security_common.proto"], |
||||
has_services = False, |
||||
) |
||||
|
||||
grpc_proto_library( |
||||
name = "handshaker_proto", |
||||
srcs = ["handshaker.proto"], |
||||
has_services = True, |
||||
deps = [ |
||||
":transport_security_common_proto", |
||||
], |
||||
) |
||||
|
||||
grpc_cc_binary( |
||||
name = "fake_handshaker_server", |
||||
testonly = True, |
||||
srcs = ["fake_handshaker_server.cc"], |
||||
language = "C++", |
||||
deps = [ |
||||
":handshaker_proto", |
||||
":transport_security_common_proto", |
||||
"//:grpc++", |
||||
"//test/cpp/util:test_config", |
||||
], |
||||
) |
@ -0,0 +1,268 @@ |
||||
/*
|
||||
* |
||||
* Copyright 2018 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. |
||||
* |
||||
*/ |
||||
|
||||
#include <memory> |
||||
#include <sstream> |
||||
#include <string> |
||||
|
||||
#include <gflags/gflags.h> |
||||
#include <grpc/grpc.h> |
||||
#include <grpc/support/log.h> |
||||
#include <grpcpp/impl/codegen/async_stream.h> |
||||
#include <grpcpp/security/server_credentials.h> |
||||
#include <grpcpp/server.h> |
||||
#include <grpcpp/server_builder.h> |
||||
#include <grpcpp/server_context.h> |
||||
|
||||
#include "test/core/tsi/alts/fake_handshaker/handshaker.grpc.pb.h" |
||||
#include "test/core/tsi/alts/fake_handshaker/handshaker.pb.h" |
||||
#include "test/core/tsi/alts/fake_handshaker/transport_security_common.pb.h" |
||||
#include "test/cpp/util/test_config.h" |
||||
|
||||
DEFINE_int32(handshaker_port, 55056, |
||||
"TCP port on which the fake handshaker server listens to."); |
||||
|
||||
// Fake handshake messages.
|
||||
constexpr char kClientInitFrame[] = "ClientInit"; |
||||
constexpr char kServerFrame[] = "ServerInitAndFinished"; |
||||
constexpr char kClientFinishFrame[] = "ClientFinished"; |
||||
// Error messages.
|
||||
constexpr char kInvalidFrameError[] = "Invalid input frame."; |
||||
constexpr char kWrongStateError[] = "Wrong handshake state."; |
||||
|
||||
namespace grpc { |
||||
namespace gcp { |
||||
|
||||
// FakeHandshakeService implements a fake handshaker service using a fake key
|
||||
// exchange protocol. The fake key exchange protocol is a 3-message protocol:
|
||||
// - Client first sends ClientInit message to Server.
|
||||
// - Server then sends ServerInitAndFinished message back to Client.
|
||||
// - Client finally sends ClientFinished message to Server.
|
||||
// This fake handshaker service is intended for ALTS integration testing without
|
||||
// relying on real ALTS handshaker service inside GCE.
|
||||
// It is thread-safe.
|
||||
class FakeHandshakerService : public HandshakerService::Service { |
||||
public: |
||||
Status DoHandshake( |
||||
ServerContext* server_context, |
||||
ServerReaderWriter<HandshakerResp, HandshakerReq>* stream) override { |
||||
Status status; |
||||
HandshakerContext context; |
||||
HandshakerReq request; |
||||
HandshakerResp response; |
||||
gpr_log(GPR_DEBUG, "Start a new handshake."); |
||||
while (stream->Read(&request)) { |
||||
status = ProcessRequest(&context, request, &response); |
||||
if (!status.ok()) return WriteErrorResponse(stream, status); |
||||
stream->Write(response); |
||||
if (context.state == COMPLETED) return Status::OK; |
||||
request.Clear(); |
||||
} |
||||
return Status::OK; |
||||
} |
||||
|
||||
private: |
||||
// HandshakeState is used by fake handshaker server to keep track of client's
|
||||
// handshake status. In the beginning of a handshake, the state is INITIAL.
|
||||
// If start_client or start_server request is called, the state becomes at
|
||||
// least STARTED. When the handshaker server produces the first fame, the
|
||||
// state becomes SENT. After the handshaker server processes the final frame
|
||||
// from the peer, the state becomes COMPLETED.
|
||||
enum HandshakeState { INITIAL, STARTED, SENT, COMPLETED }; |
||||
|
||||
struct HandshakerContext { |
||||
bool is_client = true; |
||||
HandshakeState state = INITIAL; |
||||
}; |
||||
|
||||
Status ProcessRequest(HandshakerContext* context, |
||||
const HandshakerReq& request, |
||||
HandshakerResp* response) { |
||||
GPR_ASSERT(context != nullptr && response != nullptr); |
||||
response->Clear(); |
||||
if (request.has_client_start()) { |
||||
gpr_log(GPR_DEBUG, "Process client start request."); |
||||
return ProcessClientStart(context, request.client_start(), response); |
||||
} else if (request.has_server_start()) { |
||||
gpr_log(GPR_DEBUG, "Process server start request."); |
||||
return ProcessServerStart(context, request.server_start(), response); |
||||
} else if (request.has_next()) { |
||||
gpr_log(GPR_DEBUG, "Process next request."); |
||||
return ProcessNext(context, request.next(), response); |
||||
} |
||||
return Status(StatusCode::INVALID_ARGUMENT, "Request is empty."); |
||||
} |
||||
|
||||
Status ProcessClientStart(HandshakerContext* context, |
||||
const StartClientHandshakeReq& request, |
||||
HandshakerResp* response) { |
||||
GPR_ASSERT(context != nullptr && response != nullptr); |
||||
// Checks request.
|
||||
if (context->state != INITIAL) { |
||||
return Status(StatusCode::FAILED_PRECONDITION, kWrongStateError); |
||||
} |
||||
if (request.application_protocols_size() == 0) { |
||||
return Status(StatusCode::INVALID_ARGUMENT, |
||||
"At least one application protocol needed."); |
||||
} |
||||
if (request.record_protocols_size() == 0) { |
||||
return Status(StatusCode::INVALID_ARGUMENT, |
||||
"At least one record protocol needed."); |
||||
} |
||||
// Sets response.
|
||||
response->set_out_frames(kClientInitFrame); |
||||
response->set_bytes_consumed(0); |
||||
response->mutable_status()->set_code(StatusCode::OK); |
||||
// Updates handshaker context.
|
||||
context->is_client = true; |
||||
context->state = SENT; |
||||
return Status::OK; |
||||
} |
||||
|
||||
Status ProcessServerStart(HandshakerContext* context, |
||||
const StartServerHandshakeReq& request, |
||||
HandshakerResp* response) { |
||||
GPR_ASSERT(context != nullptr && response != nullptr); |
||||
// Checks request.
|
||||
if (context->state != INITIAL) { |
||||
return Status(StatusCode::FAILED_PRECONDITION, kWrongStateError); |
||||
} |
||||
if (request.application_protocols_size() == 0) { |
||||
return Status(StatusCode::INVALID_ARGUMENT, |
||||
"At least one application protocol needed."); |
||||
} |
||||
if (request.handshake_parameters().size() == 0) { |
||||
return Status(StatusCode::INVALID_ARGUMENT, |
||||
"At least one set of handshake parameters needed."); |
||||
} |
||||
// Sets response.
|
||||
if (request.in_bytes().empty()) { |
||||
// start_server request does not have in_bytes.
|
||||
response->set_bytes_consumed(0); |
||||
context->state = STARTED; |
||||
} else { |
||||
// start_server request has in_bytes.
|
||||
if (request.in_bytes() == kClientInitFrame) { |
||||
response->set_out_frames(kServerFrame); |
||||
response->set_bytes_consumed(strlen(kClientInitFrame)); |
||||
context->state = SENT; |
||||
} else { |
||||
return Status(StatusCode::UNKNOWN, kInvalidFrameError); |
||||
} |
||||
} |
||||
response->mutable_status()->set_code(StatusCode::OK); |
||||
context->is_client = false; |
||||
return Status::OK; |
||||
} |
||||
|
||||
Status ProcessNext(HandshakerContext* context, |
||||
const NextHandshakeMessageReq& request, |
||||
HandshakerResp* response) { |
||||
GPR_ASSERT(context != nullptr && response != nullptr); |
||||
if (context->is_client) { |
||||
// Processes next request on client side.
|
||||
if (context->state != SENT) { |
||||
return Status(StatusCode::FAILED_PRECONDITION, kWrongStateError); |
||||
} |
||||
if (request.in_bytes() != kServerFrame) { |
||||
return Status(StatusCode::UNKNOWN, kInvalidFrameError); |
||||
} |
||||
response->set_out_frames(kClientFinishFrame); |
||||
response->set_bytes_consumed(strlen(kServerFrame)); |
||||
context->state = COMPLETED; |
||||
} else { |
||||
// Processes next request on server side.
|
||||
HandshakeState current_state = context->state; |
||||
if (current_state == STARTED) { |
||||
if (request.in_bytes() != kClientInitFrame) { |
||||
return Status(StatusCode::UNKNOWN, kInvalidFrameError); |
||||
} |
||||
response->set_out_frames(kServerFrame); |
||||
response->set_bytes_consumed(strlen(kClientInitFrame)); |
||||
context->state = SENT; |
||||
} else if (current_state == SENT) { |
||||
// Client finish frame may be sent along with the first payload from the
|
||||
// client, handshaker only consumes the client finish frame.
|
||||
if (request.in_bytes().substr(0, strlen(kClientFinishFrame)) != |
||||
kClientFinishFrame) { |
||||
return Status(StatusCode::UNKNOWN, kInvalidFrameError); |
||||
} |
||||
response->set_bytes_consumed(strlen(kClientFinishFrame)); |
||||
context->state = COMPLETED; |
||||
} else { |
||||
return Status(StatusCode::FAILED_PRECONDITION, kWrongStateError); |
||||
} |
||||
} |
||||
// At this point, processing next request succeeded.
|
||||
response->mutable_status()->set_code(StatusCode::OK); |
||||
if (context->state == COMPLETED) { |
||||
*response->mutable_result() = GetHandshakerResult(); |
||||
} |
||||
return Status::OK; |
||||
} |
||||
|
||||
Status WriteErrorResponse( |
||||
ServerReaderWriter<HandshakerResp, HandshakerReq>* stream, |
||||
const Status& status) { |
||||
GPR_ASSERT(!status.ok()); |
||||
HandshakerResp response; |
||||
response.mutable_status()->set_code(status.error_code()); |
||||
response.mutable_status()->set_details(status.error_message()); |
||||
stream->Write(response); |
||||
return status; |
||||
} |
||||
|
||||
HandshakerResult GetHandshakerResult() { |
||||
HandshakerResult result; |
||||
result.set_application_protocol("grpc"); |
||||
result.set_record_protocol("ALTSRP_GCM_AES128_REKEY"); |
||||
result.mutable_peer_identity()->set_service_account("peer_identity"); |
||||
result.mutable_local_identity()->set_service_account("local_identity"); |
||||
string key(1024, '\0'); |
||||
result.set_key_data(key); |
||||
result.mutable_peer_rpc_versions()->mutable_max_rpc_version()->set_major(2); |
||||
result.mutable_peer_rpc_versions()->mutable_max_rpc_version()->set_minor(1); |
||||
result.mutable_peer_rpc_versions()->mutable_min_rpc_version()->set_major(2); |
||||
result.mutable_peer_rpc_versions()->mutable_min_rpc_version()->set_minor(1); |
||||
return result; |
||||
} |
||||
}; |
||||
|
||||
} // namespace gcp
|
||||
} // namespace grpc
|
||||
|
||||
void RunServer() { |
||||
GPR_ASSERT(FLAGS_handshaker_port != 0); |
||||
std::ostringstream server_address; |
||||
server_address << "[::1]:" << FLAGS_handshaker_port; |
||||
grpc::gcp::FakeHandshakerService service; |
||||
grpc::ServerBuilder builder; |
||||
builder.AddListeningPort(server_address.str(), |
||||
grpc::InsecureServerCredentials()); |
||||
builder.RegisterService(&service); |
||||
std::unique_ptr<grpc::Server> server(builder.BuildAndStart()); |
||||
gpr_log(GPR_INFO, "Fake handshaker server listening on %s", |
||||
server_address.str().c_str()); |
||||
server->Wait(); |
||||
} |
||||
|
||||
int main(int argc, char** argv) { |
||||
grpc::testing::InitTest(&argc, &argv, true); |
||||
RunServer(); |
||||
return 0; |
||||
} |
@ -0,0 +1,224 @@ |
||||
// Copyright 2018 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"; |
||||
|
||||
import "test/core/tsi/alts/fake_handshaker/transport_security_common.proto"; |
||||
|
||||
package grpc.gcp; |
||||
|
||||
option java_package = "io.grpc.alts.internal"; |
||||
|
||||
enum HandshakeProtocol { |
||||
// Default value. |
||||
HANDSHAKE_PROTOCOL_UNSPECIFIED = 0; |
||||
|
||||
// TLS handshake protocol. |
||||
TLS = 1; |
||||
|
||||
// Application Layer Transport Security handshake protocol. |
||||
ALTS = 2; |
||||
} |
||||
|
||||
enum NetworkProtocol { |
||||
NETWORK_PROTOCOL_UNSPECIFIED = 0; |
||||
TCP = 1; |
||||
UDP = 2; |
||||
} |
||||
|
||||
message Endpoint { |
||||
// IP address. It should contain an IPv4 or IPv6 string literal, e.g. |
||||
// "192.168.0.1" or "2001:db8::1". |
||||
string ip_address = 1; |
||||
|
||||
// Port number. |
||||
int32 port = 2; |
||||
|
||||
// Network protocol (e.g., TCP, UDP) associated with this endpoint. |
||||
NetworkProtocol protocol = 3; |
||||
} |
||||
|
||||
message Identity { |
||||
oneof identity_oneof { |
||||
// Service account of a connection endpoint. |
||||
string service_account = 1; |
||||
|
||||
// Hostname of a connection endpoint. |
||||
string hostname = 2; |
||||
} |
||||
} |
||||
|
||||
message StartClientHandshakeReq { |
||||
// Handshake security protocol requested by the client. |
||||
HandshakeProtocol handshake_security_protocol = 1; |
||||
|
||||
// The application protocols supported by the client, e.g., "h2" (for http2), |
||||
// "grpc". |
||||
repeated string application_protocols = 2; |
||||
|
||||
// The record protocols supported by the client, e.g., |
||||
// "ALTSRP_GCM_AES128". |
||||
repeated string record_protocols = 3; |
||||
|
||||
// (Optional) Describes which server identities are acceptable by the client. |
||||
// If target identities are provided and none of them matches the peer |
||||
// identity of the server, handshake will fail. |
||||
repeated Identity target_identities = 4; |
||||
|
||||
// (Optional) Application may specify a local identity. Otherwise, the |
||||
// handshaker chooses a default local identity. |
||||
Identity local_identity = 5; |
||||
|
||||
// (Optional) Local endpoint information of the connection to the server, |
||||
// such as local IP address, port number, and network protocol. |
||||
Endpoint local_endpoint = 6; |
||||
|
||||
// (Optional) Endpoint information of the remote server, such as IP address, |
||||
// port number, and network protocol. |
||||
Endpoint remote_endpoint = 7; |
||||
|
||||
// (Optional) If target name is provided, a secure naming check is performed |
||||
// to verify that the peer authenticated identity is indeed authorized to run |
||||
// the target name. |
||||
string target_name = 8; |
||||
|
||||
// (Optional) RPC protocol versions supported by the client. |
||||
RpcProtocolVersions rpc_versions = 9; |
||||
} |
||||
|
||||
message ServerHandshakeParameters { |
||||
// The record protocols supported by the server, e.g., |
||||
// "ALTSRP_GCM_AES128". |
||||
repeated string record_protocols = 1; |
||||
|
||||
// (Optional) A list of local identities supported by the server, if |
||||
// specified. Otherwise, the handshaker chooses a default local identity. |
||||
repeated Identity local_identities = 2; |
||||
} |
||||
|
||||
message StartServerHandshakeReq { |
||||
// The application protocols supported by the server, e.g., "h2" (for http2), |
||||
// "grpc". |
||||
repeated string application_protocols = 1; |
||||
|
||||
// Handshake parameters (record protocols and local identities supported by |
||||
// the server) mapped by the handshake protocol. Each handshake security |
||||
// protocol (e.g., TLS or ALTS) has its own set of record protocols and local |
||||
// identities. Since protobuf does not support enum as key to the map, the key |
||||
// to handshake_parameters is the integer value of HandshakeProtocol enum. |
||||
map<int32, ServerHandshakeParameters> handshake_parameters = 2; |
||||
|
||||
// Bytes in out_frames returned from the peer's HandshakerResp. It is possible |
||||
// that the peer's out_frames are split into multiple HandshakReq messages. |
||||
bytes in_bytes = 3; |
||||
|
||||
// (Optional) Local endpoint information of the connection to the client, |
||||
// such as local IP address, port number, and network protocol. |
||||
Endpoint local_endpoint = 4; |
||||
|
||||
// (Optional) Endpoint information of the remote client, such as IP address, |
||||
// port number, and network protocol. |
||||
Endpoint remote_endpoint = 5; |
||||
|
||||
// (Optional) RPC protocol versions supported by the server. |
||||
RpcProtocolVersions rpc_versions = 6; |
||||
} |
||||
|
||||
message NextHandshakeMessageReq { |
||||
// Bytes in out_frames returned from the peer's HandshakerResp. It is possible |
||||
// that the peer's out_frames are split into multiple NextHandshakerMessageReq |
||||
// messages. |
||||
bytes in_bytes = 1; |
||||
} |
||||
|
||||
message HandshakerReq { |
||||
oneof req_oneof { |
||||
// The start client handshake request message. |
||||
StartClientHandshakeReq client_start = 1; |
||||
|
||||
// The start server handshake request message. |
||||
StartServerHandshakeReq server_start = 2; |
||||
|
||||
// The next handshake request message. |
||||
NextHandshakeMessageReq next = 3; |
||||
} |
||||
} |
||||
|
||||
message HandshakerResult { |
||||
// The application protocol negotiated for this connection. |
||||
string application_protocol = 1; |
||||
|
||||
// The record protocol negotiated for this connection. |
||||
string record_protocol = 2; |
||||
|
||||
// Cryptographic key data. The key data may be more than the key length |
||||
// required for the record protocol, thus the client of the handshaker |
||||
// service needs to truncate the key data into the right key length. |
||||
bytes key_data = 3; |
||||
|
||||
// The authenticated identity of the peer. |
||||
Identity peer_identity = 4; |
||||
|
||||
// The local identity used in the handshake. |
||||
Identity local_identity = 5; |
||||
|
||||
// Indicate whether the handshaker service client should keep the channel |
||||
// between the handshaker service open, e.g., in order to handle |
||||
// post-handshake messages in the future. |
||||
bool keep_channel_open = 6; |
||||
|
||||
// The RPC protocol versions supported by the peer. |
||||
RpcProtocolVersions peer_rpc_versions = 7; |
||||
} |
||||
|
||||
message HandshakerStatus { |
||||
// The status code. This could be the gRPC status code. |
||||
uint32 code = 1; |
||||
|
||||
// The status details. |
||||
string details = 2; |
||||
} |
||||
|
||||
message HandshakerResp { |
||||
// Frames to be given to the peer for the NextHandshakeMessageReq. May be |
||||
// empty if no out_frames have to be sent to the peer or if in_bytes in the |
||||
// HandshakerReq are incomplete. All the non-empty out frames must be sent to |
||||
// the peer even if the handshaker status is not OK as these frames may |
||||
// contain the alert frames. |
||||
bytes out_frames = 1; |
||||
|
||||
// Number of bytes in the in_bytes consumed by the handshaker. It is possible |
||||
// that part of in_bytes in HandshakerReq was unrelated to the handshake |
||||
// process. |
||||
uint32 bytes_consumed = 2; |
||||
|
||||
// This is set iff the handshake was successful. out_frames may still be set |
||||
// to frames that needs to be forwarded to the peer. |
||||
HandshakerResult result = 3; |
||||
|
||||
// Status of the handshaker. |
||||
HandshakerStatus status = 4; |
||||
} |
||||
|
||||
service HandshakerService { |
||||
// Handshaker service accepts a stream of handshaker request, returning a |
||||
// stream of handshaker response. Client is expected to send exactly one |
||||
// message with either client_start or server_start followed by one or more |
||||
// messages with next. Each time client sends a request, the handshaker |
||||
// service expects to respond. Client does not have to wait for service's |
||||
// response before sending next request. |
||||
rpc DoHandshake(stream HandshakerReq) |
||||
returns (stream HandshakerResp) { |
||||
} |
||||
} |
@ -0,0 +1,40 @@ |
||||
// Copyright 2018 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"; |
||||
|
||||
package grpc.gcp; |
||||
|
||||
option java_package = "io.grpc.alts.internal"; |
||||
|
||||
// The security level of the created channel. The list is sorted in increasing |
||||
// level of security. This order must always be maintained. |
||||
enum SecurityLevel { |
||||
SECURITY_NONE = 0; |
||||
INTEGRITY_ONLY = 1; |
||||
INTEGRITY_AND_PRIVACY = 2; |
||||
} |
||||
|
||||
// Max and min supported RPC protocol versions. |
||||
message RpcProtocolVersions { |
||||
// RPC version contains a major version and a minor version. |
||||
message Version { |
||||
uint32 major = 1; |
||||
uint32 minor = 2; |
||||
} |
||||
// Maximum supported RPC version. |
||||
Version max_rpc_version = 1; |
||||
// Minimum supported RPC version. |
||||
Version min_rpc_version = 2; |
||||
} |
Loading…
Reference in new issue