code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

README.md raw
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
```
▐▄▄▌ ▐▄▄▌ ▐▄▄▄▄▄▌ ▐▄▄▄▄▄▄▄▄▌ ▐▄▄▄▄▄▄▄▄▌  ▐▄▄▄▄▄▌▐▄▄▄▄▄▄▌
▐██▌ ▐██▌▐██▌ ▐██▌▐██▌▐█▌▐██▌▐██▌▐█▌▐██▌▐██▌    ▐██▌ ▐██▌
▐███████▌▐███████▌▐██▌▐█▌▐██▌▐██▌▐█▌▐██▌▐████▌  ▐██████▌
▐▀▀▌ ▐▀▀▌▐▀▀▌ ▐▀▀▌▐▀▀▌   ▐▀▀▌▐▀▀▌   ▐▀▀▌▐▀▀▌    ▐▀▀▌ ▐▀▀▌
▐▄▄▌ ▐▄▄▌▐▄▄▌ ▐▄▄▌▐▄▄▌   ▐▄▄▌▐▄▄▌   ▐▄▄▌ ▐▄▄▄▄▄▌▐▄▄▌ ▐▄▄▌
```

# Hammer

**tools over SSH** — a BBS-style terminal you connect to with your SSH key, plus the
HTTP service behind it. Weather is the first tool; the platform is being built around it.

Hammer is a single static Go binary. Fonts, translations, templates and page assets are
embedded with `go:embed`, so there are no runtime dependencies, no nginx in front, and
HTTP and HTTPS are served natively.

---

## Status

Hammer is an independent project. Its HTTP weather service, which is what most of this
document describes, descends from open-source code; [NOTICE](NOTICE) says which.

| Part | State |
|---|---|
| HTTP weather service | working: views, formats, 74 languages, per-IP rate limit |
| SSH front-end (menu, profiles, saved cities, tools) | working |
| SSH tools (net, qr, hash, jq, age, csv, base64, ids; optional lychee/DuckDB/MTR) | working |
| NDJSON dispatcher for scripts and agents | working |
| Per-key file area over SFTP | working |
| Background (async) jobs | working, in memory |
| Community: nicknames, wall, who's online, live chat (history kept) | working |
| Last callers and notices when someone arrives or leaves | working |
| Message forums: areas, topics, unread tracking, moderation | working |
| Telnet and a browser terminal, both keyless and read-only | working |
| CP437 for SyncTERM and other BBS terminals | working |
| Node monitor for moderators | working |
| Themes (retro 90s, retro-modern) and original ANSI art | working |
| NES games in sixel, through the nesdoor door (optional) | working |
| Doors: native, DOS through DOSBox, A-Net, DoorParty and BBSLink | working; remote servers need the operator's account |
| MRC, the chat between boards | working |
| Echomail: fsxNet over binkp, DOVE-Net over QWK | working; needs a node address from each network |
| rlogin, anonymous read-only FTP, PETSCII for Commodore terminals | working |
| `/:live`, badges and ANSI postcards | working |
| Today's bulletin at login (sun and moon for your city) | working |
| `[k]` sky & almanac, linecast's live views on a pseudo-terminal | working; needs the `games` image |
| `[o]` on the air: world radio directory, Hammer FM, `/np`, `/:radio` | working |

The direction is described under [Roadmap](#roadmap). Anything not listed as working
above does not exist yet, and this file will not pretend otherwise.

---

## The SSH front-end

One SSH port answers three ways:

| You send | You get |
|---|---|
| an interactive terminal, no command | the menu |
| a command (`ssh host hash`) or a pipe with no terminal | the [NDJSON dispatcher](#scripts-and-agents-ndjson) |
| the `sftp` subsystem | your [file area](#file-area-sftp) |

Telnet and a browser tab reach the same menu without a key; see
[Telnet and the browser](#telnet-and-the-browser).

The port is set by `bbs.port` (2222 when absent; the public instance uses 2323):

```bash
ssh -p 2323 your-server
```

**With a key** you are identified by your public key. There is no password and no account
to create. On first connection a profile is created and, when GeoIP is configured, seeded
with your city. Your default city, saved cities, units, language, view and emoji
preference are stored against that key, so they survive across sessions. The key also
owns your file area and your background jobs.

**Without a key** (`ssh -p 2323 guest@your-server`) you get in only when
`bbs.allowAnonymous` is on. An anonymous connection is a throwaway identity: it starts
from defaults, nothing you change is saved, it has no file area, and its jobs are visible
only on that connection. Hammer does not key anonymous visitors by IP address, because
everyone behind one NAT (an office, a campus, mobile CGNAT) would then share and
overwrite each other's settings.

The menu offers:

- **weather**: now, search a city, and saved cities
- **net**: DNS, TLS, HTTP and RDAP for a public host
- **qr**: ANSI QR code
- **hash**: SHA, MIME, entropy over pasted bytes
- **jq**: JSON/YAML transform
- **age**: encrypt, decrypt, generate a key
- **links / data / path**: lychee, DuckDB and ping/MTR, listed only when the binaries exist on the host
- **more**: CSV summary, Base64, UUIDs
- **forums**: message areas; see [Forums](#forums)
- **wall**, **chat** and **last callers**: see [Community](#community-wall-whos-online-chat)
- **nodes**: the node monitor, for moderators only
- **games**: NES homebrew in sixel graphics, listed only when the door is installed; see [Games](#games-nes)
- **doors**, **relay chat** and **postcards** (`d`, `r`, `p`): listed only when configured; see [Doors](#doors)
- **settings**: units, language, view, emoji, nickname
- **about**: project information

The host key is generated on first start at `hostKeyPath`. Profiles live in the SQLite
database at `profilesDb`. The SSH front-end runs alongside the HTTP server on its own
port, so both are available from one process.

Guardrails on the public port: at most `maxConnectionsPerIP` concurrent sessions per
client address (5 by default), and tool calls through the dispatcher are rate limited
per key, or per IP for anonymous callers (60 a minute, bursts of 20).

### Community: wall, who's online, chat

Everyone has a **nickname**, unique regardless of case. A new key starts with its SSH
user name (`ssh pablo@your-server` → `pablo`) when that name means something and is
free, otherwise with `guest-xxxx`. Change it under settings, or with `/nick` in the chat.

- **Who's online.** The main menu title counts the people connected right now, and the
  wall screen lists them: nickname, whether they are in the menu or the chat, and for
  how long. Several connections from one key count once.
- **Wall.** The latest one-liners from everyone sit above the main menu, the way a BBS
  greets you. `[w]` opens the whole wall. Each key may post one line every
  `bbs.community.wallCooldown` (10 minutes).
- **Chat.** `[c]` opens a live room, and `ssh -t -p 2323 your-server chat` goes straight
  in. People joining see the last 100 messages. What is said is kept in the database
  (the newest `chatKeep` lines, 2000 by default), so a restart keeps the history.
  Commands: `/help`, `/who`, `/me`, `/nick`, `/quit` (or Ctrl+C).
  Each key may send `chatPerMinute` messages a minute (20, bursts of 5).

- **Last callers.** `[l]` lists the latest visits: who, whether they came by SSH,
  telnet or the browser, and when.
- **Arrivals.** When someone with a key connects or leaves, everyone sitting at a menu
  sees it on the status bar for a few seconds. Nothing appears during a game or in the
  chat, and keyless visitors are not announced. Settings can turn it off.

Anonymous visitors read the wall, the online list and the chat, but cannot post or
talk. Whatever one person types is stripped of escape sequences, control characters and
bidirectional overrides before it reaches anyone else's screen.

**Moderation.** Keys listed in `bbs.community.admins` (fingerprints such as
`SHA256:AbC…`, from `ssh-keygen -lf ~/.ssh/id_ed25519.pub`) can `/mute nick [10m]`,
`/kick nick`, `/ban nick [reason]` and `/unban nick` in the chat, and delete wall lines
(`d 3` on the wall screen). A ban covers the chat, the wall and the forums, and survives
restarts. `[n]`, shown to moderators only, is the node monitor: every connection with
its node number, nickname, transport, address, where it is and how long it has been
idle, redrawn every two seconds; `k` hangs up on a node.

Scripts and agents get the same data through the dispatcher: `who`, `wall count=10`,
`wall.post text="..."` (keys only, same cooldown), `lastcallers count=10`, and `nodes`
for moderators. The chat is interactive only.

### Forums

`[f]` opens the message areas, each with its count of topics and of posts you have not
read. Inside an area the topics come a page at a time, pinned ones first and then the
most recently active; a topic opens at the first post you have not read. `t` starts a
topic and `r` replies. A post is typed like a paste and ends with a line holding only a
`.`; it keeps its line breaks and indentation, and loses anything that could steer
someone else's terminal.

Posting needs a key, and one key must wait `postCooldown` (30 seconds) between posts.
Keyless visitors read everything. Areas marked `readOnly` take new topics from
moderators only, and moderators can pin, lock and delete (`s 3`, `l 3`, `d 3`).

The areas come from `bbs.forums.areas`; with none there is a single `general` area.
Renaming an area in the config renames it on the next start, and dropping one from the
list deletes nothing. Unread counts are kept per key and per topic. The tables already
have room for echomail (which network an area belongs to, where a post came from), for
when the forums join FidoNet or QWK networks.

Scripts read and post through the dispatcher: `forum.areas`, `forum.topics area=general`,
`forum.read topic=12`, and `forum.post area=general title="..."` or `forum.post
topic=12`, with the post on stdin, in `input=` or from a `file=` in your file area.

### Telnet and the browser

The menu is also open to two kinds of caller who bring no key:

- **Telnet**, for SyncTERM, IcyTerm, NetRunner and the rest of the scene's clients:
  `bbs.telnet`, port 2324 by default (`telnet your-server 2324`). Hammer asks the
  client for its window size and terminal type and reads keys one at a time.
- **The browser**: `bbs.web` puts a terminal at `https://your-server/:terminal`, built
  on xterm.js and served by Hammer itself. Nothing to install.

Both are keyless visitors, whatever `allowAnonymous` says for SSH: they read the wall,
the chat and the forums and post nothing, play no games, and keep no settings beyond
the connection. To take part, connect over SSH with a key. They show up in who's online
and in the last callers as telnet or web, they count against the same per-IP cap, and
they have the same limits as SSH: 5 minutes idle, 30 minutes in all.

Behind nginx, the WebSocket at `/:terminal/ws` needs its own location with the
`Upgrade` and `Connection` headers and a long read timeout;
[deploy/nginx-hammer.conf](deploy/nginx-hammer.conf) has one.

**Character sets.** BBS terminals expect code page 437, not UTF-8. A session whose
terminal names itself as one (`syncterm`, `ansi-bbs`, `ansi`), and a telnet client that
names nothing, gets CP437: boxes, shading and art come out right, and characters CP437
lacks get an ASCII stand-in. Terminal emulators of today get UTF-8. `[9]` in settings
overrides the guess.

### Games (NES)

`[g]` opens a shelf of NES homebrew played inside the terminal. The picture is drawn in
sixel graphics, so it needs a terminal that speaks sixel: SyncTERM, IcyTerm, WezTerm,
foot, mlterm, iTerm2 or a recent Windows Terminal. SyncTERM and IcyTerm also play the
game's sound and report held keys, which lets a platformer play like a real controller;
other terminals approximate a held key with timed taps.

The games run in [nesdoor](https://github.com/AlexCorris/nesdoor), a BBS door by Alex
Corris, on the FCEUmm libretro core. Both are GPL-2.0 programs. Hammer starts the door as
a separate process for each player and never links it in. It hands the door a BBSDEV.DRP
drop file with your nickname, your key fingerprint, your terminal size and the time you
have to leave by. Your keys then go through untouched until you quit back to the menu.

| Key | In the door |
|---|---|
| arrows or WASD | D-pad |
| X or Space, Z | A, B |
| Enter, Tab | Start, Select |
| Ctrl+S, Ctrl+L | save your spot, load it |
| Ctrl+R | the console's reset button |
| M | sound on or off |
| Ctrl+Q or Esc Esc | back to the game list, and from there to Hammer |

Saves belong to your key. Leaving a game keeps a resume point, even when the line drops,
and games with a battery save keep that too. Two people can share a game: one presses
`2` instead of Enter on the controls screen, and the game shows up at the top of the
other player's list as a JOIN entry.

Playing needs an SSH key unless `bbs.games.allowAnonymous` is on, in which case an
anonymous player's saves are deleted when they leave. At most `maxPlayers` people play
at once (3 by default), because every sitting is a Python process running an emulator;
anyone else is told the slots are full. A sitting lasts `maxMinutes` (25) and never runs
past the SSH connection's 30-minute limit. At the deadline the door saves and exits.

**The shelf.** [games/catalog.yaml](games/catalog.yaml) lists 17 homebrew games whose
authors allow them to be shared: Super Tilt Bro., Nova the Squirrel, Thwaite,
Concentration Room, RHDE: Furniture Fight, Driar, Cheril the Goddess, Sir Ababol
Remastered, Mega Mountain, Lawn Mower, Lunar Limit, Falling, Babel Blox,
robotfindskitten, Pong 1K 2P, Tic Tac Two P and The Legends of Owlia. Each entry carries
its licence, its source code where the licence asks for it, a pinned download and a
SHA-256. `tools/fetchroms` installs them, refuses any file that does not match, and
writes `CREDITS.txt` beside them. No ROM is kept in this repository. Six more games are
listed without a download because their authors have not granted redistribution.

Commercial games are not offered. To add a game, copy its `.nes` file into `romsDir`
(in the Docker deploy, `deploy/games-roms`). The door reads the folder each time it
shows the list, so the game appears with no restart, and the file name becomes the
title, with `_` shown as a space. Delete the file to take it off. In the image the
catalog's games sit in `catalogDir`, and Hammer copies each one into `romsDir` the first
time it starts with it, so a game you delete stays deleted and new catalog games still
arrive with a rebuild. Only add games you have the right to share: on a public server,
every player can play, and so receive, what is in that folder.

**Setup.** The `games` Docker target, which the production compose builds, has
everything in it. On another Linux host:

```bash
git clone https://github.com/AlexCorris/nesdoor /opt/nesdoor
python3 -m venv /opt/nesdoor/venv
/opt/nesdoor/venv/bin/pip install numpy
go run ./tools/fetchroms -out /opt/nesdoor/roms
```

The door also needs `fceumm_libretro.so` in its folder: build
[libretro-fceumm](https://github.com/libretro/libretro-fceumm), or take the copy on
nesdoor's releases page if your glibc is recent enough. Then turn on `bbs.games` (see
[Configuration](#configuration)). The menu entry appears only when the door is found,
and stays hidden on Windows hosts, where the door's stdio mode cannot wait on pipes.

### Doors

The `[d]` menu lists the doors in `bbs.doors`. A door is a program the board hands the
caller to, and Hammer speaks the old ways of doing it:

| `type` | What runs |
|---|---|
| `native` | a Linux program, over stdio or a socket on fd 3 (`io: socket`), with a drop file |
| `dos` | a DOS door inside DOSBox, its COM1 wired to the caller through a null modem |
| `anet` | a door on the A-Net Online server, over rlogin |
| `doorparty` | a door on DoorParty, over rlogin inside an SSH tunnel |
| `bbslink` | a door on BBSLink, over its token handshake and telnet |

Drop files come in the usual shapes: `door32`, `doorsys`, `dorinfo`, `chain` and
`bbsdev`, all written with CRLF. DOS doors need the `full` Docker image, which adds
DOSBox to the games image. DOSBox draws to SDL's dummy video driver, so there is no X
server, and it only runs while someone plays. The doors themselves, and a FOSSIL driver
such as BNU or X00, are the operator's: put them in `/app/doors`.

```yaml
  dosbox:
    fossilDir: /app/doors/fossil
    fossilCommand: BNU /P1
  doors:
    - name: Legend of the Red Dragon
      type: dos
      dir: /app/doors/lord
      exe: START.BAT {node}
      dropfile: doorsys
      nodes: 2
    - name: TradeWars 2002
      type: bbslink
      door: tw
  doorServers:
    bbslink:
      sysCode: ${BBSLINK_SYSCODE}
      authCode: ${BBSLINK_AUTHCODE}
      schemeCode: ${BBSLINK_SCHEMECODE}
```

Remote door servers see each caller under a stable name and a password derived from the
board's secret, so scores follow the person and nobody types anything.

### MRC, the chat between boards

`[r]` joins Multi Relay Chat, the chat many BBSes share. One TLS connection to the hub
carries every caller. Rooms, `/me`, `/msg`, `/who` and the hub's own commands work as
they do elsewhere; pipe colour codes turn into ANSI colours. Keyless visitors read along.

```yaml
  mrc:
    enabled: true
    bbsName: Hammer BBS
    telnet: hammer.example.org:2324
    ssh: hammer.example.org:2323
```

### Echomail: fsxNet and DOVE-Net

Forum areas can belong to a message network. Posts written in them go out, and posts from
the rest of the network come in, threaded by their message ids. Remote authors show
under their own names.

- **FidoNet-style networks (fsxNet)**: the board is a node that calls its hub over binkp
  with CRAM-MD5, every 15 minutes by default and never more often than every 5. Nothing
  listens for the hub. Netmail from AreaFix lands in a short inbox for moderators.
- **QWK networks (DOVE-Net)**: the board is a QWK node of a Synchronet hub. Every six
  hours by default it uploads `ID.REP` and fetches `HUB.QWK` over FTP, optionally over TLS.

A post counts as sent only once the hub has acknowledged it, so a dropped call loses
nothing. Moderators have `echomail.status`, `echomail.poll` and `echomail.areafix`.

```yaml
  echomail:
    fidonet:
      enabled: true
      network: fsxnet
      address: 21:1/999
      hub: {address: 21:1/100, host: net1.fsxnet.nz}
      password: ${FSXNET_PASSWORD}
      areafixPassword: ${FSXNET_AREAFIX}
      areas:
        - {echo: FSX_GEN, name: fsxNet General}
        - {echo: FSX_BBS, name: fsxNet BBS talk}
    qwk:
      enabled: true
      network: dovenet
      hub: VERT
      host: vert.synchro.net
      id: HAMMER
      password: ${DOVENET_PASSWORD}
      conferences:
        - {number: 2001, tag: dove_general, name: DOVE-Net General}
```

### More ways in: rlogin, FTP and Commodore terminals

All three are keyless, like telnet: whoever comes in this way reads and posts nothing.

- **rlogin** (`bbs.rlogin`, port 2513): the name the client sends only suggests a
  nickname. The field BBS software uses for a password is never kept or shown.
- **FTP** (`bbs.ftp`, port 2121): anonymous and read-only, with the board's ANSI art, the
  free catalogue games and their credits, approved postcards and an index. Passive mode
  only, from a fixed port range. The operator's ROM folder and the SFTP areas never
  appear here. Behind Docker, set `publicHost` to the server's public address.
- **PETSCII** (`bbs.petscii`, ports 6400 and 6401): plain TCP for Commodore 64 and 128
  terminals, at 40 and 80 columns. These callers get screens of their own: the wall, the
  forums, who is on, the last callers and the chat, with no art, games or doors.

### Live page, badges and postcards

- **`/:live`** (`bbs.live`) shows who is on the board right now, fed by Server-Sent
  Events, with `/:live.json` beside it. Key holders appear by nickname and where they are
  in the menu. Keyless visitors are a count, and no address, transport or key leaves the
  server.
- **Badges** (`bbs.awards`) mark visits, posts, one-liners, chat lines, games and
  echomail. A new one shows on the status line; the about screen lists them, and the
  `awards` tool shows how close the next one is.
- **ANSI postcards** (`bbs.postcards`): send an `.ans` file of up to 80×50 cells and 64 KB
  with `postcard.send title=... < card.ans`. A moderator approves it on `[p]` or with
  `postcard.approve`. Approved cards appear on `[p]`, at `/:postcards` (each with a page,
  a PNG drawn like a VGA screen, and the original `.ans`) and under `postcards/` on FTP.

### Today, the sky and the radio

- **Today's bulletin.** Under the splash, every caller sees the date, the moon's phase and
  sunrise and sunset in their city, Hammer FM and the newest line on the wall. It is
  worked out in Go (NOAA's solar formulas, the moon from suncalc), with one geocoding
  lookup per city an hour, so nothing runs as a process. `[s] 0` turns it off. The
  `almanac city=...` tool gives the same in JSON, and Commodore callers have it as item 6.
- **`[k]` sky & almanac** (`bbs.linecast`) opens [linecast](https://github.com/ashuttl/linecast)
  live: the weather, the sun's arc, the moon, a planetarium and the tides, in the caller's
  city, units and language. It runs like a door on a pseudo-terminal that follows the
  caller's window, in a small pool shared by all apps (`nodes`, 2 by default). Radar and
  maps are refused: they cost far more than the rest. linecast draws in braille and half
  blocks, so a CP437 terminal is told it needs UTF-8. The `games` image carries linecast
  2.10.2 in its own venv.

  ```yaml
  linecast: {enabled: true, nodes: 2, anonymous: true}
  ```

- **`[o]` on the air** (`bbs.radio`) is a directory of stations from around the world,
  from the open [Radio Browser](https://www.radio-browser.info) list: most played, by
  name, country or genre, and favorites for callers with a key. A station's screen asks
  it what it is playing (at most 64 KiB of the stream, read once), links to its player
  page and gives the `mpv` command; `[w]` recommends it on the wall. No audio passes
  through Hammer, since SSH and telnet carry only text: listeners play the stream
  themselves. A moderator puts a station on the air as **Hammer FM** with `[h]` or
  `radio.pick`; it shows on the main menu, the bulletin, `/:radio` and the Commodore.
  In the chat, `/np` tells the room the station you last opened and what it is playing.
  `/:radio` and `/:radio/<id>` play https streams in the browser. The tools are
  `radio.top`, `radio.search`, `radio.np`, `radio.fav` and `radio.pick`.

  ```yaml
  radio: {enabled: true, publicUrl: https://hammer.example.com}
  ```

- **Where people are.** Who's online, the node monitor and `/:live` say when someone is at
  the radio or under the sky, and what they are listening to or watching. A guest's stays
  off the public page. New badges: stargazer, night owl, almanac reader, tuned in and DJ.

### Look and feel

Two themes, picked per person under settings:

- **retro 90s** (the default): the 16 VGA colours, double-line boxes with block shadows,
  dotted menu leaders, the look of a 1990s art pack.
- **retro-modern**: the same BBS layout with rounded boxes, a softer palette and gradient
  titles on truecolor terminals.

Hammer reads what your terminal can do from `TERM` and `COLORTERM` and draws at that
level: 24-bit colour, 256 colours, 16 colours, or none at all. A terminal with no colour
(`TERM=dumb`) gets plain ASCII boxes and no escape sequences.

The splash, the wall, the chat, the weather, the tools, settings and the goodbye screen
carry ANSI art drawn for Hammer, stored as ordinary `.ans` files (CP437, iCE colours,
SAUCE) that open in PabloDraw or Moebius. The splash arrives at modem speed,
`bbs.style.baud` (9600 by default); any key finishes it at once, and settings can turn
the effect off. Art needs 80 columns: narrower terminals get text titles instead.

Art lives in `internal/bbs/art/` and is regenerated with `go generate ./internal/bbs`
from `tools/artgen`. Any `.ans` file dropped there is loaded the same way, with the
credit read from its SAUCE record. Art by someone else needs the author's permission:
archives such as 16colo.rs keep the art, but the rights stay with the artists.

### Scripts and agents (NDJSON)

The dispatcher is a closed vocabulary: every tool declares its arguments, and a request
that names an unknown tool or passes an unknown, mistyped, oversized or out-of-range
argument is refused before anything runs. There is no shell behind it.

As a command, the first word names the tool and the rest are `key=value` pairs. The
answer is one JSON line, and the exit status is 0 on success and 1 on error:

```bash
ssh -p 2323 your-server hash input=hello
ssh -p 2323 your-server weather city=Lisbon view=j1
cat data.json | ssh -p 2323 your-server jq filter=.items
ssh -p 2323 your-server help                      # the vocabulary, readable
ssh -p 2323 your-server tools                     # the same, as JSON
```

Tools that take a payload read it from `input=...`, from `file=NAME` in your file area, or
from stdin. Add `output=text` to print the human-readable lines instead of JSON.

As a pipe, send one request per line and read one response per line, in order:

```bash
printf '%s\n' \
  '{"id":1,"tool":"b64.encode","args":{"input":"hi"}}' \
  '{"id":2,"tool":"ids","args":{"count":3}}' \
  | ssh -T -p 2323 your-server
```

```json
{"id":1,"ok":true,"tool":"b64.encode","result":{"data":{"base64":"aGk="},"lines":["aGk="]}}
{"id":2,"ok":true,"tool":"ids","result":{"data":{"uuids":["…","…","…"]},"lines":["…","…","…"]}}
```

Every result carries `data` (structured, for programs) and usually `lines` (what the menu
would print). Errors look like
`{"ok":false,"error":{"code":"bad_args","message":"…"}}`. The codes are `bad_request`,
`unknown_tool`, `bad_args`, `forbidden`, `not_found`, `rate_limited` (with
`retry_after` in seconds), `busy` and `failed`.

The vocabulary: `weather`, `net`, `qr`, `hash`, `jq`, `age.encrypt`, `age.decrypt`,
`age.keygen`, `b64.encode`, `b64.decode`, `csv`, `ids`, plus `links`, `data` and `path`
when their binaries are installed, and the built-ins `tools`, `files`, `job` and `jobs`.
Ask the server with `tools` for the exact arguments each one takes.

### Background jobs

Slow tools (`net`, `links`, `data`, `path`) can run in the background. Add `async=true`
(or `"async":true` in NDJSON) and you get a job id back straight away:

```bash
ssh -p 2323 your-server links file=README.md async=true
ssh -p 2323 your-server job id=j_4f1c… output=text    # later, from any connection with the same key
ssh -p 2323 your-server jobs                          # your jobs, newest first
```

Jobs live in memory, so a restart forgets them. Results are kept for `bbs.jobs.ttl` (20
minutes), a job may run for `bbs.jobs.timeout` (2 minutes), each key may have
`maxPerKey` unfinished jobs (5), and `workers` (4) run at once across everyone.

### File area (SFTP)

Each key gets a private directory, reachable with any SFTP client:

```bash
sftp -P 2323 your-server
sftp> put report.csv
```

Then point a tool at it:

```bash
ssh -p 2323 your-server csv file=report.csv
```

Paths are confined to your directory and links are refused. A key may hold
`bbs.files.quotaBytes` (50 MiB), everyone together may hold `bbs.storage.globalQuotaBytes`,
and tools read files up to `bbs.files.maxFileInput` (8 MiB). Files are deleted
`bbs.storage.ttl` after their last upload (20 minutes by default), so treat the area as
a workbench, not storage. Anonymous connections have no file area.

---

## The HTTP weather service

```bash
curl your-server:8080
```

```
▓▒░ HAMMER ░▒▓

╔══════════════════════════════════════════════════════════════╗
║ Paris, France                                     Thu 24 Sep ║
╠══════════════════════════════════════════════════════════════╣
║  ☁️  Overcast                             +22°C  feels +19°C ║
║                                                              ║
║  humidity 42%   wind ↙ 9 km/h   vis 10 km   1023 hPa         ║
╠══════════════════════════════════════════════════════════════╣
║  Today     ☁️  +16…+24     rain 21%   ↓ 9 km/h               ║
║  Tomorrow  ☀️  +15…+27     rain 9%    ← 9 km/h               ║
║  Sat 26    ☀️  +16…+25     rain 15%   ↓ 16 km/h              ║
╠══════════════════════════════════════════════════════════════╣
║  sunrise 07:40 AM   sunset 07:45 PM   🌔 waxing gibbous      ║
╚══════════════════════════════════════════════════════════════╝

▓▒░ tools over SSH ░▒▓  ssh -p 2323 your-server
```

The default is a card 64 columns wide, so it fits an 80-column terminal and a
phone. The classic report with morning, noon, evening and night is 125 columns
wide; ask for it with `?view=v1`, or with any option only it has (`0`–`3`,
`n`, `days=`).

Ask for a specific place by adding it to the path:

```bash
curl your-server:8080/London
curl your-server:8080/Salt+Lake+City
curl your-server:8080/muc          # 3-letter airport codes
curl your-server:8080/Eiffel+Tower # landmarks, mountains, stations
curl your-server:8080/@github.com  # domain names, prefixed with @
```

With no location, the report is for the location derived from your IP address.

Location names may be written in any language and in Unicode, hex-encoded or not. Spaces
become `+`:

```bash
curl your-server:8080/станция+Восток
```

### Units

Queries from the United States default to USCS units and everywhere else to metric. Add a
flag to override:

```bash
curl 'your-server:8080/Amsterdam?u'   # USCS
curl 'your-server:8080/Amsterdam?m'   # metric
curl 'your-server:8080/Amsterdam?M'   # metric, wind in m/s
```

Single-letter flags bundle without separators; long options take `&`:

```bash
curl 'your-server:8080/Amsterdam?m2&lang=nl'
```

### Views

The view selects the layout. Pass it as `?view=` or, for the ones with a short form, in
the path.

| View | Output |
|---|---|
| `card` | a compact emoji card, 64 columns wide: the default over HTTP and in the SSH front-end |
| `v1` | the classic report, 125 columns wide; also chosen by `0`–`3`, `n`, `days=` and PNG requests |
| `v2`, `v2d`, `v2n` | data-rich layout; `v2d` and `v2n` use Nerd Fonts instead of emoji |
| `line` | one line, for status bars |
| `j1`, `j2` | JSON; `j2` omits hourly data, which suits microcontrollers |
| `p1` | Prometheus metrics |
| `page` | the built-in help and translation pages |

The `v2` layout adds temperature and precipitation curves through the day, moon phases for
today and the next three days, current conditions with humidity, wind and pressure, the
timezone, dawn/sunrise/noon/sunset/dusk, and precise coordinates. It is terminal-only and
English-only.

### Output formats

ANSI and HTML are chosen from the User-Agent. Force the others explicitly:

```bash
curl 'your-server:8080/?T'          # plain text, no colours
curl 'your-server:8080/?d'          # restrict to standard console glyphs
wget 'your-server:8080/Paris.png'   # PNG
```

In a `.png` request, options are separated by `_` instead of `?` and `&`:

```bash
wget 'your-server:8080/Paris_0tqp_lang=fr.png'
```

Useful PNG options: `t` for transparency, or `transparency=0..255` for a specific level.
Transparent output composites onto photographs:

```bash
convert source.jpg <(curl 'your-server:8080/Oymyakon_tqp0.png') -geometry +50+50 -composite target.jpg
```

### One-line output

Made for tmux, weechat and other status bars:

```bash
curl 'your-server:8080/Nuremberg?format=3'
Nuremberg: 🌦 +11⁰C
```

Preconfigured formats:

| | Output |
|---|---|
| `1` | `🌦 +11⁰C` |
| `2` | `🌦 🌡️+11°C 🌬️↓4km/h` |
| `3` | `Nuremberg: 🌦 +11⁰C` |
| `4` | `Nuremberg: 🌦 🌡️+11°C 🌬️↓4km/h` |

Or build your own with percent notation:

```
c  condition symbol       t  temperature            D  dawn*
C  condition name         f  feels like             S  sunrise*
x  condition, plain text  H  high                   z  zenith*
h  humidity               L  low                    s  sunset*
w  wind                   p  precipitation (mm/3h)  d  dusk*
l  location               P  pressure (hPa)         T  current time*
m  moon phase             e  dew point              Z  local timezone
M  moon day               u  UV index (1-12)
```

`*` marks values shown in the location's local time.

```bash
curl 'your-server:8080/London?format=%l:+%c+%t\n'
London: ⛅️ +7⁰C
```

Several locations can be queried at once, separated by `:`. When a client polls on a
timer, pass `period=` so the service can pace itself:

```bash
curl 'your-server:8080/London:Stockholm:Moscow?format=3&period=60'
```

In `tmux.conf`, `%` must be escaped as `%%`. Percent notation produces no trailing
newline, so add `\n` and single-quote the query when calling from a shell.

### JSON

```bash
curl 'your-server:8080/Detroit?format=j1'
```

```json
{
  "current_condition": [
    {
      "FeelsLikeC": "25",
      "humidity": "76",
      "temp_C": "22",
      "weatherCode": "122",
      "weatherDesc": [{ "value": "Overcast" }],
      "winddir16Point": "NNE",
      "windspeedKmph": "7"
    }
  ]
}
```

Most fields explain themselves. `weatherCode` is an enumeration; the values are documented
by [WorldWeatherOnline](https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx).

### Prometheus

```bash
curl 'your-server:8080/Detroit?format=p1'
```

```
# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius
temperature_feels_like_celsius{forecast="current"} 7
```

A scrape config:

```yaml
- job_name: 'weather_detroit'
  static_configs:
    - targets: ['your-server:8080']
  metrics_path: '/Detroit'
  params:
    format: ['p1']
```

### Moon phases

```bash
curl 'your-server:8080/Moon'
curl 'your-server:8080/Moon@2016-12-25'
curl 'your-server:8080/London?format=%m'
```

Two caveats come with the Unicode representation. With some fonts `🌘` reads as either
almost-shadowed or almost-lit depending on the terminal's background, which a colour font
such as Noto Color Emoji avoids. The same glyph also means last quarter in the northern
hemisphere and first quarter in the southern, and nothing at all in the tropics; Unicode
[is aware of this](https://www.unicode.org/L2/L2017/17304-moon-var.pdf) and Hammer does
not work around it.

### Languages

Output is available in 74 languages. The location name is independent of the output
language, since many places have no English name at all.

```bash
curl -H "Accept-Language: fr" your-server:8080
curl 'your-server:8080/Berlin?lang=de'
```

Browsers get their `Accept-Language` honoured automatically.

### Emoji in the terminal

Emoji need a terminal built against Cairo 1.15.8 or newer and a font that carries the
glyphs. Noto Color Emoji works well; Emoji One is a reasonable alternative.

```xml
<!-- ~/.config/fontconfig/fonts.conf -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <alias><family>monospace</family>
    <prefer><family>Noto Color Emoji</family></prefer>
  </alias>
</fontconfig>
```

Run `fc-cache -f -v` afterwards. Note that tmux and the host terminal sometimes disagree
about the width of a given emoji, which produces alignment artefacts.

For a terminal without emoji support, `?d` restricts output to glyphs present in standard
console fonts, and the `v2d` and `v2n` views use Nerd Fonts.

### How reports look

Framed reports (`v1`, `v2`, `v2d`, `v2n` and `card`) come out in the BBS's clothes: a
`▓▒░ HAMMER ░▒▓` header, double-line frames in the retro palette, and a footer with the
tagline and the command that opens the BBS. The footer comes from the `brand` block of
the configuration:

```yaml
brand:
  host: hammer.example.com                 # replaces the upstream name in help pages
  ssh: "ssh -p 2323 hammer.example.com"    # shown at the foot of every report
```

Leave `ssh` empty to drop the footer. `?F` drops the footer for one request, `?Q` drops
header and footer, `?T` removes the colours, and `?d` keeps the frames single-line so
console fonts can draw them.

Terminals disagree about how wide an emoji is. To keep that from breaking the right-hand
border, `card` and `v2` rows move the cursor to the frame's last column before drawing
the border (`CSI n G`). The `v1` view has no emoji inside its frames and is left as is.

### Behind a proxy, and rate limits

The client address decides the default location and units, so Hammer does not take it
from request headers blindly. `X-Forwarded-For` and `X-Real-IP` are believed only when
the connection comes from a trusted proxy, listed in `server.trustedProxies` as addresses
or CIDR prefixes. The default is loopback (`127.0.0.0/8`, `::1/128`), which fits a
reverse proxy on the same host; an empty list ignores the headers entirely. The forwarded
chain is read from the right, skipping trusted hops, so entries a client writes itself
are ignored.

Weather requests are limited per client address (`server.rateLimit`, 60 a minute with
bursts of 20 by default). Past that the answer is `429 Too Many Requests` with a
`Retry-After` header. Static files are not limited. Every unseen city name costs one call
to the geocoder, so `geo.dailyLookupLimit` also caps geocoder calls per UTC day across
all clients; names already in the location cache do not count.

---

## Building

Hammer is one binary with everything inside it. The build embeds fonts from the host, so
those packages are needed at build time only.

```bash
sudo apt-get install -y --no-install-recommends \
  fontconfig fonts-dejavu-core fonts-noto-core fonts-noto-cjk \
  fonts-wqy-zenhei fonts-symbola fonts-motoya-l-cedar fonts-lexi-gulim \
  rsync

bash build.sh build
```

This produces `./srv`. The build needs cgo for the SQLite driver, so a C compiler must be
present; on a machine without one, use the Dockerfile.

```bash
docker build -t hammer .
```

That default target is `runtime`: Debian slim, the `srv` binary, `share/datasets`, and the
optional CLIs (lychee, DuckDB, ping, MTR). Mount a YAML config as `/app/config.yaml`.
`--target games` adds the NES door, and `--target full` adds DOSBox on top of that for DOS
doors.

The live loop (bind-mount, `go build`, menu items 1–9) is Compose, targeting `live`:

```bash
docker compose up -d --build
```

Rebuild inside the container as before (`CGO_ENABLED=1 go build -ldflags '-X main.version=dev' -o srv .` and restart). ping/MTR need `cap_add: NET_RAW` and `NET_ADMIN` (already in [`compose.yaml`](compose.yaml)); without those, [9] path appears but ICMP fails. Do not run the container as `--privileged`.

Other targets: `build.sh assets` re-syncs the embedded assets, `build.sh gen` regenerates
the options parser from `share/defs/`, and `build.sh clean` removes both.

Run the tests with `go test ./...`. The SQLite-backed packages (`bbs`, `weather`, `server`,
`location` and others) need cgo, like the build. On a machine without a C compiler,
[zig](https://ziglang.org) can stand in for one:

```bash
CGO_ENABLED=1 CC="zig cc -target x86_64-windows-gnu" go test ./...
```

`make go-test-zig` runs the same thing. Drop the `-target` part on Linux or macOS.

---

## Configuration

```yaml
cache:
  responses:            # rendered output
    type: lru
    size: 50000
    ttl: 10m
    enabled: true
  weather:              # raw upstream data
    type: disk
    dir: /var/cache/hammer/weather
    ttl: 45m
    enabled: true

ip:
  ipCacheDb: /hammer/cache/geoip.db
  ipCacheType: db
  geoip2: /hammer/data/GeoLite2-City.mmdb

geo:
  locationCacheDb: /hammer/cache/geoloc.db
  locationCacheType: db
  nominatim:
    - name: opencage
      type: opencage
      url: https://api.opencagedata.com/geocode/v1/json
      token: "YOUR_OPENCAGE_TOKEN"
  dailyLookupLimit: 2000    # geocoder calls per UTC day; 0 = unlimited

weather:
  wwo:
    baseUrl: "http://wttr.in/{lat},{lon}?format=j1&lang={lang}"

server:
  portHttp: 8080
  # portHttps: 8443
  # tlsCertFile: /hammer/etc/fullchain.pem
  # tlsKeyFile: /hammer/etc/privkey.pem
  trustedProxies: ["127.0.0.0/8", "::1/128"]   # whose X-Forwarded-For to believe
  rateLimit:
    requestsPerMinute: 60                       # per client IP; 0 turns it off
    burst: 20

bbs:
  enabled: true
  port: 2222
  allowAnonymous: false        # true lets keyless clients in (nothing is saved)
  maxConnectionsPerIP: 5
  hostKeyPath: /hammer/etc/hammer_ed25519
  profilesDb: /hammer/cache/hammer.db
  storage:
    globalQuotaBytes: 1073741824   # all file areas together
    ttl: 20m                       # uploads are deleted this long after arriving
  files:
    dir: /hammer/cache/files       # one private directory per key
    quotaBytes: 52428800
    maxFileInput: 8388608
  dispatch:
    requestsPerMinute: 60          # tool calls per key (per IP when anonymous)
    burst: 20
  jobs:
    workers: 4
    maxPerKey: 5
    ttl: 20m
    timeout: 2m
  style:
    defaultTheme: retro90          # or modern
    baud: 9600                     # modem effect on the splash; 0 = instant
  community:
    admins: ["SHA256:..."]         # moderators' key fingerprints
    wallShow: 20
    wallMaxLen: 80
    wallCooldown: 10m
    chatMaxLen: 280
    chatPerMinute: 20
    chatBurst: 5
    chatKeep: 2000                 # chat lines kept in the database; 0 = all
  forums:
    areas:
      - {tag: general, name: General, description: Talk about anything}
      - {tag: news, name: News, description: From the sysop, readOnly: true}
    maxTitle: 72
    maxBody: 8192
    postCooldown: 30s
  telnet:                          # keyless and read-only
    enabled: true
    port: 2324
  web:                             # keyless and read-only, at /:terminal
    enabled: true
  games:                           # NES games through nesdoor; off when absent
    enabled: true
    doorDir: /opt/nesdoor          # nesdoor.py, the FCEUmm core, saves/
    python: /opt/nesdoor/venv/bin/python   # absent: doorDir/venv, then python3
    romsDir: /opt/nesdoor/roms     # absent: doorDir/roms; drop .nes files here
    catalogDir: /opt/games/catalog # optional: games copied into romsDir once
    maxPlayers: 3                  # sittings at once
    maxMinutes: 25                 # per sitting
    allowAnonymous: false
```

The `brand` block (host name and BBS invite for HTTP reports) is described under
[How reports look](#how-reports-look).

```bash
./srv srv config.yaml
```

Unknown keys are rejected rather than ignored, so a typo fails at startup instead of
silently doing nothing.

Two external services are involved. Geocoding needs an [OpenCage](https://opencagedata.com)
token. IP geolocation needs **GeoLite2-City.mmdb** from [MaxMind](https://www.maxmind.com),
which is free with registration; without it, requests with no location fall back to a
default city. Weather data comes from whatever `weather.wwo.baseUrl` points at.

For production, run it under systemd as a dedicated user:

```bash
sudo useradd -r -s /bin/false hammer
sudo mkdir -p /hammer/{bin,cache,log,data,etc}
sudo chown -R hammer:hammer /hammer
```

---

## Roadmap

Hammer is becoming a platform for small tools reachable over SSH, with weather as one
entry in the catalogue rather than the whole product. The design follows
[research into which tasks justify being remote SSH workers](https://murad.gg/research/2026-08-26-pesquisa-130-workers-ssh-servicos-processamento-remoto):
a dispatcher with a closed vocabulary and validated arguments, never an unrestricted
shell.

The shape described there now exists. One SSH server answers three ways on the same port
and the same key: the menu for people, NDJSON for n8n, shell scripts and AI agents, and an
SFTP file area that document and image tools can read from. Tools declare which binaries
they need, and the menu and the dispatcher hide the missing ones in the same way.

Still open: jobs are held in memory and do not survive a restart, results cannot yet be
written back into the file area, and the catalogue itself is small. New tools should
arrive through the dispatcher first, with the menu entry as a view over it.

---

## Credits

Hammer is developed by Pablo Murad, 2026. The translations were written by volunteers,
listed in `share/translations/*/metadata.json`. Third-party code and its licences are
listed in [NOTICE](NOTICE).

## License

Apache License 2.0. See [LICENSE](LICENSE).