TCPサーバ・プロキシ

概要

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

機能

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

1. TCPサーバ機能

TCPサーバとして、クライアントからのTCP接続を受け付け、ハンドラーに処理を委譲します。 TCPサーバはTLSに対応しています。

  • non-TLS TCPサーバ
  • TLS TCPサーバ

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

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

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

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

2. TCPサーバランナー

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

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

3. TCPプロキシ機能

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

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

転送先ダイアルについて、TCPプロキシはそれ自体が転送先サーバを決定する機能を持ちません。 かわりに、以下のような関数シグネチャのフィールドを公開することで、 具体的な転送先サーバの決定をユーザに委ねます。 TCPプロキシは、このDial関数を利用して転送先サーバを決定します。

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

パッケージで用意されているプロキシの利用法はデフォルトのプロキシを参照ください。

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

TCPサーバはTLSを利用可能です。 TLS機能はGo言語の標準機能をそのまま利用しています。

その他、TCPサーバとして特別考慮しているセキュリティはありません。

また、znetパッケージの機能を用いることで以下のセキュリティ対策が可能です。

性能に関する特記事項

性能面での特記事項はありません。

実装例・使い方

TCPサーバ

最も基本的なTCPサーバの実装は以下の通りです。 TLSは利用していません。 この実装例のハンドラは、受信したTCPデータを標準出力に出力するのみです。

package main

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

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

// handleConn reads and prints TCP data received from the conn.
func handleConn(ctx context.Context, conn net.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 := &ztcp.Server{
		Addr:    ":8080",
		Handler: ztcp.HandlerFunc(handleConn),
	}
	log.Println("starting tcp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

TCPサーバランナー

TCPサーバランナーを利用すると、グレースフルシャットダウンを簡単に実現できます。 シャットダウンタイムアウトが発生した際には、既存のTCPコネクションのクローズ処理も実行してくれます。

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

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

	runner := &ztcp.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 tcp server at " + svr.Addr)
	if err := runner.Run(ctx); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

TLS

TLSを利用したTCPサーバの実装例は以下の通りです。 TLSサーバのcertファイルとkeyファイルのパスを指定するのみでも起動可能、より細かいTLSの設定をする場合はServer.TLSConfigを利用します。

func main() {
	svr := &ztcp.Server{
		Addr:      ":8080",
		Handler:   ztcp.HandlerFunc(handleConn),
		TLSConfig: nil,
	}
	log.Println("starting tcp server at " + svr.Addr)
	if err := svr.ListenAndServeTLS("cert.pem", "key.pem"); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

コネクション数制限

znetパッケージの機能を利用して、 同時コネクション数を制限することが可能です。 実装例は以下となります。一部エラー処理は省略しています。

func main() {
	svr := &ztcp.Server{
		Addr:    "", // This is not used when we call [Server.Serve].
		Handler: ztcp.HandlerFunc(handleConn),
	}

	ln, _ := net.Listen("tcp", ":8080") // Create a new TCP listener.
	ln = znet.NewLimitListener(ln, 10)  // Limit concurrency.

	log.Println("starting tcp server at " + ln.Addr().String())
	if err := svr.Serve(ln); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

IPホワイトリスト

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

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

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

func main() {
	svr := &ztcp.Server{
		Addr:    "", // This is not used when we call [Server.Serve].
		Handler: ztcp.HandlerFunc(handleConn),
	}

	ln, _ := net.Listen("tcp", ":8080")
	ln, _ = znet.NewWhiteListListener(ln, "127.0.0.1/32", "::1/128")

	log.Println("starting tcp server at " + ln.Addr().String())
	if err := svr.Serve(ln); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

IPブラックリスト

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

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

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

func main() {
	svr := &ztcp.Server{
		Addr:    "", // This is not used when we call [Server.Serve].
		Handler: ztcp.HandlerFunc(handleConn),
	}

	ln, _ := net.Listen("tcp", ":8080")
	ln, _ = znet.NewBlackListListener(ln, "192.168.0.0/16")

	log.Println("starting tcp server at " + ln.Addr().String())
	if err := svr.Serve(ln); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

Unixドメインソケット

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

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

func main() {
	// You can use curl to check if it works.
	// curl --unix-socket '/var/run/example.sock' http://localhost:8080/example
	svr := &ztcp.Server{
		Addr:    "unix:///var/run/example.sock",
		Handler: ztcp.HandlerFunc(handleConn),
	}
	log.Println("starting tcp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

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

func main() {
	// You can use curl to check if it works.
	// curl --abstract-unix-socket 'example' http://localhost:8080/example
	svr := &ztcp.Server{
		Addr:    "unix://@example",
		Handler: ztcp.HandlerFunc(handleConn),
	}
	log.Println("starting tcp server at " + svr.Addr)
	if err := svr.ListenAndServe(); err != nil && err != ztcp.ErrServerClosed {
		panic(err)
	}
}

デフォルトのプロキシ

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

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

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

package main

import (
	"log"

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

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

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

TLSによるプロキシ

転送先サーバとの間でTLS通信を利用する場合、ユーザ自身で転送先サーバとのコネクションを確立する必要があります。 コネクションを確立する処理はDial関数に記述します。

以下が実装例になります。

package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"log"
	"net"
	"os"

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

func main() {
	pem, _ := os.ReadFile("cert.pem")
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(pem)

	svr := &ztcp.Server{
		Addr: ":8080",
		Handler: &ztcp.Proxy{
			Dial: func(ctx context.Context, dc net.Conn) (uc net.Conn, err error) {
				return tls.Dial("tcp", "localhost:9090", &tls.Config{RootCAs: pool})
			},
		},
	}

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

この例はプロキシサーバと転送先サーバ間のみがTLSであり、クライアント側は非TLS通信になっています。 クライアント側もTLSにする場合はTCPサーバに対してTLSの設定を行います。

TLSパススルーを行う際は、通常のTCPプロキシのみで対応可能ですが、 SNI (Server Name Indication)を利用した負荷分散などを行う際は実装が必要です。

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

参考資料