The bottom of rubbs: how a connection becomes a
BBS::Session, what the telnet negotiation does, the window size (NAWS), every keyreadkeycan return,readline, writing, mouse reporting, idle limits and hang-ups (lib/bbs/server.rb,session.rb,telnet.rb); andBBS::Store, the table a board keeps its callers' data in, an SQLite file since 0.33.0 (lib/bbs/store.rb). The other blocks a content board needs (SQLite, HTTP, cache, rate limits, logging) are on Data and network. Most boards only meet these through a Flow or an Application; you need them directly in a Flowcallstep, a door, or a C64 face.
BBS.start and BBS::ServerBBS.start # port from BBS.config.port (ENV['BBS_PORT'] or 2323)
BBS.start(port: 2400)
BBS.start(port: config.port) runs BBS::Server.new(port:).run, which blocks:
0.0.0.0:PORT (prints BBS listening on port …);max_sessions callers already on, it writes Too many callers right now, try again later., logs BBS busy: … and closes; with max_sessions_per_ip calls already coming from that address, it writes Too many calls from your address, try again later. and logs BBS crowded: …;idle_seconds (900 s when nil; needs Ruby 3.2's IO#timeout=), and runs a new BBS::Session;Session#run: negotiate, enable_mouse when config.mouse and config.mouse_on_connect are both on, then FlowRunner.new(session, session_id, config.flow).run; afterwards it always switches the mouse off and closes the socket;config.on_session_end.Every line goes through BBS.log and starts with config.log_tag (see Data and network). A hang-up or a silent line (IOError, EPIPE, ECONNRESET, ETIMEDOUT, IO::TimeoutError) is logged as feedia-bbs: session: dropped by the client (…), not as an error. Any other exception is logged under session with its class, message and three lines of backtrace. Errors of accept itself (ECONNABORTED, EMFILE, ENFILE) are logged and the loop waits half a second instead of spinning. Ctrl-C and SIGTERM (docker stop) print Server stopped..
| Field | Default | Meaning |
|---|---|---|
port |
ENV['BBS_PORT'] or 2323 |
The TCP port of BBS.start. |
idle_seconds |
nil |
The socket's read/write timeout (900 s when nil) and the idle disconnect of an Application. See Idle limits. |
max_sessions |
ENV['BBS_MAX_SESSIONS'] or 100 |
Callers served at once. |
max_sessions_per_ip |
ENV['BBS_MAX_SESSIONS_PER_IP'] or 6 |
Calls served at once from one address. Private, loopback and link-local peers (bbs-web, proxying many callers from the Docker network) are not limited. Since 0.33.0. |
log_tag |
ENV['BBS_LOG_TAG'] or bbs |
What every log line starts with: the board's technical name, feedia-bbs. Since 1.0.0. |
mouse |
false |
Whether the board uses the mouse at all. |
mouse_on_connect |
true |
With mouse, switch reporting on at connect. A two-faced board sets it false (see Commodore 64). |
on_session_end |
nil |
->(session) { … }, called from the server thread after each session, also after errors. |
require 'bbs'
BBS.configure do |c|
c.port = 2400
c.idle_seconds = 600
c.max_sessions = 20
c.on_session_end = ->(session) { puts "#{session.session_id} left (#{session.term_cols}x#{session.term_rows})" }
c.flow = BBS::Flow.define do
say 'Hello!'
end
end
BBS.start
Right after the connection the session sends four telnet commands and assumes an 80×24 window until told otherwise:
| Bytes | Command | Why |
|---|---|---|
ff fb 03 |
IAC WILL SGA |
Suppress go-ahead: character at a time, not line at a time. |
ff fb 01 |
IAC WILL ECHO |
The server echoes, so readline decides what appears (e.g. a mask). |
ff fd 03 |
IAC DO SGA |
Asks the client to suppress go-ahead too. |
ff fd 1f |
IAC DO NAWS |
Asks the client to report its window size. |
The client's answers (WILL/WONT/DO/DONT + option) are read and dropped wherever they arrive: in readkey, readline, settle. There is no other option handling (no BINARY, no TTYPE). All of it goes through one IAC parser, BBS::Telnet::Parser (since 1.0.0), the same one a gateway uses in both directions. A client that does not speak telnet at all (a C64 behind a WiFi modem, nc) simply never answers.
When the client reports its size (IAC SB NAWS w1 w2 h1 h2 IAC SE) the session updates term_cols and term_rows:
IAC IAC) inside the report is read as 255;petscii!) the size stays 40×25, but naws_reported? still becomes true.| Method | Returns | Meaning |
|---|---|---|
term_cols |
Integer | Columns: 80 until reported, then the clamped report; 40 on a PETSCII line. |
term_rows |
Integer | Rows: 24 until reported; 25 on a PETSCII line. |
naws_reported? |
Boolean | True once the client has reported its window size. Real terminals do; port scanners and password-guessing bots usually do not. |
settle(timeout: 1.5) |
the session or nil |
Waits up to timeout seconds for the window size, reading and dropping whatever else arrives (a bot's root typed before any prompt goes nowhere). Returns the session when the size came, nil when it did not (until 1.0 true or false). Returns at once when the size is already known. |
resize!(cols, rows) |
— | Sets the size as a NAWS report would: clamped the same way, a zero ignored, nothing changes on a PETSCII line, and naws_reported? becomes true. The telnet NAWS handling calls it, and so does BBS::Gateway when the caller resizes during a crossing (since 0.17.1). |
settle is what BBS::Terminal.guess uses to tell a modern terminal from a C64. It also makes a cheap bot filter:
BOT_FILTER = BBS::Flow.define do
call do |_ctx, runner|
session = runner.session
next nil if session.settle(timeout: 2)
session.write "This board needs a telnet client that reports its window size.\r\n"
:halt
end
say 'Welcome, human.'
end
A call step that returns :halt ends the flow; next leaves the block with a value.
readkeykey = session.readkey # blocks until a key (or the socket timeout)
key = session.readkey(timeout: 1.0) # :idle when nothing came in 1 s
Returns one of:
"a", " ", "é", "ő");BBS::Telnet::MouseEvent — a mouse report (only when reporting is on);:idle — timeout passed without a key;nil — the caller hung up.Telnet commands and NAWS reports in between are handled and never returned. Without timeout, a read waits until the socket's own timeout (idle_seconds, 900 s by default) raises IO::TimeoutError, an IOError.
| Key | Symbol | Bytes it comes from |
|---|---|---|
| Enter / Return | :enter |
CR, LF, CR LF, CR NUL (each one Enter) |
| Tab | :tab |
09 |
| Shift-Tab | :shift_tab |
ESC [ Z |
| Backspace | :backspace |
08, 7f |
| Esc | :escape |
a lone ESC (nothing within 0.15 s), ESC ESC, or any escape sequence rubbs does not know |
| Ctrl-C | :interrupt |
03 |
| Ctrl-A … Ctrl-Z | :ctrl_a … :ctrl_z |
01–1a, except Ctrl-C, Ctrl-H, Ctrl-I, Ctrl-J and Ctrl-M, which are the keys above |
| Arrows | :up, :down, :right, :left |
ESC [ A…D, ESC O A…D |
| Home / End | :home, :end |
ESC [ H / F, ESC O H / F, ESC [ 1 ~ / 7 ~, ESC [ 4 ~ / 8 ~ |
| Insert / Delete | :insert, :delete |
ESC [ 2 ~, ESC [ 3 ~ |
| Page Up / Down | :page_up, :page_down |
ESC [ 5 ~, ESC [ 6 ~ |
| F1 – F4 | :f1 … :f4 |
ESC O P…S, ESC [ 11 ~…14 ~ |
| F5 – F12 | :f5 … :f12 |
ESC [ 15 ~, 17 ~, 18 ~, 19 ~, 20 ~, 21 ~, 23 ~, 24 ~ |
| Alt + Enter | :alt_enter |
ESC CR |
| Alt + printable | :alt_a, :alt_x, :alt_1, … |
ESC + the character, lowercased (Alt-Shift-A is also :alt_a) |
| Modified keys | :shift_up, :ctrl_f5, :alt_home, :ctrl_shift_right, … |
xterm's second parameter, see below |
| Nothing in time | :idle |
timeout: passed |
Modifiers. xterm reports Shift, Alt and Ctrl as a second CSI parameter: ESC [ 1 ; 2 A is Shift-Up, ESC [ 15 ; 5 ~ is Ctrl-F5. rubbs puts a prefix on the key's symbol:
| Parameter | Prefix | Example |
|---|---|---|
| 2 | shift_ |
:shift_up, :shift_f6 |
| 3 | alt_ |
:alt_home, :alt_page_down |
| 5 | ctrl_ |
:ctrl_right, :ctrl_f5 |
| 6 | ctrl_shift_ |
:ctrl_shift_left |
| 4, 7, 8 | none | the plain key (ESC [ 1 ; 4 A is :up) |
It applies to the CSI forms: arrows, Home/End, Insert/Delete, Page Up/Down and the ~ function keys. A modified F1–F4 in xterm's ESC [ 1 ; 5 P form is not decoded and reads as :escape.
Other bytes (NUL, 1c–1f, a broken UTF-8 sequence) are skipped. On a PETSCII line the keys are the C64's: see Commodore 64.
With mouse reporting on (enable_mouse), clicks, drags and the wheel come back as a BBS::Telnet::MouseEvent, a Struct:
| Field | Values |
|---|---|
kind |
:press, :release, :move (motion while a button is held), :wheel |
button |
:left, :middle, :right, :none; for :wheel :up or :down |
x, y |
1-based column and row |
mods |
Array of :shift, :meta, :ctrl |
Both report formats are read: SGR (ESC [ < b ; x ; y M press, … m release) and the old X10 (ESC [ M b x y, each plus 32).
| Method | What it does |
|---|---|
enable_mouse |
Writes \e[?1002h\e[?1006h: xterm button-event tracking (presses, releases, motion while a button is held) in SGR format. Does nothing when already on, or on a PETSCII line. |
disable_mouse |
Writes \e[?1006l\e[?1002l\e[?1000l when it was on. Session#run calls it at the end of every session. |
An Application turns MouseEvents into BBS::Events for widgets; a Door switches reporting off for its program. In a raw call step you get them as they are.
A call step owns the line while it runs. This one shows every key it reads, stops on Esc or after 30 idle seconds, and survives a hang-up:
require 'bbs'
KEYS = BBS::Flow.define do
call do |_ctx, runner|
s = runner.session
s.settle # wait for NAWS, or term_cols is still the default 80
s.enable_mouse
s.write "#{s.term_cols}x#{s.term_rows}, NAWS: #{s.naws_reported?}. Press keys, Esc ends.\r\n"
idle = 0
loop do
key = s.readkey(timeout: 1)
break if key.nil? || key == :escape
if key == :idle
idle += 1
break if idle >= 30
next
end
idle = 0
text = key.is_a?(BBS::Telnet::MouseEvent) ? "#{key.kind} #{key.button} at #{key.x},#{key.y}" : key.inspect
s.write "\e[1;33m#{text}\e[0m\r\n"
end
s.disable_mouse
nil
end
say 'Bye.'
end
BBS.configure { |c| c.flow = KEYS }
BBS.start
The first step of a call runs right after the negotiation is sent, before the client's answers have arrived: without settle, term_cols and term_rows would still be 80×24 and naws_reported? false. Use break, not return, to leave the loop: return inside a block raises LocalJumpError (the FlowRunner prints it as an error and goes on).
What it printed for a few inputs sent by a test script (with NAWS 100×30):
a "a"
ESC [ A :up
ESC [ 1 ; 2 A :shift_up
ESC [ 15 ; 5 ~ :ctrl_f5
ESC O P :f1
ESC [ Z :shift_tab
ESC x :alt_x
ESC CR :alt_enter
01 :ctrl_a
03 :interrupt
7f :backspace
c3 a1 "á"
ESC [ < 0;10;5 M press left at 10,5
ESC [ < 64;3;4 M wheel up at 3,4
ESC [ < 2;7;8 m release right at 7,8
ESC [ 1 ; 6 C :ctrl_shift_right
ESC [ 24 ~ :f12
require 'bbs'
MENU = BBS::Flow.define do
call do |ctx, runner|
s = runner.session
items = %w[News Files Chat Logoff]
pick = 0
loop do
s.write "\e[2J\e[H"
items.each_with_index do |item, i|
s.write(i == pick ? "\e[7m > #{item} \e[0m\r\n" : " #{item}\r\n")
end
key = s.readkey
case key
when nil then break
when :up then pick = (pick - 1) % items.size
when :down, :tab then pick = (pick + 1) % items.size
when :enter
ctx[:choice] = items[pick]
break
when String
hit = items.index { |i| i.downcase.start_with?(key.downcase) }
pick = hit if hit
end
end
nil
end
say 'Done.'
end
BBS.configure { |c| c.flow = MENU }
BBS.start
readlinename = session.readline # echoes what is typed
password = session.readline(mask: '*') # echoes a * for each character
| Behaviour | |
|---|---|
| Returns | The line as a UTF-8 String, without the line end; invalid bytes dropped. nil when the caller hangs up. |
| Ends on | CR, LF, CR LF or CR NUL (one line end). Writes \r\n when it ends. |
| Echo | Each character as typed, or mask per character (a UTF-8 character gets one mask). |
| Backspace | 08 or 7f removes one whole character and writes \b \b. |
| Limits | At most 1024 bytes (Telnet::MAX_LINE); further keys are ignored. Control bytes are ignored. |
| Timeout | None of its own: it waits until the socket timeout (idle_seconds). |
| PETSCII | On a C64 line it reads the C64's keys and echoes in PETSCII. |
A Flow's ask step uses it; in a call step:
LOGIN = BBS::Flow.define do
call do |ctx, runner|
s = runner.session
s.write 'Name: '
ctx[:name] = s.readline or next :halt
s.write 'Password: '
password = s.readline(mask: '*') or next :halt
next :halt unless password == 'secret'
nil
end
say 'Logged in.'
end
Tested: typing Zsé, Backspace, o, Enter returns "Zso" (UTF-8); a masked abc echoes ***.
| Method | What it does |
|---|---|
write(data) |
Writes a String to the caller. On a PETSCII line it goes through the Encoder first. Never raises: an error (a closed socket) returns nil, and the hang-up shows up on the next read. |
line |
The caller's socket itself, for a door that hands the line on as it is (BBS::Gateway), or for bytes that must skip the PETSCII Encoder. |
read_byte(timeout = nil) |
One raw byte as a String, :timeout, or nil; no telnet handling. |
read_raw_byte(timeout) |
One byte as an Integer for binary protocols (ZMODEM): telnet commands taken out, a doubled IAC returned as 255; :timeout or nil on hang-up. |
send_raw exists but is private (it is how the negotiation and petscii! bypass the Encoder); from outside, write to session.line.
session.write "\e[1;32mOK\e[0m\r\n"
session.line.write "\x07".b # a bell, past any encoder
| Method | Meaning |
|---|---|
session_id |
16 hex characters (SecureRandom.hex(8)), new for each connection. A FlowRunner puts it into the context as ctx[:session_id], and persist uses it as the Store key. |
term_cols, term_rows |
See NAWS. |
idle_seconds, idle_seconds= |
This caller's idle limit, copied from config.idle_seconds at connect. The Application loop reads it; setting it also sets the socket timeout (900 s for nil), so session.idle_seconds = 3600 gives one caller (a sysop) an hour. Since 0.19.0. |
petscii!, petscii? |
The C64 line, see Commodore 64. |
Idle. config.idle_seconds works in two places:
| Where | What happens |
|---|---|
| The socket | Read/write timeout (900 s when nil). A line silent that long, at a prompt, in a call step or inside a door, raises IO::TimeoutError: the session is dropped. |
BBS::Application |
Counts idle seconds between keys; at the limit it shows Disconnected after N s idle. and ends. |
Max sessions. config.max_sessions (BBS_MAX_SESSIONS, else 100) callers at once, and config.max_sessions_per_ip (BBS_MAX_SESSIONS_PER_IP, else 6) from one public address; the next one gets one line and is closed, and the server logs BBS busy or BBS crowded with both limits and the address (to stderr, unbuffered, so it reaches docker logs at once). Size max_sessions to the container's memory: a session costs roughly 0.5–1.7 MB on top of the board's own 20–40 MB.
Hang-up. When the caller drops:
readkey and readline return nil; write returns nil silently;call step that raises IOError, EPIPE or ECONNRESET halts the flow quietly; check for nil and break, or raise IOError yourself (as the C64 screen classes do);Session#run switches the mouse off and closes the socket; the server logs session dropped and calls on_session_end.BBS::StoreBBS::Store is a small table with one row per key (usually the session id): thread-safe, and robust against the bytes a terminal can send. Since 0.33.0 it lives in an SQLite file (a BBS::SQLite, see Data and network); until then it was a CSV file, and the CSV file of an older board is imported on first use.
SESSIONS = BBS::Store.new(path: 'data/sessions.csv', headers: %w[session_id timestamp player_name code])
BBS::Store.new(path:, headers:)| Argument | Meaning |
|---|---|
path: |
The file. A .csv path means the .sqlite3 file beside it (data/sessions.csv → data/sessions.sqlite3), so an older board keeps its path; any other path is the SQLite file itself. The directory is created on first use. |
headers: |
Array of column names (Strings). session_id is the key column; a timestamp column, when present, is filled automatically. A header added later becomes a new column. |
store.path, store.database_path and store.headers read them back.
| Method | Returns | What it does |
|---|---|---|
upsert(session_id:, **fields) |
the store | Inserts a row for session_id, or updates the existing one, in one transaction. |
find(session_id) |
Hash or nil |
The row as a Hash with String keys, the headers only. |
all |
Array of Hash | Every row, oldest first. |
delete(session_id) |
nil |
Removes the row; nothing happens when there is none. |
open! |
the store | Opens the file at once (and imports the old CSV), so a board does it at boot, not in its first caller's session. |
Insert (no row with that key yet): session_id is the key, timestamp the current UTC time in ISO 8601 (2026-10-27T22:15:45Z), other columns the fields of the same name (username: → username), empty (nil) when not given.
Update (the row exists): only the given fields change. timestamp keeps the time of the insert. A field set to nil becomes an empty string.
Fields that are not headers are ignored, on insert and on update alike.
require 'bbs'
USERS = BBS::Store.new(path: 'data/users.sqlite3', headers: %w[session_id timestamp username email])
USERS.upsert(session_id: 'a1', username: 'joe', email: 'joe@example.com')
USERS.upsert(session_id: 'b2', username: 'ann')
USERS.upsert(session_id: 'a1', email: 'joe@new.example')
USERS.find('a1')
# => {"session_id"=>"a1", "timestamp"=>"2026-10-27T22:15:45Z", "username"=>"joe", "email"=>"joe@new.example"}
USERS.find('zz') # => nil
USERS.all.size # => 2
USERS.delete('b2')
USERS.all.map { |r| r['username'] } # => ["joe"]
The rows are in the store table of data/users.sqlite3; sqlite3 data/users.sqlite3 'SELECT * FROM store' shows them while the board runs.
When the table is still empty and the CSV file of the path exists, the Store imports it in one transaction and renames it to users.csv.imported. A row that is not CSV, has more fields than the header or has no session_id does not stop the import: it is set aside, under the header, in users.csv.rejected for the sysop, and the log line counts it (store: 412 rows of data/users.csv imported into data/users.sqlite3, 2 set aside in data/users.csv.rejected). Call open! at boot so this happens before the first caller.
Callers type into these tables, and a terminal can send anything. Every value is turned into a String and cleaned with BBS::Text.clean: invalid UTF-8 bytes are removed, and so are the C0 control characters (tab and newline stay), DEL, the C1 controls and the bidirectional override characters. Only the ESC byte of an ANSI sequence goes: "ann\e[31m" is stored as ann[31m.
s = BBS::Store.new(path: 'data/log.sqlite3', headers: %w[session_id name note])
s.upsert(session_id: 's1', name: "ann\e[31m\x00 bé", note: "two\nlines")
s.find('s1')
# => {"session_id"=>"s1", "name"=>"ann[31m bé", "note"=>"two\nlines"}
Every call goes to the SQLite file through one shared connection behind a lock, so one Store object can be shared by all session threads. The file is in WAL mode: a command-line tool of the board can read it (and write it) while the board runs, and the board sees the change at its next call. There is no cache in memory any more.
persistA Flow's persist step writes context keys into a Store, keyed by the session id:
persist :player_name, :code, store: SESSIONS
It takes the named keys that are in the context (those not set yet are left out) and calls store.upsert(session_id: ctx's session id, **those); with none of them set it does nothing. Calling persist again later updates the same row. See Flow.
The Impostor BBS (bbs/impostor-bbs/lib/impostor.rb) keeps every debrief in one store this way:
module Impostor
SESSION_HEADERS = %w[session_id timestamp player_name code feedback email].freeze
def self.sessions_store(path) = BBS::Store.new(path: path, headers: SESSION_HEADERS)
def self.debrief(sessions:, screens_dir:)
BBS::Flow.define(screens_dir: screens_dir) do
ask :player_name, prompt: 'Player Name', transform: :upcase
ask :code, prompt: 'Code'
persist :player_name, :code, store: sessions
# ...
menu '>', loop: true do
option 'Leave feedback' do
ask :feedback, prompt: 'Feedback'
persist :feedback, store: sessions
end
option 'Subscribe to updates' do
ask :email, prompt: 'Email address', validate: :email
persist :email, store: sessions
end
option('Exit') { exit_menu }
end
end
end
end
# bbs.rb: one store, shared by both faces and every session
sessions = Impostor.sessions_store(File.expand_path('data/sessions.csv', __dir__)).open!
The key is just a string: a Store keyed by the caller's name keeps data across calls.
PROFILES = BBS::Store.new(path: 'data/profiles.sqlite3', headers: %w[session_id timestamp calls last_seen])
def count_call(name)
calls = PROFILES.find(name)&.fetch('calls').to_i
PROFILES.upsert(session_id: name, calls: calls + 1, last_seen: Time.now.utc.iso8601)
end
In an Application handler or a call step, the session id is ctx[:session_id] (or context[:session_id] inside an Application).
The Store is one table of text columns keyed by one string. A board with posts, threads or several tables uses BBS::SQLite directly, with its own schema and numbered migrations: see Data and network.
BBS.configure and a first boardask, persist, call