UDPサーバ・プロキシ

概要

UDPサーバは、UDP(レイヤー4)のサーバを実装することを目的としています。 UDPサーバは、Goの標準ライブラリでは提供されていません。 本機能ではnet/httpパッケージServerに似たインターフェースでUDPサーバを利用できることを目指しています。

UDPはTCPと異なりコネクションレスのプロトコルです。 そのため、ここで実装されるUDPサーバは仮想コネクションを 仮想コネクション

機能

UDPサーバが保有する機能は以下の通りです。

1. UDPサーバ機能

UDPサーバとして、クライアントからパケット(UDPデータグラム)を受け取り、ハンドラーに連携します。 UDPサーバはTLSに対応していません。

性能面の理由から、UDPサーバは内部で仮想コネクションを作成します。 仮想コネクションはクライアントのIP:Portに対して1つ作成されます。

サーバの終了処理は以下の2種類があります。

  • クローズ
    • Server.Closeを利用した終了処理
    • 最初にUDP接続の受付を停止(UDPリスナーのクローズ)
    • 続いて既存のUDPの処理ループをキャンセル
  • シャットダウン
    • Server.Shutdownを利用した終了処理
    • 最初にUDP接続の受付を停止(UDPリスナーのクローズ)
    • 続いて既存のUDPの処理ループが自然にクローズされるまで待機
    • シャットダウンタイムアウトが発生した際は残存する処理をそのまま残して終了

UDPサーバのハンドラのインターフェースは以下のように定義され、 各仮想コネクションに対してそれぞれ独立したGoroutineで実行されます。

type Handler interface {
	ServeUDP(ctx context.Context, conn Conn)
}

2. UDPサーバランナー

UDPサーバランナーは、グレースフルシャットダウンを簡単に実装できる機能です。 サーバランナーを利用することでシャットダウン処理の実装の手間を省き、安全にサーバをシャットダウンできます。

利用例はUDPサーバランナーを参照してください。

3. UDPプロキシ機能

UDPプロキシ機能は、UDPサーバのハンドラとして機能します。 クライアントのUDPコネクションから受け取ったパケットを別のUDPサーバへプロキシします。

graph LR
  Client -- UDP --> P
  P --> Client
  P["UDP</br>Proxy"]
  P -- UDP --> U1["Upstream"]
  P -- UDP --> U2["Upstream"]
  U1 --> P
  U2 --> P

転送先ダイアルについて、UDPプロキシはそれ自体が転送先サーバを決定する機能を持ちません。 かわりに以下のような関数シグネチャのフィールドを公開することで、 具体的な転送先サーバの決定をユーザに委ねます。 UDPプロキシは、このDial関数を利用して転送先サーバを決定します。 なお、第二引数のConnは特定のIP:Portを持つクライアントとの仮想コネクションです。

Dial func(ctx context.Context, dc Conn) (uc net.Conn, err error)

セキュリティに関する特記事項

znetパッケージの機能を用いることで以下のセキュリティ対策が可能です。 TCPと異なり、UDPはコネクションレスのプロトコルであるため、TCPサーバにあるような同時接続数の上限設定はできません。

性能に関する特記事項

UDPサーバは、クライアントのIP:Portに対して仮想コネクションを作成します。 短時間で多数のIP:Portからリクエストを受け取る状況では性能が悪化する可能性があります。

実装例・使い方

UDPサーバ

最も基本的なUDPサーバの実装は以下の通りです。 この実装例のハンドラは、受信したUDPのパケットを標準出力に出力するのみです。

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"

	"github.com/aileron-projects/go/znet/zudp"
)

// handleConn reads and prints UDP packets received from the conn.
func handleConn(ctx context.Context, conn zudp.Conn) {
	buf := make([]byte, 1<<10)
	for {
		n, err := conn.Read(buf)
		fmt.Println(string(buf[:n]))
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return
			}
			panic(err)
		}
	}
}

func main() {
	svr := &zudp.Server{
		Addr:    ":8080",
		Handler: zudp.HandlerFunc(handleConn),
	}
	log.Println("starting udp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

UDPサーバランナー

UDPサーバランナーを利用すると、グレースフルシャットダウンを簡単に実現できます。

UDPサーバランナーの実装例は以下の通りです。 syscallパッケージの利用が制限されているプラットフォームもある点に注意してください。

func main() {
	svr := &zudp.Server{
		Addr:    ":8080",
		Handler: zudp.HandlerFunc(handleConn),
	}

	runner := &zudp.ServerRunner{
		Serve:           svr.ListenAndServe,
		Shutdown:        svr.Shutdown,
		Close:           svr.Close,
		ShutdownTimeout: 10 * time.Second,
	}

	// Receive SIGINT and SIGTERM
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	log.Println("starting udp server at " + svr.Addr)
	if err := runner.Run(ctx); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

IPホワイトリスト

znetパッケージの機能を利用して、 ホワイトリスト形式で接続元IPを制限できます。 ホワイトリストはトライ木で実装されているため、IPアドレスの数が増えても処理時間の劣化はほとんどありません。 許可されないIPアドレスからの接続があった場合、UDPサーバは当該パケットを破棄します。

なお、znetパッケージを用いると、ホワイトリストの中でも一部だけは拒否する処理も可能です。

実装例は以下となります。一部エラー処理は省略しています。 この例では、ローカルホストである127.0.0.1::1のIPアドレスのみ許可しています。

type WhitelistPacketConn struct {
	net.PacketConn
	wl *znet.WhiteList
}

func (c *WhitelistPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
	n, addr, err = c.PacketConn.ReadFrom(p)
	if err == nil {
		host, _, err := net.SplitHostPort(addr.String())
		if err != nil {
			host = addr.String() // Fallback
		}
		if !c.wl.Allowed(host) {
			return n, addr, zudp.ErrSkipHandler // Return zudp.ErrSkipHandler.
		}
	}
	return n, addr, err
}

func main() {
	svr := &zudp.Server{
		Addr:    "",
		Handler: zudp.HandlerFunc(handleConn),
	}

	p, err := net.ListenPacket("udp", ":8080")
	if err != nil {
		panic(err)
	}
	wl := znet.NewWhiteList()
	_ = wl.Allow("127.0.0.1/32", "::1/128")
	pc := &WhitelistPacketConn{PacketConn: p, wl: wl}

	log.Println("starting udp server at " + pc.LocalAddr().String())
	if err := svr.Serve(pc); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

IPブラックリスト

znetパッケージの機能を利用して、 ブラックリスト形式で接続元IPを制限できます。 ブラックリストはトライ木で実装されているため、IPアドレスの数が増えても処理時間の劣化はほとんどありません。 許可されないIPアドレスからの接続があった場合、UDPサーバは当該パケットを破棄します。

なお、znetパッケージを用いると、ブラックリストの中でも一部だけは許可する処理も可能です。

実装例は以下となります。一部エラー処理は省略しています。 この例では、ローカルホストである192.168.0.0/16の範囲にあるIPアドレスを拒否しています。

type BlacklistPacketConn struct {
	net.PacketConn
	bl *znet.BlackList
}

func (c *BlacklistPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
	n, addr, err = c.PacketConn.ReadFrom(p)
	if err == nil {
		host, _, err := net.SplitHostPort(addr.String())
		if err != nil {
			host = addr.String() // Fallback
		}
		if !c.bl.Allowed(host) {
			return n, addr, zudp.ErrSkipHandler // Return zudp.ErrSkipHandler.
		}
	}
	return n, addr, err
}

func main() {
	svr := &zudp.Server{
		Addr:    "",
		Handler: zudp.HandlerFunc(handleConn),
	}

	p, err := net.ListenPacket("udp", ":8080")
	if err != nil {
		panic(err)
	}
	bl := znet.NewBlackList()
	_ = bl.Disallow("192.168.0.0/16")
	pc := &BlacklistPacketConn{PacketConn: p, bl: bl}

	log.Println("starting udp server at " + pc.LocalAddr().String())
	if err := svr.Serve(pc); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

Unixドメインソケット

UDPサーバはUnixドメインソケットを利用できます。

パス名ソケットを利用する場合は、以下のように指定します。

func main() {
	svr := &zudp.Server{
		Addr:    "unixgram:///var/run/example.sock",
		Handler: zudp.HandlerFunc(handleConn),
	}
	log.Println("starting udp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

抽象ソケットを利用する場合は、以下のように指定します。

func main() {
	svr := &zudp.Server{
		Addr:    "unixgram://@example",
		Handler: zudp.HandlerFunc(handleConn),
	}
	log.Println("starting udp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != zudp.ErrServerClosed {
		panic(err)
	}
}

デフォルトのプロキシ

zudpパッケージは、デフォルトのプロキシ機能を提供します。 この機能を利用すると、複数の転送先サーバにに対してラウンドロビンによる負荷分散を利用しながらUDPをプロキシできます。

最も基本的なUDPプロキシの利用例は以下の通りです。 なお、グレースフルシャットダウンが必要な場合は、UDPサーバのサーバランナー機能を利用します。

この例では、UDPサーバをポート番号8080で待ち受け、localhost:9090のUDPサーバへプロキシしています。

graph LR
  Client -- UDP --> P
  P --> Client
  P["UDP Proxy</br>(localhost:8080)"]
  P -- UDP --> U["Upstream</br>(localhost:9090)"]
  U --> P
package main

import (
	"log"

	"github.com/aileron-projects/go/znet/zudp"
)

func main() {
	svr := &zudp.Server{
		Addr:    ":8080",
		Handler: zudp.NewProxy("localhost:9090"),
	}

	log.Println("starting udp proxy server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil {
		panic(err)
	}
}

参考資料