Set a server up without the owner's key: spectra init --owner and the claim (#22) 77df591d5a2f
The daemon starts unowned, answers only who it is, and accepts the owner's self-signed identity from the KeyId init named and no other. Signed API requests that are refused are told why. A failing end-to-end test no longer leaves its daemon running. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 79c54a430d85
- When
- · 2026-11-03 13:25 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
77df591d5a2f981d0fff2d857c47f775cc5c408cb6f873ca52d9ae07c6d03b1f- Depends on
- 037c45d8093c 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c b576d05cf862 d17cbc7069ce e071e91a14ef
- Depended on by
- 862f0db224aa 98a0fb9f0948 67bfa06a894f eca5af92279b
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 21 atoms, 1 tree operation, 9 files
Browse the tree at this change
added tests/remote.rs +82 −0
| 1 | + | //! Setting a server up without the owner's secret key (spec §8.6, |
|
| 2 | + | //! spectra#22): `spectra init --owner KEYID`, nothing served but who it |
|
| 3 | + | //! is until the named owner claims it, and nobody else may. |
|
| 4 | + | ||
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{hermetic, run, text, Serving}; |
|
| 8 | + | use spectra_core::daemon::WireEvent; |
|
| 9 | + | use spectra_core::keys::KeyPair; |
|
| 10 | + | use spectra_core::principals::{identity_payload, signed_event}; |
|
| 11 | + | use spectra_mcp::client::{Client, Endpoint}; |
|
| 12 | + | use spectra_mcp::tls::Pinned; |
|
| 13 | + | ||
| 14 | + | fn claim_body(pair: &KeyPair) -> Vec<u8> { |
|
| 15 | + | let e = signed_event(pair, "human", "identity.owner", identity_payload(&pair.identity)); |
|
| 16 | + | serde_json::to_vec(&WireEvent::from_new(&e)).unwrap() |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[tokio::test] |
|
| 20 | + | async fn the_named_owner_claims_a_server_and_nobody_else_can() { |
|
| 21 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 22 | + | let d = dir.path(); |
|
| 23 | + | // The owner's key is on another machine: here there is only its KeyId. |
|
| 24 | + | let owner = KeyPair::generate("Andy McCall", ""); |
|
| 25 | + | let o = run(d, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]); |
|
| 26 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 27 | + | assert!(text(&o).contains("not yet claimed"), "{}", text(&o)); |
|
| 28 | + | assert!(!d.join("owner").exists(), "no owner home was read or made"); |
|
| 29 | + | ||
| 30 | + | let (srv, port) = Serving::start_tls(d); |
|
| 31 | + | let probe = Pinned::new(None); |
|
| 32 | + | let anon = Client::at(Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: probe.client_config().unwrap() }, None); |
|
| 33 | + | let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap(); |
|
| 34 | + | assert_eq!(info["name"], "earth"); |
|
| 35 | + | assert!(info["owner"].is_null(), "unclaimed"); |
|
| 36 | + | let pin = probe.seen().unwrap().1; |
|
| 37 | + | let url = format!("https://127.0.0.1:{port}"); |
|
| 38 | + | ||
| 39 | + | // Before the claim, even the owner's signed requests are refused, and say why. |
|
| 40 | + | let as_owner = Client::at(Endpoint::tls(&url, pin).unwrap(), Some(owner.clone())); |
|
| 41 | + | let r = as_owner.send("GET", "/api/v1/agents", &[], vec![]).await.unwrap(); |
|
| 42 | + | assert_eq!(r.status, 401); |
|
| 43 | + | assert!(String::from_utf8_lossy(&r.body).contains("no owner yet"), "{}", String::from_utf8_lossy(&r.body)); |
|
| 44 | + | ||
| 45 | + | // Someone else cannot claim it. |
|
| 46 | + | let mallory = KeyPair::generate("Mallory", ""); |
|
| 47 | + | let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&mallory)).await.unwrap(); |
|
| 48 | + | assert_eq!(r.status, 403); |
|
| 49 | + | assert!(String::from_utf8_lossy(&r.body).contains("another owner")); |
|
| 50 | + | ||
| 51 | + | // Nor can a claim naming the owner but signed by another key. |
|
| 52 | + | let mut forged = signed_event(&mallory, "human", "identity.owner", identity_payload(&owner.identity)); |
|
| 53 | + | forged.payload = identity_payload(&owner.identity); |
|
| 54 | + | let r = anon.send("POST", "/api/v1/claim", &[], serde_json::to_vec(&WireEvent::from_new(&forged)).unwrap()).await.unwrap(); |
|
| 55 | + | assert_eq!(r.status, 403); |
|
| 56 | + | ||
| 57 | + | // The owner can, once. |
|
| 58 | + | let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&owner)).await.unwrap(); |
|
| 59 | + | assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
|
| 60 | + | let r = as_owner.send("GET", "/api/v1/agents", &[], vec![]).await.unwrap(); |
|
| 61 | + | assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
|
| 62 | + | let r = anon.send("POST", "/api/v1/claim", &[], claim_body(&owner)).await.unwrap(); |
|
| 63 | + | assert_eq!(r.status, 403); |
|
| 64 | + | assert!(String::from_utf8_lossy(&r.body).contains("already has an owner")); |
|
| 65 | + | let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap(); |
|
| 66 | + | assert_eq!(info["owner"], owner.key.to_hex()); |
|
| 67 | + | srv.stop(); |
|
| 68 | + | ||
| 69 | + | // An owner recorded after the instance verifies, and rebuilds. |
|
| 70 | + | for args in [["admin", "verify"], ["admin", "rebuild"], ["admin", "check"]] { |
|
| 71 | + | let o = hermetic(d).args(args).output().unwrap(); |
|
| 72 | + | assert!(o.status.success(), "{args:?}: {}", text(&o)); |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | #[test] |
|
| 77 | + | fn init_refuses_what_is_not_a_key_id() { |
|
| 78 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 79 | + | let o = run(dir.path(), &["init", "--owner", "andy"]); |
|
| 80 | + | assert!(!o.status.success()); |
|
| 81 | + | assert!(text(&o).contains("not a KeyId"), "{}", text(&o)); |
|
| 82 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why. |
|
| 5 | 6 | - **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check. |
|
| 6 | 7 | - **Added: the README's quick start, a systemd user unit (`packaging/spectra.service`) and `tests/bench.rs`** for the targets of spec §24 (brief 2.7 ms, say 3.1 ms, wait wakes 4.2 ms after a mention, with 1,000 messages, in release). |
|
| 7 | 8 | - **Added: the human at a terminal (#12).** `spectra say [-c CHANNEL] [--reply-to ID] BODY`, `spectra inbox [--follow]`, `spectra search QUERY [--channel] [--author] [--since 7d]` and `spectra api METHOD PATH [-d JSON]`, each signing with the owner's own key: through the daemon when it is serving, else straight into the ledger. Every listing takes `--json` (one document); `say` and `inbox --follow` make a terminal chat with the agents. |
|
| … | 13 unchanged lines |
modified crates/spectra-core/src/daemon.rs +34 −0
| … | 101 unchanged lines |
||
| 102 | 102 | let now = crate::ids::now_ms(); |
|
| 103 | 103 | let r = { |
|
| 104 | 104 | let p = self.principals.read().await; |
|
| 105 | + | if p.owner().is_none() { |
|
| 106 | + | return Err(Refusal::NotAPrincipal("this Spectra has no owner yet: its owner claims it with `spectra connect`".into())); |
|
| 107 | + | } |
|
| 105 | 108 | self.authn.authenticate(&p, proof, method, body, now, peer.map(|x| x.uid)) |
|
| 106 | 109 | }; |
|
| 107 | 110 | match &r { |
|
| … | 81 unchanged lines |
||
| 189 | 192 | // Who is who has changed: know it before the next request arrives. |
|
| 190 | 193 | *self.principals.write().await = Principals::load(&self.ledger).await?; |
|
| 191 | 194 | Ok(appended.iter().map(|e| e.seq).collect()) |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /// The owner `spectra init --owner` named, while none is recorded. |
|
| 198 | + | pub fn named_owner(&self) -> Option<String> { |
|
| 199 | + | let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?; |
|
| 200 | + | let t: toml::Table = text.parse().ok()?; |
|
| 201 | + | t.get("owner")?.get("key")?.as_str().map(|k| k.to_ascii_lowercase()) |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | /// The owner claims a Spectra set up with `spectra init --owner` |
|
| 205 | + | /// (spec §8.6): their identity, signed by itself over its payload, and |
|
| 206 | + | /// accepted only when no owner is recorded and the key is the one named. |
|
| 207 | + | pub async fn claim(&self, w: WireEvent) -> Result<u64> { |
|
| 208 | + | let n = w.into_new_event()?; |
|
| 209 | + | if n.kind != "identity.owner" || n.actor != "human" { |
|
| 210 | + | bail!("a claim is the owner's identity.owner"); |
|
| 211 | + | } |
|
| 212 | + | let mut principals = self.principals.read().await.clone(); |
|
| 213 | + | if principals.owner().is_some() { |
|
| 214 | + | bail!("this Spectra already has an owner"); |
|
| 215 | + | } |
|
| 216 | + | let named = self.named_owner().context("this Spectra names no owner (spectra init --owner)")?; |
|
| 217 | + | let key = n.payload.get("key").and_then(|k| k.as_str()).unwrap_or("").to_ascii_lowercase(); |
|
| 218 | + | if key != named || n.auth.key.map(hex::encode) != Some(named.clone()) { |
|
| 219 | + | bail!("this Spectra was set up for another owner"); |
|
| 220 | + | } |
|
| 221 | + | principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?; |
|
| 222 | + | let appended = self.ledger.append(vec![n]).await?; |
|
| 223 | + | let _ = self.ledger.checkpoint().await; |
|
| 224 | + | *self.principals.write().await = Principals::load(&self.ledger).await?; |
|
| 225 | + | Ok(appended.first().map(|e| e.seq).unwrap_or(0)) |
|
| 192 | 226 | } |
|
| 193 | 227 | ||
| 194 | 228 | /// Write a checkpoint covering everything (at shutdown). |
|
| … | 75 unchanged lines |
modified crates/spectra-mcp/src/api.rs +15 −7
| … | 2 unchanged lines |
||
| 3 | 3 | //! may instead carry the owner's Radar session. Errors are RFC 7807 |
|
| 4 | 4 | //! problems; lists page with `?after=`; reads carry an ETag. |
|
| 5 | 5 | ||
| 6 | - | use crate::http::{authenticate, AppState, PeerInfo}; |
|
| 6 | + | use crate::http::{AppState, PeerInfo}; |
|
| 7 | 7 | use axum::body::Bytes; |
|
| 8 | 8 | use axum::extract::connect_info::ConnectInfo; |
|
| 9 | 9 | use axum::extract::{Path, Query, State}; |
|
| … | 86 unchanged lines |
||
| 96 | 96 | }); |
|
| 97 | 97 | } |
|
| 98 | 98 | } |
|
| 99 | - | authenticate(st, peer, headers, method_path, body).await.map_err(|_| { |
|
| 100 | - | problem( |
|
| 101 | - | StatusCode::UNAUTHORIZED, |
|
| 102 | - | "not authenticated", |
|
| 103 | - | Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()), |
|
| 104 | - | ) |
|
| 99 | + | let signed = headers.get("spectra-key").is_some(); |
|
| 100 | + | crate::http::authenticate_why(st, peer, headers, method_path, body).await.map_err(|(status, why)| { |
|
| 101 | + | if status == StatusCode::FORBIDDEN { |
|
| 102 | + | problem(status, "this origin may not call Spectra", None) |
|
| 103 | + | } else if signed { |
|
| 104 | + | // A signed request is told why its signature was not enough. |
|
| 105 | + | problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why)) |
|
| 106 | + | } else { |
|
| 107 | + | problem( |
|
| 108 | + | StatusCode::UNAUTHORIZED, |
|
| 109 | + | "not authenticated", |
|
| 110 | + | Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()), |
|
| 111 | + | ) |
|
| 112 | + | } |
|
| 105 | 113 | }) |
|
| 106 | 114 | } |
|
| 107 | 115 | ||
| … | 366 unchanged lines |
modified crates/spectra-mcp/src/http.rs +38 −13
| … | 60 unchanged lines |
||
| 61 | 61 | .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed)) |
|
| 62 | 62 | .route("/api/v1/ledger/events", post(owner_events)) |
|
| 63 | 63 | .route("/api/v1/instance", axum::routing::get(instance)) |
|
| 64 | + | .route("/api/v1/claim", post(claim)) |
|
| 64 | 65 | .with_state(state) |
|
| 65 | 66 | } |
|
| 66 | 67 | ||
| … | 21 unchanged lines |
||
| 88 | 89 | .into_response() |
|
| 89 | 90 | } |
|
| 90 | 91 | ||
| 92 | + | /// `POST /api/v1/claim`: the owner named by `spectra init --owner` records |
|
| 93 | + | /// their identity (spec §8.6). The event proves itself: it is signed by |
|
| 94 | + | /// the identity it records, over its payload. |
|
| 95 | + | async fn claim(State(state): State<AppState>, body: Bytes) -> Response { |
|
| 96 | + | let w: WireEvent = match serde_json::from_slice(&body) { |
|
| 97 | + | Ok(v) => v, |
|
| 98 | + | Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not an event: {e}") }))).into_response(), |
|
| 99 | + | }; |
|
| 100 | + | match state.daemon.claim(w).await { |
|
| 101 | + | Ok(seq) => axum::Json(json!({ "seq": seq })).into_response(), |
|
| 102 | + | Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(), |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | ||
| 91 | 106 | async fn not_allowed() -> StatusCode { |
|
| 92 | 107 | StatusCode::METHOD_NOT_ALLOWED |
|
| 93 | 108 | } |
|
| … | 20 unchanged lines |
||
| 114 | 129 | } |
|
| 115 | 130 | } |
|
| 116 | 131 | ||
| 117 | - | /// Authenticate a request, or the response that refuses it. |
|
| 118 | - | pub async fn authenticate( |
|
| 132 | + | /// Authenticate a request, or the status and reason that refuse it. |
|
| 133 | + | pub async fn authenticate_why( |
|
| 119 | 134 | state: &AppState, |
|
| 120 | 135 | peer: PeerInfo, |
|
| 121 | 136 | headers: &HeaderMap, |
|
| 122 | 137 | method_path: &str, |
|
| 123 | 138 | body: &[u8], |
|
| 124 | - | ) -> Result<Caller, Response> { |
|
| 139 | + | ) -> Result<Caller, (StatusCode, String)> { |
|
| 125 | 140 | if !origin_ok(headers, &state.origins) { |
|
| 126 | - | return Err(( |
|
| 127 | - | StatusCode::FORBIDDEN, |
|
| 128 | - | axum::Json(protocol::error(&Json::Null, protocol::INVALID_REQUEST, "this origin may not call Spectra")), |
|
| 129 | - | ) |
|
| 130 | - | .into_response()); |
|
| 141 | + | return Err((StatusCode::FORBIDDEN, "this origin may not call Spectra".into())); |
|
| 131 | 142 | } |
|
| 132 | 143 | let proof = proof_from(headers, method_path, body); |
|
| 133 | 144 | state.daemon.authenticate(proof.as_ref(), method_path, body, peer.0).await.map_err(|why| { |
|
| 134 | 145 | tracing::info!(%why, "refused a request"); |
|
| 135 | 146 | // The reason concerns the caller's own key, so the caller is told it: |
|
| 136 | 147 | // an agent whose delegation expired must be able to say so. |
|
| 137 | - | ( |
|
| 138 | - | StatusCode::UNAUTHORIZED, |
|
| 139 | - | axum::Json(protocol::error(&Json::Null, protocol::UNAUTHENTICATED, &format!("not authenticated: {why}"))), |
|
| 140 | - | ) |
|
| 141 | - | .into_response() |
|
| 148 | + | (StatusCode::UNAUTHORIZED, why.to_string()) |
|
| 149 | + | }) |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | /// Authenticate a request, or the JSON-RPC response that refuses it. |
|
| 153 | + | pub async fn authenticate( |
|
| 154 | + | state: &AppState, |
|
| 155 | + | peer: PeerInfo, |
|
| 156 | + | headers: &HeaderMap, |
|
| 157 | + | method_path: &str, |
|
| 158 | + | body: &[u8], |
|
| 159 | + | ) -> Result<Caller, Response> { |
|
| 160 | + | authenticate_why(state, peer, headers, method_path, body).await.map_err(|(status, why)| { |
|
| 161 | + | let (code, msg) = if status == StatusCode::FORBIDDEN { |
|
| 162 | + | (protocol::INVALID_REQUEST, why) |
|
| 163 | + | } else { |
|
| 164 | + | (protocol::UNAUTHENTICATED, format!("not authenticated: {why}")) |
|
| 165 | + | }; |
|
| 166 | + | (status, axum::Json(protocol::error(&Json::Null, code, &msg))).into_response() |
|
| 142 | 167 | }) |
|
| 143 | 168 | } |
|
| 144 | 169 | ||
| … | 93 unchanged lines |
modified docs/plan.md +1 −1
| … | 30 unchanged lines |
||
| 31 | 31 | ## Sprint 2: client and server |
|
| 32 | 32 | ||
| 33 | 33 | - [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs. |
|
| 34 | - | - [ ] #22 Remote setup: spectra init --owner KEYID and the owner's claim from the client. |
|
| 34 | + | - [x] #22 Remote setup — done 2026-11-03: spectra init --owner KEYID; unowned daemon refuses all but /api/v1/instance and POST /api/v1/claim, which takes only the named key; tests/remote.rs. Found: a failing end-to-end test left its daemon running, which held the output open and looked like a hang; Serving now kills it on drop. |
|
| 35 | 35 | - [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network. |
|
| 36 | 36 | - [ ] #23 The CLI without a ledger file: every command through the JSON API. |
|
| 37 | 37 | - [ ] #24 Radar from a workstation: spectra open's loopback proxy to the pinned server. |
|
| … | 19 unchanged lines |
modified src/cli/identity.rs +42 −0
| … | 172 unchanged lines |
||
| 173 | 173 | .with_context(|| format!("the recorded owner's key is not in {}", home.display())) |
|
| 174 | 174 | } |
|
| 175 | 175 | ||
| 176 | + | /// `spectra init --owner KEYID` (spec §8.6): a server's instance key and |
|
| 177 | + | /// ledger, with the owner named but not yet recorded; the owner claims it |
|
| 178 | + | /// from their own machine, so their secret key never comes here. |
|
| 179 | + | pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> { |
|
| 180 | + | let key = owner_key.trim().to_ascii_lowercase(); |
|
| 181 | + | if key.len() != 64 || hex::decode(&key).is_err() { |
|
| 182 | + | bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `oxy identity show` prints it)"); |
|
| 183 | + | } |
|
| 184 | + | let data = &ctx.data; |
|
| 185 | + | let db = data.join("spectra.db"); |
|
| 186 | + | if db.exists() { |
|
| 187 | + | bail!("{} already holds a Spectra (spectra identity show)", data.display()); |
|
| 188 | + | } |
|
| 189 | + | std::fs::create_dir_all(data)?; |
|
| 190 | + | #[cfg(unix)] |
|
| 191 | + | { |
|
| 192 | + | use std::os::unix::fs::PermissionsExt; |
|
| 193 | + | std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?; |
|
| 194 | + | } |
|
| 195 | + | let name = name.unwrap_or_else(hostname); |
|
| 196 | + | let instance = KeyPair::instance(data, &name)?; |
|
| 197 | + | let ledger = Ledger::open(&db, Some(instance.clone())).await?; |
|
| 198 | + | let mut first = vec![signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))]; |
|
| 199 | + | first.extend(spectra_core::conversation::default_channel_events()); |
|
| 200 | + | ledger.append(first).await?; |
|
| 201 | + | ledger.checkpoint().await?; |
|
| 202 | + | std::fs::write( |
|
| 203 | + | data.join("spectra.toml"), |
|
| 204 | + | format!( |
|
| 205 | + | "# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{key}\" # claimed from the owner's machine with `spectra connect`\n\n[server]\n# bind = \"[::]:7350\" # serve other machines over TLS (spec §8.6)\n" |
|
| 206 | + | ), |
|
| 207 | + | )?; |
|
| 208 | + | println!("{} {} {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display())); |
|
| 209 | + | println!("{}", ui::kv("owner", format!("{}{}", ui::hash(&key[..12]), ui::dim(" (named; not yet claimed)")))); |
|
| 210 | + | println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12))))); |
|
| 211 | + | println!(); |
|
| 212 | + | println!(" {} {}", ui::note(), ui::dim("serve it to other machines: set [server] bind in spectra.toml, then spectra serve")); |
|
| 213 | + | println!(" {} {}", ui::note(), ui::dim("then, on the owner's machine:")); |
|
| 214 | + | println!(" {}", ui::id("spectra connect https://HOST:7350")); |
|
| 215 | + | Ok(0) |
|
| 216 | + | } |
|
| 217 | + | ||
| 176 | 218 | fn hostname() -> String { |
|
| 177 | 219 | std::fs::read_to_string("/etc/hostname") |
|
| 178 | 220 | .ok() |
|
| … | 8 unchanged lines |
modified src/cli/mod.rs +10 −1
| … | 48 unchanged lines |
||
| 49 | 49 | /// This instance's name (default: the host name) |
|
| 50 | 50 | #[arg(long)] |
|
| 51 | 51 | name: Option<String>, |
|
| 52 | + | /// Set up a server without the owner's secret key: name the owner's |
|
| 53 | + | /// KeyId, and they claim it from their machine with `spectra |
|
| 54 | + | /// connect` (spec §8.6) |
|
| 55 | + | #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")] |
|
| 56 | + | owner: Option<String>, |
|
| 52 | 57 | }, |
|
| 53 | 58 | /// The owner's and the instance's identities: show, refresh |
|
| 54 | 59 | Identity { |
|
| … | 300 unchanged lines |
||
| 355 | 360 | let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; |
|
| 356 | 361 | match cli.command { |
|
| 357 | 362 | Command::Version { json } => version(json), |
|
| 358 | - | Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), |
|
| 363 | + | Command::Init { owner_home, name, owner: Some(key) } => { |
|
| 364 | + | let _ = owner_home; |
|
| 365 | + | runtime()?.block_on(identity::init_for(&ctx, &key, name)) |
|
| 366 | + | } |
|
| 367 | + | Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), |
|
| 359 | 368 | Command::Identity { command } => runtime()?.block_on(async { |
|
| 360 | 369 | match command { |
|
| 361 | 370 | IdentityCmd::Show { json } => identity::show(&ctx, json).await, |
|
| … | 58 unchanged lines |
modified tests/common/mod.rs +11 −0
| … | 100 unchanged lines |
||
| 101 | 101 | } |
|
| 102 | 102 | } |
|
| 103 | 103 | ||
| 104 | + | /// A test that fails mid-way must not leave its daemon running: an orphan |
|
| 105 | + | /// holds the test's output open and looks like a hang. |
|
| 106 | + | impl Drop for Serving { |
|
| 107 | + | fn drop(&mut self) { |
|
| 108 | + | if let Ok(None) = self.child.try_wait() { |
|
| 109 | + | let _ = self.child.kill(); |
|
| 110 | + | let _ = self.child.wait(); |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | ||
| 104 | 115 | pub fn setup() -> tempfile::TempDir { |
|
| 105 | 116 | let dir = tempfile::tempdir().unwrap(); |
|
| 106 | 117 | let d = dir.path(); |
|
| … | 6 unchanged lines |