# peerlink **Repository Path**: beewolf_me_com/peerlink ## Basic Information - **Project Name**: peerlink - **Description**: Windows 点对点加密通信工具:ECDSA 身份 + ECDH 前向保密 + AES-256-GCM,含 WPF 图形界面与命令行。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2026-10-22 - **Last Updated**: 2026-11-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # PeerLink 一个独立的 C# 点对点通信程序:在网络可达的前提下,提供**加密的消息与文件传输**和**可控的日志**。不需要中心服务器,不需要注册账号,两端直连。 当前状态:**核心可用 + 两套图形界面**。核心已通过本机双进程与进程内自检验证;WPF 界面完成端到端实测(连接、双向消息、文件收发与校验、日志面板、设置持久化),Avalonia 界面在无头渲染下跑通同一套流程。 平台支持:Windows x64(WPF 图形界面 + 命令行)、Windows / Linux x64(Avalonia 图形界面)、Linux x64(命令行,Ubuntu 20.04 / Debian 11 及以上)。两端协议相同,可以互相直连。 **下载**:[PeerLink-win-x64.zip](https://gitee.com/beewolf_me_com/peerlink/releases/download/v1.1.1/PeerLink-win-x64.zip)(89.8 MB,Windows x64,图形界面 + 命令行)· [PeerLink-linux-x64-desktop.tar.gz](https://gitee.com/beewolf_me_com/peerlink/releases/download/v1.1.1/PeerLink-linux-x64-desktop.tar.gz)(39.3 MB,Linux x64,图形界面)· [PeerLink-linux-x64.tar.gz](https://gitee.com/beewolf_me_com/peerlink/releases/download/v1.1.1/PeerLink-linux-x64.tar.gz)(30.7 MB,Linux x64,命令行)· [发行版说明](https://gitee.com/beewolf_me_com/peerlink/releases/tag/v1.1.1) 三个包都是自包含单文件,目标机无需安装 .NET 运行时。 > 首次使用先双击解压目录里的 `allow-firewall.bat`,否则对方连不进来。 ## 已实现 | 能力 | 说明 | |---|---| | TCP 直连 | 监听端口 + 主动连接,4 字节长度前缀分帧,单帧上限可配(默认 8 MB) | | 双向认证 | 双方各持 ECDSA P-256 长期身份,握手时互签完整 transcript | | 前向保密 | 每次连接生成临时 ECDH P-256 密钥,用后即弃,长期密钥不参与加密 | | 加密传输 | AES-256-GCM,每方向独立密钥、隐式递增 nonce;篡改或重放直接断开 | | 信任模型 | TOFU(首次即信任)+ 指纹固定;已记录的对端换钥匙会被拒绝 | | 消息 | 文本消息,带长度与哈希留痕(不落正文) | | 文件 | 64 KB 分块、顺序校验、SHA-256 端到端比对,失败自动删除 `.part` | | 断点续传 | 接收端保留 `.part` 与 sidecar 元数据,断线重连后从已收字节续传;元数据不符或校验失败自动回退到从头重传 | | 托盘 | 关闭窗口缩到托盘继续监听,托盘菜单可显示窗口、开始/停止监听、打开下载目录、退出 | | 会话管理 | 离线的会话可以右键移除,也可以用「清除离线」一次收干净;在线会话要先断开,避免把活着的链路藏起来 | | 消息复制 | 正文可拖选,Ctrl+C 或右键「复制」;焦点在别处时窗口接管 Ctrl+C,不会出现"选中了却复制不了" | | 心跳 | 定期 ping,超过 idle 超时主动断开,不产生"假在线" | | 日志 | Serilog:级别可运行时切换、按天+按大小滚动、保留份数可配、默认不记正文 | | 图形界面 | 联系人列表、气泡会话、会话颜色、发送文件、传输进度、活动日志面板、设置窗口;Windows 用 WPF,Windows/Linux 另有 Avalonia 版 | | 握手携带显示名 | 对端在联系人列表里直接显示名字,而不是一串十六进制 ID | ## 快速开始 ### 图形界面(Windows) ```bash dotnet build # 直接双击或运行(便携模式:数据存放在 exe 同目录的 data\ 下) ./src/PeerLink.App/bin/Debug/net10.0-windows/peerlink-gui.exe ``` 界面布局: - **头部**:本机 ID、指纹、显示名、监听状态;「设置」修改名称/端口/下载目录;「停止监听/开始监听」控制入站。 - **左栏**:联系人(在线绿点、离线灰点、未读红点角标);底部「连接对端」填对端 IP + 端口。 - **右侧**:气泡会话(自己蓝色靠右、对端灰色靠左);文件消息双击可在资源管理器中定位;底部传输面板显示进度与校验结果;文件可直接拖进聊天区发送。 - **状态栏**:日志级别热切换(Verbose–Error)、活动日志面板开关、下载目录直达。 - 发送消息:Enter 发送,Shift+Enter 换行;输入法组词时 Enter 先提交候选词,再按一次才发送。 启动参数:`--data <目录>`(数据目录)、`--port <端口>`、`--name <显示名>`。 设置与最近连接的对端保存在 exe 同目录的 `peerlink.ui.json`。 ### 从源码构建 需要 .NET 10 SDK,Windows 10/11 x64。 ```bash dotnet build -c Release # 生成单文件运行包(目标机无需安装 .NET 运行时) dotnet publish src/PeerLink.App/PeerLink.App.csproj -c Release -r win-x64 \ --self-contained true -p:PublishSingleFile=true \ -p:EnableCompressionInSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=none -o dist/gui ``` ### 控制台 ```bash # 机器 A(192.168.1.10) ./src/PeerLink.Cli/bin/Debug/net10.0/peerlink.exe listen --port 47777 --data data-a # 机器 B(192.168.1.20) ./src/PeerLink.Cli/bin/Debug/net10.0/peerlink.exe connect --host 192.168.1.10 --port 47777 --data data-b ``` `connect` 默认同时在本机 47777 监听,这样对端也能反向连回来,两端不必约定谁先起。只需要出站时加 `--listen-port 0`,换端口用 `--listen-port 47900`。 交互命令: ``` <任意文本> 发送给所有已连接的对端 /send <文件路径> 发送文件(接收端校验 SHA-256) /peers 查看当前连接 /id 查看本机 id 与指纹 /level Debug 运行时切换日志级别 /quit 退出 ``` 端到端自检(进程内起一对节点,互发消息与 6 MiB 文件,中途掐断再续传): ```bash ./src/PeerLink.Cli/bin/Debug/net10.0/peerlink.exe selftest ``` 输出示例: ``` [1/6] listener up on port 63392 [2/6] handshake complete: bob sees alice as 9CD8A85B593062A3 fp=9cd8:a85b:5930:62a3 [3/6] message received intact (31 chars) [4/6] file received and hashed: payload.bin 1048576 bytes sha256=9912ab12fe579ce7 [5/6] interrupted at 2818048 of 6291456 bytes (44%) [6/6] resumed from 2818048 bytes and verified sha256=b77d1ae87b0fc230 RESULT: PASS ``` 第 5 步按时间掐断,所以每次中断的偏移不同(40%–60% 之间),这正说明它是真断了再从断点续。 ## Windows 打包 一个 zip,图形界面与命令行都在里面,解压即用,目标机不需要装 .NET: ```bash python scripts/package_windows.py # 产物:dist/PeerLink-win-x64.zip(89.8 MB) ``` ``` PeerLink-win-x64/ peerlink-gui.exe WPF 图形界面 appsettings.json cli/peerlink.exe 命令行(listen / connect / selftest / diag) cli/appsettings.json allow-firewall.bat 双击提权,为程序与 TCP 47777 添加入站规则 allow-firewall.ps1 README.txt ``` 组装用白名单,只复制上面这些文件。跑过一次程序后目录里会留下 `data/`、`peerlink.ui.json`(还记着上次连过的 IP),这些不能跟着发出去——用 `copytree` 会全部带进去,所以脚本里逐个 `copy2`。 打包本身是确定的:条目顺序写死、zip 内时间戳固定为 1980-01-01、压缩级别固定。与 Linux 包同理,确定性只到归档这一层,exe 由 `dotnet publish` 产出,换目录重建会有几十字节浮动。 体积上有个已知的浪费:WPF 没有托盘 API,`TrayIcon.cs` 用了 `System.Windows.Forms.NotifyIcon`,于是整个 WinForms 栈(`System.Windows.Forms` 14 MB + `.Primitives` 3.5 MB)被拖进单文件。`.Design` 那个 5.9 MB 是设计时程序集、运行时根本不会加载,已在 csproj 里从发布列表剔除。要彻底省掉这 20 MB,得改成 `Shell_NotifyIcon` 的 P/Invoke 实现。 ## Linux(Ubuntu / Debian) 核心与 CLI 都是 `net10.0`,没有平台相关 API:身份是标准 PKCS#8 PEM,传输是纯 Socket,端口占用查询在 Linux 走 `ss` / `lsof`。因此 Linux 侧不需要改协议,只需换一个运行时标识。 ```bash # 交叉编译(Windows 上也能跑,dotnet 会拉 linux-x64 运行时包) ./scripts/publish-linux.sh # 产物:dist/PeerLink-linux-x64.tar.gz(30.7 MB,单文件,目标机无需 .NET) # 在 Windows 上构建时用这个:NTFS 没有 POSIX 权限位,脚本把 755 直接写进归档头, # 并把时间戳归零,同一目录重复构建字节一致 python scripts/package_linux.py # 在 Ubuntu 上 tar -xzf PeerLink-linux-x64.tar.gz cd PeerLink-linux-x64 chmod +x peerlink ./peerlink selftest ./peerlink listen --port 47777 --name alice ``` `scripts/publish-linux.sh` 等价于: ```bash dotnet publish src/PeerLink.Cli/PeerLink.Cli.csproj -c Release -r linux-x64 \ --self-contained true -p:PublishSingleFile=true \ -p:EnableCompressionInSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=none -o dist/linux ``` 要点: - **可执行位**。从 Windows 解压 tar 包时可能丢权限,`chmod +x peerlink` 一次即可。`publish-linux.sh` 已经把 755 写进归档头,正常 `tar -xzf` 会保留。 - **私钥权限**。`state/identity.pem` 在 Unix 上自动设为 `0600`。Windows 靠 ACL,Unix 默认 `0644` 会让同机其他用户读到身份私钥,代码里已显式收紧。 - **后台运行**。CLI 检测到 stdin 不是终端(systemd / nohup / 容器)会自动进入后台模式并等 SIGTERM,不会像普通 REPL 那样读到 EOF 立刻退出。 - **可复现到什么程度**。`tar`/`gzip` 这一层是确定的(条目顺序固定、mtime 归零、权限位写死),同一目录下重复构建字节完全一致;但 `.NET` 单文件本身在不同目录或不同机器上会有几十字节的浮动(实测 39,446,503 / 505 / 514),所以跨机复现不必强求,校验一律以发布页给出的 SHA-256 为准。 - **systemd**。`scripts/peerlink.service` 是现成模板,用专用用户跑,`StateDirectory` 给 0700,附带 `ProtectSystem=strict` 等加固项。 - **文件名**。Windows 对端发来的 `目录\文件.txt` 在 Linux 上会被规整成 `文件.txt`,不会生成带反斜杠的怪名字。 ### 桌面版(Avalonia) 图形界面有两个实现,共用同一套 ViewModel: | 平台 | 项目 | 打包 | |---|---|---| | Windows | `src/PeerLink.App`(WPF) | `peerlink-gui.exe` | | Windows / Linux | `src/PeerLink.App.Avalonia`(Avalonia 12) | `peerlink-desktop` | ```bash # Linux 桌面版(含命令行的包一起产出) python scripts/package_linux.py desktop # 产物:dist/PeerLink-linux-x64-desktop.tar.gz(39.3 MB,单文件,目标机无需 .NET) tar -xzf PeerLink-linux-x64-desktop.tar.gz cd PeerLink-linux-x64-desktop chmod +x peerlink-desktop ./peerlink-desktop ``` 再直接跑 `dotnet build` 的话,也可以 `dotnet run --project src/PeerLink.App.Avalonia`。 界面与 Windows 版一致:联系人列表、会话颜色(10 色 + 自动)、气泡左右分栏、传输进度、活动日志、设置对话框。消息正文用 `SelectableTextBlock`,可以拖选、Ctrl+C,右键有"复制"(有选区就复制选区,否则整条)。Enter 发送、Shift+Enter 换行。托盘用 Avalonia 的 `TrayIcon`,桌面支持 StatusNotifier / AppIndicator 时关闭窗口即缩到托盘、节点继续监听;不支持的桌面自动退化为"关闭即退出",日志里记一行 `tray icon unavailable`。 两个在 Linux 上踩过的坑,改法都写进代码注释了: - **输入框的 `AcceptsReturn` 必须是 `False`**。设成 `True` 时 `TextBox` 会在自己的类处理器里先把回车消费掉并标记 `Handled`,窗口上的隧道处理器未必抢得到,表现就是"按钮能发、回车没反应"。换行改由代码后置在光标处手写,Shift+Enter 照样能换行。 - **回车要按键位判断,不能只看 `e.Key`**。X11 与小键盘对主键盘回车上报的枚举不一致(`Return` / `Enter`),中文输入法预编辑时 `e.Key` 还会变成 `ImeProcessed`——这时只有 `e.PhysicalKey` 还认得出是回车。现在的判断是 `Key` 与 `PhysicalKey` 任一命中即可,`ImeProcessed` 则让给输入法上屏。 源码结构上,`src/PeerLink.Ui.Shared`(`net10.0`,无任何 UI 框架依赖)承载 ViewModel 与服务层,WPF 与 Avalonia 各自只提供两样东西:`IUiDispatcher`(回到 UI 线程)和 `IFilePicker`(原生文件对话框)。颜色在共享层是 `#RRGGBB` 字符串,两端各自转成自己的 `Brush`。 验证界面不必开窗口:`tools/UiSnapshot` 用 Avalonia 无头渲染,按 1:1 逻辑像素出图,命令行版和桌面版的双向消息、文件传输都能在一个进程里跑通。 ```bash dotnet run --project tools/UiSnapshot -- shot.png --chat --log # --width 980 --height 780 指定逻辑分辨率(默认 1180x780,窗口最小 980) # --longlog 塞一条超长日志行,检查换行是否被横向裁掉 # --entertest 发一次 Enter,断言消息发出且输入框清空;再验 Shift+Enter 换行 # --copytest 右键菜单复制整条 / 复制选区,读剪贴板比对 # --ctrlc 点气泡文本 → 把焦点抢回输入框 → 按 Ctrl+C,读剪贴板比对 # --peertest 断开后置位 HasOfflinePeers,清除离线后列表应清空 # --doubletest 点正文与点时间戳各双击一次,确认手势没被吞 # --offline 断开但不清除,用于截图检查离线会话与"清除离线"按钮 # --menu / --settings 顺带校验会话颜色菜单、渲染设置对话框 ``` ### 放行 Linux 防火墙 Ubuntu 默认 ufw 未启用,通常不用改。启用了 ufw 或连不上时: ```bash sudo ./allow-firewall.sh # 自动判断 ufw / iptables sudo ./allow-firewall.sh --dry-run # 只看状态不改 ``` 云主机还要在控制台安全组里放行对应 TCP 端口。 ## 连不上时先跑 diag 对端地址可达但连接超时,九成是防火墙丢包而不是端口没开。`diag` 把链路拆开逐段验证: ```bash ./peerlink.exe diag --host 192.168.1.20 --port 47777 ``` 输出包含地址解析、本机网卡与出口选择、ICMP 与路径 MTU、TCP 连通耗时、反向连接探测,以及端口占用者的 PID 与进程路径。连接被拒绝(RST)和静默超时被区分成不同结论,附中文原因与处理建议。 ### 放行 Windows 防火墙 Windows 默认丢弃入站连接。双击 `scripts/allow-firewall.bat`(自动请求管理员权限),它会按当前 exe 的实际路径为 GUI、CLI 和 TCP 端口添加规则,并在结束时回读校验。 手动等效命令: ```bat netsh advfirewall firewall add rule name="PeerLink TCP 47777" dir=in action=allow protocol=TCP localport=47777 ``` 端口被占用的症状是启动日志里的 `listen on 47777 failed (AddressAlreadyInUse)`,程序随后会退到临时端口,此时对端仍连旧端口就连不上。GUI 与 `diag` 都会直接显示占用进程的 PID、名称和 `taskkill` 命令。 ## 安全模型 ``` hello (明文) id, 身份公钥, 临时公钥, nonce ack (明文) id, 身份公钥, 临时公钥, nonce, 服务端签名 auth (明文) 客户端签名 ---------------- 之后全部为 AES-GCM 密文 ---------------- ``` - **临时 ECDH** 产生共享密钥,经 HKDF-SHA256 派生 64 字节,拆成「客户端→服务端」「服务端→客户端」两把密钥。方向分离意味着两侧 nonce 空间永不重叠。 - **签名覆盖整条 transcript**,客户端签名还覆盖服务端签名(channel binding),防止中间人各自建两条独立连接。 - **TOFU**:首次连接记录对端指纹并打 WARN;同一 id 换了钥匙直接拒绝连接,需人工删除 `state/known_peers.json` 条目才能恢复。 - 明文阶段只有三帧握手,且严格限制在握手完成前;握手后若收到明文帧立即断开。 已知边界(有意为之,不是遗漏): - 不做 NAT 穿透,依赖 IP 可达(符合"网络层面可达"这个前提)。 - 不做自动发现,需要手动填 IP;加 UDP 多播通告约 90 行即可补上。 - 无 CA / PKI,靠指纹人工核对(首次连接建议比对 `/id` 输出的指纹)。 - 单文件顺序传输,未做断点续传与并发多文件。 ## 日志设计 `appsettings.json`(随 exe 输出): ```json { "MinimumLevel": "Information", "Console": true, "Directory": "logs", "FilePrefix": "peerlink", "FileSizeLimitBytes": 10485760, "RetainedFileCountLimit": 10, "RollDaily": true, "LogPayloads": false } ``` - `MinimumLevel`:启动级别;运行中用 `/level Debug` 热切换(同一 `LoggingLevelSwitch` 实例)。 - 滚动:按天 + 单文件 10 MB 双条件滚动,最多保留 10 份,不会无限增长。 - 脱敏:默认 `LogPayloads=false`,正文只记 `len=31 sha256=bbc18bc5`;需要排查时再临时打开。 - 结构化:每行带 `{Properties:j}`,可直接被 Seq / Loki 之类采集。 一行真实日志: ``` 2026-10-22 09:27:52.541 [INF] message from A261384ECB80CC50 len=31 sha256=bbc18bc5 {"app":"peerlink"} ``` ## 代码结构(核心约 2,400 行 + 共享 UI 层约 1,400 行 + 两个前端) **核心与命令行** | 文件 | 行数 | 职责 | |---|---:|---| | `Core/Transport/PeerConnection.cs` | 916 | 握手、读写循环、文件收发与续传、心跳 | | `Cli/Program.cs` | 555 | 控制台交互、信号处理、自检 | | `Cli/Diag.cs` | 320 | 链路分段诊断 | | `Core/Identity/LocalIdentity.cs` | 257 | 身份密钥(含 Unix 0600 收紧)、指纹、TOFU 信任库 | | `Core/Transport/PeerLinkNode.cs` | 186 | 监听、连接池、事件上抛 | | `Core/Transport/PartialStore.cs` | 133 | `.part` 文件与 sidecar 元数据(续传状态) | | `Core/Transport/PortOwner.cs` | 134 | 端口占用定位(Windows netstat / Unix ss·lsof) | | `Core/Crypto/Handshake.cs` | 131 | transcript、签名、HKDF 派生 | | `Core/Crypto/SecureChannel.cs` | 74 | AES-GCM 加解密与计数器 | | `Core/Logging/*` | 154 | Serilog 装配、脱敏助手、日志开关 | | `Core/Protocol/Envelope.cs` | 51 | 消息类型与运行参数 | | `Core/Transport/FrameCodec.cs` | 63 | 分帧与长度上限 | **共享 UI 层**(`src/PeerLink.Ui.Shared`,`net10.0`,不引用任何 UI 框架) | 文件 | 行数 | 职责 | |---|---:|---| | `ViewModels/MainViewModel.cs` | 833 | 会话事件 → UI 状态、命令、日志面板 | | `Services/AppSession.cs` | 203 | 节点生命周期、日志装配、设置应用 | | `Services/PeerPalette.cs` | 86 | 10 色会话调色板 + 稳定自动配色(FNV-1a) | | `Services/ShellOpen.cs` | 71 | 打开目录 / 定位文件(explorer、open、xdg-open) | | `Services/AppSettings.cs` | ~110 | UI 偏好持久化、便携数据目录解析 | | `Services/ObservableLogSink.cs` | ~80 | Serilog → UI 日志面板桥接 | **两个前端** | 文件 | 行数 | 职责 | |---|---:|---| | `App/Views/MainWindow.xaml` | 630 | WPF 主窗口布局与样式 | | `App/Services/TrayIcon.cs` | 179 | WPF 托盘(NotifyIcon + 气泡提醒) | | `App/Views/SettingsWindow.xaml` | ~70 | WPF 设置对话框 | | `App.Avalonia/Views/MainWindow.axaml` | 382 | Avalonia 主窗口 | | `App.Avalonia/App.axaml.cs` | 161 | 启动、托盘、生命周期 | | `App.Avalonia/Views/SettingsWindow.axaml` | 60 | Avalonia 设置对话框 | 分层:`Views`(XAML/AXAML + 少量代码后置)→ `ViewModels`(状态与命令,与框架无关)→ `Services/AppSession`(唯一接触 socket 的地方)。框架差异被压到两个接口:`IUiDispatcher` 和 `IFilePicker`。 ## 后续可补的能力 当前只取「消息 + 文件 + 加密 + 日志」四条主干,外围功能一概没做。要接着补的话,增量粗估: | 追加能力 | 增量 | |---|---:| | UDP 多播自动发现 | ~90 行 | | 并发多文件传输 | ~200 行 | | 历史记录(SQLite) | ~200 行 | | 文件接收确认弹窗(AutoAccept 关闭路径) | ~120 行 | | 多语言界面 | ~150 行 + 词条 | | 握手报文整体封装(防 DPI 主动探测) | ~400 行 | | 握手路径加固(限长与并发约束、计算前置校验) | ~80 行 | ## 发版 ``` # 先看一遍要发什么,不联网 python scripts/publish_gitee_release.py --tag <版本号> \ --files dist/*.zip dist/*.tar.gz --dry-run # 真发:建 Release 并上传附件;已存在的 Release 只补缺失的包 GITEE_TOKEN=xxx python scripts/publish_gitee_release.py --tag <版本号> \ --files dist/*.zip dist/*.tar.gz ``` 令牌走环境变量,不进命令行历史,权限只需要 `projects`。上传前会先拦下超过 100 MB 的包(Gitee 单附件上限),传完回读附件列表核对。Release 页面上那两份 `<版本号>.zip` / `<版本号>.tar.gz` 是 Gitee 自己附的源码归档,不用管。 顺序不能反:**先推 tag,再建 Release**。让 Release 去自动建 tag 的话,本地同名 tag 再推就会被拒。 ## 授权 MIT License,见 [LICENSE](LICENSE)。