feat(ant-devnet): opt-in LAN / external-devnet mode (--host / --evm-network / --serve-port)#174
feat(ant-devnet): opt-in LAN / external-devnet mode (--host / --evm-network / --serve-port)#174Nic-dorman wants to merge 4 commits into
Conversation
Extends the ant-devnet harness so a devnet can be reached from another device on the LAN (phone, simulator, second machine). All three flags are opt-in and default off — omitting them reproduces today's loopback-only behavior exactly. - --host <ipv4>: bind 0.0.0.0 and advertise the given LAN IP in the manifest bootstrap addresses (adds DevnetConfig::advertise_ip, default None → .local(true) + 127.0.0.1, unchanged). - --evm-network arbitrum-sepolia: verify payments against the real deployed Arbitrum Sepolia contracts (no local Anvil, empty wallet key) — exercises the external-signer payment flow. - --serve-port <port>: read-only manifest HTTP API (GET /api/devnet-manifest.json + /api/info); hand-rolled HTTP/1.1, no new dependencies. Node code and the wire protocol are untouched — only the ant-devnet binary and the Devnet harness change. Adds CLI parse tests + a default-config test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds opt-in LAN/external-devnet capabilities to the existing ant-devnet harness so a devnet can be accessed from other devices, including (a) advertising a LAN IPv4 in bootstrap addresses, (b) verifying payments against a real external EVM network, and (c) optionally serving the devnet manifest via a minimal HTTP API.
Changes:
- Extend
DevnetConfigwithadvertise_ip: Option<Ipv4Addr>and use it for bootstrap address construction and local/loopback binding behavior. - Add CLI flags
--host,--evm-network, and--serve-portand wire them into devnet startup. - Implement a small read-only HTTP endpoint for serving the manifest and a derived
/api/infopayload.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/devnet.rs | Adds advertise_ip to devnet config, uses it to generate bootstrap MultiAddrs and toggles .local(...), plus a backward-compat test. |
| src/bin/ant-devnet/main.rs | Wires new CLI options into devnet config, adds external EVM network selection, and introduces a tiny manifest HTTP server. |
| src/bin/ant-devnet/cli.rs | Defines the new CLI flags and adds parsing/backward-compat tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Start Anvil and deploy contracts if EVM is enabled | ||
| let evm_info = if cli.enable_evm { | ||
| config.advertise_ip = cli.host; |
| fn local_ip_guess() -> String { | ||
| std::net::UdpSocket::bind("0.0.0.0:0") | ||
| .and_then(|s| { | ||
| s.connect("1.1.1.1:80")?; | ||
| Ok(s.local_addr()?.ip().to_string()) | ||
| }) | ||
| .unwrap_or_else(|_| "127.0.0.1".to_string()) | ||
| } |
| let req = String::from_utf8_lossy(&buf[..n]); | ||
| let raw = req.split_whitespace().nth(1).unwrap_or("/"); | ||
| let path = raw.split('?').next().unwrap_or("/").trim_end_matches('/'); | ||
| let (status, body) = match path { | ||
| "/api/devnet-manifest.json" => ("200 OK", manifest.as_str()), | ||
| "/api/info" => ("200 OK", info.as_str()), | ||
| "" | "/api" => ( | ||
| "200 OK", | ||
| "{\"service\":\"ant-devnet manifest API\",\ | ||
| \"endpoints\":[\"/api/devnet-manifest.json\",\"/api/info\"]}", | ||
| ), | ||
| _ => ("404 Not Found", "{\"error\":\"not found\"}"), | ||
| }; |
| /// (`--enable-evm`). | ||
| #[arg(long)] | ||
| pub evm_network: Option<String>, |
…many_lines CI runs clippy with -D warnings and the workspace enables clippy::pedantic, so the expanded main() tripped too_many_lines (121/100). Extract the EVM-backing resolution into resolve_evm_info; behavior unchanged, main() back to ~85 lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| let info = serde_json::json!({ | ||
| "host_ip": host_ip, | ||
| "manifest_url": format!("http://{host_ip}:{port}/api/devnet-manifest.json"), | ||
| "node_count": manifest.node_count as u64, | ||
| "bootstrap": serde_json::to_value(&manifest.bootstrap).unwrap_or(serde_json::Value::Null), | ||
| "evm": evm_block, | ||
| }); |
| for stream in listener.incoming() { | ||
| let Ok(mut stream) = stream else { continue }; | ||
| let manifest = manifest_json.clone(); | ||
| let info = info_json.clone(); | ||
| std::thread::spawn(move || { |
…EVM flags, read timeout Automated-review follow-ups on the manifest HTTP API + CLI: - Reject a loopback/unspecified --host early (would stamp unreachable bootstrap addresses into the manifest). - Manifest API now enforces GET and returns 405 for other methods. - --evm-network and --enable-evm are mutually exclusive at the clap level. - Cap the manifest-server handler with a 5s socket read timeout so a slow/idle client can't tie up a thread (listener binds 0.0.0.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review feedback in 96ab80a:
Not changed, with rationale:
fmt + clippy ( |
| if let Some(port) = cli.serve_port { | ||
| serve_manifest_api(port, cli.host, &manifest, json.clone())?; | ||
| } |
| #[arg(long)] | ||
| pub serve_port: Option<u16>, |
| std::thread::spawn(move || { | ||
| use std::io::{Read, Write}; | ||
| for stream in listener.incoming() { | ||
| let Ok(mut stream) = stream else { continue }; | ||
| // Cap how long a slow/idle client can hold a handler thread — the | ||
| // listener binds 0.0.0.0, so an unbounded blocking read is a trivial | ||
| // resource-exhaustion vector. | ||
| let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); | ||
| let manifest = manifest_json.clone(); | ||
| let info = info_json.clone(); | ||
| std::thread::spawn(move || { |
dirvine
left a comment
There was a problem hiding this comment.
Reviewed current head 96ab80aaa2fbee966a00e83c08b30c36fda32677 with a multi-reviewer pass and local runtime checks.
Verdict: changes requested before merge. The latest commit fixes loopback/unspecified --host, GET-only handling, conflicting EVM flags, and idle-read timeout. Two functional issues remain:
-
src/bin/ant-devnet/main.rs:260-267—--serve-portbind failure is swallowed.spawn_manifest_serveronly warns and returns, thenserve_manifest_apireturnsOk(())and the CLI reportsDevnet running. I reproduced this with an occupied port on the current head: the process loggedfailed to bind ... Address already in use, then continued successfully. Please bind synchronously and propagate the error. -
src/bin/ant-devnet/cli.rs:84-85/main.rs:237,261— port0is accepted but advertised as:0. The OS actually selected port49241in my current-head run, while the log and/api/infoconstruction retained port0. Either reject zero or return/uselistener.local_addr().
Also worth fixing in this pass:
--serve-portwithout--hostbinds0.0.0.0and publishes a LAN-looking API URL, but the served manifest still contains loopback bootstrap addresses. Require--host, or bind/report loopback when it is absent.- The five-second timeout bounds idle connection lifetime, but the server still creates an unbounded OS thread per connection. A small concurrency cap/inline handler would remove the remaining burst-exhaustion path.
--hostnow rejects loopback/unspecified addresses, but multicast and broadcast values remain accepted and are not usable bootstrap destinations.- Current
ant-clidoes not infer Sepolia from the manifest alone; users need--evm-network arbitrum-sepolia(orlocalto consume the manifest's custom RPC/contracts). This is not necessarily a blocker for the mobile flow, but should be documented unless the manifest schema gains an explicit network/chain ID.
Local verification on the current head:
cargo test --bin ant-devnet— 3 passedcargo test default_config_has_no_advertise_ip --lib— 1 passed- strict
cargo clippy --bin ant-devnet --all-features— passed - runtime occupied-port and port-zero cases reproduced as above
The reported Android paid upload/download result was not independently reproduced during this review.
… hardening @dirvine review + Copilot follow-ups on the manifest API: - Bind the manifest listener synchronously in serve_manifest_api and propagate the error, so a bind failure (e.g. port in use) fails the run instead of the devnet reporting 'running' without its API. - Reject --serve-port 0 at the parser (range 1..) — it would otherwise bind an ephemeral port while /api/info advertises ':0'. - --serve-port now requires --host (the API publishes a LAN URL; a loopback-only devnet would be misleading/unsafe). - Handle manifest-API connections inline in the single server thread instead of spawning an unbounded thread per connection (kept the 5s read timeout). - Also reject multicast/broadcast --host, not just loopback/unspecified. - Propagate the bootstrap serialization error in /api/info instead of silently substituting null. Adds serve_port_requires_host + serve_port_rejects_zero CLI tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @dirvine — addressed in 998493d. Blockers:
Also fixed this pass:
Not changed here (documentation / follow-up):
Local: |
| // Optional read-only HTTP API so LAN devices fetch the manifest instead of | ||
| // copying files (GET /api/devnet-manifest.json + /api/info). | ||
| if let Some(port) = cli.serve_port { | ||
| serve_manifest_api(port, cli.host, &manifest, json.clone())?; | ||
| } |
| // Bind synchronously so a failure (e.g. the port is already in use) | ||
| // propagates to the caller instead of the devnet silently coming up | ||
| // without its manifest API. | ||
| let listener = std::net::TcpListener::bind(("0.0.0.0", port)).map_err(|e| { | ||
| color_eyre::eyre::eyre!("failed to bind manifest API on 0.0.0.0:{port}: {e}") | ||
| })?; | ||
| ant_node::logging::info!( | ||
| "manifest API on http://0.0.0.0:{port}/api/devnet-manifest.json (+ /api/info)" | ||
| ); | ||
| spawn_manifest_server(listener, manifest_json, info_json); |
dirvine
left a comment
There was a problem hiding this comment.
Re-reviewed current head 998493dd8a13f842ec2b16d00ffa51fa31240916 against the previous reviewed head.
The previous blockers are addressed:
- manifest-listener bind errors now propagate;
- port zero and
--serve-portwithout--hostare rejected; - loopback/unspecified/multicast/broadcast hosts are rejected;
- unbounded per-connection threads are removed;
- bootstrap serialisation errors propagate.
Local/runtime verification:
cargo test --bin ant-devnet— 5 passedcargo test default_config_has_no_advertise_ip --lib— passed- strict clippy and build — passed
- occupied-port case now exits with the expected bind error
- normal GET succeeds and invalid host classes are rejected
Recommendation: approve once the remaining platform CI tests pass. No remaining blocker was found for the stated opt-in trusted-LAN devnet scope. The single-read HTTP parser, process-scoped detached listener, intentional DevnetConfig field addition, and possible data-directory residue on a post-start error are non-blocking limitations here.
At review time, format, clippy, documentation, all three builds, no-logging tests and security audit are green. Ubuntu/macOS/Windows test jobs remain in progress.
What
Adds a LAN / external-devnet scenario to the existing
ant-devnetharness so a devnet can be tested from another device — a phone, an iOS simulator, or a second machine — rather than only from the host box. Three opt-in flags, all default off:--host <ipv4>0.0.0.0and advertise this LAN IP in the manifest's bootstrap addresses, so other LAN devices can connect.--evm-network arbitrum-sepolia--serve-port <port>GET /api/devnet-manifest.json+/api/info) so devices fetch it instead of copying files.Why here
ant-devnetis already the maintained local-devnet tool; this is a focused enhancement to it, not a new binary or module — no new dependencies (the manifest server is hand-rolled HTTP/1.1). It's the node half of enabling on-device testing of the mobile SDK; the client half already shipped in the Autonomi mobile FFI.Backward compatibility / blast radius
--host→advertise_ip = None→.local(true)+127.0.0.1bootstrap addresses, byte-identical to today.ant-devnetbinary and theDevnet/DevnetConfigtype change. The real node binary (ant-node) and the wire protocol are untouched, and nothing here is on the production node path — so deployed testnets are unaffected.DevnetConfig(advertise_ip: Option<Ipv4Addr>). It's covered by theDefaultimpl, so..Default::default()construction is unaffected; only a struct-literal build without..Default::default()would need the new field.Tests
cli.rs: LAN flags default toNone(backward-compat contract); parse into the expected typed values; non-IPv4--hostis rejected.devnet.rs:DevnetConfig::default().advertise_ipisNone.cargo fmt --all --checkclean; strict clippy gates (-D clippy::panic/unwrap_used/expect_used) pass; release build green.Proven end-to-end out-of-band: a physical Android device completed a full paid upload + byte-perfect download round-trip against a Sepolia-backed LAN devnet launched with these flags.
🤖 Generated with Claude Code