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
5 changes: 4 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "devproxy", about = "Local HTTPS dev subdomains for Docker Compose")]
#[command(
name = "devproxy",
about = "Local HTTPS dev subdomains for Docker Compose"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
Expand Down
345 changes: 325 additions & 20 deletions src/commands/init.rs

Large diffs are not rendered by default.

23 changes: 14 additions & 9 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,28 +37,33 @@ pub fn run() -> Result<()> {
eprintln!("host port: {}", host_port.to_string().cyan());

// Write override file (port binding)
let override_path = config::write_override_file(compose_dir, &service_name, host_port, container_port)?;
eprintln!(
"override: {}",
override_path.display().to_string().cyan()
);
let override_path =
config::write_override_file(compose_dir, &service_name, host_port, container_port)?;
eprintln!("override: {}", override_path.display().to_string().cyan());

// Write project file (slug tracking -- used by `down` and `open`)
config::write_project_file(compose_dir, &slug)?;

// Verify daemon is running before starting containers
// Verify daemon is running before starting containers.
// Use a short timeout (2s) so we fail fast instead of hanging forever.
let socket_path = Config::socket_path()?;
if !socket_path.exists() {
// Clean up files we already wrote
let _ = std::fs::remove_file(&override_path);
let _ = std::fs::remove_file(compose_dir.join(".devproxy-project"));
bail!("daemon is not running (no socket at {}). Run `devproxy init` first.", socket_path.display());
bail!(
"daemon is not running (no socket at {}). Run `devproxy init` first.",
socket_path.display()
);
}
if std::os::unix::net::UnixStream::connect(&socket_path).is_err() {

// Send an actual IPC ping with a 2s timeout to verify the daemon is
// responsive, not just that a stale socket file exists.
if !crate::ipc::ping_sync(&socket_path, std::time::Duration::from_secs(2)) {
let _ = std::fs::remove_file(&override_path);
let _ = std::fs::remove_file(compose_dir.join(".devproxy-project"));
bail!(
"daemon is not running (could not connect to {}). Run `devproxy init` first.",
"daemon is not running (no response from {}). Run `devproxy init` first.",
socket_path.display()
);
}
Expand Down
60 changes: 45 additions & 15 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ impl Config {
Ok(Self::config_dir()?.join("tls-key.pem"))
}

pub fn pid_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join("daemon.pid"))
}

pub fn daemon_log_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join("daemon.log"))
}

pub fn save(&self) -> Result<()> {
let dir = Self::config_dir()?;
std::fs::create_dir_all(&dir)?;
Expand Down Expand Up @@ -118,9 +126,9 @@ pub fn find_devproxy_service(compose: &ComposeFile) -> Result<(String, u16)> {

for (name, svc) in &compose.services {
if let Some(port_str) = svc.labels.get("devproxy.port") {
let port: u16 = port_str
.parse()
.with_context(|| format!("invalid devproxy.port value '{port_str}' on service '{name}'"))?;
let port: u16 = port_str.parse().with_context(|| {
format!("invalid devproxy.port value '{port_str}' on service '{name}'")
})?;
found.push((name.clone(), port));
}
}
Expand All @@ -130,7 +138,11 @@ pub fn find_devproxy_service(compose: &ComposeFile) -> Result<(String, u16)> {
1 => Ok(found.into_iter().next().expect("checked len")),
_ => bail!(
"multiple services have devproxy.port labels: {}. Only one is supported.",
found.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ")
found
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
Expand All @@ -148,7 +160,12 @@ pub fn parse_compose_file(path: &Path) -> Result<ComposeFile> {
/// Searches for docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml.
/// Returns the full path.
pub fn find_compose_file(dir: &Path) -> Result<PathBuf> {
for name in &["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"] {
for name in &[
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
] {
let path = dir.join(name);
if path.exists() {
return Ok(path);
Expand All @@ -162,7 +179,12 @@ pub fn find_compose_file(dir: &Path) -> Result<PathBuf> {
/// The service name is validated to contain only alphanumeric, hyphen, and
/// underscore characters before being interpolated into YAML, preventing
/// injection of arbitrary YAML content.
pub fn write_override_file(dir: &Path, service_name: &str, host_port: u16, container_port: u16) -> Result<PathBuf> {
pub fn write_override_file(
dir: &Path,
service_name: &str,
host_port: u16,
container_port: u16,
) -> Result<PathBuf> {
// Validate service name to prevent YAML injection
if service_name.is_empty()
|| !service_name
Expand Down Expand Up @@ -194,11 +216,12 @@ pub fn write_project_file(dir: &Path, slug: &str) -> Result<PathBuf> {
/// Returns an error if the file doesn't exist (project not running via devproxy).
pub fn read_project_file(dir: &Path) -> Result<String> {
let path = dir.join(".devproxy-project");
let content = std::fs::read_to_string(&path)
.with_context(|| format!(
let content = std::fs::read_to_string(&path).with_context(|| {
format!(
"no .devproxy-project file found in {}. Is this project running via `devproxy up`?",
dir.display()
))?;
)
})?;
Ok(content.trim().to_string())
}

Expand Down Expand Up @@ -228,10 +251,7 @@ mod tests {
let config_dir = dir.path().to_path_buf();

// Write a minimal config so `status` tries to connect to the socket
std::fs::write(
config_dir.join("config.json"),
r#"{"domain":"test.dev"}"#,
).unwrap();
std::fs::write(config_dir.join("config.json"), r#"{"domain":"test.dev"}"#).unwrap();

// Find the binary
let mut bin = std::env::current_exe().unwrap();
Expand Down Expand Up @@ -332,7 +352,12 @@ services:
let dir = tempfile::tempdir().unwrap();
let result = read_project_file(dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains(".devproxy-project"));
assert!(
result
.unwrap_err()
.to_string()
.contains(".devproxy-project")
);
}

#[test]
Expand All @@ -348,6 +373,11 @@ services:
let dir = tempfile::tempdir().unwrap();
let result = find_compose_file(dir.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("no docker-compose.yml"));
assert!(
result
.unwrap_err()
.to_string()
.contains("no docker-compose.yml")
);
}
}
113 changes: 105 additions & 8 deletions src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,38 @@ pub enum Response {
Error { message: String },
}

/// Send a request to the daemon and get a response
/// Default timeout for IPC operations (connect + request + response).
const IPC_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);

/// Send a request to the daemon and get a response, with the default timeout.
pub async fn send_request(socket_path: &Path, request: &Request) -> Result<Response> {
let stream = UnixStream::connect(socket_path)
send_request_with_timeout(socket_path, request, IPC_TIMEOUT).await
}

/// Send a request to the daemon and get a response, with a custom timeout.
/// Returns an error if the daemon does not respond within the given duration.
pub async fn send_request_with_timeout(
socket_path: &Path,
request: &Request,
timeout: std::time::Duration,
) -> Result<Response> {
tokio::time::timeout(timeout, send_request_inner(socket_path, request))
.await
.with_context(|| {
format!(
"could not connect to daemon at {}. Is the daemon running? Try `devproxy init`.",
socket_path.display()
"timed out waiting for daemon response ({}s). The daemon may be dead. Try `devproxy init`.",
timeout.as_secs()
)
})?;
})?
}

async fn send_request_inner(socket_path: &Path, request: &Request) -> Result<Response> {
let stream = UnixStream::connect(socket_path).await.with_context(|| {
format!(
"could not connect to daemon at {}. Is the daemon running? Try `devproxy init`.",
socket_path.display()
)
})?;

let (reader, mut writer) = stream.into_split();

Expand All @@ -49,11 +71,42 @@ pub async fn send_request(socket_path: &Path, request: &Request) -> Result<Respo
let mut response_line = String::new();
buf_reader.read_line(&mut response_line).await?;

let response: Response = serde_json::from_str(response_line.trim())
.context("could not parse daemon response")?;
let response: Response =
serde_json::from_str(response_line.trim()).context("could not parse daemon response")?;
Ok(response)
}

/// Send a synchronous IPC Ping to the daemon and return true if it responds
/// with Pong within the given timeout. This is used by non-async code (init,
/// up) that needs to verify the daemon is alive without a tokio runtime.
///
/// The wire format matches the async `send_request` path: a JSON-line
/// `{"cmd":"ping"}` followed by shutdown(Write), then read a JSON-line
/// response containing `"pong"`.
pub fn ping_sync(socket_path: &Path, timeout: std::time::Duration) -> bool {
use std::io::{BufRead, Write};

let stream = match std::os::unix::net::UnixStream::connect(socket_path) {
Ok(s) => s,
Err(_) => return false,
};
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();

let mut writer = match stream.try_clone() {
Ok(w) => w,
Err(_) => return false,
};
if writer.write_all(b"{\"cmd\":\"ping\"}\n").is_err() {
return false;
}
writer.shutdown(std::net::Shutdown::Write).ok();

let mut reader = std::io::BufReader::new(&stream);
let mut response = String::new();
reader.read_line(&mut response).is_ok() && response.contains("pong")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -81,7 +134,8 @@ mod tests {

#[test]
fn deserialize_routes_response() {
let json = r#"{"status":"routes","routes":[{"slug":"swift-penguin.mysite.dev","port":51234}]}"#;
let json =
r#"{"status":"routes","routes":[{"slug":"swift-penguin.mysite.dev","port":51234}]}"#;
let resp: Response = serde_json::from_str(json).unwrap();
match resp {
Response::Routes { routes } => {
Expand All @@ -92,4 +146,47 @@ mod tests {
_ => panic!("expected Routes response"),
}
}

#[test]
fn ping_sync_returns_false_on_nonexistent_socket() {
let dir = tempfile::tempdir().unwrap();
let sock_path = dir.path().join("nonexistent.sock");
assert!(!ping_sync(
&sock_path,
std::time::Duration::from_millis(100)
));
}

/// Verify that send_request_with_timeout returns an error when the
/// daemon doesn't respond within the timeout (e.g., socket exists
/// but nothing reads from it).
#[tokio::test]
async fn send_request_timeout_on_unresponsive_socket() {
let dir = tempfile::tempdir().unwrap();
let sock_path = dir.path().join("test.sock");

// Create a listener but never accept connections -- simulates
// a hung daemon that is listening but not processing requests.
let _listener = tokio::net::UnixListener::bind(&sock_path).unwrap();

let start = std::time::Instant::now();
let result = send_request_with_timeout(
&sock_path,
&Request::Ping,
std::time::Duration::from_millis(500),
)
.await;
let elapsed = start.elapsed();

assert!(result.is_err(), "should error on unresponsive socket");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("timed out"),
"error should mention timeout: {err_msg}"
);
assert!(
elapsed < std::time::Duration::from_secs(2),
"should not wait too long (took {elapsed:?})"
);
}
}
6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();

match cli.command {
Commands::Init { domain, port, no_daemon } => commands::init::run(&domain, port, no_daemon),
Commands::Init {
domain,
port,
no_daemon,
} => commands::init::run(&domain, port, no_daemon),
Commands::Up => commands::up::run(),
Commands::Down => commands::down::run(),
Commands::Ls => commands::ls::run().await,
Expand Down
21 changes: 13 additions & 8 deletions src/proxy/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ pub fn generate_ca() -> Result<(String, String)> {
params
.distinguished_name
.push(DnType::OrganizationName, "devproxy");
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
];
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.not_before = time::OffsetDateTime::now_utc() - Duration::from_secs(3600);
params.not_after = time::OffsetDateTime::now_utc() + Duration::from_secs(365 * 24 * 3600 * 10);

Expand All @@ -40,14 +37,20 @@ pub fn generate_wildcard_cert(
let ca_key = KeyPair::from_pem(ca_key_pem).context("failed to parse CA key")?;
let ca_params = CertificateParams::from_ca_cert_pem(ca_cert_pem)
.context("failed to parse CA cert params")?;
let ca_cert = ca_params.self_signed(&ca_key).context("failed to reconstruct CA cert")?;
let ca_cert = ca_params
.self_signed(&ca_key)
.context("failed to reconstruct CA cert")?;

let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, format!("*.{domain}"));
params.subject_alt_names = vec![
SanType::DnsName(format!("*.{domain}").try_into().context("invalid wildcard DNS name")?),
SanType::DnsName(
format!("*.{domain}")
.try_into()
.context("invalid wildcard DNS name")?,
),
SanType::DnsName(domain.to_string().try_into().context("invalid DNS name")?),
];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
Expand Down Expand Up @@ -140,8 +143,10 @@ pub fn trust_ca_in_system(ca_cert_path: &Path) -> Result<()> {
.args([
"add-trusted-cert",
"-d",
"-r", "trustRoot",
"-k", "/Library/Keychains/System.keychain",
"-r",
"trustRoot",
"-k",
"/Library/Keychains/System.keychain",
])
.arg(ca_cert_path)
.status()
Expand Down
5 changes: 4 additions & 1 deletion src/proxy/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,10 @@ async fn watch_events_inner(router: &Router) -> Result<()> {
.spawn()
.context("failed to spawn docker events")?;

let stdout = child.stdout.take().context("no stdout from docker events")?;
let stdout = child
.stdout
.take()
.context("no stdout from docker events")?;
let reader = BufReader::new(stdout);
let mut lines = reader.lines();

Expand Down
Loading