diff --git a/.github/workflows/hub-integration.yml b/.github/workflows/hub-integration.yml new file mode 100644 index 0000000..967d67f --- /dev/null +++ b/.github/workflows/hub-integration.yml @@ -0,0 +1,80 @@ +# 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: | + chmod +x scripts/ci_hub_smoke.sh + ./scripts/ci_hub_smoke.sh --assert-linked + + - 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/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..1dcdc63 100644 --- a/README.md +++ b/README.md @@ -2,141 +2,161 @@ # 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 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. -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. - -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; -- join the tailnet, dial, forward, serve +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 runs 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). +| Extra process to run | A VPN daemon or Headscale | None; hub and clients run in-process | -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. +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 -### A server others can reach +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 ``` +### 1. Start the server + +Leave this process running. Do not call `tailscale_down()` or `quackscale_stop()`. + ```sql LOAD quack; LOAD quackscale; -CALL tailscale_up( - hostname => 'my-duckdb-node', - state_dir => '~/.local/share/duckdb/quackscale' +CALL quackscale_hub( + hostname => 'analytics-hub', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', -- address clients can open + state_dir => '~/.local/share/duckdb/quackscale' ); +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(); -- prints this node's quack: URI on the tailnet +FROM quack_discover(); ``` -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). +`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', state_dir => '~/.local/share/duckdb/quackscale-client'); +CALL tailscale_up( + 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); +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] + 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 - m((100.x tailnet)) - - sq -->|tailscale_dial| m + m((100.x mesh · *.quackscale.local)) + s1 -->|map / preauth| m + sq -->|WireGuard| m - c1["client · ATTACH quack"] - c2["client · 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 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. +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` 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` (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 tailnet login, Headscale, 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) | +| 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 -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/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..81933d7 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -4,11 +4,56 @@ 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). +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,7 +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. +**Notes:** Production Headscale `server_url` should be HTTPS. MagicDNS is optional; `quack_uri()` prefers MagicDNS when available, else tailnet IP. --- @@ -127,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', @@ -137,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; @@ -145,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:`. @@ -193,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/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e68487e..f0afe57 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 hub library) + → quackscale_hub (control plane + client in one process) DuckDB + quack (core) → quack_serve, ATTACH, quack_query ``` @@ -36,16 +38,23 @@ Disable libtailscale (stub build): make CMAKE_VARS="-DQUACKSCALE_WITH_TAILSCALE=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" +``` + 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 +examples/ Headscale Compose demo; examples/wirebone is local coordinator+peer duckdb/ DuckDB submodule extension-ci-tools/ Extension build makefile submodule ``` @@ -72,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 @@ -103,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 server bootstrap | Planned | +| `quackscale_hub()` in-process control plane + client | Done (optional Wirebone build) | | Community extension descriptor publish | Done (GitHub Pages on release) | ## Risks @@ -120,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 3b3701a..aa2c182 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -2,12 +2,12 @@ QuackTail combines: -1. **Tailscale or Headscale** — 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. +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,22 +17,32 @@ 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 +│ 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 │ └─────────────────────────────────────────────────────────────────┘ ``` -**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)`. +**Default control plane is the hub.** Peers always join with `CALL tailscale_up`. Only the fleet server 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 | Clients | +|---------------|--------|---------| +| **In-process hub (default)** | `quackscale_hub(...)` | `tailscale_up(control_url, authkey => 'wbkey-…', …)` | +| Headscale | `tailscale_up(control_url, authkey, …)` | Same | +| 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). 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)`. + +**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. @@ -68,47 +78,93 @@ 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 - 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' + SCOPE 'quack:analytics-hub.quackscale.local:9494' ); +ATTACH 'quack:analytics-hub.quackscale.local:9494' AS hub (TYPE quack, DISABLE_SSL true); -FROM quack_query( - 'quack:127.0.0.1:19494', - 'SELECT 1 AS probe', - token => 'your-shared-token', - disable_ssl => true +FROM hub.query('SELECT 1 AS probe'); +-- Pattern A / B+ / C / D statements here … + +DETACH hub; +CALL tailscale_down(); -- one-shot sessions +``` + +--- + +## Use case 0 — Query fleet on the in-process hub + +**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`. + +### 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' ); --- Pattern B+, A, C, or D statements here … +SELECT * FROM quackscale.nodes; +CALL quackscale_preauth(reusable => true); -- extra keys for the fleet -DETACH remote; -- if Pattern A used -SELECT 'CLIENT_DEMO_DONE' AS status; -- before tailscale_down (compose watchdog) +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) @@ -121,7 +177,14 @@ CALL tailscale_down(); 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); @@ -356,7 +419,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..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 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** 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 @@ -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 fleet server + client on one machine (in-process hub) | +| **[../examples/README.md](../examples/README.md)** | Run the Headscale Docker Compose demo | ## Extension developers @@ -21,17 +22,18 @@ 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) +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? +tailscale_quack_forward → Route Quack from embedded tsnet to 127.0.0.1 +quack_serve + serve_local → Expose DuckDB on the mesh (: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 b7a60cd..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,6 +124,107 @@ Returns one row: --- +## 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 control plane **in this process**. Peers only call [`tailscale_up`](#tailscale_up) with `control_url` and a preauth key. + +`CALL quackscale_status()` reports `linked=false` when this binary was built without the hub library. + +### `quackscale_hub` + +```sql +CALL quackscale_hub( + hostname => 'coord', + listen => '0.0.0.0:8080', + server_url => 'http://10.0.0.5:8080', + state_dir => '/var/lib/duckdb/tailscale' +); +SELECT * FROM quackscale.preauth_keys; +SELECT * FROM quackscale.nodes; +``` + +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 `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 | `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. | + +Also accepts every [`tailscale_up`](#tailscale_up) parameter (`hostname`, `authkey`, `control_url`, `state_dir`, `ephemeral`, `http_route`, …). + +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`. + +### `quackscale_status` + +```sql +CALL quackscale_status(); +``` + +| Column | Type | Meaning | +|--------|------|---------| +| `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. | + +### `quackscale_preauth` + +```sql +CALL quackscale_preauth(reusable => true); +``` + +Creates an additional preauth key. Requires a running hub. + +| 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`. + +### `quackscale_nodes` + +```sql +CALL quackscale_nodes(); +``` + +Registered nodes. Empty when the hub is not running. Prefer `SELECT * FROM quackscale.nodes` for the persisted roster. + +| 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`. | + +### `quackscale_stop` + +```sql +CALL quackscale_stop(); +``` + +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. + +--- + ## Connectivity on the mesh ### `tailscale_serve_local` diff --git a/examples/README.md b/examples/README.md index 5c97753..9c66fcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,16 @@ +# QuackTail examples + +| Demo | Control plane | Where | +|------|---------------|--------| +| **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) | + +--- + # 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`. 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 new file mode 100644 index 0000000..3380d00 --- /dev/null +++ b/examples/wirebone/README.md @@ -0,0 +1,115 @@ +# Two-process hub example + +**Query fleet on one machine:** this DuckDB is the server (hub + Quack). A second process is a fleet client. No Tailscale account, no Headscale. + +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). + +``` + fleet server fleet clients + ──────────── ───────────── + CALL quackscale_hub CALL tailscale_up( + = 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). + +## 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 — fleet server** (`coordinator.sql`): + +```bash +../../build/release/duckdb -unsigned +``` + +```sql +LOAD quack; +LOAD quackscale; + +SELECT linked FROM quackscale_status(); -- must be true + +CALL quackscale_hub( + hostname => 'analytics-hub', + 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; +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); +FROM quack_discover(); +``` + +Leave this process running. Do not call `tailscale_down()` or `quackscale_stop()`. + +**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 => 'analyst-1', + control_url => 'http://127.0.0.1:18080', + authkey => 'wbkey-…', + state_dir => '/tmp/quackscale-hub-analyst-1', + ephemeral => true +); + +FROM tailscale_status(); + +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 hub.query('SELECT 42'); + +DETACH hub; +CALL tailscale_down(); +``` + +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 + +| 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..19e5174 --- /dev/null +++ b/examples/wirebone/coordinator.sql @@ -0,0 +1,26 @@ +-- Fleet server: hub + Quack. Leave this session open. +-- ../../build/release/duckdb -unsigned +-- +-- Clients need a reachable server_url. 127.0.0.1 is for processes on this host. + +LOAD quack; +LOAD quackscale; + +SELECT linked FROM quackscale_status(); + +CALL quackscale_hub( + hostname => 'analytics-hub', + 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; +CALL quackscale_preauth(reusable => true); +SELECT * FROM quackscale.nodes; + +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..43cfb28 --- /dev/null +++ b/examples/wirebone/peer.sql @@ -0,0 +1,24 @@ +-- 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 => 'analyst-1', + control_url => 'http://127.0.0.1:18080', + authkey => 'wbkey-REPLACE-ME', + state_dir => '/tmp/quackscale-hub-analyst-1', + ephemeral => true +); + +FROM tailscale_status(); + +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 hub.query('SELECT 42'); + +DETACH hub; +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..852268d --- /dev/null +++ b/scripts/ci_hub_smoke.sh @@ -0,0 +1,164 @@ +#!/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}}" + +# 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)" +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 +} + +# 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 + stop_hub "${COORD_PID:-}" + 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="$(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 + exit 1 +fi + +cat >"${COORD_INIT}" < 'coord', + 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); +SQL + +echo "Joining in-process hub from a second DuckDB (control_url=${URL}) ..." +echo "→ hub ${URL} (state ${WORK})" +# 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 +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 fa377f0..bfd15cb 100644 --- a/src/include/tailscale_http.hpp +++ b/src/include/tailscale_http.hpp @@ -11,11 +11,16 @@ 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, *.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. ".quackscale.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..9700ac3 --- /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 = "quackscale.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..771ac80 --- /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 `quackscale` 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 = "quackscale"; + 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..47b6a79 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 = {".quackscale.local", ".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, in-process hub, 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..2b73714 --- /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 ".quackscale.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("quackscale hub already running; CALL quackscale_stop() first"); + } + + string backend = StringUtil::Lower(config.backend); + if (backend.empty()) { + backend = "duckdb"; + } + if (backend != "duckdb" && backend != "ducklake" && backend != "json") { + throw InvalidInputException("quackscale hub backend must be duckdb, ducklake, or json"); + } + if (backend == "ducklake" && config.catalog.empty()) { + throw InvalidInputException("quackscale hub 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("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("quackscale_hub 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("quackscale_hub 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("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; +#else + return {}; +#endif +} + +string WireboneBridge::CreatePreauthKey(bool reusable, bool ephemeral) { + RequireLinked(); +#if QUACKSCALE_WITH_WIREBONE + std::lock_guard g(mu); + if (!coordinator) { + throw InvalidInputException("quackscale hub is not running; CALL quackscale_hub() first"); + } + string key = + TakeCstr(wirebone_create_preauth_key(static_cast(coordinator), reusable ? 1 : 0, + ephemeral ? 1 : 0)); + if (key.empty()) { + throw IOException("quackscale_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..facf862 --- /dev/null +++ b/src/wirebone_catalog.cpp @@ -0,0 +1,158 @@ +#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 + ".quackscale"; + } else { + schema = "quackscale"; + } + 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 = "quackscale"; +} + +void WireboneCatalog::Run(const string &sql) { + auto result = con->Query(sql); + if (result->HasError()) { + throw IOException("quackscale hub 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)"); + + // 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() { + 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..4c7eb72 --- /dev/null +++ b/src/wirebone_functions.cpp @@ -0,0 +1,373 @@ +#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 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; + 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 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)); +} + +} // 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 25d5b5e..0c2b1dc 100644 --- a/test/sql/quackscale.test +++ b/test/sql/quackscale.test @@ -70,3 +70,38 @@ statement error CALL tailscale_login(not_a_real_param => true); ---- http_route + +# Hub SQL is always registered. Live hub/join is e2e-only +# (needs OpenSSL/nghttp2/zstd and a wirebone.cpp checkout). +statement ok +CALL quackscale_status(); + +statement ok +CALL quackscale_nodes(); + +statement ok +CALL quackscale_stop(); + +statement error +SELECT wirebone_bootstrap_key(); +---- +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); +---- +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..f922037 --- /dev/null +++ b/test/sql/wirebone.test @@ -0,0 +1,98 @@ +# name: test/sql/wirebone.test +# description: In-process hub stored in DuckDB tables +# group: [sql] + +require quackscale + +# Functions are always registered. Hub needs QUACKSCALE_WITH_WIREBONE=1 +# (sibling ../wirebone.cpp or QUACKSCALE_WIREBONE_DIR). +query I +SELECT linked FROM quackscale_status(); +---- +true + +# join => false: control plane only (no tsnet). Same as the old wirebone_serve alias. +statement ok +CALL quackscale_hub(listen => '127.0.0.1:0', dns_listen => '', join => false); + +query I +SELECT running FROM quackscale_status(); +---- +true + +query I +SELECT COUNT(*) >= 1 FROM quackscale.preauth_keys; +---- +true + +query I +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 hub_test_keys AS SELECT key FROM quackscale.preauth_keys; + +statement ok +CALL quackscale_preauth(reusable => true); + +query I +SELECT COUNT(*) FROM quackscale.preauth_keys; +---- +2 + +statement ok +CALL quackscale_stop(); + +query I +SELECT running FROM quackscale_status(); +---- +false + +# Tables survive stop (same DuckDB). +query I +SELECT COUNT(*) FROM quackscale.preauth_keys; +---- +2 + +statement ok +CALL quackscale_hub(listen => '127.0.0.1:0', dns_listen => '', join => false); + +# Reloaded snapshot keeps the same keys. +query I +SELECT COUNT(*) FROM quackscale.preauth_keys; +---- +2 + +query I +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 quackscale_stop(); + +statement ok +DROP TABLE hub_test_keys; + +statement error +CALL quackscale_hub(backend => 'ducklake', join => false); +---- +catalog + +statement error +CALL quackscale_hub(backend => 'nope', join => false); +---- +duckdb