From 1f01926c18301bedde7c5c1b9144c6258e1c5352 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 29 Sep 2026 17:26:37 -0500 Subject: [PATCH 1/3] feat: run serve as a headless tunnel endpoint Datum Compute wants to run `datum-connect serve` in a container as a credential-less tunnel endpoint. As PID 1 it never received Ctrl-C, so a pod stop hung until SIGKILL and relays kept routing to a dead endpoint; its key also had to be copied into the writable repo, and adding the proxy needed a separate `add tcp-proxy` step before `serve`. Key changes: - Exit on SIGTERM as well as SIGINT and shut down the router, closing the endpoint so relays drop it at once - Add --listen-key-file (DATUM_CONNECT_LISTEN_KEY_FILE) to read the 32-byte identity key from a read-only path, such as a mounted Secret - Add --tcp-proxy (DATUM_CONNECT_TCP_PROXIES) to enable a proxy at startup; repeated starts reuse the existing entry instead of duplicating it - Default CLI logging to info with RUST_LOG overrides, matching the desktop app --- cli/src/main.rs | 69 +++++++++++++++++++++++++++++++++++++++++++----- lib/src/node.rs | 14 ++++++++-- lib/src/repo.rs | 16 ++++++++--- lib/src/state.rs | 23 ++++++++++++++++ 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 4377cae..a200061 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -9,7 +9,7 @@ use lib::{ }; use std::{net::SocketAddr, path::PathBuf}; use tracing::info; -use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, prelude::*}; /// Datum Connect Agent #[derive(Parser, Debug)] @@ -23,7 +23,7 @@ struct Args { #[derive(Subcommand, Debug)] enum Commands { /// Start a tunnel server that exposes configured local services through the Datum gateway. - Serve, + Serve(ServeArgs), /// Join a proxy, i.e. connect to the proxy and expose the service locally. Connect(ConnectArgs), @@ -117,6 +117,24 @@ pub struct TunnelDevArgs { pub target_protocol: String, } +#[derive(Parser, Debug)] +pub struct ServeArgs { + /// Read the endpoint identity from this file (a raw 32-byte ed25519 secret key) + /// instead of the repo's listen key. The file is only read, never created. + #[clap(long, env = "DATUM_CONNECT_LISTEN_KEY_FILE")] + pub listen_key_file: Option, + + /// Expose a TCP service at HOST:PORT before serving, adding it if not configured yet. + /// + /// Repeatable; the environment variable takes a comma-separated list. + #[clap( + long = "tcp-proxy", + env = "DATUM_CONNECT_TCP_PROXIES", + value_delimiter = ',' + )] + pub tcp_proxies: Vec, +} + #[derive(Parser, Debug)] pub struct ConnectArgs { /// The addresses to listen on for incoming tcp connections. @@ -164,8 +182,9 @@ async fn main() -> n0_error::Result<()> { } }); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().with_filter(filter)) .with(sentry_layer) .init(); @@ -218,8 +237,29 @@ async fn main() -> n0_error::Result<()> { .await?; println!("OK."); } - Commands::Serve => { - let node = ListenNode::new(repo).await?; + Commands::Serve(args) => { + if !args.tcp_proxies.is_empty() { + let services = args + .tcp_proxies + .iter() + .map(|host| TcpProxyData::from_host_port_str(host)) + .collect::, _>>()?; + let state = repo.load_state().await?; + state + .update(&repo, |state| { + for service in services { + state.ensure_tcp_proxy(service, None); + } + }) + .await?; + } + let node = match args.listen_key_file { + Some(path) => { + let secret_key = Repo::read_secret_key(&path).await?; + ListenNode::with_secret_key(repo, secret_key).await? + } + None => ListenNode::new(repo).await?, + }; let endpoint_id = node.endpoint_id(); println!("listening as {}", endpoint_id); let bound_addrs = node.endpoint().bound_sockets(); @@ -253,7 +293,8 @@ async fn main() -> n0_error::Result<()> { p.info.resource_id, p.info.data.host, p.info.data.port ) } - tokio::signal::ctrl_c().await?; + shutdown_signal().await?; + node.shutdown().await; println!() } Commands::Connect(args) => { @@ -270,7 +311,7 @@ async fn main() -> n0_error::Result<()> { handle.advertisment().host, handle.advertisment().port, ); - tokio::signal::ctrl_c().await?; + shutdown_signal().await?; handle.abort(); } Commands::DnsDev(args) => match args { @@ -299,3 +340,17 @@ async fn main() -> n0_error::Result<()> { } Ok(()) } + +async fn shutdown_signal() -> std::io::Result<()> { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { + res = tokio::signal::ctrl_c() => res, + _ = terminate.recv() => Ok(()), + } + } + #[cfg(not(unix))] + tokio::signal::ctrl_c().await +} diff --git a/lib/src/node.rs b/lib/src/node.rs index 5de8b53..d571d27 100644 --- a/lib/src/node.rs +++ b/lib/src/node.rs @@ -59,10 +59,14 @@ pub struct ListenNode { } impl ListenNode { - #[instrument("listen-node", skip_all)] pub async fn new(repo: Repo) -> Result { - let config = repo.config().await?; let secret_key = repo.listen_key().await?; + Self::with_secret_key(repo, secret_key).await + } + + #[instrument("listen-node", skip_all)] + pub async fn with_secret_key(repo: Repo, secret_key: SecretKey) -> Result { + let config = repo.config().await?; let endpoint = build_endpoint(secret_key, &config).await?; let state = repo.load_state().await?; @@ -171,6 +175,12 @@ impl ListenNode { pub fn endpoint_id(&self) -> EndpointId { self.router.endpoint().id() } + + pub async fn shutdown(&self) { + if let Err(err) = self.router.shutdown().await { + warn!("Listen node shutdown did not finish cleanly: {err:#}"); + } + } } impl StateWrapper { diff --git a/lib/src/repo.rs b/lib/src/repo.rs index 1f960e7..ed3183e 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use iroh::SecretKey; use log::{info, warn}; @@ -123,8 +123,18 @@ impl Repo { return self.create_key(&key_file_path).await; }; - let key = tokio::fs::read(key_file_path).await?; - let key = key.as_slice().try_into().anyerr()?; + Self::read_secret_key(key_file_path).await + } + + /// Reads a raw 32-byte ed25519 secret key from a file. + pub async fn read_secret_key(path: impl AsRef) -> Result { + let path = path.as_ref(); + let key = tokio::fs::read(path) + .await + .with_context(|_| format!("failed to read secret key {}", path.display()))?; + let key = key.as_slice().try_into().with_std_context(|_| { + format!("secret key {} must be exactly 32 bytes", path.display()) + })?; Ok(SecretKey::from_bytes(key)) } diff --git a/lib/src/state.rs b/lib/src/state.rs index 0d47ca2..74ea72c 100644 --- a/lib/src/state.rs +++ b/lib/src/state.rs @@ -29,6 +29,16 @@ impl State { } } + /// Enables the proxy for `data`, adding it if no proxy targets that host and port yet. + pub fn ensure_tcp_proxy(&mut self, data: TcpProxyData, label: Option) { + if let Some(existing) = self.proxies.iter_mut().find(|p| p.info.data == data) { + existing.enabled = true; + } else { + self.proxies + .push(ProxyState::new(Advertisment::new(data, label))); + } + } + pub fn remove_proxy(&mut self, resouce_id: &str) -> Option { if let Some(idx) = self .proxies @@ -296,6 +306,19 @@ mod tests { assert!(err.to_string().contains("invalid port")); } + #[test] + fn ensure_tcp_proxy_is_idempotent() { + let data = TcpProxyData::from_host_port_str("agent.test:7777").unwrap(); + let mut state = State::default(); + state.ensure_tcp_proxy(data.clone(), Some("exec".to_string())); + state.proxies[0].enabled = false; + state.ensure_tcp_proxy(data.clone(), None); + assert_eq!(state.proxies.len(), 1); + assert!(state.proxies[0].enabled); + assert_eq!(state.proxies[0].info.data, data); + assert_eq!(state.proxies[0].info.label.as_deref(), Some("exec")); + } + // #[test] // fn test_tcp_proxy_has_codename() { // let proxy = TcpProxy::new("127.0.0.1".to_string(), 8080); From 95cfb3da6aed958026b9caa1a0737b80b0ce03ca Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 29 Sep 2026 17:26:37 -0500 Subject: [PATCH 2/3] feat: publish the CLI as the datum-connect image The repo had no container image for the CLI after the stale gateway image was removed in #185. Compute cells need one to run `serve` as a tunnel endpoint. Key changes: - Add a Dockerfile that builds only the CLI and runs `serve` as the non-root user 65532, with its repo at /var/lib/datum-connect - Publish ghcr.io/datum-cloud/datum-connect: on release tag pushes and from the manual release, whose GITHUB_TOKEN-created tag does not trigger the tag push workflow - Build the image without pushing on PRs that touch the CLI or lib The image never uses the iroh-gateway name, which datum-cloud/iroh-gateway publishes on its own. --- .dockerignore | 13 +++++++ .github/workflows/cli-image.yml | 52 ++++++++++++++++++++++++++++ .github/workflows/manual-release.yml | 11 ++++++ Dockerfile | 27 +++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/cli-image.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d3ca56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.claude +.vscode +target +.env +n0des-local +ui/node_modules +ui/target +ui/dist +ui/.dioxus +**/*.log +**/*.tmp diff --git a/.github/workflows/cli-image.yml b/.github/workflows/cli-image.yml new file mode 100644 index 0000000..e0ee85b --- /dev/null +++ b/.github/workflows/cli-image.yml @@ -0,0 +1,52 @@ +name: cli-image + +on: + push: + tags: + - "v*" + pull_request: + branches: [main] + paths: + - "**/Cargo.toml" + - "Cargo.lock" + - "cli/**" + - "lib/**" + - "Dockerfile" + - ".dockerignore" + - ".github/workflows/cli-image.yml" + workflow_call: + inputs: + version: + description: "Release tag to publish the image under" + required: true + type: string + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push datum-connect image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ github.event_name != 'pull_request' && format('ghcr.io/datum-cloud/datum-connect:{0}', inputs.version || github.ref_name) || '' }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/manual-release.yml b/.github/workflows/manual-release.yml index 752d33d..4bd5858 100644 --- a/.github/workflows/manual-release.yml +++ b/.github/workflows/manual-release.yml @@ -421,3 +421,14 @@ jobs: artifacts/**/ui/dist/*.dmg artifacts/**/ui/dist/*.AppImage artifacts/**/ui/dist/*.exe + + # The release tag is created with GITHUB_TOKEN, which does not trigger the + # tag push that publishes the image in cli-image.yml. + image: + needs: [release] + permissions: + contents: read + packages: write + uses: ./.github/workflows/cli-image.yml + with: + version: ${{ inputs.version }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a94248 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM rust:1.98-bookworm AS builder + +WORKDIR /src + +COPY . . + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + cargo build --locked --release -p datum-connect \ + && cp target/release/datum-connect /usr/local/bin/datum-connect + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -u 65532 -r -s /usr/sbin/nologin datum \ + && install -d -o 65532 -g 65532 /var/lib/datum-connect + +COPY --from=builder /usr/local/bin/datum-connect /usr/local/bin/datum-connect + +ENV DATUM_CONNECT_REPO=/var/lib/datum-connect + +USER 65532:65532 + +ENTRYPOINT ["/usr/local/bin/datum-connect"] +CMD ["serve"] From c4be6873c677526ecf06375d3f711fac8f2f03f9 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 29 Sep 2026 20:09:43 -0500 Subject: [PATCH 3/3] fix: harden the CLI image and build it for arm64 The CLI now runs as the internet-facing tunnel endpoint for shell sessions, so it should not attach default PII to Sentry events, and cells on arm64 need an image too. Key changes: - Set send_default_pii to false for the CLI; it never signs a user in, and no build bakes a Sentry DSN into it today - Build linux/amd64 and linux/arm64 by cross-compiling on the build platform rather than compiling Rust under QEMU; QEMU only runs the runtime stage's package install - Stamp the release tag into the CLI and lib versions, so the binary reports its real version instead of 0.1.0 --- .github/workflows/cli-image.yml | 6 +++++ Dockerfile | 41 +++++++++++++++++++++++++++++---- cli/src/main.rs | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-image.yml b/.github/workflows/cli-image.yml index e0ee85b..6838413 100644 --- a/.github/workflows/cli-image.yml +++ b/.github/workflows/cli-image.yml @@ -30,6 +30,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -46,7 +49,10 @@ jobs: with: context: . file: Dockerfile + platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} + build-args: | + VERSION=${{ github.event_name != 'pull_request' && (inputs.version || github.ref_name) || '' }} tags: ${{ github.event_name != 'pull_request' && format('ghcr.io/datum-cloud/datum-connect:{0}', inputs.version || github.ref_name) || '' }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 4a94248..b18d0bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,46 @@ -FROM rust:1.98-bookworm AS builder +FROM --platform=$BUILDPLATFORM rust:1.98-bookworm AS builder + +ARG BUILDARCH +ARG TARGETARCH + +RUN case "$TARGETARCH" in \ + amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \ + arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \ + *) echo "unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && rustup target add "$(cat /rust-target)" \ + && if [ "$TARGETARCH" != "$BUILDARCH" ]; then \ + dpkg --add-architecture "$TARGETARCH" \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + "crossbuild-essential-$TARGETARCH" "libssl-dev:$TARGETARCH" \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ + AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/aarch64-linux-gnu \ + AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ + CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ + X86_64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR=/usr/lib/x86_64-linux-gnu \ + X86_64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR=/usr/include WORKDIR /src COPY . . +ARG VERSION + RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/src/target \ - cargo build --locked --release -p datum-connect \ - && cp target/release/datum-connect /usr/local/bin/datum-connect + --mount=type=cache,target=/src/target,id=datum-connect-target-$TARGETARCH \ + if [ -n "$VERSION" ]; then \ + sed -i "s/^version = \".*\"/version = \"${VERSION#v}\"/" cli/Cargo.toml lib/Cargo.toml \ + && cargo update --workspace; \ + fi \ + && target="$(cat /rust-target)" \ + && cargo build --locked --release -p datum-connect --target "$target" \ + && cp "target/$target/release/datum-connect" /usr/local/bin/datum-connect FROM debian:bookworm-slim diff --git a/cli/src/main.rs b/cli/src/main.rs index a200061..ed4a7fd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -165,7 +165,7 @@ async fn main() -> n0_error::Result<()> { .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()), release: sentry::release_name!(), - send_default_pii: true, + send_default_pii: false, traces_sample_rate: 0.1, ..Default::default() });