Skip to content

RFC 854 Compliance Issues in telnetpp #276

@X1AOxiang

Description

@X1AOxiang

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:

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

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions