EN /HU | Login

Telnet, session and store

The bottom of rubbs: how a connection becomes a BBS::Session, what the telnet negotiation does, the window size (NAWS), every key readkey can return, readline, writing, mouse reporting, idle limits and hang-ups (lib/bbs/server.rb, session.rb, telnet.rb); and BBS::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 Flow call step, a door, or a C64 face.

From connection to session

BBS.start and BBS::Server

RUBY
BBS.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:

  1. listens on 0.0.0.0:PORT (prints BBS listening on port …);
  2. for each connection checks the limits: with 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: …;
  3. starts a thread, sets the socket's read/write timeout to idle_seconds (900 s when nil; needs Ruby 3.2's IO#timeout=), and runs a new BBS::Session;
  4. 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;
  5. when the session ends, however it ends, the thread decrements the count and calls 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..

The config fields of this layer

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.
RUBY
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

Telnet negotiation

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.

NAWS: the window size

When the client reports its size (IAC SB NAWS w1 w2 h1 h2 IAC SE) the session updates term_cols and term_rows:

  • columns are clamped to 20–255, rows to 8–100 (since 0.33.0); a zero is ignored (the old value stays);
  • an escaped 255 (IAC IAC) inside the report is read as 255;
  • a subnegotiation is read up to 64 bytes, the rest dropped (a 65535×65535 report cannot make every render allocate gigabytes);
  • a report can come at any time: a resized window is noticed on the next read, and an Application re-lays out its windows;
  • on a PETSCII line (after 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:

RUBY
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.

Reading keys: readkey

RUBY
key = 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 one-character String — a printable character, UTF-8 included ("a", " ", "é", "ő");
  • a Symbol — a named key (table below);
  • 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 symbols

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.

Mouse events

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.

Example: a raw key loop

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:

RUBY
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):

TEXT
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

Example: an arrow-key menu

RUBY
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

Reading a line: readline

RUBY
name     = 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:

RUBY
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 ***.

Writing

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.

RUBY
session.write "\e[1;32mOK\e[0m\r\n"
session.line.write "\x07".b          # a bell, past any encoder

Session identity and state

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 limits, session limits, hang-ups

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;
  • a Flow 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.

Persistence: BBS::Store

BBS::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.

RUBY
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.

Methods

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.

Example: a round trip

RUBY
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.

The CSV of an older board

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.

Clean values

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.

RUBY
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"}

Threads

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.

With a Flow: persist

A Flow's persist step writes context keys into a Store, keyed by the session id:

RUBY
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:

RUBY
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!

Outside a Flow

The key is just a string: a Store keyed by the caller's name keeps data across calls.

RUBY
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).

When to outgrow it

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.

See also