はじめに
はじめまして、開発本部 kintone アプリ化チームの okarin です。
先日、外部システムの kintone アプリ化のアーキテクチャを以下の記事で紹介しました。
アーキテクチャの候補のひとつだった SOCKS over SSH では、SOCKS プロトコルが使われています。SOCKS にはいくつかのバージョンがありますが、今回は RFC 1928 で定義された SOCKS5 を扱います。仕様がシンプルで、手を動かしながら学べて面白そうだったので、SOCKS サーバーを自作してみました。
SOCKS プロトコルの全体像は以下のようになります(今回は CMD=CONNECT のケースのみ考慮)。この図のうち、SOCKS サーバーの部分を実装していきます。
sequenceDiagram
participant C as クライアント
participant S as SOCKS サーバー
participant T as 宛先
Note over C,S: ① 認証方式のネゴシエーション
C->>S: 対応する認証方式の候補
S->>C: 選択した認証方式
Note over C,S: ② SOCKS リクエスト(CMD=CONNECT)
C->>S: 接続要求
Note over S,T: ③ 宛先へ接続
S->>T: TCP 接続
T->>S: 接続確立
S->>C: 応答
Note over C,T: ④ データ転送(SOCKS サーバーが中継)
C->>S: リクエスト
S->>T: 転送
T->>S: レスポンス
S->>C: 転送
実装には kintone 開発でも利用している Java を使用しています。なお学習目的のため、例外処理などは簡略化しているのでご注意ください。
今回実装したコードの全体像をみたい方は以下のリポジトリをご参照ください。
1. Echo サーバーを作る
まずはシンプルな Echo サーバーを作っていきます。以下のようなディレクトリ構成とします。
.
└── src
├── Main.java
└── SocksServer.java
Main.java を以下のように実装します。SocksServer を生成して run() を実行するだけのシンプルな処理にします。
import java.io.IOException; public class Main { public static void main(String[] args) throws IOException { SocksServer server = new SocksServer(); server.run(); } }
SocksServer.java は以下のようになります。今後出てくるコードは SocksServer.java のコードになります。
SocksServer では、ServerSocket を生成して、accept()) で新しいソケットを作成して接続要求を待機します。
接続できたら入力のストリームの内容を読み取り、そのまま出力のストリームに書き込みます。
import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.ServerSocket; import java.net.Socket; public class SocksServer { private final int PORT = 1080; public void run() throws IOException { System.out.println("socks server is running on localhost:" + PORT); try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { try (Socket socket = serverSocket.accept()) { InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); int b; while ((b = in.read()) != -1) { out.write(b); } } } } } }
実際に動かしてみましょう。
javac -d out src/*.java && java -cp out Main
別のターミナルウィンドウを開いて、nc コマンドで接続した後、適当に入力してみます。すると、入力がそのまま返ってくることが確認できます。
nc localhost 1080 # 適当な入力がそのまま返ってくる test message test message
2. 転送サーバーを作る
シンプルな Echo サーバーができたので、次はリクエストを転送するサーバーを実装していきます。
まずは run() メソッドを以下のように書き換えます。accept() 以降の処理を別メソッドに切り出し、仮想スレッド上で非同期で実行するようにします。こうすることで、複数のクライアントのリクエストに同時に対応できるようにします。
public void run() throws IOException { System.out.println("socks server is running on localhost:" + PORT); try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket socket = serverSocket.accept(); Thread.ofVirtual().start(() -> { try { handleRequest(socket); } catch (IOException e) { e.printStackTrace(); } }); } } }
続いて、pump() メソッドを作成します。このメソッドでは InputStream の内容を読み取って OutputStream に書き込みます。
private void pump(InputStream in, OutputStream out) throws IOException { byte[] buf = new byte[1024]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } }
handleRequest() メソッドは以下のようになります。
簡単のため、ここでは転送先は example.com に固定して、リクエストを転送していきます。
pump(in, targetOutput); でクライアントからのリクエストを読み取って、そのまま転送先の example.com の OutputStream に書き込んでいます。一方、example.com からのレスポンスは、別スレッドの pump(targetInput, out); で読み取り、クライアント側の OutputStream に書き込みます。
shutdownOutput() は、こちらからの送信が終わったことを相手に伝えるためのメソッドです。ソケットをクローズしてしまうと、レスポンスの読み取りができなくなってしまうので、書き込みが終了したことだけを伝えるためにこのメソッドを利用します。
private void handleRequest(Socket socket) throws IOException { String targetHost = "example.com"; int targetPort = 80; try (socket; Socket targetSocket = new Socket(targetHost, targetPort)) { InputStream targetInput = targetSocket.getInputStream(); OutputStream targetOutput = targetSocket.getOutputStream(); InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); Thread t = Thread.ofVirtual().start(() -> { try { pump(targetInput, out); socket.shutdownOutput(); } catch (IOException e) { e.printStackTrace(); } }); pump(in, targetOutput); targetSocket.shutdownOutput(); try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
ここまで実装できたら、実際に動かしてみましょう。
javac -d out src/*.java && java -cp out Main
別のターミナルウィンドウで以下の curl コマンドを実行すると、example.com からのレスポンスが返ってきます。
curl -H "Host: example.com" http://localhost:1080/
3. SOCKS プロトコルの認証方式を解釈する
ここから SOCKS プロトコルに触れていきます。
SOCKS では、以下の流れでリクエストをリレーします。
- クライアントが、使用したい認証方式の候補を送る(認証リクエスト)
- サーバーが認証方式を選択してクライアントに返す
- 選択された認証方式で認証を行う
- クライアントがリレー要求を送信する
- サーバーがリレー要求を評価して、適切な接続を確立するか、または拒否する
まずは最初の認証方式を解釈できるように実装していきます。

VER は SOCKS プロトコルのバージョンで、今回は 0x05 固定になります。NMETHODS は後続の METHODS が何個あるかを示します。METHODS は以下で決められた値のいずれかとなります。
| METHODS の値 | 意味 |
|---|---|
0x00 |
認証不要 |
0x01 |
GSSAPI |
0x02 |
ユーザー名・パスワード |
0x03 ~ 0x7F |
IANA 割り当て |
0x80 ~ 0xFE |
プライベート方式のために予約 |
0xFF |
受け入れ可能な方式なし |
これに対するレスポンスは、 VER と METHOD の2バイトのメッセージになります。サーバーはリクエストで与えられた METHODS のうちから1つを選択して METHOD として返します。

今回は認証不要な 0x00 のみサポートする方針で実装を進めます。
まずは、便利なメソッドを実装しておきます。dump() はリクエスト内容を読みやすい形式で出力するためのメソッドになります。print() は id 付きのログ出力を簡単にするためのメソッドで、bytes() は byte[] 型の配列を簡単に生成するためのメソッドになります。
private void dump(long id, String label, byte[] buf, int len) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < len; i++) { sb.append(String.format("%02x ", buf[i] & 0xFF)); } print(id, "%s: %s", label, sb); } private void print(long id, String format, Object... args) { String body = String.format(format, args); System.out.println("[#" + id + "] " + body); } private byte[] bytes(int... values) { byte[] buf = new byte[values.length]; for (int i = 0; i < values.length; i++) { buf[i] = (byte) values[i]; } return buf; }
次に、定数を定義しておきます。
public class SocksServer { private final int PORT = 1080; private final int SOCKS_VERSION = 0x05; private final int NO_AUTH_REQUIRED = 0x00; private final int NO_ACCEPTABLE_METHODS = 0xFF; // ... }
handleRequest() の処理を一旦コメントアウトしておいて、handleAuthRequest() を呼ぶようにします。また、引数に requestId を追加します。
private void handleRequest(long requestId, Socket socket) throws IOException { try (socket) { InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); handleAuthRequest(requestId, in, out); } // String targetHost = "example.com"; // int targetPort = 80; // ... }
handleAuthRequest() では、認証リクエストを解釈していきます。
まずは最初の2バイトを読み込んで、バージョンと、メソッドが何個あるかを読み取ります。その後、メソッド数分だけ読み込み、認証不要のメソッド(0x00)が含まれるかどうかを確認します。認証不要のメソッドがあった場合は、そのメソッドを選択したことをレスポンスとして返し、メソッドがない場合は受け入れ可能なメソッドがないというレスポンスを返します。
private void handleAuthRequest(long requestId, InputStream in, OutputStream out) throws IOException { byte[] authMethodRequestHeader = in.readNBytes(2); if (authMethodRequestHeader.length < 2) { throw new IllegalArgumentException("authentication request size must be greater than or equal to 2"); } dump(requestId, "authMethodHeader", authMethodRequestHeader, 2); if (authMethodRequestHeader[0] != SOCKS_VERSION) { throw new IllegalArgumentException("socks version is not supported"); } // `& 0xFF` の論理積は、byte の値を int に変換する際に 0~255 の非負整数として表現するために必要 int methodsNum = authMethodRequestHeader[1] & 0xFF; if (methodsNum == 0) { out.write(bytes(SOCKS_VERSION, NO_ACCEPTABLE_METHODS)); throw new IllegalArgumentException("method number is invalid"); } byte[] authMethods = in.readNBytes(methodsNum); if (authMethods.length != methodsNum) { throw new IllegalArgumentException("invalid auth methods"); } dump(requestId, "authMethods", authMethods, methodsNum); for (int i = 0; i < authMethods.length; i++) { if ((authMethods[i] & 0xFF) == NO_AUTH_REQUIRED) { out.write(bytes(SOCKS_VERSION, NO_AUTH_REQUIRED)); return; } } out.write(bytes(SOCKS_VERSION, NO_ACCEPTABLE_METHODS)); throw new IllegalArgumentException("no acceptable methods"); }
最後に、handleRequest() 実行時に requestId を渡すようにします。
public void run() throws IOException { System.out.println("socks server is running on localhost:" + PORT); try (ServerSocket serverSocket = new ServerSocket(PORT)) { long id = 1; while (true) { final long requestId = id; Socket socket = serverSocket.accept(); Thread.ofVirtual().start(() -> { try { handleRequest(requestId, socket); } catch (IOException e) { e.printStackTrace(); } }); id++; } } }
ここまで実装できたら、curl でリクエストを送ってみましょう。
javac -d out src/*.java && java -cp out Main # 別ターミナルウィンドウで curl を実行する curl -v --socks5 localhost:1080 http://example.com
すると、サーバー側で以下のようなログが出力されます。 0x05 0x02 0x00 0x01 という認証リクエストが送られて、それをサーバー側でハンドリングできていることが分かります。
socks server is running on localhost:1080 [#1] authMethodHeader: 05 02 [#1] authMethods: 00 01
4. SOCKS リクエストを解釈する
認証方式のやり取りが終わった後は、以下のような SOCKS リクエストがクライアントから送られてきます。

CMD はコマンドの略で CONNECT、BIND、UDP ASSOCIATE の3種類がありますが、今回は CONNECT(0x01) のみ考慮します。
RSV は予約フィールド(RESERVED)で、0x00 固定になります。
ATYP は宛先のアドレスタイプで、以下の表のようになります。
| ATYP の値 | 意味 |
|---|---|
0x01 |
IPv4 アドレス |
0x03 |
ドメイン名 |
0x04 |
IPv6 アドレス |
DST.ADDR や DST.PORT は、それぞれ希望する宛先のアドレスとポート番号になります。
レスポンスは以下の図のようになります。REP は応答コードで、BND.ADDR や BND.PORT はそれぞれバインドしたアドレス、ポート番号を表します。

REP の主要な値は以下のようになります。
| REP の値 | 意味 |
|---|---|
0x00 |
成功 |
0x04 |
ホストに到達できない |
0x05 |
接続が拒否された |
0x07 |
対応していないコマンド |
0x08 |
対応していないアドレスタイプ |
それでは、実装を進めていきます。
まずは定数を追加して、転送先のアドレス情報を保持する TargetAddress レコードクラスを定義しておきます。
public class SocksServer { // .. private final int CMD_CONNECT = 0x01; private final int REPLY_SUCCEEDED = 0x00; private final int REPLY_CONNECTION_REFUSED = 0x05; private final int REPLY_COMMAND_NOT_SUPPORTED = 0x07; private final int REPLY_ADDRESS_TYPE_NOT_SUPPORTED = 0x08; private final int RESERVED = 0x00; private final int ADDRESS_TYPE_IPV4 = 0x01; private final int ADDRESS_TYPE_IPV6 = 0x04; private record TargetAddress(String host, int port, int addressType, byte[] addressBytes, byte[] portBytes) { } }
handleRequest() で認証リクエストをハンドリングしたあとに、TargetAddress をパースする parseTargetAddress() を実行するようにします。
private void handleRequest(long requestId, Socket socket) throws IOException { try (socket) { InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); handleAuthRequest(requestId, in, out); TargetAddress address = parseTargetAddress(requestId, in, out); } }
parseTargetAddress() は以下のようになります。コマンドが CONNECT であるかどうかをチェックした後、アドレスタイプを確認して、IPv4 の場合のみ次の処理に進みます。
private TargetAddress parseTargetAddress(long requestId, InputStream in, OutputStream out) throws IOException { byte[] socksRequestHeader = in.readNBytes(4); if (socksRequestHeader.length < 4) { throw new IllegalArgumentException("socks request size is too small"); } dump(requestId, "SocksRequestHeader", socksRequestHeader, 4); if (socksRequestHeader[0] != SOCKS_VERSION) { throw new IllegalArgumentException("socks version is not supported"); } if (socksRequestHeader[1] != CMD_CONNECT) { out.write( bytes(SOCKS_VERSION, REPLY_COMMAND_NOT_SUPPORTED, RESERVED, ADDRESS_TYPE_IPV4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)); throw new IllegalArgumentException("command is not supported"); } return switch (socksRequestHeader[3]) { case ADDRESS_TYPE_IPV4 -> parseIPv4TargetAddress(requestId, in, out); default -> { out.write( bytes(SOCKS_VERSION, REPLY_ADDRESS_TYPE_NOT_SUPPORTED, RESERVED, ADDRESS_TYPE_IPV4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)); throw new IllegalArgumentException("address type is not supported"); } }; }
parseIPv4TargetAddress() は以下のようになります。アドレスとポート番号に分けて読み取ります。読み取った結果は byte[] なので、適切に変換してから TargetAddress レコードクラスのインスタンスを生成します。
private TargetAddress parseIPv4TargetAddress(long requestId, InputStream in, OutputStream out) throws IOException { byte[] hostBytes = in.readNBytes(4); if (hostBytes.length < 4) { throw new IllegalArgumentException("host size is too small"); } byte[] portBytes = in.readNBytes(2); if (portBytes.length < 2) { throw new IllegalArgumentException("port size is too small"); } dump(requestId, "ipv4 host", hostBytes, 4); dump(requestId, "ipv4 port", portBytes, 2); String host = String.format("%d.%d.%d.%d", hostBytes[0] & 0xFF, hostBytes[1] & 0xFF, hostBytes[2] & 0xFF, hostBytes[3] & 0xFF); int port = ((portBytes[0] & 0xFF) << 8) | (portBytes[1] & 0xFF); return new TargetAddress(host, port, ADDRESS_TYPE_IPV4, hostBytes, portBytes); }
ここまで実装すると、SOCKS リクエストから TargetAddress をパースすることができます。この結果を利用してレスポンスを返すところを実装していきます。
handleRequest() で TargetAddress がもつ情報から Socket を生成します。
private void handleRequest(long requestId, Socket socket) throws IOException { try (socket) { // ... TargetAddress address = parseTargetAddress(requestId, in, out); Socket targetSocket; try { targetSocket = new Socket(address.host, address.port); } catch (IOException e) { // 失敗理由に応じて応答コードを適切に返す必要があるが、ここでは簡単のため REFUSED とする out.write(bytes(SOCKS_VERSION, REPLY_CONNECTION_REFUSED, RESERVED, ADDRESS_TYPE_IPV4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)); return; } try (targetSocket) { // ... } } }
Socket の生成に成功したら、バインドしたアドレスとポート番号を取得して、接続に成功したことをクライアントに返します。
その後の処理は、以前実装してコメントアウトしておいたものとほぼ同じです。SOCKS リクエストに続く入力を TargetAddress 宛に転送し、そのレスポンスをクライアントに返します。
private void handleRequest(long requestId, Socket socket) throws IOException { try (socket) { // ... try (targetSocket) { // バインドしたアドレスとポート番号を取得 InetAddress addr = targetSocket.getLocalAddress(); byte[] bindAddress = addr.getAddress(); int bindPort = targetSocket.getLocalPort(); byte[] bindPortBytes = bytes(bindPort >> 8, bindPort); int addressType = bindAddress.length == 4 ? ADDRESS_TYPE_IPV4 : ADDRESS_TYPE_IPV6; print(requestId, "CONNECT %s:%d", address.host, address.port); out.write(bytes(SOCKS_VERSION, REPLY_SUCCEEDED, RESERVED, addressType)); out.write(bindAddress); out.write(bindPortBytes); // ここから下は以前に実装した内容と同様 InputStream targetInput = targetSocket.getInputStream(); OutputStream targetOutput = targetSocket.getOutputStream(); Thread t = Thread.ofVirtual().start(() -> { try { pump(targetInput, out); socket.shutdownOutput(); } catch (IOException e) { e.printStackTrace(); } }); pump(in, targetOutput); targetSocket.shutdownOutput(); try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } } }
それでは実際に動かしてみましょう。
curl の --socks5 オプションではクライアント側でアドレスを解決して SOCKS リクエストを送るようになります。
javac -d out src/*.java && java -cp out Main # 別ターミナルウィンドウで curl を実行する curl -v --socks5 localhost:1080 http://example.com
「Host example.com:80 was resolved.」という文言がでて、example.com からのレスポンスが返ってきていたら成功になります。
5. DOMAIN_NAME に対応
ここまでの実装では、curl が example.com の IPv4 アドレスを解決してから SOCKS サーバーにリクエストを送っていました。
しかし、Internal なネットワークの場合などはクライアント側で IP アドレスを解決することができません。そのため、SOCKS サーバー側で IP アドレスを解決できるようにしていきます。
まずは定数を追加します。
public class SocksServer { // ... private final int REPLY_SUCCEEDED = 0x00; private final int REPLY_HOST_UNREACHABLE = 0x04; // 追加 private final int REPLY_CONNECTION_REFUSED = 0x05; private final int REPLY_COMMAND_NOT_SUPPORTED = 0x07; private final int REPLY_ADDRESS_TYPE_NOT_SUPPORTED = 0x08; private final int RESERVED = 0x00; private final int ADDRESS_TYPE_IPV4 = 0x01; private final int ADDRESS_TYPE_DOMAIN_NAME = 0x03; // 追加 private final int ADDRESS_TYPE_IPV6 = 0x04; // ... }
parseTargetAddress() でアドレスタイプがドメイン名(0x03)の場合の処理を追加します。
private TargetAddress parseTargetAddress(long requestId, InputStream in, OutputStream out) throws IOException { // ... return switch (socksRequestHeader[3]) { case ADDRESS_TYPE_IPV4 -> parseIPv4TargetAddress(requestId, in, out); case ADDRESS_TYPE_DOMAIN_NAME -> parseDomainNameTargetAddress(requestId, in, out); default -> { out.write( bytes(SOCKS_VERSION, REPLY_ADDRESS_TYPE_NOT_SUPPORTED, RESERVED, ADDRESS_TYPE_IPV4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)); throw new IllegalArgumentException("address type is not supported"); } }; }
parseDomainNameTargetAddress() を以下のように実装します。処理が長いので、まずは前半のリクエストを読み取る部分だけ見ていきます。
ドメイン名の場合は先頭1バイトがドメイン名の長さになるので、1バイトだけ読み取ります。その後、ドメイン名の長さ分だけ読み取って、最後の2バイトのポート番号を読み取ります。
private TargetAddress parseDomainNameTargetAddress(long requestId, InputStream in, OutputStream out) throws IOException { // 先頭1バイトでドメイン名の長さを取得 byte[] domainNameLengthBytes = in.readNBytes(1); if (domainNameLengthBytes.length < 1) { throw new IllegalArgumentException("domain name length is invalid"); } // ドメイン名の長さ分だけ読み取る int domainNameLength = domainNameLengthBytes[0] & 0xFF; byte[] domainBytes = in.readNBytes(domainNameLength); if (domainBytes.length < domainNameLength) { throw new IllegalArgumentException("domain name is too short"); } // ポート番号を読み取る byte[] portBytes = in.readNBytes(2); if (portBytes.length < 2) { throw new IllegalArgumentException("port size is too small"); } dump(requestId, "domainNameLength", domainNameLengthBytes, 1); dump(requestId, "domain", domainBytes, domainNameLength); dump(requestId, "port", portBytes, 2); // ... }
リクエストを読み取った後は、byte[] から適切に変換して、InetAddress.getByName を利用してドメイン名から IP アドレスを解決します。アドレスを解決することができたら、アドレスタイプを確認してから TargetAddress レコードクラスのインスタンスを生成します。
private TargetAddress parseDomainNameTargetAddress(long requestId, InputStream in, OutputStream out) throws IOException { // ↓ dump 以降 // ドメイン名を byte[] から String に変換 String domainName = new String(domainBytes, StandardCharsets.US_ASCII); // ポート番号を int に変換 int port = ((portBytes[0] & 0xFF) << 8) | (portBytes[1] & 0xFF); InetAddress address; try { // ドメイン名から IP アドレスを解決 address = InetAddress.getByName(domainName); } catch (UnknownHostException e) { out.write(bytes(SOCKS_VERSION, REPLY_HOST_UNREACHABLE, RESERVED, ADDRESS_TYPE_IPV4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)); throw e; } print(requestId, "%s is resolved: %s", domainName, address.getHostAddress()); byte[] resolvedAddress = address.getAddress(); int addressType = resolvedAddress.length == 4 ? ADDRESS_TYPE_IPV4 : ADDRESS_TYPE_IPV6; return new TargetAddress(address.getHostAddress(), port, addressType, resolvedAddress, portBytes); }
これでドメイン名から IP アドレスに変換して SOCKS リクエストを処理することができるようになります。以下のコマンドで確認してみましょう。
curl の --socks5-hostname オプションは、クライアント側でアドレス解決せずにサーバー側で解決するオプションになります。
javac -d out src/*.java && java -cp out Main # 別ターミナルウィンドウで curl を実行する curl -v --socks5-hostname localhost:1080 http://example.com
「SOCKS5 connect to example.com:80 (remotely resolved)」 という文言がでて、example.com からレスポンスが返ってきていたら成功になります。
まとめ
以上で、SOCKS サーバーの基本的な部分を実装できました。RFC を読みながら実際に手を動かして自作することで、SOCKS プロトコルへの理解が深まりました。みなさんも気になるプロトコルがあったら、ぜひ自作してみてください。
サイボウズではさまざまな職種のエンジニアの仲間を増やしているところなので、興味がある方はぜひ下記のリンクからご応募ください。