Skip to content

Commit d3c92e0

Browse files
authored
Merge pull request #101 from block65/fix/uat-parity
fix: UAT-driven parity — role commands, info output, -H dispatch
2 parents 043b53e + 248dc4e commit d3c92e0

33 files changed

Lines changed: 1535 additions & 154 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,13 @@ add, remove, or change any route, request body, or response shape in
6161
(source, destination, target) and concrete entities (peer, tun, device).
6262
- Explicit identifiers: Code and logs must use explicit, fixed IDs (e.g., peer1,
6363
dmz1, nodeA). Do not use network roles as variable names.
64-
- CLI consistency: eg REPL route add examples must explicitly include the --name
65-
<peer> flag on exit/relay commands to ensure routing examples remain
66-
self-documenting.
64+
- **Interface parity (STRICT):** All interfaces — REPL, CLI, MCP, REST API, and
65+
OpenAPI spec — must expose identical operations with identical names,
66+
parameters, and defaults. The REPL is the reference implementation. When
67+
adding or changing any command, update ALL interfaces in the same commit.
68+
Never create interface-specific names (e.g. `hint_set` in MCP when the REPL
69+
uses `role`). Never split into separate tools what the REPL handles as one
70+
command.
6771

6872
## TRIPLE PR Process for lead-agent only
6973

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
Implementation: `ConnectivitySpec::Listen(Vec<AddressSpec>)`, spawn one
4444
tokio listener task per spec, `StatusResponse.listen_addr` → `repeated
4545
string listen_addrs` in proto. CLI: argh `Vec<String>` for repeated
46-
`--listen`. No known tunnel tool (e.g. ligolo) supports this.
46+
`--listen`. No known tunnel tool supports this.
4747
- [ ] HTTP/2 multiplexing
4848
- [ ] Domain fronting support
4949
- [ ] Deterministic TUN addresses based on peer identity

crates/api/src/handlers.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,21 @@ pub struct ListenResponse {
125125
pub fingerprint: String,
126126
}
127127

128-
/// Set hint request body.
128+
/// Role set request body.
129+
///
130+
/// `role` is always required. `level` defaults to `"fixed"` if omitted,
131+
/// matching the REPL behaviour where `role entry` means `role fixed entry`.
129132
#[derive(Debug, Deserialize)]
130-
pub struct HintSetRequestBody {
133+
pub struct RoleSetRequestBody {
134+
#[serde(default = "default_level")]
131135
pub level: String,
132136
pub role: String,
133137
}
134138

139+
fn default_level() -> String {
140+
"fixed".to_string()
141+
}
142+
135143
/// Logs query parameters.
136144
#[derive(Debug, Deserialize)]
137145
pub struct LogsQuery {
@@ -655,10 +663,14 @@ pub async fn shutdown(State(state): State<ApiState>) -> (StatusCode, Json<Succes
655663
}
656664
}
657665

658-
pub async fn hint_set(
666+
pub async fn role_set(
659667
State(state): State<ApiState>,
660-
Json(req): Json<HintSetRequestBody>,
668+
Json(req): Json<RoleSetRequestBody>,
661669
) -> (StatusCode, Json<SuccessResponse>) {
670+
if req.role == "auto" {
671+
return role_auto(State(state)).await;
672+
}
673+
662674
let level = match req.level.as_str() {
663675
"prefer" => HintLevel::Prefer,
664676
"exclude" => HintLevel::Exclude,
@@ -669,7 +681,7 @@ pub async fn hint_set(
669681
Json(SuccessResponse {
670682
success: false,
671683
message: Some(format!(
672-
"invalid hint level '{}' (expected: prefer, exclude, fixed)",
684+
"invalid level '{}' (expected: prefer, exclude, fixed)",
673685
req.level
674686
)),
675687
}),
@@ -686,7 +698,7 @@ pub async fn hint_set(
686698
Json(SuccessResponse {
687699
success: false,
688700
message: Some(format!(
689-
"invalid role '{}' (expected: entry, exit, relay)",
701+
"invalid role '{}' (expected: auto, entry, exit, relay)",
690702
req.role
691703
)),
692704
}),
@@ -738,7 +750,7 @@ pub async fn hint_set(
738750
}
739751
}
740752

741-
pub async fn hint_set_auto(State(state): State<ApiState>) -> (StatusCode, Json<SuccessResponse>) {
753+
pub async fn role_auto(State(state): State<ApiState>) -> (StatusCode, Json<SuccessResponse>) {
742754
let resp = state
743755
.ipc
744756
.lock()

crates/api/src/lib.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,7 @@ pub fn router(state: State) -> Router {
7777
.route("/listen", post(handlers::listen))
7878
.route("/disconnect", post(handlers::disconnect))
7979
.route("/shutdown", post(handlers::shutdown))
80-
.route(
81-
"/hints",
82-
put(handlers::hint_set).delete(handlers::hint_set_auto),
83-
)
80+
.route("/role", put(handlers::role_set))
8481
.layer(middleware::from_fn(move |req, next| {
8582
let auth = auth.clone();
8683
auth::middleware(auth, req, next)

crates/cli/src/bin/wallhack.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ fn main() {
4747
&& args.get(1).is_some_and(|a| {
4848
// "wallhack daemon" passthrough.
4949
a == "daemon"
50-
// Any flag argument: auto-negotiation or global daemon options.
50+
// Any flag argument EXCEPT -H/--host (shared with ctl CLI).
5151
// Control client commands are always bare words, never flags.
52-
|| a.starts_with('-')
52+
|| (a.starts_with('-') && *a != "-H" && *a != "--host")
5353
}));
5454

5555
if is_daemon {

crates/cli/src/daemon_cli.rs

Lines changed: 88 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,7 @@ pub struct WallhackCli {
9393
#[argh(option)]
9494
pub max_peers: Option<usize>,
9595

96-
/// prefer a role during auto-negotiation (entry, exit, relay)
97-
#[argh(option)]
98-
pub prefer_role: Option<String>,
99-
100-
/// exclude a role during auto-negotiation (entry, exit, relay)
101-
#[argh(option)]
102-
pub exclude_role: Option<String>,
103-
104-
/// override the negotiated role (entry, exit, relay)
96+
/// set role: entry, exit, relay, prefer:entry, exclude:relay, or auto
10597
#[argh(option)]
10698
pub role: Option<String>,
10799

@@ -162,13 +154,13 @@ pub struct CliError {
162154
/// Configuration build error.
163155
#[derive(Debug, thiserror::Error, PartialEq)]
164156
pub enum ConfigError {
165-
#[error("--prefer, --exclude-role, and --role are mutually exclusive")]
166-
HintFlagsConflict,
167157
#[error("--role entry requires TUN capability (CAP_NET_ADMIN)")]
168158
RoleEntryRequiresTun,
169159
#[error("--role relay requires both --connect and --listen")]
170160
RoleRelayRequiresConnectAndListen,
171-
#[error("invalid role '{0}': expected 'entry', 'exit', or 'relay'")]
161+
#[error(
162+
"invalid role '{0}': expected entry, exit, relay, prefer:entry, exclude:relay, or auto"
163+
)]
172164
InvalidRole(String),
173165
#[error("invalid address '{0}'")]
174166
InvalidAddress(String),
@@ -216,29 +208,39 @@ fn parse_role(s: &str) -> Result<ProtoNodeRole, ConfigError> {
216208
}
217209
}
218210

219-
/// Build a `RoleHint` from the mutually-exclusive hint CLI flags.
211+
/// Parse `--role` value into a `RoleHint`.
212+
///
213+
/// Accepted formats:
214+
/// - `entry` / `exit` / `relay` — fixed role (shorthand for `fixed:entry`)
215+
/// - `prefer:entry` / `exclude:relay` / `fixed:exit` — explicit level
216+
/// - `auto` — returns `None` (clear all hints)
220217
fn resolve_hint(cli: &WallhackCli) -> Result<Option<RoleHint>, ConfigError> {
221-
let hints: Vec<_> = [
222-
cli.prefer_role.as_deref().map(|s| (HintLevel::Prefer, s)),
223-
cli.exclude_role.as_deref().map(|s| (HintLevel::Exclude, s)),
224-
cli.role.as_deref().map(|s| (HintLevel::Fixed, s)),
225-
]
226-
.into_iter()
227-
.flatten()
228-
.collect();
229-
230-
match hints.len() {
231-
0 => Ok(None),
232-
1 => {
233-
let (level, role_str) = hints[0];
234-
let target = parse_role(role_str)?;
235-
Ok(Some(RoleHint {
236-
level: level.into(),
237-
target: target.into(),
238-
}))
239-
}
240-
_ => Err(ConfigError::HintFlagsConflict),
218+
let Some(ref role_str) = cli.role else {
219+
return Ok(None);
220+
};
221+
222+
if role_str == "auto" {
223+
return Ok(None);
241224
}
225+
226+
let (level, role_part) = if let Some((level_str, role)) = role_str.split_once(':') {
227+
let level = match level_str {
228+
"prefer" => HintLevel::Prefer,
229+
"exclude" => HintLevel::Exclude,
230+
"fixed" => HintLevel::Fixed,
231+
_ => return Err(ConfigError::InvalidRole(role_str.clone())),
232+
};
233+
(level, role)
234+
} else {
235+
// Bare role name = fixed
236+
(HintLevel::Fixed, role_str.as_str())
237+
};
238+
239+
let target = parse_role(role_part)?;
240+
Ok(Some(RoleHint {
241+
level: level.into(),
242+
target: target.into(),
243+
}))
242244
}
243245

244246
/// Resolve PSK from flag or `WALLHACK_PSK` environment variable.
@@ -370,23 +372,6 @@ mod tests {
370372
parse_cli_from_args(&v)
371373
}
372374

373-
#[test]
374-
fn mutually_exclusive_hint_flags() {
375-
let c = cli(&[
376-
"--prefer-role",
377-
"entry",
378-
"--role",
379-
"exit",
380-
"--listen",
381-
":6565",
382-
])
383-
.unwrap();
384-
assert_eq!(
385-
build_daemon_config(&c).unwrap_err(),
386-
ConfigError::HintFlagsConflict
387-
);
388-
}
389-
390375
#[test]
391376
fn role_entry_requires_tun() {
392377
// Only testable on machines without CAP_NET_ADMIN.
@@ -409,8 +394,8 @@ mod tests {
409394
}
410395

411396
#[test]
412-
fn valid_prefer_hint_produces_auto_config() {
413-
let c = cli(&["--prefer-role", "entry", "--listen", ":6565"]).unwrap();
397+
fn prefer_colon_syntax() {
398+
let c = cli(&["--role", "prefer:entry", "--listen", ":6565"]).unwrap();
414399
let config = build_daemon_config(&c).unwrap();
415400
match &config.mode {
416401
ModeConfig::Auto(auto) => {
@@ -422,12 +407,59 @@ mod tests {
422407
}
423408
}
424409

410+
#[test]
411+
fn exclude_colon_syntax() {
412+
let c = cli(&["--role", "exclude:relay", "--listen", ":6565"]).unwrap();
413+
let config = build_daemon_config(&c).unwrap();
414+
match &config.mode {
415+
ModeConfig::Auto(auto) => {
416+
let hint = auto.hint.as_ref().expect("hint should be set");
417+
assert_eq!(hint.level, i32::from(HintLevel::Exclude));
418+
assert_eq!(hint.target, i32::from(ProtoNodeRole::RoleRelay));
419+
}
420+
other => panic!("expected Auto, got {other:?}"),
421+
}
422+
}
423+
424+
#[test]
425+
fn bare_role_is_fixed() {
426+
let c = cli(&["--role", "exit", "--connect", "host:443"]).unwrap();
427+
let config = build_daemon_config(&c).unwrap();
428+
match &config.mode {
429+
ModeConfig::Auto(auto) => {
430+
let hint = auto.hint.as_ref().expect("hint should be set");
431+
assert_eq!(hint.level, i32::from(HintLevel::Fixed));
432+
assert_eq!(hint.target, i32::from(ProtoNodeRole::RoleExit));
433+
}
434+
other => panic!("expected Auto, got {other:?}"),
435+
}
436+
}
437+
438+
#[test]
439+
fn role_auto_clears_hint() {
440+
let c = cli(&["--role", "auto", "--listen", ":6565"]).unwrap();
441+
let config = build_daemon_config(&c).unwrap();
442+
match &config.mode {
443+
ModeConfig::Auto(auto) => assert!(auto.hint.is_none()),
444+
other => panic!("expected Auto, got {other:?}"),
445+
}
446+
}
447+
425448
#[test]
426449
fn invalid_role_string_rejected() {
427-
let c = cli(&["--prefer-role", "bogus", "--listen", ":6565"]).unwrap();
428-
assert_eq!(
450+
let c = cli(&["--role", "bogus", "--listen", ":6565"]).unwrap();
451+
assert!(matches!(
429452
build_daemon_config(&c).unwrap_err(),
430-
ConfigError::InvalidRole("bogus".to_string())
431-
);
453+
ConfigError::InvalidRole(_)
454+
));
455+
}
456+
457+
#[test]
458+
fn invalid_colon_level_rejected() {
459+
let c = cli(&["--role", "bogus:entry", "--listen", ":6565"]).unwrap();
460+
assert!(matches!(
461+
build_daemon_config(&c).unwrap_err(),
462+
ConfigError::InvalidRole(_)
463+
));
432464
}
433465
}

crates/cli/src/output.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,7 @@ pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> {
115115
if !s.listen_addr.is_empty() {
116116
println!("{:<18} {}", "listen addr:", s.listen_addr);
117117
}
118-
println!(
119-
"{:<18} tun={} listen={} connect={}",
120-
"capabilities:", s.tun_capable, s.listening, s.connecting
121-
);
118+
println!("{:<18} {}", "tun:", s.tun_capable);
122119
println!("{:<18} {}", "version:", s.version);
123120
println!("{:<18} {}", "uptime:", uptime);
124121
}

crates/daemon/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,13 @@ pub fn start_node(
137137
ModeConfig::Entry(_) => NodeRole::Entry,
138138
ModeConfig::Exit(_) => NodeRole::Exit,
139139
ModeConfig::Relay(_) => NodeRole::Relay,
140-
ModeConfig::Auto(_) => NodeRole::Indeterminate,
140+
ModeConfig::Auto(cfg) => match &cfg.hint {
141+
Some(hint) if hint.level == wallhack_wire::data::HintLevel::Fixed as i32 => {
142+
wallhack_wire::data::NodeRole::try_from(hint.target)
143+
.map_or(NodeRole::Indeterminate, NodeRole::from)
144+
}
145+
_ => NodeRole::Indeterminate,
146+
},
141147
};
142148

143149
let metrics = Arc::new(Metrics::default());

crates/mcp/src/convert.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ pub fn format_response(resp: &ManagementResponse) -> Result<String, String> {
1818
if !s.listen_addr.is_empty() {
1919
let _ = writeln!(out, "listen addr: {}", s.listen_addr);
2020
}
21-
let _ = writeln!(
22-
out,
23-
"capabilities: tun={} listen={} connect={}",
24-
s.tun_capable, s.listening, s.connecting,
25-
);
21+
let _ = writeln!(out, "tun: {}", s.tun_capable);
2622
let _ = writeln!(out, "version: {}", s.version);
2723
let _ = writeln!(out, "uptime: {}", format_uptime(s.uptime_ms));
2824
Ok(out)

0 commit comments

Comments
 (0)