文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。

进程的 umask 在普通模式位创建规则下,为新文件和目录移除权限位。应用先提供请求模式,掩码再限制这份请求。它既不是完整访问策略,也不会重写所有既有文件。如果父目录具有默认访问控制列表,创建计算方式还会改变,因此只检查进程掩码,可能无法准确预测最后得到的权限。排查时需要同时看到应用请求和目录环境。
坏掉的样子 没有默认 ACL 时,普通权限结果是请求模式与 umask 按位取反后的值进行按位与。例如请求 0666、掩码 0022,得到 0644。这个计算不是算术减法:相同掩码下请求 0600,仍然得到 0600。应用没有请求执行权限时,宽松掩码也不会主动添加执行位;掩码负责移除,而不是凭空授予应用未请求的普通权限。
限制严格的 umask 不会修复已经存在的全员可写文件。打开并截断该文件,会改变内容,却保留既有模式。后续 chmod 也可以不受当前掩码限制地扩大权限。因此,把 umask 当作权限永远不能超过的上界,会遗漏既有文件与之后权限修改两类情况。必须区分文件首次创建,以及此后围绕同一 inode 发生的操作。
父目录具有默认 ACL 时,Linux 从它派生新对象的访问 ACL,再按应用请求模式限制权限,不再按普通方式应用进程 umask。因此,即使创建者掩码是 0077,共享目录里仍可能生成组可访问文件。遇到与预期不同的模式时,应检查继承的 ACL,而不能只看到严格掩码就断言内核没有遵守权限设置。
快速验证 把脚本保存为 mask.py,在安装 Python 3 和 setfacl 的 Linux 环境中运行 python3 mask.py。临时目录所在文件系统必须支持默认 ACL。实测使用 Python 3.11.2、acl 2.3.1,以及通过 TMPDIR 选择的独立 ext4 卷。最初 tmpfs 环境拒绝设置默认 ACL,因此完整实验随后在 ext4 上重新执行:
...

聚合可以用一个覆盖前缀,替代若干更具体的路由通告。只有更具体前缀确实受到抑制、且其他对等体没有继续提供它们时,下游表项才会减少。成员不必填满整个汇总范围:几个稀疏子网也能形成一个较大的覆盖块,但该通告同时会吸引发往空洞地址的流量。因此,聚合既是规模问题,也是可达性和地址授权问题。
聚合究竟减少了什么 对等体同时收到三个成员前缀和一条汇总时,仍然持有四条路由。FRR 的 aggregate-address 10.1.0.0/16 summary-only 抑制被覆盖的更具体前缀通告,并不会把它们从聚合路由器自己的 BGP 表删除。生成汇总的前提是 BGP 表中至少有一个符合条件的更具体前缀,仅在其他路由表中存在一条记录还不够。
稳定的汇总可以把部分成员变化挡在下游之外,但这个收益有前提。最后一个成员消失、汇总属性改变,或者出口策略变化,都可能继续产生更新。抑制通告不会修复客户链路,只会改变其他网络获得的故障信息。聚合者仍然需要监控内部成员,不能因为上游只看到一条稳定路由,就认为内部全部可达。
汇总覆盖的地址通常也比列出来的几个子网更多。宣告之前,应检查整个范围的使用授权以及预期的汇总起源 AS。地址对齐、数值相邻、能节省表项,都不是拥有宣告权限的证据。提供商可以在自己的地址分配范围内合法汇总客户网络;成员来自不同起源 AS,并不自动意味着这次聚合非法,关键是整体授权和出口契约。
保留清晰的起源 FRR 的 suppress-map 用路由映射选择哪些更具体前缀受到抑制。as-set 处理的是另一个维度,即怎样表示成员路径中的 AS 信息。AS_SET 是无序集合,并非只包含客户起源号码的清单。历史上的 RFC 4271 在计算 AS 路径长度时,将整个集合计作一个,不会按集合成员数量逐个增加长度。
RFC 9774 已经废弃 AS_SET 与 AS_CONFED_SET。除非运营者为了过渡显式选择其他行为,发言者不得继续通告这些集合,收到含集合的路由时应按撤回处理。应选择明确、获得授权的汇总起源,并配置相应 ROA,不能把 AS_SET 当作常规修补办法。邻居的起源过滤器也不会因为路由器生成了一条汇总,就自动接受这个范围。
坏掉的样子 在选定转发表中的普通目的地址查找,使用最长的已安装匹配前缀。因此,有效的 /24 会覆盖其上方的 /16,不论两者的 BGP 本地优先级谁更高。不过,路由首先必须通过导入策略、下一跳解析和安装过程。BGP 为同一前缀选择候选路径,与转发时在不同前缀之间做匹配,是两个不同阶段。
...

DNS over TLS 和 DNS over HTTPS 保护其端点之间加密连接上的 DNS 流量。对于典型客户端,这条连接终止于选定解析器;解析器能够读取问题与回答,它随后发出的查询还可能使用不同传输。加密这一跳,不会自动加密整个递归过程,也不会让解析器失去记录所处理名称的能力。隐私范围应按真实连接边界说明,而不是笼统称为整个 DNS 都已保密。
坏掉的样子 加密套接字如果没有按预期验证证书,也可能连接到错误端点。应同时验证证书信任链和客户端期望的解析器身份;握手成功本身不能证明这两项检查已启用。失败时悄悄改用其他解析器或明文路径的配置,也会改变隐私契约。需要通过真实调用方了解这些行为,不能只看到配置文件中有一个 TLS 或 HTTPS 地址,就认定所有查询都符合预期。
DoH 使用 HTTPS,但四百四十三端口不会让它在所有网络中都无法识别、无法阻止或完全等同于任意网页流量。目的地址、连接时间和其他元信息仍可观察,TLS 终止点还可以检查明文 HTTP。选择解析器和传输策略时,应考虑这些边界,不能假定 HTTPS URL 会消除所有与 DNS 活动有关的信息暴露,也不能由加密推导解析器不会保存日志。
两种协议的消息边界也不同。DoT 通常使用八百五十三端口,在 TLS 内携带具有两字节 TCP 长度前缀的 DNS 消息。DoH 通常使用四百四十三端口上的 HTTPS 端点。标准 DoH 使用 application/dns-message 传递 DNS 线格式:POST 放在正文,GET 使用经 base64url 编码的 dns 参数。它不是任意 JSON 接口只要套上 HTTPS 就符合标准。
...

IPv6 路由器在向目的地转发报文时,不会替它进行分片。如果报文大于下一条链路所能承载的大小,路由器通常丢弃它,并发送 ICMPv6 Packet Too Big 反馈。源端随后可以调整报文大小,或在适当情况下通过 IPv6 分片扩展头发送分片;这些分片在目的端重组。明确各阶段由谁负责,是判断超大报文故障的基础。
坏掉的样子 假定路由器会自动拆开过大的 IPv6 包,会掩盖与大小相关的故障。相同地址与路由上,小 echo 可以成功,大 echo 却失败。端点本地接口可能支持原始大小,中间链路却不支持。应检查整条路径约束,不能仅因接口配置了 MTU 1500,就推断该大小能够端到端通过,也不能用一次小探测成功替代完整大小检查。
Packet Too Big 反馈缺失,会使经典发现更难学到这个限制。分片被过滤,或者其中一片丢失,也可能阻止正确重组。能够生成分片扩展头,不证明沿途策略都允许相应报文。不能把源端分片当作正确路径大小与必要控制流量的万能替代,它有自己的交付条件,需要通过对应流量验证,而不是只检查发送选项已经开启。
反方向也有自己的路径估计。请求发送端学到 MTU,不会自动教会响应端怎样发送较大回复。因此双向 echo 测试需要分别了解两个方向。TCP 分段还不同于 IP 分片:TCP 发送端学到限制后,可以构造更小的段,而不必把每个 IP 包再分片。应先确定观察的是哪一层的切分,避免看到多个包就统称为分片。
快速验证 将脚本保存为 check-ipv6-fragmentation.py,准备 Docker 后运行 python3 check-ipv6-fragmentation.py。此前实验使用 Python 3.11.16、iproute2 6.1.0、iputils ping 和 tcpdump 4.99.3,运行于 Linux 6.12.76;本轮未重跑网络实验,以下为保留的历史观察。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间。两台路由器和两个端点使用新建 veth,不修改宿主接口或路由:
...

TCP keepalive 询问的是:连接安静了一段时间后,对面的 TCP 端点是否仍然响应。它不询问 HTTP handler 是否正常、数据库查询是否推进、后台工作线程是否卡住。即使应用已经停顿,内核仍然可能确认收到的数据。因此,“传输对端能够回应”和“应用给出了有用结果”必须作为两个不同的观察项。
传输层探针不是应用心跳 保活有助于暴露远端连接状态已经消失,或者网络路径悄悄停止传递报文的情况。实现会在满足空闲条件后发送探针,并按照未响应探针的策略最终报告失败;但单个探针丢失不等于对端死亡,不能把网络中的临时丢包直接解释成确定性故障。
探针在应用字节流以下处理,不会变成 HTTP 请求、数据库查询或 WebSocket 心跳。如果连接终止在代理上,探测对象就是代理自身的 TCP 端点,不是代理后面的源站。某些中间设备的连接跟踪可能受探针影响,但不能由此承诺刷新应用的空闲计时,更不能保证所有 NAT 映射都会一直保留。
已经发出而尚未确认的数据还有自己的重传与失败规则,因此保活参数不是繁忙连接的通用总截止时间。正常 FIN、连接重置、应用读取超时和静默丢包是不同事件,恢复动作也未必相同。知道是哪一层报告了什么错误,比统一把它们写成“连接死了”更有用。
配置真正承载请求的 socket RFC 1122 对可选保活机制要求默认关闭,并规定默认空闲间隔不得短于两小时且应可配置。但应用和库可以显式启用并选择更短间隔,Go 的拨号器就有自己的设置。因此,查到操作系统默认值,并不能直接推出当前应用实际使用的策略;还需要沿着创建连接的代码检查有效配置。
Linux 的 tcp_keepalive_time、tcp_keepalive_intvl 和 tcp_keepalive_probes 分别表示探测前的空闲秒数、探针间隔和未响应探针数量,文档默认值为 7200、75、9。启用 SO_KEEPALIVE 后,还可以通过 TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 为单个 socket 定制策略。这些名称与接口不是 macOS、Windows 通用的系统参数,不能跨平台照搬命令。
...

别名在 Bash 读取命令时,把命令位置的一个词替换成一段 Shell 文本。alias ll='ls -l' 这种个人快捷方式很方便,但别名并不是一个可执行文件,也没有独立的位置参数作用域。非交互 Bash 默认关闭别名展开,脚本却可以明确启用它;“默认不展开”与“脚本中绝对不能使用”是两回事。理解这个区别,才能解释为什么终端里好用的简称到了自动化环境就找不到。
替换发生在读取输入时 命令位置上没有被引用的词,才可能被当成别名展开。定义 ll 后,ll 'two words' 仍会把带空格的文本作为一个参数传给命令,不能笼统地说别名必然破坏参数引用。真正的限制是:别名没有属于自己的 $1,也不能像函数那样拥有并重新排列一组独立的参数。调用者后面追加的参数,只是继续参与替换后的命令解析。
替换文本甚至可以包含管道或多条命令,因此别名在语法上并不局限于单一程序。代价是读者更难直接看出简称背后的行为。Bash 会继续检查替换文本的第一个词,但不会再次展开当前正在展开的同名别名,所以 alias ls='ls -F' 不会无限递归。还有一个容易忽略的规则:别名值末尾如果留有空白,紧随其后的命令词也会接受别名检查,不应把尾部空格当成无意义的排版。
定义本身的引号还决定另一层求值时机。单引号让美元符号保留到替换文本以后被解释;双引号却可能在定义别名时就展开变量。原本想跟随目录变量变化的快捷方式,于是可能固定住旧值。遇到这种差异时,应检查实际保存的别名定义,而不只是回看自己最初输入的那一行。
作用域与生效时机分开看 别名属于当前 Shell,不是可以导出给新 Bash 进程的环境变量。圆括号创建的子 Shell 会继承当前 Shell 状态,这与重新启动一个 Bash 程序不同。新进程读取启动文件后出现同名别名,表示配置又执行了一遍,并不证明别名通过环境自动传递。因此排查问题时,应确认命令究竟由当前交互 Shell、圆括号子 Shell,还是另一个程序启动。
shopt -s expand_aliases 能在非交互 Bash 中启用展开,但定义仍须在使用它的输入被读取之前生效。Bash 会先读完一整行或完整复合命令,再执行其中的内容。同一行先定义别名、后调用新名字,并不能赶在解析后半行之前建立定义;把定义塞进正在读取的函数体,也存在同样的时机问题。把定义单独放一行,比依赖分号后的即时生效可靠。
...

镜像的 ENTRYPOINT 和 CMD 共同参与容器创建时的启动命令。使用 exec 形式 ENTRYPOINT 时,CMD 通常提供追加给入口的默认参数;没有入口时,CMD 可以提供可执行程序及其参数。这些是启动默认值,不是强制执行边界。有权限创建容器的运行者,可以替换入口或参数,因此不能把 ENTRYPOINT 描述成无论如何都不可改变的程序锁。
坏掉的样子 在 docker run image ... 中写到镜像名之后的参数,会替换 CMD,不是自动加到原 CMD 默认值之后。exec 形式入口仍然保留,除非显式覆盖。用户以为自己只是追加一个选项,却可能丢掉全部默认参数。因此,命令行工具需要说明哪些值可省略、哪些必须重新提供,以及自身参数解析器如何处理覆盖。
exec 形式使用 JSON 数组,不会隐式运行 shell。$DEMO 这样的字符串会原样传给程序,而不是自动展开环境变量。管道、重定向和通配符也需要真正的解释器,才会获得相应含义。shell 形式会改变参数和进程语义,所以两种形式之间的切换并不只是语法美化,不能只看命令表面似乎相同。
启动包装脚本还可能让服务藏在不转发停止信号的 shell 后面。Docker 通常向容器主进程发送停止信号,等待配置的超时,必要时强制终止。即使服务自身正确安装了 SIGTERM 处理函数,如果信号只到达另一个不会转发的进程,这个处理函数也无法发挥作用。排查优雅退出失败时,应先确认信号实际发给了谁。
快速验证 先取得固定基础镜像,再用宿主 Python 和 Docker 构建器运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。实验创建独立临时容器并关闭网络,等待每个服务打印就绪信息后测试停止,结束时删除自己的容器和镜像标签,不依赖启动后盲目等待固定时长来猜测服务已经准备好:
...

Linux 可执行文件上的 setuid 位,可以在 exec 成功时把进程的有效用户 ID 改为文件属主的 ID。它不会把进程全部凭据都替换成属主的登录身份。特权辅助程序因此能够代表另一个账户完成受控操作,但输入处理和特权持续时间都会成为安全边界的一部分。模式位本身既不能证明身份转换已经发生,也不能证明这个辅助程序安全。
文件位与进程凭据分开观察 通常的 set-user-ID 转换会改变有效 UID,再将结果复制到保存的 set-user-ID;真实 UID 保持不变。组凭据是另一套状态:setuid 不会自动装入文件属主的附加组。Linux 还存在文件系统身份和能力集合,因此真实、有效与保存 UID 是有用的观察,却不是权限的完整描述。不能看到有效身份变成某个账户,就假设已经拥有该账户登录会话的全部上下文。
应检查实际部署的文件,不要推断整个发行版都采用同一权限。用于修改密码的特权程序,在容器或定制镜像里可能有不同安装方式。属主执行位位置的小写 s 表示同时具备 setuid 和属主执行位,大写 S 则表示存在 setuid 但没有那个执行位。两种显示都不能替代属主核验、目标文件核验,以及对真实启动上下文的检查。
执行环境可以抑制转换 Linux 文档列出了 no_new_privs、nosuid 挂载和被跟踪等条件,它们可能抑制特权转换;某些文档记录的组合也可能直接让执行失败。文件能力同样受到执行约束。不能为了让演示成功,就关闭这些保护。应确定目标路径与调用进程实际适用哪些政策,如实说明限制,而不是把受保护环境当作必须修复的故障。
Linux 忽略解释器脚本上的 set-user-ID 与 set-group-ID 位。因此,把原生辅助程序改成脚本并保留模式,不会保留原先的提权行为。给通用解释器增加特权也不是安全替代方案。特权程序另外启动的可执行文件,还可能主动重置凭据,所以运行独立的 id 命令,不一定忠实显示原辅助程序内部的身份。观测点必须与要验证的进程一致。
...