LinuxでSurfshark® VPNの接続切り替えを行うツール

この記事は公開から3年以上経過しています。

ネット回覧時のプライバシー保護やセキュリティの確保、海外のネットワークサービス利用のために使っている方も多いのではないかと思う定番VPNサービスのSurfshark® VPN(以降Surfshark)ですが、Linuxで利用可能な公式接続ツールはDebian/Ubuntu用のみで、私のメインマシンopenSUSE(Leap15.3)で利用できるツールの提供がありません(2021.9.27現在)。

公式接続ツールを使わなくてもNetwork-ManagerOpenVPNコマンドで接続できますが、

Network-Managerでは、

  • 数百あるSurfShark用のovpnファイルをインポートして手動で接続設定の作成が必要。
  • 接続設定数が多いとポップアップメニューからの選択が大変。

OpenVPNコマンドでは、

  • 接続時に毎回コンソールからOpenVPNコマンドの実行が必要。
  • 接続先の切り替えや接続に失敗、切断された際に毎回ovpnファイル名の入力が必要。
  • VPN認証情報をクリアテキストでローカルディスクに保存したくない。

のような個人的不満があったため、今回はOpenVPNコマンドを直接叩くよりも気軽にSurfsharkの接続/切断/切り替えを行う簡易ツールをVSCode+.NET 5(C#)で作ってみましたので、紹介します。


ツール画面イメージ

file


導入手順

1. 前提条件

  • OpenVPN(2.4.x) Clientが導入済で/usr/sbin/openvpnコマンドが実行可能であること。
    ※OpenVPN(3.x)は仕様が異なるため利用できません。

  • .NET SDK 5.0が導入済であること(ソースビルドを行う場合)。

root権限で作業を行います(At your own risk)。

2. SurfsharkのOVPNファイルを導入

  1. 以下のコマンドでSurfshark公式サイトからOVPNファイルをダウンロードします。

    cd /etc/openvpn
    sudo wget https://my.surfshark.com/vpn/api/v1/server/configurations
  2. ダウンロードしたファイルを以下のコマンドで解凍します。

    sudo unzip configurations
  3. 解凍後はダウンロードファイルは不要になるので、必要に応じて以下のコマンドで削除します。

    sudo rm configurations

3. ツールの導入

1. ビルド済バイナリを利用する場合

  1. 以下のリンクからビルド済バイナリをダウンロードしてunzip ssconnector.zipコマンド等で任意の場所に解凍します。

    バイナリはMITライセンス、VirusTotalにてウィルスチェック済です。

  2. 解凍ができたら使い方へ進みます。

2. ソースを自前でビルドする場合

以下のコマンドで後述のソースコードをビルドします。

  • 本ツールの利用環境にNET SDK(Runtime) 5.0がインストールされている場合
dotnet publish -c Release
  • 本ツールを利用する環境に.NET SDK(Runtime) 5.0がインストールされていない場合(単一ファイル版)
dotnet publish -c ReleaseSingle

サンプルソースコード(C#)

SurfsharkConnector.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <AssemblyName>ssconnector</AssemblyName>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)'=='Release' ">
    <DebugSymbols>false</DebugSymbols>
    <DebugType>None</DebugType>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)'=='ReleaseSingle' ">
    <DebugSymbols>false</DebugSymbols>
    <DebugType>None</DebugType>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  </PropertyGroup>

</Project>

Program.cs

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;

namespace SurfsharkConnector
{
    /// <summary>
    /// Main class
    /// </summary>
    internal static class Program
    {
        /// <summary>
        /// Entry point
        /// </summary>
        /// <param name="_">Arguments(Unused)</param>
        private static void Main(string[] _)
        {
            // OpenVPN config directory
            const string OVPN_DIR_PATH = "/etc/openvpn";
            // OpenVPN command path
            const string OPENVPN_CMD_PATH = "/usr/sbin/openvpn";
            // TUN device MTU
            const int TUN_MTU = 1380;

            if (!Directory.Exists(OVPN_DIR_PATH))
            {
                Console.WriteLine($"Directory '{OVPN_DIR_PATH}' does not exist.");
                return;
            }

            if (!File.Exists(OPENVPN_CMD_PATH))
            {
                Console.WriteLine($"'{OPENVPN_CMD_PATH}' command does not exist.");
                return;
            }

            // Disable console cancel keys(For instance, ctrl + c)
            Console.CancelKeyPress += (o, e) => e.Cancel = true;

            // Config selecton loop
            (string? userName, string? password, bool hasAuth) = (null, null, false);
            while (true)
            {
                // Show ovpn list
                var ovpnMap = GetAndShowConfigs(OVPN_DIR_PATH);
                var numOfOvpns = ovpnMap.Count;
                if (numOfOvpns == 0)
                {
                    Console.WriteLine("Config files does not exist.");
                    return;
                }

                Console.WriteLine($"Enter the item number from [1] to [{numOfOvpns}] or enter [q] to exit.");

                var inputVal = Console.ReadLine();

                if (string.IsNullOrEmpty(inputVal))
                    continue;

                // Exit this app
                if (inputVal == "q")
                    return;

                if (!ovpnMap.ContainsKey(inputVal))
                    continue;

                if (!hasAuth)
                {
                    do
                    {
                        Console.WriteLine("Enter the VPN user name.");
                        userName = Console.ReadLine();
                        Console.WriteLine("Enter the VPN password.");
                        password = Console.ReadLine();
                    }
                    while ((userName?.Length & password?.Length) == 0);

                    // Password masking(Extra)
                    Console.SetCursorPosition(0, Console.CursorTop - 1);
                    Console.WriteLine("*****".PadRight(password?.Length ?? 0));

                    hasAuth = true;
                }

                // Run OpenVPN command
                var psi = new ProcessStartInfo()
                {
                    FileName = "su",
                    Arguments = $"-c \"openvpn --config {ovpnMap[inputVal]} --auth-user-pass <(echo -e '{userName}\n{password}') --tun-mtu {TUN_MTU} --mssfix {TUN_MTU - 40}\"",
                    UseShellExecute = false
                };
                try
                {
                    using var proc = Process.Start(psi);
                    if (proc == null)
                    {
                        Console.WriteLine("Process could not be started.");
                        continue;
                    }
                    proc.WaitForExit();
                }
                catch
                {
                    Console.WriteLine("Unexpected error occurred during process startup.");
                    continue;
                }
            }
        }

        /// <summary>
        /// Get and show configs
        /// </summary>
        /// <param name="ovpnDirPath">OpenVPN directory</param>
        /// <returns>Dictionary of config files mapped by number</returns>
        private static IReadOnlyDictionary<string, string> GetAndShowConfigs(string ovpnDirPath)
        {
            var ovpnMap = new Dictionary<string, string>();
            var ovpnFilePaths = Directory.GetFiles(ovpnDirPath, "??-*.surfshark.com_*.ovpn").OrderBy(f => f).ToArray();
            var numOfOvpns = ovpnFilePaths.Length;
            for (int i = 0; i < numOfOvpns; ++i)
            {
                var ovpnFilePath = ovpnFilePaths[i];
                var num = i + 1;
                ovpnMap.Add(num.ToString(), ovpnFilePath);
                var ovpnFileName = Path.GetFileNameWithoutExtension(ovpnFilePath);
                var countryCode = ovpnFileName.Substring(0, 2).ToUpper();
                var countryName = "Unknown";
                if (_countryMap.ContainsKey(countryCode))
                    countryName = _countryMap[countryCode];
                Console.WriteLine($"{num,3}: {countryName} ({ovpnFileName})");
            }
            return ovpnMap;
        }

        /// <summary>
        /// Country code mapping
        /// </summary>
        private static readonly IReadOnlyDictionary<string, string> _countryMap = new Dictionary<string, string>
        {
            {"AC","Ascension Island"},
            {"AD","Andorra"},
            {"AE","United Arab Emirates"},
            {"AF","Afghanistan"},
            {"AG","Antigua & Barbuda"},
            {"AI","Anguilla"},
            {"AL","Albania"},
            {"AM","Armenia"},
            {"AO","Angola"},
            {"AQ","Antarctica"},
            {"AR","Argentina"},
            {"AS","American Samoa"},
            {"AT","Austria"},
            {"AU","Australia"},
            {"AW","Aruba"},
            {"AX","Åland Islands"},
            {"AZ","Azerbaijan"},
            {"BA","Bosnia & Herzegovina"},
            {"BB","Barbados"},
            {"BD","Bangladesh"},
            {"BE","Belgium"},
            {"BF","Burkina Faso"},
            {"BG","Bulgaria"},
            {"BH","Bahrain"},
            {"BI","Burundi"},
            {"BJ","Benin"},
            {"BL","St. Barthélemy"},
            {"BM","Bermuda"},
            {"BN","Brunei"},
            {"BO","Bolivia"},
            {"BQ","Caribbean Netherlands"},
            {"BR","Brazil"},
            {"BS","Bahamas"},
            {"BT","Bhutan"},
            {"BV","Bouvet Island"},
            {"BW","Botswana"},
            {"BY","Belarus"},
            {"BZ","Belize"},
            {"CA","Canada"},
            {"CC","Cocos (Keeling) Islands"},
            {"CD","Congo - Kinshasa"},
            {"CF","Central African Republic"},
            {"CG","Congo - Brazzaville"},
            {"CH","Switzerland"},
            {"CI","Côte d'Ivoire"},
            {"CK","Cook Islands"},
            {"CL","Chile"},
            {"CM","Cameroon"},
            {"CN","China"},
            {"CO","Colombia"},
            {"CP","Clipperton Island"},
            {"CR","Costa Rica"},
            {"CU","Cuba"},
            {"CV","Cape Verde"},
            {"CW","Curaçao"},
            {"CX","Christmas Island"},
            {"CY","Cyprus"},
            {"CZ","Czechia"},
            {"DE","Germany"},
            {"DG","Diego Garcia"},
            {"DJ","Djibouti"},
            {"DK","Denmark"},
            {"DM","Dominica"},
            {"DO","Dominican Republic"},
            {"DZ","Algeria"},
            {"EA","Ceuta & Melilla"},
            {"EC","Ecuador"},
            {"EE","Estonia"},
            {"EG","Egypt"},
            {"EH","Western Sahara"},
            {"ER","Eritrea"},
            {"ES","Spain"},
            {"ET","Ethiopia"},
            {"EU","European Union"},
            {"FI","Finland"},
            {"FJ","Fiji"},
            {"FK","Falkland Islands"},
            {"FM","Micronesia"},
            {"FO","Faroe Islands"},
            {"FR","France"},
            {"GA","Gabon"},
            {"GD","Grenada"},
            {"GE","Georgia"},
            {"GF","French Guiana"},
            {"GG","Guernsey"},
            {"GH","Ghana"},
            {"GI","Gibraltar"},
            {"GL","Greenland"},
            {"GM","Gambia"},
            {"GN","Guinea"},
            {"GP","Guadeloupe"},
            {"GQ","Equatorial Guinea"},
            {"GR","Greece"},
            {"GS","South Georgia & South Sandwich Islands"},
            {"GT","Guatemala"},
            {"GU","Guam"},
            {"GW","Guinea-Bissau"},
            {"GY","Guyana"},
            {"HK","Hong Kong SAR China"},
            {"HM","Heard & McDonald Islands"},
            {"HN","Honduras"},
            {"HR","Croatia"},
            {"HT","Haiti"},
            {"HU","Hungary"},
            {"IC","Canary Islands"},
            {"ID","Indonesia"},
            {"IE","Ireland"},
            {"IL","Israel"},
            {"IM","Isle of Man"},
            {"IN","India"},
            {"IO","British Indian Ocean Territory"},
            {"IQ","Iraq"},
            {"IR","Iran"},
            {"IS","Iceland"},
            {"IT","Italy"},
            {"JE","Jersey"},
            {"JM","Jamaica"},
            {"JO","Jordan"},
            {"JP","Japan"},
            {"KE","Kenya"},
            {"KG","Kyrgyzstan"},
            {"KH","Cambodia"},
            {"KI","Kiribati"},
            {"KM","Comoros"},
            {"KN","St. Kitts & Nevis"},
            {"KP","North Korea"},
            {"KR","South Korea"},
            {"KW","Kuwait"},
            {"KY","Cayman Islands"},
            {"KZ","Kazakhstan"},
            {"LA","Laos"},
            {"LB","Lebanon"},
            {"LC","St. Lucia"},
            {"LI","Liechtenstein"},
            {"LK","Sri Lanka"},
            {"LR","Liberia"},
            {"LS","Lesotho"},
            {"LT","Lithuania"},
            {"LU","Luxembourg"},
            {"LV","Latvia"},
            {"LY","Libya"},
            {"MA","Morocco"},
            {"MC","Monaco"},
            {"MD","Moldova"},
            {"ME","Montenegro"},
            {"MF","St. Martin"},
            {"MG","Madagascar"},
            {"MH","Marshall Islands"},
            {"MK","North Macedonia"},
            {"ML","Mali"},
            {"MM","Myanmar (Burma)"},
            {"MN","Mongolia"},
            {"MO","Macao SAR China"},
            {"MP","Northern Mariana Islands"},
            {"MQ","Martinique"},
            {"MR","Mauritania"},
            {"MS","Montserrat"},
            {"MT","Malta"},
            {"MU","Mauritius"},
            {"MV","Maldives"},
            {"MW","Malawi"},
            {"MX","Mexico"},
            {"MY","Malaysia"},
            {"MZ","Mozambique"},
            {"NA","Namibia"},
            {"NC","New Caledonia"},
            {"NE","Niger"},
            {"NF","Norfolk Island"},
            {"NG","Nigeria"},
            {"NI","Nicaragua"},
            {"NL","Netherlands"},
            {"NO","Norway"},
            {"NP","Nepal"},
            {"NR","Nauru"},
            {"NU","Niue"},
            {"NZ","New Zealand"},
            {"OM","Oman"},
            {"PA","Panama"},
            {"PE","Peru"},
            {"PF","French Polynesia"},
            {"PG","Papua New Guinea"},
            {"PH","Philippines"},
            {"PK","Pakistan"},
            {"PL","Poland"},
            {"PM","St. Pierre & Miquelon"},
            {"PN","Pitcairn Islands"},
            {"PR","Puerto Rico"},
            {"PS","Palestinian Territories"},
            {"PT","Portugal"},
            {"PW","Palau"},
            {"PY","Paraguay"},
            {"QA","Qatar"},
            {"RE","Réunion"},
            {"RO","Romania"},
            {"RS","Serbia"},
            {"RU","Russia"},
            {"RW","Rwanda"},
            {"SA","Saudi Arabia"},
            {"SB","Solomon Islands"},
            {"SC","Seychelles"},
            {"SD","Sudan"},
            {"SE","Sweden"},
            {"SG","Singapore"},
            {"SH","St. Helena"},
            {"SI","Slovenia"},
            {"SJ","Svalbard & Jan Mayen"},
            {"SK","Slovakia"},
            {"SL","Sierra Leone"},
            {"SM","San Marino"},
            {"SN","Senegal"},
            {"SO","Somalia"},
            {"SR","Suriname"},
            {"SS","South Sudan"},
            {"ST","São Tomé & Príncipe"},
            {"SV","El Salvador"},
            {"SX","Sint Maarten"},
            {"SY","Syria"},
            {"SZ","Eswatini"},
            {"TA","Tristan da Cunha"},
            {"TC","Turks & Caicos Islands"},
            {"TD","Chad"},
            {"TF","French Southern Territories"},
            {"TG","Togo"},
            {"TH","Thailand"},
            {"TJ","Tajikistan"},
            {"TK","Tokelau"},
            {"TL","Timor-Leste"},
            {"TM","Turkmenistan"},
            {"TN","Tunisia"},
            {"TO","Tonga"},
            {"TR","Turkey"},
            {"TT","Trinidad & Tobago"},
            {"TV","Tuvalu"},
            {"TW","Taiwan"},
            {"TZ","Tanzania"},
            {"UA","Ukraine"},
            {"UG","Uganda"},
            {"UK","United Kingdom"},
            {"UM","U.S. Outlying Islands"},
            {"UN","United Nations"},
            {"US","United States"},
            {"UY","Uruguay"},
            {"UZ","Uzbekistan"},
            {"VA","Vatican City"},
            {"VC","St. Vincent & Grenadines"},
            {"VE","Venezuela"},
            {"VG","British Virgin Islands"},
            {"VI","U.S. Virgin Islands"},
            {"VN","Vietnam"},
            {"VU","Vanuatu"},
            {"WF","Wallis & Futuna"},
            {"WS","Samoa"},
            {"XK","Kosovo"},
            {"YE","Yemen"},
            {"YT","Mayotte"},
            {"ZA","South Africa"},
            {"ZM","Zambia"},
            {"ZW","Zimbabwe"},
        };
    }
}

GitHub Gist


使い方

  1. root権限でssconnectorコマンドを実行します。

    sudo ssconnector
  2. 接続先の一覧に続きプロンプトが出たら接続したい接続先の番号を入力してEnterキーを押下します。
    qを入力するとプログラムを終了します。

    Enter the item number from [1] to [286] or enter [q] to exit.
    113 # 接続先ovpn番号(本例では日本国内のTCP接続のサーバー)
  3. VPNのユーザー名とパスワードを入力してEnterを押下します。
    パスワード入力中はパスワードを表示するためショルダーハッキングに注意してください(入力完了後は*****のようにマスキングします)。

    Enter the VPN user name.
    xxxx # VPNのユーザー名
    Enter the VPN password.
    xxxx # VPNのパスワード
  4. ずらずらとメッセージが表示され、以下のメッセージが表示されればVPNに接続済完了です。

                   ・
                   ・
                   ・
    Thu Sep 23 02:02:39 2021 Initialization Sequence Completed

    接続できなかったり切断や接続先の切り替えを行いたい場合はCtrl+cを押下すれば1.の一覧画面に戻ります。

  5. 最後に以下のコマンドでVPN経由で接続できているかを確認します。

    curl ipinfo.io

    コマンド実行結果:

    {
      "ip": "xxx.xxx.xxx.xxx",
      "city": "Tokyo",
      "region": "Tokyo",
      "country": "JP",
      "loc": "35.6769,139.6520",
      "org": "AS9009 M247 Ltd",
      "postal": "168-0063",
      "timezone": "Asia/Tokyo",
      "readme": "https://ipinfo.io/missingauth"
    }

    curlコマンドがインストールされていない場合は、ブラウザ経由でipinfo.ioから確認できます。

メニューのq以外でプログラムを閉じてしまう(閉じるボタン押下等)と、起動中のOpenVPNプロセスは終了せずに残ります。プロセスが残ってしまった場合はsudo killall openvpnコマンド等でOpenVPNプロセスを終了してください。


ここからは余談ですが、私の場合UDP接続であれば速い時には下り75Mbps(回線はフレッツ光マンション VDSL 100Mbpsで)/上り40Mbps程度と私の用途では充分快適に使えています(インターネットの通信速度はベストエフォートなので、接続先や日時でもかなり変動します)。

参考までに本日2021.10.2 0:00頃のSurfsharkの速度測定結果を貼っておきます。

プロバイダー … OCN IPoE
file

フランス … 82: France (fr-mrs.prod.surfshark.com_udp)
file

台湾 … 200: Taiwan (tw-tai.prod.surfshark.com_udp)
file

VPN導入にあたっては幾つかの定番VPN業者で迷いましたが、

  • 2年分先払いなら月270円(当時の為替レート)程度と格安。
  • 接続台数の制限や帯域制限がない。
  • AndroidとWindowsであればWhitelister機能で任意のアプリをVPN経由にしたり、その逆も可能。

が私のニーズに完全にマッチしたことからSurfsharkに決めました。
30日間は返金可能ということなので、日頃のネットプライバシーが気になっている方は是非試してみてください。


以上です。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする