Skip to content

Commit 6dd7c0b

Browse files
committed
feat(systemd-service-manager): 添加非root用户自动提权功能
- `install` / `start` / `stop` / `restart` / `enable` / `disable` 等system scope写操作在非root下会自动通过`sudo`重新执行脚本本身, 因此不要求手动在命令前添加`sudo` - 新增ssm_current_euid、ssm_is_root、ssm_resolve_executable_path等 辅助函数处理权限判断和路径解析 - 实现ssm_should_auto_elevate和ssm_reexec_with_sudo函数来处理 自动提权逻辑 - 更新文档说明自动提权特性 - 添加相关测试用例验证自动提权功能
1 parent 5a391d6 commit 6dd7c0b

8 files changed

Lines changed: 193 additions & 2 deletions

File tree

scripts/bash/systemd-service-manager/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
## Notes
2727

2828
- `start` / `stop` / `restart` / `status` / `logs` 这类命令依赖目标 unit 已经先执行过 `install`
29+
- `install` / `start` / `stop` / `restart` / `enable` / `disable` 这类 system scope 写操作在非 root 下会自动通过 `sudo` 重新执行脚本本身,因此不要求你手工把 `sudo` 写在命令前。
2930

3031
## Build
3132

scripts/bash/systemd-service-manager/common.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,50 @@ ssm_init_environment() {
4848
local script_path="$1"
4949
SSM_MANAGER_HOME="$(ssm_detect_manager_home "${script_path}")"
5050
}
51+
52+
# 返回当前有效 uid;测试可通过环境变量覆写,避免依赖宿主用户身份。
53+
ssm_current_euid() {
54+
if [[ -n "${SSM_TEST_EUID:-}" ]]; then
55+
printf '%s\n' "${SSM_TEST_EUID}"
56+
return 0
57+
fi
58+
59+
id -u
60+
}
61+
62+
# 判断当前是否已经具备 root 权限。
63+
ssm_is_root() {
64+
[[ "$(ssm_current_euid)" == "0" ]]
65+
}
66+
67+
# 把脚本入口解析成绝对路径,兼容 PATH 调用和显式路径调用。
68+
ssm_resolve_executable_path() {
69+
local source_path="$1"
70+
local argv0="$2"
71+
local candidate="${source_path}"
72+
73+
if [[ "${candidate}" != */* ]]; then
74+
candidate="${argv0}"
75+
fi
76+
77+
if [[ "${candidate}" != */* ]]; then
78+
candidate="$(command -v "${candidate}" 2>/dev/null || true)"
79+
fi
80+
81+
[[ -n "${candidate}" ]] || ssm_die "Unable to resolve executable path for elevation"
82+
83+
if command -v realpath >/dev/null 2>&1; then
84+
realpath "${candidate}" 2>/dev/null && return 0
85+
fi
86+
87+
if command -v readlink >/dev/null 2>&1; then
88+
readlink -f "${candidate}" 2>/dev/null && return 0
89+
fi
90+
91+
if [[ "${candidate}" == /* ]]; then
92+
printf '%s\n' "${candidate}"
93+
return 0
94+
fi
95+
96+
printf '%s/%s\n' "${PWD}" "${candidate}"
97+
}

scripts/bash/systemd-service-manager/lib/cli.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Target syntax:
3636
3737
Notes:
3838
start 前需要目标 unit 已经 install
39+
system scope 的写操作在非 root 下会自动通过 sudo 重新执行
3940
4041
Examples:
4142
systemd-service-manager init

scripts/bash/systemd-service-manager/lib/systemd.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,37 @@ ssm_load_target_context() {
141141
;;
142142
esac
143143
}
144+
145+
# 判断当前命令是否需要在非 root 下自动提权。
146+
ssm_should_auto_elevate() {
147+
local command="$1"
148+
149+
case "${command}" in
150+
install | start | stop | restart | enable | disable)
151+
;;
152+
*)
153+
return 1
154+
;;
155+
esac
156+
157+
[[ "${SSM_CLI_DRY_RUN:-0}" == "1" ]] && return 1
158+
[[ "${SSM_ELEVATED_BY_SCRIPT:-0}" == "1" ]] && return 1
159+
ssm_is_root && return 1
160+
161+
local target_kind="${SSM_CLI_POSITIONAL_ARGS[0]:-}"
162+
local target_name="${SSM_CLI_POSITIONAL_ARGS[1]:-}"
163+
ssm_load_target_context "${target_kind}" "${target_name}"
164+
[[ "${SSM_ACTIVE_SCOPE}" == "system" ]]
165+
}
166+
167+
# 以脚本绝对路径重新执行自身,并交给 sudo 完成提权。
168+
ssm_reexec_with_sudo() {
169+
local source_path="$1"
170+
local argv0="$2"
171+
shift 2
172+
local script_path
173+
script_path="$(ssm_resolve_executable_path "${source_path}" "${argv0}")"
174+
175+
command -v sudo >/dev/null 2>&1 || ssm_die "sudo is required for system-scope write operations"
176+
exec env SSM_ELEVATED_BY_SCRIPT=1 sudo -- bash "${script_path}" "$@"
177+
}

scripts/bash/systemd-service-manager/main.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ fi
5050

5151
# 顶层分发仅保留 help 和错误分支,满足第一轮测试闭环。
5252
ssm_main() {
53+
local -a original_args=("$@")
5354
ssm_init_environment "${BASH_SOURCE[0]}"
5455

5556
local command="${1:-help}"
@@ -58,6 +59,10 @@ ssm_main() {
5859
ssm_parse_common_flags "$@"
5960
set -- "${SSM_CLI_POSITIONAL_ARGS[@]}"
6061

62+
if ssm_should_auto_elevate "${command}"; then
63+
ssm_reexec_with_sudo "${BASH_SOURCE[0]}" "$0" "${original_args[@]}"
64+
fi
65+
6166
case "${command}" in
6267
help | --help | -h | '')
6368
ssm_show_help

scripts/bash/systemd-service-manager/tests/install.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ describe('install command', () => {
4444
'--project',
4545
projectRoot,
4646
'--dry-run',
47-
])
47+
], {
48+
SSM_TEST_EUID: '0',
49+
})
4850

4951
expect(result.exitCode).toBe(0)
5052
expect(result.stdout).toContain('myapp-api.service')
@@ -77,6 +79,7 @@ describe('install command', () => {
7779
workspace,
7880
['install', 'timer', 'cleanup', '--project', projectRoot],
7981
{
82+
SSM_TEST_EUID: '0',
8083
SSM_SYSTEMCTL_LOG: path.join(workspace.root, 'systemctl.log'),
8184
},
8285
)
@@ -118,7 +121,9 @@ describe('install command', () => {
118121
'--project',
119122
projectRoot,
120123
'--dry-run',
121-
])
124+
], {
125+
SSM_TEST_EUID: '0',
126+
})
122127

123128
expect(result.exitCode).toBe(0)
124129
expect(result.stdout).toContain('myapp-api.service')
@@ -151,6 +156,7 @@ describe('install command', () => {
151156
workspace,
152157
['install', 'api', '--project', projectRoot, '--start'],
153158
{
159+
SSM_TEST_EUID: '0',
154160
SSM_SYSTEMCTL_LOG: systemctlLog,
155161
},
156162
)
@@ -159,4 +165,54 @@ describe('install command', () => {
159165
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('daemon-reload')
160166
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('start myapp-api.service')
161167
})
168+
169+
it('auto-elevates system-scope install when not root', async () => {
170+
const workspace = createWorkspace()
171+
workspaces.push(workspace)
172+
173+
installMockCommand(
174+
workspace,
175+
'sudo',
176+
[
177+
'#!/usr/bin/env bash',
178+
'printf "%s\\n" "$*" >>"${SSM_SUDO_LOG}"',
179+
'if [[ "$1" == "--" ]]; then shift; fi',
180+
'SSM_TEST_EUID=0 exec "$@"',
181+
].join('\n'),
182+
)
183+
installMockCommand(
184+
workspace,
185+
'systemd-analyze',
186+
'#!/usr/bin/env bash\nexit 0\n',
187+
)
188+
installMockCommand(
189+
workspace,
190+
'systemctl',
191+
'#!/usr/bin/env bash\nprintf "%s\\n" "$*" >>"${SSM_SYSTEMCTL_LOG}"\nexit 0\n',
192+
)
193+
194+
const projectRoot = path.join(
195+
workspace.managerHome,
196+
'tests',
197+
'fixtures',
198+
'project-basic',
199+
)
200+
const sudoLog = path.join(workspace.root, 'sudo.log')
201+
const systemctlLog = path.join(workspace.root, 'systemctl.log')
202+
203+
const result = await runSource(
204+
workspace,
205+
['install', 'api', '--project', projectRoot, '--start'],
206+
{
207+
SSM_TEST_EUID: '1000',
208+
SSM_SUDO_LOG: sudoLog,
209+
SSM_SYSTEMCTL_LOG: systemctlLog,
210+
},
211+
)
212+
213+
expect(result.exitCode).toBe(0)
214+
expect(fs.readFileSync(sudoLog, 'utf8')).toContain(workspace.sourceEntry)
215+
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('daemon-reload')
216+
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('start myapp-api.service')
217+
})
162218
})

scripts/bash/systemd-service-manager/tests/lifecycle.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,18 @@ describe('lifecycle commands', () => {
4646
)
4747

4848
await runSource(workspace, ['start', 'service', 'api', '--project', projectRoot], {
49+
SSM_TEST_EUID: '0',
4950
SSM_SYSTEMCTL_LOG: systemctlLog,
5051
})
5152
await runSource(workspace, ['enable', 'service', 'api', '--project', projectRoot], {
53+
SSM_TEST_EUID: '0',
5254
SSM_SYSTEMCTL_LOG: systemctlLog,
5355
})
5456
const status = await runSource(
5557
workspace,
5658
['status', 'service', 'api', '--project', projectRoot],
5759
{
60+
SSM_TEST_EUID: '0',
5861
SSM_SYSTEMCTL_LOG: systemctlLog,
5962
},
6063
)
@@ -100,6 +103,7 @@ describe('lifecycle commands', () => {
100103
workspace,
101104
['logs', 'service', 'user-agent', '--project', projectRoot, '--follow'],
102105
{
106+
SSM_TEST_EUID: '0',
103107
SSM_JOURNALCTL_LOG: journalLog,
104108
},
105109
)
@@ -129,10 +133,52 @@ describe('lifecycle commands', () => {
129133
)
130134

131135
const result = await runSource(workspace, ['start', 'api', '--project', projectRoot], {
136+
SSM_TEST_EUID: '0',
132137
SSM_SYSTEMCTL_LOG: systemctlLog,
133138
})
134139

135140
expect(result.exitCode).toBe(0)
136141
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('start myapp-api.service')
137142
})
143+
144+
it('auto-elevates system-scope start when not root', async () => {
145+
const workspace = createWorkspace()
146+
workspaces.push(workspace)
147+
148+
const sudoLog = path.join(workspace.root, 'sudo.log')
149+
const systemctlLog = path.join(workspace.root, 'systemctl.log')
150+
151+
installMockCommand(
152+
workspace,
153+
'sudo',
154+
[
155+
'#!/usr/bin/env bash',
156+
'printf "%s\\n" "$*" >>"${SSM_SUDO_LOG}"',
157+
'if [[ "$1" == "--" ]]; then shift; fi',
158+
'SSM_TEST_EUID=0 exec "$@"',
159+
].join('\n'),
160+
)
161+
installMockCommand(
162+
workspace,
163+
'systemctl',
164+
'#!/usr/bin/env bash\nprintf "%s\\n" "$*" >>"${SSM_SYSTEMCTL_LOG}"\nexit 0\n',
165+
)
166+
167+
const projectRoot = path.join(
168+
workspace.managerHome,
169+
'tests',
170+
'fixtures',
171+
'project-basic',
172+
)
173+
174+
const result = await runSource(workspace, ['start', 'api', '--project', projectRoot], {
175+
SSM_TEST_EUID: '1000',
176+
SSM_SUDO_LOG: sudoLog,
177+
SSM_SYSTEMCTL_LOG: systemctlLog,
178+
})
179+
180+
expect(result.exitCode).toBe(0)
181+
expect(fs.readFileSync(sudoLog, 'utf8')).toContain(workspace.sourceEntry)
182+
expect(fs.readFileSync(systemctlLog, 'utf8')).toContain('start myapp-api.service')
183+
})
138184
})

scripts/bash/systemd-service-manager/tests/manager-cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('systemd service manager cli', () => {
4242
expect(sourceHelp.stdout).toContain('--dry-run 只预览将执行的操作')
4343
expect(sourceHelp.stdout).toContain('--start 安装完成后立即启动目标 unit')
4444
expect(sourceHelp.stdout).toContain('start 前需要目标 unit 已经 install')
45+
expect(sourceHelp.stdout).toContain('system scope 的写操作在非 root 下会自动通过 sudo 重新执行')
4546
expect(builtHelp.stdout).toBe(sourceHelp.stdout)
4647
})
4748

0 commit comments

Comments
 (0)