Ubuntu 20.04 Server软路由配置折腾记录

记录一下最近的一次折腾,使用一台安装了Ubuntu 20.04 Server的工控机搭建软路由,并成功配置家庭NAS的内网访问。计算机网络没好好学,全靠本能debug,感谢lcy同志的全程技术科普(

本文主要参考了https://blog.lcy.im/2017/09/14/%E4%BB%8Eubuntu-server-%E5%BC%80%E5%A7%8B%E9%85%8D%E7%BD%AE%E8%87%AA%E5%8A%A8%E5%88%86%E6%B5%81%E7%9A%84%E8%BD%AF%E8%B7%AF%E7%94%B1/

网络拓扑设计

瞎画的,是这意思就行(逃

根节点是联通的WO-67光猫路由一体机,管理子网192.168.1.x(24是子网掩码的长度,意思是前24位是这个子网固定的地址前缀),其中包括一台桥接模式运行的电力猫,提供WIFI覆盖。我们希望安装一部软路由,管理192.168.2.x子网。软路由后面桥接一台TP-LINK路由器,该路由器提供WIFI覆盖(因为软路由没有无线功能)。此外,还需要在软路由上接一台群晖NAS,并希望在整个网络中提供对NAS的访问。

软路由系统准备

我买的主机就长这样。参数是,x86-64系统,4张网卡,4G内存,32G硬盘。下文中用“软路由”称呼这台机器。

一般这种软路由都是装lede/openwrt之类的系统,不过我要装一个比较正常的ubuntu,原因是后面还要配置自动分流。和店家交流后,他们表示能装ubuntu 16.04,是正常的桌面系统。不过我在一开始折腾的时候把这个系统搞挂了,于是决定换成ubuntu 20.04 server,这样的另一个好处是没有图形系统比较省电,跑桌面系统会肉眼可见地烫手,但是ubuntu server就不会。

这个软路由是带USB+HDMI+VGA接口的,也就是说插上屏幕键盘就能当台电脑用,所以装系统就是很普通的流程:从ubuntu官网上下载amd64版本的.iso文件,用win32diskimager把它刻进U盘,然后把这个安装盘插到软路由的USB接口上,接屏幕键盘启动,启动时按F7进入启动选项,选择由U盘启动,然后跟着流程走即可。这样软路由就装上了ubuntu 20.04 server。

这时需要解决一个奇怪的问题:系统不插VGA监视器无法启动,插HDMI都不行(似乎)。不确定是我软路由硬件的原因还是ubuntu server的原因。这个必须得处理,至少VGA那个分辨率看着也太费劲了……方法是按照这个教程:https://askubuntu.com/questions/825687/what-could-prevent-an-ubuntu-server-from-booting-without-a-vga-connected-monitor,在/etc/default/grub把GRUB_TERMINAL=console一行取消注释,指示系统启动时无需有图形界面。然后用sudo update-grub更新引导选项。在折腾的时候我还在BIOS里面把启动选项设置成了legacy,关闭UEFI,估计应该没用。

至此软路由在系统层面已经准备完毕(按需用apt装包略去不表),接下来开始配置网络。

网络接口配置

在较早的linux系统中,网络接口配置是通过/etc/network/interfaces文件完成的,但在18.04以后改成了一个叫做netplan的东西。netplan的问题是,它没有“allow-hotplug”选项的简单对应,因此还是换回了原来的配置方法:https://askubuntu.com/questions/1031709/ubuntu-18-04-switch-back-to-etc-network-interfaces,主要应该就是安装ifupdown包,然后卸载掉netplan相关的东西:

# systemctl stop systemd-networkd.socket systemd-networkd networkd-dispatcher systemd-networkd-wait-online
# systemctl disable systemd-networkd.socket systemd-networkd networkd-dispatcher systemd-networkd-wait-online
# systemctl mask systemd-networkd.socket systemd-networkd networkd-dispatcher systemd-networkd-wait-online
# apt-get --assume-yes purge nplan netplan.io

然后设置文件/etc/network/interfaces:

source /etc/network/interfaces.d/*

auto lo
iface lo inet loopback

auto enp1s0
allow-hotplug enp1s0
iface enp1s0 inet static
    address 192.168.1.10/24
    gateway 192.168.1.1

iface enp2s0 inet manual
iface enp3s0 inet manual
iface enp4s0 inet manual

auto br0
iface br0 inet static
    bridge_ports enp2s0 enp3s0 enp4s0
        address 192.168.2.1/24

这里面lo就是localhost,不用管。enp1s0~enp4s0是软路由的四个网口,似乎有些机器上,它们的名字会叫作eth0,eth1之类,总之可以用命令ip a查询。我们用enp1s0作为WAN口,其他三个作为LAN口,把这三个桥接起来。

enp1s0需要有allow-hotplug,这个意思是,在软路由启动的时候即使WAN口没插线也能启动。如果不加,它自己倒是没什么问题,只是重启的时候会因为一个什么网络服务没有就绪等待两分钟。但麻烦的是,后面的dhcp服务器会无法正常启动,至少我是这样。可以看到,把WAN口在一级子网中的地址固定为了192.168.1.10,网关是光猫路由一体机。enp2s0~enp4s0的配置都是manual,因为它们的具体地址需要靠网桥br0完成。

auto br0这一句必不可少,它保障了在软路由启动的时候可以自动创建网桥(作为一个虚拟设备,好像是这么个意思),并且恰当地初始化三个LAN口。后面可以看到,bridge_ports这一句把三个LAN口桥接起来,并且规定地址(也就是软路由在二级子网中的地址)是192.168.2.1.

这样配置完/etc/network/interfaces之后,可以sudo service networking restart重启网络服务,然后用ifconfig查看网络信息,应该能看到lo,网桥和四个网口。可以重启软路由验证设置的正确性,重启之后不需要进行任何操作,networking服务会自动读取这个文件里面的配置,并创建相应的接口。

配置sysconf

在/etc/sysctl.conf里面添加如下内容,然后重启机器

net.ipv4.conf.default.rp_filter=0
net.ipv4.conf.all.rp_filter=0
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1

配置dhcp服务器

首先装包sudo apt install isc-dhcp-server

然后配置/etc/default/isc-dhcp-server,把ipv4和ipv6的接口都设置成我们的虚拟网桥br0:

INTERFACESv4="br0"
INTERFACESv6="br0"

然后设置具体的dhcp规则:

host synology-nas {
    hardware ethernet 00:11:32:93:0d:23;
    fixed-address 192.168.2.9;
}

subnet 192.168.2.0 netmask 255.255.255.0 {
    range 192.168.2.20 192.168.2.100;
    option routers 192.168.2.1;
    option domain-name-servers 192.168.2.1;
}

我这里添加了两条规则。第一条,是群晖NAS的ip地址固定为192.168.2.9,当然后缀2.9也没啥深意,只要不是192.168.2.1(软路由的ip地址)都行。hardware ethernet后面接的是NAS的MAC地址,这个如何查看呢?答案是把NAS用网线连到软路由上之后,用arp -n查看软路由的连接信息,其中的HWaddress项就是MAC地址。

第二条则是子网内的dhcp服务,地址池为192.168.2.20~192.168.2.100,网关是192.168.2.1,而最后一句dns服务器,这里是因为我使用了freedns-go:https://github.com/tuna/freedns-go,所以可以把dns服务器设置成软路由自身。如果没有,那就设成114.114.114.114,1.1.1.1,8.8.8.8之类的。

这样dhcp服务器设置完成,sudo service isc-dhcp-server restart重启dhcp服务器。这时候用网线连到路由器上,用ifconfig就能看到自动分配的地址。

配置自动nat访问互联网

然后还需要nat以访问互联网。命令是:

iptables -t nat -A POSTROUTING -o enp1s0 -j MASQUERADE

当然不能每次重启都敲这么一句,需要把它设置成一个自动启动的服务。ubuntu 20用systemd完成这件事情:https://www.howtogeek.com/687970/how-to-run-a-linux-program-at-startup-with-systemd/

首先我们创建一个文件start_nat.sh,内容如下:

#!/bin/sh
iptables -t nat -A POSTROUTING -o enp1s0 -j MASQUERADE

并且用sudo chmod +x start_nat.sh赋予执行权限,然后把它放到/usr/local/bin目录下。然后再创建一个文件/etc/systemd/system/start_nat.service,内容为

[Unit]
Description=Start NAT

[Service]
ExecStart=/usr/local/bin/start_nat.sh
RemainAfterExit=true

[Install]
WantedBy=multi-user.target

Description随便填。如果需要在.sh里面启动tmux(并且有可能在tmux里面运行某个程序),这个RemainAfterExit就必须要有。ExecStart是这个服务在启动的时候执行什么命令,也就是前面那个脚本start_nat.sh。然后我们把它的权限改成640:

sudo chmod 640 /etc/systemd/system/start_nat.service

这样我们就创建了一个名叫start_nat的服务。然后把它注册为启动时运行:

sudo systemctl daemon-reload
sudo systemctl enable start_nat
sudo systemctl start start_nat

最后那个start是现在就启动的意思。你也可以重启一次软路由。如果一切配置恰当,重启之后,连接软路由的电脑应该能直接连接到一个LAN并且正常访问互联网,无需进行任何操作。

NAS端口转发配置

到现在为止我们拥有了一个正常功能的路由器。但是我还希望在整个家庭局域网内访问NAS的各项服务,所以还需要额外折腾一下。具体来说有四个:web控制页面5051端口,webdav远程硬盘5006端口,synology drive群晖本地云6690端口,smb共享文件(比如说在‘我的电脑’里敲个\\192.168.2.9访问共享文件夹)445端口。

因此我们需要在软路由上配置四个端口转发,前三个随便更改了一下端口名称:

  • 软路由的16000端口转发至NAS的5051端口
  • 软路由的16006端口转发至NAS的5006端口
  • 软路由的16090端口转发至NAS的6690端口
  • 软路由的445端口转发至NAS的445端口

注意之前把NAS的ip地址固定为192.168.2.9了。每一个转发可以通过两条iptables命令实现,因此总共是八条,把它们添加到之前/usr/local/bin/start_nat.sh的末尾,这样每次软路由一启动就会自动完成转发设置。总的下来,start_nat.sh如下:

#!/bin/sh
iptables -t nat -A POSTROUTING -o enp1s0 -j MASQUERADE
iptables -t nat -A PREROUTING -p tcp --dport 16000 -j DNAT --to-destination 192.168.2.9:5051
iptables -t nat -A POSTROUTING -p tcp -d 192.168.2.9 --dport 5051 -j MASQUERADE
iptables -t nat -A PREROUTING -p tcp --dport 16006 -j DNAT --to-destination 192.168.2.9:5006
iptables -t nat -A POSTROUTING -p tcp -d 192.168.2.9 --dport 5006 -j MASQUERADE
iptables -t nat -A PREROUTING -p tcp --dport 16090 -j DNAT --to-destination 192.168.2.9:6690
iptables -t nat -A POSTROUTING -p tcp -d 192.168.2.9 --dport 6690 -j MASQUERADE
iptables -t nat -A PREROUTING -p tcp --dport 445 -j DNAT --to-destination 192.168.2.9:445
iptables -t nat -A POSTROUTING -p tcp -d 192.168.2.9 --dport 445 -j MASQUERADE

这样,比如说,在家庭局域网任意位置访问192.168.1.10:16000即可访问NAS的5051端口。

路由器桥接,收尾工作

在折腾之前家里的路由器不是桥接模式而是路由模式。需要更改一下。总的来说,从路由模式切换成桥接模式需要做三件事:

  • 把WAN口网线拔掉插到LAN口
  • 登录路由器管理界面,关闭dhcp
  • 在路由器管理界面手动设置一个LAN口ip地址,不要冲突

其中我遇到了一件奇怪的事情,就是设置TP-LINK路由器的时候,一开始忘了关dhcp,后来才关掉,但此时连接它WIFI的电脑会始终显示网关是192.168.2.2,也就是这台路由器的地址,而非正确的软路由地址192.168.2.1。然后通过在电脑上手动设置ip,dns服务器和网关(设为软路由地址),然后再把ip改回自动模式,解决了问题,这个方法有点奇怪但总之work了,原理不明。

最后结果如图:

C++字符输入方式初探——为什么不能eof

前言

众所周知,C++的字符处理,尤其是输入输出,各种“读到文件结束”十分复杂。本文简单研究一下C++的各种字符输入方式。

由于C++提供了各种eof方法以供判断文件结尾,这就让人产生一种写类似这样代码的冲动:

while(eof){
    读入什么东西;
}

而它在绝大多数情况下都是不能正常工作的。如果你改成这样:


while(true){
    读入什么东西;
    if(eof) break;
}

就能正常运行了,但是看上去一点也不不优美酷炫装逼吊炸天。


本文的目的就是对C++中各种主流读入方式,研究第一种方法为什么不行。

注意:本文所有测试都是在Windows环境下进行的。

C风格

C++中,字符输入输出有两种风格:一种是继承于C的,cstdio库中包含的各函数,另一种是C++风格的,iostream/fstream。

在cplusplus.com中,可以查到cstdio的如下成员函数:

getchar();fgetc();getc();
gets();fgets();
scanf();fscanf();
fread();

各种getchar

这里有三个函数:getchar,fgetc和getc。其中,fgetc和getc等价,它们都相当于给定了输入文件指针的getchar。

getchar函数的返回值是一个int:如果它成功读取字符,这个int就等于读取字符的ASCII码,否则就是EOF。这个EOF是编译器用宏定义的一个常数,也就是-1. “读取不成功”包含你能想到的各种情况:比如读完了文件的最后一个字符,或文件不存在,或文件为空。

如果用getchar读取文件,能否用eof(注意这是小eof,那个大EOF是一个宏定义的常量)判断文件是否结束呢?答案是不行。不妨做个实验:

    freopen("input.in","r",stdin);
    while(!feof(stdin)){
        cout<<(int)getchar()<

这个feof是cstdio中的eof函数(判断文件是否结束),在这里一定不能用cin.eof(),它俩根本不是一个系统的。


这个原因是为什么呢?在cstdio中,判断eof的方式是设一个标志(end of file indicator),这个标志平时是0,而当你用任何方式试图去读“文件结尾再下一位”的时候,它就会变成一个正值,表明文件结束。但是,如果一直用getchar,那么它站在文件末尾的时候并不知道下一位还有没有东西,那就需要进行一次尝试,所以就读进来了一个-1.

参见文章:
当然,不能eof也不是什么大事:用-1判就行了嘛。本来getchar返回-1就是为这个目的而设计的。

在C++读入界,getchar就像一个香港记者:跑得比谁都快,(因此称霸OI选手的快速读入模板),但读来读去,不做任何格式分析,too simple。想判空格?想判换行?客官您自己写去吧。

举个栗子:

    freopen("input.in","r",stdin);
    char c;
    while((c=getchar())!=-1){
        cout<

如此就可把一个文件里的所有字符从头到尾,原封不动地输出出来。


gets和fgets

不同于getchar/fgetc,fgets和gets有显著区别。

gets

gets的函数原型是:

char * gets ( char * str );

它的作用是:读取字符数组直到遇见一个回车('\n'),或读到了文件末尾。如果读取成功,它会把读取的东西放进str数组中(不包括最后可能的换行符,并在str的末尾添加一个'\0'),并返回str的指针。如果读取失败,str数组不变,并返回一个空指针(NULL)。如果这一行有回车,它就相应读入一个长度为0的字符数组。


那么,gets能不能用eof判文件结束呢?答案是:不太行。用下面的代码试一试:

    freopen("input.in","r",stdin);
    char s[100];
    char *s1;
    while(!feof(stdin)){
        s1=gets(s);
        printf("%d ",s1);
        cout<

你会发现:如果你的文件最后一行不是回车,那么它能正常工作。否则不行。


这个锅是这么产生的:当fgets读了最后一行,看到了回车,并发现后面没有的时候,就会设置eof标志。而这一行的读入是否成功呢?如果这一行有字符,它就认为成功,否则(即最后一行是回车)失败。如果读取失败,那它就不会改变str的值——这就导致看上去,倒数第二行被读了两次。

当然,eof不靠谱,用靠谱的方法就是了——看看gets的返回值是不是NULL即可。如果你是一名经验丰富的膜法师,不妨用这个函数,无他,代码短尔。

fgets

fgets和原型是:

char * fgets ( char * str, int num, FILE * stream );

它比gets多了两个参数:num代表最多允许读取的位数,stream是文件指针。


fgets还有一个重大的不同:它会把回车(如果有的话)放在读入的字符数组后面。这就会造成一种尴尬的局面:最后一行的后面没有回车(如果它能被成功读入的话)。其他都和gets一样。

scanf和fscanf

这两个都是大家喜闻乐见的函数。fscanf就是scanf加一个文件指针,这里按下不表。

scanf的用法比较多样,这里主要关注两种:用它读"%s"(字符数组)和"%c"(字符)。

字符数组

scanf("%s",str); 这条命令意味着:一直读字符,直到遇到空格/tab/回车/文件结束。它返回一个整数:如果读取成功,就返回读取的字符数组个数(这里只打了一个%s,所以至多是1),如果读取失败,就返回常量EOF(也就是-1)。如果想用eof读到文件结尾,就会和gets遇到同样的问题:如果文件的最后一个字符是空格/tab/回车之一,最后一次读取就会挂掉。

不用eof,用返回-1这个标准判断就能搞定了,大家都说好。

字符

scanf("%c",&ch);这条命令等价于:ch=getchar(); 自然,试图用eof判结尾也会遇到getchar中出现的问题。

换言之,scanf一个字符和getchar的功能完全一样——而且它比getchar跑得慢。

那这个用法和咸鱼有什么区别呢?

完全没有区别,它就是咸鱼。

呃……其实还有点区别。你可以指定读几个字符,并且把传过去的&ch换成一个字符数组。这个用法比较生僻,就略过了。

fread

fread的函数原型是:

size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );

它的作用是一下把一整个文件(stream)读进数组ptr里。size是要读入的每个字符的大小(以byte记),而count是总共至多读多少个字符。它的返回值是一个非负整数:成功读入的字符个数。


这个函数一般不用,用法和getchar类似,好处也是跑得比谁都快,不过得预先开一个数组。举个栗子:

    FILE *fin = fopen("input.in","r");
    char s[100]={0};
    cout<

C++风格

至于C++风格,就是大家喜闻乐见的,iostream和fstream全家桶。

首先明确一点:iostream和fstream都是继承istream类而来。换个容易理解的说法:cin和fin其实是一个东西。下面我们全部用cin为例。

研究一下istream类,其中能读东西的大概是这些函数:
cin>>yituodongxi;//一坨东西
cin.get();
cin.getline();
cin.read();


重载运算符

istream类型重载了>>(右移)运算符,使得你能够顺滑地干类似cin>>a;这样的事情。我们在这里讨论两种情况:读一个字符,和读一堆字符。

读一个

C++实现istream类型的方法比较复杂,它包装了一个streambuf类型,从而让它同时能读字符/字符串/字符数组。具体的操作细节不用关心,如果cin>>ch;(ch是char型),它做的事情是:读第一个非空格/tab/回车/文件结束的字符。

它和getchar都是“读一个字符”的方法,而如果妄图用cin.eof()判文件结尾,getchar的问题在这里同样会出现。更为坑爹的一点是:如果读不成,它会直接让你的ch保持原样,而非像getchar那样,给一个-1供人凭吊。在表面上看来,就是最后一个字符会重复读两遍。

举个栗子:

    freopen("input.in","r",stdin);
    char ch;
    while(!cin.eof()){
        cin>>ch;
        cout<<(int)ch<<" "<

读一堆

如果你cin的是一个字符串/字符数组,那它的表现和scanf一个字符数组是类似的。如果文件的最后一个字符是空格/tab/回车之一,while(eof)就会失败。

get

cin.get()重载了好多种,不过都和之前我们研究过的函数等价:

int get();//这货就是getchar
istream& get (char& c);//这货相当于scanf("%c"),但读取失败会返回NULL
istream& get (char* s, streamsize n);//这货相当于scanf("%s"),同样读取失败返回NULL
istream& get (char* s, streamsize n, char delim);//和楼上类似但可以自定义分隔符
istream& get (streambuf& sb);//能兼容上面几种
istream& get (streambuf& sb, char delim);//和楼上类似但可以自定义分隔符

getline

cin.getline()有两种:

istream& getline (char* s, streamsize n );
istream& getline (char* s, streamsize n, char delim );//能自定义分隔符

它其实和gets一样(但你得指定最大读取长度n),试图用while(eof)时遇到的问题也和gets一样:如果文件的最后一个字符是回车,就会挂掉。

举个栗子:

    freopen("input.in","r",stdin);
    char ch[100];
    while(!cin.eof()){
        cin.getline(ch,100);
        cout<

read

其实就是fread的C++版本啦。毫无保留地把所有字符读进来,从头读到尾。不过用法简洁一些,省去了sizeof(char)。

举个栗子:

    freopen("input.in","r",stdin);
    char str[100];
    cin.read(str,100);
    cout<

总结

为什么while(eof)这种写法不能正常运行呢?简而言之,分为两种:读单个字符的方法,原因是在“无法预知未来”;读一串字符的方法,原因是“无法定义失败”。好像突然就哲学起来了……(逃

我们总结出如下这张表格,作为参考:

函数名 读取个数 分隔符 eof什么时候失败 读取失败的特征
getchar/fgetc/getc 一个 永远 返回-1
gets/fgets 多个 回车('\n') 文件末尾是分隔符 返回NULL
scanf/fscanf "%c" 一个 永远 返回-1
scanf/fscanf "%s" 多个 空格/tab/回车 文件末尾是分隔符 返回-1
fread 多个
cin>>ch 一个 空格/tab/回车 永远 返回NULL
cin>>str 多个 空格/tab/回车 文件末尾是分隔符 返回NULL
cin.get() 一个 永远 返回-1
cin.get(ch) 一个 永远 返回NULL
cin.get(str) 多个 空格/tab/回车 文件末尾是分隔符 返回NULL
cin.getline 多个 回车('\n') 文件末尾是分隔符 返回NULL
cin.read 多个