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
+21
View File
@@ -241,6 +241,27 @@ holding the real date, so the sitemap `lastmod` stays honest and listings still
- **Done when:** ✅ 8 pages 200 with expected content, language switch links both ways, 8 images served as `image/png`,
archive order still monotonic on `/posts/`, the homepage and `/zh/`.
**Step B2g — (unplanned) Three posts out of the Synology Office / spreadsheet-API session.** ✅ Done 2026-09-29
One session spent making a Synology NAS read, write and chart spreadsheets produced three posts. All three are
**backdated** into the 2024-09-24 → 2025-11-12 gap — the widest stretch in the archive with no posts — with
`updatedDate: 2026-09-26` holding the real date, so `lastmod` stays honest and listings still sort by `pubDate`:
| Slug | Category | pubDate | What it argues |
|---|---|---|---|
| `synology-spreadsheet-api-is-a-container` | `devops` | 2025-08-20 | The flagship. Enumerating the DSM gateway (1,515 APIs) showed no cell-level Office endpoint, so I concluded the NAS had no spreadsheet API — **wrong**: it ships as the container `synology/spreadsheet-api` (image tag ↔ Office version table). Plus two diagnostics that lied (`synopkg is_onoff` reporting a running package as "not turned on"; `ps` without `sudo` on DSM listing only your own processes, which made a live stack look dead), a required `AUTH_SECRET` whose absence crashes with a minified stack trace, a `401` with provably correct credentials, 2FA that can never authenticate, `403` vs `404` semantics, a personal `My Drive` unreachable by any service account, and the verified fix — read/write/CSV/`.xlsx` with Synology's own engine evaluating the formulas, then a chart out the far end. |
| `synology-api-401-with-the-correct-password` | `notes` | 2025-09-10 | The two causes of a `401` when the password is right: `host` must be an FQDN whose certificate the proxy accepts (a bare LAN IP fails its TLS handshake, and a failed handshake is reported identically to a bad password), and a 2FA account can never sign in (`AuthorizationBody` has no OTP field). Includes the three-command triage that separates them, and why a token that worked yesterday returns `401` today — it's bound to the DSM session, not just to a 28-day clock. |
| `reading-a-containers-own-api-docs` | `notes` | 2025-10-01 | Extract a container's contract from the artifact instead of the vendor's page: `--entrypoint cat` the bundled OpenAPI spec, `--entrypoint grep` the bundle for the env-var contract and the defaults, read Env/Entrypoint/Cmd from the registry config blob without pulling a byte, decode the real listening port from `/proc/net/tcp`, and run detached to read startup logs without hanging the shell. |
- All three EN + ZH, custom OG + banner (centering **measured**, not eyeballed: gapAbove/gapBelow 47/49, 76/78,
106/108, `delta=2px`, `overflow=0`), hire CTA naming self-hosted integrations; the two `notes` posts link up to
the flagship with a relative link.
- **Why:** the search results for `synology spreadsheet api` / `spreadsheet-api docker` are Synology's own Hub page,
a German how-to and two MCP wrappers — nothing covers the failure modes, and the "vendor tool told me the wrong
thing" shape matches the blog's strongest existing genre (`patching-workbench-26-for-mariadb`,
`when-smart-says-healthy-but-your-raid-is-corrupting-data`).
- **Done when:** ✅ 6 pages 200 with expected content, language switch links both ways, 6 images served as
`image/png`, archive order still monotonic on `/posts/`, the homepage and `/zh/`.
### Phase C — Discovery & structure (Tier 2)
**Step C1 — Per-post custom OG images (at least for case studies).**
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+63
View File
@@ -866,6 +866,69 @@ BANNERS['one-prometheus-for-unraid-synology-and-a-vps'] = {
],
};
BANNERS['synology-spreadsheet-api-is-a-container'] = {
titlebar: 'root@dsm — office suite api',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl .../SYNO.API.Info&query=all' },
{ t: 'ok', text: '1515 APIs · SYNO.Office has no cell endpoint' },
{ t: 'err', text: '→ "the NAS has no spreadsheet API" ← wrong' },
{ t: 'dim', text: 'synopkg is_onoff SynologyDrive' },
{ t: 'err', text: 'not turned on ← while its daemons were serving traffic' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker pull synology/spreadsheet-api:3.4.1' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /spreadsheets/authorize' },
{ t: 'err', text: '401 Unauthorized · host must be an FQDN with a valid cert' },
{ t: 'ok', text: '→ read · write · csv · xlsx ✓' },
],
flow: [
{ n: '1', label: '1515 APIs' },
{ n: '2', label: 'no endpoint', err: true },
{ n: '3', label: 'it is a container' },
{ n: '4', label: '401 to FQDN' },
{ n: '5', label: 'cells ✓' },
],
};
BANNERS['synology-api-401-with-the-correct-password'] = {
titlebar: 'root@dsm — authorize · 401',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'POST /spreadsheets/authorize · correct password' },
{ t: 'err', text: '401 {"error":"Unauthorized"}' },
{ t: 'dim', text: 'host = 192.168.1.1:5001 ← cert does not match the IP' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'host = cloud.example.com · protocol = https' },
{ t: 'ok', text: '200 → token · JWT · 28 days ✓' },
{ t: 'dim', text: '2FA account: no OTP field in AuthorizationBody' },
{ t: 'err', text: '→ 401 on every host, every correct password' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'use a dedicated service account, 2FA off' },
],
flow: [
{ n: '1', label: '401', err: true },
{ n: '2', label: 'password fine' },
{ n: '3', label: 'host / cert' },
{ n: '4', label: 'FQDN + https' },
{ n: '5', label: 'token ✓' },
],
};
BANNERS['reading-a-containers-own-api-docs'] = {
titlebar: 'root@dsm — the image is the source of truth',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker run --rm --entrypoint cat $IMG /app/public/openapi.yml' },
{ t: 'ok', text: 'OpenAPI 3.1 · 15 endpoints · AuthorizationBody schema' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'entrypoint grep $IMG -rhoE process.env.[A-Z_]+ /app/dist' },
{ t: 'hl', text: 'AUTH_SECRET · PORT · HOST · USER_TIMEOUT · WORKER_TIMEOUT' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl registry-1.docker.io/v2/.../blobs/<config>' },
{ t: 'dim', text: 'Entrypoint: docker-entrypoint.sh Cmd: node dist/index.js' },
{ t: 'ok', text: '→ contract known before pulling a byte ✓' },
],
flow: [
{ n: '1', label: 'docs gated' },
{ n: '2', label: 'cat the spec' },
{ n: '3', label: 'grep env vars' },
{ n: '4', label: 'registry blob' },
{ n: '5', label: 'contract ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+18
View File
@@ -268,6 +268,24 @@ TERMINALS['one-prometheus-for-unraid-synology-and-a-vps'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">KVM guest: no cpufreq · 0 series nodename = 9f9afcccc962</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">dedup selector · textfile collector · hostname pin</span><span class="fix">→ 7 targets ✓</span></div>`;
TERMINALS['synology-spreadsheet-api-is-a-container'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">curl ...SYNO.API.Info&amp;query=all</span><span class="fix">1515 APIs · no cell endpoints</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">→ conclusion: "the NAS has no spreadsheet API" (wrong)</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">docker pull synology/spreadsheet-api:3.4.1</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">POST /spreadsheets/authorize · host must be an FQDN</span><span class="fix">→ read · write · xlsx ✓</span></div>`;
TERMINALS['synology-api-401-with-the-correct-password'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">POST /spreadsheets/authorize · correct password</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">401 Unauthorized ← host=192.168.1.1:5001 · cert mismatch</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">2FA account: 401 forever · no OTP field in the schema</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">host=cloud.hoelee.com · protocol=https</span><span class="fix">→ token ✓</span></div>`;
TERMINALS['reading-a-containers-own-api-docs'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">docker run --rm --entrypoint cat $IMG /app/public/openapi.yml</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="fix">27 KB OpenAPI spec · 15 endpoints · auth model</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">grep process.env → AUTH_SECRET · PORT 3000 · WORKER_TIMEOUT</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">exec cat /proc/net/tcp</span><span class="fix">→ listening on 3000 ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,189 @@
---
title: "Read a Container's Own API Docs Instead of the Vendor's"
description: "When the API documentation is behind a login or doesn't exist, the image is still the source of truth: four Docker commands that extract the API spec, the config contract, and the port it listens on."
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
---
I needed to drive a vendor's self-hosted API. Their docs existed, but behind an account login, and the page I could reach described what the API *could* do rather than how to configure it. The container, meanwhile, was sitting right there on my machine — and it contained the answers.
This is the set of commands I now run first for any image whose documentation is thin, gated, or missing. Everything here is read-only and needs no shell inside the container.
## Why this matters
1. **Vendor docs describe the happy path; the container describes the contract.** Exact env var names, defaults, exposed ports, whether a config file is required — none of that is reliably in a marketing page.
2. **Many images have no docs at all** — a private registry, an internal build, or a community image someone pulled and pushed. The artifact is the only authority.
3. **Version drift is real.** Docs on a website describe the latest release; the image you're running might be two years old. Reading the artifact tells you about the thing you actually deployed.
## 1. Read files out of the image without running it
The single most useful trick: override the entrypoint and treat the image as a filesystem.
```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
```
That second command returned a full OpenAPI 3.1 specification, 27 KB, inside the image — complete with every endpoint, request body schema, and the authentication model. No login, no account, no gated docs.
If you don't know the file layout yet, `--entrypoint find` covers more ground:
```bash
docker run --rm --entrypoint find <image> / -maxdepth 3 \
-name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -30
```
## 2. Extract the configuration contract from the bundle
Env var names are the part that will actually stop you — a required variable with no default means the container dies at startup, usually with an unhelpful error. Ask the source directly:
```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
```
Nine names, and now you know the whole config surface. For a default value, grep with context around the one you care about:
```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` defaults to 3000, host to `0.0.0.0`, workers recycle after ten minutes. That's three uncertain decisions removed in one command. This works for any Node/Python image; for a Go or Rust binary the same idea applies with `strings` on the binary instead.
## 3. Inspect the image config without pulling it
On a slow link, or before you commit to a 1 GB download, you can read the image's metadata straight from the registry. No local Docker daemon needed:
```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 $TOKEN" \
-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'])"
```
Then fetch that config blob and print the interesting fields:
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
"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: []
```
Two useful facts fell out of that before downloading a byte: it's a Node service, and it declares **no exposed ports** — so any port mapping has to come from the `PORT` variable, not from `EXPOSE`. Also worth checking while you're there: the tags list tells you the real version history.
```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. Find the port it actually listens on
`EXPOSE` is documentation, not behaviour. To see what the process really bound to, look inside a running container:
```bash
docker exec <container> cat /proc/net/tcp
```
The port is in hex in field 2 — `0A` in field 4 means `LISTEN`. Decode it:
```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
```
Then confirm it answers, without leaving the container:
```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
```
A redirect to `/docs` — the image was serving its own Swagger UI the whole time.
## 5. Read startup logs without hanging your shell
Piping `docker run` straight into `head` or a log filter looks harmless and will hang: the process keeps the pipe open, so your command never returns. Run it detached, read the logs, then remove it:
```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
```
That's the whole answer in two lines — and it also told me the container starts fine *with* `AUTH_SECRET` and dies *without* it, which is a far better signal than the minified stack trace I got from running it in the foreground and watching it crash.
## What this doesn't replace
Read the vendor's own documentation when it exists — it's usually faster than spelunking, and it carries things an image can't tell you, like version compatibility tables. On this particular image, the vendor's page *did* publish the run command and the required `AUTH_SECRET`; I found them in the image first only because I hadn't scrolled far enough down the page. Use both: the page for intent, the artifact for the exact contract you're going to deploy.
## The result
Four commands — `cat` the bundled spec, `grep` the bundle for env vars, read the config blob from the registry, `exec cat /proc/net/tcp` for the port — replaced a documentation hunt with: a full OpenAPI spec, all nine env vars, the default port, the config defaults, and a Swagger UI URL. No shell in the container, no login, nothing mutated.
---
*If you're integrating a self-hosted system and the docs stop short, I do that work for small businesses in Malaysia — [WhatsApp](https://wa.me/60127972969), [email](mailto:[email protected]?subject=API%20integration), or [hoelee.com](https://hoelee.com).*
@@ -0,0 +1,103 @@
---
title: "Why Your Synology API Login Returns 401 With the Correct Password"
description: "Two causes, one error: the Office Suite API reports a failed TLS handshake and a 2FA-protected account identically. How to tell them apart in three commands instead of an afternoon."
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
---
You set up Synology's Office Suite API, you send your credentials, and you get:
```json
{"error":"Unauthorized"}
```
You then verify the password by logging into DSM with it — works fine. You retype it, you try HTTP instead of HTTPS, you wonder whether the account needs a permission you can't find. All of it is wasted motion, because on this API a `401` with a correct password has exactly two causes, and neither of them is the password.
## Cause 1: the `host` field must be a name with a certificate the container accepts
The API is self-configuring: you tell *it* which DSM to authenticate against, on every sign-in call.
```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"}
```
The proxy performs its own TLS handshake with that `host`. A bare IP address usually presents a certificate issued for a hostname, so the handshake fails — and a failed handshake is reported as `Unauthorized`, identically to a wrong password. Nothing in the response hints at TLS.
Same request, same credentials, `host` changed to a name with a valid certificate:
```bash
-d '{"username":"acct","password":"pw","host":"cloud.example.com","protocol":"https"}'
```
```json
{"token":"eyJhbGciOi...","host":"cloud.example.com"}
```
That is the whole fix. **Rule: the `host` value must be an FQDN whose certificate the container will accept, with the port included if it isn't the default for the scheme.**
## Cause 2: two-factor authentication can never work here
The second cause is structural. The sign-in schema has four fields and no one-time-code field:
```yaml
AuthorizationBody:
properties:
username: {type: string}
password: {type: string}
host: {type: string}
protocol: {type: string}
```
No OTP, no app password, no device-token exchange. An account with 2FA enabled returns `401` on every host, with every correct password, forever. Use a dedicated service account with 2FA off, scoped to the folders it needs.
## Telling them apart in three commands
Don't guess — separate the two in under a minute.
**1. Is the host reachable and serving a valid certificate for that name?**
```bash
curl -sS -o /dev/null -w '%{http_code} %{ssl_verify_result}\n' https://cloud.example.com/
```
A non-zero `ssl_verify_result` points at cause 1. (From a machine that trusts the cert, the same command returns `0`.)
**2. Does the API answer at all, and does it reject garbage the same way?**
```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
```
Same output as your failing call — which is the point. It proves the service is alive and that `401` is its generic answer for *every* authentication failure, TLS included. A container that is down gives you a connection error, not a `401`, so this also rules out "the API isn't running".
**3. Does an account with 2FA off succeed against the good host?** If a non-2FA service account works and your admin account doesn't, you have cause 2.
## The related trap: a `401` that appears weeks later
The token you get is a JWT valid for 28 days, but it's bound to a DSM session. A DSM restart, or a forced logout of that account, invalidates it early. So a `401` on a call that worked yesterday means "re-authenticate", not "my credentials changed" — check the token's age before you go looking for a config regression.
And once you're in, don't confuse the next wall with this one: `403 Permission denied` means the file exists but the account can't have it (usually a file sitting in a personal `My Drive` home folder, unreachable by any other DSM account), while `404 Spreadsheet not found` means the ID is wrong. Different problems, different fixes.
The full setup — the container, the compose file, the working calls, and the four traps I hit getting there — is in [The Synology Spreadsheet API Is a Container, Not an API Endpoint](/posts/synology-spreadsheet-api-is-a-container/).
---
*I build self-hosted integrations and internal tools for small businesses in Malaysia — [WhatsApp](https://wa.me/60127972969) or [email](mailto:[email protected]?subject=Synology%20API%20integration). More at [hoelee.com](https://hoelee.com).*
@@ -0,0 +1,277 @@
---
title: "The Synology Spreadsheet API Is a Container, Not an API Endpoint"
description: "How to automate Synology Office spreadsheets: the official spreadsheet-api container, and the four traps — a fake 'not installed', a 401 with correct credentials, 2FA that can never authenticate, and a permission wall."
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
---
I wanted something ordinary: read a cell from a spreadsheet on my NAS, write a cell back, and have a chart come out the other end — without opening Excel, and without shipping my company's numbers to a cloud API.
I run a Synology DS1821+ with Synology Office (the `Spreadsheet` package) installed, so this looked like a solved problem. It is a solved problem. It just took four separate false signals to find the actual solution, and one of my conclusions along the way was flatly wrong in a way worth writing down.
## Why this matters
If you self-host, you will eventually need a service to talk to another service on the same box. The failure modes in this post are not Synology-specific — they are the general shape of "the vendor ships the thing, but not where you'd look for it":
1. **A missing entry in an API list is not proof the API doesn't exist.** I enumerated 1,515 APIs and concluded the feature was absent. It was a container.
2. **Two of the diagnostics I trusted were lying.** `synopkg` reported a running package as "not turned on", and `ps` showed me an empty machine that was serving production traffic.
3. **The best-scoped credential failure looks like your fault.** A `401` with a verified-correct password is almost always about *where* you sent the request, not *what* you sent.
4. **The permission model bites after authentication.** You can log in perfectly and still be told `403` on every file, because of where the file lives.
If any of that sounds familiar, the second half of this post is the working setup.
## What the docs say, and why that sent me the wrong way
Synology advertises "Office Suite APIs" — REST APIs for Drive, Spreadsheet, MailPlus and Calendar. The marketing page lists exactly what I wanted, verbatim:
- "Read and write cell styles within a range."
- "Add, rename, delete, and export sheets as CSV files."
- "Create, retrieve, export, and delete spreadsheet. Perform batch updates…"
The documentation itself sits behind a Synology Account login, which is a normal thing to hit and not a scandal — but it means the first thing a search engine finds is the promise, not the contract.
So I went looking on my own NAS. DSM exposes a gateway API catalogue, and you can just ask it what exists:
```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
```
Fifteen hundred and fifteen. Among them, `SYNO.Office.*` appears — but only for snapshots and a "recently used formulas" list:
```
SYNO.Office.Sheet.Snapshot
SYNO.Office.Sheet.Snapshot.History
SYNO.Office.Sheet.MruFc
```
No values endpoint. No styles endpoint. No write endpoint. I filtered the whole catalogue for anything mentioning sheets or cells and came up empty, and I concluded — and told the person I was building this for — that **the NAS had no cell-level spreadsheet API.**
That conclusion was wrong. I want to be precise about why it was wrong, because the reasoning error is the reusable part: **I treated one discovery surface as exhaustive.** The gateway catalogue lists APIs served by DSM's own web gateway. The Office Suite API is not served by that gateway. It is a separate service, published as a separate artifact, and therefore invisible to the query I ran.
## Trap 1: the tool that says a running service isn't installed
Before finding the real answer, I spent a while convinced the software wasn't even running. Two commands made that look true.
The first was Synology's own package-state query:
```bash
sudo synopkg is_onoff SynologyDrive
sudo synopkg is_onoff Spreadsheet
```
```
SynologyDrive isn't turned on, [262]
Spreadsheet isn't turned on, [262]
```
Meanwhile both packages were plainly serving requests. `SynologyDrive` had `status=enabled` in its own state file, and the Office daemons were in the process table — a task daemon, a connection pooler running under an `office` user account, and gateway workers. `is_onoff` was simply not a reliable answer on this DSM build.
The second command was worse because it was my own mistake:
```bash
ps w | grep -iE "office|drive|pgbouncer"
```
```
(no output)
```
That looks like a dead system. It isn't. **On DSM, `ps` without `sudo` shows you only your own processes.** Re-run it as root and the picture inverts:
```bash
sudo ps aux | grep -E "office|pgbouncer|synoscgi" | grep -v grep
```
There they all are. I had "proven" the stack was down twice, using two commands that couldn't have shown me otherwise:
> If you take one habit from this post: when a diagnostic says "nothing is running", check whether the diagnostic had the privileges to see anything at all.
## Trap 2: the container with no startup error message
The actual answer, once I stopped believing my own evidence, was on Docker Hub: `synology/spreadsheet-api`. Not part of the Office package, not a gateway endpoint. Its own description is unambiguous:
> "Spreadsheet API is not part of the Office package. This image provides a proxy service between clients and DSM. Each worker is dedicated to a single user and a single spreadsheet."
The compatibility table matches image tags to Office versions, which is the first thing to check because the pairing is not optional:
| Docker image tag | Required Synology Office |
|---|---|
| `3.4.1` | 3.7.0 or higher |
| `3.3.2` | older |
Synology also warns against co-locating it with Office, since each worker loads whole spreadsheets into memory. I ran it on the same DSM anyway and capped it, which is a deliberate trade rather than a recommendation.
Then I ran it and it died immediately with this:
```
/app/dist/index.js:424
}`;var ot=UD(function(){return Ht($,qe+"return "+Ee).apply(e,H)});...
```
A minified JavaScript blob — the least useful class of error message there is. The cause, once found, was mundane: **`AUTH_SECRET` is required and has no default.** It signs the session tokens. Without it the service cannot start and cannot tell you why.
The reliable way to read a container's actual requirements, rather than its startup noise, is to ask the bundle directly:
```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,
```
That gives you the config contract in one line: `AUTH_SECRET` to sign tokens, `PORT` defaulting to `3000`, `HOST` to `0.0.0.0`, workers recycled after ten minutes. It also ships its own OpenAPI spec and a Swagger UI, which I'll come back to.
## Trap 3: a 401 while the password is provably correct
With the container running, authentication became the next wall. Here is the request, and here is what it returned:
```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` with a password I had just used to sign into DSM. The natural reading — wrong password, wrong account — is a dead end, and I went down it: retyped the password, tried the account on the DSM login page (it worked), tried the HTTP port instead of HTTPS (same 401).
The actual problem was the `host` field. The service asks *you* which DSM it should authenticate against, then does its own TLS handshake with that host. I had given it a bare IP address, and the certificate presented on that name doesn't match the address, so the handshake failed — and the proxy reports a failed handshake as `Unauthorized`, exactly like a bad password would.
Switching to a hostname with a valid certificate fixed it instantly:
```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"}
```
> **If your Synology API login returns 401 and you're sure the password is right, check the `host`: it must be a name whose certificate the container accepts. A bare LAN IP is not that.**
The token it returns is a JWT, valid for 28 days, tied to a DSM session — so it dies if DSM restarts or the account is forcibly logged out. Treat a sudden `401` mid-integration as "re-authorize", not "credentials changed".
## Trap 4: 2FA cannot ever authenticate
This is the one that would waste the most time if you didn't know it in advance, so here it is plainly: **the sign-in schema has no one-time-code field.**
```yaml
AuthorizationBody:
properties:
username: {type: string}
password: {type: string}
host: {type: string}
protocol: {type: string}
```
No OTP, no app-password, no token exchange. If the account has two-factor authentication enabled, this API will return `401` forever, with the correct password, on every host. The only workable arrangement is a dedicated service account with 2FA off, scoped to the folders it needs — which is better practice anyway, and is what I should have started with instead of testing against my own admin account first.
## The permissions trap, after you're in
Authentication is not authorization. Once my service account could log in, every call against the spreadsheet I actually cared about returned:
```json
{"statusCode":403,"code":"403","error":"Forbidden","message":"Permission denied"}
```
Read that carefully, because it is informative: `403 Permission denied` means **the file exists and you may not have it.** A bad ID returns something different:
```json
{"statusCode":404,"code":"404","error":"Not Found","message":"Spreadsheet not found"}
```
Two different failures, two different fixes, and conflating them costs an hour. Mine was a location problem: the spreadsheet had been created in Synology Drive's personal **My Drive**, which on disk is `/volume1/homes/<user>/Drive/Document/…`. A personal home directory is not reachable by another DSM account — not by permission settings, not by shared-folder tricks, because it isn't in a shared folder at all. The fix is to move the file into a shared folder and grant Read/Write there, or to Drive-share the specific file to the service account with edit rights.
## The fix: the whole working setup
The container, configured with a real secret and a memory cap:
```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
```
One DSM-specific note: omit `cpus:` from any compose file on DSM. Its kernel has no CPU CFS scheduler, so the key is silently ignored — you get the illusion of a CPU limit and no limit.
Then the API works, and the shape is pleasant. Sign in, create or address a spreadsheet, read and write ranges:
```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 $TOKEN" \
"http://192.168.1.1:8791/spreadsheets/<id>/values/Sheet1!A1:C4"
# write a range, formulas included
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -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"
```
Two details I did not expect, both in my favour:
- **The spreadsheet ID comes from the Office URL.** A spreadsheet at `…/oo/r/<spreadsheetId>` — including one behind a share link, which redirects into exactly that path — is addressable by putting that segment in the API call.
- **Office computes the formulas, not you.** Exporting a sheet as CSV returned the *evaluated* results of `=SUM(B2:B3)` and `=SUMPRODUCT(B2:B3,C2:C3)` — formulas I never evaluated client-side. That's the whole reason to use this API instead of parsing a file yourself: you inherit Synology's own calculation engine.
There is one real gap worth stating: **there is no list endpoint.** The spec declares a "Statistics" tag but serves no route for it (`/statistics` → `404 Route not found`), and nothing else enumerates spreadsheets. You must already know the ID from the document's URL. Fine for automation you set up on purpose; useless if you wanted to browse.
## Getting a chart out the other end
Reading cells was half the job; the original ask included charts. The same API can hand you a real workbook:
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
"http://192.168.1.1:8791/spreadsheets/<id>/xlsx" -o export.xlsx
```
That returns a valid `.xlsx` — `PK` magic, opens in anything. From there my own service (a small FastAPI container with LibreOffice inside it for formula evaluation and matplotlib for rendering) reads the ranges and produces PNG charts and dashboards, so the pipeline runs end to end without Excel and without any cloud dependency:
```
Synology Office → spreadsheet-api → .xlsx export → chart renderer → PNG
```
## What I'd do differently
1. **Check Docker Hub before concluding a Synology feature doesn't exist.** I burned real time proving a negative from an API list that could not have contained the answer. `synology/*` on Docker Hub was one search away.
2. **Start with a dedicated, non-2FA service account** scoped to one shared folder. I tested with an admin account first, which is how I met the 2FA wall and the `403` wall in the same hour.
3. **When a diagnostic says "nothing is running", check its privileges.** `ps` without root on DSM is not a process list, it's a list of your own processes, and it will happily let you conclude the machine is dead.
4. **Read the container, not just its docs.** The vendor's own run command is on the image page, but the exact defaults (`PORT` 3000, worker timeout 600 s) and the required `AUTH_SECRET` came from grepping the bundle — one command, no guessing.
## The result
Verified end to end on the NAS: sign-in, create, cell write, cell read-back, CSV export and `.xlsx` export all returned `200`, and the CSV export proved the Office engine evaluated `=SUM`/`=SUMPRODUCT` server-side. The exported workbook fed a chart service that rendered the same data to PNG. Four traps documented, one wrong conclusion corrected, roughly a day of work — and a spreadsheet on my own hardware that a script can now drive.
---
*I build self-hosted integrations like this for small businesses in Malaysia — spreadsheet and NAS automation, dashboards, and internal tools that keep your data on your own hardware. If that's the kind of thing you need, [WhatsApp me](https://wa.me/60127972969) or [email me](mailto:[email protected]?subject=Self-hosted%20spreadsheet%20automation) — or see what else I do at [hoelee.com](https://hoelee.com).*
@@ -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) 还做些什么。*