Unix 域套接字提供本地进程间通信,无须 IP 地址、TCP 端口或外部网络路由。它仍使用内核套接字设施、缓冲区、调度和应用协议。路径套接字具有文件系统名称,但 Unix 套接字也可以没有名称,Linux 还支持抽象名称。这些形式具有不同访问规则,因此“Unix 套接字就是一个受权限保护的文件”并不是完整解释。
坏掉的样子
应用找得到套接字路径,却可能因为监听已经退出而无法连接。关闭路径套接字,不会自动删除对应文件系统条目。重启时直接绑定相同路径,便可能得到 EADDRINUSE,即使那里已经没有服务接受连接。文件存在是有用诊断信息,但不能作为健康检查;要知道服务是否可用,仍需要真实通信与预期应用响应。
Linux 连接路径流套接字,需要对套接字拥有写权限,同时能够搜索沿途目录。套接字文件上的执行位,与目录遍历所需执行位并不是同一用途。宽松的套接字权限无法补偿不可访问的父目录。反过来,让敏感控制套接字对所有本地进程可写,也可能授予很强的应用能力,不能因为没有 TCP 端口就忽略其权限。
这些规则不会自动适用于所有名称形式与操作系统。Linux 抽象套接字不依赖文件路径,修改某个路径的权限无法保护抽象名称。可移植程序也不能假定每种 Unix 实现都同样执行套接字文件权限。对端凭据和应用授权,应按照实际平台与身份模型设计,不能只根据一个看起来像文件的名字推测安全属性。
快速验证
将脚本保存为 check-unix-socket.py,准备 Docker 后执行 python3 check-unix-socket.py。固定 Linux 环境使用 Python 3.11.16,服务器在一次性目录中建立路径流套接字。独立客户端进程能够降低到用户号和组号六万五千五百三十四;实验不会修改宿主账号、现有套接字或权限,所有改动随容器清理:
import json,subprocess,uuid
image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84"
program=r"""
from pathlib import Path
from tempfile import TemporaryDirectory
import errno,json,socket,socketserver,stat,struct,subprocess,sys,threading
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.request.settimeout(1);assert self.rfile.readline()==b'ping\n'
pid,uid,gid=struct.unpack('3i',self.request.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,struct.calcsize('3i')))
self.wfile.write(json.dumps({'uid':uid,'gid':gid,'pid_positive':pid>0}).encode()+b'\n')
client_code=r'''
import json,os,socket,sys
if sys.argv[2]=='drop':os.setgroups([]);os.setgid(65534);os.setuid(65534)
try:
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as s:
s.settimeout(1);s.connect(sys.argv[1]);s.sendall(b'ping\n');out=s.makefile('rb')
with out:print(out.readline().decode().strip())
except PermissionError:print(json.dumps({'error':'EACCES','uid':os.geteuid()}))
'''
def client(path,drop=False):
p=subprocess.run([sys.executable,'-c',client_code,str(path),'drop' if drop else 'keep'],capture_output=True,text=True,timeout=3)
assert p.returncode==0,p.stderr
return json.loads(p.stdout)
with TemporaryDirectory(prefix='unix-socket-') as directory:
root=Path(directory);root.chmod(0o755);path=root/'service.sock'
server=socketserver.UnixStreamServer(str(path),Handler)
path.chmod(0o600);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
try:
owner=client(path);denied=client(path,True)
assert owner['uid']==0 and denied=={'error':'EACCES','uid':65534}
path.chmod(0o666);allowed=client(path,True);assert allowed['uid']==65534 and allowed['gid']==65534
root.chmod(0o700);directory_denied=client(path,True);assert directory_denied==denied
root.chmod(0o755)
finally:server.shutdown();server.server_close();thread.join(timeout=2);assert not thread.is_alive()
assert stat.S_ISSOCK(path.stat().st_mode)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe:
try:probe.connect(str(path));raise AssertionError('stale socket accepted')
except ConnectionRefusedError:stale='ECONNREFUSED'
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate:
try:duplicate.bind(str(path));raise AssertionError('stale path reused')
except OSError as error:assert error.errno==errno.EADDRINUSE
path.unlink()
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as replacement,socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as probe:
replacement.bind(str(path));replacement.listen(1);replacement.settimeout(1);probe.settimeout(1);probe.connect(str(path))
peer,_=replacement.accept()
with peer,probe.makefile('rb') as incoming:
peer.sendall(b'restarted');assert incoming.read(9)==b'restarted'
print(json.dumps({'owner_peer':owner,'mode_0600_denied':denied,'mode_0666_peer':allowed,
'directory_0700_denied':directory_denied,'closed_path_remains_socket':True,'stale_connect':stale,
'stale_bind':'EADDRINUSE','restarted_after_owned_path_unlink':True}))
"""
name='xbcatm-socket-'+uuid.uuid4().hex[:10]
try:
result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none',
'--memory','128m','--pids-limit','64',image,'python3','-'],input=program,
capture_output=True,text=True,timeout=60)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True))
finally:
subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)
父目录允许搜索、套接字权限为零六零零时,root 客户端能够连接,并收到服务器读取的对端凭据;降权客户端得到 EACCES。服务器通过 SO_PEERCRED 取得已经连接的进程身份,没有相信请求自行声明的用户字符串。脚本核对连接边界实际报告的有效用户号,以真实结果验证身份读取,而不只是检查选项名称出现过。
临时把套接字改成零六六六,降权客户端便能连接,服务器报告其用户号与组号均为六万五千五百三十四。随后仅把父目录改成零七零零,同一客户端再次被拒绝,尽管套接字仍允许所有用户写入。宽松模式只用于一次性容器中隔离两种权限检查,不是高权限生产服务的建议配置。目录可达性和套接字写权限必须分别考虑。
关闭服务器后,路径仍具有套接字文件类型。向它连接得到 ECONNREFUSED,新套接字绑定这个名称则得到 EADDRINUSE。这些观察区分遗留条目与正在监听的服务。程序随后删除自己明确拥有的路径,重新绑定并建立真实连接,确认收到 restarted。恢复步骤既验证名字可以复用,也验证新监听实际提供了响应。
请求使用换行分隔,响应读取遵循相应边界。Unix 流套接字不承诺一次写入等于一次读取。本地传输排除了 IP 路由,却没有取消处理部分读取、截止时间或对端意外关闭的责任。其他 Unix 套接字类型可以具有消息边界,本例明确使用 SOCK_STREAM,不能把不同类型的语义混到同一段程序解释中。
把本地访问当作应用能力管理
按合法调用方选择目录归属和套接字组,再以真实组内、组外身份验证。能够访问管理套接字的进程,即使无法连接某个 TCP 端口,也可能执行重要操作。应审阅服务器提供的命令和授权行为,不能仅按是否存在 IP 监听来判断能力大小。用户号还需要按部署的身份范围解释,而不是直接等同于某个自然人。
路径套接字还要求客户端具有正确文件系统视图。容器看不到服务器路径时,不会因为共享宿主就自动发现服务;有意共享套接字路径,则可能把服务能力交给该容器。Linux 抽象名称具有另一种命名空间边界。文档应说明选择了哪种命名方式,以及调用方怎样取得访问范围,避免把所有本地通信端点视为可互换对象。
重启流程应明确路径归属。只有确认服务管理者拥有该名称,且不会移除另一个活跃实例的入口时,才清理遗留路径。如果性能是切换传输的理由,还应使用真实负载测量;本实验验证访问和生命周期语义,没有承诺统一延迟优势。把监听健康、调用方身份与协议结果一起观察,才能说明本地通信实际满足了什么要求。
来源
- Linux unix(7),路径权限、抽象名称、流语义与对端凭据,已核验 2026-09-12。
- Python 3.11 socket,AF_UNIX 流、套接字选项与描述符生命周期,已核验 2026-09-12。