把「该睡觉了」搬出客厅:让 mac 的 launchd 每天 22:00 自动停用群晖孩子的账号,08:00 再悄悄启用
先说结论
群晖 DSM 控制台里其实有一个"停用账号"按钮,但它不会自动按时执行。借助 mac 自带的
launchd加一段 50 行的bash脚本,可以做到:每天 22:00 把孩子的两个本地账号expired标志置位,08:00 再恢复。脚本自带"改前查询 → 改 → 改后查询"的三步校验,任何一步对不上号立刻exit 1,launchd 会把执行日志写进StandardOutPath/StandardErrorPath。整件事的特别之处是**“家长"两个字被从对话里拿掉了**——你不用每天喊"该睡了”,机器会准时替你做。
本文不打算讲一个大而全的家庭 NAS 管理方案,只围绕一件具体的事:让一个原本靠"人记得点"的操作,变成"到了点就自动发生"的纯系统级动作。
如果你只想先看图,第二节那张「晚 10 点断电 / 早 8 点复电」的总览图就够了。
一、故事开头:每天一次的"赶紧关掉"对话
家里两个孩子一人一个群晖账号:mykid1、mykid2。他们用 NAS 主要是三件事——
- 写完作业把照片/文档同步上去给爸妈看;
- 晚上看动画片(NAS 上挂着一个家庭相册 + 一些本地下载的视频);
- 周末偶尔用 NAS 上的朋友联机 Minecraft 服务器。
听起来很合理,问题出在第二件:孩子一打开 SMB 共享里那个"动画"文件夹,就停不下来。22:00 该睡觉的提醒喊三遍、五遍,最后一定是我自己跑过去登录 DSM 控制面板,找到"用户与群组 → mykid1 → 编辑 → 停用此账号",再重复一遍对 mykid2 的操作。
如果你也有类似的画面,你应该已经意识到三个问题:
- 靠人记得,一定会忘。忘了第一天,第二天就再也喊不动。
- 靠威权喊,会被反弹。“你怎么又自己打开了"这种话,喊多了大家都不开心。
- 靠"我陪着你”,代价是每天 30 分钟家庭关系损耗。
这三件事本质上都不是"管账号"的问题,是"该做的事没有自动发生"的问题。
二、问题表现:手动操作的 3 个具体痛点
我把过去两周的"手动管账号"过程列出来,捋清楚到底在烦什么。
痛点 1:DSM 控制台要登 5 次
打开浏览器 → 输入 nas.example.lan:5000 → 输 admin 密码 → 等登录完成 → 找到用户 → 编辑 → 停用 → 保存。一个用户 5 次点击,两个用户 10 次点击。第二天早上来一遍反方向。
痛点 2:容易点错
群晖的"停用"复选框放在第二屏,并且有 60 秒内可以撤销的提示。如果你急着去做饭,很可能**勾了保存、退出、又改了另外一个共享目录的权限,自己都忘了。**这是个非常真实的事故——我曾经在某次手忙脚乱时把孩子的共享权限给改了,结果第二天他所有照片都看不到了。
痛点 3:周末和节假日更乱
周末孩子想看会儿纪录片,账号被停用着;工作日 8 点我已经出门上班了,账号还没恢复。无论是"忘了开"还是"忘了关",最后都用威权解决:“你去找爸爸解封”。这种话每说一次,系统的可信度就掉一格。
三、问题根因:DSM 没有"按时间执行的停用"功能
把情绪拿掉,回到技术上。群晖 DSM 在控制面板里提供了:
| 功能 | 是否存在 | 说明 |
|---|---|---|
| 手动停用账号 | ✅ | 控制面板 → 用户与群组 → 编辑 → 取消勾选"启用此账号" |
| 定时停用账号 | ❌ | 没有这个开关 |
计划任务里改 synouser |
✅ 间接能做 | 但 DSM 自带 cron 风格比较弱,调度时间只能到"每天一次",不能"工作日不开周末开" |
| SSH + 命令行 + 外部调度 | ✅✅✅ | 最灵活,但要写脚本 |
也就是说,问题不是群晖不给力,而是DSM 自带的"计划任务"粒度不够。它的最小单位是"每天一次",而我真正想要的是"工作日 22:00 停、8:00 开;周末不停"。
更关键的点是:真正按时间帮我跑命令的,是 mac。mac 24 小时在线的概率比 NAS 还高(我家 NAS 偶尔休眠),launchd 又是 macOS 自己的"计划任务"系统,比 crontab 靠谱(后面会展开)。
所以最终方案落在:
launchd(mac 端)→ 触发脚本 → SSH 到群晖 → synouser --modify → 校验
四、解决方案:把"该睡觉了"搬出客厅
用一句话总结目标:
让孩子根本不需要和别人确认"现在能不能登录"——能不能登,由系统时间决定,不由人决定。
具体怎么落地,分四步:
- 用脚本把"停用/启用"封装成幂等命令,可以重复跑。
- 在脚本里加改前/改后两次
synouser --get校验,避免静默失败。 - 用 mac 的
launchd计划在 22:00 跑disable、08:00 跑enable。 - 把执行结果写到日志文件,出问题时家长能一眼看到。
这三层结构对应三份文件:
~/scripts/synology-toggle-mykid.sh # 脚本(核心)
~/Library/LaunchAgents/local.synology.toggle-mykid.disable.plist # 22:00 停用
~/Library/LaunchAgents/local.synology.toggle-mykid.enable.plist # 08:00 启用
~/logs/synology-mykid-toggle.log # 脚本自己的日志
~/logs/synology-mykid-toggle.launchd.out.log # launchd 标准输出
~/logs/synology-mykid-toggle.launchd.err.log # launchd 错误流
下面分章节拆开讲。
五、为什么选 launchd 而不是 crontab
这是和很多教程不一样的地方。crontab 几乎所有 Linux/Mac 用户都会,但它有几个真实坑:
| 维度 | crontab -e |
launchd (LaunchAgent) |
|---|---|---|
| 电脑睡眠时漏跑 | ⚠️ 会漏 | ✅ StartCalendarInterval 唤醒后会补 |
| 半夜里叫醒电脑 | 不会 | 可选(WakeSystem=YES) |
| 日志 | 自己 redirect | 原生 StandardOutPath / StandardErrorPath |
| 跨重启自动恢复 | ❌ | ✅ launchctl load 后一直生效 |
| 管理方式 | crontab -e |
launchctl load/unload/start/stop |
| Apple 官方推荐 | 仍然可用 | ✅ 自家方案 |
最关键的是第一条:Mac 是笔记本,盖盖子就会睡。crontab 在睡眠期间不执行,醒来也不会补跑。如果 22:00 你在吃饭、孩子已经睡着了、Mac 盖在桌上,crontab 这条任务会悄无声息地漏掉。
launchd 不会。它在 macOS 唤醒后,会立刻把"睡眠期间本来该跑但没跑"的 StartCalendarInterval 任务补一遍。这个差异在生产环境里不算什么,但在家用场景里就是"功能 vs 摆设"。
我这次明确没开 WakeSystem=YES——不想半夜把 Mac 拉醒。我宁愿让它自然醒后补跑,反正 8 点以后我都在家。
六、脚本实现:50 行 bash 解决所有问题
6.1 脚本完整内容
#!/bin/bash
# Toggle mykid1 / mykid2 accounts on Synology DSM.
# Usage:
# synology-toggle-mykid.sh enable # un-expire both users
# synology-toggle-mykid.sh disable # expire both users
# synology-toggle-mykid.sh status # show current expired state
set -euo pipefail
NAS_HOST="nas.example.lan" # 替换成你内网 NAS 的地址
NAS_SSH_USER="root"
USERS=(mykid1 mykid2)
LOG_DIR="${HOME}/logs"
LOG_FILE="${LOG_DIR}/synology-mykid-toggle.log"
mkdir -p "${LOG_DIR}"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S%z')" "$*" | tee -a "${LOG_FILE}"
}
die() {
log "ERROR: $*"
exit 1
}
get_expired() {
# 通过 SSH 远程读 expired 字段。awk 把 [true] / [false] 剥出来。
ssh -o BatchMode=yes -o ConnectTimeout=10 "${NAS_SSH_USER}@${NAS_HOST}" \
"/usr/syno/sbin/synouser --get ${1} 2>/dev/null | awk -F'[][]' '/^Expired/ {print tolower(\$2); exit}'"
}
set_expired() {
# 0 = 启用,1 = 停用。中间两个空字符串是 fullname 和 mail 占位。
ssh -o BatchMode=yes -o ConnectTimeout=10 "${NAS_SSH_USER}@${NAS_HOST}" \
"/usr/syno/sbin/synouser --modify ${1} '' ${2} ''" >/dev/null
}
action="${1:-}"
case "${action}" in
status)
for u in "${USERS[@]}"; do
exp="$(get_expired "${u}")"
log "status ${u}: expired=${exp:-<not-found>}"
done
;;
enable|disable)
new_state="0"; verb_zh="启用"
[[ "${action}" == "disable" ]] && new_state="1" && verb_zh="停用"
action_upper=$(printf '%s' "${action}" | tr '[:lower:]' '[:upper:]')
log "===== ${action_upper} (${verb_zh}) ====="
# 链路预检:连不上就立刻退出,避免后面静默挂掉
ssh -o BatchMode=yes -o ConnectTimeout=10 "${NAS_SSH_USER}@${NAS_HOST}" true \
|| die "无法 SSH 到 ${NAS_SSH_USER}@${NAS_HOST}"
for u in "${USERS[@]}"; do
before="$(get_expired "${u}")"
[[ -z "${before}" ]] && { log "WARN: ${u} 找不到,跳过"; continue; }
log "${u}: before expired=${before}"
set_expired "${u}" "${new_state}"
after="$(get_expired "${u}")"
log "${u}: after expired=${after}"
if [[ "${action}" == "enable" && "${after}" != "false" ]]; then
die "${u} 启用失败 (expired=${after})"
fi
if [[ "${action}" == "disable" && "${after}" != "true" ]]; then
die "${u} 停用失败 (expired=${after})"
fi
done
log "===== DONE ====="
;;
*)
echo "Usage: $0 {enable|disable|status}" >&2
exit 2
;;
esac
把它存成 ~/scripts/synology-toggle-mykid.sh 并 chmod +x。
6.2 关键技术点拆解
这段脚本里其实藏了 4 个值得拿出来讲的点。每个都不复杂,但少了任何一个都会出问题。
点 1:免密 SSH,而不是每次都输密码。 BatchMode=yes 表示"如果需要密码就直接失败",避免在 launchd 跑的时候卡在密码提示符上。所以前置条件是:先把本机的 ~/.ssh/id_ed25519.pub 加到 NAS 的 /root/.ssh/authorized_keys 里。生成专用密钥而不是复用主密钥,是个值得养成的好习惯:
ssh-keygen -t ed25519 -f ~/.ssh/nas_curfew_ed25519 -C "nas-curfew-agent"
ssh-copy-id -i ~/.ssh/nas_curfew_ed25519.pub root@nas.example.lan
点 2:expired 才是 DSM 的"停用"语义。 群晖的"停用此账号"在底层对应的就是 /etc/shadow 里的过期标志位(synouser 的 expired 参数),而不是删除用户、改密码或停用某个服务。这是最干净的"门禁"——账号、密码、家目录、共享权限全部保留,只是登录时被拒绝。改回 0 立刻恢复,不需要重新配任何东西。
点 3:改前 + 改后两次 synouser --get。 集群管理软件最大的灾难是"看起来执行成功了,其实什么也没变"——比如网络抖动、参数解析出错、SSH 提前断流。脚本里的 before / after 模式就是为了抓住这种情形:先读一次、改一次、再读一次,如果改完的状态和期望不符,立刻 exit 1,让 launchd 把这条错误写进 err.log,你一眼就能看到。
这张图把整个校验链画得很清楚:
点 4:set -euo pipefail + die() 的失败策略。 set -e 表示任何命令非 0 退出都立刻终止;set -u 防止变量未定义被静默填空;set -o pipefail 让管道里的失败不会因为 tee 成功而被吞掉。die() 在退出前会写一行带 ERROR: 前缀的日志,这样不管脚本被 cron、launchd 还是手动跑,出错时都能在日志里看到清楚的失败点。
6.3 真实执行样例
下面这张图是一次 disable 的真实执行结果(IP 和用户名都已脱敏成 nas.example.lan):

可以看到每个用户都打印了 before / after 两行,最终 DONE。如果哪一步不对,结尾会变成 ERROR: mykid2 停用失败 (expired=false) 并退出码 1,launchd 立刻在 err.log 里看到。
synouser --get 的原始输出长这样,可以非常直观地看到 Expired 这一行:

七、launchd 怎么用:两个 plist,22 点和 8 点各一个
launchd 的所有"任务"都是 .plist 文件,放在 ~/Library/LaunchAgents/ 下,对当前用户生效。
7.1 22:00 停用的 plist
文件路径:~/Library/LaunchAgents/local.synology.toggle-mykid.disable.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.synology.toggle-mykid.disable</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/margrop/scripts/synology-toggle-mykid.sh</string>
<string>disable</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>22</integer>
<key>Minute</key> <integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/margrop/logs/synology-mykid-toggle.launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/margrop/logs/synology-mykid-toggle.launchd.err.log</string>
<key>RunAtLoad</key><false/>
<key>WakeSystem</key><false/>
<key>ProcessType</key><string>Background</string>
</dict>
</plist>
7.2 08:00 启用的 plist
文件路径:~/Library/LaunchAgents/local.synology.toggle-mykid.enable.plist,内容完全一样,只把三个地方改一下:
Label改成local.synology.toggle-mykid.enable。ProgramArguments数组里第三个元素改成<string>enable</string>。StartCalendarInterval里的Hour改成<integer>8</integer>。
7.3 加载并查看任务
launchctl unload ~/Library/LaunchAgents/local.synology.toggle-mykid.disable.plist 2>/dev/null
launchctl unload ~/Library/LaunchAgents/local.synology.toggle-mykid.enable.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/local.synology.toggle-mykid.disable.plist
launchctl load ~/Library/LaunchAgents/local.synology.toggle-mykid.enable.plist
launchctl list | grep synology
正常情况下会看到两行:
- 0 local.synology.toggle-mykid.disable
- 0 local.synology.toggle-mykid.enable
第一列是 PID(- 表示当前没在跑,这是正常的,因为是定时任务),第二列是上一次退出码(0 = 成功),第三列是 Label。
下面的截图就是当时我本机的 launchctl list + plist 内容(IP 已脱敏):

八、怎么验证它真的在工作
光加载任务是不够的,必须做一次"我现在就要它跑"的端到端测试。launchctl 的 start 命令会无视时间,立刻执行一次指定 Label:
launchctl start local.synology.toggle-mykid.disable
3 秒后看两个日志文件:
tail -20 ~/logs/synology-mykid-toggle.log # 脚本自己的日志
tail -20 ~/logs/synology-mykid-toggle.launchd.out.log # launchd 标准输出
cat ~/logs/synology-mykid-toggle.launchd.err.log # 错误流,正常应该为空
下面这张就是 22:00 自动跑到 8:00 之间的某天 enable 任务的日志:

你会看到非常干净的输出:===== ENABLE (启用) ===== → 两个用户的 before / after → ===== DONE =====。这种"我什么也没做,它自己跑成功了"的画面,就是自动化的价值。
最后不要忘了用脚本的 status 子命令验证一下最终状态:
~/scripts/synology-toggle-mykid.sh status
预期输出:
[2026-06-14 08:13:31+0800] status mykid1: expired=false
[2026-06-14 08:13:31+0800] status mykid2: expired=false
expired=false 就代表当前是"启用"。
九、完整使用方式速查
| 场景 | 命令 |
|---|---|
| 立刻停用 | launchctl start local.synology.toggle-mykid.disable |
| 立刻启用 | launchctl start local.synology.toggle-mykid.enable |
| 查看当前状态 | ~/scripts/synology-toggle-mykid.sh status |
| 看执行历史 | tail -f ~/logs/synology-mykid-toggle.log |
| 看 launchd 错误 | tail -f ~/logs/synology-mykid-toggle.launchd.err.log |
| 重新加载(改完 plist 后) | launchctl unload <plist>; launchctl load <plist> |
| 临时关掉整个流程 | launchctl unload ~/Library/LaunchAgents/local.synology.toggle-mykid.*.plist |
| 永久恢复 | launchctl load ~/Library/LaunchAgents/local.synology.toggle-mykid.*.plist |
十、可推广的"群晖时间门禁"模式
把这件事抽出来,本质上是一种"群晖时间门禁“模式:对任意本地账号,单独做按时间的启用/停用。它能直接套到很多其他场景里:
| 场景 | 改什么 | 风险 |
|---|---|---|
| 客人来家里住三天,给个临时账号 | enable 3 天,disable 退房 | 低 |
| 公司里承包商账号 | 只能工作日 9-18 登录 | 中(需要更细粒度的 cron) |
| 老人账号白天开、夜里关 | 同本文方案 | 低 |
| 给孩子周末白天放开 | 扩展 cron:周六日单独加 enable 任务 |
低 |
需要更复杂的"工作日开周末关"时,只要再加两个 plist(周六/日的特殊时间点)就行。launchd 的 StartCalendarInterval 还支持 Weekday 字段(0=周日,1=周一…),可以做出"周一到周五 8:00 enable + 22:00 disable,周六日全开"这种规则。
十一、隐私:哪些东西不能写进公开材料
这次脚本里我特意用了 nas.example.lan 作为占位符,而不是真实的内网 IP。这是个非常小的细节,但影响很大:
- 不要把 NAS 的真实内网 IP 写进脚本/博客/截图:内网 IP 一旦和用户名/共享名组合,几乎可以猜出网络结构。
- 不要把 SSH 私钥路径写成绝对路径:
/Users/margrop/.ssh/...这种带用户名的路径也是线索。 - 不要把共享文件夹名、孩子的小名写进日志:
mykid1还可以,alice-2024-photos就太具体了。 - 不要把 token / 强密码写进 plist 或脚本:脚本里如果以后要加密码,请用环境变量或 macOS Keychain。
这次写脚本时,输出日志里只有 expired=true/false 和用户名 mykid1/mykid2 这种抽象程度的信息。即使整段日志被贴到网上,也不会暴露家庭拓扑。
十二、Q&A
Q1:DSM 自带的"计划任务"不能用吗?
可以,但粒度只到"每天一次”,并且要登录 DSM 控制台设置。如果你用 DSM 自己的 Task Scheduler,脚本就变成了:
Task Scheduler → 用户定义的脚本 → /usr/syno/sbin/synouser --modify mykid1 "" 1 ""
缺点是:
- 时区设置和夏令时有时候会让人困惑。
- 没法直接写"8:00 和 22:00 各一次"两个时间点。
- 错误处理比脚本里写的
die()简单很多。
所以我更推荐脚本放 Mac、Mac 用 launchd 调度、远程 SSH 到 NAS。
Q2:为什么不用 DSM 自带的"账号配额"或"带宽限制"做这件事?
DSM 有"服务"维度的开关,但没有"用户维度 + 时间维度"的开关。带宽限制只能限制速度,不能限制能不能登。访问列表只能限制来源 IP,不能限制时间。所以最干净的实现还是"停用账号"本身。
Q3:脚本里 synouser 居然能改 expired 字段,这文档哪里有?
/usr/syno/sbin/synouser --help 直接列了:
--modify username "full name" expired{0|1} mail
也就是说,--modify 第二个位置参数是 expired。群晖的 CLI 文档没有专门强调,但只要按顺序传 用户名 "" 0|1 "" 就可以。
我前一篇《群晖 NAS CLI 管理指南速查》里也整理过 synouser 的可用参数。
Q4:会不会影响孩子的密码、家目录、共享权限?
不会。synouser --modify 改的只有 fullname、expired、mail 这三个字段。密码、UID、家目录路径、共享权限、群组关系全部保留。你甚至可以在任意时刻临时改 expired 来"封"账号,也可以在任何时候改回 0 来"解封",不会丢数据。
Q5:launchd 在 22:00 那一刻 Mac 正好睡眠了会怎样?
不会漏,但会延后。launchd 在系统唤醒后会按 StartCalendarInterval 的语义补跑错过的任务。补跑时间最晚不超过"系统唤醒 + 短暂延迟",我观察下来 1-2 分钟内一定跑。日志里你会看到 ===== DISABLE (停用) ===== 的时间戳不是 22:00:00 而是 23:47:15——这其实是正常现象,不算 bug。
Q6:能不能用 crontab 实现一样的功能?
能。crontab 写法很简单:
0 8 * * * /Users/margrop/scripts/synology-toggle-mykid.sh enable >> ~/logs/synology-mykid-toggle.log 2>&1
0 22 * * * /Users/margrop/scripts/synology-toggle-mykid.sh disable >> ~/logs/synology-mykid-toggle.log 2>&1
但睡眠会漏。这是我特意不选 crontab 的原因。如果你的 Mac 是台式机、长期不睡眠,crontab 完全够用。如果你的 Mac 是笔记本,强烈建议 launchd。
Q7:这个方案对 DSM 版本有要求吗?
我用 synouser --modify 这个能力在 DSM 6.x 和 7.x 上都验证过。expired 字段在所有现代 DSM 版本都存在。具体命令路径 /usr/syno/sbin/synouser 也是固定的。所以基本上 DSM 6.2 以后的所有版本都可以用。
Q8:能不能扩展到非工作时间禁用整个家庭账号?
可以。把脚本里的 USERS 数组扩成 (mykid1 mykid2 mywife myown),再额外写一个 weekday-9to18-disable.plist:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>9</integer>
<key>Minute</key> <integer>0</integer>
<key>Weekday</key> <integer>1</integer> <!-- 周一 9:00 -->
</dict>
配合 Weekday=5(周五 18:00 enable)就能做到"工作日 9 点全家禁 NAS、周五 6 点解禁"。但这个粒度就属于真正的运维方案了,建议先从最简版本用起。
十三、为什么这件事值得专门写一篇
写到结尾回看一下,其实这次做的事单看每一行都不复杂:
synouser --modify一行命令就能改 expired;launchd加个 plist 就能定时;synouser --get加个 if 就能做校验。
复杂的是把这件事做成"家长可以放心交给系统"的状态:
- 家长不需要每天手动点;
- 孩子不需要每天问"我能不能登";
- 出了问题,日志里能立刻看到;
- 临时需要手动调整,
launchctl start一下就行; - 整件事不依赖任何第三方服务、不依赖公网、不依赖任何账号系统。
家庭自动化这件事,最怕的不是做得不够多,而是做得不够可靠。一个半自动的方案比"全自动但偶尔出错"的方案更糟糕——前者家长能主动管理,后者会让所有人对系统失去信任。
把"该睡觉了"这件事从客厅搬到 launchd 里,本质上是在做一件小小的"系统信任工程":让一个 50 行的脚本,承担起一段原本由人承担的责任,并且用日志、用校验、用退出码,对这件事负全责。
如果你家里也有类似的"靠人记得"的小事,这套思路可以直接套用。NAS 时间门禁只是其中一种形态,任何"按时间执行的开关"都可以用 launchd + 脚本 + 校验这三件套搭出来。
来源
- Synology 官方 CLI 指南:https://global.download.synology.com/download/Document/Software/DeveloperGuide/Firmware/DSM/All/enu/Synology_DiskStation_Administration_CLI_Guide.pdf
- Apple 官方
launchd.plist参考:https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html - 本文作者同系列前置文章:群晖 NAS CLI 管理指南速查、群晖 SSH 命令详解、OpenClaw / HermesAgent 集成群晖操作 SKILL