Golang 中网络请求使用指定网卡

当发送一个网络请求时,默认流量会通过当前电脑的默认网卡接口流出与流入,但有时需要对将流量通过指定的网卡进行流出流入,这时我们可能需要进行一些额外的开发工作,对其实现主要用到了 Dialer.Control 配置项。

type Dialer struct {

// If Control is not nil, it is called after creating the network
// connection but before actually dialing.
//
// Network and address parameters passed to Control method are not
// necessarily the ones passed to Dial. For example, passing "tcp" to Dial
// will cause the Control function to be called with "tcp4" or "tcp6".
Control func(network, address string, c syscall.RawConn) error
}

可以看到这是一个函数类型的参数。

环境

当前系统一共两个网卡 ens33ens160 ,ip地址分别为 192.168.3.80192.168.3.48

➜  ~ ifconfig
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.80 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::8091:2406:c51e:ecb9 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:90 txqueuelen 1000 (Ethernet)
      RX packets 4805008 bytes 826619853 (826.6 MB)
      RX errors 0 dropped 104152 overruns 0 frame 0
      TX packets 732513 bytes 284605386 (284.6 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
      inet 192.168.3.48 netmask 255.255.255.0 broadcast 192.168.3.255
      inet6 fe80::259a:d8d4:80a9:7fa4 prefixlen 64 scopeid 0x20<link>
      ether 00:0c:29:4f:05:9a txqueuelen 1000 (Ethernet)
      RX packets 4158530 bytes 746167179 (746.1 MB)
      RX errors 1 dropped 106875 overruns 0 frame 0
      TX packets 351616 bytes 149235606 (149.2 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
      inet 127.0.0.1 netmask 255.0.0.0
      inet6 ::1 prefixlen 128 scopeid 0x10<host>
      loop txqueuelen 1000 (Local Loopback)
      RX packets 426742 bytes 80978543 (80.9 MB)
      RX errors 0 dropped 0 overruns 0 frame 0
      TX packets 426742 bytes 80978543 (80.9 MB)
      TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

路由表记录

➜  ~ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.3.1     0.0.0.0         UG    100    0        0 ens33
0.0.0.0         192.168.3.1     0.0.0.0         UG    101    0        0 ens160
169.254.0.0     0.0.0.0         255.255.0.0     U     1000   0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     100    0        0 ens33
192.168.3.0     0.0.0.0         255.255.255.0   U     101    0        0 ens160
​

从最后两条路由记录可以看到对于 192.168.3.0/24 这个段的流量会匹配的两个物理网卡,但由于配置的 Metric 的优先级比较的高,因此最终流量只会走网卡 ens33

我们可以在另一台机器 192.168.3.58python3 搭建一个HTTPServer,用命令 python3 -m http.server 8080 即可快速启用一个webserver,这时在这台机器用 curl 发送一个请求。

curl -v http://web.test.com:8080/test/index.html

然后看下python3 的日志

sxf@sxf-virtual-machine:/data/8080$ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
192.168.3.80 - - [16/Jan/2023 15:08:18] "GET /test/index.html HTTP/1.1" 200 -

可以看到客户端的IP正是 ens33网卡,即当前流量是经过默认网卡流出。

实现

下面我们再看一下如何用 Golang 来指定网卡,为了方便这里直接贴出来完整的代码

package main

import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"net/url"
"syscall"

"golang.org/x/sys/unix"
"net"
)

var interfaceName string = "ens160" // ens33、ens160

const (
network string = "tcp"
address string = "192.168.3.58:8080"
)

func main() {
  // 重要核心代码
d := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return setSocketOptions(network, address, c, interfaceName)
},
}

ctx := context.Background()
// 拨号获取一个连接
conn, err := d.DialContext(ctx, network, address)
if err != nil {
panic(err)
}

// 1. create new request
reqURL, _ := url.Parse("http://web.test.com/test/index.html")
hdr := http.Header{}
req := &http.Request{
Method: "GET",
URL:   reqURL,
Header: hdr,
}
err = req.Write(conn)
if err != nil {
panic(err)
}

// 2. Get a response
r := bufio.NewReader(conn)
resp, err := http.ReadResponse(r, req)
if err != nil {
panic(err)
}
defer resp.Body.Close()

status := resp.StatusCode
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(conn.LocalAddr())
for name, vv := range resp.Header {
fmt.Print(name, ":")
for _, v := range vv {
fmt.Print(v)
}
fmt.Println()
}
fmt.Println()
fmt.Println(status)
fmt.Println(string(body))
}

func isTCPSocket(network string) bool {
switch network {
case "tcp", "tcp4", "tcp6":
return true
default:
return false
}
}

func isUDPSocket(network string) bool {
switch network {
case "udp", "udp4", "udp6":
return true
default:
return false
}
}

func setSocketOptions(network, address string, c syscall.RawConn, interfaceName string) (err error) {
if interfaceName == "" || !isTCPSocket(network) && !isUDPSocket(network) {
return
}

var innerErr error
err = c.Control(func(fd uintptr) {
host, _, _ := net.SplitHostPort(address)
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
return
}

if interfaceName != "" {
if innerErr = unix.BindToDevice(int(fd), interfaceName); innerErr != nil {
return
}
}

})

if innerErr != nil {
err = innerErr
}
return
}

我们首先自定义了 Dialer.Control 的实现

    d := &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
return setSocketOptions(network, address, c, interfaceName)
},
}

这里指定了自定义实现函数setSocketOptions, 下面是实现

func setSocketOptions(network, address string, c syscall.RawConn, interfaceName string) (err error) {
if interfaceName == "" || !isTCPSocket(network) && !isUDPSocket(network) {
return
}

var innerErr error
err = c.Control(func(fd uintptr) {
host, _, _ := net.SplitHostPort(address)
if ip := net.ParseIP(host); ip != nil && !ip.IsGlobalUnicast() {
return
}

// 核心代理
if interfaceName != "" {
if innerErr = unix.BindToDevice(int(fd), interfaceName); innerErr != nil {
return
}
}

})

if innerErr != nil {
err = innerErr
}
return
}

这里重点是使用了 unix.BindToDevice() 函数来指定了要使用的物理网卡,其参数类型是 int 整型,其实现为官方库 https://pkg.go.dev/golang.org/x/sys/unix#BindToDevice

// BindToDevice binds the socket associated with fd to device.
func BindToDevice(fd int, device string) (err error) {
return SetsockoptString(fd, SOL_SOCKET, SO_BINDTODEVICE, device)
}

func SetsockoptString(fd, level, opt int, s string) (err error) {
var p unsafe.Pointer
if len(s) > 0 {
p = unsafe.Pointer(&[]byte(s)[0])
}
return setsockopt(fd, level, opt, p, uintptr(len(s)))
}

func setsockopt(s int, level int, name int, val unsafe.Pointer, vallen uintptr) (err error) {
_, _, e1 := Syscall6(SYS_SETSOCKOPT, uintptr(s), uintptr(level), uintptr(name), uintptr(val), uintptr(vallen), 0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}

通过函数一级级的调用,最终调用了 Syscall6 函数,到这里基本可以不用再往下跟踪了,对于我们来讲,个人觉得只要跟踪到 setsocketopt 函数就可以了,对于写 c 的同学来说,对于这个函数还是非常熟悉的。

此时我们自己的环境修改一些配置并运行上面的程序,在 webserver 的日志里可以看到客户端的ip就是上面程序指定网卡的ip地址。

总结

在一些网络底层基本都会用到 Dialer.Control 这个字段配置,在C中这么多通过 setsocketopt 实现的方法都是在这里实现的,这里推荐大家参考一下耗子叔以前写的一篇博客 从一次经历谈 TIME_WAIT 的那些事 ,采用的也是类似的方法。

Linux下两种 DNAT 用法的差异

前段时间使用 iptablesDNAT 实现一个业务需求的时候,遇到了一些问题这里将其整个过程记录下来。

需求

这里假设开发机地址为 192.168.3.80,要实现的需求是当用户在开发机访问一个IP地址 192.168.3.196时,将请求转发到另一台机器 192.168.3.58,很明显直接使用 DNAT 来实现即可。

问题现象

iptables 命令如下

sudo iptables -t nat -F
sudo iptables -t nat -A PREROUTING -d 192.168.3.196 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080
sudo iptables -t nat -A POSTROUTING -d 192.168.3.58 -p tcp --dport 8080 -j SNAT --to-source 192.168.3.196:8080

这时在开发机器访问

curl http://192.168.3.196:8080

发现提示错误

curl: (7) Failed to connect to 192.168.3.196 port 8080: Connection refused

奇怪了,竟然不能访问,确认路由规则是写入成功的。网上查找了一些资料好像全是这种写法,只不过用法有怕差异,这时直觉告诉我应该对 DNAT 理解不到位,遗漏了一些重要的知识点。

上面这种写法一般都是将开发机当作一个中转服务器跳板来使用,多种情况下都有一个公网ip,与我们的真正需求有一些不一样。

现在我们再以将其视为中转服务器的角色测试一次,当然这个规则不能直接使用上面的这个,需要把访问的目标ip更换成开发机器的IP地址。

sudo iptables -t nat -F
sudo iptables -t nat -A PREROUTING -d 192.168.3.80 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080
sudo iptables -t nat -A POSTROUTING -d 192.168.3.58 -p tcp --dport 8080 -j SNAT --to-source 192.168.3.80:8080

第一条是数据包出去规则,需要做DNAT,第二条是数据包回来规则,必须做一次 SNAT,否则数据包将去无回,无法响应。

这时再找一台机器访问 curl 192.168.3.80:8080,可以看到响应结果符合预期。

问题分析

现在我们基本确认了是我们的用法不对,到底是哪里出错了呢?这里我们一起看一下这张 iptables 数据流向图。

iptables Processing Flowchart

从图中可以看到,对于数据流入一共有两类,一类是外部数据包流入 ,即左侧的 Incoming Packet;另一类是本机生成的数据包流入,即右侧的 Locally generated Packet,对于对数据包的流出只有一处,即下方的 Outgoing Packet

对于数据包首个经过的表是不一样的,对于外部流入的数据包首个经过的是PREROUTING 表,而对于本地生成的数据包而言经过的是 OUTPUT 这个表,最后统一从同一个地方流出。

也就是说针对不的类型的包,经过的表是不同的,这个正是我们最上面失败的原因。

解决问题

我们要实现的场景其实是 Locally generated Packet 这类,所以使用的表应该是 OUTPUT才是正确的,现在我们清除原来的规则,重新写入新规则测试一下

sudo iptables -t nat -F
sudo iptables -t nat -A OUTPUT -d 192.168.3.196 -p tcp --dport 8080 -j DNAT --to-destination 192.168.3.58:8080

注意对于 SNAT而言,只对 INPUT/POSTROUTING有效。

再次测试 curl 192.168.3.196:8080 响应正常。

总结

针对 iptables 的 DNAT 的实现,需要根据数据包的来源不同而采用不同的处理方法,一共分外部数据包和本地数据包两类。其中对于外部数据包除了做 DNAT外,还要再做一个 SNAT 规则,否则数据包将有去无回;而对于本地数据包而言,只需要在 OUTPUT 表中做一个 DNAT 即可,并不需要SNAT,同时也不支持 SNAT。对于SNAT 只对 INPUT/POSTROUTEING 才有效。

https 是如何建立连接的

一、什么是HTTPS、TLS、SSL

HTTPS,也称作HTTP over TLS。TLS的前身是SSL,TLS 1.0通常被标示为SSL 3.1,TLS 1.1为SSL 3.2,TLS 1.2为SSL 3.3。下图描述了在TCP/IP协议栈中TLS(各子协议)和HTTP的关系。

二、HTTP和HTTPS协议的区别

1、HTTPS协议需要到证书颁发机构(Certificate Authority,简称CA)申请证书,一般免费证书很少,需要交费。

2、HTTP是超文本传输协议,信息是明文传输,HTTPS则是具有安全性的SSL加密传输协议。

3、HTTP和HTTPS使用的是完全不同的连接方式,使用的端口也不一样,前者是80,后者是443。

4、HTTP的连接很简单,是无状态的。

5、HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比HTTP协议安全。

从上面可看出,HTTPS和HTTP协议相比提供了

· 数据完整性:内容传输经过完整性校验

· 数据隐私性:内容经过对称加密,每个连接生成一个唯一的加密密钥

· 身份认证:第三方无法伪造服务端(客户端)身份

其中,数据完整性和隐私性由TLS Record Protocol保证,身份认证由TLS Handshaking Protocols实现。

三、证书

1、什么是证书呢?

2、证书中包含什么信息

证书信息:过期时间和序列号

所有者信息:姓名等

所有者公钥

3、为什么服务端要发送证书给客户端

互联网有太多的服务需要使用证书来验证身份,以至于客户端(操作系统或浏览器等)无法内置所有证书,需要通过服务端将证书发送给客户端。

4、客户端为什么要验证接收到的证书

中间人攻击

5、客户端如何验证接收到的证书

为了回答这个问题,需要引入数字签名(Digital Signature)。

将一段文本通过哈希(hash)和私钥加密处理后生成数字签名。

假设消息传递在Bob,Susan和Pat三人之间发生。Susan将消息连同数字签名一起发送给Bob,Bob接收到消息后,可以这样验证接收到的消息就是Susan发送的

当然,这个前提是Bob知道Susan的公钥。更重要的是,和消息本身一样,公钥不能在不安全的网络中直接发送给Bob。

此时就引入了证书颁发机构(Certificate Authority,简称CA),CA数量并不多,Bob客户端内置了所有受信任CA的证书。CA对Susan的公钥(和其他信息)数字签名后生成证书。

Susan将证书发送给Bob后,Bob通过CA证书的公钥验证证书签名。

Bob信任CA,CA信任Susan, 使得 Bob信任Susan,信任链(Chain Of Trust)就是这样形成的。

事实上,Bob客户端内置的是CA的根证书(Root Certificate),HTTPS协议中服务器会发送证书链(Certificate Chain)给客户端。

正式开始HTTPS的内容:

一、HTTPS的基本原理

从上面可知,HTTPS能够加密信息,以免敏感信息被第三方获取。所以很多银行网站或电子邮箱等等安全级别较高的服务都会采用HTTPS协议。

HTTPS其实是有两部分组成:HTTP +SSL/ TLS,也就是在HTTP上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过TLS进行加密,所以传输的数据都是加密后的数据。具体是如何进行加密,解密,验证的,且看下图。

1. 客户端发起HTTPS请求

这个没什么好说的,就是用户在浏览器里输入一个HTTPS网址,然后连接到server的443端口。

2. 服务端的配置

采用HTTPS协议的服务器必须要有一套数字证书,可以自己制作,也可以向组织申请。区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用受信任的公司申请的证书则不会弹出提示页面(startssl就是个不错的选择,有1年的免费服务)。这套证书其实就是一对公钥和私钥。如果对公钥和私钥不太理解,可以想象成一把钥匙和一个锁头,只是全世界只有你一个人有这把钥匙,你可以把锁头给别人,别人可以用这个锁把重要的东西锁起来,然后发给你,因为只有你一个人有这把钥匙,所以只有你才能看到被这把锁锁起来的东西。

3. 传送证书

这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等等。

4. 客户端解析证书

这部分工作是由客户端的TLS来完成的,首先会验证公钥是否有效,比如颁发机构,过期时间等等,如果发现异常,则会弹出一个警告框,提示证书存在问题。如果证书没有问题,那么就生成一个随机值。然后用证书对该随机值进行加密。就好像上面说的,把随机值用锁头锁起来,这样除非有钥匙,不然看不到被锁住的内容。

5. 传送加密信息

这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。

6. 服务端解密信息

服务端用私钥解密后,得到了客户端传过来的随机值(私钥),然后把内容通过该值进行对称加密。所谓对称加密就是,将信息和私钥(随机值)通过某种算法混合在一起,这样除非知道私钥(随机值),不然无法获取内容,而正好客户端和服务端都知道这个私钥(随机值),所以只要加密算法够彪悍,私钥(随机值)够复杂,数据就够安全。

7. 传输加密后的信息

这部分信息是服务端用私钥(随机值)加密后的信息,可以在客户端被还原

8. 客户端解密信息

客户端用之前生成的私钥(随机值)解密服务端传过来的信息,于是获取了解密后的内容。整个过程第三方即使监听到了数据,也束手无策。

二、HTTPS的通信流程和握手过程

1、HTTPS对应的通信时序图:

2、HTTPS在传输数据之前需要客户端(浏览器)与服务端(网站)之间进行一次握手,在握手过程中将确立双方加密传输数据的密码信息。TLS/SSL协议不仅仅是一套加密传输的协议,更是一件经过艺术家精心设计的艺术品,TLS/SSL中使用了非对称加密,对称加密以及HASH算法。握手过程的具体描述如下:

1. 浏览器将自己支持的一套加密规则发送给网站。

2.网站从中选出一组加密算法与HASH算法,并将自己的身份信息以证书的形式发回给浏览器。证书里面包含了网站地址,加密公钥,以及证书的颁发机构等信息。

3.浏览器获得网站证书之后浏览器要做以下工作:

a) 验证证书的合法性(颁发证书的机构是否合法,证书中包含的网站地址是否与正在访问的地址一致等),如果证书受信任,则浏览器栏里面会显示一个小锁头,否则会给出证书不受信的提示。

b) 如果证书受信任,或者是用户接受了不受信的证书,浏览器会生成一串随机数的密码,并用证书中提供的公钥加密。

c) 使用约定好的HASH算法计算握手消息,并使用生成的随机数对消息进行加密,最后将之前生成的所有信息发送给网站。

4.网站接收浏览器发来的数据之后要做以下的操作:

a) 使用自己的私钥将信息解密取出密码,使用密码解密浏览器发来的握手消息,并验证HASH是否与浏览器发来的一致。

b) 使用密码加密一段握手消息,发送给浏览器。

5.浏览器解密并计算握手消息的HASH,如果与服务端发来的HASH一致,此时握手过程结束,之后所有的通信数据将由之前浏览器生成的随机密码并利用对称加密算法进行加密。

实现 Linux 终端录屏转gif动画

在一些开源其中,有些文档使用git动画来介绍的话效果会好很多,所以这里把在Linux终端下如何生成git动画效果整理出来,供大家参考。

安装录屏软件 asciinema

Mac

 brew install asciinema

Ubuntu

 sudo apt-add-repository ppa:zanchey/asciinema

Debian

 sudo apt-get install asciinema

Pip安装

 sudo pip3 install asciinema

目前此软件不支持 Windows。更多安装教程参考:https://zhuanlan.zhihu.com/p/28423868

用法介绍

 ❯ asciinema -h
 usage: asciinema [-h] [--version] {rec,play,cat,upload,auth} ...
 ​
 Record and share your terminal sessions, the right way.
 ​
 positional arguments:
  {rec,play,cat,upload,auth}
    rec                 Record terminal session
    play               Replay terminal session
    cat                 Print full output of terminal session
    upload             Upload locally saved terminal session to asciinema.org
    auth               Manage recordings on asciinema.org account
 ​
 optional arguments:
  -h, --help           show this help message and exit
  --version             show program's version number and exit
 ​
 example usage:
  Record terminal and upload it to asciinema.org:
    asciinema rec
  Record terminal to local file:
    asciinema rec demo.cast
  Record terminal and upload it to asciinema.org, specifying title:
    asciinema rec -t "My git tutorial"
  Record terminal to local file, limiting idle time to max 2.5 sec:
    asciinema rec -i 2.5 demo.cast
  Replay terminal recording from local file:
    asciinema play demo.cast
  Replay terminal recording hosted on asciinema.org:
    asciinema play https://asciinema.org/a/difqlgx86ym6emrmd8u62yqu8
  Print full output of recorded session:
    asciinema cat demo.cast
 ​
 For help on a specific command run:
  asciinema <command> -h
 ​

录屏

录屏命令

 asciinema rec

此时生成的文件将保存到临时目录里,一般为 /tmp/目录

也可以指定文件名

 asciinema rec demo.cast

当看到以下信息表示录屏工作开始,以后的操作将会被记录下来

 asciinema: recording asciicast to /tmp/tmpg4auzrud-ascii.cast
 asciinema: press <ctrl-d> or type "exit" when you're done

此时你可以进行正常的操作。

当操作完成后,按 ctrol-dexit退出录屏,看到提示信息

 asciinema: recording finished
 asciinema: press <enter> to upload to asciinema.org, <ctrl-c> to save locally
 asciinema: asciicast saved to /tmp/tmp1tj9jqnx-ascii.cast

如果按enter键会将结束自动上传到 asciinema.org网站,按下Ctrl+C 表示进行本地存储, 这里存储位置为 /tmp/tmp1tj9jqnx-ascii.cast

这种方法会在用户本地生成一个 .cast的文件,后面我们对其进行回放。

回放

我们先预览下上面生成的动画效果

 asciinema play demo.cast

查看会话

有时候我们需要查看用户的所有终端历史会话内容,此时可执行命令

 asciinema cat demo.cast

如果在屏幕过程中存在一些特殊命令,如 vi,则会话内容将显示成为乱码,还有可能提示错误。

上传

我们也可以将本地生成的文件上传到公网

asciinema upload demo.cast

提示

asciinema upload demo.cast
View the recording at:

    https://asciinema.org/a/KG2utenPw4pXk12TcEprPDaRh

This installation of asciinema recorder hasn't been linked to any asciinema.org
account. All unclaimed recordings (from unknown installations like this one)
are automatically archived 7 days after upload.

If you want to preserve all recordings made on this machine, connect this
installation with asciinema.org account by opening the following link:

    https://asciinema.org/connect/4fc6bdf3-ecc4-445a-a045-540aa101dee1

我们可以直接在浏览器里访问上面的URL来访问生成的效果。

转成Gif

有时候我们需要将上面的录屏内容转成gif格式在网络上传播,这时我们还需要利用一些工具将其转为gif动画才可以。这里我们使用一个docker镜像 asciinema/asciicast2gif 来操作

下载镜像

下载 Docker 镜像到本地

 docker pull asciinema/asciicast2gif

转换命令

docker run --rm -v $PWD:/data asciinema/asciicast2gif -s 2 -t solarized-dark demo.cast demo.gif

为了方便,我们用命令别名操作,将以下代码保存到 .bashrc 文件中,最后再执行 source ~/.bashrc 应用配置(如果用的zsh的话,则需要保存到 .zshrc文件)

 alias asciicast2gif='docker run --rm -v $PWD:/data asciinema/asciicast2gif'

以后就可以直接使用命令

 asciicast2gif demo.cast demo.gif

这里将录屏生成的json文件demo.json转成 demo.gif 文件,这时我们可以看下gif的生成效果。

另外在生成动画的时候,也可以指定一些参数,如倍速、缩放比例、高度和宽度,如

 asciicast2gif -t solarized-dark -s 2 -S 1 -w 400 -h 500 demo.cast demo.gif

参数

-t 表示颜色方案,必须为 asciinema, tango, solarized-dark, solarized-light, monokai (default: asciinema) 其中的一个,默认方案是 asciinema

-s 表示动画速度,默认为1

-S 图像比例/像素密度(默认值:2)

-w 将端子剪裁到指定的列数(宽度)

-h 将终端剪裁到指定的行数(高度)

常见问题

有时候在转gif的出现失败的情况,如果指定了一些参数的话,可以试着将参数移除试看看。我在用的时候经常出现在指定宽度和高度参数的时候会转换失败,将这两个参数省略则没有问题,怀疑是需要宽高不合理造成的。

参考资料

Linux 中的 Tun/Tap 介绍

TUN/TAP 设备

在计算机中TUN与TAP是操作系统内核中的虚拟网络设备。不同于硬件设备这些虚拟的网络设备全部用软件实现,但提供了与硬件设备完全相同的功能。

我们先了解一下物理设备的工作原理

所有主机物理网卡收到的数据包时,会先将其交给内核的 Network Stack 处理,然后通过 Socket API 通知给用户态的用户程序。

Linux 中 Tun/Tap 驱动程序为应用程序提供了两种交互方式:

  • 虚拟网络接口和字符设备/dev/net/tun。写入字符设备/dev/net/tun的数据会发送到虚拟网络接口中;
  • 发送到虚拟网络接口中的数据也会出现在该字符设备上;

我们再看下 tun 设备的工作原理

用户态应用往字符设备 /dev/tunX 写数据时,写入的数据都会出现在TUN虚拟设备上,当内核发送一个包给 TUN 虚拟设备时,通过读这个字符设备 /dev/tunX 同样可以拿到包的内容。

用户态应用程序写数据到 tun/tap 设备后进入内核态,内核态通过TCP协议复制到用户态,最后数据再次复制到内核态并通过物理网卡转发出去,期间共经历了三次用户态与内核态的复制操作,相比传统的一次复制操作来说,开销还是比较大的,因此性能会有一定的下降,这正是它的缺点。

TAP 设备与 TUN 设备工作方式完全相同,区别在于:

  • TUN 设备的 /dev/tunX 文件收发的是 IP 层数据包,只能工作在 IP 层,无法与物理网卡做 bridge,但是可以通过三层交换(如 ip_forward)与物理网卡连通。
  • TAP 设备的 /dev/tapX 文件收发的是 MAC 层数据包,拥有 MAC 层功能,可以与物理网卡做 bridge,支持 MAC 层广播

应用场景

tun/tap 的最主要应用场景就是vpn。
基实现原理就是用到隧道技术,将无法直接发送的包通先封成允许通过的合法数据包,然后经过隧道的方式传递给对方,对方收到数据包再解包成原始数据包,再继续传递下去,直到接收端收到,然后响应并原路返回。
以下图为例

  1. 应用进程(用户态)发起一个请求时,数据包并不是直接通过eth0网卡流出去,而是将请求数据包写入一个 TUN 字符设备,此时字符设备的数据会被发到虚拟网卡上(进入内核态)。根据TUN设置的特点,凡是写到这个设备的数据都可以在设备的另一端被应用程序读出的原理,应用程序客户端VPN(Port:28001)不断的从TUN 设备里将数据包读出来,然后再经过物理的网卡 eth0(IP1) 网卡流出,这一步就是一个普通的应用程序的客户端发起一个请求的过程。
  2. 流出的数据包通过 eth0 (IP2)被服务端VPN(Port:38001)接收到,然后再将收到的数据包以同样的方式写入 TUN 设备,此时进入内核态,经过 TCP/IP 协议栈,则再次将数据包经过物理网卡eth0(IP2)出去,经过交换同机或路由器直到最终到达目的主机(目的主机非本机)。
  3. 然后目的主机将响应按原来的线路返回给发起请求应用程序。

总结

  • 数据包在整个流程中,需要进行一些封包解包的操作, 这个操作由设备驱动完成
  • 如果数据包目的地不是VPN(Port:38001)当前所在主机的话,则需要向数据流向其它机器,此时务必修改IPTABLES的来源地址进行,即需要做SNAT,否则响应数据包将无法原路返回给客户端。

使用方法

我们先介绍一下在 Linux 中是如何对 TUN/TAP 虚拟设备进行操作创建管理和删除的,但对于如何充分利用这些设备必须通过编写程序代码来实现,在后面会给出网友整理出来的演示代码。

命令行输入 ip help 查看 ip 命令是否支持 tun/tap 工具,支持的话就会显示 tuntap 选项:

# ip help
Usage: ip [ OPTIONS ] OBJECT { COMMAND | help }
       ip [ -force ] -batch filename
where  OBJECT := { link | addr | addrlabel | route | rule | neigh | ntable |
                   tunnel | tuntap | maddr | mroute | mrule | monitor | xfrm |
                   netns | l2tp | tcp_metrics | token }

不支持就请升级或下载最新的 iproute2 工具包,也可以使用类似的 tunctl 工具。

先查看一下 ip tuntap 的基本用法

# ip tuntap help
Usage: ip tuntap { add | del | show | list | lst | help } [ dev PHYS_DEV ]
    [ mode { tun | tap } ] [ user USER ] [ group GROUP ]
    [ one_queue ] [ pi ] [ vnet_hdr ] [ multi_queue ] [ name NAME ]

Where:    USER  := { STRING | NUMBER }
    GROUP := { STRING | NUMBER }
  1. 创建 tap/tun 设备:
# ip tuntap add dev tap0 mod tap # 创建 tap 
# ip tuntap add dev tun0 mod tun # 创建 tun

# ifconfig -a

新添加的虚拟网卡默认是 DOWN 状态.
对于 tun 类型的虚拟网卡,它的MAC地址全是 0,这个是正常的

  1. 激活虚拟网卡
# ip link set tun0 up
# ip link set tap0 up

对它的操作与普通网卡的命令是一样的

  1. 分配IP
# ip addr add 10.0.0.1/24 dev tun0

此时 PING 10.0.0.1 是可以通的。

# ping 10.0.0.1
PING 10.0.0.1 (10.0.0.1) 56(84) bytes of data.
64 bytes from 10.0.0.1: icmp_seq=1 ttl=64 time=0.031 ms
64 bytes from 10.0.0.1: icmp_seq=2 ttl=64 time=0.036 ms
64 bytes from 10.0.0.1: icmp_seq=3 ttl=64 time=0.031 ms
64 bytes from 10.0.0.1: icmp_seq=4 ttl=64 time=0.037 ms
  1. 删除 tap/tun 设备:
# ip tuntap del dev tap0 mod tap # 删除 tap
# ip tuntap del dev tun0 mod tun # 删除 tun

代码演示

上面我们手动创建了虚拟网卡,但没有办法测试网卡的使用效果,下面是一段实现虚拟网卡读取的演示代码,代码摘自:https://segmentfault.com/a/1190000009249039

#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <linux/if_tun.h>
#include<stdlib.h>
#include<stdio.h>

int tun_alloc(int flags)
{

    struct ifreq ifr;
    int fd, err;
    char *clonedev = "/dev/net/tun";

    if ((fd = open(clonedev, O_RDWR)) < 0) {
        return fd;
    }

    memset(&ifr, 0, sizeof(ifr));
    ifr.ifr_flags = flags;

    if ((err = ioctl(fd, TUNSETIFF, (void *) &ifr)) < 0) {
        close(fd);
        return err;
    }

    printf("Open tun/tap device: %s for reading...n", ifr.ifr_name);

    return fd;
}

int main()
{

    int tun_fd, nread;
    char buffer[1500];

    /* Flags: IFF_TUN   - TUN device (no Ethernet headers)
     *        IFF_TAP   - TAP device
     *        IFF_NO_PI - Do not provide packet information
     */
    tun_fd = tun_alloc(IFF_TUN | IFF_NO_PI);

    if (tun_fd < 0) {
        perror("Allocating interface");
        exit(1);
    }

    while (1) {
        nread = read(tun_fd, buffer, sizeof(buffer));
        if (nread < 0) {
            perror("Reading from interface");
            close(tun_fd);
            exit(1);
        }

        printf("Read %d bytes from tun/tap devicen", nread);
    }
    return 0;
}

演示

#--------------------------第一个shell窗口----------------------
#将上面的程序保存成tun.c,然后编译
dev@debian:~$ gcc tun.c -o tun

#启动tun程序,程序会创建一个新的tun设备,
#程序会阻塞在这里,等着数据包过来
dev@debian:~$ sudo ./tun
Open tun/tap device tun1 for reading...
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device
Read 84 bytes from tun/tap device

#--------------------------第二个shell窗口----------------------
#启动抓包程序,抓经过tun1的包
# tcpdump -i tun1
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun1, link-type RAW (Raw IP), capture size 262144 bytes
19:57:13.473101 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 1, length 64
19:57:14.480362 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 2, length 64
19:57:15.488246 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 3, length 64
19:57:16.496241 IP 192.168.3.11 > 192.168.3.12: ICMP echo request, id 24028, seq 4, length 64

#--------------------------第三个shell窗口----------------------
#./tun启动之后,通过ip link命令就会发现系统多了一个tun设备,
#在我的测试环境中,多出来的设备名称叫tun1,在你的环境中可能叫tun0
#新的设备没有ip,我们先给tun1配上IP地址
dev@debian:~$ sudo ip addr add 192.168.3.11/24 dev tun1

#默认情况下,tun1没有起来,用下面的命令将tun1启动起来
dev@debian:~$ sudo ip link set tun1 up

#尝试ping一下192.168.3.0/24网段的IP,
#根据默认路由,该数据包会走tun1设备,
#由于我们的程序中收到数据包后,啥都没干,相当于把数据包丢弃了,
#所以这里的ping根本收不到返回包,
#但在前两个窗口中可以看到这里发出去的四个icmp echo请求包,
#说明数据包正确的发送到了应用程序里面,只是应用程序没有处理该包
dev@debian:~$ ping -c 4 192.168.3.12
PING 192.168.3.12 (192.168.3.12) 56(84) bytes of data.

--- 192.168.3.12 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3023ms

参考资料

用 Goalng 开发 OPA 策略

Open Policy Agent 简称OPA是一个开源的通用策略引擎,可在整个堆栈中实现统一的、上下文感知的策略实施。OPA 已经成为了云原生计算基金会 (CNCF) 领域的毕业项目,已经在 Kubernetes / Istio 等多个知名项目里使用 。

OPA的核心思想就是策略即代码。

它使用Rego语言开发,Rego 的灵感来自 Datalog,它是一种易于理解、已有数十年的历史的查询语言。Rego 扩展了 Datalog 以支持 JSON 等文档模型。对于它的详细介绍请参考官方文档 https://www.openpolicyagent.org/docs/latest/policy-language/#what-is-rego,这里不再介绍,本方主要介绍如何使用Golang 来开发一个opa策略。

概述

OPA 将策略决策与策略执行分离。当您的软件需要做出策略决策时,它会查询 OPA 并提供结构化数据(例如 JSON)作为输入。 OPA 接受任意结构化数据作为输入。

对于它的输入一般称为 input, 可以为任意类型,输出也一样可以为任意类型,即可以输出布尔值truefalse,也可以输出一个 JSON 字符串对象。

Continue reading

一文看懂Golang 定时器源码

计时器分 Timer 和 Ticker 两种,它们底层基本是一样的,两差的区别请参考 https://blog.haohtml.com/archives/19859, 这里我们的介绍对象是 Timer 。

golang timer

计时器结构体

https://github.com/golang/go/blob/go1.17.6/src/time/sleep.go#L84-L98

 // NewTimer creates a new Timer that will send
 // the current time on its channel after at least duration d.
 func NewTimer(d Duration) *Timer {
     c := make(chan Time, 1)
     t := &Timer{
         C: c,
         r: runtimeTimer{
             when: when(d),
             f:    sendTime,
             arg:  c,
         },
     }
     startTimer(&t.r)
     return t
 }

通过调用 NewTimer() 函数创建一个 Timer,首先创建一个长度为1的有缓冲channel,再创建一个Timer的结构体,并将 channel 置于 Timer 结构体内。

注意这里的 runtimeTimer.f 字段是一个函数 sendTime ,其实现如下

func sendTime(c interface{}, seq uintptr) {
	// Non-blocking send of time on c.
	// Used in NewTimer, it cannot block anyway (buffer).
	// Used in NewTicker, dropping sends on the floor is
	// the desired behavior when the reader gets behind,
	// because the sends are periodic.
	select {
	case c.(chan Time) <- Now():
	default:
	}
}

当 sendTime 函数主要用在 newTimer() 时,它以无阻塞的方式将当前时间 Now() 发送到 c 通道里。如果用在 newTicker() 时,如果读取落后,会将发送丢弃,它是周期性的。

我们给出 Timer 的结构体声明。

 type Timer struct {
  C <-chan Time
  r runtimeTimer
 }

一共两个字段,为了理解方面我们称 runtimeTimer 为 timer 值。

我们再看一下其中的 runtimeTimer 结构体的声明

 // Interface to timers implemented in package runtime.
 // Must be in sync with ../runtime/time.go:/^type timer
 type runtimeTimer struct {
  pp       uintptr
  when     int64
  period   int64
  f        func(interface{}, uintptr) // NOTE: must not be closure
  arg      interface{}
  seq      uintptr
  nextwhen int64
  status   uint32
 }

对于 runnerTimer结构体要与在 runtime/time.go 文件中的 timer 结构体保持同步。

结构体字段说明

  • pp 指针类型,这里指 GPM 中的 P。如果这个计时器 timer 在一个heap 上,它在哪个 P 的堆上
  • when 表示唤醒执行的时间,表示什么时间开始执行
  • period 周期,一定是大于 0; when+period 表示下次唤醒执行的时间
  • f 执行函数,不允许为匿名函数,最好为非阻塞函数
  • arg 上面f函数的参数
  • seq 同 arg,其在 runOneTimer 函数中的调用方式为 f(arg, seq)
  • nextwhen 下次运行的时间,其值只有在 timerModifiedXX status 状态下才设置
  • status 状态,其定义的的可用值有10种,定义在 runtime/time.go,我们下面对这些状态进行了介绍。

每次开启一个goroutine 执行 f(arg, now),基中when表示执行的时间,而 when+period 表示下次执行的时间。(这时有点疑问,对调用的函数参数,f的第二个参数是 now, 但后面介绍的时候第二个参数却是 seq)

通过查看 https://github.com/golang/go/blob/go1.17.6/src/runtime/time.go#L41-L116 可知以下几点:

Continue reading

Golang常见编译参数

在执行 go build 命令的时候,经常需要添加一些参数,或许是为了调试,也或许是为了生成最终部署二进制文件。

在编译特定包时需要传递参数,格式应遵守“包名=参数列表”,如

go build -gcflags -gcflags='log=-N -l' main.go

-gcflags

go build 可以用 -gcflagsgo编译器传入参数,也就是传给 go tool compile 的参数,因此可以用 go tool compile –help 查看所有可用的参数。

其中 -m 可以检查代码的编译优化情况,包括逃逸情况和函数是否内联。

-ldflags

go build用 -ldflags 给go链接器传入参数,实际是给go tool link的参数,可以用go tool link –help查看可用的参数。

常用-X来指定版本号等编译时才决定的参数值。例如代码中定义var buildVer string,然后在编译时用go build -ldflags “-X main.buildVer=1.0” … 来赋值。注意-X只能给string类型变量赋值。


Golang中的 CGO_ENABLED 环境变量

Golang中的编译参数

开发中经常使用 go build 命令来编译我们的程序源码,然后将生成二进制文件直接部署,极其方便。

对于 go build 有一些参数,对于针对程序源码进行一些编译优化,下面我们对经常使用的一些参数来介绍一下。

环境变量

环境变量需要在go命令前面设置,如果多个变量的话,中间需要用“空格”分隔。下面我们介绍一个非常常见到的一些环境变量

$ CGO_ENABLED=1 GOARCH=amd64 GOOS=linux go build -o myserver main.go

除了这里给出的这几个变量外,还有一些其它变量,如 GODEBUG、GOFLAGS、GOPROXY 等,所有支持环境变量都可以在 https://github.com/golang/go/blob/a88575d662a7e8e4fbb31bf139bcffc063e2a734/src/cmd/go/internal/help/helpdoc.go#L485 里找到,有兴趣的话可以看看他们的作用。

这里重点介绍一下 CGO_ENABLED 环境变量对我们程序的影响。 CGO_ENABLED是用来控制golang 编译期间是否支持调用 cgo 命令的开关,其值为1或0,默认情况下值为1,可以用 go env 查看默认值。

如果你的程序里调用了cgo 命令,此参数必须设置为1,否则将编译时出错。这里直接用文档 https://go.dev/blog/cgo 中的一个例子验证。

package main

// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s) {
//   printf("%sn", s);
// }
import "C"
import "unsafe"

func main() {
	cs := C.CString("Hello from stdio")
	C.myprint(cs)
	C.free(unsafe.Pointer(cs))
}

然后我们执行一下 启用 CGO_ENABLED=1 的情况

root@ubuntu:/home/sxf/gotest# CGO_ENABLED=1 go run main.go
root@ubuntu:/home/sxf/gotest# ./main
Hello from stdio

可以看到输出正常,这里也可以省略不写变量,因为默认情况为启用CGO状态

启用 CGO_ENABLED=0 的情况

root@ubuntu:/home/sxf/gotest# CGO_ENABLED=0 go build main.go
go: no Go source files

可以看到编译失败,验证了我们上面说的情况。

这里提示找不到go源文件,不清楚底层是如何判断这一情况的。

那么,如果我们一个程序里未调用cgo,在编译时指定 CGO_ENABLED 不同值话,又会发生什么呢?编译的二进制有何区别呢?

root@ubuntu:/home/sxf/gotest# CGO_ENABLED=1 go build -o cgo_main main.go
root@ubuntu:/home/sxf/gotest# CGO_ENABLED=0 go build -o no_cgo_main main.go
root@ubuntu:/home/sxf/gotest# ls -al
total 3804
drwxrwxr-x  2 sxf  sxf     4096 Nov 25 10:22 .
drwxr-x--- 47 sxf  sxf     4096 Nov 19 12:48 ..
-rwxr-xr-x  1 root root 1937311 Nov 25 10:22 cgo_main
-rw-rw-r--  1 sxf  sxf      119 Oct 29 09:07 go.mod
-rw-rw-r--  1 sxf  sxf      241 Oct 29 09:07 go.sum
-rw-rw-r--  1 sxf  sxf       72 Nov 25 10:21 main.go
-rwxr-xr-x  1 root root 1937311 Nov 25 10:22 no_cgo_main

可以看到两种情况都可能编译成功,且两者生成的二进制文件是完全一样的。由此总结出可以看出如果程序里未调用 cgo 的话,此变量值并没有影响的。

那么问题来了,为什么这么多项目里编译的时候都明确指定了此环境变量的值呢,主要是编译器在编译时会根据不同的情况使用不同的编译方法。 当CGO_ENABLED=1,进行编译时会将文件中引用libc的库(比如常用的net包),以动态链接的方式生成目标文件。 当CGO_ENABLED=0,进行编译时则会把在目标文件中未定义的符号(外部函数)一起链接到可执行文件中。

不论哪种方式,都可以使用静态连接编译

参考

理解 firewalld/ufw 与iptables、netfilter 的关系

iptables 作为 Linux/Unix 下一款优秀的防火墙软件,在安全方面发挥着极其重要的作用,作为系统管理员来讲一点也不陌生。不过对于一些新手来说,复杂性是一个门槛,Linux厂商为了解决这个问题,于是推出了新的管理工具,如 Centos 下的 Firewalld 和 Ubuntu 下的ufw, 他们对新手十分友好,只需要几个很简单的命令即可实现想要的功能,再不也必为记不住iptables中的四表五键而烦恼了。
那么,是不是有了 firewalld 和 ufw就不需要iptables了呢?并不是的。

首先我们要清楚firewalld、ufw 与iptables的关系,可以理解为两者只是对iptables其进行了一层封装,它们在用户交互方面做了非常多的改进,使其对用户更加友好,不需要再记住原来那么多命令了。

而目前对于一些系统管理员来讲,大概率还是会直接使用 iptables,主要原因是灵活性,当然也有一定的历史原因。对比前面两个管理工具,他们也存在一定的问题,如只能对单条规则进行管理,详细参考相关文档。


另外对于 firewalld 还有图形界面。


除了这三个还有一个 netfilter 的东西,它又是什么呢?

firewalld/ufw 自身并不具备防火墙的功能,而是和 iptables 一样需要通过内核的 netfilter 来实现,也就是说 firewalld 和 iptables 一样,他们的作用都是用于维护规则,而真正使用规则干活的是内核的netfilter。所以iptables服务和firewalld服务都不是真正的防火墙,只是用来定义防火墙规则功能的管理工具,通过iptables将定义好的规则交给内核中的netfilter(网络过滤器来读取)从而实现真正的防火墙功能。不过由于用户一般操作的都是iptables,所以称其为防火墙也并没有什么不妥的。

总结一下,Netfilter/Iptables 是Linux系统自带的防火墙,Iptables管理规则,Netfilter则是规则的执行者,它们一起实现Linux下安全防护。

以上就是他们四者的关系。

参考资料

https://www.cnblogs.com/kevingrace/p/6265113.html