背景
打开手机连接WiFi的时候,发现有个叫小米共享WiFi无密码登录,试着连接一下,发现他会跳转一个验证页面,验证页面有【点击如下内容,开始3分钟网络体验】和【红包上网】,一个是看广告获取上网时长,一个是花钱上网。 还可以这样利用空闲网络获取收益,打算简单实现一下。
目标
尝试实现一个路由WiFi无密码连接,连接后跳转登录网页,登录后才可以上网,10分钟后需要重新登录。先把基本流程打通,加入广告需要第三接口接入。
环境
- Redmi路由器AC2100(OpenWrt 21.02.7)
- 红米手机
Captive Portal机制
sequenceDiagram
participant Android设备
participant 网络连接
participant Captive Portal检测服务器
Android设备->>网络连接: 尝试连接网络
网络连接-->>Android设备: 返回网络连接成功
Android设备->>Captive Portal检测服务器: 发送HTTP请求(通常为固定URL[generate_204])
Captive Portal检测服务器-->>Android设备: 返回HTTP状态码/响应
alt 响应为204(No Content)
Android设备->>Android设备: 认为网络正常
else 响应为301(或其他3xx状态码)
Android设备->>Android设备: 认为存在Captive Portal
Android设备->>用户: 提示用户登录Captive Portal
else 响应为非3xx且非204
Android设备->>Android设备: 认为网络异常
end
用户->>Captive Portal: 提供认证信息
Captive Portal-->>用户: 验证通过,开放网络访问
Android设备->>Captive Portal检测服务器: 再次发送HTTP请求
Captive Portal检测服务器-->>Android设备: 返回204(No Content)
Android设备->>Android设备: 认为网络正常
实现
了解一下Captive Portal机制,我们需要拦截验证请求(带有/generate_204),如果登录了就返回204,没有登录就返回302并且携带跳转页面,等待用户的验证。
如何找到这个验证请求呢?可以通过Fiddler抓手机的数据包得到。
我这里拿到http://connect.rom.miui.com/generate_204,因为我的是红米手机。
如何拦截到generate_204请求,路由器是有dns服务的,可以在dns服务上添加域名解析到我的web应用上,修改/etc/config/dhcp,末尾添加一行list address '/connect.rom.miui.com/192.168.1.195',如下
config dnsmasq
option domainneeded '1'
option boguspriv '1'
option filterwin2k '0'
option localise_queries '1'
option rebind_protection '1'
option rebind_localhost '1'
option local '/lan/'
option domain 'lan'
option expandhosts '1'
option nonegcache '0'
option authoritative '1'
option readethers '1'
option leasefile '/tmp/dhcp.leases'
option resolvfile '/tmp/resolv.conf.d/resolv.conf.auto'
option nonwildcard '1'
option localservice '1'
option ednspacket_max '1232'
list address '/connect.rom.miui.com/192.168.1.195'
192.168.1.195是我的PC的ip连接路由器的lan1,登录的web服务我这里使用nodejs,下面是后台代码,页面代码在完整项目里
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const path = require('path');
const fs = require('fs').;
const app = express();
//存ip
const map = new Map();
// 使用body-parser中间件来解析JSON数据
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 提供静态文件
app.use(express.static(path.join(__dirname, 'public')));
function isLogin(ip){
if(map.get(ip)){
const data = map.get(ip);
console.log("data",data)
//不超过10分钟
if(new Date().getTime() - data.time< 10 * 60 * 1000){
return true;
}
}
return false
}
// 登录路由
app.post('/login', (req, res) => {
const reqIp = req.ip;
const { username, password } = req.body;
// 假设这是一个简单的用户名密码验证
if (username === 'admin' && password === 'admin') {
// 登录成功,存储登录信息到map
map.set(reqIp,{username:username,time:new Date().getTime()})
res.json({ success: true });
} else {
res.json({ success: false, message: '用户名或密码错误' });
}
});
// 主页路由(登录成功后跳转)
app.get('/', async (req, res) => {
const reqIp = req.ip;
if (!isLogin(reqIp)) {
// 如果没有登录,重定向到登录页
res.writeHead(302, { 'Location': '/login' });
res.end();
return
}
try {
// 读取文件内容
const filePath = path.join(__dirname, 'public', 'auth.html');
const fileContent = await fs.readFile(filePath, 'utf8');
// 替换内容
const username = map.get(reqIp).username;
const modifiedContent = fileContent.replace(/{{username}}/g, username);
// 发送修改后的内容
res.send(modifiedContent);
} catch (err) {
console.error('读取文件时发生错误:', err);
res.status(500).send('内部服务器错误');
}
});
app.get('/generate_204', (req, res) => {
const reqIp = req.ip;
console.log(reqIp)
if (!isLogin(reqIp)) {
// 如果没有登录,重定向到登录页
res.writeHead(302, { 'Location': '/login' });
res.end();
return
}
res.status(204).end();
});
//登录
app.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
//注销
app.get('/logout', (req, res) => {
const reqIp = req.ip;
map.set(reqIp,null);
res.json({ success: true });
});
// 启动服务器
const port = 80;
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
发现域名是connect.rom.miui.com的,解析到我的web服务ip上,请求就可以走到web服务,web服务再判断是否登录,有登录就返回响应码204,没有登录就返回301和登录的url,登录的url也是指向web服务,登录成功后。下次的generate_204请求就会返回204,下面大致流程
sequenceDiagram
participant Android设备
participant DNS服务
participant Web服务
participant 登录页面
Android设备->>DNS服务: 请求域名解析
DNS服务-->>Android设备: 解析 `generate_204` 到 Web 服务IP
Android设备->>Web服务: 发送 `generate_204` 请求
Web服务->>Web服务: 判断用户是否已登录
alt 已登录
Web服务-->>Android设备: 返回 HTTP 204(No Content)
Android设备->>Android设备: 网络连接正常
else 未登录
Web服务-->>Android设备: 返回 HTTP 301 重定向到登录页面
Android设备->>登录页面: 打开登录页面
用户->>登录页面: 提交登录信息
登录页面-->>Web服务: 验证用户登录
Web服务-->>Android设备: 登录成功
Android设备->>Web服务: 再次发送 `generate_204` 请求
Web服务-->>Android设备: 返回 HTTP 204(No Content)
Android设备->>Android设备: 网络连接正常
end
上面只是把表面的效果实现了,实际上手机上的应用还是可以上网的,因为我们只是针对connect.rom.miui.com域名,我们还得通过iptables拦截网络访问。
我的wifi使用路由器的wlan0口,桥接到br-lan,如下可以查到两个wifi,三个有线,我需要拦截从wlan0口的数据包不允许通过。
root@OpenWrt:~# brctl show
bridge name bridge id STP enabled interfaces
br-lan 7fff.28d1271c4329 no lan2
wlan0
lan3
wlan1
lan1
然后通过iptables是拦截不到wlan1到br-lan数据包的,怎么办呢?
可以通过安装ebtables,在ebtables中标记wlan0流量,iptables也是可以拿到这个标记的,然后限制通过。
# 安装ebtalbes
opkg update
opkg install ebtales
# 标记流量
ebtables -A INPUT -i wlan0 -j mark --set-mark 1
# 拒绝标记流量
iptables -I FORWARD -m mark --mark 1 -j DROP
# 允许80端口标记流量 主要给验证请求通过(generate_204)
iptables -I FORWARD -p tcp --dport 80 -m mark --mark 1 -j ACCEPT
下面可以查看添加的规则:
ebtables -L
iptables -L FORWARD -v -n
上面已经将wifi来的数据包都拦截,现在添加动态允许通过规则,我们可以通过tcpdump抓包去generate_204的响应,如果是204添加iptables允许通过规则,否则就删除iptables允许通过规则,openwrt下可以通过lua脚本允许
安装lua
opkg update
opkg install lua
创建rule.lua,内容如下:
-- 动态修改 iptables 规则
function add_rule(ip)
-- 检查规则是否已经存在
local check_command = string.format("iptables -C FORWARD -p tcp -s %s -j ACCEPT", ip)
local exists = os.execute(check_command) == 0
if not exists then
-- 如果规则不存在,则添加规则
local command = string.format("iptables -I FORWARD -p tcp -s %s -j ACCEPT", ip)
os.execute(command)
print("Added rule for IP: " .. ip)
else
print("Rule for IP: " .. ip .. " already exists.")
end
end
-- 删除 iptables 规则
function delete_rule(ip)
-- 检查规则是否已经存在
local check_command = string.format("iptables -C FORWARD -p tcp -s %s -j ACCEPT", ip)
local exists = os.execute(check_command) == 0
if exists then
-- 如果规则存在,则删除规则
local command = string.format("iptables -D FORWARD -p tcp -s %s -j ACCEPT", ip)
os.execute(command)
print("Deleted rule for IP: " .. ip)
else
print("Rule for IP: " .. ip .. " does not exist.")
end
end
-- 解析 tcpdump 输出
function process_packet(packet)
-- 提取源 IP 和响应码
local src_ip, dest_ip, response_code = string.match(packet, "IP (%d+%.%d+%.%d+%.%d+)%.%d+ > (%d+%.%d+%.%d+%.%d+)%.%d+:.-HTTP/1.%d (%d+)")
-- 检查是否匹配
if dest_ip and response_code then
if response_code == "302" then
-- 响应码 302,删除允许通过
delete_rule(dest_ip)
elseif response_code == "204" then
-- 响应码 204,允许通过规则
add_rule(dest_ip)
end
end
end
-- 启动 tcpdump 命令并实时处理
function start_tcpdump()
local command = "tcpdump -i br-lan -nn tcp src port 80 and src host 192.168.1.195 -l"
local file = io.popen(command) -- 打开tcpdump输出
while true do
local packet = file:read("*l") -- 持续读取每一行数据包
if packet then
process_packet(packet) -- 处理每个数据包
else
break
end
end
end
-- 启动 tcpdump 监听
start_tcpdump()
执行脚本
lua rule.lua