Add 3 posts (EN + ZH): the Synology Spreadsheet API container, the 401-with-correct-password gotcha, and reading a container's own API docs
Deploy / build (push) Successful in 24s

This commit is contained in:
2026-09-29 01:43:00 +08:00
parent d88a5f6f2c
commit 48472e0daa
15 changed files with 1265 additions and 0 deletions
@@ -0,0 +1,214 @@
---
title: "与其看供应商文档,不如直接读容器自带的 API 文档"
description: "当 API 文档藏在登录墙后面、甚至根本不存在时,镜像本身就是唯一的事实来源:四条 Docker 命令,就能挖出 API 规范、配置契约,以及它真正监听的端口。"
pubDate: 2025-10-01
updatedDate: 2026-09-26
category: notes
tags: [docker, openapi, api, documentation, debugging, reverse-engineering]
ogImage: /og/reading-a-containers-own-api-docs.png
banner: /banners/reading-a-containers-own-api-docs.png
draft: false
---
我需要对接一个供应商自托管的 API。他们的文档是有的,但挡在账号登录后面;
而我能看到的那一页,只描述了 API *能*做什么,没说该怎么配置。与此同时,
容器就安安静静地躺在我机器上——而答案就在它里面。
现在,凡是文档单薄、要登录、或者压根没有的镜像,我都会先跑这套命令。
下面所有操作都是只读的,也不需要进入容器开 shell。
## 为什么这很重要
1. **供应商文档描述的是理想路径;容器描述的才是契约。** 确切的 env 变量名、
默认值、暴露的端口、是否需要配置文件——这些在营销页面上都不可靠。
2. **很多镜像压根没有文档**——私有仓库、内部构建,或是别人拉了又推上去的
社区镜像。构件本身是唯一权威。
3. **版本漂移是真实存在的。** 网站上的文档描述的是最新版本;而你正在跑的
镜像可能是两年前的了。读构件,了解的是你实际部署的那个东西。
## 1. 不运行镜像,直接读里面的文件
最有用的一招:覆盖 entrypoint,把镜像当成文件系统来用。
```bash
# what's in there?
docker run --rm --entrypoint ls <image> -la /app
# does it ship an API spec?
docker run --rm --entrypoint cat <image> /app/public/openapi.yml | head -40
```
第二条命令直接在镜像里返回了一份完整的 OpenAPI 3.1 规范,27 KB——每个端点、
请求体 schema、认证模型,一应俱全。不用登录、不用账号、没有文档墙。
如果你还不知道文件布局,用 `--entrypoint find` 能覆盖更广:
```bash
docker run --rm --entrypoint find <image> / -maxdepth 3 \
-name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -30
```
## 2. 从 bundle 里挖出配置契约
真正会让你卡住的往往是 env 变量名——一个没有默认值的必需变量,意味着容器
启动时就挂掉,通常还带一条毫无帮助的错误信息。直接问源码吧:
```bash
docker run --rm --entrypoint grep <image> \
-rhoE 'process\.env\.[A-Za-z_][A-Za-z0-9_]*' /app/dist | sort -u
```
```
AUTH_SECRET
DEBUG
HOST
LOKI_HOST
METRICS_TOKEN
PORT
USER_TIMEOUT
WORKER_TIMEOUT
WORKER_PATH
```
九个变量名,整个配置面就清楚了。想看默认值,用带上下文的 grep 抓你关心的
那一个:
```bash
docker run --rm --entrypoint grep <image> \
-ohE '.{0,80}process\.env\.PORT.{0,80}' /app/dist/index.js
```
```js
serverPort: parseInt(process.env.PORT) || 3e3,
serverHost: process.env.HOST || "0.0.0.0",
workerTimeout: parseInt(process.env.WORKER_TIMEOUT) || 600*1e3,
```
`PORT` 默认是 3000,host 默认是 `0.0.0.0`,worker 每十分钟回收一次。一条命令
就消掉了三个不确定的决策点。这套办法适用于任何 Node/Python 镜像;对 Go 或
Rust 的二进制,同样的思路,只是把 grep 换成对二进制跑 `strings`。
## 3. 不拉镜像,直接查镜像配置
在慢速网络下,或者在决定下载 1 GB 镜像之前,你可以直接从 registry 读取镜像
的元数据。本地连 Docker daemon 都不需要:
```bash
REPO=synology/spreadsheet-api
TAG=3.4.1
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$REPO:pull" \
| python -c "import json,sys; print(json.load(sys.stdin)['token'])")
# manifest (follow the platform entry if it's a multi-arch index)
curl -s -H "Authorization: Bearer ***" \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
"https://registry-1.docker.io/v2/$REPO/manifests/$TAG" \
| python -c "import json,sys; m=json.load(sys.stdin); print(m['config']['digest'])"
```
然后取回那个 config blob,把关心的字段打印出来:
```bash
curl -s -H "Authorization: Bearer ***" \
"https://registry-1.docker.io/v2/$REPO/blobs/<config-digest>" \
| python -c "
import json,sys
c = json.load(sys.stdin)['config']
print('Env: ', c.get('Env'))
print('Entrypoint:', c.get('Entrypoint'))
print('Cmd: ', c.get('Cmd'))
print('Workdir: ', c.get('WorkingDir'))
print('Ports: ', list((c.get('ExposedPorts') or {}).keys()))"
```
```
Env: ['PATH=...', 'NODE_VERSION=22.18.0', 'WORKER_PATH=/app/dist/spreadsheet_worker.js']
Entrypoint: ['docker-entrypoint.sh']
Cmd: ['node', 'dist/index.js']
Workdir: /app
Ports: []
```
还没下载一个字节,就有两条有用信息跳出来了:这是个 Node 服务,而且它**没有
声明任何暴露端口**——所以任何端口映射都得靠 `PORT` 变量,而不是 `EXPOSE`。
既然来了,顺带也值得查一下:tag 列表会告诉你真实的版本历史。
```bash
curl -s "https://hub.docker.com/v2/repositories/$REPO/tags/?page_size=25" \
| python -c "import json,sys; [print(t['name'], t['last_updated'][:10]) for t in json.load(sys.stdin)['results']]"
```
## 4. 找出它真正监听的端口
`EXPOSE` 是文档,不是行为。想知道进程真正绑定的端口,要看运行中的容器内部:
```bash
docker exec <container> cat /proc/net/tcp
```
端口以十六进制写在字段 2 里——字段 4 是 `0A` 表示 `LISTEN`。解码一下:
```bash
docker exec <container> sh -c \
"awk 'NR>1 && \$4==\"0A\" {print \$2}' /proc/net/tcp" \
| cut -d: -f2 | while read h; do printf '%d\n' "0x$h"; done
```
```
3000
```
然后不离开容器,确认它真的在响应:
```bash
docker exec <container> sh -c 'wget -qSO- -O- http://127.0.0.1:3000/ 2>&1 | head -5'
```
```
HTTP/1.1 302 Found
location: /docs
```
重定向到 `/docs`——原来镜像一直在提供它自己的 Swagger UI。
## 5. 读启动日志,别把 shell 挂死
把 `docker run` 直接接到 `head` 或日志过滤器上,看起来无害,实际上会挂死:
进程一直占着管道,你的命令永远不会返回。改成后台运行,读日志,然后删掉它:
```bash
docker run -d --name probe -e AUTH_SECRET=probe-only <image>
sleep 8
docker logs probe 2>&1 | head -20
docker rm -f probe
```
```
[02:06:17 UTC] INFO: Server listening at http://127.0.0.1:3000
[02:06:17 UTC] INFO: Server listening at http://172.27.0.2:3000
```
两行日志就是全部答案——而且它还告诉我:容器*有* `AUTH_SECRET` 就能正常启动,
*没有*就挂。这比我在前台运行、眼睁睁看着它崩溃后拿到的那段压缩过的堆栈信息
有用得多。
## 这不能替代什么
如果供应商自己的文档存在,还是要读——通常比钻洞翻找更快,而且它携带镜像
无法告诉你的东西,比如版本兼容性对照表。就拿这个镜像来说,供应商页面*确实*
发布了运行命令和必需的 `AUTH_SECRET`;我之所以先在镜像里找到它们,只是因为我
没把页面往下滚够。两个都用:页面看意图,构件看你要部署的确切契约。
## 结果
四条命令——`cat` 出打包的规范、`grep` 出 env 变量、从 registry 读 config blob、
`exec cat /proc/net/tcp` 找端口——把一场文档大海捞针,换成了:一份完整的
OpenAPI 规范、全部九个 env 变量、默认端口、配置默认值,以及一个 Swagger UI
地址。没进容器开 shell,不用登录,什么都没改动。
---
*如果你正在对接一个自托管系统,而文档又戛然而止——这正是我为马来西亚中小
企业做的活:[WhatsApp](https://wa.me/60127972969)、[邮件](mailto:[email protected]?subject=API%20integration),
或 [hoelee.com](https://hoelee.com)。*
@@ -0,0 +1,103 @@
---
title: "密码正确,Synology API 登录却返回 401,问题出在哪"
description: "两种原因,同一个报错:Office Suite API 对 TLS 握手失败和受 2FA 保护的账号返回完全相同的错误。三条命令就能区分,不必折腾一下午。"
pubDate: 2025-09-10
updatedDate: 2026-09-26
category: notes
tags: [synology, dsm, rest-api, tls, 2fa, debugging, authentication]
ogImage: /og/synology-api-401-with-the-correct-password.png
banner: /banners/synology-api-401-with-the-correct-password.png
draft: false
---
你搭好了 Synology 的 Office Suite API,把凭据发过去,得到的却是:
```json
{"error":"Unauthorized"}
```
然后你用这组密码登录 DSM 验证——一切正常。你重新输入一遍,改用 HTTP 而不是 HTTPS,怀疑是不是账号缺了什么找不到的权限。这些全都是白费功夫,因为在这套 API 上,密码正确却返回 `401` 的原因恰好只有两个,而这两个都不是密码的问题。
## 原因一:`host` 字段必须是一个名字,且证书要能被容器接受
这个 API 是自配置的:每次登录调用时,由你来告诉*它*要对哪台 DSM 进行认证。
```bash
curl -s -X POST http://<api-host>:8791/spreadsheets/authorize \
-H 'Content-Type: application/json' \
-d '{"username":"acct","password":"pw","host":"192.168.1.1:5001","protocol":"https"}'
```
```json
{"error":"Unauthorized"}
```
代理会与那个 `host` 自行完成 TLS 握手。裸 IP 地址通常拿到的证书是为某个主机名签发的,于是握手失败——而失败的握手会被报告为 `Unauthorized`,和密码错误一模一样。响应里没有任何线索指向 TLS。
同样的请求、同样的凭据,把 `host` 换成一个持有有效证书的名字:
```bash
-d '{"username":"acct","password":"pw","host":"cloud.example.com","protocol":"https"}'
```
```json
{"token":"eyJhbGciOi...","host":"cloud.example.com"}
```
修复方法就这么多。**规则:`host` 的值必须是一个 FQDN,且其证书能被容器接受;如果端口不是该协议默认端口,也要一并写上。**
## 原因二:双重认证在这里永远行不通
第二个原因是结构性的。登录的 schema 只有四个字段,没有一次性验证码字段:
```yaml
AuthorizationBody:
properties:
username: {type: string}
password: {type: string}
host: {type: string}
protocol: {type: string}
```
没有 OTP,没有应用专用密码,也没有设备令牌交换。启用了 2FA 的账号,无论在哪个 `host` 上、无论密码多正确,都会永远返回 `401`。请改用关闭了 2FA 的专用服务账号,并把权限范围限定在它需要的文件夹上。
## 三条命令区分二者
别靠猜——用不了一分钟就能把两种原因分开。
**1. `host` 是否可达,并且是否为该名字提供了有效证书?**
```bash
curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://cloud.example.com/
```
非零的 `ssl_verify_result` 指向原因一。(在信任该证书的机器上,同一命令返回 `0`。)
**2. API 到底有没有应答?它对乱写的垃圾请求是不是也这么拒绝?**
```bash
curl -s -o - -w '\n%{http_code}\n' -X POST http://<api-host>:8791/spreadsheets/authorize \
-H 'Content-Type: application/json' \
-d '{"username":"nobody","password":"wrong","host":"cloud.example.com","protocol":"https"}'
```
```json
{"error":"Unauthorized"}
401
```
和你失败的那次调用输出一样——这正是关键。它证明服务是活的,并且 `401` 是它对*所有*认证失败的通用回答,TLS 失败也不例外。如果容器宕了,你得到的是连接错误而不是 `401`,所以这一步也能排除"API 没在运行"。
**3. 一个关闭了 2FA 的账号在好用的 `host` 上能成功吗?** 如果非 2FA 服务账号能用而你的管理员账号不行,那就是原因二。
## 相关的坑:几周后才冒出来的 `401`
你拿到的 token 是一个有效期 28 天的 JWT,但它绑定在 DSM 会话上。DSM 重启,或该账号被强制登出,都会让它提前失效。所以昨天还能用的调用今天返回 `401`,意思是"重新认证",而不是"我的凭据变了"——先检查 token 的年龄,再去找配置回归的问题。
而且登录进去之后,别把下一道墙和这一道搞混:`403 Permission denied` 表示文件存在但账号拿不到(通常是文件放在个人的 `My Drive` 主目录里,其他 DSM 账号都访问不到);`404 Spreadsheet not found` 则表示 ID 写错了。不同的问题,不同的修法。
完整的搭建过程——容器、compose 文件、可用的调用,以及我踩过的四个坑——都在 [Synology Spreadsheet API 是一个容器,不是一个 API 端点](/posts/synology-spreadsheet-api-is-a-container/) 里。
---
*我为马来西亚的中小企业构建自托管的集成和内部工具——[WhatsApp](https://wa.me/60127972969) 或 [邮件](mailto:[email protected]?subject=Synology%20API%20integration)。更多信息见 [hoelee.com](https://hoelee.com)。*
@@ -0,0 +1,277 @@
---
title: "Synology 电子表格 API 是一个容器,不是一个 API 端点"
description: "如何在 Synology Office 电子表格上做自动化:官方的 spreadsheet-api 容器,以及四个陷阱——一个假的\"未安装\"、凭据正确却报 401、永远无法通过认证的 2FA,还有一堵权限墙。"
pubDate: 2025-08-20
updatedDate: 2026-09-26
category: devops
tags: [synology, dsm, docker, portainer, rest-api, spreadsheet, debugging, self-hosting]
ogImage: /og/synology-spreadsheet-api-is-a-container.png
banner: /banners/synology-spreadsheet-api-is-a-container.png
draft: false
---
我想要的东西很普通:从我 NAS 上的电子表格读一个单元格,把一个单元格写回去,然后从另一端得到一张图表——不用打开 Excel,也不用把公司的数据送到某个云 API。
我跑着一台 Synology DS1821+,装好了 Synology Office(`Spreadsheet` 套件),所以这看起来是个已解决的问题。它确实是个已解决的问题。只是要穿过四个相互矛盾的假信号才能找到真正的答案,而且其中有一个我中途得出的结论错得相当彻底,值得写下来。
## 为什么这件事值得写
如果你自托管,总有一天你需要一个服务去和同一台机器上的另一个服务对话。这篇帖子里的故障模式并不只属于 Synology——它们就是"厂商把东西寄来了,但不在你会去找的地方"这个问题的通用形态:
1. **API 清单里缺一条,不等于这个 API 不存在。** 我枚举了 1,515 个 API,得出结论说这个功能不存在。其实它是一个容器。
2. **我信任的两个诊断工具在撒谎。** `synopkg` 把一个正在运行的套件报成"未开启",`ps` 给我看了一台空荡荡、却正在服务生产流量的机器。
3. **范围最精准的凭据失败,看起来却像是你的错。** 密码验证过正确还返回 `401`,几乎总是关于你把请求发到了*哪里*,而不是你发了*什么*。
4. **权限模型在认证之后才咬人。** 你可以完美登录,却仍然对每个文件都收到 `403`,原因在于文件所在的位置。
如果以上任何一条听起来耳熟,这篇帖子的后半部分就是能跑通的完整配置。
## 文档说了什么,以及它为什么把我带偏
Synology 宣传"Office Suite APIs"——面向 Drive、Spreadsheet、MailPlus 和 Calendar 的 REST API。营销页面上逐字列出了我想要的正是这些:
- "读取并写入某个范围内的单元格样式。"
- "新增、重命名、删除表格,并把工作表导出为 CSV 文件。"
- "创建、读取、导出和删除电子表格。执行批量更新……"
文档本身藏在 Synology 账户登录后面——这是很常见的事,不是什么丑闻——但它意味着搜索引擎先找到的是承诺,而不是契约。
于是我自己在 NAS 上找。DSM 暴露了一个网关 API 目录,你可以直接问它存在什么:
```bash
curl -s "http://192.168.1.1:8081/webapi/query.cgi?api=SYNO.API.Info&query=all" \
| python -c "import json,sys; d=json.load(sys.stdin)['data']; print(len(d), 'APIs')"
```
```
1515 APIs
```
一千五百一十五个。其中出现了 `SYNO.Office.*`——但只有快照和一个"最近使用的公式"列表:
```
SYNO.Office.Sheet.Snapshot
SYNO.Office.Sheet.Snapshot.History
SYNO.Office.Sheet.MruFc
```
没有 values 端点。没有 styles 端点。没有写入端点。我把整个目录过滤了一遍,找任何提到 sheet 或 cell 的东西,结果一无所获,然后我得出结论——并且告诉了托我建这个东西的人——**这台 NAS 没有单元格级的电子表格 API。**
那个结论是错的。我想把错在哪说清楚,因为那个推理错误才是可以复用的部分:**我把一个发现面当成了全部。** 网关目录列出的是 DSM 自己的 Web 网关服务的 API。Office Suite API 不由那个网关提供。它是一个独立服务,作为一个独立产物发布,因此对我跑的那次查询不可见。
## 陷阱一:把正在运行的服务报成未安装的工具
在找到真正的答案之前,我花了不少时间确信这个软件根本没在跑。两条命令让这个说法看起来很成立。
第一条是 Synology 自己的套件状态查询:
```bash
sudo synopkg is_onoff SynologyDrive
sudo synopkg is_onoff Spreadsheet
```
```
SynologyDrive isn't turned on, [262]
Spreadsheet isn't turned on, [262]
```
与此同时两个套件都明摆着在服务请求。`SynologyDrive` 自己的状态文件里是 `status=enabled`,Office 的守护进程也在进程表里——一个任务守护进程、一个跑在 `office` 用户账户下的连接池,还有网关 worker。在这套 DSM 版本上,`is_onoff` 根本不是一个可靠的答案。
第二条命令更糟,因为那是我自己的失误:
```bash
ps w | grep -iE "office|drive|pgbouncer"
```
```
(no output)
```
那看起来像一台死机。它不是。**在 DSM 上,不带 `sudo` 的 `ps` 只会显示你自己的进程。** 用 root 重跑一遍,画面就反转了:
```bash
sudo ps aux | grep -E "office|pgbouncer|synoscgi" | grep -v grep
```
它们全都在。我用两条根本不可能让我看到真相的命令,"证明"了两次这套栈是挂的:
> 如果要从这篇帖子里带走一个习惯:当一个诊断工具说"什么都没有在跑"时,先检查这个诊断工具到底有没有权限看到任何东西。
## 陷阱二:没有启动错误信息的容器
真正的答案,在我停止相信自己亲手得出的证据之后,出现在 Docker Hub 上:`synology/spreadsheet-api`。它不是 Office 套件的一部分,也不是网关端点。它自己的描述说得很清楚:
> "Spreadsheet API 不是 Office 套件的一部分。这个镜像在客户端和 DSM 之间提供代理服务。每个 worker 专属于单个用户和单个电子表格。"
兼容性表格把镜像标签对到 Office 版本上,这是第一件要查的事,因为配对不是可选的:
| Docker 镜像标签 | 所需的 Synology Office 版本 |
|---|---|
| `3.4.1` | 3.7.0 或更高 |
| `3.3.2` | 更老的版本 |
Synology 还警告不要把镜像和 Office 放在同一台机器上,因为每个 worker 会把整个电子表格加载进内存。我仍然把它跑在同一个 DSM 上,并加了内存上限——这是有意的取舍,不是推荐做法。
然后我跑了它,它立刻死掉,吐出一段这个:
```
/app/dist/index.js:424
}`;var ot=UD(function(){return Ht($,qe+"return "+Ee).apply(e,H)});...
```
一段压缩过的 JavaScript——最没用的一类错误信息。真相一旦找到,其实很平常:**`AUTH_SECRET` 是必填项,而且没有默认值。** 它给会话令牌签名。没有它,服务就起不来,而且没法告诉你为什么。
要可靠地读出容器的真实要求,而不是听它的启动噪音,直接向打包产物要答案:
```bash
docker run --rm --entrypoint grep synology/spreadsheet-api:3.4.1 \
-ohE '.{0,80}process\.env\.PORT.{0,80}' /app/dist/index.js
```
```js
serverPort: parseInt(process.env.PORT) || 3e3,
serverHost: process.env.HOST || "0.0.0.0",
workerTimeout: parseInt(process.env.WORKER_TIMEOUT) || 600*1e3,
```
一行就给出配置契约:`AUTH_SECRET` 给令牌签名,`PORT` 默认 `3000`,`HOST` 默认 `0.0.0.0`,worker 十分钟后回收。它还自带一份 OpenAPI 规范和 Swagger UI,这个我后面会再说到。
## 陷阱三:密码被证明正确,却收到 401
容器跑起来之后,认证成了下一堵墙。这是请求,这是它返回的东西:
```bash
curl -s -X POST http://192.168.1.1:8791/spreadsheets/authorize \
-H 'Content-Type: application/json' \
-d '{"username":"<service-account>","password":"<password>","host":"192.168.1.1:5001","protocol":"https"}'
```
```json
{"error":"Unauthorized"}
```
`401 Unauthorized`,而我用的正是刚刚登录过 DSM 的密码。最自然的解读——密码错了、账户错了——是一条死路,而我真走了进去:重新敲一遍密码,在 DSM 登录页试那个账户(能登录),把 HTTPS 换成 HTTP 端口(同样 401)。
真正的问题出在 `host` 字段。这个服务反过来问*你*该对着哪个 DSM 做认证,然后自己跟那个 host 做一次 TLS 握手。我给它的是一个裸 IP 地址,而用那个名字出示的证书和地址不匹配,于是握手失败——代理把一个失败的握手报成 `Unauthorized`,跟密码错误一模一样。
换成一个持有有效证书的主机名,立刻就修好了:
```bash
curl -s -X POST http://192.168.1.1:8791/spreadsheets/authorize \
-H 'Content-Type: application/json' \
-d '{"username":"<service-account>","password":"<password>","host":"cloud.example.com","protocol":"https"}'
```
```json
{"token":"eyJhbGciOi...","host":"cloud.example.com"}
```
> **如果你的 Synology API 登录返回 401 而你又确定密码是对的,去查 `host`:它必须是一个容器能接受其证书的名字。一个裸的局域网 IP 不行。**
它返回的令牌是 JWT,有效期 28 天,绑定一个 DSM 会话——所以 DSM 重启或账户被强制登出,它就会失效。集成中途突然冒出来的 `401`,当作"重新授权"来对待,而不是"凭据变了"。
## 陷阱四:2FA 永远无法认证
这个是如果你事先不知道就会浪费最多时间的一个,所以直说:**登录 schema 里没有一次性验证码字段。**
```yaml
AuthorizationBody:
properties:
username: {type: string}
password: {type: string}
host: {type: string}
protocol: {type: string}
```
没有 OTP,没有应用密码,没有令牌交换。如果账户开了两步验证,这个 API 会永远返回 `401`——密码正确也一样,换哪个 host 都一样。唯一可行的安排是开一个关掉 2FA 的专用服务账户,权限只限定在它需要的文件夹——这本来就是更好的做法,也是我一开始就该做的,而不是先用我自己的管理员账户去试。
## 进得来之后:权限陷阱
认证不等于授权。等我的服务账户能登录了,针对我真正关心的那个电子表格的每次调用都返回:
```json
{"statusCode":403,"code":"403","error":"Forbidden","message":"Permission denied"}
```
仔细读一下,它信息量很大:`403 Permission denied` 意味着**文件存在,但你无权拿到。** 一个错误的 ID 会返回别的东西:
```json
{"statusCode":404,"code":"404","error":"Not Found","message":"Spreadsheet not found"}
```
两种不同的失败,两种不同的修法,把两者混为一谈就要白花一个小时。我的问题是位置问题:那个电子表格建在 Synology Drive 的个人 **My Drive** 里,在磁盘上是 `/volume1/homes/<user>/Drive/Document/…`。个人主目录别的 DSM 账户够不着——权限设置不行,共享文件夹的技巧也不行,因为它根本不在任何共享文件夹里。修法是把这个文件移进一个共享文件夹并在那里授予读写权限,或者用 Drive 把那个特定文件分享给服务账户并授予编辑权。
## 修复:一整套能跑通的配置
容器,配上一个真正的密钥和内存上限:
```yaml
services:
spreadsheet-api:
image: synology/spreadsheet-api:3.4.1
container_name: spreadsheet-api
restart: always
environment:
AUTH_SECRET: "<long-random-string>"
PORT: "3000"
HOST: "0.0.0.0"
ports:
- "8791:3000"
mem_limit: 2g
```
一条只属于 DSM 的注意事项:DSM 上任何 compose 文件都别写 `cpus:`。它的内核没有 CPU CFS 调度器,这个键会被静默忽略——你得到的是有 CPU 限制的错觉,其实没有任何限制。
然后 API 就能用了,而且形态很讨喜。登录,创建或指定一个电子表格,读写范围:
```bash
TOKEN=$(curl -s -X POST http://192.168.1.1:8791/spreadsheets/authorize \
-H 'Content-Type: application/json' \
-d '{"username":"<acct>","password":"<pw>","host":"cloud.example.com","protocol":"https"}' \
| python -c "import json,sys; print(json.load(sys.stdin)['token'])")
# read a range (sheet-qualified A1 notation)
curl -s -H "Authorization: Bearer ***" \
"http://192.168.1.1:8791/spreadsheets/<id>/values/Sheet1!A1:C4"
# write a range, formulas included
curl -s -X PUT -H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
-d '{"values": [["Item","Qty"],["Widget",12],["Gadget",7],["Total","=SUM(B2:B3)"]]}' \
"http://192.168.1.1:8791/spreadsheets/<id>/values/Sheet1!A1:B4"
```
两个我没想到的细节,都是对我有利的:
- **电子表格 ID 来自 Office 的 URL。** 一个位于 `…/oo/r/<spreadsheetId>` 的电子表格——包括分享链接转到的也正是这个路径——把它那一段放进 API 调用就能寻址。
- **公式是 Office 算的,不是你算的。** 把表格导出成 CSV 返回的是 `=SUM(B2:B3)` 和 `=SUMPRODUCT(B2:B3,C2:C3)` 的*求值后*结果——这些公式我从没在客户端算过。这正是用这个 API 而不是自己解析文件的全部理由:你继承了 Synology 自己的计算引擎。
有一个值得说出来的真实缺口:**没有 list 端点。** 规范声明了一个 "Statistics" 标签,却没有为它提供任何路由(`/statistics` → `404 Route not found`),也没有别的东西能枚举电子表格。你必须已经从文档的 URL 知道那个 ID。对你有意搭好的自动化来说没问题;如果你想浏览,那就没用。
## 从另一端得到一张图表
读单元格只是半件事;最初的要求里还有图表。同一个 API 能直接给你一份真正的 workbook:
```bash
curl -s -H "Authorization: Bearer ***" \
"http://192.168.1.1:8791/spreadsheets/<id>/xlsx" -o export.xlsx
```
那会返回一个合法的 `.xlsx`——`PK` 魔数,什么都能打开。接着我自己的服务(一个小的 FastAPI 容器,里面有 LibreOffice 负责公式求值,matplotlib 负责渲染)读取范围并生成 PNG 图表和仪表盘,整条管线端到端跑通,不需要 Excel,也不需要任何云依赖:
```
Synology Office → spreadsheet-api → .xlsx export → chart renderer → PNG
```
## 重来一次我会怎么做
1. **在断定某个 Synology 功能不存在之前,先去查 Docker Hub。** 我花了不少真实时间,从一个根本不可能包含答案的 API 清单去证明一个反面结论。`synology/*` 在 Docker Hub 上,一次搜索就到。
2. **一开始就用一个关掉 2FA 的专用服务账户**,权限限定在单个共享文件夹。我先用管理员账户试,所以同一小时内同时撞上了 2FA 墙和 `403` 墙。
3. **当一个诊断工具说"什么都没有在跑",检查它的权限。** DSM 上不带 root 的 `ps` 不是进程列表,它是你自己的进程列表,它会高高兴兴地让你得出机器已死的结论。
4. **读容器本身,别只读它的文档。** 厂商自己的运行命令在镜像页面上,但精确的默认值(`PORT` 3000,worker 超时 600 秒)和必填的 `AUTH_SECRET` 来自 grep 打包产物——一条命令,不用猜。
## 结果
在 NAS 上端到端验证:登录、创建、写单元格、读回单元格、CSV 导出和 `.xlsx` 导出全部返回 `200`,CSV 导出证明了 Office 引擎在服务端求值了 `=SUM`/`=SUMPRODUCT`。导出的 workbook 喂给图表服务,把同一份数据渲染成 PNG。四个陷阱记录在案,一个错误结论得到纠正,大约一天的活——现在我自己的硬件上有一份脚本可以驱动的电子表格。
---
*我在马来西亚为小企业做这类自托管集成——电子表格和 NAS 自动化、仪表盘,以及把你的数据留在你自己硬件上的内部工具。如果你需要这类东西,[WhatsApp 联系我](https://wa.me/60127972969) 或 [给我发邮件](mailto:[email protected]?subject=Self-hosted%20spreadsheet%20automation)——也可以看看我在 [hoelee.com](https://hoelee.com) 还做些什么。*