dragoman 0.1.3

PID redirection and content negotiation server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# dragoman

A web server for scholarly PID (Persistent Identifier) resolution with full [DOI content negotiation](https://citation.doi.org/docs.html). Send a DOI as the URL path; receive a redirect to the landing page or metadata in any supported format depending on the `Accept` header.

## Installation

### Prerequisites

- Rust 1.75+ ([rustup.rs]https://rustup.rs)

### Install

```bash
cargo install dragoman
```

This builds a release binary and installs it to `~/.cargo/bin/dragoman`. Make sure `~/.cargo/bin` is on your `PATH` (the Rust installer adds this automatically).

## Local SQLite database

dragoman can serve metadata directly from a local SQLite database in the [commonmeta](https://commonmeta.org) format, bypassing the live Crossref/DataCite APIs. This dramatically reduces latency and API load for high-traffic deployments.

### Database format

The database is a SQLite3 file with a single `works` table whose columns map one-to-one to the commonmeta v1.0 schema. The `id` column is the canonical DOI URL (e.g. `https://doi.org/10.5281/zenodo.1234`). Complex fields (contributors, references, …) are stored as JSON text.

You can build a database from any commonmeta-supported source using the [commonmeta](https://github.com/front-matter/commonmeta-rs) CLI.

## Running the server

### Start

```bash
# Default port 3456
dragoman start

# Custom port
dragoman start --port 8080

# With a local database
dragoman start --db /data/commonmeta-2026-06-15.sqlite3

# Write a PID file so the process can be stopped later
dragoman start --pid-file /tmp/dragoman.pid

# All options together
dragoman start --port 8080 --db /data/commonmeta-2026-06-15.sqlite3 --pid-file /tmp/dragoman.pid
```

Options can also be supplied as environment variables (flags take precedence):

```bash
PORT=8080 DRAGOMAN_DB=/data/commonmeta-2026-06-15.sqlite3 RUST_LOG=dragoman=debug dragoman start
```

During development you can use `cargo run` in place of the installed binary:

```bash
# Run from the project root — the sqlite3 file in the root is loaded by filename
cargo run -- start --db commonmeta-2026-06-15.sqlite3

# Or with a full path
cargo run -- start --db /data/commonmeta-2026-06-15.sqlite3
```

### Error: port already in use

If the chosen port is already in use, the server logs an error and exits:

```text
ERROR dragoman: failed to bind  port=3456  error=Address already in use (os error 48)
```

Choose a different port with `--port` or stop the process that holds the port.

### Error: database file not found

If `--db` points to a path that does not exist, the server exits at startup before accepting any traffic:

```text
ERROR dragoman: failed to open database  path=…  error=sqlite file not found: '…'
```

Pass the correct path or an absolute path to avoid working-directory ambiguity.

### Stop

```bash
# Stop using the default PID file location (/tmp/dragoman.pid)
dragoman stop

# Stop using a custom PID file
dragoman stop --pid-file /var/run/dragoman.pid
```

`dragoman stop` sends `SIGTERM` to the running process. The server handles the signal gracefully: it finishes in-flight requests and removes the PID file before exiting. Pressing `Ctrl-C` has the same effect.

## CLI reference

```text
dragoman <COMMAND>

Commands:
  start  Start the server (runs in the foreground)
  stop   Stop a running server by sending SIGTERM to its PID file
  help   Print help

dragoman start [OPTIONS]
  -p, --port <PORT>          TCP port to listen on [env: PORT] [default: 3456]
  -d, --db <PATH>            Local commonmeta SQLite3 database [env: DRAGOMAN_DB]
      --pid-file <PATH>      Write PID to this file on startup [env: DRAGOMAN_PID_FILE]

dragoman stop [OPTIONS]
      --pid-file <PATH>      PID file to read [env: DRAGOMAN_PID_FILE] [default: /tmp/dragoman.pid]
```

## Environment variables

| Variable | Default | Description |
| --- | --- | --- |
| `PORT` | `3456` | TCP port to listen on. |
| `DRAGOMAN_DB` | *(none)* | Path to a local commonmeta SQLite3 database. Metadata is served from the database before falling back to the live API. The server exits on startup if the path is set but the file cannot be opened. |
| `DRAGOMAN_PID_FILE` | *(none)* | Path for the PID file written by `start` and read by `stop`. |
| `RUST_LOG` | `dragoman=info` | Log filter (see [`tracing-subscriber`]https://docs.rs/tracing-subscriber). Use `dragoman=debug` to log per-request cache hits. |

## Usage

### Redirect (HTML / browser)

When the `Accept` header prefers `text/html` or is absent, dragoman redirects to the DOI's landing page:

```bash
# Follow the redirect
curl -L http://localhost:3456/10.5281/zenodo.1089100

# Inspect the redirect target without following
curl -s -o /dev/null -w "%{redirect_url}" http://localhost:3456/10.5281/zenodo.1089100
# https://zenodo.org/record/1089100
```

### Content negotiation

Send an `Accept` header to receive metadata instead of a redirect.

#### BibTeX

```bash
curl -H "Accept: application/x-bibtex" \
     http://localhost:3456/10.5281/zenodo.1089100
```

#### CSL-JSON

```bash
curl -H "Accept: application/vnd.citationstyles.csl+json" \
     http://localhost:3456/10.5281/zenodo.1089100
```

#### DataCite JSON

```bash
curl -H "Accept: application/vnd.datacite.datacite+json" \
     http://localhost:3456/10.5281/zenodo.1089100
```

#### RIS

```bash
curl -H "Accept: application/x-research-info-systems" \
     http://localhost:3456/10.5281/zenodo.1089100
```

#### Crossref XML

```bash
curl -H "Accept: application/vnd.crossref.unixref+xml" \
     http://localhost:3456/10.1016/j.jaci.2019.09.015
```

#### Schema.org JSON-LD

```bash
curl -H "Accept: application/vnd.schemaorg.ld+json" \
     http://localhost:3456/10.5281/zenodo.1089100
```

#### Formatted citation

`text/x-bibliography` accepts optional `style=` and `locale=` parameters. Style names come from the [CSL style repository](https://github.com/citation-style-language/styles); locale codes from the [CSL locales repository](https://github.com/citation-style-language/locales).

```bash
# APA (default)
curl -H "Accept: text/x-bibliography; style=apa" \
     http://localhost:3456/10.5281/zenodo.1089100

# Vancouver in French
curl -H "Accept: text/x-bibliography; style=vancouver; locale=fr-FR" \
     http://localhost:3456/10.5281/zenodo.1089100
```

### Query parameter overrides

Use `?format=` instead of an `Accept` header:

```bash
curl "http://localhost:3456/10.5281/zenodo.1089100?format=bibtex"
curl "http://localhost:3456/10.5281/zenodo.1089100?format=citation&style=apa&locale=de-DE"
```

Force a specific registration agency (useful for testing):

```bash
curl -H "Accept: application/x-bibtex" \
     "http://localhost:3456/10.5281/zenodo.1089100?source=datacite"
```

## Supported formats

| Accept header | `?format=` value | Notes |
| --- | --- | --- |
| `application/x-bibtex` | `bibtex` | |
| `text/x-bibliography` | `citation` | `style=` and `locale=` params |
| `application/vnd.commonmeta+json` | `commonmeta` | |
| `application/vnd.crossref.unixref+xml` | `crossref_xml` | |
| `application/vnd.crossref.unixsd+xml` | `crossref_xml` | alias |
| `application/vnd.citationstyles.csl+json` | `csl` | |
| `application/vnd.datacite.datacite+json` | `datacite` | |
| `application/vnd.datacite.datacite+xml` | `datacite_xml` | |
| `application/vnd.inveniordm.v1+json` | `inveniordm` | |
| `application/x-research-info-systems` | `ris` | |
| `application/vnd.schemaorg.ld+json` | `schemaorg` | |
| `text/html` / *(absent)* || 307 redirect to landing page |

## HTTP status codes

| Code | Meaning |
| --- | --- |
| 200 | Metadata returned |
| 307 | Redirect to landing page |
| 404 | DOI not found |
| 406 | Requested content type not supported |
| 502 | Upstream API error |

## Deployment (macOS)

### Installation via Homebrew

dragoman can be installed from the [front-matter Homebrew tap](https://github.com/front-matter/homebrew-tap):

```bash
brew tap front-matter/tap
brew install dragoman
```

This builds dragoman from source (requires Rust, installed automatically as a build dependency) and places the binary at `$(brew --prefix)/bin/dragoman`.

#### Recommended SQLite path

```text
$(brew --prefix)/var/dragoman/commonmeta.sqlite3
```

Which resolves to:

- `/opt/homebrew/var/dragoman/commonmeta.sqlite3` — Apple Silicon
- `/usr/local/var/dragoman/commonmeta.sqlite3` — Intel

#### Place the database

```bash
mkdir -p "$(brew --prefix)/var/dragoman"
cp commonmeta.sqlite3 "$(brew --prefix)/var/dragoman/commonmeta.sqlite3"
```

#### Run as a background service (launchd)

```bash
# Start at login and keep alive
brew services start dragoman

# Check status
brew services info dragoman

# View logs
tail -f "$(brew --prefix)/var/log/dragoman.log"

# Stop the service
brew services stop dragoman
```

`brew services start` installs a launchd plist in `~/Library/LaunchAgents/` and starts the service immediately. It restarts automatically on crash and at login.

To run as a system-level daemon (starts at boot, not tied to a user login), use `sudo brew services start dragoman`. This installs the plist in `/Library/LaunchDaemons/` instead.

#### Configuration

To change the port or other settings, edit the service environment variables and restart:

```bash
# Open the generated plist for editing
open "$(brew --prefix)/opt/dragoman/homebrew.mxcl.dragoman.plist"
brew services restart dragoman
```

### Manual installation (without Homebrew)

```bash
# Install Rust if not already installed
curl https://sh.rustup.rs -sSf | sh

cargo install dragoman
sudo cp ~/.cargo/bin/dragoman /opt/homebrew/bin/dragoman
```

> **Intel Macs:** replace `/opt/homebrew` with `/usr/local` in all paths below.

#### Run as a launchd daemon

The bundled `com.front-matter.dragoman.plist` targets Apple Silicon paths.

```bash
sudo mkdir -p /opt/homebrew/var/dragoman /opt/homebrew/var/log
sudo cp commonmeta.sqlite3 /opt/homebrew/var/dragoman/commonmeta.sqlite3

sudo cp com.front-matter.dragoman.plist /Library/LaunchDaemons/
sudo launchctl load -w /Library/LaunchDaemons/com.front-matter.dragoman.plist
```

Check logs:

```bash
tail -f /opt/homebrew/var/log/dragoman.log
```

Stop and disable:

```bash
sudo launchctl unload -w /Library/LaunchDaemons/com.front-matter.dragoman.plist
```

### Updating

#### With Homebrew

```bash
brew upgrade dragoman
brew services restart dragoman
```

#### Manual

```bash
cargo install dragoman
sudo cp ~/.cargo/bin/dragoman /opt/homebrew/bin/dragoman
sudo launchctl kickstart -k system/com.front-matter.dragoman
```

---

## Deployment (Debian / systemd)

This section covers running dragoman as a persistent system service on a Debian 13 server.

### 1. Build the binary

On the server, install Rust and install the binary:

```bash
curl https://sh.rustup.rs -sSf | sh
source ~/.cargo/env
cargo install dragoman
sudo cp ~/.cargo/bin/dragoman /usr/local/bin/dragoman
```

Or cross-compile locally and copy the binary:

```bash
# macOS → Linux x86-64 (requires cross)
cargo install cross
cross build --release --target x86_64-unknown-linux-gnu
scp target/x86_64-unknown-linux-gnu/release/dragoman user@server:/usr/local/bin/dragoman
```

### 2. Create system user and directories

```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin dragoman
sudo mkdir -p /var/lib/dragoman /etc/dragoman
sudo chown dragoman:dragoman /var/lib/dragoman
```

### 3. Place the SQLite database

The recommended database path is `/var/lib/dragoman/commonmeta.sqlite3`:

```bash
sudo cp commonmeta.sqlite3 /var/lib/dragoman/commonmeta.sqlite3
sudo chown dragoman:dragoman /var/lib/dragoman/commonmeta.sqlite3
```

### 4. Create the environment file

```bash
sudo tee /etc/dragoman/env > /dev/null <<'EOF'
PORT=3456
DRAGOMAN_DB=/var/lib/dragoman/commonmeta.sqlite3
RUST_LOG=dragoman=info
EOF
sudo chmod 640 /etc/dragoman/env
sudo chown root:dragoman /etc/dragoman/env
```

### 5. Install and enable the systemd unit

```bash
sudo cp dragoman.service /etc/systemd/system/dragoman.service
sudo systemctl daemon-reload
sudo systemctl enable --now dragoman
```

Check the service is running:

```bash
sudo systemctl status dragoman
sudo journalctl -u dragoman -f
```

### Updating the binary

```bash
sudo systemctl stop dragoman
sudo cp target/release/dragoman /usr/local/bin/dragoman
sudo systemctl start dragoman
```

### Updating the database

The database file can be replaced while the service is running. dragoman opens
the SQLite file once at startup; to pick up a new file, restart the service:

```bash
sudo cp commonmeta-new.sqlite3 /var/lib/dragoman/commonmeta.sqlite3
sudo chown dragoman:dragoman /var/lib/dragoman/commonmeta.sqlite3
sudo systemctl restart dragoman
```

### Reverse proxy

#### Caddy (standalone)

[Caddy](https://caddyserver.com) is the recommended reverse proxy for standalone deployments. It handles TLS certificates automatically via Let's Encrypt.

```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy
```

Add a site block to `/etc/caddy/Caddyfile`:

```caddy
doi.example.com {
    reverse_proxy localhost:3456
}
```

Reload Caddy:

```bash
sudo systemctl reload caddy
```

#### Traefik via Coolify

If the server already runs [Coolify](https://coolify.io), its Traefik instance can route directly to the dragoman systemd service. Add a file-provider config to the Coolify dynamic configuration directory:

```bash
sudo tee /data/coolify/proxy/dynamic/dragoman.yml > /dev/null <<'EOF'
http:
  routers:
    dragoman:
      rule: "Host(`doi.example.com`)"
      service: dragoman
      entryPoints:
        - https
      tls:
        certResolver: letsencrypt
  services:
    dragoman:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:3456"
EOF
```

Traefik picks up the file automatically — no reload needed. `host.docker.internal` resolves to the host from inside the Traefik container; dragoman must listen on all interfaces (`0.0.0.0:3456`, which is the default).

## License

MIT