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 +33 -7
- package/OPS.md +573 -0
- package/README.md +6 -4
- package/bin/.gitkeep +0 -0
- package/bin/knowless-server +313 -0
- package/config.example.env +74 -0
- package/package.json +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,39 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
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`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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": {
|