feat(minimald,minimal2): HTTPS reverse proxy with mTLS auth and ssh-forward CLI - #554
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughIntroduces a ChangesmTLS HTTPS Proxy, SSH Forwarding, and Cert Issuance
Sequence Diagram(s)sequenceDiagram
participant User as User (terminal)
participant minimal as minimal CLI
participant minimald as minimald daemon
participant CertAuthority
participant SSH as ssh process
rect rgba(100, 149, 237, 0.5)
Note over User,CertAuthority: minimal login flow
User->>minimal: minimal login [--cert-dir ...]
minimal->>minimald: IssueClientCert RPC (subject_cn from $USER)
minimald->>CertAuthority: sign_client_cert(subject_cn)
CertAuthority-->>minimald: cert_pem, key_pem, ca_cert_pem
minimald-->>minimal: IssueClientCertResponse
minimal->>minimal: write client.pem, client.key (0600), ca.pem
minimal-->>User: Print saved paths + curl example
end
rect rgba(60, 179, 113, 0.5)
Note over User,SSH: minimal ssh-forward flow
User->>minimal: minimal ssh-forward <session> LOCAL:HOST:PORT
minimal->>minimald: GetSessionRecord RPC (validate session)
minimald-->>minimal: session found
minimal->>SSH: exec ssh -N -L LOCAL:HOST:PORT -o ProxyCommand="minimal proxy --socket ..." -o ExitOnForwardFailure=yes
SSH->>minimald: channel_open_direct_tcpip (Auth::Local)
minimald->>minimald: validate SessionId, connect upstream TCP (10s timeout)
minimald-->>SSH: relay bidirectional bytes
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (5)
crates/minimald/src/net/mod.rs (1)
407-410: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid synchronous filesystem I/O on async control paths.
std::fs::*inattach/ensure_running/stopcan block Tokio worker threads during attach/teardown bursts. Prefertokio::fs(orspawn_blocking) here.#!/bin/bash # Verify sync filesystem calls inside async functions in this file. rg -n --type=rust -C 3 'async fn (attach|ensure_running|stop)|std::fs::(create_dir_all|write|remove_file)' crates/minimald/src/net/mod.rsSuggested patch
- pub async fn attach(&mut self) -> Result<AttachResult, NetError> { + pub async fn attach(&mut self) -> Result<AttachResult, NetError> { let lease = self.allocator.allocate()?; - self.write_config()?; + self.write_config().await?; self.ensure_running().await?; self.attached += 1; ... } - fn write_config(&self) -> Result<(), NetError> { - std::fs::create_dir_all(&self.state_dir).map_err(|source| NetError::WriteConfig { + async fn write_config(&self) -> Result<(), NetError> { + tokio::fs::create_dir_all(&self.state_dir).await.map_err(|source| NetError::WriteConfig { path: self.state_dir.clone(), source, })?; let path = self.config_path(); let body = render_gvproxy_config(self.allocator.subnet(), self.allocator.leases()); - std::fs::write(&path, body).map_err(|source| NetError::WriteConfig { path, source }) + tokio::fs::write(&path, body).await.map_err(|source| NetError::WriteConfig { path, source }) } - match std::fs::remove_file(&sock) { + match tokio::fs::remove_file(&sock).await { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => return Err(NetError::Io(e)), } - let _ = std::fs::remove_file(self.control_socket()); + let _ = tokio::fs::remove_file(self.control_socket()).await; Ok(()) }Also applies to: 437-445, 482-485, 595-595
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/mod.rs` around lines 407 - 410, Replace all synchronous filesystem I/O operations in the async functions `attach`, `ensure_running`, and `stop` with their asynchronous equivalents from the `tokio::fs` module. Specifically, convert `std::fs::create_dir_all`, `std::fs::write`, and `std::fs::remove_file` calls to `tokio::fs::create_dir_all`, `tokio::fs::write`, and `tokio::fs::remove_file` respectively, and await each of these async operations. If there are filesystem operations without direct tokio equivalents, wrap them using `tokio::task::spawn_blocking` to prevent blocking the Tokio worker threads during attach/teardown operations.crates/minimald/src/net/proxy.rs (1)
364-391: 🩺 Stability & Availability | 🔵 TrivialThe ephemeral CA behavior is already documented — consider clarifying it at the function definition level.
The code correctly implements an in-memory CA that is regenerated at daemon startup (no persistence). This is already acknowledged in
ServerStateinserver.rs(lines 100–101): "daemon's lifetime; clients must callminimal loginagain after a restart."The implication you identified is accurate: every restart invalidates previously-issued client certs and rotates the server cert, requiring users to re-run
minimal login. This is intentional MVP behavior.However, the doc comment on
CertAuthority::generate()(lines 355–362) currently describes only the technical details (ECDSA P-256, localhost SAN) without mentioning the ephemeral nature. Consider expanding the doc comment to clarify: "This CA exists only for the lifetime of the daemon and is regenerated on restart, invalidating any previously-issued client certificates."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/proxy.rs` around lines 364 - 391, The doc comment for the CertAuthority::generate() function currently only describes the technical implementation details (ECDSA P-256 key, localhost SAN) but omits documentation about the ephemeral nature of the CA. Expand the doc comment for the generate() method to clarify that the CA exists only for the lifetime of the daemon, is regenerated on each restart, and that any previously-issued client certificates become invalid after a daemon restart. This will make the MVP behavior explicit to users and developers reading the code.crates/minimal2/src/main.rs (2)
39-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueHelp text default path is Linux-specific.
The help text states the default is
~/.config/minimal/, butcmd_loginresolves the directory viadirs::config_dir(), which on macOS returns~/Library/Application Support. Since the daemon socket resolution inclient.rsiscfg(target_os)-aware for both Linux and macOS, this static help/example will mislead macOS users. Consider phrasing it as "the platform config directory" or noting the macOS path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal2/src/main.rs` around lines 39 - 73, The docstring for the Login command variant contains hardcoded Linux-specific paths like ~/.config/minimal/ that do not match the actual behavior on macOS, where dirs::config_dir() returns ~/Library/Application Support. Update the help text and examples in the Login variant's docstring to indicate the paths are platform-specific or use a generic term like "the platform config directory" instead of hardcoding the Linux path, so that documentation accurately reflects the behavior across both Linux and macOS systems.
556-570: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueForward-spec validation is effectively a no-op.
splitn(3, ':')only guarantees three colon-separated segments;forward_argthen reconstructs a string identical toargs.forward, so this block adds no normalization. The ports are also never validated as numeric, so malformed input (e.g.abc:host:def) is silently forwarded tossh -L, surfacing as an opaque ssh error. The comment about handling compact IPv4 forms is also misleading—nothing here distinguishes that case, and an IPv6remote-hostcontaining colons would be mis-split.Consider validating
local_port/remote_portparse asu16and dropping the redundant reconstruction.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimal2/src/main.rs` around lines 556 - 570, The forward-spec validation block is redundant and does not actually validate port numbers. The code splits the forward spec into three parts and then reconstructs an identical string in forward_arg, which provides no normalization. To fix this, parse local_port and remote_port as u16 values to ensure they are valid numeric port numbers, returning an error with a helpful message if parsing fails for either port. Remove the forward_arg variable entirely since it is identical to args.forward and serves no purpose. This will catch malformed input like abc:host:def early with a clear error message instead of silently passing invalid input to ssh.crates/minimald/src/connection.rs (1)
464-518: 🩺 Stability & Availability | 🔵 TrivialUpstream connect blocks the SSH connection handler, serializing message processing for up to 10 seconds.
The
TcpStream::connectawaited in this handler will prevent the russh Handler from returning until the connection succeeds, times out, or fails. Since russh dispatches Handler methods as async futures that must complete before other messages are processed on the same connection, a slow or unreachable target stalls all concurrent channel operations (channel_open, requests, etc.) on that SSH session for the duration of the timeout. The impact is scoped to the single client, but multiplexed forwarding sessions could experience head-of-line blocking.If you want to preserve the clean rejection behavior (avoiding accepting the channel then failing to connect), this is an acceptable tradeoff. Otherwise consider a shorter grace period or accepting optimistically and connecting in the spawned relay task.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/connection.rs` around lines 464 - 518, The `channel_open_direct_tcpip` handler is blocking the SSH connection handler while awaiting the upstream `TcpStream::connect` call with a 10-second timeout, which serializes all message processing on that connection. Either reduce the grace period by decreasing the `Duration::from_secs(10)` value to a shorter timeout, or accept the channel optimistically without waiting for the upstream connection to succeed and instead spawn the TCP connection logic in the relay task that handles the actual data forwarding.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/minimal2/src/main.rs`:
- Around line 580-582: The comment at the beginning of this ssh tunnel setup
block incorrectly states that both `-N` (no command) and `-f` (background) are
being used, but the actual command arguments only pass `-N`. Since the process
is replaced via exec(), the ssh process intentionally takes over the foreground
and `-f` should not be used. Update the comment to accurately reflect that only
`-N` is passed and clarify why the tunnel intentionally runs in the foreground
rather than as a background process.
- Around line 652-657: The client private key is being written with default
permissions (typically 0644), making the TLS private key world-readable which is
a security risk. Replace the std::fs::write call for client_key_path with an
approach that creates the file with restrictive permissions (0600 on Unix,
readable and writable only by the owner). Use std::fs::OpenOptions with
platform-specific permission settings (via std::os::unix::fs::OpenOptionsExt on
Unix systems) to set the mode to 0600 after file creation, or use an alternative
method that supports setting permissions directly during file creation.
Additionally, consider applying restrictive permissions to the directory created
at line 645 to prevent world traversal if needed.
In `@crates/minimald-rpc/src/lib.rs`:
- Around line 399-404: The documentation comment for the `IssueClientCert` RPC
method incorrectly states that DER-encoded certificate and private key are
returned, but the actual response schema fields use PEM encoding (cert_pem,
key_pem, ca_cert_pem). Update the documentation comment to accurately reflect
that PEM-encoded formats are returned instead of DER-encoded formats, ensuring
the contract documentation aligns with the actual implementation and does not
mislead integrators.
In `@crates/minimald/src/connection.rs`:
- Around line 520-529: The current implementation uses
tokio::io::copy_bidirectional which terminates both relay directions when either
the SSH channel or upstream TCP connection reaches EOF, potentially causing
half-open connections and data loss for protocols requiring graceful shutdown.
Replace the tokio::io::copy_bidirectional call in the spawned async block with
either tokio::io::copy_bidirectional_with_sizes to provide more control over
relay behavior, or implement a manual relay loop using tokio::select! that
spawns two independent copy tasks (one for each direction using tokio::io::copy)
so that an EOF from the SSH channel does not immediately close the upstream
write-half, allowing proper graceful shutdown semantics.
In `@crates/minimald/src/rpc.rs`:
- Around line 224-226: Update the documentation comment for the client
certificate signing handler (starting around line 224) to accurately reflect the
actual return type. Instead of stating the function returns DER artifacts (cert
DER, key DER, and CA cert DER), update the doc comment to indicate it returns
PEM-encoded fields from the IssueClientCertResponse struct (cert_pem, key_pem,
and ca_cert_pem). This ensures the documentation matches the actual
implementation and prevents confusion for users of the RPC handler.
---
Nitpick comments:
In `@crates/minimal2/src/main.rs`:
- Around line 39-73: The docstring for the Login command variant contains
hardcoded Linux-specific paths like ~/.config/minimal/ that do not match the
actual behavior on macOS, where dirs::config_dir() returns ~/Library/Application
Support. Update the help text and examples in the Login variant's docstring to
indicate the paths are platform-specific or use a generic term like "the
platform config directory" instead of hardcoding the Linux path, so that
documentation accurately reflects the behavior across both Linux and macOS
systems.
- Around line 556-570: The forward-spec validation block is redundant and does
not actually validate port numbers. The code splits the forward spec into three
parts and then reconstructs an identical string in forward_arg, which provides
no normalization. To fix this, parse local_port and remote_port as u16 values to
ensure they are valid numeric port numbers, returning an error with a helpful
message if parsing fails for either port. Remove the forward_arg variable
entirely since it is identical to args.forward and serves no purpose. This will
catch malformed input like abc:host:def early with a clear error message instead
of silently passing invalid input to ssh.
In `@crates/minimald/src/connection.rs`:
- Around line 464-518: The `channel_open_direct_tcpip` handler is blocking the
SSH connection handler while awaiting the upstream `TcpStream::connect` call
with a 10-second timeout, which serializes all message processing on that
connection. Either reduce the grace period by decreasing the
`Duration::from_secs(10)` value to a shorter timeout, or accept the channel
optimistically without waiting for the upstream connection to succeed and
instead spawn the TCP connection logic in the relay task that handles the actual
data forwarding.
In `@crates/minimald/src/net/mod.rs`:
- Around line 407-410: Replace all synchronous filesystem I/O operations in the
async functions `attach`, `ensure_running`, and `stop` with their asynchronous
equivalents from the `tokio::fs` module. Specifically, convert
`std::fs::create_dir_all`, `std::fs::write`, and `std::fs::remove_file` calls to
`tokio::fs::create_dir_all`, `tokio::fs::write`, and `tokio::fs::remove_file`
respectively, and await each of these async operations. If there are filesystem
operations without direct tokio equivalents, wrap them using
`tokio::task::spawn_blocking` to prevent blocking the Tokio worker threads
during attach/teardown operations.
In `@crates/minimald/src/net/proxy.rs`:
- Around line 364-391: The doc comment for the CertAuthority::generate()
function currently only describes the technical implementation details (ECDSA
P-256 key, localhost SAN) but omits documentation about the ephemeral nature of
the CA. Expand the doc comment for the generate() method to clarify that the CA
exists only for the lifetime of the daemon, is regenerated on each restart, and
that any previously-issued client certificates become invalid after a daemon
restart. This will make the MVP behavior explicit to users and developers
reading the code.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 564b5152-377a-46fa-9e21-99441b3533c0
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (12)
Cargo.tomlcrates/minimal2/src/main.rscrates/minimald-rpc/src/lib.rscrates/minimald/Cargo.tomlcrates/minimald/src/connection.rscrates/minimald/src/net/mod.rscrates/minimald/src/net/proxy.rscrates/minimald/src/rpc.rscrates/minimald/src/server.rscrates/minvmd/Cargo.tomlcrates/minvmd/src/net.rsdocs/specs/05-spec-minvmd-gvproxy-pidfd/05-spec-minvmd-gvproxy-pidfd.md
💤 Files with no reviewable changes (2)
- docs/specs/05-spec-minvmd-gvproxy-pidfd/05-spec-minvmd-gvproxy-pidfd.md
- crates/minvmd/Cargo.toml
|
Revise claim for head df072ca. |
|
Auto-revise 1 of 3. |
This comment has been minimized.
This comment has been minimized.
|
Commit pushed:
|
- Fix stale `-f` comment in ssh-forward (was never passed to ssh) - Write client.key with mode 0o600 to prevent world-readable TLS key - Fix DER→PEM in IssueClientCert doc comments (minimald-rpc, rpc.rs) - Restore NetError::InvalidPrefix for misconfigured prefix (distinct from SubnetExhausted which signals a valid but exhausted subnet) - Restore R1.4 SIGTERM behavior: re-add attached_count to GvproxySwitch and PtaskAttachment Drop impl that signals gvproxy on last PTask detach - Add R1.4 test last_ptask_detach_terminates_switch - R4.9: validate session in cmd_ssh_forward via RPC before exec()ing ssh; pass session UUID as SSH username for server-side gate - R4.9: validate session exists in channel_open_direct_tcpip via ssh_username before accepting the forward channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Addressed all review comments in the follow-up commit: HIGH — Security (private key permissions)
HIGH — Spec compliance R4.9 (session not used)
MEDIUM — Correctness (
MEDIUM — Spec compliance (R1.4 last-detach SIGTERM removed out of scope)
LOW — Stale comment (false
Minor doc fixes
All changes compiled clean against
|
There was a problem hiding this comment.
🧹 Nitpick comments (2)
crates/minimald/src/net/mod.rs (1)
443-451: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winSync
std::fscalls now run on the async runtime thread.
write_configis invoked from the asyncattach(Line 415), and the same conversion lands inensure_running(Line 488) andstop(Line 601). Replacingtokio::fswithstd::fsperformscreate_dir_all/write/remove_fileas blocking syscalls on a tokio worker thread, which can stall other tasks on that thread. For these small local files the stall is typically brief, but it is a resilience regression versus the prior non-blocking implementation. Consider reverting totokio::fsfor the async paths or wrapping the work intokio::task::spawn_blocking.Please confirm whether
attach/ensure_running/stopsit on a latency-sensitive path where blocking the executor would be observable (e.g. many concurrent attaches on a shared runtime).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/net/mod.rs` around lines 443 - 451, The write_config method uses blocking std::fs operations (create_dir_all and write) which run on the async runtime thread when called from async functions like attach, ensure_running, and stop. This can stall other tasks on the same executor thread. Either replace the std::fs::create_dir_all and std::fs::write calls in write_config with their tokio::fs equivalents and make write_config an async function, or alternatively wrap the entire std::fs work block in tokio::task::spawn_blocking to offload the blocking operations to a dedicated thread pool. Choose based on whether attach, ensure_running, and stop are on latency-sensitive paths where blocking the executor would cause observable stalls.crates/minvmd/src/net.rs (1)
95-97: 🎯 Functional Correctness | 🔵 TrivialRestore prefix validation in
SwitchSubnet::newto prevent silent MAC collisions and misconfiguration.
SwitchSubnet::newin minvmd is infallible and accepts any prefix, diverging from the sister moduleminimaldwhere it validates prefixes within8..=29viaNetError::InvalidPrefix. This creates two risks:
- MAC collisions:
MacAddr::for_switch_ipuses only octets [1], [2], [3] (line 153); a prefix wider than/8lets octet [0] vary uncovered by the derived MAC, producing silent collisions.- Insufficient subnet space: A prefix narrower than
/29leaves no room for the four reserved addresses (network, gateway, host-alias, broadcast) plus a PTask. The default/16is safe, but this public constructor no longer guards misconfiguration.Consider restoring the checked constructor from minimald (returning
Result<Self, NetError>), or documenting why minvmd intentionally diverges.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minvmd/src/net.rs` around lines 95 - 97, The `SwitchSubnet::new` constructor currently accepts any prefix value without validation, which can lead to MAC collisions when prefix is wider than /8 (since MacAddr::for_switch_ip only uses octets [1], [2], [3]) and insufficient subnet space when prefix is narrower than /29. Restore prefix validation by changing the constructor to return Result<Self, NetError> instead of Self, and add validation logic that rejects prefixes outside the range 8..=29, returning NetError::InvalidPrefix for invalid values. This aligns the implementation with the corresponding validation in the sister module minimald.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/minimald/src/net/mod.rs`:
- Around line 443-451: The write_config method uses blocking std::fs operations
(create_dir_all and write) which run on the async runtime thread when called
from async functions like attach, ensure_running, and stop. This can stall other
tasks on the same executor thread. Either replace the std::fs::create_dir_all
and std::fs::write calls in write_config with their tokio::fs equivalents and
make write_config an async function, or alternatively wrap the entire std::fs
work block in tokio::task::spawn_blocking to offload the blocking operations to
a dedicated thread pool. Choose based on whether attach, ensure_running, and
stop are on latency-sensitive paths where blocking the executor would cause
observable stalls.
In `@crates/minvmd/src/net.rs`:
- Around line 95-97: The `SwitchSubnet::new` constructor currently accepts any
prefix value without validation, which can lead to MAC collisions when prefix is
wider than /8 (since MacAddr::for_switch_ip only uses octets [1], [2], [3]) and
insufficient subnet space when prefix is narrower than /29. Restore prefix
validation by changing the constructor to return Result<Self, NetError> instead
of Self, and add validation logic that rejects prefixes outside the range
8..=29, returning NetError::InvalidPrefix for invalid values. This aligns the
implementation with the corresponding validation in the sister module minimald.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 69178938-33ef-41f5-a033-76d6b62c2f58
📒 Files selected for processing (6)
crates/minimal2/src/main.rscrates/minimald-rpc/src/lib.rscrates/minimald/src/connection.rscrates/minimald/src/net/mod.rscrates/minimald/src/rpc.rscrates/minvmd/src/net.rs
🚧 Files skipped from review as they are similar to previous changes (4)
- crates/minimald-rpc/src/lib.rs
- crates/minimald/src/connection.rs
- crates/minimald/src/rpc.rs
- crates/minimal2/src/main.rs
|
Revise claim for head d926fae. |
|
Auto-revise 2 of 3. |
|
Commit pushed:
|
|
Addressed remaining review comments in the follow-up commit: CodeRabbit (on d926fae) — sync
CodeRabbit (on d926fae) —
sdd-validate proof-artifact blocker — no unit test for
Threads not requiring code changes:
All changes compiled and tested clean against
|
Restore async filesystem I/O in minimald's gvproxy switch helpers, add SwitchSubnet::new prefix validation in minvmd, and extract the direct-tcpip relay into a testable helper with a unit test. - minimald/src/net/mod.rs: revert std::fs → tokio::fs in write_config (create_dir_all/write), ensure_running (remove_file), and stop (remove_file) — blocking I/O on async threads was a regression introduced by the previous revise pass (caught by CodeRabbit). - minvmd/src/net.rs: add assert!((8..=29).contains(&prefix)) to SwitchSubnet::new — rejects prefixes too wide for MAC uniqueness (/7 or wider) or too narrow for host-address space (/30+), mirroring the validation in minimald::SwitchSubnet::new. - minimald/src/connection.rs: extract relay_streams<A, B> generic helper from channel_open_direct_tcpip; add unit test relay_streams_forwards_bytes_bidirectionally using tokio::io::duplex to demonstrate the relay path without a live SSH stack (addresses sdd-validate proof-artifact blocker). Closes #502
|
/revise CI is red on |
|
The |
|
/revise CI still red on |
Clippy fix pushedCommit pushed: the revise passes converted the RPC dispatch match from expression-style arms ( Fix: wrapped each Commitlint — needs manual history rewriteThe To fix the commit messages, please run locally: git rebase -i main
# reword df072ca to shorten/fix the subject, e.g.:
# feat(minimald,minimal2): add HTTPS/mTLS proxy and ssh-forward CLI
git push --force-with-lease origin sdd/502-https-mtls-proxy-ssh-forward-cf7f89795ef1526bAlternatively, if you squash-merge the PR via GitHub's 'Squash and merge' button, use a title that passes commitlint — this avoids the need to rebase. Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "index.crates.io"See Network Configuration for more information. |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
crates/minimald/src/connection.rs (1)
491-554: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
direct-tcpipauthorization is fail-open and unscoped — arbitrary internal targets reachable.Two problems compound here:
Validation is skipped entirely for non-UUID / missing usernames. The session check is nested inside
if let Some(ref uname)andif let Ok(session_id) = SessionId::parse_str(uname). IfusernameisNone, or simply not a UUID, both blocks are skipped and the channel is accepted with no session check. An authenticated local client can connect with any arbitrary-l <non-uuid>and bypass the check completely. Security checks should fail closed.No binding of target to the session. Even on the happy path only session existence is verified;
host_to_connect/port_to_connectare never validated against the named session's switch address, sossh -Lcan reach any TCP endpoint visible from the daemon's network namespace (R4.9). This extends the previously flagged concern with the specific bypass mechanism above.🔒 Suggested fail-closed restructuring (target scoping still required)
- // Validate the session identified by the SSH username (R4.9). The client - // passes the session UUID as `-l <uuid>` so the server can confirm the - // session exists before accepting the forward. - if let Some(ref uname) = username { - if let Ok(session_id) = SessionId::parse_str(uname) { - let mngr = serv.sessions_manager().await; - match mngr.get_session(SessionKeyPredicate::Id(session_id)).await { - Ok(Some(_)) => {} - Ok(None) => { - tracing::warn!( - %session_id, - "direct-tcpip rejected: session not found" - ); - return Ok(false); - } - Err(e) => { - tracing::warn!( - %session_id, - error = %e, - "direct-tcpip rejected: session lookup failed" - ); - return Ok(false); - } - } - } - } + // Validate the session identified by the SSH username (R4.9). + let Some(uname) = username else { + tracing::warn!("direct-tcpip rejected: no SSH username"); + return Ok(false); + }; + let Ok(session_id) = SessionId::parse_str(&uname) else { + tracing::warn!(value = %uname, "direct-tcpip rejected: username not a uuid"); + return Ok(false); + }; + let mngr = serv.sessions_manager().await; + let session_handle = match mngr.get_session(SessionKeyPredicate::Id(session_id)).await { + Ok(Some(h)) => h, + Ok(None) => { + tracing::warn!(%session_id, "direct-tcpip rejected: session not found"); + return Ok(false); + } + Err(e) => { + tracing::warn!(%session_id, error = %e, "direct-tcpip rejected: session lookup failed"); + return Ok(false); + } + }; + // TODO(R4.9): also verify (host_to_connect, port_to_connect) is the + // switch address/port owned by `session_handle`, rejecting otherwise.Confirm there is no host/port-to-session enforcement elsewhere that this handler relies on:
#!/bin/bash # Look for any switch-address / allowed-target accessor on the session API. rg -nP --type=rust -C3 '\b(switch_addr|switch_ip|switch_socket|allowed_(host|port|target)|forward)\b' crates/minimald/src ast-grep outline crates/minimald/src/session.rs --view expanded 2>/dev/null | head -80🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/minimald/src/connection.rs` around lines 491 - 554, The direct-tcpip authorization logic has two fail-open vulnerabilities. First, the session validation inside the nested if blocks is entirely skipped when username is None or cannot be parsed as a SessionId, allowing unauthenticated bypasses. Restructure the validation so that if a username is provided, failure to parse it as a valid SessionId causes the channel to be rejected rather than proceeding to the TcpStream::connect call. Second, the code only checks that a session exists but never validates whether the host_to_connect and port_to_connect are actually authorized for that session. After successfully retrieving the session in the mngr.get_session call, add validation to ensure the target host and port match an allowed or expected address associated with that session (such as a switch_addr property) before proceeding to establish the upstream connection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@crates/minimald/src/connection.rs`:
- Around line 491-554: The direct-tcpip authorization logic has two fail-open
vulnerabilities. First, the session validation inside the nested if blocks is
entirely skipped when username is None or cannot be parsed as a SessionId,
allowing unauthenticated bypasses. Restructure the validation so that if a
username is provided, failure to parse it as a valid SessionId causes the
channel to be rejected rather than proceeding to the TcpStream::connect call.
Second, the code only checks that a session exists but never validates whether
the host_to_connect and port_to_connect are actually authorized for that
session. After successfully retrieving the session in the mngr.get_session call,
add validation to ensure the target host and port match an allowed or expected
address associated with that session (such as a switch_addr property) before
proceeding to establish the upstream connection.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c841d722-6d1a-48b8-b12b-a3ec5fa947b4
📒 Files selected for processing (3)
crates/minimald/src/connection.rscrates/minimald/src/rpc.rscrates/minvmd/src/net.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/minimald/src/rpc.rs
|
Revise claim for head b133751. |
|
Auto-revise 3 of 3. |
- Fix stale `-f` comment in ssh-forward (was never passed to ssh) - Write client.key with mode 0o600 to prevent world-readable TLS key - Fix DER→PEM in IssueClientCert doc comments (minimald-rpc, rpc.rs) - Restore NetError::InvalidPrefix for misconfigured prefix (distinct from SubnetExhausted which signals a valid but exhausted subnet) - Restore R1.4 SIGTERM behavior: re-add attached_count to GvproxySwitch and PtaskAttachment Drop impl that signals gvproxy on last PTask detach - Add R1.4 test last_ptask_detach_terminates_switch - R4.9: validate session in cmd_ssh_forward via RPC before exec()ing ssh; pass session UUID as SSH username for server-side gate - R4.9: validate session exists in channel_open_direct_tcpip via ssh_username before accepting the forward channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restore async filesystem I/O in minimald's gvproxy switch helpers, add SwitchSubnet::new prefix validation in minvmd, and extract the direct-tcpip relay into a testable helper with a unit test. - minimald/src/net/mod.rs: revert std::fs → tokio::fs in write_config (create_dir_all/write), ensure_running (remove_file), and stop (remove_file) — blocking I/O on async threads was a regression introduced by the previous revise pass (caught by CodeRabbit). - minvmd/src/net.rs: add assert!((8..=29).contains(&prefix)) to SwitchSubnet::new — rejects prefixes too wide for MAC uniqueness (/7 or wider) or too narrow for host-address space (/30+), mirroring the validation in minimald::SwitchSubnet::new. - minimald/src/connection.rs: extract relay_streams<A, B> generic helper from channel_open_direct_tcpip; add unit test relay_streams_forwards_bytes_bidirectionally using tokio::io::duplex to demonstrate the relay path without a live SSH stack (addresses sdd-validate proof-artifact blocker). Closes #502
b133751 to
4a0eeff
Compare
|
Warning The Fix: clippy warnings (auto-revise) Addressed the clippy lint failures blocking CI:
Verified locally:
|
|
Commit pushed:
|
|
Commit pushed addressing the remaining open review threads. Security fix — direct-tcpip authorization fail-closed (CodeRabbit duplicate comment / PRRT_kwDOPdfcxM6Lv65K) The nested
Only a valid, existing session UUID as the SSH Clippy fix — Replaced three Threads not requiring code changes:
Verified:
|
sdd-validate findings — implementation boundaryBoundary resolved: Implementation (all changed files are application code, not spec/architecture/spike). Gate set applied: Implementation gates (proof artifacts, changed-files scope, no credentials). Gate 1 — Proof artifacts re-executed and passingThe three CLI proof artifacts in task #502 require a live Consumer CI baseline: ⛔ Blocker — Proof artifact 2 (R4.5 auth-failure non-disclosure): no consumer gateThe PR adds Per the implementation gate: proof verified by no gate → Evidence: Fix path: add a CI job (or extend ⛔ Blocker — Proof artifact 3 (UC2b valid cert → 200): no consumer gateIdentical situation to proof artifact 2. The PR adds Evidence: same as above — i️ Info — Proof artifact 1 (R4.8/R4.9 ssh-forward CLI): infrastructure limit, relay core covered by CIThe CLI proof ( Gate 2 — Changed files within task scopeDeclared scope in task #502:
Build/dependency files (
Gate 3 — No real credentials in diff✅ Clean. All cryptographic material (CA, server cert, client certs) is generated at runtime by Summary
Blockers: 2.
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "index.crates.io"See Network Configuration for more information.
|
- Fix stale `-f` comment in ssh-forward (was never passed to ssh) - Write client.key with mode 0o600 to prevent world-readable TLS key - Fix DER→PEM in IssueClientCert doc comments (minimald-rpc, rpc.rs) - Restore NetError::InvalidPrefix for misconfigured prefix (distinct from SubnetExhausted which signals a valid but exhausted subnet) - Restore R1.4 SIGTERM behavior: re-add attached_count to GvproxySwitch and PtaskAttachment Drop impl that signals gvproxy on last PTask detach - Add R1.4 test last_ptask_detach_terminates_switch - R4.9: validate session in cmd_ssh_forward via RPC before exec()ing ssh; pass session UUID as SSH username for server-side gate - R4.9: validate session exists in channel_open_direct_tcpip via ssh_username before accepting the forward channel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restore async filesystem I/O in minimald's gvproxy switch helpers, add SwitchSubnet::new prefix validation in minvmd, and extract the direct-tcpip relay into a testable helper with a unit test. - minimald/src/net/mod.rs: revert std::fs → tokio::fs in write_config (create_dir_all/write), ensure_running (remove_file), and stop (remove_file) — blocking I/O on async threads was a regression introduced by the previous revise pass (caught by CodeRabbit). - minvmd/src/net.rs: add assert!((8..=29).contains(&prefix)) to SwitchSubnet::new — rejects prefixes too wide for MAC uniqueness (/7 or wider) or too narrow for host-address space (/30+), mirroring the validation in minimald::SwitchSubnet::new. - minimald/src/connection.rs: extract relay_streams<A, B> generic helper from channel_open_direct_tcpip; add unit test relay_streams_forwards_bytes_bidirectionally using tokio::io::duplex to demonstrate the relay path without a live SSH stack (addresses sdd-validate proof-artifact blocker). Closes #502
…rning The revise pass converted match arms from expression style (arm value = JoinHandle, discarded by outer `;`) to block style (spawn(...); inside blocks), which caused rustc to flag each spawn call with `unused_must_use` under -D warnings. Wrap each spawn call with drop() so the JoinHandle is explicitly consumed rather than silently discarded as a statement value.
The rebase onto main took main's net.rs (with the SwitchSubnetError thiserror derive) but this branch's stale minvmd/Cargo.toml predated that dependency. Restore thiserror.workspace + refresh the lock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
This branch predated the #552 pidfd spec merge, so its tree would delete docs/specs/05-spec-minvmd-gvproxy-pidfd/ on merge. Restore from main — different feature, out of #502 scope. (Same stale-base drop fixed on the sibling ingress PR.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
…other direct-tcpip: restructure the nested `if let Some(...) && let Ok(...)` session validation to fail-closed `let-else` chains. Previously a connection with no SSH username (or a non-UUID username) bypassed session validation entirely and forwarded to any reachable TCP endpoint. Now missing or non-UUID usernames are rejected with a structured tracing::warn before the TcpStream::connect is attempted. proxy.rs: replace three `io::Error::new(io::ErrorKind::Other, e)` calls in `CertAuthority::build_server_config` with `io::Error::other(e)` (clippy `io_other_error`). Semantically identical; resolves the outstanding clippy `-D warnings` failure on the `networking-proxy` feature.
The HTTPS/mTLS reverse proxy is behind the non-default networking-proxy feature, so the workspace test job never compiled or ran its proofs (R4.5 mtls_missing_cert_returns_401, UC2b mtls_valid_cert_routes_to_backend). Add an explicit step so they run as a required check (sdd-validate Gate 1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
…king-proxy When minimald is built without the networking-proxy feature, the IssueClientCert dispatch arm dropped the channel without a response, so the client saw an opaque EOF/channel-close instead of a readable "feature not enabled" message. Send an Errorable::Err over the channel before closing so the client's oneshot_rpc surfaces a clear diagnostic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
… policy imports) #554's branch carried stale-base copies of work that landed via #556: a duplicate NetError::InvalidPrefix variant and unused IngressPolicy/ SessionPolicy imports in rpc.rs. Take main's net/mod.rs and trim the imports so the rebased branch builds against the post-#556 main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
614d9a8 to
3aded33
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/minimald/src/rpc.rs`:
- Around line 236-238: The certificate issuance path is using the login username
as the SAN value, which can break `minimal login` for non-ASCII names. Update
the flow around `IssueClientCertRequest.subject_cn`, `handle_channel`, and
`sign_client_cert` so the daemon supplies a fixed ASCII SAN value for
`CertificateParams::new(...)` and sets the subject CN separately, or validates
and falls back to a safe value before calling rcgen.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1307e579-1ebd-44ac-9bc3-ecbeea4480ae
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
.github/workflows/ci.ymlCargo.tomlcrates/minimal2/src/main.rscrates/minimald-rpc/src/lib.rscrates/minimald/Cargo.tomlcrates/minimald/src/connection.rscrates/minimald/src/net/proxy.rscrates/minimald/src/rpc.rscrates/minimald/src/server.rs
🚧 Files skipped from review as they are similar to previous changes (7)
- crates/minimald/Cargo.toml
- Cargo.toml
- crates/minimald-rpc/src/lib.rs
- crates/minimald/src/connection.rs
- crates/minimald/src/server.rs
- crates/minimal2/src/main.rs
- crates/minimald/src/net/proxy.rs
…e in CN minimal2 forwards the login username (USER/LOGNAME) as subject_cn, which both sign_client_cert paths passed to CertificateParams::new() as a SAN. rcgen parses SANs as DNS names and rejects non-ASCII, so a non-ASCII username broke cert issuance / minimal login. The proxy authenticates on CA-signed cert presence (not the SAN/CN), so use a fixed ASCII SAN and carry the username in the subject CN (UTF-8-safe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143kv2BRrRqGxmVwwHskQtS
Closes #502
Summary
CertAuthority(rcgen ECDSA P-256 self-signed CA + server cert) andserve_httpstocrates/minimald/src/net/proxy.rsunder thenetworking-proxycargo feature. Uses tokio-rustls (ring backend); no OpenSSL dependency.WebPkiClientVerifier::allow_unauthenticatedallows anonymous TLS connections to complete so the HTTP layer can return401 Unauthorizedwith an empty body. No PTask hostname or switch IP appears in the error response. Auth failures emit atracing::warn!event with structured fields.IssueClientCertRPC /minimal login: new SSH RPC inminimald-rpc; handler signs a fresh client cert with the daemon's in-memory CA and returns PEM strings.minimal loginwritesclient.pem,client.key,ca.pemto~/.config/minimal/.minimal ssh-forwardand direct-tcpip:minimal ssh-forward <session> local:host:portshells out tossh -Lthrough the existing minimald ProxyCommand (SSH fallback for networks blocking WireGuard). The russh server now implementschannel_open_direct_tcpip, connecting to the target and relaying bytes bidirectionally.Scope coordination (from issue #502 comment)
serve_httpsreuses theRouterrouting core from B5 (#500) — no duplicate proxy. It wraps the existinghandle_connection_iohelper (generalized to accept anyAsyncRead + AsyncWrite + Unpin) with TLS termination.Test plan
cargo test -p minimald --features networking-proxy net::proxy::tests— 7 tests pass, including the two new mTLS proof artifacts:mtls_missing_cert_returns_401_with_no_topology(R4.5: 401 with empty body)mtls_valid_cert_routes_to_backend(UC2b: valid cert → 200 from backend)cargo test -p minimald(no feature) — 59 tests pass; all pre-existing tests unaffectedcargo clippy -p minimald -p minimald-rpc -p minimal2 --all-targets -- -D warnings— cleancargo fmtapplied🤖 Generated with [Claude Code]((claude.com/redacted)
Summary by CodeRabbit
New Features
Bug Fixes