Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

## TRIPLE PR Process for lead-agent only

Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
Implementation: `ConnectivitySpec::Listen(Vec<AddressSpec>)`, spawn one
tokio listener task per spec, `StatusResponse.listen_addr` → `repeated
string listen_addrs` in proto. CLI: argh `Vec<String>` for repeated
`--listen`. No known tunnel tool (e.g. ligolo) supports this.
`--listen`. No known tunnel tool supports this.
- [ ] HTTP/2 multiplexing
- [ ] Domain fronting support
- [ ] Deterministic TUN addresses based on peer identity
Expand Down
26 changes: 19 additions & 7 deletions crates/api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,21 @@ pub struct ListenResponse {
pub fingerprint: String,
}

/// Set hint request body.
/// Role set request body.
///
/// `role` is always required. `level` defaults to `"fixed"` if omitted,
/// matching the REPL behaviour where `role entry` means `role fixed entry`.
#[derive(Debug, Deserialize)]
pub struct HintSetRequestBody {
pub struct RoleSetRequestBody {
#[serde(default = "default_level")]
pub level: String,
pub role: String,
}

fn default_level() -> String {
"fixed".to_string()
}

/// Logs query parameters.
#[derive(Debug, Deserialize)]
pub struct LogsQuery {
Expand Down Expand Up @@ -655,10 +663,14 @@ pub async fn shutdown(State(state): State<ApiState>) -> (StatusCode, Json<Succes
}
}

pub async fn hint_set(
pub async fn role_set(
State(state): State<ApiState>,
Json(req): Json<HintSetRequestBody>,
Json(req): Json<RoleSetRequestBody>,
) -> (StatusCode, Json<SuccessResponse>) {
if req.role == "auto" {
return role_auto(State(state)).await;
}

let level = match req.level.as_str() {
"prefer" => HintLevel::Prefer,
"exclude" => HintLevel::Exclude,
Expand All @@ -669,7 +681,7 @@ pub async fn hint_set(
Json(SuccessResponse {
success: false,
message: Some(format!(
"invalid hint level '{}' (expected: prefer, exclude, fixed)",
"invalid level '{}' (expected: prefer, exclude, fixed)",
req.level
)),
}),
Expand All @@ -686,7 +698,7 @@ pub async fn hint_set(
Json(SuccessResponse {
success: false,
message: Some(format!(
"invalid role '{}' (expected: entry, exit, relay)",
"invalid role '{}' (expected: auto, entry, exit, relay)",
req.role
)),
}),
Expand Down Expand Up @@ -738,7 +750,7 @@ pub async fn hint_set(
}
}

pub async fn hint_set_auto(State(state): State<ApiState>) -> (StatusCode, Json<SuccessResponse>) {
pub async fn role_auto(State(state): State<ApiState>) -> (StatusCode, Json<SuccessResponse>) {
let resp = state
.ipc
.lock()
Expand Down
5 changes: 1 addition & 4 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@ pub fn router(state: State) -> Router {
.route("/listen", post(handlers::listen))
.route("/disconnect", post(handlers::disconnect))
.route("/shutdown", post(handlers::shutdown))
.route(
"/hints",
put(handlers::hint_set).delete(handlers::hint_set_auto),
)
.route("/role", put(handlers::role_set))
.layer(middleware::from_fn(move |req, next| {
let auth = auth.clone();
auth::middleware(auth, req, next)
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/bin/wallhack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ fn main() {
&& args.get(1).is_some_and(|a| {
// "wallhack daemon" passthrough.
a == "daemon"
// Any flag argument: auto-negotiation or global daemon options.
// Any flag argument EXCEPT -H/--host (shared with ctl CLI).
// Control client commands are always bare words, never flags.
|| a.starts_with('-')
|| (a.starts_with('-') && *a != "-H" && *a != "--host")
}));

if is_daemon {
Expand Down
144 changes: 88 additions & 56 deletions crates/cli/src/daemon_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,7 @@ pub struct WallhackCli {
#[argh(option)]
pub max_peers: Option<usize>,

/// prefer a role during auto-negotiation (entry, exit, relay)
#[argh(option)]
pub prefer_role: Option<String>,

/// exclude a role during auto-negotiation (entry, exit, relay)
#[argh(option)]
pub exclude_role: Option<String>,

/// override the negotiated role (entry, exit, relay)
/// set role: entry, exit, relay, prefer:entry, exclude:relay, or auto
#[argh(option)]
pub role: Option<String>,

Expand Down Expand Up @@ -162,13 +154,13 @@ pub struct CliError {
/// Configuration build error.
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum ConfigError {
#[error("--prefer, --exclude-role, and --role are mutually exclusive")]
HintFlagsConflict,
#[error("--role entry requires TUN capability (CAP_NET_ADMIN)")]
RoleEntryRequiresTun,
#[error("--role relay requires both --connect and --listen")]
RoleRelayRequiresConnectAndListen,
#[error("invalid role '{0}': expected 'entry', 'exit', or 'relay'")]
#[error(
"invalid role '{0}': expected entry, exit, relay, prefer:entry, exclude:relay, or auto"
)]
InvalidRole(String),
#[error("invalid address '{0}'")]
InvalidAddress(String),
Expand Down Expand Up @@ -216,29 +208,39 @@ fn parse_role(s: &str) -> Result<ProtoNodeRole, ConfigError> {
}
}

/// Build a `RoleHint` from the mutually-exclusive hint CLI flags.
/// Parse `--role` value into a `RoleHint`.
///
/// Accepted formats:
/// - `entry` / `exit` / `relay` — fixed role (shorthand for `fixed:entry`)
/// - `prefer:entry` / `exclude:relay` / `fixed:exit` — explicit level
/// - `auto` — returns `None` (clear all hints)
fn resolve_hint(cli: &WallhackCli) -> Result<Option<RoleHint>, ConfigError> {
let hints: Vec<_> = [
cli.prefer_role.as_deref().map(|s| (HintLevel::Prefer, s)),
cli.exclude_role.as_deref().map(|s| (HintLevel::Exclude, s)),
cli.role.as_deref().map(|s| (HintLevel::Fixed, s)),
]
.into_iter()
.flatten()
.collect();

match hints.len() {
0 => Ok(None),
1 => {
let (level, role_str) = hints[0];
let target = parse_role(role_str)?;
Ok(Some(RoleHint {
level: level.into(),
target: target.into(),
}))
}
_ => Err(ConfigError::HintFlagsConflict),
let Some(ref role_str) = cli.role else {
return Ok(None);
};

if role_str == "auto" {
return Ok(None);
}

let (level, role_part) = if let Some((level_str, role)) = role_str.split_once(':') {
let level = match level_str {
"prefer" => HintLevel::Prefer,
"exclude" => HintLevel::Exclude,
"fixed" => HintLevel::Fixed,
_ => return Err(ConfigError::InvalidRole(role_str.clone())),
};
(level, role)
} else {
// Bare role name = fixed
(HintLevel::Fixed, role_str.as_str())
};

let target = parse_role(role_part)?;
Ok(Some(RoleHint {
level: level.into(),
target: target.into(),
}))
}

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

#[test]
fn mutually_exclusive_hint_flags() {
let c = cli(&[
"--prefer-role",
"entry",
"--role",
"exit",
"--listen",
":6565",
])
.unwrap();
assert_eq!(
build_daemon_config(&c).unwrap_err(),
ConfigError::HintFlagsConflict
);
}

#[test]
fn role_entry_requires_tun() {
// Only testable on machines without CAP_NET_ADMIN.
Expand All @@ -409,8 +394,8 @@ mod tests {
}

#[test]
fn valid_prefer_hint_produces_auto_config() {
let c = cli(&["--prefer-role", "entry", "--listen", ":6565"]).unwrap();
fn prefer_colon_syntax() {
let c = cli(&["--role", "prefer:entry", "--listen", ":6565"]).unwrap();
let config = build_daemon_config(&c).unwrap();
match &config.mode {
ModeConfig::Auto(auto) => {
Expand All @@ -422,12 +407,59 @@ mod tests {
}
}

#[test]
fn exclude_colon_syntax() {
let c = cli(&["--role", "exclude:relay", "--listen", ":6565"]).unwrap();
let config = build_daemon_config(&c).unwrap();
match &config.mode {
ModeConfig::Auto(auto) => {
let hint = auto.hint.as_ref().expect("hint should be set");
assert_eq!(hint.level, i32::from(HintLevel::Exclude));
assert_eq!(hint.target, i32::from(ProtoNodeRole::RoleRelay));
}
other => panic!("expected Auto, got {other:?}"),
}
}

#[test]
fn bare_role_is_fixed() {
let c = cli(&["--role", "exit", "--connect", "host:443"]).unwrap();
let config = build_daemon_config(&c).unwrap();
match &config.mode {
ModeConfig::Auto(auto) => {
let hint = auto.hint.as_ref().expect("hint should be set");
assert_eq!(hint.level, i32::from(HintLevel::Fixed));
assert_eq!(hint.target, i32::from(ProtoNodeRole::RoleExit));
}
other => panic!("expected Auto, got {other:?}"),
}
}

#[test]
fn role_auto_clears_hint() {
let c = cli(&["--role", "auto", "--listen", ":6565"]).unwrap();
let config = build_daemon_config(&c).unwrap();
match &config.mode {
ModeConfig::Auto(auto) => assert!(auto.hint.is_none()),
other => panic!("expected Auto, got {other:?}"),
}
}

#[test]
fn invalid_role_string_rejected() {
let c = cli(&["--prefer-role", "bogus", "--listen", ":6565"]).unwrap();
assert_eq!(
let c = cli(&["--role", "bogus", "--listen", ":6565"]).unwrap();
assert!(matches!(
build_daemon_config(&c).unwrap_err(),
ConfigError::InvalidRole("bogus".to_string())
);
ConfigError::InvalidRole(_)
));
}

#[test]
fn invalid_colon_level_rejected() {
let c = cli(&["--role", "bogus:entry", "--listen", ":6565"]).unwrap();
assert!(matches!(
build_daemon_config(&c).unwrap_err(),
ConfigError::InvalidRole(_)
));
}
}
5 changes: 1 addition & 4 deletions crates/cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,7 @@ pub fn print_response(resp: &ManagementResponse) -> Result<(), CtlError> {
if !s.listen_addr.is_empty() {
println!("{:<18} {}", "listen addr:", s.listen_addr);
}
println!(
"{:<18} tun={} listen={} connect={}",
"capabilities:", s.tun_capable, s.listening, s.connecting
);
println!("{:<18} {}", "tun:", s.tun_capable);
println!("{:<18} {}", "version:", s.version);
println!("{:<18} {}", "uptime:", uptime);
}
Expand Down
8 changes: 7 additions & 1 deletion crates/daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,13 @@ pub fn start_node(
ModeConfig::Entry(_) => NodeRole::Entry,
ModeConfig::Exit(_) => NodeRole::Exit,
ModeConfig::Relay(_) => NodeRole::Relay,
ModeConfig::Auto(_) => NodeRole::Indeterminate,
ModeConfig::Auto(cfg) => match &cfg.hint {
Some(hint) if hint.level == wallhack_wire::data::HintLevel::Fixed as i32 => {
wallhack_wire::data::NodeRole::try_from(hint.target)
.map_or(NodeRole::Indeterminate, NodeRole::from)
}
_ => NodeRole::Indeterminate,
},
};

let metrics = Arc::new(Metrics::default());
Expand Down
6 changes: 1 addition & 5 deletions crates/mcp/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ pub fn format_response(resp: &ManagementResponse) -> Result<String, String> {
if !s.listen_addr.is_empty() {
let _ = writeln!(out, "listen addr: {}", s.listen_addr);
}
let _ = writeln!(
out,
"capabilities: tun={} listen={} connect={}",
s.tun_capable, s.listening, s.connecting,
);
let _ = writeln!(out, "tun: {}", s.tun_capable);
let _ = writeln!(out, "version: {}", s.version);
let _ = writeln!(out, "uptime: {}", format_uptime(s.uptime_ms));
Ok(out)
Expand Down
Loading