Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ file tracks notable changes since the move to the monorepo.
StartOS now measures the port from the Internet in that case and reports
what it finds.

- **Client connections through StartOS's TLS-terminating reverse proxy now fail
within 15 seconds if StartOS cannot connect to the service or complete a
required TLS handshake with it.**

- **Transfers preserve the source filesystem format.** StartOS mounts source
filesystems read-only while copying persistent data, repairing ext4 only when
needed to mount it. This leaves the source drive available as a fallback.
Expand Down
3 changes: 3 additions & 0 deletions projects/start-os/docs/src/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Each table has the following columns:
> [!NOTE]
> The port-forwarding and firewall tests need the service **running** only for an address it serves directly — a raw public IP, or another non-SSL binding. StartOS SSL-terminates every HTTP interface behind its always-on reverse proxy, so those stay testable even while the service is stopped (as do DNS tests). A non-SSL address's Test buttons are therefore disabled while its service is stopped; and because that service often restarts when a domain is added or an address is enabled, StartOS then shows its reachability tests as untested (not failed) and still opens the setup modal, so you can set up forwarding and re-test once it is running.

> [!NOTE]
> For an interface whose TLS StartOS terminates, StartOS allows up to 15 seconds to connect to the service and complete any required TLS handshake with it. If that connection is not ready in time, the client connection ends.

> [!NOTE]
> Unlike a private LAN address, an IPv6 **global-unicast address (GUA)** is a single globally-routable address, so how far it reaches is a choice. A GUA row keeps the usual on/off toggle, and its **Access** column becomes a **Local / Public** dropdown:
>
Expand Down
1 change: 1 addition & 0 deletions shared-libs/crates/start-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ serde_toml = { package = "toml", version = "0.9" }

[dev-dependencies]
clap_mangen = "0.2.33"
tokio = { version = "1.38.1", features = ["test-util"] }

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.18.0"
Expand Down
80 changes: 69 additions & 11 deletions shared-libs/crates/start-core/src/net/vhost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ fn compute_bind_reqs<A: Accept + 'static>(mapping: &Mapping<A>) -> VHostBindRequ
/// still held by a torn-down listener — instead of latching the hole until the
/// next network change.
const BIND_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const BACKEND_DIAL_TIMEOUT: Duration = Duration::from_secs(15);

/// Listener that manages its own TCP listeners with IP-level precision.
/// Binds ALL IPs of public gateways and ONLY matching private IPs.
Expand Down Expand Up @@ -1254,23 +1255,31 @@ where
) -> Option<(ServerConfig, Self::PreprocessRes)> {
let peer = extract::<TcpMetadata, _>(metadata).map(|m| m.peer_addr);
let plain_connect = || async {
TcpStream::connect(self.addr)
let deadline = tokio::time::Instant::now() + BACKEND_DIAL_TIMEOUT;
let stream = tokio::time::timeout_at(deadline, TcpStream::connect(self.addr))
.await
.with_ctx(|_| (ErrorKind::Network, self.addr))
.log_err()
.log_err()?
.with_ctx(|_| (ErrorKind::Network, self.addr))
.log_err()?;
Comment on lines +1258 to +1264

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just tokio::time::timeout??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason for the wrapper. I removed it and now use Tokio’s timeout API directly at both awaits. I kept timeout_at rather than two independent timeout calls so the ordinary TCP connect and optional TLS handshake share one 15-second budget instead of each receiving 15 seconds. Rebased and re-ran the ALPN tests plus the start-core all-targets check in ab81885.

Some((stream, deadline, self.addr))
};
// Source-preserving passthrough (container): open the internal leg from
// the client's own address so the backend sees the real peer (RFC §4.6).
// The box gateways the container, so replies transit it and the divert
// routes them back. Manual LAN passthroughs and terminating targets
// connect plainly — the box isn't their gateway.
let tcp_stream = match self.transparent_leg(peer) {
let (tcp_stream, deadline, backend_addr) = match self.transparent_leg(peer) {
Some((client, target)) => {
crate::net::transparent::ensure_divert_infra_once()
.await
.log_err();
match crate::net::transparent::transparent_connect(client, target).await {
Ok(stream) => stream,
Ok(stream) => (
stream,
tokio::time::Instant::now() + BACKEND_DIAL_TIMEOUT,
target,
),
// Degraded, not fatal: the backend sees this host rather than
// the client. Better than dropping a working connection.
Err(e) => {
Expand Down Expand Up @@ -1329,12 +1338,17 @@ where
Some(client_cfg) => {
// Called even for an empty list: without it the connector falls
// back to `client_cfg`'s own protocols.
let target_stream = TlsConnector::from(client_cfg.clone())
.with_alpn(dialled)
.connect(ServerName::IpAddress(self.addr.ip().into()), tcp_stream)
.await
.with_ctx(|_| (ErrorKind::Network, self.addr))
.log_err()?;
let target_stream = tokio::time::timeout_at(
deadline,
TlsConnector::from(client_cfg.clone())
.with_alpn(dialled)
.connect(ServerName::IpAddress(self.addr.ip().into()), tcp_stream),
)
.await
.with_ctx(|_| (ErrorKind::Network, backend_addr))
.log_err()?
.with_ctx(|_| (ErrorKind::Network, backend_addr))
.log_err()?;
let negotiated = target_stream
.get_ref()
.1
Expand Down Expand Up @@ -2888,7 +2902,6 @@ mod upstream_alpn_tests {
let mut client = client_config_no_verify(provider()).unwrap();
client.alpn_protocols = client_alpn.iter().map(|a| a.as_bytes().to_vec()).collect();
let tcp = TcpStream::connect(addr).await.unwrap();
// Neither the client's connect nor `get_config` is bounded.
let handshake = tokio::time::timeout(
Duration::from_secs(10),
TlsConnector::from(Arc::new(client))
Expand Down Expand Up @@ -2967,6 +2980,51 @@ mod upstream_alpn_tests {
.expect_err("the listener cannot serve a client the backend refused");
}

#[tokio::test(start_paused = true)]
async fn a_backend_stalling_its_tls_handshake_declines_the_connection() {
let backend = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (socket, _) = backend.accept().await.unwrap();
let _ = accepted_tx.send(());
std::future::pending::<()>().await;
drop(socket);
});

let handler = Preprocessing {
target: target(backend_addr, rewrap(), None),
base_alpn: Vec::new(),
probe: b"",
};
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
let listener_addr = listener.local_addr().unwrap();
let mut vhost = TlsListener::new(listener, handler);
tokio::spawn(async move {
let _ = futures::future::poll_fn(|cx| vhost.poll_accept(cx)).await;
});

assert_eq!(BACKEND_DIAL_TIMEOUT, Duration::from_secs(15));
let mut client = tokio::spawn(async move {
let tcp = TcpStream::connect(listener_addr).await.unwrap();
TlsConnector::from(Arc::new(client_config_no_verify(provider()).unwrap()))
.connect(ServerName::IpAddress(Ipv4Addr::LOCALHOST.into()), tcp)
.await
});
tokio::select! {
_ = &mut client => panic!("the client failed before the backend accepted TCP"),
accepted = accepted_rx => accepted.expect("the backend accepts TCP"),
}
let handshake = tokio::time::timeout(Duration::from_secs(20), client)
.await
.expect("the client handshake settles before the guard")
.expect("the client task runs to completion");
assert!(
handshake.is_err(),
"the listener declines a backend that stalls its TLS handshake"
);
}

/// The vhost reuses a live target whose config compares equal, so `alpn`
/// has to be part of a target's identity.
#[test]
Expand Down