与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取; 并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异, 虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。 (pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。) 验证:客户端 368 → 374/374,analyze 干净。
570 lines
23 KiB
Dart
570 lines
23 KiB
Dart
/// 局域网直连底座 —— 在本机内网地址上开一个**只对内网**的小服务
|
||
///
|
||
/// ── 为什么要它 ──────────────────────────────────────────────────────
|
||
/// 现有架构里所有流量都过云端 gateway。同一 Wi-Fi 下这是浪费:
|
||
/// 数据要先绕到腾讯云、再绕回来。局域网直连把它变成"点对点",
|
||
/// 延迟从几十 ms 降到几 ms,且**不消耗服务器流量包**
|
||
/// —— 轻量服务器按流量计费,这是真金白银。
|
||
///
|
||
/// 它同时是两件事的**共同底座**(所以先做它,不分成两遍):
|
||
/// · 指标走内网(LAN 优先)
|
||
/// · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制)
|
||
///
|
||
/// ── ⚠️ 安全要点(比功能本身重要)────────────────────────────────────
|
||
/// **绝不能 `HttpServer.bind(InternetAddress.anyIPv4)`** —— 那会同时绑到
|
||
/// 公网网卡上,等于在公网开了一个端口。本项目的教训是
|
||
/// **"只守入口而放行后续动作等于没守"**,故这里:
|
||
/// · 只绑 [pickLanAddress] 选中的**那一个**内网地址;
|
||
/// · 只暴露只读的 `/lan/ping`,不含任何设备数据;
|
||
/// · 将来接数据/文件时用**一次性令牌**鉴权 ——
|
||
/// **不拿"同网段"当授权**(那是隐蔽监控的形态,见方案 2.1 的合规红线)。
|
||
///
|
||
/// ── 地址怎么选 ──────────────────────────────────────────────────────
|
||
/// 一个进程常有多个网卡(虚拟网卡 / VPN / Docker / 回环)。选错的后果是
|
||
/// "看着开了、对面却连不上"。判定收敛在 [pickLanAddress] 一条纯函数里,
|
||
/// 便于单测穷举各种网卡组合。
|
||
library;
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
|
||
import 'package:crypto/crypto.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
/// 从网卡列表里挑出一个**适合对内提供服务**的内网地址。
|
||
///
|
||
/// 排除规则(每条都有具体场景,不是想当然):
|
||
/// · 回环 `127.` / `::1` —— 对面连不上;
|
||
/// · 链路本地 `169.254.` / `fe80::` —— 只在没有 DHCP 时自配,不可路由;
|
||
/// · IPv6 —— 本项目的目标设备(手机/墨水瓶)与家庭网多是 IPv4,
|
||
/// 先只走 v4,少一半的踩坑面;
|
||
/// · 虚拟网卡(utun / docker / br- / veth / vmnet / bridge / tap / tun)
|
||
/// —— VPN 与容器网段的地址不在同一 Wi-Fi 里,选了必然连不上。
|
||
///
|
||
/// 优先级:**私有网段优先**(192.168 / 10 / 172.16-31),
|
||
/// 其余可路由的 v4 次之。返回 null 表示"没有可用的内网地址"
|
||
/// —— 此时**不启动服务**,而不是退而求其次绑到公网。
|
||
/// 入参用 `(网卡名, 地址列表)` 这种简单形状,而不是直接吃 `NetworkInterface` ——
|
||
/// 因为 **`NetworkInterface` 没有公开构造函数**,吃了它这个纯函数就没法在测试里
|
||
/// 构造"VPN + Wi-Fi 同时存在"之类的场景,而**恰恰是这些场景最容易选错**。
|
||
/// 选错的表现是"服务看着开了、对面却连不上",很难在真机上定位。
|
||
InternetAddress? pickLanAddress(List<({String name, List<InternetAddress> addrs})> ifaces) {
|
||
const virtualPrefixes = [
|
||
'utun', 'docker', 'br-', 'veth', 'vmnet', 'bridge', 'tap', 'tun', 'lo',
|
||
'llw', 'awdl', 'anpi', 'gif', 'stf', 'ap', 'p2p',
|
||
];
|
||
bool likelyVirtual(String name) {
|
||
final n = name.toLowerCase();
|
||
return virtualPrefixes.any(n.startsWith);
|
||
}
|
||
|
||
bool isPrivateV4(InternetAddress a) {
|
||
final b = a.rawAddress;
|
||
if (b.length != 4) return false;
|
||
final x = b[0], y = b[1];
|
||
if (x == 192 && y == 168) return true;
|
||
if (x == 10) return true;
|
||
if (x == 172 && y >= 16 && y <= 31) return true;
|
||
return false;
|
||
}
|
||
|
||
bool usable(InternetAddress a) {
|
||
if (a.type != InternetAddressType.IPv4) return false;
|
||
if (a.isLoopback) return false;
|
||
final b = a.rawAddress;
|
||
if (b.length != 4) return false;
|
||
// 169.254.0.0/16 链路本地
|
||
if (b[0] == 169 && b[1] == 254) return false;
|
||
// 0.0.0.0 / 广播
|
||
if (b[0] == 0 || b[0] == 255) return false;
|
||
return true;
|
||
}
|
||
|
||
final candidates = <InternetAddress>[];
|
||
for (final nic in ifaces) {
|
||
if (likelyVirtual(nic.name)) continue; // 虚拟网卡整张跳过
|
||
for (final a in nic.addrs) {
|
||
if (usable(a)) candidates.add(a);
|
||
}
|
||
}
|
||
if (candidates.isEmpty) return null;
|
||
// 私有网段优先:同 Wi-Fi 的设备基本都在私有网段
|
||
return candidates.firstWhere(isPrivateV4, orElse: () => candidates.first);
|
||
}
|
||
|
||
/// 进程级单例 —— 一个进程只开一个内网服务。
|
||
///
|
||
/// 做成单例而不是每页各开一个:端口是**进程级独占资源**,
|
||
/// 两处各起一个会撞端口;且 LAN 服务本来描述的就是"这台机器",
|
||
/// 不是"某个页面"。
|
||
class LanService {
|
||
LanService._();
|
||
static final LanService instance = LanService._();
|
||
|
||
HttpServer? _server;
|
||
InternetAddress? _addr;
|
||
int? _port;
|
||
Object? _lastError;
|
||
|
||
/// 待传出的文件登记:令牌 → 句柄。**有界**:过期即清扫(见 [_sweepOutgoing]),
|
||
/// 且没有登记就不会暴露任何东西 —— 服务默认不共享任何文件。
|
||
final Map<String, LanOutgoing> _outgoing = {};
|
||
|
||
/// 当前登记数(测试与自检用)
|
||
int get outgoingCount => _outgoing.length;
|
||
|
||
/// 登记一个待传出的文件,返回带**一次性令牌**的句柄。
|
||
///
|
||
/// 令牌是"谁拿到谁才能取",由上层显式发放 ——
|
||
/// **不拿"同网段"当授权**(那是隐蔽监控的形态,见文件头合规红线)。
|
||
Future<LanOutgoing> offer(String path,
|
||
{Duration ttl = const Duration(minutes: 5)}) async {
|
||
await _sweepOutgoing();
|
||
final f = File(path);
|
||
if (!await f.exists()) throw ArgumentError('文件不存在:$path');
|
||
if (!await f.stat().then((s) => s.type == FileSystemEntityType.file)) {
|
||
throw ArgumentError('不是普通文件:$path');
|
||
}
|
||
final size = await f.length();
|
||
// 流式算哈希:大文件不能整个读进内存(这正是本模块要解决的问题本身)
|
||
final digest = await sha256.bind(f.openRead()).first;
|
||
final o = LanOutgoing(
|
||
token: _newToken(),
|
||
name: p.basename(path),
|
||
size: size,
|
||
sha256: digest.toString(),
|
||
file: f,
|
||
expiresAt: DateTime.now().add(ttl),
|
||
);
|
||
_outgoing[o.token] = o;
|
||
return o;
|
||
}
|
||
|
||
/// 撤销登记(传完就该撤:令牌的有效期不该比传输本身长)
|
||
void revoke(String token) => _outgoing.remove(token);
|
||
|
||
Future<void> _sweepOutgoing() async {
|
||
_outgoing.removeWhere((_, o) => o.expired);
|
||
}
|
||
|
||
/// 处理 `GET /file/<token>` —— **流式**返回,支持 `Range` 断点续传。
|
||
Future<void> _serveFile(HttpRequest req, String token) async {
|
||
await _sweepOutgoing();
|
||
final o = _outgoing[token];
|
||
// 令牌不存在 / 已过期 → **一律 404**。
|
||
// 刻意不用 403:403 会泄漏"这个令牌曾经存在",等于给爆破一个反馈信号。
|
||
if (o == null) {
|
||
req.response.statusCode = HttpStatus.notFound;
|
||
await req.response.close();
|
||
return;
|
||
}
|
||
|
||
final total = o.size;
|
||
var start = 0;
|
||
var end = total - 1;
|
||
var partial = false;
|
||
|
||
final range = req.headers.value(HttpHeaders.rangeHeader);
|
||
if (range != null) {
|
||
final m = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim());
|
||
if (m != null && (m.group(1)!.isNotEmpty || m.group(2)!.isNotEmpty)) {
|
||
final a = m.group(1)!;
|
||
final b = m.group(2)!;
|
||
if (a.isEmpty) {
|
||
// `bytes=-N` = 最后 N 字节
|
||
final n = int.tryParse(b) ?? 0;
|
||
start = (total - n).clamp(0, total);
|
||
} else {
|
||
start = int.tryParse(a) ?? 0;
|
||
end = b.isEmpty ? total - 1 : (int.tryParse(b) ?? 0);
|
||
}
|
||
if (total == 0 || start >= total || end < start) {
|
||
req.response
|
||
..statusCode = HttpStatus.requestedRangeNotSatisfiable
|
||
..headers.set(HttpHeaders.contentRangeHeader, 'bytes */$total');
|
||
await req.response.close();
|
||
return;
|
||
}
|
||
end = end.clamp(start, total - 1);
|
||
partial = true;
|
||
}
|
||
}
|
||
|
||
final len = end - start + 1;
|
||
final res = req.response
|
||
..statusCode = partial ? HttpStatus.partialContent : HttpStatus.ok
|
||
..headers.contentType = ContentType.binary
|
||
..headers.set(HttpHeaders.acceptRangesHeader, 'bytes')
|
||
// 文件名可能含中文 → 用 URI 编码放在头里(HTTP 头不是 UTF-8 安全的)
|
||
..headers.set('x-file-name', Uri.encodeComponent(o.name))
|
||
..headers.set('x-file-sha256', o.sha256)
|
||
..contentLength = len;
|
||
if (partial) {
|
||
res.headers.set(HttpHeaders.contentRangeHeader, 'bytes $start-$end/$total');
|
||
}
|
||
if (len > 0) {
|
||
await res.addStream(o.file.openRead(start, end + 1));
|
||
}
|
||
await res.close();
|
||
}
|
||
|
||
/// 形如 `192.168.1.5:54321`;未启动时为 null。
|
||
String? get endpoint {
|
||
final a = _addr;
|
||
final p = _port;
|
||
if (a == null || p == null) return null;
|
||
return '${a.address}:$p';
|
||
}
|
||
|
||
String? get address => _addr?.address;
|
||
int? get port => _port;
|
||
bool get running => _server != null;
|
||
|
||
/// 启动失败的原因(例如"没有可用的内网地址")。
|
||
/// 刻意暴露出来给自检页显示 —— **"没开"和"开了但对面连不上"必须能分辨**。
|
||
Object? get lastError => _lastError;
|
||
|
||
/// 幂等:已启动则直接返回。
|
||
Future<void> start() async {
|
||
if (_server != null) return;
|
||
_lastError = null;
|
||
try {
|
||
final nics = await NetworkInterface.list(
|
||
includeLoopback: false,
|
||
includeLinkLocal: false,
|
||
type: InternetAddressType.IPv4,
|
||
);
|
||
final addr = pickLanAddress(
|
||
[for (final n in nics) (name: n.name, addrs: n.addresses)]);
|
||
if (addr == null) {
|
||
_lastError = '没有可用的内网地址(未启动,且不会退而绑公网)';
|
||
return;
|
||
}
|
||
// ★ 只绑这一个地址、端口交给系统分配(避免与别的程序撞固定端口)
|
||
final server = await HttpServer.bind(addr, 0);
|
||
_server = server;
|
||
_addr = addr;
|
||
_port = server.port;
|
||
unawaited(_serve(server));
|
||
} catch (e) {
|
||
_lastError = e;
|
||
}
|
||
}
|
||
|
||
Future<void> stop() async {
|
||
final s = _server;
|
||
_server = null;
|
||
_addr = null;
|
||
_port = null;
|
||
// 停止服务 = **不再共享任何文件**。登记必须一并清掉:
|
||
// 留着的话,下次 start() 会带着一批上一轮的令牌复活 ——
|
||
// 令牌不该比它所属的那次服务活得更久。
|
||
_outgoing.clear();
|
||
try {
|
||
await s?.close(force: true);
|
||
} catch (_) {
|
||
// 关不掉不该让调用方失败(与其它"清理失败只记不抛"同一处理)
|
||
}
|
||
}
|
||
|
||
Future<void> _serve(HttpServer server) async {
|
||
await for (final req in server) {
|
||
try {
|
||
// 目前只暴露一个**只读、无数据**的探活端点。
|
||
// 真正的数据/文件通道接进来时,必须带一次性令牌 —— 见文件头。
|
||
final path = req.uri.path;
|
||
if (req.method == 'GET' && path == '/lan/ping') {
|
||
req.response
|
||
..statusCode = 200
|
||
..headers.contentType = ContentType.json
|
||
..write(jsonEncode({'ok': true, 'v': 1}));
|
||
} else if (req.method == 'GET' && path.startsWith('/file/')) {
|
||
await _serveFile(req, path.substring('/file/'.length));
|
||
return; // _serveFile 自己关响应
|
||
} else {
|
||
req.response.statusCode = HttpStatus.notFound;
|
||
}
|
||
await req.response.close();
|
||
} catch (_) {
|
||
// 单个请求出错不能让整个服务停摆
|
||
try {
|
||
req.response.statusCode = HttpStatus.internalServerError;
|
||
await req.response.close();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 探测**一个**内网端点是否真的可达(查看端用)。
|
||
///
|
||
/// ── 超时为什么刻意短(800ms)──────────────────────────────────────
|
||
/// 探测只决定"走哪条路",而**云端通道本来就是通的**。
|
||
/// 若为了等内网而把超时放到好几秒,就等于把"局域网更快"这个优化
|
||
/// 变成了"每次连接都先卡几秒" —— 优化的代价转嫁给了体验。
|
||
/// 探不通就立刻走云端,用户感知不到这一步的存在。
|
||
///
|
||
/// 只认 `/lan/ping` 且**要求 body 里 `ok:true`** ——
|
||
/// 光看状态码不够:同一网段里别的服务也可能返回 200。
|
||
Future<bool> probeLanEndpoint(
|
||
String endpoint, {
|
||
Duration timeout = const Duration(milliseconds: 800),
|
||
}) async {
|
||
final client = HttpClient()..connectionTimeout = timeout;
|
||
try {
|
||
final req = await client
|
||
.getUrl(Uri.parse('http://$endpoint/lan/ping'))
|
||
.timeout(timeout);
|
||
final resp = await req.close().timeout(timeout);
|
||
if (resp.statusCode != 200) return false;
|
||
final body = await resp
|
||
.transform(const Utf8Decoder(allowMalformed: true))
|
||
.join()
|
||
.timeout(timeout);
|
||
return body.contains('"ok":true');
|
||
} catch (_) {
|
||
// 连不上、超时、DNS 失败…… 一律当作"不可达"。
|
||
// 探测失败不是错误,只是"这条更快但非必需的路不可用"。
|
||
return false;
|
||
} finally {
|
||
client.close(force: true);
|
||
}
|
||
}
|
||
|
||
/// 依次探测,返回**第一个可达**的端点;都不可达返回 null。
|
||
///
|
||
/// 顺序即优先级:服务端下发的顺序就是采集端上报的顺序。
|
||
Future<String?> pickReachableLanEndpoint(
|
||
List<String> endpoints, {
|
||
Duration timeout = const Duration(milliseconds: 800),
|
||
}) async {
|
||
for (final e in endpoints) {
|
||
if (await probeLanEndpoint(e, timeout: timeout)) return e;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// 文件传输(A 方案:内网直连,**数据不经服务器**)
|
||
//
|
||
// 这一块存在的理由就是产品负责人的原始诉求:**文件不落服务端**,
|
||
// 因而**不受服务器磁盘与流量包限制**。所以:文件从发送端的磁盘
|
||
// 直接流到接收端的磁盘,服务端一分钟都不参与。
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
/// 一次"待传出"的文件登记(= 接收端凭令牌可取的东西)。
|
||
class LanOutgoing {
|
||
LanOutgoing({
|
||
required this.token,
|
||
required this.name,
|
||
required this.size,
|
||
required this.sha256,
|
||
required this.file,
|
||
required this.expiresAt,
|
||
});
|
||
|
||
final String token;
|
||
final String name;
|
||
final int size;
|
||
final String sha256;
|
||
final File file;
|
||
final DateTime expiresAt;
|
||
|
||
bool get expired => DateTime.now().isAfter(expiresAt);
|
||
}
|
||
|
||
/// 下载结果 —— **把"传完了"和"内容对"分开说**。
|
||
///
|
||
/// 只报"写入了 N 字节"是不够的:大文件最容易出的问题恰恰是
|
||
/// "字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。
|
||
class LanDownloadResult {
|
||
const LanDownloadResult({
|
||
required this.path,
|
||
required this.bytes,
|
||
required this.sha256,
|
||
required this.expectedSha256,
|
||
required this.verified,
|
||
});
|
||
|
||
final String path;
|
||
final int bytes;
|
||
final String? sha256;
|
||
final String? expectedSha256;
|
||
|
||
/// 端到端校验是否通过。**为 false 时调用方必须当作失败**,不可只用字节数。
|
||
final bool verified;
|
||
}
|
||
|
||
/// 生成一次性令牌:**16 字节**密码学随机 → base64url(去填充)。
|
||
///
|
||
/// 为什么是 16 而不是 32:领用它的场景是"同一局域网 + 5 分钟 TTL",
|
||
/// 128 位随机在这个窗口内爆破不现实;而令牌要**塞进领取码里发给对方**,
|
||
/// 每多 16 字节,码就长 22 个字符 —— 用户抄错、二维码变密的代价是真实的。
|
||
/// 32 位是给"长期有效凭据"用的强度,这里不是那个场景。
|
||
///
|
||
/// 为什么不用"同网段"当授权(**合规红线**):那等于把物理接近
|
||
/// 当成授权依据,正是隐蔽监控的形态。令牌由**上层显式发放**,
|
||
/// 谁拿到谁才能取 —— 与配对码是同一套思路。
|
||
String _newToken() {
|
||
final r = Random.secure();
|
||
final b = List<int>.generate(16, (_) => r.nextInt(256));
|
||
return base64Url.encode(b).replaceAll('=', '');
|
||
}
|
||
|
||
/// 从内网端点下载一个已登记的文件到本地,并做**端到端校验**。
|
||
///
|
||
/// ── 为什么校验必须做,而且必须作为返回值 ────────────────────────────
|
||
/// "写入了 N 字节"不等于"内容对":大文件传输最常出的问题是
|
||
/// 断点拼接错位、对端文件在中途被换过。所以收完比对 sha256,
|
||
/// 并把结果**放进返回值** —— 调用方据此决定算不算成功,
|
||
/// 而不是把它打成一行日志然后当没事发生。
|
||
Future<LanDownloadResult> downloadLanFile({
|
||
required String endpoint,
|
||
required String token,
|
||
required String savePath,
|
||
Duration timeout = const Duration(seconds: 30),
|
||
}) async {
|
||
final client = HttpClient()..connectionTimeout = timeout;
|
||
File? out;
|
||
try {
|
||
final req =
|
||
await client.getUrl(Uri.parse('http://$endpoint/file/$token')).timeout(timeout);
|
||
final resp = await req.close().timeout(timeout);
|
||
if (resp.statusCode != 200) {
|
||
throw HttpException('下载失败:HTTP ${resp.statusCode}', uri: req.uri);
|
||
}
|
||
final expected = resp.headers.value('x-file-sha256');
|
||
|
||
out = File(savePath);
|
||
await out.parent.create(recursive: true);
|
||
final sink = out.openWrite();
|
||
var bytes = 0;
|
||
await for (final chunk in resp) {
|
||
sink.add(chunk);
|
||
bytes += chunk.length;
|
||
}
|
||
await sink.flush();
|
||
await sink.close();
|
||
|
||
final actual = (await sha256.bind(out.openRead()).first).toString();
|
||
return LanDownloadResult(
|
||
path: savePath,
|
||
bytes: bytes,
|
||
sha256: actual,
|
||
expectedSha256: expected,
|
||
// 对端没给哈希时不能算"验过" —— 那只是"没报错"。
|
||
// **"没坏"与"验过了"是两件事**,不能拿后者冒充前者。
|
||
verified: expected != null && expected == actual,
|
||
);
|
||
} finally {
|
||
client.close(force: true);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
// 领取码 —— 把"去哪取、凭什么取"编码成一段可以转给对方的字符串
|
||
//
|
||
// ── 为什么用码,而不是走服务端信令 ──────────────────────────────────
|
||
// 走服务端最顺(对端直接弹出来),但需要给采集端加寻址 + 改下游事件白名单 + 部署。
|
||
// 先用"码"把**可用闭环**做出来:发送方出示、接收方粘贴,
|
||
// 数据仍然是**两端直连、不经服务器**。体验优化留给下一步。
|
||
//
|
||
// 码里放什么:端点、令牌、**文件名与大小**(接收方在下载前就能看到"要收什么",
|
||
// 这是"先确认再收"的前提)。**不放哈希** —— 完整哈希从响应头拿,
|
||
// 放进码里只会让它变长而不增加保证。
|
||
// ══════════════════════════════════════════════════════════════════════
|
||
|
||
const String _kTicketPrefix = 'INKW-FT1.';
|
||
|
||
/// 把"去哪取、凭什么取"编成领取码。
|
||
String encodeLanTicket({
|
||
required String endpoint,
|
||
required String token,
|
||
required String name,
|
||
required int size,
|
||
}) {
|
||
final payload = jsonEncode({'e': endpoint, 't': token, 'n': name, 's': size});
|
||
return _kTicketPrefix + base64Url.encode(utf8.encode(payload)).replaceAll('=', '');
|
||
}
|
||
|
||
/// 解析领取码。
|
||
///
|
||
/// **对垃圾输入必须给明确结果而不是抛得莫名其妙** —— 用户是从聊天窗口里
|
||
/// 复制粘贴过来的,多一个空格、少一个字符都很常见。
|
||
/// 返回 null 表示"这不是一个有效的领取码",调用方据此给出人话提示。
|
||
LanTicket? parseLanTicket(String raw) {
|
||
final s = raw.trim();
|
||
if (!s.startsWith(_kTicketPrefix)) return null;
|
||
final body = s.substring(_kTicketPrefix.length);
|
||
if (body.isEmpty) return null;
|
||
try {
|
||
// base64Url.decode 要求长度合规;补回填充再解
|
||
final padded = body + '=' * ((4 - body.length % 4) % 4);
|
||
final decoded = utf8.decode(base64Url.decode(padded));
|
||
final j = jsonDecode(decoded);
|
||
if (j is! Map) return null;
|
||
final e = j['e'];
|
||
final t = j['t'];
|
||
final n = j['n'];
|
||
final sz = j['s'];
|
||
if (e is! String || t is! String || n is! String || sz is! int) return null;
|
||
if (e.isEmpty || t.isEmpty || sz < 0) return null;
|
||
// ★ 端点必须**只允许私有网段 IPv4**。
|
||
//
|
||
// 这一条是安全底线,不是"格式检查":码是**从外部粘贴进来**的,
|
||
// 里面的端点会被拿去做 HTTP 请求 —— 若放行任意主机名/公网地址,
|
||
// 粘贴一条码就等于让应用去连任意地址(SSRF 面)。
|
||
// 而且本功能的前提就是"同一局域网",公网地址本来也不该出现在这里。
|
||
final parsed = _parsePrivateV4Endpoint(e);
|
||
if (parsed == null) return null;
|
||
// 令牌字符集收一道:它会被拼进 URL 路径,不能让奇怪东西混进去
|
||
if (!RegExp(r'^[A-Za-z0-9_\-]{8,64}$').hasMatch(t)) return null;
|
||
return LanTicket(endpoint: parsed, token: t, name: n, size: sz);
|
||
} catch (_) {
|
||
return null; // 不是 base64、不是 JSON、字段类型不对 —— 统统算"无效码"
|
||
}
|
||
}
|
||
|
||
/// 校验并规范化 `ip:port`,**只接受私有网段 IPv4**。
|
||
///
|
||
/// ⚠️ 服务端 `server/shared/lan-endpoint.js` 有一份语义相同的 JS 实现 ——
|
||
/// 那不是重复,而是**纵深防御**:两端处在不同进程、不同信任位置,
|
||
/// 客户端这道是"别去连不该连的地方",服务端那道是"别把不该发的发出去"。
|
||
/// 跨语言无法共享代码,故两处都要有,且**判据必须一致**。
|
||
String? _parsePrivateV4Endpoint(String s) {
|
||
final m = RegExp(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})$')
|
||
.firstMatch(s.trim());
|
||
if (m == null) return null;
|
||
final parts = m.group(1)!.split('.');
|
||
final n = <int>[];
|
||
for (final x in parts) {
|
||
final v = int.tryParse(x);
|
||
if (v == null || v > 255) return null;
|
||
n.add(v);
|
||
}
|
||
final isPrivate = n[0] == 10 ||
|
||
(n[0] == 192 && n[1] == 168) ||
|
||
(n[0] == 172 && n[1] >= 16 && n[1] <= 31);
|
||
if (!isPrivate) return null;
|
||
final port = int.tryParse(m.group(2)!);
|
||
if (port == null || port < 1 || port > 65535) return null;
|
||
return '${m.group(1)}:$port';
|
||
}
|
||
|
||
/// 解析出来的领取信息。
|
||
class LanTicket {
|
||
const LanTicket({
|
||
required this.endpoint,
|
||
required this.token,
|
||
required this.name,
|
||
required this.size,
|
||
});
|
||
|
||
final String endpoint;
|
||
final String token;
|
||
final String name;
|
||
final int size;
|
||
}
|