sightingdb 0.7.0

A database designed for Sightings, a technique to count items
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
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
<p align="center"><img src="doc/sightingdb-logo3_128.png"/></p>

SightingDB is a database designed for Sightings, a technique to count items. This is helpful for Threat Intelligence as Sightings allow
to enrich indicators or attributes with Observations, rather than Reputation.

Simply speaking, by pushing data to SightingDB, you will get the first time it was observed, the last time, its count.

However, it will also provide the following features:
* Keep track of how many times something was searched
* Keep track of the hourly statistics per item
* Get the consensus for each item (how many namespaces contain the same value)
* Expire data with a per-value TTL
* Answer lookups over DNS, using the DNSBL conventions security tooling already speaks
* Ingest from a MISP ZeroMQ feed, and import STIX 2.1 bundles
* Browse namespaces and values in a browser, with a histogram of when each value was seen

SightingDB is designed to scale writing and reading. There is no global lock: namespaces are locked independently, and within a namespace each value has its own lock, so concurrent writes to different values never contend.

The database is held in memory and snapshotted to disk (see `dbdir` below). Set no `dbdir` to run purely in memory.

Getting started
===============

	$ cargo install sightingdb
	$ sightingdb --setup

`--setup` asks a few questions, prints exactly what it intends to do, and only
then does it: directories with sensible modes, a configuration, a self-signed
certificate, an admin API key, and a systemd unit or launchd job. Without root
it installs under `~/.sightingdb` for the current user; with `sudo` on Linux it
installs system-wide under `/etc` and `/var/lib` and creates a `sightingdb`
service account.

Nothing existing is replaced without being asked, file by file, and API keys
and certificates are never replaced at all — re-running setup on an installed
system keeps them. The admin key is shown once, when it is first created.

Building
========

1) Make sure you have Rust and Cargo installed. The toolchain is pinned in `rust-toolchain.toml`; rustup will fetch it automatically.
2) Run `make` (or `cargo build`).

You will need OpenSSL development headers to build (`libssl-dev` on Debian/Ubuntu, `openssl` from Homebrew on macOS).

Running
=======

To run from the source directory:

1. Generate a certificate: `./target/debug/sightingdb -c etc/sightingdb.toml --install-selfsigned-keys`
2. Start the daemon: `./target/debug/sightingdb -c etc/sightingdb.toml`

`--install-selfsigned-keys` writes a self-signed certificate and a `0600` key at
the configured `ssl_cert` and `ssl_key` paths and exits. It never overwrites an
existing file, so pointing those settings at a real certificate is safe. The
generated certificate names `localhost`, `127.0.0.1` and `::1`, lasts a year,
and is for getting started — clients have to skip verification (`curl -k`).

Set `ssl = false` in `[daemon]` to serve plain HTTP instead.

Without `-c`, the configuration is looked up in `/etc/sightingdb/sightingdb.toml` and then `~/.sightingdb/sightingdb.toml`.


Running as a service
--------------------

The recommended way to run SightingDB in the background is under a service
manager, which handles restarts, log capture and startup ordering for you. A
hardened systemd unit is provided:

	sudo install -m 0644 etc/sightingdb.service /etc/systemd/system/
	sudo systemctl daemon-reload
	sudo systemctl enable --now sightingdb

Keep `daemonize = false` for that, and point log4rs at a console appender so the
logs land in the journal (`journalctl -u sightingdb`).

Setting `daemonize = true` instead makes SightingDB detach on its own: it
re-executes itself with `stdin` on `/dev/null`, `stdout` and `stderr` on the
`log_out` and `log_err` files, and its own process group, then the launcher
exits. A pid file is written to the first writable location out of
`/var/run/sightingdb.pid`, `~/.sightingdb/sightingdb.pid` or `./sightingdb.pid`,
and removed again on a clean shutdown.

Detaching by re-executing rather than by forking is deliberate. `fork` carries
over only the calling thread, so a forked daemon silently loses anything already
running in the background — including log4rs' own configuration reloader. The
child here starts from a clean `exec`, so `refresh_rate` keeps working. Nothing
changes directory either, so relative paths in the configuration keep resolving.

Send `SIGTERM` to stop: in-flight requests are drained, the database is written
out, and the pid file is removed.

Options
-------

	-c, --config <FILE>          Configuration file (default: see above)
	    --setup                  Install: directories, configuration, certificate, key, service
	    --installed              List the files this installation uses, and whether each is there
	    --start                  Start the installed service
	    --stop                   Stop it. 
	    --restart                Restart it
	    --erase                  Stop it, remove its service and empty the database
	    --erase-hard             The same, and the configuration, keys and certificate too
	    --install-selfsigned-keys Write a self-signed cert and key, then exit
	    --import-stix <PATH>     Import STIX 2.1 bundles, then exit
	-l, --logging-config <FILE>  log4rs configuration file (default: etc/log4rs.yml)
	-k, --apikey <APIKEY>        Set the default API key, replacing the built-in 'changeme'
	-v, --verbose...             Increase verbosity

What is installed
-----------------

	$ sightingdb --installed
	SightingDB 0.5.7 — the files this installation uses

	  present  configuration    /etc/sightingdb/sightingdb.toml     2.9 KB, mode 0644
	  present  logging config   /etc/sightingdb/log4rs.yml          110 B, mode 0644
	  present  API keys         /etc/sightingdb/acl.toml            265 B, mode 0600  (rewritten when a key is saved)
	  missing  tiers            /etc/sightingdb/tiers.toml            (written when a tier is changed)
	  present  TLS certificate  /etc/sightingdb/ssl/cert.pem        1.2 KB, mode 0644
	  present  TLS key          /etc/sightingdb/ssl/key.pem         1.7 KB, mode 0600
	  present  database         /var/lib/sightingdb                 7 file(s), 4.2 MB
	  present  binary           /usr/local/bin/sightingdb           12.4 MB, mode 0755  (left alone by --erase)
	  present  service          /etc/systemd/system/sightingdb.service  1.6 KB, mode 0644

Paths come from the configuration, so these are the files *this* install reads
and writes, in full — not the ones a default install would use. A missing file
is often normal: the tiers file appears the first time a tier is changed. A key
or certificate that more than its owner can read is called out with the `chmod`
that fixes it.

Starting and stopping
---------------------

	$ sightingdb --stop
	$ sightingdb --start
	$ sightingdb --restart

**`kill` does not stop it, and neither does `kill -9`.** Both service managers
are told to keep it running — the systemd unit has `Restart=on-failure` and the
launchd plist has `KeepAlive` — so the supervisor starts it again the moment it
dies. Stopping means telling the supervisor, which these flags do, and which
differs by platform and by whether the service is the system's or your own:

	systemctl stop sightingdb                 # Linux, system
	systemctl --user stop sightingdb          # Linux, yours
	launchctl bootout system/com.github.stricaud.sightingdb          # macOS, system
	launchctl bootout gui/$(id -u)/com.github.stricaud.sightingdb    # macOS, yours

`--installed` prints the right one for the service it finds. `--restart` uses
`systemctl restart` or launchd's `kickstart -k`, since launchd has no restart
verb of its own.

### It seems to restart every few seconds

Then something else already has the port, and the supervisor is faithfully
restarting the copy that lost:

	INFO sightingdb - Starting Sighting Daemon
	INFO sightingdb - Saving the database to /Users/you/.sightingdb/db
	(ten seconds later, the same again)

The reason is now logged where the rest of it is — `cannot listen on
https://127.0.0.1:9999: something already has that address` — but the usual
cause is **two services installed at once**: an install from before 0.5 used
the launchd label `com.devo.sightingdb`, and `--setup` installs
`com.github.stricaud.sightingdb`. Both run the same binary against the same
configuration, one wins the port, and launchd restarts the other every ten
seconds forever.

`sightingdb --installed` lists every service it finds and says so outright when
there is more than one. To remove an old launchd job:

	launchctl bootout gui/$(id -u)/com.devo.sightingdb
	rm ~/Library/LaunchAgents/com.devo.sightingdb.plist

Removing it again
-----------------

	$ sightingdb --erase        # the service and the database; keeps your settings
	$ sightingdb --erase-hard   # those, and the configuration, keys and certificate

Both list what will go and what will stay, then require the word `erase` typed
in full — not `y`, and there is no flag to skip it. They stop the service, stop
any daemon still running with this configuration, and then remove the files.

| | `--erase` | `--erase-hard` |
| --- | --- | --- |
| snapshots in `dbdir` | removed | removed |
| the service unit | removed | removed |
| `sightingdb.toml` | kept | removed |
| `acl_file`, `tiers_file` | kept | removed |
| TLS certificate and key | kept | removed |
| the binary | kept | kept |

`--erase` is the one to reach for when something is wrong with an installation:
it clears the way for `--setup` to run again, and you keep your API keys, your
certificate and everything you configured. `--erase-hard` is the counterpart of
`--setup` — afterwards nothing of the installation is left.

Both remove **only what this installation owns**. A logging configuration found
in `/etc` or your home directory is the machine's rather than this install's, a
service unit that runs a different configuration belongs to that one, and the
binary is left where it is — all of them named under "And leaves" before you
confirm. In `dbdir` only the snapshot files are removed, and then the directory
if that is all it held; anything else stays, and it says so.

`--setup` refuses to run while a service is installed, for the reason above: two
of them would fight over the port and one would be restarted forever. It says
which flag to use.

Client Demo
===========

Writing
-------
	$ curl -k https://localhost:9999/w/my/namespace/?val=127.0.0.1
	{"message":"ok","count":1}
	$ curl -k https://localhost:9999/w/another/namespace/?val=127.0.0.1
	{"message":"ok","count":1}
	$ curl -k https://localhost:9999/w/another/namespace/?val=127.0.0.1
	{"message":"ok","count":2}

Pass `timestamp=<unix seconds>` to record a sighting at a specific time; without it the sighting is recorded now.

Pass `ttl=<seconds>` to expire the value that long after it was last seen. Writing the value again pushes the deadline out; writing without `ttl=` leaves the existing one alone, and `ttl=0` clears it. Expired values read as `404` immediately and are reclaimed by the next sweep, which also gives back the consensus they were holding.

Pass `tags=<comma,separated>` to record what is known about the value beyond the fact that it was seen:

	$ curl -k 'https://localhost:9999/w/my/namespace/?val=127.0.0.1&tags=stix-type:ipv4-addr,tlp:amber'
	{"message":"ok","count":1}

Tags are *merged*, so one feed contributing `stix-type:ipv4-addr` and another contributing `tlp:amber` leave the value knowing both. See [Tags](#tags).

Reading
-------
	$ curl -k https://localhost:9999/r/my/namespace/?val=127.0.0.1
	{"value":"127.0.0.1","first_seen":1566624658,"last_seen":1566624658,"count":1,"tags":"","ttl":0,"consensus":2}

	$ curl -k https://localhost:9999/r/another/namespace/?val=127.0.0.1
	{"value":"127.0.0.1","first_seen":1566624686,"last_seen":1566624689,"count":2,"tags":"","ttl":0,"consensus":2}

	$ curl -k https://localhost:9999/rs/my/namespace/?val=127.0.0.1
	{"value":"127.0.0.1","first_seen":1593719022,"last_seen":1593721509,"count":10,"tags":"","ttl":0,"consensus":1,"stats":{"1593716400":2,"1593720000":8}}

Omit `val=` to list every value in a namespace:

	$ curl -k https://localhost:9999/r/my/namespace/
	{"attributes":[{"value":"127.0.0.1","first_seen":1566624658,"last_seen":1566624658,"count":1,"tags":"","ttl":0,"consensus":2}]}

Reading is recorded as a "shadow sighting" under `_shadow/<namespace>`, so you can see how often a value was searched for. Add `noshadow` to the query string to suppress that.

Namespaces whose first path segment begins with `_` are internal: `_all` is the
consensus tally, `_shadow/*` is what was searched for, and `_config` held API
keys on older deployments. The database writes them about itself, and **no
write route can reach them** — `/w`, `/wb`, `/vwb` and `/d` all answer `403`,
whatever the key. A client able to write `_all` could give a value a consensus
no namespace supports.

Reading them stays allowed apart from `_config`, so `/r/_all?val=<value>` is
how you ask what a value's consensus is, and `/r/_shadow/<namespace>` is how
you review searches. The underscore only counts at the front:
`feeds/_private/ips` is an ordinary namespace.

To ask how many values a namespace holds without fetching them:

	$ curl -k 'https://localhost:9999/r/my/namespace/?count'
	{"namespace":"my/namespace","values":3,"exact":true,"paged_in":false}

This is O(1) — the value map already knows its own length. `exact` is `false`
when the namespace has ever held a TTL, because a value stops being visible the
moment it expires but is only removed by the next sweep, leaving the stored
count an upper bound in between. Counting raises no shadow sighting.

Bulk
----
	$ curl -k -X POST https://localhost:9999/wb -H 'Content-Type: application/json' \
	    -d '{"items":[{"namespace":"my/namespace","value":"127.0.0.1"}]}'
	{"message":"ok","written":1,"items":[{"index":0,"namespace":"my/namespace","value":"127.0.0.1","status":"ok","count":1}]}

`timestamp`, `ttl`, `tags` and `noshadow` are optional on each item.

Every item gets its own outcome in `items`, in the order you sent them, so one
bad entry tells you which one it was instead of casting doubt on the batch. A
successful item carries the value's resulting `count` — the same number `/w`
answers with, read under the lock that incremented it, so you do not need a
follow-up read to learn it:

	$ curl -k -X POST https://localhost:9999/wb -H 'Content-Type: application/json' \
	    -d '{"items":[{"namespace":"my/namespace","value":"127.0.0.1"},{"namespace":"my/namespace","value":""}]}'
	{"message":"partial","written":1,"items":[{"index":0,...,"status":"ok","count":2},{"index":1,...,"status":"error","error":"Refusing to write an empty value."}],"errors":[...]}

A failing item does not discard the ones beside it: whatever was accepted is
kept, and `written` counts it. An item your key may not write fails the same
way as any other — its `status` is `error` and the rest of the batch still
lands — so **read `items` rather than the status code** to find out what
happened to a given entry.

The status describes the batch as a whole:

| status | `message` | when |
| ------ | --------- | ---- |
| `200`  | `ok`      | every item landed |
| `200`  | `partial` | some landed, some did not |
| `403`  | `failed`  | nothing landed, and every item was refused |
| `400`  | `failed`  | nothing landed, for reasons that were not all refusals |

### Checking a batch first

`/vwb` is a dry run of `/wb`. It takes the same body and answers what `/wb`
would have answered for it — the same status, the same `message`, the same
`status` and `error` on every item — and records nothing at all:

	$ curl -k -X POST https://localhost:9999/vwb -H 'Content-Type: application/json' \
	    -d '{"items":[{"namespace":"my/namespace","value":"127.0.0.1"},{"namespace":"my/namespace","value":""}]}'
	{"message":"partial","writable":1,"items":[{"index":0,...,"status":"ok"},{"index":1,...,"status":"error","error":"Refusing to write an empty value."}],"errors":[...]}

`writable` rather than `written`, because nothing was. Items carry no `count`
for the same reason. A `message` of `ok` means the same batch sent to `/wb`
would be accepted in full.

Both routes decide each item with the same code, so the dry run cannot drift
into approving something the writer rejects. It is a report, not a reservation:
the ACL can be rewritten between the two calls, so a batch that validates can
still be refused when you write it. `/vwb` is for checking a batch *before* you
commit to it — it does not replace reading the `items` that `/wb` gives back.

It also leaves no trace, which `/wb` cannot: probing with a real write records a
sighting you then have to live with.

### Which values were rejected

Every write path answers its own caller about a value it would not write — but
a caller is not always there to read it, and the ZMQ ingest has no caller at
all. So all of them also record it, and the management interface can be asked
after the fact:

	$ curl -k -H 'Authorization: changeme' \
	    'https://localhost:9999/_management/api/rejections?namespace=feeds&limit=5'
	{"rejections":[{"when":1790263370,"namespace":"feeds/misp/ips","value":"","reason":"Refusing to write an empty value.","source":"ingest"}],"total":1,"capacity":1000}

`source` is which path turned it away: `write` (`/w`), `bulkwrite` (`/wb`),
`management`, or `ingest`. Newest first, filtered to what your key may read.
`DELETE` the same URL to forget them all, which needs an unscoped key.

The record is **bounded and in memory**. Once `total` reaches `capacity` the
oldest entries are being dropped to make room, it does not survive a restart,
and it is deliberately kept out of snapshots, consensus and the STIX export —
a value that was never written has no business appearing there. It is for
working out why a feed is failing, not for audit. Set `rejection_log` in
`[daemon]` to resize it, or to 0 to switch it off.

`/vwb` records nothing here, since it writes nothing — a dry run would
otherwise let anyone flood the record without writing a thing.

The `errors` array predates `items` and is still sent when there are failures;
everything in it also appears in `items`.

Authentication
--------------
	$ curl -H 'Authorization: changeme' -k https://localhost:9999/w/my/namespace/?val=127.0.0.1
	{"message":"ok","count":1}

Authentication is on unless `authenticate=false` is set in the configuration. Keys and their permissions are declared in the `[acl]` section; see below.

**Where to put the key.** Three ways, in the order you should prefer them:

| | | |
| --- | --- | --- |
| `acl_file`, or `[acl]` | best | A file the daemon reads. Nothing else on the host sees it, permissions are the filesystem's, and it is the only one that can carry more than one key with different rights. What the Helm chart uses. |
| `SIGHTINGDB_APIKEY` | acceptable | Replaces the built-in default key, for a container or a first run. Visible to whatever can read `/proc/<pid>/environ`, and to `kubectl describe pod` if set inline rather than from a Secret. |
| `-k <key>` | avoid | The same thing on the command line, where `ps` shows it to every process on the host. The daemon warns when you use it. |

The last two grant one key unrestricted access; scoped grants need the file.

REST Endpoints
==============
	/w: write (GET)
	/wb: write in bulk mode (POST)
	/vwb: check a bulk write without recording it (POST)
	/r: read (GET)
	/rs: read with statistics (GET)
	/rb: read in bulk mode (POST)
	/rbs: read with statistics in bulk mode (POST)
	/d: delete (GET)
	/stix: export a namespace as a STIX 2.1 bundle (GET)
	/_api/stix: export one or more namespaces as STIX 2.1 (POST)
	/_api/tier: set a namespace's tier and idle window (POST)
	/c: configure (GET, not implemented)
	/i: info (GET)
	/health: liveness and readiness, no key required (GET)
	/_api/openapi.yaml: this API as an OpenAPI 3 document (GET)

API reference
=============

[`doc/routes.md`](doc/routes.md) walks every route with a real request and the
response it actually returns, including the error cases and what each status
code means. Start there if you are writing a client by hand.

[`doc/openapi.yaml`](doc/openapi.yaml) describes the whole HTTP API — data,
STIX, storage and management — as an OpenAPI 3.0.3 document. Import it into
Postman (**File → Import**), Insomnia, Bruno or anything else that reads
OpenAPI, and every endpoint arrives with its parameters, example bodies and
responses.

A running instance also serves it, so you can always ask an instance to
describe *itself* rather than trusting a file that may be older:

	$ curl -k https://localhost:9999/_api/openapi.yaml -o sightingdb.yaml

That copy has the version rewritten to whatever the server actually is. Set
the API key once in Postman as a collection-level API-key auth with the header
name `Authorization` and the key as the value — no `Bearer` in front of it —
and every request inherits it.

A test asserts that every route the server registers appears in the document,
so an endpoint added without documentation fails the build rather than being
discovered by whoever imported the collection.

Status codes
------------

Every endpoint answers with JSON and a meaningful status code:

	200 OK         the request succeeded
	400 Bad Request malformed query string, JSON body, or a missing val=
	401 Unauthorized no Authorization header was sent
	403 Forbidden   unknown API key, or an attempt to reach the _config tree
	404 Not Found   no such namespace, or no such value inside it
	501 Not Implemented  /c

Configuration
=============

Either listener can be turned off, so one instance can serve HTTP, DNS, or
both. `enabled = false` under `[daemon]` runs DNS only; `enabled = false` under
`[dns]` (or simply omitting the section) runs HTTP only. Disabling both is a
startup error rather than a process that listens on nothing.

Beyond the listen address and TLS settings, `[daemon]` accepts:

	enabled           Serve the HTTP API (default true).

	dbdir             Directory for snapshots. Unset or blank runs in memory only.
	snapshot_interval Seconds between snapshots (default 300). 0 saves only on shutdown.
	sweep_interval    Seconds between eviction sweeps (default 60). 0 disables the sweeper.
	stats_retention   Hourly statistics buckets kept per value (default 0 = unlimited).
	shadow_ttl        Seconds a shadow sighting is kept (default 0 = forever).
	rejection_log     Rejected values kept in memory (default 1000, 0 = off).

The retention settings default to keeping everything, so upgrading an existing
install never starts discarding data on its own. The configuration shipped in
`etc/sightingdb.toml` sets 30-day windows for both, which is what bounds memory
growth — without them, statistics accumulate one bucket per hour per value and
`_shadow/*` grows for every distinct search, forever.

DNS lookups
===========

SightingDB can answer over DNS as well as HTTP, following the DNSBL/RBL
conventions, so anything that can already consult a blocklist — Postfix,
rspamd, Suricata, a shell script with `dig` — can query it unmodified.

	$ dig +short 4.3.2.1.malware.sdb.example.com
	127.0.0.1

	$ dig +short 9.9.9.9.malware.sdb.example.com
	127.0.0.3

	$ dig +short TXT 9.9.9.9.malware.sdb.example.com
	"count=15 first_seen=1786774648 last_seen=1786774648 consensus=1 ttl=86400 tags=\"\""

The TXT record carries every field the HTTP API reports. `tags` is quoted
because it is free-form, and a record too long for one DNS character-string is
split across several, which clients join back together.

A value that was never seen answers NXDOMAIN, which is both the DNSBL idiom and
what lets resolvers cache the negative. A value that was seen answers with a
`127.0.0.x` address whose last octet gives the order of magnitude: `1` is once,
`2` is single digits, `3` is tens, and so on up to `9`. A client that only
checks "did I get an address at all" works unchanged.

Three ways of spelling a value in the query name are supported, chosen per
namespace in the configuration:

	ip      4.3.2.1.malware.sdb.example.com    ->  1.2.3.4
	        (reversed octets, as RBLs do; IPv6 uses the ip6.arpa nibble form)
	domain  evil.com.domains.sdb.example.com   ->  evil.com
	base32  <base32 of the value>.hashes.sdb.example.com

TCP is supported, and a reply too large for a datagram comes back truncated so
the client retries over it.

### Before you enable it

**DNS has no authentication.** The `[acl]` section does not apply, so anything
reachable over DNS is readable by anyone who can send a packet. Accordingly:

* Only namespaces named under `[dns.namespaces]` answer at all; everything else
  in the database is NXDOMAIN, indistinguishable from a value that was never
  seen. Namespaces beginning with `_` are refused at startup.
* The listener binds to `127.0.0.1` unless you say otherwise.
* Names outside the configured zone are REFUSED rather than answered, so this
  can never act as an open resolver.
* `rate_limit` caps queries per second per source address, dropping rather than
  refusing once a source is over budget — an error reply is still an amplified
  packet. Responses are capped at 1232 bytes even if a client advertises more.
* Shadow sightings are off by default, since over DNS they would be an
  unauthenticated write path.

Management interface
====================

Point a browser at `/_management/` and sign in with an API key holding the
`admin` grant — `changeme` on a fresh install:

	[acl]
	changeme   = "rw, admin"
	feeds-only = "admin, r:feeds"

A namespace is a path, so the interface browses one the way a file manager
browses directories: `feeds` holds `feeds/misp/ips`, and a path can be a folder
and a namespace at once — holding values of its own while other namespaces sit
underneath it. Each level lists what is below it and the values stored at that
path, paged and filterable since a namespace can hold a great many.
`/_management/feeds/ips/` is a direct link to that namespace, so views are
bookmarkable. Ticking **search everywhere** looks through whole namespace names
instead of walking a level at a time.

**New namespace** creates one before it holds anything, nesting as deep as you
like: `misp/ips` under `feeds` creates the whole path. An empty namespace is a
real namespace — it is snapshotted, it survives a restart, and sweeps do not
reclaim it, since only namespaces a sweep *empties* are litter.

**Add values** records one value or a pasted list of them, optionally with
[tags](#tags), a TTL and the time they were seen. They are counted towards consensus exactly as a
`/w/` write would be; nothing added here is a second class of sighting. Writing
to a namespace that does not exist creates it, as it does everywhere else.

**Export STIX** downloads the namespace being browsed as a STIX 2.1 bundle; see
[Exporting](#exporting).

Clicking a value shows what the database knows about it: its tags, which can be
edited there and are what the STIX export reads; a histogram of when it was
seen, built from the hourly statistics already kept; and a force-directed graph
of **every namespace holding that value** — the point of consensus made
visible. Colour is the top-level namespace, shape says whether a node is the
value, a folder or a namespace, and size is how often the value was seen there.
Folders on the way down are drawn too, so namespaces sharing a path cluster
together. Click a node to browse to it.

Finding those namespaces is arranged to be cheap: `_all` already knows how many
namespaces hold the value, which gives the search something to stop at,
namespaces already in memory are searched first, and evicted shards are read
back only if that target has not been reached by then.

**Access is two-layered.** The `admin` grant is what reaches the interface at
all; ordinary read grants then decide which namespaces are visible inside it,
and write grants decide what may be created or added to. In the example above,
`feeds-only` signs in but sees only `feeds/*` — everything else answers `404`,
the same as a namespace that does not exist, so browsing cannot be used to
enumerate what is out of reach — and, having no write grant, it cannot create a
namespace or add a value anywhere. The relationship graph obeys the same rule:
it draws only namespaces the key may read, while the consensus figure beside it
still counts every namespace, so a scoped key can tell it is not seeing all of
them without being told their names.

An admin key is required **even when `authenticate = false`**. Turning
authentication off is a decision about the sighting API; it should not hand the
management interface to anyone who can reach the port.

The configuration view is **read-only**: settings are read from the file at
startup, so changing them means editing the file and restarting. The interface
reports what the server is actually doing rather than pretending to edit it.

Charts use [Apache ECharts](https://echarts.apache.org/), vendored into the
binary rather than loaded from a CDN so the interface works on a host with no
internet access. See `assets/README.md`.

Importing
=========

ZeroMQ
------

SightingDB can subscribe to a ZeroMQ publisher and record what it hears. The
usual source is MISP, whose publisher sends `<topic> <json>` frames:

	[zmq]
	endpoint = "tcp://misp.example.com:50000"
	topics = ["misp_json_attribute", "misp_json"]
	format = "misp"
	require_to_ids = true

	[zmq.types]
	ip-src = "misp/ips"
	domain = "misp/domains"
	md5 = "misp/hashes"

MISP's own tags come across as they are — `tlp:amber` means the same thing here
— alongside `misp-type:`, `misp-category:` and `misp-event:`, and the MISP type
is translated to a `stix-type:` where there is a one-to-one mapping, so the STIX
export can build a pattern without knowing anything about MISP. See
[Tags](#tags).

Attributes are read from `misp_json_attribute`, and from whole events on
`misp_json` including attributes nested inside objects. Only mapped types are
ingested unless `default_namespace` is set, MISP's own timestamps are preserved,
and `require_to_ids=true` limits ingest to attributes MISP flagged as
actionable. A publisher that goes away is retried rather than being fatal.

`format=native` instead reads `{"items":[{"namespace":..,"value":..}]}`, for
publishers that speak SightingDB directly. Note that in this mode the publisher
chooses its own namespaces, so only subscribe to a source you trust — the
`_config` tree is refused, but nothing else is.

This uses a native Rust ZeroMQ implementation, so the release binaries stay
self-contained; it interoperates with libzmq publishers like MISP's.

STIX 2.1
--------

	$ sightingdb -c /etc/sightingdb/sightingdb.toml --import-stix bundles/

Reads one file or every `.json` in a directory, then exits. Three kinds of
object are understood:

* `observed-data` — `number_observed` observations between `first_observed` and
  `last_observed`, following `object_refs` (and the deprecated embedded
  `objects`).
* `sighting` — its `count` and `first_seen`/`last_seen`, resolving
  `sighting_of_ref` and `observed_data_refs`.
* `indicator` — the literal values in its STIX pattern.

Counts and windows survive the import: an observed-data seen 12 times between
two instants becomes a value with `count=12` whose `first_seen` and `last_seen`
bracket that window. A file object yields one sighting per hash. A bundle that
fails to parse is reported and skipped so the rest of the import continues.

What the bundle says *about* a value is kept as [tags](#tags): the observable
type, the indicator's id and `indicator_types`, TLP markings (by reference to
the well-known ids as well as by definition), `confidence`, the identity that
published it and the identities in `where_sighted_refs`, the name, and
`valid_until`. That is what lets [the export](#stix-21-1) put the value back on
the wire as STIX without inventing the parts a bare value cannot hold.

Tags
====

A sighting on its own is `<namespace, value, count, first_seen, last_seen>`.
That is enough to answer "how often, and when" and nothing else — it does not
say what the value *is*, who saw it, or how it may be shared. Tags carry that,
and are what makes the STIX export able to produce something a consumer can act
on.

A tag set is a **comma-separated list**, each entry either a bare label or
`key:value`:

	stix-type:ipv4-addr, tlp:amber, confidence:80, identity:Beta Cyber Intelligence Company

Comma is the separator because the interesting values contain spaces, so a
value can contain anything *except* a comma — colons included, which is what a
URL or an RFC 3339 timestamp needs. A key may repeat; `indicator-type` below
does. Whitespace around entries is trimmed.

Tags arrive three ways: the importers write what the source said (see
[Importing](#importing)), `tags=` on a write adds to them, and the management
interface edits them by hand. Writes **merge**, so two feeds each contribute
what they know; only the management interface's tag box replaces a set, which
is how a wrong tag comes off.

Vocabulary
----------

These are the keys the STIX export understands. Anything else is kept and
ignored, so your own conventions cost nothing.

| Tag | What it does |
| --- | --- |
| `stix-type:<type>` | The observable type, and so the pattern: `ipv4-addr`, `domain-name`, `url`, `email-addr`, `mutex`, `windows-registry-key`, or `file.<ALGORITHM>` for a hash. Without it the type is taken from the `[stix.types]` mapping for the namespace, and failing that from the shape of the value. |
| `stix-id:indicator--<uuid>` | Reuse this indicator id instead of minting one. Written by the STIX importer, so a value that arrived as STIX goes back out under the id its publisher gave it. |
| `indicator-type:<value>` | Adds to `indicator_types`. Repeatable. The STIX vocabulary is `malicious-activity`, `anomalous-activity`, `benign`, `compromised`, `attribution`, `unknown`, ... |
| `tlp:<white\|green\|amber\|red>` | Marks the indicator and the sighting with the matching TLP marking definition, which is included in the bundle. |
| `confidence:<0-100>` | STIX `confidence` on both objects. Out-of-range values are ignored. |
| `identity:<name>` | Who saw it: becomes an `identity` object referenced from `where_sighted_refs`. Repeatable. Without one, the sighting is attributed to the publishing identity. |
| `name:<text>` | Indicator `name`. |
| `description:<text>` | Indicator `description`. |
| `valid-until:<rfc3339 or unix seconds>` | Indicator `valid_until`. Otherwise a TTL supplies one, counted from the last sighting exactly as the database counts it. |

The importers also write tags that the export does not read but a person might
want: `misp-type:`, `misp-category:`, `misp-event:`, and MISP's own tags as
they were published.

Tags are visible wherever a value is: in `/r` and `/rs` responses, in the DNS
TXT answer, and in the management interface.

Exporting
=========

STIX 2.1
--------

	$ curl -k -H 'Authorization: changeme' https://localhost:9999/stix/feeds/misp/ips

One namespace becomes a STIX 2.1 bundle shaped after the OASIS ["Sighting of an
Indicator"](https://oasis-open.github.io/cti-documentation/examples/sighting-of-an-indicator)
example: for each value an `indicator` carrying the pattern and a `sighting`
pointing at it with the count and the observation window, plus the `identity`
objects they refer to and any TLP markings used.

	{
	  "type": "bundle",
	  "id": "bundle--...",
	  "objects": [
	    {"type": "identity", "id": "identity--...", "name": "SightingDB",
	     "identity_class": "system", ...},
	    {"type": "indicator", "id": "indicator--...",
	     "pattern": "[ipv4-addr:value = '198.51.100.7']", "pattern_type": "stix",
	     "indicator_types": ["malicious-activity"], "confidence": 80,
	     "valid_from": "2020-09-13T12:26:40.000Z", ...},
	    {"type": "sighting", "id": "sighting--...", "count": 12,
	     "first_seen": "2020-09-13T12:26:40.000Z",
	     "last_seen": "2020-09-14T09:03:11.000Z",
	     "sighting_of_ref": "indicator--...",
	     "where_sighted_refs": ["identity--..."],
	     "x_sightingdb_namespace": "feeds/misp/ips", ...}
	  ]
	}

The export is a read, so it answers to the same key and the same permissions as
`/r`. The management interface's **Export STIX** button downloads the bundle for
the namespace being browsed, by calling the same automation endpoint below — so
what the button gives you and what a script gets cannot drift apart.

For automation
--------------

	$ curl -k -X POST https://localhost:9999/_api/stix \
	    -H 'Authorization: changeme' -H 'Content-Type: application/json' \
	    -d '{"namespaces": ["feeds/misp/ips", "feeds/otx/ips"], "q": "10.0.", "limit": 5000}'

`POST /_api/stix` is the same export for a script: a namespace is a path, so
POST saves encoding it into a URL, and several can be gathered into one bundle.

| Field | |
| --- | --- |
| `namespaces` | The namespaces to export. |
| `namespace` | Shorthand for one, so a one-liner stays a one-liner. |
| `q` | Substring filter over values, as when browsing. |
| `limit` | Values read per namespace: 10,000 by default, 100,000 at most. |

**Every namespace is authorized on its own**, exactly as a bulk read is: naming
one the key may not read refuses the whole request with `403` rather than
quietly returning the half it is allowed. `_config` is refused outright. As
everywhere else on the data API, `authenticate = false` means there is no ACL
to consult — the switch that opens `/r` opens this too.

Because ids are deterministic, gathering namespaces is worth doing: a value in
two of them has **one** indicator between them and a sighting each.

	X-SightingDB-Exported: 3
	X-SightingDB-Skipped: 0
	X-SightingDB-Truncated: false
	X-SightingDB-Missing: feeds/nope

A namespace that does not exist is named in `X-SightingDB-Missing` and the rest
still come back; if none of them exist the answer is `404`.

**Ids are deterministic.** Every id is a UUIDv5 derived from a SightingDB
namespace UUID and the thing it names, which has two consequences worth
relying on: exporting the same data twice produces the same bundle byte for
byte, and the same value in two namespaces produces *one* indicator with a
sighting each — so a consumer merging both bundles sees one indicator sighted
twice rather than two indicators. A `stix-id:` tag overrides the minted id.

Who the bundle is published as comes from the configuration:

	[stix]
	identity = "Alpha Threat Analysis Org."
	identity_class = "organization"

The default is `SightingDB` with class `system` — a database reporting what it
saw. That identity is the `created_by_ref` of everything in the bundle, and the
`where_sighted_refs` of any sighting whose value carries no `identity:` tag.

**A value with no observable type is skipped**, because a STIX indicator is a
pattern and there is no pattern without a type. The response says how many that
was, rather than leaving it to be noticed:

	X-SightingDB-Exported: 412
	X-SightingDB-Skipped: 3
	X-SightingDB-Untyped: 0
	X-SightingDB-Truncated: false

Tag those values with `stix-type:`, or map the namespace in `[stix.types]` so
the whole namespace has a type. The management interface has a Tags column on
the values list for this, and an Observable column beside it showing which
values currently have none.

`limit=` caps how many values one export reads (10,000 by default, 100,000 at
most); `X-SightingDB-Truncated` says when the namespace held more.

### Exporting a subtree

An export covers the one namespace named. Add `recursive` — `?recursive` on
`GET /stix/<namespace>`, or `"recursive": true` in the `POST /_api/stix` body —
to take every namespace below it as well, in one bundle. The management
interface offers it as a checkbox when you press **Export STIX**.

	X-SightingDB-Namespaces: 4
	X-SightingDB-Exported: 412

"Below" matches whole path segments, so `feeds` covers `feeds` itself and
`feeds/misp/ips`, and never `feeds-internal` — a different namespace, not a
child. The namespace you name is authorized as always, so asking for a subtree
your key may not read is refused; a namespace *found* underneath that it may
not read is left out instead, exactly as it is absent from the namespace tree.

`limit=` is the budget for the whole export rather than for each namespace in
it, so a recursive export of a large tree reads what you asked for and no more.
`X-SightingDB-Truncated` says when the budget ran out with more to give.

Note that this changes `limit=` for an existing caller that named several
namespaces in one `POST /_api/stix`: the limit used to apply to each. Naming a
single namespace is unaffected.

### Exporting everything anyway

To take the whole namespace regardless, add `untyped=include` — `?untyped=include`
on `GET /stix/<namespace>`, or `"untyped": "include"` in the `POST /_api/stix`
body. The management interface asks which you want when you press **Export
STIX**.

Values nothing could identify then go out as the custom observable
`x-sightingdb-value`, each indicator carrying `x_sightingdb_untyped: true`:

	{"type":"indicator","pattern":"[x-sightingdb-value:value = 'whatever this is']",
	 "pattern_type":"stix","x_sightingdb_untyped":true, ...}

	X-SightingDB-Exported: 415
	X-SightingDB-Skipped: 0
	X-SightingDB-Untyped: 3

STIX 2.1 has no plain-text observable, and the specification requires a custom
type to carry an `x-` prefix, so this is our own rather than a standard one.
Naming it rather than borrowing something close — `artifact`, say — is what
keeps the bundle honest: a consumer is told this is a value SightingDB could
not classify, instead of being handed a pattern that claims something false
about it. Filter on `x_sightingdb_untyped` rather than on the type name.

A value carrying `stix-type:` explicitly is never counted as untyped, even
where that type is `x-sightingdb-value`: someone said what it was on purpose.
Skipping stays the default, so an existing export does not change shape.

Round trip
----------

Importing a bundle and exporting it again preserves what both formats can hold:
the importer writes the observable type, the indicator id, its `indicator_types`,
markings, confidence, the identities and the validity window as tags, and the
exporter reads them back. See [Tags](#tags).

Counts survive too. A bundle that pairs an indicator with a sighting of it —
which is what this export produces — is read as *one* observation of the
sighting's count, not as the sighting plus the indicator again. An indicator
nothing points at is still an observation of its own.

Access control
==============

API keys and what each may reach are declared in an `[acl]` section:

	[acl]
	admin     = "rw, admin"
	analyst   = "r"
	feed-misp = "rw:feeds/misp"
	mixed     = "r, w:staging"

Each entry is `<apikey> = <grant>[, <grant>...]`. A grant is `r`, `w` or `rw`,
optionally scoped with `:<namespace prefix>`; without a prefix it covers every
namespace. A key's grants are unioned, and anything not granted is denied.

Prefixes match **whole path segments**, so `rw:feeds/misp` covers
`feeds/misp` and `feeds/misp/ips` but not `feeds/misp-internal` or `feeds`.

**A refusal is logged.** The client is told `403`; the server logs which
address asked, what it asked to do, to which namespace, and a short fingerprint
of the key — never the key itself, since a log is copied and read by more
people than a credential should be. Unlike the answer to the client, the log
distinguishes a key that does not exist from one that exists but is not allowed
there: hiding that from a caller stops it probing, while telling the operator
is the whole point.

	WARN sightingdb::handlers - Refused 10.0.0.9:5000: no such key c211eb3e, asked to write 'feeds/ips'
	WARN sightingdb::handlers - Refused 10.0.0.9:5000: key 4f2a91bd may not read 'private'

A request with no key at all is logged at debug, since on an open port that is
ordinary noise.

A refusal is always `403` with the same body whether the key is unknown or
merely out of scope, so that probing cannot tell valid keys from invalid ones.

`-k <key>` still overrides everything with a single full-access key, replacing
the built-in `changeme`.

Keys are stored in the configuration in the clear. Keep that file readable only
by the user the daemon runs as, and serve over TLS.

### Upgrading

Older versions kept API keys in the database and gave every key full access to
everything. If the configuration has no `[acl]` section, keys restored from a
snapshot keep exactly that access, so upgrading does not lock out a running
deployment — the daemon logs a warning telling you to scope them. Adding an
`[acl]` section makes it authoritative, and the keys in the snapshot are then
ignored.

Persistence
===========

The database is written to `<dbdir>/sightingdb.json` every `snapshot_interval`
seconds and once more on a clean shutdown. Snapshots are written to a temporary
file and renamed into place, so a crash mid-write leaves the previous snapshot
intact rather than a truncated one; at most `snapshot_interval` seconds of
writes are at risk.

A snapshot that exists but cannot be parsed is a fatal startup error rather than
a silent fresh start, since starting empty would look like total data loss and
the next save would make it real.

API keys are *not* in the snapshot: they come from the configuration, so
permissions are reviewable and can live in version control.

Keeping it in memory, or not
----------------------------

Storage is one file per **top-level namespace** — a shard — so `feeds/misp/ips`
and `feeds/otx/domains` live in `feeds`, and a shard is paged in and out as a
unit. Which is why the settings below belong to the shard and cover everything
under it: there is no finer setting because there is no finer eviction.

	[storage]
	default_tier = "hot"
	warm_idle = 3600
	tiers_file = "tiers.toml"

	[storage.tiers]
	archive = "cold"
	feeds = { tier = "warm", warm_idle = 86400 }
	staging = { warm_idle = 300 }

| Tier | |
| --- | --- |
| `hot` | Never evicted. |
| `warm` | Written out and dropped once untouched for `warm_idle` seconds. |
| `cold` | Dropped at the next sweep once idle. |

`warm_idle` is how long "untouched" means, and an entry may set it for one shard
rather than taking the global one — a feed worth keeping for a day and a staging
tree worth keeping for five minutes are both warm, on different windows. An
entry may also set the window alone, leaving the tier to `default_tier`.

Evicted data is not gone: the shard is read back when it is next used, so
`cold` costs one load per burst of activity rather than one per operation.

**Changing it while it runs.** Both halves are editable from the management
interface — a tier control and, for a warm shard, the number of seconds beside
it — and over HTTP for automation:

	$ curl -k -X POST https://localhost:9999/_api/tier \
	    -H 'Authorization: changeme' -H 'Content-Type: application/json' \
	    -d '{"namespace": "feeds/misp/ips", "tier": "warm", "warm_idle": 86400}'
	{"shard":"feeds","tier":"warm","warm_idle":86400,"own_tier":true,"own_warm_idle":true,
	 "effect":"'feeds' and everything under it is dropped after 86400s untouched"}

Name any namespace and the setting lands on its shard, which the reply says out
loud: a change made from a row deep in a tree is a change to everything beside
it. Either field takes `"default"` to stop overriding and go back to
`[storage]`, and a change is written to `tiers_file` so it survives a restart.

Certificate expiry
------------------

A server serving TLS reports when its certificate runs out, and says so loudly
before it does — at startup, in the log, and in the management interface:

	WARN sightingdb::tls - The TLS certificate /etc/sightingdb/ssl/cert.pem
	expires in 10 day(s). Replace it before then.

**Configuration** shows it with the days remaining, amber under 30 days and red
once expired. Thirty days is the line: long enough to notice, order and install
a replacement without hurrying.

	"tls": {"cert": "/etc/sightingdb/ssl/cert.pem",
	        "not_after": 1792530174, "days_left": 10}

`/_management/api/info` reports `not_after` as an absolute moment rather than a
countdown, so the answer cannot go stale between being sent and being read; the
page works out "soon" from the same threshold the server logs at, so the two
cannot disagree.

A certificate made by `--setup` is good for a year, so this starts mattering
about eleven months in — which is exactly when nobody is thinking about it.

Setting one up
--------------

`sightingdb --setup` asks how the server should run before anything else:

	How should this server run?
	  1) standalone          stores everything, no other servers (the usual answer)
	  2) node in a galaxy    stores namespaces; other servers forward to it
	  3) router              stores nothing of its own; forwards to its peers
	  4) node and router     stores some namespaces and forwards the rest

Standalone is the usual answer, and the three others are the shapes a galaxy is
built from. For anything but standalone it also asks which namespaces the server
stores, and for a name — unique in the galaxy, defaulting to the hostname,
because two servers sharing a name would each take the other's contribution for
their own.

The configuration it writes is commented throughout, so the file is where you go
to change your mind rather than somewhere to look settings up from. For a
forwarding mode it includes a `[galaxy]` template, **commented out**: a peer's
key comes from that peer's own ACL, which does not exist until that peer has
been set up. So install the other servers first, create a key on each for this
one, then fill it in and restart. Until then the server has no peers and
reports itself as a plain node, which is what it is.

What a server stores
--------------------

By default a server stores every namespace, which is what a standalone one
wants. `namespaces` in `[storage]` narrows it:

	[storage]
	namespaces = ["/"]                  # everything, said explicitly (the default)
	#namespaces = ["feeds", "threats"]  # those subtrees and nothing else
	#namespaces = []                    # nothing of its own: a pure router

Prefixes match whole path segments, so `feeds` holds `feeds/misp/ips` and never
`feeds-internal`. The internal namespaces — `_all`, `_shadow/*`, `_config` — are
always held whatever this says, because a server cannot keep its own consensus
tally otherwise; listing one is an error.

A write to a namespace the server does not store is refused with **421
Misdirected Request**: the request arrived somewhere that cannot serve it,
which is neither forbidden nor missing and needs to stay distinguishable from
both. Reads of a namespace it does not hold are `404`, as they already were.

	$ curl -k 'https://localhost:9999/w/other?val=1.2.3.4'
	{"message":"This server does not store 'other'. Its [storage] namespaces list
	  says what it holds."}                                               # 421

Counting, and node identity
---------------------------

A sighting is counted **per server**, not as one running total. The stored
shape is a map from node id to count, and what a client is given is the sum —
so nothing a reader sees changes.

The reason is that an increment is not idempotent. `/w` means "add one", so any
sync that read a peer and wrote back what it found would double the count, and
double it again on the next round. Keeping each server's contribution separate
makes merging two copies a union of disjoint entries: applying the same merge
twice changes nothing, which is what makes replication safe to retry.

	[daemon]
	node_id = "local"

`local` is right until the server joins a galaxy. Then give each one its own
name — two sharing an id would each take the other's contribution for their own,
and a merge would lose one of them. Letters, digits, `-`, `_` and `.`, up to 64
characters.

Changing it later is safe but not free: the old name keeps its contribution in
the stored data and the new one starts from zero, so the total is unchanged
while the attribution splits in two.

Hourly statistics are kept the same way and reported merged, so `/rs` shows one
bucket per hour however many servers contributed to it. `stats_retention` is
counted within each server's own buckets, since retention means "how far back
this server remembers" rather than something to be shared out.

### Upgrading

The snapshot format is version 2. A version 1 database — one total per value —
**opens and is migrated on load**: the totals become the reading server's own
contribution, which is the only thing they can be, and the file is rewritten in
the new shape on the next save. Nothing to do by hand.

	INFO  ...feeds-eacacfab.json.zst is version 1; migrating to version 2 on load

A snapshot from a *newer* build is refused rather than rewritten, so a
downgrade cannot quietly drop fields it does not know about.

### Syncing between servers

`POST /_api/merge` folds a peer's copy of a value into this server's. It is not
a sighting — nothing is counted — and every field combines by a rule that
ignores order and repetition, so the same merge can be sent twice and two peers'
copies can arrive either way round:

	counts      the greater of the two, per server
	stats       the same, per hourly bucket
	first_seen  the earlier          last_seen   the later
	tags        the union
	ttl         the shortest non-zero one, zero meaning never

Read what to send with `?for_merge`, which answers in exactly the shape the
merge route takes:

	$ curl -k 'https://localhost:9999/r/feeds/ips?val=1.2.3.4&noshadow&for_merge'
	{"counts":{"node-a":3},"stats":{"node-a":{"1791658800":3}},
	 "first_seen":1791660347,"last_seen":1791660347,"tags":"","ttl":0}

The counts are **per server**, which is the whole point: offering a total would
make the receiver attribute every server's sightings to the sender, and two
servers exchanging totals inflate each other without bound.

An entry naming the receiving server is ignored and reported in
`ignored_self` — a peer does not get to say what this server has seen.

Merging is authorized as a write, so a peer's key bounds what it may merge
exactly as it bounds what it may write.

`changed: false` on an item means the local copy already held everything
offered. That is a success: during catch-up it is how a caller learns it has
converged.

Galaxy
------

`[galaxy]` lists the other servers this one knows about:

	[galaxy]
	max_hops = 4
	peers = [
	  { url = "https://node-a.example:9999", key = "..." },
	  { url = "https://node-b.example:9999", key = "..." },
	]

**Nothing is forwarded yet.** At this stage the peers are parsed, validated and
reported — `/_management/api/info` carries a `role` object describing what this
server stores and who it knows — so that a topology can be described before it
can be used.

Each peer carries the key this server authenticates to it with, and **that key
is the bound on what this server may do there**. A key granted `rw:feeds` on the
peer cannot write anywhere else, however this server is configured or
compromised, because the peer's own ACL decides. Give each one the narrowest
grant that does the job.

`max_hops` is what stops a miswired cascade: a cycle inflates every count that
travels round it, so it is a limit rather than a tuning knob. A peer may itself
have peers, so a router in front of routers is allowed.

### Forwarding

A server that does not store a namespace passes the request to the peers that
do, so a client talks only to the entry point and never needs to know the
topology.

	peers = [
	  { url = "https://node-a:9999", key = "..." },
	  { url = "https://node-b:9999", key = "...", namespaces = ["feeds"] },
	]

`namespaces` on a peer says what it holds — absent means a full mirror. It is
declared here rather than asked of the peer, because discovery would need the
peer key to carry an `admin` grant and the point of that key is to be the
narrowest thing that works. The cost is that it must agree with the peer's own
`[storage] namespaces`.

**Writes fan out** to every live mirror of the namespace. **Reads go to one**,
chosen by hashing the value over the mirrors, so the same value is always read
from the same place while the mirror set is unchanged — without that, two
consecutive reads could be served by mirrors at different stages of catching up
and show a count going *down*.

A mirror being down does not fail a write: the mirror that took it is what the
others catch up from. What is refused is a write that reached no mirror at all.

	$ curl -k 'https://lb:9999/w/feeds/ips?val=1.2.3.4'   # lb stores nothing
	{"message":"ok","count":1,"new":true}

Answers carry `X-SightingDB-Forwarded: 1`. A request for a namespace nowhere in
reach is `421`; a cascade wired in a loop is `508`, caught by `max_hops`.

**A forwarded write is counted for the server it came from**, not for each
mirror that stores it. Without that, one write fanned out to two mirrors is
counted twice over and merging them adds the two together — three writes
through a load balancer became six once the mirrors synced. The origin travels
in `X-SightingDB-Origin` and is passed along unchanged, so the attribution is
the entry point the client actually talked to.

### Catching up

A server that was down comes back, finds it is behind, and fills in from a peer
that was up — on a timer, with nobody touching it.

	[galaxy]
	sync_interval = 300     # seconds between passes; 0 switches it off

Each pass asks every peer which namespaces it holds, keeps the ones this server
stores, and walks `/r/<namespace>?for_merge` in pages, applying each in place.
Namespaces created while this server was away are found too, through
`/_api/namespaces` — which exists for that and nothing else.

The first pass after startup is a full one, because a server that has just
started has no idea what it missed. Later passes use `?count` as a trigger and
skip a namespace whose peer holds no more values than this one — O(1) on both
sides, which is what makes it usable on a timer. That is a heuristic rather
than a proof: equal counts do not mean equal contents. The full pass on startup
is what stops it being load-bearing.

**While a server is catching up it says so**, in `catching_up` on `/health`. A
router reads that and sends reads to a mirror that is current instead, because
one still catching up would under-report. Writes go to it throughout: they land
directly, and the pass fills in the history behind them. Withholding writes
instead would mean the target keeps moving and a pass under load would never
provably finish.

If every mirror is catching up, one answers anyway. An under-reported count
beats no answer, and refusing would make a whole galaxy unreadable for as long
as it took to start.

	nb down      1.2.3.4=3/-  9.9.9.9=4/-  7.7.7.7=2/-
	+3s          1.2.3.4=3/3  9.9.9.9=4/4  7.7.7.7=2/2

### Searches are recorded where the client is

A read through a router records its shadow sighting **on the router**, and the
forwarded read carries `noshadow` so the mirror serving it records nothing. A
search is then counted once, where the client actually was, instead of once on
whichever mirror happened to answer:

	five reads through a router
	  shadow on the router  5
	  shadow on node-a      0
	  shadow on node-b      0

That also means a read costs the mirrors no write at all, which is what keeps
serving more clients from multiplying write load across a galaxy.

### Consensus across a galaxy

A server in front of a galaxy keeps the galaxy-wide tally, because it sees the
*logical* write — one namespace, one value — while mirroring is a detail below
it. Read it back with `/r/_all?val=<value>`, which is an ordinary namespace read
and needs no endpoint of its own.

	1.2.3.4 written into alpha/one, alpha/two (node-a) and beta/one (node-b)
	  router 3   node-a 2   node-b 1     only the router is right

It is kept by counting forwarded writes the mirrors report as `new`, so it only
ever rises: values expire and namespaces are deleted on the nodes, consensus is
released there, and neither reaches anything in front of them.

	[galaxy]
	reconcile_interval = 3600   # seconds; 0 switches it off

So it is rebuilt on a timer by surveying the galaxy — for each namespace, which
values it holds — and taking consensus to be the number of *namespaces* holding
each value. A set rather than a sum, because the same namespace mirrored three
times is still one namespace.

This walks the galaxy, which is why it has its own long interval. It is a
repair; the incremental tally answers reads in between, and a pass that finds
nothing wrong logs nothing.

### Keys are gossiped, not shared

`acl_file` is rewritten by whichever server served the management request, so
saving a key on one would otherwise leave the others ignorant of it. A change is
now passed on to the peers through **each peer's own management interface** —
which means the peer's ACL decides whether to accept it.

That is what makes this safe without a shared file: a server can only
administer a peer it holds an `admin` key for. A server holding a narrow
`rw:feeds` key there cannot change anything, so gossip flows from whoever holds
the credentials towards the servers they administer, and a narrowly-scoped peer
cannot push back.

	save 'analyst' on the router
	  router  [analyst, changeme]
	  node-a  [analyst, changeme, lb-admin]   took it
	  node-b  [changeme, lb-narrow]           not ours to administer

Safe to repeat: saving a key *sets* its grants rather than adding to them. A
change is pushed as it happens, and this server's keys are offered again every
`gossip_interval` (600s by default; 0 switches the periodic offer off) so a
peer that was down for a change picks it up.

> **A revocation made while a server was offline does not reach it.**
>
> The periodic offer is deliberately additive — it never deletes a key a peer
> has and this server does not, because this server is not necessarily the only
> place keys are managed, and a timer that quietly revoked a key added elsewhere
> would be worse than one that failed to propagate a deletion.
>
> So a key revoked while a peer was down **stays live on that peer**. Repeat the
> revocation once it is back.
>
> The management interface points this out rather than leaving you to find it:
> the **Keys** page shows a "Keys across the galaxy" panel listing, per peer,
> which keys this server revoked that the peer still accepts. It appears only
> when there is something to say.

	revoked_but_present  ["doomed"]      <- revoked here, still live there
	only_on_peer         ["lb-admin"]    <- the peer's own keys; ordinary
	missing              []              <- not sent yet; the offer fixes it

`only_on_peer` is kept separate on purpose. A peer has keys of its own —
including the one this server authenticates with — and reporting those as stale
revocations would make the panel cry wolf every time it was opened.

The record of revocations is held in memory, so it is lost on restart: after
one, a revocation that never landed moves from `revoked_but_present` into
`only_on_peer` and stops being flagged. Checking the peer's own key list is then
the way to find it.

Tiers are not gossiped yet; `tiers_file` has the same shape of problem.

### Seeing the galaxy

`GET /_management/api/galaxy` walks the peers, so one request describes a whole
cascade: this server, its peers with their health, and each peer's own answer
under `below`. The management interface draws it under **Galaxy** — routers as
diamonds, full mirrors and partial mirrors in different colours, and red kept
for a server that is not answering.

Misconfiguration is refused at startup rather than at the first forward — a
peer without a key, a url without a scheme, the same peer listed twice, or
`max_hops = 0`.

The `role` object reports which of the three a server is:

	$ curl -k -H 'Authorization: changeme' https://localhost:9999/_management/api/info
	{..., "role": {"kind": "router", "mirrors_everything": false,
	       "namespaces": [], "peers": ["https://node-a:9999"], "max_hops": 3}}

`kind` is `node` (stores namespaces, forwards nothing), `router` (stores none of
its own, exists to forward) or `both`. It describes a configuration, not a type:
any server can be any of them. Peer **keys are never in the response** — they
are credentials.
It needs write access to the namespace, and a configured `tiers_file` — without
one the tiers are whatever the configuration says and cannot be changed here.

Containers and Kubernetes
=========================

	$ make            # what every target does
	$ make deploy     # into the cluster kubectl already points at
	$ make dev        # into a kind cluster this creates for the purpose

Both build a container image from the source in your working tree, install the
Helm chart, wait for the pod and then write a sighting through it to prove it
works. The difference is the cluster:

* **`make deploy`** uses whatever `kubectl config current-context` names —
  Docker Desktop, Rancher Desktop, minikube, k3d, or a kind cluster you made
  yourself. It hands the image over the way that cluster expects: `kind load`,
  `k3d image import`, `minikube image load`, or — for current Docker Desktop,
  whose node is a container of its own rather than a user of this machine's
  image store — `docker save` piped into the node's containerd. `make load`
  does only that step.
* **`make dev`** creates a kind cluster named `sightingdb` first, and
  `make teardown` deletes it and everything in it.

Then `make port-forward` and `make admin-key` get you into the management
interface, and `make uninstall` removes the release.

**These targets refuse to touch a cluster that is not local.** They install,
delete and write data, so they check the context against a list of local ones
(`docker-desktop`, `rancher-desktop`, `minikube`, `colima`, `kind-*`, `k3d-*`,
`k3s-*`) and stop if it is anything else — a `kubectl config use-context` away
from production should not be one keystroke away from `make deploy`. The
context is resolved once and passed explicitly to every helm and kubectl call,
so nothing can be redirected midway. `ALLOW_ANY_CONTEXT=1` overrides it
deliberately, and `CONTEXT=<name>` picks one without switching your current
context.

A cluster that is not local — one that has to *pull* the image — needs it in a
registry it can reach:

	$ make image-push IMAGE=registry.example.com/sightingdb TAG=0.5.6
	$ helm upgrade --install sightingdb ./helm/sightingdb \
	    --set image.repository=registry.example.com/sightingdb --set image.tag=0.5.6

Docker
------

	$ docker build -f docker/Dockerfile -t sightingdb:dev .
	$ docker run --rm -p 9999:9999 -v sightingdb:/var/lib/sightingdb \
	    -e SIGHTINGDB_APIKEY=$(openssl rand -hex 20) sightingdb:dev

`SIGHTINGDB_APIKEY` replaces the built-in default key. The daemon reads the
variable itself rather than the entrypoint turning it into `-k`, so the key
does not appear in `ps`. For more than one key, or for keys with scoped grants,
mount an `acl_file` — see [Authentication](#authentication).

The image builds this working tree — not a clone of the repository — in a
builder stage and ships the binary on `debian-slim`. Mount a volume at
`/var/lib/sightingdb`: without one the database lives only as long as the
container. TLS is off in the baked-in configuration, because a certificate
built into an image is the same certificate for everyone who pulls it;
terminate TLS in front of it, or mount a key and certificate and set `ssl`,
`ssl_cert` and `ssl_key`.

**It never runs as root.** The image creates a `sightingdb` user and group at
uid/gid 10001 and starts the daemon as that user, the same way the systemd unit
does with `User=sightingdb`. SightingDB has no privilege-dropping code and needs
none: nothing it opens requires root, so there is no window where it is root and
nothing to get wrong in the dropping. The one exception is a port below 1024 —
the DNS listener on 53 — and that is a capability rather than a reason to start
as root:

	$ docker run --cap-add NET_BIND_SERVICE ...        # or publish 53:5353

Two consequences worth knowing. A **bind mount** from the host arrives owned by
whoever owns it on the host, so it has to be writable by uid 10001
(`chown 10001:10001 ./data`) — a named volume inherits the image's ownership and
needs nothing. And because the data directory is owned by group 0 and is
group-writable, the image also works where the uid is assigned rather than
chosen, as on OpenShift.

Helm
----

The chart is in [`helm/sightingdb`](helm/sightingdb), with its own
[README](helm/sightingdb/README.md). Every option in
[`etc/sightingdb.toml`](etc/sightingdb.toml) has a value, and the configuration
file is rendered from them:

	helm install sightingdb ./helm/sightingdb \
	  --namespace sightingdb --create-namespace \
	  --set image.repository=ghcr.io/you/sightingdb --set image.tag=0.5.5

Worth knowing before you deploy it:

* **One pod.** SightingDB holds its data in memory and snapshots it to one
  directory, so a second replica would be a second, unrelated database — the
  chart is a StatefulSet of one and has no `replicaCount`. Scale by giving the
  pod more memory.
* **The admin key lives in a Secret.** `acl.keys` renders an `acl.toml` into
  `<release>-acl`, alongside an `admin-key` entry holding the first key with the
  `admin` grant — `make admin-key`, or:

		kubectl -n sightingdb get secret sightingdb-acl -o jsonpath='{.data.admin-key}' | base64 -d

  Leave `acl.keys` empty and one is generated on install and kept across
  upgrades, so no known key is ever shipped.
* **Keys are seeded, not managed.** An init container copies that `acl.toml`
  onto the data volume, because the management interface rewrites the file and a
  mounted Secret is read-only. Keys created in the interface therefore live on
  the volume and survive upgrades; they are not written back into the Secret.
* **Namespaces can be created up front.** `bootstrap.namespaces` creates them
  through the management API after install and upgrade, so a deployment starts
  with the structure its writers and ACL prefixes assume rather than with
  nothing until the first write.
* **`/health` is the probe.** It needs no API key whatever `authenticate` is
  set to, and reports the version, uptime and how many shards are in memory.
  There is no separate readiness path: the snapshot is restored before the
  listener is bound, so an answer at all means the database is up.

Tests
=====

	cargo test

`tests/` also holds Python scripts that exercise a running server; they require the SightingDB Python client library.