protovalidateにIPプリフィックスをバリデーションするためのカスタム関数を追加した話

この記事は BBSakura Networks Advent Calendar 2023 の 12/20 の記事です。

こんにちは。BBSakura Networksで全社のテックリードとモバイルコアの開発をするチームのリーダーを兼務している、日下部(@higebu)です。

はじめに

BBSakuraではシステム間通信にProtocol Buffersを多用しています。例えば、さくらのセキュアモバイルコネクトでは、さくらのクラウドのバックエンドとの連携やモバイルコアのコンポーネント間の通信に使用していて、OCXのバックエンドでも使用しています。

どちらのサービスもネットワークを制御するシステムで、入力されたパラメータのバリデーションや整合性のチェックに苦労しています。OCXでの苦労話はJANOG51での川畑の発表や下記の記事で紹介しています。

blog.bbsakura.net

今回、protovalidateにIPプリフィックスをバリデーションするための機能を追加したので、追加した機能の紹介、使い方、今後の話をしたいと思います。

protovalidateとは

protovalidateはBufが開発している、Protocol Buffersで定義されたメッセージのバリデーションのためのライブラリ群で、protoc-gen-validate(PGV)の後継とされています。 Common Expression Language(CEL)ベースになっているため、複雑なバリデーションルールを簡潔に記述することが可能です。 各言語用のライブラリはGo、C++、Python、Javaのベータ版がリリースされており、TypeScriptにも対応予定となっています。

詳しくは下記のBufの記事や公式リポジトリのREADMEを参照してください。 buf.build

CELはGoogleが作った式の評価のためのシンプルな言語で、Protocol Buffersを使って実装されています。 最近ではKubernetesのCRDのバリデーションAdmission Policiesのバリデーションでも使われているため、知っている方もいるかもしれません。

追加した機能の紹介

CELのカスタム関数として isIpPrefix() という関数を追加しています。ドキュメントにも同じことが書いてあるのですが下記のような仕様になっています。

  • string.isIpPrefix() -> bool
  • string.isIpPrefix(4) -> bool
  • string.isIpPrefix(6) -> bool
  • string.isIpPrefix(true) -> bool
  • string.isIpPrefix(4,true) -> bool
  • string.isIpPrefix(6,true) -> bool

引数には4/6に加えて、true/falseを取るようになっています。4/6はIPv4/IPv6のことで、trueの場合はネットワークアドレスかどうかをチェックしています。

これを使うと、下記のようにIPv4のプレフィックスかどうかをチェックするバリデーションルールを書くことができます。

message IPv4Route {
  string ipv4_prefix = 1 [(buf.validate.field).cel = {
    id: "ipv4_route.ipv4_prefix",
    message: "value must be a valid IPv4 prefix",
    expression: "this.isIpPrefix(4,true)"
  }];
}

ただ、毎回このように書くのはだるいのと、 isIp() と雰囲気を合わせるため、下記のように書けるようにもしています。

message IPv4Route {
  string ipv4_prefix = 1 [(buf.validate.field).string.ipv4_prefix = true];
}

先ほど説明した、isIpPrefix() の引数のパターンに合わせて、 string.ip_with_prefixlen string.ipv4_with_prefixlen string.ipv6_with_prefixlen string.ip_prefix string.ipv4_prefix string.ipv6_prefix を定義しています。

詳しくは buf/validate/validate.proto を見てください。

使い方

ここからはBufによるprotoファイルからのコード生成についてある程度知っているという前提で説明していきます。 あまり知らないという方は Bufのドキュメントを見に行っていただければと思います。

まず、 buf.yamldepsbuf.build/bufbuild/protovalidate を足す必要があります。例としては下記のような感じです。

version: v1
name: buf.build/higebu/example
deps:
  - buf.build/bufbuild/protovalidate
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

buf.gen.yaml は好きな言語のコード生成の設定をしていただければ良いです。ここではGoのコードを生成したいので下記のようにします。

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go:v1.31.0
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go:v1.3.0
    out: gen/go
    opt:
      - paths=source_relative

次に、protoファイルを作ります。今回は、雑に proto/example/v1/example.proto とします。例として、IPv4/IPv6アドレスの追加、IPv4/IPv6ルートの追加を行うAPIを持ったサービスを定義しています。 IPアドレスの追加は、インターフェースへのIPアドレスの追加、つまり ip addr add のイメージで、ルートの追加は ip route add のイメージでお願いします。

syntax = "proto3";

package higebu.example.v1;

import "buf/validate/validate.proto";

option go_package = "github.com/higebu/protovalidate-ip-prefix-example/gen/go/higebu/example/v1;examplev1";

service ExampleService {
  rpc AddIPv4Address(AddIPv4AddressRequest) returns (AddIPv4AddressResponse) {}
  rpc AddIPv6Address(AddIPv6AddressRequest) returns (AddIPv6AddressResponse) {}
  rpc AddIPv4Route(AddIPv4RouteRequest) returns (AddIPv4RouteResponse) {}
  rpc AddIPv6Route(AddIPv6RouteRequest) returns (AddIPv6RouteResponse) {}
}

message AddIPv4AddressRequest {
  string name = 1;
  string address = 2 [(buf.validate.field).string.ipv4_with_prefixlen = true];
}

message AddIPv4AddressResponse {
  string name = 1;
  string address = 2;
}

message AddIPv6AddressRequest {
  string name = 1;
  string address = 2 [(buf.validate.field).string.ipv6_with_prefixlen = true];
}

message AddIPv6AddressResponse {
  string name = 1;
  string address = 2;
}

message AddIPv4RouteRequest {
  string name = 1;
  string prefix = 2 [(buf.validate.field).string.ipv4_prefix = true];
  string nexthop = 3 [(buf.validate.field).string.ipv4 = true];
}

message AddIPv4RouteResponse {
  string name = 1;
  string prefix = 2;
  string nexthop = 3;
}

message AddIPv6RouteRequest {
  string name = 1;
  string prefix = 2 [(buf.validate.field).string.ipv6_prefix = true];
  string nexthop = 3 [(buf.validate.field).string.ipv6 = true];
}

message AddIPv6RouteResponse {
  string name = 1;
  string prefix = 2;
  string nexthop = 3;
}

AddIPv4AddressRequest では、 ipv4_with_prefixlen を使い、 192.168.100.5/24 のようにプレフィックス長が付いているIPアドレスのみ受け付けるようにしています。 また、 AddIPv4RouteRequest では prefixipv4_prefix を使い、 192.168.100.0/24 のようにアドレス部分がネットワークアドレスになっていて、プレフィックス長が付いている値のみ受け付けるようにしています。 IPv6についても同様です。

protoファイルができたら、buf generate でGoのコードを生成します。問題なければ gen/go ディレクトリ配下にコードが生成されるはずです。

次にサーバのrpcの実装ですが、特に特殊なことをする必要はありません。本当はインターフェースにIPアドレスを付けるなど何かすごい処理をするはずですが、例として、リクエストの中身を返すだけにしています。

func (*Server) AddIPv4Address(ctx context.Context, req *examplev1.AddIPv4AddressRequest) (*examplev1.AddIPv4AddressResponse, error) {
    return &examplev1.AddIPv4AddressResponse{
        Name:    req.GetName(),
        Address: req.GetAddress(),
    }, nil
}

バリデーションを行えるようにするにはサーバ起動時の処理を少し追加する必要があります。具体的には下記のようになります。

import (
    "log"
    "net"

    protovalidate_middleware "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate"
    "google.golang.org/grpc"

    "github.com/bufbuild/protovalidate-go"
    examplev1 "github.com/higebu/protovalidate-ip-prefix-example/gen/go/proto/example/v1"
)


func main() {
    lis, err := net.Listen("tcp", "localhost:9000")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    validator, err := protovalidate.New()
    if err != nil {
        log.Fatal(err)
    }
    s := New()
    g := grpc.NewServer(grpc.UnaryInterceptor(protovalidate_middleware.UnaryServerInterceptor(validator)))
    examplev1.RegisterExampleServiceServer(g, s)
    g.Serve(lis)
}

普段と違うところは、 protovalidate.New() しているところと、生成したvalidatorを protovalidate_middleware.UnaryServerInterceptor() に渡しているところだけだと思います。 github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidateを使うことで、サーバの各メソッドでバリデーションに関する処理を書かなくても良くなるため、使うことをおすすめします。

紹介したコードは下記のリポジトリに置いています。

github.com

置いているコードにはmain関数がないため、サーバを起動することはできませんが、同様の処理をテストコードに書いているため、 go test -v ./server を実行すると雰囲気がわかると思います。

今後の話

今後ですが、IPプレフィックスに関する、より高度なバリデーションを行えるようにしたいと思っています。具体的にはIPアドレスが指定したプリフィクスの範囲に含まれるかどうかなどをバリデーションルールとして記述できるようにしたいです。 また、protovalidateはまだプロダクション環境に導入していないため、導入を進めていきたいと思っています。

最後に

機能追加自体にご興味がある方は下記のプルリクエストから、ソースコードを追っていただければ雰囲気がわかると思います。

github.com

protovalidate にはProtocol Buffersの定義、ドキュメント、Examples、テストツールがあり、まずは、その辺りにコードを追加することになります。

特に、テストツールとして、protovalidate-conformance という各言語の実装が仕様を満たしているかどうかチェックできるツールがあり、これに対してテストケースを足しておかないと、各言語の実装を行っても最終的なテストができないので注意が必要です。

その後、各言語のリポジトリ(protovalidate-goprotovalidate-pythonprotovalidate-javaprotovalidate-cc)に対して仕様を満たすようにコードを追加していけば良いです。

protovalidate-conformanceですが、JavaとPythonは修正中の自分のprotovalidateリポジトリをベースにテストが可能ですが、GoとC++については、protovalidateの修正がマージされた後でないとテストできません。これはメンテナの方にやっていただくしかないので、ある程度できたらメンテナの方にお願いしましょう。

GoとPythonにはIPプリフィックスをパース、操作するための標準ライブラリが存在するため比較的簡単でしたが、Java、C++にはないので大変でした。

ちなみにC++については弊社のpainaさんに実装していただきました。ありがとうございました!

実は、IPプリフィックスをバリデーションするための機能は過去にpainaさんがPGVに追加していました。プルリクエストも出しています。 しかし、PGVへの機能追加がフリーズされていたためか、Goのみに対応していたためかわかりませんが、反応がありませんでした。。。 そのような状況で、どうしようかなと思っていたところにprotovalidateの発表があったため、それに乗っていこうとしている話でした。