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
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ jobs:
echo "running netns tunnel tests under sudo: $BIN"
# --exact so ping_across_yipd_tunnel does not also match _under_loss.
for mode in poll uring; do
for t in ping_across_yipd_tunnel ping_across_yipd_tunnel_under_loss arq_recovers_bulk_loss l2_tap_ping_or_arp_across_tunnel; do
for t in ping_across_yipd_tunnel ping_across_yipd_tunnel_under_loss arq_recovers_bulk_loss l2_tap_ping_or_arp_across_tunnel triangle_full_mesh_ping; do
LOG="/tmp/netns-$mode-$t.log"
if [ "$mode" = "uring" ]; then
echo "running $t with UringDriver (opt-in: YIP_USE_URING=1)"
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ license = "MPL-2.0"
repository = "https://github.com/femboyisp/yip"

[workspace.dependencies]
blake2 = "=0.10.6"
thiserror = "2.0.9"
tracing = "0.1.41"

Expand Down
2 changes: 1 addition & 1 deletion bin/yipd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
yip-crypto = { path = "../../crates/yip-crypto" }
yip-transport = { path = "../../crates/yip-transport" }
yip-device = { path = "../../crates/yip-device" }
blake2 = "0.10.6"
blake2 = { workspace = true }

[lints]
workspace = true

[package.metadata.cargo-shear]
ignored = ["yip-io"]

Check warning on line 20 in bin/yipd/Cargo.toml

View workflow job for this annotation

GitHub Actions / shear

shear/redundant_ignore

redundant ignore `yip-io` (remove from ignored list)
59 changes: 59 additions & 0 deletions bin/yipd/src/addr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Self-certifying, key-derived mesh addresses: a node's inner IPv6 is derived
//! from its X25519 public key, so the address IS the identity — no authority.
#![allow(dead_code)]
use std::net::Ipv6Addr;

use blake2::digest::{Update, VariableOutput};
use blake2::Blake2sVar;

/// Domain-separation context so the address derivation can't collide with any
/// other use of the key.
const DOMAIN: &[u8] = b"yip-addr-v1";
/// The mesh occupies fd00::/8 (IPv6 ULA); every node address begins with 0xfd.
pub const MESH_PREFIX_LEN: u8 = 8;

/// Derive a node's inner IPv6 address from its public key:
/// `0xfd || BLAKE2s(DOMAIN || pubkey)[0..15]`.
pub fn node_addr(pubkey: &[u8; 32]) -> Ipv6Addr {
let mut h = Blake2sVar::new(15).expect("15 is a valid blake2s output len");
h.update(DOMAIN);
h.update(pubkey);
let mut digest = [0u8; 15];
h.finalize_variable(&mut digest)
.expect("output len matches");
let mut octets = [0u8; 16];
octets[0] = 0xfd;
octets[1..].copy_from_slice(&digest);
Ipv6Addr::from(octets)
}

/// True iff `addr` is the address `pubkey` derives to (self-certification check).
pub fn verify_addr(addr: Ipv6Addr, pubkey: &[u8; 32]) -> bool {
node_addr(pubkey) == addr
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn addr_is_ula_and_deterministic() {
let pk = [7u8; 32];
let a = node_addr(&pk);
assert_eq!(a.octets()[0], 0xfd, "must be in fd00::/8 ULA space");
assert_eq!(node_addr(&pk), a, "derivation is deterministic");
}

#[test]
fn addr_verifies_only_its_own_key() {
let pk = [7u8; 32];
let other = [8u8; 32];
assert!(verify_addr(node_addr(&pk), &pk));
assert!(!verify_addr(node_addr(&pk), &other));
}

#[test]
fn distinct_keys_give_distinct_addrs() {
assert_ne!(node_addr(&[1u8; 32]), node_addr(&[2u8; 32]));
}
}
185 changes: 113 additions & 72 deletions bin/yipd/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,36 @@ use std::net::SocketAddr;

use crate::mode::TunnelMode;

/// Configuration for a single remote peer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerConfig {
pub public_key: [u8; 32],
pub endpoint: SocketAddr,
}

/// Static configuration for one yip tunnel endpoint.
#[derive(Debug)]
pub struct Config {
/// Local X25519 private key (32 bytes).
pub local_private: [u8; 32],
/// Local X25519 public key (32 bytes). Carried in config for key-management /
/// re-advertisement in future milestones; not consumed by the M6 data path itself.
#[expect(
dead_code,
reason = "used for key identity; data path reads local_private"
)]
/// Local X25519 public key (32 bytes). Used by `PeerManager` to derive
/// this node's self-certifying mesh address (`node_addr`).
pub local_public: [u8; 32],
/// Remote peer's X25519 public key (32 bytes).
pub peer_public: [u8; 32],
/// Remote peer's UDP endpoint (used by the initiator to send the first
/// handshake message; the responder learns it from the incoming datagram).
pub peer_endpoint: SocketAddr,
/// List of remote peers.
pub peers: Vec<PeerConfig>,
/// Local UDP address to bind.
pub listen: SocketAddr,
/// TUN/TAP device name (e.g. `"yip0"`).
pub device: String,
/// Tunnel mode selected from `device_kind=tun|tap` (`tun` by default).
pub device_kind: TunnelMode,
/// Whether this peer initiates the Noise-IK handshake.
pub initiate: bool,
}

// ── hex decode helper ─────────────────────────────────────────────────────────

fn hex_to_32(hex: &str) -> io::Result<[u8; 32]> {
/// Decode a 64-char hex string into 32 bytes. Shared with `main.rs`'s
/// `--addr` subcommand so the two paths cannot drift.
pub(crate) fn hex_to_32(hex: &str) -> io::Result<[u8; 32]> {
if hex.len() != 64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
Expand Down Expand Up @@ -75,28 +75,62 @@ fn missing(key: &str) -> io::Error {
)
}

// ── peer block flush helper ──────────────────────────────────────────────────

fn flush_peer_block(
cur_pk: Option<[u8; 32]>,
cur_ep: Option<SocketAddr>,
peers: &mut Vec<PeerConfig>,
) -> io::Result<()> {
if let (Some(pk), Some(ep)) = (cur_pk, cur_ep) {
peers.push(PeerConfig {
public_key: pk,
endpoint: ep,
});
} else if cur_pk.is_some() || cur_ep.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"peer block missing public_key or endpoint".to_string(),
));
}
Ok(())
}

// ── Config::parse ─────────────────────────────────────────────────────────────

impl Config {
/// Parse a `key=value` config text into a [`Config`].
///
/// Lines beginning with `#` and blank lines are ignored.
/// Supports `[peer]` block syntax with `public_key` and `endpoint` fields.
/// Also supports legacy single `peer_public`+`peer_endpoint` fields.
/// Returns an `io::Error` for any missing or malformed fields.
pub fn parse(text: &str) -> io::Result<Config> {
let mut local_private: Option<[u8; 32]> = None;
let mut local_public: Option<[u8; 32]> = None;
let mut peer_public: Option<[u8; 32]> = None;
let mut peer_endpoint: Option<SocketAddr> = None;
let mut peers: Vec<PeerConfig> = Vec::new();
let mut cur_pk: Option<[u8; 32]> = None;
let mut cur_ep: Option<SocketAddr> = None;
let mut legacy_peer_public: Option<[u8; 32]> = None;
let mut legacy_peer_endpoint: Option<SocketAddr> = None;
let mut listen: Option<SocketAddr> = None;
let mut device: Option<String> = None;
let mut device_kind = TunnelMode::default();
let mut initiate: Option<bool> = None;

for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}

// Check for [peer] block header
if line == "[peer]" {
flush_peer_block(cur_pk, cur_ep, &mut peers)?;
cur_pk = None;
cur_ep = None;
continue;
}

let (key, val) = line.split_once('=').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
Expand All @@ -108,9 +142,16 @@ impl Config {
match key {
"local_private" => local_private = Some(hex_to_32(val)?),
"local_public" => local_public = Some(hex_to_32(val)?),
"peer_public" => peer_public = Some(hex_to_32(val)?),
"public_key" => cur_pk = Some(hex_to_32(val)?),
"endpoint" => {
cur_ep =
Some(val.parse::<SocketAddr>().map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
})?)
}
"peer_public" => legacy_peer_public = Some(hex_to_32(val)?),
"peer_endpoint" => {
peer_endpoint =
legacy_peer_endpoint =
Some(val.parse::<SocketAddr>().map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, e.to_string())
})?)
Expand All @@ -123,30 +164,45 @@ impl Config {
}
"device" => device = Some(val.to_owned()),
"device_kind" => device_kind = TunnelMode::parse_device_kind(val)?,
"initiate" => match val {
"true" | "1" | "yes" => initiate = Some(true),
"false" | "0" | "no" => initiate = Some(false),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid boolean for 'initiate': {val}"),
))
}
},
// Unknown keys are silently ignored for forward-compatibility.
// Silently ignore unknown keys for forward-compatibility. The
// netns config files still contain `initiate=true|false` from
// before Task 5 removed the field; this is intentional so
// those fixtures don't need editing (verified by
// `parse_config_unknown_key_is_silently_ignored`, which this
// arm's removal now also exercises for `initiate` itself).
_ => {}
}
}

// Flush any trailing peer block
flush_peer_block(cur_pk, cur_ep, &mut peers)?;

// If no [peer] blocks, try legacy single-peer format
if peers.is_empty() {
if let (Some(pk), Some(ep)) = (legacy_peer_public, legacy_peer_endpoint) {
peers.push(PeerConfig {
public_key: pk,
endpoint: ep,
});
}
}

// Peers list must not be empty
if peers.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"no peers configured (use [peer] blocks or legacy peer_public/peer_endpoint)"
.to_string(),
));
}

Ok(Config {
local_private: local_private.ok_or_else(|| missing("local_private"))?,
local_public: local_public.ok_or_else(|| missing("local_public"))?,
peer_public: peer_public.ok_or_else(|| missing("peer_public"))?,
peer_endpoint: peer_endpoint.ok_or_else(|| missing("peer_endpoint"))?,
peers,
listen: listen.ok_or_else(|| missing("listen"))?,
device: device.ok_or_else(|| missing("device"))?,
device_kind,
initiate: initiate.ok_or_else(|| missing("initiate"))?,
})
}
}
Expand All @@ -166,9 +222,8 @@ mod tests {
peer_public=00000000000000000000000000000000000000000000000000000000000000bb\n";
let c = Config::parse(text).unwrap();
assert_eq!(c.device, "yip0");
assert!(c.initiate);
assert_eq!(c.local_private[31], 0xff);
assert_eq!(c.peer_public[31], 0xbb);
assert_eq!(c.peers[0].public_key[31], 0xbb);
}

#[test]
Expand All @@ -186,7 +241,7 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003
";
let c = Config::parse(text).unwrap();
assert_eq!(c.device, "yip1");
assert!(!c.initiate);
assert_eq!(c.peers.len(), 1);
}

#[test]
Expand Down Expand Up @@ -277,21 +332,6 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003
assert!(Config::parse(text).is_err());
}

#[test]
fn parse_config_bad_initiate_returns_error() {
let text = "\
device=yip0
listen=0.0.0.0:51820
peer_endpoint=10.0.0.2:51820
initiate=maybe
local_private=0000000000000000000000000000000000000000000000000000000000000001
local_public=0000000000000000000000000000000000000000000000000000000000000002
peer_public=0000000000000000000000000000000000000000000000000000000000000003
";
let err = Config::parse(text).unwrap_err();
assert!(err.to_string().contains("invalid boolean"));
}

#[test]
fn parse_config_unknown_key_is_silently_ignored() {
// Unknown keys must not cause an error (forward-compat).
Expand Down Expand Up @@ -372,26 +412,27 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003
}

#[test]
fn parse_config_initiate_numeric_aliases() {
let yes_text = "\
device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=1\n\
local_private=0000000000000000000000000000000000000000000000000000000000000001\n\
local_public=0000000000000000000000000000000000000000000000000000000000000002\n\
peer_public=0000000000000000000000000000000000000000000000000000000000000003\n";
assert!(Config::parse(yes_text).unwrap().initiate);

let no_text = "\
device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=0\n\
local_private=0000000000000000000000000000000000000000000000000000000000000001\n\
local_public=0000000000000000000000000000000000000000000000000000000000000002\n\
peer_public=0000000000000000000000000000000000000000000000000000000000000003\n";
assert!(!Config::parse(no_text).unwrap().initiate);

let yes_text2 = "\
device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=yes\n\
local_private=0000000000000000000000000000000000000000000000000000000000000001\n\
local_public=0000000000000000000000000000000000000000000000000000000000000002\n\
peer_public=0000000000000000000000000000000000000000000000000000000000000003\n";
assert!(Config::parse(yes_text2).unwrap().initiate);
fn parses_multiple_peers_and_legacy_single() {
// New [peer] block form:
let text = "local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\
local_public=000000000000000000000000000000000000000000000000000000000000aa01\n\
listen=0.0.0.0:51820\ndevice=yip0\n\
[peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b1\nendpoint=10.0.0.2:51820\n\
[peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b2\nendpoint=10.0.0.3:51820\n";
let cfg = Config::parse(text).expect("parses");
assert_eq!(cfg.peers.len(), 2);
assert_eq!(cfg.peers[0].endpoint, "10.0.0.2:51820".parse().unwrap());
assert_eq!(cfg.peers[1].public_key[31], 0xb2);
}

#[test]
fn legacy_single_peer_becomes_one_entry() {
let text = "device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\n\
local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\
local_public=00000000000000000000000000000000000000000000000000000000000000aa\n\
peer_public=00000000000000000000000000000000000000000000000000000000000000bb\n";
let cfg = Config::parse(text).expect("legacy parses");
assert_eq!(cfg.peers.len(), 1);
assert_eq!(cfg.peers[0].public_key[31], 0xbb);
}
}
Loading
Loading