diff --git a/dstack/gateway/dstack-app/builder/entrypoint.sh b/dstack/gateway/dstack-app/builder/entrypoint.sh index 8125cc163..42b1da08a 100755 --- a/dstack/gateway/dstack-app/builder/entrypoint.sh +++ b/dstack/gateway/dstack-app/builder/entrypoint.sh @@ -114,7 +114,6 @@ tls_versions = ["1.2"] listen_addr = "0.0.0.0" listen_port = "${PROXY_LISTEN_PORT:-443}" connect_top_n = 3 -localhost_enabled = false app_address_ns_compat = true workers = ${PROXY_WORKERS:-32} max_connections_per_app = ${MAX_CONNECTIONS_PER_APP:-0} diff --git a/dstack/gateway/gateway.toml b/dstack/gateway/gateway.toml index a93bff51b..12e594ffc 100644 --- a/dstack/gateway/gateway.toml +++ b/dstack/gateway/gateway.toml @@ -56,6 +56,11 @@ insecure_no_auth = false [core.debug] insecure_enable_debug_rpc = false +# Route the app address "localhost" to 127.0.0.1 on the gateway host. Off by +# default: the app address also comes from the _dstack-app-address TXT record of +# arbitrary custom domains, so enabling this lets any DNS zone owner reach the +# gateway's own loopback on a port of their choosing, bypassing port_policy. +insecure_localhost_backend = false insecure_skip_attestation = false key_file = "debug_key.json" address = "127.0.0.1:8012" @@ -80,7 +85,6 @@ agent_port = 8090 buffer_size = 65536 # number of hosts to try to connect to connect_top_n = 3 -localhost_enabled = false app_address_ns_prefix = "_dstack-app-address" app_address_ns_compat = true workers = 32 diff --git a/dstack/gateway/src/config.rs b/dstack/gateway/src/config.rs index 94f37c8f2..32aa61c0c 100644 --- a/dstack/gateway/src/config.rs +++ b/dstack/gateway/src/config.rs @@ -129,7 +129,6 @@ pub struct ProxyConfig { /// 1 MiB pipe fed without the syscall rate 8 KiB imposed. pub buffer_size: usize, pub connect_top_n: usize, - pub localhost_enabled: bool, pub workers: usize, /// Run one single-threaded runtime per worker, each with its own /// `SO_REUSEPORT` listener, instead of one accept thread feeding a shared @@ -522,6 +521,19 @@ pub struct DebugConfig { pub insecure_enable_debug_rpc: bool, #[serde(default)] pub insecure_skip_attestation: bool, + /// Let the app-address `localhost` resolve to 127.0.0.1, so a hostname can + /// be routed to a service on the gateway host itself. + /// + /// This lives under `debug` and carries the `insecure_` prefix because the + /// app address is not only read from the platform's own `.` + /// grammar: it also comes from the `_dstack-app-address` TXT record of an + /// arbitrary custom domain. With this on, anyone who controls any DNS zone + /// can point the gateway at its own loopback -- where the admin and debug + /// listeners bind precisely because being unreachable is their access + /// control -- and pick the port, since the `localhost` shortcut is not a + /// registered instance and so bypasses `port_policy` entirely. + #[serde(default)] + pub insecure_localhost_backend: bool, /// Path to pre-generated debug key data file (JSON format containing key, quote, event_log, and vm_config) #[serde(default)] pub key_file: String, diff --git a/dstack/gateway/src/main.rs b/dstack/gateway/src/main.rs index eb0eecec9..c8c66ca4f 100644 --- a/dstack/gateway/src/main.rs +++ b/dstack/gateway/src/main.rs @@ -21,7 +21,7 @@ use rocket::{ }; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tracing::info; +use tracing::{info, warn}; use admin_service::AdminRpcHandler; use main_service::{Proxy, ProxyOptions, RpcHandler}; @@ -245,6 +245,15 @@ async fn main() -> Result<()> { if config.sync.enabled && config.sync.node_id == 0 { anyhow::bail!("node_id must be greater than 0"); } + if config.debug.insecure_localhost_backend { + warn!( + "core.debug.insecure_localhost_backend = true; the app address \"localhost\" now \ + resolves to 127.0.0.1 on this host. App addresses also come from the \ + _dstack-app-address TXT record of arbitrary custom domains, so any DNS zone owner \ + can reach this host's loopback on a port of their choosing, bypassing port_policy. \ + Never use this outside local development" + ); + } // Before anything reads `proxy.ktls`: the acceptor built later decides // whether to extract session secrets from it. proxy::disable_ktls_if_unsupported(&mut config.proxy); diff --git a/dstack/gateway/src/main_service.rs b/dstack/gateway/src/main_service.rs index c44a19e9a..b8ed0d1d7 100644 --- a/dstack/gateway/src/main_service.rs +++ b/dstack/gateway/src/main_service.rs @@ -1143,7 +1143,7 @@ impl ProxyState { } pub(crate) fn select_top_n_hosts(&mut self, id: &str) -> Result { - if self.config.proxy.localhost_enabled && id == "localhost" { + if self.config.debug.insecure_localhost_backend && id == "localhost" { return Ok(smallvec![AddressInfo { ip: Ipv4Addr::new(127, 0, 0, 1), counter: Default::default(), diff --git a/dstack/gateway/test-run/proxy/gwconfig.py b/dstack/gateway/test-run/proxy/gwconfig.py index fe4f52b56..44a230e4c 100755 --- a/dstack/gateway/test-run/proxy/gwconfig.py +++ b/dstack/gateway/test-run/proxy/gwconfig.py @@ -58,6 +58,7 @@ def main(): rpc_domain = "" set_ulimit = false [core.debug] +insecure_localhost_backend = true insecure_skip_attestation = true insecure_enable_debug_rpc = false [core.admin] @@ -81,7 +82,6 @@ def main(): [core.proxy] listen_addr = "127.0.0.1" listen_port = {o["proxy_port"]} -localhost_enabled = true base_domain = "{o["base_domain"]}" cert_chain = "{cert}" cert_key = "{key}" diff --git a/dstack/gateway/test-run/test_proxy.sh b/dstack/gateway/test-run/test_proxy.sh index 18944a30a..76d1ba929 100755 --- a/dstack/gateway/test-run/test_proxy.sh +++ b/dstack/gateway/test-run/test_proxy.sh @@ -35,8 +35,8 @@ ORIGIN_PLAIN=$((BASE_PORT + 4)) ORIGIN_TLS=$((BASE_PORT + 5)) BASE_DOMAIN="gwtest.local" -# `localhost_enabled` routes `localhost-[s]` to 127.0.0.1:, which is -# what lets these tests run without registering a CVM. +# `insecure_localhost_backend` routes `localhost-[s]` to 127.0.0.1:, +# which is what lets these tests run without registering a CVM. SNI_TERMINATE="localhost-$ORIGIN_PLAIN.$BASE_DOMAIN" SNI_PASSTHROUGH="localhost-${ORIGIN_TLS}s.$BASE_DOMAIN" ADMIN_TOKEN="proxy-integration-test"