Stefenson's Blog

一个渣渣程序员的笔记

Stefenson Wang's avatar Stefenson Wang

PPPoE服务器编写

PPPoE(Point-to-Point Protocol Over Ethernet)协议是一个以太网上的点对点协议,是将点对点协议(PPP)封装在以太网(Ethernet)框架中的一种网络隧道协议。
它是一个链路层协议,如果要想自己实现一个PPPoE服务器需要了解链路层编程的相关知识。
网络五层架构中:应用——传输——网络——链路——物理。链路层属于比较靠底层的一层,所以难度还是有的。编程的时候相当于是直接与网卡打交道,编写难度比较高。

这里在Windows中,我比较推荐 WinPcap 进行编程,虽然Windows中也有面向网卡的编程,但是一般只有C的接口,并且相关资料匮乏,往往还需要与各个型号的网卡打交道,所以推荐使用WinPcap,接口统一并且易于掌握,只是有些时候速度不太理想。
使用WinPcap,你甚至可以发送一个一字节的报文(虽然他毫无意义)!在WinPcap里,TCP/IP报文头将不再是限制!你甚至不会被链路层默认封装头限制!报文里的每一个字节全部由你自由定制!WinPcap!你值得拥有!

咳咳,扯远了(感觉跟打了个广告一样……)下面说回PPPoE编程。

PPPoE工作流程

PPPoE流程
上面的图是一个PPPoE完整的工作流程(点击可查看大图)。
双向箭头的地方表示顺序可以互换,任何一方都可以发起,没有严格要求谁先发起。

从图中可以看出,PPPoE流程分为五个阶段。
其实不细分,PPPoE只有两个阶段,一个是PPPoED阶段,后面的LCP/Auth/IPCP/PPP全部都可以纳入PPP Session阶段。

PPPoE服务器在后续数据交互的过程还要参与,它不仅仅是提供IP配置给客户端,后续通讯中全部建立在PPP Session之上,服务器需要处理这些Session包,一是与客户端通讯确认是否在线管理session,二是对数据进行重新封包,去掉PPP报文头,转换为一般的报文与外部目标设备通讯,并且吧通讯结果使用PPP封包发送给客户端。所以说PPPoE服务器对服务器性能要求非常高。
从上面的描述中也可以看出PPP Session是一个很重要的概念,PPPoE通讯中,链接保持、数据传输、链路通讯、链路状态汇报和修改全部都是在PPP Session通讯上进行的,Session是一个PPPoE通讯中的关键通道。

下面就每一个阶段讲解其流程和报文结构。

链路报文头

首先介绍一下链路报文头:

Destination Source Type
6 Bytes 6 Bytes 2 Bytes

由于我们做的是链路层编程,报文里面每一个字节都需要我们填进去,所以开头这一部分我们也需要了解。
Destination:目标的Mac地址,PADI阶段客户端会使用FF:FF:FF:FF:FF:FF广播地址。
Source:来源MAC,该值在组织报文时理论上可以随意设置,可以不与当前发送设备的MAC一致,链路层编程时Source不会被自动填入当前设备的MAC地址,所以这里可以随便写(要来搞事吗www)。
Type:报文类型,目前我所知道的取值有这些

0x8863……………..PPPoE Discovery
0x8864……………..PPPoE Session
0x0800……………..IPv4

PPPoE Discovery对应的就是PPPoED阶段报文类型,在PPPoED阶段Type取该值。
PPPoE Session对应的是PPPoED阶段之后链路通讯的报文类型,在LCP/Auth/IPCP/PPP阶段Type都使用该值。
IPv4为目前网络中使用的普通报文类型,现阶段v6没有普及,如果不使用PPPoE方式进行网络接入,报文类型一般都是IPv4。

PPPoE报文通用部分

在链路报文头之后,PPPoE报文有一段通用的部分:

Version&Type Code Session ID Payload Length
1 Bytes 1 Bytes 2 Bytes 2 Bytes

Version&Type:现阶段只有一个取值:0x11,表示PPPoE版本1,类型1。
Code:表示PPPoE报文细分类型,目前有如下取值

Code值 含义 描述
0x00 Session Data PPP流程通讯时的类型
0x09 PADI PADI
0x07 PADO PADO
0x19 PADR PADR
0x65 PADS PADS
0xA7 PADT PADT

PADI/PADO/PADR/PADS/PADT是PPPoED阶段使用的Code,Session Data是PPPoE Session阶段使用的Code。
其中Session ID在PADI/PADO/PADR阶段为0x0000,在PADS中服务器会分配一个Session ID给客户,在PADT中需要携带Session ID告知接收方要断开的Session。
Session Data阶段,Session ID表示客户端与服务器之间建立的Session编号。
Payload Length为后面跟的数据总长,不包括PPPoE头和链路头。

PPPoED(PPPoE Discovery)

PPPoED阶段主要工作是客户端发现并确定要注册的PPPoE服务器,在相关的PPPoE服务器注册Session。
它的流程简单描述如下:

1.客户端通过PADI通知PPPoE服务器自己的接入请求。
2.PPPoE服务器接到PADI请求之后判断是否要答复,需要答复回复PADO。
3.客户端判断PADO内容是否是自己期望的服务器,如果是,回复PADR申请Session。
4.PPPoE服务器收到了PADR,根据当前服务器状况判断是否要开启Session,如果可以接受回复PADS。

去掉开头的链路报文头和PPPoE通用部分,PPPoED阶段报文数据形式:

Tag Type Tag Length Tag Data
2 Bytes 2 Bytes x Bytes

PPPoED中Payload就是PPPoE Tag的列表,上面这一段会重复多次,他的各个部分含义:
Tag Type表示类型,可取值

Type值 含义 备注
0x0000 End of list Tags列表结束标志
0x0101 Service-Name 服务器名称,客户端携带,说明选择特定的服务器
长度可以为0表示接受所有PPPoE服务器
一般情况下长度是0
0x0102 AC-Name 服务器返回服务器自己的名字
0x0103 Host-Uniq Discovery阶段特征值,作用类似于SessionID
每个客户端在一个流程中会保持该值唯一
服务器答复的时候最好对该值进行判断并维持该值返回
0x0104 AC-Cookie -
0x0105 Vendor-Specific -
0x0110 Reply-Session-ID -
0x0201 Service-Name-Error -
0x0202 AC-System-Error -

以上有备注说明的是比较重要的字段,也是一个PPPoE服务器的基本能力所应具备的字段,其他字段解释请查阅RFC文档。
AC-Name当然可以自己发挥了,比如我写的就是stefenson-pppoe-server,当然也可以迎合客户端要求,根据客户端 0x0101 所需进行返回,机不机智?

Tag Length表示Tag的数据长,不包含Type和Length占用的长度。

Tag Data表示Tag携带的数据。

PPPoE Session阶段共用部分

PPPoED成功完成之后进入PPPoE Session阶段。
Session阶段会先携带一个通用的两字节数据 Point to Point Protocol
该值表示接下来的Session数据是哪种类型,包括以下取值

0x8021…………..IPCP
0xC021…………..LCP
0xC023…………..PAP
0xC223…………..CHAP
0x0021…………..IPv4

当取值 0x0021 时,该报文携带的是要通讯的数据,后面的Data与链路报文类型IPv4内容一致,去掉PPPoE部分,修改链路头Type为IPv4这就是一个正常的IPv4报文。
注意这一点在后面数据链路通讯时十分重要,是PPPoE真正实现通讯的基本要点。

紧接着的内容,如果不是IPv4,数据组织形式一样:

Code Identifier Length Data
1 Bytes 1 Bytes 2 Bytes x Bytes

Code根据不同类型的有不同的意义,后面分开进行说明。
Identifier作为PPP阶段报文应答标识,用于区别报文应答关系。
Length为全长,包括Code、Identifier、Length占用的长度,不仅仅是Data的长度,也就是说 Length = Data.Length + 4。

LCP Discussion

LCP全称 Link Control Protocol,链路控制协议,控制链路吞吐量,链路通断等链路基本信息。
它的Code有如下取值

Code值 含义 描述
0x01 Configuration Request 链路协商
0x02 Configuration ACK 协商通过
0x03 Configuration NAK 协商拒绝
0x04 Configuration Reject 协商回绝
0x05 Terminate Request 链路终止请求
0x06 Terminate ACK 终止请求确认
0x09 Echo Requeest 心跳请求
0x0A Echo Reply 心跳确认

NAK和Reject的区别是,NAK表示对方的所列出的链路配置恕无法接受,而Reject则表示发过来的消息意义不明或者信息缺失,这边解析不了很迷茫(看不懂你到底要表达啥的意思)。

Data的组织形式为下面结构的多项叠加

Type Length Data
1 Bytes 1 Bytes x Bytes

这里的Length表示全长,包括Type和Length占用的字节,所以Length = Data.Length + 2。

Type取值和含义

0x01…………..MRU(Maximum Receive Unit)
0x02…………..Async-Control-Character-Map
0x03…………..Auth Type
0x05…………..Magic Number
0x06…………..Link Quality Compression/Monitoring
0x07…………..Procotol Field Compression
0x08…………..Address and Control Field Compression

当Type为MRU时,长度为 2 Bytes,表示报文发送方最大可接收的报文大小,一般不超过1484。
当Type为Auth Type时,Data可取值有这些:0xC023表示采用PAP认证、0xC223 + 0x05表示使用MD5挑战方式的CHAP认证。当然也存在其他取值,具体可参阅RFC文档。
当Type为Magic Number时,长度为 4 Bytes,值随机,答复的时候要带上用于辨识答复方数据可靠性。
其他的Type含义参阅RFC文档,这里所说的是几个比较重要的Type值,这几个是实现PPPoE服务器中必须处理和了解的几个值。

LCP Discussion阶段中双方互相发送Configuration Request,相互确认对方的信息,同意回复Configuration ACK并携带对方的Request信息。
如果不同意对方的Request,这时候回复NAK,并且在NAK中携带自己期待的配置信息,收到NAK之后如果接受列出的配置,带上NAK中的配置重新Configuration Request。
如果发现对方报文中存在错误,这时候需要回复Reject,并且携带对方报文中意义不明或者错误的信息内容等待进一步答复。

比如 Configuration Request: Auth Type 0x0000,这时候接收方会回复Configuration Reject: Auth Type 0x0000,相当于该信息我不知道它对不对,反正我这解析不出来,我不管你得换一个。

LCP Discussion过程可能会来回多次,反复确认双方配置(Request–NAK–Request–Reject–Request–…),如果在多次Configuration依旧无法协商完成只能LCP Terminate Request结束这个短暂的邂逅了。虽然这种情况一般很少见,但也不排除,相当于两个人谈一场交易,结果怎么也谈不拢,那交易肯定也没法进行,再死皮赖脸的协商怕不是要打一架了。

其实这个过程看起来就跟两个人在进行一场协商一样的不是吗www?
用自然语言表述这个过程可以编这么一个对话

A:请求:我可接收最大重量为1484的包裹,暗号7A908B06,请确认。
B:OK我方收到,已知阁下可接收最大重量为1484的包裹,答复暗号7A908B06。
B:另外我方最大可接受重量为1400的包裹,认证方式编码C023,暗号7A908B07,请确认。
A:哈?你现在还在用C023认证?你不怕泄密吗?回绝(Reject)认证方式编码C023,你得换一个,答复暗号7A908B07。
B:真是事多……重新请求:我方最大可接受重量为1400的包裹,认证方式编码C223+05,暗号7A908B08,请确认。
A:等等,1400最大重量太小了吧,我这边接受不了,拒绝(NAK),你得把配置换成这样:最大可接受重量为1450的包裹,认证方式编码C223+05,答复暗号7A908B08。
B:(MMP……)行行行,再次请求:我方最大可接受重量为1450的包裹,认证方式编码C223+05,暗号7A908B09,这回可以了吧?
A:……
B:……喂?
A:……啊,不好意思,我这边刚刚有点事没听清,你再说一遍?
B:你大爷……时间到了,老子不伺候了!再见!
——用户 B 断开连接——
A:??????

Auth(认证)

LCP协商之后接下来就是认证了。
由于认证的方式是在LCP阶段确认的,所以这里根据不同的认证方式有两种数据组织方式

PAP

PAP阶段Code取值有

0x01……….Auth Request
0x02……….Auth ACK
0x03……….Auth NAK

当Code为Auth Request时Data组织形式如下

Peer-ID-Length Peer-ID Password-Length Password
1 Bytes x Bytes 1 Bytes x Bytes

其中Peer-ID就是用户名,Password为密码。两个Length都是指后面数据长,不包括Length所占用的字节。
从这里不难看出,PAP直接携带用户名密码,并且所支持的最大用户名长和密码长都是255字节。
使用PAP方式,客户端直接发送PAP-Auth Request,服务器收到报文后解析用户名密码判断该用户是否合法,认证成功返回Auth ACK,否则返回Auth NAK。

当Code为Auth Ack或者Auth NAK的时候,后面Data先跟一字节长度提示,然后填入长度控制的字符串作为认证提示,认证提示可以随便填写,就是一个字符串。

比如你可以在Auth ACK之后跟上信息:Authentication Failed! Rua!,没错就是可以这么傲娇。

CHAP

CHAP阶段Code取值有

0x01……….Challenge
0x02……….Response
0x03……….Success
0x04……….Failure

当Code为Challenge或者Response的时候Data组织形式如下:

Value Size Value Name
1 Bytes x Bytes y Bytes

其中Value Size表示Value长度,一般是16。Name为用户名,以0x00结尾。
Value一般是16字节的数据,各个阶段意义略微有区别。
当时用CHAP认证的时候,服务器首先发送Challenge消息,里面的用户名可以为空。
客户端收到Challenge之后,通过之前商讨的挑战方式,重新计算Value,并携带用户名,发送Response给服务器。
服务器通过客户端答复的Value判断认证是否成功,成功返回Success,否则返回Failure。

同样的当Code为Failure或者Success的时候,后面Data是一个返回给客户端的字符串,表示认证信息,以0x00结尾。

MD5挑战模式下,Challenge的期望值为:MD5(id + password + value)。
其中id为PPPoE Session阶段携带的Identifier,value为服务器Challenge发过来的值。计算完之后重新放入Value返回给服务器。
服务器收到之后也会用同样的方式处理,对比两者结果是否一致判断用户是否合法。

认证失败一般紧接着就会发送LCP Terminate Request断开链接,释放当前Session。

IPCP Discussion

认证完成之后就是IP协商了。
IPCP全称 IP Control Protocol,IP控制协议,用于控制接入设备的IP配置信息。
IPCP阶段Code可以取值:

0x01……….Configuration Request
0x02……….Configuration ACK
0x03……….Configuration NAK

Data组织形式为:

Type Length IP Address
1 Bytes 1 Bytes 4 Bytes

Type可取值

0x03……….IP
0x81……….DNS1
0x83……….DNS2

Length为总长,Length = 6。
从这里不难发现,PPP接入下没有子网掩码,因为在PPP模式下不存在子网概念,所有设备相互独立,所以客户端一般在配置PPP网络的时候习惯性将子网掩码设置为255.255.255.255。

IPCP流程
服务器首先发出一个IPCP Request报文,只带0x03字段,这个报文告知客户端服务器的IP地址信息。
客户端一般在收到服务器发来的IPCP Request报文之后才发送IPCP Request请求自己的IP地址。
一般情况下,客户端发过来的请求中携带的IP信息为0.0.0.0,也有可能是上次认证时分配到的IP。
服务器收到客户端的IPCP Request之后判断配置信息是否正确,正确回复ACK,否则回复NAK并附上正确的IP配置信息。

这个过程其实跟LCP Discussion过程类似,只是起手动作需要服务器发起,客户端需要先确认服务器IP才会进行接下来的确认。
后续流程除了没有Reject消息,跟LCP Discussion流程是一样的,对了回复ACK,不对回复NAK并携带上所需的信息。

IPCP协商完成之后,PPP链接建立过程结束。

PPP链接建立之后

PPPoE链路建立之后,上文也提到后续的数据依旧是以PPPoE Session方式发送,不过P2P Protocol的值变为0x0021(IPv4)
这时候,一般的网卡是无法解析该消息的,需要把PPP消息中PPPoE部分剔除封装成普通的IPv4报文,再发送给相关设备,这样网卡才能处理该消息。
这个策略可以考虑使用假MAC的方式,PPPoE服务器通过假MAC发送消息给相关设备,相关设备的信息发送回来之后PPPoE服务器截获该消息(其实WinPcap直接就能收到)然后重新用PPP封装在发送给客户端。
换句话说,PPPoE链路建立之后,通讯过程依旧跟普通的IPv4通信相差很大。

PPPoE报文总览

一张图总结一下PPPoE报文
PPPoE报文

其他一些注意事项和技巧

  • WinPcap监听会监听到Destination非自己的报文,包括自己发的,如果不过滤Source会造成报文没有被过滤,自己的报文自己答复接着进入死循环浪费资源。这点需要注意
  • LCP控制报文中如果收到Echo Request需要及时回复Echo Reply,如果没有答复Echo Request,超过一定次数之后会使对方认为链路断开,链接丢失。所写的PPPoE也应该具备这个机制,毕竟Session资源紧张。
  • Session报文的Identifier变化一般有两种思路,当然这个策略也可以自己设计

    1.当没有回复的时候使ID + 1重新发送,直到得到答复之后ID重置为0发送下一个消息。
    2.当Package的类型改变的时候重制ID为0,否则ID递增。

  • LCP的Echo Request心跳报文中,ID应该只随心跳次数递增,双方个自己记录心跳情况。

  • LCP/IPCP都需要双方各一个Request和ACK之后才可配置好链接相关信息。
  • Session建立过程中,如果在某阶段耗时过长即可认为流程超时,断开并清理当前Session。
  • 为了保证报文质量,PPPoE建立过程中,所有报文长度最短应为60字节,不够应当补齐。

写在最后

看完PPPoE协议的内容,是不是有种晕头转向的感觉?
确实DHCP协议与PPPoE协议比起来简直是小巫见大巫,毕竟链路层协议,复杂程度肯定是要上升的。
不过也不需要太敬畏它,PPPoE依旧只是一个协议,一个准则而已,有死板的地方,也有可以搞事的地方(喂)。
总的来看其实PPPoE协议就是一层一层展开的,理解了PPPoE协议其实你也就理解了网络分这么多层到底有什么意义了。
网络报文其实就是约定好的一层一层数据组装到一起,按照一定模式一定能够将其解开,这样的设计还方便了各部分只关注自己所需要关注的部分,省去了很多解析上的麻烦,只能感叹当年这些网络工程师的智慧。
这里面的很多策略都是可以自己发挥的地方,可以尝试一下设计一套自己的高效的策略。
设计PPPoE服务器最关键的地方就是学会链路层编程,理解网络报文这种层级关系,理解PPPoE协议中各个部分所需要遵循的规则(比如CHAP认证时的Challenge规则)。
由于PPPoE服务器在后期通信中依旧担当一个举足轻重的角色,所以PPPoE服务器对服务器要求很高,普通电脑性能不好的话是无法承担这个任务的,这就是为什么很少在PC平台下有PPPoE服务器软件的原因。
目前商用的PPPoE服务器都是各厂商自己设计好的一套解决方案,而不简单是一个软件,里面设计好的各个单元不同分工,这样才有能力承接PPPoE的所有工作。
所以我们设计PPPoE服务器并不是说要在普通PC上实现能用的PPPoE服务器,而是通过写PPPoE服务器的过程,理解链路层编程,理解网络分层结构,这才是最终目的。

可以拓展的地方:不使用WinPcap,使用更底层的C完成PPPoE服务器编程,这样效率更高,说不定性能可以达到和一些PPPoE服务器持平。

希望本文对你有所启发,Thanks for Reading!

相关文献

RFC2561(PPPoE)
RFC1661(PPP)
RFC1570(LCP Etensions)
RFC2484(LCP Option)
RFC1334(PAP)
RFC1994(CHAP)
RFC2865(RADIUS)
RFC1332(IPCP)
RFC1877(IPCP DNS)