Skip to content

Commit a2011f5

Browse files
committed
Allow token-less mesh creation for adhoc
This commit refactors adhoc to make the token optional for creating or joining meshes. This will be useful when running in some orchestrators where imids can be learned out of band. In the process it make some minor simplifications to the adhoc module, including putting full responsibility for maintaining mesh names in the root.
1 parent 2d2ace4 commit a2011f5

9 files changed

Lines changed: 618 additions & 100 deletions

File tree

designs/adhoc_bootstrap.md

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,9 @@ intermesh adhoc init --mesh <mesh> --name <name>
122122
1. **Check existing membership**: The daemon checks `DaemonState::adhoc_membership()`
123123
for persisted membership.
124124

125-
- If already root of the *same* mesh with the same name,
126-
treat `--init` as idempotent token regeneration (ok + token).
127-
- If already in a *different* mesh than the requested `--mesh`,
128-
return error + error_message.
125+
- If already root of the *same* mesh, treat `--init` as idempotent
126+
token regeneration (ok + token).
127+
- If already in a *different* mesh: return error.
129128

130129
2. **Create and persist Membership**: A `Membership` struct is created
131130
containing the mesh pattern, root IMID, root IP, and node name. This is
@@ -166,35 +165,36 @@ the mesh (via SSH, configuration management, or other secure channels).
166165

167166
## Joining a Mesh
168167

169-
A client joins an existing mesh with `intermesh adhoc join`:
168+
A client joins an existing mesh via token or hard-coded root identity:
170169

171170
```
172171
intermesh adhoc join <token> --name <name>
172+
intermesh adhoc join --mesh <domain> --root-imid <imid> --root-ip <ip>
173173
```
174174

175-
### Join Process
175+
Token mode requires a bootstrap token from root. Hard-coded mode is for
176+
orchestrated environments where root identity is provisioned out of band —
177+
no token exchange or gRPC to root occurs.
178+
179+
### Join Process (Token Mode)
176180

177181
1. **Check existing membership**: The daemon checks
178182
`DaemonState::adhoc_membership()` for persisted membership.
179183

180-
For `--join`, the daemon enforces a single-mesh constraint, but idempotent
181-
re-joins are allowed:
184+
Idempotency is based on root identity, not name:
182185
- If not in a mesh: proceed with join.
183-
- If already in the same mesh with the same name: return ok (no-op).
184-
- If already in a different mesh or with a different name: return error.
186+
- If already joined to the same root (`root_imid` matches): return ok.
187+
- If already joined to a different root: return error.
185188

186189
2. **Client validates token**: The client decodes the token, verifies the
187190
signature, and checks expiration. The client prompts for confirmation
188191
showing the mesh domain and root IMID before proceeding.
189192

190193
3. **Client sends bootstrap request**: The client connects to the root node's
191-
IP address (from token) and sends a `JoinRequest` containing:
194+
IP address (from token) and sends a `JoinTokenRequest` containing:
192195
- The original token (for root to validate)
193-
- The requested node name (short name only, e.g., "db" not "db.test.mesh")
194-
195-
Note: The daemon-to-daemon `JoinRequest.name` field currently uses the short
196-
name (first label). This should be changed to use the full node name for
197-
consistency with the admin API.
196+
- The requested name (`requested_name`) — this is what the joiner wants
197+
to be called, but root's endorsement is what makes it authoritative
198198

199199
4. **Root processes request**: The root validates the token signature and
200200
expiration, then adds the joiner to its persisted `Membership`. The
@@ -218,7 +218,8 @@ intermesh adhoc join <token> --name <name>
218218
If the root knows the joiner's IP, it also endorses it.
219219

220220
5. **Client creates local Membership**: After receiving confirmation, the
221-
client creates a `Membership` struct and persists it to the state file.
221+
client creates a `Membership` struct (with `root_name: None` — the client
222+
learns its name from root's endorsement) and persists it to the state file.
222223
`Membership::to_bases()` generates the client's intended endorsements,
223224
pushed to the endorsement manager:
224225

@@ -262,6 +263,34 @@ its own subdomain.
262263
The client is now fully integrated into the mesh and can discover other nodes
263264
through gossip.
264265

266+
### Join Process (Hard-Coded Mode)
267+
268+
Hard-coded join (`intermesh adhoc join --mesh <domain> --root-imid <imid>
269+
--root-ip <ip>`) writes local `Membership` directly from explicit args. No
270+
token exchange, no gRPC to root. The orchestrator is responsible for running
271+
`adhoc add` on root first, then `adhoc join` on each joiner.
272+
273+
The joiner does not specify a name — it learns its name from root's
274+
endorsement after gossip convergence. Idempotency is based on `root_imid`:
275+
re-joining the same root is a no-op, joining a different root is an error.
276+
277+
## Adding Members (Root-Only)
278+
279+
Root can add members explicitly without token exchange:
280+
281+
```
282+
intermesh adhoc add --name <name> --imid <imid> --ip <ip>
283+
```
284+
285+
This is a root-only operation that adds a joiner entry to `Membership.joiners`
286+
and recomputes intent. The endorsement manager's reconcile loop signs the
287+
new member's name, delegation, and IP endorsements. IP is required — there
288+
is no discovery mechanism in adhoc mode.
289+
290+
Validation: mesh must be initialized (`FailedPrecondition`), caller must be
291+
root (`PermissionDenied`), and name must be under the mesh pattern
292+
(`InvalidArgument`).
293+
265294
## Mesh Membership
266295

267296
### Explicit persistence
@@ -277,11 +306,15 @@ struct Membership {
277306
mesh: NamePattern, // e.g. "**.test.mesh"
278307
root_imid: Imid,
279308
root_ip: IpAddr,
280-
my_name: Name, // e.g. "db.test.mesh"
281-
joiners: BTreeMap<Imid, Joiner>, // root-only: accepted joiners
309+
root_name: Option<Name>, // only set on root (e.g. "root.test.mesh")
310+
joiners: BTreeMap<Imid, Joiner>, // root-only: accepted joiners
282311
}
283312
```
284313

314+
`root_name` is only set on the root node (used for self-endorsement in
315+
`root_bases()`). Non-root nodes set it to `None` — they learn their name
316+
from root's endorsement, not from local state.
317+
285318
Membership is accessed via `DaemonState::adhoc_membership()` and set via
286319
`adhoc::Handle::set_membership()`, which persists to disk and pushes intent
287320
to the endorsement manager atomically under a mutex.
@@ -293,19 +326,26 @@ can diff intent vs observed and re-sign any missing endorsements.
293326

294327
### CLI UX
295328

296-
The CLI commands `intermesh adhoc init` and `intermesh adhoc join` are designed
297-
to be idempotent where possible:
329+
The CLI commands are designed to be idempotent where possible:
298330

299331
- `intermesh adhoc init --mesh <mesh> --name <name>`
300332
- if not in a mesh: initialize and generate token
301-
- if already root of the same mesh with same name: regenerate token
302-
- if already in a different mesh or with different name: error
333+
- if already root of the same mesh: regenerate token (idempotent)
334+
- if already in a different mesh: error
303335

304-
- `intermesh adhoc join <token> --name <name>`
336+
- `intermesh adhoc join <token> --name <name>` (token mode)
305337
- if not in a mesh: join using the token
306-
- if already in the same mesh with same name: success (no-op)
307-
- if already in the same mesh with different name: error (no rename)
308-
- if already in a different mesh: error
338+
- if already joined to the same root: success (no-op)
339+
- if already joined to a different root: error
340+
341+
- `intermesh adhoc join --mesh <domain> --root-imid <imid> --root-ip <ip>`
342+
(hard-coded mode)
343+
- if not in a mesh: join with explicit root identity
344+
- if already joined to the same root: success (no-op)
345+
- if already joined to a different root: error
346+
347+
- `intermesh adhoc add --name <name> --imid <imid> --ip <ip>` (root-only)
348+
- adds a member by explicit identity, no token exchange
309349

310350
### Leaving a Mesh (future)
311351

@@ -323,22 +363,18 @@ The adhoc protocol is defined in `proto/adhoc.proto`.
323363

324364
The admin API uses standard gRPC status codes for errors:
325365

326-
- `Init(InitRequest) -> InitResponse`
327-
- On success: returns response with `token` field
328-
- On error: returns gRPC `Status` (e.g., `ALREADY_EXISTS`, `INVALID_ARGUMENT`)
366+
- `Init(InitRequest) -> InitResponse` — initialize mesh, returns token
367+
- `Join(JoinTokenRequest) -> JoinResponse` — token-based join
368+
- `JoinDirect(JoinDirectRequest) -> JoinResponse` — hard-coded join
369+
- `Add(AddRequest) -> AddResponse` — add member by IMID (root-only)
329370

330-
- `Join(AdminJoinRequest) -> AdminJoinResponse`
331-
- On success: returns empty response
332-
- On error: returns gRPC `Status` with error message
371+
All RPCs return gRPC `Status` on error (e.g., `ALREADY_EXISTS`,
372+
`INVALID_ARGUMENT`, `PERMISSION_DENIED`).
333373

334374
Mesh membership status is exposed via the main state dump (`StateDump`) rather
335375
than a dedicated RPC. The state dump includes `adhoc_membership` containing
336376
`Membership` data (if in a mesh).
337377

338-
This design:
339-
- Uses idiomatic gRPC error handling
340-
- Reuses existing state introspection infrastructure
341-
- Allows CLI to query mesh status *before* init/join calls
342-
343378
- **`JoinService`**: Network interface for daemon-to-daemon bootstrap
344-
- `Accept(JoinRequest) -> JoinResponse`: Process join request from a peer
379+
- `Accept(JoinTokenRequest) -> JoinResponse`: Process join request from a
380+
peer

proto/adhoc.proto

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package intermesh.adhoc;
66
service AdminService {
77
rpc Init(InitRequest) returns (InitResponse);
88
rpc Join(JoinRequest) returns (JoinResponse);
9+
rpc Add(AddRequest) returns (AddResponse);
910
}
1011

1112
message InitRequest {
@@ -19,14 +20,30 @@ message InitResponse {
1920

2021
// Daemon-to-daemon join acceptor API.
2122
service JoinService {
22-
rpc Accept(JoinRequest) returns (JoinResponse);
23+
rpc Accept(JoinTokenRequest) returns (JoinResponse);
2324
}
2425

25-
// Note JoinRequest and JoinResponse are used both between the cli and the
26-
// daemon, and between the joining and root daemon.
26+
// CLI-to-daemon join: one operation, two modes.
2727
message JoinRequest {
28+
oneof mode {
29+
JoinTokenRequest token = 1;
30+
JoinDirectRequest direct = 2;
31+
}
32+
}
33+
34+
// Token-based join: joiner presents token to root over mTLS.
35+
message JoinTokenRequest {
2836
Token token = 1;
29-
string node_name = 2; // (Name) Full node name (e.g., "db.test.mesh")
37+
string requested_name = 2; // (Name) Name the joiner wants (e.g., "db.test.mesh")
38+
}
39+
40+
// Direct join: joiner writes local membership from explicit root identity.
41+
// No token, no gRPC to root. CLI-to-daemon only. Joiner learns its name
42+
// from root's endorsement, not from this request.
43+
message JoinDirectRequest {
44+
string mesh_pattern = 1; // (NamePattern) e.g., "**.test.mesh"
45+
string root_imid = 2; // (IMID) Root node's IMID
46+
string root_ip = 3; // (IP) Root node's IP address
3047
}
3148

3249
message JoinResponse {
@@ -44,6 +61,14 @@ message Token {
4461
bytes signature = 2;
4562
}
4663

64+
message AddRequest {
65+
string name = 1; // (Name) Full node name (e.g., "db.test.mesh")
66+
string imid = 2; // (IMID) Node's IMID
67+
string ip = 3; // (IP) Node's IP address
68+
}
69+
70+
message AddResponse {}
71+
4772
message Membership {
4873
string mesh_pattern = 1; // (NamePattern) e.g., "**.test.mesh"
4974
string root_imid = 2; // (IMID) Root node's IMID

src/bin/intermesh.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use intermesh::cmd_status;
66
use intermesh::daemon;
77
use intermesh::daemon_state::{DaemonArgs, DaemonState};
88
use intermesh::fmt;
9-
use intermesh::modules::adhoc::cli::{cmd_init, cmd_join, InitArgs, JoinArgs};
9+
use intermesh::modules::adhoc::cli::{cmd_add, cmd_init, cmd_join, AddArgs, InitArgs, JoinArgs};
1010
use std::path::PathBuf;
1111
use tokio::process::Command;
1212

@@ -84,6 +84,9 @@ enum AdhocCommands {
8484

8585
/// Join an existing mesh network
8686
Join(JoinArgs),
87+
88+
/// Add a member to the mesh (root only)
89+
Add(AddArgs),
8790
}
8891

8992
#[derive(Subcommand)]
@@ -178,6 +181,7 @@ async fn main() -> Result<()> {
178181
Some(Commands::Adhoc { command }) => match command {
179182
AdhocCommands::Init(args) => cmd_init(args, cli.admin_socket).await?,
180183
AdhocCommands::Join(args) => cmd_join(args, cli.admin_socket).await?,
184+
AdhocCommands::Add(args) => cmd_add(args, cli.admin_socket).await?,
181185
},
182186
Some(Commands::Debug { command }) => match command {
183187
DebugCommands::Dump { toml } => {

src/daemon_state.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ mod tests {
468468
mesh: "**.test.mesh".parse().unwrap(),
469469
root_imid: ImidKeypair::generate().to_imid(),
470470
root_ip: "192.168.1.1".parse().unwrap(),
471-
my_name: "db.test.mesh".parse().unwrap(),
471+
root_name: None,
472472
joiners: BTreeMap::new(),
473473
};
474474
state

0 commit comments

Comments
 (0)