Add post: A Read-Only NocoDB Dashboard for a Database on Another Machine
Deploy / build (push) Successful in 28s
Deploy / build (push) Successful in 28s
EN + ZH twins. The recipe for pointing NocoDB at a MySQL/MariaDB database on another machine: the Docker SNAT source-IP trap (the DB sees the host IP, not the container IP), a SELECT-only grant restricted to that one host, the async source-creation API with no job-status route, the auto-sync that makes a manual table step unnecessary (and the create-table route that makes junk tables), and what a read-only source costs (no metadata edits, UTC-labelled DATETIMEs). Also adds the og-gen TERMINALS and banner-gen BANNERS entries for the slug (keeping the sibling session's entries untouched) and the generated PNGs.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -624,6 +624,67 @@ BANNERS['why-i-still-bought-a-local-gpu'] = {
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['migrating-codeigniter-iis-to-openlitespeed'] = {
|
||||
titlebar: 'root@cyberpanel — docroot public/',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'cp -r app/ public/ → docroot = public_html/public' },
|
||||
{ t: 'dim', text: 'on IIS these routes only ever answered 302 (SSO) — the code never ran' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -sI http://new-host/lifecode' },
|
||||
{ t: 'err', text: 'Fatal error: Call to undefined function env() · Constants.php' },
|
||||
{ t: 'err', text: 'Fatal error: Cannot call constructor · Welcome.php' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'pure constants only · override initController()' },
|
||||
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 19-page A4 report renders ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'IIS → OpenLiteSpeed' },
|
||||
{ n: '2', label: '500 on /lifecode', err: true },
|
||||
{ n: '3', label: 'SSO had hidden it' },
|
||||
{ n: '4', label: 'two fatal fixes' },
|
||||
{ n: '5', label: 'report renders ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['upgrading-codeigniter-46-to-47'] = {
|
||||
titlebar: '~/numerology-report — composer update',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'composer update codeigniter4/framework' },
|
||||
{ t: 'ok', text: '4.6.3 → 4.7.4 · upgrade guide read · 8 breaking changes audited' },
|
||||
{ t: 'dim', text: 'none of the documented changes applied to this codebase' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'php spark routes' },
|
||||
{ t: 'err', text: 'Undefined property: Config\\App::$permittedURIChars' },
|
||||
{ t: 'err', text: 'Undefined property: Config\\Format::$jsonEncodeDepth' },
|
||||
{ t: 'ok', text: 'merge project-space configs by hand → routes + report OK ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: '4.6 → 4.7' },
|
||||
{ n: '2', label: 'guide: 8 changes' },
|
||||
{ n: '3', label: 'none applied' },
|
||||
{ n: '4', label: '2 undefined props', err: true },
|
||||
{ n: '5', label: 'merge configs ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
BANNERS['read-only-nocodb-dashboard-for-a-remote-database'] = {
|
||||
titlebar: 'root@dsm — nocodb · bridge_hoelee',
|
||||
lines: [
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'docker exec mysql-server mysql -h192.168.1.124 -e "SELECT CURRENT_USER();"' },
|
||||
{ t: 'err', text: '[email protected] ← the host IP, not 172.16.0.4' },
|
||||
{ t: 'dim', text: 'container egress is SNAT-ed through the host' },
|
||||
{ t: 'cmd', text: 'nocodb → POST /meta/bases/{id}/sources · mysql2' },
|
||||
{ t: 'hl', text: 'meta.dbVersion = 10.11.19-MariaDB-ubu2404 → connected' },
|
||||
{ t: 'prompt', text: '$' }, { t: 'cmd', text: "GRANT SELECT ON appdb.* TO 'nocodb_ro'@'192.168.1.1'" },
|
||||
{ t: 'err', text: 'DROP command denied — read-only enforced by the database' },
|
||||
{ t: 'ok', text: '4 tables live · 0 rows duplicated ✓' },
|
||||
],
|
||||
flow: [
|
||||
{ n: '1', label: 'NocoDB → VM db' },
|
||||
{ n: '2', label: 'SNAT → host IP', err: true },
|
||||
{ n: '3', label: 'SELECT-only grant' },
|
||||
{ n: '4', label: 'source auto-sync' },
|
||||
{ n: '5', label: 'live dashboard ✓' },
|
||||
],
|
||||
};
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
let category = 'devops';
|
||||
|
||||
@@ -197,6 +197,23 @@ TERMINALS['why-i-still-bought-a-local-gpu'] = `
|
||||
<div class="line"><span class="prompt"> </span><span class="fix">local: 50 calls · $0.00 · runs on my card</span></div>
|
||||
`;
|
||||
|
||||
TERMINALS['read-only-nocodb-dashboard-for-a-remote-database'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">docker exec mysql-server mysql -h192.168.1.124 -e "SELECT CURRENT_USER();"</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">[email protected] — the host IP, not the container IP</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">GRANT SELECT ON appdb.* · source auto-sync</span><span class="fix">→ live ✓</span></div>`;
|
||||
|
||||
TERMINALS['migrating-codeigniter-iis-to-openlitespeed'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -sI http://new-host/lifecode</span><span class="err">→ 500</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">Fatal error: Call to undefined function env() · Constants.php</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">Fatal error: Cannot call constructor · Welcome.php</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">pure constants · override initController()</span><span class="fix">→ report renders ✓</span></div>`;
|
||||
|
||||
TERMINALS['upgrading-codeigniter-46-to-47'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">composer update codeigniter4/framework</span><span class="fix">4.6.3 → 4.7.4</span></div>
|
||||
<div class="line"><span class="prompt">INFO</span><span class="cmd">upgrade guide read · 8 breaking changes · none apply</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">Undefined property Config\\App::$permittedURIChars → 500</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">merge project-space configs by hand</span><span class="fix">→ report renders ✓</span></div>`;
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
if (!existsSync(postPath)) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
---
|
||||
title: "A Read-Only NocoDB Dashboard for a Database on Another Machine"
|
||||
description: "Point NocoDB at a MySQL/MariaDB database on another machine without copying a single row — and avoid the Docker SNAT source-IP trap and two API traps I hit."
|
||||
pubDate: 2026-09-29
|
||||
category: devops
|
||||
tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
|
||||
ogImage: /og/read-only-nocodb-dashboard-for-a-remote-database.png
|
||||
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
My report app writes every lead and every order into MariaDB running on a small VM. The
|
||||
person who actually runs the business wanted to *look* at those rows — filter them, sort
|
||||
them, export them — without SSH-ing into the VM and without asking me every time.
|
||||
|
||||
The obvious answer is a spreadsheet-style admin UI. The wrong answer is a second copy of
|
||||
the data. This post is the recipe I ended up with: **NocoDB pointed straight at the real
|
||||
database, through an account that can only `SELECT`** — plus the three traps that cost me
|
||||
the afternoon, one of which is a Docker networking fact that will bite you in any
|
||||
container-to-LAN setup, not just this one.
|
||||
|
||||
## Why not sync the rows into NocoDB?
|
||||
|
||||
There were three ways to get the owner a dashboard, and I want to be honest that the other
|
||||
two are legitimate — they just cost something:
|
||||
|
||||
1. **Push every row to NocoDB's API as the app writes it.** Two writes per record, error
|
||||
handling on both, and the dashboard silently drifts the first time a push fails. The
|
||||
dashboard becomes a second system of record that is *usually* right.
|
||||
2. **A cron job that syncs rows every N minutes.** Same duplication, plus a staleness
|
||||
window, plus a "which side won?" problem when someone edits a row in the dashboard.
|
||||
3. **Point NocoDB at the database as an external source.** Zero duplication, always
|
||||
live, one connection config. The dashboard *is* the database, viewed differently.
|
||||
|
||||
I took option 3. The cost is real and worth stating up front: the database's port has to
|
||||
be reachable from wherever NocoDB runs, and NocoDB now holds a database credential. Both
|
||||
are acceptable if the credential is a read-only account limited to one host — which is
|
||||
exactly what the rest of this post builds.
|
||||
|
||||
## The trap nobody warns you about: which IP the database sees
|
||||
|
||||
This is the part I got wrong first, and it is not NocoDB-specific.
|
||||
|
||||
My NocoDB runs in Docker on a Synology NAS, on a user-defined bridge network
|
||||
(`bridge_hoelee`, container IP `172.16.0.4`). The MariaDB it needs to reach lives on a
|
||||
different machine at `192.168.1.124` — outside the bridge subnet.
|
||||
|
||||
My first instinct was to grant the account to the container subnet:
|
||||
|
||||
```sql
|
||||
-- WRONG (well, useless): the container's own IP is not what the server sees
|
||||
CREATE USER 'nocodb_ro'@'172.16.0.%' IDENTIFIED BY '<password>';
|
||||
```
|
||||
|
||||
It never matched. When a container connects to an address **outside its bridge network**,
|
||||
the traffic is NATed out through the host — Docker's masquerade rule rewrites the source
|
||||
address to the **host's** IP. The database sees `192.168.1.1` (the NAS), not
|
||||
`172.16.0.4` (the container).
|
||||
|
||||
Don't reason about it, measure it. One `SELECT` from *any* container on the same bridge
|
||||
tells you the truth, and it takes ten seconds:
|
||||
|
||||
```bash
|
||||
docker exec mysql-server mysql -h192.168.1.124 -unocodb_ro -B -e "SELECT CURRENT_USER();"
|
||||
# [email protected] <- the host IP, not the container IP
|
||||
```
|
||||
|
||||
(Pass the password through `MYSQL_PWD` rather than `-p<password>`: on the command line it
|
||||
ends up in `ps` output and your shell history.)
|
||||
|
||||
Once you know the source address, the grant writes itself — **one host, one schema, one
|
||||
privilege**:
|
||||
|
||||
```sql
|
||||
CREATE USER 'nocodb_ro'@'192.168.1.1' IDENTIFIED BY '<password>';
|
||||
GRANT SELECT ON appdb.* TO 'nocodb_ro'@'192.168.1.1';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Two rules I'd apply to any dashboard account:
|
||||
|
||||
- **Never reuse the application's own database user.** The app's account can `INSERT`,
|
||||
`UPDATE` and `DELETE`; the dashboard's cannot. If the dashboard credential leaks out of
|
||||
a browser session, the blast radius is "someone can read rows", not "someone can
|
||||
rewrite the business".
|
||||
- **Grant to the exact source address, not a wildcard.** `'192.168.1.1'` is one line, and
|
||||
it survives a `SHOW GRANTS` review.
|
||||
|
||||
## Creating the external source through the API
|
||||
|
||||
NocoDB (image `nocodb/nocodb:2026.09.0` in my case, listening on `10380`) exposes a meta
|
||||
API; the workspace token lives in the `nc_api_tokens` table of its own meta database. The
|
||||
call that creates an external MySQL source looks like this:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$NC/api/v2/meta/bases/$BASE/sources" \
|
||||
-H "xc-token: $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"type": "mysql2",
|
||||
"title": "app-vm",
|
||||
"config": {
|
||||
"client": "mysql2",
|
||||
"connection": {
|
||||
"host": "192.168.1.124", "port": 3306,
|
||||
"user": "nocodb_ro", "password": "<password>",
|
||||
"database": "appdb"
|
||||
}
|
||||
}
|
||||
}'
|
||||
# {"id":"joblzodc9u91flnkw"}
|
||||
```
|
||||
|
||||
Two things about that response are surprising:
|
||||
|
||||
1. **You get a job id, not a source.** Source creation is asynchronous. There is also
|
||||
**no job-status route** — I probed `/api/v2/jobs/{id}`, `/api/v1/db/meta/jobs/{id}`,
|
||||
`/api/v2/meta/jobs/{id}` and `/api/v1/jobs/{id}`, and all four are 404. Don't sit there
|
||||
polling; verify the outcome instead.
|
||||
2. **It auto-syncs every existing table of that database into the base.** You do not add
|
||||
tables one by one. A few seconds later the base already contained all four of my
|
||||
tables.
|
||||
|
||||
The verification I settled on reads the source list and looks at `meta`:
|
||||
|
||||
```
|
||||
source budabxoawz5lwn9 type=mysql2 order=1 meta=None
|
||||
source b2sdp3iw0ed1bgo type=mysql2 order=2 meta={'dbVersion': '10.11.19-MariaDB-ubu2404'}
|
||||
```
|
||||
|
||||
That `dbVersion` is echoed from the **target** server, so it doubles as proof that the
|
||||
connection succeeded — and as a free reminder that the far end is MariaDB, not MySQL.
|
||||
|
||||
Reading rows is then the normal records API:
|
||||
|
||||
```bash
|
||||
curl -s -H "xc-token: $TOKEN" \
|
||||
"$NC/api/v2/tables/m01ejjhhne1h59z/records?limit=1" | jq '.pageInfo.totalRows'
|
||||
```
|
||||
|
||||
## The trap: `POST /meta/bases/{id}/tables` does not mean "attach this table"
|
||||
|
||||
Because the tables appeared automatically, I assumed the table route was the *manual*
|
||||
version of the same thing — attach an existing table from a chosen source. It is not.
|
||||
`POST /api/v2/meta/bases/{baseId}/tables` **creates a brand-new empty table in the base's
|
||||
default source**. I passed my four table names and got four empty tables inside NocoDB's
|
||||
own internal database, named `nc_pd1g___leads`, `nc_pd1g___orders` and so on.
|
||||
|
||||
They delete cleanly (`DELETE /api/v2/meta/tables/{tid}`), and nothing was harmed because
|
||||
the real tables live in the external source. But it is a good example of an API route
|
||||
whose name suggests "connect" and whose behaviour is "create". For external sources, the
|
||||
sync you want already happened at source-creation time.
|
||||
|
||||
## What a read-only source costs you
|
||||
|
||||
Read-only is the design goal, but it does have two visible consequences, and I'd rather
|
||||
document them than pretend they don't exist.
|
||||
|
||||
**You cannot change the table metadata.** I wanted the leads table's primary display
|
||||
column to be the person's name; NocoDB had auto-picked a dedupe hash instead. Promoting
|
||||
the column returned:
|
||||
|
||||
```
|
||||
400 {"error":"ERR_DATABASE_OP_FAILED","message":"This request couldn't be processed by the database..."}
|
||||
```
|
||||
|
||||
Set column order and visibility in the view (UI) instead of fighting the API. It is a
|
||||
cosmetic problem, not a data problem.
|
||||
|
||||
**`DATETIME` columns render with a UTC label.** A row written at 01:54 local time comes
|
||||
back as `2026-09-27 01:54:16+00:00`. The value is correct; the zone suffix is a
|
||||
presentation artefact. Fine for browsing, wrong for cross-timezone arithmetic — don't
|
||||
build on it.
|
||||
|
||||
There is also a small proof hiding in this: the auto-sync pulled in my app's `migrations`
|
||||
table (it is a table like any other). Trying to remove it from the base failed with
|
||||
`DROP command denied` — from the database, not from NocoDB. That is the read-only grant
|
||||
doing its job, and it is a better verification than any UI label.
|
||||
|
||||
## Four smaller gotchas worth stealing
|
||||
|
||||
1. **Identify a service by an endpoint, not by the port you remember.** My first probes
|
||||
went to the wrong port and came back with Go-style `404 page not found` bodies, which
|
||||
look exactly like "the API moved in this version". `curl -s /api/v1/health` on the
|
||||
right port answered `{"message":"OK"}` and settled it in one second. Related: an
|
||||
unauthenticated NocoDB meta call answers **401**; a 404 with a Go-flavoured body means
|
||||
you are talking to a different process entirely.
|
||||
2. **NocoDB's `NC_DB` is not a DSN.** It is
|
||||
`mysql2://mysql-server:3306/?d=<db>&u=<user>&p=<password>` — parse the **query
|
||||
string**. I parsed it as `user:password@host`, got an empty host, and silently skipped
|
||||
a whole verification block.
|
||||
3. **A soft-deleted base answers `404 ERR_BASE_NOT_FOUND`.** The base still exists in
|
||||
`nc_bases_v2` (with `deleted=1`) but is invisible to the API. "Not found" here means
|
||||
*deleted or out of scope*, not *wrong route* — check the meta table before you go
|
||||
hunting for a typo in your URL.
|
||||
4. **The bases list is scoped to the token's workspace.** A workspace token lists only
|
||||
that workspace's live bases. A suspiciously short list is a scope or soft-delete
|
||||
symptom, not an authentication failure.
|
||||
|
||||
## What I'd do differently
|
||||
|
||||
- **Measure the source address before writing the grant.** I granted a container-subnet
|
||||
wildcard because I reasoned about the container's own IP. Ten seconds of
|
||||
`SELECT CURRENT_USER()` would have told me the answer, and it is a fact you cannot
|
||||
deduce reliably from the compose file.
|
||||
- **Treat read-only as the architecture, not a limitation.** Accept that the dashboard
|
||||
cannot rewrite metadata, and do cosmetic work in the view layer.
|
||||
- **Don't call the table-create route at all.** The source creation already imported
|
||||
everything; reaching for a second API route only created cleanup work.
|
||||
- **One database account per consumer** — app, dashboard, backup job. The dashboard's
|
||||
credential is the one most likely to be pasted into a browser or a ticket, so it should
|
||||
be the weakest one in the system.
|
||||
|
||||
## The result
|
||||
|
||||
- One external source, **four tables live** (leads, orders, order files, agent ledger),
|
||||
**zero duplicated rows** — the owner browses the same rows the application writes, the
|
||||
moment they are written.
|
||||
- The dashboard account can only `SELECT` on one schema from one host, and that is
|
||||
enforced by the **database**: NocoDB's own table-drop attempt comes back
|
||||
`DROP command denied`.
|
||||
- Setup time: about 30 minutes of API probing on the first pass, ~10 minutes now that the
|
||||
recipe is written down (grant → source → verify `dbVersion` → read a row).
|
||||
|
||||
If you are doing this for a client, the shape is worth copying even if NocoDB is not the
|
||||
tool you choose: **the dashboard connects to the real data, through an account that
|
||||
physically cannot write to it, and you verify both halves** — that the connection works
|
||||
(`CURRENT_USER()`, `dbVersion`) and that the account is harmless (`DROP` refused).
|
||||
|
||||
---
|
||||
|
||||
*I'm Lee Teong Hoe (Mr Hoelee). I wire self-hosted dashboards like this to existing
|
||||
databases — least-privilege accounts, NocoDB or plain SQL views, behind Traefik and
|
||||
Cloudflare, on a NAS or a small VM.*
|
||||
|
||||
*Want this set up for your business? [WhatsApp +60 12-797 2969](https://wa.me/60127972969)
|
||||
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
|
||||
· [hoelee.com](https://hoelee.com)*
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "给另一台机器上的数据库配一个 NocoDB 只读看板"
|
||||
description: "把 NocoDB 直接指向另一台机器上的 MySQL/MariaDB,一行数据都不用复制 —— 以及我踩到的 Docker SNAT 源 IP 陷阱和两个 API 陷阱。"
|
||||
pubDate: 2026-09-29
|
||||
category: devops
|
||||
tags: ["nocodb", "mysql", "mariadb", "docker", "networking", "dashboard"]
|
||||
ogImage: /og/read-only-nocodb-dashboard-for-a-remote-database.png
|
||||
banner: /banners/read-only-nocodb-dashboard-for-a-remote-database.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
我的报告应用把每一条留资、每一张订单都写进一台小 VM 上的 MariaDB。真正在跑这门生意的人想**看**这些行 —— 筛选、排序、导出 —— 但不想 SSH 进 VM,也不想每次都来问我。
|
||||
|
||||
最直觉的答案是做一个表格风格的后台界面。最糟的答案是把数据复制一份。这篇写的是我最后落地的配方:**让 NocoDB 直接指向真库,用的账号只能 `SELECT`** —— 外加三个吃掉我半下午的坑,其中一个是 Docker 网络事实,在任何「容器访问局域网」的场景里都会咬人,不只是这一次。
|
||||
|
||||
## 为什么不把行同步进 NocoDB?
|
||||
|
||||
给业主做看板有三条路,另两条也是正路,只是各有代价:
|
||||
|
||||
1. **应用写入时顺手推一份到 NocoDB 的 API。** 每条记录写两次、两边都要处理失败,而且推送第一次失败的那一刻,看板就开始悄悄漂移 —— 它变成了一个「通常是对的」的第二数据源。
|
||||
2. **cron 定时同步。** 同样的重复数据,再加一个延迟窗口,再加一个「谁赢了」的问题:有人在看板里改了行怎么办?
|
||||
3. **把数据库当作 NocoDB 的外部数据源。** 零重复、永远实时、一份连接配置。看板**就是**这个库,只是换了个方式看。
|
||||
|
||||
我选了第 3 条。代价说在前面:数据库的端口必须能被 NocoDB 所在的主机访问到,而且 NocoDB 手上会有一个数据库凭据。只要这个凭据是「只读 + 限定一个来源主机」的账号,这两点都可以接受 —— 后面的内容就是怎么把它做出来。
|
||||
|
||||
## 没人提醒你的坑:数据库到底看到哪个 IP
|
||||
|
||||
这是我最先做错的地方,而且它和 NocoDB 无关。
|
||||
|
||||
我的 NocoDB 跑在群晖 NAS 的 Docker 里,位于一个自定义 bridge 网络(`bridge_hoelee`,容器 IP `172.16.0.4`)。它要连的 MariaDB 在另一台机器 `192.168.1.124` 上 —— 在 bridge 子网之外。
|
||||
|
||||
我的第一反应是给容器子网授权:
|
||||
|
||||
```sql
|
||||
-- 错的(更准确说:没用):容器自己的 IP 根本不是服务端看到的地址
|
||||
CREATE USER 'nocodb_ro'@'172.16.0.%' IDENTIFIED BY '<password>';
|
||||
```
|
||||
|
||||
它永远匹配不上。当容器连接**自己 bridge 网络之外**的地址时,流量会经宿主机做 NAT 出去 —— Docker 的 masquerade 规则把源地址改写成**宿主机**的 IP。数据库看到的是 `192.168.1.1`(NAS),不是 `172.16.0.4`(容器)。
|
||||
|
||||
别推理,去量。在**同一个 bridge 上任意一个容器**里跑一条 `SELECT` 就能得到真相,十秒钟的事:
|
||||
|
||||
```bash
|
||||
docker exec mysql-server mysql -h192.168.1.124 -unocodb_ro -B -e "SELECT CURRENT_USER();"
|
||||
# [email protected] <- 宿主机 IP,不是容器 IP
|
||||
```
|
||||
|
||||
(口令用 `MYSQL_PWD` 传,别用 `-p<password>`:写在命令行里会进 `ps` 输出和 shell 历史。)
|
||||
|
||||
知道来源地址之后,授权就是一行 —— **一个 host、一个库、一个权限**:
|
||||
|
||||
```sql
|
||||
CREATE USER 'nocodb_ro'@'192.168.1.1' IDENTIFIED BY '<password>';
|
||||
GRANT SELECT ON appdb.* TO 'nocodb_ro'@'192.168.1.1';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
给看板账号两条我建议照做的规矩:
|
||||
|
||||
- **绝不复用应用自己的数据库账号。** 应用的账号能 `INSERT`/`UPDATE`/`DELETE`,看板的不能。万一这个凭据从浏览器会话里漏出去,影响面是「有人能读数据」,而不是「有人能改写生意」。
|
||||
- **授权到确切的来源地址,不要通配。** `'192.168.1.1'` 就一行字,而且经得起 `SHOW GRANTS` 复核。
|
||||
|
||||
## 用 API 建外部数据源
|
||||
|
||||
NocoDB(我这边镜像是 `nocodb/nocodb:2026.09.0`,监听 `10380`)有 meta API;workspace token 存在它自己元数据库的 `nc_api_tokens` 表里。建一个外部 MySQL 数据源的调用长这样:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$NC/api/v2/meta/bases/$BASE/sources" \
|
||||
-H "xc-token: $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"type": "mysql2",
|
||||
"title": "app-vm",
|
||||
"config": {
|
||||
"client": "mysql2",
|
||||
"connection": {
|
||||
"host": "192.168.1.124", "port": 3306,
|
||||
"user": "nocodb_ro", "password": "<password>",
|
||||
"database": "appdb"
|
||||
}
|
||||
}
|
||||
}'
|
||||
# {"id":"joblzodc9u91flnkw"}
|
||||
```
|
||||
|
||||
这个返回有两个意外:
|
||||
|
||||
1. **拿到的是 job id,不是数据源。** 建源是异步的。而且**没有**任何查询 job 状态的路由 —— 我试了 `/api/v2/jobs/{id}`、`/api/v1/db/meta/jobs/{id}`、`/api/v2/meta/jobs/{id}`、`/api/v1/jobs/{id}`,四个全是 404。别在那儿轮询,去验证结果。
|
||||
2. **它会把那个库里已有的表全部自动同步进 base。** 你不需要一张张加表。几秒之后,我的四张表已经全在 base 里了。
|
||||
|
||||
我最后用的验证方式是读数据源列表、看 `meta`:
|
||||
|
||||
```
|
||||
source budabxoawz5lwn9 type=mysql2 order=1 meta=None
|
||||
source b2sdp3iw0ed1bgo type=mysql2 order=2 meta={'dbVersion': '10.11.19-MariaDB-ubu2404'}
|
||||
```
|
||||
|
||||
那个 `dbVersion` 是**对端**服务器回显的版本,所以它既是「连接成功」的证据,也顺手提醒你对面是 MariaDB 而不是 MySQL。
|
||||
|
||||
之后读行就是正常的 records API:
|
||||
|
||||
```bash
|
||||
curl -s -H "xc-token: $TOKEN" \
|
||||
"$NC/api/v2/tables/m01ejjhhne1h59z/records?limit=1" | jq '.pageInfo.totalRows'
|
||||
```
|
||||
|
||||
## 陷阱:`POST /meta/bases/{id}/tables` 不是「挂载这张表」
|
||||
|
||||
因为表是自动出现的,我就以为那条建表路由是同一件事的**手动**版本 —— 从指定数据源挂一张已有的表。并不是。`POST /api/v2/meta/bases/{baseId}/tables` 是**在 base 的默认源里新建一张空表**。我传了四个表名,结果在 NocoDB 自己的内部数据库里多出四张空表:`nc_pd1g___leads`、`nc_pd1g___orders` 之类。
|
||||
|
||||
它们能干净删掉(`DELETE /api/v2/meta/tables/{tid}`),而且因为真表在外部源里,什么也没坏。但这是个很好的例子:**一个名字暗示「连接」、行为却是「新建」的 API 路由。** 对外部源来说,你想要的同步在建源那一刻就已经做完了。
|
||||
|
||||
## 只读源要付的代价
|
||||
|
||||
只读是设计目标,但它确实有两个看得见的后果,我宁愿写下来而不是假装不存在。
|
||||
|
||||
**你改不动表的元数据。** 我想把 leads 表的主显示列改成姓名,NocoDB 自动挑的是一个去重哈希。把列提升为主显示列时返回:
|
||||
|
||||
```
|
||||
400 {"error":"ERR_DATABASE_OP_FAILED","message":"This request couldn't be processed by the database..."}
|
||||
```
|
||||
|
||||
列顺序和显示与否,去视图(UI)里调。这是外观问题,不是数据问题。
|
||||
|
||||
**`DATETIME` 列会带上 UTC 标签。** 本地时间 01:54 写入的行,读回来是 `2026-09-27 01:54:16+00:00`。值是对的,时区后缀只是展示层的事。拿来浏览没问题,拿它做跨时区换算就错了 —— 别在它上面搭逻辑。
|
||||
|
||||
这里还藏着一个小小的证明:自动同步把我应用的 `migrations` 表也拉进来了(它也就是一张表)。想把它从 base 里删掉时,报的是 `DROP command denied` —— 拒绝来自**数据库**,不是 NocoDB。这就是只读授权在干活,它比任何 UI 标签都更值得当作验收依据。
|
||||
|
||||
## 四个值得顺手拿走的小坑
|
||||
|
||||
1. **认服务要看端点,不要看你记忆里的端口。** 我第一批探测打到了错的端口,返回的是 Go 风格的 `404 page not found` 正文 —— 看起来就像「这个版本 API 搬家了」。在对的端口上 `curl -s /api/v1/health` 回了 `{"message":"OK"}`,一秒钟定案。相关的一点:未认证的 NocoDB meta 调用回的是 **401**;如果你拿到 404 且正文是 Go 味道的,说明你在跟另一个进程说话。
|
||||
2. **NocoDB 的 `NC_DB` 不是 DSN。** 它是 `mysql2://mysql-server:3306/?d=<db>&u=<user>&p=<password>` —— 要解析 **query string**。我按 `user:password@host` 解析,得到空 host,然后静默跳过了一整段验证。
|
||||
3. **软删除的 base 会回 `404 ERR_BASE_NOT_FOUND`。** base 其实还在 `nc_bases_v2` 里(`deleted=1`),但对 API 不可见。「Not found」在这里的意思是**已删除或超出权限范围**,不是**路由写错了** —— 先去 meta 表里查一眼,别在 URL 里找错字。
|
||||
4. **base 列表是按 token 的 workspace 过滤的。** workspace token 只会列出该 workspace 里活着的 base。列表短得可疑,是权限范围或软删除的症状,不是认证失败。
|
||||
|
||||
## 换我会怎么做
|
||||
|
||||
- **先量出来源地址,再写授权。** 我因为按容器的 IP 去推理,先授了一个容器子网通配。十秒钟的 `SELECT CURRENT_USER()` 就能告诉我答案,而且这是你没法可靠地从 compose 文件里推出来的事实。
|
||||
- **把只读当成架构,而不是限制。** 接受看板改不动元数据,外观的活留给视图层。
|
||||
- **压根别碰建表那条路由。** 建源时已经全部导入完了;多伸一次手,只是给自己制造清理工作。
|
||||
- **每个消费者一个数据库账号** —— 应用、看板、备份任务各一个。看板这个凭据最可能被贴进浏览器或工单里,所以它应该是整个系统里权限最弱的那个。
|
||||
|
||||
## 结果
|
||||
|
||||
- 一个外部数据源,**四张表实时可用**(留资、订单、订单文件、代理台账),**零重复行** —— 业主看到的就是应用写入的同一批行,写进去就能看到。
|
||||
- 看板账号只能对**一个库、来自一个主机**做 `SELECT`,而且这是由**数据库**保证的:NocoDB 自己发起的删表尝试返回 `DROP command denied`。
|
||||
- 耗时:第一遍约 30 分钟的 API 试探;配方写下来之后约 10 分钟(授权 → 建源 → 验证 `dbVersion` → 读一行)。
|
||||
|
||||
如果你是要给客户做这件事,这个形态值得照抄,哪怕最后选的不是 NocoDB:**看板连的是真数据,用的账号物理上写不进去,而且两半都要验证** —— 连接是通的(`CURRENT_USER()`、`dbVersion`),账号是无害的(`DROP` 被拒)。
|
||||
|
||||
---
|
||||
|
||||
*我是 Lee Teong Hoe(Mr Hoelee)。我负责把这类自托管看板接到已有数据库上 —— 最小权限账号、NocoDB 或纯 SQL 视图、放在 Traefik 与 Cloudflare 之后,跑在 NAS 或小 VM 上。*
|
||||
|
||||
*需要给你的生意搭一套?[WhatsApp +60 12-797 2969](https://wa.me/60127972969)
|
||||
· [[email protected]](mailto:[email protected]?subject=Read-only%20dashboard%20for%20my%20database)
|
||||
· [hoelee.com](https://hoelee.com)*
|
||||
Reference in New Issue
Block a user