Hi, while reviewing telnetpp's Telnet implementation against RFC 854, we noticed several places where the current behavior appears to differ from the specification. Each item below cites the relevant RFC text alongside the corresponding source code for your reference. We hope this is helpful for improving RFC conformance.
1. Acknowledgment Sent for Already-Active Option, Risking Negotiation Loop
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Negotiation Rules)
"If a party receives what appears to be a request to enter some mode it is already in, the request should not be acknowledged. This non-response is essential to prevent endless loops in the negotiation."
Analysis:
In option.hpp, the negotiate() method handles incoming WILL/DO/WON'T/DON'T messages via a state machine. When the option's internal state is active and a remote_positive negotiation is received (i.e., a DO command for a server_option that is already active), the code at line 168 unconditionally calls write_negotiation(local_positive), which sends WILL back to the peer. This differs from what RFC 854 specifies: a redundant request for an already-active mode should receive no response. The current behavior may create a negotiation loop where the remote party, upon receiving an unsolicited WILL, interprets it as a new request and responds with DO, which in turn triggers another WILL, and so on.
Source Code Evidence (option.hpp):
// option.hpp:163-176
case internal_state::active:
if (neg == remote_positive)
{
write_negotiation(local_positive);
}
else
{
state_ = internal_state::inactive;
on_state_changed();
write_negotiation(local_negative);
}
break;
2. Subnegotiation Sent Without Prior WILL/DO Agreement
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Subnegotiation)
"such expanded negotiations should never begin until some prior (standard) negotiation has established that both parties are capable of parsing the expanded syntax."
Analysis:
The write_subnegotiation() method in option.hpp (lines 245–248) is a protected helper that directly invokes session_.write() with subnegotiation data, without first checking whether state_ is internal_state::active. Options start in the inactive state (line 275), and multiple option implementations — including charset::server, naws::server, and new_environ::server — expose public-facing methods (e.g., request_charsets(), select_charset()) that call write_subnegotiation() without an active-state guard. By contrast, the receive side (subnegotiate(), line 204) does check state_ == active before processing incoming subnegotiations, indicating awareness of the requirement on the receive path. The send path currently does not enforce the same precondition.
Source Code Evidence (option.hpp):
// option.hpp:242-275
//* =====================================================================
/// \brief Write a subnegotiation to the session
//* =====================================================================
void write_subnegotiation(telnetpp::bytes content)
{
session_.write(telnetpp::subnegotiation{code_, content});
}
private:
// ...
enum class internal_state : std::uint8_t
{
inactive,
activating,
active,
deactivating,
};
telnetpp::session &session_;
telnetpp::option_type code_;
internal_state state_ = internal_state::inactive;
3. Rejected Option Requests Not Tracked; Re-Request Possible Immediately
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Negotiation Rules)
"rejected requests should not be repeated until something changes. … A good rule of thumb is that a re-request should only occur as a result of subsequent information from the other end of the connection or when demanded by local human intervention."
Analysis:
The option state machine in option.hpp uses four states: inactive, activating, active, and deactivating. When a WILL/DO activation request is rejected by the remote (receiving a WON'T/DON'T in the activating state), negotiate() (lines 152–163) transitions the state back to inactive. This post-rejection inactive state is structurally identical to the initial inactive state — no rejection flag or rejected state exists. Consequently, any subsequent call to activate() from inactive (line 81–83) will re-send the same WILL/DO negotiation without any intervening information from the remote or local user action. RFC 854 currently expects that a rejected request not be re-sent unless something changes.
Source Code Evidence (option.hpp):
// option.hpp:78-91
{
switch (state_)
{
case internal_state::inactive:
state_ = internal_state::activating;
write_negotiation(local_positive);
break;
case internal_state::activating:
break;
case internal_state::active:
on_state_changed();
break;
4. Bare CR Transmitted Without Required LF or NUL Suffix
RFC Reference: RFC 854 Section "THE NETWORK VIRTUAL TERMINAL" (NVT Printer)
"the sequence "CR LF" must be treated as a single "new line" character and used whenever their combined action is intended; the sequence "CR NUL" must be used where a carriage return alone is actually desired; and the CR character must be avoided in other contexts."
Analysis:
All plain application data sent through telnetpp passes through generate_escaped() in generate_helper.hpp. This function iterates over each byte and exclusively handles the IAC (0xFF) byte by splitting the output span to duplicate it. There is no check for CR (0x0D) and no logic to ensure that any CR is followed by either LF (0x0A) or NUL (0x00). If application code supplies a byte array containing a bare CR (e.g., {0x0D, 0x41}), it is transmitted as-is over the network. RFC 854 requires that a CR character not appear in the data stream without an immediately following LF or NUL.
Source Code Evidence (generate_helper.hpp):
// generate_helper.hpp:10-36
template <class Continuation>
constexpr void generate_escaped(telnetpp::bytes data, Continuation &&cont)
{
telnetpp::bytes::iterator begin = data.begin();
telnetpp::bytes::iterator current = data.begin();
telnetpp::bytes::iterator end = data.end();
// If we come across an 0xFF byte in the data, then it must be repeated.
// We do this by splitting the span into two, one of which ends with the
// 0xFF byte and the second that begins with it. In this way, the byte is
// duplicated without requiring any extra allocations.
while (current != end)
{
if (*current == telnetpp::iac)
{
cont({begin, current + 1});
begin = current;
}
++current;
}
if (begin != end)
{
cont({begin, end});
}
}
5. CR-NUL Sequence Not Stripped on Receive; NUL Passed to Application
RFC Reference: RFC 854 Section "THE NETWORK VIRTUAL TERMINAL" (NVT Printer)
"a NUL received in the data stream after a CR (in the absence of options negotiations which explicitly specify otherwise) should be stripped out prior to applying the NVT to local character set mapping."
Analysis:
The telnetpp parser (parser.hpp) handles incoming bytes in parse_idle(). In the default branch (line 86), all non-IAC bytes — including CR (0x0D) and NUL (0x00) — are unconditionally appended to plain_data_ via push_back(by). The parser's state machine enum contains only state_idle, state_iac, state_negotiation, and related subnegotiation states; there is no CR-tracking state. When emit_plain_data() is called, the accumulated buffer is passed directly to the application callback in session.cpp at line 74 without any filtering or NUL stripping. A received CR-NUL sequence therefore reaches the application with the NUL byte intact, differing from the RFC 854 requirement that the NUL be stripped before character set mapping.
Source Code Evidence (session.cpp):
// session.cpp:69-82
auto const &token_handler = [this,
callback](telnetpp::element const &elem) {
std::visit(
detail::overloaded{
[&](telnetpp::bytes input_content) {
callback(input_content);
},
[&](telnetpp::command const &cmd) {
pimpl_->command_router_(cmd);
},
[&](telnetpp::negotiation const &neg) {
pimpl_->negotiation_router_(neg);
},
[&](telnetpp::subnegotiation const &sub) {
6. Go-Ahead (GA) Command Not Sent in Default Half-Duplex Mode
RFC Reference: RFC 854 Section "TRANSMISSION OF DATA"
"When a process has completed sending data to an NVT printer and has no queued input from the NVT keyboard for further processing (i.e., when a process at one end of a TELNET connection cannot proceed without input from the other end), the process must transmit the TELNET Go Ahead (GA) command."
Analysis:
The library defines the GA constant (value 249) in core.hpp and provides a suppress_ga option (option code 3) for negotiating full-duplex mode. However, there is no code anywhere in telnetpp that automatically sends GA when output is complete and the implementation is waiting for input. In session.cpp, async_read() signals read completion by calling callback({}) at line 89, and write() sends data through generate() — neither path appends or triggers a GA. The suppress_ga option is implemented as a way to negotiate away from GA-sending behavior, but the base GA-sending behavior itself does not appear to be implemented. In the initial state before suppress_ga is negotiated, RFC 854 currently requires GA to be sent.
Source Code Evidence (session.cpp):
// session.cpp:84-100
}},
elem);
};
pimpl_->parser_(content, token_handler);
callback({});
});
}
// ==========================================================================
// WRITE
// ==========================================================================
void session::write(telnetpp::element const &elem)
{
telnetpp::generate(
elem, [this](telnetpp::bytes data) { channel_->write(data); });
}
7. Control Functions Not Provided to Network Users by Default
RFC Reference: RFC 854 Section "CONTROL FUNCTIONS" (General)
"a system which does provide the function to a local user is obliged to provide the same function to a network user who transmits the standard representation for that function."
"Send back to the NVT some visible (i.e., printable) evidence that the AYT was received." (RFC 854, AYT definition)
Analysis:
Received TELNET commands (including AYT, IP, AO, EC, and EL) are dispatched through command_router_ in session.cpp (line 77). The session constructor sets a default unregistered-route handler only for negotiation_router_ (responding WON'T/DON'T to unknown option negotiations); the command_router_ does not have a corresponding default unregistered route. When no handler has been registered for a command type via session::install(), the router's operator() (router.hpp, line 125) returns a default-constructed void value — silently discarding the command. For AYT specifically, RFC 854 requires that visible printable evidence be returned to the sender. For all control functions (IP, AO, AYT, EC, EL), if the host system makes these functions available locally, they are expected to be equally available to network users. Currently, these commands are dropped unless the application explicitly installs handlers, and the library itself provides no default implementations.
Source Code Evidence (session.cpp and router.hpp):
// session.cpp:24-40 (constructor — command_router_ receives no default handler)
// session.cpp:76-77 (command dispatch)
[&](telnetpp::command const &cmd) {
pimpl_->command_router_(cmd);
},
// router.hpp:120-131
std::forward<Args>(args)...);\
}
// This garbage is just to return either a default-constructed Result,
// or void if Result is void.
return detail::return_default_constructed<result_type>{}();
}
private:
registered_functions_map_type registered_functions_;
function_type unregistered_route_;
};
8. TCP Urgent/Synch Mechanism Absent: Urgent Mode, DM Handling, and Data Filtering Not Implemented
RFC Reference: RFC 854 Section "THE SYNCH SIGNAL"
"A Synch signal consists of a TCP Urgent notification, coupled with the TELNET command DATA MARK. The Urgent notification, which is not subject to the flow control pertaining to the TELNET connection, is used to invoke special handling of the data stream by the process which receives it. In this mode, the data stream is immediately scanned for 'interesting' signals as defined below, discarding intervening data."
"If TCP indicates the end of Urgent data before the DM is found, TELNET should continue the special handling of the data stream until the DM is found."
"Data Mark: This should always be accompanied by a TCP Urgent notification." (RFC 854 command table)
"Abort Output (AO): Allow the current process to (appear to) run to completion, but do not send its output to the user. Also, send a Synch to the user." (RFC 854 NVT command definitions)
Analysis:
The telnetpp library does not currently implement the TCP Urgent/Synch mechanism in any of its components. Four related behaviors specified by RFC 854 are correspondingly absent:
-
Urgent mode reception: The session::impl struct (session.cpp, lines 13–19) contains no urgent-mode state variable. The async_read() function processes all incoming data identically via the parser and token_handler, with no path for detecting a TCP Urgent notification or entering a special data-handling mode.
-
Data filtering during urgent mode: Because no urgent mode state exists, the bytes handler in token_handler (session.cpp, line 74) unconditionally forwards all received data bytes to the application callback. RFC 854 requires that during urgent mode, the implementation scan only for "interesting" signals (IP, AO, AYT, and all TELNET commands) while discarding all other data including EC and EL.
-
Synch signal on AO: Even if an application installed an AO handler, the session::write() and channel abstraction interfaces accept only plain byte data (channel_->write(data)) with no TCP Urgent flag parameter. Consequently, the library has no mechanism to transmit DM as the last octet of a TCP Urgent segment, which is the form RFC 854 defines for a Synch signal.
-
DM sent without TCP Urgent: generate_command() in generate_helper.hpp (lines 41–47) treats DM (code 242) identically to all other commands, emitting {IAC, 0xF2} as ordinary in-band bytes. RFC 854 specifies that DM should always be accompanied by a TCP Urgent notification.
Source Code Evidence (session.cpp):
// session.cpp:13-19 (impl struct — no urgent_mode_ field)
// session.cpp:66-91 (async_read — uniform data handling, no urgency path)
void session::async_read(std::function<void(telnetpp::bytes)> const &callback)
{
channel_->async_read([this, callback](telnetpp::bytes content) {
auto const &token_handler = [this,
callback](telnetpp::element const &elem) {
std::visit(
detail::overloaded{
[&](telnetpp::bytes input_content) {
callback(input_content);
},
[&](telnetpp::command const &cmd) {
pimpl_->command_router_(cmd);
},
[&](telnetpp::negotiation const &neg) {
pimpl_->negotiation_router_(neg);
},
[&](telnetpp::subnegotiation const &sub) {
pimpl_->subnegotiation_router_(sub);
}},
elem);
};
pimpl_->parser_(content, token_handler);
callback({});
});
}
Source Code Evidence (generate_helper.hpp):
// generate_helper.hpp:41-47 (DM treated as ordinary command)
template <class Continuation>
constexpr void generate_command(telnetpp::command cmd, Continuation &&cont)
{
telnetpp::byte const data[] = {telnetpp::iac, cmd.value()};
cont(data);
}
Thank you for taking the time to review these observations. We recognize that some of these behaviors (such as GA and Synch) are commonly omitted in practice and that the library's design may intentionally defer certain behaviors to the application layer. We are sharing this for your consideration in case it is useful for improving RFC conformance or documentation clarity.
Hi, while reviewing telnetpp's Telnet implementation against RFC 854, we noticed several places where the current behavior appears to differ from the specification. Each item below cites the relevant RFC text alongside the corresponding source code for your reference. We hope this is helpful for improving RFC conformance.
1. Acknowledgment Sent for Already-Active Option, Risking Negotiation Loop
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Negotiation Rules)
Analysis:
In
option.hpp, thenegotiate()method handles incoming WILL/DO/WON'T/DON'T messages via a state machine. When the option's internal state isactiveand aremote_positivenegotiation is received (i.e., a DO command for aserver_optionthat is already active), the code at line 168 unconditionally callswrite_negotiation(local_positive), which sends WILL back to the peer. This differs from what RFC 854 specifies: a redundant request for an already-active mode should receive no response. The current behavior may create a negotiation loop where the remote party, upon receiving an unsolicited WILL, interprets it as a new request and responds with DO, which in turn triggers another WILL, and so on.Source Code Evidence (
option.hpp):2. Subnegotiation Sent Without Prior WILL/DO Agreement
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Subnegotiation)
Analysis:
The
write_subnegotiation()method inoption.hpp(lines 245–248) is aprotectedhelper that directly invokessession_.write()with subnegotiation data, without first checking whetherstate_isinternal_state::active. Options start in theinactivestate (line 275), and multiple option implementations — includingcharset::server,naws::server, andnew_environ::server— expose public-facing methods (e.g.,request_charsets(),select_charset()) that callwrite_subnegotiation()without an active-state guard. By contrast, the receive side (subnegotiate(), line 204) does checkstate_ == activebefore processing incoming subnegotiations, indicating awareness of the requirement on the receive path. The send path currently does not enforce the same precondition.Source Code Evidence (
option.hpp):3. Rejected Option Requests Not Tracked; Re-Request Possible Immediately
RFC Reference: RFC 854 Section "GENERAL CONSIDERATIONS" (Option Negotiation Rules)
Analysis:
The
optionstate machine inoption.hppuses four states:inactive,activating,active, anddeactivating. When a WILL/DO activation request is rejected by the remote (receiving a WON'T/DON'T in theactivatingstate),negotiate()(lines 152–163) transitions the state back toinactive. This post-rejectioninactivestate is structurally identical to the initialinactivestate — no rejection flag orrejectedstate exists. Consequently, any subsequent call toactivate()frominactive(line 81–83) will re-send the same WILL/DO negotiation without any intervening information from the remote or local user action. RFC 854 currently expects that a rejected request not be re-sent unless something changes.Source Code Evidence (
option.hpp):4. Bare CR Transmitted Without Required LF or NUL Suffix
RFC Reference: RFC 854 Section "THE NETWORK VIRTUAL TERMINAL" (NVT Printer)
Analysis:
All plain application data sent through telnetpp passes through
generate_escaped()ingenerate_helper.hpp. This function iterates over each byte and exclusively handles the IAC (0xFF) byte by splitting the output span to duplicate it. There is no check for CR (0x0D) and no logic to ensure that any CR is followed by either LF (0x0A) or NUL (0x00). If application code supplies a byte array containing a bare CR (e.g.,{0x0D, 0x41}), it is transmitted as-is over the network. RFC 854 requires that a CR character not appear in the data stream without an immediately following LF or NUL.Source Code Evidence (
generate_helper.hpp):5. CR-NUL Sequence Not Stripped on Receive; NUL Passed to Application
RFC Reference: RFC 854 Section "THE NETWORK VIRTUAL TERMINAL" (NVT Printer)
Analysis:
The telnetpp parser (
parser.hpp) handles incoming bytes inparse_idle(). In thedefaultbranch (line 86), all non-IAC bytes — including CR (0x0D) and NUL (0x00) — are unconditionally appended toplain_data_viapush_back(by). The parser's state machine enum contains onlystate_idle,state_iac,state_negotiation, and related subnegotiation states; there is no CR-tracking state. Whenemit_plain_data()is called, the accumulated buffer is passed directly to the application callback insession.cppat line 74 without any filtering or NUL stripping. A received CR-NUL sequence therefore reaches the application with the NUL byte intact, differing from the RFC 854 requirement that the NUL be stripped before character set mapping.Source Code Evidence (
session.cpp):6. Go-Ahead (GA) Command Not Sent in Default Half-Duplex Mode
RFC Reference: RFC 854 Section "TRANSMISSION OF DATA"
Analysis:
The library defines the GA constant (value 249) in
core.hppand provides asuppress_gaoption (option code 3) for negotiating full-duplex mode. However, there is no code anywhere in telnetpp that automatically sends GA when output is complete and the implementation is waiting for input. Insession.cpp,async_read()signals read completion by callingcallback({})at line 89, andwrite()sends data throughgenerate()— neither path appends or triggers a GA. Thesuppress_gaoption is implemented as a way to negotiate away from GA-sending behavior, but the base GA-sending behavior itself does not appear to be implemented. In the initial state beforesuppress_gais negotiated, RFC 854 currently requires GA to be sent.Source Code Evidence (
session.cpp):7. Control Functions Not Provided to Network Users by Default
RFC Reference: RFC 854 Section "CONTROL FUNCTIONS" (General)
Analysis:
Received TELNET commands (including AYT, IP, AO, EC, and EL) are dispatched through
command_router_insession.cpp(line 77). The session constructor sets a default unregistered-route handler only fornegotiation_router_(responding WON'T/DON'T to unknown option negotiations); thecommand_router_does not have a corresponding default unregistered route. When no handler has been registered for a command type viasession::install(), the router'soperator()(router.hpp, line 125) returns a default-constructed void value — silently discarding the command. For AYT specifically, RFC 854 requires that visible printable evidence be returned to the sender. For all control functions (IP, AO, AYT, EC, EL), if the host system makes these functions available locally, they are expected to be equally available to network users. Currently, these commands are dropped unless the application explicitly installs handlers, and the library itself provides no default implementations.Source Code Evidence (
session.cppandrouter.hpp):8. TCP Urgent/Synch Mechanism Absent: Urgent Mode, DM Handling, and Data Filtering Not Implemented
RFC Reference: RFC 854 Section "THE SYNCH SIGNAL"
Analysis:
The telnetpp library does not currently implement the TCP Urgent/Synch mechanism in any of its components. Four related behaviors specified by RFC 854 are correspondingly absent:
Urgent mode reception: The
session::implstruct (session.cpp, lines 13–19) contains no urgent-mode state variable. Theasync_read()function processes all incoming data identically via the parser andtoken_handler, with no path for detecting a TCP Urgent notification or entering a special data-handling mode.Data filtering during urgent mode: Because no urgent mode state exists, the
byteshandler intoken_handler(session.cpp, line 74) unconditionally forwards all received data bytes to the application callback. RFC 854 requires that during urgent mode, the implementation scan only for "interesting" signals (IP, AO, AYT, and all TELNET commands) while discarding all other data including EC and EL.Synch signal on AO: Even if an application installed an AO handler, the
session::write()and channel abstraction interfaces accept only plain byte data (channel_->write(data)) with no TCP Urgent flag parameter. Consequently, the library has no mechanism to transmit DM as the last octet of a TCP Urgent segment, which is the form RFC 854 defines for a Synch signal.DM sent without TCP Urgent:
generate_command()ingenerate_helper.hpp(lines 41–47) treats DM (code 242) identically to all other commands, emitting{IAC, 0xF2}as ordinary in-band bytes. RFC 854 specifies that DM should always be accompanied by a TCP Urgent notification.Source Code Evidence (
session.cpp):Source Code Evidence (
generate_helper.hpp):Thank you for taking the time to review these observations. We recognize that some of these behaviors (such as GA and Synch) are commonly omitted in practice and that the library's design may intentionally defer certain behaviors to the application layer. We are sharing this for your consideration in case it is useful for improving RFC conformance or documentation clarity.