一、目标h2
这次折腾的目标很明确:不用第三方日历服务,搭一套自己可控的日历系统,并让本地 Agent 能够像普通客户端一样安全地读写日历。
最终希望做到:
- 手机、桌面邮件客户端、网页端共用同一套日历。
- 服务端使用 Nextcloud Calendar 作为 CalDAV 后端。
- 本地 Agent 不直接进入容器或数据库,只通过 HTTPS CalDAV 操作日历。
- 所有写入操作都必须先 dry-run,再经用户确认,最后写入并回读验证。
- 应用密码、
.env、UID、ETag、备份密码等敏感信息不进入日志和 Git。
最终链路如下:
手机 / 桌面日历客户端 │ │ CalDAV / HTTPS ▼Nextcloud Calendar ▲ │ CalDAV / HTTPS │calendarctl CLI ▲ │本地 Agent / Skill二、整体架构h2
服务端使用 Nextcloud AIO,前面放一层宿主机 Nginx 负责 HTTPS 和反向代理。
Cloudflare DNS │ ▼calendar.example.com │ ▼宿主机 Nginx :443 │ ▼Nextcloud AIO Apache :11000 │ ▼Nextcloud / PostgreSQL / Redis / Notify PushAIO 管理页面只监听本机地址,不直接暴露到公网:
127.0.0.1:8080需要访问 AIO 管理页面时,通过 SSH 隧道转发:
ssh -N -L 8080:127.0.0.1:8080 my-server然后在本机浏览器打开:
https://127.0.0.1:8080这样 AIO 管理入口不会暴露在公网,只在需要维护时临时访问。
三、Nextcloud AIO 部署要点h2
AIO 主容器只需要暴露本机管理端口,Apache 服务则绑定到宿主机回环地址,由 Nginx 转发。
关键配置大致如下:
127.0.0.1:8080:8080APACHE_PORT=11000APACHE_IP_BINDING=127.0.0.1核心容器包括:
nextcloud-aio-mastercontainernextcloud-aio-apachenextcloud-aio-nextcloudnextcloud-aio-databasenextcloud-aio-redisnextcloud-aio-notify-push这次没有启用 Office、Talk、Imaginary、Whiteboard、ClamAV 等组件。对单人或轻量使用场景来说,少开组件可以减少资源占用和维护复杂度。
四、Nginx 与 HTTPSh2
宿主机 Nginx 负责接收公网 80/443 请求,Nextcloud AIO Apache 只监听本机:
127.0.0.1:11000配置完成后先检查 Nginx:
nginx -t证书可以使用 Certbot 签发,并确认自动续期定时器正常:
systemctl is-enabled certbot.timersystemctl is-active certbot.timercertbot renew --dry-run如果 renew --dry-run 能成功,说明证书续期链路基本可用。
五、账户与日历设计h2
不要让 Agent 使用管理员账户。比较稳妥的方式是创建一个专用普通用户,例如:
calendar-bot再由管理员创建一个正式日历,例如:
工作日历然后共享给 calendar-bot,授予可编辑权限。
在 CalDAV 中需要特别注意一点:日历显示名称可以改,但 CalDAV 路径不要随意改。客户端或自动化工具应该固定使用发现到的日历路径,而不是依赖可变的显示名。
示例路径:
/remote.php/dav/calendars/calendar-bot/work_shared_by_admin/关键原则:
显示名称可以改CalDAV 路径不要改自动化工具固定使用 calendar-path六、CalDAV 联通验证h2
CalDAV 基础地址通常是:
https://calendar.example.com/remote.php/dav/认证成功后,DAV PROPFIND 请求应返回:
HTTP 207 Multi-Status日历发现结果可以整理成类似结构:
{ "name": "工作日历 (admin)", "displayname": "工作日历 (admin)", "path": "/remote.php/dav/calendars/calendar-bot/work_shared_by_admin/", "color": "#5b64b3"}只要能稳定发现日历路径,后续 CLI 和 Agent 就可以围绕这个路径工作。
七、calendarctl CLI 设计h2
我没有让 Agent 直接拼 CalDAV 请求,而是封装了一个本地 CLI 工具 calendarctl。
项目结构大致如下:
~/Dev/calendar-agent/├── bin/calendarctl├── calendarctl/├── skills/├── tests/├── pyproject.toml└── .envCLI 入口只负责调用项目虚拟环境中的 Python:
exec "$PROJECT_DIR/.venv/bin/python" -m calendarctl.cli "$@".env 保存运行时配置:
NEXTCLOUD_URLNEXTCLOUD_USERNAMENEXTCLOUD_APP_PASSWORDNEXTCLOUD_CALENDAR_NAMENEXTCLOUD_TIMEZONE权限应设置为:
chmod 600 .env并确认没有被 Git 跟踪:
git ls-files -- .env无输出才是预期结果。
八、CLI 功能与安全流程h2
8.1 日历发现h3
./bin/calendarctl calendars --json用于确认当前账户可访问哪些日历,以及每个日历对应的 CalDAV 路径。
8.2 查询事件h3
查询时建议显式传入时间范围:
./bin/calendarctl list \ --from "2026-07-29 00:00" \ --to "2026-07-29 23:59" \ --calendar-path "/remote.php/dav/calendars/calendar-bot/work_shared_by_admin/" \ --json这样可以避免一次性拉取过多历史事件,也便于 Agent 控制上下文大小。
8.3 新建事件h3
新建事件必须走固定流程:
解析自然语言→ dry-run→ 展示拟写入内容→ 用户确认→ --yes 写入→ list 回读验证写入时使用:
If-None-Match: *可以避免 UID 冲突时覆盖已有事件。
8.4 更新事件h3
更新事件需要先读取当前 ETag,再带 If-Match 写入:
按 UID 定位→ 获取当前 ETag→ dry-run→ 用户确认→ If-Match 写入→ 回读验证如果事件在操作过程中被其他客户端修改,ETag 发生变化,服务端会通过 HTTP 412 拒绝覆盖。
8.5 删除与恢复h3
删除同样需要基于 ETag:
按 UID 定位→ 获取 ETag→ dry-run→ 用户确认→ If-Match 删除恢复删除事件时,优先使用 Nextcloud 原生 CalDAV 回收站,而不是重新 PUT 一个相同 UID 的事件。
示例:
MOVE + If-Match这样能保留服务端对删除和恢复的语义,也减少 UID 冲突风险。
九、Agent Skill 约束h2
本地 Agent 只允许通过 CLI 操作日历,不直接读取 .env,也不直接发 CalDAV 请求。
Skill 中应明确禁止:
- 直接发 CalDAV 请求。
- 直接读取
.env。 - 输出应用密码。
- 绕过 dry-run。
- 未经确认写入。
- 修改固定的
calendar-path。
标准流程固定为:
解析→ dry-run→ 展示变更→ 用户确认→ --yes→ 读取回验这个约束非常重要。Agent 可以帮忙操作,但不应该拥有“静默修改日历”的能力。
十、Python 打包问题h2
项目里同时存在 CLI 代码和 Skill 文档时,Setuptools 可能误识别多个顶层包,例如:
Multiple top-level packages discovered in a flat-layout可以在 pyproject.toml 中明确包发现范围:
[tool.setuptools.packages.find]where = ["."]include = ["calendarctl*"]exclude = ["skills*", "tests*", "docs*"]namespaces = false这样只打包 CLI 代码,不把 Skill、测试和文档打进 Python 包。
测试使用:
.venv/bin/python -m pytest tests/ -q十一、手机与桌面客户端接入h2
11.1 iPhoneh3
在 Nextcloud 中为手机创建独立应用密码:
头像→ 个人设置→ 安全→ 设备和会话→ 创建应用密码然后在 iPhone 中添加 CalDAV:
设置→ App→ 日历→ 日历账户→ 添加账户→ 其他→ 添加 CalDAV 账户填写示例:
服务器:calendar.example.com用户名:nextcloud-admin密码:iPhone 专用应用密码描述:Nextcloud建议每台设备使用独立应用密码,设备停用后可以单独撤销。
11.2 Thunderbirdh3
Thunderbird 原生支持 CalDAV。
路径:
Thunderbird→ 日历→ 新建日历→ 网络→ CalDAV推荐填写:
用户名:nextcloud-admin位置:https://calendar.example.com/remote.php/dav/认证同样使用 Thunderbird 专用应用密码。
如果自动发现失败,可以尝试更具体的用户日历路径:
https://calendar.example.com/remote.php/dav/calendars/nextcloud-admin/十二、备份与自启动h2
Nextcloud AIO 支持 Borg 备份。备份目录可以放在宿主机,例如:
/srv/nextcloud-backup首次备份后应确认:
Last backup successful也可以检查 Borg 仓库大小:
du -sh /srv/nextcloud-backup/borg自动备份时间需要注意服务器时区。例如希望北京时间每天 03<30>30> 备份,而服务器使用 UTC,则应配置为:
19:30 UTC基础服务自启动也要确认:
systemctl is-enabled dockersystemctl is-enabled nginx
systemctl is-active dockersystemctl is-active nginxNextcloud AIO 的 Borg 备份容器不需要常驻运行,它只在备份期间启动,这是正常现象。
十三、安全注意事项h2
不要公开或提交:
Nextcloud 应用密码Borg 备份密码.envAIO 管理密码真实 UID / ETag真实服务器路径真实内网拓扑建议:
- 每台设备单独生成应用密码。
- 设备停用后单独撤销应用密码。
- Borg 密码保存到密码管理器。
.env权限保持600。- 定期确认备份成功时间。
- Agent 写入操作永远需要用户确认。
十四、排障经验h2
这次部署中最容易踩坑的点有几个:
- AIO 管理端口不要暴露到公网。
- Nextcloud 域名、反向代理和 AIO Apache 端口要对应一致。
- CalDAV 显示名称可以改,但路径不应随意改。
- Agent 不应直接操作容器、数据库或
.env。 - 更新和删除事件必须基于 ETag,避免覆盖其他客户端的改动。
- 手机和桌面客户端最好使用独立应用密码。
- 备份密码必须保存好,Borg 密码丢失后无法恢复备份。
最终比较稳定的形态是:Nextcloud 负责标准 CalDAV,手机和桌面客户端作为普通日历客户端接入,本地 Agent 只通过 CLI 和 CalDAV 做受控读写。
十五、常用命令速查h2
发现日历:
cd ~/Dev/calendar-agent./bin/calendarctl calendars --json查询指定日期:
./bin/calendarctl list \ --from "2026-07-29 00:00" \ --to "2026-07-29 23:59" \ --calendar-path "/remote.php/dav/calendars/calendar-bot/work_shared_by_admin/" \ --json运行测试:
.venv/bin/python -m pytest tests/ -q检查 Git:
git status --shortgit ls-files -- .env检查 AIO 容器:
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep nextcloud-aio检查备份:
du -sh /srv/nextcloud-backup/borg检查证书续期:
systemctl is-enabled certbot.timersystemctl is-active certbot.timercertbot renew --dry-run十六、最终效果h2
完成后,整套链路可以形成闭环:
Nextcloud AIO→ HTTPS 反向代理→ CalDAV 账户与共享日历→ calendarctl CLI→ 本地 Agent / Skill→ 手机和桌面客户端双向同步→ Borg 自动备份→ Certbot 自动续期这套方案的重点不在于“让 Agent 能操作日历”,而在于让它以普通客户端身份、在明确边界内、安全地操作日历。