当以一种比较粗放的方式去管理拨号连接时,我们一般会使用默认值新建一个连接,按需点击“连接”按钮即可实现访问需求。但是这种使用会带来一些问题:由于“远程网关”的配置项默认是开启状态,所有流量都会从远程服务器所在的网络走,而实际上我们的目的往往只是为了访问目标网络中的某些有限资源,这就让拨号服务承担了过多无关流量,网络质量将比正常访问互联网要糟糕得多。

为了更精准地引导我们的流量(全局代理不在讨论范围之内,这个场景本身即要求所有流量从远程绕一遍),推荐的做法是:取消远程网关设置,精准地为需要访问的远程资源添加路由,其它流量则正常从默认网关出站。流程如下:

  1. 新建拨号连接(pptp, sstp, l2tp, ike, 甚至pppoe);
  2. 修改设置(比如设置加密方式、身份验证等项目,关闭远程网关),保存用户名密码;
  3. 需要使用时,拨号,添加去往所有目标网段的静态路由,Next Hop为拨号连接获取的IP。

前两步还好,设置完成以后就不需要再动了,拨号这一步比较麻烦,每次都需要手动拨号,并添加静态路由,甚至有些网络不止一个网段,而且还做不了路由汇总,这时候就可以借助自动维护来完成繁琐的操作。

工具

windows自带了两个拨号软件,rasdial和rasphone,用法类似,都是对电脑中的拨号连接进行操作。


rasdial /?
用法:
        rasdial entryname [username [password|*]] [/DOMAIN:domain]
                [/PHONE:phonenumber] [/CALLBACK:callbacknumber]
                [/PHONEBOOK:phonebookfile] [/PREFIXSUFFIX]

        rasdial [entryname] /DISCONNECT

        rasdial

想要实现自动化,只要将连接提前设置好,再按个人喜好,使用开发或者脚本手段借助rasdial或者rasphone命令即可拨号,完成拨号动作后,利用route add命令添加静态路由,就完成了整个拨号的流程。

rasdial的坑:必须手动指定用户名、密码,但是明文存储用户名密码不是一个好的选择,如果拨号连接使用EAP认证,则可以正常拨号。

rasphone的坑:可以直接使用保存的用户名密码,但是在Windows11中登录窗口无法跳过(较早版本的windows中可以通过修改phonebook的PreviewUserPw项来跳过),需要人工干预,而保存了用户名密码的L2TP连接则可以直接拨号。

那么,根据不同的连接类型来调用不同的拨号程序则是自动化首要解决的问题。

一个演示


@ECHO OFF
COLOR 0A
TITLE VPN dialer
CACLS "%SYSTEMROOT%\System32\Config\System" >nul 2>&1 
IF NOT %errorlevel%==0 (
  ECHO 当前没有管理员权限,无法添加路由,请在弹出的窗口中点“是”
  PING 127.1 -n 3 >nul
  GOTO UACPrompt
) ELSE ( GOTO TASK )

:UACPrompt
  ECHO SET UAC = CreateObject^("Shell.Application"^) > "%temp%\reqadmin.vbs"
  ECHO UAC.ShellExecute "%~s0", "", "", "runas", 1 >> "%temp%\reqadmin.vbs"  
  "%temp%\reqadmin.vbs"
  EXIT /B

:TASK
  IF EXIST "%temp%\reqadmin.vbs" ( DEL "%temp%\reqadmin.vbs" )
  RASPHONE
  REM 只用rasphone命令会出现弹窗,需要手动选择,也可以用rasphone -d 拨号名直接进行拨号
  ROUTE DELETE 192.168.1.0 mask 255.255.255.0 >nul
  ROUTE DELETE 10.0.0.0 mask 255.255.255.0 >nul
  ROUTE DELETE 172.16.0.0 mask 255.255.0.0 >nul
  ROUTE -p add 192.168.1.0 mask 255.255.255.0 192.168.50.100 >nul
  ROUTE -p add 10.0.0.0 mask 255.255.255.0 192.168.60.1 >nul
  ROUTE -p add 172.16.0.0 mask 255.255.0.0 192.168.60.1 >nul
  ROUTE PRINT -4
  ECHO OK
  PAUSE

另一个演示

下面以C#为例,演示如何遍历所有连接、获取连接状态、拨号、挂断。

界面和功能比较简单,借助DotRas类库实现连接的枚举、状态获取、挂断连接等功能,借助rasdial和rasphone实现拨号功能。

    /// <summary>
    /// 一个简化的拨号连接对象
    /// </summary>
    public class EntrySimple
    {
        /// <summary>
        /// 拨号连接名
        /// </summary>
        public string Name { get; set; }
        /// <summary>
        /// 拨号服务器地址
        /// </summary>
        public string PhoneNumber { get; set; }
        /// <summary>
        /// 是否EAP认证,根据此项决定使用哪个程序拨号
        /// </summary>
        public bool TypeEAP { get; set; }
        /// <summary>
        /// 路由条目
        /// </summary>
        public List<string> Routes { get; set; }
        /// <summary>
        /// 获取这个拨号条目对应的连接,未拨号时将返回null
        /// </summary>
        RasConnection Connection => RasConnection.GetActiveConnections().FirstOrDefault(c => c.EntryName == Name);
        public EntrySimple(string name, string phoneNumber, bool eap)
        {
            TypeEAP = eap;
            Name = name;
            PhoneNumber = phoneNumber;
            Routes = new();
        }
        /// <summary>
        /// 拨号功能,先拨号,再添加路由
        /// </summary>
        public void Connect()
        {
            if (Connection is null)
            {
                if (TypeEAP)
                {//也可以用DotRas.Dialer.Dial()替代
                    Process.Start(
                        new ProcessStartInfo("rasdial")
                        {
                            Arguments = Name,
                            WindowStyle = ProcessWindowStyle.Hidden
                        }).WaitForExit();
                }
                else
                {
                    Process.Start("rasphone", $"-d {Name}").WaitForExit();
                }
                var iface = NetworkInterface.GetAllNetworkInterfaces().Where(i => i.Name == Name).FirstOrDefault();
                if (iface != null)
                {//查询拨号连接获取到的地址
                    string ip = iface.GetIPProperties().UnicastAddresses[0].Address.ToString();
                    foreach (var route in Routes)
                    {
                        string strRoute = route.Replace("DIALIP", ip);
                        Process.Start(new ProcessStartInfo("cmd") { Arguments = $"/c ROUTE ADD {strRoute}" }).WaitForExit();
                    }
                }
            }
        }
        /// <summary>
        /// 是否处于连接状态
        /// </summary>
        public bool IsConnected => Connection != null;
        /// <summary>
        /// 挂断
        /// </summary>
        public void HangUp() => Connection?.HangUp();
    }
    public partial class Form1 : Form
    {
        public Point Position;
        readonly Dictionary<string, EntrySimple> DictEntries = new();
        RasPhoneBook phoneBook = new();
        public Form1()
        {
            InitializeComponent();
            listEntries.MultiSelect = false;
            //打开默认电话簿(这个对象包含了所有拨号连接)
            phoneBook.Open(RasPhoneBook.GetPhoneBookPath(RasPhoneBookType.User));
            Timer timer = new();
            timer.Interval = 3000;
            timer.Tick += (s, e) => EnumEntries(false);
            timer.Start();
        }
        private void BtnQuit_Click(object sender, EventArgs e) => Environment.Exit(0);
        private void Form1_Shown(object sender, EventArgs e) => EnumEntries();
        private void EnumEntries(bool showmsg = true)
        {
            int selectedIndex = -1;
            if (listEntries.SelectedItems.Count > 0) selectedIndex = listEntries.SelectedItems[0].Index;
            if (showmsg) WriteMessage("开始检测本地拨号连接");
            listEntries.Clear();
            DictEntries.Clear();
            var connections = RasConnection.GetActiveConnections();
            foreach (var entry in phoneBook.Entries)
            {//遍历电话簿里所有连接
                entry.Options.PreviewUserPassword = false;//不弹出登录窗口
                entry.Options.RemoteDefaultGateway = false;//不使用远程网关
                entry.NetworkProtocols.IPv6 = false;//不用IPV6
                entry.Update();//不管之前如何,一定重新设置这几项,并保存
                var item = listEntries.Items.Add(entry.Name, connections.Any(c => c.EntryName == entry.Name) ? 1 : 2);
                var entrySimple = new EntrySimple(entry.Name, entry.PhoneNumber, entry.Options.RequireEap);
                try
                {//配置文件里不一定存在这个服务器的路由条目
                    entrySimple.Routes = ConfigurationManager.AppSettings[entry.PhoneNumber.ToLower()].
                        Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries).ToList();
                }
                catch (Exception) { }
                item.Tag = entrySimple;
                DictEntries[entry.PhoneNumber.ToLower()] = entrySimple;
            }
            //如果先前选中了某个项,刷新后仍然尝试选择它
            if (selectedIndex >= 0 && selectedIndex < listEntries.Items.Count) listEntries.Items[selectedIndex].Selected = true;
            if (showmsg) WriteMessage("所有连接检测完成");
        }
        private void LblTitle_MouseMove(object sender, MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left) Location = new Point(Location.X + e.X - Position.X, Location.Y + e.Y - Position.Y);
        }

        private void LblTitle_MouseDown(object sender, MouseEventArgs e) => Position = e.Location;

        private void ListEntries_SelectedIndexChanged(object sender, EventArgs e)
        {
            if (listEntries.SelectedItems.Count > 0)
            {
                ShowInfo(listEntries.SelectedItems[0].Tag as EntrySimple);
            }
            else
            {
                txtInfo.Clear();
            }
        }
        private void ShowInfo(EntrySimple entry) =>
            txtInfo.Text =
            $"{entry.Name} - [{(entry.IsConnected ? "Connected" : "DisConnected")}] \r\n" +
            $"Server:\t{entry.PhoneNumber}\r\n" +
            $"Routes:\r\n{string.Join("\r\n", entry.Routes)}";
        private void WriteMessage(string msg, bool date = true, bool msgbox = false)
        {
            string result = date ? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") : "";
            txtLogs.AppendText($"{result}   {msg}\r\n");
            if (msgbox) MessageBox.Show(msg);
        }
        private void BtnMin_Click(object sender, EventArgs e) => WindowState = FormWindowState.Minimized;
        private void ListEntries_MouseDoubleClick(object sender, MouseEventArgs e)
        {//双击时,拨号,或挂断连接
            ListViewHitTestInfo info = listEntries.HitTest(e.X, e.Y);
            if (info.Item != null)
            {
                if (listEntries.SelectedItems.Count > 0)
                {
                    var entry = listEntries.SelectedItems[0].Tag as EntrySimple;
                    if (entry.IsConnected)
                    {
                        entry.HangUp();
                        WriteMessage($"挂断 {entry.Name}", date: true);
                    }
                    else
                    {
                        entry.Connect();
                        WriteMessage($"开始连接到 {entry.Name}", date: true);
                    }
                }
            }
        }
        private void MnuInit_Click(object sender, EventArgs e) => EnumEntries();

        private void MnuDisconnectAll_Click(object sender, EventArgs e)
        {
            WriteMessage("断开所有连接");
            RasConnection.GetActiveConnections().ToList().ForEach(c => c.HangUp());
        }
    }

替代

DotRas命名空间下其实有一个RasDialer类,提供了Dial()方法,但是这个方法和rasdial具有相同的缺点,必须指定连接的用户名和密码。在EntrySimple.Connect()方法里,可以用来替代rasdial拨号,无法替代rasphone拨号。

整个DotRas涉及到的功能,几乎都可以用rasapi32.dll替代实现,例如枚举所有活动连接、枚举电话簿所有条目,挂断一个连接等等。


        /// <summary>
        /// 获取当前已经建立的连接
        /// </summary>
        /// <param name="rasconn"></param>
        /// <param name="cb"></param>
        /// <param name="connections"></param>
        /// <returns></returns>
        [DllImport("rasapi32.dll", SetLastError = true, CharSet = CharSet.Auto)]
        static extern int RasEnumConnections([In, Out] RASCONN[] rasconn, [In, Out] ref int cb, [Out] out int connections);

        [DllImport("rasapi32.dll", CharSet = CharSet.Auto)]
        static extern uint RasGetConnectionStatistics(IntPtr hRasConn, [In, Out] RasStats lpStatistics);

        [DllImport("rasapi32.dll", CharSet = CharSet.Auto)]
        static extern uint RasEnumEntries(
            string reserved,              // reserved, must be NULL
            string lpszPhonebook,         // pointer to full path and file name of phone-book file
            [In, Out] RasEntryName[] lprasentryname, // buffer to receive phone-book entries
            ref int lpcb,                  // size in bytes of buffer
            out int lpcEntries             // number of entries written to buffer
);
        [DllImport("rasapi32.dll")]
        static extern int RasGetConnectStatus(IntPtr hrasconn, ref RASCONNSTATUS lprasconnstatus);
        [DllImport("rasapi32.dll")]
        static extern uint RasHangUp(IntPtr hRasConn);