mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-24 20:24:09 +00:00
kmsv2: add grpc service
Signed-off-by: Krzysztof Ostrowski <kostrows@redhat.com>
This commit is contained in:
parent
52cb0c28ce
commit
371b3b3be8
@ -7,9 +7,11 @@ go 1.19
|
|||||||
require (
|
require (
|
||||||
github.com/gogo/protobuf v1.3.2
|
github.com/gogo/protobuf v1.3.2
|
||||||
google.golang.org/grpc v1.51.0
|
google.golang.org/grpc v1.51.0
|
||||||
|
k8s.io/klog/v2 v2.80.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-logr/logr v1.2.3 // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
golang.org/x/net v0.4.0 // indirect
|
golang.org/x/net v0.4.0 // indirect
|
||||||
|
5
staging/src/k8s.io/kms/go.sum
generated
5
staging/src/k8s.io/kms/go.sum
generated
@ -19,6 +19,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
|
|||||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
|
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
||||||
|
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
@ -156,3 +159,5 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=
|
||||||
|
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
|
||||||
|
141
staging/src/k8s.io/kms/service/grpc_service.go
Normal file
141
staging/src/k8s.io/kms/service/grpc_service.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023 The Kubernetes 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
|
"k8s.io/klog/v2"
|
||||||
|
kmsapi "k8s.io/kms/apis/v2alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GRPCService is a gprc server that runs the kms v2 alpha 1 API.
|
||||||
|
type GRPCService struct {
|
||||||
|
addr string
|
||||||
|
timeout time.Duration
|
||||||
|
server *grpc.Server
|
||||||
|
|
||||||
|
kmsService Service
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ kmsapi.KeyManagementServiceServer = (*GRPCService)(nil)
|
||||||
|
|
||||||
|
// NewGRPCService creates an instance of GRPCService.
|
||||||
|
func NewGRPCService(
|
||||||
|
address string,
|
||||||
|
timeout time.Duration,
|
||||||
|
|
||||||
|
kmsService Service,
|
||||||
|
) *GRPCService {
|
||||||
|
klog.V(4).InfoS("KMS plugin configured", "address", address, "timeout", timeout)
|
||||||
|
|
||||||
|
return &GRPCService{
|
||||||
|
addr: address,
|
||||||
|
timeout: timeout,
|
||||||
|
kmsService: kmsService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe accepts incoming connections on a Unix socket. It is a blocking method.
|
||||||
|
// Returns non-nil error unless Close or Shutdown is called.
|
||||||
|
func (s *GRPCService) ListenAndServe() error {
|
||||||
|
ln, err := net.Listen("unix", s.addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
gs := grpc.NewServer(
|
||||||
|
grpc.ConnectionTimeout(s.timeout),
|
||||||
|
)
|
||||||
|
s.server = gs
|
||||||
|
|
||||||
|
kmsapi.RegisterKeyManagementServiceServer(gs, s)
|
||||||
|
|
||||||
|
klog.V(4).InfoS("kms plugin serving", "address", s.addr)
|
||||||
|
return gs.Serve(ln)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown performs a grafecul shutdown. Doesn't accept new connections and
|
||||||
|
// blocks until all pending RPCs are finished.
|
||||||
|
func (s *GRPCService) Shutdown() {
|
||||||
|
klog.V(4).InfoS("kms plugin shutdown", "address", s.addr)
|
||||||
|
if s.server != nil {
|
||||||
|
s.server.GracefulStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the server by closing all connections immediately and cancels
|
||||||
|
// all active RPCs.
|
||||||
|
func (s *GRPCService) Close() {
|
||||||
|
klog.V(4).InfoS("kms plugin close", "address", s.addr)
|
||||||
|
if s.server != nil {
|
||||||
|
s.server.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status sends a status request to specified kms service.
|
||||||
|
func (s *GRPCService) Status(ctx context.Context, _ *kmsapi.StatusRequest) (*kmsapi.StatusResponse, error) {
|
||||||
|
res, err := s.kmsService.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &kmsapi.StatusResponse{
|
||||||
|
Version: res.Version,
|
||||||
|
Healthz: res.Healthz,
|
||||||
|
KeyId: res.KeyID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt sends a decryption request to specified kms service.
|
||||||
|
func (s *GRPCService) Decrypt(ctx context.Context, req *kmsapi.DecryptRequest) (*kmsapi.DecryptResponse, error) {
|
||||||
|
klog.V(4).InfoS("decrypt request received", "id", req.Uid)
|
||||||
|
|
||||||
|
plaintext, err := s.kmsService.Decrypt(ctx, req.Uid, &DecryptRequest{
|
||||||
|
Ciphertext: req.Ciphertext,
|
||||||
|
KeyID: req.KeyId,
|
||||||
|
Annotations: req.Annotations,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &kmsapi.DecryptResponse{
|
||||||
|
Plaintext: plaintext,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt sends an encryption request to specified kms service.
|
||||||
|
func (s *GRPCService) Encrypt(ctx context.Context, req *kmsapi.EncryptRequest) (*kmsapi.EncryptResponse, error) {
|
||||||
|
klog.V(4).InfoS("encrypt request received", "id", req.Uid)
|
||||||
|
|
||||||
|
encRes, err := s.kmsService.Encrypt(ctx, req.Uid, req.Plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &kmsapi.EncryptResponse{
|
||||||
|
Ciphertext: encRes.Ciphertext,
|
||||||
|
KeyId: encRes.KeyID,
|
||||||
|
Annotations: encRes.Annotations,
|
||||||
|
}, nil
|
||||||
|
}
|
192
staging/src/k8s.io/kms/service/grpc_service_test.go
Normal file
192
staging/src/k8s.io/kms/service/grpc_service_test.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023 The Kubernetes 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
kmsapi "k8s.io/kms/apis/v2alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "v2alpha1"
|
||||||
|
|
||||||
|
func TestGRPCService(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
defaultTimeout := 30 * time.Second
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
address := filepath.Join(os.TempDir(), "kmsv2.sock")
|
||||||
|
plaintext := []byte("lorem ipsum dolor sit amet")
|
||||||
|
r := rand.New(rand.NewSource(time.Now().Unix()))
|
||||||
|
id, err := makeID(r.Read)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kmsService := newBase64Service(id)
|
||||||
|
server := NewGRPCService(address, defaultTimeout, kmsService)
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.Cleanup(server.Shutdown)
|
||||||
|
|
||||||
|
client := newClient(t, address)
|
||||||
|
|
||||||
|
t.Run("should be able to encrypt and decrypt through unix domain sockets", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
encRes, err := client.Encrypt(ctx, &kmsapi.EncryptRequest{
|
||||||
|
Plaintext: plaintext,
|
||||||
|
Uid: id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(plaintext, encRes.Ciphertext) {
|
||||||
|
t.Fatal("plaintext and ciphertext shouldn't be equal!")
|
||||||
|
}
|
||||||
|
|
||||||
|
decRes, err := client.Decrypt(ctx, &kmsapi.DecryptRequest{
|
||||||
|
Ciphertext: encRes.Ciphertext,
|
||||||
|
KeyId: encRes.KeyId,
|
||||||
|
Annotations: encRes.Annotations,
|
||||||
|
Uid: id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(decRes.Plaintext, plaintext) {
|
||||||
|
t.Errorf("want: %q, have: %q", plaintext, decRes.Plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should return status data", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
status, err := client.Status(ctx, &kmsapi.StatusRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Healthz != "ok" {
|
||||||
|
t.Errorf("want: %q, have: %q", "ok", status.Healthz)
|
||||||
|
}
|
||||||
|
if len(status.KeyId) == 0 {
|
||||||
|
t.Errorf("want: len(keyID) > 0, have: %d", len(status.KeyId))
|
||||||
|
}
|
||||||
|
if status.Version != version {
|
||||||
|
t.Errorf("want %q, have: %q", version, status.Version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(t *testing.T, address string) kmsapi.KeyManagementServiceClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cnn, err := grpc.Dial(
|
||||||
|
address,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithDialer(func(addr string, t time.Duration) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", addr)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = cnn.Close() })
|
||||||
|
|
||||||
|
return kmsapi.NewKeyManagementServiceClient(cnn)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testService struct {
|
||||||
|
decrypt func(ctx context.Context, uid string, req *DecryptRequest) ([]byte, error)
|
||||||
|
encrypt func(ctx context.Context, uid string, data []byte) (*EncryptResponse, error)
|
||||||
|
status func(ctx context.Context) (*StatusResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Service = (*testService)(nil)
|
||||||
|
|
||||||
|
func (s *testService) Decrypt(ctx context.Context, uid string, req *DecryptRequest) ([]byte, error) {
|
||||||
|
return s.decrypt(ctx, uid, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testService) Encrypt(ctx context.Context, uid string, data []byte) (*EncryptResponse, error) {
|
||||||
|
return s.encrypt(ctx, uid, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testService) Status(ctx context.Context) (*StatusResponse, error) {
|
||||||
|
return s.status(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeID(rand func([]byte) (int, error)) (string, error) {
|
||||||
|
b := make([]byte, 10)
|
||||||
|
if _, err := rand(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBase64Service(keyID string) *testService {
|
||||||
|
decrypt := func(_ context.Context, _ string, req *DecryptRequest) ([]byte, error) {
|
||||||
|
if req.KeyID != keyID {
|
||||||
|
return nil, fmt.Errorf("keyID mismatch. want: %q, have: %q", keyID, req.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.DecodeString(string(req.Ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt := func(_ context.Context, _ string, data []byte) (*EncryptResponse, error) {
|
||||||
|
return &EncryptResponse{
|
||||||
|
Ciphertext: []byte(base64.StdEncoding.EncodeToString(data)),
|
||||||
|
KeyID: keyID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := func(_ context.Context) (*StatusResponse, error) {
|
||||||
|
return &StatusResponse{
|
||||||
|
Version: version,
|
||||||
|
Healthz: "ok",
|
||||||
|
KeyID: keyID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testService{
|
||||||
|
decrypt: decrypt,
|
||||||
|
encrypt: encrypt,
|
||||||
|
status: status,
|
||||||
|
}
|
||||||
|
}
|
54
staging/src/k8s.io/kms/service/interface.go
Normal file
54
staging/src/k8s.io/kms/service/interface.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023 The Kubernetes 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
/*
|
||||||
|
Copied from: k8s.io/apiserver/pkg/storage/value/encrypt/envelope/kmsv2
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Service allows encrypting and decrypting data using an external Key Management Service.
|
||||||
|
type Service interface {
|
||||||
|
// Decrypt a given bytearray to obtain the original data as bytes.
|
||||||
|
Decrypt(ctx context.Context, uid string, req *DecryptRequest) ([]byte, error)
|
||||||
|
// Encrypt bytes to a ciphertext.
|
||||||
|
Encrypt(ctx context.Context, uid string, data []byte) (*EncryptResponse, error)
|
||||||
|
// Status returns the status of the KMS.
|
||||||
|
Status(ctx context.Context) (*StatusResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptResponse is the response from the Envelope service when encrypting data.
|
||||||
|
type EncryptResponse struct {
|
||||||
|
Ciphertext []byte
|
||||||
|
KeyID string
|
||||||
|
Annotations map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptRequest is the request to the Envelope service when decrypting data.
|
||||||
|
type DecryptRequest struct {
|
||||||
|
Ciphertext []byte
|
||||||
|
KeyID string
|
||||||
|
Annotations map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusResponse is the response from the Envelope service when getting the status of the service.
|
||||||
|
type StatusResponse struct {
|
||||||
|
Version string
|
||||||
|
Healthz string
|
||||||
|
KeyID string
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user