diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 03c70f8e..ddd6845d 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -25,6 +25,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ libssl3 \ procps \ + openssh-server \ + sudo \ && rm -rf /var/lib/apt/lists/* # Copy the huskies binary and entrypoint from the main image. @@ -32,18 +34,34 @@ COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh # Non-root user — Claude Code refuses --dangerously-skip-permissions as root. +# -s /bin/bash required for SSH sessions to start a real shell. RUN groupadd -r huskies \ - && useradd -r -g huskies -m -d /home/huskies huskies \ + && useradd -r -g huskies -m -d /home/huskies -s /bin/bash huskies \ && mkdir -p /home/huskies/.claude \ + && mkdir -p /home/huskies/.ssh \ + && chmod 700 /home/huskies/.ssh \ && chown -R huskies:huskies /home/huskies \ && mkdir -p /workspace \ && chown huskies:huskies /workspace \ - && git config --global init.defaultBranch master + && git config --global init.defaultBranch master \ + && echo "huskies ALL=(root) NOPASSWD: /usr/sbin/sshd" > /etc/sudoers.d/huskies-sshd \ + && chmod 0440 /etc/sudoers.d/huskies-sshd \ + && mkdir -p /run/sshd \ + && sed -i \ + -e 's/#PasswordAuthentication yes/PasswordAuthentication no/' \ + -e 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' \ + -e 's/UsePAM yes/UsePAM no/' \ + /etc/ssh/sshd_config + +# Shell profile for SSH sessions: land in /workspace and load toolchain paths. +RUN printf 'cd /workspace\n[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"\n' \ + > /home/huskies/.profile \ + && chown huskies:huskies /home/huskies/.profile USER huskies WORKDIR /workspace -EXPOSE 3001 +EXPOSE 3001 22 ENTRYPOINT ["entrypoint.sh"] CMD ["huskies", "/workspace"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a00bd823..0d14c244 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,22 @@ #!/bin/sh set -e +# ── SSH authorized key ──────────────────────────────────────────────── +# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair. +# Write it to authorized_keys so the user can connect with the matching +# private key stored at ~/.huskies//id_ed25519 on the host. +if [ -n "$HUSKIES_SSH_PUBKEY" ]; then + mkdir -p /home/huskies/.ssh + chmod 700 /home/huskies/.ssh + printf '%s\n' "$HUSKIES_SSH_PUBKEY" > /home/huskies/.ssh/authorized_keys + chmod 600 /home/huskies/.ssh/authorized_keys +fi + +# ── SSH daemon ──────────────────────────────────────────────────────── +# Start sshd in the background so the container accepts SSH connections. +# Uses sudo (huskies has NOPASSWD for /usr/sbin/sshd in sudoers.d). +sudo /usr/sbin/sshd -D -e & + # ── Git identity ───────────────────────────────────────────────────── # Agents commit code inside the container. Without a git identity, # commits fail or use garbage defaults. Fail loudly at startup so the diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 75903572..51b8d8a2 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -1,4 +1,4 @@ -//! `new project ` chat command — Phase 2a stack-overlay framework. +//! `new project ` chat command — Phase 3: SSH-remote editor access. //! //! Provisions a project container and registers it with the gateway. //! The command is gateway-only: `new project [--stack ]`. @@ -12,6 +12,13 @@ //! Stack images follow the naming convention `huskies-project-`. //! The base image (no language tooling) is `huskies-project-base`. //! +//! Phase 3 (story 1108): an ed25519 SSH keypair is generated per project. +//! The private key is stored at `~/.huskies//id_ed25519` on the host. +//! The public key is passed to the container as `HUSKIES_SSH_PUBKEY` and +//! installed in `~/.ssh/authorized_keys` by the entrypoint. The SSH server +//! is bound to a host-local port in the 2200–2300 range and recorded in +//! `projects.toml` as `ssh_port`. +//! //! Adding a new stack requires only: //! 1. `docker/stacks//Dockerfile.fragment` — overlay instructions //! 2. `docker/stacks//markers` — detection marker filenames @@ -157,12 +164,39 @@ pub fn image_for_stack(stack: Option<&str>) -> String { } } +/// Generate an ed25519 SSH keypair at `key_path` (private) and `key_path.pub` (public). +/// +/// Calls `ssh-keygen -t ed25519 -N "" -f ` with no passphrase. +/// Returns the public key string (trimmed) on success. +async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result { + let out = tokio::process::Command::new("ssh-keygen") + .args(["-t", "ed25519", "-N", ""]) + .arg("-f") + .arg(key_path) + .output() + .await + .map_err(|e| format!("ssh-keygen not available: {e}"))?; + + if !out.status.success() { + return Err(format!( + "ssh-keygen failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let pub_path = key_path.with_extension("pub"); + tokio::fs::read_to_string(&pub_path) + .await + .map(|s| s.trim().to_string()) + .map_err(|e| format!("Cannot read public key {}: {e}", pub_path.display())) +} + /// Bootstrap a new project from the `new project [--stack ]` command. /// /// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git init`, -/// auto-detects or honours the requested stack, launches the appropriate -/// Docker container, and registers the project in both the gateway's -/// in-memory store and the CRDT. +/// auto-detects or honours the requested stack, generates an SSH keypair, +/// launches the appropriate Docker container, and registers the project in +/// both the gateway's in-memory store and the CRDT. /// /// On any failure after the host directory is created, the directory is removed /// and the error message includes "Partial state removed at ``". @@ -263,9 +297,37 @@ pub async fn handle_new_project( }; let image = image_for_stack(resolved_stack.as_deref()); - // ── Allocate port and launch container ─────────────────────────────────── + // ── Generate SSH keypair ───────────────────────────────────────────────── + // Private key: ~/.huskies//id_ed25519 (host-side, mode 600 by ssh-keygen) + // Public key: installed in the container via HUSKIES_SSH_PUBKEY env var + + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); + let ssh_key_dir = std::path::PathBuf::from(&home).join(".huskies").join(name); + if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await { + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "Failed to create SSH key directory `{}`: {e}\n\nPartial state removed at `{}`.", + ssh_key_dir.display(), + host_path.display() + ); + } + let private_key_path = ssh_key_dir.join("id_ed25519"); + let pubkey = match generate_ssh_keypair(&private_key_path).await { + Ok(k) => k, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&host_path).await; + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + return format!( + "SSH keypair generation failed: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ); + } + }; + + // ── Allocate ports and launch container ────────────────────────────────── let port = find_free_port(3100); + let ssh_port = find_free_port(2200); let container_url = format!("http://127.0.0.1:{port}"); let container_name = format!("huskies-{name}"); @@ -277,6 +339,10 @@ pub async fn handle_new_project( &container_name, "-p", &format!("127.0.0.1:{port}:3001"), + "-p", + &format!("127.0.0.1:{ssh_port}:22"), + "-e", + &format!("HUSKIES_SSH_PUBKEY={pubkey}"), "-v", &format!("{}:/workspace", host_path.display()), "--restart", @@ -296,12 +362,20 @@ pub async fn handle_new_project( // Update the in-memory projects store and persist to projects.toml. { let mut projects = projects_store.write().await; - projects.insert(name.to_string(), ProjectEntry::with_url(&container_url)); + projects.insert( + name.to_string(), + ProjectEntry { + url: Some(container_url.clone()), + auth_token: None, + ssh_port: Some(ssh_port), + }, + ); crate::service::gateway::io::save_config(&projects, config_dir).await; } crate::slog!( - "[new-project] Created project '{name}' at {container_url} (image={image})" + "[new-project] Created project '{name}' at {container_url} \ + ssh=127.0.0.1:{ssh_port} (image={image})" ); let stack_note = match resolved_stack.as_deref() { @@ -319,6 +393,8 @@ pub async fn handle_new_project( - Host path: `{host}`\n\ - Container: `{container_name}` → `{container_url}`\n\ {stack_note}\ + - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \ + -i ~/.huskies/{name}/id_ed25519`\n\ \n\ Use `switch {name}` then `status` to view the pipeline.", host = host_path.display() @@ -327,6 +403,7 @@ pub async fn handle_new_project( Ok(out) => { let stderr = String::from_utf8_lossy(&out.stderr); let _ = tokio::fs::remove_dir_all(&host_path).await; + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; format!( "Docker container launch failed: {}\n\nPartial state removed at `{}`.", stderr.trim(), @@ -335,6 +412,7 @@ pub async fn handle_new_project( } Err(e) => { let _ = tokio::fs::remove_dir_all(&host_path).await; + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; format!( "Docker container launch failed: {e}\n\nPartial state removed at `{}`.", host_path.display() @@ -535,6 +613,45 @@ mod tests { assert!(warnings.is_empty()); } + #[tokio::test] + async fn generate_ssh_keypair_creates_key_files() { + // Skip if ssh-keygen is not available in this environment. + if tokio::process::Command::new("ssh-keygen") + .arg("-V") + .output() + .await + .is_err() + { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let key_path = dir.path().join("id_ed25519"); + let pubkey = generate_ssh_keypair(&key_path).await.unwrap(); + + // Private key file must exist and be non-empty. + assert!(key_path.exists(), "private key file not created"); + // Public key is returned as a trimmed string starting with the key type. + assert!( + pubkey.starts_with("ssh-ed25519"), + "public key should start with ssh-ed25519, got: {pubkey}" + ); + // Public key file must also exist. + let pub_path = dir.path().join("id_ed25519.pub"); + assert!(pub_path.exists(), "public key file not created"); + let pub_contents = std::fs::read_to_string(&pub_path).unwrap(); + assert_eq!(pub_contents.trim(), pubkey); + } + + #[test] + fn find_free_port_returns_bindable_port() { + let port = find_free_port(2200); + // The returned port must be in range and actually bindable. + assert!((2200..2300).contains(&port)); + let listener = std::net::TcpListener::bind(("127.0.0.1", port)); + assert!(listener.is_ok(), "returned port {port} is not bindable"); + } + #[test] fn detect_stack_go_mod() { let dir = tempfile::tempdir().unwrap(); diff --git a/server/src/gateway/tests.rs b/server/src/gateway/tests.rs index 4e2c1615..1ea89c44 100644 --- a/server/src/gateway/tests.rs +++ b/server/src/gateway/tests.rs @@ -1175,6 +1175,7 @@ async fn ws_only_sled_handles_tools_list_and_tools_call() { ProjectEntry { url: None, auth_token: Some("secret".into()), + ssh_port: None, }, ); let config = GatewayConfig { @@ -1244,6 +1245,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() { ProjectEntry { url: None, auth_token: Some("alpha-tok".into()), + ssh_port: None, }, ); projects.insert( @@ -1251,6 +1253,7 @@ async fn two_concurrent_sleds_are_routed_by_active_project() { ProjectEntry { url: None, auth_token: Some("beta-tok".into()), + ssh_port: None, }, ); let config = GatewayConfig { diff --git a/server/src/service/gateway/config.rs b/server/src/service/gateway/config.rs index 996e68b9..f9665bb5 100644 --- a/server/src/service/gateway/config.rs +++ b/server/src/service/gateway/config.rs @@ -26,6 +26,13 @@ pub struct ProjectEntry { /// `[sled_tokens]` table for projects that set this field. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_token: Option, + /// Host-local port for SSH access into the project container. + /// + /// Set by `new project` (story 1108). The container's SSH server is bound + /// to `127.0.0.1::22` so the user can connect with + /// `ssh huskies@127.0.0.1 -p -i ~/.huskies//id_ed25519`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_port: Option, } impl ProjectEntry { @@ -36,6 +43,7 @@ impl ProjectEntry { Self { url: Some(url.into()), auth_token: None, + ssh_port: None, } } @@ -205,6 +213,7 @@ auth_token = "secret" ProjectEntry { url: None, auth_token: Some("secret".into()), + ssh_port: None, }, ); let config = GatewayConfig { @@ -238,6 +247,7 @@ auth_token = "secret" ProjectEntry { url: None, auth_token: Some("tok".into()), + ssh_port: None, }, ); assert_eq!(validate_project_exists(&projects, "ws").unwrap(), ""); @@ -256,6 +266,7 @@ auth_token = "secret" let e = ProjectEntry { url: None, auth_token: Some("tok".into()), + ssh_port: None, }; assert!(!e.has_url()); } @@ -297,6 +308,7 @@ auth_token = "secret" let entry = ProjectEntry { url: Some("http://a:3001".into()), auth_token: Some("mysecret".into()), + ssh_port: None, }; let mut projects = BTreeMap::new(); projects.insert("myproj".into(), entry); @@ -315,4 +327,43 @@ auth_token = "secret" Some("mysecret") ); } + + #[test] + fn roundtrip_project_entry_with_ssh_port() { + let entry = ProjectEntry { + url: Some("http://127.0.0.1:3101".into()), + auth_token: None, + ssh_port: Some(2201), + }; + let mut projects = BTreeMap::new(); + projects.insert("myproj".into(), entry); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + }; + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.projects["myproj"].ssh_port, Some(2201)); + // ssh_port must appear in the serialised TOML. + assert!( + toml_str.contains("ssh_port = 2201"), + "ssh_port missing from TOML: {toml_str}" + ); + } + + #[test] + fn ssh_port_none_is_omitted_from_toml() { + let entry = ProjectEntry::with_url("http://127.0.0.1:3101"); + let mut projects = BTreeMap::new(); + projects.insert("p".into(), entry); + let config = GatewayConfig { + projects, + sled_tokens: BTreeMap::new(), + }; + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!( + !toml_str.contains("ssh_port"), + "ssh_port should be omitted when None: {toml_str}" + ); + } } diff --git a/server/src/service/gateway/mod.rs b/server/src/service/gateway/mod.rs index 5531f2ac..de614732 100644 --- a/server/src/service/gateway/mod.rs +++ b/server/src/service/gateway/mod.rs @@ -747,6 +747,7 @@ mod tests { ProjectEntry { url: None, auth_token: Some("tok".into()), + ssh_port: None, }, ); let config = GatewayConfig { @@ -885,6 +886,7 @@ mod tests { ProjectEntry { url: Some("http://huskies:3001".into()), auth_token: Some("secret-token".into()), + ssh_port: None, }, ); let config = GatewayConfig {