Files
inkwatch/client/lib/host/lan_service.dart
T
PMon Dev 5f382fbca8 feat(lan): 同步领取码(主干 f16bdc3)
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取;
并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异,
虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。
(pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。)

验证:客户端 368 → 374/374,analyze 干净。
2026-09-18 23:00:29 +08:00

570 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 局域网直连底座 —— 在本机内网地址上开一个**只对内网**的小服务
///
/// ── 为什么要它 ──────────────────────────────────────────────────────
/// 现有架构里所有流量都过云端 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;
}