diff --git a/common/noxssh_server.c b/common/noxssh_server.c index 10ac3a1..f41d77b 100644 --- a/common/noxssh_server.c +++ b/common/noxssh_server.c @@ -1245,6 +1245,26 @@ static netnox_return_t srv_send_global_request_failure(netnox_ssh_server_t * s) return srv_send_packet(s, p, 1u); } +/* Unsupported global requests still need a reply when requested. In + * particular, OpenSSH ServerAlive probes run while exec/shell owns the + * receive loop; REQUEST_FAILURE is a valid liveness reply, not permission + * to enable forwarding or another unsupported operation. */ +static netnox_return_t srv_handle_global_request(netnox_ssh_server_t *s, + const uint8_t *payload, + uint32_t payload_len) +{ + uint32_t off = 1u, request_len; + if(s == NULL || payload == NULL || payload_len < 6u || + payload[0] != NETNOX_SSH_MSG_GLOBAL_REQUEST) return NETNOX_RETURN_BAD_PARAM; + /* Subtract before comparing: an adversarial u32 string length must not + * wrap an offset and manufacture an in-bounds want-reply byte. */ + if(netnox_ssh_payload_read_u32(payload,payload_len,&off,&request_len) != NETNOX_RETURN_SUCCESS || + request_len == 0u || request_len >= payload_len - off) return NETNOX_RETURN_FAILED; + off += request_len; + if(payload[off] != 0u) return srv_send_global_request_failure(s); + return NETNOX_RETURN_SUCCESS; +} + static netnox_return_t srv_send_channel_open_confirmation(netnox_ssh_server_t * s, uint32_t client_sender_channel) { @@ -1358,6 +1378,37 @@ static netnox_return_t srv_send_channel_close(netnox_ssh_server_t * s) return srv_send_packet(s, payload, payload_len); } +#ifndef _WIN32 +/* EOF is a half-close, never connection termination (RFC4254 section5.3). + * channel_open stays set until CLOSE; completion already sent our CLOSE + * before clearing it, so a late peer CLOSE must not generate a duplicate. */ +static netnox_return_t srv_handle_channel_end(netnox_ssh_server_t *s, + const uint8_t *payload, uint32_t len) +{ + uint32_t off = 1u, recipient; + if(!s || !payload || len != 5u || + (payload[0] != NETNOX_SSH_MSG_CHANNEL_EOF && + payload[0] != NETNOX_SSH_MSG_CHANNEL_CLOSE) || + netnox_ssh_payload_read_u32(payload,len,&off,&recipient) != NETNOX_RETURN_SUCCESS || + recipient != s->local_channel_id) return NETNOX_RETURN_BAD_PARAM; + if(payload[0] == NETNOX_SSH_MSG_CHANNEL_CLOSE && s->channel_open) { + if(srv_send_channel_close(s) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; + s->channel_open = 0u; + } + return NETNOX_RETURN_SUCCESS; +} + +static netnox_return_t srv_begin_close_wait(uint64_t *deadline) +{ + uint64_t now; + if(*deadline) return NETNOX_RETURN_SUCCESS; // Late packets cannot extend it. + now = srv_now_ms(); + if(now == UINT64_MAX || now > UINT64_MAX - 10000u) return NETNOX_RETURN_FAILED; + *deadline = now + 10000u; + return NETNOX_RETURN_SUCCESS; +} +#endif + static netnox_return_t srv_maybe_window_adjust(netnox_ssh_server_t * s) { uint8_t payload[64]; @@ -1380,6 +1431,25 @@ static netnox_return_t srv_maybe_window_adjust(netnox_ssh_server_t * s) } #ifndef _WIN32 +// 1=readable, 2=interrupted/retry, 0=peer hangup/drain expired, -1=error. +// Drain only after child completion/CLOSE, so the peer can send its final +// CLOSE/DISCONNECT before TCP teardown. This bounds the first-byte wait; +// complete packet receipt still uses its existing callback/packet deadlines. +static int srv_wait_close_packet(netnox_ssh_server_t *s, uint64_t deadline) +{ + uint64_t now = srv_now_ms(); + struct pollfd p = {s->transport_fd, POLLIN, 0}; + int rc; + if(now == UINT64_MAX || s->transport_fd < 0) return -1; + if(now >= deadline) return 0; + rc = poll(&p,1,(int)(deadline-now)); + if(rc < 0) return errno == EINTR ? 2 : -1; + if(!rc) return 0; + if(p.revents & POLLIN) return 1; + if(p.revents & POLLHUP) return 0; + return -1; +} + static netnox_return_t srv_send_exit_status(netnox_ssh_server_t *s, uint32_t status) { uint8_t p[64]; uint32_t len = 0u; @@ -1409,7 +1479,7 @@ static int srv_write_child(int fd, const uint8_t *data, uint32_t len, int pty) static netnox_return_t srv_run_session_unix(netnox_ssh_server_t *s, const char *cmd, int shell) { - int pair[2] = {-1, -1}, child_fd = -1, status = 0, child_reaped = 0, output_eof = 0; + int pair[2] = {-1, -1}, child_fd = -1, status = 0, child_reaped = 0, output_eof = 0, peer_closed = 0; pid_t pid; struct winsize ws; uint8_t payload[NETNOX_SSH_MAX_PACKET_LEN], output[4096]; @@ -1469,7 +1539,9 @@ static netnox_return_t srv_run_session_unix(netnox_ssh_server_t *s, const char * uint32_t off = 1u, channel, amount; const uint8_t *data; uint32_t data_len; if(srv_recv_packet(s, payload, sizeof(payload), &len) != NETNOX_RETURN_SUCCESS || !len) break; - if(payload[0] == NETNOX_SSH_MSG_CHANNEL_DATA) { + if(payload[0] == NETNOX_SSH_MSG_GLOBAL_REQUEST) { + if(srv_handle_global_request(s,payload,len) != NETNOX_RETURN_SUCCESS) break; + } else if(payload[0] == NETNOX_SSH_MSG_CHANNEL_DATA) { if(netnox_ssh_payload_read_u32(payload,len,&off,&channel) != NETNOX_RETURN_SUCCESS || channel != s->local_channel_id || netnox_ssh_payload_read_string_view(payload,len,&off,&data,&data_len) != NETNOX_RETURN_SUCCESS || off != len || data_len > s->local_window_size || srv_write_child(child_fd,data,data_len,s->want_pty)) break; @@ -1481,9 +1553,14 @@ static netnox_return_t srv_run_session_unix(netnox_ssh_server_t *s, const char * channel != s->local_channel_id || amount > UINT32_MAX - s->remote_window_size) break; s->remote_window_size += amount; } else if(payload[0] == NETNOX_SSH_MSG_CHANNEL_EOF) { + if(srv_handle_channel_end(s,payload,len) != NETNOX_RETURN_SUCCESS) break; if(!s->want_pty) shutdown(child_fd, SHUT_WR); else { const uint8_t eot = 4u; (void)srv_write_child(child_fd,&eot,1u,1); } - } else if(payload[0] == NETNOX_SSH_MSG_CHANNEL_CLOSE || payload[0] == NETNOX_SSH_MSG_DISCONNECT) break; + } else if(payload[0] == NETNOX_SSH_MSG_CHANNEL_CLOSE) { + if(srv_handle_channel_end(s,payload,len) != NETNOX_RETURN_SUCCESS) break; + peer_closed = 1; + break; + } else if(payload[0] == NETNOX_SSH_MSG_DISCONNECT) break; else if(payload[0] == NETNOX_SSH_MSG_CHANNEL_REQUEST && s->want_pty) { const uint8_t *req; uint32_t req_len; uint32_t cols, rows, width, height; if(netnox_ssh_payload_read_u32(payload,len,&off,&channel) != NETNOX_RETURN_SUCCESS || @@ -1515,8 +1592,9 @@ static netnox_return_t srv_run_session_unix(netnox_ssh_server_t *s, const char * if(!child_reaped && waitpid(pid,&status,WNOHANG) == pid) child_reaped = 1; if(output_eof && child_reaped) { uint32_t exit_code = WIFEXITED(status) ? (uint32_t)WEXITSTATUS(status) : 128u + (uint32_t)WTERMSIG(status); - (void)srv_send_exit_status(s, exit_code); - (void)srv_send_channel_eof(s); (void)srv_send_channel_close(s); + if(srv_send_exit_status(s, exit_code) != NETNOX_RETURN_SUCCESS || + srv_send_channel_eof(s) != NETNOX_RETURN_SUCCESS || + srv_send_channel_close(s) != NETNOX_RETURN_SUCCESS) break; s->channel_open = 0u; close(child_fd); return NETNOX_RETURN_SUCCESS; } } @@ -1527,7 +1605,7 @@ static netnox_return_t srv_run_session_unix(netnox_ssh_server_t *s, const char * if(waitpid(pid,&status,WNOHANG)==0) { kill(-pid,SIGKILL); waitpid(pid,&status,0); } } s->channel_open = 0u; - return NETNOX_RETURN_FAILED; + return peer_closed ? NETNOX_RETURN_SUCCESS : NETNOX_RETURN_FAILED; } #else static netnox_return_t srv_run_exec_win32(netnox_ssh_server_t * s, const char * cmd) @@ -1785,6 +1863,9 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) { uint8_t payload[NETNOX_SSH_MAX_PACKET_LEN]; uint32_t payload_len = 0u; +#ifndef _WIN32 + uint64_t close_deadline_ms = 0u; +#endif if(s == NULL || s->send_cb == NULL || s->recv_cb == NULL) { return NETNOX_RETURN_BAD_PARAM; @@ -1796,6 +1877,13 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) } for(;;) { +#ifndef _WIN32 + if(close_deadline_ms) { + int ready = srv_wait_close_packet(s,close_deadline_ms); + if(ready == 2) continue; + if(ready <= 0) return ready == 0 ? NETNOX_RETURN_SUCCESS : NETNOX_RETURN_FAILED; + } +#endif if(srv_recv_packet(s, payload, (uint32_t)sizeof(payload), &payload_len) != NETNOX_RETURN_SUCCESS) { SRVDBG("receive packet failed"); break; @@ -1818,23 +1906,8 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) case NETNOX_SSH_MSG_EXT_INFO: continue; case NETNOX_SSH_MSG_GLOBAL_REQUEST: { - uint32_t off = 1u; - const uint8_t * req = NULL; - uint32_t req_len = 0u; - uint8_t want = 0u; - if(payload_len < 3u) { - continue; - } - if(netnox_ssh_payload_read_string_view(payload, payload_len, &off, &req, &req_len) != NETNOX_RETURN_SUCCESS) { - continue; - } - if(off >= payload_len) { - continue; - } - want = payload[off]; - if(want != 0u) { - (void)srv_send_global_request_failure(s); - } + if(srv_handle_global_request(s,payload,payload_len) != NETNOX_RETURN_SUCCESS) + return NETNOX_RETURN_FAILED; continue; } case NETNOX_SSH_MSG_SERVICE_REQUEST: { @@ -1872,6 +1945,9 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) } continue; case NETNOX_SSH_MSG_CHANNEL_OPEN: { +#ifndef _WIN32 + if(close_deadline_ms) return NETNOX_RETURN_FAILED; +#endif uint32_t off = 1u; const uint8_t * typ = NULL; uint32_t typ_len = 0u; @@ -1973,6 +2049,7 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) if(srv_run_session_unix(s, cmdbuf, 0) != NETNOX_RETURN_SUCCESS) { return NETNOX_RETURN_FAILED; } + if(srv_begin_close_wait(&close_deadline_ms) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; #else if(srv_run_exec_win32(s, cmdbuf) != NETNOX_RETURN_SUCCESS) { return NETNOX_RETURN_FAILED; @@ -1985,6 +2062,7 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) #ifndef _WIN32 if(want_reply && srv_send_channel_success(s) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; if(srv_run_session_unix(s, NULL, 1) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; + if(srv_begin_close_wait(&close_deadline_ms) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; #else if(want_reply) (void)srv_send_channel_failure(s); #endif @@ -2039,9 +2117,18 @@ netnox_return_t netnox_ssh_server_serve_one(netnox_ssh_server_t * s) continue; } case NETNOX_SSH_MSG_CHANNEL_EOF: - case NETNOX_SSH_MSG_CHANNEL_CLOSE: + case NETNOX_SSH_MSG_CHANNEL_CLOSE: { +#ifndef _WIN32 + if(srv_handle_channel_end(s,payload,payload_len) != NETNOX_RETURN_SUCCESS) + return NETNOX_RETURN_FAILED; + if(payload[0] == NETNOX_SSH_MSG_CHANNEL_CLOSE && + srv_begin_close_wait(&close_deadline_ms) != NETNOX_RETURN_SUCCESS) return NETNOX_RETURN_FAILED; + continue; +#else s->channel_open = 0u; return NETNOX_RETURN_SUCCESS; +#endif + } default: SRVDBG("unhandled msg %u", (unsigned)payload[0]); continue; diff --git a/docs/server.md b/docs/server.md index b8942d8..552f6bb 100644 --- a/docs/server.md +++ b/docs/server.md @@ -101,3 +101,103 @@ Evidence under `E:/MPUNoxV_build_artifacts/ethernet_qualification_20261013`: RX busy/buffer drops were recorded. These tests prove working SSH over physical Ethernet, not line-rate, zero-drop operation, complete link-loss recovery, power-cycle persistence or autonomous FPGA flash configuration. + +### Quiet exec keepalive correction (source candidate) + +The Unix exec/PTY session has its own receive loop. Source through `2e294de` +answered unsupported global requests in the outer connection loop but +dropped them inside a running session. OpenSSH sends a global +`keepalive@openssh.com` request with a requested reply and counts missing +replies toward its `ServerAliveCountMax` timeout. A failure reply is valid +liveness evidence and does not grant the unsupported operation. See the +[OpenSSH 9.5 client implementation](https://github.com/openssh/openssh-portable/blob/V_9_5_P1/clientloop.c#L444-L483). + +The source fix uses one validated global-request handler in both loops. +It sends no reply for want-reply zero and `SSH_MSG_REQUEST_FAILURE` for a +nonzero flag. Truncated headers, missing boolean and overflowing string +lengths fail closed; transport send failures propagate. Request-specific +arguments for unsupported requests remain opaque, and forwarding stays +unsupported. No authentication, credential, host seed, crypto or client +keepalive setting changes are part of this correction. The correction is +for the Unix server, not the unused Windows one-shot `_popen` implementation. + +Offline test: + +```sh +python3 -B scripts/test_session_keepalive.py --fixture-parent +``` + +This compiles the actual parser and both actual dispatch blocks with +`-Wall -Wextra -Werror`, using a simulated send transport. It checks repeated +quiet-session probes, want-reply zero/one/nonzero, unsupported request +arguments, send failure, and malformed/truncated/overflow requests. It +does not establish encrypted transport, a Unix child or RV64 execution. + +Root must compile the full Linux server and run the existing isolated-account +`scripts/ci_smoke_sshd.sh`. That smoke now includes a quiet 45-second command +with stdout still open and a quiet 45-second output-EOF child returning 9, +both with `ServerAliveInterval=10` and `ServerAliveCountMax=3`. The former +11-second smoke without keepalive probes did not cover this failure. + +On 2026-10-14, the board operator reported a read-only +`sleep 45; printf QUIET_COMMAND_COMPLETED` reproducer exiting 255 after +45.391 seconds with no stdout under those same keepalive settings. This is +pre-fix reproduction, not validation of a new binary. After source and +binary qualification, root should repeat that exact command through the +existing trusted-host/public-key SSH policy and require exit 0 plus the +exact marker; also check the output-EOF case returns 9 rather than 255. +The root-owned updater must not resume from an ambiguous timeout without +its separate checkpoint/card-state review. + +### Fast PTY close correction (source candidate) + +The longer keepalive CI smoke exposed an older close-order bug on the fast +Linux host: after the PTY child completed, the outer loop received a queued +stdin `CHANNEL_EOF` and immediately returned, closing TCP before the client +finished its CLOSE/DISCONNECT exchange. The log ended at packet96 and +OpenSSH reported `client_loop: send disconnect: Broken pipe`, exit255. +The daemon's180-second timeout had not expired. This failure is not ignored. + +[RFC4254 section5.3](https://www.rfc-editor.org/rfc/rfc4254#section-5.3) +defines EOF as a half-close and requires a reciprocal CLOSE unless already +sent. [OpenSSH9.5](https://github.com/openssh/openssh-portable/blob/V_9_5_P1/clientloop.c#L1526-L1536) +sends a final transport DISCONNECT and treats a failed send as fatal. +The correction validates EOF/CLOSE framing and recipient, keeps EOF from +terminating the connection, acknowledges CLOSE exactly once, and preserves +the transport briefly for the final client packets. Successful child +completion now also requires successful exit-status, EOF and CLOSE sends. +An active client's intentional CLOSE still ends its child session and +receives the required CLOSE reply, without manufacturing a child exit code. + +The post-close read wait uses a fixed10-second absolute deadline that late +EOF, duplicate CLOSE, IGNORE or keepalive packets cannot extend. No new +channel opens while that completed connection drains. It ends promptly on +client DISCONNECT/hangup or expiry; there is no added unconditional sleep. +The existing packet reader checks its10-second deadline before each receive +callback; the POSIX daemon configures each blocking callback with a10-second +socket timeout. A callback entered just before the packet deadline can still +wait another10 seconds. Therefore the bound is the10-second first-byte poll +plus up to roughly20 seconds of packet receipt, excluding crypto processing; +it is **not a hard10-second whole-drain deadline**. This bound assumes the +daemon's socket timeout was installed and does not apply to arbitrary blocking +caller-supplied transport callbacks. No authentication, keys or SSH keepalive +policy changes are part of this correction. The unused Windows one-shot +server retains its original EOF/CLOSE path. + +```sh +python3 -B scripts/test_channel_close.py --fixture-parent +``` + +The native fixture compiles the actual channel-end, completion and outer +dispatch blocks plus clock/poll helpers with simulated transport. It checks +EOF alone keeps the connection open; EOF/CLOSE/DISCONNECT ordering; +reply-once and send failures; framing/channel identity; fixed deadlines, +readable-with-hangup, interruption/errors; and exit-status9/EOF/CLOSE order. +It is not actual Unix/fork/PTY or encrypted SSH integration. The Linux smoke +retains the original PTY check and adds20 fast-close repetitions plus a PTY +returning9. All markers must occur as standalone output lines, so an echoed +input command cannot satisfy the check; any exit255 remains a failure. Native/full Linux builds, +CI and root-owned physical retests must qualify this successor separately +from the prior keepalive binary. Where VM sudo is unavailable, use the +unprivileged native fixtures/build/CTest and root's authorized existing-account +temporary-daemon tests; no new account or credential change is required. diff --git a/scripts/ci_smoke_sshd.sh b/scripts/ci_smoke_sshd.sh index b6aaf7b..c5d3d2b 100644 --- a/scripts/ci_smoke_sshd.sh +++ b/scripts/ci_smoke_sshd.sh @@ -24,6 +24,8 @@ cleanup() { rm -rf -- "$KEYDIR" } trap cleanup EXIT +python3 -B "$ROOT/scripts/test_session_keepalive.py" --fixture-parent "$KEYDIR" +python3 -B "$ROOT/scripts/test_channel_close.py" --fixture-parent "$KEYDIR" ssh-keygen -q -t ed25519 -N '' -f "$KEYDIR/id" echo "ci_smoke_sshd: account setup $(date -u +%FT%TZ)" sudo useradd -m -U -s /bin/sh "$USER_NAME" @@ -53,6 +55,18 @@ SSH_OPTS=(-o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o echo "ci_smoke_sshd: OpenSSH exec $(date -u +%FT%TZ)" OUT="$(ssh "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1" 'printf ci_exec_ok')" [[ "$OUT" == 'ci_exec_ok' ]] +echo "ci_smoke_sshd: quiet live-output child with ServerAlive probes $(date -u +%FT%TZ)" +OUT="$(ssh "${SSH_OPTS[@]}" -o ServerAliveInterval=10 -o ServerAliveCountMax=3 \ + "$USER_NAME@127.0.0.1" 'sleep 45; printf ci_quiet_keepalive_ok')" +[[ "$OUT" == 'ci_quiet_keepalive_ok' ]] +echo "ci_smoke_sshd: quiet output-EOF child with ServerAlive probes $(date -u +%FT%TZ)" +if ssh "${SSH_OPTS[@]}" -o ServerAliveInterval=10 -o ServerAliveCountMax=3 \ + "$USER_NAME@127.0.0.1" 'exec 0<&- 1>&- 2>&-; sleep 45; exit 9' > /dev/null 2>"$KEYDIR/keepalive.err"; then + echo 'quiet keepalive child returned success instead of status 9' >&2; exit 1 +else + rc=$? + if ((rc != 9)); then cat "$KEYDIR/keepalive.err" >&2; echo "quiet keepalive child returned status $rc instead of 9" >&2; exit 1; fi +fi echo "ci_smoke_sshd: quiet child after output EOF $(date -u +%FT%TZ)" if ssh "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1" 'exec 0<&- 1>&- 2>&-; sleep 11; exit 9' > /dev/null 2>"$KEYDIR/quiet.err"; then echo 'quiet child returned success instead of status 9' >&2; exit 1 @@ -61,8 +75,21 @@ else if ((rc != 9)); then cat "$KEYDIR/quiet.err" >&2; echo "quiet child returned status $rc instead of 9" >&2; exit 1; fi fi echo "ci_smoke_sshd: OpenSSH PTY $(date -u +%FT%TZ)" -OUT="$(printf 'printf ci_shell_ok; exit\n' | ssh -tt "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1")" -[[ "$OUT" == *ci_shell_ok* ]] +OUT="$(printf 'printf "\\nci_shell_ok\\n"; exit\n' | ssh -tt "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1")" +printf '%s\n' "$OUT" | tr -d '\r' | grep -Fxq 'ci_shell_ok' +echo "ci_smoke_sshd: repeated fast OpenSSH PTY close $(date -u +%FT%TZ)" +for attempt in {1..20}; do + OUT="$(printf 'printf "\\nci_fast_shell_ok\\n"; exit\n' | ssh -tt "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1")" + printf '%s\n' "$OUT" | tr -d '\r' | grep -Fxq 'ci_fast_shell_ok' +done +echo "ci_smoke_sshd: fast OpenSSH PTY nonzero exit $(date -u +%FT%TZ)" +if OUT="$(printf 'printf "\\nci_shell_status\\n"; exit 9\n' | ssh -tt "${SSH_OPTS[@]}" "$USER_NAME@127.0.0.1")"; then + echo 'PTY returned success instead of status9' >&2; exit 1 +else + rc=$? + [[ "$rc" -eq 9 ]] || { echo "PTY returned status$rc instead of9" >&2; exit 1; } + printf '%s\n' "$OUT" | tr -d '\r' | grep -Fxq 'ci_shell_status' +fi if ssh "${SSH_OPTS[@]}" "no_such_noxssh_user@127.0.0.1" 'printf bad' >/dev/null 2>&1; then echo 'invalid account authenticated' >&2; exit 1 fi diff --git a/scripts/test_channel_close.py b/scripts/test_channel_close.py new file mode 100644 index 0000000..e180fea --- /dev/null +++ b/scripts/test_channel_close.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Actual C channel-end/outer/completion blocks, with simulated transport/clock. + +No credentials, child process or network. Real fast-PTY integration is covered +separately by ci_smoke_sshd.sh on an authorized Linux test host. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import tempfile +from test_session_keepalive import braces + + +def function(source,name): + m=re.search(r"static (?:uint32_t|netnox_return_t|void|int) "+re.escape(name)+r"\s*\(",source) + if not m:raise ValueError("missing production function "+name) + return braces(source,m.start()) + + +def harness(source,header): + names=("NETNOX_SSH_MSG_GLOBAL_REQUEST","NETNOX_SSH_MSG_REQUEST_FAILURE", + "NETNOX_SSH_MSG_CHANNEL_EOF","NETNOX_SSH_MSG_CHANNEL_CLOSE", + "NETNOX_SSH_MSG_CHANNEL_REQUEST","NETNOX_SSH_MSG_DISCONNECT") + constants="\n".join(re.search(r"^#define "+n+r" .*",header,re.M)[0] for n in names)+"\n" + helpers="\n".join(function(source,n) for n in ("netnox_ssh_write_u32_be", + "netnox_ssh_read_u32_be","netnox_ssh_payload_append_u8","netnox_ssh_payload_append_u32", + "netnox_ssh_payload_append_string","netnox_ssh_payload_read_u32", + "srv_send_global_request_failure","srv_handle_global_request","srv_send_channel_eof", + "srv_send_channel_close","srv_handle_channel_end","srv_begin_close_wait", + "srv_wait_close_packet","srv_send_exit_status")) + outer=source[source.index("netnox_return_t netnox_ssh_server_serve_one("):] + end=braces(outer,outer.index("case NETNOX_SSH_MSG_CHANNEL_EOF:")) + global_case=braces(outer,outer.index("case NETNOX_SSH_MSG_GLOBAL_REQUEST:")) + disconnect=outer[outer.index("case NETNOX_SSH_MSG_DISCONNECT:"):outer.index("case NETNOX_SSH_MSG_IGNORE:")] + session=source[source.index("static netnox_return_t srv_run_session_unix("):] + completion=braces(session,session.index("if(output_eof && child_reaped)")) + preamble=r''' +#include +#include +#include +#include +// Standard native headers above keep their platform configuration; the +// extracted dispatch below selects the Unix branch being qualified. +#undef _WIN32 +typedef int netnox_return_t; +#define NETNOX_RETURN_SUCCESS 0 +#define NETNOX_RETURN_FAILED -1 +#define NETNOX_RETURN_BAD_PARAM -2 +typedef struct { int transport_fd; uint32_t local_channel_id,remote_channel_id; + uint8_t channel_open; uint32_t unchanged; } netnox_ssh_server_t; +struct pollfd { int fd; short events,revents; }; +#define POLLIN 1 +#define POLLHUP 2 +#define POLLERR 4 +#define POLLNVAL 8 +static uint64_t fake_now=100; +static int poll_rc=1,poll_events=POLLIN,poll_calls,poll_timeout; +static uint64_t srv_now_ms(void) {return fake_now;} +static int poll(struct pollfd *p,int count,int timeout) { + if(count!=1) return -1; + poll_calls++;poll_timeout=timeout;p->revents=(short)poll_events;return poll_rc; +} +static int sends,fail_at,closed_fd; +static uint8_t packets[8][128];static uint32_t packet_sizes[8]; +static netnox_return_t srv_send_packet(netnox_ssh_server_t *s,const uint8_t *p,uint32_t n) { + if(!s || sends>=8 || n>128) return -1; + memcpy(packets[sends],p,n);packet_sizes[sends++]=n; + return sends==fail_at ? -1 : 0; +} +static int close(int fd) {closed_fd=fd;return 0;} +#define WIFEXITED(s) (((s)&0x7f)==0) +#define WEXITSTATUS(s) (((s)>>8)&0xff) +#define WTERMSIG(s) ((s)&0x7f) +''' + fixture=r''' +// 777 means the finite injected sequence ended while the connection stays +// alive; it is deliberately not success/closed. No drain timeout is invented. +static uint64_t observed_deadline; +static int consumed; +static int outer_packets(netnox_ssh_server_t *s,const uint8_t **p,const uint32_t *n, + int count,uint64_t initial_deadline) { + uint64_t close_deadline_ms=initial_deadline; + consumed=0; + for(int i=0;itransport_fd=6;s->local_channel_id=0;s->remote_channel_id=7; + s->channel_open=(uint8_t)open;s->unchanged=0x12345678;sends=fail_at=closed_fd=0; + fake_now=100;poll_rc=1;poll_events=POLLIN;poll_calls=poll_timeout=0; +} +int main(void) { + netnox_ssh_server_t state,*s=&state; + uint8_t eof[]={96,0,0,0,0},cl[]={97,0,0,0,0},bye[]={1},wrong[]={96,0,0,0,1}; + const uint8_t *p[]={eof,cl,bye};uint32_t n[]={5,5,1}; + fresh(s,1);CHECK(outer_packets(s,p,n,1,0)==777);CHECK(s->channel_open==1); + CHECK(sends==0 && consumed==1 && observed_deadline==0); + fresh(s,1);CHECK(outer_packets(s,p,n,2,0)==777);CHECK(s->channel_open==0); + CHECK(sends==1 && packets[0][0]==97 && packet_sizes[0]==5 && packets[0][4]==7); + CHECK(observed_deadline==10100 && consumed==2); + fresh(s,1);CHECK(outer_packets(s,p,n,3,0)==0);CHECK(consumed==3 && sends==1); + fresh(s,0);CHECK(outer_packets(s,p,n,3,10100)==0);CHECK(consumed==3 && sends==0); + const uint8_t *duplicates[]={cl,eof,cl};uint32_t dn[]={5,5,5}; + fresh(s,1);CHECK(outer_packets(s,duplicates,dn,3,0)==777); + CHECK(sends==1 && observed_deadline==10100 && s->channel_open==0); + uint8_t keepalive[27]={80,0,0,0,21};memcpy(keepalive+5,"keepalive@openssh.com",21);keepalive[26]=1; + const uint8_t *with_probe[]={cl,keepalive,eof,cl};uint32_t pn[]={5,27,5,5}; + fresh(s,1);CHECK(outer_packets(s,with_probe,pn,4,0)==777); + CHECK(sends==2 && packets[0][0]==97 && packets[1][0]==82 && observed_deadline==10100); + fresh(s,1);fail_at=1;CHECK(outer_packets(s,p+1,n+1,1,0)<0);CHECK(s->channel_open==1); + fresh(s,1);CHECK(srv_handle_channel_end(s,wrong,5)<0);CHECK(sends==0 && s->channel_open==1); + for(unsigned length=0;length<5;length++) CHECK(srv_handle_channel_end(s,eof,length)<0); + uint8_t extra[]={96,0,0,0,0,1};CHECK(srv_handle_channel_end(s,extra,6)<0); + CHECK(srv_handle_channel_end(NULL,eof,5)<0);CHECK(srv_handle_channel_end(s,NULL,5)<0); + puts("PASS actual outer EOF stays open, CLOSE reply-once, queued EOF/CLOSE before DISCONNECT, framing/recipient/send error"); + uint64_t deadline=0;CHECK(srv_begin_close_wait(&deadline)==0 && deadline==10100); + fake_now=9000;CHECK(srv_begin_close_wait(&deadline)==0 && deadline==10100); + CHECK(srv_wait_close_packet(s,deadline)==1 && poll_timeout==1100); + poll_events=POLLIN|POLLHUP;CHECK(srv_wait_close_packet(s,deadline)==1); + poll_events=POLLHUP;CHECK(srv_wait_close_packet(s,deadline)==0); + poll_events=POLLERR;CHECK(srv_wait_close_packet(s,deadline)<0); + poll_events=POLLNVAL;CHECK(srv_wait_close_packet(s,deadline)<0); + poll_rc=-1;errno=EINTR;CHECK(srv_wait_close_packet(s,deadline)==2); + errno=EIO;CHECK(srv_wait_close_packet(s,deadline)<0); + poll_rc=0;CHECK(srv_wait_close_packet(s,deadline)==0); + fake_now=deadline;int calls=poll_calls;CHECK(srv_wait_close_packet(s,deadline)==0 && poll_calls==calls); + fake_now=UINT64_MAX;deadline=0;CHECK(srv_begin_close_wait(&deadline)<0 && deadline==0); + CHECK(srv_wait_close_packet(s,100)<0); + puts("PASS fixed non-extending close deadline, readable+hangup drains data, idle expiry, EINTR/error/clock failure"); + for(int fail=0;fail<=3;fail++) { + fresh(s,1);fail_at=fail;int rc=completed(s,9<<8); + if(fail) {CHECK(rc<0 && sends==fail && closed_fd==0 && s->channel_open==1);} + else {CHECK(rc==0 && sends==3 && closed_fd==99 && s->channel_open==0); + CHECK(packets[0][0]==98 && packets[1][0]==96 && packets[2][0]==97); + CHECK(packet_sizes[0]==25 && packets[0][24]==9);} + CHECK(s->unchanged==0x12345678); + } + puts("PASS actual completion exit-status9/EOF/CLOSE order, each failed send cannot report success"); + printf("PASS %u native actual-C close checks; no Unix/SSH/child integration\n",tests);return 0; +} +'''.replace("END_CASE",end).replace("GLOBAL_CASE",global_case).replace("DISCONNECT_CASE",disconnect).replace("COMPLETION_BLOCK",completion) + return preamble+constants+helpers+fixture + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument("--source-root",type=Path,default=Path(__file__).resolve().parent.parent) + p.add_argument("--fixture-parent",type=Path,required=True) + p.add_argument("--cc",default=os.environ.get("NOXSSH_FIXTURE_CC") or ( + "D:/Argenox_FPGA/builds/kc705-mpunoxv/toolchains/msys2_overlay/mingw64/bin/gcc.exe" if os.name=="nt" else "cc")) + a=p.parse_args();source=(a.source_root/"common/noxssh_server.c").read_text() + code=harness(source,(a.source_root/"common/noxssh_common.h").read_text()) + a.fixture_parent.mkdir(parents=True,exist_ok=True) + with tempfile.TemporaryDirectory(prefix="noxssh-close-",dir=a.fixture_parent) as scratch: + root=Path(scratch);c=root/"actual.c";exe=root/"actual.exe" + c.write_text(code,encoding="ascii",newline="\n") + env=os.environ.copy();env["PATH"]=str(Path(a.cc).parent)+os.pathsep+env["PATH"] + subprocess.run([a.cc,"-std=c99","-O2","-Wall","-Wextra","-Werror",str(c),"-o",str(exe)],check=True,env=env) + subprocess.run([str(exe)],check=True,env=env) + + +if __name__=="__main__":main() diff --git a/scripts/test_session_keepalive.py b/scripts/test_session_keepalive.py new file mode 100644 index 0000000..afa6be7 --- /dev/null +++ b/scripts/test_session_keepalive.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Compile actual global-request parser and both call-site blocks with transport stubs. + +No SSH, keys, accounts, forked child, board or real network is used. The separate +Linux ci_smoke_sshd.sh exercises real quiet child/SSH transport integration. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def braces(source, start): + first=source.index("{",start) + depth=1;end=first+1 + while depth: + if source[end]=="{":depth+=1 + elif source[end]=="}":depth-=1 + end+=1 + return source[start:end] + + +def function(source, name): + match=re.search(r"static (?:uint32_t|netnox_return_t) "+re.escape(name)+r"\s*\(",source) + if not match:raise ValueError("missing production function "+name) + return braces(source,match.start()) + + +def harness(source, constants): + active_start=source.index("static netnox_return_t srv_run_session_unix(") + active=braces(source,active_start) + # Compile the actual call-site block, not a separately handwritten model. + session=braces(active,active.index("if(payload[0] == NETNOX_SSH_MSG_GLOBAL_REQUEST)")) + outer=source[source.index("netnox_return_t netnox_ssh_server_serve_one("):] + outer=braces(outer,outer.index("case NETNOX_SSH_MSG_GLOBAL_REQUEST:")) + preamble=r''' +#include +#include +#include +#include +typedef int netnox_return_t; +#define NETNOX_RETURN_SUCCESS 0 +#define NETNOX_RETURN_FAILED -1 +#define NETNOX_RETURN_BAD_PARAM -2 +typedef struct { uint32_t unchanged; } netnox_ssh_server_t; +static int sends, fail_send; +static uint8_t sent[8]; +static netnox_return_t srv_send_packet(netnox_ssh_server_t *s,const uint8_t *p,uint32_t len) +{ + if(!s || len!=1 || sends>=8) return NETNOX_RETURN_FAILED; + sent[sends++]=p[0]; + return fail_send ? NETNOX_RETURN_FAILED : NETNOX_RETURN_SUCCESS; +} +''' + extracted="\n".join(function(source,n) for n in ("netnox_ssh_read_u32_be", + "netnox_ssh_payload_read_u32","srv_send_global_request_failure","srv_handle_global_request")) + wrappers=''' +static int session_call(netnox_ssh_server_t *s,const uint8_t *payload,uint32_t len,int repeat) +{ + int probe; + if(!len) return NETNOX_RETURN_FAILED; + for(probe=0;probesizeof(data)*2) return 99; + len=(uint32_t)(strlen(argv[2])/2); + for(uint32_t i=0;i4) return 97; + if(!strcmp(argv[1],"session")) rc=session_call(s,data,len,repeat); + else if(!strcmp(argv[1],"outer")) rc=outer_call(s,data,len,repeat); + else if(!strcmp(argv[1],"null-state")) rc=srv_handle_global_request(NULL,data,len); + else if(!strcmp(argv[1],"null-packet")) rc=srv_handle_global_request(s,NULL,len); + else { + rc=NETNOX_RETURN_SUCCESS; + for(int probe=0;probe