wcsshd/wcrlogind -- who wantsto test
From
Robert Wolfe@1:261/20 to
All on Sun Oct 18 17:42:12 2026
========================================================
======================
wcsshd / wcrlogind -- ARCHITECTURE DESIGN (pre-implementation)
Two daemons, one project: an SSH server and an rlogin server for Wildcat!
Interactive Net Server, both acting as GATEWAYS into Wildcat!'s existing
Telnet-based terminal/login service rather than reimplementing it. ========================================================
======================
WHY "GATEWAY", NOT "REIMPLEMENT"
----------------------------------
Wildcat! already has a complete interactive terminal service -- login
prompt, main menu, message areas, doors, everything -- reachable today
over plain Telnet. Neither an SSH daemon nor an rlogin daemon needs to
know anything about THAT (no new RPC surface, no menu code, nothing wcimaprpc.pas/wcserverrpc.pas-shaped). Both new daemons instead:
1. Accept the outer connection (SSH or rlogin) and speak that
protocol's own handshake/framing.
2. Open a SECOND, plain TCP connection, from wcsshd/wcrlogind itself
to Wildcat!'s existing Telnet listener (localhost by default).
3. From then on, act as a byte-shovel between the two: bytes from the
SSH channel / rlogin socket go to the inner Telnet connection and
back, in both directions, for the life of the session.
This is the same "reuse the existing pipeline, only build the front
door" philosophy wcsmtpd already applies to the hook chain/prespool
queue -- there, wcsmtpd.exe replaces wcsmtp.exe's LISTENING role only
and hands off to Wildcat!'s existing downstream machinery; here, wcsshd.exe/wcrlogind.exe replace nothing about Wildcat!'s terminal
service, they just put an SSH- or rlogin-shaped front door in front of
the SAME Telnet listener that's already there. Wildcat!'s own login
prompt, account lockout, security level checks, everything -- all still
apply, unmodified, inside the gateway.
ONE CONSEQUENCE WORTH STATING UP FRONT: because Telnet's own IAC
(Interpret As Command) negotiation bytes only exist on the INNER
connection (between the gateway and Wildcat!), and neither SSH nor
rlogin clients understand IAC at all, the gateway must be a competent
enough Telnet CLIENT itself to keep that inner connection sane (answer Wildcat!'s option negotiations sensibly, never forward a raw IAC byte
out to the SSH/rlogin side) -- see section 2.
1. RESEARCH: WHAT'S ACTUALLY AVAILABLE, AND THE CRYPTO DECISION ------------------------------------------------------------
SSH is not TLS -- OpenSSL implements TLS/SSL, not the SSH transport/ auth/connection protocols (RFC 4251-4254), so wctls.pas/openssl.pas
(already built for wcsmtpd/wcimapd) are NOT reusable here for the SSH
protocol itself, even though both ultimately rest on similar symmetric- cipher/MAC primitives under the hood. Implementing SSH's key exchange,
ciphers, MACs, and framing correctly BY HAND, from scratch, in Pascal,
for something that will authenticate real BBS accounts over the open
Internet, is not a responsible use of a from-scratch implementation --
subtle mistakes in hand-rolled crypto are exactly the kind of thing
that turns into a real vulnerability, and this was flagged to and
confirmed with the project owner directly rather than assumed silently.
DECISION (confirmed): wcsshd.exe dynamically loads libssh (a mature,
actively maintained, security-audited C SSH library) exactly the way
wctls.pas dynamically loads OpenSSL and wcserverrpc.pas/wcimaprpc.pas dynamically load wcsrv2(.x64).dll -- LoadLibrary/GetProcAddress, a function-pointer table per needed export, fail-soft (wcsshd.exe still
starts and logs a clear reason if libssh can't be loaded, same pattern
as "TLS not configured" in wcsmtpd/wcimapd).
WHAT'S RESEARCHED VS. ASSUMED (read before implementing wclibssh.pas):
- libssh's server API shape (ssh_bind_new/ssh_bind_options_set/
ssh_bind_listen/ssh_bind_accept, ssh_new+ssh_handle_key_exchange
per accepted connection, ssh_set_server_callbacks with an
auth_password_function/channel_open_request_session_function/
pty_request_function/shell_request_function callback struct,
ssh_event_new+ssh_event_add_session+ssh_event_dopoll as the poll
loop that drives those callbacks, ssh_channel_read/ssh_channel_
write for the actual data path) reflects libssh's long-stable,
well-documented public C API (api.libssh.org) -- confirmed via
libssh's own published server API reference and its
examples/ssh_server_fork.c sample during this design pass.
- A PREBUILT libssh.dll for Windows is NOT something libssh's own
project ships directly (libssh.org's own downloads are source
tarballs) -- the practical way to get one is vcpkg
("vcpkg install libssh") or a MinGW/MSYS2 build, both of which
produce a libssh.dll wcsshd.exe can dynamically load the same way
it would load any other DLL. This is a REAL extra install step for
whoever runs wcsshd.exe with TLS enabled -- exactly like OpenSSL
being a separate install for wcsmtpd/wcimapd's TLS support, just a
less common one, and it will be documented just as plainly in
wcsshd-INSTALL.txt (including the vcpkg command) rather than
assumed present.
- NOT verified: the EXACT export names/ordinals/calling convention
of a specific libssh.dll build (vcpkg's vs. a hand-built MinGW one
can differ slightly in packaging, e.g. static vs. import-lib
naming) -- wclibssh.pas's GetProcAddress calls are written against
the documented C API names, and like every other dynamic binding
in this project, missing exports are detected and reported by name
rather than assumed present (same pattern as wcserverrpc.pas's
DLL-export sanity check). This gets exercised for real only once
tested against an actual libssh.dll on the target Windows machine
-- flagged honestly in wcsshd-RELEASE-NOTES.txt's CURRENT STATUS,
not glossed over.
2. THE SHARED TELNET GATEWAY (wctelnetgw.pas) ------------------------------------------------------------
One unit, used by both daemons, doing three things:
a) OPEN the inner connection: a plain TCP connect (fpSocket/fpConnect,
same Sockets-unit primitives as every other daemon in this project)
to Cfg.TelnetHost:Cfg.TelnetPort (default 127.0.0.1:23 -- i.e.
Wildcat!'s own Telnet listener, assumed running on the SAME
machine as wcsshd/wcrlogind; a different host is supported via ini
but not the common case).
b) SPEAK JUST ENOUGH TELNET on that inner connection to keep it
working without dragging the outer client into it at all:
- Any IAC (byte 255) sequence arriving FROM Wildcat! is parsed
(IAC + one of WILL/WONT/DO/DONT + one option byte, or IAC SB
... IAC SE for subnegotiation) and stripped out of what gets
forwarded to the outer client -- the outer SSH/rlogin session
only ever sees the actual terminal text/data bytes.
- The gateway answers a SMALL, fixed set of option negotiations
itself rather than implementing full RFC 854/855-1091 option
negotiation generally: DO ECHO -> WONT (the gateway does not
want Wildcat! also echoing on top of whatever the outer
client/terminal already does -- avoids double-echo, a classic
Telnet-gateway bug), DO/WILL SUPPRESS-GO-AHEAD -> agree (WILL/
DO respectively -- harmless, commonly expected), everything
else -> WONT/DONT (decline) -- Wildcat!'s Telnet listener is
expected to fall back to sane line-based behavior for anything
declined, same as talking to a very old/plain Telnet client.
- A NAWS-equivalent (terminal window size) is NOT negotiated in
v1 -- see KNOWN LIMITATIONS; Wildcat! gets whatever its own
default assumption is, same as a client that never sends NAWS
today. Terminal TYPE (Telnet TTYPE option) similarly not
forwarded in v1 -- SSH's own pty-req terminal type / rlogin's
own terminal-type field (see sections 3-4) are captured by the
gateway but not yet threaded through as a Telnet TTYPE answer;
documented as a roadmap item, not silently dropped.
- IAC 255 255 (a literal 0xFF data byte, escaped per Telnet's
own rules) arriving from Wildcat! is un-escaped back to one
0xFF data byte before forwarding outward, and -- if the OUTER
client ever sends a raw 0xFF byte inward (SSH: basically never,
since SSH channel data isn't Telnet-framed at all; rlogin:
possible, it's raw bytes) -- it gets escaped TO IAC 255 255
before being sent to Wildcat!, so Wildcat!'s own Telnet parser
never misreads a legitimate data byte as the start of an IAC
sequence.
c) BRIDGE bytes bidirectionally for the life of the session: two
directions, driven the same way regardless of which outer protocol
is in front -- inner-to-outer bytes go through the IAC filter in
(b) then out through whatever the caller's "send to outer client"
callback is (an SSH channel write, or a raw rlogin socket send);
outer-to-inner bytes go through the escape step in (b) then a
plain send on the inner Telnet socket. wcsshd.exe drives this loop
from inside libssh's own event loop (ssh_event_dopoll, section 5);
wcrlogind.exe drives it with a plain two-thread-or-select loop
matching wcsmtpd's existing blocking-I/O-per-thread style (each
rlogin session already gets its own OS thread, same as an SMTP/
IMAP session, so a second inner reader thread per session is a
small, well-precedented addition, not a new concurrency model).
OPTIONAL CREDENTIAL AUTO-FEED (used by wcsshd only, see section 5):
wctelnetgw.pas accepts an optional (Username, Password: string) pair
at bridge-start time. If given, the FIRST bytes written to the inner
connection, before the bidirectional bridge loop starts, are
Username + CRLF, then Password + CRLF -- i.e. the gateway TYPES the
already-SSH-authenticated credentials into Wildcat!'s own login
prompt on the user's behalf, so an SSH user isn't asked to log in
twice. THIS IS A DOCUMENTED ASSUMPTION, not a verified fact: it
assumes Wildcat!'s Telnet listener's login sequence is exactly
"username prompt, then password prompt, in that order, each ended by
the user's Enter key" with nothing else required first (no "Press
any key", no ANSI-detection prompt, etc. -- real Wildcat! installs
commonly have one or more of those). wcsshd-TROUBLESHOOTING.txt will
need to say plainly that this may need a small adjustable "pre-login
skip" (a configurable number of lines/prompts to wait through, or a
configurable expected-prompt substring to wait for before typing the
username) once tested against a real system, and ships with that as
a documented, ini-adjustable value rather than a hardcoded assumption
-- see section 6, [Gateway] LoginPromptWait.
wcrlogind does NOT get this treatment (see section 4) -- rlogin
cannot carry a password at all, so there is nothing valid to
auto-feed beyond the username, and even that is UNAUTHENTICATED
client-supplied data (see section 4's security note) -- auto-feeding
an unverified username is far lower-stakes than a password would be,
but it's still worth being honest that "auto-feed" here means
something weaker for rlogin than for SSH.
3. wcsshd.exe -- SSH DAEMON ------------------------------------------------------------
Listener architecture: IDENTICAL in shape to wcsmtpd.pas's Alpha-4
dual-stack design -- TListenerThread/TListenerFamily ported over
unchanged, IPv6-preferred/IPv4-fallback, both on by default, each
listener's bind failure independent/fail-soft. Cfg.ListenPort default
2222 (NOT 22 -- same "safe by default, non-standard until you've
tested" rule as every other daemon in this project's family of ports:
wcsmtpd 2525, wcimapd 1430). ONE listener per address family in v1 (no
separate "implicit" vs upgraded mode the way SMTP/IMAP have -- SSH is
encrypted from the very first byte from the client's point of view, so
there's only one kind of SSH listener to run, not two).
Per-connection flow (TSshSession = class(TThread), same base shape as TSmtpSession/a hypothetical TImapSession):
1. Accept -> ssh_new (libssh session object) bound to the accepted
socket's fd, ssh_handle_key_exchange (the SSH transport-layer
handshake -- version exchange, KEX, host key, encryption/MAC
algorithm negotiation -- all handled INSIDE libssh, wcsshd.pas
never touches raw KEX/cipher bytes itself, which is the entire
point of using the library).
2. Host key: wcsshd.exe needs its own SSH host key (RSA or ed25519,
a PEM file, generated once with ssh-keygen or libssh's own
ssh_pki_generate -- documented in wcsshd-INSTALL.txt) -- loaded
via ssh_bind_options_set(SSH_BIND_OPTIONS_HOSTKEY, path) at
ssh_bind_new time, once per process, same "load once, use for
every accepted connection" lifetime as wctls.pas's shared SSL_CTX.
3. Authentication: libssh's server callback mechanism
(ssh_server_callbacks_struct.auth_password_function) is invoked
BY libssh, inside ssh_event_dopoll, whenever the connecting client
attempts password auth. wcsshd's callback does exactly what
wcimaprpc.pas's WcRpcLogin already does for wcimapd -- validate
(username, password) against the real Wildcat! user database. To
avoid a cross-project unit dependency, wcsshd's project gets its
OWN small wcauthrpc.pas: a copy of just the auth-relevant slice of
wcimaprpc.pas (WcRpcInit/Shutdown, WcRpcCreateContext/
DeleteContext, WcRpcLogin/Logout, TUser/TUserInfo types) -- none
of the conference/message-store API wcimapd needed, since wcsshd
never touches mail. Same non-Windows-fails-soft-but-still-compiles
structure wcimaprpc.pas now has (see wcimapd's own recent
refactor), for the same Linux-dev-and-test reason.
Returns SSH_AUTH_SUCCESS only on a real WcRpcLogin success; every
other case (bad credentials, RPC unavailable) returns
SSH_AUTH_DENIED with a generic reason, same non-distinguishing-
error-message discipline as every other login path in this
project. MaxLoginAttempts-style throttling: libssh itself already
limits auth tries per connection (AuthTries option); wcsshd sets
that rather than reinventing it.
4. Channel + pty + shell: once authenticated, libssh's
channel_open_request_session_function fires when the client opens
its session channel; pty_request_function captures the client's
requested terminal type/dimensions (kept for a future NAWS/TTYPE
forward, see section 2); shell_request_function (or exec_function,
if a client asks to run a command instead of a shell -- v1 treats
ANY exec request the same as a shell request, ignoring the
specific command string, since there's nothing here for it to
mean other than "give me the Wildcat! terminal") is where wcsshd
opens the inner Telnet connection (section 2) and starts the
bridge, feeding the SAME (username, password) that just passed
WcRpcLogin as the auto-feed credentials.
5. Bridge runs (section 2c) until either side closes; wcsshd then
ssh_channel_close/ssh_disconnect/ssh_free/closes the inner Telnet
socket, in that order, all best-effort (never raise during
cleanup, same rule as every Free/Shutdown path elsewhere in this
project).
Idle timeout / watchdog: same TWatchdog force-close-the-socket-from- another-thread pattern as wcsmtpd, applied to the OUTER accepted
socket (closing it unblocks whatever libssh call is currently pending
on that fd the same way it unblocks a plain fpRecv elsewhere).
Public-key authentication: OUT OF SCOPE for v1 (see section 6) --
Wildcat!'s own account model is username+password, with no existing
concept of "this SSH public key maps to this BBS account" to check a
key against, so there is nothing correct for wcsshd to do with a
pubkey auth attempt beyond declining it and telling the client to use
password auth instead (ssh_bind_options_set constrains offered auth
methods so libssh only advertises password to begin with, rather than
accepting pubkey attempts and always failing them one at a time).
4. wcrlogind.exe -- RLOGIN DAEMON ------------------------------------------------------------
rlogin (RFC 1282) predates any real security model -- on the wire it
is: client connects (traditionally to port 513, privileged-source-port
by convention only, not enforced by this implementation), server
expects four NUL-terminated fields with no framing beyond the NULs:
client-user-name \0 server-user-name \0 terminal-type/speed \0
then (v0-era) waits for a single 0-byte ack from the server before the
session becomes a raw bidirectional byte stream, or (v1282-clarified)
proceeds directly, plus an OOB "window-size" control sequence
(0xFF 0xFF "ss" + a struct with rows/cols) the client MAY send later
over the socket's out-of-band/urgent channel for terminal resizing --
v1 of wcrlogind reads and discards that OOB sequence if present
(acknowledging it exists so it doesn't corrupt the data stream) but
does not forward it to Wildcat! (same NAWS gap noted in section 2).
THERE IS NO PASSWORD FIELD IN THE RLOGIN PROTOCOL AT ALL. Its original
security model was host-based trust (a server-side .rhosts file
saying "this client host + this client username is allowed in as this
server username, no password needed") -- something Wildcat! has no
equivalent of and wcrlogind does NOT attempt to implement (implementing .rhosts-style trust would mean SKIPPING Wildcat!'s own password check
entirely, which is a much worse security posture than requiring it).
So wcrlogind.exe's rlogin handling is: parse the three NUL-terminated
fields, open the Telnet gateway (section 2), auto-feed ONLY the
client-supplied username (never a password -- there isn't one) as a
convenience so the user doesn't have to retype it, and let Wildcat!'s
own Telnet login prompt ask for the actual password INTERACTIVELY over
the (still cleartext, since rlogin itself is never encrypted) bridged connection, exactly as a plain Telnet client would experience.
SECURITY POSTURE, STATED PLAINLY (goes in wcrlogind-README.txt's own
"read this first," not buried): wcrlogind.exe provides ZERO
confidentiality and ZERO real authentication of its own -- it is
exactly as secure as plain Telnet (because that's what it's a thin
front-end for), with the one difference that the connecting client's
claimed username is taken on faith and pre-filled rather than typed.
It exists purely for legacy rlogin-client compatibility, not as a security-hardened option -- wcsshd.exe is the one to point actual
"I want this encrypted" traffic at. Given that, wcrlogind's [Listen]
Address in the shipped ini defaults to 127.0.0.1 (loopback only, NOT 0.0.0.0/::) -- the one deliberate exception to every other daemon in
this project defaulting to all-interfaces -- so a fresh install can't accidentally expose a cleartext-password login path to the open
Internet; an admin has to explicitly widen it, with a loud comment in wcrlogind.ini explaining why.
Listener architecture: same dual-stack TListenerThread/TListenerFamily
shape as wcsmtpd/wcsshd, port default 5130 (non-standard, vs. the
conventional 513, same reasoning as every other port default in this
project -- doubly so here given the loopback-default point above).
5. EVENT LOOP / THREADING NOTE (wcsshd specifically) ------------------------------------------------------------
libssh's server callback model is normally driven by ssh_event_dopoll
in a loop on ONE session at a time (or via ssh_event_add_session for a
few sessions multiplexed on one poll loop) -- for wcsshd, the simplest
correct mapping onto this project's established "one OS thread per
connection" model (used by every other daemon here) is: each
TSshSession thread creates its OWN ssh_event, adds only its own
ssh_session to it, and loops ssh_event_dopoll(event, timeout_ms) with
a short timeout (so the loop can also check the watchdog-driven
Terminated flag between polls) until the channel/bridge is done or the connection drops -- i.e. one event loop per thread per connection, NOT
one shared event loop multiplexing every connection (which would be
more "libssh-idiomatic" for a single-threaded event-driven server, but
would mean rewriting this project's entire concurrency model just for
this one daemon). This keeps wcsshd.pas's shape recognizable next to
every other daemon here (wcsmtpd/wcimapd), at the cost of one extra
OS thread's worth of overhead per SSH connection, which is the same
tradeoff this project already made for wcsmtpd/wcimapd over a
single-threaded select() server.
6. CONFIG SCHEMA
------------------------------------------------------------
wcsshd.ini
[Listen] Address/EnableIPv4/Address6/EnableIPv6/Hostname/Port
(Port default 2222, see section 3)
[SSH] HostKeyFile (PEM, required -- no default, wcsshd logs
"TLS"-style "SSH not configured" and refuses to start
any listener without one, since there is no sane
"plaintext fallback" for an SSH daemon the way TLS-
optional made sense for SMTP/IMAP), MaxAuthTries
(default 3, passed straight to libssh), LibSshDllName
(override, same idea as openssl.pas's DLL-name
fallback list but user-settable here since libssh's
own Windows packaging isn't as standardized as
OpenSSL's -- see section 1).
[Gateway] TelnetHost/TelnetPort (default 127.0.0.1:23),
LoginPromptWait (section 2's documented-assumption
escape hatch -- default 0, meaning "type the username
immediately"; settable to a small delay in ms, or a
future version could match a configurable prompt
substring instead of a blind delay -- v1 ships the
simple delay-based version and documents the
limitation).
[Limits] SessionTimeoutSecs, MaxConnections -- same meaning as
every other daemon.
[Options] ConnectionLog -- same structured-log convention.
wcrlogind.ini
[Listen] Same fields, Address defaults to 127.0.0.1 (see
section 4), Port default 5130.
[Gateway] TelnetHost/TelnetPort -- same as wcsshd's.
[Limits] / [Options] Same shape as wcsshd's.
7. CONNECTION LOGGING ------------------------------------------------------------
Same two-file convention as wcsmtpd/wcimapd (free-text wcsshd<date>.log
/ wcrlogind<date>.log plus a structured wcsshd-conn<date>.log / wcrlogind-conn<date>.log), same [cid=N ip=...] tagging. Structured
fields, beyond the usual cid/family/peer/listener/duration_ms:
wcsshd: auth_result (success/denied/error), username (only once
auth SUCCEEDED -- never logged for a failed/unknown attempt,
so the log itself can't be used to enumerate valid
usernames the way a bad error message could), cipher/kex
(whatever libssh negotiated, via ssh_get_cipher_in/
ssh_get_kex_algo-equivalent queries once available),
bytes_in/bytes_out (both directions of the bridge).
wcrlogind: claimed_username (the client-supplied, UNVERIFIED rlogin
username -- explicitly labeled "claimed" in the field name
itself so nobody mistakes it for an authenticated identity
when reading the log), bytes_in/bytes_out.
"Detailed connection logging", as asked for, means this structured log
plus the free-text log's own connect/auth-attempt/disconnect narrative
-- not packet-level tracing of the bridged Telnet session's actual
terminal content, which is exactly the kind of thing a BBS user would
NOT expect logged (their session transcript, potentially including
whatever they type at Wildcat!'s own password prompt on the inner
connection) -- wcsshd/wcrlogind log connection METADATA, never the
bridged data stream's contents. This boundary gets stated explicitly
in both README.txt files.
8. WHAT'S DELIBERATELY OUT OF SCOPE FOR v1 ------------------------------------------------------------
- SSH public-key authentication (section 3) -- password only.
- NAWS / Telnet TTYPE forwarding through the gateway (section 2) --
Wildcat! sees whatever its own no-negotiation default is.
- rlogin's OOB window-size resize sequence is drained, not acted on.
- Any host-based trust model for rlogin (section 4) -- deliberately
never implemented, not a gap.
- SSH host-key rotation/multiple host keys (RSA + ed25519 offered
together) -- v1 loads exactly one HostKeyFile.
- A configurable per-daemon Wildcat! "which Telnet node/port" beyond
a single TelnetHost:TelnetPort pair -- no multi-node load
balancing across several Telnet listeners.
========================================================
======================
--- Platinum Xpress/Win/WINServer v10.0gr19.1
* Origin: Over the Brink : Grand Island, NY - brinkbbs.org (1:261/20)