use std::path::PathBuf;
use std::time::Duration;
use clap::{Args, Subcommand};
use net_sdk::bootstrap_credential::{BrowserBootstrapCredential, Psk};
use net_sdk::enrollment::InviteToken;
use serde::Serialize;
use crate::commands::identity::{parse_entity_hex, read_identity_file};
use crate::commands::org::{publish_staged, stage_beside};
use crate::error::{generic, invalid_args, CliError};
use crate::parsers::hex_decode_32;
use crate::prelude::{emit_value, OutputFormat};
const DEFAULT_PSK_TTL_SECS: u64 = 30 * 24 * 3600;
const DEFAULT_INVITE_TTL_SECS: u64 = 15 * 60;
#[derive(Subcommand, Debug)]
pub enum AnchorCommand {
#[command(subcommand)]
Credential(CredentialCommand),
#[cfg(feature = "rtc-bootstrap")]
Ls(LsArgs),
#[cfg(feature = "rtc-bootstrap")]
Stats(StatsArgs),
#[cfg(feature = "rtc-bootstrap")]
Serve(Box<ServeArgs>),
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(Args, Debug)]
pub struct LsArgs {
#[arg(long, value_name = "PATH")]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
#[arg(long = "wait-secs", default_value_t = 5)]
pub wait_secs: u64,
#[command(flatten)]
pub remote: crate::commands::aggregator::RemoteAttachArgs,
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(Args, Debug)]
pub struct StatsArgs {
#[arg(long, value_name = "PATH")]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
#[arg(long = "wait-secs", default_value_t = 5)]
pub wait_secs: u64,
#[command(flatten)]
pub remote: crate::commands::aggregator::RemoteAttachArgs,
}
#[derive(Subcommand, Debug)]
pub enum CredentialCommand {
Mint(MintArgs),
Inspect(InspectArgs),
}
#[derive(Args, Debug)]
pub struct MintArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long, value_name = "HEX")]
pub root: String,
#[arg(long = "issuer-identity", value_name = "PATH")]
pub issuer_identity: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
#[arg(long = "anchor-noise-pubkey", value_name = "HEX")]
pub anchor_noise_pubkey: String,
#[arg(long = "psk-hex", value_name = "HEX", conflicts_with = "psk_file")]
pub psk_hex: Option<String>,
#[arg(long = "psk-file", value_name = "PATH")]
pub psk_file: Option<PathBuf>,
#[arg(long, value_name = "URL")]
pub url: String,
#[arg(long = "invite-ttl-secs", default_value_t = DEFAULT_INVITE_TTL_SECS)]
pub invite_ttl_secs: u64,
#[arg(long = "psk-ttl-secs", default_value_t = DEFAULT_PSK_TTL_SECS)]
pub psk_ttl_secs: u64,
#[arg(long, value_name = "PATH")]
pub out: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct InspectArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long, value_name = "STRING|@PATH")]
pub credential: String,
#[arg(long = "psk-hex", value_name = "HEX")]
pub psk_hex: Option<String>,
}
#[derive(Serialize)]
struct MintReport {
credential: String,
trust_domain: String,
issuer: String,
bootstrap_url: String,
nonce_expires_at: u64,
psk_expires_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
written_to: Option<String>,
}
#[derive(Serialize)]
struct InspectReport {
root: String,
rendezvous: String,
bootstrap_url: String,
anchor_noise_pubkey: String,
trust_domain: String,
issuer: String,
nonce_expires_at: u64,
psk_expires_at: u64,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
trust_domain_matches: Option<bool>,
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(serde::Serialize)]
struct AnchorRow {
node: String,
rtc_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rtc_stun_addr: Option<String>,
rtc_bootstrap: Option<String>,
noise_pubkey: Option<String>,
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(serde::Serialize)]
struct IceStatsRow {
node: String,
rtc_configured: bool,
ice_attempted: u64,
ice_direct: u64,
ice_relayed: u64,
ice_failed: u64,
ice_pending: u64,
ice_direct_ratio: Option<f64>,
ice_direct_display: String,
denominator: &'static str,
caveat: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
bootstrap_rate_limited: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
bootstrap_rate_table_full: Option<u64>,
}
#[cfg(feature = "rtc-bootstrap")]
const ICE_DENOMINATOR: &str = "ice_attempted = direct-path ATTEMPTS, one per signalling dialog \
(the offer this anchor sent, or an offer it accepted — including each browser's bootstrap \
dialog, and on a browser leaf its own bootstrap dialog with its anchor, so a page that went \
direct with one peer reports TWO attempts). Not sessions, not peers: a retry after a timeout \
spends two attempts, and an ICE restart inside one dialog is one. PER PARTICIPANT, never per \
system: this is THIS node's own ledger. Two browsers going direct through one anchor is three \
dialogs in the system, and no counter reports three — each browser reports two and this \
anchor reports two. Summing ledgers across nodes double-counts every pair dialog.";
#[cfg(feature = "rtc-bootstrap")]
const ICE_CAVEAT: &str = "Reported, never gated. NOT a success rate for sessions: a relayed \
session is not a failed one — the routed path through an anchor is a supported disposition, \
and ICE reaching `connected` is not a health gate. NOT a per-pair fact either: ask the pair \
(`peer_endpoint`) whether a given pair is direct. `udp_blocked` is absent because a node \
signalling over UDP cannot have UDP blocked; that term belongs to the browser leaf.";
pub async fn run(
cmd: AnchorCommand,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
match cmd {
AnchorCommand::Credential(CredentialCommand::Mint(args)) if args.inspect_target => {
let psk_source = psk_source(&args)?;
let issuer = load_credential_issuer(&args).await?;
let profile = crate::context::resolve_profile(config_path, profile_name).await?;
let mut target = crate::target::TargetInspection::local(&profile, "offline");
target.configured_identity(issuer.entity_id().as_bytes());
target.source = Some(args.issuer_identity);
target.destination = args.out;
target.provenance("identity", "flag");
target.provenance("source", "flag");
target.provenance(
"destination",
if target.destination.is_some() {
"flag"
} else {
"stdout"
},
);
target.provenance("psk", psk_source);
emit_value(
OutputFormat::resolve_oneshot(output),
&CredentialTargetInspection {
target,
psk_source,
psk_file: args.psk_file,
credential_stdout_on_execution: true,
},
)
.map_err(|e| generic(format!("write inspection: {e}")))
}
AnchorCommand::Credential(CredentialCommand::Inspect(args)) if args.inspect_target => {
let profile = crate::context::resolve_profile(config_path, profile_name).await?;
let mut target = crate::target::TargetInspection::local(&profile, "offline");
if let Some(path) = args.credential.strip_prefix('@') {
target.source = Some(PathBuf::from(path));
target.provenance("source", "file");
} else {
target.provenance("source", "inline");
}
let psk_source = if args.psk_hex.is_some() {
"inline"
} else {
"unused"
};
target.provenance("psk", psk_source);
emit_value(
OutputFormat::resolve_oneshot(output),
&CredentialTargetInspection {
target,
psk_source,
psk_file: None,
credential_stdout_on_execution: false,
},
)
.map_err(|e| generic(format!("write inspection: {e}")))
}
AnchorCommand::Credential(CredentialCommand::Mint(args)) => run_mint(args, output).await,
AnchorCommand::Credential(CredentialCommand::Inspect(args)) => {
run_inspect(args, output).await
}
#[cfg(feature = "rtc-bootstrap")]
AnchorCommand::Ls(args) => run_ls(args, output, config_path, profile_name).await,
#[cfg(feature = "rtc-bootstrap")]
AnchorCommand::Stats(args) => run_stats(args, output, config_path, profile_name).await,
#[cfg(feature = "rtc-bootstrap")]
AnchorCommand::Serve(args) => run_serve(*args, output, config_path, profile_name).await,
}
}
#[derive(Serialize)]
struct CredentialTargetInspection {
#[serde(flatten)]
target: crate::target::TargetInspection,
psk_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
psk_file: Option<PathBuf>,
credential_stdout_on_execution: bool,
}
fn psk_source(args: &MintArgs) -> Result<&'static str, CliError> {
match (&args.psk_file, &args.psk_hex) {
(Some(_), _) => Ok("file"),
(None, Some(_)) => Ok("inline"),
(None, None) => Err(invalid_args(
"one of --psk-hex or --psk-file is required: the credential IS the PSK plus an invite",
)),
}
}
async fn load_credential_issuer(args: &MintArgs) -> Result<net_sdk::identity::Identity, CliError> {
let issuer_file = read_identity_file(&args.issuer_identity, args.insecure_permissions).await?;
let seed = hex_decode_32(&issuer_file.seed_hex)
.map_err(|e| invalid_args(format!("--issuer-identity: seed_hex: {e}")))?;
Ok(net_sdk::identity::Identity::from_seed(seed))
}
#[cfg(feature = "rtc-bootstrap")]
async fn run_ls(
args: LsArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
use crate::context::{require_remote_attach, resolve_profile, CliContext};
let profile = resolve_profile(config_path, profile_name).await?;
let remote = require_remote_attach(&profile, &args.remote, || {
invalid_args("anchor ls needs a live mesh target: pass --node-addr/--node-pubkey/--node-id/--psk-hex or set profile defaults")
})?;
if args.remote.inspect_target {
return crate::target::inspect(
&profile,
&args.remote,
args.identity.as_deref(),
Some(&remote),
"remote",
)
.await?
.emit(output);
}
let target = remote.node_id;
let ctx =
CliContext::build_with_remote(&profile, args.identity.as_deref(), args.node, false, remote)
.await?;
let mesh = ctx.require_mesh()?;
let raw = tokio::time::timeout(
std::time::Duration::from_secs(args.wait_secs.max(1)),
mesh.call_raw_bytes(
target,
net_sdk::rtc_bootstrap::ANCHOR_DIRECTORY_SERVICE,
Vec::new(),
),
)
.await
.map_err(|_| generic("the anchor directory did not answer in time"))?
.map_err(|e| {
generic(format!(
"the anchor directory did not answer ({e}) — is this node running \
`net-mesh anchor serve`?"
))
})?;
let rows: Vec<AnchorRow> =
serde_json::from_slice::<Vec<net_sdk::rtc_bootstrap::AnchorDirectoryRow>>(&raw)
.map_err(|e| generic(format!("the anchor directory's reply did not parse: {e}")))?
.into_iter()
.map(|row| AnchorRow {
node: row.node,
rtc_addr: row.rtc_addr,
rtc_stun_addr: row.rtc_stun_addr,
rtc_bootstrap: row.rtc_bootstrap,
noise_pubkey: row.noise_pubkey,
})
.collect();
emit_value(OutputFormat::resolve_oneshot(output), &rows)
.map_err(|e| generic(format!("write anchor ls: {e}")))?;
Ok(())
}
#[cfg(feature = "rtc-bootstrap")]
async fn run_stats(
args: StatsArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
use crate::context::{require_remote_attach, resolve_profile, CliContext};
let profile = resolve_profile(config_path, profile_name).await?;
let remote = require_remote_attach(&profile, &args.remote, || {
invalid_args("anchor stats needs a live mesh target: pass --node-addr/--node-pubkey/--node-id/--psk-hex or set profile defaults")
})?;
if args.remote.inspect_target {
return crate::target::inspect(
&profile,
&args.remote,
args.identity.as_deref(),
Some(&remote),
"remote",
)
.await?
.emit(output);
}
let target = remote.node_id;
let ctx =
CliContext::build_with_remote(&profile, args.identity.as_deref(), args.node, false, remote)
.await?;
let mesh = ctx.require_mesh()?;
let raw = tokio::time::timeout(
Duration::from_secs(args.wait_secs.max(1)),
mesh.call_raw_bytes(
target,
net_sdk::rtc_bootstrap::ANCHOR_ICE_STATS_SERVICE,
Vec::new(),
),
)
.await
.map_err(|_| generic("the anchor's ICE stats did not answer in time"))?
.map_err(|e| {
generic(format!(
"the anchor's ICE stats did not answer ({e}) — is this node running \
`net-mesh anchor serve`?"
))
})?;
let stats = serde_json::from_slice::<net_sdk::rtc_bootstrap::AnchorIceStats>(&raw)
.map_err(|e| generic(format!("the anchor's ICE stats reply did not parse: {e}")))?;
let row = IceStatsRow {
ice_direct_display: ice_direct_display(&stats),
node: stats.node,
rtc_configured: stats.rtc_configured,
ice_attempted: stats.attempted,
ice_direct: stats.direct,
ice_relayed: stats.relayed,
ice_failed: stats.failed,
ice_pending: stats.pending,
ice_direct_ratio: stats.direct_ratio,
denominator: ICE_DENOMINATOR,
caveat: ICE_CAVEAT,
bootstrap_rate_limited: stats.bootstrap_rate_limited,
bootstrap_rate_table_full: stats.bootstrap_rate_table_full,
};
emit_value(OutputFormat::resolve_oneshot(output), &row)
.map_err(|e| generic(format!("write anchor stats: {e}")))?;
Ok(())
}
#[cfg(feature = "rtc-bootstrap")]
fn ice_direct_display(stats: &net_sdk::rtc_bootstrap::AnchorIceStats) -> String {
if !stats.rtc_configured {
return "no rtc driver — this node keeps no attempt ledger".to_string();
}
let Some(ratio) = stats.direct_ratio else {
return "no attempts yet — no direct-path ratio exists (this is not 0%)".to_string();
};
let head = format!(
"{}/{} attempts ({}%)",
stats.direct,
stats.attempted,
(ratio * 100.0).round() as u64
);
if stats.pending == 0 {
head
} else {
format!(
"{head}, {} attempt(s) still in flight — the outcome terms sum to \
ice_attempted only once that is 0",
stats.pending
)
}
}
async fn run_mint(args: MintArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let root = parse_entity_hex(&args.root)?;
let anchor_noise_pubkey = hex_decode_32(&args.anchor_noise_pubkey)
.map_err(|e| invalid_args(format!("--anchor-noise-pubkey: {e}")))?;
let psk = Psk::new(read_psk(&args).await?);
if args.invite_ttl_secs == 0 || args.psk_ttl_secs == 0 {
return Err(invalid_args(
"a zero TTL mints a credential that is already expired",
));
}
let issuer = load_credential_issuer(&args).await?;
let invite = InviteToken::mint(
&root,
args.url.clone(),
Duration::from_secs(args.invite_ttl_secs),
);
let credential = BrowserBootstrapCredential::mint(
&issuer,
invite,
anchor_noise_pubkey,
psk,
args.url.clone(),
Duration::from_secs(args.psk_ttl_secs),
);
let encoded = credential.encode();
let parsed = BrowserBootstrapCredential::decode(&encoded).map_err(|e| {
invalid_args(format!(
"refusing to emit a credential that does not parse back: {e}"
))
})?;
let written_to = match args.out.as_ref() {
None => None,
Some(path) => {
if path.exists() && !args.force {
return Err(generic(format!(
"{} already exists; pass --force to overwrite",
path.display()
)));
}
let tmp = stage_beside(path, encoded.as_bytes(), true).await?;
publish_staged_or_replace(&tmp, path, args.force).await?;
Some(path.display().to_string())
}
};
emit_value(
OutputFormat::resolve_oneshot(output),
&MintReport {
credential: encoded,
trust_domain: parsed.trust_domain.to_string(),
issuer: hex_string(parsed.issuer.as_bytes()),
bootstrap_url: parsed.bootstrap_url.clone(),
nonce_expires_at: parsed.nonce_expires_at(),
psk_expires_at: parsed.psk_expires_at(),
written_to,
},
)
.map_err(|e| generic(format!("write anchor credential mint: {e}")))?;
Ok(())
}
async fn run_inspect(args: InspectArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let raw = match args.credential.strip_prefix('@') {
Some(path) => tokio::fs::read_to_string(path)
.await
.map_err(|e| invalid_args(format!("--credential @{path}: {e}")))?,
None => args.credential.clone(),
};
let credential = BrowserBootstrapCredential::decode(&raw)
.map_err(|e| invalid_args(format!("--credential: {e}")))?;
let trust_domain_matches = match args.psk_hex.as_deref() {
None => None,
Some(hex) => {
let psk =
Psk::new(hex_decode_32(hex).map_err(|e| invalid_args(format!("--psk-hex: {e}")))?);
Some(credential.check_trust_domain(&psk).is_ok())
}
};
let status = match credential.validate() {
Ok(()) => "ok".to_string(),
Err(e) => e.to_string(),
};
emit_value(
OutputFormat::resolve_oneshot(output),
&InspectReport {
root: hex_string(credential.root().as_bytes()),
rendezvous: credential.invite.rendezvous.clone(),
bootstrap_url: credential.bootstrap_url.clone(),
anchor_noise_pubkey: hex_string(&credential.anchor_noise_pubkey),
trust_domain: credential.trust_domain.to_string(),
issuer: hex_string(credential.issuer.as_bytes()),
nonce_expires_at: credential.nonce_expires_at(),
psk_expires_at: credential.psk_expires_at(),
status,
trust_domain_matches,
},
)
.map_err(|e| generic(format!("write anchor credential inspect: {e}")))?;
Ok(())
}
async fn read_psk(args: &MintArgs) -> Result<[u8; 32], CliError> {
psk_source(args)?;
let hex = match (args.psk_file.as_ref(), args.psk_hex.as_ref()) {
(Some(path), _) => tokio::fs::read_to_string(path)
.await
.map_err(|e| invalid_args(format!("--psk-file {}: {e}", path.display())))?,
(None, Some(hex)) => hex.clone(),
(None, None) => return Err(invalid_args(
"one of --psk-hex or --psk-file is required: the credential IS the PSK plus an invite",
)),
};
hex_decode_32(hex.trim()).map_err(|e| invalid_args(format!("psk: {e}")))
}
async fn publish_staged_or_replace(
tmp: &std::path::Path,
final_path: &std::path::Path,
force: bool,
) -> Result<(), CliError> {
if force && final_path.exists() {
let tmp_owned = tmp.to_path_buf();
let final_owned = final_path.to_path_buf();
return tokio::task::spawn_blocking(move || std::fs::rename(&tmp_owned, &final_owned))
.await
.map_err(|e| generic(format!("publish task panicked: {e}")))?
.map_err(|e| generic(format!("publishing the credential failed: {e}")));
}
publish_staged(tmp, final_path).await
}
fn hex_string(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(Args, Debug)]
pub struct ServeArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long, value_name = "ADDR")]
pub bind: Option<String>,
#[arg(long = "psk-file", value_name = "PATH")]
pub psk_file: PathBuf,
#[arg(long = "listen", value_name = "ADDR")]
pub listen: Vec<String>,
#[arg(long = "url", value_name = "URL")]
pub url: String,
#[arg(long = "rtc-public-addr", value_name = "ADDR")]
pub rtc_public_addr: Vec<String>,
#[arg(long = "rtc-bind", value_name = "ADDR")]
pub rtc_bind: Vec<String>,
#[arg(long = "rtc-stun-bind", value_name = "ADDR")]
pub rtc_stun_bind: Vec<String>,
#[arg(long = "rtc-stun-public-addr", value_name = "ADDR")]
pub rtc_stun_public_addr: Vec<String>,
#[arg(long = "rtc-max-peers", value_name = "N")]
pub rtc_max_peers: Option<usize>,
#[arg(long = "tls-cert", value_name = "PATH", requires = "tls_key")]
pub tls_cert: Option<PathBuf>,
#[arg(long = "tls-key", value_name = "PATH")]
pub tls_key: Option<PathBuf>,
#[arg(
long = "acme-directory",
value_name = "URL",
conflicts_with = "tls_cert"
)]
pub acme_directory: Option<String>,
#[arg(long = "acme-email", value_name = "EMAIL")]
pub acme_email: Option<String>,
#[arg(long = "acme-cache", value_name = "DIR")]
pub acme_cache: Option<PathBuf>,
#[arg(long = "acme-challenge-addr", value_name = "ADDR")]
pub acme_challenge_addr: Vec<String>,
#[arg(
long = "credential-issuer",
value_name = "HEX",
required_unless_present = "issuer_identity"
)]
pub credential_issuer: Option<String>,
#[arg(long = "issuer-identity", value_name = "PATH")]
pub issuer_identity: Option<PathBuf>,
#[arg(long)]
pub insecure_permissions: bool,
#[arg(
long = "game",
value_name = "ID[:PER_MINUTE]",
requires = "issuer_identity"
)]
pub game: Vec<String>,
#[arg(
long = "open-games",
value_name = "STATE_FILE",
requires = "issuer_identity"
)]
pub open_games: Option<PathBuf>,
#[arg(long = "open-games-max", value_name = "N", requires = "open_games")]
pub open_games_max: Option<usize>,
#[arg(
long = "open-games-per-minute",
value_name = "N",
requires = "open_games"
)]
pub open_games_per_minute: Option<u32>,
#[arg(
long = "open-game-max-players",
value_name = "N",
requires = "open_games"
)]
pub open_game_max_players: Option<usize>,
#[arg(long = "credentials-per-minute")]
pub credentials_per_minute: Option<u32>,
#[arg(long = "game-stats-secs", default_value_t = 0)]
pub game_stats_secs: u64,
#[arg(
long = "allow-origin",
value_name = "ORIGIN",
required_unless_present = "open_games"
)]
pub allow_origin: Vec<String>,
#[arg(long = "offers-per-minute")]
pub offers_per_minute: Option<u32>,
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(serde::Serialize)]
struct ServeReport {
node: String,
listening_on: String,
bootstrap_url: String,
rtc_addr: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rtc_stun_addr: Option<String>,
trust_domain: String,
noise_pubkey: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
games: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
open_games: bool,
#[serde(skip_serializing_if = "Option::is_none")]
credential_endpoint: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
also_listening_on: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rtc_addrs: Option<Vec<String>>,
}
#[cfg(feature = "rtc-bootstrap")]
fn parse_games(values: &[String]) -> Result<Vec<net_sdk::game_anchor::GameConfig>, CliError> {
values
.iter()
.map(|value| {
let (id, limit) = match value.split_once(':') {
Some((id, n)) => (
id,
Some(n.parse::<u32>().map_err(|_| {
invalid_args(format!("--game {value}: the per-minute ceiling is not a number"))
})?),
),
None => (value.as_str(), None),
};
if !net_sdk::game_anchor::is_valid_game_id(id) {
return Err(invalid_args(format!(
"--game {id:?}: use lowercase letters, digits, '.', '-' and '_' (at most {} bytes)",
net_sdk::game_anchor::MAX_GAME_ID_LEN
)));
}
let mut config = net_sdk::game_anchor::GameConfig::new(id);
if let Some(limit) = limit {
config.issue_per_minute = limit;
}
Ok(config)
})
.collect()
}
#[cfg(feature = "rtc-bootstrap")]
fn one_per_family(
flag: &str,
values: &[String],
) -> Result<(Option<std::net::SocketAddr>, Option<std::net::SocketAddr>), CliError> {
let mut v4 = None;
let mut v6 = None;
for value in values {
let addr: std::net::SocketAddr = value
.parse()
.map_err(|e| invalid_args(format!("{flag} {value}: {e}")))?;
let slot = if addr.is_ipv4() { &mut v4 } else { &mut v6 };
if let Some(previous) = slot.replace(addr) {
return Err(invalid_args(format!(
"{flag} is given twice for one address family ({previous} and {addr}); it \
takes at most one IPv4 and one IPv6 address"
)));
}
}
Ok((v4, v6))
}
#[cfg(feature = "rtc-bootstrap")]
fn addrs_or_default(
flag: &str,
values: &[String],
default: &str,
) -> Result<Vec<std::net::SocketAddr>, CliError> {
if values.is_empty() {
return Ok(vec![default.parse().map_err(|e| {
invalid_args(format!("{flag} default {default}: {e}"))
})?]);
}
values
.iter()
.map(|value| {
value
.parse()
.map_err(|e| invalid_args(format!("{flag} {value}: {e}")))
})
.collect()
}
#[cfg(feature = "rtc-bootstrap")]
const DEFAULT_ANCHOR_MAX_PEERS: usize = 1024;
#[cfg(feature = "rtc-bootstrap")]
fn rtc_config_from_args(args: &ServeArgs) -> Result<net::adapter::net::rtc::RtcConfig, CliError> {
let mut rtc = net::adapter::net::rtc::RtcConfig::new().with_bootstrap_url(args.url.clone());
let max_peers = args.rtc_max_peers.unwrap_or(DEFAULT_ANCHOR_MAX_PEERS);
if max_peers == 0 {
return Err(invalid_args("--rtc-max-peers must be at least 1"));
}
rtc.max_peers = max_peers;
rtc.serve_stun = true;
let (bind, bind_v6) = one_per_family("--rtc-bind", &args.rtc_bind)?;
let (public, public_v6) = one_per_family("--rtc-public-addr", &args.rtc_public_addr)?;
match (bind, bind_v6) {
(Some(bind), Some(bind_v6)) => {
rtc.bind_addr = Some(bind);
rtc.bind_addr_v6 = Some(bind_v6);
rtc.public_addr = public;
rtc.public_addr_v6 = public_v6;
}
(single, single_v6) => {
rtc.bind_addr = single.or(single_v6);
if public.is_some() && public_v6.is_some() {
return Err(invalid_args(
"--rtc-public-addr names an IPv4 and an IPv6 address, but only one --rtc-bind socket exists; give an --rtc-bind of each family for a dual-stack anchor",
));
}
rtc.public_addr = public.or(public_v6);
}
}
let (stun_bind, stun_bind_v6) = one_per_family("--rtc-stun-bind", &args.rtc_stun_bind)?;
let (stun_public, stun_public_v6) =
one_per_family("--rtc-stun-public-addr", &args.rtc_stun_public_addr)?;
match (stun_bind, stun_bind_v6) {
(Some(bind), Some(bind_v6)) => {
rtc.stun_addr = Some(bind);
rtc.stun_addr_v6 = Some(bind_v6);
rtc.stun_public_addr = stun_public;
rtc.stun_public_addr_v6 = stun_public_v6;
}
(single, single_v6) => {
rtc.stun_addr = single.or(single_v6);
if stun_public.is_some() && stun_public_v6.is_some() {
return Err(invalid_args(
"--rtc-stun-public-addr names an IPv4 and an IPv6 address, but only one \
--rtc-stun-bind socket exists; give an --rtc-stun-bind of each family",
));
}
rtc.stun_public_addr = stun_public.or(stun_public_v6);
}
}
Ok(rtc)
}
#[cfg(feature = "rtc-bootstrap")]
fn resolve_serve(args: &ServeArgs) -> Result<ResolvedServe, CliError> {
use net_sdk::rtc_bootstrap::{AcmeConfig, BootstrapTls};
let bind: std::net::SocketAddr = args
.bind
.as_deref()
.unwrap_or("0.0.0.0:0")
.parse()
.map_err(|e| invalid_args(format!("--bind: {e}")))?;
let listen = addrs_or_default("--listen", &args.listen, "0.0.0.0:8443")?;
let issuer = args
.credential_issuer
.as_deref()
.map(parse_entity_hex)
.transpose()?;
parse_games(&args.game)?;
let challenge = addrs_or_default(
"--acme-challenge-addr",
&args.acme_challenge_addr,
"0.0.0.0:80",
)?;
let tls = match (&args.tls_cert, &args.tls_key, &args.acme_directory) {
(Some(cert), Some(key), None) => BootstrapTls::Operator {
cert_pem: cert.clone(),
key_pem: key.clone(),
},
(None, _, Some(directory)) => {
let domain = args
.url
.trim_start_matches("https://")
.split('/')
.next()
.unwrap_or_default()
.to_string();
let acme = AcmeConfig::new(
directory.clone(),
domain,
args.acme_email.clone().ok_or_else(|| {
invalid_args("--acme-email is required with --acme-directory")
})?,
args.acme_cache
.clone()
.unwrap_or_else(|| std::env::temp_dir().join("net-mesh-acme")),
);
BootstrapTls::Acme(acme)
}
_ => {
return Err(invalid_args(
"browser-trusted TLS is required: pass --tls-cert/--tls-key, or \
--acme-directory/--acme-email. There is no self-signed mode, because \
a browser refuses one",
))
}
};
let mut rtc = rtc_config_from_args(args)?;
rtc.bind_addr
.get_or_insert_with(|| std::net::SocketAddr::new(bind.ip(), 0));
if rtc.stun_public_addr.is_some() && rtc.stun_addr.is_none() {
return Err(invalid_args(
"--rtc-stun-public-addr requires --rtc-stun-bind",
));
}
Ok(ResolvedServe {
bind,
listen,
issuer,
challenge,
tls,
rtc,
})
}
#[cfg(feature = "rtc-bootstrap")]
struct ResolvedServe {
bind: std::net::SocketAddr,
listen: Vec<std::net::SocketAddr>,
issuer: Option<net_sdk::identity::EntityId>,
challenge: Vec<std::net::SocketAddr>,
tls: net_sdk::rtc_bootstrap::BootstrapTls,
rtc: net::adapter::net::rtc::RtcConfig,
}
#[cfg(feature = "rtc-bootstrap")]
#[derive(Serialize)]
struct ServeInspection {
#[serde(flatten)]
target: crate::target::TargetInspection,
listen: std::net::SocketAddr,
#[serde(skip_serializing_if = "Vec::is_empty")]
listen_additional: Vec<std::net::SocketAddr>,
rtc_bind: Option<std::net::SocketAddr>,
rtc_public_addr: Option<std::net::SocketAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
rtc_bind_v6: Option<std::net::SocketAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
rtc_public_addr_v6: Option<std::net::SocketAddr>,
rtc_stun_bind: Option<std::net::SocketAddr>,
rtc_stun_public_addr: Option<std::net::SocketAddr>,
tls: &'static str,
tls_cert: Option<PathBuf>,
tls_key: Option<PathBuf>,
acme_cache: Option<PathBuf>,
acme_challenge_bind: Option<std::net::SocketAddr>,
#[serde(skip_serializing_if = "Vec::is_empty")]
acme_challenge_bind_additional: Vec<std::net::SocketAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
credential_issuer_fingerprint: Option<String>,
}
#[cfg(feature = "rtc-bootstrap")]
async fn run_serve(
args: ServeArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
use net_sdk::rtc_bootstrap::{serve_bootstrap, AcmeState, BootstrapConfig, BootstrapTls};
use net_sdk::Mesh;
let resolved = resolve_serve(&args)?;
if args.inspect_target {
let profile = crate::context::resolve_profile(config_path, profile_name).await?;
let mut target = crate::target::TargetInspection::standalone_service(
&profile,
resolved.bind.to_string(),
);
for (name, explicit) in [
("bind", args.bind.is_some()),
("listen", !args.listen.is_empty()),
("rtc_bind", !args.rtc_bind.is_empty()),
] {
target.provenance(name, if explicit { "flag" } else { "default" });
}
target.provenance("psk", "flag");
target.provenance("credential_issuer", "flag");
target.provenance("tls", "flag");
target.provenance(
"rtc_public_addr",
if !args.rtc_public_addr.is_empty() {
"flag"
} else {
"runtime"
},
);
target.provenance(
"rtc_stun_bind",
if !args.rtc_stun_bind.is_empty() {
"flag"
} else {
"unused"
},
);
let (tls, tls_cert, tls_key, acme_cache) = match &resolved.tls {
BootstrapTls::Operator { cert_pem, key_pem } => (
"operator",
Some(cert_pem.clone()),
Some(key_pem.clone()),
None,
),
BootstrapTls::Acme(config) => ("acme", None, None, Some(config.cache_dir.clone())),
};
for (name, explicit) in [
("acme_cache", args.acme_cache.is_some()),
("acme_challenge_bind", !args.acme_challenge_addr.is_empty()),
] {
target.provenance(
name,
if tls != "acme" {
"unused"
} else if explicit {
"flag"
} else {
"default"
},
);
}
return emit_value(
OutputFormat::resolve_oneshot(output),
&ServeInspection {
target,
listen: resolved.listen[0],
listen_additional: resolved.listen[1..].to_vec(),
rtc_bind: resolved.rtc.bind_addr,
rtc_public_addr: resolved.rtc.public_addr,
rtc_bind_v6: resolved.rtc.bind_addr_v6,
rtc_public_addr_v6: resolved.rtc.public_addr_v6,
rtc_stun_bind: resolved.rtc.stun_addr,
rtc_stun_public_addr: resolved.rtc.stun_public_addr,
tls,
tls_cert,
tls_key,
acme_cache,
acme_challenge_bind: if tls == "acme" {
Some(resolved.challenge[0])
} else {
None
},
acme_challenge_bind_additional: if tls == "acme" {
resolved.challenge[1..].to_vec()
} else {
Vec::new()
},
credential_issuer_fingerprint: resolved
.issuer
.as_ref()
.map(|issuer| crate::target::public_fingerprint(issuer.as_bytes())),
},
)
.map_err(|e| generic(format!("write anchor target inspection: {e}")));
}
let psk_hex = tokio::fs::read_to_string(&args.psk_file)
.await
.map_err(|e| invalid_args(format!("--psk-file {}: {e}", args.psk_file.display())))?;
let psk = hex_decode_32(psk_hex.trim()).map_err(|e| invalid_args(format!("psk: {e}")))?;
let issuer_identity = match &args.issuer_identity {
Some(path) => {
let file = read_identity_file(path, args.insecure_permissions).await?;
let seed = hex_decode_32(&file.seed_hex)
.map_err(|e| invalid_args(format!("--issuer-identity: seed_hex: {e}")))?;
Some(net_sdk::identity::Identity::from_seed(seed))
}
None => None,
};
let issuer = match (resolved.issuer.clone(), &issuer_identity) {
(Some(named), Some(identity)) if &named != identity.entity_id() => {
return Err(invalid_args(
"--credential-issuer is not the public half of --issuer-identity",
))
}
(Some(named), _) => named,
(None, Some(identity)) => identity.entity_id().clone(),
(None, None) => {
return Err(invalid_args(
"one of --credential-issuer or --issuer-identity is required",
))
}
};
let games = parse_games(&args.game)?;
let mesh = Mesh::builder(&resolved.bind.to_string(), &psk)
.map_err(|e| generic(format!("mesh builder: {e}")))?
.rtc(resolved.rtc)
.build()
.await
.map_err(|e| generic(format!("starting the anchor: {e}")))?;
mesh.start();
let sdk_psk = Psk::new(psk);
let mut listener_config = BootstrapConfig::new(
resolved.listen[0],
sdk_psk.clone(),
issuer,
resolved.tls,
args.allow_origin
.first()
.cloned()
.unwrap_or_else(|| args.url.clone()),
);
listener_config.allowed_origins = args.allow_origin.clone();
listener_config.ws_allowed_origins = args.allow_origin.clone();
listener_config.any_origin = args.open_games.is_some();
listener_config.acme = AcmeState::new();
listener_config.additional_bind_addrs = resolved.listen[1..].to_vec();
listener_config.acme_challenge_addr = resolved.challenge[0];
listener_config.additional_acme_challenge_addrs = resolved.challenge[1..].to_vec();
if let Some(limit) = args.offers_per_minute {
listener_config.offers_per_ip_per_minute = limit;
}
let _directory = net_sdk::rtc_bootstrap::serve_anchor_directory(&mesh)
.map_err(|e| generic(format!("serving the anchor directory: {e}")))?;
let _ice_stats = net_sdk::rtc_bootstrap::serve_anchor_ice_stats(&mesh)
.map_err(|e| generic(format!("serving the anchor ICE stats: {e}")))?;
let mut _enrollment = None;
let mut _game_stats = None;
let admits_games = !games.is_empty() || args.open_games.is_some();
if let (true, Some(identity)) = (admits_games, &issuer_identity) {
let mut registry = net_sdk::game_anchor::GameRegistry::from_identity(identity, games)
.map_err(|e| invalid_args(format!("--game: {e}")))?;
if let Some(state_file) = &args.open_games {
let mut open = net_sdk::game_anchor::OpenGames::new(Some(state_file.clone()));
if let Some(max) = args.open_games_max {
open.capacity = max;
}
if let Some(limit) = args.open_games_per_minute {
open.total_per_minute = limit;
}
if let Some(players) = args.open_game_max_players {
open.max_players_per_game = Some(players);
}
registry = registry.with_open_games(open).map_err(|e| {
generic(format!(
"--open-games: reading {}: {e}",
state_file.display()
))
})?;
}
let registry = std::sync::Arc::new(registry);
_enrollment = Some(
net_sdk::game_anchor::serve_game_enrollment(
&mesh,
std::sync::Arc::clone(®istry),
net_sdk::game_anchor::DEFAULT_GRANT_TTL,
)
.map_err(|e| generic(format!("serving enrollment: {e}")))?,
);
let mut issuance = net_sdk::rtc_bootstrap::CredentialIssuance::new(
std::sync::Arc::clone(®istry),
identity.clone(),
args.url.clone(),
);
if let Some(limit) = args.credentials_per_minute {
issuance.per_ip_per_minute = limit;
}
listener_config.credential_issuance = Some(issuance);
if args.game_stats_secs > 0 {
let every = std::time::Duration::from_secs(args.game_stats_secs);
_game_stats = Some(tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
tick.tick().await;
loop {
tick.tick().await;
let mut line = serde_json::json!({ "game_stats": registry.stats() });
if let Some(open) = registry.open_stats() {
line["open_games"] = serde_json::json!(open);
}
println!("{line}");
}
}));
}
}
let node = std::sync::Arc::clone(mesh.node());
let handle = serve_bootstrap(node, listener_config)
.await
.map_err(|e| generic(format!("starting the bootstrap listener: {e}")))?;
emit_value(
OutputFormat::resolve_oneshot(output),
&ServeReport {
node: format!("{:#x}", mesh.node().node_id()),
listening_on: handle.local_addr().to_string(),
bootstrap_url: args.url.clone(),
rtc_addr: mesh.node().rtc_public_addr().map(|a| a.to_string()),
rtc_stun_addr: mesh.node().rtc_public_stun_addr().map(|a| a.to_string()),
trust_domain: sdk_psk.trust_domain().to_string(),
noise_pubkey: hex_string(mesh.node().public_key()),
games: args
.game
.iter()
.map(|g| g.split(':').next().unwrap_or_default().to_string())
.collect(),
open_games: args.open_games.is_some(),
credential_endpoint: (!args.game.is_empty() || args.open_games.is_some())
.then(|| format!("{}/credential", args.url.trim_end_matches('/'))),
also_listening_on: handle.local_addrs()[1..]
.iter()
.map(|addr| addr.to_string())
.collect(),
rtc_addrs: net_sdk::rtc_bootstrap::published_rtc_addrs(mesh.node()),
},
)
.map_err(|e| generic(format!("write anchor serve: {e}")))?;
tokio::signal::ctrl_c()
.await
.map_err(|e| generic(format!("waiting for ctrl-c: {e}")))?;
if let Some(task) = _game_stats {
task.abort();
}
handle.shutdown().await;
Ok(())
}
#[cfg(all(test, feature = "rtc-bootstrap"))]
mod tests {
use super::*;
fn serve_args(extra: &[&str]) -> ServeArgs {
use clap::Parser;
#[derive(Parser)]
struct Root {
#[command(flatten)]
serve: ServeArgs,
}
let mut argv = vec![
"net-mesh",
"--psk-file",
"psk.hex",
"--url",
"https://anchor.example.com",
"--credential-issuer",
"00",
"--allow-origin",
"https://app.example.com",
];
argv.extend_from_slice(extra);
Root::parse_from(argv).serve
}
fn try_serve_args(argv: &[&str]) -> Result<ServeArgs, clap::Error> {
use clap::Parser;
#[derive(Parser)]
struct Root {
#[command(flatten)]
serve: ServeArgs,
}
let mut full = vec!["net-mesh"];
full.extend_from_slice(argv);
Root::try_parse_from(full).map(|root| root.serve)
}
const BASE: [&str; 6] = [
"--psk-file",
"psk.hex",
"--url",
"https://anchor.example.com",
"--allow-origin",
"https://app.example.com",
];
#[test]
fn the_session_ceiling_defaults_to_1024_and_zero_is_refused() {
let bare = rtc_config_from_args(&serve_args(&[])).expect("defaults parse");
assert_eq!(bare.max_peers, 1024);
let set = rtc_config_from_args(&serve_args(&["--rtc-max-peers", "32"])).expect("parses");
assert_eq!(set.max_peers, 32);
assert!(rtc_config_from_args(&serve_args(&["--rtc-max-peers", "0"])).is_err());
}
#[test]
fn open_games_make_the_origin_list_optional_and_need_the_key() {
let open = [
"--psk-file",
"psk.hex",
"--url",
"https://anchor.example.com",
"--issuer-identity",
"issuer.json",
"--open-games",
"open-games.txt",
"--open-games-max",
"10",
"--open-game-max-players",
"32",
];
let args = try_serve_args(&open).expect("no --allow-origin needed");
assert!(args.allow_origin.is_empty());
assert_eq!(
args.open_games.as_deref(),
Some(std::path::Path::new("open-games.txt"))
);
assert_eq!(
(args.open_games_max, args.open_game_max_players),
(Some(10), Some(32))
);
assert!(
try_serve_args(&open[..6]).is_err(),
"without --open-games, --allow-origin is required"
);
let mut no_key = open[..4].to_vec();
no_key.extend(["--credential-issuer", "00", "--open-games", "x"]);
assert!(try_serve_args(&no_key).is_err(), "open games need the key");
let mut limit_alone = BASE.to_vec();
limit_alone.extend(["--credential-issuer", "00", "--open-games-max", "10"]);
assert!(
try_serve_args(&limit_alone).is_err(),
"a limit needs --open-games"
);
}
#[test]
fn serve_names_its_issuer_one_way_or_the_other_and_game_needs_the_key() {
assert!(try_serve_args(&BASE).is_err(), "no issuer at all");
let mut with_key = BASE.to_vec();
with_key.extend([
"--issuer-identity",
"issuer.json",
"--game",
"alpha",
"--game",
"beta:5",
]);
let args = try_serve_args(&with_key).expect("identity file alone names the issuer");
assert_eq!(args.credential_issuer, None);
assert_eq!(args.game, ["alpha", "beta:5"]);
let mut game_without_key = BASE.to_vec();
game_without_key.extend(["--credential-issuer", "00", "--game", "alpha"]);
assert!(
try_serve_args(&game_without_key).is_err(),
"--game requires --issuer-identity"
);
}
#[test]
fn game_flags_parse_ids_and_ceilings_and_refuse_bad_ones() {
let games = parse_games(&["alpha".into(), "beta.2:5".into()]).unwrap();
assert_eq!(games[0], net_sdk::game_anchor::GameConfig::new("alpha"));
assert_eq!(
(games[1].id.as_str(), games[1].issue_per_minute),
("beta.2", 5)
);
for bad in ["Alpha", "net-lobby:x", "a:many", ""] {
assert!(parse_games(&[bad.into()]).is_err(), "{bad:?}");
}
}
#[test]
fn the_stun_flags_reach_the_rtc_config_and_are_absent_by_default() {
let configured = rtc_config_from_args(&serve_args(&[
"--rtc-stun-bind",
"0.0.0.0:3479",
"--rtc-stun-public-addr",
"203.0.113.7:3479",
]))
.expect("both flags parse");
assert_eq!(
configured.stun_addr,
Some("0.0.0.0:3479".parse().expect("addr")),
"--rtc-stun-bind is the SECOND socket's bind"
);
assert_eq!(
configured.stun_public_addr,
Some("203.0.113.7:3479".parse().expect("addr")),
"--rtc-stun-public-addr is the endpoint that gets announced"
);
let bare = rtc_config_from_args(&serve_args(&[])).expect("no STUN flags parse");
assert_eq!(
bare.stun_addr, None,
"no flag, no second socket: an anchor that configures nothing announces \
nothing"
);
assert_eq!(bare.stun_public_addr, None);
assert!(
bare.serve_stun,
"the RTC socket still answers STUN — it is the diagnostic UdpBlocked \
probe's target, and the second socket is additional to it, never a \
replacement"
);
let both_pairs = rtc_config_from_args(&serve_args(&[
"--rtc-bind",
"0.0.0.0:7101",
"--rtc-public-addr",
"203.0.113.7:7101",
"--rtc-stun-bind",
"0.0.0.0:3479",
]))
.expect("all four parse");
assert_eq!(
both_pairs.bind_addr,
Some("0.0.0.0:7101".parse().expect("addr"))
);
assert_eq!(
both_pairs.public_addr,
Some("203.0.113.7:7101".parse().expect("addr"))
);
assert_eq!(
both_pairs.stun_addr,
Some("0.0.0.0:3479".parse().expect("addr"))
);
assert_eq!(
both_pairs.stun_public_addr, None,
"a bind without a public address announces the socket's own resolved \
endpoint, decided in the driver — not here"
);
}
#[test]
fn a_malformed_stun_address_names_the_flag_that_carried_it() {
for (flag, value) in [
("--rtc-stun-bind", "not-an-address"),
("--rtc-stun-public-addr", "203.0.113.7"),
] {
let err = rtc_config_from_args(&serve_args(&[flag, value]))
.expect_err("a malformed address must be refused");
assert_eq!(err.kind(), crate::error::ExitCodeKind::InvalidArgs);
assert!(
err.to_string().contains(flag),
"the refusal must name the flag the operator typed: {err}"
);
}
}
#[test]
fn the_listing_row_omits_the_stun_endpoint_unless_the_anchor_announced_one() {
let row = AnchorRow {
node: "0x1".to_string(),
rtc_addr: Some("203.0.113.7:7101".to_string()),
rtc_stun_addr: None,
rtc_bootstrap: Some("https://anchor.example.com".to_string()),
noise_pubkey: None,
};
assert_eq!(
serde_json::to_string(&row).expect("serialize"),
r#"{"node":"0x1","rtc_addr":"203.0.113.7:7101","rtc_bootstrap":"https://anchor.example.com","noise_pubkey":null}"#,
"the row an anchor without a STUN endpoint produces, key for key"
);
let announced = AnchorRow {
rtc_stun_addr: Some("203.0.113.7:3479".to_string()),
..row
};
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&announced).expect("serialize"))
.expect("parse");
assert_eq!(
json["rtc_stun_addr"], "203.0.113.7:3479",
"and the announced endpoint travels under its own key, beside rtc_addr"
);
assert_eq!(
json["rtc_addr"], "203.0.113.7:7101",
"which is a different endpoint, and stays one"
);
}
#[test]
fn the_serve_report_echoes_the_announced_stun_endpoint_and_omits_the_key_without_one() {
let base = ServeReport {
node: "0x1".to_string(),
listening_on: "0.0.0.0:8443".to_string(),
bootstrap_url: "https://anchor.example.com".to_string(),
rtc_addr: None,
rtc_stun_addr: None,
trust_domain: "td".to_string(),
noise_pubkey: "ab".to_string(),
games: vec![],
open_games: false,
credential_endpoint: None,
also_listening_on: vec![],
rtc_addrs: None,
};
let json = serde_json::to_string(&base).expect("serialize");
assert!(
!json.contains("also_listening_on")
&& !json.contains("rtc_addrs")
&& !json.contains("open_games"),
"a single-listener, single-socket anchor reports neither dual-stack key: {json}"
);
assert!(
!json.contains("games") && !json.contains("credential_endpoint"),
"an anchor without --game reports neither key: {json}"
);
assert!(
!json.contains("rtc_stun_addr"),
"an anchor that configured no STUN endpoint reports no such key: {json}"
);
assert!(
json.contains(r#""rtc_addr":null"#),
"…while its sibling keeps the shape it already had, null and all: {json}"
);
let announced = ServeReport {
rtc_addr: Some("203.0.113.7:7101".to_string()),
rtc_stun_addr: Some("203.0.113.7:3479".to_string()),
..base
};
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&announced).expect("serialize"))
.expect("parse");
assert_eq!(json["rtc_stun_addr"], "203.0.113.7:3479");
assert_eq!(
json["rtc_addr"], "203.0.113.7:7101",
"the two endpoints are reported separately because they ARE separate"
);
}
#[test]
fn rtc_binds_of_each_family_make_a_dual_stack_anchor() {
let dual = rtc_config_from_args(&serve_args(&[
"--rtc-bind",
"[::]:7101",
"--rtc-bind",
"0.0.0.0:7101",
"--rtc-public-addr",
"[2001:db8::7]:7101",
"--rtc-public-addr",
"203.0.113.7:7101",
]))
.expect("dual-stack flags");
assert_eq!(dual.bind_addr, Some("0.0.0.0:7101".parse().unwrap()));
assert_eq!(dual.bind_addr_v6, Some("[::]:7101".parse().unwrap()));
assert_eq!(dual.public_addr, Some("203.0.113.7:7101".parse().unwrap()));
assert_eq!(
dual.public_addr_v6,
Some("[2001:db8::7]:7101".parse().unwrap()),
"each public address advertises the socket of its own family, whatever the \
flag order"
);
assert_eq!(dual.validate(), None);
let single_v6 = rtc_config_from_args(&serve_args(&[
"--rtc-bind",
"[::]:7101",
"--rtc-public-addr",
"[2001:db8::7]:7101",
]))
.expect("one IPv6 socket");
assert_eq!(
(single_v6.bind_addr, single_v6.bind_addr_v6),
(Some("[::]:7101".parse().unwrap()), None),
"one IPv6 bind is still the primary socket, as before"
);
assert_eq!(
single_v6.public_addr,
Some("[2001:db8::7]:7101".parse().unwrap())
);
}
#[test]
fn stun_binds_of_each_family_make_a_stun_endpoint_per_family() {
let dual = rtc_config_from_args(&serve_args(&[
"--rtc-stun-bind",
"[::]:3479",
"--rtc-stun-bind",
"0.0.0.0:3479",
"--rtc-stun-public-addr",
"[2001:db8::7]:3479",
"--rtc-stun-public-addr",
"203.0.113.7:3479",
]))
.expect("per-family STUN flags");
assert_eq!(dual.stun_addr, Some("0.0.0.0:3479".parse().unwrap()));
assert_eq!(dual.stun_addr_v6, Some("[::]:3479".parse().unwrap()));
assert_eq!(
dual.stun_public_addr,
Some("203.0.113.7:3479".parse().unwrap())
);
assert_eq!(
dual.stun_public_addr_v6,
Some("[2001:db8::7]:3479".parse().unwrap())
);
assert!(
rtc_config_from_args(&serve_args(&[
"--rtc-stun-bind",
"0.0.0.0:3479",
"--rtc-stun-bind",
"127.0.0.1:3480",
]))
.is_err(),
"two STUN sockets of one family are refused"
);
}
#[test]
fn two_rtc_addresses_of_one_family_are_refused() {
for flags in [
["--rtc-bind", "0.0.0.0:7101", "--rtc-bind", "127.0.0.1:7102"],
[
"--rtc-public-addr",
"[2001:db8::1]:1",
"--rtc-public-addr",
"[2001:db8::2]:1",
],
] {
assert!(
rtc_config_from_args(&serve_args(&flags)).is_err(),
"{flags:?} must be refused: two sockets of one family is ambiguous"
);
}
assert!(
rtc_config_from_args(&serve_args(&[
"--rtc-bind",
"0.0.0.0:7101",
"--rtc-public-addr",
"203.0.113.7:7101",
"--rtc-public-addr",
"[2001:db8::7]:7101",
]))
.is_err(),
"an IPv6 public address with no IPv6 socket is refused"
);
}
fn resolvable_serve_args(extra: &[&str]) -> ServeArgs {
let issuer = hex_string(
net_sdk::identity::Identity::from_seed([0x61; 32])
.entity_id()
.as_bytes(),
);
let mut argv = vec![
"--psk-file",
"psk.hex",
"--url",
"https://anchor.example.com",
"--credential-issuer",
issuer.as_str(),
"--allow-origin",
"https://app.example.com",
"--tls-cert",
"c.pem",
"--tls-key",
"k.pem",
];
argv.extend_from_slice(extra);
try_serve_args(&argv).expect("serve args parse")
}
#[test]
fn listen_and_acme_challenge_addresses_are_repeatable() {
let resolved = resolve_serve(&resolvable_serve_args(&[
"--listen",
"0.0.0.0:443",
"--listen",
"[::]:443",
]))
.expect("resolves");
assert_eq!(
resolved.listen,
vec![
"0.0.0.0:443".parse::<std::net::SocketAddr>().unwrap(),
"[::]:443".parse().unwrap()
]
);
assert_eq!(
resolved.challenge,
vec!["0.0.0.0:80".parse::<std::net::SocketAddr>().unwrap()],
"the challenge default is unchanged"
);
let defaulted = resolve_serve(&resolvable_serve_args(&[])).expect("resolves");
assert_eq!(
defaulted.listen,
vec!["0.0.0.0:8443".parse::<std::net::SocketAddr>().unwrap()],
"the listen default is unchanged"
);
}
}