Readme
▐▄▄▌ ▐▄▄▌ ▐▄▄▄▄▄▌ ▐▄▄▄▄▄▄▄▄▌ ▐▄▄▄▄▄▄▄▄▌ ▐▄▄▄▄▄▌▐▄▄▄▄▄▄▌
▐██▌ ▐██▌▐██▌ ▐██▌▐██▌▐█▌▐██▌▐██▌▐█▌▐██▌▐██▌ ▐██▌ ▐██▌
▐███████▌▐███████▌▐██▌▐█▌▐██▌▐██▌▐█▌▐██▌▐████▌ ▐██████▌
▐▀▀▌ ▐▀▀▌▐▀▀▌ ▐▀▀▌▐▀▀▌ ▐▀▀▌▐▀▀▌ ▐▀▀▌▐▀▀▌ ▐▀▀▌ ▐▀▀▌
▐▄▄▌ ▐▄▄▌▐▄▄▌ ▐▄▄▌▐▄▄▌ ▐▄▄▌▐▄▄▌ ▐▄▄▌ ▐▄▄▄▄▄▌▐▄▄▌ ▐▄▄▌
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 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. 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 |
the sftp subsystem |
your file area |
Telnet and a browser tab reach the same menu without a key; see Telnet and the browser.
The port is set by bbs.port (2222 when absent; the public instance uses 2323):
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
- wall, chat and last callers: see Community
- nodes: the node monitor, for moderators only
- games: NES homebrew in sixel graphics, listed only when the door is installed; see Games
- doors, relay chat and postcards (
d,r,p): listed only when configured; see 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 everybbs.community.wallCooldown(10 minutes).Chat.
[c]opens a live room, andssh -t -p 2323 your-server chatgoes straight in. People joining see the last 100 messages. What is said is kept in the database (the newestchatKeeplines, 2000 by default), so a restart keeps the history. Commands:/help,/who,/me,/nick,/quit(or Ctrl+C). Each key may sendchatPerMinutemessages 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.webputs a terminal athttps://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 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, 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 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:
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, or take the copy on
nesdoor’s releases page if your glibc is recent enough. Then turn on bbs.games (see
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.
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.
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.REPand fetchesHUB.QWKover 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.
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, setpublicHostto 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.jsonbeside 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 theawardstool shows how close the next one is. - ANSI postcards (
bbs.postcards): send an.ansfile of up to 80×50 cells and 64 KB withpostcard.send title=... < card.ans. A moderator approves it on[p]or withpostcard.approve. Approved cards appear on[p], at/:postcards(each with a page, a PNG drawn like a VGA screen, and the original.ans) and underpostcards/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] 0turns it off. Thealmanac city=...tool gives the same in JSON, and Commodore callers have it as item 6. [k]sky & almanac (bbs.linecast) opens 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. Thegamesimage carries linecast 2.10.2 in its own venv.
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 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 thempvcommand;[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]orradio.pick; it shows on the main menu, the bulletin,/:radioand the Commodore. In the chat,/nptells the room the station you last opened and what it is playing. The tools areradio.top,radio.search,radio.np,radio.favandradio.pick.
On the web, /:radio is the same directory to browse: the most played, the just played and
top voted, every station with filters by name, country, genre and language, 30 to a
page, lists of all countries, genres and languages, and “surprise me” for a station at
random. A station’s page plays it right there when the stream is https. A station
listed with an http address is tried once over https, since browsers refuse plain http
audio on an https page; when there is no https stream, the page offers a one-line
.m3u playlist that opens in VLC or mpv, never the endless stream itself. The pages
run no scripts and allow 60 views a minute per address.
radio: {enabled: true, publicUrl: https://hammer.example.com}
- Where people are. Who’s online, the node monitor and
/:livesay 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:
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:
printf '%s\n' \
'{"id":1,"tool":"b64.encode","args":{"input":"hi"}}' \
'{"id":2,"tool":"ids","args":{"count":3}}' \
| ssh -T -p 2323 your-server
{"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:
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:
sftp -P 2323 your-server
sftp> put report.csv
Then point a tool at it:
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
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:
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 +:
curl your-server:8080/станция+Восток
Units
Queries from the United States default to USCS units and everywhere else to metric. Add a flag to override:
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 &:
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:
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 &:
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:
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:
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.
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:
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
curl 'your-server:8080/Detroit?format=j1'
{
"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.
Prometheus
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:
- job_name: 'weather_detroit'
static_configs:
- targets: ['your-server:8080']
metrics_path: '/Detroit'
params:
format: ['p1']
Moon phases
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 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.
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.
<!-- ~/.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:
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.
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.
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:
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); 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 can stand in for one:
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
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.
./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
token. IP geolocation needs GeoLite2-City.mmdb from MaxMind,
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:
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: 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.
License
Apache License 2.0. See LICENSE.