Iphone连接wifi热点跳转captive portal页面原理以及页面跳转慢原因分析
iPhone里面有两种探测方式:
方法一:
猜测这个对应首次连接一个新的SSID,经过了文档提到的hotspothelper的“Evaluate”流程, 对应文档的“Sequence When Network Is Captive and UI Is Required” 流程。 不管有人说apple会使用了随机使用多个域名来探测。
- 第一个HTTP请求
captive.apole.com GET /hotspot-detect.html HTTP/1.0 User-Agent: CaptiveNetworkSupport-346.50.1 wispr\r\n Connection: close\r\n
- 第二个HTTP请求
www.apple.com GET / HTTP/1.1 Connection: keep-alive\r\n User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Mobile/14E304\r\n
- 第二步页面加载完之后,40毫秒后发送探测
www.apple.com GET /library/test/success.htm HTTP/1.0 User-Agent: CaptiveNetworkSupport-346.50.1 wispr Connection: close\r\n
- 用户提交数据后,连发两个第3步一样的探测包。
方法二:
猜测这个对应已经连过了的SSID,缓存直接找到best_helper 没有“Evaluate”阶段的流程, 对应文档的“Sequence When Network Is Captive (Cached)”,直接从Maintaining state 开始认证流程。
- 第一个HTTP请求
captive.apole.com GET /hotspot-detect.html HTTP/1.0 User-Agent: CaptiveNetworkSupport-346.50.1 wispr\r\n Connection: close\r\n
- 第二个HTTP请求
100毫秒秒后 captive.apole.com GET /hotspot-detect.html HTTP/1.1 Connection: keep-alive\r\n User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Mobile/14E304\r\n
- 等第2步的页面加载完后,过了200毫秒,又马上发送探测
captive.apole.com GET /hotspot-detect.html HTTP/1.0 User-Agent: CaptiveNetworkSupport-346.50.1 wispr\r\n Connection: close\r\n
- 第2步用户操作提交数据后,过了400毫秒, 马上连发2个和第3步一样的探测。
总结一下iPhone的认证页面跳转流程
+---------------------------------+
| “扫描附近wifi热点” scan 阶段 | 这个阶段iOS遍历所有的 helper,各个助手模块自己给wifi加注释(
+-------------------+-------------+ 界面可以看到的wifi热点下面的描述吧)和提供wifi连接密码。
| 各个助手模块应该也可以强制设置某个wifi热点不需要认证,直接自动登录等。
|
|
缓存里面是否有这个wifi热点的记录
|
|
+------------------------------->+
| |
否 | | 是
| |
v v
+-------------------------+ +-------------------------------+
| Evaluate阶段 | | Maintain阶段 |
| CNA 发送 HTTP/1.0探测 | | | <----+
| 参考下文的说明 | | 每隔300秒CNA发送HTTP/1.0 探测 | |
+----------+--------------+ +-------------------------------+ |
| | |
| | |
| | |
| 需要重复认证 |
| | |
是不是需要portal认证的网络 | |
| | |
| | |
| | |
| v |
| 是 +-------------------+ |
+-----------------------> | Authenticate | |
| +-------------------+------------------+ |
| | PresentUI阶段 弹出认证窗口 | |
| | | |
| | CNA发送 HTTP/1.1 请求加载portal页面 | |
| | 之后每次portal页面加载完成都会触发 | |
| | HTTP/1.0探测 。如果HTTP/1.1连接超时 | |
| | 会显示空白页,最后会报告服务器超时 | |
| +---------------------+----------------+ |
| | |
否 | +---------------------------------+ |
| | ^
| HTTP/1.0 探测显示网络正常 |
| | |
v v |
+------------+-------------+---------+ |
| 认证完成,wifi “完成”按钮可用 | ------------------------------------------------>-+
+------------------------------------+
其他观察结果和结论:
- iPhone 不会对
captive.apple.com或者www.apple.com GET /hotspot-detect.html HTTP/1.0 User-Agent: CaptiveNetworkSupport-346.50.1 wispr\r\n Connection: close\r\n
这个HTTP/1.0请求返回的的http response 进行任何处理,如果这个respone里面有各种跳转, iPhone也不会执行跳转。 页面跳转只会在 HTTP/1.1那个的结果里面进行。
-
跳转出来的Portal页面每次刷新,都会触发HTTP/1.0的探测包。 如果这个请求到了苹果的服务器 回复了Sucess页面的话(参考下面),iPhone就认为wifi网络正常,UI上面的 “完成”按钮就变为可用。 它这个探测发送的很快, 所以必须确保网关放行规则起作用后,页面还能刷新一次或者放行规则起 作用后页面再返回,触发这个探测来点亮图标。
-
利用http status code 302/303的跳转portal页面的方式,相比较javascript的跳转方式, 前者可以让iPhone少发一次HTTP/1.0探测包。
- 安装“wifi万能钥匙”的情况下,wifi万能钥匙和iphone自身都会发送探测包。 “wifi万能钥匙” 不但用iPhone还会用android的方式的HTTP来探测网络。
wifi万能钥匙发起的iphone探测
GET / HTTP/1.1\r\n
Host: captive.apple.com\r\n
User-Agent: Zeus/95 CFNetwork/811.4.18 Darwin/16.5.0\r\n
Accept-Language: zh-cn\r\n
Connection: keep-alive\r\n
[Full request URI: http://captive.apple.com/]
Apple服务器给它的回应
HTTP/1.1 304 Not Modified\r\n
Cache-Control: max-age=300\r\n
Connection: keep-alive\r\n
Via: http/1.1 uslax1-edge-bx-005.ts.apple.com (ApacheTrafficServer/7.0.0)\r\n
Server: ATS/7.0.0\r\n
wifi万能钥匙发送的探测
GET /generate_204 HTTP/1.1\r\n
Host: c.51y5.net\r\n
Accept: */*\r\n
Accept-Language: zh-cn\r\n
Connection: keep-alive\r\n
Accept-Encoding: gzip, deflate\r\n
User-Agent: Zeus/95 CFNetwork/811.4.18 Darwin/16.5.0\r\n
\r\n
wifi万能钥匙自己服务器发送的andoird探测包回应
HTTP/1.1 204 No Content\r\n
[Expert Info (Chat/Sequence): HTTP/1.1 204 No Content\r\n]
Request Version: HTTP/1.1
Status Code: 204
Response Phrase: No Content
Server: nginx\r\n
Connection: keep-alive\r\n
正常iphone 发送的探测包 和回应
GET /hotspot-detect.html HTTP/1.0\r\n
Host: captive.apple.com\r\n
Connection: close\r\n
User-Agent: CaptiveNetworkSupport-346.50.1 wispr\r\n
正常iPhone 服务器探测包的回应
HTTP/1.0 200 OK\r\n
Cache-Control: max-age=300\r\n
Accept-Ranges: bytes\r\n
Content-Type: text/html\r\n
Server: ATS/7.0.0\r\n
CDNUUID: 8ec1d738-abf5-4482-ae50-d04bbd52e601-1473127713\r\n
Via: https/1.1 jptyo5-edge-lx-010.ts.apple.com (ApacheTrafficServer/7.0.0), http/1.1 jptyo5-edge-bx-004.ts.apple.com (ApacheTrafficServer/7.0.0)\r\n
X-Cache: hit-fresh, hit-fresh\r\n
Etag: "41ba060eb1c0898e0a4a0cca36a8ca91"\r\n
Age: 116\r\n
\r\n
<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>\n
可以知道wifi万能钥匙的iPhone方式探测包url有点问题的。
-
如果某次页面弹出有问题,“完成” 按钮没有亮, 客人又点 “取消”,“继续使用网络”,那么 “auto-join”按钮会被禁用。 这个应该对应文档里面的 kNEHotspotHelperCommandTypePresentUI状态 返回“kNEHotspotHelperResultFailure (or any other result) A fatal error occurred; auto-join disabled.” 然后客人如果选择继续“使用网络”后。 在断开,重连到wifi热点来,因为auto-join被取消了,认证页面 也是不会再自动弹出来的。
-
Evaluate阶段导致的45超时问题 发现iPhone首次连新的ssid的热点时,弹出页面都是在45秒左右。(ios 10.3.1版本 安装有“wifi万能驱动”) 这个原因应该是首次连接时, iPhone默认执行“Evaluate”流程,给所有的系统所有的hotspothelper模块都发送 “Evaluate” 命令,系统部本身自带一个Captive Network Assitant(CNA) helper,安装了“Wifi万能驱动” “QQ浏览器wifi助手” 之后的APP之后,他们也都安装所有的Helper模块。 iOS系统发所有的模块下发命令后,要求各个模块回复“网络是否可用的评估值”。一旦有任何一个helper模块回复 “high” 表示这个模块很自信自己能够处理接下来的“登录认证”,那么其他没有返回结果的helper模块切换到后台,他们的结果 也会被忽略。如果所有的helper模块都没有任何回复,那么iOS直接认为网络可用,状态直接变成Authenticated“认证完成”。
而出现问题的原因,打开是各个wifi助手app没有实现这个Evaluate命令,或者由于某种原因一直不回复Evaluate命令。 系统默认的CNA helper的Evaluate被调用了使用 HTTP1.0 探测 captive.apple.com,但CNA helper了返回的结果应该是“Low” (不是很确定自己能不能处理后面的认证)。这样iOS还没看到一个“high”的结果,又有其他helper没有回复,就会一直等待 到45秒的超时结束。过了45秒之后才能勉强的把这个“low”结果的CNA作为best helper,在继续下面的 “Authenticating”。 这个其实是各个APP没有响应这个Evaluate命令造成的,如果助手APP快速返回“low”,iOS拿到所有helper的结果不会等待 这个45秒了。虽然这样处理iOS看到多个low的回复,只能任意选一个进行下面的“认证”了,认证的时候各个模块可以 再直接返回失败的让iOS把自己从helper列表删除,iOS会把不支持的helper 排除在外,然后再重做Evalute。 听说有厂商已经给这些“助手类hotspothelper”的开发者报告问题,大多的APP在最新版里面已经修复这个问题。 其实如果系统默认的CNA helper返回的是“high”也能避免这个超时等待,但对应该新的网络SSID,它确实不能保证后面能 不能认证成功返回low也是合理的。 如果自己实现APP helper模块,可以通过ssid和网络探测等方式知道是自己能处理的 wifi热点,直接返回high也是能够避免这个45秒的超市等待的。
但如果使用“CNA”成功做过一次portal认证之后,是没有这个45秒超时等待问题了。因为iOS有个cache缓存机制,第二次 连接的时候他通过cache知道这个“CNA” helper模块能够处理这个网络的认证,那么他就跳过“Evaluate”阶段,直接让 CNA helper开始“Maintaining”处理。所以第二次以后就没有这个“Evaluate”的45秒超时等待了。
根据apple开发者论坛的说法在iOS的任务管理器里面把“wifi助手”进程给结束掉,iOS就也不会调用这些APP的hotspothelper 这样没有其他app hotspothelper的干扰,应该也可以避免这个首次连接的超时问题。
-
这个HotspotHelper接口按apple的说法是专门给用了开发“captive portal”的app用的,专门用来辅助wifi热点认证的。 hotspothelper模块在整个认证过程中出现错误,可能导致iOS把这个模块从这个wifi网络的“有效列表”中删除,不再参与后面的 重试。可能 wifi热点还会被 “断开” ,或者wifi热点的“auto-join”按钮被禁用等。这都可能影响到最终的认证行为。具体可以 参考Apple的状态说明。
-
其他导致页面显示慢的问题,估计主要是网络原因导致 HTTP/1.0和 HTTP/1.1两个连接创建失败或者超时导致的 如果到了“PresentUI阶段”,但HTTP1.1的请求portal页面的请求失败了,那么就显示的是空白页面了。其他的影响 因素还有dns解析的速度,网关或者防火墙是否有连接数量限制导致的丢包等等。 可以pc连接上网络,然后暂时不认证portal页面,然后用一些http测试工具测试一下portal服务器连接的性能,抓包 看看,比如用这个工具https://github.com/rakyll/hey ./hey -more=1 -n 256 -c 4 -q 4 http://captive.apple.com/hotspot-detect.htm
另外需要避免portal页面的http服务打开了TCP的tcp_tw_recycle选项,然后用户全部通过NAT网络连接这个服务器。 这样tcp的timestamp选项和tcp_tw_recycle一起使用时, NAT网络用户的创建连接会很大概率失败。 原因这两篇文章有介绍: 一个NAT问题引起的思考 http://perthcharles.github.io/2015/08/27/timestamp-NAT/ Coping with the TCP TIME-WAIT state on busy Linux servers https://vincent.bernat.im/en/blog/2014-tcp-time-wait-state-linux
-
按照苹果文档的说法,wifi热点还没有认证通过的时候(“非Authenticated”),默认 路由是不会是wifi设备的,自己的helper里面需要从wifi上面发送探测网络包,是需要 明确指定了出去的网络设备才行的。但在实际测试发现,不知道是不是一些助手helper 模块的干扰,有的时候是会看到一些 “支付宝”或者“微信”等的443端口的https还有8080 或者80的端口出来。有可能这是wifi被错误的设置了 authenticated 状态,又要等到 后面CNA探测到问题再开始显示portal页面。但在这之前已经有很多后台页面尝试在wifi 设备上发送请求了,有的比如qq的浏览器加载portal页面,还会自己尝试加载重定向后 的页面等等。
测试方法
笔记本windows 10系统 + “移动热点” + 手机无线连笔记本 + dns劫持+ wireshark抓包
配置测试环境 右键 开始菜单 -> “设置“ -> “网络设置” -> “移动热点” 在网络设备里面找到移动热点对应的 网络设备,修改 dns服务器地址为本地虚假dns服务器的ip。 这个ip 不要用和 改设备一样的,使用本机的其他ip比如承载网卡的ip。 保证 手机端获取的dns转到自己的dns服务器就可以了。 用于测试golang dns server 代码。
package main
import (
"flag"
"fmt"
"github.com/miekg/dns"
"html/template"
"log"
"math/rand"
"net"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
)
const (
DEFAULT_PORT = 53
DEFAULT_TIMEOUT = 20 * time.Second
DEFAULT_PROTO = "udp"
)
var (
LOG *log.Logger
FAKE_IP net.IP
PROTO string
TIMEOUT time.Duration
DEBUG bool
TCP_REGEX *regexp.Regexp
DEFAULT_SERVERS = []string{
// "8.8.8.8", // google
// "8.8.4.4", // google
// "208.67.222.222", // OpenDNS
// "208.67.220.220", // OpenDNS
"114.114.114.114", // 114dns
"180.76.76.76", // baidu
"223.5.5.5", // ali
"223.6.6.6", // ali
"119.29.29.29", // tencent dnspod
}
LOGIN_USER = make([]int64, 0, 8)
USER_MUTEX = &sync.RWMutex{}
REDIRECT_MODE int
)
func ipv4ToInt64(ip net.IP) int64 {
if DEBUG {
fmt.Println("ip to int64: ", ip)
}
ip4 := ip.To4()
if len(ip4) == net.IPv4len {
n := (int64(ip4[0]) << 24) | (int64(ip4[1]) << 16) | (int64(ip4[2]) << 8) | int64(ip[3])
return n
}
return 0
}
func userExist(ip int64) bool {
USER_MUTEX.RLock()
found := false
for i := 0; i < len(LOGIN_USER); i++ {
if LOGIN_USER[i] == ip {
if DEBUG {
fmt.Println("User Exist: ", ip)
}
found = true
break
}
}
USER_MUTEX.RUnlock()
return found
}
func addUser(ip int64) {
if !userExist(ip) {
if DEBUG {
fmt.Println("Add User:", ip)
}
USER_MUTEX.Lock()
LOGIN_USER = append(LOGIN_USER, ip)
USER_MUTEX.Unlock()
}
}
func init() {
LOG = log.New(os.Stderr, "[DNS PROXY] ", log.LstdFlags)
var proto, pattern, fakeIP string
var timeout int
flag.BoolVar(&DEBUG, "debug", false, "调试开关")
flag.StringVar(&proto, "protocol", "udp", "udp|tcp 用udp还是tcp连接远程dns服务器")
flag.IntVar(&timeout, "timeout", 0, "timeout 等待远程dns服务器的超时时间, 单位秒")
flag.StringVar(&pattern, "regex", ".*", "需要过滤域名的正则表达式,默认所有域名都返回假ip")
flag.StringVar(&fakeIP, "fake_ip", "", "dns查询返回的假ip地址")
flag.IntVar(&REDIRECT_MODE, "redirect_mode", 0, "网页重定向的方式 0到4数字")
flag.Parse()
switch proto {
case "tcp":
PROTO = "tcp"
case "udp":
PROTO = "udp"
default:
PROTO = DEFAULT_PROTO
}
if timeout > 5 {
TIMEOUT = time.Duration(timeout) * time.Second
} else {
TIMEOUT = DEFAULT_TIMEOUT
}
if len(pattern) > 0 {
if re, err := regexp.Compile(pattern); err != nil {
LOG.Fatalf("Compiling pattern [%s] was %s", pattern, err)
} else {
TCP_REGEX = re
}
}
FAKE_IP = net.ParseIP(fakeIP)
LOG.Printf("Use %s protocol to connect to the remote DNS server", PROTO)
LOG.Printf("Timeout duration: %s", TIMEOUT)
if TCP_REGEX != nil {
LOG.Printf("Compiling tcp regex pattern [%s]", TCP_REGEX)
}
LOG.Printf("Use fake IP %s", fakeIP)
LOG.Println("Redirect mode: ", REDIRECT_MODE)
}
type Proxy struct {
Servers []string
}
func (p Proxy) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
if TCP_REGEX != nil && FAKE_IP != nil {
q := req.Question[0]
isknownUser := false
ip, _, err := net.SplitHostPort(w.RemoteAddr().String())
if DEBUG {
fmt.Println("dns client ip: ", ip)
}
if err == nil {
userIP := net.ParseIP(ip)
if userIP != nil {
isknownUser = userExist(ipv4ToInt64(userIP))
}
}
if TCP_REGEX.MatchString(q.Name) && !isknownUser {
if DEBUG {
LOG.Printf("DEBUG return a fake ip: %s => %s", q.Name, FAKE_IP)
}
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
m.RecursionAvailable = true
m.Answer = make([]dns.RR, 0, 1)
m.Answer = append(m.Answer,
&dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 0,
},
A: FAKE_IP.To4(),
})
w.WriteMsg(m)
return
}
}
c := new(dns.Client)
c.Net = PROTO
// if TCP_REGEX != nil {
// for _, q := range r.Question {
// if TCP_REGEX.MatchString(q.Name) {
// LOG.Printf("Tcp proto regex match: %s", q.Name)
// c.Net = "tcp"
// break
// }
// }
// }
c.ReadTimeout = TIMEOUT
c.WriteTimeout = TIMEOUT
if rs, _, err := c.Exchange(req, p.Server()); err == nil {
if DEBUG {
LOG.Printf("DEBUG %s\n%v", w.RemoteAddr(), rs)
}
w.WriteMsg(rs)
} else {
m := new(dns.Msg)
m.SetRcode(req, dns.RcodeServerFailure)
w.WriteMsg(m)
LOG.Printf("%s %s", w.RemoteAddr(), err)
}
}
func (p Proxy) Server() string {
sl := len(p.Servers)
if sl > 0 {
i := rand.Intn(sl)
s := p.Servers[i]
if strings.Index(s, ":") == -1 {
s = fmt.Sprintf("%s:%d", s, DEFAULT_PORT)
}
return s
}
return "8.8.8.8:53"
}
// ----------------------------------------------------------------------------
type PortalServer struct{}
func (h *PortalServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var urlPath = r.URL.Path
fmt.Println("query url:", urlPath)
if urlPath == "/login.go" {
if len(r.FormValue("UserName")) != 0 {
if len(r.FormValue("WISPrVersion")) != 0 ||
len(r.FormValue("FNAME")) != 0 ||
len(r.FormValue("OriginatingServer")) != 0 {
fmt.Println("用户使用wispr方式登录")
}
fmt.Fprint(w, "<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>"+r.FormValue("id")+" 登录成功"+"</BODY> </HTML>")
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
fmt.Println("Failed to SplitHostPort:", r.RemoteAddr)
return
}
userIP := net.ParseIP(ip)
if userIP == nil {
fmt.Println("Failed to ParseIP:", ip)
return
}
addUser(ipv4ToInt64(userIP))
// 页面返回后,iPhone很快就下发探测包。我们慢点回复确保它探测的时候AddUser
// 规则已经其作用了。
time.Sleep(500 * time.Millisecond)
if DEBUG {
fmt.Println("user has submit id to login.go:", ip)
}
} else {
fmt.Fprint(w,
`<HTML>
<HEAD>
<TITLE>portal</TITLE>
</HEAD>
<BODY>
<form action="login.go" method="post">
<table border="0">
<tr>
<td>用户名:</td> <td> <input type="text" name="UserName" /> </td>
</tr>
<tr>
<td>密码:</td> <td><input type="text" name="Password" /> </td>
</tr>
<tr>
<td></td>
<td><input type="submit" value="提交" /> </td>
<tr>
</form>
</BODY>
</HTML> `)
}
return
}
switch REDIRECT_MODE {
default:
// case 0: // 直接输出
fmt.Fprint(w,
`<HTML>
<HEAD>
<TITLE>portal</TITLE>
</HEAD>
<BODY>
<form action="login.go" method="post">
<table border="0">
<tr>
<td>用户名:</td> <td> <input type="text" name="UserName" /> </td>
</tr>
<tr>
<td>密码:</td> <td><input type="text" name="Password" /> </td>
</tr>
<tr>
<td></td>
<td><input type="submit" value="提交" /> </td>
<tr>
</form>
</BODY>
</HTML> `)
case 1: // http/1.0 重定向
// overwrite the client http version
r.Proto = "HTTP/1.0"
r.ProtoMajor = 1
// r.ProtoMinor = 0
if r.ProtoMinor == 0 {
http.Redirect(w, r, "/login.go?mode=11", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login.go?mode=12", http.StatusSeeOther)
}
case 2: // http/1.1 重定向
// overwrite the client http version
r.Proto = "HTTP/1.1"
r.ProtoMajor = 1
r.ProtoMinor = 1
http.Redirect(w, r, "/login.go?mode=2", http.StatusSeeOther)
case 3: // javescript 跳转
portalUrl := "http://" + FAKE_IP.To4().String() + "/login.go?mode=3"
tmpl, err := template.New("foo").Parse(
`<html>
<script language="javascript">
location.replace("");
</script>
<body>
<p align="center">
<a href=">">Welcome. If the browser cannot be redirected automatically, please click here, Thank you.</a>
</body>
</html>`)
// type TemplateData struct {
// PortalUrl string
// }
if err != nil {
fmt.Println("Template parse error. ", err)
fmt.Fprint(w, "Template parse error.")
return
}
err = tmpl.Execute(w, struct{ PortalUrl string }{PortalUrl: portalUrl})
if err != nil {
fmt.Println("Template execute error. ", err)
fmt.Fprint(w, "Template execute error.")
}
case 4: // wispr
// wispr specification 在网上已经找不到了。
// 参考 https://github.com/wichert/wispr/blob/master/src/wispr/__init__.py 这里的代码大概猜测一下
// https://docs.microsoft.com/en-us/windows-hardware/drivers/mobilebroadband/wispr-authentication
wisprLoginUrl := "http://" + FAKE_IP.To4().String() + "/login.go"
// 临时把method改一下,不然method == "GET" 的时候Redirect方法写一条链接进去网页内容里面去,我们不要写这个
method := r.Method
r.Method = "POST"
http.Redirect(w, r, wisprLoginUrl, http.StatusFound)
r.Method = method
fmt.Fprint(w,
`<HTML>
<!--
<?xml version="1.0" encoding="UTF-8"?>
<WISPAccessGatewayParam xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://www.acmewisp.com/WISPAccessGatewayParam.xsd">
<Redirect>
<VersionHigh>1.0</VersionHigh>
<VersionLow>0.0</VersionLow>
<AccessProcedure>1.0</AccessProcedure>
<AccessLocation>Hotel Guest Network</AccessLocation>
<LocationName>Hotel Test</LocationName>
<LoginURL>`+wisprLoginUrl+`</LoginURL>
<MessageType>100</MessageType>
<ResponseCode>0</ResponseCode>
</Redirect>
</WISPAccessGatewayParam>
-->
</HTML>`
case 5: // http head refresh 重定向 非标准,但大多浏览器都支持
// req.Header.Add("If-None-Match", `W/"wyzzy"`)
portalUrl := "http://" + FAKE_IP.To4().String() + "/login.go?mode=5"
// w.Header().Set("Refresh", "0; url="+portalUrl) // android 不支持这个,但支持下面的。IE 两个都支持
fmt.Fprint(w, `<HTML><HEAD><TITLE>portal</TITLE><meta http-equiv="refresh" content="0; url=`+portalUrl+`"></HEAD></HTML>`)
}
}
func PrintLocalIP() {
ifaces, err := net.Interfaces()
// handle err
if err != nil {
fmt.Println("获取不到本地ip地址\n")
return
}
for _, i := range ifaces {
addrs, err := i.Addrs()
if err != nil {
fmt.Println("获取不到本地ip地址\n")
return
}
for _, address := range addrs {
// check the address type and if it is not a loopback the display it
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
fmt.Println(`可以通过这个地址访问 http:\\` + ipnet.IP.String())
}
}
}
}
}
//------------------------------------------------------------------------------
func main() {
go func(msg string) {
var h PortalServer
// http.ListenAndServe("localhost:4000", &h)
fmt.Println("\nhttp portal服务器已经启动")
PrintLocalIP()
http.ListenAndServe(":80", &h)
}("http go coroutine")
// ----------------------
var servers []string
args := flag.Args()
if len(args) > 0 {
servers = append(servers, args...)
} else {
servers = DEFAULT_SERVERS
}
LOG.Printf("Servers: %s", servers)
proxyer := Proxy{servers}
if err := dns.ListenAndServe("0.0.0.0:53", "udp", proxyer); err != nil {
LOG.Fatal(err)
}
}
这里http server和dns都写到一起了,这里只是让dns返回本地http的ip就可以,认证之前 所有的http都被域名劫持跳转到本地了。 测试android和iPhone都可以连wifi后自动正常跳出portal认证页面。
其他有用的测试手段
-
通过Xcode 或者iTools工具查看iPhone的syslog (console log) 测试之前,先安装iTools 4.0 (http://www.itools.cn/) 然后iPhone 通过数据线和电脑连接,iTools里面选择 “实时日志”。 然后再进行测试, 根据网上说法,登陆认证是CNA如果出错会有syslog记录。
-
抓取iPhone的网络通讯包 iOS Packet Tracing(利用usb链接iPhone和MAC电脑,然后使用rvictl在pc可以模拟出一个网络设备抓iPhone的网络包) https://developer.apple.com/library/content/qa/qa1176/_index.html#//apple_ref/doc/uid/DTS10001707-CH1-SECIOSPACKETTRACING
-
上Apple develop 论坛( Apple Developer Forums / Core OS / Networking) 有个Apple的技术工程师叫做eskimo的好像对NEhotspothelper接口很了解, 他留有邮箱可以向他咨询吧
参考文档:
Apple官方文档的“Hotspot Network Subsystem Programming Guide”里面的wifi认证状态机的说明 https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/Hotspot_Network_Subsystem_Guide/Contents/AuthStateMachine.html#//apple_ref/doc/uid/TP40016639-CH2-SW1
Apple的 Network Extenstion接口 https://developer.apple.com/reference/networkextension
https://forums.developer.apple.com/message/205571#205571 https://forums.developer.apple.com/message/223136#223136
2017-06-22补充
通过测试和手机查看iPhone手机的syslog,evaluate阶段系统内置插件(BUILTIN)返回的是 confidence none。 另外像“微信”这样的应用,如果没有开移动蜂窝网络登录微信,也会evaluate阶段超时,等待45秒才能弹出portal页面。 应该是微信里面的public wifi功能也实现了这个接口,但在没有网络时没有正确处理这个evaluate命令。给微信的人反馈 了就不知道他们后续怎么处理了。
从测试来看,iPhone的portal页面慢,这个evaluate超时是出现较多的,很多应用,什么“地铁wifi”类似的wifi助手类应用 都可能有影响,连微信都有问题。那肯定要怪苹果文档没写好吧,各个应用开发者都没有实现对。
另外也看到 iPhone的springboard(桌面管理进程?) 出现崩溃,导致CaptiveNetworkSupport发送launch Websheet时候 启动com.apple.WebSheet应用失败,这样最终10秒超时后日志记录websheet died报告,iPhone在PresentUI阶段返回临时 错误,就会临时断开wifi热点。因为websheet是专门显示portal页面的进程,启动不起来,那肯定就有问题了。iPhone这个 时候应该会进入死循环,不停在各个ssid直接做wifi漫游。但都会由于这个问题连不上。出现这种情形应该比较少见,但一旦出现 只能通过放行流量,让苹果在evaluate阶段就探测认为是no captive网络,这样就需要弹portal网页直接可以使用wifi流量, 因为后面的PresentUI阶段肯定是会失败。这个应该是iPhone自身的问题,只能通过重启系统来让各个模块恢复正常了。 苹果笔记本上面也观察到类似现象。
如果网络被评估为no captive 网络,auto-join和auto-login按钮不可设置,不可用。反之这两个选项才可以设置。 各个认证阶段失败,ios禁用或者永久禁用这个wifi热点的,参考苹果的文档。
iOS只会在判断wifi网络可用后,才会把默认路由切换到wifi接口上面来。 但如果cache里面 有提示wifi 是no captive 的也会马上切换过来,这个可能会有些影响,因为到真正检测到网络是不是可用还需要探测。那些探测(probe)网络是否可用 的HTTP包发送时应该是需要专门绑定的wifi的interface才会从这个非默认路由的接口出来吧。
苹果这个capitve portal页面有3个模块,后面文章的里面我有详细分析各个模块的功能。各个进程之间通过xpc来进行跨进程通讯。
- CaptiveNetworkSupport: 运行在configd进程里面,hotspot的状态机处理主要逻辑都在这里面。会在适当的时候通知其他两个。
- captiveagent: 独立的进程,专门发送HTTP/1.0探测。
- WebSheet: 独立进程,专门显示HTTP/1.1 供用户操作的UI,由captiveNetworkSupport唤醒
2017-06-23补充
按照文档,有一个300秒的Maintaining timer.,这样每隔300秒ios都会主动发送一个 http请求去探测。 但根据实际测试,抓包和syslog都没有看到这个间隔300秒的探测包。 大概看了一下代码,日志里面有“Enabling passive detection”,实在PassiveDetectSetNotificationCallBack 函数里面打印出来的, 它监控 “com.apple.symptoms.managed_events.captive-network” 这个notification事件,收到事件之后再做maintain探测。 启用了这个之后CNInfoAuthenticated就没有设置这个300秒的定时器事件了。 这个不知道事件不知道怎么触发的,反正re-join同一个 wifi热点肯定是触发maintain的探测的。 这个是ios 10.2的测试结果。但ios 10.3.2 里面又没有这个“Enabling passive detection” 了,不知道怎么回事,反正确实没有看到这个300秒的探测。
2017-07-10 补充
苹果锁屏后屏幕关闭后,如果手机没有一直充电,wifi会自动断开。等重新点亮屏幕之后才重新链接wifi。 解锁后,苹果会马上发送HTTP/1.0探测,如果页面正常返回Success页面不需要验证的情况,手机状态栏 会立即显示wifi图标,wifi网络应该马上接通。反之如果HTTP/1.0的probe检测到captive portal 页面,那就是需要 弹出UI验证页面。CaptiveNetworkSupport需要显示页面之前会有个UIAllowed()的检查,这个检查按照字幕 意思应该是在屏幕点亮解锁之后可以显示UI的时候才通过。实际测试发现,只有用户操作打开浏览器访问网络 了,或者用户打开系统的“wifi设置”,这个UIAllowed()的检查才通过,手机才会通知websheet应用加载HTTP/1.1 页面弹出验证页面。 如果用户没有操作,估计没有网络流量访问的时候,是不会自动弹出websheet登录验证窗口的。 从syslog和反汇编的代码看, CaptiveNetworkSupport 打印“en0 waiting for UI” 的log之后就一直等待系统发送 “com.apple.airport.userNotification“ ”com.apple.airportsettingsvisible“ 的这两个notification的某一个过来时候才会进行 ”launch websheet“的动作。按照苹果的文档前面一个notification应该对应”network state changes“ 网络状态变化通知, 后面对应系统”设置wifi“ 界面显示的通知。所以前面写的用户的操作触发了弹出 portal页面是很合理的。 这个苹果的实现估计可以做的更好一些,一般人都是希望马上弹出验证框,然后马上连上wifi的。但ios现在的实现 依赖这两个通知,只有浏览器之类网络访问或者”wifi设置“显示了的动作才触发系统下发这个通知,虽然没有太大问题, 但会觉得wifi不会马上自动连接是个问题。