Keyi-Sudo
大家好,我是安国立,网名 qaqland,平时主要活跃在 deepin 和 Alpine Linux 开源社区, 日常关注的方向是 Wayland、Git 等。
keyi-sudo 是一个我在 2025 年开始准备的项目,目的是提供轻量级 sudo 替代品,
满足日常使用的提权需求,同时保持简单易用性。
这次分享我最想讲的,不只是“我写了一个什么工具”,而是: 如何从 0 开始规划一个小项目,并把它推进到相对完整的生命周期。
问题与目标
良好的开端是成功的一半,在动笔之前,应该先明确当前问题和预期目标。
- 方向 & 竞品:要做什么、和谁不一样、项目的定位和边界在哪里、什么功能坚决不做
- 受众 & 需求:先确定目标用户范围,再由假想的用户确定需求的优先级,什么功能必须要有
- 收益 & 维护:能力越小责任越小,少做功能认真优化,维护压力小更不会弃坑
具体到 keyi-sudo 上,有以下几个方面的考虑:
Sudo 冗余 & OpenDoas 不足
Sudo 很强大但是也相对庞大,源码超过 500 个文件,安装体积不低于 1MB。 很多功能明显超出个人场景的实际需求:
- 企业级 I/O 审计与会话回放
- LDAP/SSSD 等目录服务集成
- 多插件链路扩展(主程序搭配一串插件)
OpenDoas 是 OpenBSD 的 doas 组件在 Linux 系统的移植版本,
虽然轻量但是功能欠缺,特别是少了 sudoedit 这样的借权编辑功能。
借权编辑文件的核心思路其实很朴素:
- 先用高权限把目标文件复制到临时目录
- 再用普通用户身份调用编辑器修改临时文件
- 最后回到高权限把内容覆盖写回原文件
对普通用户的日常编辑体验而言,借权编辑的直接收益是:编辑器进程始终运行在用户身份下, 用户自己的编辑器配置和家目录下的插件都能正常使用,不会因为提权到 root 而丢失。
我就是懒 & 反对无效密码
首先明确一个前提,ssh、login 解决的是“能不能进入系统”的问题; sudo、doas 解决的是“进入系统后,能否有更高权限”的问题。 这两层可能用的是同一套密码认证,但是职责不同,不能混为一谈。
我反对的不是密码本身,而是进入会话之后那种重复、机械性的密码输入。 对单用户的个人设备来说,这一步更多是在打断操作,而不是提升安全性。
一旦当前会话已经失守,再多输入一次提权密码,也通常保护不了系统和数据。 所以我更倾向于直接放开会话内提权,把复杂度留给前面的身份进入环节。
sudo 全量免密配置示例:
# /etc/sudoers
qaqland ALL=(ALL:ALL) NOPASSWD: ALL
doas 全量免密配置示例:
# /etc/doas.conf
permit nopass qaqland as root
不服就干,学习的好机会
认识世界是为了改造世界。践行黑客精神。
设计与实现
keyi 支持三种运行模式:命令执行、借权编辑、交互模式。
第一种是最基础、也是最常用的模式:在普通命令前面加 keyi,就可以直接以 root 身份执行:
$ keyi id -u
0
如果命令需要额外环境变量,也可以直接写在命令前面传进去:
$ keyi FOO=BAR printenv FOO
BAR
第二种是借权编辑,对应前面提到的 sudoedit 需求:改系统文件,但编辑器仍然跑在用户身份下:
$ keyi -e /etc/apt/sources.list
第三种是交互模式,适合连续执行多条管理命令,不用每次都重新起一条提权命令:
$ keyi -i
提权 & 鉴权
在 Linux 进程模型里,每个进程至少有两组身份:
ruid(Real User ID):启动进程的真实用户身份euid(Effective User ID):内核做权限判断时的实际参考身份
大部分程序两者相等,而 SUID 程序可以让 euid 在启动时变成文件属主,从而有提权能力。
SUID(Set User ID)是文件权限位的一种(例如 chmod u+s /path/to/bin),例如:
$ ls -l /usr/bin/sudo
-rwsr-xr-x 3 root root 306456 12月28日 16:19 /usr/bin/sudo
^ SUID 位
下面是一个最小示例,演示使用 euid 提权的流程:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
uid_t ruid = getuid();
uid_t euid = geteuid();
printf("before: ruid=%4d, euid=%4d\n", ruid, euid);
// 常见于 SUID 程序: ruid=user, euid=root
if (ruid != euid) {
if (setuid(euid) != 0) {
perror("setuid");
return 1;
}
printf(" after: ruid=%4d, euid=%4d\n", getuid(), geteuid());
}
execlp("id", "id", "-u", NULL);
}
鉴权是为了在提权之前确认用户身份。“有”鉴权很关键,不然就是后门。
keyi-sudo 基于 0 配置原则,直接复用内核已经提供的文件权限判断。
方法很简单,把 keyi 可执行文件的属主设为 root,
属组设为信任组(比如 sudo 组),再配合 SUID 和执行权限位进行控制:
- 调用用户必须属于指定的信任组
- 对 group 保留
x权限 - 对 other 不给
x权限
这样一来,访问控制发生在程序启动之前。只有在组用户才能把程序运行起来, 这样复用了内核已有的权限检查机制,减少了额外策略的代码实现。
$ ls -l /usr/local/bin/keyi
-rwsr-x--- 1 root anguoli 22856 3月11日 10:45 /usr/local/bin/keyi
^ 其他用户没有执行权限
提权命令流程
-
解析参数/模式
- 用户输入类似:
keyi FOO=BAR id - 先把前面的
NAME=VALUE环境变量识别出来,再识别模式(默认是执行命令)。
- 用户输入类似:
-
读取当前身份信息
- 读取 ruid/euid(谁在调用、程序当前以谁运行)。
- 读取调用者用户名(用于日志)。
-
做安全前置检查
- 检查程序是否真的在预期的提权状态(euid 应该是 root)。
- 检查可执行文件权限位是否符合要求(避免权限扩散)。
-
重建安全环境
- 清空当前环境变量(
clearenv)。 - 只设置必要变量:
USER/LOGNAME/HOME/PATH/LANG(以及可选TERM)。 - 目的:减少环境注入风险。
- 清空当前环境变量(
-
应用用户传入的环境变量
- 把命令行里
NAME=VALUE写回新环境(仅用户显式传入的)。
- 把命令行里
-
切到高权限身份
- 调用
initgroups/setgid/setuid切换到目标高权限身份。 - 到这一步,后续命令就是以高权限运行。
- 调用
-
写审计日志
- 用
syslog(LOG_AUTH)记录:谁、在什么目录、以谁的身份、执行了什么命令。
- 用
-
执行目标命令
execvp()启动真正命令(如id、cat等)。- 成功则当前进程被新命令替换;失败则报错退出。
借权编辑文件
相比 sudo,keyi-sudo 的借权编辑实现更简单,而且做了很多取舍:
目标文件必须已经存在,不做“新建文件”等语义
keyi -e 只能修改已有系统文件,不负责创建新文件,实现中直接以读写方式打开目标文件。
int src = open(path, O_RDWR | O_NOFOLLOW | O_CLOEXEC);
失败大部分情况下有以下几种情况,每种情况都不好处理:
- ENOENT 文件不存在:用户输入正确吗?是否应该创建新文件?如果创建又失败了,用户该怎么办?
- EISDIR 目标路径是个目录:应该编辑目录下的所有文件吗?
- EACCES 或其他打开失败:原因可能并不单一,例如权限策略拒绝、只读文件系统、挂载选项限制等。
为这些失败场景扩展交互逻辑会明显抬高复杂度。所以 keyi-sudo 选择直接拒绝并报错,
要求用户先确认目标路径满足前提条件,例如文件已经存在、路径不是目录;
其他不满足前提的情况也交由调用者先处理。
这样做的好处是边界清晰透明,行为可预期,代码更好写也更容易做收敛。
仅支持单文件编辑,不支持一次编辑多个文件
这也是一个刻意的取舍:多文件编辑很难给出“原子提交”语义。 例如同时编辑 3 个文件,如果第 2 个写回失败,就会出现一个现实问题: 前面成功写回的文件要不要回滚?如何保证回滚一定成功?
在缺少完整事务机制(临时快照、回滚日志、失败恢复策略)的前提下, 多文件编辑“看起来方便”,但会把一致性和复杂度大幅拉高。 所以 keyi-sudo 当前版本只支持单文件借权编辑:
- 成功路径更短,异常分支更少。
- 用户对结果的预期更明确:一次只处理一个文件,失败时也只需要关注这一个文件的状态。
- 维护成本更低,不需要在工具层面模拟半套事务系统。
代码之外
当 keyi-sudo 代码基本完成后,接下来还要准备很多工作:
- README.md、SKILLS.md、CONTRIBUTING.md 文档等等
- shell-completion 终端补全
- man 手册页
- 单元测试、集成测试
- CI/CD 与质量检查
- 相关构建流程及发行版打包
- 向大家介绍(就像此刻这样)
当然少不了维护和迭代,各种改代码修 bug、适配更多发行版等等。
到这里差不多就完成了一个项目的起步,希望大家乐在其中、玩得开心。
// EOF