工作流与学习实验

tline:从 TCP 到远端终端

目标不是先造一个 SSH,也不是立刻接入真实串口,而是把键盘、TCP、PTY、shell、进程组和退出回收连成一条可以观察的路径。

讨论整理:2026-07-25 定位:一次性 PoC 的阅读地图 实现事实源:tline-poc/tline.c 与运行输出
当前 PoC 没有认证、加密和授权,不能暴露到不可信网络,也不能被描述成可交付的远程运维产品。
本页目录

先固定一句话

tline v0 验证的是:已有三层网络可达时,一台机器能否通过 TCP 获得另一台机器上的交互式终端。relay 负责网络可达,tline 负责终端字节与进程生命周期,两者暂时不揉成一个系统。

01

先证明最短路径

最小场景里,WSL 是操作端,138 和 51 都可以各自启动一个 tline server。操作端直接连接目标,不要求 138 再替 51 转发终端会话。

当前拓扑 relay 网络只提供 IP 可达性
A:WSL tline connect
现有网络 / relay 把 TCP 字节送到目标
B 或 C tline serve + bash

B 和 C 是两个可独立连接的目标,不是必须串起来的两级 server。

PTY 不要求设备被动等待连接

当前 PoC 由目标机器监听 TCP;这只是网络发起方向,不是 PTY 模型的限制。PTY master / slave 和 bash 仍然位于设备本地,master 左侧既可以接入站连接,也可以由设备 agent 主动建立出站连接。

这种 reverse-connect 形态不要求先有虚拟网络。操作端和设备都可以通过普通可出站网络连接中心,由应用层根据设备身份和 session 直接转发终端帧;只有需要为任意应用提供透明 IP 可达性时,虚拟网卡、地址分配、路由和二层转发才重新变得必要。

未来可验证的 reverse-connect 形态 架构推演,不是当前实现事实
操作端 do_connect + 本地终端
中心服务 身份、授权、配对、转发
设备 agent 主动拨出 + 本地 PTY
设备 agent 持有 PTY master 收到获准的调试会话后,在会话流与 master 之间转发字节和窗口消息
PTY slave → bash / 调试程序 继续获得 stdin/stdout/stderr、line discipline、进程组和控制信号语义

操作端可以复用当前 do_connect 的终端侧能力;设备端需要的是 outbound dial / register 加上当前 handle_clientforkpty() 和 bridge 的终端侧能力,不是再运行一份 do_connect

这条路的第一层边界:设备身份、操作人授权、显式配对、传输加密、会话过期与吊销、审计和资源限制必须先进入设计。若一条长期连接同时承载控制与多个调试会话,还需要明确的 session id、多路复用和断线恢复语义。

这一次要证明

  • 键盘输入能够到达远端 shell;
  • 远端输出能够完整返回;
  • Ctrl-C 和窗口大小仍有终端语义;
  • 断开后 shell 能被回收,server 还能接受下一次连接。

这一次不证明

  • 认证、加密、授权和审计;
  • 跨重启会话恢复和 tmux 式驻留;
  • 真实 UART 设备访问;
  • 多租户、资源隔离和生产级守护。

PoC 的停止条件:主路径跑通,生命周期问题被识别并留下验证方法,然后冻结。它不是靠继续增加功能来证明价值。

02

一条按键怎样到达 bash

“远程终端”不是把一个 shell 对象穿过网络。真正穿过网络的是字节和少量控制消息;终端语义由两端的 tty、PTY 和进程组共同恢复。

先确认 PTY 在哪里

远端 PTY 的物理归属 master 和 slave 都在 server 所在机器的内核里
tline client · 操作端 从本地 stdin 读按键,把远端输出写到本地 stdout
TCP 跨机器传输 DATA、RESIZE 和 EXIT 帧
tline server · 目标端进程 持有 socket,也持有远端 PTY master 的文件描述符
PTY master · 目标端内核 server 从这里读取 shell 输出,并向这里写入客户端输入
PTY slave · 同一个目标端内核 成为 controlling tty,并连接到 bash 的 stdin/stdout/stderr
远端 bash 认为自己正在一个普通终端里运行

client 在逻辑上操纵 master,但真正的 master fd 由 server 进程持有,无法穿过 TCP 交给另一台机器。

再展开本地终端

从终端模拟器到远端 shell 两套终端边界,中间由 TCP 连接
Ghostty / Windows Terminal 本地终端模拟器,负责显示和键盘输入
本地 tty 或 PTY tline client 继承它作为自己的 stdin/stdout
tline client 把本地 tty 切到 raw mode,只搬运字节和控制状态
TCP 连接操作端和目标端
tline server 在 socket 与远端 PTY master 之间桥接
远端 PTY master / slave 在目标端内核里恢复完整 tty 语义
bash 与它启动的程序 继续使用熟悉的 stdin/stdout/stderr、termios 和作业控制
输入方向 本地键盘到远端前台进程
本地 tty raw mode 读取按键
tline client 封装 DATA 帧
TCP 可靠、有序字节流
PTY master server 写入
PTY slave 行规程与前台进程组
bash / command 读取 stdin
输出方向 远端程序到本地屏幕
bash / command 写 stdout / stderr
PTY slave → master 终端输出成为字节
tline server 封装 DATA 帧
TCP 保持发送顺序
本地 stdout 终端模拟器渲染

网络通只解决“字节能不能过去”。交互式体验还要求控制字符、窗口尺寸、退出顺序和异常断开都能沿着同一条路径得到处理。

03

PTY 为什么是核心

普通 pipe 可以连接 stdin/stdout,却不会自动给程序一个“我正在终端里运行”的环境。PTY 是一对虚拟终端设备:应用一侧操作 master,shell 一侧把 slave 当成真正终端。

PTY master

  • 由 tline server 持有;
  • 读到 shell 的终端输出;
  • 写入来自远端键盘的输入;
  • 通过 ioctl 更新窗口尺寸。

PTY slave

  • 成为 bash 的 stdin/stdout/stderr;
  • 可以成为会话的 controlling terminal;
  • 维护行规程和特殊控制字符;
  • 知道哪个进程组处于前台。

pipe、PTY 和串口不是一回事

对象 主要用途 有没有终端语义 在 tline 中的位置
pipe 进程间单向字节流 没有 controlling tty、窗口和前台进程组 不足以单独支撑完整交互式 shell
PTY 在内核里模拟一端终端 有,slave 可以表现成 tty 当前 PoC 的核心适配层
UART / 串口 与真实设备交换串行字节 取决于远端设备协议,不自动等于 Unix shell 未来可替换 PTY 一侧的另一种 transport

forkpty() 可以理解为一组动作的封装:创建 PTY、fork、让子进程接管 slave,并把父进程留在 master 一侧。它方便,但没有取消 session、controlling tty 和子进程回收,只是把一部分步骤包起来了。

04

TCP 是字节流,帧是应用自己定义的

一次 send() 不对应对端一次 recv()。TCP 只承诺在连接存活且没有错误时,按顺序交付字节。因此 tline 需要自己的长度和类型字段。

抽象帧格式 固定头部后跟 payload
length payload 长度,网络字节序
type 消息类型
payload 终端字节或控制参数

DATA

携带键盘输入或 PTY 输出。内容可以是任意字节,不依赖换行分隔。

RESIZE

携带 rows / cols,server 再用 TIOCSWINSZ 更新 PTY。

EXIT

表示远端会话结束。应当在残留 DATA 排空以后发送。

可靠不等于永不阻塞。对端停止读取时,TCP 发送缓冲区最终会填满,阻塞式写入会卡住当前事件循环。问题通常是双向活性和反压,不应含混地称为“TCP 丢帧”。

如果未来需要同时承受高速输出和控制消息,才需要非阻塞 I/O、独立发送队列、高水位线和明确的丢弃策略。v0 可以先记录这个边界,不必为了想象中的吞吐提前造完整流控层。

05

谁创建谁,谁等待谁

进程生命周期不能只靠信号处理器“顺手收一下”。先画出所有权,退出逻辑才有确定位置。

概念进程树 每条连接持有自己的 PTY 和 shell
server 主进程
持有 listener
接受连接,创建连接处理进程,并回收这些直接子进程。
连接处理进程
持有 socket、PTY master 和 shell PID
在 socket 与 PTY 之间搬运帧,负责结束自己的 shell。
shell 会话
bash 与它启动的前台命令
使用 PTY slave,形成自己的 session / process group 关系。

归属原则:server 只回收自己的连接子进程;连接处理进程只回收自己创建的 shell。谁拿到了 PID,谁就要知道何时 waitpid(),不能依赖另一个层级猜测。

父进程退出并不自动保证所有后代都按预期结束。socket 断开以后,连接处理进程还需要关闭 PTY、向正确的进程或进程组发信号,并等待 shell,避免孤儿进程、僵尸进程或 PID 复用后的误杀。

06

session、进程组与 setsid

Unix 终端控制不是“一个进程对应一个终端”,而是围绕 session、process group 和 controlling terminal 组织的。

先把三张图分开

APUE 把这些知识分散在进程关系、信号、daemon、Terminal I/O 和 PTY 几章里。它们看起来像同一张图,实际回答的是三个不同问题;先分开,后面才不会把父子关系、文件描述符和作业控制混成一件事。

数据路径

回答字节经过哪里,以及谁持有 PTY master。

tline client
    ↕ TCP frames
connection handler
    ↕ PTY master
kernel PTY
    ↕ PTY slave
bash / foreground job

父子进程树

回答谁创建谁,以及退出后谁负责等待和回收。

tline serve
└── connection handler
    └── bash
        ├── sleep
        └── yes | head
控制关系 回答终端把输入和信号交给谁
session
├── controlling terminal: /dev/pts/N
│   └── foreground PGID: 当前前台 job
├── process group: bash
├── foreground process group: yes | head
└── background process group: some-job &

同一个 tty 模型:/dev/ttyS0 由 UART 硬件提供输入,/dev/tty1 由虚拟控制台提供输入,/dev/pts/N 由 PTY master 提供输入;进入 line discipline 以后,shell 看到的仍是终端接口。tline 改变的是终端左侧的数据来源,没有取消右侧的 session、进程组和作业控制。

当前只把这三张图作为阅读锚点,不表示已经掌握 APUE 中的全部信号和作业控制细节。以后若启动内核中的进程、调度与终端实现研究,再沿着这组接口进入内核侧。

session 一组相关进程组;最多关联一个 controlling terminal
process group shell 管道或一个 job 的进程集合;终端只把输入和控制信号交给前台进程组
process bash、sleep、vim 等具体进程

setsid() 具体做什么

  1. 创建一个新的 session,调用者成为 session leader;
  2. 同时创建一个新的 process group,调用者成为 process group leader;
  3. 让调用者脱离原来的 controlling terminal。

setsid() 不是“转后台”的同义词。它重建的是终端和作业控制的归属关系。调用者如果已经是 process group leader,调用会失败,因此常见模式是先 fork(),让子进程再调用 setsid()

daemon 为什么调用 setsid

它想脱离启动它的终端和 session。经典 double fork 的第二次 fork 会让最终进程不再是 session leader,从而避免它日后意外重新取得 controlling terminal。

PTY shell 为什么也需要 session

方向恰好相反:它要主动建立一个新 session,再把 PTY slave 设成自己的 controlling terminal,让 bash 的作业控制能够工作。

daemon:
fork → child setsid → optional second fork → no controlling terminal

interactive PTY child:
fork → child setsid → PTY slave becomes controlling tty → exec bash

所以同一个 setsid() 会同时出现在 daemon 和终端程序里。它们不是目的相同,而是都要重新安排“这个进程属于哪个会话、受哪个终端控制”。

07

SIGCHLD 与 SIGCLD

子进程退出、停止或继续时,内核可以向父进程发送 SIGCHLD。退出状态会一直保留,直到父进程调用 wait()waitpid();只收到信号并不等于已经完成回收。

SIGCHLD

POSIX 使用的正式名称。新代码应使用它,并明确是否关心 stop / continue 事件。

SIGCLD

来自早期 System V 的历史名字。现代 Linux / glibc 通常把它定义成 SIGCHLD 的别名,但旧系统曾有不同语义,不能据此写可移植代码。

你记得它们“好像不完全一样”并不是错觉:历史接口确实存在差异。对今天的 Linux 程序,实践结论很简单:写 SIGCHLD,不要依赖旧式 SIGCLD 行为。

为什么 handler 里通常要循环 waitpid

传统 Unix signal 不是可靠的事件队列。多个子进程几乎同时退出时,信号可能合并成一次通知,因此一次 handler 需要把所有已经结束的直接子进程收干净。

while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    /* record or discard this direct child's exit status */
}

fork 和 exec 的信号继承规则

动作 signal disposition 会怎样 tline 中的对应处理
fork() 子进程复制父进程的默认、忽略和自定义 handler 状态。 连接子进程会继承 server 的 SIGCHLD handler 和 SIGPIPE 忽略状态。
exec() 自定义 handler 通常恢复默认;被忽略的信号仍保持忽略。 因此不能指望 execl("/bin/bash", ...) 自动恢复被 server 忽略的 SIGPIPE
重新划边界 进入新角色前,主动恢复不应继续继承的处置。 连接进程恢复 SIGCHLD 默认处置;bash child 在 exec 前恢复 SIGPIPE 默认处置。

tline 里容易出现的继承问题

fork() 后,子进程会继承父进程的 signal disposition。若 server 主进程安装了一个“回收所有子进程”的 SIGCHLD handler,连接处理子进程也会继承它;它随后创建自己的 bash 时,继承来的 handler 可能先把 bash 回收,打乱连接层显式的 waitpid(shell_pid, ...)

server 安装 handler 回收连接子进程
fork 后被继承 连接进程也会响应 SIGCHLD
bash 退出 可能被通用 handler 抢先 wait
重置并显式等待 连接进程拥有 bash 生命周期

清晰的做法是:连接子进程在创建 PTY shell 前,把 SIGCHLD 恢复成默认处置;随后由连接层按已知 PID 显式等待。bash child 还要在 exec 前把 server 为 socket 写入而忽略的 SIGPIPE 恢复成默认处置,避免服务端策略污染用户程序。这里修复的不是两个信号技巧,而是进程角色和所有权。

08

Ctrl-C 不是直接发给 bash

本地终端进入 raw mode 后,按下 Ctrl-C 通常只是产生字节 0x03。tline 把这个字节送到远端 PTY master,真正解释它的是远端 PTY slave 的 line discipline。

Ctrl-C 的完整路径 VINTR 通常对应 0x03
本地 Ctrl-C raw mode 下成为字节
DATA / TCP 原样传到远端
PTY line discipline 识别 VINTR
SIGINT 发给前台 process group
sleep 退出 bash 仍然保持会话

这也解释了为什么 `sleep 100` 被中断后连接还能继续:终端把信号发给当前前台 job,而不是杀掉整个 session 或网络连接。

窗口变化也是控制面消息

本地 SIGWINCH 读取 rows / cols
RESIZE 帧 独立于 DATA
TIOCSWINSZ 更新远端 PTY
远端 SIGWINCH vim / shell 重新布局

外部发送给 tline client 的 SIGINT、SIGTERM 或 SIGHUP 是另一条路径。signal handler 不应直接做复杂的 tcsetattr() 清理;更稳的模型是 handler 只设置 sig_atomic_t 标志或唤醒主循环,再由普通控制流恢复本地终端。

09

主路径跑通以后,问题集中在退出

远程 shell 最容易展示的是“连上了”;更能决定它是否可靠的,是快速退出、半关闭、积压输出、信号竞态和 daemon 启动失败。

检查点 容易出现的误判 更准确的处理原则
POLLIN | POLLHUP 看见 HUP 就立即退出 HUP 只表示不会再有新数据;已有缓冲数据仍要持续读到 EOF / EIO。
PTY 快速输出后退出 一次 4 KiB read 后发送 EXIT 先排空 PTY,再按 DATA 后 EXIT 的顺序结束。
本地 raw mode 先改终端,再安装清理路径 先保存状态并建立退出路径,最后进入 raw;所有返回都经过统一 cleanup。
daemon 启动 父进程先退出 0,就算启动成功 bind/listen 应先成功,或用 readiness pipe 把子进程结果回报给父进程。
阻塞式 socket 写入 认为大量输出会“丢 TCP 帧” TCP 不会保留应用帧边界;真正风险是事件循环阻塞、反向控制无法推进。
强制断开客户端 socket 关闭就等于 shell 消失 连接层还要关闭 PTY、终止正确的进程组并显式 wait。

从初版到当前 PoC

初版报告记录了约 765 行 C、严格编译参数下零 warning、约 50 KiB 可执行文件,并跑通登录、命令执行、Ctrl-C、窗口变化、正常退出、强制断开和连续连接。后续独立复核还跑过连续两次连接、20,000 行输出以及 8 KiB 快速输出。

复核随后暴露了 signal handler 继承、PTY HUP 排空、终端恢复和 daemon bind/listen readiness 四类问题。当前单文件版本已分别加入显式子进程回收、HUP 后数据排空、flag-only signal handler、统一终端恢复和 daemon 前置 bind/listen;bash child 还会在 exec 前恢复 SIGPIPE 默认处置。构建保持零 warning,针对快速输出、外部信号、端口占用、daemon 连接和重复连接的测试均已跑通。

这证明的是一个边界清楚、可以真实使用的远程 PTY PoC,不是安全远程运维产品。并发上限、应用层流控、完整进程树回收、认证、加密、授权和审计仍明确留在边界之外。

证据分层:“命令能执行”是功能证据;“快速输出没有截断”是边界证据;“读懂谁回收谁、为什么不会竞态”才开始进入代码主权。

保留的最小回归集

  1. 严格编译参数下零 warning;
  2. 本地和跨机器连接,普通命令输出正确;
  3. Ctrl-C 只中断远端前台 job,连接仍在;
  4. 窗口 resize 后远端 stty size 一致;
  5. 短时间输出超过单个 buffer,尾部标记仍存在;
  6. 客户端异常断开后 shell 被回收,server 可再次连接;
  7. 端口被占用时 daemon 启动必须返回失败;
  8. 客户端被外部信号终止后,本地 termios 恢复。
10

安全不是后补功能,但这次先冻结

能启动远端 bash 的服务,本质上已经暴露了远程代码执行能力。没有认证和加密时,它只能运行在明确隔离、参与者可信、生命周期很短的实验环境中。

PoC 现在必须遵守

  • 不监听公网和不可信共享网络;
  • 不以 root 运行;
  • 不用“IP 地址看起来可信”代替身份;
  • 验证结束后停止服务并冻结功能。

若继续产品化,第一天就要设计

  • 节点身份、认证与密钥生命周期;
  • 目标 allowlist、最小权限和吊销;
  • 传输加密、重放边界和审计事件;
  • 连接上限、资源限制和失败恢复。

“先加一个明文 token”通常只会制造一种已经安全的错觉。若安全设计尚未开始,诚实地限制实验环境,比增加一个无法轮换、吊销和审计的临时口令更清楚。

11

以后接 UART,会变成另一条路径

tline 这个名字最终可以靠近串口,但当前 PoC 的远端对象是 PTY 和 bash。真实 UART 不是把设备路径换一下就自动得到同一种语义。

当前:远端 Unix shell

TCP ↔ frame ↔ PTY master ↔ PTY slave ↔ bash

关心 session、前台进程组、窗口、控制字符和 shell 回收。

未来:真实串口设备

TCP ↔ frame ↔ /dev/tty* ↔ UART ↔ board

关心波特率、数据位、校验、设备锁、断线重连和二进制透明。

两条路径可以复用 framing、网络传输和一部分终端 UI,却不应假装拥有相同的会话模型。板子是否提供 shell、是否支持 resize、Ctrl-C 是普通字节还是特殊命令,都由目标协议决定。

继续的合理顺序不是立刻接板子,而是先读懂并收住当前 PTY PoC。只有真实串口痛点再次出现时,再单独定义 UART transport 的验收边界。

12

一页摘要

问题 当前答案
tline 在证明什么? 已有网络可达时,用 TCP + PTY 建立一个可交互、可退出的远端 bash 主路径。
为什么不能只用 pipe? 交互式程序需要 controlling tty、窗口大小、line discipline 和前台进程组。
setsid() 是什么? 创建新 session 和 process group,并脱离旧 controlling terminal;daemon 用它脱离,PTY child 用它重新建立终端归属。
SIGCHLD 和 SIGCLD 呢? 现代 POSIX 代码用 SIGCHLD;SIGCLD 是历史名字,在 Linux 通常只是别名,旧系统语义曾不同。
Ctrl-C 怎样工作? 本地传 0x03,远端 PTY line discipline 把 SIGINT 发给前台进程组。
当前最重要的代码边界? 进程回收、PTY 数据排空、终端恢复、daemon readiness 与阻塞反压。
为什么还不是产品? 没有身份、认证、加密、授权、审计、资源限制和完整恢复机制。
下一步是什么? 先阅读当前实现和针对性测试;不因“以后可以接串口”立即扩项目。

这次真正打通的知识链是:TCP 负责传字节,协议负责分帧,PTY 恢复终端语义,session 和进程组决定信号去向,进程所有权决定谁负责回收。