From 05f6bf324e8f4368cf46a4923e2bf4d39c1ce0f1 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 11:11:38 +0200 Subject: [PATCH] Mint token-grouped hub keys and persist group columns in the catalog. --- README.md | 4 +-- docs/AUTHENTICATION.md | 22 +++++++------ docs/GUIDE.md | 2 +- docs/REFERENCE.md | 10 ++++-- examples/wirebone/README.md | 4 +-- examples/wirebone/coordinator.sql | 2 +- src/include/wirebone_bridge.hpp | 4 ++- src/wirebone_bridge.cpp | 14 +++++--- src/wirebone_catalog.cpp | 18 ++++++++--- src/wirebone_functions.cpp | 24 +++++++++++--- test/sql/wirebone.test | 53 +++++++++++++++++++++++++++++-- 11 files changed, 122 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 1dcdc63..526b869 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ CALL quackscale_hub( state_dir => '~/.local/share/duckdb/quackscale' ); -CALL quackscale_preauth(reusable => true); -- fleet key; share with every client +CALL quackscale_preauth(reusable => true, token => 'analytics'); -- fleet group; same string as QUACK_TAILNET_TOKEN SELECT * FROM quackscale.preauth_keys; SELECT * FROM quackscale.nodes; @@ -76,7 +76,7 @@ CALL tailscale_serve_local(port => 9494); FROM quack_discover(); ``` -`server_url` must be reachable from clients (`127.0.0.1` only if they share the host). Copy a `wbkey-…` from `quackscale.preauth_keys`. +`server_url` must be reachable from clients (`127.0.0.1` only if they share the host). Copy a `wbkey-…` minted with that group's `token`. One hub can serve several groups: a different `token` is a different mesh. Untagged keys stay on the shared hub plane. ### 2. Join a client diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 81933d7..8c86e70 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -1,13 +1,15 @@ # Authentication -QuackTail uses **two independent credential layers**. Both matter in production unless you deliberately relax Quack auth on a locked-down tailnet. +QuackScale uses **two credential layers**. On the in-process hub they can share one secret: mint a preauth key with `token` set to the same string as `QUACK_TAILNET_TOKEN`. | Layer | Question | Configure with | |-------|----------|----------------| -| **Tailnet** | Is this process on our mesh? | Tailscale / Headscale / hub key → `CALL tailscale_up`, or `CALL quackscale_hub` | +| **Mesh group** | Which peers can WireGuard to me? | `CALL quackscale_preauth(token => …)` then `authkey` on `tailscale_up` | | **Quack** | May this caller run SQL over HTTP? | `QUACK_TAILNET_TOKEN`, `CREATE SECRET`, or custom auth macro | -Tailnet ACLs control **who can open TCP to port 9494**. Quack tokens control **who may execute SQL** once connected. See [Quack security](https://duckdb.org/docs/current/quack/security). +The hub process is **shared**: every token group can reach it (so `ATTACH` still works). Peers that joined with different `token` values never see each other in the netmap. Untagged keys (no `token`) stay on that shared plane — do not give clients the bootstrap key if you want groups isolated. + +On Tailscale SaaS / Headscale the mesh is their ACL model; Quack tokens still gate SQL once connected. See [Quack security](https://duckdb.org/docs/current/quack/security). The default fleet uses the **in-process hub** ([below](#in-process-hub)): `quackscale_hub` on the server, `tailscale_up` on every client. Tailscale SaaS and Headscale are alternatives with the same client call. @@ -32,7 +34,7 @@ CALL quackscale_hub( server_url => 'http://10.0.0.5:8080', state_dir => '/var/lib/duckdb/tailscale' ); -CALL quackscale_preauth(reusable => true); +CALL quackscale_preauth(reusable => true, token => 'analytics'); SELECT * FROM quackscale.nodes; SELECT * FROM quackscale.preauth_keys; ``` @@ -43,12 +45,12 @@ Clients (they do not start a hub). Repeat with a distinct `hostname` and `state_ CALL tailscale_up( hostname => 'analyst-1', control_url => 'http://10.0.0.5:8080', - authkey => 'wbkey-…', -- FROM quackscale.preauth_keys + authkey => 'wbkey-…', -- FROM quackscale.preauth_keys for this token state_dir => '/var/lib/duckdb/tailscale' ); ``` -Create extra keys with `CALL quackscale_preauth(reusable => true)`. Preauth keys and node IPs persist in the `quackscale` schema (or `backend => 'ducklake', catalog => 'lake'`). Use `backend => 'json', state_path => '…'` only for the standalone file format. +Mint one reusable key per group: `CALL quackscale_preauth(reusable => true, token => 'analytics')`. Use the same string as `QUACK_TAILNET_TOKEN`. Preauth keys and node IPs persist in the `quackscale` schema (or `backend => 'ducklake', catalog => 'lake'`). Use `backend => 'json', state_path => '…'` only for the standalone file format. `server_url` must be an address **clients can open**. `127.0.0.1` is fine for two processes on one machine; use a LAN or overlay IP for a fleet. @@ -250,7 +252,7 @@ SET GLOBAL quack_authentication_function = 'quacktail_dev_auth'; **Each fleet client** -1. Same `QUACK_TAILNET_TOKEN`; hub clients also need a `wbkey-` from `quackscale.preauth_keys` +1. Same `QUACK_TAILNET_TOKEN`; hub clients need a `wbkey-` minted with that `token` 2. `LOAD quackscale; CALL tailscale_up(control_url, authkey, …);` 3. `LOAD quack; CREATE SECRET ...;` then `ATTACH 'quack:analytics-hub.quackscale.local:9494'` 4. One-shot jobs: `DETACH …; CALL tailscale_down();` — required or the process hangs @@ -259,8 +261,10 @@ SET GLOBAL quack_authentication_function = 'quacktail_dev_auth'; ## Security -- Rotate `QUACK_TAILNET_TOKEN` like an API key; update servers and clients together -- Restrict tailnet ACLs to who may reach peer TCP **9494** +- Rotate `QUACK_TAILNET_TOKEN` like an API key; mint a matching `quackscale_preauth(token => …)` and update servers and clients together +- One hub, many groups: different `token` values cannot WireGuard to each other; they can still reach the hub +- Do not share the hub bootstrap key with clients if you rely on group isolation +- Restrict who may reach peer TCP **9494** (mesh group + Quack token) - `allow_other_hostname => true` is for tailnet binds — do not expose raw Quack on the public internet without TLS in front ([Quack exposure model](https://duckdb.org/docs/current/quack/security#exposure-model)) ## References diff --git a/docs/GUIDE.md b/docs/GUIDE.md index aa2c182..5200245 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -128,7 +128,7 @@ CALL quackscale_hub( ); SELECT * FROM quackscale.nodes; -CALL quackscale_preauth(reusable => true); -- extra keys for the fleet +CALL quackscale_preauth(reusable => true, token => 'analytics'); CALL quack_serve( 'quack:127.0.0.1:9494', diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index ff59aca..610142c 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -184,17 +184,19 @@ CALL quackscale_status(); ### `quackscale_preauth` ```sql -CALL quackscale_preauth(reusable => true); +CALL quackscale_preauth(reusable => true, token => 'analytics'); ``` -Creates an additional preauth key. Requires a running hub. +Creates an additional preauth key. Requires a running hub. Nodes that join with the same `token` form one mesh group; the hub (bootstrap / `shared`) is visible to every group. Use the same string as `QUACK_TAILNET_TOKEN` so mesh group and SQL auth match. | Parameter | Type | Default | Meaning | |-----------|------|---------|---------| | `reusable` | BOOLEAN | `true` | Key may be used more than once. | | `ephemeral` | BOOLEAN | `false` | Nodes registered with this key are ephemeral. | +| `token` | VARCHAR | empty | Mesh group id. Empty = shared hub plane (legacy). | +| `shared` | BOOLEAN | true iff `token` is empty | Visible to every group (hub). | -Returns `key`, `reusable`, `ephemeral`. +Returns `key`, `reusable`, `ephemeral`, `token`, `shared`. ### `quackscale_nodes` @@ -212,6 +214,8 @@ Registered nodes. Empty when the hub is not running. Prefer `SELECT * FROM quack | `ipv6` | VARCHAR | Allocated ULA. | | `node_key` | VARCHAR | `nodekey:…` | | `online` | BOOLEAN | Recently seen on `/machine/map`. | +| `token` | VARCHAR | Mesh group, or NULL if untagged / hub plane. | +| `shared` | BOOLEAN | Visible to every group. | ### `quackscale_stop` diff --git a/examples/wirebone/README.md b/examples/wirebone/README.md index 3380d00..6bfa016 100644 --- a/examples/wirebone/README.md +++ b/examples/wirebone/README.md @@ -57,8 +57,8 @@ CALL quackscale_hub( SELECT * FROM quackscale_status(); SELECT * FROM quackscale.preauth_keys; -CALL quackscale_preauth(reusable => true); --- copy a wbkey-… into every client session +CALL quackscale_preauth(reusable => true, token => 'analytics'); +-- copy that wbkey-… into every client that should share this group CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); CALL tailscale_serve_local(port => 9494); diff --git a/examples/wirebone/coordinator.sql b/examples/wirebone/coordinator.sql index 19e5174..545e263 100644 --- a/examples/wirebone/coordinator.sql +++ b/examples/wirebone/coordinator.sql @@ -18,7 +18,7 @@ CALL quackscale_hub( SELECT * FROM quackscale_status(); SELECT * FROM quackscale.preauth_keys; -CALL quackscale_preauth(reusable => true); +CALL quackscale_preauth(reusable => true, token => 'analytics'); SELECT * FROM quackscale.nodes; CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); diff --git a/src/include/wirebone_bridge.hpp b/src/include/wirebone_bridge.hpp index 9700ac3..5dc0d3c 100644 --- a/src/include/wirebone_bridge.hpp +++ b/src/include/wirebone_bridge.hpp @@ -43,6 +43,8 @@ struct WireboneNodeRow { string ipv6; string node_key; bool online = false; + string token; + bool shared = false; }; //! In-process Tailscale/Headscale-compatible control plane (Wirebone). @@ -56,7 +58,7 @@ class WireboneBridge { WireboneServeStatus Serve(ClientContext &context, const WireboneServeConfig &config); void Stop(); string BootstrapKey() const; - string CreatePreauthKey(bool reusable, bool ephemeral); + string CreatePreauthKey(bool reusable, bool ephemeral, const string &token = string(), int shared = -1); vector Nodes() const; //! Loopback control URL for the in-process client (http://127.0.0.1:). string LocalControlURL() const; diff --git a/src/wirebone_bridge.cpp b/src/wirebone_bridge.cpp index 2b73714..52eacde 100644 --- a/src/wirebone_bridge.cpp +++ b/src/wirebone_bridge.cpp @@ -205,16 +205,16 @@ string WireboneBridge::BootstrapKey() const { #endif } -string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) { +string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral, const string &token, int shared) { RequireLinked(); #if QUACKSCALE_WITH_WIREBONE std::lock_guard g(mu); if (!coordinator) { throw InvalidInputException("quackscale hub is not running; CALL quackscale_hub() first"); } - string key = - TakeCstr(wirebone_create_preauth_key(static_cast(coordinator), reusable ? 1 : 0, - ephemeral ? 1 : 0)); + string key = TakeCstr(wirebone_create_preauth_key_ex(static_cast(coordinator), + reusable ? 1 : 0, ephemeral ? 1 : 0, + token.empty() ? nullptr : token.c_str(), shared)); if (key.empty()) { throw IOException("quackscale_preauth failed to create a key"); } @@ -225,6 +225,8 @@ string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) { #else (void)reusable; (void)ephemeral; + (void)token; + (void)shared; return {}; #endif } @@ -257,6 +259,10 @@ vector WireboneBridge::Nodes() const { row.node_key = nodes[i].node_key; } row.online = nodes[i].online != 0; + if (nodes[i].token) { + row.token = nodes[i].token; + } + row.shared = nodes[i].shared != 0; out.push_back(std::move(row)); } wirebone_free_nodes(nodes, count); diff --git a/src/wirebone_catalog.cpp b/src/wirebone_catalog.cpp index facf862..b65eb0d 100644 --- a/src/wirebone_catalog.cpp +++ b/src/wirebone_catalog.cpp @@ -61,10 +61,16 @@ void WireboneCatalog::EnsureSchema() { Run("CREATE SCHEMA IF NOT EXISTS " + schema); Run("CREATE TABLE IF NOT EXISTS " + Qualify("meta") + " (k VARCHAR PRIMARY KEY, v VARCHAR)"); Run("CREATE TABLE IF NOT EXISTS " + Qualify("preauth_keys") + - " (key VARCHAR PRIMARY KEY, reusable BOOLEAN, ephemeral BOOLEAN, used BOOLEAN, expires_unix BIGINT)"); + " (key VARCHAR PRIMARY KEY, reusable BOOLEAN, ephemeral BOOLEAN, used BOOLEAN, expires_unix BIGINT, " + "token VARCHAR, shared BOOLEAN)"); Run("CREATE TABLE IF NOT EXISTS " + Qualify("nodes") + " (id UBIGINT PRIMARY KEY, stable_id VARCHAR, hostname VARCHAR, machine_key VARCHAR, node_key VARCHAR, " - "disco_key VARCHAR, ipv4 VARCHAR, ipv6 VARCHAR, endpoints VARCHAR, online BOOLEAN, ephemeral BOOLEAN)"); + "disco_key VARCHAR, ipv4 VARCHAR, ipv6 VARCHAR, endpoints VARCHAR, online BOOLEAN, ephemeral BOOLEAN, " + "token VARCHAR, shared BOOLEAN)"); + Run("ALTER TABLE " + Qualify("preauth_keys") + " ADD COLUMN IF NOT EXISTS token VARCHAR DEFAULT ''"); + Run("ALTER TABLE " + Qualify("preauth_keys") + " ADD COLUMN IF NOT EXISTS shared BOOLEAN DEFAULT false"); + Run("ALTER TABLE " + Qualify("nodes") + " ADD COLUMN IF NOT EXISTS token VARCHAR DEFAULT ''"); + Run("ALTER TABLE " + Qualify("nodes") + " ADD COLUMN IF NOT EXISTS shared BOOLEAN DEFAULT false"); // One-cycle alias so older SQL that reads wirebone.* still works. string alias_schema = "wirebone"; @@ -121,7 +127,9 @@ void WireboneCatalog::SaveSnapshot(const string &json) { string(k.value("reusable", true) ? "true" : "false") + ", " + string(k.value("ephemeral", false) ? "true" : "false") + ", " + string(k.value("used", false) ? "true" : "false") + ", " + - std::to_string(k.value("expires_unix", 0)) + ")"); + std::to_string(k.value("expires_unix", 0)) + ", '" + + Escape(k.value("token", std::string())) + "', " + + string(k.value("shared", k.value("token", std::string()).empty()) ? "true" : "false") + ")"); } } Run("DELETE FROM " + Qualify("nodes")); @@ -139,7 +147,9 @@ void WireboneCatalog::SaveSnapshot(const string &json) { Escape(n.value("disco_key", std::string())) + "', '" + Escape(n.value("ipv4", std::string())) + "', '" + Escape(n.value("ipv6", std::string())) + "', '" + Escape(endpoints) + "', " + string(n.value("online", false) ? "true" : "false") + ", " + - string(n.value("ephemeral", false) ? "true" : "false") + ")"); + string(n.value("ephemeral", false) ? "true" : "false") + ", '" + + Escape(n.value("token", std::string())) + "', " + + string(n.value("shared", n.value("token", std::string()).empty()) ? "true" : "false") + ")"); } } Run("COMMIT"); diff --git a/src/wirebone_functions.cpp b/src/wirebone_functions.cpp index 4c7eb72..1372e8f 100644 --- a/src/wirebone_functions.cpp +++ b/src/wirebone_functions.cpp @@ -185,6 +185,8 @@ static void WireboneStopFunction(ClientContext &, TableFunctionInput &data_p, Da struct WirebonePreauthBindData : public TableFunctionData { bool reusable = true; bool ephemeral = false; + string token; + int shared = -1; bool finished = false; }; @@ -193,8 +195,13 @@ static unique_ptr WirebonePreauthBind(ClientContext &, TableFuncti auto bind = make_uniq(); bind->reusable = NamedBool(input, "reusable", true); bind->ephemeral = NamedBool(input, "ephemeral", false); - return_types = {LogicalType::VARCHAR, LogicalType::BOOLEAN, LogicalType::BOOLEAN}; - names = {"key", "reusable", "ephemeral"}; + bind->token = NamedString(input, "token"); + if (input.named_parameters.find("shared") != input.named_parameters.end()) { + bind->shared = NamedBool(input, "shared", false) ? 1 : 0; + } + return_types = {LogicalType::VARCHAR, LogicalType::BOOLEAN, LogicalType::BOOLEAN, LogicalType::VARCHAR, + LogicalType::BOOLEAN}; + names = {"key", "reusable", "ephemeral", "token", "shared"}; return std::move(bind); } @@ -203,11 +210,14 @@ static void WirebonePreauthFunction(ClientContext &, TableFunctionInput &data_p, if (bind.finished) { return; } - auto key = WireboneBridge::Get().CreatePreauthKey(bind.reusable, bind.ephemeral); + auto key = WireboneBridge::Get().CreatePreauthKey(bind.reusable, bind.ephemeral, bind.token, bind.shared); + const bool shared = bind.shared >= 0 ? bind.shared != 0 : bind.token.empty(); output.SetCardinality(1); output.SetValue(0, 0, Value(key)); output.SetValue(1, 0, Value::BOOLEAN(bind.reusable)); output.SetValue(2, 0, Value::BOOLEAN(bind.ephemeral)); + output.SetValue(3, 0, bind.token.empty() ? Value() : Value(bind.token)); + output.SetValue(4, 0, Value::BOOLEAN(shared)); bind.finished = true; } @@ -223,8 +233,8 @@ static unique_ptr WireboneNodesBind(ClientContext &, TableFunction bind->rows = WireboneBridge::Get().Nodes(); } return_types = {LogicalType::UBIGINT, LogicalType::VARCHAR, LogicalType::VARCHAR, LogicalType::VARCHAR, - LogicalType::VARCHAR, LogicalType::BOOLEAN}; - names = {"id", "hostname", "ipv4", "ipv6", "node_key", "online"}; + LogicalType::VARCHAR, LogicalType::BOOLEAN, LogicalType::VARCHAR, LogicalType::BOOLEAN}; + names = {"id", "hostname", "ipv4", "ipv6", "node_key", "online", "token", "shared"}; return std::move(bind); } @@ -242,6 +252,8 @@ static void WireboneNodesFunction(ClientContext &, TableFunctionInput &data_p, D output.SetValue(3, i, row.ipv6.empty() ? Value() : Value(row.ipv6)); output.SetValue(4, i, row.node_key.empty() ? Value() : Value(row.node_key)); output.SetValue(5, i, Value::BOOLEAN(row.online)); + output.SetValue(6, i, row.token.empty() ? Value() : Value(row.token)); + output.SetValue(7, i, Value::BOOLEAN(row.shared)); } output.SetCardinality(count); bind.offset += count; @@ -363,6 +375,8 @@ void RegisterWireboneFunctions(ExtensionLoader &loader) { TableFunction preauth("quackscale_preauth", {}, WirebonePreauthFunction, WirebonePreauthBind); preauth.named_parameters["reusable"] = LogicalType::BOOLEAN; preauth.named_parameters["ephemeral"] = LogicalType::BOOLEAN; + preauth.named_parameters["token"] = LogicalType::VARCHAR; + preauth.named_parameters["shared"] = LogicalType::BOOLEAN; loader.RegisterFunction(preauth); RegisterTableAlias(loader, preauth, "wirebone_preauth"); diff --git a/test/sql/wirebone.test b/test/sql/wirebone.test index f922037..100ca67 100644 --- a/test/sql/wirebone.test +++ b/test/sql/wirebone.test @@ -47,6 +47,43 @@ SELECT COUNT(*) FROM quackscale.preauth_keys; ---- 2 +statement ok +CALL quackscale_preauth(reusable => true, token => 'alpha'); + +query I +SELECT token FROM quackscale.preauth_keys WHERE token = 'alpha'; +---- +alpha + +query I +SELECT COUNT(*) FROM quackscale.preauth_keys WHERE token = 'alpha' AND NOT shared; +---- +1 + +query I +SELECT COUNT(*) FROM quackscale.preauth_keys; +---- +3 + +# second isolated group +statement ok +CALL quackscale_preauth(reusable => true, token => 'beta'); + +query I +SELECT token FROM quackscale.preauth_keys WHERE token = 'beta'; +---- +beta + +query I +SELECT COUNT(*) FROM quackscale.preauth_keys WHERE COALESCE(token, '') = '' AND shared; +---- +2 + +query I +SELECT COUNT(*) FROM quackscale.preauth_keys; +---- +4 + statement ok CALL quackscale_stop(); @@ -59,16 +96,26 @@ false query I SELECT COUNT(*) FROM quackscale.preauth_keys; ---- -2 +4 statement ok CALL quackscale_hub(listen => '127.0.0.1:0', dns_listen => '', join => false); -# Reloaded snapshot keeps the same keys. +# Reloaded snapshot keeps the same keys and group tags. query I SELECT COUNT(*) FROM quackscale.preauth_keys; ---- -2 +4 + +query I +SELECT token FROM quackscale.preauth_keys WHERE token = 'alpha'; +---- +alpha + +query I +SELECT token FROM quackscale.preauth_keys WHERE token = 'beta'; +---- +beta query I SELECT COUNT(*) FROM quackscale.preauth_keys k JOIN hub_test_keys t USING (key);