vccs
Vue 3용 차트 컴포넌트 모음, Recharts에서 포팅
Hardcharger는 GizmoData의 오픈소스 DuckDB 2.0 서버로 Warp SQL(HTTP·Apache Arrow) 기반입니다
Hardcharger is GizmoData’s open-source DuckDB 2.0 server on Warp SQL (HTTP + Apache Arrow): role-based access control, row policies, SSO and mTLS built in, a web UI with file upload, ADBC, JDBC and Ja
데이터C++ · PythonGitHub @gizmodata온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
🚀 Hardcharger, the analytics server from GizmoData
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Hardcharger™ puts DuckDB 2.0 on the network. Clients connect over Chargewire, an open HTTP protocol that moves Apache Arrow record batches straight from DuckDB to your application: no gRPC, no Flight, no sidecars.
It is the successor to GizmoSQL, rebuilt from scratch for DuckDB 2.0, and it is free: no editions, no license keys. Everything that was Enterprise in GizmoSQL is in every Hardcharger build.
/metrics endpoint, query profiles and KILL SESSION.HARDCHARGER_* environment variables, or a YAML file.Run Hardcharger from a native app for macOS 15+ on Apple silicon or Windows: start and stop the server, open its SQL workspace, manage settings, and follow the logs. Closing the window leaves the server running in the macOS menu bar or Windows system tray; Quit Hardcharger stops it.
The dashboard images show the design preview with sample data. The app runs the same
hardcharger-server executable shipped in the command-line packages and opens its built-in
web UI in a separate window. It also bundles the hardcharger CLI. Pick the desktop app
for an interactive computer, or the command-line packages for terminals, automation,
containers and operating-system services. Both remain supported.
For v2.1.0+, desktop release artifacts use the Hardcharger-desktop-… prefix:
the macOS .dmg is signed and notarized, and the Windows .msi is signed. The existing
Windows Hardcharger-windows-…msi installs the command-line tools. Linux users continue
to use the command-line packages or Docker; a Linux desktop package is follow-up work.
See the desktop guide for installation, network sharing, certificates,
settings import/export, and the difference between closing and quitting.
Hardcharger has a web UI compiled into the server binary: nothing extra to install, and it is
served on the server's own port and TLS certificate. It is off by default; --web-ui turns it on
(or HARDCHARGER_WEB_UI=true, or web_ui: true in the YAML file).
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 30 -subj /CN=localhost -addext subjectAltName=DNS:localhost -keyout server.key -out server.pem
HARDCHARGER_ADMIN_PASSWORD='change-me' hardcharger-server --web-ui --tls-cert server.pem --tls-key server.key --database demo.duckdb
# open https://localhost:31337/ui and sign in as hardcharger / change-me (the certificate is self-signed)
--enable-instrumentation), captured query profiles, and separate query-detail windows.
Server Configuration (v2.1.0+) shows current session/global values, edits and resets
settings at supported runtime scopes, and identifies startup settings that need a restart.
Both tabs are available in a browser through --web-ui and in the desktop SQL workspace.
See SQL Monitor and Server Configuration.The UI's source is in ui/, built and tested with the server in the same CI run.
The whole server runs on the iPhone or iPad itself: the same hardcharger-server, DuckDB 2.0
and its built-in extensions, built into the app. The app shows:
hardcharger command-line client in the app, with its dot commands and
formatting: Return runs a statement once it ends with ;, ↑/↓ history, ⌘↩ on iPad.It needs iOS 18 or later. Build it from ios/.
Measured on one machine (Apple M5 Max) against GizmoSQL 1.41 with the same data (details and method):
| Workload | Hardcharger | GizmoSQL 1.41 | |
|---|---|---|---|
| The 22 queries derived from TPC-H, SF10 | 832 ms | 1,201 ms | 1.44× faster |
| Fetch 10M rows (1.73 GB of Arrow) | 0.83 s | 1.32 s | 1.59× faster |
| 10 concurrent clients | 39.0 queries/s | 21.9 queries/s | 1.8× the throughput |
| 10 concurrent clients, p95 latency | 471 ms | 1,328 ms | 2.8× lower |
Over a real network: two AWS c8gn.8xlarge machines, TLS on both servers, median of five runs (details and method; the loads: partitioned uploads):
| Workload | Hardcharger | GizmoSQL 1.42 | |
|---|---|---|---|
| Fetch SF10 lineitem (60M rows, 10.5 GB of Arrow) | 0.97 s | 15.2 s | 15.7× faster |
| Fetch SF1 lineitem (6M rows) | 0.148 s | 1.74 s | 11.7× faster |
| Fetch 100M narrow rows | 0.45 s | 5.43 s | 12.0× faster |
| Fetch SF1 lineitem across regions (46 ms away) | 1.96 s | 12.26 s | 6.3× faster |
| Load SF10 lineitem (bulk ingest, 8 upload parts) | 3.00 s | 9.44 s | 3.1× faster |
| Load SF1 lineitem across regions (8 upload parts) | 2.91 s | 13.94 s | 4.8× faster |
The queries and data (lineitem at scale factor 1 or 10) come from DuckDB's tpch extension,
and the runs follow none of the TPC-H run rules or metrics. Derived from the TPC-H benchmark;
not an official TPC-H result and not comparable to published TPC-H results, as these results
do not comply with the TPC-H specification. TPC-H™ is a trademark of the Transaction Processing
Performance Council (TPC).
Every result travels through an asynchronous pipeline: no stage waits for another unless a queue is full.
flowchart LR
D[(DuckDB 2.0<br/>parallel execution)] --> P[Produce<br/>Arrow batches,<br/>122,880 rows each]
P --> Z[Compress<br/>lz4_frame or zstd,<br/>worker pool, when asked for]
Z --> T[Encrypt and send<br/>TLS in 1 MiB writes,<br/>overlapped with the next]
T --> C1[connection 1]
T --> C2[connection 2]
T --> CN[connection N]
lz4_frame (for fast links) or zstd (for slow
ones) runs on a pool of threads, several batches at once, and the batches still leave in
order. A buffer that a sample shows won't shrink enough is sent as it is, by reference,
so compression costs only what it saves. The Go/Python driver picks the codec from the
link's round trip by default.hardcharger CLI for
8; result_partitions, --result-partitions change it). Small results still come as one
stream.adbc.chargewire.ingest.parts), inserted from all of them in parallel and committed as
one: all or nothing.Correctness is never traded for speed: a result is complete only when every partition has arrived with its end-of-stream marker, so a query that fails mid-stream can never look like a short, successful one.
docker run --rm -p 31337:31337 -p 31338:31338 \
-e HARDCHARGER_ADMIN_PASSWORD='change-me' \
ghcr.io/gizmodata/hardcharger:latest
Images are multi-architecture (linux/amd64, linux/arm64) and published to both
ghcr.io/gizmodata/hardcharger and docker.io/gizmodata/hardcharger. Port 31337 speaks
Chargewire; 31338 answers /health.
The :2.0.0-adbc and :latest-adbc variants on both registries include the same
server and CLI plus 15 native ADBC drivers: BigQuery, Databricks, Exasol, Flight SQL,
SQL Server, MySQL, PostgreSQL, Redshift, Snowflake, Trino, GizmoSQL, Oracle, Db2,
Quack and Hardcharger. Both linux/amd64 and linux/arm64 are supported. For example:
docker run --rm -p 31337:31337 -p 31338:31338 \
-e HARDCHARGER_ADMIN_PASSWORD='change-me' \
ghcr.io/gizmodata/hardcharger:2.0.0-adbc
Driver manifests are installed system-wide in /etc/adbc/drivers; stable library
paths are /usr/local/lib/libadbc_driver_<name>.so. An ADBC driver manager in the
container can discover them by name (hardcharger, gizmosql, snowflake, etc.).
SNOWFLAKE_ADBC_DRIVER_PATH points at the bundled Snowflake library. Driver versions,
source-asset digests and notices are under /usr/share/doc/hardcharger-adbc/; dbc's
driver manifests and bundled license files are retained in /etc/adbc/drivers.
These variants use the same non-root user and server entrypoint as the base image.
See releasing for the separate driver-layer workflow.
Installed extensions (INSTALL) and persistent secrets live under the home directory
(~/.duckdb/), as DuckDB keeps them. Without a usable one (no HOME, or a container run
as a user the image has no account for, which gets HOME=/), the server uses the
account's home from the password database, else .duckdb/ in the database file's
directory, and says so at startup; mount a writable volume for the database (--database /data/my.duckdb) and they go there. Init SQL can set extension_directory and
secret_directory to anywhere else.
In v2.1.0+, non-iOS builds statically include DuckDB's standard core_functions,
parquet, json and icu extensions. Optional extensions—including httpfs, postgres,
ducklake, tpch and autocomplete—load at runtime and can be replaced independently
of the server. Automatic installation and loading of known extensions are enabled by
default, matching GizmoSQL; signature verification stays enabled. A first use may need
network access and a writable extension cache. For offline use, preinstall the needed
modules for the server's DuckDB version and platform. Administrators can select a custom
repository or disable automatic installation/loading through init SQL. See
Extensions. iOS keeps its extensions built in and dynamic loading off.
ghcr.io/gizmodata/hardcharger:main (and :sha-<commit>) is the development build: the
newest commit on main that passed every CI gate, built exactly as a release. Use
:latest or a version tag for anything else.
pip install hardcharger
import hardcharger
with hardcharger.Server(password="change-me") as server:
print(server.uri) # hardcharger://127.0.0.1:<port>?tls=false
The hardcharger package is the server: it downloads hardcharger-server for your
platform on first use and runs it. The client is adbc-driver-hardcharger (below);
pip install "hardcharger[adbc]" installs both, and server.connect() then returns a
connection. See python/README.md.
These packages contain the server and command-line client. For the graphical app in
v2.1.0+, choose a Hardcharger-desktop-… artifact instead; see Desktop app.
Linux (x86-64 and arm64), macOS (Apple silicon) and Windows (x64 and arm64) builds are attached to every release:
tar -xzf hardcharger-macos-arm64.tar.gz
HARDCHARGER_ADMIN_PASSWORD='change-me' ./hardcharger-macos-arm64/bin/hardcharger-server --database my.duckdb
On Windows (PowerShell), unzip hardcharger-windows-amd64.zip or -arm64.zip, or run
Hardcharger-windows-amd64.msi / -arm64.msi, which installs under Program Files and
puts hardcharger-server.exe and the hardcharger.exe client on the PATH. Ctrl+C or
Ctrl+Break stops the server as SIGINT or SIGTERM does elsewhere.
Expand-Archive hardcharger-windows-amd64.zip -DestinationPath .
$env:HARDCHARGER_ADMIN_PASSWORD = 'change-me'
.\hardcharger-windows-amd64\bin\hardcharger-server.exe --database my.duckdb
pip install adbc-driver-hardcharger
from adbc_driver_hardcharger import dbapi as hardcharger
with hardcharger.connect(uri="hardcharger://localhost?tls=false", username="hardcharger", password="change-me") as conn:
with conn.cursor() as cur:
cur.execute(operation="SELECT 42 AS answer, ? AS greeting", parameters=["hello"])
print(cur.fetch_arrow_table())
hardcharger://host is HTTPS on port 31337; ?tls=false is plain HTTP for local
development. hardcharger-adbc is a native
Go driver, on the Chargewire ADBC driver, with
query and session tags, hardcharger.* settings and browser sign-in. Its library loads into
any ADBC driver manager (C/C++, R, Rust, ...) as driver = "hardcharger", with the same
hardcharger:// URIs, and Go programs import it directly.
Properties properties = new Properties();
properties.setProperty("user", "hardcharger");
properties.setProperty("password", "change-me");
properties.setProperty("useEncryption", "false"); // plain HTTP on localhost
try (Connection connection = DriverManager.getConnection("jdbc:hardcharger://localhost", properties);
ResultSet rows = connection.createStatement().executeQuery("SELECT 42 AS answer")) {
rows.next();
System.out.println(rows.getInt("answer"));
}
hardcharger-jdbc is a single jar for Java 17+,
with no gRPC, Netty or protobuf: jdbc:hardcharger://host[:port] (port 31337 by default) on
the Chargewire JDBC driver. Its README shows how
to add it to DBeaver and other BI tools.
import { connect } from "@gizmodata/hardcharger";
const conn = await connect({ url: "hardcharger://localhost?tls=false", username: "hardcharger", password: "change-me" });
const table = await conn.query("SELECT 42 AS answer, ? AS greeting", ["hello"]);
console.log(table.toArray().map((row) => row.toJSON()));
await conn.close();
@gizmodata/hardcharger, the Hardcharger™
JavaScript client, takes the same hardcharger:// URLs (port 31337 by default) and returns Arrow JS Tables or streams of record
batches, in Node, browsers (allow the page's origin with --cors-origins), Deno, Bun and
Cloudflare Workers. No native code, no gRPC. It is a thin layer on
@gizmodata/chargewire, the generic JavaScript client
for any Chargewire server, which it re-exports.
Chargewire is plain HTTP, so curl works. Rows come back as an Arrow IPC stream (here read
with pyarrow, pip install pyarrow):
TOKEN=$(curl -s localhost:31337/chargewire/v1/auth/token -H 'Content-Type: application/json' \
-d '{"grant_type": "password", "username": "hardcharger", "password": "change-me"}' | jq -r .access_token)
SESSION=$(curl -s localhost:31337/chargewire/v1/sessions -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{}' | jq -r .session_id)
curl -s localhost:31337/chargewire/v1/sessions/$SESSION/statements -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Accept: application/vnd.apache.arrow.stream' \
-d '{"sql": "SELECT 42 AS answer"}' |
python3 -c 'import sys, pyarrow as pa; print(pa.ipc.open_stream(source=sys.stdin.buffer).read_all().to_pylist())'
[{'answer': 42}]
Without that Accept header the answer is the statement's JSON document: its state, and
in result its Arrow schema and partitions, whose rows GET …/statements/{id}/result
returns (Chargewire §6.3, §6.4).
A session (and its statements, results, prepared statements and ingests) belongs to the token
that opened it: its user and that token's authority, meaning its role, scoped token,
catalog_access rules, tenant and identity-provider groups. Any other token gets
404 session_not_found on it, a readonly or scoped token of the same user included, so a
client that attaches to a session by its id runs with that session's own rights or not at
all. A new token with the same authority, such as signing in again with the same password,
reaches the session.
The server describes its API in OpenAPI 3.1 at GET /chargewire/v1/openapi.json (no sign-in
needed; discovery, GET /chargewire, lists it as capability openapi), for API tools and client
generators; the web UI's API page shows it:
curl -s localhost:31337/chargewire/v1/openapi.json | jq '{openapi, title: .info.title, paths: (.paths | length)}'
hardcharger, the command-line client, ships beside hardcharger-server in every tarball
and in the image. It uses TLS by default. Connect to a server without TLS:
hardcharger --no-tls (the same as --tls=false or --tls false). Run it with no
statement for an interactive shell (it asks for the password, or reads
HARDCHARGER_PASSWORD):
$ hardcharger --host localhost --no-tls --username hardcharger
Password:
Hardcharger Client 0.3.0
Connected to localhost:31337
Type '.help' for help, '.quit' to exit.
my.main> CREATE OR REPLACE TABLE trips AS SELECT * FROM (VALUES (1, 'Boston', 12.5), (2, 'Denver', 30.0)) t(id, city, miles);
OK, 2 rows affected
my.main> SELECT city, miles FROM trips ORDER BY miles DESC;
┌─────────┬──────────────┐
│ city │ miles │
│ varchar │ decimal(3,1) │
├─────────┼──────────────┤
│ Denver │ 30.0 │
│ Boston │ 12.5 │
├─────────┴──────────────┤
│ 2 rows 2 columns │
└────────────────────────┘
my.main> .quit
History, tab completion, syntax highlighting, a pager for long results, dot commands
(.help, .tables, .mode, ...), and Ctrl-C cancels the running statement while the
shell goes on. A large result is read over up to 8 connections at once, in the same order;
--result-partitions N (or HARDCHARGER_RESULT_PARTITIONS) changes that, and 1 reads every
result over one connection.
One statement with --command (-c), in any output mode (--csv, --json,
--markdown, --table, or .mode for the rest):
$ HARDCHARGER_PASSWORD='change-me' hardcharger --host localhost --no-tls --username hardcharger --csv \
--command "SELECT city, miles FROM trips ORDER BY id"
city,miles
Boston,12.5
Denver,30.0
A script from a heredoc, a pipe or --file; the exit status is 1 when a statement fails:
$ export HARDCHARGER_PASSWORD='change-me'
$ hardcharger --host localhost --no-tls --username hardcharger --quiet <<'SQL'
INSERT INTO trips VALUES (3, 'Austin', 8.5);
SELECT count(*) AS trips, sum(miles) AS miles FROM trips;
SQL
OK, 1 row affected
┌────────┬───────────────┐
│ trips │ miles │
│ bigint │ decimal(38,1) │
├────────┼───────────────┤
│ 3 │ 51.0 │
├────────┴───────────────┤
│ 1 row 2 columns │
└────────────────────────┘
The client uses TLS unless told otherwise, as the drivers do: --no-tls (or --tls=false,
--tls false, --tls off, or HARDCHARGER_TLS=false) is plain HTTP, for a local server like
this one started without --tls-cert; in the shell, .connect HOST PORT USERNAME --no-tls.
Against such a server without it, the client says so and connects nothing in plaintext. When
nothing answers, the error says why (Could not connect to http://localhost:9: connection refused. Is the server running on that port?), and a script run without any connection
options connects to the default (https://localhost:31337) at its first statement, so its
error is the connection's too. --tls-roots names a private CA, --mtls-cert and --mtls-key a client
certificate, and --auth-type external signs in through the server's browser sign-in. A URI
takes the place of --host, --port and --tls, with the Python driver's rules:
hardcharger://db.example.com is HTTPS to port 31337, and
hardcharger 'hardcharger://localhost?tls=false' --username hardcharger is plain HTTP. It is GizmoSQL's
gizmosql_client, on Chargewire:
docs/migrating-from-gizmosql.md
maps its options and lists what changed.
| 🦆 DuckDB 2.0, DuckLake and DuckDB's extensions | ✅ |
🖥️ Built-in web UI: SQL editor, results grid, schema browser, exports (--web-ui) | ✅ |
| 📱 iPhone and iPad app: the server on the device, its web UI and a SQL console | ✅ |
| ⚡ Chargewire: streamed and resumable results, bind parameters, prepared statements, bulk ingest, lz4_frame and zstd | ✅ |
| 🔐 TLS and mTLS, password and token sign-in, SSO via OIDC/JWKS, browser sign-in, authorized e-mail filters | ✅ |
| 👥 Role-based access control: users, roles, grants, ownership, system privileges | ✅ |
| 🛡️ Row policies, column masking, tenants, per-catalog access rules, Open Policy Agent | ✅ |
| 🧾 Tamper-evident audit log | ✅ |
| 🔭 Session and statement instrumentation, catalog logging, query profiles | ✅ |
📈 Prometheus /metrics and SQL metrics | ✅ |
🚦 Statement queue, timeouts, KILL SESSION, hardcharger_cancel(), graceful shutdown | ✅ |
🔍 hardcharger_active_connections(), hardcharger_server_info() (no instrumentation needed) | ✅ |
| 💳 License key | Not needed |
Quack is DuckDB Labs' own remote protocol: a
DuckDB extension that lets one DuckDB serve its databases (CALL quack_serve(...)) and another
ATTACH them over HTTP. It comes from DuckDB's extension repository and loads on first use,
works on every platform DuckDB ships extensions for (Windows included) and in the browser through
DuckDB-Wasm, lets one query join remote tables with local ones, and it is fast. If every client
is a DuckDB you trust, Quack is the easy choice.
Hardcharger™ is built for the other case: one server that many people and applications share, reached through drivers, with each user's access governed by the server. Both are young (Quack is an experimental extension, Hardcharger is 0.x); this is what each offers today:
| Hardcharger | Quack | |
|---|---|---|
ATTACH from DuckDB, joining remote tables with local ones | ✅ ¹ | ✅ |
Drivers that don't embed DuckDB: ADBC, JDBC, JavaScript, curl | ✅ | ❌ ² |
| Browsers | ✅ | ✅ ³ |
| Bind parameters sent to the server | ✅ | ❌ ⁴ |
| Bulk ingest, cancellation | ✅ | ✅ |
| TLS | ✅ | ✅ ⁵ |
| Users with passwords, tokens, SSO (OIDC/JWKS) | ✅ | partial ⁶ |
| Client certificates (mTLS), as identity too | ✅ | ❌ ⁷ |
| Roles and grants, checked against each statement's plan | ✅ | partial ⁸ |
| Row policies, column masking, tenants, Open Policy Agent | ✅ | ❌ |
| Tamper-evident audit log | ✅ | ❌ |
Instrumentation tables, Prometheus /metrics | ✅ | partial ⁹ |
Statement queue, statement timeouts, KILL SESSION | ✅ | partial ¹⁰ |
| Standalone server: binaries, Docker image, Windows MSI, YAML config | ✅ | ❌ ¹¹ |
| Built-in web UI | ✅ | ❌ |
| Written, versioned protocol spec with a conformance suite | ✅ | partial ¹² |
| License | Apache 2.0 | MIT |
adbc or Query.Farm's adbc_scanner. Each attaches Hardcharger's tables, joins them with local
ones and writes to them (CREATE TABLE … AS through adbc, INSERT through adbc_scanner).
adbc_scanner sends filters to the server with bind parameters; adbc sends only the
columns, and DuckDB filters the rows. Neither is published for DuckDB 2.0
yet; both work on DuckDB 1.5.6, and adbc also on DuckDB 2.0 built from its source
(setup).Access-Control-Allow-Origin: *).SELECT … WHERE i = ? on an attached table fails ("Values were not
provided for the following parameters"), because the client sends WHERE (i = $1) to the
server without the value. It runs with SET disabled_optimizers = 'remote_pushdown', but then
the client reads the whole table and filters it locally.quack_authentication_function can point at your own SQL
function (per-user tokens from a table, say), but the server keeps no user for the session;
DuckDB's docs suggest writing an extension to record one.quack_authorization_function, gets each statement's text and can allow, refuse or
rewrite it; by default it allows everything. DuckDB has no roles or grants.CALL enable_logging('Quack')), and
quack_active_connections() lists connections and their current SQL. We found no metrics
endpoint.quack_server_max_connections) and quack_cancel(), which stops another
connection's query. We found no statement queue and no server-side statement timeout.Checked on running servers, with Quack e7a9be3f8c (the commit DuckDB 2.0 pins and its extension
repository serves) and Hardcharger; docs/hardcharger-vs-quack.md
shows how.
Measured on 2026-10-19 on AWS c8gn.8xlarge machines: server and client in one placement group
(LAN), or the client in another region, about 46 ms away (WAN). TLS on every server unless the
row says plaintext; median of five runs. Hardcharger at 2718a17e with the Chargewire ADBC
driver's default settings, Quack e7a9be3f8c attached from DuckDB 2.0, GizmoSQL 1.42 for
reference. The data is TPC-H-derived, as under Performance. These are the
shapes where Hardcharger came out ahead;
the full results
include the shapes where Quack is faster.
| Workload | Hardcharger | Quack | GizmoSQL 1.42 |
|---|---|---|---|
| Fetch SF1 lineitem (6M rows), WAN | 0.383 s | 1.508 s | 12.972 s |
| Load SF1 lineitem, WAN | 1.107 s | 1.801 s † | 13.802 s |
| Fetch SF1 lineitem, LAN | 0.126 s | 0.164 s | 1.484 s |
| Load SF1 lineitem, LAN | 0.538 s | 0.600 s † | 1.607 s |
| Fetch SF10 lineitem (60M rows), LAN, plaintext | 0.746 s | 0.962 s | 13.216 s |
† Quack with preserve_insertion_order = false. Hardcharger's driver loads in parallel parts,
which keep no row order either. Quack keeping insertion order, its default, took 2.684 s (WAN)
and 1.016 s (LAN).
Every option is a flag (--read-only), an environment variable (HARDCHARGER_READ_ONLY)
or a key in a YAML file passed with --config (read_only: true). Flags win over
environment variables, which win over the file. hardcharger-server --help lists them all.
# hardcharger.yaml
database: /data/analytics.duckdb
port: 31337
health_port: 31338
log_level: info
print_queries: true
init_sql_commands_file: /etc/hardcharger/init.sql
Started with a text log (the default), the server opens it with a banner: the wordmark,
its version, the Chargewire and DuckDB versions, and where to go (the web UI's address when
--web-ui is on, else the Chargewire endpoint), then the documentation's:
__ __ __ __
/ / / /___ __________/ /____/ /_ ____ __________ ____ _____
/ /_/ / __ `/ ___/ __ / ___/ __ \/ __ `/ ___/ __ `/ _ \/ ___/
/ __ / /_/ / / / /_/ / /__/ / / / /_/ / / / /_/ / __/ /
/_/ /_/\__,_/_/ \__,_/\___/_/ /_/\__,_/_/ \__, /\___/_/
/____/
Hardcharger 2.0.0 | Chargewire 1.0.0 | DuckDB v2.0.0 | by GizmoData
UI http://localhost:31337/ui/ Docs docs.hardcharger.gizmodata.com
http://build-box:31337/ui/
It is plain ASCII within 80 columns, and never in a JSON log (--log-format json).
--no-banner (or --banner=false, HARDCHARGER_NO_BANNER=true, HARDCHARGER_BANNER=false,
banner: false in the YAML file) leaves it out. Every boolean option takes --no-NAME.
The addresses printed (in the banner, and as url= on the Hardcharger is ready and the web UI is on lines) are ones a browser can open: a server bound to every interface (0.0.0.0, the default,
or ::) prints http://localhost:<port> and http://<this machine's name>:<port>
(the second as also=), and with TLS the certificate's first DNS name (else the machine's
name), so the browser shows no certificate warning; listen= is the address bound.
--log-emoji auto|always|never (HARDCHARGER_LOG_EMOJI, log_emoji:) marks the level of a text
log line: ✅ for an outcome (ready, stopped), ⚠️ for a warning, ❌ for an error and 💥 for one
that stops the server (info and debug lines get none). auto, the default, marks lines only
when the log goes to a terminal and the locale is UTF-8 (LC_ALL, LC_CTYPE or LANG
names UTF-8; on Windows, the console's code page is UTF-8), so a file, a pipe, systemd's
journal, Docker and Kubernetes get none. JSON lines never have them. The log has no colour.
In SQL, SET SESSION name = value changes a DuckDB setting for your session only. A plain
SET name = value takes the setting's own default scope, which for about half of DuckDB's
settings (arrow_lossless_conversion among them) is GLOBAL: then only an administrator
may run it, and it changes the default of every session that has no value of its own.
A new session starts in the database's catalog (memory, or the --database file's) and its
main schema. To start every session in a catalog the init SQL attaches, name it:
init_sql_commands: "ATTACH ':memory:' AS lake; CREATE SCHEMA lake.sales;"
default_catalog: lake # flag: --default-catalog lake
default_schema: sales # flag: --default-schema sales
catalog / schema session options (Chargewire POST /chargewire/v1/sessions) still
win, and USE still moves a session anywhere. A schema option alone is that schema in the
default catalog. default_schema alone is that schema in the database's catalog;
default_catalog alone starts in that catalog's default schema (main).USE x or USE x.y sets where sessions
start, as in GizmoSQL 1.x. The server reads where the init SQL's connection ended up (a
USE that a later one undid doesn't count) and logs it: init SQL ended in USE lake.sales: sessions start there (set default_catalog to choose explicitly). Setting either option turns
this off.system, _hardcharger_system, the
instrumentation and log catalogs) stops the server with exit code 1 and says why
(default_catalog 'lake' is not attached: the attached catalogs are memory (attach it in the init SQL)).USE could: a user held
to catalog_access rules that don't include it, or a tenant's user, starts where they would
without it (logged at debug level), and a tenant with a default catalog starts its users
there. A user outside any tenant sees every catalog's name, so they start in the default and
their grants decide what they may read there.hardcharger_server_info()
(default_catalog, default_schema).Behind an HTTP reverse proxy (an ingress, an application load balancer) every connection
comes from the proxy, so by default the log, the audit records, hardcharger_active_connections()
and the per-address bound on browser sign-ins all see the proxy's address. List the proxies in
trusted_proxies (CIDRs or single addresses, IPv4 or IPv6) and Hardcharger takes the client's
address from their forwarding header instead:
trusted_proxies: [10.0.0.0/8, fd00::/8] # flag: --trusted-proxies 10.0.0.0/8,fd00::/8
X-Forwarded-For or Forwarded header is ignored. The default (empty) trusts no one.X-Forwarded-For is read right to left, skipping each hop that is itself a trusted proxy;
the first that isn't is the client (when every hop is trusted, the leftmost). Several
instances of the header count as one list, in order. A request without X-Forwarded-For
uses RFC 7239 Forwarded: for= the same way (quoted values, "[2001:db8::7]:4711"). When
both are present X-Forwarded-For wins: the common proxies append to it and pass a
client's own Forwarded header on unchanged. Behind a proxy that sets only Forwarded,
have it drop X-Forwarded-For.unknown, obfuscated (for=_hidden), not an IP address, or otherwise
malformed stops the search, and the proxy's own address is recorded.203.0.113.9, [2001:db8::7] (a direct client's is
127.0.0.1:51234, [::1]:51234). Where it was forwarded, the proxy's own address:port
is kept beside it: proxy_address in hardcharger_active_connections(), proxy in
security log and audit lines.An existing plain DuckDB file keeps its storage format: the --database file and ordinary
files you ATTACH. --storage-version (storage_version) selects the format for a new
server database or a new plain attached database; unset, these use DuckDB 2.0's format.
v2.1.0+ removes Hardcharger's DuckLake metadata storage-version patch. DuckLake now
follows upstream behavior: its DuckDB metadata database defaults to the latest storage
format, including when attaching an existing metadata file. Writes/checkpoints can upgrade
it so an older DuckDB cannot reopen it. --storage-version does not override DuckLake's
metadata choice. Back up metadata before using it with a newer server.
Hardcharger no longer guarantees that older DuckDB clients can reopen DuckLake metadata
after writes by a newer server. Use the compatibility and migration controls supported by
your chosen upstream DuckLake version. DuckLake's catalog format and its
AUTOMATIC_MIGRATION option are separate from the metadata database's storage format.
PostgreSQL metadata has no DuckDB file storage version. See Extensions.
Open files. A query over a DuckLake table holds many of its data files open at once, so
a table with thousands of partition files needs more than the 1024 open files that
containers and services usually start with. The server raises its soft limit to the hard
limit at startup (v2.1.1+) and warns in its log when the hard limit is below 8192. Raise
that with ulimit -Hn, docker run --ulimit nofile=1048576:1048576, Compose's
ulimits: nofile:, or systemd's LimitNOFILE=.
The server logs to stderr (log_file for a file), at log_level (info by default), as
text or, with log_format: json, one JSON object per line. Statements are logged as they
start and end with print_queries (on by default), at query_log_level. Whatever those
say, a statement stopped by a cancel gets one info line, statement cancelled, with its
session_id, statement_id (the q_… id), user, reason and duration (seconds, its
wait in the statement queue included):
{"time":"2026-10-20T14:31:39.702Z","level":"info","message":"statement cancelled","session_id":"s_…","statement_id":"q_…","user":"joe","reason":"timeout","duration":"1.003"}
reason is Chargewire's cancel_reason: client (the client's cancel, or it closed the
connection of a streamed result), timeout (Chargewire-Statement-Timeout or
query_timeout_seconds), killed (KILL SESSION), cancelled (hardcharger_cancel()),
session_closed (the session was deleted) or server_shutdown. A statement that
ends on its own, or fails, gets none.
With block_sensitive_paths on (the default), no statement can open, list, write, move or
delete the credential and system files on the server host, whoever runs it, the
administrator included:
.ssh, .aws, .azure, .config/gcloud, .kube,
.docker/config.json, .netrc, .pgpass, .git-credentials and .gnupg, and DuckDB's
secret directory (~/.duckdb/stored_secrets, or wherever the init SQL moved it);/etc/passwd, /etc/shadow, /etc/gshadow, /etc/master.passwd, /etc/sudoers and
/etc/sudoers.d, /proc and /dev/fd (except /proc/self/cgroup, which DuckDB reads to
size its memory limit), and /var/run/secrets and /run/secrets (container and
Kubernetes secrets);\\.\, \\?\);sensitive_paths (comma-separated).The check sits in DuckDB's file system, so it sees the path DuckDB is about to use, however
the statement arrived at it: a literal, a concatenation, a variable, a prepared statement's
parameter, query('…'), a macro or view, file_search_path, ~, .., a symlink, a second
hard link, or a different letter case where the file system ignores case. A refusal reads
Permission denied: Hardcharger blocked access to '<path>' (<what it is>). A glob that
reaches into a protected directory from above (/home/*/.ssh/*, **/id_rsa) lists its
other matches only; one whose fixed part is protected (~/.ssh/*) is refused. SQL text is
not inspected for paths, so string literals such as '{"a": 1}', '.*' or
'~/.ssh/id_rsa' used as values work as anywhere else. SET secret_directory is refused
after startup, because DuckDB's secret manager reads that directory outside its file system.
Two limits:
init_sql_commands, init_sql_commands_file) run before the guard switches on, so init
SQL may read a mounted credential, for example to create a secret from it. (GizmoSQL 1.x
checks init SQL too.)sqlite_scanner is closed separately, at plan time (see the access-control section).
postgres_scanner is not: PostgreSQL's client library reads ~/.pgpass, and files named by
passfile=, sslkey= and similar connection options, itself. So a statement that may
ATTACH a PostgreSQL database (the administrator, or a role granted MANAGE CATALOGS) can
make the server connect with a password or client key it finds there. Keep PostgreSQL
credentials off a server host whose users you wouldn't trust with them.Three SQL functions show what the server is doing, with no instrumentation or metrics
turned on. hardcharger_active_connections() has a row per open session: who (user, role,
tenant, how they signed in), from where (the latest request's address, behind a trusted
proxy the forwarded one with the proxy's in proxy_address, its User-Agent, TLS version and
client certificate), what it runs (state: idle, queued, running, streaming,
idle_in_transaction or idle_in_transaction_aborted; the statement, redacted as the log
redacts it; since when), and its open result streams, partitions, uploads and retained
results:
$ hardcharger --command "SELECT session_id, username, client_info, state, statement FROM hardcharger_active_connections() WHERE NOT is_current"
┌────────────────────────────────────┬─────────────┬────────────────────┬─────────┬──────────────────────────────────────────────────────────┐
│ session_id │ username │ client_info │ state │ statement │
│ varchar │ varchar │ varchar │ varchar │ varchar │
├────────────────────────────────────┼─────────────┼────────────────────┼─────────┼──────────────────────────────────────────────────────────┤
│ s_dx7wohhsyptjqkqgukmrlp3ow5t2gtsa │ hardcharger │ hardcharger-client │ running │ SELECT count(*) FROM range(10000000000) a, range(1000) b │
├────────────────────────────────────┴─────────────┴────────────────────┴─────────┴──────────────────────────────────────────────────────────┤
│ 1 row 5 columns │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
hardcharger_cancel(session_id) stops what a session runs and keeps the session: its
statement ends cancelled (cancel_reason cancelled), result streams are cut off, uploads
in flight are aborted, and its next statement runs as usual. It returns whether there was
anything to stop. KILL SESSION does the same, then ends the session (killed):
$ hardcharger --command "SELECT hardcharger_cancel(session_id) AS cancelled FROM hardcharger_active_connections() WHERE state = 'running' AND NOT is_current"
┌───────────┐
│ cancelled │
│ boolean │
├───────────┤
│ true │
├───────────┤
│ 1 row │
└───────────┘
A statement's statement_id is its Chargewire id (q_...), the one its client got in
Chargewire-Statement-Id and error responses. With instrumentation on (--enable-instrumentation),
the same id finds it in the history once it is over: sql_executions.execution_id, and
sql_statements.statement_id for a submitted statement (a prepared statement's row is its
p_..., with an execution per request under each request's q_...; the statements of a
script sent in one request are q_....1, q_....2, ...). The query log's statement_id
and prepared_id fields carry the same ids. Here the statement above, cancelled:
$ hardcharger --command "SELECT statement_id, state, statement FROM hardcharger_active_connections() WHERE NOT is_current"
┌────────────────────────────────────┬─────────┬──────────────────────────────────────────────────────────┐
│ statement_id │ state │ statement │
│ varchar │ varchar │ varchar │
├────────────────────────────────────┼─────────┼──────────────────────────────────────────────────────────┤
│ q_n5zugypkhvoe7e6poydlvnsqaqnfzk6d │ running │ SELECT count(*) FROM range(10000000000) a, range(1000) b │
├────────────────────────────────────┴─────────┴──────────────────────────────────────────────────────────┤
│ 1 row 3 columns │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────┘
$ hardcharger --command "SELECT execution_id, status, cancel_reason, sql_text FROM _hardcharger_instr.execution_details WHERE execution_id = 'q_n5zugypkhvoe7e6poydlvnsqaqnfzk6d'"
┌────────────────────────────────────┬───────────┬───────────────┬──────────────────────────────────────────────────────────┐
│ execution_id │ status │ cancel_reason │ sql_text │
│ varchar │ varchar │ varchar │ varchar │
├────────────────────────────────────┼───────────┼───────────────┼──────────────────────────────────────────────────────────┤
│ q_n5zugypkhvoe7e6poydlvnsqaqnfzk6d │ cancelled │ cancelled │ SELECT count(*) FROM range(10000000000) a, range(1000) b │
├────────────────────────────────────┴───────────┴───────────────┴──────────────────────────────────────────────────────────┤
│ 1 row 4 columns │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
hardcharger_server_info() is one row: the versions, the instance and cluster ids, the
start time and the uptime for everyone; the host (hostname, OS, CPU model and count,
memory), the listen addresses and ports, TLS and mutual TLS, the server certificate's
SHA-256 fingerprint, the web UI, the access-control store and the session count for an
administrator without catalog_access rules (NULL for everyone else):
$ hardcharger --command "SELECT hardcharger_version, CAST(uptime AS VARCHAR) AS uptime, cpu_model, cpu_count, memory_total_bytes, session_count FROM hardcharger_server_info()"
┌─────────────────────┬─────────────────┬──────────────┬───────────┬────────────────────┬───────────────┐
│ hardcharger_version │ uptime │ cpu_model │ cpu_count │ memory_total_bytes │ session_count │
│ varchar │ varchar │ varchar │ integer │ bigint │ integer │
├─────────────────────┼─────────────────┼──────────────┼───────────┼────────────────────┼───────────────┤
│ 0.3.0 │ 00:00:01.097479 │ Apple M5 Max │ 18 │ 68719476736 │ 2 │
├─────────────────────┴─────────────────┴──────────────┴───────────┴────────────────────┴───────────────┤
│ 1 row 6 columns │
└───────────────────────────────────────────────────────────────────────────────────────────────────────┘
Who sees and cancels what: an administrator without catalog_access rules, every session;
anyone else, a restricted administrator included, the sessions of their own principal (the
same user and tenant), and hardcharger_sessions() follows the same rule. Retained
(resumable) results are kept for result_retention_seconds (default 3600) after their
statement ends; the statement document's expires_at says until when.
max_ingest_message_bytes defaults to 67108864 (64 MiB). Each Arrow IPC message
must fit on the wire, including framing and metadata, and its aggregate expanded buffers
must fit before decompression. This applies to ordinary ingest, partitioned-ingest parts,
dictionaries and Arrow prepared parameters. Oversized input returns HTTP 413
payload_too_large before the requested allocation. Split larger uploads into smaller
record batches: the limit is per message, not per upload. Configure this positive startup
setting with YAML, --max-ingest-message-bytes or HARDCHARGER_MAX_INGEST_MESSAGE_BYTES;
it is visible in Server Configuration and takes effect after restarting the server.
A session is evicted within 5 s (the server's sweep interval) after session_idle_timeout
seconds (default 900; 0 never evicts) without a request on it, unless one of its statements
is still running, one of its ingests is in progress, one of its results is being read (a
slow download keeps it to the end of the stream), or it holds a retained result that hasn't
expired. That last one follows Chargewire §5.7, so that a result kept for re-reading stays
readable until its expires_at: a session whose statement ran in JSON mode (a retained
result) lives up to result_retention_seconds after the statement ended, then goes at the
next sweep. To bound a session's life by the idle timeout alone, set
result_retention_seconds no higher than session_idle_timeout, or have clients delete
statements (or the session) they are done with. An evicted session answers
session_not_found.
With instrumentation on (--enable-instrumentation), the server keeps its history in
tables: instances, sessions, sql_statements and sql_executions, and the views
session_activity, active_sessions, session_stats and execution_details, which only
an administrator can read. By default they live in a DuckDB file next to the database
(instrumentation_db_path), for development or a single server. Use PostgreSQL in
production, and for any cluster of more than one replica: attach it in the init SQL and
name it in instrumentation_catalog (instrumentation_schema picks its schema). Any other
catalog, DuckLake among them, is refused at startup.
init_sql_commands: "INSTALL postgres; LOAD postgres; ATTACH 'host=pg.internal dbname=ops user=hardcharger' AS ops (TYPE postgres);"
enable_instrumentation: true
instrumentation_catalog: ops
instrumentation_schema: hardcharger
Recording never holds up a statement. Records are queued and written by one background
writer in batches, a transaction each, so the tables keep up with tens of thousands of
statements a second however large they grow. A statement appears in sql_executions as
queued or executing from the writer's next batch, usually within milliseconds, and one
that starts and ends within a batch appears once, finished; hardcharger_active_connections()
always shows what runs now. If the catalog falls behind, the queue holds up to 128 MiB of
records and then drops new ones: hardcharger_server_info() counts them
(instrumentation_queued, instrumentation_dropped, instrumentation_failed), as does
/metrics (hardcharger_instrumentation_*), and the log says so at most once a minute. At
shutdown the server writes the backlog for up to 30 seconds, then closes its sessions and
executions in the tables.
Point rbac_store_uri at a PostgreSQL database, the one every replica of a cluster
shares, and manage access in SQL:
CREATE ROLE analyst;
GRANT SELECT ON SCHEMA analytics TO analyst; -- every table, now and later
GRANT SELECT (email, signup_ts) ON crm.users TO analyst; -- two columns only
CREATE USER joe WITH PASSWORD '…'; -- signs in with it
GRANT analyst TO joe;
CREATE VIEW published.daily AS SELECT … FROM raw.events; -- read with its owner's rights
GRANT SELECT ON published.daily TO analyst;
REVOKE analyst FROM joe; -- reaches joe's open sessions too
Each statement is checked inside DuckDB against its own plan, after binding and again before optimizing, so nested SQL, views and macros can't route around a grant. Anything the check can't classify is refused. Changes reach every replica within about a second.
The init SQL (init_sql_commands, init_sql_commands_file) can hold these statements too.
They run as the administrator, in order with the DuckDB statements around them, so a grant
can name a table the init SQL has just created:
CREATE TABLE IF NOT EXISTS sales (amount DECIMAL(12, 2));
CREATE ROLE IF NOT EXISTS readers;
GRANT SELECT ON sales TO readers;
CREATE USER IF NOT EXISTS analyst WITH PASSWORD '…';
GRANT readers TO analyst;
The init SQL runs at every start, and on every replica of a cluster, against a store that
keeps what earlier starts created: write IF NOT EXISTS, or the second start stops at
already exists. CREATE USER IF NOT EXISTS leaves an existing user's password as it is
(ALTER USER … WITH PASSWORD changes it). A failing statement stops the server like any init
SQL error, and its log line leaves out the statement's passwords. Keep
passwords in init_sql_commands_file rather than an environment variable: the server keeps
that file out of SQL's reach (see below). Without rbac_store_uri, an access-control
statement in the init SQL stops the server before anything runs (access control is not enabled on this server).
Use PostgreSQL in production, and for any cluster of more than one replica. For development, or a server that will only ever run one replica, the store can be a DuckDB file instead:
rbac_store_uri: duckdb:/var/lib/hardcharger/rbac.duckdb # or any path ending in .duckdb
The file is created 0600 (one that other users can read or write is refused), only one
server may open it (a second one on the same file stops at startup), and no SQL statement
reaches it, the administrator's included. Each change is checkpointed into the file at
once, so nothing depends on its write-ahead log. There is no migration between the two
stores: moving to PostgreSQL later means creating the users, roles and grants there again.
The guard above sits in DuckDB's file system, and an extension with file I/O of its own
(sqlite_scanner) does not go through it: ATTACH '<path>' (TYPE sqlite), the sqlite:
path prefix, and the sqlite_scan / sqlite_query / sqlite_attach table functions open
the file with SQLite's own library. So those are refused separately, when the resolved path
names one of the server's files or a sibling (the store, the database, the instrumentation
and log files, and their WAL and temporary siblings): at plan time, after the path has been
worked out, by every spelling — a computed path, .., a relative path, a symlink, a hard
link, a differing case, a Windows 8.3 name. Attaching an unrelated SQLite file still works.
A DuckDB-storage ATTACH of one of these files is left to the file-system guard, which
already covers it (and is how the server attaches its own). This closes the one way SQL
could reach the server's files outside DuckDB's file system; the sqlite_scanner load still
takes the administrator or a role granted MANAGE EXTENSIONS, which belong only to roles you
would trust with the server's files.
Access control never lets the store hold up the server, and never fails open on anything it has to write:
rbac_store_timeout_seconds (default 10). A
store that doesn't answer in time fails the statement that needed it with access control store unavailable: timed out after N s, and what that statement was doing is rolled back
as soon as the store answers again, so a CREATE USER the client was told failed never
appears later. (A timeout while committing says the change may have been applied.)
Statements that need the store at the same time (many sessions creating tables, as a dbt
run does) take turns in the order they asked, and one waiting its turn fails only when a
store statement has been outstanding for the timeout, never because the line ahead of it
is long. For a
PostgreSQL store the server also adds libpq's connect_timeout, TCP keepalives,
tcp_user_timeout and a statement_timeout to the connection string, each unless the
string sets it already.CREATE TABLE or CREATE VIEW records its owner in the store before it runs: when that can't
be done, the statement fails and creates nothing, so no object is ever left without an
owner.hardcharger_rbac_store_up and hardcharger_rbac_snapshot_age_seconds. A revoke made
on another replica during the outage doesn't reach this one until the store answers.
Set rbac_max_snapshot_age_seconds to bound that: once the store has not answered for
longer, every principal but the administrator is refused (fail closed) until it does.
The default, 0, keeps serving; pick a limit when a stale grant is worse than an outage.Grants name catalogs, so a store two clusters shared by mistake would apply one cluster's
grants to the other's catalogs of the same names. The store records the cluster_id of
the first server that opens it, and a server with another cluster_id refuses to start
(the access control store belongs to cluster ...). Replicas and restarts of the same
cluster share it as before. To share users, roles and grants between clusters on purpose,
set rbac_store_shared: true on the clusters that join. A server with no cluster_id
opens any store, with a warning when the store belongs to a cluster. The store also
records its schema version, and a server refuses a store a later version has upgraded.
Under access control, a sign-in token (token_exchange, browser sign-in, or the token sent as
user token's password) signs in a user of the store, the administrator role aside: an
administrator creates them first (CREATE USER "[email protected]", no password needed), or a
first sign-in is refused with user … is not defined on this server. An administrator creates it with CREATE USER. With token_auto_provision_users: true, the first sign-in creates the user
instead:
token_allowed_issuer: https://login.example.com/oauth2/default
token_allowed_audience: hardcharger
token_auto_provision_users: true
# token_auto_provision_issuer: defaults to token_allowed_issuer
token_auto_provision_issuer (default token_allowed_issuer) that
token_authorized_emails admits provisions, and only for the roles user and readonly; an
admin token signs in as an administrator, as before, and creates nothing.email, else sub), has no password and
no grants (only PUBLIC's), and gets the token's role in its provisioned_role attribute.
Grant it roles (GRANT analyst TO USER "[email protected]"), or map its identity-provider
groups to roles (CREATE GROUP MAPPING).readonly token signs in readonly with every token from then on,
whatever role later tokens carry, until an administrator lifts it with ALTER USER "[email protected]" UNSET ATTRIBUTE provisioned_role. A readonly session runs no access-control
statement but SHOW … and SET ROLE, so it can't mint itself a scoped token (which signs in
as a user).acme/pat) in a tenant that doesn't
exist is refused.provisioned a user on its first sign-in, with the user, the issuer and the role) in the access-control audit chain.Startup refuses token_auto_provision_users without rbac_store_uri, without a way to verify
tokens (token_signature_verify_cert_path, token_jwks_uri, or token_allowed_issuer with
token_allowed_audience), or with a token_auto_provision_issuer other than
token_allowed_issuer (whose tokens alone sign in, so it would provision no one). With no
token_allowed_issuer, tokens of any issuer the key verifies sign in, and only
token_auto_provision_issuer's provision.
A sign-in token whose role is readonly gives a session that reads and changes nothing but
itself, with or without an access-control store. It may run queries (SELECT, EXPLAIN,
SHOW, DESCRIBE, SUMMARIZE, PIVOT, the PRAGMAs that read such as table_info and
database_list) and the statements that change only its own session: transactions
(BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT), SET VARIABLE and
RESET VARIABLE, USE, and SET / RESET of a DuckDB setting kept for the session
(SET SESSION default_order = 'desc', SET TimeZone = '…', SET search_path = '…').
Everything else is refused with User '<name>' has a readonly session and cannot run statements that modify state.: INSERT, UPDATE, DELETE, MERGE, bulk ingest, CREATE
(temporary tables, views and macros too), ALTER, DROP, COPY either way, EXPORT,
IMPORT, CHECKPOINT, VACUUM, ATTACH, DETACH, INSTALL, LOAD, CALL, SQL-level
PREPARE / EXECUTE, SET GLOBAL, a plain SET of a setting whose default scope is global
(SET default_order), and settings that reach the server (threads, memory_limit,
enable_profiling, profiling_output, the extension and secret settings, ...). DuckDB's own
analysis of each statement decides what writes, so a write hidden in a query
(WITH c AS (INSERT … RETURNING *) SELECT * FROM c, EXPLAIN ANALYZE of one), in a
transaction, in a batch or in a prepared statement is refused too. Under access control a
readonly token adds this to the user's grants: it never widens them.
People can sign in with your OpenID Connect identity provider in a browser: Chargewire's
oauth_browser flow, on the server's own port and TLS. The hardcharger client
(--auth-type external), the drivers and the web UI all use it. Hardcharger is the
provider's confidential client: the client id and secret stay on the server, and clients get
Hardcharger's own access token, never the provider's tokens.
token_allowed_issuer: https://login.example.com/oauth2/default # found through its OpenID configuration
token_allowed_audience: hardcharger # the ID tokens' audience (the client id)
token_authorized_emails: ["*@example.com"]
token_default_role: user
oauth_client_id: hardcharger
# oauth_client_secret: from HARDCHARGER_OAUTH_CLIENT_SECRET, not the file
oauth_base_url: https://db.example.com # this server's public URL
oauth_provider_name: Okta # "Sign in with Okta"
Register <oauth_base_url>/chargewire/v1/auth/oauth/callback with the identity provider as the
client's redirect URI (here https://db.example.com/chargewire/v1/auth/oauth/callback; the server logs
the one it uses at startup). oauth_redirect_uri overrides it. Earlier builds had a separate
sign-in port (31339, /oauth/callback): that port and its options are gone, so a redirect URI
registered for it must be replaced.
| Option | Default | Meaning |
|---|---|---|
oauth_client_id | none | Set: browser sign-in is offered (needs token_allowed_issuer and token_allowed_audience) |
oauth_client_secret | none | The client secret (prefer HARDCHARGER_OAUTH_CLIENT_SECRET) |
oauth_scopes | openid profile email | |
oauth_base_url | http(s)://localhost:PORT | The server's public URL, for the redirect URI; set, the web UI's return after sign-in is an absolute URL on it |
oauth_redirect_uri | <oauth_base_url>/chargewire/v1/auth/oauth/callback | The redirect URI, given whole |
oauth_provider_name | none | The provider's name for people; without it the UI says "single sign-on" |
oauth_instance_id | none | Starts every state with <id>., for a proxy on another origin that receives the provider's redirects for several servers and forwards each to its server's callback (set oauth_redirect_uri to the proxy and oauth_base_url to the server) |
oauth_max_flows | 10000 | Sign-ins in progress at once (also at most 100 per client address); beyond, 429 |
The server sends the provider a code request with state, PKCE (S256) and a nonce, exchanges
the code itself, and signs the user in with the ID token exactly as token_exchange would:
the provider's JWKS, issuer, audience, token_authorized_emails, roles and, under access
control, the store's users (created on first sign-in with token_auto_provision_users). A sign-in is collected once by the client that started it, with a
secret that is never in a URL; sign-ins expire after 10 minutes. hardcharger_server_info()
has oauth_enabled.
SHOW GRANTS answers for one user or role. To query, join and filter grants, use the
table functions below. They read the copy of the store the server already holds, and each
one returns only the rows the caller may see:
| Function | Rows |
|---|---|
hardcharger_grants() | Object, column and system privileges, plus ownerships: grantee, privilege, object, grantor, grant option, tenant |
hardcharger_role_members() | Who holds each role: users, roles, and identity-provider groups (CREATE GROUP MAPPING) |
hardcharger_roles(), hardcharger_users() | Names and attributes that aren't secret (never a password hash or a token) |
hardcharger_policies() | Row policies and column masks, with their expressions and whether they apply to you |
hardcharger_effective_privileges([user_name]) | What you hold after role inheritance, ownership, SET ROLE and a scoped token's limits, and the role each privilege comes from |
-- joe: what can I do, and which role gives it to me?
SELECT privilege, object_type, schema_name, object_name, column_name, via
FROM hardcharger_effective_privileges();
privilege | object_type | schema_name | object_name | column_name | via
SELECT | TABLE | crm | users | email | analyst
SELECT | TABLE | crm | users | signup_ts | analyst
SELECT | VIEW | published | daily | NULL | analyst
SELECT | SCHEMA | analytics | NULL | NULL | analyst
With a second role as well (CREATE ROLE finance; GRANT SELECT ON crm.users TO finance; GRANT finance TO ann;):
-- The administrator: who may read crm.users?
SELECT grantee, privilege, column_name FROM hardcharger_grants()
WHERE schema_name = 'crm' AND object_name = 'users';
grantee | privilege | column_name
finance | SELECT | NULL
analyst | SELECT | email
analyst | SELECT | signup_ts
The same query as joe returns only the two analyst rows. The administrator sees
everything, and hardcharger_effective_privileges('ann') answers for any user. A user sees
grants to the roles it holds (directly, inherited, or through its sign-in token's groups),
the memberships that give it those roles, those roles, its own user row, and policies on
tables it can see. It never sees another user's grants, whatever it filters or counts. A
tenant admin (holding its tenant's tenant_admin role) also sees everything of its own tenant:
its users, roles, memberships, grants and policies, and its users' effective privileges, and
nothing of another tenant's. An
administrator token with catalog_access rules sees grants, ownerships and policies only
on objects in the catalogs its rules reach, and no users or memberships. On a server without
an access-control store, every function returns no rows.
DuckDB's information_schema can't be extended, so the SQL standard's privilege views live
in the server's read-only system catalog, under _hardcharger_system.hardcharger_information_schema:
table_privileges, column_privileges, role_table_grants, applicable_roles and
enabled_roles. They keep the standard's column names (grantor, grantee,
table_catalog, table_schema, table_name, privilege_type, is_grantable, …), and a
schema or catalog grant appears once for each table and view in it. A tool that reads
information_schema.table_privileges reads the same columns under this qualified name.
They follow the same visibility rules:
SELECT grantee, table_schema, table_name, privilege_type
FROM _hardcharger_system.hardcharger_information_schema.table_privileges ORDER BY ALL;
grantee | table_schema | table_name | privilege_type
analyst | analytics | orders | SELECT
analyst | published | daily | SELECT
With mutual TLS (mtls_ca_cert), a client can sign in with its certificate and no
password: Chargewire's client_certificate grant, --auth-type mtls in the hardcharger
client. The certificate proves who the user is; grants decide what they may do.
tls_cert: /etc/hardcharger/server.pem
tls_key: /etc/hardcharger/server.key
mtls_ca_cert: /etc/hardcharger/client-ca.pem # client certificates are verified against it
mtls_certificate_sign_in: true # off by default: certificates only admit connections
mtls_identity_source: san_email # or san_uri (SPIFFE IDs), subject_cn
mtls_admin_identities: [[email protected]] # sign in as the administrator
mtls_crl_file: /etc/hardcharger/client-ca.crl # revocation, reloaded when it changes
| Option | Default | Meaning |
|---|---|---|
mtls_certificate_sign_in | false | Offer certificate sign-in at all. Off, mtls_ca_cert only admits connections, and the grant is answered unsupported_grant_type |
mtls_require_client_cert | true | Refuse connections without a certificate at the handshake; false lets them connect and sign in another way |
mtls_identity_source | san_email | Which part of the certificate is the identity. A certificate with none, or several, values of that kind is refused |
mtls_admin_identities | none | Identities that sign in as the administrator. Under access control, any other identity must be a user of the store (CREATE USER "[email protected]", a password optional); without it, no other identity signs in |
mtls_crl_file | none | PEM CRLs, one for every CA of a client's chain (the root's too): every certificate of the chain is checked, so a revoked intermediate revokes what it issued, and a CA without a CRL is refused. A revoked certificate is refused at the handshake. The file is looked at every 5 seconds and reloaded when it changes, and open connections are checked again then and when a CRL passes its nextUpdate (an expired CRL refuses everything) |
mtls_crl_allow_missing_ca_crls | false | Accept a CA above the leaf that has no CRL in the file |
mtls_require_identity_match | false | A password or token sign-in over a client certificate must be for the certificate's identity |
A browser page on another origin (one of cors_origins) can sign in with the user's
certificate too: allowed origins get Access-Control-Allow-Credentials: true, and the page
calls with credentials: "include" (@gizmodata/chargewire does with authType: "client_certificate"). The browser's CORS preflight carries no credentials and may come
without a certificate, so for such pages set mtls_require_client_cert: false: the
handshake then admits the preflight, and the sign-in still needs the certificate. Pages on
the server's own origin (the embedded UI) send no preflight and need neither.
Tokens from a certificate sign-in are bound to it (RFC 8705's cnf claim): they work only on
connections presenting the same certificate, so a copied token is no use without the private
key. A session opened with such a token, and any scoped token minted from it, stay bound to the
same certificate (a session takes only tokens bound as its opener's was, and a bound session
can't mint scoped tokens FOR USER someone else). Identities are printable ASCII. Every refusal
(a bound token on the wrong connection, another identity's sign-in, a revoked certificate, an
unknown identity) is a security warning in the log and an audit record, with the user, the
token's id, the certificate presented and the client's address. They are rate-limited per source (ten a minute
in full, then one summary that counts the rest), a connection is closed after five failed
sign-ins, and certificates the TLS handshake refuses (revoked, expired, untrusted) are
recorded too. Identities match user names as user names compare (ignoring ASCII case), except URI identities such as
SPIFFE IDs, whose paths are case-sensitive: they must match exactly, case included. Under access
control a SPIFFE ID is a user name like any other:
CREATE USER "spiffe://example.org/ns/etl/sa/loader" (always system scope, never a tenant's).
Of several CRLs from one CA the newest is used; delta CRLs are refused. TLS has to end at Hardcharger (a TCP or TLS-passthrough load balancer) for this; a
proxy that ends TLS takes the certificate with it. Sign-ins with a certificate are written to
the audit log (kind: authentication, method: client_certificate, the identity and the
certificate's thumbprint).
flowchart LR
subgraph Clients
A[ADBC: Go, Python, C, R, Rust]
J[JDBC: Java, BI tools]
H[Any HTTP client]
end
subgraph Hardcharger
W[Chargewire SDK<br/>HTTP/1.1 + TLS, sessions,<br/>Arrow IPC streaming]
S[Security<br/>sign-in, RBAC, audit]
D[(DuckDB 2.0)]
end
A & J & H -- "Chargewire: HTTP + Arrow IPC" --> W
W --> S --> D
S -. users, roles, grants .-> P[(PostgreSQL)]
D -. instrumentation, logs .-> L[(DuckDB / PostgreSQL)]
git clone --recurse-submodules https://github.com/gizmodata/hardcharger.git
cd hardcharger/server
./scripts/build-deps.sh # DuckDB 2.0, Arrow C++ and replxx, pinned
OPENSSL_ROOT_DIR=$(brew --prefix openssl@3) cmake -S . -B build -G Ninja
cmake --build build
To embed the web UI, build it first (Node 24 or later) and point the server's build at the tarball it makes:
(cd ../ui && npm ci && npm run build && npm run package) # ui/release/hardcharger-ui-<version>.tar.gz
cmake -S . -B build -G Ninja -DHARDCHARGER_WEB_UI="$(ls "$PWD"/../ui/release/hardcharger-ui-*.tar.gz)"
cmake --build build
CMake checks every file against the tarball's MANIFEST, gzips each one into the binary and
logs the build's content SHA-256. ui/README.md covers the UI's own checks and
its browser tests.
docker compose -f server/tests/docker-compose.yml up -d --wait # PostgreSQL + S3 for the DuckLake suites
docker run -d --name hardcharger-test-pg -e POSTGRES_USER=hardcharger \
-e POSTGRES_PASSWORD=hardcharger -e POSTGRES_DB=hardcharger \
-p 127.0.0.1:55433:5432 postgres:17-alpine # the access-control store
cd server/tests
uv venv .venv && uv pip install --python .venv/bin/python -r pyproject.toml
.venv/bin/python -m pytest -q # -m "not services" without Docker
# -m "not web_ui" on a build without the UI
The Chargewire conformance suite runs against the same build:
cd third_party/chargewire/conformance
uv venv .venv && uv pip install --python .venv/bin/python -e .
CHARGEWIRE_TEST_SERVER_BINARY=../../../server/build/hardcharger-server \
CHARGEWIRE_TEST_SERVER_PASSWORD_ENV=HARDCHARGER_ADMIN_PASSWORD \
CHARGEWIRE_TEST_SERVER_USERNAME=chargewire HARDCHARGER_ADMIN_USERNAME=chargewire \
CHARGEWIRE_TEST_SERVER_MTLS_FLAG=--mtls-ca-cert CHARGEWIRE_TEST_SERVER_CORS_FLAG=--cors-origins \
CHARGEWIRE_TEST_SERVER_IDLE_FLAG=--session-idle-timeout \
CHARGEWIRE_TEST_SERVER_DRAIN_FLAG=--shutdown-grace-period-seconds \
.venv/bin/python -m pytest -q
Its access-control tests (test_authorization.py) need the CHARGEWIRE_TEST_AUTHZ_* variables
that .github/workflows/server.yml sets (a DuckDB access-control store, privileges granted to a
role); without them they skip, saying so.
The access-control suites run on PostgreSQL by default; HARDCHARGER_TEST_RBAC_STORE=duckdb .venv/bin/python -m pytest -q -m rbac_store runs them again on the DuckDB file store, as CI does.
The web UI's lint, typecheck, unit and browser tests (Chromium and WebKit, against a server build that embeds it) are in ui/README.md.
The client's 1.x shell script runs on its own too (bash server/tests/test_client_shell.sh).
Suites that need PostgreSQL or the S3 gateway fail, never skip, when those aren't there.
BATTERY.md tracks GizmoSQL's test battery as ported, file by file.
Hardcharger takes only its own names: HARDCHARGER_* variables, hardcharger_* SQL
functions, _hardcharger_* catalogs. docs/migrating-from-gizmosql.md
maps every GizmoSQL name to its Hardcharger name.
Apache License 2.0; see LICENSE.
The binaries contain third-party open-source software, each part under its own licence:
THIRD_PARTY_NOTICES.md lists every statically linked library (DuckDB
and what it vendors, its built-in extensions, Arrow, Boost, OpenSSL and the rest) with its version
and full licence text. The embedded web UI's JavaScript packages are listed in
THIRD_PARTY_NOTICES-web-ui.txt. Every release archive, the MSI and the container image
(/usr/share/doc/hardcharger/) carry both files, hardcharger-server --licenses and
hardcharger --licenses print them, and the web UI links its own from About (Open-source
licences). The tpch extension's data generator (built in on iOS) is under the TPC's End User
License Agreement, not an open-source licence.
/chargewire/v1/openapi.json), in Swagger UI. Try it out sends requests to this server only,
signed in as you: the page adds your sign-in to each request as it leaves, so there is no
token to paste, and it never goes to another origin or into storage. Arrow results come back
as a file to download./ui at all, and with --mtls-certificate-sign-in the page offers Sign in with my
certificate, no password needed.CALL quack_serve(...)