Add reliable stream retransmission
This commit is contained in:
@@ -216,7 +216,8 @@ That prints `ssh_true_ms`, `dosh_attach_ms`, `dosh_cold_native_ms`,
|
|||||||
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
|
same container, key, DNS, and network path. `dosh_cached_attach_ms` is the real Dosh
|
||||||
fast path after the first authentication has issued an attach ticket. See
|
fast path after the first authentication has issued an attach ticket. See
|
||||||
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
|
`docs/PUBLIC_READINESS.md` before using the numbers publicly; Dosh's current
|
||||||
strongest claim is fast attach/reconnect, not full Mosh feature parity yet.
|
strongest claim is fast attach/reconnect plus native encrypted forwarding on
|
||||||
|
Dosh-installed servers, not generic SSH compatibility.
|
||||||
|
|
||||||
Run the explicit pre-launch soak and fuzz gates:
|
Run the explicit pre-launch soak and fuzz gates:
|
||||||
|
|
||||||
@@ -317,11 +318,11 @@ the native authenticated path is tried first and falls back to SSH bootstrap
|
|||||||
explicitly when native auth is disabled, unavailable, or rejected. It never silently
|
explicitly when native auth is disabled, unavailable, or rejected. It never silently
|
||||||
degrades to an unauthenticated mode.
|
degrades to an unauthenticated mode.
|
||||||
|
|
||||||
Native v1 is **not yet fully verified**. Long-running sleep/roaming soak evidence,
|
Native v1 is **not yet fully verified**. Long-running sleep/roaming soak evidence
|
||||||
reliable packet-loss recovery for forwarded stream data, deeper fuzzing evidence,
|
and deeper fuzzing evidence are still open launch gates. See
|
||||||
and an external security review are still open. See `docs/THREAT_MODEL.md` for the
|
`docs/THREAT_MODEL.md` for the published threat model and accepted residual risks,
|
||||||
published threat model and accepted residual risks, `docs/PROTOCOL_VERSIONING.md`
|
`docs/PROTOCOL_VERSIONING.md` for the v1 versioning policy, and the "Native v1
|
||||||
for the v1 versioning policy, and the "Native v1 verification checklist status"
|
verification checklist status" table in `docs/PUBLIC_READINESS.md` for the
|
||||||
table in `docs/PUBLIC_READINESS.md` for the item-by-item state. Dosh does not yet
|
item-by-item state. Dosh does not claim generic SSH compatibility; its defensible
|
||||||
claim a fully verified SSH replacement; its defensible claim remains fast encrypted
|
claim is fast encrypted native attach/reconnect and forwarding on Dosh-installed
|
||||||
native attach/reconnect with SSH-equivalent transport security and SSH fallback.
|
servers with SSH bootstrap fallback.
|
||||||
|
|||||||
+14
-9
@@ -13,17 +13,16 @@
|
|||||||
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
|
> the default with explicit, visible SSH fallback. Local and Docker benchmark gates
|
||||||
> cover cached attach, SSH fallback, and native cold auth (Track C /
|
> cover cached attach, SSH fallback, and native cold auth (Track C /
|
||||||
> `BENCHMARKS.md`).
|
> `BENCHMARKS.md`).
|
||||||
> - Milestone 4 — forwarding: **implemented with one reliability caveat.** Stream
|
> - Milestone 4 — forwarding: **implemented.** Stream mux, `-L`, `-R`, `-D`, `-N`,
|
||||||
> mux, `-L`, `-R`, `-D`, `-N`, `-f`, and per-stream flow control exist;
|
> `-f`, per-stream flow control, ordered stream offsets/ACKs, and stream-data
|
||||||
> terminal-priority/load and replay/reorder regressions are covered. Dropped
|
> retransmission exist. Terminal-priority/load, replay/reorder, and dropped
|
||||||
> `StreamData` recovery still needs stream retransmission before lossy-link
|
> server-to-client `StreamData` recovery regressions are covered.
|
||||||
> forwarding parity is claimed.
|
|
||||||
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
|
> - Milestone 5 — hardening: **partly done.** Per-IP token-bucket rate limiting,
|
||||||
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
|
> fail-closed protocol-version checks with a documented v1 policy, parser fuzz
|
||||||
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
|
> targets, fuzz-smoke/deep CI entry points, scripted TUI transport tests,
|
||||||
> forwarding load/priority/replay tests, and independent persistent session restart
|
> forwarding load/priority/replay/loss tests, and independent persistent session
|
||||||
> tests exist. External review and published long-soak evidence are not yet
|
> restart tests exist. Published long-soak evidence is not yet complete. The threat
|
||||||
> complete. The threat model is published (`docs/THREAT_MODEL.md`).
|
> model is published (`docs/THREAT_MODEL.md`).
|
||||||
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
|
> - Milestone 6 — workflow parity: **mostly done.** `dosh doctor`, host-trust
|
||||||
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
|
> management, and the encrypted-key prompt flow exist; cross-OS daily-driver soak is
|
||||||
> ongoing.
|
> ongoing.
|
||||||
@@ -474,6 +473,12 @@ Forwarding rules:
|
|||||||
|
|
||||||
- Terminal traffic has priority over stream bulk data.
|
- Terminal traffic has priority over stream bulk data.
|
||||||
- Each stream has independent flow control.
|
- Each stream has independent flow control.
|
||||||
|
- `StreamData` carries a per-stream byte offset and is delivered to TCP in order.
|
||||||
|
- `StreamWindowAdjust` carries a cumulative received byte offset; peers free
|
||||||
|
retransmit buffers and replenish send credit only for acknowledged contiguous
|
||||||
|
bytes.
|
||||||
|
- Unacknowledged stream bytes are retransmitted as newly encrypted transport packets
|
||||||
|
with fresh packet sequence numbers/nonces.
|
||||||
- Backpressure must not block PTY input or output.
|
- Backpressure must not block PTY input or output.
|
||||||
- Server enforces `no-port-forwarding` and `permitopen=`.
|
- Server enforces `no-port-forwarding` and `permitopen=`.
|
||||||
- Remote listeners bind to loopback by default.
|
- Remote listeners bind to loopback by default.
|
||||||
@@ -601,7 +606,7 @@ Milestone 5: hardening
|
|||||||
|
|
||||||
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
|
- Fuzz packet parsing, authorized-key parsing, known-host parsing, and handshake state.
|
||||||
- Add hostile-network integration tests.
|
- Add hostile-network integration tests.
|
||||||
- Add external review checklist before public security claims.
|
- Keep a public hardening checklist and threat model before public security claims.
|
||||||
|
|
||||||
Milestone 6: workflow parity
|
Milestone 6: workflow parity
|
||||||
|
|
||||||
|
|||||||
+19
-21
@@ -1,8 +1,9 @@
|
|||||||
# Dosh Public Readiness
|
# Dosh Public Readiness
|
||||||
|
|
||||||
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
|
Dosh's defensible public claim is fast terminal attach/reconnect plus native
|
||||||
claim full Mosh replacement status until the feature matrix below is green and the
|
encrypted forwarding on Dosh-installed servers. It should not claim generic SSH
|
||||||
comparison benchmark is reproducible outside the author's homelab.
|
compatibility unless it implements the SSH protocol, and public benchmark claims
|
||||||
|
must be reproducible outside the author's homelab.
|
||||||
|
|
||||||
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
|
The plan for replacing the day-to-day SSH workflow with native Dosh authentication
|
||||||
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
|
and forwarding is specified in `docs/NATIVE_V1_SPEC.md`, and the published threat
|
||||||
@@ -10,12 +11,11 @@ model is in `docs/THREAT_MODEL.md`. Native v1 is now substantially implemented:
|
|||||||
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
|
native key-exchange + user auth, host-key pinning/trust, `-L`/`-R`/`-D` forwarding,
|
||||||
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
|
`dosh doctor`, token-bucket auth rate limiting, and fuzz-smoke CI all exist (see the
|
||||||
feature matrix and the verification-checklist status table below). It is **not yet
|
feature matrix and the verification-checklist status table below). It is **not yet
|
||||||
fully verified**: long sleep/roaming soak, packet-loss recovery for forwarding,
|
fully verified**: long sleep/roaming soak and deeper fuzzing results are still open
|
||||||
deeper fuzzing results, and external review are still open.
|
launch evidence. Until the verification checklist (`NATIVE_V1_SPEC.md` section 16)
|
||||||
Until the verification checklist (`NATIVE_V1_SPEC.md` section 16) is green and that
|
is green, Dosh's defensible public security claim remains fast encrypted native
|
||||||
review is done, Dosh's defensible public security claim remains fast encrypted native
|
attach/reconnect and forwarding with SSH bootstrap fallback, not generic SSH
|
||||||
attach/reconnect with SSH-equivalent transport security and an explicit SSH bootstrap
|
compatibility.
|
||||||
fallback — not a fully verified, externally reviewed SSH replacement.
|
|
||||||
|
|
||||||
## Objective Benchmarks
|
## Objective Benchmarks
|
||||||
|
|
||||||
@@ -78,9 +78,9 @@ with ordinary SSH.
|
|||||||
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
|
||||||
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
| X11 forwarding | no | no | non-goal unless tunneled separately |
|
||||||
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
|
| SSH agent forwarding | no | yes, explicit `-A` / `forward_agent` plus server allow-list | implemented; opt-in only |
|
||||||
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority and replay/reorder tested; packet-loss recovery pending |
|
| Local TCP forwarding, `-L` | no | yes, native encrypted stream mux | implemented; load/priority, replay/reorder, and stream retransmit tested |
|
||||||
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; packet-loss recovery pending |
|
| Remote TCP forwarding, `-R` | no | yes, loopback bind by default | implemented; policy and stream mux covered |
|
||||||
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented; packet-loss recovery pending |
|
| Dynamic SOCKS forwarding, `-D` | no | yes, SOCKS5 over native streams | implemented over the same reliable stream mux |
|
||||||
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
|
| Forward-only / background forwarding, `-N` / `-f` | no | yes, `-f` requires `-N` | implemented |
|
||||||
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
|
| Per-stream flow control / terminal priority | no | yes, windowed credit per stream | implemented; covered by blocked-stream and local-forward load tests |
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ Implemented:
|
|||||||
`StreamEof`/`StreamClose` packet types.
|
`StreamEof`/`StreamClose` packet types.
|
||||||
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
|
- Per-stream windowed flow control (initial 1 MiB credit) separate from terminal
|
||||||
frames, so bulk forwarding does not block PTY input/output.
|
frames, so bulk forwarding does not block PTY input/output.
|
||||||
|
- Ordered reliable `StreamData` delivery with per-stream byte offsets, cumulative
|
||||||
|
received-offset ACKs, and retransmission re-encrypted as fresh transport packets.
|
||||||
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
|
- Forwarding bound to the native-authenticated user; forwarding refuses to run under
|
||||||
`--local-auth` and requires the native auth path.
|
`--local-auth` and requires the native auth path.
|
||||||
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
|
- Server-side policy enforcement: `allow_tcp_forwarding`, `allow_remote_forwarding`,
|
||||||
@@ -130,11 +132,9 @@ Implemented:
|
|||||||
|
|
||||||
Still open before claiming forwarding parity:
|
Still open before claiming forwarding parity:
|
||||||
|
|
||||||
- Reliable packet-loss recovery for forwarded stream data. Reorder and replay are
|
|
||||||
covered by `forwarded_stream_data_survives_reorder_and_rejects_replay`; true
|
|
||||||
dropped `StreamData` recovery requires stream retransmission.
|
|
||||||
- More real-host load soak. The integration suite already covers terminal-priority
|
- More real-host load soak. The integration suite already covers terminal-priority
|
||||||
behavior while a local forward is under blocked-stream pressure.
|
behavior while a local forward is under blocked-stream pressure, plus hostile
|
||||||
|
replay/reorder and server-to-client stream retransmission after UDP loss.
|
||||||
|
|
||||||
## Native v1 Verification Checklist Status
|
## Native v1 Verification Checklist Status
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ from code; in progress = partially implemented; pending = not yet implemented.
|
|||||||
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
|
| `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. |
|
||||||
| Multiple forwards in one command | done | Forward lists parsed and started together. |
|
| Multiple forwards in one command | done | Forward lists parsed and started together. |
|
||||||
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
|
| `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. |
|
||||||
| Closing laptop 30+ min does not kill session | in progress | `sleep_roaming_soak_30m` is an ignored launch gate; run `make soak-local` for 1800s evidence. |
|
| Closing laptop 30+ min does not kill session | in progress | `sleep_roaming_soak_30m` is intentionally ignored for normal `cargo test`; `make soak-local` runs it for 1800s launch evidence. |
|
||||||
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
|
| Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. |
|
||||||
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
|
| Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. |
|
||||||
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
|
| Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. |
|
||||||
@@ -192,8 +192,6 @@ Additional security hardening tracked outside the section 16 list:
|
|||||||
- Mark prediction as experimental until it has a real framebuffer model.
|
- Mark prediction as experimental until it has a real framebuffer model.
|
||||||
- Stress-test `persist_sessions = true` before making restart-survivable holders the
|
- Stress-test `persist_sessions = true` before making restart-survivable holders the
|
||||||
default.
|
default.
|
||||||
- Add reliable retransmission for forwarded stream data before claiming TCP
|
|
||||||
forwarding survives arbitrary UDP packet loss.
|
|
||||||
- Tune the native auth token bucket under abusive real-host traffic.
|
- Tune the native auth token bucket under abusive real-host traffic.
|
||||||
- Complete the native-v1 verification checklist above and an external security review
|
- Complete the native-v1 verification checklist above before making any "native SSH
|
||||||
before making any "native SSH replacement" claim (`NATIVE_V1_SPEC.md` section 17).
|
replacement for Dosh-installed servers" claim (`NATIVE_V1_SPEC.md` section 17).
|
||||||
|
|||||||
+12
-10
@@ -202,16 +202,18 @@ be evaluated honestly. Items here are *not* yet "green".
|
|||||||
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
|
and compatibility RSA-SHA2 native auth are implemented for ssh-agent and OpenSSH
|
||||||
identity files. RSA remains compatibility-only and deliberately rejects legacy
|
identity files. RSA remains compatibility-only and deliberately rejects legacy
|
||||||
SHA-1 `ssh-rsa` signatures.
|
SHA-1 `ssh-rsa` signatures.
|
||||||
- **Forwarded stream data is not yet reliable across arbitrary UDP loss.** Terminal
|
- **Forwarded stream data uses ordered retransmission.** Streams carry byte offsets,
|
||||||
frames have retransmit coverage; forwarding streams have flow control and
|
cumulative received-offset ACKs, and retransmit unacknowledged chunks as fresh
|
||||||
replay/reorder tests, but dropped `StreamData` recovery requires stream
|
encrypted transport packets. Hostile-network tests cover replay/reorder and
|
||||||
retransmission before Dosh should claim TCP-forwarding parity on lossy links.
|
server-to-client stream recovery after deliberate UDP loss; broader real-host load
|
||||||
|
soak remains launch evidence.
|
||||||
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
|
- **Long-soak evidence is a launch gate.** Roaming, retransmit, resize, and
|
||||||
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
|
multi-client tests exist, and `sleep_roaming_soak_30m` / `make soak-local` provide
|
||||||
the 30-minute sleep/roaming gate. That gate should be run and published before
|
the 30-minute sleep/roaming gate. That gate should be run and published before
|
||||||
public security/reliability claims.
|
public security/reliability claims.
|
||||||
- **No external security review yet.** The spec's milestone 5 requires an external
|
- **No third-party audit claim.** Dosh maintains a public threat model and hardening
|
||||||
review checklist before public security claims. That review has not happened.
|
checklist, but should not market itself as externally audited unless that actually
|
||||||
|
happens.
|
||||||
|
|
||||||
### Auth posture
|
### Auth posture
|
||||||
|
|
||||||
@@ -233,7 +235,7 @@ cached attach, and Mosh startup.
|
|||||||
Current status: this threat model is published (this document). The verification
|
Current status: this threat model is published (this document). The verification
|
||||||
checklist is **not yet fully green** — see the item-by-item status table in
|
checklist is **not yet fully green** — see the item-by-item status table in
|
||||||
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
|
`docs/PUBLIC_READINESS.md` ("Native v1 verification checklist status") and the known
|
||||||
gaps in section 6 above. Until the gaps close and an external review is complete,
|
gaps in section 6 above. Until the gaps close, Dosh's defensible public claim remains
|
||||||
Dosh's defensible public claim remains **fast, encrypted native attach/reconnect with
|
**fast, encrypted native attach/reconnect and forwarding with SSH bootstrap
|
||||||
SSH-equivalent transport security and SSH bootstrap fallback** — not a fully verified,
|
fallback** on Dosh-installed servers, not generic SSH compatibility or a third-party
|
||||||
externally reviewed SSH replacement.
|
audited security product.
|
||||||
|
|||||||
+200
-13
@@ -179,6 +179,14 @@ enum ForwardEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PendingStreamChunk {
|
||||||
|
offset: u64,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
last_sent: Instant,
|
||||||
|
attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "current_thread")]
|
#[tokio::main(flavor = "current_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
@@ -2135,6 +2143,7 @@ async fn run_terminal(
|
|||||||
let mut last_packet_at = Instant::now();
|
let mut last_packet_at = Instant::now();
|
||||||
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
let mut status_tick = tokio::time::interval(Duration::from_secs(1));
|
||||||
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
|
let mut resize_tick = tokio::time::interval(Duration::from_millis(250));
|
||||||
|
let mut stream_retransmit_tick = tokio::time::interval(Duration::from_millis(200));
|
||||||
let mut last_size = terminal_size();
|
let mut last_size = terminal_size();
|
||||||
// React to terminal resize the instant it happens via SIGWINCH (mosh-style),
|
// React to terminal resize the instant it happens via SIGWINCH (mosh-style),
|
||||||
// instead of waiting up to one `resize_tick`. The 250ms poll below stays as a
|
// instead of waiting up to one `resize_tick`. The 250ms poll below stays as a
|
||||||
@@ -2174,6 +2183,10 @@ async fn run_terminal(
|
|||||||
let mut opened_streams: HashSet<u64> = HashSet::new();
|
let mut opened_streams: HashSet<u64> = HashSet::new();
|
||||||
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
|
let mut stream_send_credit: HashMap<u64, usize> = HashMap::new();
|
||||||
let mut stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>> = HashMap::new();
|
let mut stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>> = HashMap::new();
|
||||||
|
let mut stream_next_send_offset: HashMap<u64, u64> = HashMap::new();
|
||||||
|
let mut stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>> = HashMap::new();
|
||||||
|
let mut stream_next_recv_offset: HashMap<u64, u64> = HashMap::new();
|
||||||
|
let mut stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>> = HashMap::new();
|
||||||
if let Some(frame) = first_frame {
|
if let Some(frame) = first_frame {
|
||||||
if !forward_only {
|
if !forward_only {
|
||||||
render_frame(&frame)?;
|
render_frame(&frame)?;
|
||||||
@@ -2398,6 +2411,8 @@ async fn run_terminal(
|
|||||||
agent_writers.insert(open.stream_id, writer);
|
agent_writers.insert(open.stream_id, writer);
|
||||||
opened_streams.insert(open.stream_id);
|
opened_streams.insert(open.stream_id);
|
||||||
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
||||||
|
stream_next_send_offset.entry(open.stream_id).or_insert(0);
|
||||||
|
stream_next_recv_offset.entry(open.stream_id).or_insert(0);
|
||||||
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
||||||
let forward_tx = remote_forward_tx.clone();
|
let forward_tx = remote_forward_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -2440,6 +2455,8 @@ async fn run_terminal(
|
|||||||
stream_writers.insert(open.stream_id, writer);
|
stream_writers.insert(open.stream_id, writer);
|
||||||
opened_streams.insert(open.stream_id);
|
opened_streams.insert(open.stream_id);
|
||||||
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
stream_send_credit.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
||||||
|
stream_next_send_offset.entry(open.stream_id).or_insert(0);
|
||||||
|
stream_next_recv_offset.entry(open.stream_id).or_insert(0);
|
||||||
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
send_stream_open_ok(&socket, addr, &cred, &mut send_seq, open.stream_id).await?;
|
||||||
let forward_tx = remote_forward_tx.clone();
|
let forward_tx = remote_forward_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -2490,6 +2507,8 @@ async fn run_terminal(
|
|||||||
last_packet_at = Instant::now();
|
last_packet_at = Instant::now();
|
||||||
opened_streams.insert(ok.stream_id);
|
opened_streams.insert(ok.stream_id);
|
||||||
stream_send_credit.entry(ok.stream_id).or_insert(STREAM_INITIAL_WINDOW);
|
stream_send_credit.entry(ok.stream_id).or_insert(STREAM_INITIAL_WINDOW);
|
||||||
|
stream_next_send_offset.entry(ok.stream_id).or_insert(0);
|
||||||
|
stream_next_recv_offset.entry(ok.stream_id).or_insert(0);
|
||||||
if pending_socks_replies.remove(&ok.stream_id)
|
if pending_socks_replies.remove(&ok.stream_id)
|
||||||
&& let Some(writer) = stream_writers.get_mut(&ok.stream_id)
|
&& let Some(writer) = stream_writers.get_mut(&ok.stream_id)
|
||||||
{
|
{
|
||||||
@@ -2503,6 +2522,8 @@ async fn run_terminal(
|
|||||||
ok.stream_id,
|
ok.stream_id,
|
||||||
&mut stream_send_credit,
|
&mut stream_send_credit,
|
||||||
&mut stream_pending_data,
|
&mut stream_pending_data,
|
||||||
|
&mut stream_next_send_offset,
|
||||||
|
&mut stream_sent_data,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
PacketKind::StreamOpenReject => {
|
PacketKind::StreamOpenReject => {
|
||||||
@@ -2521,6 +2542,10 @@ async fn run_terminal(
|
|||||||
opened_streams.remove(&reject.stream_id);
|
opened_streams.remove(&reject.stream_id);
|
||||||
stream_send_credit.remove(&reject.stream_id);
|
stream_send_credit.remove(&reject.stream_id);
|
||||||
stream_pending_data.remove(&reject.stream_id);
|
stream_pending_data.remove(&reject.stream_id);
|
||||||
|
stream_next_send_offset.remove(&reject.stream_id);
|
||||||
|
stream_sent_data.remove(&reject.stream_id);
|
||||||
|
stream_next_recv_offset.remove(&reject.stream_id);
|
||||||
|
stream_recv_pending.remove(&reject.stream_id);
|
||||||
stream_writers.remove(&reject.stream_id);
|
stream_writers.remove(&reject.stream_id);
|
||||||
}
|
}
|
||||||
PacketKind::StreamData => {
|
PacketKind::StreamData => {
|
||||||
@@ -2531,11 +2556,17 @@ async fn run_terminal(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
last_packet_at = Instant::now();
|
last_packet_at = Instant::now();
|
||||||
let len = data.bytes.len();
|
let stream_id = data.stream_id;
|
||||||
if let Some(writer) = stream_writers.get_mut(&data.stream_id) {
|
let (writes, consumed, received_offset) =
|
||||||
let _ = writer.write_all(&data.bytes).await;
|
accept_stream_data(&mut stream_next_recv_offset, &mut stream_recv_pending, data);
|
||||||
} else if let Some(writer) = agent_writers.get_mut(&data.stream_id) {
|
if let Some(writer) = stream_writers.get_mut(&stream_id) {
|
||||||
let _ = writer.write_all(&data.bytes).await;
|
for bytes in &writes {
|
||||||
|
let _ = writer.write_all(bytes).await;
|
||||||
|
}
|
||||||
|
} else if let Some(writer) = agent_writers.get_mut(&stream_id) {
|
||||||
|
for bytes in &writes {
|
||||||
|
let _ = writer.write_all(bytes).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Always return flow-control credit so a stream whose local
|
// Always return flow-control credit so a stream whose local
|
||||||
// writer is already gone cannot wedge the server's send window.
|
// writer is already gone cannot wedge the server's send window.
|
||||||
@@ -2544,8 +2575,9 @@ async fn run_terminal(
|
|||||||
addr,
|
addr,
|
||||||
&cred,
|
&cred,
|
||||||
&mut send_seq,
|
&mut send_seq,
|
||||||
data.stream_id,
|
stream_id,
|
||||||
len,
|
consumed,
|
||||||
|
received_offset,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
PacketKind::StreamWindowAdjust => {
|
PacketKind::StreamWindowAdjust => {
|
||||||
@@ -2556,7 +2588,12 @@ async fn run_terminal(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
last_packet_at = Instant::now();
|
last_packet_at = Instant::now();
|
||||||
add_stream_credit(&mut stream_send_credit, adjust.stream_id, adjust.bytes);
|
ack_stream_data(
|
||||||
|
&mut stream_sent_data,
|
||||||
|
&mut stream_send_credit,
|
||||||
|
adjust.stream_id,
|
||||||
|
adjust.received_offset,
|
||||||
|
);
|
||||||
flush_stream_pending_data(
|
flush_stream_pending_data(
|
||||||
&socket,
|
&socket,
|
||||||
addr,
|
addr,
|
||||||
@@ -2565,6 +2602,8 @@ async fn run_terminal(
|
|||||||
adjust.stream_id,
|
adjust.stream_id,
|
||||||
&mut stream_send_credit,
|
&mut stream_send_credit,
|
||||||
&mut stream_pending_data,
|
&mut stream_pending_data,
|
||||||
|
&mut stream_next_send_offset,
|
||||||
|
&mut stream_sent_data,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
PacketKind::StreamClose => {
|
PacketKind::StreamClose => {
|
||||||
@@ -2579,6 +2618,10 @@ async fn run_terminal(
|
|||||||
opened_streams.remove(&close.stream_id);
|
opened_streams.remove(&close.stream_id);
|
||||||
stream_send_credit.remove(&close.stream_id);
|
stream_send_credit.remove(&close.stream_id);
|
||||||
stream_pending_data.remove(&close.stream_id);
|
stream_pending_data.remove(&close.stream_id);
|
||||||
|
stream_next_send_offset.remove(&close.stream_id);
|
||||||
|
stream_sent_data.remove(&close.stream_id);
|
||||||
|
stream_next_recv_offset.remove(&close.stream_id);
|
||||||
|
stream_recv_pending.remove(&close.stream_id);
|
||||||
stream_writers.remove(&close.stream_id);
|
stream_writers.remove(&close.stream_id);
|
||||||
agent_writers.remove(&close.stream_id);
|
agent_writers.remove(&close.stream_id);
|
||||||
}
|
}
|
||||||
@@ -2590,6 +2633,8 @@ async fn run_terminal(
|
|||||||
Some(ForwardEvent::Open { stream_id, target_host, target_port, writer, socks5_reply }) => {
|
Some(ForwardEvent::Open { stream_id, target_host, target_port, writer, socks5_reply }) => {
|
||||||
stream_writers.insert(stream_id, writer);
|
stream_writers.insert(stream_id, writer);
|
||||||
stream_send_credit.insert(stream_id, STREAM_INITIAL_WINDOW);
|
stream_send_credit.insert(stream_id, STREAM_INITIAL_WINDOW);
|
||||||
|
stream_next_send_offset.entry(stream_id).or_insert(0);
|
||||||
|
stream_next_recv_offset.entry(stream_id).or_insert(0);
|
||||||
if socks5_reply {
|
if socks5_reply {
|
||||||
pending_socks_replies.insert(stream_id);
|
pending_socks_replies.insert(stream_id);
|
||||||
}
|
}
|
||||||
@@ -2615,6 +2660,8 @@ async fn run_terminal(
|
|||||||
&opened_streams,
|
&opened_streams,
|
||||||
&mut stream_send_credit,
|
&mut stream_send_credit,
|
||||||
&mut stream_pending_data,
|
&mut stream_pending_data,
|
||||||
|
&mut stream_next_send_offset,
|
||||||
|
&mut stream_sent_data,
|
||||||
).await?;
|
).await?;
|
||||||
}
|
}
|
||||||
Some(ForwardEvent::Close { stream_id }) => {
|
Some(ForwardEvent::Close { stream_id }) => {
|
||||||
@@ -2622,6 +2669,10 @@ async fn run_terminal(
|
|||||||
opened_streams.remove(&stream_id);
|
opened_streams.remove(&stream_id);
|
||||||
stream_send_credit.remove(&stream_id);
|
stream_send_credit.remove(&stream_id);
|
||||||
stream_pending_data.remove(&stream_id);
|
stream_pending_data.remove(&stream_id);
|
||||||
|
stream_next_send_offset.remove(&stream_id);
|
||||||
|
stream_sent_data.remove(&stream_id);
|
||||||
|
stream_next_recv_offset.remove(&stream_id);
|
||||||
|
stream_recv_pending.remove(&stream_id);
|
||||||
stream_writers.remove(&stream_id);
|
stream_writers.remove(&stream_id);
|
||||||
agent_writers.remove(&stream_id);
|
agent_writers.remove(&stream_id);
|
||||||
send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?;
|
send_stream_close(&socket, addr, &cred, &mut send_seq, stream_id).await?;
|
||||||
@@ -2676,6 +2727,15 @@ async fn run_terminal(
|
|||||||
disconnect_status.apply(action)?;
|
disconnect_status.apply(action)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = stream_retransmit_tick.tick() => {
|
||||||
|
retransmit_stream_data(
|
||||||
|
&socket,
|
||||||
|
addr,
|
||||||
|
&cred,
|
||||||
|
&mut send_seq,
|
||||||
|
&mut stream_sent_data,
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Leave the terminal clean on exit: erase any lingering status line.
|
// Leave the terminal clean on exit: erase any lingering status line.
|
||||||
@@ -2986,9 +3046,14 @@ async fn send_stream_data(
|
|||||||
cred: &CachedCredential,
|
cred: &CachedCredential,
|
||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
stream_id: u64,
|
stream_id: u64,
|
||||||
|
offset: u64,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
|
let body = protocol::to_body(&StreamData {
|
||||||
|
stream_id,
|
||||||
|
offset,
|
||||||
|
bytes,
|
||||||
|
})?;
|
||||||
send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamData, &body).await
|
send_stream_packet(socket, addr, cred, send_seq, PacketKind::StreamData, &body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3002,6 +3067,8 @@ async fn queue_or_send_stream_data(
|
|||||||
opened_streams: &HashSet<u64>,
|
opened_streams: &HashSet<u64>,
|
||||||
stream_send_credit: &mut HashMap<u64, usize>,
|
stream_send_credit: &mut HashMap<u64, usize>,
|
||||||
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
||||||
|
stream_next_send_offset: &mut HashMap<u64, u64>,
|
||||||
|
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !opened_streams.contains(&stream_id)
|
if !opened_streams.contains(&stream_id)
|
||||||
|| stream_send_credit.get(&stream_id).copied().unwrap_or(0) < bytes.len()
|
|| stream_send_credit.get(&stream_id).copied().unwrap_or(0) < bytes.len()
|
||||||
@@ -3016,7 +3083,18 @@ async fn queue_or_send_stream_data(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||||
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await
|
let offset = *stream_next_send_offset.entry(stream_id).or_default();
|
||||||
|
stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||||
|
stream_sent_data.entry(stream_id).or_default().insert(
|
||||||
|
offset,
|
||||||
|
PendingStreamChunk {
|
||||||
|
offset,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn flush_stream_pending_data(
|
async fn flush_stream_pending_data(
|
||||||
@@ -3027,6 +3105,8 @@ async fn flush_stream_pending_data(
|
|||||||
stream_id: u64,
|
stream_id: u64,
|
||||||
stream_send_credit: &mut HashMap<u64, usize>,
|
stream_send_credit: &mut HashMap<u64, usize>,
|
||||||
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
stream_pending_data: &mut HashMap<u64, VecDeque<Vec<u8>>>,
|
||||||
|
stream_next_send_offset: &mut HashMap<u64, u64>,
|
||||||
|
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some(pending) = stream_pending_data.get_mut(&stream_id) else {
|
let Some(pending) = stream_pending_data.get_mut(&stream_id) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -3041,7 +3121,18 @@ async fn flush_stream_pending_data(
|
|||||||
}
|
}
|
||||||
let bytes = pending.pop_front().expect("pending front exists");
|
let bytes = pending.pop_front().expect("pending front exists");
|
||||||
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
*stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||||
send_stream_data(socket, addr, cred, send_seq, stream_id, bytes).await?;
|
let offset = *stream_next_send_offset.entry(stream_id).or_default();
|
||||||
|
stream_next_send_offset.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||||
|
stream_sent_data.entry(stream_id).or_default().insert(
|
||||||
|
offset,
|
||||||
|
PendingStreamChunk {
|
||||||
|
offset,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?;
|
||||||
}
|
}
|
||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
stream_pending_data.remove(&stream_id);
|
stream_pending_data.remove(&stream_id);
|
||||||
@@ -3049,9 +3140,103 @@ async fn flush_stream_pending_data(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: u32) {
|
async fn retransmit_stream_data(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
addr: SocketAddr,
|
||||||
|
cred: &CachedCredential,
|
||||||
|
send_seq: &mut u64,
|
||||||
|
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut retransmit = Vec::new();
|
||||||
|
for (stream_id, chunks) in stream_sent_data.iter_mut() {
|
||||||
|
for chunk in chunks.values_mut() {
|
||||||
|
if now.duration_since(chunk.last_sent) < Duration::from_millis(200) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunk.last_sent = now;
|
||||||
|
chunk.attempts = chunk.attempts.saturating_add(1);
|
||||||
|
retransmit.push((*stream_id, chunk.offset, chunk.bytes.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (stream_id, offset, bytes) in retransmit {
|
||||||
|
send_stream_data(socket, addr, cred, send_seq, stream_id, offset, bytes).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_stream_data(
|
||||||
|
stream_next_recv_offset: &mut HashMap<u64, u64>,
|
||||||
|
stream_recv_pending: &mut HashMap<u64, BTreeMap<u64, Vec<u8>>>,
|
||||||
|
data: StreamData,
|
||||||
|
) -> (Vec<Vec<u8>>, usize, u64) {
|
||||||
|
let stream_id = data.stream_id;
|
||||||
|
let expected = stream_next_recv_offset.entry(stream_id).or_insert(0);
|
||||||
|
if data.offset < *expected {
|
||||||
|
return (Vec::new(), 0, *expected);
|
||||||
|
}
|
||||||
|
if data.offset > *expected {
|
||||||
|
stream_recv_pending
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.entry(data.offset)
|
||||||
|
.or_insert(data.bytes);
|
||||||
|
return (Vec::new(), 0, *expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writes = vec![data.bytes];
|
||||||
|
let mut consumed = writes[0].len();
|
||||||
|
*expected = expected.saturating_add(consumed as u64);
|
||||||
|
|
||||||
|
while let Some(bytes) = stream_recv_pending
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.remove(expected)
|
||||||
|
{
|
||||||
|
consumed = consumed.saturating_add(bytes.len());
|
||||||
|
*expected = expected.saturating_add(bytes.len() as u64);
|
||||||
|
writes.push(bytes);
|
||||||
|
}
|
||||||
|
if stream_recv_pending
|
||||||
|
.get(&stream_id)
|
||||||
|
.is_some_and(BTreeMap::is_empty)
|
||||||
|
{
|
||||||
|
stream_recv_pending.remove(&stream_id);
|
||||||
|
}
|
||||||
|
(writes, consumed, *expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ack_stream_data(
|
||||||
|
stream_sent_data: &mut HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||||
|
stream_send_credit: &mut HashMap<u64, usize>,
|
||||||
|
stream_id: u64,
|
||||||
|
received_offset: u64,
|
||||||
|
) {
|
||||||
|
let Some(sent) = stream_sent_data.get_mut(&stream_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let acked_offsets: Vec<u64> = sent
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(offset, chunk)| {
|
||||||
|
let end = chunk.offset.saturating_add(chunk.bytes.len() as u64);
|
||||||
|
(end <= received_offset).then_some(*offset)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut acked_bytes = 0usize;
|
||||||
|
for offset in acked_offsets {
|
||||||
|
if let Some(chunk) = sent.remove(&offset) {
|
||||||
|
acked_bytes = acked_bytes.saturating_add(chunk.bytes.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sent.is_empty() {
|
||||||
|
stream_sent_data.remove(&stream_id);
|
||||||
|
}
|
||||||
|
add_stream_credit(stream_send_credit, stream_id, acked_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: usize) {
|
||||||
let credit = stream_send_credit.entry(stream_id).or_default();
|
let credit = stream_send_credit.entry(stream_id).or_default();
|
||||||
*credit = credit.saturating_add(bytes as usize);
|
*credit = credit.saturating_add(bytes).min(STREAM_INITIAL_WINDOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_stream_window_adjust(
|
async fn send_stream_window_adjust(
|
||||||
@@ -3061,9 +3246,11 @@ async fn send_stream_window_adjust(
|
|||||||
send_seq: &mut u64,
|
send_seq: &mut u64,
|
||||||
stream_id: u64,
|
stream_id: u64,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
received_offset: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let body = protocol::to_body(&StreamWindowAdjust {
|
let body = protocol::to_body(&StreamWindowAdjust {
|
||||||
stream_id,
|
stream_id,
|
||||||
|
received_offset,
|
||||||
bytes: bytes.min(u32::MAX as usize) as u32,
|
bytes: bytes.min(u32::MAX as usize) as u32,
|
||||||
})?;
|
})?;
|
||||||
send_stream_packet(
|
send_stream_packet(
|
||||||
|
|||||||
+216
-17
@@ -20,7 +20,7 @@ use dosh::protocol::{
|
|||||||
TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
TicketAttachBody, TicketAttachEnvelope, TicketAttachOkEnvelope,
|
||||||
};
|
};
|
||||||
use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session};
|
use dosh::pty::{PtyHandle, PtyOutput, adopt_pty_from_fd, spawn_pty_session};
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||||
@@ -375,6 +375,10 @@ struct ClientState {
|
|||||||
opened_streams: HashSet<u64>,
|
opened_streams: HashSet<u64>,
|
||||||
stream_send_credit: HashMap<u64, usize>,
|
stream_send_credit: HashMap<u64, usize>,
|
||||||
stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>>,
|
stream_pending_data: HashMap<u64, VecDeque<Vec<u8>>>,
|
||||||
|
stream_next_send_offset: HashMap<u64, u64>,
|
||||||
|
stream_sent_data: HashMap<u64, BTreeMap<u64, PendingStreamChunk>>,
|
||||||
|
stream_next_recv_offset: HashMap<u64, u64>,
|
||||||
|
stream_recv_pending: HashMap<u64, BTreeMap<u64, Vec<u8>>>,
|
||||||
/// Current transport key epoch (0 = original handshake key). Bumped on rekey.
|
/// Current transport key epoch (0 = original handshake key). Bumped on rekey.
|
||||||
epoch: u64,
|
epoch: u64,
|
||||||
/// When the current epoch began, for the wall-clock rekey trigger.
|
/// When the current epoch began, for the wall-clock rekey trigger.
|
||||||
@@ -394,6 +398,14 @@ struct PendingFrame {
|
|||||||
attempts: u8,
|
attempts: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PendingStreamChunk {
|
||||||
|
offset: u64,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
last_sent: Instant,
|
||||||
|
attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
struct PendingNativeAuth {
|
struct PendingNativeAuth {
|
||||||
client: dosh::native::NativeClientHello,
|
client: dosh::native::NativeClientHello,
|
||||||
server: NativeServerHello,
|
server: NativeServerHello,
|
||||||
@@ -1115,6 +1127,10 @@ async fn handle_native_user_auth(
|
|||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
stream_send_credit: HashMap::new(),
|
stream_send_credit: HashMap::new(),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
@@ -1260,6 +1276,10 @@ async fn handle_bootstrap_attach(
|
|||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
stream_send_credit: HashMap::new(),
|
stream_send_credit: HashMap::new(),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
@@ -1379,6 +1399,10 @@ async fn handle_ticket_attach(
|
|||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
stream_send_credit: HashMap::new(),
|
stream_send_credit: HashMap::new(),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
@@ -1745,6 +1769,14 @@ async fn handle_stream_open(
|
|||||||
client
|
client
|
||||||
.stream_send_credit
|
.stream_send_credit
|
||||||
.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
.insert(open.stream_id, STREAM_INITIAL_WINDOW);
|
||||||
|
client
|
||||||
|
.stream_next_send_offset
|
||||||
|
.entry(open.stream_id)
|
||||||
|
.or_insert(0);
|
||||||
|
client
|
||||||
|
.stream_next_recv_offset
|
||||||
|
.entry(open.stream_id)
|
||||||
|
.or_insert(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1798,7 +1830,8 @@ async fn handle_stream_data(
|
|||||||
let (key, session_name) = find_client_decrypt_key(state, &packet.header)?;
|
let (key, session_name) = find_client_decrypt_key(state, &packet.header)?;
|
||||||
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
|
let body = protocol::decrypt_body(packet, &key, CLIENT_TO_SERVER)?;
|
||||||
let data: StreamData = protocol::from_body(&body)?;
|
let data: StreamData = protocol::from_body(&body)?;
|
||||||
let writer = {
|
let stream_id = data.stream_id;
|
||||||
|
let (writer, writes, consumed, received_offset) = {
|
||||||
let mut locked = state.lock().expect("server state poisoned");
|
let mut locked = state.lock().expect("server state poisoned");
|
||||||
let session = locked
|
let session = locked
|
||||||
.sessions
|
.sessions
|
||||||
@@ -1813,17 +1846,27 @@ async fn handle_stream_data(
|
|||||||
}
|
}
|
||||||
client.endpoint = peer;
|
client.endpoint = peer;
|
||||||
client.last_seen = Instant::now();
|
client.last_seen = Instant::now();
|
||||||
client.stream_writers.get(&data.stream_id).cloned()
|
let writer = client.stream_writers.get(&data.stream_id).cloned();
|
||||||
|
let (writes, consumed, received_offset) = accept_stream_data(client, data);
|
||||||
|
(writer, writes, consumed, received_offset)
|
||||||
};
|
};
|
||||||
let len = data.bytes.len();
|
|
||||||
if let Some(writer) = writer {
|
if let Some(writer) = writer {
|
||||||
let _ = writer.send(data.bytes).await;
|
for bytes in writes {
|
||||||
|
let _ = writer.send(bytes).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Always return flow-control credit, even when the stream's writer is already
|
// Always return flow-control credit, even when the stream's writer is already
|
||||||
// gone (closed/unknown). The peer debited its send window for these bytes, so
|
// gone (closed/unknown). The peer debited its send window for these bytes, so
|
||||||
// skipping the adjust would wedge the stream at a permanent credit deficit.
|
// skipping the adjust would wedge the stream at a permanent credit deficit.
|
||||||
send_stream_window_adjust_to_client(state, socket, packet.header.conn_id, data.stream_id, len)
|
send_stream_window_adjust_to_client(
|
||||||
.await?;
|
state,
|
||||||
|
socket,
|
||||||
|
packet.header.conn_id,
|
||||||
|
stream_id,
|
||||||
|
consumed,
|
||||||
|
received_offset,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1913,11 +1956,7 @@ async fn handle_stream_window_adjust(
|
|||||||
}
|
}
|
||||||
client.endpoint = peer;
|
client.endpoint = peer;
|
||||||
client.last_seen = Instant::now();
|
client.last_seen = Instant::now();
|
||||||
add_stream_credit(
|
ack_stream_data(client, adjust.stream_id, adjust.received_offset);
|
||||||
&mut client.stream_send_credit,
|
|
||||||
adjust.stream_id,
|
|
||||||
adjust.bytes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
flush_stream_pending_data_to_client(state, socket, packet.header.conn_id, adjust.stream_id)
|
flush_stream_pending_data_to_client(state, socket, packet.header.conn_id, adjust.stream_id)
|
||||||
.await
|
.await
|
||||||
@@ -1948,6 +1987,10 @@ async fn handle_stream_close(
|
|||||||
client.opened_streams.remove(&close.stream_id);
|
client.opened_streams.remove(&close.stream_id);
|
||||||
client.stream_send_credit.remove(&close.stream_id);
|
client.stream_send_credit.remove(&close.stream_id);
|
||||||
client.stream_pending_data.remove(&close.stream_id);
|
client.stream_pending_data.remove(&close.stream_id);
|
||||||
|
client.stream_next_send_offset.remove(&close.stream_id);
|
||||||
|
client.stream_sent_data.remove(&close.stream_id);
|
||||||
|
client.stream_next_recv_offset.remove(&close.stream_id);
|
||||||
|
client.stream_recv_pending.remove(&close.stream_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2312,8 +2355,16 @@ async fn queue_or_send_stream_data_to_client(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||||
|
let offset = *client.stream_next_send_offset.entry(stream_id).or_default();
|
||||||
|
client
|
||||||
|
.stream_next_send_offset
|
||||||
|
.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||||
client.send_seq += 1;
|
client.send_seq += 1;
|
||||||
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
|
let body = protocol::to_body(&StreamData {
|
||||||
|
stream_id,
|
||||||
|
offset,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
})?;
|
||||||
let packet = protocol::encode_encrypted(
|
let packet = protocol::encode_encrypted(
|
||||||
PacketKind::StreamData,
|
PacketKind::StreamData,
|
||||||
client_id,
|
client_id,
|
||||||
@@ -2323,6 +2374,19 @@ async fn queue_or_send_stream_data_to_client(
|
|||||||
SERVER_TO_CLIENT,
|
SERVER_TO_CLIENT,
|
||||||
&body,
|
&body,
|
||||||
)?;
|
)?;
|
||||||
|
client
|
||||||
|
.stream_sent_data
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(
|
||||||
|
offset,
|
||||||
|
PendingStreamChunk {
|
||||||
|
offset,
|
||||||
|
bytes,
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
send = Some((client.endpoint, packet));
|
send = Some((client.endpoint, packet));
|
||||||
}
|
}
|
||||||
send
|
send
|
||||||
@@ -2364,8 +2428,16 @@ async fn flush_stream_pending_data_to_client(
|
|||||||
client.stream_pending_data.remove(&stream_id);
|
client.stream_pending_data.remove(&stream_id);
|
||||||
}
|
}
|
||||||
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
*client.stream_send_credit.entry(stream_id).or_default() -= bytes.len();
|
||||||
|
let offset = *client.stream_next_send_offset.entry(stream_id).or_default();
|
||||||
|
client
|
||||||
|
.stream_next_send_offset
|
||||||
|
.insert(stream_id, offset.saturating_add(bytes.len() as u64));
|
||||||
client.send_seq += 1;
|
client.send_seq += 1;
|
||||||
let body = protocol::to_body(&StreamData { stream_id, bytes })?;
|
let body = protocol::to_body(&StreamData {
|
||||||
|
stream_id,
|
||||||
|
offset,
|
||||||
|
bytes: bytes.clone(),
|
||||||
|
})?;
|
||||||
let packet = protocol::encode_encrypted(
|
let packet = protocol::encode_encrypted(
|
||||||
PacketKind::StreamData,
|
PacketKind::StreamData,
|
||||||
client_id,
|
client_id,
|
||||||
@@ -2375,6 +2447,19 @@ async fn flush_stream_pending_data_to_client(
|
|||||||
SERVER_TO_CLIENT,
|
SERVER_TO_CLIENT,
|
||||||
&body,
|
&body,
|
||||||
)?;
|
)?;
|
||||||
|
client
|
||||||
|
.stream_sent_data
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(
|
||||||
|
offset,
|
||||||
|
PendingStreamChunk {
|
||||||
|
offset,
|
||||||
|
bytes,
|
||||||
|
last_sent: Instant::now(),
|
||||||
|
attempts: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
send = Some((client.endpoint, packet));
|
send = Some((client.endpoint, packet));
|
||||||
}
|
}
|
||||||
send
|
send
|
||||||
@@ -2386,9 +2471,72 @@ async fn flush_stream_pending_data_to_client(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: u32) {
|
fn accept_stream_data(client: &mut ClientState, data: StreamData) -> (Vec<Vec<u8>>, usize, u64) {
|
||||||
|
let stream_id = data.stream_id;
|
||||||
|
let expected = client.stream_next_recv_offset.entry(stream_id).or_insert(0);
|
||||||
|
if data.offset < *expected {
|
||||||
|
return (Vec::new(), 0, *expected);
|
||||||
|
}
|
||||||
|
if data.offset > *expected {
|
||||||
|
client
|
||||||
|
.stream_recv_pending
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.entry(data.offset)
|
||||||
|
.or_insert(data.bytes);
|
||||||
|
return (Vec::new(), 0, *expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writes = vec![data.bytes];
|
||||||
|
let mut consumed = writes[0].len();
|
||||||
|
*expected = expected.saturating_add(consumed as u64);
|
||||||
|
|
||||||
|
while let Some(bytes) = client
|
||||||
|
.stream_recv_pending
|
||||||
|
.entry(stream_id)
|
||||||
|
.or_default()
|
||||||
|
.remove(expected)
|
||||||
|
{
|
||||||
|
consumed += bytes.len();
|
||||||
|
*expected = expected.saturating_add(bytes.len() as u64);
|
||||||
|
writes.push(bytes);
|
||||||
|
}
|
||||||
|
if client
|
||||||
|
.stream_recv_pending
|
||||||
|
.get(&stream_id)
|
||||||
|
.is_some_and(BTreeMap::is_empty)
|
||||||
|
{
|
||||||
|
client.stream_recv_pending.remove(&stream_id);
|
||||||
|
}
|
||||||
|
(writes, consumed, *expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ack_stream_data(client: &mut ClientState, stream_id: u64, received_offset: u64) {
|
||||||
|
let Some(sent) = client.stream_sent_data.get_mut(&stream_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let acked_offsets: Vec<u64> = sent
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(offset, chunk)| {
|
||||||
|
let end = chunk.offset.saturating_add(chunk.bytes.len() as u64);
|
||||||
|
(end <= received_offset).then_some(*offset)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut acked_bytes = 0usize;
|
||||||
|
for offset in acked_offsets {
|
||||||
|
if let Some(chunk) = sent.remove(&offset) {
|
||||||
|
acked_bytes = acked_bytes.saturating_add(chunk.bytes.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sent.is_empty() {
|
||||||
|
client.stream_sent_data.remove(&stream_id);
|
||||||
|
}
|
||||||
|
add_stream_credit(&mut client.stream_send_credit, stream_id, acked_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_stream_credit(stream_send_credit: &mut HashMap<u64, usize>, stream_id: u64, bytes: usize) {
|
||||||
let credit = stream_send_credit.entry(stream_id).or_default();
|
let credit = stream_send_credit.entry(stream_id).or_default();
|
||||||
*credit = credit.saturating_add(bytes as usize);
|
*credit = credit.saturating_add(bytes).min(STREAM_INITIAL_WINDOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_stream_window_adjust_to_client(
|
async fn send_stream_window_adjust_to_client(
|
||||||
@@ -2397,9 +2545,11 @@ async fn send_stream_window_adjust_to_client(
|
|||||||
client_id: [u8; 16],
|
client_id: [u8; 16],
|
||||||
stream_id: u64,
|
stream_id: u64,
|
||||||
bytes: usize,
|
bytes: usize,
|
||||||
|
received_offset: u64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let body = protocol::to_body(&StreamWindowAdjust {
|
let body = protocol::to_body(&StreamWindowAdjust {
|
||||||
stream_id,
|
stream_id,
|
||||||
|
received_offset,
|
||||||
bytes: bytes.min(u32::MAX as usize) as u32,
|
bytes: bytes.min(u32::MAX as usize) as u32,
|
||||||
})?;
|
})?;
|
||||||
send_stream_packet_to_client(
|
send_stream_packet_to_client(
|
||||||
@@ -2425,6 +2575,10 @@ async fn send_stream_close_to_client(
|
|||||||
client.opened_streams.remove(&stream_id);
|
client.opened_streams.remove(&stream_id);
|
||||||
client.stream_send_credit.remove(&stream_id);
|
client.stream_send_credit.remove(&stream_id);
|
||||||
client.stream_pending_data.remove(&stream_id);
|
client.stream_pending_data.remove(&stream_id);
|
||||||
|
client.stream_next_send_offset.remove(&stream_id);
|
||||||
|
client.stream_sent_data.remove(&stream_id);
|
||||||
|
client.stream_next_recv_offset.remove(&stream_id);
|
||||||
|
client.stream_recv_pending.remove(&stream_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let body = protocol::to_body(&StreamClose { stream_id })?;
|
let body = protocol::to_body(&StreamClose { stream_id })?;
|
||||||
@@ -2642,7 +2796,7 @@ async fn retransmit_pending(
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut sends = Vec::new();
|
let mut sends = Vec::new();
|
||||||
for session in locked.sessions.values_mut() {
|
for session in locked.sessions.values_mut() {
|
||||||
for client in session.clients.values_mut() {
|
for (client_id, client) in session.clients.iter_mut() {
|
||||||
for pending in client.pending.iter_mut() {
|
for pending in client.pending.iter_mut() {
|
||||||
if pending.output_seq <= client.last_acked {
|
if pending.output_seq <= client.last_acked {
|
||||||
continue;
|
continue;
|
||||||
@@ -2655,6 +2809,39 @@ async fn retransmit_pending(
|
|||||||
sends.push((client.endpoint, pending.packet.clone()));
|
sends.push((client.endpoint, pending.packet.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let stream_ids: Vec<u64> = client.stream_sent_data.keys().copied().collect();
|
||||||
|
let mut retransmit_chunks = Vec::new();
|
||||||
|
for stream_id in stream_ids {
|
||||||
|
let Some(chunks) = client.stream_sent_data.get_mut(&stream_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for chunk in chunks.values_mut() {
|
||||||
|
if now.duration_since(chunk.last_sent) < Duration::from_millis(200) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chunk.last_sent = now;
|
||||||
|
chunk.attempts = chunk.attempts.saturating_add(1);
|
||||||
|
retransmit_chunks.push((stream_id, chunk.offset, chunk.bytes.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (stream_id, offset, bytes) in retransmit_chunks {
|
||||||
|
client.send_seq += 1;
|
||||||
|
let body = protocol::to_body(&StreamData {
|
||||||
|
stream_id,
|
||||||
|
offset,
|
||||||
|
bytes,
|
||||||
|
})?;
|
||||||
|
let packet = protocol::encode_encrypted(
|
||||||
|
PacketKind::StreamData,
|
||||||
|
*client_id,
|
||||||
|
client.send_seq,
|
||||||
|
client.last_acked,
|
||||||
|
&client.session_key,
|
||||||
|
SERVER_TO_CLIENT,
|
||||||
|
&body,
|
||||||
|
)?;
|
||||||
|
sends.push((client.endpoint, packet));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sends
|
sends
|
||||||
@@ -3015,6 +3202,10 @@ mod tests {
|
|||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
stream_send_credit: HashMap::new(),
|
stream_send_credit: HashMap::new(),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
@@ -3211,6 +3402,10 @@ mod tests {
|
|||||||
opened_streams: HashSet::new(),
|
opened_streams: HashSet::new(),
|
||||||
stream_send_credit: HashMap::new(),
|
stream_send_credit: HashMap::new(),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
@@ -3300,6 +3495,10 @@ mod tests {
|
|||||||
opened_streams: HashSet::from([42]),
|
opened_streams: HashSet::from([42]),
|
||||||
stream_send_credit: HashMap::from([(42, 0)]),
|
stream_send_credit: HashMap::from([(42, 0)]),
|
||||||
stream_pending_data: HashMap::new(),
|
stream_pending_data: HashMap::new(),
|
||||||
|
stream_next_send_offset: HashMap::new(),
|
||||||
|
stream_sent_data: HashMap::new(),
|
||||||
|
stream_next_recv_offset: HashMap::new(),
|
||||||
|
stream_recv_pending: HashMap::new(),
|
||||||
epoch: 0,
|
epoch: 0,
|
||||||
epoch_started: Instant::now(),
|
epoch_started: Instant::now(),
|
||||||
epoch_packets: 0,
|
epoch_packets: 0,
|
||||||
|
|||||||
+5
-1
@@ -38,12 +38,14 @@ pub fn is_implicit_session_name(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const MAGIC: &[u8; 4] = b"DOSH";
|
pub const MAGIC: &[u8; 4] = b"DOSH";
|
||||||
|
// v4: added reliable stream offsets/acks to `StreamData` and
|
||||||
|
// `StreamWindowAdjust`.
|
||||||
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
|
// v3: added `ForwardingKind::Agent` (SSH-agent forwarding). The new variant rides
|
||||||
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
|
// inside `NativeUserAuth.requested_forwardings`, so a pre-agent peer would fail to
|
||||||
// deserialize it; bumping the wire version makes such a peer answer with a clear
|
// deserialize it; bumping the wire version makes such a peer answer with a clear
|
||||||
// version-mismatch reject instead. Existing variants' bincode discriminants are
|
// version-mismatch reject instead. Existing variants' bincode discriminants are
|
||||||
// unchanged, so the bump is purely a compatibility gate.
|
// unchanged, so the bump is purely a compatibility gate.
|
||||||
pub const VERSION: u8 = 3;
|
pub const VERSION: u8 = 4;
|
||||||
pub const HEADER_LEN: usize = 58;
|
pub const HEADER_LEN: usize = 58;
|
||||||
|
|
||||||
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
|
/// Stable, user-facing reason string the server puts in an `AttachReject` when a
|
||||||
@@ -416,12 +418,14 @@ pub struct StreamOpenReject {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StreamData {
|
pub struct StreamData {
|
||||||
pub stream_id: u64,
|
pub stream_id: u64,
|
||||||
|
pub offset: u64,
|
||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StreamWindowAdjust {
|
pub struct StreamWindowAdjust {
|
||||||
pub stream_id: u64,
|
pub stream_id: u64,
|
||||||
|
pub received_offset: u64,
|
||||||
pub bytes: u32,
|
pub bytes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-4
@@ -20,7 +20,7 @@
|
|||||||
//! tests are reproducible and fast.
|
//! tests are reproducible and fast.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Read;
|
use std::io::{Read, Write};
|
||||||
use std::net::{SocketAddr, TcpListener, UdpSocket};
|
use std::net::{SocketAddr, TcpListener, UdpSocket};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -38,7 +38,7 @@ use dosh::native::{self, ForwardingKind, ForwardingRequest, NativeAuthOk, Native
|
|||||||
use dosh::protocol::{
|
use dosh::protocol::{
|
||||||
self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
|
self, AttachOk, AttachReject, CLIENT_TO_SERVER, Frame, Header, Input, NativeAuthOkBody,
|
||||||
NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
|
NativeClientHelloBody, NativeServerHelloBody, NativeUserAuthBody, PacketKind, ResumeRequest,
|
||||||
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk,
|
SERVER_TO_CLIENT, StreamData, StreamOpen, StreamOpenOk, StreamWindowAdjust,
|
||||||
};
|
};
|
||||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
@@ -627,6 +627,25 @@ fn wait_for_stream_open_ok(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wait_for_stream_data(
|
||||||
|
socket: &UdpSocket,
|
||||||
|
key: &[u8; 32],
|
||||||
|
stream_id: u64,
|
||||||
|
millis: u64,
|
||||||
|
) -> Option<(Header, StreamData)> {
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(millis);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Some((header, data)) =
|
||||||
|
recv_stream_packet::<StreamData>(socket, key, PacketKind::StreamData)
|
||||||
|
{
|
||||||
|
if data.stream_id == stream_id {
|
||||||
|
return Some((header, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
|
fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
let port = listener.local_addr().unwrap().port();
|
||||||
@@ -656,6 +675,34 @@ fn start_tcp_collector() -> (u16, Receiver<Vec<u8>>) {
|
|||||||
(port, rx)
|
(port, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_tcp_echo() -> u16 {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
thread::spawn(move || {
|
||||||
|
let Ok((mut stream, _)) = listener.accept() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
|
.unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) => return,
|
||||||
|
Ok(n) => {
|
||||||
|
let _ = stream.write_all(&buf[..n]);
|
||||||
|
}
|
||||||
|
Err(ref err)
|
||||||
|
if err.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
|| err.kind() == std::io::ErrorKind::TimedOut => {}
|
||||||
|
Err(_) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
|
fn collect_tcp(rx: &Receiver<Vec<u8>>, millis: u64) -> Vec<u8> {
|
||||||
let deadline = Instant::now() + Duration::from_millis(millis);
|
let deadline = Instant::now() + Duration::from_millis(millis);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -895,6 +942,7 @@ fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
|
|||||||
open_ok.seq,
|
open_ok.seq,
|
||||||
protocol::to_body(&StreamData {
|
protocol::to_body(&StreamData {
|
||||||
stream_id,
|
stream_id,
|
||||||
|
offset: 0,
|
||||||
bytes: b"A".to_vec(),
|
bytes: b"A".to_vec(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -909,6 +957,7 @@ fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
|
|||||||
open_ok.seq,
|
open_ok.seq,
|
||||||
protocol::to_body(&StreamData {
|
protocol::to_body(&StreamData {
|
||||||
stream_id,
|
stream_id,
|
||||||
|
offset: 1,
|
||||||
bytes: b"B".to_vec(),
|
bytes: b"B".to_vec(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -916,8 +965,8 @@ fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
|
|||||||
|
|
||||||
let mut seen = collect_tcp(&tcp_rx, 1500);
|
let mut seen = collect_tcp(&tcp_rx, 1500);
|
||||||
assert!(
|
assert!(
|
||||||
seen.contains(&b'A') && seen.contains(&b'B'),
|
seen.starts_with(b"AB"),
|
||||||
"reordered stream bytes did not both reach TCP target: {:?}",
|
"reordered stream bytes were not delivered once in-order to TCP target: {:?}",
|
||||||
String::from_utf8_lossy(&seen)
|
String::from_utf8_lossy(&seen)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -933,6 +982,7 @@ fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
|
|||||||
CLIENT_TO_SERVER,
|
CLIENT_TO_SERVER,
|
||||||
&protocol::to_body(&StreamData {
|
&protocol::to_body(&StreamData {
|
||||||
stream_id,
|
stream_id,
|
||||||
|
offset: 2,
|
||||||
bytes: b"X".to_vec(),
|
bytes: b"X".to_vec(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -959,6 +1009,94 @@ fn forwarded_stream_data_survives_reorder_and_rejects_replay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwarded_stream_data_recovers_after_server_to_client_loss() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let port = free_udp_port();
|
||||||
|
let config = write_server_config(&dir, port);
|
||||||
|
let signing_key = SigningKey::from_bytes(&[43u8; 32]);
|
||||||
|
authorize_ed25519_key(&dir, &signing_key);
|
||||||
|
let target_port = start_tcp_echo();
|
||||||
|
let mut server = start_server(&dir, &config);
|
||||||
|
let relay = Relay::spawn(port, 0xA11CEu64);
|
||||||
|
|
||||||
|
let attached = native_attach_through_relay(
|
||||||
|
&relay,
|
||||||
|
&signing_key,
|
||||||
|
vec![ForwardingRequest {
|
||||||
|
kind: ForwardingKind::Local,
|
||||||
|
bind_host: Some("127.0.0.1".to_string()),
|
||||||
|
listen_port: 0,
|
||||||
|
target_host: Some("127.0.0.1".to_string()),
|
||||||
|
target_port: Some(target_port),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let stream_id = 55;
|
||||||
|
let open = StreamOpen {
|
||||||
|
stream_id,
|
||||||
|
target_host: "127.0.0.1".to_string(),
|
||||||
|
target_port,
|
||||||
|
};
|
||||||
|
send_stream_packet(
|
||||||
|
&attached.socket,
|
||||||
|
&relay,
|
||||||
|
&attached.ok,
|
||||||
|
PacketKind::StreamOpen,
|
||||||
|
3,
|
||||||
|
attached.ok.initial_seq,
|
||||||
|
protocol::to_body(&open).unwrap(),
|
||||||
|
);
|
||||||
|
let open_ok =
|
||||||
|
wait_for_stream_open_ok(&attached.socket, &attached.ok.session_key, stream_id, 3000)
|
||||||
|
.expect("stream did not open");
|
||||||
|
|
||||||
|
// Drop the first server->client echo and at least one retransmit tick. Once
|
||||||
|
// the link clears, the unacked stream chunk must be re-encrypted with a new
|
||||||
|
// packet sequence and delivered at the same stream offset.
|
||||||
|
relay.set_drop_s2c(100);
|
||||||
|
send_stream_packet(
|
||||||
|
&attached.socket,
|
||||||
|
&relay,
|
||||||
|
&attached.ok,
|
||||||
|
PacketKind::StreamData,
|
||||||
|
4,
|
||||||
|
open_ok.seq,
|
||||||
|
protocol::to_body(&StreamData {
|
||||||
|
stream_id,
|
||||||
|
offset: 0,
|
||||||
|
bytes: b"PING".to_vec(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
relay.set_drop_s2c(0);
|
||||||
|
|
||||||
|
let (_header, data) =
|
||||||
|
wait_for_stream_data(&attached.socket, &attached.ok.session_key, stream_id, 3000)
|
||||||
|
.expect("lost server->client stream data was not retransmitted");
|
||||||
|
assert_eq!(data.offset, 0);
|
||||||
|
assert_eq!(data.bytes, b"PING");
|
||||||
|
|
||||||
|
send_stream_packet(
|
||||||
|
&attached.socket,
|
||||||
|
&relay,
|
||||||
|
&attached.ok,
|
||||||
|
PacketKind::StreamWindowAdjust,
|
||||||
|
5,
|
||||||
|
open_ok.seq,
|
||||||
|
protocol::to_body(&StreamWindowAdjust {
|
||||||
|
stream_id,
|
||||||
|
received_offset: 4,
|
||||||
|
bytes: 4,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(relay);
|
||||||
|
let _ = server.kill();
|
||||||
|
let _ = server.wait();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_packets_after_resume_are_ignored_not_fatal() {
|
fn stale_packets_after_resume_are_ignored_not_fatal() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user