phone-remote/app/security.py(第 41-67 行)+ app/config.py(lan_candidates 第 59-98 行)+ app/pair.py(75 行)<img>"发一张有时效的签名通行证;并自动找出"手机真正能连上的那个局域网 IP"。
<img src> 请求带不了 Authorization 头;把 token 放进 URL 又会写进日志——两难。
HMAC 签名 URL(security.py 第 41-67 行):
def _file_signature(filename: str, expires: int) -> str:
payload = f"{filename}:{expires}".encode("utf-8")
return hmac.new(settings.token.encode("utf-8"), payload, hashlib.sha256).hexdigest()[:24]
def signed_file_url(filename: str, ttl_seconds: int = SIGNED_URL_TTL) -> str:
"""给静态文件签一个有时效的链接。
为什么不用 token:`<img>` 发出的请求带不了 Authorization header,走 token 校验必然 401。
而把 token 塞进查询参数会写进访问日志 —— 不行。
折中是签名:用 token 当 HMAC 密钥,只有拿到 token 的人能生成,且带过期时间。"""
expires = int(time.time()) + ttl_seconds
return f"/api/screenshot/{filename}?exp={expires}&sig={_file_signature(filename, expires)}"
def verify_signed_file(filename: str, exp: int, sig: str) -> bool:
...
if expires < time.time():
return False
return hmac.compare_digest(_file_signature(filename, expires), sig)
局域网 IP 探测(config.py 第 59-98 行,节选):
def lan_candidates() -> list[tuple[str, str]]:
"""列出真实网卡上的私有 IPv4,排除虚拟网卡(VMware/Radmin 的地址手机连不上)。"""
for name, addrs in psutil.net_if_addrs().items():
low = name.lower()
is_tailscale = TAILSCALE_NIC_HINT in low # Tailscale 是有用的虚拟网卡,豁免
if not is_tailscale and any(hint in low for hint in _VIRTUAL_NIC_HINTS):
continue
...
usable = (ip.is_private and not ip.is_loopback and not ip.is_link_local) or (ip in TAILSCALE_NETWORK)
...
score = 0 if any(k in low for k in ("wlan", "wi-fi", "wireless", "无线")) else 1
if not str(ip).startswith("192.168."):
score += 1
if is_tailscale:
score += 5 # 局域网优先,Tailscale 排后面
found.append((score, str(ip), name))
found.sort()
return [(ip, name) for _, ip, name in found]
配对二维码的隐私巧思(pair.py 第 1-24 行):
"""为什么 token 放在 # 后面而不是查询参数:
- URL fragment 不会随 HTTP 请求发给服务端,也就不会写进访问日志
- 前端取到后立刻用 replaceState 抹掉,浏览器历史里也不留
- 代价:这个二维码等同于 token 本身,截图别乱发
"""
return f"http://{host}:{settings.port}/#token={settings.token}"
HMAC(密钥, 文件名:过期时间),链接里没有 token,泄露只影响一张图<img> 无法带 header 时的通用解法——这就是云存储"预签名 URL"的原理192.168.* 优先、Tailscale 靠后——把现实经验编码成规则is_private 对 100.64.0.0/10 返回 False 的坑(注释里写明了)
hmac/hashlib/time),函数级可直接抄psutil,函数级可复用qrcode/pillow(可选)test_signed_file.py(50 行)+ test_tailscale.py(44 行)signedlink:sign_url(path, ttl) / verify(path, exp, sig),框架无关lanip:first_reachable_ip(),附"排除虚拟网卡"的说明qrcode 库负责编码,你的 ascii_qr 用半块字符在终端渲染(体验细节)第 16 课(安全设计)