From 42255a9ca2bf927c43ca4badbc87b7f3685e1317 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 01:51:04 +0200 Subject: [PATCH 1/5] Embed an in-process Wirebone coordinator with DuckDB-backed state. One DuckDB process can serve the Tailscale control plane and join as a tsnet client, persisting nodes and preauth keys in local tables or an attached DuckLake catalog. --- CMakeLists.txt | 18 +- README.md | 8 +- cmake/Wirebone.cmake | 83 +++++++ docs/AUTHENTICATION.md | 37 +++ docs/DEVELOPMENT.md | 13 +- docs/GUIDE.md | 6 +- docs/REFERENCE.md | 123 ++++++++++ src/include/tailscale_http.hpp | 10 +- src/include/wirebone_bridge.hpp | 81 +++++++ src/include/wirebone_catalog.hpp | 56 +++++ src/include/wirebone_functions.hpp | 9 + src/quackscale_extension.cpp | 2 + src/tailscale_http.cpp | 41 +++- src/wirebone_bridge.cpp | 281 ++++++++++++++++++++++ src/wirebone_catalog.cpp | 146 ++++++++++++ src/wirebone_functions.cpp | 358 +++++++++++++++++++++++++++++ test/sql/quackscale.test | 26 +++ test/sql/wirebone.test | 85 +++++++ 18 files changed, 1368 insertions(+), 15 deletions(-) create mode 100644 cmake/Wirebone.cmake create mode 100644 src/include/wirebone_bridge.hpp create mode 100644 src/include/wirebone_catalog.hpp create mode 100644 src/include/wirebone_functions.hpp create mode 100644 src/wirebone_bridge.cpp create mode 100644 src/wirebone_catalog.cpp create mode 100644 src/wirebone_functions.cpp create mode 100644 test/sql/wirebone.test diff --git a/CMakeLists.txt b/CMakeLists.txt index 55044d8..84c95ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.5) set(TARGET_NAME quackscale) option(QUACKSCALE_WITH_TAILSCALE "Embed libtailscale (requires Go)" ON) +option(QUACKSCALE_WITH_WIREBONE "Embed Wirebone coordinator when sources + deps are available" ON) set(EXTENSION_NAME ${TARGET_NAME}_extension) set(LOADABLE_EXTENSION_NAME ${TARGET_NAME}_loadable_extension) @@ -14,12 +15,16 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) include_directories(src/include) -set(EXTENSION_SOURCES src/quackscale_extension.cpp src/attach_ducklake.cpp src/tailscale_bridge.cpp src/tailscale_forwarder.cpp src/tailscale_log_capture.cpp src/tailscale_http.cpp) +set(EXTENSION_SOURCES src/quackscale_extension.cpp src/attach_ducklake.cpp src/tailscale_bridge.cpp src/tailscale_forwarder.cpp src/tailscale_log_capture.cpp src/tailscale_http.cpp src/wirebone_bridge.cpp src/wirebone_functions.cpp src/wirebone_catalog.cpp) if(QUACKSCALE_WITH_TAILSCALE) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Libtailscale.cmake) endif() +if(QUACKSCALE_WITH_WIREBONE) + include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Wirebone.cmake) +endif() + build_static_extension(${TARGET_NAME} ${EXTENSION_SOURCES}) build_loadable_extension(${TARGET_NAME} " " ${EXTENSION_SOURCES}) @@ -45,6 +50,17 @@ endfunction() quackscale_link_tailscale(${EXTENSION_NAME}) quackscale_link_tailscale(${LOADABLE_EXTENSION_NAME}) +function(quackscale_link_wirebone_or_stub target_name) + if(QUACKSCALE_WITH_WIREBONE AND COMMAND quackscale_link_wirebone) + quackscale_link_wirebone(${target_name}) + else() + target_compile_definitions(${target_name} PRIVATE QUACKSCALE_WITH_WIREBONE=0) + endif() +endfunction() + +quackscale_link_wirebone_or_stub(${EXTENSION_NAME}) +quackscale_link_wirebone_or_stub(${LOADABLE_EXTENSION_NAME}) + install( TARGETS ${EXTENSION_NAME} EXPORT "${DUCKDB_EXPORT_SET}" diff --git a/README.md b/README.md index 0be8a17..cfbc484 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ QuackScale inverts that. Each DuckDB process carries its own tailnet identity an Each node clears two independent checks. The tailnet asks whether the machine belongs to your mesh, and Tailscale or [Headscale](https://github.com/juanfont/headscale) ACLs decide which nodes may open a connection. A Quack token then asks whether the caller may run SQL. A stolen token buys nothing from a machine off the mesh, and a machine on the mesh still needs a token. Set `QUACK_TAILNET_TOKEN` once and the whole fleet shares one secret. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). -You drive all of it from SQL. Joining, status, ping, forward, serve, and teardown are `CALL` table functions, so you keep the network in the same migrations and init scripts as your data. One SQL surface covers Tailscale's hosted control plane and a self-hosted [Headscale](https://headscale.net/): set `control_url` and a preauth key, and nothing else changes. +You drive all of it from SQL. Joining, status, ping, forward, serve, and teardown are `CALL` table functions, so you keep the network in the same migrations and init scripts as your data. One SQL surface covers Tailscale's hosted control plane, a self-hosted [Headscale](https://headscale.net/), and an in-process [Wirebone](https://github.com/lmangani/wirebone.cpp) coordinator (`CALL quackscale_serve`): set `control_url` and a preauth key, and the client path does not change. ## Install @@ -77,7 +77,7 @@ CALL tailscale_serve_local(port => 9494); FROM quack_discover(); -- prints this node's quack: URI on the tailnet ``` -Leave a long-lived server running with a persistent `state_dir`, and do not call `tailscale_down()`. For Headscale, add `control_url` and a preauth key. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). +Leave a long-lived server running with a persistent `state_dir`, and do not call `tailscale_down()`. For Headscale, add `control_url` and a preauth key. To host the control plane in this same process, `CALL quackscale_serve(...)` instead of `tailscale_up`. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). ### A client that reaches it @@ -125,14 +125,14 @@ flowchart TB A server joins the tailnet with `tailscale_up`, attaches a DuckLake catalog when it owns one, and serves Quack on loopback through `quack_serve` and `tailscale_serve_local`. Clients reach it over encrypted TCP across the mesh, and no node listens on the public internet. -`tailscale_up` wraps DuckDB's HTTP layer. QuackScale then dials any tailnet host you name (`100.64.0.0/10` or `*.ts.net`) over tsnet, so `ATTACH 'quack:100.x:9494'` works on its own. Pass `http_route => false` to turn this off. Three cases fall outside the router: a bare MagicDNS short name, a pinned `127.0.0.1:` endpoint, and a non-HTTP client. For those, `tailscale_quack_forward` listens on loopback and dials the peer. To read a server-owned DuckLake catalog, call `attach_ducklake`. The [guide](docs/GUIDE.md) works through each pattern. +`tailscale_up` wraps DuckDB's HTTP layer. QuackScale then dials any tailnet host you name (`100.64.0.0/10`, `*.ts.net`, or `*.wirebone.local`) over tsnet, so `ATTACH 'quack:100.x:9494'` works on its own. Pass `http_route => false` to turn this off. Three cases fall outside the router: a bare MagicDNS short name, a pinned `127.0.0.1:` endpoint, and a non-HTTP client. For those, `tailscale_quack_forward` listens on loopback and dials the peer. To read a server-owned DuckLake catalog, call `attach_ducklake`. The [guide](docs/GUIDE.md) works through each pattern. ## Where to next | You want to… | Read | |--------------|------| | Pick a pattern: remote tables, server-owned DuckLake, or shared Parquet | [docs/GUIDE.md](docs/GUIDE.md) | -| Set up tailnet login, Headscale, and Quack tokens | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | +| Set up tailnet login, Headscale, Wirebone, and Quack tokens | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | | Look up a SQL command and its parameters | [docs/REFERENCE.md](docs/REFERENCE.md) | | Run a two-node proof on Docker Compose | [examples/README.md](examples/README.md) | | Build the extension, or work on it | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | diff --git a/cmake/Wirebone.cmake b/cmake/Wirebone.cmake new file mode 100644 index 0000000..3224734 --- /dev/null +++ b/cmake/Wirebone.cmake @@ -0,0 +1,83 @@ +# Embed the Wirebone coordinator (POSIX; needs OpenSSL, nghttp2, libzstd). +# +# Source lookup, first match wins: +# 1. QUACKSCALE_WIREBONE_DIR (cache/path) +# 2. ${CMAKE_CURRENT_SOURCE_DIR}/third_party/wirebone +# 3. ${CMAKE_CURRENT_SOURCE_DIR}/../wirebone.cpp (sibling checkout) + +set(_qs_wirebone_candidates "") +if(QUACKSCALE_WIREBONE_DIR) + list(APPEND _qs_wirebone_candidates "${QUACKSCALE_WIREBONE_DIR}") +endif() +list(APPEND _qs_wirebone_candidates + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/wirebone" + "${CMAKE_CURRENT_SOURCE_DIR}/../wirebone.cpp") + +set(QUACKSCALE_WIREBONE_SOURCE "") +foreach(_qs_wb_dir IN LISTS _qs_wirebone_candidates) + if(EXISTS "${_qs_wb_dir}/CMakeLists.txt" AND EXISTS "${_qs_wb_dir}/include/wirebone/wirebone.h") + get_filename_component(QUACKSCALE_WIREBONE_SOURCE "${_qs_wb_dir}" ABSOLUTE) + break() + endif() +endforeach() + +if(NOT QUACKSCALE_WIREBONE_SOURCE) + message(STATUS "QuackScale: Wirebone sources not found; coordinator SQL disabled " + "(set QUACKSCALE_WIREBONE_DIR or checkout wirebone.cpp next to this repo)") + set(QUACKSCALE_WITH_WIREBONE OFF CACHE BOOL "Embed Wirebone coordinator" FORCE) + return() +endif() + +if(WIN32) + message(STATUS "QuackScale: Wirebone is POSIX-only; coordinator SQL disabled on Windows") + set(QUACKSCALE_WITH_WIREBONE OFF CACHE BOOL "Embed Wirebone coordinator" FORCE) + return() +endif() + +find_package(PkgConfig QUIET) +if(NOT PkgConfig_FOUND) + message(STATUS "QuackScale: pkg-config missing; Wirebone coordinator SQL disabled") + set(QUACKSCALE_WITH_WIREBONE OFF CACHE BOOL "Embed Wirebone coordinator" FORCE) + return() +endif() + +pkg_check_modules(_QS_WB_CRYPTO QUIET libcrypto) +pkg_check_modules(_QS_WB_NGHTTP2 QUIET libnghttp2) +pkg_check_modules(_QS_WB_ZSTD QUIET libzstd) +if(NOT _QS_WB_CRYPTO_FOUND OR NOT _QS_WB_NGHTTP2_FOUND OR NOT _QS_WB_ZSTD_FOUND) + message(STATUS "QuackScale: OpenSSL/nghttp2/zstd not found; Wirebone coordinator SQL disabled") + set(QUACKSCALE_WITH_WIREBONE OFF CACHE BOOL "Embed Wirebone coordinator" FORCE) + return() +endif() + +set(QUACKSCALE_WITH_WIREBONE ON CACHE BOOL "Embed Wirebone coordinator") +set(WIREBONE_BUILD_CLI OFF CACHE BOOL "Build the wirebone CLI" FORCE) +set(WIREBONE_BUILD_TESTS OFF CACHE BOOL "Build wirebone unit tests" FORCE) + +if(NOT TARGET wirebone) + add_subdirectory("${QUACKSCALE_WIREBONE_SOURCE}" "${CMAKE_BINARY_DIR}/third_party/wirebone") +endif() +target_compile_definitions(wirebone PRIVATE WIREBONE_DUCKDB_ZSTD=1) + +message(STATUS "QuackScale: Wirebone coordinator from ${QUACKSCALE_WIREBONE_SOURCE}") + +function(quackscale_link_wirebone target_name) + target_compile_definitions(${target_name} PRIVATE QUACKSCALE_WITH_WIREBONE=1) + target_include_directories(${target_name} PRIVATE "${QUACKSCALE_WIREBONE_SOURCE}/include") + if(TARGET nlohmann_json) + get_target_property(_qs_nlohmann_inc nlohmann_json INTERFACE_INCLUDE_DIRECTORIES) + if(_qs_nlohmann_inc) + target_include_directories(${target_name} PRIVATE ${_qs_nlohmann_inc}) + endif() + endif() + add_dependencies(${target_name} wirebone) + # Archive + absolute dylibs so DuckDB's custom extension link line sees them + # (imported CMake targets do not propagate through the generated loader). + target_link_libraries(${target_name} "$" + ${_QS_WB_CRYPTO_LINK_LIBRARIES} + ${_QS_WB_NGHTTP2_LINK_LIBRARIES} + ${_QS_WB_ZSTD_LINK_LIBRARIES}) + if(UNIX AND NOT APPLE) + target_link_libraries(${target_name} pthread) + endif() +endfunction() diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 7c55716..106d022 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -94,6 +94,43 @@ CALL tailscale_up( --- +## Wirebone (in-process control plane) + +[Wirebone](https://github.com/lmangani/wirebone.cpp) is a Tailscale/Headscale-compatible coordinator that QuackScale can embed. One DuckDB process hosts the control plane; that same process — and every peer — joins with the existing `tailscale_up` client (libtailscale). No Headscale container and no Tailscale SaaS. + +Coordinator + client in one process: + +```sql +LOAD quackscale; + +CALL quackscale_serve( + hostname => 'duckdb-coord', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '/var/lib/duckdb/tailscale' +); +-- coordinator rows: SELECT * FROM wirebone.nodes; +``` + +Or two calls: `CALL wirebone_serve(...)` then `CALL tailscale_up(control_url => 'http://127.0.0.1:8080', authkey => wirebone_bootstrap_key(), ...)`. + +Peers (client only): + +```sql +CALL tailscale_up( + hostname => 'duckdb-node-b', + control_url => 'http://10.0.0.5:8080', + authkey => 'wbkey-…', -- from the coordinator's wirebone_preauth / bootstrap key + state_dir => '/var/lib/duckdb/tailscale' +); +``` + +Create extra keys with `CALL wirebone_preauth(reusable => true)`. Preauth keys and node IPs persist in the `wirebone` schema of this DuckDB (or `backend => 'ducklake', catalog => 'lake'` for a shared catalog). Use `backend => 'json', state_path => '…'` only if you need the standalone file format. + +`CALL wirebone_status()` reports `linked=false` if this binary was built without Wirebone (missing sources or OpenSSL/nghttp2/zstd). See [DEVELOPMENT.md](DEVELOPMENT.md). + +--- + ## Quack HTTP tokens After a node is on the tailnet, Quack still requires application-level auth. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e68487e..132acd1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,6 +11,8 @@ QuackScale does **not** reimplement Quack. It provides tailnet lifecycle SQL, a ```text DuckDB + quackscale + libtailscale → tailscale_up, tailscale_quack_forward, quack_uri, attach_ducklake +DuckDB + quackscale + wirebone (optional) + → wirebone_serve / quackscale_serve (coordinator + client in one process) DuckDB + quack (core) → quack_serve, ATTACH, quack_query ``` @@ -36,14 +38,21 @@ Disable libtailscale (stub build): make CMAKE_VARS="-DQUACKSCALE_WITH_TAILSCALE=OFF" ``` +Wirebone (in-process coordinator) is enabled automatically on POSIX when `../wirebone.cpp` or `third_party/wirebone` exists and pkg-config can see OpenSSL (`libcrypto`), nghttp2, and libzstd. Coordinator state is stored in DuckDB tables (`wirebone.meta`, `wirebone.preauth_keys`, `wirebone.nodes`) or in an attached DuckLake catalog. Override the source path with `-DQUACKSCALE_WIREBONE_DIR=…`, or force it off: + +```sh +make CMAKE_VARS="-DQUACKSCALE_WITH_WIREBONE=OFF" +``` + Docker Compose images build from source by default — see [examples/Dockerfile](../examples/Dockerfile) and `.dockerignore`. ## Repository layout ```text cmake/Libtailscale.cmake Go c-archive build + Go 1.25.5 bootstrap +cmake/Wirebone.cmake Optional in-process coordinator (sibling or QUACKSCALE_WIREBONE_DIR) third_party/libtailscale/ git submodule -src/ C++ extension (bridge, forwarder, attach_ducklake) +src/ C++ extension (bridge, forwarder, attach_ducklake, wirebone catalog) scripts/e2e/ Compose entrypoint, bootstrap, verify-image examples/ Docker Compose two-node demo duckdb/ DuckDB submodule @@ -103,7 +112,7 @@ Each Pages deploy replaces the whole site (one DuckDB version hosted). To host m | Headscale + Compose e2e | Done | | `ATTACH … TYPE quacktail_lake` (Tier 3 native catalog) | Planned | | `ducklake_discover()` enriched discovery | Planned | -| `quackscale_serve()` one-call server bootstrap | Planned | +| `quackscale_serve()` one-call coordinator + client | Done (Wirebone; optional build) | | Community extension descriptor publish | Done (GitHub Pages on release) | ## Risks diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 3b3701a..8633dba 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -2,12 +2,12 @@ QuackTail combines: -1. **Tailscale or Headscale** — private mesh between nodes +1. **Tailscale, Headscale, or in-process Wirebone** — private mesh between nodes 2. **Quack** — DuckDB’s HTTP protocol (`quack:` URIs, port **9494**) 3. **QuackScale** — joins DuckDB to the tailnet and forwards Quack across it 4. **DuckLake** (optional) — lakehouse catalog + Parquet on a QuackTail node -QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. +QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. When built with Wirebone, one node can be the control plane **and** a mesh client (`CALL quackscale_serve`); other nodes stay client-only (`CALL tailscale_up`). Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md](REFERENCE.md). Build: [DEVELOPMENT.md](DEVELOPMENT.md). @@ -75,7 +75,7 @@ LOAD quackscale; CALL tailscale_up( hostname => 'my-client', - control_url => 'http://headscale:8080', -- omit for Tailscale SaaS + control_url => 'http://headscale:8080', -- omit for Tailscale SaaS; Wirebone uses the coordinator URL authkey => '…', state_dir => '/tmp/client-tailscale', ephemeral => true diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index b7a60cd..6317e38 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -124,6 +124,129 @@ Returns one row: --- +## In-process coordinator (Wirebone) + +A QuackScale build that finds [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) (sibling checkout or `QUACKSCALE_WIREBONE_DIR`) can host the Tailscale/Headscale-compatible control plane **in the same process** as the tsnet client. Peers still call `tailscale_up` with `control_url` and a preauth key; they do not run Wirebone. + +`CALL wirebone_status()` reports `linked=false` when the extension was built without Wirebone. + +### `wirebone_serve` + +```sql +CALL wirebone_serve( + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + domain => 'wirebone.local' +); +SELECT * FROM wirebone.preauth_keys; +SELECT * FROM wirebone.nodes; +``` + +Starts the coordinator on a background thread. State lives in DuckDB tables in the `wirebone` schema of the current database (or an attached DuckLake catalog). The same process can then join as a client with [`tailscale_up`](#tailscale_up) or [`quackscale_serve`](#quackscale_serve). + +| Parameter | Type | Default | Meaning | +|-----------|------|---------|---------| +| `listen` | VARCHAR | `0.0.0.0:8080` | Bind address for the control plane. | +| `server_url` | VARCHAR | `http://127.0.0.1:8080` | URL advertised to other nodes. | +| `backend` | VARCHAR | `duckdb` | `duckdb` (local tables), `ducklake` (shared catalog), or `json` (legacy file). | +| `catalog` | VARCHAR | current database | Catalog that holds schema `wirebone`. Required for `ducklake`. | +| `database` | VARCHAR | none | Dedicated `.duckdb` file when you do not want the session database. | +| `state_path` | VARCHAR | none | JSON file when `backend => 'json'`. | +| `coordinator_state` | VARCHAR | none | Alias for `state_path`. | +| `domain` | VARCHAR | `wirebone.local` | MagicDNS suffix (`hostname.domain`). | +| `dns_listen` | VARCHAR | `0.0.0.0:5353` | UDP MagicDNS listener; empty disables it. | + +Tables (same schema on DuckLake): `wirebone.meta`, `wirebone.preauth_keys`, `wirebone.nodes`. + +Returns one row: `bound`, `control_url`, `domain`, `preauth_key` (bootstrap key created on first serve). + +### `quackscale_serve` + +```sql +CALL quackscale_serve( + hostname => 'duckdb-coord', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + catalog => 'memory', + state_dir => '/var/lib/duckdb/tailscale' +); +``` + +One-call coordinator **and** client: starts Wirebone, then `tailscale_up` against `http://127.0.0.1:` using the bootstrap preauth key. Set `join => false` to start only the control plane. + +Accepts every [`wirebone_serve`](#wirebone_serve) parameter plus every [`tailscale_up`](#tailscale_up) parameter, and: + +| Parameter | Type | Default | Meaning | +|-----------|------|---------|---------| +| `join` | BOOLEAN | `true` | Also join the mesh as a tsnet client. | + +Returns one row: `bound`, `control_url`, `domain`, `preauth_key`, `running`, `hostname`, `tailnet_ips`. + +### `wirebone_status` + +```sql +CALL wirebone_status(); +``` + +| Column | Type | Meaning | +|--------|------|---------| +| `linked` | BOOLEAN | This build embeds Wirebone. | +| `running` | BOOLEAN | Coordinator thread is up. | +| `bound` | VARCHAR | Actual listen address, or NULL. | +| `control_url` | VARCHAR | Advertised URL, or NULL. | +| `domain` | VARCHAR | MagicDNS domain, or NULL. | +| `preauth_key` | VARCHAR | Bootstrap key, or NULL. | + +### `wirebone_preauth` + +```sql +CALL wirebone_preauth(reusable => true); +``` + +Creates an additional preauth key. Requires a running coordinator. + +| Parameter | Type | Default | Meaning | +|-----------|------|---------|---------| +| `reusable` | BOOLEAN | `true` | Key may be used more than once. | +| `ephemeral` | BOOLEAN | `false` | Nodes registered with this key are ephemeral. | + +Returns `key`, `reusable`, `ephemeral`. + +### `wirebone_bootstrap_key` + +```sql +SELECT wirebone_bootstrap_key(); +``` + +Scalar. Returns the bootstrap preauth key from the running coordinator. + +### `wirebone_nodes` + +```sql +CALL wirebone_nodes(); +``` + +Registered nodes. Empty when the coordinator is not running. + +| Column | Type | Meaning | +|--------|------|---------| +| `id` | UBIGINT | Numeric node id. | +| `hostname` | VARCHAR | Node name. | +| `ipv4` | VARCHAR | Allocated `100.64/10` address. | +| `ipv6` | VARCHAR | Allocated ULA. | +| `node_key` | VARCHAR | `nodekey:…` | +| `online` | BOOLEAN | Recently seen on `/machine/map`. | + +### `wirebone_stop` + +```sql +CALL wirebone_stop(); +``` + +Stops the coordinator thread. Does not call `tailscale_down`. Returns `stopped=true`. + +--- + ## Connectivity on the mesh ### `tailscale_serve_local` diff --git a/src/include/tailscale_http.hpp b/src/include/tailscale_http.hpp index fa377f0..444205e 100644 --- a/src/include/tailscale_http.hpp +++ b/src/include/tailscale_http.hpp @@ -11,11 +11,15 @@ namespace duckdb { class DatabaseInstance; //! True if `proto_host_port` (e.g. "http://100.95.32.19:9494") names a tailnet host: -//! an IPv4 in the CGNAT range 100.64.0.0/10, or a *.ts.net MagicDNS name. Scheme and -//! :port are stripped before the test. NOTE: bare MagicDNS short names ("lake-server") -//! are NOT matched — those still need tailscale_quack_forward. +//! an IPv4 in the CGNAT range 100.64.0.0/10, a *.ts.net MagicDNS name, *.wirebone.local, +//! or a suffix registered via RegisterTailnetMagicDnsSuffix. Scheme and :port are stripped +//! before the test. NOTE: bare MagicDNS short names ("lake-server") are NOT matched — +//! those still need tailscale_quack_forward. bool IsTailnetHost(const string &proto_host_port); +//! Extra MagicDNS suffix to treat as a tailnet host (e.g. ".wirebone.local"). Idempotent. +void RegisterTailnetMagicDnsSuffix(const string &suffix); + //! Install TailscaleHTTPUtil as the database's global HTTP util, wrapping whatever util is //! currently registered (httpfs, after auto-load). Idempotent: a second call is a no-op. Called //! from tailscale_up (after the node is up) and from tailscale_login (the wrap is inert until the diff --git a/src/include/wirebone_bridge.hpp b/src/include/wirebone_bridge.hpp new file mode 100644 index 0000000..8d806dd --- /dev/null +++ b/src/include/wirebone_bridge.hpp @@ -0,0 +1,81 @@ +#pragma once + +#ifndef QUACKSCALE_WITH_WIREBONE +#define QUACKSCALE_WITH_WIREBONE 0 +#endif + +#include "duckdb/common/string.hpp" +#include "duckdb/common/vector.hpp" +#include "wirebone_catalog.hpp" + +#include +#include + +namespace duckdb { + +class ClientContext; + +struct WireboneServeConfig { + string listen = "0.0.0.0:8080"; + string server_url = "http://127.0.0.1:8080"; + string state_path; + string domain = "wirebone.local"; + string dns_listen = "0.0.0.0:5353"; + //! `duckdb` (default), `ducklake`, or `json`. + string backend = "duckdb"; + string catalog; + string database; +}; + +struct WireboneServeStatus { + bool linked = false; + bool running = false; + string bound; + string control_url; + string domain; + string preauth_key; +}; + +struct WireboneNodeRow { + uint64_t id = 0; + string hostname; + string ipv4; + string ipv6; + string node_key; + bool online = false; +}; + +//! In-process Tailscale/Headscale-compatible control plane (Wirebone). +//! One DuckDB process can host this and still join as a tsnet client via TailscaleBridge. +class WireboneBridge { +public: + static WireboneBridge &Get(); + + bool Linked() const; + WireboneServeStatus Status() const; + WireboneServeStatus Serve(ClientContext &context, const WireboneServeConfig &config); + void Stop(); + string BootstrapKey() const; + string CreatePreauthKey(bool reusable, bool ephemeral); + vector Nodes() const; + //! Loopback control URL for the in-process client (http://127.0.0.1:). + string LocalControlURL() const; + +private: + WireboneBridge() = default; + ~WireboneBridge(); + + WireboneBridge(const WireboneBridge &) = delete; + WireboneBridge &operator=(const WireboneBridge &) = delete; + + void RequireLinked() const; + + mutable std::mutex mu; +#if QUACKSCALE_WITH_WIREBONE + void *coordinator = nullptr; // wirebone_coordinator* + WireboneCatalog catalog; +#endif + WireboneServeStatus last; +}; + +} // namespace duckdb diff --git a/src/include/wirebone_catalog.hpp b/src/include/wirebone_catalog.hpp new file mode 100644 index 0000000..8cf58f7 --- /dev/null +++ b/src/include/wirebone_catalog.hpp @@ -0,0 +1,56 @@ +#pragma once + +#ifndef QUACKSCALE_WITH_WIREBONE +#define QUACKSCALE_WITH_WIREBONE 0 +#endif + +#include "duckdb.hpp" +#include "duckdb/common/string.hpp" +#include "duckdb/common/unique_ptr.hpp" +#include "duckdb/main/connection.hpp" +#include "duckdb/main/database.hpp" + +#include + +namespace duckdb { + +class ClientContext; + +struct WireboneCatalogConfig { + //! `duckdb` (default), `ducklake`, or `json`. + string backend = "duckdb"; + //! Catalog that holds the `wirebone` schema. Empty = current database. + string catalog; + //! Optional dedicated DuckDB file (ignored for ducklake / current-db). + string database; +}; + +//! Coordinator state as DuckDB tables (same process) or DuckLake (shared catalog). +class WireboneCatalog { +public: + void Open(ClientContext &context, const WireboneCatalogConfig &config); + void Close(); + bool Opened() const { + return static_cast(con); + } + + void EnsureSchema(); + string LoadSnapshot(); + void SaveSnapshot(const string &json); + + string SchemaName() const { + return schema; + } + +private: + void Run(const string &sql); + string Qualify(const string &ident) const; + string Escape(const string &value) const; + + unique_ptr owned_db; + unique_ptr con; + string schema = "wirebone"; + std::mutex write_mu; +}; + +} // namespace duckdb diff --git a/src/include/wirebone_functions.hpp b/src/include/wirebone_functions.hpp new file mode 100644 index 0000000..e5b9112 --- /dev/null +++ b/src/include/wirebone_functions.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include "duckdb/main/extension/extension_loader.hpp" + +namespace duckdb { + +void RegisterWireboneFunctions(ExtensionLoader &loader); + +} // namespace duckdb diff --git a/src/quackscale_extension.cpp b/src/quackscale_extension.cpp index b882542..9d9f3cc 100644 --- a/src/quackscale_extension.cpp +++ b/src/quackscale_extension.cpp @@ -5,6 +5,7 @@ #include "attach_ducklake.hpp" #include "tailscale_bridge.hpp" #include "tailscale_http.hpp" +#include "wirebone_functions.hpp" #include "duckdb.hpp" #include "duckdb/common/exception.hpp" @@ -568,6 +569,7 @@ static void LoadInternal(ExtensionLoader &loader) { loader.RegisterFunction(ScalarFunction("quack_token", {}, LogicalType::VARCHAR, QuackTokenFunction)); RegisterAttachDucklakeFunctions(loader); + RegisterWireboneFunctions(loader); } } // namespace diff --git a/src/tailscale_http.cpp b/src/tailscale_http.cpp index dce859d..3e72248 100644 --- a/src/tailscale_http.cpp +++ b/src/tailscale_http.cpp @@ -9,6 +9,7 @@ #include "duckdb/main/extension_helper.hpp" #include +#include #include #ifndef _WIN32 @@ -45,6 +46,33 @@ static void ParseProtoHostPort(const string &proto_host_port, string &host_out, } } +namespace { + +std::mutex g_magicdns_suffix_mu; +vector g_magicdns_suffixes = {".wirebone.local"}; + +} // namespace + +void RegisterTailnetMagicDnsSuffix(const string &suffix) { + string s = StringUtil::Lower(suffix); + while (!s.empty() && s.back() == '.') { + s.pop_back(); + } + if (s.empty()) { + return; + } + if (s.front() != '.') { + s = "." + s; + } + std::lock_guard g(g_magicdns_suffix_mu); + for (auto &existing : g_magicdns_suffixes) { + if (existing == s) { + return; + } + } + g_magicdns_suffixes.push_back(std::move(s)); +} + bool IsTailnetHost(const string &proto_host_port) { // Only intercept plaintext HTTP (or scheme-less) URLs. We speak plaintext over WireGuard and // never negotiate TLS, so https:// URLs — even to a tailnet host — are left to the delegate @@ -57,8 +85,17 @@ bool IsTailnetHost(const string &proto_host_port) { if (host.empty()) { return false; } - // MagicDNS FQDNs. - if (StringUtil::EndsWith(StringUtil::Lower(host), ".ts.net")) { + const string host_l = StringUtil::Lower(host); + // MagicDNS FQDNs (Tailscale SaaS, Wirebone default, plus any served domain). + { + std::lock_guard g(g_magicdns_suffix_mu); + for (auto &suffix : g_magicdns_suffixes) { + if (StringUtil::EndsWith(host_l, suffix)) { + return true; + } + } + } + if (StringUtil::EndsWith(host_l, ".ts.net")) { return true; } // IPv4 in the CGNAT range 100.64.0.0/10 (100.64.x.x .. 100.127.x.x). diff --git a/src/wirebone_bridge.cpp b/src/wirebone_bridge.cpp new file mode 100644 index 0000000..6d5e749 --- /dev/null +++ b/src/wirebone_bridge.cpp @@ -0,0 +1,281 @@ +#include "wirebone_bridge.hpp" + +#include "duckdb/common/exception.hpp" +#include "duckdb/common/string_util.hpp" +#include "duckdb/main/client_context.hpp" +#include "tailscale_http.hpp" + +#include +#include + +#if QUACKSCALE_WITH_WIREBONE +extern "C" { +#include "wirebone/wirebone.h" +} +#endif + +namespace duckdb { + +namespace { + +#if QUACKSCALE_WITH_WIREBONE +void PersistToCatalog(const char *json, void *user) { + if (json && user) { + static_cast(user)->SaveSnapshot(json); + } +} + +string TakeCstr(char *p) { + if (!p) { + return {}; + } + string s(p); + std::free(p); + return s; +} + +string PortFromBound(const string &bound) { + auto colon = bound.rfind(':'); + if (colon == string::npos || colon + 1 >= bound.size()) { + return "8080"; + } + return bound.substr(colon + 1); +} + +string NormalizeMagicDnsDomain(const string &domain) { + string d = StringUtil::Lower(domain); + while (!d.empty() && d.back() == '.') { + d.pop_back(); + } + if (d.empty()) { + return ".wirebone.local"; + } + if (d.front() != '.') { + d = "." + d; + } + return d; +} +#endif + +} // namespace + +WireboneBridge::~WireboneBridge() { + Stop(); +} + +WireboneBridge &WireboneBridge::Get() { + static WireboneBridge instance; + return instance; +} + +bool WireboneBridge::Linked() const { +#if QUACKSCALE_WITH_WIREBONE + return true; +#else + return false; +#endif +} + +void WireboneBridge::RequireLinked() const { +#if !QUACKSCALE_WITH_WIREBONE + throw NotImplementedException( + "QuackScale was built without Wirebone (QUACKSCALE_WITH_WIREBONE=OFF). " + "Checkout wirebone.cpp next to this repo (or set QUACKSCALE_WIREBONE_DIR) and rebuild " + "with OpenSSL, nghttp2, and libzstd."); +#endif +} + +WireboneServeStatus WireboneBridge::Status() const { + std::lock_guard g(mu); + WireboneServeStatus st = last; + st.linked = Linked(); +#if QUACKSCALE_WITH_WIREBONE + st.running = coordinator && wirebone_running(static_cast(coordinator)); +#else + st.running = false; +#endif + return st; +} + +WireboneServeStatus WireboneBridge::Serve(ClientContext &context, const WireboneServeConfig &config) { + RequireLinked(); +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + if (coordinator) { + throw InvalidInputException("wirebone coordinator already running; CALL wirebone_stop() first"); + } + + string backend = StringUtil::Lower(config.backend); + if (backend.empty()) { + backend = "duckdb"; + } + if (backend != "duckdb" && backend != "ducklake" && backend != "json") { + throw InvalidInputException("wirebone backend must be duckdb, ducklake, or json"); + } + if (backend == "ducklake" && config.catalog.empty()) { + throw InvalidInputException("wirebone backend=ducklake requires catalog => ''"); + } + + const bool use_catalog = backend != "json"; + if (use_catalog) { + WireboneCatalogConfig cat_cfg; + cat_cfg.backend = backend; + cat_cfg.catalog = config.catalog; + cat_cfg.database = config.database; + catalog.Open(context, cat_cfg); + catalog.EnsureSchema(); + } + + wirebone_config cfg {}; + cfg.listen = config.listen.c_str(); + cfg.server_url = config.server_url.c_str(); + cfg.state_path = use_catalog ? "" : config.state_path.c_str(); + cfg.domain = config.domain.c_str(); + cfg.dns_listen = config.dns_listen.c_str(); + + auto *c = wirebone_create(&cfg); + if (!c) { + catalog.Close(); + throw IOException("wirebone_serve failed to create coordinator"); + } + if (use_catalog) { + auto snap = catalog.LoadSnapshot(); + if (!snap.empty() && wirebone_import_state(c, snap.c_str()) != 0) { + wirebone_destroy(c); + catalog.Close(); + throw IOException("wirebone_serve failed to import DuckDB snapshot"); + } + wirebone_set_persist_callback(c, PersistToCatalog, &catalog); + wirebone_persist(c); + } + if (wirebone_start(c) != 0) { + wirebone_destroy(c); + catalog.Close(); + throw IOException("wirebone_serve failed to start on %s", config.listen); + } + + coordinator = c; + last.linked = true; + last.running = true; + last.bound = TakeCstr(wirebone_bound_address(c)); + last.control_url = config.server_url; + last.domain = config.domain; + last.preauth_key = TakeCstr(wirebone_bootstrap_key(c)); + if (last.preauth_key.empty()) { + last.preauth_key = TakeCstr(wirebone_create_preauth_key(c, 1, 0)); + } + + RegisterTailnetMagicDnsSuffix(NormalizeMagicDnsDomain(config.domain)); + return last; +#else + (void)context; + (void)config; + return Status(); +#endif +} + +void WireboneBridge::Stop() { +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + if (!coordinator) { + last.running = false; + return; + } + auto *c = static_cast(coordinator); + wirebone_persist(c); + wirebone_stop(c); + wirebone_destroy(c); + coordinator = nullptr; + catalog.Close(); + last.running = false; +#endif +} + +string WireboneBridge::BootstrapKey() const { + RequireLinked(); +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + if (!coordinator) { + throw InvalidInputException("wirebone coordinator is not running; CALL wirebone_serve() first"); + } + string key = TakeCstr(wirebone_bootstrap_key(static_cast(coordinator))); + return key.empty() ? last.preauth_key : key; +#else + return {}; +#endif +} + +string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) { + RequireLinked(); +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + if (!coordinator) { + throw InvalidInputException("wirebone coordinator is not running; CALL wirebone_serve() first"); + } + string key = + TakeCstr(wirebone_create_preauth_key(static_cast(coordinator), reusable ? 1 : 0, + ephemeral ? 1 : 0)); + if (key.empty()) { + throw IOException("wirebone_preauth failed to create a key"); + } + if (last.preauth_key.empty()) { + last.preauth_key = key; + } + return key; +#else + (void)reusable; + (void)ephemeral; + return {}; +#endif +} + +vector WireboneBridge::Nodes() const { + RequireLinked(); +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + vector out; + if (!coordinator) { + return out; + } + size_t count = 0; + wirebone_node_info *nodes = + wirebone_list_nodes(static_cast(coordinator), &count); + out.reserve(count); + for (size_t i = 0; i < count; ++i) { + WireboneNodeRow row; + row.id = nodes[i].id; + if (nodes[i].hostname) { + row.hostname = nodes[i].hostname; + } + if (nodes[i].ipv4) { + row.ipv4 = nodes[i].ipv4; + } + if (nodes[i].ipv6) { + row.ipv6 = nodes[i].ipv6; + } + if (nodes[i].node_key) { + row.node_key = nodes[i].node_key; + } + row.online = nodes[i].online != 0; + out.push_back(std::move(row)); + } + wirebone_free_nodes(nodes, count); + return out; +#else + return {}; +#endif +} + +string WireboneBridge::LocalControlURL() const { + std::lock_guard g(mu); + if (last.bound.empty()) { + return last.control_url; + } +#if QUACKSCALE_WITH_WIREBONE + return "http://127.0.0.1:" + PortFromBound(last.bound); +#else + return last.control_url; +#endif +} + +} // namespace duckdb diff --git a/src/wirebone_catalog.cpp b/src/wirebone_catalog.cpp new file mode 100644 index 0000000..b03465f --- /dev/null +++ b/src/wirebone_catalog.cpp @@ -0,0 +1,146 @@ +#include "wirebone_catalog.hpp" + +#include "duckdb.hpp" +#include "duckdb/common/exception.hpp" +#include "duckdb/common/string_util.hpp" +#include "duckdb/main/client_context.hpp" +#include "duckdb/main/connection.hpp" +#include "duckdb/main/database.hpp" +#include "duckdb/main/materialized_query_result.hpp" + +#if QUACKSCALE_WITH_WIREBONE +#include +#include +#include +#endif + +namespace duckdb { + +void WireboneCatalog::Open(ClientContext &context, const WireboneCatalogConfig &config) { + Close(); + if (!config.catalog.empty() && config.catalog != "memory" && config.catalog != "current") { + schema = config.catalog + ".wirebone"; + } else { + schema = "wirebone"; + } + if (!config.database.empty() && config.backend != "ducklake") { + owned_db = make_uniq(config.database); + con = make_uniq(*owned_db); + } else { + con = make_uniq(DatabaseInstance::GetDatabase(context)); + } +} + +void WireboneCatalog::Close() { + std::lock_guard g(write_mu); + con.reset(); + owned_db.reset(); + schema = "wirebone"; +} + +void WireboneCatalog::Run(const string &sql) { + auto result = con->Query(sql); + if (result->HasError()) { + throw IOException("wirebone catalog: %s\n%s", result->GetError(), sql); + } +} + +string WireboneCatalog::Qualify(const string &ident) const { + return schema + "." + ident; +} + +string WireboneCatalog::Escape(const string &value) const { + return StringUtil::Replace(value, "'", "''"); +} + +void WireboneCatalog::EnsureSchema() { + if (!con) { + return; + } + std::lock_guard g(write_mu); + 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)"); + 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)"); +} + +string WireboneCatalog::LoadSnapshot() { + if (!con) { + return {}; + } + std::lock_guard g(write_mu); + auto result = con->Query("SELECT v FROM " + Qualify("meta") + " WHERE k = 'snapshot'"); + if (result->HasError() || result->RowCount() == 0) { + return {}; + } + return result->GetValue(0, 0).ToString(); +} + +void WireboneCatalog::SaveSnapshot(const string &json) { + if (!con) { + return; + } +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(write_mu); + Run("BEGIN TRANSACTION"); + try { + Run("INSERT OR REPLACE INTO " + Qualify("meta") + " VALUES ('snapshot', '" + Escape(json) + "')"); + nlohmann::json j = nlohmann::json::parse(json.empty() ? "{}" : json); + if (j.contains("noise_private")) { + Run("INSERT OR REPLACE INTO " + Qualify("meta") + " VALUES ('noise_private', '" + + Escape(j["noise_private"].get()) + "')"); + } + if (j.contains("next_node_id")) { + Run("INSERT OR REPLACE INTO " + Qualify("meta") + " VALUES ('next_node_id', '" + + std::to_string(j["next_node_id"].get()) + "')"); + } + if (j.contains("next_ip_index")) { + Run("INSERT OR REPLACE INTO " + Qualify("meta") + " VALUES ('next_ip_index', '" + + std::to_string(j["next_ip_index"].get()) + "')"); + } + Run("DELETE FROM " + Qualify("preauth_keys")); + if (j.contains("preauth_keys")) { + for (const auto &k : j["preauth_keys"]) { + Run("INSERT INTO " + Qualify("preauth_keys") + " VALUES ('" + + Escape(k.value("key", std::string())) + "', " + + 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)) + ")"); + } + } + Run("DELETE FROM " + Qualify("nodes")); + if (j.contains("nodes")) { + for (const auto &n : j["nodes"]) { + std::string endpoints; + if (n.contains("endpoints")) { + endpoints = n["endpoints"].dump(); + } + Run("INSERT INTO " + Qualify("nodes") + " VALUES (" + std::to_string(n.value("id", 0)) + ", '" + + Escape(n.value("stable_id", std::string())) + "', '" + + Escape(n.value("hostname", std::string())) + "', '" + + Escape(n.value("machine_key", std::string())) + "', '" + + Escape(n.value("node_key", std::string())) + "', '" + + 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") + ")"); + } + } + Run("COMMIT"); + } catch (...) { + try { + con->Query("ROLLBACK"); + } catch (...) { + } + throw; + } +#else + (void)json; +#endif +} + +} // namespace duckdb diff --git a/src/wirebone_functions.cpp b/src/wirebone_functions.cpp new file mode 100644 index 0000000..25d3c0e --- /dev/null +++ b/src/wirebone_functions.cpp @@ -0,0 +1,358 @@ +#include "wirebone_functions.hpp" + +#include "tailscale_bridge.hpp" +#include "tailscale_http.hpp" +#include "wirebone_bridge.hpp" + +#include "duckdb.hpp" +#include "duckdb/common/exception.hpp" +#include "duckdb/function/scalar_function.hpp" +#include "duckdb/function/table_function.hpp" +#include "duckdb/main/database.hpp" + +namespace duckdb { + +namespace { + +static string NamedString(TableFunctionBindInput &input, const char *key, const string &fallback = string()) { + auto it = input.named_parameters.find(key); + if (it == input.named_parameters.end() || it->second.IsNull()) { + return fallback; + } + return it->second.GetValue(); +} + +static bool NamedBool(TableFunctionBindInput &input, const char *key, bool fallback) { + auto it = input.named_parameters.find(key); + if (it == input.named_parameters.end() || it->second.IsNull()) { + return fallback; + } + return it->second.GetValue(); +} + +static void MaybeInstallHttpRoute(ClientContext &context, bool http_route) { +#ifdef QUACKSCALE_WITH_TAILSCALE + if (http_route) { + RegisterTailscaleHTTPUtil(DatabaseInstance::GetDatabase(context)); + } +#else + (void)context; + (void)http_route; +#endif +} + +static WireboneServeConfig ParseServeConfig(TableFunctionBindInput &input) { + WireboneServeConfig cfg; + string listen = NamedString(input, "listen"); + if (!listen.empty()) { + cfg.listen = listen; + } + string server_url = NamedString(input, "server_url"); + if (!server_url.empty()) { + cfg.server_url = server_url; + } + cfg.state_path = NamedString(input, "state_path"); + if (cfg.state_path.empty()) { + cfg.state_path = NamedString(input, "coordinator_state"); + } + string domain = NamedString(input, "domain"); + if (!domain.empty()) { + cfg.domain = domain; + } + if (input.named_parameters.find("dns_listen") != input.named_parameters.end()) { + cfg.dns_listen = NamedString(input, "dns_listen"); + } + string backend = NamedString(input, "backend"); + if (!backend.empty()) { + cfg.backend = backend; + } + cfg.catalog = NamedString(input, "catalog"); + cfg.database = NamedString(input, "database"); + return cfg; +} + +static TailscaleAuthConfig ParseJoinConfig(TableFunctionBindInput &input) { + TailscaleAuthConfig config; + if (!input.inputs.empty() && !input.inputs[0].IsNull()) { + config.hostname = input.inputs[0].GetValue(); + } + string hostname = NamedString(input, "hostname"); + if (!hostname.empty()) { + config.hostname = hostname; + } + config.authkey = NamedString(input, "authkey"); + config.control_url = NamedString(input, "control_url"); + config.state_dir = NamedString(input, "state_dir"); + config.ephemeral = NamedBool(input, "ephemeral", false); + config.loopback_proxy = NamedBool(input, "loopback_proxy", false); + return config; +} + +static void RegisterServeParameters(TableFunction &function) { + function.named_parameters["listen"] = LogicalType::VARCHAR; + function.named_parameters["server_url"] = LogicalType::VARCHAR; + function.named_parameters["state_path"] = LogicalType::VARCHAR; + function.named_parameters["coordinator_state"] = LogicalType::VARCHAR; + function.named_parameters["domain"] = LogicalType::VARCHAR; + function.named_parameters["dns_listen"] = LogicalType::VARCHAR; + function.named_parameters["backend"] = LogicalType::VARCHAR; + function.named_parameters["catalog"] = LogicalType::VARCHAR; + function.named_parameters["database"] = LogicalType::VARCHAR; +} + +static void EmitServeRow(DataChunk &output, const WireboneServeStatus &st) { + output.SetCardinality(1); + output.SetValue(0, 0, st.bound.empty() ? Value() : Value(st.bound)); + output.SetValue(1, 0, st.control_url.empty() ? Value() : Value(st.control_url)); + output.SetValue(2, 0, st.domain.empty() ? Value() : Value(st.domain)); + output.SetValue(3, 0, st.preauth_key.empty() ? Value() : Value(st.preauth_key)); +} + +struct WireboneServeBindData : public TableFunctionData { + WireboneServeConfig config; + bool finished = false; +}; + +static unique_ptr WireboneServeBind(ClientContext &, TableFunctionBindInput &input, + vector &return_types, vector &names) { + auto bind = make_uniq(); + bind->config = ParseServeConfig(input); + return_types = {LogicalType::VARCHAR, LogicalType::VARCHAR, LogicalType::VARCHAR, LogicalType::VARCHAR}; + names = {"bound", "control_url", "domain", "preauth_key"}; + return std::move(bind); +} + +static void WireboneServeFunction(ClientContext &context, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + if (bind.finished) { + return; + } + auto st = WireboneBridge::Get().Serve(context, bind.config); + EmitServeRow(output, st); + bind.finished = true; +} + +struct WireboneStatusBindData : public TableFunctionData { + bool finished = false; +}; + +static unique_ptr WireboneStatusBind(ClientContext &, TableFunctionBindInput &, + vector &return_types, vector &names) { + return_types = {LogicalType::BOOLEAN, LogicalType::BOOLEAN, LogicalType::VARCHAR, LogicalType::VARCHAR, + LogicalType::VARCHAR, LogicalType::VARCHAR}; + names = {"linked", "running", "bound", "control_url", "domain", "preauth_key"}; + return make_uniq(); +} + +static void WireboneStatusFunction(ClientContext &, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + if (bind.finished) { + return; + } + auto st = WireboneBridge::Get().Status(); + output.SetCardinality(1); + output.SetValue(0, 0, Value::BOOLEAN(st.linked)); + output.SetValue(1, 0, Value::BOOLEAN(st.running)); + output.SetValue(2, 0, st.bound.empty() ? Value() : Value(st.bound)); + output.SetValue(3, 0, st.control_url.empty() ? Value() : Value(st.control_url)); + output.SetValue(4, 0, st.domain.empty() ? Value() : Value(st.domain)); + output.SetValue(5, 0, st.preauth_key.empty() ? Value() : Value(st.preauth_key)); + bind.finished = true; +} + +struct WireboneStopBindData : public TableFunctionData { + bool finished = false; +}; + +static unique_ptr WireboneStopBind(ClientContext &, TableFunctionBindInput &, + vector &return_types, vector &names) { + return_types = {LogicalType::BOOLEAN}; + names = {"stopped"}; + return make_uniq(); +} + +static void WireboneStopFunction(ClientContext &, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + if (bind.finished) { + return; + } + WireboneBridge::Get().Stop(); + output.SetCardinality(1); + output.SetValue(0, 0, Value::BOOLEAN(true)); + bind.finished = true; +} + +struct WirebonePreauthBindData : public TableFunctionData { + bool reusable = true; + bool ephemeral = false; + bool finished = false; +}; + +static unique_ptr WirebonePreauthBind(ClientContext &, TableFunctionBindInput &input, + vector &return_types, vector &names) { + 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"}; + return std::move(bind); +} + +static void WirebonePreauthFunction(ClientContext &, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + if (bind.finished) { + return; + } + auto key = WireboneBridge::Get().CreatePreauthKey(bind.reusable, bind.ephemeral); + 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)); + bind.finished = true; +} + +struct WireboneNodesBindData : public TableFunctionData { + vector rows; + idx_t offset = 0; +}; + +static unique_ptr WireboneNodesBind(ClientContext &, TableFunctionBindInput &, + vector &return_types, vector &names) { + auto bind = make_uniq(); + if (WireboneBridge::Get().Linked() && WireboneBridge::Get().Status().running) { + 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"}; + return std::move(bind); +} + +static void WireboneNodesFunction(ClientContext &, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + const idx_t count = MinValue(STANDARD_VECTOR_SIZE, bind.rows.size() - bind.offset); + if (count == 0) { + return; + } + for (idx_t i = 0; i < count; i++) { + auto &row = bind.rows[bind.offset + i]; + output.SetValue(0, i, Value::UBIGINT(row.id)); + output.SetValue(1, i, row.hostname.empty() ? Value() : Value(row.hostname)); + output.SetValue(2, i, row.ipv4.empty() ? Value() : Value(row.ipv4)); + 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.SetCardinality(count); + bind.offset += count; +} + +static void WireboneBootstrapKeyFunction(DataChunk &, ExpressionState &, Vector &result) { + if (!WireboneBridge::Get().Linked() || !WireboneBridge::Get().Status().running) { + throw InvalidInputException("wirebone_bootstrap_key: CALL wirebone_serve() first"); + } + result.Reference(Value(WireboneBridge::Get().BootstrapKey())); +} + +struct QuackscaleServeBindData : public TableFunctionData { + WireboneServeConfig serve; + TailscaleAuthConfig join; + bool do_join = true; + bool http_route = true; + bool finished = false; +}; + +static unique_ptr QuackscaleServeBind(ClientContext &, TableFunctionBindInput &input, + vector &return_types, vector &names) { + auto bind = make_uniq(); + bind->serve = ParseServeConfig(input); + bind->join = ParseJoinConfig(input); + bind->do_join = NamedBool(input, "join", true); + bind->http_route = NamedBool(input, "http_route", true); + return_types = {LogicalType::VARCHAR, LogicalType::VARCHAR, LogicalType::VARCHAR, LogicalType::VARCHAR, + LogicalType::BOOLEAN, LogicalType::VARCHAR, LogicalType::LIST(LogicalType::VARCHAR)}; + names = {"bound", "control_url", "domain", "preauth_key", "running", "hostname", "tailnet_ips"}; + return std::move(bind); +} + +static void QuackscaleServeFunction(ClientContext &context, TableFunctionInput &data_p, DataChunk &output) { + auto &bind = data_p.bind_data->CastNoConst(); + if (bind.finished) { + return; + } + + auto st = WireboneBridge::Get().Serve(context, bind.serve); + + bool running = false; + string hostname; + vector ips; + if (bind.do_join) { + if (bind.join.control_url.empty()) { + bind.join.control_url = WireboneBridge::Get().LocalControlURL(); + } + if (bind.join.authkey.empty()) { + bind.join.authkey = st.preauth_key; + } + if (bind.join.hostname.empty()) { + bind.join.hostname = "quackscale"; + } + TailscaleBridge::Get().Up(bind.join); + auto status = TailscaleBridge::Get().Status(); + running = status.running; + hostname = status.hostname; + ips = status.ips; + if (running) { + MaybeInstallHttpRoute(context, bind.http_route); + } + } + + vector ip_values; + ip_values.reserve(ips.size()); + for (auto &ip : ips) { + ip_values.emplace_back(ip); + } + + output.SetCardinality(1); + output.SetValue(0, 0, st.bound.empty() ? Value() : Value(st.bound)); + output.SetValue(1, 0, st.control_url.empty() ? Value() : Value(st.control_url)); + output.SetValue(2, 0, st.domain.empty() ? Value() : Value(st.domain)); + output.SetValue(3, 0, st.preauth_key.empty() ? Value() : Value(st.preauth_key)); + output.SetValue(4, 0, Value::BOOLEAN(running)); + output.SetValue(5, 0, hostname.empty() ? Value() : Value(hostname)); + output.SetValue(6, 0, Value::LIST(LogicalType::VARCHAR, std::move(ip_values))); + bind.finished = true; +} + +} // namespace + +void RegisterWireboneFunctions(ExtensionLoader &loader) { + TableFunction serve("wirebone_serve", {}, WireboneServeFunction, WireboneServeBind); + RegisterServeParameters(serve); + loader.RegisterFunction(serve); + + loader.RegisterFunction(TableFunction("wirebone_status", {}, WireboneStatusFunction, WireboneStatusBind)); + loader.RegisterFunction(TableFunction("wirebone_stop", {}, WireboneStopFunction, WireboneStopBind)); + loader.RegisterFunction(TableFunction("wirebone_nodes", {}, WireboneNodesFunction, WireboneNodesBind)); + + TableFunction preauth("wirebone_preauth", {}, WirebonePreauthFunction, WirebonePreauthBind); + preauth.named_parameters["reusable"] = LogicalType::BOOLEAN; + preauth.named_parameters["ephemeral"] = LogicalType::BOOLEAN; + loader.RegisterFunction(preauth); + + loader.RegisterFunction( + ScalarFunction("wirebone_bootstrap_key", {}, LogicalType::VARCHAR, WireboneBootstrapKeyFunction)); + + TableFunction both("quackscale_serve", {}, QuackscaleServeFunction, QuackscaleServeBind); + RegisterServeParameters(both); + both.named_parameters["hostname"] = LogicalType::VARCHAR; + both.named_parameters["authkey"] = LogicalType::VARCHAR; + both.named_parameters["control_url"] = LogicalType::VARCHAR; + both.named_parameters["state_dir"] = LogicalType::VARCHAR; + both.named_parameters["ephemeral"] = LogicalType::BOOLEAN; + both.named_parameters["loopback_proxy"] = LogicalType::BOOLEAN; + both.named_parameters["http_route"] = LogicalType::BOOLEAN; + both.named_parameters["join"] = LogicalType::BOOLEAN; + loader.RegisterFunction(both); +} + +} // namespace duckdb diff --git a/test/sql/quackscale.test b/test/sql/quackscale.test index 25d5b5e..dd574e4 100644 --- a/test/sql/quackscale.test +++ b/test/sql/quackscale.test @@ -70,3 +70,29 @@ statement error CALL tailscale_login(not_a_real_param => true); ---- http_route + +# Wirebone coordinator SQL is always registered. Live serve/join is e2e-only +# (needs OpenSSL/nghttp2/zstd and a wirebone.cpp checkout). +statement ok +CALL wirebone_status(); + +statement ok +CALL wirebone_nodes(); + +statement ok +CALL wirebone_stop(); + +statement error +SELECT wirebone_bootstrap_key(); +---- +wirebone_serve + +statement error +CALL wirebone_serve(not_a_real_param => true); +---- +listen + +statement error +CALL quackscale_serve(not_a_real_param => true); +---- +join diff --git a/test/sql/wirebone.test b/test/sql/wirebone.test new file mode 100644 index 0000000..801391b --- /dev/null +++ b/test/sql/wirebone.test @@ -0,0 +1,85 @@ +# name: test/sql/wirebone.test +# description: In-process Wirebone coordinator stored in DuckDB tables +# group: [sql] + +require quackscale + +# Functions are always registered. Serve needs QUACKSCALE_WITH_WIREBONE=1 +# (sibling ../wirebone.cpp or QUACKSCALE_WIREBONE_DIR). +query I +SELECT linked FROM wirebone_status(); +---- +true + +statement ok +CALL wirebone_serve(listen => '127.0.0.1:0', dns_listen => ''); + +query I +SELECT running FROM wirebone_status(); +---- +true + +query I +SELECT COUNT(*) >= 1 FROM wirebone.preauth_keys; +---- +true + +query I +SELECT COUNT(*) FROM wirebone.meta WHERE k = 'snapshot'; +---- +1 + +statement ok +CREATE TABLE wirebone_test_keys AS SELECT key FROM wirebone.preauth_keys; + +statement ok +CALL wirebone_preauth(reusable => true); + +query I +SELECT COUNT(*) FROM wirebone.preauth_keys; +---- +2 + +statement ok +CALL wirebone_stop(); + +query I +SELECT running FROM wirebone_status(); +---- +false + +# Tables survive stop (same DuckDB). +query I +SELECT COUNT(*) FROM wirebone.preauth_keys; +---- +2 + +statement ok +CALL wirebone_serve(listen => '127.0.0.1:0', dns_listen => ''); + +# Reloaded snapshot keeps the same keys. +query I +SELECT COUNT(*) FROM wirebone.preauth_keys; +---- +2 + +query I +SELECT COUNT(*) FROM wirebone.preauth_keys k JOIN wirebone_test_keys t USING (key); +---- +1 + +statement ok +CALL wirebone_stop(); + +statement ok +DROP TABLE wirebone_test_keys; + +statement error +CALL wirebone_serve(backend => 'ducklake'); +---- +catalog + +statement error +CALL wirebone_serve(backend => 'nope'); +---- +duckdb From a4ccdb09c80bf66f263d0d6aa6c0cc2096614f7c Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 02:24:14 +0200 Subject: [PATCH 2/5] Expose the in-process control plane as quackscale_hub. Collapse the Wirebone SQL names into a hub vs tailscale_up convention, persist state in the quackscale schema, and add a PR smoke that embeds public wirebone.cpp. --- .github/workflows/hub-integration.yml | 81 +++++++++++++++++ README.md | 83 +++++++++++++---- docs/AUTHENTICATION.md | 37 ++++---- docs/DEVELOPMENT.md | 23 +++-- docs/GUIDE.md | 85 ++++++++++++++++-- docs/README.md | 19 ++-- docs/REFERENCE.md | 90 +++++++------------ examples/README.md | 12 ++- examples/wirebone/README.md | 112 +++++++++++++++++++++++ examples/wirebone/coordinator.sql | 26 ++++++ examples/wirebone/peer.sql | 24 +++++ examples/wirebone/run.sh | 3 + scripts/ci_hub_smoke.sh | 123 ++++++++++++++++++++++++++ src/include/tailscale_http.hpp | 7 +- src/include/wirebone_bridge.hpp | 2 +- src/include/wirebone_catalog.hpp | 4 +- src/tailscale_http.cpp | 4 +- src/wirebone_bridge.cpp | 20 ++--- src/wirebone_catalog.cpp | 20 ++++- src/wirebone_functions.cpp | 59 +++++++----- test/e2e/README.md | 18 +++- test/sql/quackscale.test | 19 ++-- test/sql/wirebone.test | 53 ++++++----- 23 files changed, 741 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/hub-integration.yml create mode 100644 examples/wirebone/README.md create mode 100644 examples/wirebone/coordinator.sql create mode 100644 examples/wirebone/peer.sql create mode 100755 examples/wirebone/run.sh create mode 100755 scripts/ci_hub_smoke.sh diff --git a/.github/workflows/hub-integration.yml b/.github/workflows/hub-integration.yml new file mode 100644 index 0000000..edc8602 --- /dev/null +++ b/.github/workflows/hub-integration.yml @@ -0,0 +1,81 @@ +# Join QuackScale to an in-process hub (build from source on PR). +# Analog of headscale-integration.yml: source build + two-process smoke, no Headscale. +name: Hub integration + +on: + workflow_dispatch: + inputs: + wirebone_ref: + description: 'lmangani/wirebone.cpp ref to embed as the hub library' + required: false + default: main + type: string + pull_request: + paths: + - '.github/workflows/hub-integration.yml' + - 'scripts/ci_hub_smoke.sh' + - 'examples/wirebone/**' + - 'src/**' + - 'cmake/**' + - 'third_party/libtailscale/**' + - 'test/sql/wirebone.test' + - 'test/sql/quackscale.test' + +jobs: + hub-smoke: + name: Hub + QuackScale smoke + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Checkout Wirebone (hub library) + uses: actions/checkout@v4 + with: + repository: lmangani/wirebone.cpp + path: third_party/wirebone + ref: ${{ github.event.inputs.wirebone_ref || 'main' }} + + - uses: actions/setup-go@v5 + with: + go-version: '1.25.5' + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential cmake ninja-build patch ccache curl pkg-config \ + libssl-dev libnghttp2-dev libzstd-dev + + - name: Build QuackScale + run: GEN=ninja make release + + - name: Assert hub is linked + run: | + linked="$(./build/release/duckdb -unsigned -bail -c "LOAD quackscale; SELECT linked FROM quackscale_status();" | tail -1 | tr -d '[:space:]')" + echo "quackscale_status.linked=${linked}" + test "${linked}" = "true" + + - name: Hub SQL unit tests + working-directory: build/release + run: | + ./test/unittest test/sql/quackscale.test + ./test/unittest test/sql/wirebone.test + + - name: Run hub smoke test + run: | + chmod +x scripts/ci_hub_smoke.sh + ./scripts/ci_hub_smoke.sh + + - name: Upload hub smoke logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: hub-smoke-logs + path: .e2e-work/hub-smoke/ + if-no-files-found: warn diff --git a/README.md b/README.md index cfbc484..07c4de5 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ # QuackScale -QuackScale embeds a Tailscale client ([libtailscale](https://github.com/tailscale/libtailscale)) inside DuckDB. A DuckDB process joins your private [Tailscale](https://tailscale.com/) network ([tailnet](https://tailscale.com/docs/concepts/tailnet)) and reaches its peers over encrypted WireGuard. It needs no VPN sidecar and opens no public port. +QuackScale embeds a Tailscale client ([libtailscale](https://github.com/tailscale/libtailscale)) inside DuckDB. A DuckDB process joins a private [tailnet](https://tailscale.com/docs/concepts/tailnet) and reaches its peers over encrypted WireGuard. It needs no VPN sidecar and opens no public port. -Once your process is on the tailnet, you read any HTTP asset a peer serves as if it sat on local disk. `FROM read_csv('http://my-laptop.ts.net:8000/sales.csv')` pulls the file straight off another machine over the encrypted mesh, with no public URL and no copy step. +The control plane can be [Tailscale](https://tailscale.com/) SaaS, a self-hosted [Headscale](https://github.com/juanfont/headscale) process, or this DuckDB process (`CALL quackscale_hub`). Peers always join the same way: `CALL tailscale_up`. Pair it with DuckDB's [Quack](https://duckdb.org/docs/current/quack/overview) HTTP protocol and you have **QuackTail**: SQL engines that find each other on `100.x` addresses and [MagicDNS](https://tailscale.com/docs/features/magicdns), then run `ATTACH`, `quack_query`, and DuckLake workloads across the mesh. ```sql LOAD quack; -- HTTP server, ATTACH, quack_query -LOAD quackscale; -- join the tailnet, dial, forward, serve +LOAD quackscale; -- tailscale_up to join, quackscale_hub to host ``` QuackScale is the network layer that carries DuckDB's HTTP across a tailnet, from a plain file read to `quack` and `ducklake`. @@ -27,11 +27,25 @@ QuackScale inverts that. Each DuckDB process carries its own tailnet identity an | What the database trusts | Whatever the perimeter admits | Peers its control plane already trusts | | Encryption | TLS you configure and renew | WireGuard between every node | | Reaching across networks | Manual port forwarding, VPN appliance | Direct paths or DERP relays | -| Extra process to run | A VPN daemon or appliance | None; tsnet runs in-process | +| Extra process to run | A VPN daemon or appliance | None; tsnet (and optionally the hub) run in-process | -Each node clears two independent checks. The tailnet asks whether the machine belongs to your mesh, and Tailscale or [Headscale](https://github.com/juanfont/headscale) ACLs decide which nodes may open a connection. A Quack token then asks whether the caller may run SQL. A stolen token buys nothing from a machine off the mesh, and a machine on the mesh still needs a token. Set `QUACK_TAILNET_TOKEN` once and the whole fleet shares one secret. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). +Each node clears two independent checks. The tailnet asks whether the machine belongs to your mesh, and the control plane decides which nodes may open a connection. A Quack token then asks whether the caller may run SQL. A stolen token buys nothing from a machine off the mesh, and a machine on the mesh still needs a token. Set `QUACK_TAILNET_TOKEN` once and the whole fleet shares one secret. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). -You drive all of it from SQL. Joining, status, ping, forward, serve, and teardown are `CALL` table functions, so you keep the network in the same migrations and init scripts as your data. One SQL surface covers Tailscale's hosted control plane, a self-hosted [Headscale](https://headscale.net/), and an in-process [Wirebone](https://github.com/lmangani/wirebone.cpp) coordinator (`CALL quackscale_serve`): set `control_url` and a preauth key, and the client path does not change. +You drive all of it from SQL. Joining, status, ping, forward, serve, and teardown are `CALL` table functions, so you keep the network in the same migrations and init scripts as your data. + +## Control plane + +Pick one. The client path does not change. + +| | Hosted Tailscale | Headscale | In-process hub | +|---|---|---|---| +| Who runs the control plane | Tailscale | A Headscale process | This DuckDB process | +| Hub / server SQL | `CALL tailscale_up(...)` | `CALL tailscale_up(control_url, authkey, …)` | `CALL quackscale_hub(...)` | +| Peer SQL | `CALL tailscale_up(...)` | Same, plus `control_url` and a Headscale key | `CALL tailscale_up(control_url, authkey, …)` | +| MagicDNS | `*.ts.net` | Your Headscale base domain | `*.quackscale.local` | +| Control-plane state | Tailscale | Headscale's store | DuckDB tables (`quackscale.*`), or DuckLake | + +`quackscale_hub` starts the control plane and joins it (`join` defaults to true). `CALL quackscale_hub(..., join => false)` is control plane only. Community builds that were not linked against the hub library report `linked=false` from `quackscale_status()`; they still join Tailscale or Headscale. ## Install @@ -54,7 +68,7 @@ Prebuilt QuackTail binary bundles ship on each [release](https://github.com/quac ## Quick start -### A server others can reach +### Join an existing tailnet ```sh export TS_AUTHKEY='tskey-auth-...' # Tailscale auth key, or a Headscale preauth key @@ -77,7 +91,31 @@ CALL tailscale_serve_local(port => 9494); FROM quack_discover(); -- prints this node's quack: URI on the tailnet ``` -Leave a long-lived server running with a persistent `state_dir`, and do not call `tailscale_down()`. For Headscale, add `control_url` and a preauth key. To host the control plane in this same process, `CALL quackscale_serve(...)` instead of `tailscale_up`. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). +Leave a long-lived server running with a persistent `state_dir`, and do not call `tailscale_down()`. For Headscale, add `control_url` and a preauth key. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). + +### Host the control plane here + +No Tailscale account and no Headscale process. This node is the hub; peers only call `tailscale_up`. Requires a hub-linked build (`SELECT linked FROM quackscale_status()`). + +```sql +LOAD quack; +LOAD quackscale; + +CALL quackscale_hub( + hostname => 'coord', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '~/.local/share/duckdb/quackscale' +); + +SELECT * FROM quackscale.nodes; -- hub roster +SELECT * FROM quackscale.preauth_keys; -- keys for peers + +CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); +CALL tailscale_serve_local(port => 9494); +``` + +Create extra keys with `CALL quackscale_preauth(reusable => true)`. State lives in the `quackscale` schema of this DuckDB (or `backend => 'ducklake', catalog => 'lake'`). A two-process walkthrough is in [examples/wirebone](examples/wirebone/README.md). ### A client that reaches it @@ -87,10 +125,16 @@ After `tailscale_up`, tailnet `quack:` addresses route over the mesh on their ow LOAD quack; LOAD quackscale; -CALL tailscale_up(hostname => 'my-client', state_dir => '~/.local/share/duckdb/quackscale-client'); +CALL tailscale_up( + hostname => 'my-client', + control_url => 'http://10.0.0.5:8080', -- omit for Tailscale SaaS + authkey => 'wbkey-…', -- or TS_AUTHKEY / Headscale key + state_dir => '~/.local/share/duckdb/quackscale-client' +); CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:100.x.x.x:9494'); ATTACH 'quack:100.x.x.x:9494' AS remote (TYPE quack, DISABLE_SSL true); +-- Hub MagicDNS: ATTACH 'quack:coord.quackscale.local:9494' … FROM remote.query('SELECT 42'); @@ -103,7 +147,7 @@ CALL tailscale_down(); -- a one-shot client must close tsnet, or the process h ```mermaid flowchart TB subgraph server["Quack server"] - s1[tailscale_up] + s1["tailscale_up or quackscale_hub"] dl[ATTACH ducklake] pq[(Parquet volume)] sq[quack_serve + serve_local] @@ -112,31 +156,36 @@ flowchart TB dl --> sq end + wb["Hub control plane
optional, same process"] + s1 -.-> wb + m((100.x tailnet)) sq -->|tailscale_dial| m + wb -.->|map / preauth| m - c1["client · ATTACH quack"] - c2["client · attach_ducklake"] + c1["peer · tailscale_up + ATTACH quack"] + c2["peer · attach_ducklake"] m -->|encrypted TCP| c1 m -->|encrypted TCP| c2 ``` -A server joins the tailnet with `tailscale_up`, attaches a DuckLake catalog when it owns one, and serves Quack on loopback through `quack_serve` and `tailscale_serve_local`. Clients reach it over encrypted TCP across the mesh, and no node listens on the public internet. +A server joins with `tailscale_up`, or hosts the mesh itself with `quackscale_hub`. It attaches a DuckLake catalog when it owns one, and serves Quack on loopback through `quack_serve` and `tailscale_serve_local`. Clients reach it over encrypted TCP across the mesh, and no node listens on the public internet. -`tailscale_up` wraps DuckDB's HTTP layer. QuackScale then dials any tailnet host you name (`100.64.0.0/10`, `*.ts.net`, or `*.wirebone.local`) over tsnet, so `ATTACH 'quack:100.x:9494'` works on its own. Pass `http_route => false` to turn this off. Three cases fall outside the router: a bare MagicDNS short name, a pinned `127.0.0.1:` endpoint, and a non-HTTP client. For those, `tailscale_quack_forward` listens on loopback and dials the peer. To read a server-owned DuckLake catalog, call `attach_ducklake`. The [guide](docs/GUIDE.md) works through each pattern. +`tailscale_up` wraps DuckDB's HTTP layer. QuackScale then dials any tailnet host you name (`100.64.0.0/10`, `*.ts.net`, or `*.quackscale.local`) over tsnet, so `ATTACH 'quack:100.x:9494'` works on its own. Pass `http_route => false` to turn this off. Three cases fall outside the router: a bare MagicDNS short name, a pinned `127.0.0.1:` endpoint, and a non-HTTP client. For those, `tailscale_quack_forward` listens on loopback and dials the peer. To read a server-owned DuckLake catalog, call `attach_ducklake`. The [guide](docs/GUIDE.md) works through each pattern. ## Where to next | You want to… | Read | |--------------|------| | Pick a pattern: remote tables, server-owned DuckLake, or shared Parquet | [docs/GUIDE.md](docs/GUIDE.md) | -| Set up tailnet login, Headscale, Wirebone, and Quack tokens | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | +| Set up Tailscale, Headscale, an in-process hub, and Quack tokens | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | | Look up a SQL command and its parameters | [docs/REFERENCE.md](docs/REFERENCE.md) | -| Run a two-node proof on Docker Compose | [examples/README.md](examples/README.md) | +| Run a two-process hub proof on one machine | [examples/wirebone](examples/wirebone/README.md) | +| Run a two-node Headscale proof on Docker Compose | [examples/README.md](examples/README.md) | | Build the extension, or work on it | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | ## License -MIT, from the [DuckDB extension template](https://github.com/duckdb/extension-template). libtailscale is [BSD-3-Clause](https://github.com/tailscale/libtailscale/blob/main/LICENSE). +MIT, from the [DuckDB extension template](https://github.com/duckdb/extension-template). libtailscale is [BSD-3-Clause](https://github.com/tailscale/libtailscale/blob/main/LICENSE). Wirebone is [MIT](https://github.com/lmangani/wirebone.cpp). diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 106d022..35ffa62 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -4,7 +4,7 @@ QuackTail uses **two independent credential layers**. Both matter in production | Layer | Question | Configure with | |-------|----------|----------------| -| **Tailnet** | Is this process on our mesh? | `TS_AUTHKEY`, Headscale preauth key, or browser login → `CALL tailscale_up` | +| **Tailnet** | Is this process on our mesh? | Tailscale / Headscale / hub key → `CALL tailscale_up`, or `CALL quackscale_hub` | | **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). @@ -94,40 +94,45 @@ CALL tailscale_up( --- -## Wirebone (in-process control plane) +## In-process hub -[Wirebone](https://github.com/lmangani/wirebone.cpp) is a Tailscale/Headscale-compatible coordinator that QuackScale can embed. One DuckDB process hosts the control plane; that same process — and every peer — joins with the existing `tailscale_up` client (libtailscale). No Headscale container and no Tailscale SaaS. +One DuckDB process can host the Tailscale/Headscale-compatible control plane. That process — and every peer — joins with `tailscale_up`. No Headscale container and no Tailscale SaaS. The library behind the hub is [Wirebone](https://github.com/lmangani/wirebone.cpp); operators do not call it by that name. -Coordinator + client in one process: +| Job | SQL | +|-----|-----| +| This node is the hub (joins by default) | `CALL quackscale_hub(...)` | +| Hub, control plane only | `CALL quackscale_hub(..., join => false)` | +| Peer | `CALL tailscale_up(control_url => …, authkey => 'wbkey-…')` | ```sql LOAD quackscale; -CALL quackscale_serve( - hostname => 'duckdb-coord', - listen => '0.0.0.0:8080', - server_url => 'http://10.0.0.5:8080', - state_dir => '/var/lib/duckdb/tailscale' +CALL quackscale_hub( + hostname => 'duckdb-coord', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '/var/lib/duckdb/tailscale' ); --- coordinator rows: SELECT * FROM wirebone.nodes; +SELECT * FROM quackscale.nodes; +SELECT * FROM quackscale.preauth_keys; ``` -Or two calls: `CALL wirebone_serve(...)` then `CALL tailscale_up(control_url => 'http://127.0.0.1:8080', authkey => wirebone_bootstrap_key(), ...)`. - -Peers (client only): +Peers (client only — they do not start a hub): ```sql CALL tailscale_up( hostname => 'duckdb-node-b', control_url => 'http://10.0.0.5:8080', - authkey => 'wbkey-…', -- from the coordinator's wirebone_preauth / bootstrap key + authkey => 'wbkey-…', -- FROM quackscale.preauth_keys / quackscale_preauth state_dir => '/var/lib/duckdb/tailscale' ); ``` -Create extra keys with `CALL wirebone_preauth(reusable => true)`. Preauth keys and node IPs persist in the `wirebone` schema of this DuckDB (or `backend => 'ducklake', catalog => 'lake'` for a shared catalog). Use `backend => 'json', state_path => '…'` only if you need the standalone file format. +Create extra keys with `CALL quackscale_preauth(reusable => true)`. Preauth keys and node IPs persist in the `quackscale` schema of this DuckDB (or `backend => 'ducklake', catalog => 'lake'` for a shared catalog). Use `backend => 'json', state_path => '…'` only if you need the standalone file format. + +`server_url` must be an address **peers can open**. `127.0.0.1` is fine for two processes on one machine; use a LAN or overlay IP for a fleet. -`CALL wirebone_status()` reports `linked=false` if this binary was built without Wirebone (missing sources or OpenSSL/nghttp2/zstd). See [DEVELOPMENT.md](DEVELOPMENT.md). +`CALL quackscale_status()` reports `linked=false` if this binary was built without the hub (missing sources or OpenSSL/nghttp2/zstd). See [DEVELOPMENT.md](DEVELOPMENT.md). Local walkthrough: [examples/wirebone](../examples/wirebone/README.md). SQL: [REFERENCE.md](REFERENCE.md#in-process-hub). --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 132acd1..f0afe57 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,8 +11,8 @@ QuackScale does **not** reimplement Quack. It provides tailnet lifecycle SQL, a ```text DuckDB + quackscale + libtailscale → tailscale_up, tailscale_quack_forward, quack_uri, attach_ducklake -DuckDB + quackscale + wirebone (optional) - → wirebone_serve / quackscale_serve (coordinator + client in one process) +DuckDB + quackscale + wirebone (optional hub library) + → quackscale_hub (control plane + client in one process) DuckDB + quack (core) → quack_serve, ATTACH, quack_query ``` @@ -38,7 +38,7 @@ Disable libtailscale (stub build): make CMAKE_VARS="-DQUACKSCALE_WITH_TAILSCALE=OFF" ``` -Wirebone (in-process coordinator) is enabled automatically on POSIX when `../wirebone.cpp` or `third_party/wirebone` exists and pkg-config can see OpenSSL (`libcrypto`), nghttp2, and libzstd. Coordinator state is stored in DuckDB tables (`wirebone.meta`, `wirebone.preauth_keys`, `wirebone.nodes`) or in an attached DuckLake catalog. Override the source path with `-DQUACKSCALE_WIREBONE_DIR=…`, or force it off: +The in-process hub is enabled automatically on POSIX when `../wirebone.cpp` or `third_party/wirebone` exists and pkg-config can see OpenSSL (`libcrypto`), nghttp2, and libzstd. Hub state is stored in DuckDB tables (`quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`) or in an attached DuckLake catalog. Override the source path with `-DQUACKSCALE_WIREBONE_DIR=…`, or force it off: ```sh make CMAKE_VARS="-DQUACKSCALE_WITH_WIREBONE=OFF" @@ -54,7 +54,7 @@ cmake/Wirebone.cmake Optional in-process coordinator (sibling or QUACKS third_party/libtailscale/ git submodule src/ C++ extension (bridge, forwarder, attach_ducklake, wirebone catalog) scripts/e2e/ Compose entrypoint, bootstrap, verify-image -examples/ Docker Compose two-node demo +examples/ Headscale Compose demo; examples/wirebone is local coordinator+peer duckdb/ DuckDB submodule extension-ci-tools/ Extension build makefile submodule ``` @@ -81,11 +81,12 @@ When bumping the DuckDB target: |----------|---------|---------| | [headscale-e2e.yml](../.github/workflows/headscale-e2e.yml) | **Manual only** | Release-binary two-node e2e (no source build) | | [headscale-integration.yml](../.github/workflows/headscale-integration.yml) | PR | Source build + Headscale smoke | +| [hub-integration.yml](../.github/workflows/hub-integration.yml) | PR | Source build + in-process hub smoke (embeds [wirebone.cpp](https://github.com/lmangani/wirebone.cpp)) | | [Release.yml](../.github/workflows/Release.yml) | Release published / manual | Extension repo → GitHub Pages; linux QuackTail tarball → Releases | | [libtailscale-integration.yml](../.github/workflows/libtailscale-integration.yml) | PR | libtailscale `go test` | | [MainDistributionPipeline.yml](../.github/workflows/MainDistributionPipeline.yml) | PR | Extension distribution CI | -**E2e never runs on push/PR** and never compiles DuckDB in CI — use `workflow_dispatch` on `headscale-e2e` with a release tag. Full DuckLake compose demo is local dev only (`scripts/ci_compose_e2e.sh`). +**Headscale e2e never runs on push/PR** and never compiles DuckDB in CI — use `workflow_dispatch` on `headscale-e2e` with a release tag. Hub integration **does** compile DuckDB (same as Headscale integration) so it can embed the hub library. Full DuckLake compose demo is local dev only (`scripts/ci_compose_e2e.sh`). ### Release and GitHub Pages @@ -112,7 +113,7 @@ Each Pages deploy replaces the whole site (one DuckDB version hosted). To host m | Headscale + Compose e2e | Done | | `ATTACH … TYPE quacktail_lake` (Tier 3 native catalog) | Planned | | `ducklake_discover()` enriched discovery | Planned | -| `quackscale_serve()` one-call coordinator + client | Done (Wirebone; optional build) | +| `quackscale_hub()` in-process control plane + client | Done (optional Wirebone build) | | Community extension descriptor publish | Done (GitHub Pages on release) | ## Risks @@ -129,8 +130,14 @@ Each Pages deploy replaces the whole site (one DuckDB version hosted). To host m make test ``` -SQL unit tests do not require a live tailnet. E2e: [test/e2e/README.md](../test/e2e/README.md), [examples/README.md](../examples/README.md). +SQL unit tests do not require a live tailnet. `test/sql/wirebone.test` needs a hub-linked build (`../wirebone.cpp` or `third_party/wirebone`). Two-process hub smoke (CI): + +```sh +./scripts/ci_hub_smoke.sh +``` + +E2e: [test/e2e/README.md](../test/e2e/README.md), [examples/wirebone](../examples/wirebone/README.md), [examples/README.md](../examples/README.md). ## License -MIT (extension template). libtailscale is [BSD-3-Clause](https://github.com/tailscale/libtailscale/blob/main/LICENSE). +MIT (extension template). libtailscale is [BSD-3-Clause](https://github.com/tailscale/libtailscale/blob/main/LICENSE). Wirebone is [MIT](https://github.com/lmangani/wirebone.cpp). diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 8633dba..9a02472 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -2,12 +2,12 @@ QuackTail combines: -1. **Tailscale, Headscale, or in-process Wirebone** — private mesh between nodes +1. **Tailscale, Headscale, or an in-process hub** — private mesh between nodes 2. **Quack** — DuckDB’s HTTP protocol (`quack:` URIs, port **9494**) 3. **QuackScale** — joins DuckDB to the tailnet and forwards Quack across it 4. **DuckLake** (optional) — lakehouse catalog + Parquet on a QuackTail node -QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. When built with Wirebone, one node can be the control plane **and** a mesh client (`CALL quackscale_serve`); other nodes stay client-only (`CALL tailscale_up`). +QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. One node can host the control plane **and** join it (`CALL quackscale_hub`); other nodes stay client-only (`CALL tailscale_up`). Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md](REFERENCE.md). Build: [DEVELOPMENT.md](DEVELOPMENT.md). @@ -18,7 +18,8 @@ Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md ```text ┌─────────────────────────────────────────────────────────────────┐ │ quacktail-server (long-lived) │ -│ tailscale_up → quack_serve(127.0.0.1:9494) → tailscale_serve_local +│ tailscale_up — or quackscale_hub (control plane + client) │ +│ → quack_serve(127.0.0.1:9494) → tailscale_serve_local │ │ optional: ATTACH ducklake:… AS lake (local or s3:// Parquet) │ └───────────────────────────────┬─────────────────────────────────┘ │ tailscale_dial (encrypted) @@ -30,9 +31,19 @@ Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md └─────────────────────────────────────────────────────────────────┘ ``` -**Transparent routing (default).** After `tailscale_up`, QuackScale wraps DuckDB's HTTP layer so requests to tailnet hosts (`100.64.0.0/10`, `*.ts.net`) are dialed over tsnet — `ATTACH 'quack:100.x.x.x:9494'` works with no forwarder. Disable with `tailscale_up(..., http_route => false)`. +**Choose a control plane first.** Peers always join with `CALL tailscale_up`. Only the hub node calls `quackscale_hub`. -**Why `tailscale_quack_forward`?** It covers what the router does not: bare MagicDNS **short** names (no `.ts.net` suffix), a pinned `127.0.0.1:` endpoint, or non-HTTP clients. It listens on loopback and dials the peer via `tailscale_dial`. The compose demo uses it (stable hostnames) and also probes the direct-router path. +| Control plane | Server join | Peer join | +|---------------|-------------|-----------| +| Tailscale SaaS | `tailscale_up` | `tailscale_up` | +| Headscale | `tailscale_up(control_url, authkey, …)` | Same | +| In-process hub | `quackscale_hub(...)` | `tailscale_up(control_url, authkey => 'wbkey-…', …)` | + +Hub state is DuckDB tables by default (`quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`). Use `backend => 'ducklake', catalog => 'lake'` when several hubs should share one catalog. Credentials: [AUTHENTICATION.md](AUTHENTICATION.md#in-process-hub). Local walkthrough: [examples/wirebone](../examples/wirebone/README.md). + +**Transparent routing (default).** After `tailscale_up` (or `quackscale_hub`), QuackScale wraps DuckDB's HTTP layer so requests to tailnet hosts (`100.64.0.0/10`, `*.ts.net`, `*.quackscale.local`) are dialed over tsnet — `ATTACH 'quack:100.x.x.x:9494'` works with no forwarder. Disable with `tailscale_up(..., http_route => false)`. + +**Why `tailscale_quack_forward`?** It covers what the router does not: bare MagicDNS **short** names (no `.ts.net` / `.quackscale.local` suffix), a pinned `127.0.0.1:` endpoint, or non-HTTP clients. It listens on loopback and dials the peer via `tailscale_dial`. The compose demo uses it (stable hostnames) and also probes the direct-router path. **Why `tailscale_down`?** `tailscale_up` and the forwarder start background threads. One-shot DuckDB processes **hang after SQL finishes** unless tsnet is shut down. @@ -75,7 +86,7 @@ LOAD quackscale; CALL tailscale_up( hostname => 'my-client', - control_url => 'http://headscale:8080', -- omit for Tailscale SaaS; Wirebone uses the coordinator URL + control_url => 'http://headscale:8080', -- omit for Tailscale SaaS; hub: the hub's server_url authkey => '…', state_dir => '/tmp/client-tailscale', ephemeral => true @@ -111,6 +122,65 @@ CALL tailscale_down(); --- +## Use case 0 — Self-hosted hub + +**Story:** One DuckDB process is the mesh hub. Every other node is a client. No Tailscale account, no Headscale container. + +`quackscale_hub` starts the control plane and joins it (`join` defaults to true). Peers only call `tailscale_up`. + +### Hub (long-lived) + +```sql +LOAD quack; +LOAD quackscale; + +CALL quackscale_hub( + hostname => 'analytics-hub', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', -- address peers can reach + state_dir => '/var/lib/quacktail/hub' +); + +SELECT * FROM quackscale.nodes; +CALL quackscale_preauth(reusable => true); -- extra keys for the fleet + +CALL quack_serve( + 'quack:127.0.0.1:9494', + allow_other_hostname => true, + token => quack_token() +); +CALL tailscale_serve_local(port => 9494); +FROM quack_discover(); +``` + +**Do not** call `tailscale_down()` or `quackscale_stop()` on a steady-state hub. + +### Peer + +```sql +LOAD quack; +LOAD quackscale; + +CALL tailscale_up( + hostname => 'analyst-laptop', + control_url => 'http://10.0.0.5:8080', + authkey => 'wbkey-…', + state_dir => '~/.local/share/duckdb/quackscale-client', + ephemeral => true +); + +-- MagicDNS, or the 100.x from the hub's quackscale.nodes +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); + +FROM remote.query('SELECT 42'); +DETACH remote; +CALL tailscale_down(); +``` + +If MagicDNS short names fail, use `tailscale_quack_forward(host => 'analytics-hub', …)` or the tailnet IP. Runnable copy: [examples/wirebone](../examples/wirebone/README.md). + +--- + ## Use case 1 — Remote DuckDB hub (Pattern A) **Story:** A central DuckDB node serves live tables to analysts and services on the tailnet. @@ -356,7 +426,8 @@ Upstream: [duckdb/duckdb#22605](https://github.com/duckdb/duckdb/issues/22605). | Demo | Command | |------|---------| -| **Two-node cluster + DuckLake** | [examples/README.md](../examples/README.md) | +| **Two-process hub (no Docker)** | [examples/wirebone/README.md](../examples/wirebone/README.md) | +| **Two-node Headscale + DuckLake** | [examples/README.md](../examples/README.md) | | **DuckLake compose details** | [examples/ducklake/README.md](../examples/ducklake/README.md) | | **Host DuckDB → compose stack** | `scripts/local_remote_headscale_test.sh` | | **Network-only probe** | `docker compose --profile debug run --rm tailscale-probe` | diff --git a/docs/README.md b/docs/README.md index 69b4671..6048746 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # QuackTail documentation -QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale or Headscale). These docs are for **integrators** — operators wiring servers, clients, tokens, and lake catalogs — not for extension C++ development. +QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale, Headscale, or an in-process hub). These docs are for **integrators** — operators wiring servers, clients, tokens, and lake catalogs — not for extension C++ development. ## Start here @@ -8,9 +8,10 @@ QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale or |----------|-------------------------| | **[Why QuackScale](../README.md#why-quackscale)** | Understand why a DuckDB process carries its own tailnet identity, and how the pieces fit | | **[GUIDE.md](GUIDE.md)** | Pick a pattern, run use cases, connect clients, query DuckLake, avoid known pitfalls | -| **[AUTHENTICATION.md](AUTHENTICATION.md)** | Configure tailnet login, Headscale, and Quack HTTP tokens | +| **[AUTHENTICATION.md](AUTHENTICATION.md)** | Configure Tailscale, Headscale, an in-process hub, and Quack HTTP tokens | | **[REFERENCE.md](REFERENCE.md)** | Look up a `quackscale` SQL command and its parameters | -| **[../examples/README.md](../examples/README.md)** | Run the two-node Docker Compose demo | +| **[../examples/wirebone/README.md](../examples/wirebone/README.md)** | Run a two-process hub proof (no Headscale) | +| **[../examples/README.md](../examples/README.md)** | Run the two-node Headscale Docker Compose demo | ## Extension developers @@ -21,17 +22,19 @@ QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale or ## Quick orientation ```text -Tailscale / Headscale → Is this machine on our mesh? -Quack token → May this caller run SQL over HTTP? -tailscale_quack_forward → Route Quack from embedded tsnet to 127.0.0.1 -quack_serve + serve_local → Expose DuckDB on the tailnet (:9494) +Tailscale / Headscale / hub → Is this machine on our mesh? +Quack token → May this caller run SQL over HTTP? +quackscale_hub → Host the control plane in this process +tailscale_up → Join as a client (every peer, including the hub) +tailscale_quack_forward → Route Quack from embedded tsnet to 127.0.0.1 +quack_serve + serve_local → Expose DuckDB on the tailnet (:9494) ``` Load both extensions in every session: ```sql LOAD quack; -- HTTP server, ATTACH, quack_query -LOAD quackscale; -- tailscale_up, forwarder, attach_ducklake, … +LOAD quackscale; -- tailscale_up, quackscale_hub, forwarder, attach_ducklake, … ``` Do **not** copy the random `auth_token` from each `CALL quack_serve`. Use a **shared** fleet token — see [AUTHENTICATION.md](AUTHENTICATION.md). diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6317e38..ff59aca 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -38,8 +38,8 @@ The first positional argument, if given, sets `hostname`. | Parameter | Type | Default | Meaning | |-----------|------|---------|---------| | `hostname` | VARCHAR | none | Node name on the tailnet. May also be passed positionally. | -| `authkey` | VARCHAR | `TS_AUTHKEY` env | Tailscale or Headscale preauth key. | -| `control_url` | VARCHAR | Tailscale SaaS | Control-plane URL. Set for Headscale. | +| `authkey` | VARCHAR | `TS_AUTHKEY` env | Tailscale, Headscale, or Wirebone (`wbkey-…`) preauth key. | +| `control_url` | VARCHAR | Tailscale SaaS | Control-plane URL. Set for Headscale or Wirebone. | | `state_dir` | VARCHAR | none | Directory for persisted tailnet identity. | | `ephemeral` | BOOLEAN | `false` | Register as an ephemeral node, removed when it disconnects. | | `loopback_proxy` | BOOLEAN | `false` | Start the libtailscale loopback SOCKS proxy (used by the deprecated `tailscale_quack_proxy`). | @@ -124,86 +124,70 @@ Returns one row: --- -## In-process coordinator (Wirebone) +## In-process hub -A QuackScale build that finds [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) (sibling checkout or `QUACKSCALE_WIREBONE_DIR`) can host the Tailscale/Headscale-compatible control plane **in the same process** as the tsnet client. Peers still call `tailscale_up` with `control_url` and a preauth key; they do not run Wirebone. +A QuackScale build that finds [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) (sibling checkout or `QUACKSCALE_WIREBONE_DIR`) can host the control plane **in this process**. Peers only call [`tailscale_up`](#tailscale_up) with `control_url` and a preauth key. -`CALL wirebone_status()` reports `linked=false` when the extension was built without Wirebone. +`CALL quackscale_status()` reports `linked=false` when this binary was built without the hub library. -### `wirebone_serve` +### `quackscale_hub` ```sql -CALL wirebone_serve( +CALL quackscale_hub( + hostname => 'coord', listen => '0.0.0.0:8080', server_url => 'http://10.0.0.5:8080', - domain => 'wirebone.local' + state_dir => '/var/lib/duckdb/tailscale' ); -SELECT * FROM wirebone.preauth_keys; -SELECT * FROM wirebone.nodes; +SELECT * FROM quackscale.preauth_keys; +SELECT * FROM quackscale.nodes; ``` -Starts the coordinator on a background thread. State lives in DuckDB tables in the `wirebone` schema of the current database (or an attached DuckLake catalog). The same process can then join as a client with [`tailscale_up`](#tailscale_up) or [`quackscale_serve`](#quackscale_serve). +Starts the control plane on a background thread and, by default, joins it as a tsnet client (`tailscale_up` against `http://127.0.0.1:` using the bootstrap key). Set `join => false` for control plane only. + +State lives in DuckDB tables in the `quackscale` schema (or an attached DuckLake catalog). | Parameter | Type | Default | Meaning | |-----------|------|---------|---------| | `listen` | VARCHAR | `0.0.0.0:8080` | Bind address for the control plane. | | `server_url` | VARCHAR | `http://127.0.0.1:8080` | URL advertised to other nodes. | | `backend` | VARCHAR | `duckdb` | `duckdb` (local tables), `ducklake` (shared catalog), or `json` (legacy file). | -| `catalog` | VARCHAR | current database | Catalog that holds schema `wirebone`. Required for `ducklake`. | +| `catalog` | VARCHAR | current database | Catalog that holds schema `quackscale`. Required for `ducklake`. | | `database` | VARCHAR | none | Dedicated `.duckdb` file when you do not want the session database. | | `state_path` | VARCHAR | none | JSON file when `backend => 'json'`. | | `coordinator_state` | VARCHAR | none | Alias for `state_path`. | -| `domain` | VARCHAR | `wirebone.local` | MagicDNS suffix (`hostname.domain`). | +| `domain` | VARCHAR | `quackscale.local` | MagicDNS suffix (`hostname.domain`). | | `dns_listen` | VARCHAR | `0.0.0.0:5353` | UDP MagicDNS listener; empty disables it. | +| `join` | BOOLEAN | `true` | Also join the mesh as a tsnet client. | -Tables (same schema on DuckLake): `wirebone.meta`, `wirebone.preauth_keys`, `wirebone.nodes`. - -Returns one row: `bound`, `control_url`, `domain`, `preauth_key` (bootstrap key created on first serve). - -### `quackscale_serve` - -```sql -CALL quackscale_serve( - hostname => 'duckdb-coord', - listen => '0.0.0.0:8080', - server_url => 'http://10.0.0.5:8080', - catalog => 'memory', - state_dir => '/var/lib/duckdb/tailscale' -); -``` - -One-call coordinator **and** client: starts Wirebone, then `tailscale_up` against `http://127.0.0.1:` using the bootstrap preauth key. Set `join => false` to start only the control plane. +Also accepts every [`tailscale_up`](#tailscale_up) parameter (`hostname`, `authkey`, `control_url`, `state_dir`, `ephemeral`, `http_route`, …). -Accepts every [`wirebone_serve`](#wirebone_serve) parameter plus every [`tailscale_up`](#tailscale_up) parameter, and: - -| Parameter | Type | Default | Meaning | -|-----------|------|---------|---------| -| `join` | BOOLEAN | `true` | Also join the mesh as a tsnet client. | +Tables (same schema on DuckLake): `quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`. Returns one row: `bound`, `control_url`, `domain`, `preauth_key`, `running`, `hostname`, `tailnet_ips`. -### `wirebone_status` +### `quackscale_status` ```sql -CALL wirebone_status(); +CALL quackscale_status(); ``` | Column | Type | Meaning | |--------|------|---------| -| `linked` | BOOLEAN | This build embeds Wirebone. | -| `running` | BOOLEAN | Coordinator thread is up. | +| `linked` | BOOLEAN | This build embeds the hub library. | +| `running` | BOOLEAN | Hub thread is up. | | `bound` | VARCHAR | Actual listen address, or NULL. | | `control_url` | VARCHAR | Advertised URL, or NULL. | | `domain` | VARCHAR | MagicDNS domain, or NULL. | | `preauth_key` | VARCHAR | Bootstrap key, or NULL. | -### `wirebone_preauth` +### `quackscale_preauth` ```sql -CALL wirebone_preauth(reusable => true); +CALL quackscale_preauth(reusable => true); ``` -Creates an additional preauth key. Requires a running coordinator. +Creates an additional preauth key. Requires a running hub. | Parameter | Type | Default | Meaning | |-----------|------|---------|---------| @@ -212,21 +196,13 @@ Creates an additional preauth key. Requires a running coordinator. Returns `key`, `reusable`, `ephemeral`. -### `wirebone_bootstrap_key` +### `quackscale_nodes` ```sql -SELECT wirebone_bootstrap_key(); +CALL quackscale_nodes(); ``` -Scalar. Returns the bootstrap preauth key from the running coordinator. - -### `wirebone_nodes` - -```sql -CALL wirebone_nodes(); -``` - -Registered nodes. Empty when the coordinator is not running. +Registered nodes. Empty when the hub is not running. Prefer `SELECT * FROM quackscale.nodes` for the persisted roster. | Column | Type | Meaning | |--------|------|---------| @@ -237,13 +213,15 @@ Registered nodes. Empty when the coordinator is not running. | `node_key` | VARCHAR | `nodekey:…` | | `online` | BOOLEAN | Recently seen on `/machine/map`. | -### `wirebone_stop` +### `quackscale_stop` ```sql -CALL wirebone_stop(); +CALL quackscale_stop(); ``` -Stops the coordinator thread. Does not call `tailscale_down`. Returns `stopped=true`. +Stops the hub thread. Does not call `tailscale_down`. Returns `stopped=true`. + +`quackscale_serve`, `wirebone_serve` (control plane only), `wirebone_status`, `wirebone_preauth`, `wirebone_nodes`, `wirebone_stop`, and `wirebone_bootstrap_key` remain as hidden aliases for one cycle. --- diff --git a/examples/README.md b/examples/README.md index 5c97753..edeabc3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,16 @@ +# QuackTail examples + +| Demo | Control plane | Where | +|------|---------------|--------| +| **Two-process hub** | In-process control plane (no extra daemon) | [wirebone/README.md](wirebone/README.md) | +| **Docker Compose** | Headscale | this file | +| **DuckLake on Compose** | Headscale | [ducklake/README.md](ducklake/README.md) | + +--- + # QuackTail Docker Compose example -Two-node **Headscale + QuackTail** demo on Linux: server joins the tailnet and serves Quack; client `ATTACH`es via `tailscale_quack_forward`. +Two-node **Headscale + QuackTail** demo on Linux: server joins the tailnet and serves Quack; client `ATTACH`es via `tailscale_quack_forward`. To host the control plane inside DuckDB instead, use [wirebone/](wirebone/README.md) (`CALL quackscale_hub`). **Integration guide:** [docs/GUIDE.md](../docs/GUIDE.md) · **DuckLake demo:** [ducklake/README.md](ducklake/README.md) diff --git a/examples/wirebone/README.md b/examples/wirebone/README.md new file mode 100644 index 0000000..26d622b --- /dev/null +++ b/examples/wirebone/README.md @@ -0,0 +1,112 @@ +# Two-process hub example + +One DuckDB process is the mesh hub. A second process joins as a client. No Tailscale account, no Headscale container. + +**Needs:** a hub-linked QuackScale build (`SELECT linked FROM quackscale_status()` is `true`). Check out [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) next to this repo (or set `QUACKSCALE_WIREBONE_DIR`) and rebuild. See [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md). + +``` + hub (this machine) peer + ────────────────── ──── + CALL quackscale_hub CALL tailscale_up( + = control plane + tailscale_up control_url, wbkey-…) + quackscale.nodes / preauth_keys + optional: quack_serve + serve_local ──► ATTACH quack:coord.quackscale.local:9494 +``` + +SQL reference: [docs/REFERENCE.md](../../docs/REFERENCE.md#in-process-hub). Credentials: [docs/AUTHENTICATION.md](../../docs/AUTHENTICATION.md#in-process-hub). + +## Automated mesh proof + +Same script CI runs ([`scripts/ci_hub_smoke.sh`](../../scripts/ci_hub_smoke.sh)). From this directory, with `build/release/duckdb` already built at the repo root: + +```bash +./run.sh +``` + +Expect the peer `CALL tailscale_status()` to show `running true` and a `wbkey-` printed for the join. Override the binary with `DUCKDB` or `DUCKDB_BIN`. GitHub Actions: [hub-integration.yml](../../.github/workflows/hub-integration.yml). + +## Two terminals (QuackTail) + +Set a shared Quack token if you will serve HTTP: + +```sh +export QUACK_TAILNET_TOKEN='your-shared-token' +``` + +**Terminal 1 — hub** (`coordinator.sql`): + +```bash +../../build/release/duckdb -unsigned +``` + +```sql +LOAD quack; +LOAD quackscale; + +SELECT linked FROM quackscale_status(); -- must be true + +CALL quackscale_hub( + hostname => 'coord', + listen => '127.0.0.1:18080', + server_url => 'http://127.0.0.1:18080', + dns_listen => '', + state_dir => '/tmp/quackscale-hub-coord' +); + +SELECT * FROM quackscale_status(); +SELECT * FROM quackscale.preauth_keys; +-- copy the wbkey-… into the peer session + +CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); +CALL tailscale_serve_local(port => 9494); +FROM quack_discover(); +``` + +Leave this process running. Do not call `tailscale_down()` or `quackscale_stop()`. + +**Terminal 2 — peer** (`peer.sql`): + +```sql +LOAD quack; +LOAD quackscale; + +CALL tailscale_up( + hostname => 'peer', + control_url => 'http://127.0.0.1:18080', + authkey => 'wbkey-…', + state_dir => '/tmp/quackscale-hub-peer', + ephemeral => true +); + +FROM tailscale_status(); + +CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:coord.quackscale.local:9494'); +ATTACH 'quack:coord.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); + +FROM remote.query('SELECT 42'); + +DETACH remote; +CALL tailscale_down(); +``` + +On the hub, `SELECT * FROM quackscale.nodes` should list both hostnames. If MagicDNS fails, attach the peer to the hub's `100.x` from `quackscale.nodes` or `quack_discover()`, or use `tailscale_quack_forward(host => 'coord', port => 9494)`. + +## Roles + +| Call | This process | +|------|----------------| +| `quackscale_hub` | Control plane **and** tsnet client | +| `quackscale_hub(..., join => false)` | Control plane only | +| `tailscale_up` | Client only (every peer) | + +`server_url` must be reachable from peers. `127.0.0.1` is correct for two processes on one host; use a LAN address for other machines. + +## State + +Default backend is DuckDB tables in the session database: `quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`. For a shared catalog: + +```sql +CALL quackscale_hub(backend => 'ducklake', catalog => 'lake', …); +``` + +`backend => 'json'` is the standalone file format and is not required here. diff --git a/examples/wirebone/coordinator.sql b/examples/wirebone/coordinator.sql new file mode 100644 index 0000000..c37e6e2 --- /dev/null +++ b/examples/wirebone/coordinator.sql @@ -0,0 +1,26 @@ +-- Hub: control plane + client on one process. Leave this session open. +-- ../../build/release/duckdb -unsigned +-- +-- Peers need a reachable server_url. 127.0.0.1 is for two processes on this host. + +LOAD quack; +LOAD quackscale; + +SELECT linked FROM quackscale_status(); + +CALL quackscale_hub( + hostname => 'coord', + listen => '127.0.0.1:18080', + server_url => 'http://127.0.0.1:18080', + dns_listen => '', + state_dir => '/tmp/quackscale-hub-coord' +); + +SELECT * FROM quackscale_status(); +SELECT * FROM quackscale.preauth_keys; +SELECT * FROM quackscale.nodes; + +-- Optional: serve Quack on the mesh (needs LOAD quack + QUACK_TAILNET_TOKEN). +CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); +CALL tailscale_serve_local(port => 9494); +FROM quack_discover(); diff --git a/examples/wirebone/peer.sql b/examples/wirebone/peer.sql new file mode 100644 index 0000000..60f67be --- /dev/null +++ b/examples/wirebone/peer.sql @@ -0,0 +1,24 @@ +-- Client only. Paste the hub's wbkey-… into authkey. +-- ../../build/release/duckdb -unsigned + +LOAD quack; +LOAD quackscale; + +CALL tailscale_up( + hostname => 'peer', + control_url => 'http://127.0.0.1:18080', + authkey => 'wbkey-REPLACE-ME', + state_dir => '/tmp/quackscale-hub-peer', + ephemeral => true +); + +FROM tailscale_status(); + +-- Optional: attach the hub's Quack listener (same QUACK_TAILNET_TOKEN). +CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:coord.quackscale.local:9494'); +ATTACH 'quack:coord.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); + +FROM remote.query('SELECT 42'); + +DETACH remote; +CALL tailscale_down(); diff --git a/examples/wirebone/run.sh b/examples/wirebone/run.sh new file mode 100755 index 0000000..b0276e4 --- /dev/null +++ b/examples/wirebone/run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Thin wrapper around the CI hub smoke (two-process join, no Headscale). +exec "$(cd "$(dirname "$0")/../.." && pwd)/scripts/ci_hub_smoke.sh" diff --git a/scripts/ci_hub_smoke.sh b/scripts/ci_hub_smoke.sh new file mode 100755 index 0000000..affa104 --- /dev/null +++ b/scripts/ci_hub_smoke.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Two-process hub smoke: this DuckDB hosts the control plane; a second process joins. +# Analog of scripts/ci_headscale_smoke.sh (no Headscale container). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DUCKDB="${DUCKDB:-${DUCKDB_BIN:-$ROOT/build/release/duckdb}}" +LISTEN="${HUB_LISTEN:-127.0.0.1:18080}" +URL="http://${LISTEN}" +WORK="$(mktemp -d /tmp/quackscale-hub-XXXXXX)" +LOG_DIR="${HUB_CI_LOG_DIR:-$ROOT/.e2e-work/hub-smoke}" +COORD_DB="${WORK}/coord.duckdb" +COORD_INIT="${WORK}/coord_init.sql" +COORD_LOG="${WORK}/coord.log" +PEER_LOG="${WORK}/peer.log" +KEY_FILE="${WORK}/preauth.key" +WAIT_SEC="${HUB_READY_SEC:-60}" + +copy_logs() { + mkdir -p "${LOG_DIR}" + cp -f "${COORD_LOG}" "${PEER_LOG}" "${COORD_INIT}" "${LOG_DIR}/" 2>/dev/null || true +} + +cleanup() { + local code=$? + copy_logs + if [[ -n "${COORD_PID:-}" ]]; then + kill "${COORD_PID}" 2>/dev/null || true + wait "${COORD_PID}" 2>/dev/null || true + fi + rm -rf "${WORK}" + exit "${code}" +} +trap cleanup EXIT INT TERM + +if [[ ! -x "${DUCKDB}" ]]; then + echo "error: DuckDB not found at ${DUCKDB} (set DUCKDB or DUCKDB_BIN)" >&2 + exit 1 +fi + +linked="$("${DUCKDB}" -unsigned -bail -c "LOAD quackscale; SELECT linked FROM quackscale_status();" | tail -1 | tr -d '[:space:]')" +if [[ "${linked}" != "true" ]]; then + echo "error: this DuckDB was built without the hub (quackscale_status.linked=${linked})" >&2 + echo "checkout wirebone.cpp into third_party/wirebone (or set QUACKSCALE_WIREBONE_DIR) and rebuild with OpenSSL, nghttp2, and libzstd." >&2 + exit 1 +fi + +cat >"${COORD_INIT}" < 'coord', + listen => '${LISTEN}', + server_url => '${URL}', + dns_listen => '', + state_dir => '${WORK}/coord-ts' +); +COPY (SELECT preauth_key FROM quackscale_status()) TO '${KEY_FILE}' (FORMAT csv, HEADER false); +SQL + +echo "Joining in-process hub from a second DuckDB (control_url=${URL}) ..." +echo "→ hub ${URL} (state ${WORK})" +sleep infinity | "${DUCKDB}" -unsigned -bail -batch "${COORD_DB}" -init "${COORD_INIT}" >"${COORD_LOG}" 2>&1 & +COORD_PID=$! + +ready=0 +for ((i = 1; i <= WAIT_SEC; i++)); do + if ! kill -0 "${COORD_PID}" 2>/dev/null; then + echo "error: hub DuckDB exited (see ${COORD_LOG})" >&2 + tail -40 "${COORD_LOG}" >&2 || true + exit 1 + fi + if [[ -s "${KEY_FILE}" ]] && curl -sf "${URL}/healthz" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done + +if [[ "${ready}" != 1 ]]; then + echo "error: hub did not become ready within ${WAIT_SEC}s (see ${COORD_LOG})" >&2 + tail -40 "${COORD_LOG}" >&2 || true + exit 1 +fi + +KEY="$(tr -d '[:space:]' <"${KEY_FILE}")" +echo "→ bootstrap ${KEY}" +echo "--- SQL ---" +cat < 'peer', + control_url => '${URL}', + authkey => '${KEY}', + state_dir => '${WORK}/peer-ts', + ephemeral => true +); +CALL tailscale_status(); +CALL tailscale_down(); +SQL +echo "--- DuckDB output ---" + +"${DUCKDB}" -unsigned -bail -c " +LOAD quackscale; +CALL tailscale_up( + hostname => 'peer', + control_url => '${URL}', + authkey => '${KEY}', + state_dir => '${WORK}/peer-ts', + ephemeral => true +); +CALL tailscale_status(); +CALL tailscale_down(); +" | tee "${PEER_LOG}" || { + echo "error: peer failed to join (hub log follows)" >&2 + tail -40 "${COORD_LOG}" >&2 || true + exit 1 +} + +if ! grep -q 'true' "${PEER_LOG}"; then + echo "error: peer tailscale_status did not report running" >&2 + exit 1 +fi + +echo "Hub + QuackTail smoke test passed." diff --git a/src/include/tailscale_http.hpp b/src/include/tailscale_http.hpp index 444205e..bfd15cb 100644 --- a/src/include/tailscale_http.hpp +++ b/src/include/tailscale_http.hpp @@ -11,13 +11,14 @@ namespace duckdb { class DatabaseInstance; //! True if `proto_host_port` (e.g. "http://100.95.32.19:9494") names a tailnet host: -//! an IPv4 in the CGNAT range 100.64.0.0/10, a *.ts.net MagicDNS name, *.wirebone.local, -//! or a suffix registered via RegisterTailnetMagicDnsSuffix. Scheme and :port are stripped +//! an IPv4 in the CGNAT range 100.64.0.0/10, a *.ts.net MagicDNS name, *.quackscale.local +//! (and the legacy *.wirebone.local suffix), or a suffix registered via +//! RegisterTailnetMagicDnsSuffix. Scheme and :port are stripped //! before the test. NOTE: bare MagicDNS short names ("lake-server") are NOT matched — //! those still need tailscale_quack_forward. bool IsTailnetHost(const string &proto_host_port); -//! Extra MagicDNS suffix to treat as a tailnet host (e.g. ".wirebone.local"). Idempotent. +//! Extra MagicDNS suffix to treat as a tailnet host (e.g. ".quackscale.local"). Idempotent. void RegisterTailnetMagicDnsSuffix(const string &suffix); //! Install TailscaleHTTPUtil as the database's global HTTP util, wrapping whatever util is diff --git a/src/include/wirebone_bridge.hpp b/src/include/wirebone_bridge.hpp index 8d806dd..9700ac3 100644 --- a/src/include/wirebone_bridge.hpp +++ b/src/include/wirebone_bridge.hpp @@ -19,7 +19,7 @@ struct WireboneServeConfig { string listen = "0.0.0.0:8080"; string server_url = "http://127.0.0.1:8080"; string state_path; - string domain = "wirebone.local"; + string domain = "quackscale.local"; string dns_listen = "0.0.0.0:5353"; //! `duckdb` (default), `ducklake`, or `json`. string backend = "duckdb"; diff --git a/src/include/wirebone_catalog.hpp b/src/include/wirebone_catalog.hpp index 8cf58f7..771ac80 100644 --- a/src/include/wirebone_catalog.hpp +++ b/src/include/wirebone_catalog.hpp @@ -19,7 +19,7 @@ class ClientContext; struct WireboneCatalogConfig { //! `duckdb` (default), `ducklake`, or `json`. string backend = "duckdb"; - //! Catalog that holds the `wirebone` schema. Empty = current database. + //! Catalog that holds the `quackscale` schema. Empty = current database. string catalog; //! Optional dedicated DuckDB file (ignored for ducklake / current-db). string database; @@ -49,7 +49,7 @@ class WireboneCatalog { unique_ptr owned_db; unique_ptr con; - string schema = "wirebone"; + string schema = "quackscale"; std::mutex write_mu; }; diff --git a/src/tailscale_http.cpp b/src/tailscale_http.cpp index 3e72248..47b6a79 100644 --- a/src/tailscale_http.cpp +++ b/src/tailscale_http.cpp @@ -49,7 +49,7 @@ static void ParseProtoHostPort(const string &proto_host_port, string &host_out, namespace { std::mutex g_magicdns_suffix_mu; -vector g_magicdns_suffixes = {".wirebone.local"}; +vector g_magicdns_suffixes = {".quackscale.local", ".wirebone.local"}; } // namespace @@ -86,7 +86,7 @@ bool IsTailnetHost(const string &proto_host_port) { return false; } const string host_l = StringUtil::Lower(host); - // MagicDNS FQDNs (Tailscale SaaS, Wirebone default, plus any served domain). + // MagicDNS FQDNs (Tailscale SaaS, in-process hub, plus any served domain). { std::lock_guard g(g_magicdns_suffix_mu); for (auto &suffix : g_magicdns_suffixes) { diff --git a/src/wirebone_bridge.cpp b/src/wirebone_bridge.cpp index 6d5e749..2b73714 100644 --- a/src/wirebone_bridge.cpp +++ b/src/wirebone_bridge.cpp @@ -48,7 +48,7 @@ string NormalizeMagicDnsDomain(const string &domain) { d.pop_back(); } if (d.empty()) { - return ".wirebone.local"; + return ".quackscale.local"; } if (d.front() != '.') { d = "." + d; @@ -102,7 +102,7 @@ WireboneServeStatus WireboneBridge::Serve(ClientContext &context, const Wirebone #if QUACKSCALE_WITH_WIREBONE std::lock_guard g(mu); if (coordinator) { - throw InvalidInputException("wirebone coordinator already running; CALL wirebone_stop() first"); + throw InvalidInputException("quackscale hub already running; CALL quackscale_stop() first"); } string backend = StringUtil::Lower(config.backend); @@ -110,10 +110,10 @@ WireboneServeStatus WireboneBridge::Serve(ClientContext &context, const Wirebone backend = "duckdb"; } if (backend != "duckdb" && backend != "ducklake" && backend != "json") { - throw InvalidInputException("wirebone backend must be duckdb, ducklake, or json"); + throw InvalidInputException("quackscale hub backend must be duckdb, ducklake, or json"); } if (backend == "ducklake" && config.catalog.empty()) { - throw InvalidInputException("wirebone backend=ducklake requires catalog => ''"); + throw InvalidInputException("quackscale hub backend=ducklake requires catalog => ''"); } const bool use_catalog = backend != "json"; @@ -136,14 +136,14 @@ WireboneServeStatus WireboneBridge::Serve(ClientContext &context, const Wirebone auto *c = wirebone_create(&cfg); if (!c) { catalog.Close(); - throw IOException("wirebone_serve failed to create coordinator"); + throw IOException("quackscale_hub failed to create the control plane"); } if (use_catalog) { auto snap = catalog.LoadSnapshot(); if (!snap.empty() && wirebone_import_state(c, snap.c_str()) != 0) { wirebone_destroy(c); catalog.Close(); - throw IOException("wirebone_serve failed to import DuckDB snapshot"); + throw IOException("quackscale_hub failed to import DuckDB snapshot"); } wirebone_set_persist_callback(c, PersistToCatalog, &catalog); wirebone_persist(c); @@ -151,7 +151,7 @@ WireboneServeStatus WireboneBridge::Serve(ClientContext &context, const Wirebone if (wirebone_start(c) != 0) { wirebone_destroy(c); catalog.Close(); - throw IOException("wirebone_serve failed to start on %s", config.listen); + throw IOException("quackscale_hub failed to start on %s", config.listen); } coordinator = c; @@ -196,7 +196,7 @@ string WireboneBridge::BootstrapKey() const { #if QUACKSCALE_WITH_WIREBONE std::lock_guard g(mu); if (!coordinator) { - throw InvalidInputException("wirebone coordinator is not running; CALL wirebone_serve() first"); + throw InvalidInputException("quackscale hub is not running; CALL quackscale_hub() first"); } string key = TakeCstr(wirebone_bootstrap_key(static_cast(coordinator))); return key.empty() ? last.preauth_key : key; @@ -210,13 +210,13 @@ string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) { #if QUACKSCALE_WITH_WIREBONE std::lock_guard g(mu); if (!coordinator) { - throw InvalidInputException("wirebone coordinator is not running; CALL wirebone_serve() first"); + 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)); if (key.empty()) { - throw IOException("wirebone_preauth failed to create a key"); + throw IOException("quackscale_preauth failed to create a key"); } if (last.preauth_key.empty()) { last.preauth_key = key; diff --git a/src/wirebone_catalog.cpp b/src/wirebone_catalog.cpp index b03465f..facf862 100644 --- a/src/wirebone_catalog.cpp +++ b/src/wirebone_catalog.cpp @@ -19,9 +19,9 @@ namespace duckdb { void WireboneCatalog::Open(ClientContext &context, const WireboneCatalogConfig &config) { Close(); if (!config.catalog.empty() && config.catalog != "memory" && config.catalog != "current") { - schema = config.catalog + ".wirebone"; + schema = config.catalog + ".quackscale"; } else { - schema = "wirebone"; + schema = "quackscale"; } if (!config.database.empty() && config.backend != "ducklake") { owned_db = make_uniq(config.database); @@ -35,13 +35,13 @@ void WireboneCatalog::Close() { std::lock_guard g(write_mu); con.reset(); owned_db.reset(); - schema = "wirebone"; + schema = "quackscale"; } void WireboneCatalog::Run(const string &sql) { auto result = con->Query(sql); if (result->HasError()) { - throw IOException("wirebone catalog: %s\n%s", result->GetError(), sql); + throw IOException("quackscale hub catalog: %s\n%s", result->GetError(), sql); } } @@ -65,6 +65,18 @@ void WireboneCatalog::EnsureSchema() { 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)"); + + // One-cycle alias so older SQL that reads wirebone.* still works. + string alias_schema = "wirebone"; + if (schema.size() > 10 && schema.rfind(".quackscale") == schema.size() - 11) { + alias_schema = schema.substr(0, schema.size() - 11) + ".wirebone"; + } + if (alias_schema != schema) { + Run("CREATE SCHEMA IF NOT EXISTS " + alias_schema); + Run("CREATE OR REPLACE VIEW " + alias_schema + ".meta AS SELECT * FROM " + Qualify("meta")); + Run("CREATE OR REPLACE VIEW " + alias_schema + ".preauth_keys AS SELECT * FROM " + Qualify("preauth_keys")); + Run("CREATE OR REPLACE VIEW " + alias_schema + ".nodes AS SELECT * FROM " + Qualify("nodes")); + } } string WireboneCatalog::LoadSnapshot() { diff --git a/src/wirebone_functions.cpp b/src/wirebone_functions.cpp index 25d3c0e..4c7eb72 100644 --- a/src/wirebone_functions.cpp +++ b/src/wirebone_functions.cpp @@ -249,11 +249,16 @@ static void WireboneNodesFunction(ClientContext &, TableFunctionInput &data_p, D static void WireboneBootstrapKeyFunction(DataChunk &, ExpressionState &, Vector &result) { if (!WireboneBridge::Get().Linked() || !WireboneBridge::Get().Status().running) { - throw InvalidInputException("wirebone_bootstrap_key: CALL wirebone_serve() first"); + throw InvalidInputException("wirebone_bootstrap_key: CALL quackscale_hub() first"); } result.Reference(Value(WireboneBridge::Get().BootstrapKey())); } +static void RegisterTableAlias(ExtensionLoader &loader, TableFunction fn, const char *name) { + fn.name = name; + loader.RegisterFunction(fn); +} + struct QuackscaleServeBindData : public TableFunctionData { WireboneServeConfig serve; TailscaleAuthConfig join; @@ -326,33 +331,43 @@ static void QuackscaleServeFunction(ClientContext &context, TableFunctionInput & } // namespace void RegisterWireboneFunctions(ExtensionLoader &loader) { - TableFunction serve("wirebone_serve", {}, WireboneServeFunction, WireboneServeBind); - RegisterServeParameters(serve); - loader.RegisterFunction(serve); - - loader.RegisterFunction(TableFunction("wirebone_status", {}, WireboneStatusFunction, WireboneStatusBind)); - loader.RegisterFunction(TableFunction("wirebone_stop", {}, WireboneStopFunction, WireboneStopBind)); - loader.RegisterFunction(TableFunction("wirebone_nodes", {}, WireboneNodesFunction, WireboneNodesBind)); - - TableFunction preauth("wirebone_preauth", {}, WirebonePreauthFunction, WirebonePreauthBind); + TableFunction hub("quackscale_hub", {}, QuackscaleServeFunction, QuackscaleServeBind); + RegisterServeParameters(hub); + hub.named_parameters["hostname"] = LogicalType::VARCHAR; + hub.named_parameters["authkey"] = LogicalType::VARCHAR; + hub.named_parameters["control_url"] = LogicalType::VARCHAR; + hub.named_parameters["state_dir"] = LogicalType::VARCHAR; + hub.named_parameters["ephemeral"] = LogicalType::BOOLEAN; + hub.named_parameters["loopback_proxy"] = LogicalType::BOOLEAN; + hub.named_parameters["http_route"] = LogicalType::BOOLEAN; + hub.named_parameters["join"] = LogicalType::BOOLEAN; + loader.RegisterFunction(hub); + RegisterTableAlias(loader, hub, "quackscale_serve"); + + TableFunction coord_only("wirebone_serve", {}, WireboneServeFunction, WireboneServeBind); + RegisterServeParameters(coord_only); + loader.RegisterFunction(coord_only); + + TableFunction status("quackscale_status", {}, WireboneStatusFunction, WireboneStatusBind); + loader.RegisterFunction(status); + RegisterTableAlias(loader, status, "wirebone_status"); + + TableFunction stop("quackscale_stop", {}, WireboneStopFunction, WireboneStopBind); + loader.RegisterFunction(stop); + RegisterTableAlias(loader, stop, "wirebone_stop"); + + TableFunction nodes("quackscale_nodes", {}, WireboneNodesFunction, WireboneNodesBind); + loader.RegisterFunction(nodes); + RegisterTableAlias(loader, nodes, "wirebone_nodes"); + + TableFunction preauth("quackscale_preauth", {}, WirebonePreauthFunction, WirebonePreauthBind); preauth.named_parameters["reusable"] = LogicalType::BOOLEAN; preauth.named_parameters["ephemeral"] = LogicalType::BOOLEAN; loader.RegisterFunction(preauth); + RegisterTableAlias(loader, preauth, "wirebone_preauth"); loader.RegisterFunction( ScalarFunction("wirebone_bootstrap_key", {}, LogicalType::VARCHAR, WireboneBootstrapKeyFunction)); - - TableFunction both("quackscale_serve", {}, QuackscaleServeFunction, QuackscaleServeBind); - RegisterServeParameters(both); - both.named_parameters["hostname"] = LogicalType::VARCHAR; - both.named_parameters["authkey"] = LogicalType::VARCHAR; - both.named_parameters["control_url"] = LogicalType::VARCHAR; - both.named_parameters["state_dir"] = LogicalType::VARCHAR; - both.named_parameters["ephemeral"] = LogicalType::BOOLEAN; - both.named_parameters["loopback_proxy"] = LogicalType::BOOLEAN; - both.named_parameters["http_route"] = LogicalType::BOOLEAN; - both.named_parameters["join"] = LogicalType::BOOLEAN; - loader.RegisterFunction(both); } } // namespace duckdb diff --git a/test/e2e/README.md b/test/e2e/README.md index 23b9256..3bb0072 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -21,6 +21,21 @@ chmod +x scripts/ci_headscale_e2e.sh Expect `PASSED`, `CLIENT_DEMO_DONE` / `Demo passed`, and `seed-from-server` in client output. +## Hub smoke (source build, PR) + +GitHub Actions: [`.github/workflows/hub-integration.yml`](../../.github/workflows/hub-integration.yml) + +- **Trigger:** pull_request (and `workflow_dispatch`) +- **Flow:** checkout [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) into `third_party/wirebone`, `make release`, SQL unit tests, then two DuckDB processes — `quackscale_hub` + `tailscale_up` +- **Script:** [`scripts/ci_hub_smoke.sh`](../../scripts/ci_hub_smoke.sh) (same as [`examples/wirebone/run.sh`](../../examples/wirebone/run.sh)) + +```bash +# after a hub-linked GEN=ninja make release +./scripts/ci_hub_smoke.sh +``` + +Expect `Hub + QuackTail smoke test passed.` + ## Local compose e2e (source build — not CI) For the full DuckLake + `attach_ducklake` demo (builds DuckDB in Docker): @@ -37,7 +52,8 @@ Same as [examples/README.md](../../examples/README.md). Use this on a dev machin | Workflow | Trigger | Builds DuckDB? | |----------|---------|----------------| -| [headscale-integration.yml](../../.github/workflows/headscale-integration.yml) | PR | Yes — smoke test only | +| [headscale-integration.yml](../../.github/workflows/headscale-integration.yml) | PR | Yes — Headscale smoke | +| [hub-integration.yml](../../.github/workflows/hub-integration.yml) | PR | Yes — in-process hub smoke | | [libtailscale-integration.yml](../../.github/workflows/libtailscale-integration.yml) | PR | Go tests | | [MainDistributionPipeline.yml](../../.github/workflows/MainDistributionPipeline.yml) | PR / release | Extension CI | diff --git a/test/sql/quackscale.test b/test/sql/quackscale.test index dd574e4..0c2b1dc 100644 --- a/test/sql/quackscale.test +++ b/test/sql/quackscale.test @@ -71,21 +71,30 @@ CALL tailscale_login(not_a_real_param => true); ---- http_route -# Wirebone coordinator SQL is always registered. Live serve/join is e2e-only +# Hub SQL is always registered. Live hub/join is e2e-only # (needs OpenSSL/nghttp2/zstd and a wirebone.cpp checkout). statement ok -CALL wirebone_status(); +CALL quackscale_status(); statement ok -CALL wirebone_nodes(); +CALL quackscale_nodes(); statement ok -CALL wirebone_stop(); +CALL quackscale_stop(); statement error SELECT wirebone_bootstrap_key(); ---- -wirebone_serve +quackscale_hub + +statement error +CALL quackscale_hub(not_a_real_param => true); +---- +join + +# Hidden aliases still resolve. +statement ok +CALL wirebone_status(); statement error CALL wirebone_serve(not_a_real_param => true); diff --git a/test/sql/wirebone.test b/test/sql/wirebone.test index 801391b..f922037 100644 --- a/test/sql/wirebone.test +++ b/test/sql/wirebone.test @@ -1,85 +1,98 @@ # name: test/sql/wirebone.test -# description: In-process Wirebone coordinator stored in DuckDB tables +# description: In-process hub stored in DuckDB tables # group: [sql] require quackscale -# Functions are always registered. Serve needs QUACKSCALE_WITH_WIREBONE=1 +# Functions are always registered. Hub needs QUACKSCALE_WITH_WIREBONE=1 # (sibling ../wirebone.cpp or QUACKSCALE_WIREBONE_DIR). query I -SELECT linked FROM wirebone_status(); +SELECT linked FROM quackscale_status(); ---- true +# join => false: control plane only (no tsnet). Same as the old wirebone_serve alias. statement ok -CALL wirebone_serve(listen => '127.0.0.1:0', dns_listen => ''); +CALL quackscale_hub(listen => '127.0.0.1:0', dns_listen => '', join => false); query I -SELECT running FROM wirebone_status(); +SELECT running FROM quackscale_status(); ---- true query I -SELECT COUNT(*) >= 1 FROM wirebone.preauth_keys; +SELECT COUNT(*) >= 1 FROM quackscale.preauth_keys; ---- true query I -SELECT COUNT(*) FROM wirebone.meta WHERE k = 'snapshot'; +SELECT COUNT(*) FROM quackscale.meta WHERE k = 'snapshot'; ---- 1 +# One-cycle schema alias. +query I +SELECT COUNT(*) >= 1 FROM wirebone.preauth_keys; +---- +true + statement ok -CREATE TABLE wirebone_test_keys AS SELECT key FROM wirebone.preauth_keys; +CREATE TABLE hub_test_keys AS SELECT key FROM quackscale.preauth_keys; statement ok -CALL wirebone_preauth(reusable => true); +CALL quackscale_preauth(reusable => true); query I -SELECT COUNT(*) FROM wirebone.preauth_keys; +SELECT COUNT(*) FROM quackscale.preauth_keys; ---- 2 statement ok -CALL wirebone_stop(); +CALL quackscale_stop(); query I -SELECT running FROM wirebone_status(); +SELECT running FROM quackscale_status(); ---- false # Tables survive stop (same DuckDB). query I -SELECT COUNT(*) FROM wirebone.preauth_keys; +SELECT COUNT(*) FROM quackscale.preauth_keys; ---- 2 statement ok -CALL wirebone_serve(listen => '127.0.0.1:0', dns_listen => ''); +CALL quackscale_hub(listen => '127.0.0.1:0', dns_listen => '', join => false); # Reloaded snapshot keeps the same keys. query I -SELECT COUNT(*) FROM wirebone.preauth_keys; +SELECT COUNT(*) FROM quackscale.preauth_keys; ---- 2 query I -SELECT COUNT(*) FROM wirebone.preauth_keys k JOIN wirebone_test_keys t USING (key); +SELECT COUNT(*) FROM quackscale.preauth_keys k JOIN hub_test_keys t USING (key); ---- 1 +# Hidden aliases still resolve. +query I +SELECT running FROM wirebone_status(); +---- +true + statement ok -CALL wirebone_stop(); +CALL quackscale_stop(); statement ok -DROP TABLE wirebone_test_keys; +DROP TABLE hub_test_keys; statement error -CALL wirebone_serve(backend => 'ducklake'); +CALL quackscale_hub(backend => 'ducklake', join => false); ---- catalog statement error -CALL wirebone_serve(backend => 'nope'); +CALL quackscale_hub(backend => 'nope', join => false); ---- duckdb From 687b9f2acaa762a955f5048c149cfa7df1d8b41d Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 09:16:13 +0200 Subject: [PATCH 3/5] Parse hub linked status from CSV, not the boxed table border. DuckDB's default renderer ends with a bottom rule, so tail -1 was never true or false. --- .github/workflows/hub-integration.yml | 5 ++--- scripts/ci_hub_smoke.sh | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/hub-integration.yml b/.github/workflows/hub-integration.yml index edc8602..967d67f 100644 --- a/.github/workflows/hub-integration.yml +++ b/.github/workflows/hub-integration.yml @@ -57,9 +57,8 @@ jobs: - name: Assert hub is linked run: | - linked="$(./build/release/duckdb -unsigned -bail -c "LOAD quackscale; SELECT linked FROM quackscale_status();" | tail -1 | tr -d '[:space:]')" - echo "quackscale_status.linked=${linked}" - test "${linked}" = "true" + chmod +x scripts/ci_hub_smoke.sh + ./scripts/ci_hub_smoke.sh --assert-linked - name: Hub SQL unit tests working-directory: build/release diff --git a/scripts/ci_hub_smoke.sh b/scripts/ci_hub_smoke.sh index affa104..98ac21d 100755 --- a/scripts/ci_hub_smoke.sh +++ b/scripts/ci_hub_smoke.sh @@ -5,6 +5,30 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DUCKDB="${DUCKDB:-${DUCKDB_BIN:-$ROOT/build/release/duckdb}}" + +# DuckDB's default box renderer ends with a border line (└────┘). Always use CSV. +hub_ci_linked() { + local out + out="$("${DUCKDB}" :memory: -unsigned -bail -batch -csv -noheader -c \ + "LOAD quackscale; SELECT linked FROM quackscale_status();")" + printf '%s\n' "${out}" | { grep -E '^(true|false)$' || true; } | tail -1 | tr -d '\r' +} + +if [[ "${1:-}" == "--assert-linked" ]]; then + if [[ ! -x "${DUCKDB}" ]]; then + echo "error: DuckDB not found at ${DUCKDB} (set DUCKDB or DUCKDB_BIN)" >&2 + exit 1 + fi + linked="$(hub_ci_linked)" + echo "quackscale_status.linked=${linked}" + if [[ "${linked}" != "true" ]]; then + echo "error: this DuckDB was built without the hub" >&2 + echo "checkout wirebone.cpp into third_party/wirebone (or set QUACKSCALE_WIREBONE_DIR) and rebuild with OpenSSL, nghttp2, and libzstd." >&2 + exit 1 + fi + exit 0 +fi + LISTEN="${HUB_LISTEN:-127.0.0.1:18080}" URL="http://${LISTEN}" WORK="$(mktemp -d /tmp/quackscale-hub-XXXXXX)" @@ -38,7 +62,7 @@ if [[ ! -x "${DUCKDB}" ]]; then exit 1 fi -linked="$("${DUCKDB}" -unsigned -bail -c "LOAD quackscale; SELECT linked FROM quackscale_status();" | tail -1 | tr -d '[:space:]')" +linked="$(hub_ci_linked)" if [[ "${linked}" != "true" ]]; then echo "error: this DuckDB was built without the hub (quackscale_status.linked=${linked})" >&2 echo "checkout wirebone.cpp into third_party/wirebone (or set QUACKSCALE_WIREBONE_DIR) and rebuild with OpenSSL, nghttp2, and libzstd." >&2 From bb23dff2e0cbb6c314c6c967aee93097e1065067 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 09:45:45 +0200 Subject: [PATCH 4/5] Force-kill the hub DuckDB session after the smoke test. tsnet ignores SIGTERM and wait on the sleep|duckdb pipeline left the Action running after success. --- scripts/ci_hub_smoke.sh | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/ci_hub_smoke.sh b/scripts/ci_hub_smoke.sh index 98ac21d..852268d 100755 --- a/scripts/ci_hub_smoke.sh +++ b/scripts/ci_hub_smoke.sh @@ -45,13 +45,26 @@ copy_logs() { cp -f "${COORD_LOG}" "${PEER_LOG}" "${COORD_INIT}" "${LOG_DIR}/" 2>/dev/null || true } +# Hub DuckDB is kept alive with an open stdin pipe and may be running tsnet, which +# ignores SIGTERM. Kill the whole session (TERM then KILL) so CI cannot hang on wait. +stop_hub() { + local pid="${1:-}" + [[ -n "${pid}" ]] || return 0 + kill -TERM -- "-${pid}" 2>/dev/null || kill -TERM "${pid}" 2>/dev/null || true + local i + for i in 1 2 3 4 5 6 7 8; do + kill -0 "${pid}" 2>/dev/null || return 0 + sleep 0.25 + done + kill -KILL -- "-${pid}" 2>/dev/null || kill -KILL "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true +} + cleanup() { local code=$? + trap - EXIT INT TERM copy_logs - if [[ -n "${COORD_PID:-}" ]]; then - kill "${COORD_PID}" 2>/dev/null || true - wait "${COORD_PID}" 2>/dev/null || true - fi + stop_hub "${COORD_PID:-}" rm -rf "${WORK}" exit "${code}" } @@ -76,6 +89,7 @@ CALL quackscale_hub( listen => '${LISTEN}', server_url => '${URL}', dns_listen => '', + join => false, state_dir => '${WORK}/coord-ts' ); COPY (SELECT preauth_key FROM quackscale_status()) TO '${KEY_FILE}' (FORMAT csv, HEADER false); @@ -83,7 +97,10 @@ SQL echo "Joining in-process hub from a second DuckDB (control_url=${URL}) ..." echo "→ hub ${URL} (state ${WORK})" -sleep infinity | "${DUCKDB}" -unsigned -bail -batch "${COORD_DB}" -init "${COORD_INIT}" >"${COORD_LOG}" 2>&1 & +# New session so cleanup can SIGKILL tsnet threads that ignore TERM. +HUB_DUCKDB="${DUCKDB}" HUB_DB="${COORD_DB}" HUB_INIT="${COORD_INIT}" \ + setsid sh -c 'sleep infinity | exec "$HUB_DUCKDB" -unsigned -bail -batch "$HUB_DB" -init "$HUB_INIT"' \ + >"${COORD_LOG}" 2>&1 & COORD_PID=$! ready=0 From f6c596d160a4c6f5b28caec6015a63bcc80de208 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Tue, 25 Aug 2026 10:31:15 +0200 Subject: [PATCH 5/5] Lead docs and examples with an in-process hub query fleet. The README now starts a fleet server with quackscale_hub and joins clients via tailscale_up; Tailscale and Headscale stay as alternatives. --- README.md | 173 +++++++++++++----------------- docs/AUTHENTICATION.md | 116 ++++++++++---------- docs/GUIDE.md | 79 +++++++------- docs/README.md | 13 ++- examples/README.md | 4 +- examples/wirebone/README.md | 39 +++---- examples/wirebone/coordinator.sql | 8 +- examples/wirebone/peer.sql | 16 +-- 8 files changed, 209 insertions(+), 239 deletions(-) diff --git a/README.md b/README.md index 07c4de5..1dcdc63 100644 --- a/README.md +++ b/README.md @@ -2,189 +2,160 @@ # QuackScale -QuackScale embeds a Tailscale client ([libtailscale](https://github.com/tailscale/libtailscale)) inside DuckDB. A DuckDB process joins a private [tailnet](https://tailscale.com/docs/concepts/tailnet) and reaches its peers over encrypted WireGuard. It needs no VPN sidecar and opens no public port. +QuackScale turns a set of DuckDB processes into a **query fleet** on a private WireGuard mesh. One process is the **hub**: it hosts the control plane and serves SQL. Every other process is a **client**: it joins that hub and `ATTACH`es the server as if it were local. -The control plane can be [Tailscale](https://tailscale.com/) SaaS, a self-hosted [Headscale](https://github.com/juanfont/headscale) process, or this DuckDB process (`CALL quackscale_hub`). Peers always join the same way: `CALL tailscale_up`. - -Pair it with DuckDB's [Quack](https://duckdb.org/docs/current/quack/overview) HTTP protocol and you have **QuackTail**: SQL engines that find each other on `100.x` addresses and [MagicDNS](https://tailscale.com/docs/features/magicdns), then run `ATTACH`, `quack_query`, and DuckLake workloads across the mesh. +No Tailscale account, no Headscale container, no public port. Peers find the server at `analytics-hub.quackscale.local`. ```sql LOAD quack; -- HTTP server, ATTACH, quack_query -LOAD quackscale; -- tailscale_up to join, quackscale_hub to host +LOAD quackscale; -- quackscale_hub on the server, tailscale_up on every client ``` -QuackScale is the network layer that carries DuckDB's HTTP across a tailnet, from a plain file read to `quack` and `ducklake`. +Pair it with DuckDB's [Quack](https://duckdb.org/docs/current/quack/overview) protocol and you have **QuackTail**: engines that `ATTACH`, `quack_query`, and run DuckLake across the mesh. + +You can still join a hosted [Tailscale](https://tailscale.com/) tailnet or a [Headscale](https://github.com/juanfont/headscale) server with the same `CALL tailscale_up`. The default path is the in-process hub. ## Why QuackScale -Most teams expose a SQL engine by walling it off. You bind DuckDB or Quack to localhost, where nothing can reach it, or you bind a public IP and defend it with TLS certificates, firewall rules, and a VPN appliance. The database has no identity of its own. It trusts whatever the perimeter lets through. +Most teams expose a SQL engine by walling it off. You bind DuckDB or Quack to localhost, where nothing can reach it, or you bind a public IP and defend it with TLS certificates, firewall rules, and a VPN appliance. The database has no identity of its own. -QuackScale inverts that. Each DuckDB process carries its own tailnet identity and speaks WireGuard ([how Tailscale works](https://tailscale.com/blog/how-tailscale-works)) to the peers your control plane already trusts. Nothing listens on the public internet, so you have nothing there to defend. +QuackScale inverts that. Each DuckDB process carries its own mesh identity and speaks WireGuard to the peers the hub already trusts. Nothing listens on the public internet. -| | Perimeter model | QuackTail | +| | Perimeter model | QuackTail fleet | |---|---|---| | What listens publicly | A public IP with TLS, firewall, and VPN in front | Nothing public; Quack binds loopback and serves the mesh | -| What the database trusts | Whatever the perimeter admits | Peers its control plane already trusts | +| What the database trusts | Whatever the perimeter admits | Peers the hub already admitted | | Encryption | TLS you configure and renew | WireGuard between every node | -| Reaching across networks | Manual port forwarding, VPN appliance | Direct paths or DERP relays | -| Extra process to run | A VPN daemon or appliance | None; tsnet (and optionally the hub) run in-process | - -Each node clears two independent checks. The tailnet asks whether the machine belongs to your mesh, and the control plane decides which nodes may open a connection. A Quack token then asks whether the caller may run SQL. A stolen token buys nothing from a machine off the mesh, and a machine on the mesh still needs a token. Set `QUACK_TAILNET_TOKEN` once and the whole fleet shares one secret. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). - -You drive all of it from SQL. Joining, status, ping, forward, serve, and teardown are `CALL` table functions, so you keep the network in the same migrations and init scripts as your data. - -## Control plane +| Extra process to run | A VPN daemon or Headscale | None; hub and clients run in-process | -Pick one. The client path does not change. - -| | Hosted Tailscale | Headscale | In-process hub | -|---|---|---|---| -| Who runs the control plane | Tailscale | A Headscale process | This DuckDB process | -| Hub / server SQL | `CALL tailscale_up(...)` | `CALL tailscale_up(control_url, authkey, …)` | `CALL quackscale_hub(...)` | -| Peer SQL | `CALL tailscale_up(...)` | Same, plus `control_url` and a Headscale key | `CALL tailscale_up(control_url, authkey, …)` | -| MagicDNS | `*.ts.net` | Your Headscale base domain | `*.quackscale.local` | -| Control-plane state | Tailscale | Headscale's store | DuckDB tables (`quackscale.*`), or DuckLake | - -`quackscale_hub` starts the control plane and joins it (`join` defaults to true). `CALL quackscale_hub(..., join => false)` is control plane only. Community builds that were not linked against the hub library report `linked=false` from `quackscale_status()`; they still join Tailscale or Headscale. +Each node clears two checks. The mesh asks whether the machine belongs to this fleet. A Quack token then asks whether the caller may run SQL. Set `QUACK_TAILNET_TOKEN` once for the whole fleet. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). ## Install -QuackScale needs **DuckDB v1.5.5** and unsigned extensions. Install from the custom extension repository: +QuackScale needs **DuckDB v1.5.5** and unsigned extensions. ```sql INSTALL quackscale FROM community; LOAD quackscale; -``` - -From the CLI, start `duckdb -unsigned`, then `LOAD quackscale;`. - -You also need `quack` (and `ducklake`, for lake workloads) from `core_nightly`: - -```sql INSTALL quack FROM core_nightly; LOAD quack; ``` -Prebuilt QuackTail binary bundles ship on each [release](https://github.com/quackscience/duckdb-quackscale/releases). To build from source, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). +From the CLI: `duckdb -unsigned`, then `LOAD quackscale;`. A hub-linked source build reports `SELECT linked FROM quackscale_status()` as `true` (checkout [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) next to this repo). Community builds without the hub still join Tailscale or Headscale. Prebuilt bundles: [releases](https://github.com/quackscience/duckdb-quackscale/releases). From source: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). -## Quick start +## Quick start: a query fleet -### Join an existing tailnet +One long-lived **server** is the hub and the Quack endpoint. Any number of **clients** (laptops, jobs, other DuckDBs) join with the same control URL and a `wbkey-` from the hub. ```sh -export TS_AUTHKEY='tskey-auth-...' # Tailscale auth key, or a Headscale preauth key -export QUACK_TAILNET_TOKEN='your-shared-token' # one shared secret for the whole fleet -duckdb -unsigned +export QUACK_TAILNET_TOKEN='your-shared-token' # same secret on every node ``` -```sql -LOAD quack; -LOAD quackscale; +### 1. Start the server -CALL tailscale_up( - hostname => 'my-duckdb-node', - state_dir => '~/.local/share/duckdb/quackscale' -); - -CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); -CALL tailscale_serve_local(port => 9494); - -FROM quack_discover(); -- prints this node's quack: URI on the tailnet -``` - -Leave a long-lived server running with a persistent `state_dir`, and do not call `tailscale_down()`. For Headscale, add `control_url` and a preauth key. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). - -### Host the control plane here - -No Tailscale account and no Headscale process. This node is the hub; peers only call `tailscale_up`. Requires a hub-linked build (`SELECT linked FROM quackscale_status()`). +Leave this process running. Do not call `tailscale_down()` or `quackscale_stop()`. ```sql LOAD quack; LOAD quackscale; CALL quackscale_hub( - hostname => 'coord', + hostname => 'analytics-hub', listen => '0.0.0.0:8080', - server_url => 'http://10.0.0.5:8080', + server_url => 'http://10.0.0.5:8080', -- address clients can open state_dir => '~/.local/share/duckdb/quackscale' ); -SELECT * FROM quackscale.nodes; -- hub roster -SELECT * FROM quackscale.preauth_keys; -- keys for peers +CALL quackscale_preauth(reusable => true); -- fleet key; share with every client +SELECT * FROM quackscale.preauth_keys; +SELECT * FROM quackscale.nodes; + +CREATE TABLE IF NOT EXISTS events (id INTEGER, payload VARCHAR); CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); CALL tailscale_serve_local(port => 9494); +FROM quack_discover(); ``` -Create extra keys with `CALL quackscale_preauth(reusable => true)`. State lives in the `quackscale` schema of this DuckDB (or `backend => 'ducklake', catalog => 'lake'`). A two-process walkthrough is in [examples/wirebone](examples/wirebone/README.md). +`server_url` must be reachable from clients (`127.0.0.1` only if they share the host). Copy a `wbkey-…` from `quackscale.preauth_keys`. -### A client that reaches it +### 2. Join a client -After `tailscale_up`, tailnet `quack:` addresses route over the mesh on their own. Attach the address `quack_discover()` printed on the server, with no forwarder: +Repeat on every other DuckDB in the fleet. Change `hostname` per machine. ```sql LOAD quack; LOAD quackscale; CALL tailscale_up( - hostname => 'my-client', - control_url => 'http://10.0.0.5:8080', -- omit for Tailscale SaaS - authkey => 'wbkey-…', -- or TS_AUTHKEY / Headscale key - state_dir => '~/.local/share/duckdb/quackscale-client' + hostname => 'analyst-1', -- analyst-2, job-etl, … + control_url => 'http://10.0.0.5:8080', + authkey => 'wbkey-…', + state_dir => '~/.local/share/duckdb/quackscale-analyst-1' ); -CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:100.x.x.x:9494'); -ATTACH 'quack:100.x.x.x:9494' AS remote (TYPE quack, DISABLE_SSL true); --- Hub MagicDNS: ATTACH 'quack:coord.quackscale.local:9494' … +CREATE SECRET ( + TYPE quack, + TOKEN 'your-shared-token', + SCOPE 'quack:analytics-hub.quackscale.local:9494' +); +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS hub (TYPE quack, DISABLE_SSL true); -FROM remote.query('SELECT 42'); +FROM hub.query('SELECT 42'); +SELECT * FROM hub.events; -DETACH remote; -CALL tailscale_down(); -- a one-shot client must close tsnet, or the process hangs +DETACH hub; +CALL tailscale_down(); -- one-shot jobs must close tsnet, or the process hangs ``` +A second client is the same SQL with `hostname => 'job-etl'` and its own `state_dir`. On the server, `SELECT * FROM quackscale.nodes` lists the fleet. + +If MagicDNS fails, attach the `100.x` from `quackscale.nodes` or `quack_discover()`, or `CALL tailscale_quack_forward(host => 'analytics-hub', port => 9494)`. Two-process walkthrough: [examples/wirebone](examples/wirebone/README.md). + +### Join Tailscale or Headscale instead + +Clients still call `tailscale_up`. For Tailscale SaaS, omit `control_url` and set `TS_AUTHKEY`. For Headscale, pass that server's URL and a Headscale preauth key. See [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). + ## How it fits together ```mermaid flowchart TB - subgraph server["Quack server"] - s1["tailscale_up or quackscale_hub"] + subgraph server["Fleet server"] + s1[quackscale_hub] dl[ATTACH ducklake] - pq[(Parquet volume)] + pq[(Parquet)] sq[quack_serve + serve_local] s1 --> dl dl --- pq dl --> sq end - wb["Hub control plane
optional, same process"] - s1 -.-> wb - - m((100.x tailnet)) - - sq -->|tailscale_dial| m - wb -.->|map / preauth| m + m((100.x mesh · *.quackscale.local)) + s1 -->|map / preauth| m + sq -->|WireGuard| m - c1["peer · tailscale_up + ATTACH quack"] - c2["peer · attach_ducklake"] + c1["analyst-1 · tailscale_up"] + c2["job-etl · tailscale_up"] + c3["analyst-2 · tailscale_up"] - m -->|encrypted TCP| c1 - m -->|encrypted TCP| c2 + m -->|ATTACH quack:analytics-hub.quackscale.local:9494| c1 + m --> c2 + m --> c3 ``` -A server joins with `tailscale_up`, or hosts the mesh itself with `quackscale_hub`. It attaches a DuckLake catalog when it owns one, and serves Quack on loopback through `quack_serve` and `tailscale_serve_local`. Clients reach it over encrypted TCP across the mesh, and no node listens on the public internet. +The server is the control plane **and** a mesh member (`join` defaults to true). It serves Quack on loopback through `quack_serve` and `tailscale_serve_local`. Clients `ATTACH` over the mesh; nothing listens on the public internet. -`tailscale_up` wraps DuckDB's HTTP layer. QuackScale then dials any tailnet host you name (`100.64.0.0/10`, `*.ts.net`, or `*.quackscale.local`) over tsnet, so `ATTACH 'quack:100.x:9494'` works on its own. Pass `http_route => false` to turn this off. Three cases fall outside the router: a bare MagicDNS short name, a pinned `127.0.0.1:` endpoint, and a non-HTTP client. For those, `tailscale_quack_forward` listens on loopback and dials the peer. To read a server-owned DuckLake catalog, call `attach_ducklake`. The [guide](docs/GUIDE.md) works through each pattern. +`tailscale_up` (and the hub, after it joins) wrap DuckDB's HTTP layer so `100.64.0.0/10`, `*.quackscale.local`, and `*.ts.net` dial over tsnet. Pass `http_route => false` to turn that off. Bare MagicDNS short names, a pinned `127.0.0.1:`, and non-HTTP clients still need `tailscale_quack_forward`. Server-owned DuckLake: `attach_ducklake`. Patterns: [docs/GUIDE.md](docs/GUIDE.md). ## Where to next | You want to… | Read | |--------------|------| -| Pick a pattern: remote tables, server-owned DuckLake, or shared Parquet | [docs/GUIDE.md](docs/GUIDE.md) | -| Set up Tailscale, Headscale, an in-process hub, and Quack tokens | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | -| Look up a SQL command and its parameters | [docs/REFERENCE.md](docs/REFERENCE.md) | -| Run a two-process hub proof on one machine | [examples/wirebone](examples/wirebone/README.md) | -| Run a two-node Headscale proof on Docker Compose | [examples/README.md](examples/README.md) | -| Build the extension, or work on it | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | +| Run the hub server + a client on one machine | [examples/wirebone](examples/wirebone/README.md) | +| Pick a query pattern: remote tables, DuckLake, shared Parquet | [docs/GUIDE.md](docs/GUIDE.md) | +| Tokens, extra preauth keys, Tailscale / Headscale | [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) | +| SQL commands and parameters | [docs/REFERENCE.md](docs/REFERENCE.md) | +| Headscale Docker Compose demo | [examples/README.md](examples/README.md) | +| Build from source | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | ## License diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 35ffa62..81933d7 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -9,6 +9,51 @@ QuackTail uses **two independent credential layers**. Both matter in production 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 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. + +--- + +## In-process hub + +One DuckDB process hosts the control plane and serves SQL. Every other process joins with `tailscale_up`. No Headscale container and no Tailscale SaaS. The library behind the hub is [Wirebone](https://github.com/lmangani/wirebone.cpp); operators do not call it by that name. + +| Job | SQL | +|-----|-----| +| Fleet server (hub + client) | `CALL quackscale_hub(...)` | +| Control plane only | `CALL quackscale_hub(..., join => false)` | +| Fleet client | `CALL tailscale_up(control_url => …, authkey => 'wbkey-…')` | + +```sql +LOAD quackscale; + +CALL quackscale_hub( + hostname => 'analytics-hub', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '/var/lib/duckdb/tailscale' +); +CALL quackscale_preauth(reusable => true); +SELECT * FROM quackscale.nodes; +SELECT * FROM quackscale.preauth_keys; +``` + +Clients (they do not start a hub). Repeat with a distinct `hostname` and `state_dir` per machine: + +```sql +CALL tailscale_up( + hostname => 'analyst-1', + control_url => 'http://10.0.0.5:8080', + authkey => 'wbkey-…', -- FROM quackscale.preauth_keys + 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. + +`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. + +`CALL quackscale_status()` reports `linked=false` if this binary was built without the hub. See [DEVELOPMENT.md](DEVELOPMENT.md). Walkthrough: [examples/wirebone](../examples/wirebone/README.md). SQL: [REFERENCE.md](REFERENCE.md#in-process-hub). + --- ## Tailnet login (Tailscale SaaS) @@ -90,49 +135,7 @@ CALL tailscale_up( **Compose demo:** control URL `http://headscale:8080`, preauth key written to `/work/authkey`. See [examples/README.md](../examples/README.md). -**Notes:** Production `server_url` should be HTTPS. MagicDNS is optional; `quack_uri()` prefers MagicDNS when available, else tailnet IP. - ---- - -## In-process hub - -One DuckDB process can host the Tailscale/Headscale-compatible control plane. That process — and every peer — joins with `tailscale_up`. No Headscale container and no Tailscale SaaS. The library behind the hub is [Wirebone](https://github.com/lmangani/wirebone.cpp); operators do not call it by that name. - -| Job | SQL | -|-----|-----| -| This node is the hub (joins by default) | `CALL quackscale_hub(...)` | -| Hub, control plane only | `CALL quackscale_hub(..., join => false)` | -| Peer | `CALL tailscale_up(control_url => …, authkey => 'wbkey-…')` | - -```sql -LOAD quackscale; - -CALL quackscale_hub( - hostname => 'duckdb-coord', - listen => '0.0.0.0:8080', - server_url => 'http://10.0.0.5:8080', - state_dir => '/var/lib/duckdb/tailscale' -); -SELECT * FROM quackscale.nodes; -SELECT * FROM quackscale.preauth_keys; -``` - -Peers (client only — they do not start a hub): - -```sql -CALL tailscale_up( - hostname => 'duckdb-node-b', - control_url => 'http://10.0.0.5:8080', - authkey => 'wbkey-…', -- FROM quackscale.preauth_keys / quackscale_preauth - 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 of this DuckDB (or `backend => 'ducklake', catalog => 'lake'` for a shared catalog). Use `backend => 'json', state_path => '…'` only if you need the standalone file format. - -`server_url` must be an address **peers can open**. `127.0.0.1` is fine for two processes on one machine; use a LAN or overlay IP for a fleet. - -`CALL quackscale_status()` reports `linked=false` if this binary was built without the hub (missing sources or OpenSSL/nghttp2/zstd). See [DEVELOPMENT.md](DEVELOPMENT.md). Local walkthrough: [examples/wirebone](../examples/wirebone/README.md). SQL: [REFERENCE.md](REFERENCE.md#in-process-hub). +**Notes:** Production Headscale `server_url` should be HTTPS. MagicDNS is optional; `quack_uri()` prefers MagicDNS when available, else tailnet IP. --- @@ -169,7 +172,8 @@ Keep **`TS_AUTHKEY`** separate from Quack tokens. LOAD quack; LOAD quackscale; -CALL tailscale_up(hostname => 'warehouse-a', state_dir => '…'); +CALL quackscale_hub(hostname => 'warehouse-a', listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', state_dir => '…'); CALL quack_serve( 'quack:127.0.0.1:9494', @@ -179,7 +183,7 @@ CALL quack_serve( CALL tailscale_serve_local(port => 9494); ``` -**Client** (after `tailscale_quack_forward` — see [GUIDE.md](GUIDE.md)): +**Client** (same `QUACK_TAILNET_TOKEN` as the server): ```sql LOAD quack; @@ -187,10 +191,10 @@ LOAD quack; CREATE SECRET ( TYPE quack, TOKEN 'your-shared-quack-secret', - SCOPE 'quack:127.0.0.1:19494' + SCOPE 'quack:warehouse-a.quackscale.local:9494' ); -ATTACH 'quack:127.0.0.1:19494' AS remote (TYPE quack, DISABLE_SSL true); +ATTACH 'quack:warehouse-a.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); ``` `SCOPE` must match how the client reaches the server. With the forwarder, that is `quack:127.0.0.1:`. @@ -235,21 +239,21 @@ SET GLOBAL quack_authentication_function = 'quacktail_dev_auth'; ## End-to-end checklist -**Each long-lived server** +**Fleet server (long-lived)** -1. `export TS_AUTHKEY` (or Headscale preauth key) and `export QUACK_TAILNET_TOKEN` +1. `export QUACK_TAILNET_TOKEN` (and a Tailscale/Headscale key only if you are not using the hub) 2. `LOAD quack; LOAD quackscale;` -3. `CALL tailscale_up(...)` with persistent `state_dir` +3. `CALL quackscale_hub(...)` with persistent `state_dir` (or `CALL tailscale_up` on Tailscale/Headscale) 4. Optional: `SET GLOBAL quack_authentication_function` (Modes 2–3) 5. `CALL quack_serve(..., token => quack_token()); CALL tailscale_serve_local(port => 9494);` -6. Do **not** call `tailscale_down()` on steady-state servers +6. Do **not** call `tailscale_down()` or `quackscale_stop()` on a steady-state server -**Each one-shot client** +**Each fleet client** -1. Same `QUACK_TAILNET_TOKEN` available for secrets / `quack_query` -2. `LOAD quackscale; CALL tailscale_up(...); CALL tailscale_quack_forward(...);` -3. `LOAD quack; CREATE SECRET ...;` then query / attach -4. `DETACH remote; SELECT 'done'; CALL tailscale_down();` — required or the process hangs +1. Same `QUACK_TAILNET_TOKEN`; hub clients also need a `wbkey-` from `quackscale.preauth_keys` +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 --- diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9a02472..aa2c182 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -2,12 +2,12 @@ QuackTail combines: -1. **Tailscale, Headscale, or an in-process hub** — private mesh between nodes +1. **An in-process hub** (or Tailscale / Headscale) — private mesh between DuckDB processes 2. **Quack** — DuckDB’s HTTP protocol (`quack:` URIs, port **9494**) -3. **QuackScale** — joins DuckDB to the tailnet and forwards Quack across it +3. **QuackScale** — one node hosts the fleet (`quackscale_hub`); the rest join (`tailscale_up`) 4. **DuckLake** (optional) — lakehouse catalog + Parquet on a QuackTail node -QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. One node can host the control plane **and** join it (`CALL quackscale_hub`); other nodes stay client-only (`CALL tailscale_up`). +QuackScale does **not** replace Quack or DuckLake. It makes them reachable on MagicDNS / `100.x.x.x` without exposing the public internet. The default fleet: one hub, many clients. Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md](REFERENCE.md). Build: [DEVELOPMENT.md](DEVELOPMENT.md). @@ -17,29 +17,28 @@ Credentials: [AUTHENTICATION.md](AUTHENTICATION.md). SQL commands: [REFERENCE.md ```text ┌─────────────────────────────────────────────────────────────────┐ -│ quacktail-server (long-lived) │ -│ tailscale_up — or quackscale_hub (control plane + client) │ -│ → quack_serve(127.0.0.1:9494) → tailscale_serve_local │ +│ fleet server (long-lived) │ +│ quackscale_hub → quack_serve(127.0.0.1:9494) → serve_local │ │ optional: ATTACH ducklake:… AS lake (local or s3:// Parquet) │ └───────────────────────────────┬─────────────────────────────────┘ - │ tailscale_dial (encrypted) + │ WireGuard (encrypted) ┌───────────────────────────────▼─────────────────────────────────┐ -│ quacktail-client (job, laptop, container) │ -│ tailscale_up → tailscale_quack_forward → quack:127.0.0.1:19494 │ -│ quack_query / attach_ducklake / ATTACH quack AS remote │ +│ fleet clients (analyst, job, other DuckDB) │ +│ tailscale_up(control_url, wbkey) → ATTACH quack:hub.quackscale.local:9494 +│ quack_query / attach_ducklake / FROM hub.events │ │ tailscale_down() at end of one-shot sessions │ └─────────────────────────────────────────────────────────────────┘ ``` -**Choose a control plane first.** Peers always join with `CALL tailscale_up`. Only the hub node calls `quackscale_hub`. +**Default control plane is the hub.** Peers always join with `CALL tailscale_up`. Only the fleet server calls `quackscale_hub`. -| Control plane | Server join | Peer join | -|---------------|-------------|-----------| -| Tailscale SaaS | `tailscale_up` | `tailscale_up` | +| Control plane | Server | Clients | +|---------------|--------|---------| +| **In-process hub (default)** | `quackscale_hub(...)` | `tailscale_up(control_url, authkey => 'wbkey-…', …)` | | Headscale | `tailscale_up(control_url, authkey, …)` | Same | -| In-process hub | `quackscale_hub(...)` | `tailscale_up(control_url, authkey => 'wbkey-…', …)` | +| Tailscale SaaS | `tailscale_up` | `tailscale_up` | -Hub state is DuckDB tables by default (`quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`). Use `backend => 'ducklake', catalog => 'lake'` when several hubs should share one catalog. Credentials: [AUTHENTICATION.md](AUTHENTICATION.md#in-process-hub). Local walkthrough: [examples/wirebone](../examples/wirebone/README.md). +Hub state is DuckDB tables by default (`quackscale.meta`, `quackscale.preauth_keys`, `quackscale.nodes`). Use `backend => 'ducklake', catalog => 'lake'` when several hubs should share one catalog. Credentials: [AUTHENTICATION.md](AUTHENTICATION.md#in-process-hub). Fleet walkthrough: [examples/wirebone](../examples/wirebone/README.md). **Transparent routing (default).** After `tailscale_up` (or `quackscale_hub`), QuackScale wraps DuckDB's HTTP layer so requests to tailnet hosts (`100.64.0.0/10`, `*.ts.net`, `*.quackscale.local`) are dialed over tsnet — `ATTACH 'quack:100.x.x.x:9494'` works with no forwarder. Disable with `tailscale_up(..., http_route => false)`. @@ -79,52 +78,39 @@ Both operational tables + lake on one node? ## Standard client connection recipe -This sequence is what the [Compose demo](../examples/README.md) proves: +This sequence is the fleet client. Hub walkthrough: [examples/wirebone](../examples/wirebone/README.md). The Headscale Compose demo uses `tailscale_quack_forward` and `quack:127.0.0.1:19494` instead of MagicDNS. ```sql +LOAD quack; LOAD quackscale; CALL tailscale_up( - hostname => 'my-client', - control_url => 'http://headscale:8080', -- omit for Tailscale SaaS; hub: the hub's server_url - authkey => '…', + hostname => 'analyst-1', + control_url => 'http://10.0.0.5:8080', + authkey => 'wbkey-…', state_dir => '/tmp/client-tailscale', ephemeral => true ); -CALL tailscale_quack_forward( - host => 'quacktail-server', - port => 9494, - local_port => 19494 -); -CALL tailscale_ping(host => 'quacktail-server', port => 9494); -- optional - -LOAD quack; CREATE SECRET ( TYPE quack, TOKEN 'your-shared-token', - SCOPE 'quack:127.0.0.1:19494' -); - -FROM quack_query( - 'quack:127.0.0.1:19494', - 'SELECT 1 AS probe', - token => 'your-shared-token', - disable_ssl => true + SCOPE 'quack:analytics-hub.quackscale.local:9494' ); +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS hub (TYPE quack, DISABLE_SSL true); --- Pattern B+, A, C, or D statements here … +FROM hub.query('SELECT 1 AS probe'); +-- Pattern A / B+ / C / D statements here … -DETACH remote; -- if Pattern A used -SELECT 'CLIENT_DEMO_DONE' AS status; -- before tailscale_down (compose watchdog) -CALL tailscale_down(); +DETACH hub; +CALL tailscale_down(); -- one-shot sessions ``` --- -## Use case 0 — Self-hosted hub +## Use case 0 — Query fleet on the in-process hub -**Story:** One DuckDB process is the mesh hub. Every other node is a client. No Tailscale account, no Headscale container. +**Story:** One DuckDB is the fleet server (hub + Quack). Analysts, jobs, and other DuckDBs join as clients. No Tailscale account, no Headscale container. `quackscale_hub` starts the control plane and joins it (`join` defaults to true). Peers only call `tailscale_up`. @@ -191,7 +177,14 @@ If MagicDNS short names fail, use `tailscale_quack_forward(host => 'analytics-hu LOAD quack; LOAD quackscale; -CALL tailscale_up(hostname => 'analytics-hub', state_dir => '/var/lib/quacktail/hub', …); +CALL quackscale_hub( + hostname => 'analytics-hub', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '/var/lib/quacktail/hub' +); +-- Or, if this node only joins an existing Tailscale/Headscale mesh: +-- CALL tailscale_up(hostname => 'analytics-hub', state_dir => '/var/lib/quacktail/hub', …); CREATE TABLE IF NOT EXISTS events (id INTEGER, payload VARCHAR, ts TIMESTAMP); diff --git a/docs/README.md b/docs/README.md index 6048746..fad6925 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # QuackTail documentation -QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale, Headscale, or an in-process hub). These docs are for **integrators** — operators wiring servers, clients, tokens, and lake catalogs — not for extension C++ development. +QuackTail is **DuckDB + Quack + QuackScale** as a query fleet on a private mesh (in-process hub by default; Tailscale or Headscale if you already have them). These docs are for **integrators** — operators wiring servers, clients, tokens, and lake catalogs — not for extension C++ development. ## Start here @@ -10,8 +10,8 @@ QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale, He | **[GUIDE.md](GUIDE.md)** | Pick a pattern, run use cases, connect clients, query DuckLake, avoid known pitfalls | | **[AUTHENTICATION.md](AUTHENTICATION.md)** | Configure Tailscale, Headscale, an in-process hub, and Quack HTTP tokens | | **[REFERENCE.md](REFERENCE.md)** | Look up a `quackscale` SQL command and its parameters | -| **[../examples/wirebone/README.md](../examples/wirebone/README.md)** | Run a two-process hub proof (no Headscale) | -| **[../examples/README.md](../examples/README.md)** | Run the two-node Headscale Docker Compose demo | +| **[../examples/wirebone/README.md](../examples/wirebone/README.md)** | Run a fleet server + client on one machine (in-process hub) | +| **[../examples/README.md](../examples/README.md)** | Run the Headscale Docker Compose demo | ## Extension developers @@ -22,12 +22,11 @@ QuackTail is **DuckDB + Quack + QuackScale** on a private tailnet (Tailscale, He ## Quick orientation ```text -Tailscale / Headscale / hub → Is this machine on our mesh? +quackscale_hub → This process is the fleet server (control plane + join) +tailscale_up → This process is a fleet client Quack token → May this caller run SQL over HTTP? -quackscale_hub → Host the control plane in this process -tailscale_up → Join as a client (every peer, including the hub) tailscale_quack_forward → Route Quack from embedded tsnet to 127.0.0.1 -quack_serve + serve_local → Expose DuckDB on the tailnet (:9494) +quack_serve + serve_local → Expose DuckDB on the mesh (:9494) ``` Load both extensions in every session: diff --git a/examples/README.md b/examples/README.md index edeabc3..9c66fcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ | Demo | Control plane | Where | |------|---------------|--------| -| **Two-process hub** | In-process control plane (no extra daemon) | [wirebone/README.md](wirebone/README.md) | +| **Query fleet (hub)** | In-process hub — start a server, join clients | [wirebone/README.md](wirebone/README.md) | | **Docker Compose** | Headscale | this file | | **DuckLake on Compose** | Headscale | [ducklake/README.md](ducklake/README.md) | @@ -10,7 +10,7 @@ # QuackTail Docker Compose example -Two-node **Headscale + QuackTail** demo on Linux: server joins the tailnet and serves Quack; client `ATTACH`es via `tailscale_quack_forward`. To host the control plane inside DuckDB instead, use [wirebone/](wirebone/README.md) (`CALL quackscale_hub`). +Two-node **Headscale + QuackTail** demo on Linux: server joins the tailnet and serves Quack; client `ATTACH`es via `tailscale_quack_forward`. For the default fleet (hub inside DuckDB, no Headscale), start with [wirebone/](wirebone/README.md) and the [README](../README.md#quick-start-a-query-fleet). **Integration guide:** [docs/GUIDE.md](../docs/GUIDE.md) · **DuckLake demo:** [ducklake/README.md](ducklake/README.md) diff --git a/examples/wirebone/README.md b/examples/wirebone/README.md index 26d622b..3380d00 100644 --- a/examples/wirebone/README.md +++ b/examples/wirebone/README.md @@ -1,16 +1,18 @@ # Two-process hub example -One DuckDB process is the mesh hub. A second process joins as a client. No Tailscale account, no Headscale container. +**Query fleet on one machine:** this DuckDB is the server (hub + Quack). A second process is a fleet client. No Tailscale account, no Headscale. -**Needs:** a hub-linked QuackScale build (`SELECT linked FROM quackscale_status()` is `true`). Check out [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) next to this repo (or set `QUACKSCALE_WIREBONE_DIR`) and rebuild. See [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md). +Same SQL as the README, with `127.0.0.1` so both processes share a host. A third terminal can copy `peer.sql` with a different `hostname` and `state_dir`. + +**Needs:** a hub-linked build (`SELECT linked FROM quackscale_status()` is `true`). Check out [wirebone.cpp](https://github.com/lmangani/wirebone.cpp) next to this repo (or set `QUACKSCALE_WIREBONE_DIR`) and rebuild. See [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md). ``` - hub (this machine) peer - ────────────────── ──── + fleet server fleet clients + ──────────── ───────────── CALL quackscale_hub CALL tailscale_up( - = control plane + tailscale_up control_url, wbkey-…) - quackscale.nodes / preauth_keys - optional: quack_serve + serve_local ──► ATTACH quack:coord.quackscale.local:9494 + = control plane + join control_url, wbkey-…) + quack_serve + serve_local ──► ATTACH quack:analytics-hub.quackscale.local:9494 + quackscale.nodes / preauth_keys analyst-1, job-etl, … ``` SQL reference: [docs/REFERENCE.md](../../docs/REFERENCE.md#in-process-hub). Credentials: [docs/AUTHENTICATION.md](../../docs/AUTHENTICATION.md#in-process-hub). @@ -33,7 +35,7 @@ Set a shared Quack token if you will serve HTTP: export QUACK_TAILNET_TOKEN='your-shared-token' ``` -**Terminal 1 — hub** (`coordinator.sql`): +**Terminal 1 — fleet server** (`coordinator.sql`): ```bash ../../build/release/duckdb -unsigned @@ -46,7 +48,7 @@ LOAD quackscale; SELECT linked FROM quackscale_status(); -- must be true CALL quackscale_hub( - hostname => 'coord', + hostname => 'analytics-hub', listen => '127.0.0.1:18080', server_url => 'http://127.0.0.1:18080', dns_listen => '', @@ -55,7 +57,8 @@ CALL quackscale_hub( SELECT * FROM quackscale_status(); SELECT * FROM quackscale.preauth_keys; --- copy the wbkey-… into the peer session +CALL quackscale_preauth(reusable => true); +-- copy a wbkey-… into every client session CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); CALL tailscale_serve_local(port => 9494); @@ -64,32 +67,32 @@ FROM quack_discover(); Leave this process running. Do not call `tailscale_down()` or `quackscale_stop()`. -**Terminal 2 — peer** (`peer.sql`): +**Terminal 2 — fleet client** (`peer.sql`). A third client is the same file with `hostname => 'job-etl'` and a new `state_dir`. ```sql LOAD quack; LOAD quackscale; CALL tailscale_up( - hostname => 'peer', + hostname => 'analyst-1', control_url => 'http://127.0.0.1:18080', authkey => 'wbkey-…', - state_dir => '/tmp/quackscale-hub-peer', + state_dir => '/tmp/quackscale-hub-analyst-1', ephemeral => true ); FROM tailscale_status(); -CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:coord.quackscale.local:9494'); -ATTACH 'quack:coord.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); +CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:analytics-hub.quackscale.local:9494'); +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS hub (TYPE quack, DISABLE_SSL true); -FROM remote.query('SELECT 42'); +FROM hub.query('SELECT 42'); -DETACH remote; +DETACH hub; CALL tailscale_down(); ``` -On the hub, `SELECT * FROM quackscale.nodes` should list both hostnames. If MagicDNS fails, attach the peer to the hub's `100.x` from `quackscale.nodes` or `quack_discover()`, or use `tailscale_quack_forward(host => 'coord', port => 9494)`. +On the server, `SELECT * FROM quackscale.nodes` should list `analytics-hub` and each client. If MagicDNS fails, attach the `100.x` from `quackscale.nodes` or `quack_discover()`, or use `tailscale_quack_forward(host => 'analytics-hub', port => 9494)`. ## Roles diff --git a/examples/wirebone/coordinator.sql b/examples/wirebone/coordinator.sql index c37e6e2..19e5174 100644 --- a/examples/wirebone/coordinator.sql +++ b/examples/wirebone/coordinator.sql @@ -1,7 +1,7 @@ --- Hub: control plane + client on one process. Leave this session open. +-- Fleet server: hub + Quack. Leave this session open. -- ../../build/release/duckdb -unsigned -- --- Peers need a reachable server_url. 127.0.0.1 is for two processes on this host. +-- Clients need a reachable server_url. 127.0.0.1 is for processes on this host. LOAD quack; LOAD quackscale; @@ -9,7 +9,7 @@ LOAD quackscale; SELECT linked FROM quackscale_status(); CALL quackscale_hub( - hostname => 'coord', + hostname => 'analytics-hub', listen => '127.0.0.1:18080', server_url => 'http://127.0.0.1:18080', dns_listen => '', @@ -18,9 +18,9 @@ CALL quackscale_hub( SELECT * FROM quackscale_status(); SELECT * FROM quackscale.preauth_keys; +CALL quackscale_preauth(reusable => true); SELECT * FROM quackscale.nodes; --- Optional: serve Quack on the mesh (needs LOAD quack + QUACK_TAILNET_TOKEN). CALL quack_serve('quack:127.0.0.1:9494', allow_other_hostname => true, token => quack_token()); CALL tailscale_serve_local(port => 9494); FROM quack_discover(); diff --git a/examples/wirebone/peer.sql b/examples/wirebone/peer.sql index 60f67be..43cfb28 100644 --- a/examples/wirebone/peer.sql +++ b/examples/wirebone/peer.sql @@ -1,24 +1,24 @@ --- Client only. Paste the hub's wbkey-… into authkey. +-- Fleet client. Paste a wbkey-… from the server's quackscale.preauth_keys. -- ../../build/release/duckdb -unsigned +-- Another client: change hostname and state_dir (e.g. job-etl). LOAD quack; LOAD quackscale; CALL tailscale_up( - hostname => 'peer', + hostname => 'analyst-1', control_url => 'http://127.0.0.1:18080', authkey => 'wbkey-REPLACE-ME', - state_dir => '/tmp/quackscale-hub-peer', + state_dir => '/tmp/quackscale-hub-analyst-1', ephemeral => true ); FROM tailscale_status(); --- Optional: attach the hub's Quack listener (same QUACK_TAILNET_TOKEN). -CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:coord.quackscale.local:9494'); -ATTACH 'quack:coord.quackscale.local:9494' AS remote (TYPE quack, DISABLE_SSL true); +CREATE SECRET (TYPE quack, TOKEN 'your-shared-token', SCOPE 'quack:analytics-hub.quackscale.local:9494'); +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS hub (TYPE quack, DISABLE_SSL true); -FROM remote.query('SELECT 42'); +FROM hub.query('SELECT 42'); -DETACH remote; +DETACH hub; CALL tailscale_down();