DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
ssh_conn.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file ssh_conn.cpp
6 * @brief TCP-transport ↔ SSH-protocol glue.
7 */
8
16#if DETWS_ENABLE_SSH_ZLIB
18#endif
22#include "services/clock.h" // detws_millis() for the server-initiated re-key timer
23#include <string.h>
24
25// All SSH connection-layer state, owned by one instance (internal linkage): the SSH-slot ->
26// TCP-conn-slot mapping (0xFF = free), the one-time init flag, and the per-slot deferred-close
27// flags. Grouped so it is one named owner, unreachable from any other translation unit.
29{
31 bool init_done = false;
32 volatile bool close[MAX_SSH_CONNS];
33};
34static SshConnCtx s_sshc;
35
36static void ensure_init()
37{
38 if (s_sshc.init_done)
39 return;
40 for (uint8_t j = 0; j < MAX_SSH_CONNS; j++)
41 s_sshc.conn_for_ssh[j] = 0xFF;
42 s_sshc.init_done = true;
43}
44
45// ---------------------------------------------------------------------------
46// Outbound: frame + (encrypt/MAC) one SSH message and write it to the socket.
47// ---------------------------------------------------------------------------
48
49static void ssh_emit(uint8_t i, const uint8_t *payload, size_t len)
50{
51 if (i >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[i] == 0xFF)
52 return; // GCOVR_EXCL_LINE defensive: ssh_emit is only invoked by dispatch/poll with a live, mapped slot
53 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[i]];
54 if (!det_conn_active(conn->id))
55 return; // GCOVR_EXCL_LINE defensive: the invoking dispatch/poll already verified the conn is ACTIVE with a pcb
56
57 // Borrow the wire buffer from the shared scratch arena (released on return).
58 const size_t wire_cap = SSH_WIRE_CAP;
59 ScratchScope scope;
60 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
61 if (!wire)
62 return; // arena exhausted: drop the outbound message (fail closed)
63 size_t wlen = 0;
64 if (ssh_pkt_send(i, payload, len, wire, &wlen, wire_cap) != 0)
65 return;
66 det_conn_send(conn->id, wire, (u16_t)wlen);
67 det_conn_flush(conn->id);
68}
69
70// ssh_pkt_recv handler: dispatch one decrypted message, remember fatal results.
71static void ssh_msg_handler(uint8_t i, uint8_t msg_type, const uint8_t *payload, size_t len)
72{
73 if (ssh_server_dispatch(i, msg_type, payload, len) < 0)
74 s_sshc.close[i] = true;
75}
76
78{
79 ensure_init();
80 ssh_server_set_emit_cb(ssh_emit);
81}
82
83// The SSH connection ProtoHandler (Layer 5 dispatch seam) - installed by proto_register_builtins()
84// via this accessor, so this module carries no dependency on the session layer.
87{
88 // Wire the dispatcher's binary-packet emit callback here, at the one seam every consumer must go
89 // through to install SSH: a consumer that registers this handler can then never be left with the
90 // emit callback unset. Without it the server sends its identification banner (emitted directly by
91 // ssh_conn_accept) but every framed SSH packet after it - KEXINIT, KEXDH_REPLY, everything - is
92 // silently dropped, so the handshake stalls and the client is reset on the idle timeout. Idempotent.
94 return &s_ssh_handler;
95}
96
97int ssh_conn_send(uint8_t ssh_slot, uint32_t channel, const uint8_t *data, size_t len)
98{
99 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
100 return -1;
101 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
102 if (!det_conn_active(conn->id))
103 return -1;
104
105 // Frame the application bytes as SSH_MSG_CHANNEL_DATA (bounded by the peer
106 // window / max packet), then encrypt+MAC and write to the socket.
107 // Borrow the payload + wire buffers from the shared scratch arena (released on
108 // return); an exhausted arena fails closed.
109 const size_t wire_cap = SSH_WIRE_CAP;
110 ScratchScope scope;
111 uint8_t *payload = (uint8_t *)scratch_alloc(SSH_PKT_BUF_SIZE, 16);
112 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
113 if (!payload || !wire)
114 return -1;
115 size_t plen = 0;
116 if (ssh_channel_build_data(ssh_slot, channel, data, len, payload, &plen, SSH_PKT_BUF_SIZE) != 0)
117 return -1;
118 size_t wlen = 0;
119 if (ssh_pkt_send(ssh_slot, payload, plen, wire, &wlen, wire_cap) != 0)
120 return -1;
121 det_conn_send(conn->id, wire, (u16_t)wlen);
122 det_conn_flush(conn->id);
123 return (int)len;
124}
125
126int ssh_conn_close_channel(uint8_t ssh_slot, uint32_t channel)
127{
128 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
129 return -1;
130 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
131 if (!det_conn_active(conn->id))
132 return -1;
133
134 uint8_t close_msgs[10];
135 size_t clen = 0;
136 if (ssh_channel_build_close(ssh_slot, channel, close_msgs, &clen, sizeof(close_msgs)) != 0 || clen != 10)
137 return -1;
138
139 // close_msgs holds CHANNEL_EOF then CHANNEL_CLOSE; each is its own SSH message,
140 // so frame and send the two halves as two binary packets (RFC 4253 6). Borrow
141 // the wire buffer from the shared scratch arena (released on return).
142 const size_t wire_cap = SSH_WIRE_CAP;
143 ScratchScope scope;
144 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
145 if (!wire)
146 return -1;
147 for (size_t off = 0; off < 10; off += 5)
148 {
149 size_t wlen = 0;
150 if (ssh_pkt_send(ssh_slot, close_msgs + off, 5, wire, &wlen, wire_cap) != 0)
151 return -1;
152 det_conn_send(conn->id, wire, (u16_t)wlen);
153 }
154 det_conn_flush(conn->id);
155 return 0;
156}
157
158int ssh_conn_open_forwarded(uint8_t ssh_slot, const char *conn_addr, uint16_t conn_port, const char *orig_addr,
159 uint16_t orig_port)
160{
161 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
162 return -1;
163 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
164 if (!det_conn_active(conn->id))
165 return -1;
166
167 // Borrow the payload + wire buffers from the shared scratch arena (released on
168 // return); an exhausted arena fails closed.
169 const size_t wire_cap = SSH_WIRE_CAP;
170 ScratchScope scope;
171 uint8_t *payload = (uint8_t *)scratch_alloc(SSH_PKT_BUF_SIZE, 16);
172 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
173 if (!payload || !wire)
174 return -1;
175 size_t plen = 0;
176 int ch = ssh_channel_open_forwarded(ssh_slot, conn_addr, conn_port, orig_addr, orig_port, payload, &plen,
178 if (ch < 0)
179 return -1; // channel pool full / build failed
180 size_t wlen = 0;
181 if (ssh_pkt_send(ssh_slot, payload, plen, wire, &wlen, wire_cap) != 0)
182 return -1;
183 det_conn_send(conn->id, wire, (u16_t)wlen);
184 det_conn_flush(conn->id);
185 return ch;
186}
187
188void ssh_conn_poll(uint8_t conn_slot)
189{
190 // The dispatch loop calls on_poll for every slot uniformly (no per-protocol gate); it used to poll
191 // only ACTIVE slots, so keep that here to preserve behavior.
192 TcpConn *conn = &conn_pool[conn_slot];
193 if (!det_conn_active(conn_slot))
194 return;
195 uint8_t j = conn->proto_slot;
196 if (j >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[j] != conn_slot)
197 return;
198
199 // Server-initiated re-key (RFC 4253 §9): once the volume (packet-count proxy) or time budget since
200 // the last KEX is spent and the channel is not already re-keying, emit a fresh KEXINIT so a
201 // long-lived / high-throughput session re-keys in place instead of being dropped at the
202 // sequence-number wrap. The existing KEXINIT dispatch carries it to completion.
203 SshSession *s = &ssh_sess[j];
205 {
206 uint32_t elapsed = detws_millis() - s->last_kex_ms;
207 if (ssh_rekey_due(ssh_pkt[j].seq_no_send, ssh_pkt[j].seq_no_recv, elapsed, SSH_REKEY_PACKET_THRESHOLD,
209 {
210 uint8_t buf[SSH_PKT_BUF_SIZE];
211 size_t n = 0;
212 if (ssh_transport_begin_rekey(j, buf, &n, sizeof(buf)) == 0)
213 ssh_emit(j, buf, n);
214 }
215 }
216
217#if DETWS_SSH_PORT_FORWARD
218 ssh_forward_pump(j);
219#endif
220}
221
222// ---------------------------------------------------------------------------
223// Connection lifecycle
224// ---------------------------------------------------------------------------
225
226void ssh_conn_accept(uint8_t conn_slot)
227{
228 ensure_init();
229 TcpConn *conn = &conn_pool[conn_slot];
230
231 // Allocate a free SSH session slot.
232 uint8_t j = 0xFF;
233 for (uint8_t k = 0; k < MAX_SSH_CONNS; k++)
234 if (s_sshc.conn_for_ssh[k] == 0xFF)
235 {
236 j = k;
237 break;
238 }
239 if (j == 0xFF)
240 {
241 // No SSH capacity: drop the connection (transport owns the teardown).
242 det_conn_close(conn->id);
243 return;
244 }
245
246 s_sshc.conn_for_ssh[j] = conn_slot;
247 conn->proto_slot = j;
248 s_sshc.close[j] = false;
249
251 ssh_pkt_init(j);
253#if DETWS_ENABLE_SSH_ZLIB
254 ssh_comp_reset(j); // clear compression state for the new connection (not run on a re-key)
255#endif
256
257 // Send the server identification banner (raw, before any binary packet).
258 uint8_t banner[64];
259 size_t blen = 0;
260 if (ssh_transport_server_banner(banner, &blen, sizeof(banner)) == 0 && det_conn_active(conn->id))
261 {
262 det_conn_send(conn->id, banner, (u16_t)blen);
263 det_conn_flush(conn->id);
264 }
265}
266
267static void close_conn(uint8_t conn_slot)
268{
269 det_conn_close(conn_slot); // transport owns detach + slot reset + close
270 ssh_conn_close(conn_slot);
271}
272
273void ssh_conn_rx(uint8_t conn_slot)
274{
275 TcpConn *conn = &conn_pool[conn_slot];
276 uint8_t j = conn->proto_slot;
277 if (j >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[j] != conn_slot)
278 return;
279
280 // Drain the ring into a linear scratch buffer via the transport read API.
281 static uint8_t buf[RX_BUF_SIZE];
282 size_t n = det_conn_read(conn_slot, buf, sizeof(buf));
283 if (n == 0)
284 return;
285
286 size_t off = 0;
287 if (ssh_sess[j].phase == SshPhase::SSH_PHASE_BANNER)
288 {
289 size_t consumed = 0;
290 int rc = ssh_transport_recv_banner(j, buf, n, &consumed);
291 if (rc < 0)
292 {
293 close_conn(conn_slot);
294 return;
295 }
296 if (rc == 0)
297 return; // need more banner bytes
298 off = consumed;
300 }
301
302 if (off < n)
303 ssh_pkt_recv(j, buf + off, n - off, ssh_msg_handler);
304
305 ssh_wipe(buf, n);
306
307 if (s_sshc.close[j])
308 close_conn(conn_slot);
309}
310
311void ssh_conn_close(uint8_t conn_slot)
312{
313 TcpConn *conn = &conn_pool[conn_slot];
314 uint8_t j = conn->proto_slot;
315 if (j < MAX_SSH_CONNS)
316 {
317#if DETWS_SSH_PORT_FORWARD
318 ssh_forward_reset(j); // close any forwarded TCP sockets this connection owned
319#endif
320 // Zero all key material and session state for this slot.
321 ssh_keymat_wipe(j);
322 ssh_dh_wipe(j);
323 ssh_wipe(&ssh_sess[j], sizeof(SshSession));
324 s_sshc.conn_for_ssh[j] = 0xFF;
325 }
327}
#define SSH_REKEY_TIME_MS
Elapsed-time re-key trigger in milliseconds (RFC 4253 §9: "after each hour"). Default 1 hour.
#define MAX_SSH_CONNS
Maximum simultaneous SSH connections.
#define RX_BUF_SIZE
Ring-buffer capacity in bytes per connection slot.
#define SSH_REKEY_PACKET_THRESHOLD
Re-key when either packet sequence number reaches this value.
#define SSH_PKT_BUF_SIZE
Packet assembly buffer per SSH connection (bytes).
RAII scope guard for transient scratch borrows.
Definition scratch.h:110
Pluggable monotonic clock for all library timing.
uint32_t detws_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Layer 5 (Session) - per-protocol connection handler dispatch table.
void * scratch_alloc(size_t n, size_t align)
Borrow n bytes of scratch, aligned to align.
Definition scratch.cpp:85
Shared per-dispatch scratch arena (Layer 5, session-scoped memory).
int ssh_channel_open_forwarded(uint8_t i, const char *conn_addr, uint16_t conn_port, const char *orig_addr, uint16_t orig_port, uint8_t *out, size_t *out_len, size_t cap)
Open a server-initiated "forwarded-tcpip" channel (RFC 4254 §7.2, ssh -R).
int ssh_channel_build_data(uint8_t i, uint32_t channel, const uint8_t *data, size_t len, uint8_t *out, size_t *out_len, size_t cap)
Build an SSH_MSG_CHANNEL_DATA message carrying data to the client on channel channel (a local channel...
int ssh_channel_build_close(uint8_t i, uint32_t channel, uint8_t *out, size_t *out_len, size_t cap)
Build SSH_MSG_CHANNEL_EOF + SSH_MSG_CHANNEL_CLOSE for channel channel and mark it closed.
void ssh_channel_init(uint8_t i)
Reset channel state for slot i.
SSH connection protocol - multiplexed "session" channels (RFC 4254).
SSH per-connection compression owner (server-to-client zlib / zlib@openssh.com).
int ssh_conn_close_channel(uint8_t ssh_slot, uint32_t channel)
Close an SSH channel from the server side: frame CHANNEL_EOF and CHANNEL_CLOSE as two binary packets ...
Definition ssh_conn.cpp:126
int ssh_conn_open_forwarded(uint8_t ssh_slot, const char *conn_addr, uint16_t conn_port, const char *orig_addr, uint16_t orig_port)
Open a server-initiated "forwarded-tcpip" channel to the client (ssh -R): build the CHANNEL_OPEN (RFC...
Definition ssh_conn.cpp:158
void ssh_conn_rx(uint8_t conn_slot)
Drain conn_slot's receive ring buffer through the SSH stack.
Definition ssh_conn.cpp:273
void ssh_conn_accept(uint8_t conn_slot)
Handle a new ConnProto::PROTO_SSH connection on conn_slot.
Definition ssh_conn.cpp:226
void ssh_conn_poll(uint8_t conn_slot)
Per-loop poll hook for an SSH connection (registered as the SSH protocol handler's on_poll)....
Definition ssh_conn.cpp:188
int ssh_conn_send(uint8_t ssh_slot, uint32_t channel, const uint8_t *data, size_t len)
Send application data to the client over an SSH channel.
Definition ssh_conn.cpp:97
void ssh_conn_setup()
One-time setup: install the dispatcher's binary-packet emit callback.
Definition ssh_conn.cpp:77
const ProtoHandler * ssh_proto_handler(void)
Definition ssh_conn.cpp:86
void ssh_conn_close(uint8_t conn_slot)
Tear down SSH state for conn_slot (disconnect / error).
Definition ssh_conn.cpp:311
Glue between the TCP transport (conn_pool) and the SSH protocol stack.
SSH direct-tcpip port-forwarding owner (the ssh -L target side).
SSH session key material - types, pools, and security model.
SshPacketState ssh_pkt[MAX_SSH_CONNS]
Static packet state pool (BSS). One entry per SSH slot.
int ssh_pkt_send(uint8_t i, const uint8_t *payload, size_t payload_len, uint8_t *out, size_t *out_len, size_t out_cap)
Build and send one SSH binary packet.
void ssh_pkt_init(uint8_t i)
Initialize the packet state for SSH connection slot i.
int ssh_pkt_recv(uint8_t i, const uint8_t *data, size_t len, ssh_msg_handler_t handler)
Receive and process one or more SSH binary packets from data.
SSH binary packet protocol: framing, AES-256-CTR encryption, HMAC-SHA2-256 integrity,...
#define SSH_WIRE_CAP
Definition ssh_packet.h:181
int ssh_server_dispatch(uint8_t i, uint8_t msg_type, const uint8_t *payload, size_t len)
Dispatch one decrypted inbound SSH message for slot i.
void ssh_server_set_emit_cb(SshEmitCb cb)
Install the outbound-message callback.
SSH message dispatcher - ties the transport, auth, and channel layers into one server state machine.
int ssh_transport_recv_banner(uint8_t i, const uint8_t *data, size_t len, size_t *consumed)
Feed raw bytes while awaiting the client identification string.
int ssh_transport_begin_rekey(uint8_t i, uint8_t *out, size_t *out_len, size_t cap)
Begin a server-initiated re-key by emitting a fresh KEXINIT.
bool ssh_rekey_due(uint32_t seq_send, uint32_t seq_recv, uint32_t elapsed_ms, uint32_t pkt_threshold, uint32_t time_threshold_ms)
Pure re-key decision (RFC 4253 §9: "after each gigabyte ... or after each hour").
SshSession ssh_sess[MAX_SSH_CONNS]
Static pool of SSH session state (BSS), one per SSH slot.
void ssh_transport_init(uint8_t i)
Reset transport state for slot i to the start of a handshake.
int ssh_transport_server_banner(uint8_t *out, size_t *out_len, size_t cap)
Write the server identification string ("SSH-2.0-…\r\n") to out.
SSH transport-layer protocol state machine (RFC 4253).
@ SSH_PHASE_KEXINIT
Awaiting the client KEXINIT.
@ SSH_PHASE_OPEN
Authenticated; connection/channel protocol active.
@ SSH_PHASE_BANNER
Awaiting the client identification string.
Per-protocol connection event/poll callbacks (Layer 5 dispatch vtable).
volatile bool close[MAX_SSH_CONNS]
Definition ssh_conn.cpp:32
bool init_done
Definition ssh_conn.cpp:31
uint8_t conn_for_ssh[MAX_SSH_CONNS]
Definition ssh_conn.cpp:30
bool kex_active
True while KEX is in progress (no user data).
Definition ssh_packet.h:148
SshPhase phase
Current handshake phase.
uint32_t last_kex_ms
detws_millis() when the last KEX completed (server-initiated re-key timer).
A single TCP connection context.
Definition tcp.h:72
uint8_t id
Fixed slot index (0 … MAX_CONNS-1).
Definition tcp.h:73
uint8_t proto_slot
Per-protocol session/pool index (0xFF = none): the SSH session, an MQTT/Modbus session,...
Definition tcp.h:89
bool det_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:362
void det_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:413
void det_conn_close(uint8_t slot)
Close connection slot gracefully (tcp_close), aborting if the FIN cannot be queued....
Definition tcp.cpp:467
TcpConn conn_pool[CONN_POOL_SLOTS]
Static pool of connection contexts. Defined in tcp.cpp. Sized CONN_POOL_SLOTS: MAX_CONNS TCP slots pl...
Definition tcp.cpp:347
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.
#define DETWS_PROTO_SLOT_NONE
Sentinel for TcpConn::proto_slot meaning "no per-protocol session bound".
Definition tcp.h:111