knowless 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,13 +7,39 @@ Versioning is [SemVer](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- - Standalone server (`bin/knowless-server`): env-var-driven CLI with
11
- `--print-config` and `--config-check`, forward-auth deployment shape
12
- for self-hosters gating no-auth services. (Tracked in TASKS.md
13
- Phase 6.)
14
- - `OPS.md`: full operator setup walkthrough (Postfix, null-route for
15
- sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
16
- Traefik). (Tracked in TASKS.md Phase 7.)
10
+ - Caddy forward-auth Docker integration test (TASKS.md 6.8).
11
+
12
+ ## [0.1.3] 2026-04-28
13
+
14
+ Standalone-deployment release. The library could already be embedded
15
+ in a Node service since v0.1.0; v0.1.3 closes the operator-side story
16
+ so a self-hoster can `npx knowless-server` and have a working
17
+ forward-auth gate in front of arbitrary services.
18
+
19
+ ### Added
20
+
21
+ - **Standalone server** — `bin/knowless-server` ships a self-contained
22
+ HTTP server for forward-auth deployments. Configuration is via
23
+ `KNOWLESS_*` env vars (PRD FR-49 to FR-56); CLI flags are inspection-
24
+ only:
25
+ - `--help` lists every env var with default and purpose
26
+ - `--version` prints the package version
27
+ - `--print-config` prints effective config with secrets redacted as
28
+ `<set>` / `<unset>`
29
+ - `--config-check` validates required vars are present, the secret
30
+ is ≥64 hex chars, the SMTP host is reachable, and the DB path is
31
+ writable. Suitable for systemd `ExecStartPre`.
32
+ - `config.example.env` — documented sample env file at repo root.
33
+ Operators copy this and load via `node --env-file=...` or systemd
34
+ `EnvironmentFile=`. Library does not auto-load it (FR-56).
35
+ - Startup log block (FR-54) with effective config, SMTP check result,
36
+ and listening address.
37
+ - **`OPS.md`** — full operator setup walkthrough: Postfix
38
+ outbound-only install, **required** null-route for sham mail,
39
+ SPF / DKIM / PTR / DMARC, port-25 verification, hardened systemd
40
+ unit, Caddy / nginx / Traefik forward-auth examples, Tailscale
41
+ pattern, reverse-proxy rate limiting, fail2ban / Turnstile
42
+ references, backup guidance.
17
43
 
18
44
  ## [0.1.2] — 2026-04-28
19
45
 
package/OPS.md ADDED
@@ -0,0 +1,573 @@
1
+ # OPS — knowless operator setup
2
+
3
+ This guide takes a fresh Ubuntu/Debian VPS to "knowless is delivering
4
+ magic-link mail to your users' inbox." Every step is a manual decision
5
+ point for the operator. There are no automation scripts; ops is your
6
+ job and we provide the checklist.
7
+
8
+ If any step here surprises you — outbound port 25, DKIM, PTR records,
9
+ the null-route requirement — stop and read PRD §11 first. knowless
10
+ trades operator effort for a small, auditable surface. If you'd rather
11
+ delegate email to a SaaS, knowless is the wrong tool.
12
+
13
+ ---
14
+
15
+ ## 1. Prerequisites
16
+
17
+ - Ubuntu 22.04 / 24.04 or Debian 12, on a host that can:
18
+ - bind a public DNS name (e.g. `auth.example.com`)
19
+ - send outbound TCP/25 (verify before going further — see §3)
20
+ - have a working PTR record for its public IPv4 (and IPv6 if used)
21
+ - A domain with control of its DNS records
22
+ - Node.js ≥ 20 installed
23
+ - A reverse proxy in front of HTTP (Caddy, nginx, or Traefik)
24
+
25
+ knowless does not handle TLS termination. Your reverse proxy does.
26
+
27
+ ---
28
+
29
+ ## 2. Install Postfix (outbound-only)
30
+
31
+ knowless submits mail to a localhost MTA over plain SMTP on port 25
32
+ without auth. The MTA does the actual delivery. Postfix is the
33
+ recommended choice; any MTA that accepts unauthenticated localhost
34
+ submission works.
35
+
36
+ ```sh
37
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y postfix mailutils
38
+ # When the installer asks: choose "Internet Site"
39
+ # System mail name: your sending domain (e.g. example.com)
40
+ ```
41
+
42
+ Minimal `/etc/postfix/main.cf` for outbound-only:
43
+
44
+ ```
45
+ myhostname = mail.example.com
46
+ mydomain = example.com
47
+ myorigin = $mydomain
48
+ inet_interfaces = loopback-only
49
+ inet_protocols = ipv4
50
+ mydestination =
51
+ relayhost =
52
+ smtp_tls_security_level = may
53
+ smtp_tls_loglevel = 1
54
+ ```
55
+
56
+ `inet_interfaces = loopback-only` means Postfix accepts submission
57
+ only from `127.0.0.1`. knowless connects there. Restart:
58
+
59
+ ```sh
60
+ sudo systemctl restart postfix
61
+ sudo systemctl enable postfix
62
+ ```
63
+
64
+ Verify the localhost submission works:
65
+
66
+ ```sh
67
+ echo "Subject: test" | sendmail -v you@somewhere-you-control.com
68
+ sudo tail /var/log/mail.log
69
+ ```
70
+
71
+ If you see `status=sent` you are done with §2.
72
+
73
+ ---
74
+
75
+ ## 3. Verify outbound port 25
76
+
77
+ Many cloud providers (AWS, GCP, Azure, Oracle, DigitalOcean droplets,
78
+ Hetzner cloud, …) block outbound TCP/25 by default to limit spam from
79
+ compromised instances. **Test before troubleshooting anything else.**
80
+
81
+ ```sh
82
+ nc -zv gmail-smtp-in.l.google.com 25
83
+ # Expected: succeeded / Connection to ... 25 port [tcp/smtp] succeeded
84
+ ```
85
+
86
+ If this hangs or fails:
87
+ - AWS: open a "Request to remove email sending limitations" ticket.
88
+ - GCP: port 25 is permanently blocked. Use a relay (see below) or
89
+ move to a provider that allows it.
90
+ - Hetzner: port 25 is blocked on new accounts; ask support.
91
+ - Most VPS hosts (Vultr, OVH, Linode, your own metal): open by default.
92
+
93
+ **Alternative if port 25 is permanently blocked:** configure Postfix
94
+ to relay through a transactional provider's submission endpoint
95
+ (587/465 with auth). knowless still talks to localhost; Postfix is
96
+ what relays out. That's a Postfix concern, not a knowless concern.
97
+
98
+ ---
99
+
100
+ ## 4. Null-route for sham mail (REQUIRED)
101
+
102
+ knowless's silent-miss design (PRD FR-2 to FR-6) submits a real-shaped
103
+ SMTP message on every login attempt — including ones where the email
104
+ doesn't map to any registered handle. The "sham" submissions are
105
+ addressed to `null@knowless.invalid` by default. Without a null-route,
106
+ Postfix will queue these forever trying to resolve a nonexistent
107
+ domain. **Configure the null-route. SPEC §7.4.**
108
+
109
+ ```sh
110
+ # /etc/postfix/transport
111
+ knowless.invalid discard:silently dropped by knowless null-route
112
+ ```
113
+
114
+ Then:
115
+
116
+ ```sh
117
+ sudo postmap /etc/postfix/transport
118
+ ```
119
+
120
+ Add to `/etc/postfix/main.cf`:
121
+
122
+ ```
123
+ transport_maps = hash:/etc/postfix/transport
124
+ ```
125
+
126
+ Reload:
127
+
128
+ ```sh
129
+ sudo systemctl reload postfix
130
+ ```
131
+
132
+ Verify discard works:
133
+
134
+ ```sh
135
+ echo "test" | mail -s "should be dropped" null@knowless.invalid
136
+ sudo tail /var/log/mail.log | grep knowless.invalid
137
+ # Expected: status=sent (silently dropped by knowless null-route)
138
+ ```
139
+
140
+ If you customized `shamRecipient` in your knowless config to point
141
+ elsewhere, change the transport entry's domain to match.
142
+
143
+ ---
144
+
145
+ ## 5. SPF, DKIM, PTR
146
+
147
+ Without these your magic-link mail goes to spam. There is no
148
+ shortcut here.
149
+
150
+ ### 5.1 SPF
151
+
152
+ Add a TXT record at the apex of your sending domain:
153
+
154
+ ```
155
+ example.com. TXT "v=spf1 mx a ~all"
156
+ ```
157
+
158
+ If your knowless server is *not* an MX for the domain, replace
159
+ `mx` with `ip4:1.2.3.4` listing the server's public IP.
160
+
161
+ ### 5.2 DKIM
162
+
163
+ Install OpenDKIM:
164
+
165
+ ```sh
166
+ sudo apt-get install -y opendkim opendkim-tools
167
+ sudo opendkim-genkey -D /etc/opendkim/keys -d example.com -s mail
168
+ sudo chown opendkim:opendkim /etc/opendkim/keys/mail.private
169
+ sudo chmod 600 /etc/opendkim/keys/mail.private
170
+ ```
171
+
172
+ `/etc/opendkim/keys/mail.txt` now contains the DNS record. Publish it
173
+ as `mail._domainkey.example.com` in your DNS.
174
+
175
+ Wire OpenDKIM into Postfix — add to `/etc/postfix/main.cf`:
176
+
177
+ ```
178
+ milter_default_action = accept
179
+ smtpd_milters = inet:localhost:8891
180
+ non_smtpd_milters = $smtpd_milters
181
+ ```
182
+
183
+ Configure `/etc/opendkim.conf`:
184
+
185
+ ```
186
+ Domain example.com
187
+ KeyFile /etc/opendkim/keys/mail.private
188
+ Selector mail
189
+ Socket inet:8891@localhost
190
+ ```
191
+
192
+ Restart both:
193
+
194
+ ```sh
195
+ sudo systemctl restart opendkim postfix
196
+ ```
197
+
198
+ Verify with `mail-tester.com`: send a test, expect 9–10/10.
199
+
200
+ ### 5.3 PTR (reverse DNS)
201
+
202
+ Your server's public IP must reverse-resolve to a hostname. **Most
203
+ providers expose this in their control panel** (Hetzner: "rDNS";
204
+ DigitalOcean: name your droplet `mail.example.com`; OVH: "Reverse
205
+ DNS" tab). Set the PTR to the same hostname you put in
206
+ `myhostname` (§2). Verify:
207
+
208
+ ```sh
209
+ dig -x 1.2.3.4 +short
210
+ # Expected: mail.example.com.
211
+ ```
212
+
213
+ A missing or generic PTR (`ec2-...`, `static.cloud-provider.tld`)
214
+ is the single most common reason mail lands in spam.
215
+
216
+ ### 5.4 Optional: DMARC
217
+
218
+ ```
219
+ _dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"
220
+ ```
221
+
222
+ Start with `p=none` to monitor. Move to `p=quarantine` later if you
223
+ want stricter handling.
224
+
225
+ ---
226
+
227
+ ## 6. Run knowless-server under systemd
228
+
229
+ ```sh
230
+ sudo useradd --system --home /var/lib/knowless --create-home knowless
231
+ sudo install -d -o knowless -g knowless -m 0750 /var/lib/knowless /etc/knowless
232
+ ```
233
+
234
+ Generate a secret and create `/etc/knowless/knowless.env`:
235
+
236
+ ```sh
237
+ sudo install -m 0600 -o knowless -g knowless /dev/null /etc/knowless/knowless.env
238
+ sudo tee /etc/knowless/knowless.env > /dev/null <<EOF
239
+ KNOWLESS_SECRET=$(openssl rand -hex 32)
240
+ KNOWLESS_BASE_URL=https://auth.example.com
241
+ KNOWLESS_FROM=auth@example.com
242
+ KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
243
+ KNOWLESS_COOKIE_DOMAIN=example.com
244
+ KNOWLESS_COOKIE_SECURE=true
245
+ KNOWLESS_HOST=127.0.0.1
246
+ KNOWLESS_PORT=8080
247
+ EOF
248
+ sudo chmod 0600 /etc/knowless/knowless.env
249
+ ```
250
+
251
+ `/etc/systemd/system/knowless.service`:
252
+
253
+ ```ini
254
+ [Unit]
255
+ Description=knowless passwordless auth server
256
+ Wants=network-online.target postfix.service
257
+ After=network-online.target postfix.service
258
+
259
+ [Service]
260
+ Type=simple
261
+ User=knowless
262
+ Group=knowless
263
+ EnvironmentFile=/etc/knowless/knowless.env
264
+ ExecStartPre=/usr/bin/npx --yes knowless-server --config-check
265
+ ExecStart=/usr/bin/npx --yes knowless-server
266
+ Restart=on-failure
267
+ RestartSec=2
268
+
269
+ # Hardening
270
+ NoNewPrivileges=true
271
+ ProtectSystem=strict
272
+ ProtectHome=true
273
+ ReadWritePaths=/var/lib/knowless
274
+ PrivateTmp=true
275
+ PrivateDevices=true
276
+ ProtectKernelTunables=true
277
+ ProtectKernelModules=true
278
+ ProtectControlGroups=true
279
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
280
+ LockPersonality=true
281
+ SystemCallArchitectures=native
282
+
283
+ [Install]
284
+ WantedBy=multi-user.target
285
+ ```
286
+
287
+ Enable:
288
+
289
+ ```sh
290
+ sudo systemctl daemon-reload
291
+ sudo systemctl enable --now knowless
292
+ sudo systemctl status knowless
293
+ sudo journalctl -u knowless -f
294
+ ```
295
+
296
+ `ExecStartPre=knowless-server --config-check` ensures a misconfigured
297
+ deploy fails to start instead of silently breaking auth.
298
+
299
+ ---
300
+
301
+ ## 7. Reverse proxy — pick one
302
+
303
+ knowless does not terminate TLS. Your proxy fronts it on `:443`,
304
+ forwards `Host` and `X-Forwarded-For`, and (for forward-auth
305
+ deployments) routes protected upstreams through `/verify`.
306
+
307
+ ### 7.1 Caddy
308
+
309
+ ```caddy
310
+ auth.example.com {
311
+ reverse_proxy 127.0.0.1:8080
312
+ }
313
+
314
+ # Protect Uptime Kuma with knowless forward-auth
315
+ kuma.example.com {
316
+ forward_auth 127.0.0.1:8080 {
317
+ uri /verify
318
+ copy_headers X-Knowless-Handle
319
+ }
320
+ reverse_proxy 127.0.0.1:3001
321
+ }
322
+ ```
323
+
324
+ Caddy auto-issues TLS via Let's Encrypt and trusts itself as a proxy
325
+ on the loopback. knowless's default `KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1`
326
+ covers this.
327
+
328
+ ### 7.2 nginx
329
+
330
+ ```nginx
331
+ # /etc/nginx/sites-available/auth.example.com
332
+ server {
333
+ listen 443 ssl http2;
334
+ server_name auth.example.com;
335
+ # ssl_certificate / ssl_certificate_key managed by certbot
336
+
337
+ location / {
338
+ proxy_pass http://127.0.0.1:8080;
339
+ proxy_set_header Host $host;
340
+ proxy_set_header X-Forwarded-For $remote_addr;
341
+ proxy_set_header X-Forwarded-Proto $scheme;
342
+ }
343
+ }
344
+
345
+ # Protect a service with auth_request
346
+ server {
347
+ listen 443 ssl http2;
348
+ server_name kuma.example.com;
349
+
350
+ location = /_knowless_verify {
351
+ internal;
352
+ proxy_pass http://127.0.0.1:8080/verify;
353
+ proxy_pass_request_body off;
354
+ proxy_set_header Content-Length "";
355
+ proxy_set_header X-Original-URI $request_uri;
356
+ proxy_set_header Cookie $http_cookie;
357
+ }
358
+
359
+ error_page 401 = @knowless_login;
360
+ location @knowless_login {
361
+ return 302 https://auth.example.com/login?next=https://$host$request_uri;
362
+ }
363
+
364
+ location / {
365
+ auth_request /_knowless_verify;
366
+ auth_request_set $handle $upstream_http_x_knowless_handle;
367
+ proxy_set_header X-Knowless-Handle $handle;
368
+ proxy_pass http://127.0.0.1:3001;
369
+ }
370
+ }
371
+ ```
372
+
373
+ ### 7.3 Traefik
374
+
375
+ ```yaml
376
+ # dynamic.yml
377
+ http:
378
+ middlewares:
379
+ knowless:
380
+ forwardAuth:
381
+ address: "http://127.0.0.1:8080/verify"
382
+ authResponseHeaders: [ "X-Knowless-Handle" ]
383
+
384
+ routers:
385
+ auth:
386
+ rule: "Host(`auth.example.com`)"
387
+ service: knowless
388
+ tls: { certResolver: le }
389
+ kuma:
390
+ rule: "Host(`kuma.example.com`)"
391
+ service: kuma
392
+ middlewares: [ knowless ]
393
+ tls: { certResolver: le }
394
+
395
+ services:
396
+ knowless:
397
+ loadBalancer:
398
+ servers: [{ url: "http://127.0.0.1:8080" }]
399
+ kuma:
400
+ loadBalancer:
401
+ servers: [{ url: "http://127.0.0.1:3001" }]
402
+ ```
403
+
404
+ ---
405
+
406
+ ## 8. Tailscale / WireGuard pattern
407
+
408
+ A common deployment: knowless lives on a public VPS (port 25 open,
409
+ PTR set), but the services it protects live on a home server.
410
+ Connect them with a mesh VPN.
411
+
412
+ ```
413
+ public VPS home server
414
+ +----------------+ +-------------------+
415
+ | Caddy :443 | | Uptime Kuma :3001 |
416
+ | knowless :8080 | | Vaultwarden :8222 |
417
+ | tailscale0 |--------------| tailscale0 |
418
+ +----------------+ +-------------------+
419
+ ```
420
+
421
+ In Caddy on the VPS:
422
+
423
+ ```caddy
424
+ kuma.example.com {
425
+ forward_auth 127.0.0.1:8080 {
426
+ uri /verify
427
+ }
428
+ reverse_proxy 100.64.0.5:3001 # tailscale IP of home server
429
+ }
430
+ ```
431
+
432
+ Home server exposes nothing publicly. The VPS terminates TLS, runs
433
+ auth, and proxies through the mesh.
434
+
435
+ ---
436
+
437
+ ## 9. Reverse-proxy rate limiting (defence in depth)
438
+
439
+ knowless ships modest in-process limits as a baseline (FR-39).
440
+ Operators expecting elevated abuse should layer stricter limits at
441
+ the proxy. The proxy sees traffic earlier and can drop it without
442
+ even hitting Node.
443
+
444
+ ### 9.1 Caddy
445
+
446
+ ```caddy
447
+ auth.example.com {
448
+ rate_limit {
449
+ zone login_ip {
450
+ key {client_ip}
451
+ window 1m
452
+ events 10
453
+ }
454
+ match path /login
455
+ }
456
+ reverse_proxy 127.0.0.1:8080
457
+ }
458
+ ```
459
+
460
+ (Requires the `caddy-ratelimit` plugin; build with
461
+ `xcaddy build --with github.com/mholt/caddy-ratelimit`.)
462
+
463
+ ### 9.2 nginx
464
+
465
+ ```nginx
466
+ limit_req_zone $binary_remote_addr zone=knowless_login:10m rate=10r/m;
467
+
468
+ server {
469
+ location = /login {
470
+ limit_req zone=knowless_login burst=5 nodelay;
471
+ proxy_pass http://127.0.0.1:8080;
472
+ }
473
+ }
474
+ ```
475
+
476
+ ---
477
+
478
+ ## 10. fail2ban / Cloudflare Turnstile (heavier abuse profiles)
479
+
480
+ Optional; both have trade-offs.
481
+
482
+ **fail2ban** can tail `journalctl -u knowless` and block IPs that
483
+ trip many rate-limit responses:
484
+
485
+ ```ini
486
+ # /etc/fail2ban/filter.d/knowless.conf
487
+ [Definition]
488
+ failregex = ^.*\s+\[knowless\]\s+rate-limited\s+ip=<HOST>
489
+ ignoreregex =
490
+ ```
491
+
492
+ ```ini
493
+ # /etc/fail2ban/jail.d/knowless.conf
494
+ [knowless]
495
+ enabled = true
496
+ filter = knowless
497
+ backend = systemd
498
+ journalmatch = _SYSTEMD_UNIT=knowless.service
499
+ maxretry = 20
500
+ findtime = 600
501
+ bantime = 3600
502
+ action = iptables[name=knowless, port=https, protocol=tcp]
503
+ ```
504
+
505
+ (knowless does not log structured rate-limit lines today; if you want
506
+ this you may need to wrap it. See Issue tracker.)
507
+
508
+ **Cloudflare Turnstile** is the lowest-friction CAPTCHA but
509
+ introduces a third-party dependency on Cloudflare for every login
510
+ form load. knowless doesn't integrate it natively; if you need it,
511
+ embed the widget in your own login page (use the library mode
512
+ `renderLoginForm` as a starting template).
513
+
514
+ Both are last-resort knobs. Most operators won't need them.
515
+
516
+ ---
517
+
518
+ ## 11. Operational checks
519
+
520
+ Once running:
521
+
522
+ ```sh
523
+ # Validate config
524
+ sudo -u knowless npx --yes knowless-server --config-check
525
+
526
+ # Print effective config (secrets redacted)
527
+ sudo -u knowless npx --yes knowless-server --print-config
528
+
529
+ # Watch logs
530
+ sudo journalctl -u knowless -f
531
+
532
+ # Send yourself a magic link end-to-end
533
+ curl -i -X POST https://auth.example.com/login \
534
+ -d email=you@somewhere-you-control.com
535
+ # Then check your inbox (NOT spam) for the link.
536
+ ```
537
+
538
+ If the email lands in spam, work through §5 (SPF, DKIM, PTR, DMARC)
539
+ in that order — most spam-folder verdicts trace to one of those four.
540
+
541
+ ---
542
+
543
+ ## 12. Backup and recovery
544
+
545
+ The only stateful file is the SQLite database (`KNOWLESS_DB_PATH`,
546
+ default `/var/lib/knowless/knowless.db`). It contains:
547
+
548
+ - Handles (HMAC outputs of email addresses)
549
+ - Active token hashes (short-lived, 15 min default)
550
+ - Session ID hashes (30 day default)
551
+ - Rate-limit counters
552
+
553
+ Loss is recoverable: users sign in again and get a new session. There
554
+ is no irreplaceable data here. A weekly `sqlite3 knowless.db .backup`
555
+ is sufficient.
556
+
557
+ The HMAC secret in `/etc/knowless/knowless.env` is the actual asset
558
+ to protect. Losing it logs every user out and changes every handle
559
+ (emails will be re-derived to new opaque values on next login).
560
+ **Back up the secret separately, out-of-band, encrypted.** Never
561
+ commit it.
562
+
563
+ ---
564
+
565
+ ## 13. Where things are documented
566
+
567
+ - **PRD** (`docs/01-product/PRD.md`) — what the library does and
568
+ doesn't do, threat model, NO-GO list
569
+ - **SPEC** (`docs/02-design/SPEC.md`) — wire formats, exact flows,
570
+ schema
571
+ - **GUIDE.md** — application-developer integration in library mode
572
+ - **README.md** — short orientation
573
+ - **OPS.md** (this document) — operator-side setup
package/README.md CHANGED
@@ -104,16 +104,18 @@ By choosing knowless, you commit to:
104
104
  silent-miss sham mail is dropped, not delivered to NXDOMAIN
105
105
  - Accepting that this is the **only email** your service ever sends
106
106
 
107
- These are documented in `OPS.md` (forthcoming with 0.2.0). Until
108
- then, see `GUIDE.md` for the high-level path and the
109
- [Postfix outbound-only setup guide](https://www.postfix.org/STANDARD_CONFIGURATION_README.html#stand_alone)
110
- upstream.
107
+ These are documented in [`OPS.md`](OPS.md): Postfix install,
108
+ null-route, SPF/DKIM/PTR, systemd unit, Caddy / nginx / Traefik
109
+ forward-auth examples, Tailscale pattern, reverse-proxy rate limiting,
110
+ and fail2ban / Turnstile references.
111
111
 
112
112
  ## Documentation
113
113
 
114
114
  - [`README.md`](README.md) (this file) — project pitch, six-line example
115
115
  - [`GUIDE.md`](GUIDE.md) — adopter walkthrough: who it's for, who it
116
116
  isn't, how to integrate, configuration reference, FAQ
117
+ - [`OPS.md`](OPS.md) — operator setup: Postfix, null-route, DNS,
118
+ systemd, reverse-proxy forward-auth examples
117
119
  - [`knowless.context.md`](knowless.context.md) — dense AI-agent
118
120
  integration guide (tables, gotchas, public API at a glance)
119
121
  - [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
package/bin/.gitkeep ADDED
File without changes
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ // knowless-server — standalone forward-auth HTTP server.
3
+ // All configuration via KNOWLESS_* env vars. PRD §7.12 (FR-49 to FR-56).
4
+
5
+ import { parseArgs } from 'node:util';
6
+ import { createServer } from 'node:http';
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+ import net from 'node:net';
11
+ import fs from 'node:fs';
12
+ import { knowless } from '../src/index.js';
13
+
14
+ const PKG = JSON.parse(
15
+ readFileSync(
16
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
17
+ 'utf8',
18
+ ),
19
+ );
20
+
21
+ // [optionKey, envVar, default (null = required), purpose, secret?]
22
+ const SPEC = [
23
+ ['secret', 'KNOWLESS_SECRET', null, 'HMAC secret, ≥64 hex chars (32 bytes). REQUIRED.', true],
24
+ ['baseUrl', 'KNOWLESS_BASE_URL', null, 'Public base URL for magic links. REQUIRED.'],
25
+ ['from', 'KNOWLESS_FROM', null, 'Sender email address. REQUIRED.'],
26
+ ['dbPath', 'KNOWLESS_DB_PATH', './knowless.db', 'SQLite database path.'],
27
+ ['cookieDomain', 'KNOWLESS_COOKIE_DOMAIN', '', 'Session cookie domain. Defaults to baseUrl hostname.'],
28
+ ['cookieSecure', 'KNOWLESS_COOKIE_SECURE', 'true', 'Set Secure flag on cookies. "false" only for localhost dev.'],
29
+ ['tokenTtlSeconds', 'KNOWLESS_TOKEN_TTL_SECONDS', '900', 'Magic-link token lifetime (seconds).'],
30
+ ['sessionTtlSeconds', 'KNOWLESS_SESSION_TTL_SECONDS', '2592000', 'Session lifetime (seconds, default 30d).'],
31
+ ['linkPath', 'KNOWLESS_LINK_PATH', '/auth/callback', 'Magic-link callback path.'],
32
+ ['loginPath', 'KNOWLESS_LOGIN_PATH', '/login', 'Login form path.'],
33
+ ['verifyPath', 'KNOWLESS_VERIFY_PATH', '/verify', 'Forward-auth check path.'],
34
+ ['logoutPath', 'KNOWLESS_LOGOUT_PATH', '/logout', 'Logout path.'],
35
+ ['smtpHost', 'KNOWLESS_SMTP_HOST', 'localhost', 'SMTP host for outgoing magic-link mail.'],
36
+ ['smtpPort', 'KNOWLESS_SMTP_PORT', '25', 'SMTP port.'],
37
+ ['openRegistration', 'KNOWLESS_OPEN_REGISTRATION', 'false', 'Allow new-handle creation on first email.'],
38
+ ['subject', 'KNOWLESS_SUBJECT', 'Sign in', 'Email subject line.'],
39
+ ['includeLastLoginInEmail', 'KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL', 'true', 'Append last-sign-in note to magic-link email.'],
40
+ ['maxActiveTokensPerHandle', 'KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE', '5', 'Cap on concurrent magic links per handle. 0 disables.'],
41
+ ['maxLoginRequestsPerIpPerHour', 'KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR', '30', 'Per-IP login submission cap. 0 disables.'],
42
+ ['maxNewHandlesPerIpPerHour', 'KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR', '3', 'Per-IP account-creation cap (openRegistration only). 0 disables.'],
43
+ ['honeypotFieldName', 'KNOWLESS_HONEYPOT_FIELD_NAME', 'website', 'Name of the honeypot field.'],
44
+ ['trustedProxies', 'KNOWLESS_TRUSTED_PROXIES', '127.0.0.1,::1', 'Comma-separated IPs trusted for X-Forwarded-For.'],
45
+ ];
46
+
47
+ // PORT is a runtime-only knob; not part of the library options object.
48
+ const SERVER_SPEC = [
49
+ ['port', 'KNOWLESS_PORT', '8080', 'TCP port the HTTP server binds.'],
50
+ ['host', 'KNOWLESS_HOST', '0.0.0.0', 'Address the HTTP server binds.'],
51
+ ];
52
+
53
+ const NUMERIC = new Set([
54
+ 'tokenTtlSeconds',
55
+ 'sessionTtlSeconds',
56
+ 'smtpPort',
57
+ 'maxActiveTokensPerHandle',
58
+ 'maxLoginRequestsPerIpPerHour',
59
+ 'maxNewHandlesPerIpPerHour',
60
+ ]);
61
+ const BOOLEAN = new Set([
62
+ 'cookieSecure',
63
+ 'openRegistration',
64
+ 'includeLastLoginInEmail',
65
+ ]);
66
+ const LIST = new Set(['trustedProxies']);
67
+
68
+ function loadFromEnv(env = process.env) {
69
+ const opts = {};
70
+ for (const [key, envVar, def] of SPEC) {
71
+ const raw = env[envVar];
72
+ const value = raw !== undefined && raw !== '' ? raw : def;
73
+ if (value === null) continue; // required, missing — leave unset
74
+ if (value === '') continue; // optional with empty default — skip
75
+ if (NUMERIC.has(key)) opts[key] = Number(value);
76
+ else if (BOOLEAN.has(key)) opts[key] = value === 'true' || value === '1';
77
+ else if (LIST.has(key)) opts[key] = value.split(',').map((s) => s.trim()).filter(Boolean);
78
+ else opts[key] = value;
79
+ }
80
+ const serverConfig = {};
81
+ for (const [key, envVar, def] of SERVER_SPEC) {
82
+ const raw = env[envVar];
83
+ const value = raw !== undefined && raw !== '' ? raw : def;
84
+ serverConfig[key] = key === 'port' ? Number(value) : value;
85
+ }
86
+ return { opts, serverConfig };
87
+ }
88
+
89
+ function printHelp() {
90
+ process.stdout.write(`knowless-server v${PKG.version}
91
+
92
+ Usage: knowless-server [--help | --version | --print-config | --config-check]
93
+
94
+ Configuration is via environment variables. CLI flags do NOT set config.
95
+
96
+ Required env vars:
97
+ `);
98
+ for (const [, envVar, def, purpose] of SPEC) {
99
+ if (def !== null) continue;
100
+ process.stdout.write(` ${envVar}\n ${purpose}\n`);
101
+ }
102
+ process.stdout.write('\nOptional env vars (with defaults):\n');
103
+ for (const [, envVar, def, purpose] of SPEC) {
104
+ if (def === null) continue;
105
+ const shown = def === '' ? '<derived>' : def;
106
+ process.stdout.write(` ${envVar}=${shown}\n ${purpose}\n`);
107
+ }
108
+ for (const [, envVar, def, purpose] of SERVER_SPEC) {
109
+ process.stdout.write(` ${envVar}=${def}\n ${purpose}\n`);
110
+ }
111
+ process.stdout.write(
112
+ '\nLoad a .env file with: node --env-file=knowless.env $(which knowless-server)\n',
113
+ );
114
+ }
115
+
116
+ function formatConfig({ opts, serverConfig }) {
117
+ const lines = [];
118
+ for (const [key, envVar, , , isSecret] of SPEC) {
119
+ if (!(key in opts)) {
120
+ lines.push(`${envVar}=<unset>`);
121
+ continue;
122
+ }
123
+ let val = opts[key];
124
+ if (isSecret) val = '<set>';
125
+ else if (Array.isArray(val)) val = val.join(',');
126
+ lines.push(`${envVar}=${val}`);
127
+ }
128
+ for (const [key, envVar] of SERVER_SPEC) {
129
+ lines.push(`${envVar}=${serverConfig[key]}`);
130
+ }
131
+ return lines.join('\n');
132
+ }
133
+
134
+ function checkRequired(opts) {
135
+ const errors = [];
136
+ for (const [key, envVar, def] of SPEC) {
137
+ if (def !== null) continue;
138
+ if (!(key in opts)) errors.push(`${envVar} is missing`);
139
+ }
140
+ if (opts.secret && opts.secret.length < 64) {
141
+ errors.push('KNOWLESS_SECRET must be at least 64 hex chars (32 bytes)');
142
+ }
143
+ return errors;
144
+ }
145
+
146
+ function checkSmtpReachable(host, port, timeoutMs = 2000) {
147
+ return new Promise((resolve) => {
148
+ const sock = net.createConnection({ host, port });
149
+ let done = false;
150
+ const finish = (ok, err) => {
151
+ if (done) return;
152
+ done = true;
153
+ try { sock.destroy(); } catch { /* tolerate */ }
154
+ resolve({ ok, err });
155
+ };
156
+ sock.setTimeout(timeoutMs);
157
+ sock.once('connect', () => finish(true));
158
+ sock.once('timeout', () => finish(false, new Error('connection timed out')));
159
+ sock.once('error', (e) => finish(false, e));
160
+ });
161
+ }
162
+
163
+ function checkDbWritable(dbPath) {
164
+ if (dbPath === ':memory:') return { ok: true };
165
+ try {
166
+ const dir = dirname(dbPath);
167
+ fs.accessSync(dir, fs.constants.W_OK);
168
+ return { ok: true };
169
+ } catch (err) {
170
+ return { ok: false, err };
171
+ }
172
+ }
173
+
174
+ async function runConfigCheck({ opts, serverConfig }, { print } = { print: true }) {
175
+ if (print) process.stdout.write(formatConfig({ opts, serverConfig }) + '\n');
176
+ const errors = checkRequired(opts);
177
+ if (errors.length) {
178
+ for (const e of errors) process.stderr.write(`config error: ${e}\n`);
179
+ return 1;
180
+ }
181
+ const smtp = await checkSmtpReachable(opts.smtpHost, opts.smtpPort);
182
+ if (!smtp.ok) {
183
+ process.stderr.write(
184
+ `config error: SMTP ${opts.smtpHost}:${opts.smtpPort} not reachable: ${smtp.err.message}\n`,
185
+ );
186
+ return 1;
187
+ }
188
+ process.stdout.write(`SMTP ${opts.smtpHost}:${opts.smtpPort} reachable: OK\n`);
189
+ const db = checkDbWritable(opts.dbPath);
190
+ if (!db.ok) {
191
+ process.stderr.write(
192
+ `config error: DB path ${opts.dbPath} not writable: ${db.err.message}\n`,
193
+ );
194
+ return 1;
195
+ }
196
+ process.stdout.write(`DB path ${opts.dbPath} writable: OK\n`);
197
+ return 0;
198
+ }
199
+
200
+ function buildRouter(auth, cfg) {
201
+ const map = new Map([
202
+ [`POST ${cfg.loginPath}`, auth.login],
203
+ [`GET ${cfg.loginPath}`, auth.loginForm],
204
+ [`GET ${cfg.linkPath}`, auth.callback],
205
+ [`GET ${cfg.verifyPath}`, auth.verify],
206
+ [`POST ${cfg.logoutPath}`, auth.logout],
207
+ ]);
208
+ return (req, res) => {
209
+ const url = new URL(req.url, 'http://placeholder.invalid');
210
+ const handler = map.get(`${req.method} ${url.pathname}`);
211
+ if (!handler) {
212
+ res.statusCode = 404;
213
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
214
+ res.end('not found\n');
215
+ return;
216
+ }
217
+ Promise.resolve()
218
+ .then(() => handler(req, res))
219
+ .catch((err) => {
220
+ process.stderr.write(`[knowless] handler error: ${err.stack || err.message}\n`);
221
+ if (!res.headersSent) {
222
+ res.statusCode = 500;
223
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
224
+ res.end('internal error\n');
225
+ }
226
+ });
227
+ };
228
+ }
229
+
230
+ async function runServer({ opts, serverConfig }) {
231
+ const errors = checkRequired(opts);
232
+ if (errors.length) {
233
+ for (const e of errors) process.stderr.write(`config error: ${e}\n`);
234
+ return 1;
235
+ }
236
+ const auth = knowless(opts);
237
+ const router = buildRouter(auth, auth.config);
238
+ const server = createServer(router);
239
+
240
+ await new Promise((resolve, reject) => {
241
+ server.once('error', reject);
242
+ server.listen(serverConfig.port, serverConfig.host, () => {
243
+ server.off('error', reject);
244
+ resolve();
245
+ });
246
+ });
247
+
248
+ // FR-54: single startup log block.
249
+ const block =
250
+ '--- knowless-server started ---\n' +
251
+ formatConfig({ opts, serverConfig }) +
252
+ `\nlistening: http://${serverConfig.host}:${serverConfig.port}\n` +
253
+ '-------------------------------\n';
254
+ process.stdout.write(block);
255
+
256
+ const shutdown = (sig) => {
257
+ process.stdout.write(`[knowless] ${sig}, shutting down\n`);
258
+ server.close(() => {
259
+ try { auth.close(); } catch { /* tolerate */ }
260
+ process.exit(0);
261
+ });
262
+ setTimeout(() => process.exit(1), 5000).unref();
263
+ };
264
+ process.on('SIGINT', () => shutdown('SIGINT'));
265
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
266
+ return null;
267
+ }
268
+
269
+ async function main(argv = process.argv.slice(2)) {
270
+ let parsed;
271
+ try {
272
+ parsed = parseArgs({
273
+ args: argv,
274
+ options: {
275
+ help: { type: 'boolean' },
276
+ version: { type: 'boolean' },
277
+ 'print-config': { type: 'boolean' },
278
+ 'config-check': { type: 'boolean' },
279
+ },
280
+ strict: true,
281
+ allowPositionals: false,
282
+ });
283
+ } catch (err) {
284
+ process.stderr.write(`error: ${err.message}\nrun with --help for usage\n`);
285
+ return 2;
286
+ }
287
+ const { values } = parsed;
288
+ if (values.help) { printHelp(); return 0; }
289
+ if (values.version) { process.stdout.write(PKG.version + '\n'); return 0; }
290
+
291
+ const cfg = loadFromEnv();
292
+ if (values['print-config']) {
293
+ process.stdout.write(formatConfig(cfg) + '\n');
294
+ return 0;
295
+ }
296
+ if (values['config-check']) {
297
+ return runConfigCheck(cfg);
298
+ }
299
+ const code = await runServer(cfg);
300
+ return code === null ? undefined : code;
301
+ }
302
+
303
+ const isEntrypoint = import.meta.url === `file://${process.argv[1]}`;
304
+ if (isEntrypoint) {
305
+ main().then((code) => {
306
+ if (code !== undefined) process.exit(code);
307
+ }).catch((err) => {
308
+ process.stderr.write(`fatal: ${err.stack || err.message}\n`);
309
+ process.exit(1);
310
+ });
311
+ }
312
+
313
+ export { loadFromEnv, formatConfig, checkRequired, SPEC, SERVER_SPEC, main };
@@ -0,0 +1,74 @@
1
+ # knowless-server — example configuration. PRD FR-56.
2
+ #
3
+ # Copy to e.g. /etc/knowless/knowless.env (mode 0600) and load via:
4
+ # node --env-file=/etc/knowless/knowless.env $(which knowless-server)
5
+ # or systemd:
6
+ # EnvironmentFile=/etc/knowless/knowless.env
7
+ #
8
+ # This file is documentation, NOT loaded automatically by the library.
9
+
10
+ # --- REQUIRED ---
11
+
12
+ # HMAC secret for handle derivation and session signing.
13
+ # Generate with: openssl rand -hex 32
14
+ KNOWLESS_SECRET=
15
+
16
+ # Public base URL of this auth server.
17
+ KNOWLESS_BASE_URL=https://auth.example.com
18
+
19
+ # Sender address for outgoing magic-link mail.
20
+ KNOWLESS_FROM=auth@example.com
21
+
22
+ # --- COMMON ---
23
+
24
+ # SQLite database path. Parent dir must be writable.
25
+ KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
26
+
27
+ # Cookie domain. Defaults to baseUrl hostname; usually set to your eTLD+1
28
+ # (e.g. example.com) so the session cookie is shared across subdomains.
29
+ # KNOWLESS_COOKIE_DOMAIN=example.com
30
+
31
+ # Set "false" only for http://localhost development. Library logs a warning.
32
+ KNOWLESS_COOKIE_SECURE=true
33
+
34
+ # Allow new-handle creation on first email. Default false (closed-reg).
35
+ KNOWLESS_OPEN_REGISTRATION=false
36
+
37
+ # --- SERVER ---
38
+
39
+ KNOWLESS_HOST=0.0.0.0
40
+ KNOWLESS_PORT=8080
41
+
42
+ # --- SMTP ---
43
+ # Default assumes a local Postfix on the same host (recommended).
44
+
45
+ KNOWLESS_SMTP_HOST=localhost
46
+ KNOWLESS_SMTP_PORT=25
47
+
48
+ # --- TIMINGS (seconds) ---
49
+
50
+ KNOWLESS_TOKEN_TTL_SECONDS=900
51
+ KNOWLESS_SESSION_TTL_SECONDS=2592000
52
+
53
+ # --- PATHS ---
54
+
55
+ KNOWLESS_LOGIN_PATH=/login
56
+ KNOWLESS_LINK_PATH=/auth/callback
57
+ KNOWLESS_VERIFY_PATH=/verify
58
+ KNOWLESS_LOGOUT_PATH=/logout
59
+
60
+ # --- ABUSE LIMITS (0 disables) ---
61
+
62
+ KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE=5
63
+ KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR=30
64
+ KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR=3
65
+ KNOWLESS_HONEYPOT_FIELD_NAME=website
66
+
67
+ # Comma-separated. Reverse proxy must be in this list for X-Forwarded-For
68
+ # to be honored.
69
+ KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1
70
+
71
+ # --- EMAIL CONTENT ---
72
+
73
+ KNOWLESS_SUBJECT=Sign in
74
+ KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL=true
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "knowless",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js"
9
9
  },
10
+ "bin": {
11
+ "knowless-server": "./bin/knowless-server"
12
+ },
10
13
  "files": [
11
14
  "src/",
15
+ "bin/",
16
+ "config.example.env",
12
17
  "LICENSE",
13
18
  "NOTICE",
14
19
  "README.md",
15
20
  "CHANGELOG.md",
16
21
  "GUIDE.md",
22
+ "OPS.md",
17
23
  "knowless.context.md"
18
24
  ],
19
25
  "engines": {