< the skull

HAMMER
======
2026-08-27

Before the web had that name, people dialed into a BBS. You connected a modem to
a phone number, waited for the handshake to finish, and watched a menu appear on
the screen. Inside, you could read messages, download files, and use programs
running on the computer at the other end. It was an entire service contained in
a text terminal.

Hammer grew out of that old idea. I took a BBS front end I had sketched and
abandoned, then restored it on top of a weather service I already maintained.
The retro curiosity eventually became something I actually use: a terminal you
can reach over SSH, with no password or account, that can show the weather
forecast, diagnose a network, generate a QR code, and handle other jobs without
installing anything on the client machine.

This article explains how I built it.

Hammer in one sentence
----------------------

In one sentence, Hammer is a single Go binary that starts two services at once:
an HTTP weather server, with terminal-friendly curves and icons, and an
interactive SSH front end. Weather was the starting point. Today, it is only the
first tool in a much larger catalog.

You connect like this:

  ssh -p 2323 guest@hammer.example.com

The command opens a colored menu drawn with box characters and displayed in your
language. There is no program to install and no account to create.

The decision that defined the project: one binary
-------------------------------------------------

The first architectural choice turned out to be the most important. Hammer is a
static executable with no runtime dependencies. The fonts used to render images,
translations for 75 languages, HTML templates, and help pages all live inside
the binary through `go:embed`. There is no nginx in front of it, no assets
directory that needs to follow the deployment, and no forgotten file waiting to
break everything.

This completely changes the deployment experience. Moving the service to another
server means copying one file.

The cost appears at build time. The binary is around 35 MB and, because it uses
SQLite to store profiles, the build requires cgo and a C compiler. On a Windows
machine without a C compiler, I build it inside a Linux container. On the
server, the Dockerfile takes care of it.

The weather core: inheritance and rewriting
-------------------------------------------

The weather service is a Go reimplementation of a terminal-based forecast
service. Processing happens through a sequence of stages, each defined by an
interface that can be replaced:

  client IP → geolocation → option parsing → geocoding →
  upstream request → translation → rendering → formatting → cache

The visitor's approximate city comes from an offline IP geolocation database.
When a user enters a city name, an external geocoding service turns that name
into coordinates. No key is stored in the code. Secrets come from a `.env` file
that never enters version control, and appear in the configuration as
`${VARIABLE}`.

The forecast can be displayed in several forms through the `view` option. The
classic presentation is a report made with ASCII art. My favorite is the one I
built from scratch during the restoration: an emoji card.

The emoji card and the problem almost nobody notices
----------------------------------------------------

Rendering an emoji in a terminal sounds trivial until you draw a box around it.

Width is the problem. Most emojis occupy two cells, but some terminals count
them as one. When that happens, the right border shifts and the entire layout
bends out of shape.

I solved it with a simple rule applied without exceptions. Each emoji gets a
fixed three-cell field, and the final field on every line contains ASCII text
positioned away from the border. If the terminal calculates a glyph's width
incorrectly, the drift stays in the middle of the line and the frame remains
intact.

This is the result in a 64-column terminal:

There is also a narrow 46-column version for people connecting from a phone. The
card respects the selected language. Words such as "sunrise," "sunset," and
"feels," along with weekday and moon phase names, come from the translation
system. None of them are locked to English inside the renderer.

The SSH front end: how Hammer recognizes you
--------------------------------------------

The SSH server starts in the same process as HTTP. Its port also hosts the menu.
The interesting part is how Hammer identifies each visitor.

When you connect with an SSH key, the key fingerprint becomes your identity. In
anonymous mode, when no key is provided, the server accepts the connection and
uses a hash of the IP address. In both cases, preferences live in a SQLite
database: default city, saved cities, units, language, and presentation. When
you return, the menu already looks the way you left it.

Anonymous access was a deliberate choice. I wanted to reduce the barrier to
entry to zero, including for someone who has never generated an SSH key. When
this mode is active, the server accepts keyboard-interactive authentication
without presenting a challenge. Public key authentication remains available for
people who already use SSH every day.

The detected language selects one of 75 translation packs. The complete BBS
interface contains 134 translatable text keys. Menus, notices, and labels appear
in Portuguese for visitors from Brazil, in English for everyone else by default,
and in dozens of other languages when applicable.

I also spent a disproportionate amount of time on the details of typing into a
raw terminal. Nobody usually praises them, but everyone notices when they are
missing. Arrow keys do not dump strange characters into the field. Accents
appear correctly while someone types "São Paulo." Backspace erases the whole
letter, not half a character. Ctrl-C cancels the action without dropping the
session. Long output is paginated instead of disappearing above the top of the
screen.

When the project stopped being only about weather
-------------------------------------------------

The turning point came with a question: which tasks make sense as a remote
service launched from inside a terminal?

The answer became the tool catalog in the menu.

  ╭─┤ ⚡ HAMMER ├─────────────────────────────────────╮
  │  [1]  weather         Example City                │
  │  [2]  network         DNS, TLS, HTTP, RDAP        │
  │  [3]  qr              URL, phone, text            │
  │  [4]  hash            SHA, entropy, type          │
  │  [5]  jq              transform JSON/YAML         │
  │  [6]  age             encrypt / decrypt           │
  │  [7]  links           check broken links          │
  │  [8]  data            query local databases       │
  │  [9]  route           ping / MTR                  │
  │  [s]  settings        [a] about   [m] more        │
  ╰───────────────────────────────────────────────────╯

The tools fall into two groups.

Native Go tools
---------------

These are always available. Their logic is part of the binary, so they do not
depend on programs installed on the server:

  * `network`: queries A, AAAA, MX, TXT, NS, and CNAME DNS records; inspects the
    validity, issuer, and SANs of TLS certificates; makes an HTTP request; and
    retrieves a domain record through RDAP.
  * `qr`: turns a URL, phone number, or text into a QR code drawn directly in
    the terminal with ANSI half blocks.
  * `hash`: accepts pasted bytes and calculates SHA-256, SHA-1, MD5, and Shannon
    entropy. It also identifies the file type from its magic bytes and checks
    whether the content is valid UTF-8. The material is never executed, only
    inspected.
  * `jq`: applies a jq filter to pasted JSON or YAML. Despite the name, it does
    not call the external `jq` executable. The implementation is written in Go
    and works on any host.
  * `age`: encrypts and decrypts pasted text with modern cryptography, and can
    generate key pairs. The private key is never written to disk.
  * `more`: opens a submenu with CSV statistical summaries, base64 conversion,
    and UUID generation.

External and optional tools
---------------------------

The second group appears only when the server has the required executable.
During startup, Hammer looks for `lychee`, `duckdb`, `mtr`, `ping`, and
`ocrmypdf` in the `PATH`. If it finds one, it shows the corresponding tool. If
it does not, the option stays hidden.

The user never opens a menu item that is destined to fail. The binary keeps
working on its own but gains capabilities according to what is present on the
machine:

  * `links`: extracts URLs from text and checks which are broken with `lychee`.
  * `data`: runs preselected SQL queries against local databases with `duckdb`.
  * `route`: uses `ping` and, when available, `mtr` against a host.
  * `ocr`: accepts a PDF, applies optical character recognition with `ocrmypdf`,
    and returns the text.

The part I am proudest of: security
-----------------------------------

An open SSH service with no password, capable of running network tools at
anyone's request, is an invitation to become an attack proxy. If the network or
ping functions accepted any destination, someone could use my server to probe
internal networks or reach services that should only be accessible locally.

This is the classic SSRF problem. It sat at the center of the design from the
beginning.

The defense has three layers.

A network guard
---------------

Every tool that resolves a user-supplied host goes through a filter. It rejects
loopback, private ranges, link-local addresses, cloud provider metadata ranges,
and other reserved addresses that should not be reachable from the internet.

The check runs again after every redirect. A public server cannot get around it
by replying with "go to 127.0.0.1." When a destination resolves to a private
address, Hammer can show the DNS result but does not continue with TLS or HTTP.

An executor that does not use a shell
-------------------------------------

External tools are never called through a command string. Hammer sends arguments
as a list directly to the process, without interpolating them into a shell. A
quote or `;` in the input therefore has no path to become command injection.

This central executor also enforces a timeout, limits output size to protect
memory, and runs with a minimal environment.

Path confinement and quotas
---------------------------

Tools that handle files, such as OCR and data queries, resolve every path inside
a root directory. Any attempt to escape with `..` is rejected.

The entire BBS shares a storage quota and runs automatic cleanup. Files count
toward a global limit and are removed after a period, preventing the disk from
filling on its own. There is also a cap on simultaneous connections per IP
address.

The mental model is simple: open at the entrance, strict about what happens
inside. Anyone can connect, but every action has to pass through a gate.

How Hammer is built and run
---------------------------

The build begins with a script that synchronizes assets with the embed
directory, copies the fonts, and compiles the static binary with its version
included. In production, a two-stage Dockerfile installs the required tools only
during compilation and delivers a lean final image.

Secrets live in a `.env` file outside version control. The configuration is
stored in YAML, with values read from environment variables. This lets the same
file work on different machines while only the `.env` changes.

In a typical deployment, the HTTP server listens only on loopback, behind a
reverse proxy that handles HTTPS. SSH is exposed directly on a dedicated port.

  # Illustrative example with no real values
  bbs:
    enabled: true
    port: 2323
    allowAnonymous: true
    storage:
      globalQuotaBytes: 1073741824   # 1 GB shared with automatic cleanup
  server:
    portHttp: 8080

What I learned during the restoration
-------------------------------------

Two things became clear while I worked on Hammer.

First, the single-binary constraint, which looked like a limitation, gave the
project its identity. Every capability has to live inside the binary or discover
for itself whether it can run. That forced me to separate the essential from the
optional. The menu adapting to the server is not a presentation trick. It is a
direct consequence of that discipline.

Second, an open service remains sustainable only when security is part of the
foundation. The network guard and shell-free executor did not appear after an
incident. They came before the first network tool, because without those
protections the tool should not exist.

Hammer is still a text menu in a terminal, the same format used forty years ago.
Now it runs over SSH, speaks 75 languages, and offers tools built for today's
problems.

An old format, restored to do new work.


< the skull