L2TP

第二层隧道协议 (L2TP) 允许 L2 帧通过 IP 网络进行隧道传输。

本文档涵盖内核的 L2TP 子系统。它记录了希望使用 L2TP 子系统的应用程序开发人员的内核 API,并提供了一些关于内部实现的技术细节,这些细节可能对内核开发人员和维护人员有用。

概述

内核的 L2TP 子系统实现了 L2TPv2 和 L2TPv3 的数据路径。L2TPv2 通过 UDP 传输。L2TPv3 通过 UDP 或直接通过 IP(协议 115)传输。

L2TP RFC 定义了两种基本类型的 L2TP 数据包:控制数据包(“控制平面”)和数据数据包(“数据平面”)。内核仅处理数据数据包。更复杂的控制数据包由用户空间处理。

一个 L2TP 隧道携带一个或多个 L2TP 会话。每个隧道都与一个套接字关联。每个会话都与一个虚拟网络设备关联,例如 pppNl2tpethN,数据帧通过它们在 L2TP 之间传递。L2TP 标头中的字段标识隧道或会话以及它是控制数据包还是数据数据包。当使用 Linux 内核 API 设置隧道和会话时,我们只是在设置 L2TP 数据路径。控制协议的所有方面都由用户空间处理。

这种职责的划分导致在建立隧道和会话时出现自然的 Operations 序列。该过程如下所示

  1. 创建一个隧道套接字。通过该套接字与对等方交换 L2TP 控制协议消息,以便建立隧道。

  2. 使用从对等方使用控制协议消息获得的信息,在内核中创建隧道上下文。

  3. 通过隧道套接字与对等方交换 L2TP 控制协议消息,以便建立会话。

  4. 使用从对等方使用控制协议消息获得的信息,在内核中创建会话上下文。

L2TP API

本节记录 L2TP 子系统的每个用户空间 API。

隧道套接字

L2TPv2 始终使用 UDP。L2TPv3 可以使用 UDP 或 IP 封装。

要创建供 L2TP 使用的隧道套接字,请使用标准的 POSIX 套接字 API。

例如,对于使用 IPv4 地址和 UDP 封装的隧道

int sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

或者对于使用 IPv6 地址和 IP 封装的隧道

int sockfd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_L2TP);

此处不需要介绍 UDP 套接字编程。

IPPROTO_L2TP 是内核 L2TP 子系统实现的 IP 协议类型。L2TPIP 套接字地址在 include/uapi/linux/l2tp.h 中的 struct sockaddr_l2tpip 和 struct sockaddr_l2tpip6 中定义。该地址包括 L2TP 隧道(连接)ID。要使用 L2TP IP 封装,L2TPv3 应用程序应使用本地分配的隧道 ID 绑定 L2TPIP 套接字。当对等方的隧道 ID 和 IP 地址已知时,必须完成连接。

如果 L2TP 应用程序需要处理来自使用 L2TPIP 的对等方的 L2TPv3 隧道建立请求,则必须打开一个专用的 L2TPIP 套接字来侦听这些请求,并使用隧道 ID 0 绑定该套接字,因为隧道建立请求的目标是隧道 ID 0。

当隧道套接字关闭时,L2TP 隧道及其所有会话将自动关闭。

PPPoL2TP 会话套接字 API

对于 PPP 会话类型,必须打开 PPPoL2TP 套接字并将其连接到 L2TP 会话。

在创建 PPPoL2TP 套接字时,应用程序通过套接字 connect() 调用向内核提供关于隧道和会话的信息。会提供源和目标隧道 ID 以及会话 ID,还有 UDP 或 L2TPIP 套接字的文件描述符。请参见 include/linux/if_pppol2tp.h 中的 struct pppol2tp_addr。由于历史原因,对于 L2TPv2/L2TPv3 IPv4/IPv6 隧道,地址结构略有不同,用户空间必须使用与隧道套接字类型匹配的适当结构。

用户空间可以使用 setsockopt 和 ioctl 在 PPPoX 套接字上控制隧道或会话的行为。支持以下套接字选项:-

DEBUG

调试消息类别的位掩码。请参见下文。

SENDSEQ

  • 0 => 不发送带序列号的数据包

  • 1 => 发送带序列号的数据包

RECVSEQ

  • 0 => 接收数据包序列号是可选的

  • 1 => 丢弃不带序列号的接收数据包

LNSMODE

  • 0 => 充当 LAC。

  • 1 => 充当 LNS。

REORDERTO

重排序超时时间(以毫秒为单位)。如果为 0,则不尝试重排序。

除了标准的 PPP ioctl 之外,还提供了一个 PPPIOCGL2TPSTATS,用于使用相应隧道或会话的 PPPoX 套接字从内核检索隧道和会话统计信息。

用户空间示例代码

  • 创建会话 PPPoX 数据套接字

    /* Input: the L2TP tunnel UDP socket `tunnel_fd`, which needs to be
     * bound already (both sockname and peername), otherwise it will not be
     * ready.
     */
    
    struct sockaddr_pppol2tp sax;
    int session_fd;
    int ret;
    
    session_fd = socket(AF_PPPOX, SOCK_DGRAM, PX_PROTO_OL2TP);
    if (session_fd < 0)
            return -errno;
    
    sax.sa_family = AF_PPPOX;
    sax.sa_protocol = PX_PROTO_OL2TP;
    sax.pppol2tp.fd = tunnel_fd;
    sax.pppol2tp.addr.sin_addr.s_addr = addr->sin_addr.s_addr;
    sax.pppol2tp.addr.sin_port = addr->sin_port;
    sax.pppol2tp.addr.sin_family = AF_INET;
    sax.pppol2tp.s_tunnel  = tunnel_id;
    sax.pppol2tp.s_session = session_id;
    sax.pppol2tp.d_tunnel  = peer_tunnel_id;
    sax.pppol2tp.d_session = peer_session_id;
    
    /* session_fd is the fd of the session's PPPoL2TP socket.
     * tunnel_fd is the fd of the tunnel UDP / L2TPIP socket.
     */
    ret = connect(session_fd, (struct sockaddr *)&sax, sizeof(sax));
    if (ret < 0 ) {
            close(session_fd);
            return -errno;
    }
    
    return session_fd;
    

L2TP 控制数据包仍可在 tunnel_fd 上读取。

  • 创建 PPP 通道

    /* Input: the session PPPoX data socket `session_fd` which was created
     * as described above.
     */
    
    int ppp_chan_fd;
    int chindx;
    int ret;
    
    ret = ioctl(session_fd, PPPIOCGCHAN, &chindx);
    if (ret < 0)
            return -errno;
    
    ppp_chan_fd = open("/dev/ppp", O_RDWR);
    if (ppp_chan_fd < 0)
            return -errno;
    
    ret = ioctl(ppp_chan_fd, PPPIOCATTCHAN, &chindx);
    if (ret < 0) {
            close(ppp_chan_fd);
            return -errno;
    }
    
    return ppp_chan_fd;
    

LCP PPP 帧将在 ppp_chan_fd 上可用进行读取。

  • 创建 PPP 接口

    /* Input: the PPP channel `ppp_chan_fd` which was created as described
     * above.
     */
    
    int ifunit = -1;
    int ppp_if_fd;
    int ret;
    
    ppp_if_fd = open("/dev/ppp", O_RDWR);
    if (ppp_if_fd < 0)
            return -errno;
    
    ret = ioctl(ppp_if_fd, PPPIOCNEWUNIT, &ifunit);
    if (ret < 0) {
            close(ppp_if_fd);
            return -errno;
    }
    
    ret = ioctl(ppp_chan_fd, PPPIOCCONNECT, &ifunit);
    if (ret < 0) {
            close(ppp_if_fd);
            return -errno;
    }
    
    return ppp_if_fd;
    

IPCP/IPv6CP PPP 帧将在 ppp_if_fd 上可用进行读取。

然后可以使用 netlink 的 RTM_NEWLINK、RTM_NEWADDR、RTM_NEWROUTE 或 ioctl 的 SIOCSIFMTU、SIOCSIFADDR、SIOCSIFDSTADDR、SIOCSIFNETMASK、SIOCSIFFLAGS 或 ip 命令像往常一样配置 ppp<ifunit> 接口。

  • 通过桥接要桥接的两个 L2TP 会话的 PPP 通道,可以支持桥接具有 PPP 伪线类型的 L2TP 会话(也称为 L2TP 隧道交换或 L2TP 多跳)。

    /* Input: the session PPPoX data sockets `session_fd1` and `session_fd2`
     * which were created as described further above.
     */
    
    int ppp_chan_fd;
    int chindx1;
    int chindx2;
    int ret;
    
    ret = ioctl(session_fd1, PPPIOCGCHAN, &chindx1);
    if (ret < 0)
            return -errno;
    
    ret = ioctl(session_fd2, PPPIOCGCHAN, &chindx2);
    if (ret < 0)
            return -errno;
    
    ppp_chan_fd = open("/dev/ppp", O_RDWR);
    if (ppp_chan_fd < 0)
            return -errno;
    
    ret = ioctl(ppp_chan_fd, PPPIOCATTCHAN, &chindx1);
    if (ret < 0) {
            close(ppp_chan_fd);
            return -errno;
    }
    
    ret = ioctl(ppp_chan_fd, PPPIOCBRIDGECHAN, &chindx2);
    close(ppp_chan_fd);
    if (ret < 0)
            return -errno;
    
    return 0;
    

可以注意到,在桥接 PPP 通道时,PPP 会话不会在本地终止,也不会创建本地 PPP 接口。在一个通道上到达的 PPP 帧将直接传递到另一个通道,反之亦然。

PPP 通道不需要保持打开状态。只需要保持打开会话 PPPoX 数据套接字。

更一般地,也可以以相同的方式例如桥接 PPPoL2TP PPP 通道与其他类型的 PPP 通道,例如 PPPoE。

请在 PPP 通用驱动程序和通道接口中查看有关 PPP 方面的更多详细信息。

旧的仅限 L2TPv2 的 API

当 L2TP 在 2.6.23 版本首次添加到 Linux 内核时,它仅实现了 L2TPv2,不包含 netlink API。相反,内核中的隧道和会话实例仅使用 PPPoL2TP 套接字直接管理。PPPoL2TP 套接字的使用方式如“PPPoL2TP 会话套接字 API”部分所述,但是隧道和会话实例是在套接字的 connect() 上自动创建的,而不是通过单独的 netlink 请求创建的。

  • 隧道是通过隧道管理套接字管理的,这是一个专用的 PPPoL2TP 套接字,连接到(无效的)会话 ID 0。当 PPPoL2TP 隧道管理套接字连接时,会创建 L2TP 隧道实例,并在套接字关闭时销毁。

  • 当 PPPoL2TP 套接字连接到非零会话 ID 时,会在内核中创建会话实例。会话参数使用 setsockopt 设置。当套接字关闭时,L2TP 会话实例将被销毁。

仍然支持此 API,但不鼓励使用。相反,新的 L2TPv2 应用程序应使用 netlink 先创建隧道和会话,然后为会话创建 PPPoL2TP 套接字。

非托管的 L2TPv3 隧道

内核 L2TP 子系统还支持静态(非托管)L2TPv3 隧道。非托管隧道没有用户空间隧道套接字,并且不与对等端交换控制消息以设置隧道;隧道是在隧道的每一端手动配置的。所有配置都是使用 netlink 完成的。在这种情况下,不需要 L2TP 用户空间应用程序——隧道套接字由内核创建,并使用在 L2TP_CMD_TUNNEL_CREATE netlink 请求中发送的参数进行配置。iproute2ip 实用程序具有用于管理静态 L2TPv3 隧道的命令;请使用 ip l2tp help 获取更多信息。

调试

L2TP 子系统通过 debugfs 文件系统提供一系列调试接口。

要访问这些接口,必须首先挂载 debugfs 文件系统

# mount -t debugfs debugfs /debug

然后可以访问 l2tp 目录下的文件,这些文件提供了内核中存在的当前隧道和会话上下文的摘要

# cat /debug/l2tp/tunnels

应用程序不应使用 debugfs 文件来获取 L2TP 状态信息,因为文件格式可能会更改。它被实现来提供额外的调试信息,以帮助诊断问题。应用程序应改为使用 netlink API。

此外,L2TP 子系统使用标准内核事件跟踪 API 实现跟踪点。可以按如下方式查看可用的 L2TP 事件

# find /debug/tracing/events/l2tp

最后,还提供了 /proc/net/pppol2tp,用于向后兼容原始 pppol2tp 代码。它仅列出了有关 L2TPv2 隧道和会话的信息。不鼓励使用它。

内部实现

本节适用于内核开发人员和维护人员。

套接字

UDP 套接字由网络核心实现。当使用 UDP 套接字创建 L2TP 隧道时,该套接字通过在 UDP 套接字上设置 encap_rcv 和 encap_destroy 回调来设置为封装的 UDP 套接字。当在套接字上接收到数据包时,会调用 l2tp_udp_encap_recv。当用户空间关闭套接字时,会调用 l2tp_udp_encap_destroy。

L2TPIP 套接字在 net/l2tp/l2tp_ip.cnet/l2tp/l2tp_ip6.c 中实现。

隧道

内核为每个 L2TP 隧道保留一个 struct l2tp_tunnel 上下文。l2tp_tunnel 始终与 UDP 或 L2TP/IP 套接字关联,并保留隧道中的会话列表。当隧道首次在 L2TP 核心注册时,套接字上的引用计数会增加。这可确保当 L2TP 的数据结构引用它时,不会删除套接字。

隧道由唯一的隧道 ID 标识。对于 L2TPv2,ID 为 16 位,对于 L2TPv3,ID 为 32 位。在内部,ID 存储为 32 位值。

隧道保存在每个网络的列表中,并通过隧道 ID 索引。隧道 ID 命名空间由 L2TPv2 和 L2TPv3 共享。

处理隧道套接字关闭可能是 L2TP 实现中最棘手的部分。如果用户空间关闭隧道套接字,则必须关闭和销毁 L2TP 隧道及其所有会话。由于隧道上下文保存了对隧道套接字的引用,因此在隧道 sock_put 其套接字之前,不会调用套接字的 sk_destruct。对于 UDP 套接字,当用户空间关闭隧道套接字时,会调用套接字的 encap_destroy 处理程序,L2TP 使用该处理程序来启动其隧道关闭操作。对于 L2TPIP 套接字,套接字的关闭处理程序会启动相同的隧道关闭操作。首先关闭所有会话。每个会话都会删除其隧道引用。当隧道引用达到零时,隧道会删除其套接字引用。

会话

内核为每个会话保留一个 struct l2tp_session 上下文。每个会话都有私有数据,这些数据用于特定于会话类型的数据。对于 L2TPv2,会话始终承载 PPP 流量。对于 L2TPv3,会话可以承载以太网帧(以太网伪线)或其他数据类型,例如 PPP、ATM、HDLC 或帧中继。Linux 当前仅实现以太网和 PPP 会话类型。

某些 L2TP 会话类型也有套接字(PPP 伪线),而其他类型没有(以太网伪线)。

与隧道一样,L2TP 会话由唯一的会话 ID 标识。与隧道 ID 一样,对于 L2TPv2,会话 ID 为 16 位,对于 L2TPv3,会话 ID 为 32 位。在内部,ID 存储为 32 位值。

会话保留对其父隧道的引用,以确保当一个或多个会话引用该隧道时,该隧道保持存在。

会话保存在每个网络的列表中。L2TPv2 会话和 L2TPv3 会话存储在单独的列表中。L2TPv2 会话通过由 16 位隧道 ID 和 16 位会话 ID 组成的 32 位键进行键控。L2TPv3 会话按 32 位会话 ID 进行键控,因为 L2TPv3 会话 ID 在所有隧道中都是唯一的。

尽管 L2TPv3 RFC 指定 L2TPv3 会话 ID 不受隧道限制,但 Linux 实现历史上允许这样做。使用以 sk 和会话 ID 为键的每个网络的哈希表支持此类会话 ID 冲突。在查找 L2TPv3 会话时,列表条目可能会链接到具有该会话 ID 的多个会话,在这种情况下,使用与给定 sk(隧道)匹配的会话。

PPP

net/l2tp/l2tp_ppp.c 实现了 PPPoL2TP 套接字系列。每个 PPP 会话都有一个 PPPoL2TP 套接字。

PPPoL2TP 套接字的 sk_user_data 引用 l2tp_session。

用户空间使用 PPPoL2TP 套接字通过 L2TP 发送和接收 PPP 数据包。只有 PPP 控制帧通过此套接字传递:PPP 数据包完全由内核处理,在 L2TP 会话及其关联的 pppN netdev 之间通过内核 PPP 子系统的 PPP 通道接口传递。

L2TP PPP 实现通过关闭其对应的 L2TP 会话来处理 PPPoL2TP 套接字的关闭。这很复杂,因为它必须考虑与 netlink 会话创建/销毁请求以及尝试重新连接到正在关闭的会话的 pppol2tp_connect 竞争。PPP 会话保留对其关联套接字的引用,以使当会话引用它时套接字保持存在。

以太网

net/l2tp/l2tp_eth.c 实现了 L2TPv3 以太网伪线。它管理每个会话的 netdev。

L2TP 以太网会话通过 netlink 请求创建和销毁,或者在隧道销毁时销毁。与 PPP 会话不同,以太网会话没有关联的套接字。

其他

RFC

内核代码实现了以下 RFC 中指定的数据路径功能

RFC2661

L2TPv2

https://tools.ietf.org/html/rfc2661

RFC3931

L2TPv3

https://tools.ietf.org/html/rfc3931

RFC4719

L2TPv3 以太网

https://tools.ietf.org/html/rfc4719

实现

许多开源应用程序都使用 L2TP 内核子系统

iproute2

https://github.com/shemminger/iproute2

go-l2tp

https://github.com/katalix/go-l2tp

tunneldigger

https://github.com/wlanslovenija/tunneldigger

xl2tpd

https://github.com/xelerance/xl2tpd

限制

当前实现存在许多限制

  1. 与 openvswitch 的接口尚未实现。将 OVS 以太网和 VLAN 端口映射到 L2TPv3 隧道可能很有用。

  2. VLAN 伪线是使用配置了 VLAN 子接口的 l2tpethN 接口实现的。由于 L2TPv3 VLAN 伪线只承载一个 VLAN,因此最好使用单个网络设备,而不是每个 VLAN 会话使用一对 l2tpethNl2tpethN:M。为此添加了 netlink 属性 L2TP_ATTR_VLAN_ID,但它从未实现。

测试

内核内置的自测程序会对非托管 L2TPv3 以太网功能进行测试。请参阅 tools/testing/selftests/net/l2tp.sh

另一个测试套件 l2tp-ktest 涵盖了所有的 L2TP API 和隧道/会话类型。未来可能会将其集成到内核内置的 L2TP 自测程序中。