Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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

Expand Down
22 changes: 13 additions & 9 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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;
```
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 7 additions & 3 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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`

Expand Down
4 changes: 2 additions & 2 deletions examples/wirebone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion examples/wirebone/coordinator.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
4 changes: 3 additions & 1 deletion src/include/wirebone_bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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<WireboneNodeRow> Nodes() const;
//! Loopback control URL for the in-process client (http://127.0.0.1:<port>).
string LocalControlURL() const;
Expand Down
14 changes: 10 additions & 4 deletions src/wirebone_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::mutex> g(mu);
if (!coordinator) {
throw InvalidInputException("quackscale hub is not running; CALL quackscale_hub() first");
}
string key =
TakeCstr(wirebone_create_preauth_key(static_cast<wirebone_coordinator *>(coordinator), reusable ? 1 : 0,
ephemeral ? 1 : 0));
string key = TakeCstr(wirebone_create_preauth_key_ex(static_cast<wirebone_coordinator *>(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");
}
Expand All @@ -225,6 +225,8 @@ string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) {
#else
(void)reusable;
(void)ephemeral;
(void)token;
(void)shared;
return {};
#endif
}
Expand Down Expand Up @@ -257,6 +259,10 @@ vector<WireboneNodeRow> 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);
Expand Down
18 changes: 14 additions & 4 deletions src/wirebone_catalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));
Expand All @@ -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");
Expand Down
24 changes: 19 additions & 5 deletions src/wirebone_functions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -193,8 +195,13 @@ static unique_ptr<FunctionData> WirebonePreauthBind(ClientContext &, TableFuncti
auto bind = make_uniq<WirebonePreauthBindData>();
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);
}

Expand All @@ -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;
}

Expand All @@ -223,8 +233,8 @@ static unique_ptr<FunctionData> 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);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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");

Expand Down
53 changes: 50 additions & 3 deletions test/sql/wirebone.test
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Expand Down
Loading