feature: 用户授权、解除权限
This commit is contained in:
@@ -150,6 +150,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) |
|
| **java** | Java 应用:按 JDK 版本套用调优参数并运行 jar | [README.java.md](README.java.md) |
|
||||||
| **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) |
|
| **git** | Git 历史:按日期范围调整提交时间并强推 | [README.git.md](README.git.md) |
|
||||||
| **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) |
|
| **pkg** | 系统软件包:刷新包管理器索引 | [README.pkg.md](README.pkg.md) |
|
||||||
|
| **user** | 用户管理:授予或移除用户的 root/sudo 权限 | [README.user.md](README.user.md) |
|
||||||
|
|
||||||
辅助命令(不涉及具体资源,直接用即可):
|
辅助命令(不涉及具体资源,直接用即可):
|
||||||
|
|
||||||
@@ -177,6 +178,8 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` |
|
| 跑 Java 应用 | `kopsc java run app.jar`、`kopsc java run --jdk 17 --print-flags app.jar` |
|
||||||
| 调整提交时间 | `kopsc git retime 2026-10-01 2026-10-15`(加 `--yes` 才执行) |
|
| 调整提交时间 | `kopsc git retime 2026-10-01 2026-10-15`(加 `--yes` 才执行) |
|
||||||
| 刷新软件源 | `kopsc pkg refreshindex` |
|
| 刷新软件源 | `kopsc pkg refreshindex` |
|
||||||
|
| 授予用户 root 权限 | `kopsc user grant alice --yes` |
|
||||||
|
| 移除用户 root 权限 | `kopsc user revoke alice --yes` |
|
||||||
|
|
||||||
## 平台识别
|
## 平台识别
|
||||||
|
|
||||||
@@ -239,6 +242,7 @@ kopsc 的命令按用途分组,每组的完整说明(命令表、选项、
|
|||||||
| pkg | [`README.pkg.md`](README.pkg.md) |
|
| pkg | [`README.pkg.md`](README.pkg.md) |
|
||||||
| java | [`README.java.md`](README.java.md) |
|
| java | [`README.java.md`](README.java.md) |
|
||||||
| git | [`README.git.md`](README.git.md) |
|
| git | [`README.git.md`](README.git.md) |
|
||||||
|
| user | [`README.user.md`](README.user.md) |
|
||||||
|
|
||||||
新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。
|
新分组需要新建 `README.<分组>.md`,并在本文件「命令一览」的表格里加一行链接。
|
||||||
每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。
|
每条命令至少要写清:**作用**、**实际执行什么**、**示例**;有独有选项时补选项表。
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# 用户管理
|
||||||
|
|
||||||
|
跨平台的用户权限管理命令,简化 Linux 和 macOS 上繁琐的 root/sudo 权限操作。
|
||||||
|
|
||||||
|
## 命令一览
|
||||||
|
|
||||||
|
| 命令 | 用途 | 实际执行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `kopsc user grant <用户名>` | 授予用户 root/sudo 权限 | Linux: `usermod -aG wheel/sudo <用户名>`<br>macOS: `dseditgroup -o edit -t user -a <用户名> admin`<br>Windows: `net localgroup Administrators <用户名> /add` |
|
||||||
|
| `kopsc user revoke <用户名>` | 移除用户的 root/sudo 权限 | Linux: `gpasswd -d <用户名> wheel/sudo`<br>macOS: `dseditgroup -o edit -t user -d <用户名> admin`<br>Windows: `net localgroup Administrators <用户名> /delete` |
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
### 授予用户 root/sudo 权限
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 预览将执行的操作
|
||||||
|
kopsc user grant alice
|
||||||
|
|
||||||
|
# 确认执行(需要当前用户已有 sudo/管理员权限)
|
||||||
|
kopsc user grant alice --yes
|
||||||
|
|
||||||
|
# 简写
|
||||||
|
kopsc user grant alice -y
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行后效果**:
|
||||||
|
- **Linux**:用户 `alice` 被添加到 `wheel` 组(CentOS/RHEL)或 `sudo` 组(Debian/Ubuntu),可以使用 `sudo` 执行管理员命令
|
||||||
|
- **macOS**:用户 `alice` 被添加到 `admin` 组,获得管理员权限
|
||||||
|
- **Windows**:用户 `alice` 被添加到 `Administrators` 组,获得管理员权限
|
||||||
|
|
||||||
|
### 移除用户的 root/sudo 权限
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 预览将执行的操作
|
||||||
|
kopsc user revoke alice
|
||||||
|
|
||||||
|
# 确认执行
|
||||||
|
kopsc user revoke alice --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行后效果**:
|
||||||
|
- **Linux**:用户 `alice` 从 `wheel` 或 `sudo` 组移除,失去 sudo 权限
|
||||||
|
- **macOS**:用户 `alice` 从 `admin` 组移除,失去管理员权限
|
||||||
|
- **Windows**:用户 `alice` 从 `Administrators` 组移除,失去管理员权限
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **需要管理员权限**:执行这些命令的当前用户必须已有 sudo/管理员权限
|
||||||
|
2. **重新登录生效**:权限变更需要用户重新登录后才能生效
|
||||||
|
3. **破坏性命令**:这两个命令都需要 `--yes` 确认,避免误操作
|
||||||
|
4. **自动检测发行版**:Linux 上会自动检测应该使用 `wheel` 组还是 `sudo` 组
|
||||||
|
|
||||||
|
## 平台差异
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
不同发行版使用不同的管理员组:
|
||||||
|
- **CentOS/RHEL/Rocky/AlmaLinux**:使用 `wheel` 组
|
||||||
|
- **Debian/Ubuntu/Mint**:使用 `sudo` 组
|
||||||
|
|
||||||
|
kopsc 会自动检测系统中存在的组,优先使用 `wheel`,其次是 `sudo`。
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
macOS 使用 `admin` 组管理管理员权限,通过 `dseditgroup` 命令操作。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Windows 使用 `Administrators` 本地组,通过 `net localgroup` 命令操作。
|
||||||
|
|
||||||
|
## 示例场景
|
||||||
|
|
||||||
|
### 场景 1:新入职的运维工程师需要 sudo 权限
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建用户(系统命令)
|
||||||
|
sudo useradd -m -s /bin/bash ops01
|
||||||
|
|
||||||
|
# 授予 sudo 权限(kopsc 命令)
|
||||||
|
kopsc user grant ops01 --yes
|
||||||
|
|
||||||
|
# 通知用户重新登录
|
||||||
|
echo "请重新登录以启用 sudo 权限"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2:离职员工需要移除管理员权限
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 移除管理员权限
|
||||||
|
kopsc user revoke former_employee --yes
|
||||||
|
|
||||||
|
# 可选:禁用账户(系统命令)
|
||||||
|
sudo usermod -L former_employee
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3:批量管理用户权限
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 批量授权
|
||||||
|
for user in alice bob charlie; do
|
||||||
|
kopsc user grant $user --yes
|
||||||
|
done
|
||||||
|
|
||||||
|
# 批量撤销
|
||||||
|
for user in alice bob charlie; do
|
||||||
|
kopsc user revoke $user --yes
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 问题:提示"未找到 wheel 或 sudo 组"
|
||||||
|
|
||||||
|
**原因**:系统使用了非标准的管理员组名称
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 查看系统的管理员组:`cat /etc/group | grep -E '^(wheel|sudo|admin)'`
|
||||||
|
2. 手动执行命令:`sudo usermod -aG <组名> <用户名>`
|
||||||
|
|
||||||
|
### 问题:执行后用户仍无法使用 sudo
|
||||||
|
|
||||||
|
**原因**:权限变更需要重新登录才能生效
|
||||||
|
|
||||||
|
**解决**:让用户退出并重新登录,或执行 `su - <用户名>` 切换到该用户
|
||||||
|
|
||||||
|
### 问题:提示权限不足
|
||||||
|
|
||||||
|
**原因**:当前用户没有 sudo/管理员权限
|
||||||
|
|
||||||
|
**解决**:使用已有管理员权限的账户执行,或临时使用 `sudo` 前缀
|
||||||
@@ -223,6 +223,7 @@ var groupDescriptions = map[string]string{
|
|||||||
"proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出",
|
"proc": "本机进程:在后台运行命令并写日志,关掉终端也不退出",
|
||||||
"java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar",
|
"java": "Java 应用:按 JDK 版本自动套用调优参数并前台运行 jar",
|
||||||
"git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推",
|
"git": "Git 历史:把指定日期范围的提交时间改到 21:00-23:00 并强推",
|
||||||
|
"user": "用户管理:授予或移除用户的 root/sudo 权限",
|
||||||
}
|
}
|
||||||
|
|
||||||
// newGroup builds an intermediate command such as `kopsc disk`.
|
// newGroup builds an intermediate command such as `kopsc disk`.
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ var builtins = map[string]Builtin{
|
|||||||
"net.ports": runNetPorts,
|
"net.ports": runNetPorts,
|
||||||
"proc.nohup": runNohup,
|
"proc.nohup": runNohup,
|
||||||
"proc.wait": runProcWait,
|
"proc.wait": runProcWait,
|
||||||
|
"user.grant": runUserGrant,
|
||||||
|
"user.revoke": runUserRevoke,
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDiagnose bridges the registry builtin to the diagnose package.
|
// runDiagnose bridges the registry builtin to the diagnose package.
|
||||||
|
|||||||
@@ -25,10 +25,15 @@ func TestForcePushUsesLease(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForcePushExplainsRejectedLease(t *testing.T) {
|
// lfsAdvisory is the git-lfs note that used to be mistaken for the cause.
|
||||||
|
const lfsAdvisory = `Locking support detected on remote "origin". Consider enabling it with:
|
||||||
|
$ git config lfs.https://gitea.example.com/a/b.git/info/lfs.locksverify true
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestForcePushExplainsLeaseRejection(t *testing.T) {
|
||||||
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||||
"push --force-with-lease origin main": {
|
"push --force-with-lease origin main": {
|
||||||
errOut: " ! [rejected] main -> main (stale info)",
|
errOut: lfsAdvisory + " ! [rejected] main -> main (stale info)\n",
|
||||||
err: errors.New("exit status 1"),
|
err: errors.New("exit status 1"),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -36,11 +41,64 @@ func TestForcePushExplainsRejectedLease(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected an error")
|
t.Fatal("expected an error")
|
||||||
}
|
}
|
||||||
for _, want := range []string{"强推被拒绝", "git fetch", "回滚"} {
|
msg := err.Error()
|
||||||
if !strings.Contains(err.Error(), want) {
|
for _, want := range []string{"租约校验失败", "--force-with-lease=main:", "回滚"} {
|
||||||
t.Errorf("error missing %q: %v", want, err)
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("message missing %q:\n%s", want, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The git-lfs advice is noise here and must not lead the message.
|
||||||
|
if strings.Contains(msg, "Locking support detected") {
|
||||||
|
t.Errorf("LFS advisory leaked into the lease message:\n%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForcePushExplainsLFSMissing(t *testing.T) {
|
||||||
|
stderr := lfsAdvisory +
|
||||||
|
"Git LFS upload failed: 0% (0/1)\n" +
|
||||||
|
" (missing) doc/ffmpeg.zip (6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018)\n" +
|
||||||
|
"hint: Your push was rejected due to missing or corrupt local objects.\n" +
|
||||||
|
"hint: You can disable this check with: `git config lfs.allowincompletepush true`\n"
|
||||||
|
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||||
|
"push --force-with-lease origin main": {errOut: stderr, err: errors.New("exit status 1")},
|
||||||
|
}}
|
||||||
|
err := forcePush(context.Background(), runner, "origin", "main")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
for _, want := range []string{
|
||||||
|
"LFS 完整性检查",
|
||||||
|
"doc/ffmpeg.zip",
|
||||||
|
"lfs.allowincompletepush=true",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The lease wording would mislead: LFS, not the remote, blocked this push.
|
||||||
|
if strings.Contains(msg, "租约") {
|
||||||
|
t.Errorf("LFS failure was reported as a lease problem:\n%s", msg)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "Locking support detected") {
|
||||||
|
t.Errorf("LFS advisory leaked into the message:\n%s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForcePushExplainsProtectedBranch(t *testing.T) {
|
||||||
|
runner := &fakeGitRunner{responses: map[string]gitResponse{
|
||||||
|
"push --force-with-lease origin main": {
|
||||||
|
errOut: "remote: error: GH006: protected branch update failed\nremote: pre-receive hook declined\n",
|
||||||
|
err: errors.New("exit status 1"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
err := forcePush(context.Background(), runner, "origin", "main")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "分支保护") {
|
||||||
|
t.Fatalf("err = %v, want a branch-protection message", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "租约") {
|
||||||
|
t.Errorf("protected branch was reported as a lease problem: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsRejectedPush(t *testing.T) {
|
func TestIsRejectedPush(t *testing.T) {
|
||||||
@@ -57,6 +115,10 @@ func TestIsRejectedPush(t *testing.T) {
|
|||||||
if isRejectedPush("fatal: unable to access remote") {
|
if isRejectedPush("fatal: unable to access remote") {
|
||||||
t.Error("network errors are not lease rejections")
|
t.Error("network errors are not lease rejections")
|
||||||
}
|
}
|
||||||
|
// An LFS failure is not a remote-side rejection.
|
||||||
|
if isRejectedPush("Git LFS upload failed:\n (missing) a.zip (abc)") {
|
||||||
|
t.Error("LFS failures must not be classified as lease rejections")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForcePushReportsNetworkError(t *testing.T) {
|
func TestForcePushReportsNetworkError(t *testing.T) {
|
||||||
@@ -70,3 +132,13 @@ func TestForcePushReportsNetworkError(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want a git push failure", err)
|
t.Fatalf("err = %v, want a git push failure", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstMissingObject(t *testing.T) {
|
||||||
|
stderr := "Git LFS upload failed:\n (missing) doc/a.zip (abc123)\n (missing) upx.exe (def456)"
|
||||||
|
if got := firstMissingObject(stderr); got != "doc/a.zip" {
|
||||||
|
t.Errorf("firstMissingObject = %q", got)
|
||||||
|
}
|
||||||
|
if got := firstMissingObject("no entries"); got != "未知对象" {
|
||||||
|
t.Errorf("firstMissingObject = %q, want a placeholder", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,27 +240,105 @@ func backupHint(branch, ref string) string {
|
|||||||
//
|
//
|
||||||
// --force-with-lease is deliberate: it refuses to overwrite commits the local
|
// --force-with-lease is deliberate: it refuses to overwrite commits the local
|
||||||
// repository has not seen, so a colleague's push is never silently discarded.
|
// repository has not seen, so a colleague's push is never silently discarded.
|
||||||
|
//
|
||||||
|
// git writes a lot to stderr on a failed push, and only some of it explains the
|
||||||
|
// failure: git-lfs prints advisory notes ("Locking support detected…") that used
|
||||||
|
// to be shown as if they were the cause. The error below classifies what
|
||||||
|
// actually went wrong and tells the user what to run next.
|
||||||
func forcePush(ctx context.Context, runner gitRunner, remote, branch string) error {
|
func forcePush(ctx context.Context, runner gitRunner, remote, branch string) error {
|
||||||
out, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch)
|
_, stderr, err := runner.Run(ctx, "push", "--force-with-lease", remote, branch)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
if isRejectedPush(stderr) {
|
|
||||||
return fmt.Errorf("远程分支 %s/%s 已有本地未知的新提交,强推被拒绝;"+
|
|
||||||
"请先 git fetch 并确认后再试(本地历史已改写,可用备份引用回滚): %s",
|
|
||||||
remote, branch, strings.TrimSpace(stderr))
|
|
||||||
}
|
|
||||||
return gitError("git push", stderr, err)
|
|
||||||
}
|
|
||||||
_ = out
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return classifyPushError(remote, branch, stderr, err)
|
||||||
|
}
|
||||||
|
|
||||||
// isRejectedPush reports whether git refused the push because the remote moved
|
// classifyPushError turns git's stderr into one actionable message.
|
||||||
// (force-with-lease stale info, or a protected branch).
|
func classifyPushError(remote, branch, stderr string, err error) error {
|
||||||
|
detail := pushDetail(stderr)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case isLFSIncomplete(stderr):
|
||||||
|
return fmt.Errorf("强推被 LFS 完整性检查拦下:本地缺少远程要接收的 LFS 对象(%s)。\n"+
|
||||||
|
"如果当前提交用到的对象都已存在,可以跳过检查:\n"+
|
||||||
|
" git -c lfs.allowincompletepush=true push --force-with-lease %s %s",
|
||||||
|
firstMissingObject(stderr), remote, branch)
|
||||||
|
|
||||||
|
case isLeaseRejected(stderr):
|
||||||
|
return fmt.Errorf("强推被拒绝:远程分支 %s/%s 有本地未知的新提交(租约校验失败)。\n"+
|
||||||
|
"确认远端确实该被覆盖后,显式指定远端当前 SHA 再推:\n"+
|
||||||
|
" git push --force-with-lease=%s:<远端SHA> %s %s\n"+
|
||||||
|
"(本地历史已改写,如需撤销可用备份引用回滚)\n%s",
|
||||||
|
remote, branch, branch, remote, branch, detail)
|
||||||
|
|
||||||
|
case isProtected(stderr):
|
||||||
|
return fmt.Errorf("强推被拒绝:%s/%s 受分支保护,服务器不允许改写历史。\n"+
|
||||||
|
"请先在仓库设置里临时放开保护,或改推到另一个分支后再合并。\n%s",
|
||||||
|
remote, branch, detail)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return gitError("git push", stderr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushDetail keeps git's own output for the message, dropping the advisory
|
||||||
|
// lines that only repeat what kopsc already says.
|
||||||
|
func pushDetail(stderr string) string {
|
||||||
|
var kept []string
|
||||||
|
for _, line := range strings.Split(stderr, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || isLFSAdvisory(trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, trimmed)
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLFSAdvisory reports whether a line is git-lfs advice rather than a failure.
|
||||||
|
func isLFSAdvisory(line string) bool {
|
||||||
|
return strings.HasPrefix(line, "Locking support detected") ||
|
||||||
|
strings.HasPrefix(line, "$ git config lfs.") ||
|
||||||
|
strings.HasPrefix(line, "hint: You can disable this check")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLeaseRejected reports whether git refused because the remote moved.
|
||||||
|
func isLeaseRejected(stderr string) bool {
|
||||||
|
return strings.Contains(stderr, "stale info") ||
|
||||||
|
strings.Contains(stderr, "[rejected]") ||
|
||||||
|
(strings.Contains(stderr, "rejected") && !isLFSIncomplete(stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// isProtected reports a branch-protection refusal.
|
||||||
|
func isProtected(stderr string) bool {
|
||||||
|
return strings.Contains(stderr, "protected branch") ||
|
||||||
|
strings.Contains(stderr, "pre-receive hook declined") ||
|
||||||
|
strings.Contains(stderr, "hook declined")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLFSIncomplete reports that the push was stopped by git-lfs upload checks.
|
||||||
|
func isLFSIncomplete(stderr string) bool {
|
||||||
|
return strings.Contains(stderr, "LFS upload failed") ||
|
||||||
|
strings.Contains(stderr, "LFS upload missing objects") ||
|
||||||
|
strings.Contains(stderr, "(missing)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRejectedPush reports whether git refused the push for a remote-side reason,
|
||||||
|
// as opposed to a local failure such as LFS objects.
|
||||||
func isRejectedPush(stderr string) bool {
|
func isRejectedPush(stderr string) bool {
|
||||||
for _, marker := range []string{"stale info", "rejected", "protected branch", "hook declined"} {
|
return isLeaseRejected(stderr) || isProtected(stderr)
|
||||||
if strings.Contains(stderr, marker) {
|
}
|
||||||
return true
|
|
||||||
|
// firstMissingObject extracts the first "(missing) <path> (<oid>)" entry.
|
||||||
|
func firstMissingObject(stderr string) string {
|
||||||
|
for _, line := range strings.Split(stderr, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "(missing)") {
|
||||||
|
fields := strings.Fields(strings.TrimPrefix(line, "(missing)"))
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return fields[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
}
|
||||||
|
return "未知对象"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runUserGrant implements `kopsc user grant <username>`: add a user to the
|
||||||
|
// sudo/admin group to grant root privileges.
|
||||||
|
//
|
||||||
|
// Linux: adds to 'wheel' (CentOS/RHEL) or 'sudo' (Debian/Ubuntu)
|
||||||
|
// macOS: adds to 'admin' group
|
||||||
|
// Windows: adds to 'Administrators' group
|
||||||
|
//
|
||||||
|
// This is a destructive operation requiring sudo/admin privileges.
|
||||||
|
func runUserGrant(ctx context.Context, req BuiltinRequest) error {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return fmt.Errorf("缺少用户名;用法: kopsc user grant <用户名>")
|
||||||
|
}
|
||||||
|
username := req.Args[0]
|
||||||
|
|
||||||
|
if err := checkUserExists(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return grantLinux(ctx, username, req)
|
||||||
|
case "darwin":
|
||||||
|
return grantDarwin(ctx, username, req)
|
||||||
|
case "windows":
|
||||||
|
return grantWindows(ctx, username, req)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUserRevoke implements `kopsc user revoke <username>`: remove a user from
|
||||||
|
// the sudo/admin group to revoke root privileges.
|
||||||
|
func runUserRevoke(ctx context.Context, req BuiltinRequest) error {
|
||||||
|
if len(req.Args) == 0 {
|
||||||
|
return fmt.Errorf("缺少用户名;用法: kopsc user revoke <用户名>")
|
||||||
|
}
|
||||||
|
username := req.Args[0]
|
||||||
|
|
||||||
|
if err := checkUserExists(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
return revokeLinux(ctx, username, req)
|
||||||
|
case "darwin":
|
||||||
|
return revokeDarwin(ctx, username, req)
|
||||||
|
case "windows":
|
||||||
|
return revokeWindows(ctx, username, req)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantLinux adds user to wheel (CentOS/RHEL) or sudo (Debian/Ubuntu) group.
|
||||||
|
func grantLinux(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
// Detect distro by checking which group exists
|
||||||
|
if groupExists("wheel") {
|
||||||
|
return runCommand(ctx, "usermod", []string{"-aG", "wheel", username}, req)
|
||||||
|
}
|
||||||
|
if groupExists("sudo") {
|
||||||
|
return runCommand(ctx, "usermod", []string{"-aG", "sudo", username}, req)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeLinux removes user from wheel or sudo group.
|
||||||
|
func revokeLinux(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
if groupExists("wheel") {
|
||||||
|
return runCommand(ctx, "gpasswd", []string{"-d", username, "wheel"}, req)
|
||||||
|
}
|
||||||
|
if groupExists("sudo") {
|
||||||
|
return runCommand(ctx, "gpasswd", []string{"-d", username, "sudo"}, req)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("未找到 wheel 或 sudo 组,请确认系统配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantDarwin adds user to admin group on macOS.
|
||||||
|
func grantDarwin(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-a", username, "admin"}, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeDarwin removes user from admin group on macOS.
|
||||||
|
func revokeDarwin(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
return runCommand(ctx, "dseditgroup", []string{"-o", "edit", "-t", "user", "-d", username, "admin"}, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantWindows adds user to Administrators group.
|
||||||
|
func grantWindows(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/add"}, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeWindows removes user from Administrators group.
|
||||||
|
func revokeWindows(ctx context.Context, username string, req BuiltinRequest) error {
|
||||||
|
return runCommand(ctx, "net", []string{"localgroup", "Administrators", username, "/delete"}, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand executes a command and streams output to the request's stdout/stderr.
|
||||||
|
func runCommand(ctx context.Context, name string, args []string, req BuiltinRequest) error {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdout = req.Stdout
|
||||||
|
cmd.Stderr = req.Stderr
|
||||||
|
cmd.Stdin = req.Stdin
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("执行失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupExists checks if a Unix group exists on the system.
|
||||||
|
func groupExists(name string) bool {
|
||||||
|
out, err := exec.Command("getent", "group", name).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUserExists verifies that the given username exists on the system.
|
||||||
|
// Returns a user-friendly error if the user does not exist.
|
||||||
|
func checkUserExists(username string) error {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux", "darwin":
|
||||||
|
// id command works on both Linux and macOS
|
||||||
|
err := exec.Command("id", username).Run()
|
||||||
|
exists = (err == nil)
|
||||||
|
case "windows":
|
||||||
|
// net user command on Windows
|
||||||
|
err := exec.Command("net", "user", username).Run()
|
||||||
|
exists = (err == nil)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的操作系统: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("用户 %q 不存在", username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserGrantMissingArgs(t *testing.T) {
|
||||||
|
req := BuiltinRequest{
|
||||||
|
Args: []string{},
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}
|
||||||
|
err := runUserGrant(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "缺少用户名") {
|
||||||
|
t.Errorf("expected missing username error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRevokeMissingArgs(t *testing.T) {
|
||||||
|
req := BuiltinRequest{
|
||||||
|
Args: []string{},
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}
|
||||||
|
err := runUserRevoke(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "缺少用户名") {
|
||||||
|
t.Errorf("expected missing username error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserGrantUnsupportedOS(t *testing.T) {
|
||||||
|
// This test only runs on unsupported platforms (none currently)
|
||||||
|
// We test the error path by mocking runtime.GOOS which is not possible
|
||||||
|
// in Go, so we just verify the function doesn't panic with valid input
|
||||||
|
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
|
||||||
|
req := BuiltinRequest{
|
||||||
|
Args: []string{"testuser"},
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
}
|
||||||
|
err := runUserGrant(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "不支持的操作系统") {
|
||||||
|
t.Errorf("expected unsupported OS error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupExists(t *testing.T) {
|
||||||
|
// Test with a group that should exist on most Unix systems
|
||||||
|
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||||
|
// 'staff' or 'wheel' should exist on macOS, 'root' or 'wheel' on Linux
|
||||||
|
exists := groupExists("root") || groupExists("wheel") || groupExists("staff")
|
||||||
|
if !exists {
|
||||||
|
t.Log("warning: none of root/wheel/staff groups found, this is unusual but not fatal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -551,6 +551,64 @@ func Builtin() *Registry {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "user.grant",
|
||||||
|
Group: "user",
|
||||||
|
Summary: "授予用户 root/sudo 权限",
|
||||||
|
Action: "将指定用户添加到系统的管理员组,使其获得 root/sudo 权限。" +
|
||||||
|
"Linux: 添加到 wheel(CentOS/RHEL)或 sudo(Debian/Ubuntu)组;" +
|
||||||
|
"macOS: 添加到 admin 组;Windows: 添加到 Administrators 组。" +
|
||||||
|
"需要当前用户已有 sudo/管理员权限;执行后用户需重新登录才能生效",
|
||||||
|
Usage: "<用户名>",
|
||||||
|
MinArgs: 1,
|
||||||
|
RequiresYes: true,
|
||||||
|
Variants: []Variant{
|
||||||
|
{
|
||||||
|
Match: Match{OS: "linux"},
|
||||||
|
Builtin: "user.grant",
|
||||||
|
Note: "Linux 内置实现(usermod -aG wheel/sudo)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "darwin"},
|
||||||
|
Builtin: "user.grant",
|
||||||
|
Note: "macOS 内置实现(dseditgroup -a admin)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "windows"},
|
||||||
|
Builtin: "user.grant",
|
||||||
|
Note: "Windows 内置实现(net localgroup Administrators /add)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "user.revoke",
|
||||||
|
Group: "user",
|
||||||
|
Summary: "移除用户的 root/sudo 权限",
|
||||||
|
Action: "将指定用户从系统的管理员组移除,取消其 root/sudo 权限。" +
|
||||||
|
"Linux: 从 wheel(CentOS/RHEL)或 sudo(Debian/Ubuntu)组移除;" +
|
||||||
|
"macOS: 从 admin 组移除;Windows: 从 Administrators 组移除。" +
|
||||||
|
"需要当前用户已有 sudo/管理员权限;执行后用户需重新登录才能生效",
|
||||||
|
Usage: "<用户名>",
|
||||||
|
MinArgs: 1,
|
||||||
|
RequiresYes: true,
|
||||||
|
Variants: []Variant{
|
||||||
|
{
|
||||||
|
Match: Match{OS: "linux"},
|
||||||
|
Builtin: "user.revoke",
|
||||||
|
Note: "Linux 内置实现(gpasswd -d wheel/sudo)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "darwin"},
|
||||||
|
Builtin: "user.revoke",
|
||||||
|
Note: "macOS 内置实现(dseditgroup -d admin)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "windows"},
|
||||||
|
Builtin: "user.revoke",
|
||||||
|
Note: "Windows 内置实现(net localgroup Administrators /delete)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestBuiltinIDsStable(t *testing.T) {
|
|||||||
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
|
||||||
"diagnose",
|
"diagnose",
|
||||||
"disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "pkg.refreshindex",
|
"disk.usage", "net.pids", "net.ports", "proc.nohup", "proc.wait", "java.run", "git.retime", "pkg.refreshindex",
|
||||||
|
"user.grant", "user.revoke",
|
||||||
}
|
}
|
||||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
|
||||||
|
|||||||
Reference in New Issue
Block a user