You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Serial ports gain a third mode: Kermit Server. Alongside Modem
(AT Command) Mode and Telnet-Serial Mode, each serial port (A/B) can
now run as an always-on Kermit server: as soon as the port is enabled it
listens for Kermit packets directly on the wire — no AT commands, no
dialing, no menu. It is the same server ATDT KERMIT reaches from the
modem emulator, but always on and with no AT layer; received files land
in transfer_dir, and it re-arms after every FINISH/BYE so the wire stays
a live server. The port reopens automatically if the device disappears
(matching modem mode). Selectable from the GUI Mode dropdown, the web
config's per-port "More…" popup, and the telnet per-port T toggle
(which now cycles Modem → Console → Kermit). Persists as serial_a_mode / serial_b_mode = kermit. Auth and the telnet menu are
bypassed by design — enable only on trusted serial lines (same posture as allow_atdt_kermit).
YMODEM receives multi-file batches (sb file1 file2 …). The receiver
previously ran the end-of-batch handshake right after the first file's EOT,
so a batch sender lost every file after the first (and could hang waiting for
the receiver). It now reads the next block 0 at each EOT — a named block 0
starts the next file, the null block 0 ends the batch — and returns every
file. The first file keeps the user-entered name; files 2..N use the sender's
(sanitized) block-0 name, saved atomically like ZMODEM/Kermit batches. A
corrupt inter-file block 0 is NAK-retried (bounded), the batch is capped at
1000 files, and a non-UTF-8 file name is received under a generated name
rather than truncating the batch.
Weather works worldwide, not just US zip codes. The Weather menu now
accepts any city name or postal code (London, SW1A 1AA, Zürich, 62051), percent-encoding the query so spaces and non-ASCII are safe. A City, Country or City, Region qualifier disambiguates common names
(London, GB vs London, Ontario; Paris, France vs Paris, Texas), and
the matched country is shown. A new weather_units setting — auto
(default: Fahrenheit/mph for the US, Celsius/km/h elsewhere), us, or metric — controls display units; press U on the weather screen to cycle
them in place (no re-fetch). Wired into the telnet Other-Settings menu, the
web config page, and the GUI. The config key weather_zip is renamed to weather_location; an existing weather_zip value migrates automatically
on first load, and any saved location persists across sessions as before.
Configurable desktop GUI display scale (gui_zoom). The console window
now honors a gui_zoom setting: auto (default) follows the monitor's own
scale factor as before, while a number (e.g. 1.0, 1.25, 0.8) pins the
window's pixels-per-point absolutely so a display that reports an inflated
DPI no longer renders the GUI oversized. Selectable as "Display scale" from
the GUI's Server → More panel and the web config's Server → More page
(Auto / 75% / 100% / 125% / 150% / 200%), and clamped to 0.5–3.0.
Show the file being downloaded on the SELECT PROTOCOL screen. The
download protocol picker now displays the file name (truncated to the
terminal width) and byte size above the protocol list, so the user can
confirm the right file before choosing a protocol.
Make directories from the telnet file-transfer menu. A new M option
creates a subdirectory inside the current transfer working directory (the
name is validated like a filename — a single component, no .. or /), then
asks whether to make it the working directory.
Weather falls back to MET Norway when Open-Meteo is unreachable. If the
Open-Meteo forecast host can't be reached, the Weather menu now automatically
retries the forecast against MET Norway (api.met.no Locationforecast 2.0 —
free, no API key, independent infrastructure), reusing the coordinates
already geocoded via Open-Meteo (worldwide coverage, so the fallback works for
any location). MET's data is kept in metric and converted to your chosen units
at display time, and its symbol codes mapped to descriptions; you only see an
error if both providers fail.
Wait for the receiver before starting a Kermit download
(kermit_wait_for_receiver, default on). A Kermit transfer is
receiver-driven at the start — the receiving side sends a NAK to solicit the
sender's Send-Init (Frank da Cruz, Kermit Protocol Manual §4). The gateway
now holds its Send-Init until that poke arrives and then sends exactly one, on
both interactive downloads and Kermit server GET responses. A client that
never pokes (e.g. C-Kermit) falls through a short bounded wait and gets an
unprompted Send-Init as before. Wired into the telnet Kermit-settings menu
(G), the web config page, and the GUI.
Verbose Kermit receive-path logging. With verbose = true, the Kermit
upload/receive path now emits periodic per-packet progress plus a
per-file summary (bytes, packets, block-check type), matching the diagnostic
style of the XMODEM/YMODEM/ZMODEM paths. Off by default.
Changed
Warning popups are now dark red (GUI and web). Security/confirmation
warnings previously looked identical to ordinary popups, so it wasn't obvious
the modal was blocking the next click and had to be acknowledged. The GUI's
four warning popups (ATDT-KERMIT, Kermit-server, disable-IP-safety,
master-needs-SSH) now use a dark-red panel + red border. On the web, the
native confirm()/alert() warnings (disable web server, change web port,
master-needs-SSH) are replaced with matching dark-red modal dialogs whose
overlay blocks the form until the operator chooses Continue/Cancel. The web
also gains the enable-guard warnings it was missing versus the GUI — Disable IP Safety, Kermit Server, and Allow ATDT KERMIT now raise
the same red confirmation before they take effect.
ZMODEM batch receive is capped at 1000 files (MAX_BATCH_FILES, matching
YMODEM and Kermit), so a peer that streams endless files can't grow the
in-memory batch without bound; the receiver cancels and errors past the cap.
Config UI: tidier frames via "More" popups. The web config page now keeps
the Master/Slave relay settings under the Server frame's More… popup
(they were a separate card), matching the GUI and returning the page to six
frames. The AI Chat, Browser, and Weather frame (both web and GUI) is now
three rows — API key and homepage on the frame, with a More… button that
opens the weather location and units.
Weather fetch fails fast with a clearer message. The Open-Meteo request
now uses a 5 s connect timeout (was a single 15 s global) and retries once on
a transient transport failure, so an unreachable/blocked forecast host no
longer hangs the Weather menu for 15 s. Errors are distinguished:
"Not found - try 'City, Country'." (no geocoder match) vs "Weather service
unreachable. Try again later." (network/host down) vs "Weather service
returned bad data." (parse).
Fixed
Serial Kermit Server Mode transfers at full speed. Kermit Server Mode was
far slower than the same server launched from the File Transfer menu. The
bridge that pumps bytes between the wire and the Kermit server
(run_console_bridge) drained its outbound queue only between wire reads,
and Kermit is stop-and-wait — so while the gateway composed each reply the
wire sat idle and the bridge blocked out the full wire-read timeout before
writing that reply, adding a fixed delay to every gateway-originated packet
(each ACK when receiving, each DATA when sending — hence the slowdown in both
directions). The menu/ATDT KERMIT server never had this because its pump
produces and flushes the reply in the same iteration that consumed the
request. Server Mode now uses that same inline pump (run_kermit_bridge_inline,
modeled on the modem online-mode path) instead of the decoupled two-thread
bridge, so a reply leaves the wire the moment it's produced. (The interactive
Serial Gateway console still uses run_console_bridge, whose backpressure
design is intentional there.)
Kermit server uploads no longer drop a file on a name collision. When an
uploaded file's name already exists in transfer_dir, every Kermit-server
receive path (the telnet-menu server, the standalone TCP listener, and the
new serial Kermit Server Mode / ATDT KERMIT) now renames the incoming file
DOS/CP-M-Kermit style instead of skipping it — the base name is numbered
within 8 characters the way CP/M Kermit clients (e.g. kercpm3) do on a
download collision: abcdefgh.txt → abcdefg0.txt … abcdefg9.txt → abcdef10.txt, and a shorter name such as hi.txt → hi0.txt. The original
file is never overwritten, and a verified resume still replaces its own
partial in place. (The pre-existing telnet-menu and TCP-listener paths
previously skipped such a collision with an "already exists" note.)
ATDT KERMIT uploads now actually save to disk. The serial Kermit-server
dial path passed a no-op file-commit hook to the server, but the Kermit
receiver only buffers uploaded files in memory and relies on that hook to
persist them — so a client send/put over ATDT KERMIT completed on the
wire but left nothing in transfer_dir (downloads/get, which read from
disk, were unaffected). Both the always-on serial Kermit Server Mode (new,
above) and the ATDT KERMIT dial now commit each received file with the same
filename / subdir path-safety validation as the telnet and TCP-listener
Kermit server paths.
Kermit server GET is now case-insensitive. A client requesting a file in
a different case than it is stored on disk — CP/M clients such as kercpm3
uppercase filenames — no longer fails "File not found" and burns a retry
re-requesting under another case. The server prefers an exact match, then
falls back to a case-insensitive match among the transfer directory's direct
entries, so the path-traversal protection is unchanged.
Kermit server downloads no longer provoke spurious retransmissions on
vintage receivers. The server was sending its Send-Init unsolicited; a
receiver-driven client (e.g. kercpm3 on CP/M) pokes with a NAK to solicit
it, that poke crossed our Send-Init on the wire, and we resent it — delivering
a duplicate the client tallied as a retry. The server now waits for the poke
and answers with a single Send-Init. Combined with the case-insensitive fix
above, this cuts the retry count such clients report on a clean download from
2–3 down to the single, unavoidable initiating NAK that the Kermit
receiver-driven start requires (uploads read 0 — there the client is the
sender and never pokes). Documented in usermanual.html and kermit.html.
Kermit sender no longer cascades retransmits on a duplicate ACK. On a
serial download to a hardware CP/M client (kercpm3 / Kermit-80), the
receiver-driven start makes the gateway send its Send-Init twice, so the
client's first ACK arrives duplicated — and the sender treated that stale,
already-satisfied ACK by retransmitting the current packet, whose re-ACK
became the next stale ACK: a self-perpetuating cascade that showed as a burst
of dozens of "retries" before the transfer settled and ran clean. A
stale/duplicate ACK is now discarded without retransmitting (the sender keeps
reading for the ACK that advances the window); a retransmit still fires only
on a NAK for the current sequence or a read timeout.
Kermit remote dir / remote help replies no longer staircase on CP/M
clients. The server built those listings with bare-LF line endings;
C-Kermit on Unix masks this via the tty's ONLCR, but a hardware CP/M client
(Kermit-80) does no translation, so each line stepped down without returning
to column 0. Both bodies are now CRLF-encoded before transfer (existing CRLFs
left intact; TYPE's verbatim file bytes are deliberately untouched).
Web browser surfaces the real error when the AI chat API rejects a
request. The Groq client treated every non-2xx response as an opaque
transport error (http status: 401) and discarded the JSON body, so its
code to extract Groq's descriptive error.message (e.g. "Invalid API Key",
rate-limit text) never ran. It now reads the body on error responses and
reports Groq's own message.
ZMODEM downloads are no longer throttled to ~5 KB/s on fast links. When
reading a hex header the receiver drained up to three trailing bytes (CR, LF,
and an optional XON), but Forsberg's zsendhdr omits the XON for ZACK and ZFIN frames — so on those the drain blocked the full 200 ms per frame
waiting for an XON that never comes. Because our sender ACK-gates every
1 KB subpacket (ZCRCQ → read ZACK), that phantom wait capped throughput
near one subpacket per 200 ms regardless of link speed. The receiver now
waits for the third trailing byte only for frame types that actually carry
it. No wire bytes change; slow retro links are unaffected (a subpacket's own
transmission already dwarfs 200 ms there).
Plain XMODEM sends no longer report a false failure at xmodem_max_retries = 1. A Forsberg-compliant receiver NAKs the firstEOT to verify
end-of-file and ACKs only the resent one (our own receiver does this), so
completing the handshake requires at least two EOT attempts. The send-side EOT loop was bounded by xmodem_max_retries, so at the minimum setting of
1 it sent a single EOT, took the expected verification NAK as failure,
and reported an error on a transfer that had actually succeeded. The EOT
budget now floors at 2; a receiver that ACKs the first EOT still returns on
the first pass, so the common case is unchanged.
Serial dial-out stays responsive to shutdown and config restarts. When
an ATDT target resolved to several unreachable addresses, the modem tried
each in turn and could block the serial thread for (address count × the S7
timeout) — during which a server shutdown or a per-port config restart was
stalled. The dial loop now checks the shutdown/restart flags between address
attempts and bails with NO CARRIER. The peer-dial answer-wait is likewise
clamped to the same 60 s ceiling ATDT uses, so a large S7 can't pin the
caller's port for up to 255 s while a local peer rings.
SIGHUP reloads instead of shutting the service down. SIGHUP was wired
to the same shutdown flag as SIGINT/SIGTERM, so systemctl reload cleanly
stopped the gateway — and because the exit was clean (code 0), Restart=on-failure
did not bring it back, leaving the service down. SIGHUP now arms the
restart/reload path (re-reading config) instead of exiting, matching the
shipped systemd unit's ExecReload.
Kermit CAPAS long-packet / sliding-window bits corrected. The capability
mask had LONGPKT and SLIDING transposed versus the canonical layout
(C-Kermit ckcmai.c: long = 0x02, sliding = 0x04). Gateway↔gateway sessions
and the test suite were self-consistent and unaffected, but a third-party peer
advertising one capability without the other (e.g. G-Kermit, MS-DOS Kermit —
long packets, no windows) was misread, desyncing the rest of the Send-Init.
Now fixed and pinned against the C-Kermit source.
Serial console/modem no longer busy-loops at 100% CPU on a port EOF
(macOS).run_console_bridge and command_mode_tick treated a
zero-length read (Ok(0)) as "no data" and re-polled immediately. The port
is opened with a read timeout, so an idle read is Err(TimedOut) — Ok(0)
actually means the device closed (e.g. a PTY master after its slave exits,
where loss surfaces as EOF rather than the Err(EIO) a real ttyUSB gives).
Both now treat it as a disconnect (reopening in modem mode), matching the
online-path readers. Inert on Linux.
ZMODEM receiver no longer emits the sender's OO trailer. Per Forsberg
§8.4 the receiver replies ZFIN and then reads the sender's OO; emitting
our own was a role inversion (harmless in practice — the peer had already
sent its own and exited).
Security
SSH server refuses to overwrite an unreadable host key. If the host-key
file existed but failed to parse (e.g. truncated by a full disk), the server
silently generated a new key and wrote it over the old one — changing the
server's SSH identity and tripping every client's "REMOTE HOST
IDENTIFICATION HAS CHANGED" warning (and potentially clobbering a merely
truncated, recoverable key). It now refuses to start the SSH server in that
case, leaving the file untouched for the operator to restore or remove, the
way sshd treats a bad host key. A missing key file is still generated
normally on first run.
Punter receive can no longer be hung by a flood of empty blocks. A peer
that streamed valid-checksum, non-final, zero-payload blocks would spin the
receive loop forever: an empty block never grows the output (so the file-size
cap never trips) and passes the checksum (so the bad-block cap never trips).
A conformant C1 sender emits exactly one header-only block per phase (block 0,
which only announces block 1's size), so the receiver now bounds the number
of accepted empty non-final blocks and gives up on a peer that exceeds it.
Text-mode web browser can no longer be crashed by a deeply-nested page.
A page whose HTML nested tags tens of thousands deep (e.g. unclosed <div>s,
well under the 1 MB body cap) parsed into a DOM so deep that the browser's
recursive title/form extractors overflowed the worker-thread stack and
aborted the entire gateway process (all telnet/SSH sessions), a
remotely-content-triggered denial of service. The browser now rejects a
document nested deeper than 512 element levels ("Page is too deeply nested to
render.") before those walks run.
Refreshed dependencies to clear RustSec advisories.cargo update
moved aes (yanked) → 0.9.1, memmap2 (RUSTSEC-2026-0186 unsound) → 0.9.11,
dropped anyhow (RUSTSEC-2026-0190 unsound), and bumped the egui/eframe stack
to 0.34.3 and russh to 0.60.3. The two quick-xml DoS advisories
(RUSTSEC-2026-0194/0195) are waived in .cargo/audit.toml: quick-xml is a
build-time proc-macro dependency (wayland-scanner) that parses trusted
Wayland protocol XML at compile time — it is not in the shipped binary and the
gateway does no runtime XML parsing, so neither DoS path is reachable.
Web config UI: enabling login no longer widens IP exposure. The
private-IP allowlist now applies whenever disable_ip_safety is off,
regardless of whether "Require Login" is on. Previously, enabling security dropped the allowlist — accepting any source IP, gated only by
cleartext-HTTP Basic auth on a page that renders the login password and Groq
API key into form fields. Login-gated access from arbitrary IPs is now an
explicit disable_ip_safety = true opt-in. (The telnet listener is
unchanged and intentionally still opens to any IP under security_enabled —
it echoes no secrets and is the retro-hardware access path.)
Relay onward-dial now requires the master's allow_peer_dial. A slave's
Model-B onward-dial — asking the master to open an outbound TCP connection to
an arbitrary external host:port — was gated only by gateway_role = master
master_accept_relays. It now also requires the master's allow_peer_dial
(the same opt-in that already governs peer-dial), closing an authenticated
SSRF/pivot/port-scan primitive available to any holder of the shared
credentials.
Text-mode web browser no longer re-sends a form POST over cleartext. On a
TLS error an HTTPS form submission was transparently retried over http://,
re-sending the form fields (possibly credentials) in the clear before the
downgrade notice was shown — an active MITM could force a TLS error to strip
encryption and capture the body. A POST is now refused on a TLS error;
idempotent GET page loads still downgrade with a warning banner.
Web-browser page text is sanitized before it reaches the terminal. Remote
content (HTML, text/plain, and gopher) now passes through the same sanitize_for_terminal filter as the AI-chat path, stripping ANSI/CSI/OSC
escape sequences a malicious or MITM'd page could use to manipulate a retro
terminal. Link-number sentinels are preserved. Coverage also includes the
page URL (a gopher selector can carry escapes into the status line) and all
rendered form text — form/field labels, Select option text (sanitized in
place), and displayed field values (sanitized at display time so the
submitted value stays byte-exact) — which the form view/edit UI prints.
An unreadable existing config is no longer reset to insecure defaults. If egateway.conf is present but can't be read (non-UTF-8, corruption, or a
permission/I/O error), the gateway now refuses to start rather than
overwriting it with security_enabled = false / password changeme. Config
and dial-map saves also fsync before the atomic rename, so a crash or power
loss between write and rename can't publish a truncated file (which would
then trip the new fail-loud guard on the next start). An existing file that
parses to no recognized settings (empty, whitespace-only, or comments-only
— e.g. an external truncation to zero bytes) is likewise treated as
unreadable rather than as "all defaults," so it can't silently downgrade the
gateway either.
Startup warns on the wide-open combination.disable_ip_safety = true
together with security_enabled = false — an unauthenticated gateway
reachable from any IP — now emits a startup warning, matching the guard the
GUI/telnet toggle popups already apply.
ZMODEM: bound control-frame floods that make no forward progress. The
45 s negotiation deadline and per-read timeout bound silence, not a peer
that streams valid control frames. The receiver now bounds progressless
control frames (ZRQINIT/ZSINIT/ZFREECNT/ZSTDERR/unknown), reset by a real
ZFILE, and the sender bounds stale-ZRINIT drains per ZFILE attempt, so a
chatty-but-progressless peer can no longer keep a session alive indefinitely.
Telnet: the session subnegotiation reader is now slowloris-bounded. read_subneg_payload bounds each read with SB_DRAIN_TIMEOUT, so a peer that
sends IAC SB then stalls without IAC SE can no longer pin the session and
its max_sessions slot when idle_timeout_secs = 0. This matches the two
gateway-path SB readers, which were already bounded.
Serial: the direct peer-dial ring is now shutdown/restart-aware. While an ATD <Port>@<ip> to a local modem port was ringing unanswered, the caller's
serial thread parked in a blocking wait and ignored shutdown/restart for up to
the clamped S7 window. The ring now races a shutdown/restart poll (the same
idiom the modem-port announcer uses), so a config restart or shutdown is
responsive within ~100 ms.