ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
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 PC_ENABLE_SSH_ZLIB
18#endif
21#include "server/mmgr/scratch.h"
22#include "server/mmgr/secure.h"
23#include "services/system/clock.h" // pc_millis() for the server-initiated re-key timer
24#include <string.h>
25
26// All SSH connection-layer state, owned by one instance (internal linkage): the SSH-slot ->
27// TCP-conn-slot mapping (0xFF = free), the one-time init flag, and the per-slot deferred-close
28// flags. Grouped so it is one named owner, unreachable from any other translation unit.
30{
32 bool init_done = false;
33 volatile bool close[MAX_SSH_CONNS];
34};
35static SshConnCtx s_sshc;
36
37static void ensure_init()
38{
39 if (s_sshc.init_done)
40 {
41 return;
42 }
43 for (uint8_t j = 0; j < MAX_SSH_CONNS; j++)
44 {
45 s_sshc.conn_for_ssh[j] = 0xFF;
46 }
47 s_sshc.init_done = true;
48}
49
50// ---------------------------------------------------------------------------
51// Outbound: frame + (encrypt/MAC) one SSH message and write it to the socket.
52// ---------------------------------------------------------------------------
53
54static void ssh_emit(uint8_t i, const uint8_t *payload, size_t len)
55{
56 if (i >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[i] == 0xFF) // GCOVR_EXCL_LINE i is always in range: every caller
57 {
58 return; // GCOVR_EXCL_LINE defensive: ssh_emit is only invoked by dispatch/poll with a live, mapped slot
59 }
60 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[i]];
61 // NOT defensive - this fires in practice. pc_ssh_conn_rx checks only the slot mapping, never
62 // liveness, so a socket that dies between the inbound read and the reply reaches here with a
63 // mapped but dead conn. (An earlier GCOVR_EXCL here claimed "the invoking dispatch/poll already
64 // verified the conn is ACTIVE with a pcb"; that is not true of the rx path, and the exclusion
65 // was hiding a live branch. test_conn_emit_drops_reply_on_dead_socket now covers it.)
66 if (!pc_conn_active(conn->id))
67 {
68 return;
69 }
70
71 // Borrow the wire buffer from the shared scratch arena (released on return).
72 const size_t wire_cap = SSH_WIRE_CAP;
73 ScratchScope scope;
74 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
75 if (!wire)
76 {
77 return; // arena exhausted: drop the outbound message (fail closed)
78 }
79 size_t wlen = 0;
80 if (ssh_pkt_send(i, payload, len, wire, &wlen, wire_cap) != 0)
81 {
82 return;
83 }
84 pc_conn_send(conn->id, wire, (u16_t)wlen);
85 pc_conn_flush(conn->id);
86}
87
88// ssh_pkt_recv handler: dispatch one decrypted message, remember fatal results.
89static void ssh_msg_handler(uint8_t i, uint8_t msg_type, const uint8_t *payload, size_t len)
90{
91 if (pc_ssh_server_dispatch(i, msg_type, payload, len) < 0)
92 {
93 s_sshc.close[i] = true;
94 }
95}
96
98{
99 ensure_init();
101}
102
103// The SSH connection ProtoHandler (Layer 5 dispatch seam) - installed by proto_register_builtins()
104// via this accessor, so this module carries no dependency on the session layer.
107{
108 // Wire the dispatcher's binary-packet emit callback here, at the one seam every consumer must go
109 // through to install SSH: a consumer that registers this handler can then never be left with the
110 // emit callback unset. Without it the server sends its identification banner (emitted directly by
111 // pc_ssh_conn_accept) but every framed SSH packet after it - KEXINIT, KEXDH_REPLY, everything - is
112 // silently dropped, so the handshake stalls and the client is reset on the idle timeout. Idempotent.
114 return &s_ssh_handler;
115}
116
117int pc_ssh_conn_send(uint8_t ssh_slot, uint32_t channel, const uint8_t *data, size_t len)
118{
119 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
120 {
121 return -1;
122 }
123 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
124 if (!pc_conn_active(conn->id))
125 {
126 return -1;
127 }
128
129 // Frame the application bytes as SSH_MSG_CHANNEL_DATA (bounded by the peer
130 // window / max packet), then encrypt+MAC and write to the socket.
131 // Borrow the payload + wire buffers from the shared scratch arena (released on
132 // return); an exhausted arena fails closed.
133 const size_t wire_cap = SSH_WIRE_CAP;
134 ScratchScope scope;
135 uint8_t *payload = (uint8_t *)scratch_alloc(SSH_PKT_BUF_SIZE, 16);
136 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
137 if (!payload || !wire)
138 {
139 return -1;
140 }
141 size_t plen = 0;
142 if (pc_ssh_channel_build_data(ssh_slot, channel, data, len, payload, &plen, SSH_PKT_BUF_SIZE) != 0)
143 {
144 return -1;
145 }
146 size_t wlen = 0;
147 if (ssh_pkt_send(ssh_slot, payload, plen, wire, &wlen, wire_cap) != 0)
148 {
149 return -1;
150 }
151 pc_conn_send(conn->id, wire, (u16_t)wlen);
152 pc_conn_flush(conn->id);
153 return (int)len;
154}
155
156int pc_ssh_conn_close_channel(uint8_t ssh_slot, uint32_t channel)
157{
158 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
159 {
160 return -1;
161 }
162 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
163 if (!pc_conn_active(conn->id))
164 {
165 return -1;
166 }
167
168 uint8_t close_msgs[10];
169 size_t clen = 0;
170 // GCOVR_EXCL_START clen != 10 cannot be true: build_close only returns 0 after writing exactly the ten
171 // bytes of CHANNEL_EOF + CHANNEL_CLOSE, so that half of the guard is unreachable.
172 if (pc_ssh_channel_build_close(ssh_slot, channel, close_msgs, &clen, sizeof(close_msgs)) != 0 || clen != 10)
173 {
174 return -1;
175 }
176 // GCOVR_EXCL_STOP
177
178 // close_msgs holds CHANNEL_EOF then CHANNEL_CLOSE; each is its own SSH message,
179 // so frame and send the two halves as two binary packets (RFC 4253 6). Borrow
180 // the wire buffer from the shared scratch arena (released on return).
181 const size_t wire_cap = SSH_WIRE_CAP;
182 ScratchScope scope;
183 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
184 if (!wire)
185 {
186 return -1;
187 }
188 for (size_t off = 0; off < 10; off += 5)
189 {
190 size_t wlen = 0;
191 if (ssh_pkt_send(ssh_slot, close_msgs + off, 5, wire, &wlen, wire_cap) != 0)
192 {
193 return -1;
194 }
195 pc_conn_send(conn->id, wire, (u16_t)wlen);
196 }
197 pc_conn_flush(conn->id);
198 return 0;
199}
200
201int pc_ssh_conn_open_forwarded(uint8_t ssh_slot, const char *conn_addr, uint16_t conn_port, const char *orig_addr,
202 uint16_t orig_port)
203{
204 if (ssh_slot >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[ssh_slot] == 0xFF)
205 {
206 return -1;
207 }
208 TcpConn *conn = &conn_pool[s_sshc.conn_for_ssh[ssh_slot]];
209 if (!pc_conn_active(conn->id))
210 {
211 return -1;
212 }
213
214 // Borrow the payload + wire buffers from the shared scratch arena (released on
215 // return); an exhausted arena fails closed.
216 const size_t wire_cap = SSH_WIRE_CAP;
217 ScratchScope scope;
218 uint8_t *payload = (uint8_t *)scratch_alloc(SSH_PKT_BUF_SIZE, 16);
219 uint8_t *wire = (uint8_t *)scratch_alloc(wire_cap, 16);
220 if (!payload || !wire)
221 {
222 return -1;
223 }
224 size_t plen = 0;
225 int ch = pc_ssh_channel_open_forwarded(ssh_slot, conn_addr, conn_port, orig_addr, orig_port, payload, &plen,
227 if (ch < 0)
228 {
229 return -1; // channel pool full / build failed
230 }
231 size_t wlen = 0;
232 if (ssh_pkt_send(ssh_slot, payload, plen, wire, &wlen, wire_cap) != 0)
233 {
234 return -1;
235 }
236 pc_conn_send(conn->id, wire, (u16_t)wlen);
237 pc_conn_flush(conn->id);
238 return ch;
239}
240
241void pc_ssh_conn_poll(uint8_t conn_slot)
242{
243 // The dispatch loop calls on_poll for every slot uniformly (no per-protocol gate); it used to poll
244 // only ACTIVE slots, so keep that here to preserve behavior.
245 TcpConn *conn = &conn_pool[conn_slot];
246 if (!pc_conn_active(conn_slot))
247 {
248 return;
249 }
250 uint8_t j = conn->proto_slot;
251 if (j >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[j] != conn_slot)
252 {
253 return;
254 }
255
256 // Server-initiated re-key (RFC 4253 §9): once the volume (packet-count proxy) or time budget since
257 // the last KEX is spent and the channel is not already re-keying, emit a fresh KEXINIT so a
258 // long-lived / high-throughput session re-keys in place instead of being dropped at the
259 // sequence-number wrap. The existing KEXINIT dispatch carries it to completion.
260 SshSession *s = &ssh_sess[j];
262 {
263 uint32_t elapsed = pc_millis() - s->last_kex_ms;
264 if (ssh_rekey_due(ssh_pkt[j].seq_no_send, ssh_pkt[j].seq_no_recv, elapsed, SSH_REKEY_PACKET_THRESHOLD,
266 {
267 uint8_t buf[SSH_PKT_BUF_SIZE];
268 size_t n = 0;
269 // GCOVR_EXCL_START begin_rekey cannot fail here: j < MAX_SSH_CONNS is checked above, buf is
270 // SSH_PKT_BUF_SIZE, and generating the new ephemeral is infallible for a valid slot.
271 if (ssh_transport_begin_rekey(j, buf, &n, sizeof(buf)) == 0)
272 {
273 ssh_emit(j, buf, n);
274 }
275 // GCOVR_EXCL_STOP
276 }
277 }
278
279#if PC_SSH_PORT_FORWARD
280 pc_ssh_forward_pump(j);
281#endif
282}
283
284// ---------------------------------------------------------------------------
285// Connection lifecycle
286// ---------------------------------------------------------------------------
287
288void pc_ssh_conn_accept(uint8_t conn_slot)
289{
290 ensure_init();
291 TcpConn *conn = &conn_pool[conn_slot];
292
293 // Allocate a free SSH session slot.
294 uint8_t j = 0xFF;
295 for (uint8_t k = 0; k < MAX_SSH_CONNS; k++)
296 {
297 if (s_sshc.conn_for_ssh[k] == 0xFF)
298 {
299 j = k;
300 break;
301 }
302 }
303 if (j == 0xFF)
304 {
305 // No SSH capacity: drop the connection (transport owns the teardown).
306 pc_conn_close(conn->id);
307 return;
308 }
309
310 s_sshc.conn_for_ssh[j] = conn_slot;
311 conn->proto_slot = j;
312 s_sshc.close[j] = false;
313
315 ssh_pkt_init(j);
317#if PC_ENABLE_SSH_ZLIB
318 ssh_comp_reset(j); // clear compression state for the new connection (not run on a re-key)
319#endif
320
321 // Send the server identification banner (raw, before any binary packet).
322 uint8_t banner[64];
323 size_t blen = 0;
324 // GCOVR_EXCL_START the banner build cannot fail: SSH_SERVER_VERSION + CRLF is 17 bytes into a 64-byte
325 // buffer, so only the liveness half of this guard is exercisable.
326 if (ssh_transport_server_banner(banner, &blen, sizeof(banner)) == 0 && pc_conn_active(conn->id))
327 {
328 pc_conn_send(conn->id, banner, (u16_t)blen);
329 pc_conn_flush(conn->id);
330 }
331 // GCOVR_EXCL_STOP
332}
333
334static void close_conn(uint8_t conn_slot)
335{
336 pc_conn_close(conn_slot); // transport owns detach + slot reset + close
337 pc_ssh_conn_close(conn_slot);
338}
339
340void pc_ssh_conn_rx(uint8_t conn_slot)
341{
342 TcpConn *conn = &conn_pool[conn_slot];
343 uint8_t j = conn->proto_slot;
344 if (j >= MAX_SSH_CONNS || s_sshc.conn_for_ssh[j] != conn_slot)
345 {
346 return;
347 }
348
349 // Drain the ring into a linear scratch buffer via the transport read API.
350 static uint8_t buf[RX_BUF_SIZE];
351 size_t n = pc_conn_read(conn_slot, buf, sizeof(buf));
352 if (n == 0)
353 {
354 return;
355 }
356
357 size_t off = 0;
358 if (ssh_sess[j].phase == SshPhase::SSH_PHASE_BANNER)
359 {
360 size_t consumed = 0;
361 int rc = ssh_transport_recv_banner(j, buf, n, &consumed);
362 if (rc < 0)
363 {
364 close_conn(conn_slot);
365 return;
366 }
367 if (rc == 0)
368 {
369 return; // need more banner bytes
370 }
371 off = consumed;
373 }
374
375 if (off < n)
376 {
377 ssh_pkt_recv(j, buf + off, n - off, ssh_msg_handler);
378 }
379
380 pc_secure_wipe(buf, n);
381
382 if (s_sshc.close[j])
383 {
384 close_conn(conn_slot);
385 }
386}
387
388void pc_ssh_conn_close(uint8_t conn_slot)
389{
390 TcpConn *conn = &conn_pool[conn_slot];
391 uint8_t j = conn->proto_slot;
392 if (j < MAX_SSH_CONNS)
393 {
394#if PC_SSH_PORT_FORWARD
395 pc_ssh_forward_reset(j); // close any forwarded TCP sockets this connection owned
396#endif
397 // Zero all key material and session state for this slot.
398 ssh_keymat_wipe(j);
399 ssh_dh_wipe(j);
400 pc_secure_wipe(&ssh_sess[j], sizeof(SshSession));
401 s_sshc.conn_for_ssh[j] = 0xFF;
402 }
404}
#define MAX_SSH_CONNS
Definition c2_defaults.h:85
#define RX_BUF_SIZE
Definition c2_defaults.h:50
RAII scope guard for transient scratch borrows.
Definition scratch.h:196
Pluggable monotonic clock for all library timing.
uint32_t pc_millis(void)
The library's monotonic time at 1000 Hz (milliseconds).
Definition clock.h:71
Layer 5 (Session) - per-protocol connection handler dispatch table.
#define SSH_REKEY_TIME_MS
Elapsed-time re-key trigger in milliseconds (RFC 4253 §9: "after each hour"). Default 1 hour.
#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).
void * scratch_alloc(size_t n, size_t align)
Borrow n bytes of scratch, aligned to align.
Definition scratch.cpp:135
Shared per-dispatch scratch arena (Layer 5, session-scoped memory).
Secure pool accessor - borrows that hold key material.
int pc_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.
int pc_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).
void pc_ssh_channel_init(uint8_t i)
Reset channel state for slot i.
int pc_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...
SSH connection protocol - multiplexed "session" channels (RFC 4254).
SSH per-connection compression owner (server-to-client zlib / zlib@openssh.com).
void pc_ssh_conn_accept(uint8_t conn_slot)
Handle a new ConnProto::PROTO_SSH connection on conn_slot.
Definition ssh_conn.cpp:288
void pc_ssh_conn_setup()
One-time setup: install the dispatcher's binary-packet emit callback.
Definition ssh_conn.cpp:97
void pc_ssh_conn_close(uint8_t conn_slot)
Tear down SSH state for conn_slot (disconnect / error).
Definition ssh_conn.cpp:388
int pc_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:117
void pc_ssh_conn_rx(uint8_t conn_slot)
Drain conn_slot's receive ring buffer through the SSH stack.
Definition ssh_conn.cpp:340
int pc_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:201
const ProtoHandler * ssh_proto_handler(void)
Definition ssh_conn.cpp:106
void pc_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:241
int pc_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:156
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:191
void pc_ssh_server_set_emit_cb(SshEmitCb cb)
Install the outbound-message callback.
int pc_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.
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:33
bool init_done
Definition ssh_conn.cpp:32
uint8_t conn_for_ssh[MAX_SSH_CONNS]
Definition ssh_conn.cpp:31
bool kex_active
True while KEX is in progress (no user data).
Definition ssh_packet.h:152
SshPhase phase
Current handshake phase.
uint32_t last_kex_ms
pc_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:92
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:425
void pc_conn_flush(uint8_t slot)
Flush queued bytes / finish the send on slot (TLS-aware).
Definition tcp.cpp:561
void pc_conn_close(uint8_t slot)
Close connection slot gracefully (tcp_close), aborting if the FIN cannot be queued....
Definition tcp.cpp:645
bool pc_conn_send(uint8_t slot, const void *data, u16_t len)
Send len bytes on connection slot (copies data; TLS-aware).
Definition tcp.cpp:500
Layer 4 (Transport) - TCP connection pool, ring buffers, and lwIP integration.
#define PC_PROTO_SLOT_NONE
Sentinel for TcpConn::proto_slot meaning "no per-protocol session bound".
Definition tcp.h:114