背景
家里有一台闲置的工控机,拿来跑 OpenMediaVault(OMV)搭了个 NAS,平时存文件、放相册、跑一些自用的小服务。因为家庭宽带天然具备 IPv6 地址,不需要额外做内网穿透或者买公网 IPv4,NAS 本身就能直接暴露在公网上被访问——这既是优点(省事、免费、速度不受限速影响),也是最大的风险点:IPv6 地址虽然理论上难以被批量扫描,但只要地址泄露一次,NAS 管理面板、文件服务、相册就直接暴露给了整个互联网,没有云厂商的 WAF、没有 NAT 的天然屏障。
OMV 自带的账号密码登录显然不够。为了不让 NAS 及挂在后面的其他内部系统(相册、文件管理等)直接裸奔在公网,索性利用 NAS 本身的容器功能(Docker),跑一个独立的 OpenResty 网关容器,在所有请求真正打到后端服务之前,先过一道邮箱验证码认证——相当于给整个家庭内网入口做了一层统一的 Zero Trust 收口,管理面板、业务系统本身完全不需要额外改造,认证逻辑全部收敛在网关这一层。
整个方案分三块:Nginx 配置部分、登录页渲染、验证码发送、Session 校验与续期。开发过程中踩了几个典型的坑,记录下来。

编辑
一、Nginx 配置部分
整个网关分两类 server block:一类是认证域名本身(auth.3137666.xyz),负责渲染登录页、发送验证码、校验登录;另一类是所有需要保护的业务域名(比如 omv4.3137666.xyz),在 access_by_lua_file 阶段统一拦截、校验 Session,通过后再 proxy_pass 到后端真实服务。
1.1 http 层公共配置
第一个大坑:外网ipv6到宿主机,宿主机与容器通信,openresty-alpine容器不带ipv6,邮件对外通信的域名使用的双栈,返回的ipv6无法通信,一定一定要关闭ipv6=off
nginx
http {
lua_package_path "/usr/local/openresty/nginx/lua/?.lua;;";
# 会话、验证码、冷却期统一存放在这个共享内存字典
lua_shared_dict zta_cache 10m;
resolver 127.0.0.11 ipv6=off; # resty.http 请求邮件 API 需要 DNS 解析,与容器的dns一致。
# ... 其他通用配置 ...
}
1.2 统一认证域名:auth.3137666.xyz
nginx
# ==================== 1. 统一认证中心 (auth.3137666.xyz:8443) ====================
server {
listen 8443 ssl;
server_name auth.3137666.xyz;
ssl_certificate /etc/nginx/ssl/3137666.xyz.fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/3137666.xyz.key;
location = / {
root /usr/local/openresty/nginx/html; # 导航页面,不做认证
index index.html;
}
# 登录页渲染与 POST 校验
location /login {
content_by_lua_file /etc/nginx/lua/auth_login.lua;
}
# 异步发送验证码接口
location /api/send-code {
content_by_lua_file /etc/nginx/lua/send_otp.lua;
}
# 登出清空 Session
location /logout {
content_by_lua_file /etc/nginx/lua/auth_logout.lua;
}
location = /filebrowser {
return 302 https://sample.3137666.xyz:8443;#内部系统
}
#其他内部系统
}
}
1.3 受保护的业务域名(以 OMV NAS 为例)
nginx
server {
listen 8443 ssl;
server_name omv.3137666.xyz;
ssl_certificate /etc/nginx/ssl/3137666.xyz.fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/3137666.xyz.key;
access_by_lua_file /etc/nginx/lua/session_auth.lua;
location / {
# 填写你内网实际服务的地址与端口
proxy_pass http://192.168.0.251:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
#其他server
{
#。。。
}
相册、文件管理他内部系统,各自建一个。
二、登录流程设计
2.1 未登录跳转
访问受保护资源时,access_by_lua 阶段检查 Cookie 里的 ZTA_SESSION,没有或者失效就重定向到登录页,同时把原始访问地址带上,方便登录后跳回去:
lua
编辑
2.2 登录页 + 验证码发送
登录页是一个纯前端表单,邮箱 + 六位验证码,点击”获取验证码”调用 /api/send-code,输入验证码后 POST /login 完成校验。

编辑
三、流量校验与Session滑动续期,Cookie同步
一周内面免校验登录,剩余一天,情况下有流量通过,自动续期一周,服务端 session时间与客户端cookie时间同步。保证状态一致。

编辑
四、小结
| 问题 | 表现 | 本质 |
|---|---|---|
| 验证码发送时好时坏 | 连接resned发送超时失败 | 容器无ipv6,dns解析出的ipv6无法通信,nginx配置关闭ipv6 |
| Session 续期失效 | 服务端 session 有效但用户被踢下线 | 服务端 TTL 和客户端 Cookie Max-Age 没同步刷新 |
| 验证码倒计时误触发 | 输错邮箱也进入 60 秒冷却 | HTTP 状态码在前端被忽略,业务逻辑判断依赖了错误的信号 |
另外还有一个不经过认证的导航页面,使用相同布局,系列不同二级子域名的情况下,部分浏览器安全设计会被认作广告进行隐藏不显示。通过单一域名+路径,然后进行302跳转到对应二级子域解决。
对于自建 NAS 这类暴露在公网、又想保持轻量的场景,用 OpenResty + ngx.shared.DICT 撑起一套邮箱验证码 + Session 管理是完全够用的方案,成本低、无需额外中间件。
放两张界面截图:

编辑

编辑

编辑

编辑

编辑