中文 English

把「该睡觉了」搬出客厅:让 mac 的 launchd 每天 22:00 自动停用群晖孩子的账号,08:00 再悄悄启用

发布时间: 2026-06-14
Synology DSM NAS launchd macOS 群晖 自动化 家长 SSH CLI

先说结论

群晖 DSM 控制台里其实有一个"停用账号"按钮,但它不会自动按时执行。借助 mac 自带的 launchd 加一段 50 行的 bash 脚本,可以做到:每天 22:00 把孩子的两个本地账号 expired 标志置位,08:00 再恢复。脚本自带"改前查询 → 改 → 改后查询"的三步校验,任何一步对不上号立刻 exit 1,launchd 会把执行日志写进 StandardOutPath/StandardErrorPath。整件事的特别之处是**“家长"两个字被从对话里拿掉了**——你不用每天喊"该睡了”,机器会准时替你做。

本文不打算讲一个大而全的家庭 NAS 管理方案,只围绕一件具体的事:让一个原本靠"人记得点"的操作,变成"到了点就自动发生"的纯系统级动作。

如果你只想先看图,第二节那张「晚 10 点断电 / 早 8 点复电」的总览图就够了。

晚 10 点准时"断网",早 8 点自动"复电"

一、故事开头:每天一次的"赶紧关掉"对话

家里两个孩子一人一个群晖账号:mykid1mykid2。他们用 NAS 主要是三件事——

  1. 写完作业把照片/文档同步上去给爸妈看;
  2. 晚上看动画片(NAS 上挂着一个家庭相册 + 一些本地下载的视频);
  3. 周末偶尔用 NAS 上的朋友联机 Minecraft 服务器。

听起来很合理,问题出在第二件:孩子一打开 SMB 共享里那个"动画"文件夹,就停不下来。22:00 该睡觉的提醒喊三遍、五遍,最后一定是我自己跑过去登录 DSM 控制面板,找到"用户与群组 → mykid1 → 编辑 → 停用此账号",再重复一遍对 mykid2 的操作。

如果你也有类似的画面,你应该已经意识到三个问题:

  1. 靠人记得,一定会忘。忘了第一天,第二天就再也喊不动。
  2. 靠威权喊,会被反弹。“你怎么又自己打开了"这种话,喊多了大家都不开心。
  3. 靠"我陪着你”,代价是每天 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 → 校验

四、解决方案:把"该睡觉了"搬出客厅

用一句话总结目标:

让孩子根本不需要和别人确认"现在能不能登录"——能不能登,由系统时间决定,不由人决定。

具体怎么落地,分四步:

  1. 用脚本把"停用/启用"封装成幂等命令,可以重复跑。
  2. 在脚本里加改前/改后两次 synouser --get 校验,避免静默失败。
  3. 用 mac 的 launchd 计划在 22:00 跑 disable、08:00 跑 enable
  4. 把执行结果写到日志文件,出问题时家长能一眼看到。

这三层结构对应三份文件:

~/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.shchmod +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 里的过期标志位(synouserexpired 参数),而不是删除用户、改密码或停用某个服务。这是最干净的"门禁"——账号、密码、家目录、共享权限全部保留,只是登录时被拒绝。改回 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: 前缀的日志,这样不管脚本被 cronlaunchd 还是手动跑,出错时都能在日志里看到清楚的失败点。

6.3 真实执行样例

下面这张图是一次 disable 的真实执行结果(IP 和用户名都已脱敏成 nas.example.lan):

toggle-mykid.sh disable 实际执行日志

可以看到每个用户都打印了 before / after 两行,最终 DONE。如果哪一步不对,结尾会变成 ERROR: mykid2 停用失败 (expired=false) 并退出码 1,launchd 立刻在 err.log 里看到。

synouser --get 的原始输出长这样,可以非常直观地看到 Expired 这一行:

synouser –get mykid1 的原始输出

七、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,内容完全一样,只把三个地方改一下:

  1. Label 改成 local.synology.toggle-mykid.enable
  2. ProgramArguments 数组里第三个元素改成 <string>enable</string>
  3. 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 list 输出 + plist 内容(占位符版本)

八、怎么验证它真的在工作

光加载任务是不够的,必须做一次"我现在就要它跑"的端到端测试。launchctlstart 命令会无视时间,立刻执行一次指定 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 任务的日志:

toggle-mykid.sh 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(周六/日的特殊时间点)就行。launchdStartCalendarInterval 还支持 Weekday 字段(0=周日,1=周一…),可以做出"周一到周五 8:00 enable + 22:00 disable,周六日全开"这种规则。

十一、隐私:哪些东西不能写进公开材料

这次脚本里我特意用了 nas.example.lan 作为占位符,而不是真实的内网 IP。这是个非常小的细节,但影响很大:

  1. 不要把 NAS 的真实内网 IP 写进脚本/博客/截图:内网 IP 一旦和用户名/共享名组合,几乎可以猜出网络结构。
  2. 不要把 SSH 私钥路径写成绝对路径/Users/margrop/.ssh/... 这种带用户名的路径也是线索。
  3. 不要把共享文件夹名、孩子的小名写进日志mykid1 还可以,alice-2024-photos 就太具体了。
  4. 不要把 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 ""

缺点是:

  1. 时区设置和夏令时有时候会让人困惑。
  2. 没法直接写"8:00 和 22:00 各一次"两个时间点。
  3. 错误处理比脚本里写的 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 改的只有 fullnameexpiredmail 这三个字段。密码、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 点解禁"。但这个粒度就属于真正的运维方案了,建议先从最简版本用起。

十三、为什么这件事值得专门写一篇

写到结尾回看一下,其实这次做的事单看每一行都不复杂

复杂的是把这件事做成"家长可以放心交给系统"的状态

  1. 家长不需要每天手动点;
  2. 孩子不需要每天问"我能不能登";
  3. 出了问题,日志里能立刻看到;
  4. 临时需要手动调整,launchctl start 一下就行;
  5. 整件事不依赖任何第三方服务、不依赖公网、不依赖任何账号系统。

家庭自动化这件事,最怕的不是做得不够多,而是做得不够可靠。一个半自动的方案比"全自动但偶尔出错"的方案更糟糕——前者家长能主动管理,后者会让所有人对系统失去信任。

把"该睡觉了"这件事从客厅搬到 launchd 里,本质上是在做一件小小的"系统信任工程":让一个 50 行的脚本,承担起一段原本由人承担的责任,并且用日志、用校验、用退出码,对这件事负全责

如果你家里也有类似的"靠人记得"的小事,这套思路可以直接套用。NAS 时间门禁只是其中一种形态,任何"按时间执行的开关"都可以用 launchd + 脚本 + 校验这三件套搭出来

来源