More P2P docs (#2492)

* Remove relay

* restructure p2p

* wip

* cleanup webrtc

* split up P2P docs

* wip

* more wip

* the fork has moved

* finish local network discovery

* Document the relay system

* be less stupid

* a

* remote ip from deploy script

* remove debug from deploy script

* Explain relay setup and usage

* Physical pain

* fix

* error handling for relay setup

* Listeners Relay state + merge it into NLM state

* `node_remote_identity`

* redo libraries hook

* toggle relay active in settings

* Dedicated network settings page

* Stablise P2P debug page

* warning for rspc remote

* Linear links in docs

* fix p2p settings switches

* fix typescript errors on general page

* fix ipv6 listener status

* discovery method in UI

* Remove p2p debug menu on the sidebar

* wip

* lol

* wat

* fix

* another attempt at fixing library hook

* fix

* Remove sync from sidebar

* fix load library code

* I hate this

* Detect connections over the relay

* fix

* fixes

* a

* fix mDNS

* a bunch o fixes

* a bunch of state management fixes

* Metadata sync on connection

* skill issue

* fix markdown

* Clippy cleanup

* Backport #2380

* Update interface/locales/en/common.json

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/local-network-discovery.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/local-network-discovery.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p_proto.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/overview.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/overview.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/relay.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p_proto.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p_proto.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/transport-layer.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p_proto.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/local-network-discovery.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* Update docs/developers/p2p/sd_p2p_proto.mdx

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>

* a

* Cleaning binario section

* cleanup Docker message

* idk

* Idempotent listeners

* Manual peers working????

* minor fixes

* crazy idea - don't panic in the event loop

* fixes

* debug

* debug

* LAN badge in network settings

* Use `dns_lookup` instead of `tokio::net::lookup_host`

* fix

* bruh sandwich

* proper dialing

* a

* remove logs

* fix

* Small cleanup

* manual peers state on connected device

* a

* Fix manual discovery state + give it a badge

* Clippy improvements

* flip discovery priority

* Add `addrs` to debug query

* connection candidates in debug

* Fix state

* Clippppppppppppy

* Manual discovery badge

* Flesh out ping example

* Usage guide

* `sd_p2p_proto` examples

* More discovery docs

* More docs work

* docs docs docs and more docs

* PONG

* rename

---------

Co-authored-by: Matthew Yung <117509016+myung03@users.noreply.github.com>
This commit is contained in:
Oscar Beaumont 2024-05-30 21:48:12 +08:00 committed by GitHub
parent 3428644a29
commit b015763a6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3089 additions and 1589 deletions

27
Cargo.lock generated
View file

@ -2683,6 +2683,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "dns-lookup"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5766087c2235fec47fafa4cfecc81e494ee679d0fd4a59887ea0919bfb0e4fc"
dependencies = [
"cfg-if",
"libc",
"socket2 0.5.5",
"windows-sys 0.48.0",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@ -4255,7 +4267,7 @@ dependencies = [
"httpdate",
"itoa 1.0.10",
"pin-project-lite",
"socket2 0.4.10",
"socket2 0.5.5",
"tokio",
"tower-service",
"tracing",
@ -4429,7 +4441,7 @@ dependencies = [
[[package]]
name = "if-watch"
version = "3.2.0"
source = "git+https://github.com/oscartbeaumont/if-watch.git?rev=a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3#a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3"
source = "git+https://github.com/spacedriveapp/if-watch.git?rev=a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3#a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3"
dependencies = [
"async-io 2.3.0",
"core-foundation",
@ -8554,9 +8566,9 @@ dependencies = [
[[package]]
name = "rmp"
version = "0.8.12"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9860a6cc38ed1da53456442089b4dfa35e7cedaa326df63017af88385e6b20"
checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
dependencies = [
"byteorder",
"num-traits",
@ -8565,9 +8577,9 @@ dependencies = [
[[package]]
name = "rmp-serde"
version = "1.1.2"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bffea85eea980d8a74453e5d02a8d93028f3c34725de143085a844ebe953258a"
checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db"
dependencies = [
"byteorder",
"rmp",
@ -9462,8 +9474,10 @@ version = "0.2.0"
dependencies = [
"base64 0.21.7",
"base91",
"dns-lookup",
"ed25519-dalek",
"flume 0.11.0",
"futures",
"futures-core",
"hash_map_diff",
"if-watch",
@ -9473,6 +9487,7 @@ dependencies = [
"pin-project-lite",
"rand_core 0.6.4",
"reqwest 0.11.23",
"rmp-serde",
"serde",
"sha256",
"specta",

View file

@ -1,17 +1,17 @@
[workspace]
resolver = "2"
members = [
"core",
"core/crates/*",
"crates/*",
"apps/cli",
"apps/p2p-relay",
"apps/desktop/src-tauri",
"apps/desktop/crates/*",
"apps/mobile/modules/sd-core/core",
"apps/mobile/modules/sd-core/android/crate",
"apps/mobile/modules/sd-core/ios/crate",
"apps/server",
"core",
"core/crates/*",
"crates/*",
"apps/cli",
"apps/p2p-relay",
"apps/desktop/src-tauri",
"apps/desktop/crates/*",
"apps/mobile/modules/sd-core/core",
"apps/mobile/modules/sd-core/android/crate",
"apps/mobile/modules/sd-core/ios/crate",
"apps/server",
]
[workspace.package]
@ -21,19 +21,19 @@ repository = "https://github.com/spacedriveapp/spacedrive"
[workspace.dependencies]
prisma-client-rust = { git = "https://github.com/brendonovich/prisma-client-rust", rev = "4f9ef9d38ca732162accff72b2eb684d2f120bab", features = [
"migrations",
"specta",
"sqlite",
"sqlite-create-many",
"migrations",
"specta",
"sqlite",
"sqlite-create-many",
], default-features = false }
prisma-client-rust-cli = { git = "https://github.com/brendonovich/prisma-client-rust", rev = "4f9ef9d38ca732162accff72b2eb684d2f120bab", features = [
"migrations",
"specta",
"sqlite",
"sqlite-create-many",
"migrations",
"specta",
"sqlite",
"sqlite-create-many",
], default-features = false }
prisma-client-rust-sdk = { git = "https://github.com/brendonovich/prisma-client-rust", rev = "4f9ef9d38ca732162accff72b2eb684d2f120bab", features = [
"sqlite",
"sqlite",
], default-features = false }
rspc = { version = "0.1.4" }
@ -89,7 +89,7 @@ webp = "0.3.0"
[patch.crates-io]
# Proper IOS Support
if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3" }
if-watch = { git = "https://github.com/spacedriveapp/if-watch.git", rev = "a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3" }
# We hack it to the high heavens
rspc = { git = "https://github.com/spacedriveapp/rspc.git", rev = "ab12964b140991e0730c3423693533fba71efb03" }

View file

@ -3,10 +3,13 @@
set -e
SERVER="54.176.132.155"
SERVER=""
TARGET_DIR=$(cargo metadata | jq -r .target_directory)
cargo zigbuild --target aarch64-unknown-linux-musl --release
echo "$TARGET_DIR/aarch64-unknown-linux-musl/release/sd-p2p-relay"
scp "$TARGET_DIR/aarch64-unknown-linux-musl/release/sd-p2p-relay" ec2-user@$SERVER:/home/ec2-user/sd-p2p-relay
# ssh ec2-user@$SERVER
# ./sd-p2p-relay init
# Enter the `P2P_SECRET` secret env var from Vercel
# ./sd-p2p-relay

View file

@ -111,9 +111,15 @@ ADD --chmod=755 --checksum=sha256:1d127c69218f2cd14964036f2b057c4b2652cda3996c69
COPY --chmod=755 entrypoint.sh /usr/bin/
# P2P config
ENV SD_DOCKER=true
# Expose webserver
EXPOSE 8080
# Expose P2P
EXPOSE 7373
# Create the data directory to store the database
VOLUME [ "/data" ]

View file

@ -77,8 +77,11 @@ model Instance {
// Enum: sd_core::node::RemoteIdentity
remote_identity Bytes
node_id Bytes
metadata Bytes? // TODO: This should not be optional
// Enum: uuid::Uuid
node_id Bytes
// Enum: sd_core::node::RemoteIdentity
node_remote_identity Bytes? // TODO: This should not be optional
metadata Bytes? // TODO: This should not be optional
last_seen DateTime // Time core started for owner, last P2P message for P2P node
date_created DateTime

View file

@ -44,6 +44,10 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}
mod library {
use std::str::FromStr;
use sd_p2p::RemoteIdentity;
use crate::util::MaybeUndefined;
use super::*;
@ -75,6 +79,7 @@ mod library {
library.instance_uuid,
library.identity.to_remote_identity(),
node_config.id,
node_config.identity.to_remote_identity(),
&node.p2p.peer_metadata(),
)
.await?;
@ -139,6 +144,7 @@ mod library {
library.instance_uuid,
library.identity.to_remote_identity(),
node_config.id,
node_config.identity.to_remote_identity(),
node.p2p.peer_metadata(),
)
.await?;
@ -152,7 +158,9 @@ mod library {
instance.uuid,
instance.identity,
instance.node_id,
node.p2p.peer_metadata(),
RemoteIdentity::from_str(&instance.node_remote_identity)
.expect("malformed remote identity in the DB"),
instance.metadata,
)
.await?;
}

View file

@ -1,7 +1,7 @@
use crate::{
invalidate_query,
node::{
config::{NodeConfig, NodeConfigP2P, NodePreferences},
config::{is_in_docker, NodeConfig, NodeConfigP2P, NodePreferences},
get_hardware_model_name, HardwareModel,
},
old_job::JobProgressEvent,
@ -116,6 +116,7 @@ struct NodeState {
config: SanitisedNodeConfig,
data_path: String,
device_model: Option<String>,
is_in_docker: bool,
}
pub(crate) fn mount() -> Arc<Router> {
@ -151,6 +152,7 @@ pub(crate) fn mount() -> Arc<Router> {
.expect("Found non-UTF-8 path")
.to_string(),
device_model: Some(device_model),
is_in_docker: is_in_docker(),
})
})
})

View file

@ -1,3 +1,5 @@
use std::collections::HashSet;
use crate::{
invalidate_query,
node::config::{P2PDiscoveryState, Port},
@ -20,10 +22,12 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
pub struct ChangeNodeNameArgs {
pub name: Option<String>,
pub p2p_port: Option<Port>,
pub p2p_ipv4_enabled: Option<bool>,
pub p2p_ipv6_enabled: Option<bool>,
pub p2p_disabled: Option<bool>,
pub p2p_ipv6_disabled: Option<bool>,
pub p2p_relay_disabled: Option<bool>,
pub p2p_discovery: Option<P2PDiscoveryState>,
pub p2p_remote_access: Option<bool>,
pub p2p_manual_peers: Option<HashSet<String>>,
pub image_labeler_version: Option<String>,
}
R.mutation(|node, args: ChangeNodeNameArgs| async move {
@ -48,17 +52,23 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
if let Some(port) = args.p2p_port {
config.p2p.port = port;
};
if let Some(enabled) = args.p2p_ipv4_enabled {
config.p2p.ipv4 = enabled;
if let Some(enabled) = args.p2p_disabled {
config.p2p.disabled = enabled;
};
if let Some(enabled) = args.p2p_ipv6_enabled {
config.p2p.ipv6 = enabled;
if let Some(enabled) = args.p2p_ipv6_disabled {
config.p2p.disable_ipv6 = enabled;
};
if let Some(enabled) = args.p2p_relay_disabled {
config.p2p.disable_relay = enabled;
};
if let Some(discovery) = args.p2p_discovery {
config.p2p.discovery = discovery;
};
if let Some(remote_access) = args.p2p_remote_access {
config.p2p.remote_access = remote_access;
config.p2p.enable_remote_access = remote_access;
};
if let Some(manual_peers) = args.p2p_manual_peers {
config.p2p.manual_peers = manual_peers;
};
#[cfg(feature = "ai")]

View file

@ -3,7 +3,7 @@ use crate::p2p::{operations, ConnectionMethod, DiscoveryMethod, Header, P2PEvent
use sd_p2p::{PeerConnectionCandidate, RemoteIdentity};
use rspc::{alpha::AlphaRouter, ErrorCode};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use specta::Type;
use std::{path::PathBuf, sync::PoisonError};
use tokio::io::AsyncWriteExt;
@ -26,21 +26,32 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}) {
queued.push(P2PEvent::PeerChange {
identity: peer.identity(),
connection: if peer.is_connected_with_hook(node.p2p.libraries_hook_id) {
ConnectionMethod::Relay
} else if peer.is_connected() {
ConnectionMethod::Local
connection: if peer.is_connected() {
if node.p2p.quic.is_relayed(peer.identity()) {
ConnectionMethod::Relay
} else {
ConnectionMethod::Local
}
} else {
ConnectionMethod::Disconnected
},
discovery: match peer
discovery: if peer
.connection_candidates()
.contains(&PeerConnectionCandidate::Relay)
.iter()
.any(|c| matches!(c, PeerConnectionCandidate::Manual(_)))
{
true => DiscoveryMethod::Relay,
false => DiscoveryMethod::Local,
DiscoveryMethod::Manual
} else if peer
.connection_candidates()
.iter()
.all(|c| *c == PeerConnectionCandidate::Relay)
{
DiscoveryMethod::Relay
} else {
DiscoveryMethod::Local
},
metadata,
addrs: peer.addrs(),
});
}
@ -59,51 +70,13 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.query(|node, _: ()| async move { Ok(node.p2p.state().await) })
})
.procedure("listeners", {
#[derive(Serialize, Type)]
#[serde(tag = "type")]
pub enum ListenerState {
Listening,
Error { error: String },
Disabled,
}
#[derive(Serialize, Type)]
pub struct Listeners {
ipv4: ListenerState,
ipv6: ListenerState,
}
R.query(|node, _: ()| async move {
let addrs = node
Ok(node
.p2p
.p2p
.listeners()
.iter()
.flat_map(|l| l.addrs.clone())
.collect::<Vec<_>>();
let errors = node
.p2p
.listener_errors
.listeners
.lock()
.unwrap_or_else(PoisonError::into_inner);
Ok(Listeners {
ipv4: match errors.ipv4 {
Some(ref err) => ListenerState::Error { error: err.clone() },
None => match addrs.iter().any(|f| f.is_ipv4()) {
true => ListenerState::Listening,
false => ListenerState::Disabled,
},
},
ipv6: match errors.ipv6 {
Some(ref err) => ListenerState::Error { error: err.clone() },
None => match addrs.iter().any(|f| f.is_ipv6()) {
true => ListenerState::Listening,
false => ListenerState::Disabled,
},
},
})
.unwrap_or_else(PoisonError::into_inner)
.clone())
})
})
.procedure("debugConnect", {

View file

@ -8,6 +8,7 @@ use sd_utils::uuid_to_bytes;
use std::{
collections::{hash_map::Entry, HashMap},
str::FromStr,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
@ -167,6 +168,8 @@ pub async fn run_actor(
collection.instance_uuid,
instance.identity,
instance.node_id,
RemoteIdentity::from_str(&instance.node_remote_identity)
.expect("malformed remote identity in the DB"),
node.p2p.peer_metadata(),
)
.await
@ -247,6 +250,7 @@ pub async fn upsert_instance(
uuid: Uuid,
identity: RemoteIdentity,
node_id: Uuid,
node_remote_identity: RemoteIdentity,
metadata: HashMap<String, String>,
) -> prisma_client_rust::Result<()> {
db.instance()
@ -258,9 +262,14 @@ pub async fn upsert_instance(
node_id.as_bytes().to_vec(),
Utc::now().into(),
Utc::now().into(),
vec![instance::metadata::set(Some(
serde_json::to_vec(&metadata).expect("unable to serialize metadata"),
))],
vec![
instance::node_remote_identity::set(Some(
node_remote_identity.get_bytes().to_vec(),
)),
instance::metadata::set(Some(
serde_json::to_vec(&metadata).expect("unable to serialize metadata"),
)),
],
),
vec![],
)

View file

@ -71,10 +71,11 @@ pub enum LibraryConfigVersion {
V8 = 8,
V9 = 9,
V10 = 10,
V11 = 11,
}
impl ManagedVersion<LibraryConfigVersion> for LibraryConfig {
const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V10;
const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V11;
const KIND: Kind = Kind::Json("version");
@ -446,6 +447,21 @@ impl LibraryConfig {
.await?;
}
(LibraryConfigVersion::V10, LibraryConfigVersion::V11) => {
db.instance()
.update_many(
vec![],
vec![instance::node_remote_identity::set(Some(
// This is a remote identity that doesn't exist. The expectation is that:
// - The current node will update it's own and notice the change causing it to push the updated id to the cloud
// - All other instances will be updated when the regular sync process with the cloud happens
"SaEhml9thV088ocsOXZ17BrNjFaROB0ojwBvnPHhztI".into(),
))],
)
.exec()
.await?;
}
_ => {
error!("Library config version is not handled: {:?}", current);
return Err(VersionManagerError::UnexpectedMigration {

View file

@ -9,7 +9,7 @@ use crate::{
};
use sd_core_sync::SyncMessage;
use sd_p2p::Identity;
use sd_p2p::{Identity, RemoteIdentity};
use sd_prisma::prisma::{crdt_operation, instance, location, SortOrder};
use sd_utils::{
db,
@ -437,7 +437,14 @@ impl Libraries {
.as_ref()
.map(|metadata| serde_json::from_slice(metadata).expect("invalid metadata"));
let instance_node_id = Uuid::from_slice(&instance.node_id)?;
if instance_node_id != node_config.id || curr_metadata != Some(node.p2p.peer_metadata()) {
let instance_node_remote_identity = instance
.node_remote_identity
.as_ref()
.and_then(|v| RemoteIdentity::from_bytes(v).ok());
if instance_node_id != node_config.id
|| instance_node_remote_identity != Some(node_config.identity.to_remote_identity())
|| curr_metadata != Some(node.p2p.peer_metadata())
{
info!(
"Detected that the library '{}' has changed node from '{}' to '{}'. Reconciling node data...",
id, instance_node_id, node_config.id
@ -450,6 +457,13 @@ impl Libraries {
instance::id::equals(instance.id),
vec![
instance::node_id::set(node_config.id.as_bytes().to_vec()),
instance::node_remote_identity::set(Some(
node_config
.identity
.to_remote_identity()
.get_bytes()
.to_vec(),
)),
instance::metadata::set(Some(
serde_json::to_vec(&node.p2p.peer_metadata())
.expect("invalid peer metadata"),
@ -581,7 +595,13 @@ impl Libraries {
.expect("invalid metadata")
});
let should_update = this_instance.node_id != node_config.id
|| curr_metadata != Some(node.p2p.peer_metadata());
|| RemoteIdentity::from_str(
&this_instance.node_remote_identity,
)
.ok() != Some(
node_config.identity.to_remote_identity(),
) || curr_metadata
!= Some(node.p2p.peer_metadata());
if should_update {
warn!("Library instance on cloud is outdated. Updating...");
@ -592,6 +612,7 @@ impl Libraries {
library.id,
this_instance.uuid,
Some(node_config.id),
Some(node_config.identity.to_remote_identity()),
Some(node.p2p.peer_metadata()),
)
.await
@ -630,6 +651,10 @@ impl Libraries {
instance.uuid,
instance.identity,
instance.node_id,
RemoteIdentity::from_str(
&instance.node_remote_identity,
)
.expect("malformed remote identity from API"),
instance.metadata,
)
.await

View file

@ -8,6 +8,7 @@ use sd_p2p::Identity;
use sd_utils::error::FileIOError;
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
};
@ -46,23 +47,23 @@ pub enum Port {
impl Port {
pub fn get(&self) -> u16 {
if is_in_docker() {
return 7373;
}
match self {
Port::Random => 0,
Port::Discrete(port) => *port,
}
}
pub fn is_random(&self) -> bool {
pub fn is_default(&self) -> bool {
matches!(self, Port::Random)
}
}
fn default_as_true() -> bool {
true
}
fn skip_if_true(value: &bool) -> bool {
*value
pub fn is_in_docker() -> bool {
std::env::var("SD_DOCKER").as_deref() == Ok("true")
}
fn skip_if_false(value: &bool) -> bool {
@ -73,14 +74,26 @@ fn skip_if_false(value: &bool) -> bool {
pub struct NodeConfigP2P {
#[serde(default)]
pub discovery: P2PDiscoveryState,
#[serde(default, skip_serializing_if = "Port::is_random")]
#[serde(default, skip_serializing_if = "Port::is_default")]
pub port: Port,
#[serde(default = "default_as_true", skip_serializing_if = "skip_if_true")]
pub ipv4: bool,
#[serde(default = "default_as_true", skip_serializing_if = "skip_if_true")]
pub ipv6: bool,
#[serde(default, skip_serializing_if = "skip_if_false")]
pub remote_access: bool,
pub disabled: bool,
#[serde(default, skip_serializing_if = "skip_if_false")]
pub disable_ipv6: bool,
#[serde(default, skip_serializing_if = "skip_if_false")]
pub disable_relay: bool,
#[serde(default, skip_serializing_if = "skip_if_false")]
pub enable_remote_access: bool,
/// A list of peer addresses to try and manually connect to, instead of relying on discovery.
///
/// All of these are valid values:
/// - `localhost`
/// - `otbeaumont.me` or `otbeaumont.me:3000`
/// - `127.0.0.1` or `127.0.0.1:300`
/// - `[::1]` or `[::1]:3000`
/// which is why we use `String` not `SocketAddr`
#[serde(default)]
pub manual_peers: HashSet<String>,
}
impl Default for NodeConfigP2P {
@ -88,9 +101,11 @@ impl Default for NodeConfigP2P {
Self {
discovery: P2PDiscoveryState::Everyone,
port: Port::Random,
ipv4: true,
ipv6: true,
remote_access: false,
disabled: true,
disable_ipv6: true,
disable_relay: true,
enable_remote_access: false,
manual_peers: Default::default(),
}
}
}

View file

@ -1,6 +1,8 @@
use std::sync::Arc;
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use sd_p2p::{flume::bounded, HookEvent, HookId, PeerConnectionCandidate, RemoteIdentity, P2P};
use sd_p2p::{
flume::bounded, hooks::QuicHandle, HookEvent, PeerConnectionCandidate, RemoteIdentity, P2P,
};
use serde::Serialize;
use specta::Type;
use tokio::sync::broadcast;
@ -28,6 +30,8 @@ pub enum DiscoveryMethod {
Relay,
// Found via mDNS or a manual IP
Local,
// Found via manual entry on either node
Manual,
}
// This is used for synchronizing events between the backend and the frontend.
@ -40,6 +44,7 @@ pub enum P2PEvent {
connection: ConnectionMethod,
discovery: DiscoveryMethod,
metadata: PeerMetadata,
addrs: HashSet<SocketAddr>,
},
// Delete a peer
PeerDelete {
@ -69,7 +74,7 @@ pub struct P2PEvents {
}
impl P2PEvents {
pub fn spawn(p2p: Arc<P2P>, libraries_hook_id: HookId) -> Self {
pub fn spawn(p2p: Arc<P2P>, quic: Arc<QuicHandle>) -> Self {
let events = broadcast::channel(15);
let (tx, rx) = bounded(15);
let _ = p2p.register_hook("sd-frontend-events", tx);
@ -77,62 +82,66 @@ impl P2PEvents {
let events_tx = events.0.clone();
tokio::spawn(async move {
while let Ok(event) = rx.recv_async().await {
let event = match event {
// We use `HookEvent::PeerUnavailable`/`HookEvent::PeerAvailable` over `HookEvent::PeerExpiredBy`/`HookEvent::PeerDiscoveredBy` so that having an active connection is treated as "discovered".
// It's possible to have an active connection without mDNS data (which is what Peer*By` are for)
HookEvent::PeerConnectedWith(_, peer)
| HookEvent::PeerAvailable(peer)
// This will fire for updates to the mDNS metadata which are important for UX.
| HookEvent::PeerDiscoveredBy(_, peer) => {
let metadata = match PeerMetadata::from_hashmap(&peer.metadata()) {
Ok(metadata) => metadata,
Err(e) => {
println!(
"Invalid metadata for peer '{}': {:?}",
peer.identity(),
e
);
let peer = match event {
HookEvent::PeerDisconnectedWith(_, identity)
| HookEvent::PeerExpiredBy(_, identity) => {
let peers = p2p.peers();
let Some(peer) = peers.get(&identity) else {
let _ = events_tx.send(P2PEvent::PeerDelete { identity });
continue;
}
};
};
P2PEvent::PeerChange {
identity: peer.identity(),
connection: if peer.is_connected_with_hook(libraries_hook_id) {
ConnectionMethod::Relay
} else if peer.is_connected() {
ConnectionMethod::Local
} else {
ConnectionMethod::Disconnected
},
discovery: match peer
.connection_candidates()
.contains(&PeerConnectionCandidate::Relay)
{
true => DiscoveryMethod::Relay,
false => DiscoveryMethod::Local,
},
metadata,
}
}
HookEvent::PeerUnavailable(identity) => P2PEvent::PeerDelete { identity },
HookEvent::PeerDisconnectedWith(_, identity) => {
let peers = p2p.peers();
let Some(peer) = peers.get(&identity) else {
peer.clone()
},
// We use `HookEvent::PeerUnavailable`/`HookEvent::PeerAvailable` over `HookEvent::PeerExpiredBy`/`HookEvent::PeerDiscoveredBy` so that having an active connection is treated as "discovered".
// It's possible to have an active connection without mDNS data (which is what Peer*By` are for)
HookEvent::PeerConnectedWith(_, peer)
| HookEvent::PeerAvailable(peer)
// This will fire for updates to the mDNS metadata which are important for UX.
| HookEvent::PeerDiscoveredBy(_, peer) => peer,
HookEvent::PeerUnavailable(identity) => {
let _ = events_tx.send(P2PEvent::PeerDelete { identity });
continue;
};
},
HookEvent::Shutdown { _guard } => break,
_ => continue,
};
if !peer.is_connected() {
P2PEvent::PeerDelete { identity }
} else {
continue;
}
}
HookEvent::Shutdown { _guard } => break,
_ => continue,
let Ok(metadata) = PeerMetadata::from_hashmap(&peer.metadata()).map_err(|err| {
println!("Invalid metadata for peer '{}': {err:?}", peer.identity())
}) else {
continue;
};
let _ = events_tx.send(event);
let _ = events_tx.send(P2PEvent::PeerChange {
identity: peer.identity(),
connection: if peer.is_connected() {
if quic.is_relayed(peer.identity()) {
ConnectionMethod::Relay
} else {
ConnectionMethod::Local
}
} else {
ConnectionMethod::Disconnected
},
discovery: if peer
.connection_candidates()
.iter()
.any(|c| matches!(c, PeerConnectionCandidate::Manual(_)))
{
DiscoveryMethod::Manual
} else if peer
.connection_candidates()
.iter()
.all(|c| *c == PeerConnectionCandidate::Relay)
{
DiscoveryMethod::Relay
} else {
DiscoveryMethod::Local
},
metadata,
addrs: peer.addrs(),
});
}
});

View file

@ -1,6 +1,9 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::HashMap,
sync::{Arc, Mutex, PoisonError},
};
use sd_p2p::{flume::bounded, HookEvent, HookId, PeerConnectionCandidate, RemoteIdentity, P2P};
use sd_p2p::{hooks::QuicHandle, RemoteIdentity, P2P};
use tracing::error;
use crate::library::{Libraries, LibraryManagerEvent};
@ -10,91 +13,127 @@ use crate::library::{Libraries, LibraryManagerEvent};
/// This hooks is responsible for:
/// - injecting library peers into the P2P system so we can connect to them over internet.
///
pub fn libraries_hook(p2p: Arc<P2P>, libraries: Arc<Libraries>) -> HookId {
let (tx, rx) = bounded(15);
let hook_id = p2p.register_hook("sd-libraries-hook", tx);
pub fn libraries_hook(p2p: Arc<P2P>, quic: Arc<QuicHandle>, libraries: Arc<Libraries>) {
let nodes_to_instance = Arc::new(Mutex::new(HashMap::new()));
let handle = tokio::spawn(async move {
if let Err(err) = libraries
.rx
.clone()
.subscribe(|msg| {
let p2p = p2p.clone();
async move {
match msg {
LibraryManagerEvent::InstancesModified(library)
| LibraryManagerEvent::Load(library) => {
p2p.metadata_mut().insert(
library.id.to_string(),
library.identity.to_remote_identity().to_string(),
);
let handle = tokio::spawn({
let quic = quic.clone();
let Ok(instances) =
library.db.instance().find_many(vec![]).exec().await
else {
return;
};
async move {
if let Err(err) = libraries
.rx
.clone()
.subscribe(|msg| {
let p2p = p2p.clone();
let nodes_to_instance = nodes_to_instance.clone();
let quic = quic.clone();
for i in instances.iter() {
let identity = RemoteIdentity::from_bytes(&i.remote_identity)
.expect("lol: invalid DB entry");
// Skip self
if identity == library.identity.to_remote_identity() {
continue;
}
p2p.clone().discover_peer(
hook_id,
identity,
HashMap::new(), // TODO: We should probs cache this so we have something
[PeerConnectionCandidate::Relay].into_iter().collect(),
async move {
match msg {
LibraryManagerEvent::InstancesModified(library)
| LibraryManagerEvent::Load(library) => {
p2p.metadata_mut().insert(
library.id.to_string(),
library.identity.to_remote_identity().to_string(),
);
}
}
LibraryManagerEvent::Edit(_library) => {
// TODO: Send changes to all connected nodes or queue sending for when they are online!
}
LibraryManagerEvent::Delete(library) => {
p2p.metadata_mut().remove(&library.id.to_string());
let Ok(instances) =
library.db.instance().find_many(vec![]).exec().await
else {
return;
};
for i in instances.iter() {
let identity = RemoteIdentity::from_bytes(&i.remote_identity)
.expect("lol: invalid DB entry");
let peers = p2p.peers();
let Some(peer) = peers.get(&identity) else {
continue;
let Ok(instances) =
library.db.instance().find_many(vec![]).exec().await
else {
return;
};
peer.undiscover_peer(hook_id);
let mut nodes_to_instance = nodes_to_instance
.lock()
.unwrap_or_else(PoisonError::into_inner);
for i in instances.iter() {
let identity = RemoteIdentity::from_bytes(&i.remote_identity)
.expect("invalid instance identity");
let node_identity = RemoteIdentity::from_bytes(
i.node_remote_identity
.as_ref()
.expect("node remote identity is required"),
)
.expect("invalid node remote identity");
// Skip self
if i.identity.is_some() {
continue;
}
nodes_to_instance
.entry(identity)
.or_insert(vec![])
.push(node_identity);
quic.track_peer(
node_identity,
serde_json::from_slice(
i.metadata.as_ref().expect("this is a required field"),
)
.expect("invalid metadata"),
);
}
}
LibraryManagerEvent::Edit(_library) => {
// TODO: Send changes to all connected nodes or queue sending for when they are online!
}
LibraryManagerEvent::Delete(library) => {
p2p.metadata_mut().remove(&library.id.to_string());
let Ok(instances) =
library.db.instance().find_many(vec![]).exec().await
else {
return;
};
let mut nodes_to_instance = nodes_to_instance
.lock()
.unwrap_or_else(PoisonError::into_inner);
for i in instances.iter() {
let identity = RemoteIdentity::from_bytes(&i.remote_identity)
.expect("invalid remote identity");
let node_identity = RemoteIdentity::from_bytes(
i.node_remote_identity
.as_ref()
.expect("node remote identity is required"),
)
.expect("invalid node remote identity");
// Skip self
if i.identity.is_some() {
continue;
}
// Only remove if all instances pointing to this node are removed
let Some(identities) = nodes_to_instance.get_mut(&identity)
else {
continue;
};
if let Some(i) =
identities.iter().position(|i| i == &node_identity)
{
identities.remove(i);
}
if identities.is_empty() {
quic.untrack_peer(node_identity);
}
}
}
}
}
}
})
.await
{
error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
}
});
tokio::spawn(async move {
while let Ok(event) = rx.recv_async().await {
match event {
HookEvent::Shutdown { _guard } => {
handle.abort();
break;
}
_ => continue,
})
.await
{
error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
}
}
});
hook_id
tokio::spawn(async move {
quic.shutdown().await;
handle.abort();
});
}

View file

@ -14,11 +14,13 @@ use axum::routing::IntoMakeService;
use sd_p2p::{
flume::{bounded, Receiver},
HookId, Libp2pPeerId, Mdns, Peer, QuicTransport, RelayServerEntry, RemoteIdentity,
UnicastStream, P2P,
hooks::{Libp2pPeerId, Mdns, QuicHandle, QuicTransport, RelayServerEntry},
Peer, RemoteIdentity, UnicastStream, P2P,
};
use sd_p2p_tunnel::Tunnel;
use serde::Serialize;
use serde_json::json;
use specta::Type;
use std::{
collections::HashMap,
convert::Infallible,
@ -28,30 +30,44 @@ use std::{
use tower_service::Service;
use tracing::error;
use tokio::sync::oneshot;
use tokio::sync::{oneshot, Notify};
use tracing::info;
use uuid::Uuid;
use super::{P2PEvents, PeerMetadata};
#[derive(Default)]
pub struct ListenerErrors {
pub ipv4: Option<String>,
pub ipv6: Option<String>,
#[derive(Default, Clone, Serialize, Type)]
#[serde(tag = "type")]
pub enum ListenerState {
Listening,
Error {
error: String,
},
#[default]
NotListening,
}
#[derive(Default, Clone, Serialize, Type)]
pub struct Listeners {
ipv4: ListenerState,
ipv6: ListenerState,
relay: ListenerState,
}
pub struct P2PManager {
pub(crate) p2p: Arc<P2P>,
mdns: Mutex<Option<Mdns>>,
quic: QuicTransport,
quic_transport: QuicTransport,
pub quic: Arc<QuicHandle>,
// The `libp2p::PeerId`. This is for debugging only, use `RemoteIdentity` instead.
lp2p_peer_id: Libp2pPeerId,
pub(crate) events: P2PEvents,
pub(super) spacedrop_pairing_reqs: Arc<Mutex<HashMap<Uuid, oneshot::Sender<Option<String>>>>>,
pub(super) spacedrop_cancellations: Arc<Mutex<HashMap<Uuid, Arc<AtomicBool>>>>,
pub(crate) node_config: Arc<config::Manager>,
pub libraries_hook_id: HookId,
pub listener_errors: Mutex<ListenerErrors>,
pub listeners: Mutex<Listeners>,
relay_config: Mutex<Vec<RelayServerEntry>>,
trigger_relay_config_update: Notify,
}
impl P2PManager {
@ -68,18 +84,20 @@ impl P2PManager {
let (tx, rx) = bounded(25);
let p2p = P2P::new(SPACEDRIVE_APP_ID, node_config.get().await.identity, tx);
let (quic, lp2p_peer_id) = QuicTransport::spawn(p2p.clone()).map_err(|e| e.to_string())?;
let libraries_hook_id = libraries_hook(p2p.clone(), libraries);
libraries_hook(p2p.clone(), quic.handle(), libraries);
let this = Arc::new(Self {
p2p: p2p.clone(),
lp2p_peer_id,
mdns: Mutex::new(None),
quic,
events: P2PEvents::spawn(p2p.clone(), libraries_hook_id),
events: P2PEvents::spawn(p2p.clone(), quic.handle()),
quic: quic.handle(),
quic_transport: quic,
spacedrop_pairing_reqs: Default::default(),
spacedrop_cancellations: Default::default(),
node_config,
libraries_hook_id,
listener_errors: Default::default(),
listeners: Default::default(),
relay_config: Default::default(),
trigger_relay_config_update: Default::default(),
});
this.on_node_config_change().await;
@ -112,8 +130,46 @@ impl P2PManager {
} else {
match resp.json::<Vec<RelayServerEntry>>().await {
Ok(config) => {
this.quic.set_relay_config(config).await;
info!("Updated p2p relay configuration successfully.")
node.p2p
.relay_config
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone_from(&config);
let config = {
let node_config = node.config.get().await;
if !node_config.p2p.disabled
&& !node_config.p2p.disable_relay
{
config
} else {
vec![]
}
};
let no_relays = config.len();
this.listeners
.lock()
.unwrap_or_else(PoisonError::into_inner)
.relay = match this.quic_transport.set_relay_config(config).await {
Ok(_) => {
info!(
"Updated p2p relay configuration successfully."
);
if no_relays == 0 {
this.quic.disable();
ListenerState::NotListening
} else {
this.quic.enable();
ListenerState::Listening
}
}
Err(err) => ListenerState::Error {
error: err.to_string(),
},
};
}
Err(err) => {
error!("Failed to parse p2p relay configuration: {err:?}")
@ -124,7 +180,10 @@ impl P2PManager {
Err(err) => error!("Error pulling p2p relay configuration: {err:?}"),
}
tokio::time::sleep(Duration::from_secs(11 * 60)).await;
tokio::select! {
_ = this.trigger_relay_config_update.notified() => {}
_ = tokio::time::sleep(Duration::from_secs(11 * 60)) => {}
}
}
});
}))
@ -136,6 +195,8 @@ impl P2PManager {
// TODO: Remove this and add a subscription system to `config::Manager`
pub async fn on_node_config_change(&self) {
self.trigger_relay_config_update.notify_waiters();
let config = self.node_config.get().await;
if config.p2p.discovery == P2PDiscoveryState::ContactsOnly {
@ -154,44 +215,68 @@ impl P2PManager {
let port = config.p2p.port.get();
info!(
"Setting quic ipv4 listener to: {:?}",
config.p2p.ipv4.then_some(port)
);
if let Err(err) = self
.quic
.set_ipv4_enabled(config.p2p.ipv4.then_some(port))
.await
{
let ipv4_port = (!config.p2p.disabled).then_some(port);
info!("Setting quic ipv4 listener to: {ipv4_port:?}");
self.listeners
.lock()
.unwrap_or_else(PoisonError::into_inner)
.ipv4 = if let Err(err) = self.quic_transport.set_ipv4_enabled(ipv4_port).await {
error!("Failed to enabled quic ipv4 listener: {err}");
self.node_config.write(|c| c.p2p.ipv4 = false).await.ok();
self.node_config
.write(|c| c.p2p.disabled = false)
.await
.ok();
self.listener_errors
.lock()
.unwrap_or_else(PoisonError::into_inner)
.ipv4 = Some(err.to_string());
}
ListenerState::Error {
error: err.to_string(),
}
} else {
match !config.p2p.disabled {
true => ListenerState::Listening,
false => ListenerState::NotListening,
}
};
info!(
"Setting quic ipv6 listener to: {:?}",
config.p2p.ipv6.then_some(port)
);
if let Err(err) = self
.quic
.set_ipv6_enabled(config.p2p.ipv6.then_some(port))
.await
{
let enable_ipv6 = !config.p2p.disabled && !config.p2p.disable_ipv6;
let ipv6_port = enable_ipv6.then_some(port);
info!("Setting quic ipv6 listener to: {ipv6_port:?}");
self.listeners
.lock()
.unwrap_or_else(PoisonError::into_inner)
.ipv6 = if let Err(err) = self.quic_transport.set_ipv6_enabled(ipv6_port).await {
error!("Failed to enabled quic ipv6 listener: {err}");
self.node_config.write(|c| c.p2p.ipv6 = false).await.ok();
self.node_config
.write(|c| c.p2p.disable_ipv6 = false)
.await
.ok();
self.listener_errors
.lock()
.unwrap_or_else(PoisonError::into_inner)
.ipv6 = Some(err.to_string());
}
ListenerState::Error {
error: err.to_string(),
}
} else {
match enable_ipv6 {
true => ListenerState::Listening,
false => ListenerState::NotListening,
}
};
let should_revert = match config.p2p.discovery {
P2PDiscoveryState::Everyone | P2PDiscoveryState::ContactsOnly => {
self.quic_transport
.set_manual_peer_addrs(config.p2p.manual_peers);
let should_revert = match (config.p2p.disabled, config.p2p.discovery) {
(true, _) | (_, P2PDiscoveryState::Disabled) => {
let mdns = {
let mut mdns = self.mdns.lock().unwrap_or_else(PoisonError::into_inner);
mdns.take()
};
if let Some(mdns) = mdns {
mdns.shutdown().await;
info!("mDNS shutdown successfully.");
}
false
}
(_, P2PDiscoveryState::Everyone | P2PDiscoveryState::ContactsOnly) => {
let mut mdns = self.mdns.lock().unwrap_or_else(PoisonError::into_inner);
if mdns.is_none() {
match Mdns::spawn(self.p2p.clone()) {
@ -209,18 +294,6 @@ impl P2PManager {
false
}
}
P2PDiscoveryState::Disabled => {
let mdns = {
let mut mdns = self.mdns.lock().unwrap_or_else(PoisonError::into_inner);
mdns.take()
};
if let Some(mdns) = mdns {
mdns.shutdown().await;
info!("mDNS shutdown successfully.");
}
false
}
};
// The `should_revert` bit is weird but we need this future to stay `Send` as rspc requires.
@ -268,6 +341,7 @@ impl P2PManager {
"active_connections": p.active_connections(),
"connection_methods": p.connection_methods().iter().map(|id| format!("{:?}", id)).collect::<Vec<_>>(),
"discovered_by": p.discovered_by().iter().map(|id| format!("{:?}", id)).collect::<Vec<_>>(),
"candidates": p.connection_candidates().iter().map(|a| format!("{a:?}")).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"hooks": self.p2p.hooks().iter().map(|(id, name)| json!({
"id": format!("{:?}", id),
@ -275,7 +349,8 @@ impl P2PManager {
"listener_addrs": listeners.iter().find(|l| l.is_hook_id(*id)).map(|l| l.addrs.clone()),
})).collect::<Vec<_>>(),
"config": node_config.p2p,
"relay_config": self.quic.get_relay_config(),
"relay_config": self.quic_transport.get_relay_config(),
"listeners": self.listeners.lock().unwrap_or_else(PoisonError::into_inner).clone(),
})
}
@ -348,7 +423,7 @@ async fn start(
}
};
}
Header::Http => {
Header::RspcRemote => {
let remote = stream.remote_identity();
let Err(err) = operations::rspc::receiver(stream, &mut service, &node).await
else {

View file

@ -1,12 +1,37 @@
use sd_p2p::UnicastStream;
use std::{error::Error, sync::Arc};
use sd_p2p::{RemoteIdentity, UnicastStream, P2P};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::debug;
use crate::p2p::Header;
/// Send a ping to all peers we are connected to
#[allow(unused)]
pub async fn ping() {
todo!();
pub async fn ping(p2p: Arc<P2P>, identity: RemoteIdentity) -> Result<(), Box<dyn Error>> {
let peer = p2p
.peers()
.get(&identity)
.ok_or("Peer not found, has it been discovered?")?
.clone();
let mut stream = peer.new_stream().await?;
stream.write_all(&Header::Ping.to_bytes()).await?;
let mut result = [0; 4];
let _ = stream.read_exact(&mut result).await?;
if result != *b"PONG" {
return Err("Failed to receive pong".into());
}
Ok(())
}
pub(crate) async fn receiver(stream: UnicastStream) {
pub(crate) async fn receiver(mut stream: UnicastStream) {
debug!("Received ping from peer '{}'", stream.remote_identity());
stream
.write_all(b"PONG")
.await
.expect("Failed to send pong");
}

View file

@ -22,7 +22,7 @@ pub async fn remote_rspc(
.clone();
let mut stream = peer.new_stream().await?;
stream.write_all(&Header::Http.to_bytes()).await?;
stream.write_all(&Header::RspcRemote.to_bytes()).await?;
let (mut sender, conn) = hyper::client::conn::handshake(stream).await?;
tokio::task::spawn(async move {
@ -46,7 +46,7 @@ pub(crate) async fn receiver(
// TODO: Authentication
#[allow(clippy::todo)]
if node.config.get().await.p2p.remote_access {
if !node.config.get().await.p2p.enable_remote_access {
todo!("No way buddy!");
}

View file

@ -12,7 +12,7 @@ pub enum Header {
Spacedrop(SpaceblockRequests),
Sync(Uuid),
// A HTTP server used for rspc requests and streaming files
Http,
RspcRemote,
}
#[derive(Debug, Error)]
@ -44,7 +44,7 @@ impl Header {
.await
.map_err(HeaderError::SyncRequest)?,
)),
5 => Ok(Self::Http),
5 => Ok(Self::RspcRemote),
d => Err(HeaderError::DiscriminatorInvalid(d)),
}
}
@ -62,7 +62,7 @@ impl Header {
encode::uuid(&mut bytes, uuid);
bytes
}
Self::Http => vec![5],
Self::RspcRemote => vec![5],
}
}
}

View file

@ -49,6 +49,7 @@ pub struct Instance {
pub identity: RemoteIdentity,
#[serde(rename = "nodeId")]
pub node_id: Uuid,
pub node_remote_identity: String,
pub metadata: HashMap<String, String>,
}
@ -209,6 +210,7 @@ pub mod library {
instance_uuid: Uuid,
instance_identity: RemoteIdentity,
node_id: Uuid,
node_remote_identity: RemoteIdentity,
metadata: &HashMap<String, String>,
) -> Result<CreateResult, Error> {
let Some(auth_token) = config.auth_token else {
@ -226,6 +228,7 @@ pub mod library {
"instanceUuid": instance_uuid,
"instanceIdentity": instance_identity,
"nodeId": node_id,
"nodeRemoteIdentity": node_remote_identity,
"metadata": metadata,
}))
.with_auth(auth_token)
@ -277,6 +280,7 @@ pub mod library {
library_id: Uuid,
instance_id: Uuid,
node_id: Option<Uuid>,
node_remote_identity: Option<RemoteIdentity>,
metadata: Option<HashMap<String, String>>,
) -> Result<(), Error> {
let Some(auth_token) = config.auth_token else {
@ -291,6 +295,7 @@ pub mod library {
))
.json(&json!({
"nodeId": node_id,
"nodeRemoteIdentity": node_remote_identity,
"metadata": metadata,
}))
.with_auth(auth_token)
@ -311,6 +316,7 @@ pub mod library {
instance_uuid: Uuid,
instance_identity: RemoteIdentity,
node_id: Uuid,
node_remote_identity: RemoteIdentity,
metadata: HashMap<String, String>,
) -> Result<Vec<Instance>, Error> {
let Some(auth_token) = config.auth_token else {
@ -326,6 +332,7 @@ pub mod library {
.json(&json!({
"instanceIdentity": instance_identity,
"nodeId": node_id,
"nodeRemoteIdentity": node_remote_identity,
"metadata": metadata,
}))
.with_auth(auth_token)

View file

@ -31,6 +31,8 @@ tokio-stream = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
reqwest = { workspace = true }
futures = { workspace = true }
ed25519-dalek = { version = "2.1.1", features = [] }
flume = "=0.11.0" # Must match version used by `mdns-sd`
@ -59,7 +61,8 @@ sha256 = "1.5.0"
stable-vec = "0.4.0"
hash_map_diff = "0.2.0"
sync_wrapper = "0.1.2"
reqwest.workspace = true
rmp-serde = "1.3.0"
dns-lookup = "2.0.4"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }

141
crates/p2p/src/hook.rs Normal file
View file

@ -0,0 +1,141 @@
use std::{
collections::{BTreeSet, HashSet},
fmt,
net::SocketAddr,
sync::Arc,
};
use flume::Sender;
use tokio::sync::oneshot;
use crate::{Peer, PeerConnectionCandidate, RemoteIdentity};
#[derive(Debug, Clone)]
pub enum HookEvent {
/// `P2P::service` has changed
MetadataModified,
/// A new listener was registered with the P2P system.
ListenerRegistered(ListenerId),
/// A listener's address was added.
ListenerAddrAdded(ListenerId, SocketAddr),
/// A listener's address was removed.
ListenerAddrRemoved(ListenerId, SocketAddr),
/// A listener was unregistered from the P2P system.
ListenerUnregistered(ListenerId),
/// A peer was inserted into `P2P::peers`
/// This peer could have connected to or have been discovered by a hook.
PeerAvailable(Arc<Peer>),
/// A peer was removed from `P2P::peers`
/// This is due to it no longer being discovered, containing no active connections or available connection methods.
PeerUnavailable(RemoteIdentity),
/// A peer was discovered by a hook
/// This will fire for *every peer* per every *hook* that discovers it.
PeerDiscoveredBy(HookId, Arc<Peer>),
/// A hook expired a peer
/// This will fire for *every peer* per every *hook* that discovers it.
PeerExpiredBy(HookId, RemoteIdentity),
/// "Connections" are an internal concept to the P2P library but they will be automatically triggered by `Peer::new_stream`.
/// They are a concept users of the application may care about so they are exposed here.
/// A new listener established a connection with a peer
PeerConnectedWith(ListenerId, Arc<Peer>),
/// A connection closed with a peer.
PeerDisconnectedWith(ListenerId, RemoteIdentity),
/// Your hook or the P2P system was told to shutdown.
Shutdown {
// We can detect when this guard is dropped, it doesn't need to be used.
_guard: ShutdownGuard,
},
}
#[derive(Debug)]
pub struct ShutdownGuard(pub(crate) Option<oneshot::Sender<()>>);
impl ShutdownGuard {
pub(crate) fn new() -> (Self, oneshot::Receiver<()>) {
let (tx, rx) = oneshot::channel();
(Self(Some(tx)), rx)
}
}
impl Drop for ShutdownGuard {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
impl Clone for ShutdownGuard {
fn clone(&self) -> Self {
Self(None)
}
}
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct HookId(pub(crate) usize);
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct ListenerId(pub(crate) usize);
impl From<ListenerId> for HookId {
fn from(value: ListenerId) -> Self {
Self(value.0)
}
}
#[derive(Debug)]
pub(crate) struct Hook {
/// A name used for debugging purposes.
pub(crate) name: &'static str,
/// A channel to send events to the hook.
/// This hooks implementing will be responsible for subscribing to this channel.
pub(crate) tx: Sender<HookEvent>,
/// If this hook is a listener this will be set.
pub(crate) listener: Option<ListenerData>,
}
impl Hook {
pub fn send(&self, event: HookEvent) {
let _ = self.tx.send(event);
}
pub fn acceptor(
&self,
id: ListenerId,
peer: &Arc<Peer>,
addrs: &BTreeSet<PeerConnectionCandidate>,
) {
if let Some(listener) = &self.listener {
(listener.acceptor.0)(id, peer, addrs);
}
}
}
#[derive(Debug)]
pub(crate) struct ListenerData {
/// The address the listener is bound to.
/// These will be advertised by any discovery methods attached to the P2P system.
pub addrs: HashSet<SocketAddr>,
/// This is a function over a channel because we need to ensure the code runs prior to the peer being emitted to the application.
/// If not the peer would have no registered way to connect to it initially which would be confusing.
#[allow(clippy::type_complexity)]
pub acceptor: HandlerFn<
Arc<dyn Fn(ListenerId, &Arc<Peer>, &BTreeSet<PeerConnectionCandidate>) + Send + Sync>,
>,
}
/// A little wrapper for functions to make them `Debug`.
#[derive(Clone)]
pub(crate) struct HandlerFn<F>(pub(crate) F);
impl<F> fmt::Debug for HandlerFn<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "HandlerFn")
}
}

View file

@ -1,141 +1,9 @@
use std::{
collections::{BTreeSet, HashSet},
fmt,
net::SocketAddr,
sync::Arc,
};
//! Components implemented as P2P hooks.
//!
//! Although these are included within `sd_p2p` you could be implemented in userspace.
use flume::Sender;
use tokio::sync::oneshot;
mod mdns;
mod quic;
use crate::{Peer, PeerConnectionCandidate, RemoteIdentity};
#[derive(Debug, Clone)]
pub enum HookEvent {
/// `P2P::service` has changed
MetadataModified,
/// A new listener was registered with the P2P system.
ListenerRegistered(ListenerId),
/// A listener's address was added.
ListenerAddrAdded(ListenerId, SocketAddr),
/// A listener's address was removed.
ListenerAddrRemoved(ListenerId, SocketAddr),
/// A listener was unregistered from the P2P system.
ListenerUnregistered(ListenerId),
/// A peer was inserted into `P2P::peers`
/// This peer could have connected to or have been discovered by a hook.
PeerAvailable(Arc<Peer>),
/// A peer was removed from `P2P::peers`
/// This is due to it no longer being discovered, containing no active connections or available connection methods.
PeerUnavailable(RemoteIdentity),
/// A peer was discovered by a hook
/// This will fire for *every peer* per every *hook* that discovers it.
PeerDiscoveredBy(HookId, Arc<Peer>),
/// A hook expired a peer
/// This will fire for *every peer* per every *hook* that discovers it.
PeerExpiredBy(HookId, RemoteIdentity),
/// "Connections" are an internal concept to the P2P library but they will be automatically triggered by `Peer::new_stream`.
/// They are a concept users of the application may care about so they are exposed here.
/// A new listener established a connection with a peer
PeerConnectedWith(ListenerId, Arc<Peer>),
/// A connection closed with a peer.
PeerDisconnectedWith(ListenerId, RemoteIdentity),
/// Your hook or the P2P system was told to shutdown.
Shutdown {
// We can detect when this guard is dropped, it doesn't need to be used.
_guard: ShutdownGuard,
},
}
#[derive(Debug)]
pub struct ShutdownGuard(pub(crate) Option<oneshot::Sender<()>>);
impl ShutdownGuard {
pub(crate) fn new() -> (Self, oneshot::Receiver<()>) {
let (tx, rx) = oneshot::channel();
(Self(Some(tx)), rx)
}
}
impl Drop for ShutdownGuard {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
impl Clone for ShutdownGuard {
fn clone(&self) -> Self {
Self(None)
}
}
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct HookId(pub(crate) usize);
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct ListenerId(pub(crate) usize);
impl From<ListenerId> for HookId {
fn from(value: ListenerId) -> Self {
Self(value.0)
}
}
#[derive(Debug)]
pub(crate) struct Hook {
/// A name used for debugging purposes.
pub(crate) name: &'static str,
/// A channel to send events to the hook.
/// This hooks implementing will be responsible for subscribing to this channel.
pub(crate) tx: Sender<HookEvent>,
/// If this hook is a listener this will be set.
pub(crate) listener: Option<ListenerData>,
}
impl Hook {
pub fn send(&self, event: HookEvent) {
let _ = self.tx.send(event);
}
pub fn acceptor(
&self,
id: ListenerId,
peer: &Arc<Peer>,
addrs: &BTreeSet<PeerConnectionCandidate>,
) {
if let Some(listener) = &self.listener {
(listener.acceptor.0)(id, peer, addrs);
}
}
}
#[derive(Debug)]
pub(crate) struct ListenerData {
/// The address the listener is bound to.
/// These will be advertised by any discovery methods attached to the P2P system.
pub addrs: HashSet<SocketAddr>,
/// This is a function over a channel because we need to ensure the code runs prior to the peer being emitted to the application.
/// If not the peer would have no registered way to connect to it initially which would be confusing.
#[allow(clippy::type_complexity)]
pub acceptor: HandlerFn<
Arc<dyn Fn(ListenerId, &Arc<Peer>, &BTreeSet<PeerConnectionCandidate>) + Send + Sync>,
>,
}
/// A little wrapper for functions to make them `Debug`.
#[derive(Clone)]
pub(crate) struct HandlerFn<F>(pub(crate) F);
impl<F> fmt::Debug for HandlerFn<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "HandlerFn")
}
}
pub use mdns::Mdns;
pub use quic::{Libp2pPeerId, QuicHandle, QuicTransport, RelayServerEntry};

View file

@ -1,3 +1,7 @@
//! mDNS-based service discovery.
//!
//! This uses [mdns-sd](https://docs.rs/mdns-sd) under the hood.
use std::{
collections::HashMap, net::SocketAddr, pin::Pin, str::FromStr, sync::Arc, time::Duration,
};
@ -29,6 +33,22 @@ impl Mdns {
Ok(Self { p2p, hook_id })
}
// pub fn is_discovered_by(&self, identity: &RemoteIdentity) -> bool {
// self.p2p
// .peers()
// .get(identity)
// .map(|p| p.discovered_by().contains(&self.hook_id))
// .unwrap_or(false)
// }
// pub fn is_connected_with(&self, identity: &RemoteIdentity) -> bool {
// self.p2p
// .peers()
// .get(identity)
// .map(|p| p.is_connected_with_hook(self.hook_id))
// .unwrap_or(false)
// }
pub async fn shutdown(self) {
self.p2p.unregister_hook(self.hook_id).await;
}

View file

@ -0,0 +1,10 @@
//! Quic-based transport.
//!
//! This uses [libp2p](https://docs.rs/libp2p) under the hood.
pub(super) mod handle;
pub(super) mod transport;
pub(super) mod utils;
pub use handle::QuicHandle;
pub use transport::{Libp2pPeerId, QuicTransport, RelayServerEntry};

View file

@ -0,0 +1,122 @@
use std::{
collections::{HashMap, HashSet},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, PoisonError,
},
};
use tokio::sync::Notify;
use crate::{HookId, PeerConnectionCandidate, RemoteIdentity, P2P};
/// A handle to the QUIC hook.
///
/// This allows for manually registering peers, which is required so that we can ask the relay to connect to them.
#[derive(Debug)]
pub struct QuicHandle {
pub(super) shutdown: Notify,
pub(super) p2p: Arc<P2P>,
pub(super) hook_id: HookId,
pub(super) nodes: Mutex<HashMap<RemoteIdentity, HashMap<String, String>>>,
pub(super) enabled: AtomicBool,
pub(super) connected_via_relay: Mutex<HashSet<RemoteIdentity>>,
}
impl QuicHandle {
/// A future that resolves when the QUIC hook is shut down.
pub async fn shutdown(&self) {
self.shutdown.notified().await
}
/// add a new peer to be tracked.
///
/// This will allow the relay to connect to it.
pub fn track_peer(&self, identity: RemoteIdentity, metadata: HashMap<String, String>) {
self.nodes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(identity, metadata.clone());
if self.enabled.load(Ordering::Relaxed) {
self.p2p.clone().discover_peer(
self.hook_id,
identity,
metadata,
[PeerConnectionCandidate::Relay].into_iter().collect(),
);
}
}
/// remove a peer from being tracked.
///
/// This will stop the relay from trying to connect to it.
pub fn untrack_peer(&self, identity: RemoteIdentity) {
self.nodes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&identity);
if self.enabled.load(Ordering::Relaxed) {
if let Some(peer) = self.p2p.peers().get(&identity) {
peer.undiscover_peer(self.hook_id)
}
}
}
/// remove all peers from being tracked.
pub fn untrack_all(&self) {
let mut nodes = self.nodes.lock().unwrap_or_else(PoisonError::into_inner);
for (node, _) in nodes.drain() {
if let Some(peer) = self.p2p.peers().get(&node) {
peer.undiscover_peer(self.hook_id)
}
}
}
/// enabled the track peers from being registered to the P2P system.
///
/// This allows easily removing them when the relay is disabled.
pub fn enable(&self) {
self.enabled.store(true, Ordering::Relaxed);
for (identity, metadata) in self
.nodes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
{
self.p2p.clone().discover_peer(
self.hook_id,
*identity,
metadata.clone(),
[PeerConnectionCandidate::Relay].into_iter().collect(),
);
}
}
/// disabled tracking the peers from being registered to the P2P system.
pub fn disable(&self) {
self.enabled.store(false, Ordering::Relaxed);
for (identity, _) in self
.nodes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
{
if let Some(peer) = self.p2p.peers().get(identity) {
peer.undiscover_peer(self.hook_id)
}
}
}
/// check if a peer is being relayed.
pub fn is_relayed(&self, identity: RemoteIdentity) -> bool {
self.connected_via_relay
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&identity)
.is_some()
}
}

View file

@ -0,0 +1,928 @@
use std::{
collections::{BTreeSet, HashMap, HashSet},
io::{self, ErrorKind},
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
sync::{atomic::AtomicBool, Arc, Mutex, MutexGuard, PoisonError, RwLock},
time::Duration,
};
use flume::{bounded, Receiver, Sender};
use futures::future::join_all;
use libp2p::{
autonat, dcutr,
futures::{AsyncReadExt, AsyncWriteExt, StreamExt},
multiaddr::Protocol,
noise, relay,
swarm::{dial_opts::DialOpts, NetworkBehaviour, SwarmEvent},
yamux, Multiaddr, PeerId, Stream, StreamProtocol, Swarm, SwarmBuilder,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
time::timeout,
};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::{
handle::QuicHandle,
utils::{
identity_to_libp2p_keypair, remote_identity_to_libp2p_peerid, socketaddr_to_multiaddr,
},
};
use crate::{
hooks::quic::utils::multiaddr_to_socketaddr, identity::REMOTE_IDENTITY_LEN, ConnectionRequest,
HookEvent, ListenerId, PeerConnectionCandidate, RemoteIdentity, UnicastStream, P2P,
};
const PROTOCOL: StreamProtocol = StreamProtocol::new("/sdp2p/1");
/// [libp2p::PeerId] for debugging purposes only.
#[derive(Debug)]
#[allow(dead_code)]
pub struct Libp2pPeerId(libp2p::PeerId);
#[derive(Debug)]
enum InternalEvent {
RegisterListener {
id: ListenerId,
ipv4: bool,
addr: SocketAddr,
result: oneshot::Sender<Result<(), String>>,
},
UnregisterListener {
id: ListenerId,
ipv4: bool,
result: oneshot::Sender<Result<(), String>>,
},
RegisterRelays {
relays: Vec<RelayServerEntry>,
result: oneshot::Sender<Result<(), String>>,
},
RegisterPeerAddr {
// These can be socket addr's or FQDN's
addrs: HashSet<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayServerEntry {
id: Uuid,
peer_id: String,
addrs: Vec<SocketAddr>,
}
#[derive(NetworkBehaviour)]
struct MyBehaviour {
stream: libp2p_stream::Behaviour,
// TODO: Can this be optional?
relay: relay::client::Behaviour,
// TODO: Can this be optional?
autonat: autonat::Behaviour,
// TODO: Can this be optional?
dcutr: dcutr::Behaviour,
}
#[derive(Debug, Error)]
pub enum QuicTransportError {
#[error("Failed to modify the SwarmBuilder: {0}")]
SwarmBuilderCreation(String),
#[error("Internal response channel closed: {0}")]
SendChannelClosed(String),
#[error("Internal response channel closed: {0}")]
ReceiveChannelClosed(#[from] oneshot::error::RecvError),
#[error("Failed internal event: {0}")]
InternalEvent(String),
#[error("Failed to create the Listener: {0}")]
ListenerSetup(std::io::Error),
}
/// Transport using Quic to establish a connection between peers.
/// This uses `libp2p` internally.
#[derive(Debug)]
pub struct QuicTransport {
id: ListenerId,
p2p: Arc<P2P>,
internal_tx: Sender<InternalEvent>,
relay_config: Mutex<Vec<RelayServerEntry>>,
ipv4_listener: Mutex<ListenerInfo>,
ipv6_listener: Mutex<ListenerInfo>,
handle: Arc<QuicHandle>,
}
#[derive(Debug, Clone, Default)]
enum ListenerInfo {
/// The listener is disabled.
#[default]
Disabled,
/// The user requested a specific port.
Absolute(SocketAddr),
/// The user requested a random port.
/// The value contains the selected port.
Random(SocketAddr),
}
impl QuicTransport {
/// Spawn the `QuicTransport` and register it with the P2P system.
/// Be aware spawning this does nothing unless you call `Self::set_ipv4_enabled`/`Self::set_ipv6_enabled` to enable the listeners.
pub fn spawn(p2p: Arc<P2P>) -> Result<(Self, Libp2pPeerId), QuicTransportError> {
let keypair = identity_to_libp2p_keypair(p2p.identity());
let libp2p_peer_id = Libp2pPeerId(keypair.public().to_peer_id());
let (tx, rx) = bounded(15);
let (internal_tx, internal_rx) = bounded(15);
let (connect_tx, connect_rx) = mpsc::channel(15);
let id = p2p.register_listener("libp2p-quic", tx, move |listener_id, peer, _addrs| {
// TODO: I don't love this always being registered. Really it should only show up if the other device is online (do a ping-type thing)???
peer.clone()
.listener_available(listener_id, connect_tx.clone());
});
let swarm = SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_quic()
.with_relay_client(noise::Config::new, yamux::Config::default)
.map_err(|err| QuicTransportError::SwarmBuilderCreation(err.to_string()))?
.with_behaviour(|keypair, relay_behaviour| MyBehaviour {
stream: libp2p_stream::Behaviour::new(),
relay: relay_behaviour,
autonat: autonat::Behaviour::new(keypair.public().to_peer_id(), Default::default()),
dcutr: dcutr::Behaviour::new(keypair.public().to_peer_id()),
})
.map_err(|err| QuicTransportError::SwarmBuilderCreation(err.to_string()))?
.with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(60)))
.build();
let handle = Arc::new(QuicHandle {
shutdown: Default::default(),
p2p: p2p.clone(),
hook_id: id.into(),
nodes: Default::default(),
enabled: AtomicBool::new(true),
connected_via_relay: Default::default(),
});
tokio::spawn(start(
p2p.clone(),
id,
swarm,
rx,
internal_rx,
connect_rx,
handle.clone(),
));
Ok((
Self {
id,
p2p,
internal_tx,
relay_config: Mutex::new(Vec::new()),
ipv4_listener: Default::default(),
ipv6_listener: Default::default(),
handle,
},
libp2p_peer_id,
))
}
/// Configure the relay servers to use.
/// This method will replace any existing relay servers.
pub async fn set_relay_config(
&self,
relays: Vec<RelayServerEntry>,
) -> Result<(), QuicTransportError> {
let (tx, rx) = oneshot::channel();
let event = InternalEvent::RegisterRelays {
relays: relays.clone(),
result: tx,
};
self.internal_tx
.send(event)
.map_err(|e| QuicTransportError::SendChannelClosed(e.to_string()))?;
let result = rx
.await
.map_err(QuicTransportError::ReceiveChannelClosed)
.and_then(|r| r.map_err(QuicTransportError::InternalEvent));
if result.is_ok() {
*self
.relay_config
.lock()
.unwrap_or_else(PoisonError::into_inner) = relays;
}
result
}
pub fn get_relay_config(&self) -> Vec<RelayServerEntry> {
self.relay_config
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn set_manual_peer_addrs(&self, addrs: HashSet<String>) {
self.internal_tx
.send(InternalEvent::RegisterPeerAddr { addrs })
.ok();
}
// `None` on the port means disabled. Use `0` for random port.
pub async fn set_ipv4_enabled(&self, port: Option<u16>) -> Result<(), QuicTransportError> {
self.setup_listener(
port.map(|p| SocketAddr::from((Ipv4Addr::UNSPECIFIED, p))),
true,
|this| {
this.ipv4_listener
.lock()
.unwrap_or_else(PoisonError::into_inner)
},
)
.await
}
pub async fn set_ipv6_enabled(&self, port: Option<u16>) -> Result<(), QuicTransportError> {
self.setup_listener(
port.map(|p| SocketAddr::from((Ipv6Addr::UNSPECIFIED, p))),
false,
|this| {
this.ipv6_listener
.lock()
.unwrap_or_else(PoisonError::into_inner)
},
)
.await
}
async fn setup_listener(
&self,
addr: Option<SocketAddr>,
ipv4: bool,
get_listener: impl Fn(&Self) -> MutexGuard<ListenerInfo>,
) -> Result<(), QuicTransportError> {
let mut desired = match addr {
Some(addr) if addr.port() == 0 => ListenerInfo::Random(addr),
Some(addr) => ListenerInfo::Absolute(addr),
None => ListenerInfo::Disabled,
};
let (tx, rx) = oneshot::channel();
let event = {
let listener_state = get_listener(self).clone();
match (listener_state, &mut desired) {
// Desired state is the same as current state
// This is designed to preserve the random port that was determined earlier, making this operation idempotent.
(ListenerInfo::Disabled, ListenerInfo::Disabled)
| (ListenerInfo::Absolute(_), ListenerInfo::Absolute(_))
| (ListenerInfo::Random(_), ListenerInfo::Random(_)) => return Ok(()),
// We are enabled and want to be disabled
(_, ListenerInfo::Disabled) => InternalEvent::UnregisterListener {
id: self.id,
ipv4,
result: tx,
},
// We are any state (but not the same as the desired state) and want to be enabled
(_, ListenerInfo::Random(ref mut addr))
| (_, ListenerInfo::Absolute(ref mut addr)) => {
// We mutable assign back to `desired` so it can be saved if this operation succeeds.
if addr.port() == 0 {
addr.set_port(
TcpListener::bind(*addr)
.await
.map_err(QuicTransportError::ListenerSetup)?
.local_addr()
.map_err(QuicTransportError::ListenerSetup)?
.port(),
);
}
InternalEvent::RegisterListener {
id: self.id,
ipv4,
addr: *addr,
result: tx,
}
}
}
};
self.internal_tx
.send(event)
.map_err(|e| QuicTransportError::SendChannelClosed(e.to_string()))?;
rx.await
.map_err(QuicTransportError::ReceiveChannelClosed)
.and_then(|r| r.map_err(QuicTransportError::InternalEvent))?;
*get_listener(self) = desired;
Ok(())
}
pub fn handle(&self) -> Arc<QuicHandle> {
self.handle.clone()
}
pub async fn shutdown(self) {
self.p2p.unregister_hook(self.id.into()).await;
}
}
async fn start(
p2p: Arc<P2P>,
id: ListenerId,
mut swarm: Swarm<MyBehaviour>,
rx: Receiver<HookEvent>,
internal_rx: Receiver<InternalEvent>,
mut connect_rx: mpsc::Receiver<ConnectionRequest>,
handle: Arc<QuicHandle>,
) {
let mut ipv4_listener = None;
let mut ipv6_listener = None;
let mut control = swarm.behaviour().stream.new_control();
#[allow(clippy::unwrap_used)] // TODO: Error handling
let mut incoming = control.accept(PROTOCOL).unwrap();
let map = Arc::new(RwLock::new(HashMap::new()));
let mut relay_config = Vec::new();
let mut registered_relays = HashMap::new();
let mut manual_addrs = HashSet::new();
let mut manual_addr_dial_attempts = HashMap::new();
let (manual_peers_dial_tx, mut manual_peers_dial_rx) = mpsc::channel(15);
let mut interval = tokio::time::interval(Duration::from_secs(60));
let mut peer_id_to_addrs: HashMap<PeerId, HashSet<SocketAddr>> = HashMap::new();
loop {
tokio::select! {
Ok(event) = rx.recv_async() => match event {
HookEvent::PeerExpiredBy(_, identity) => {
let Some(peer) = p2p.peers.read().unwrap_or_else(PoisonError::into_inner).get(&identity).cloned() else {
continue;
};
let peer_id = remote_identity_to_libp2p_peerid(&identity);
let addrs = {
let state = peer.state.read().unwrap_or_else(PoisonError::into_inner);
get_addrs(peer_id, &relay_config, state.discovered.values().flatten())
};
let mut control = control.clone();
tokio::spawn(async move {
match timeout(Duration::from_secs(5), control.open_stream_with_addrs(
peer_id,
PROTOCOL,
addrs
)).await {
Ok(Ok(_)) => {}
Err(_) | Ok(Err(_)) => peer.disconnected_from(id),
};
});
},
HookEvent::Shutdown { _guard } => {
let connected_peers = swarm.connected_peers().cloned().collect::<Vec<_>>();
for peer_id in connected_peers {
let _ = swarm.disconnect_peer_id(peer_id);
}
if let Some((id, _)) = ipv4_listener.take() {
let _ = swarm.remove_listener(id);
}
if let Some((id, _)) = ipv6_listener.take() {
let _ = swarm.remove_listener(id);
}
// TODO: We don't break the event loop so libp2p can be polled to keep cleaning up.
// break;
},
_ => {},
},
Some((peer_id, mut stream)) = incoming.next() => {
let p2p = p2p.clone();
let map = map.clone();
let peer_id_to_addrs = peer_id_to_addrs.clone();
tokio::spawn(async move {
let mut mode = [0; 1];
match stream.read_exact(&mut mode).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to read mode with libp2p::PeerId({peer_id:?}): {e:?}");
return;
}
}
match mode[0] {
// This is the regular mode for relay or mDNS
0 => {},
// Used for manual peers to discover the peers identity.
1 => {
match stream.write_all(&p2p.identity().to_remote_identity().get_bytes()).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to write remote identity in mode 1 with libp2p::PeerId({peer_id:?}): {e:?}");
return;
}
}
}
mode => {
warn!("Peer libp2p::PeerId({peer_id:?}) attempted to use invalid mode '{mode}'");
return;
}
}
let mut actual = [0; REMOTE_IDENTITY_LEN];
match stream.read_exact(&mut actual).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to read remote identity with libp2p::PeerId({peer_id:?}): {e:?}");
return;
},
}
let identity = match RemoteIdentity::from_bytes(&actual) {
Ok(i) => i,
Err(e) => {
warn!("Failed to parse remote identity with libp2p::PeerId({peer_id:?}): {e:?}");
return;
},
};
// We need to go `PeerId -> RemoteIdentity` but as `PeerId` is a hash that's impossible.
// So to make this work the connection initiator will send their remote identity.
// It is however untrusted as they could send anything, so we convert it to a PeerId and check it matches the PeerId for this connection.
// If it matches, we are certain they own the private key as libp2p takes care of ensuring the PeerId is trusted.
let remote_identity_peer_id = remote_identity_to_libp2p_peerid(&identity);
if peer_id != remote_identity_peer_id {
warn!("Derived remote identity '{remote_identity_peer_id:?}' does not match libp2p::PeerId({peer_id:?})");
return;
}
map.write().unwrap_or_else(PoisonError::into_inner).insert(peer_id, identity);
let remote_metadata = match read_metadata(&mut stream, &p2p).await {
Ok(metadata) => metadata,
Err(e) => {
warn!("Failed to read metadata from '{}': {e}", identity);
return;
},
};
// For mode 1 the stream will be dropped now
if mode[0] != 1 {
let stream = UnicastStream::new(identity, stream.compat());
p2p.connected_to_incoming(
id,
remote_metadata,
stream,
);
} else {
p2p.discover_peer(id.into(), identity, remote_metadata, peer_id_to_addrs.get(&peer_id).into_iter().flatten().map(|v| PeerConnectionCandidate::Manual(*v)).collect());
}
debug!("established inbound stream with '{}'", identity);
});
},
event = swarm.select_next_some() => match event {
SwarmEvent::ConnectionEstablished { peer_id, endpoint, connection_id, .. } => {
if let Some(addr) = multiaddr_to_socketaddr(endpoint.get_remote_address()) {
peer_id_to_addrs.entry(peer_id).or_default().insert(addr);
}
if let Some((addr, socket_addr)) = manual_addr_dial_attempts.remove(&connection_id) {
let mut control = control.clone();
let map = map.clone();
let p2p = p2p.clone();
let self_remote_identity = p2p.identity().to_remote_identity();
debug!("Successfully dialled manual peer '{addr}' found peer '{peer_id}'. Opening stream to get peer information...");
tokio::spawn(async move {
match control.open_stream_with_addrs(
peer_id,
PROTOCOL,
vec![socketaddr_to_multiaddr(&socket_addr)]
).await {
Ok(mut stream) => {
match stream.write_all(&[1]).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to write mode 1 to manual peer '{addr}': {e}");
return;
},
}
let mut identity = [0; REMOTE_IDENTITY_LEN];
match stream.read_exact(&mut identity).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to read remote identity from manual peer '{addr}': {e}");
return;
},
}
let identity = match RemoteIdentity::from_bytes(&identity) {
Ok(i) => i,
Err(e) => {
warn!("Failed to parse remote identity from manual peer '{addr}': {e}");
return;
},
};
info!("Successfully connected with manual peer '{addr}' and found peer '{identity}'");
map.write().unwrap_or_else(PoisonError::into_inner).insert(peer_id, identity);
match stream.write_all(&self_remote_identity.get_bytes()).await {
Ok(_) => {
debug!("Established manual connection with '{identity}'");
let remote_metadata = match send_metadata(&mut stream, &p2p).await {
Ok(metadata) => metadata,
Err(e) => {
warn!("Failed to send metadata to manual peer '{identity}': {e}");
return;
},
};
p2p.discover_peer(id.into(), identity, remote_metadata, BTreeSet::from([PeerConnectionCandidate::Manual(socket_addr)]));
},
Err(e) => {
warn!("Failed to write remote identity to manual peer '{identity}': {e}");
return;
},
}
stream.close().await.ok();
},
Err(e) => {
warn!("Failed to open stream with manual peer '{addr}': {e}");
},
}
});
}
if endpoint.is_relayed() {
if let Some((remote_identity, _)) = handle.nodes.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.find(|(i, _)| remote_identity_to_libp2p_peerid(i) == peer_id) {
handle.connected_via_relay.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(*remote_identity);
}
}
}
SwarmEvent::ConnectionClosed { peer_id, num_established: 0, connection_id, endpoint, .. } => {
if let Some(addr) = multiaddr_to_socketaddr(endpoint.get_remote_address()) {
peer_id_to_addrs.entry(peer_id).or_default().remove(&addr);
}
if let Some((addr, _)) = manual_addr_dial_attempts.remove(&connection_id) {
warn!("Failed to establish manual connection with '{addr}'");
}
let Some(identity) = map.write().unwrap_or_else(PoisonError::into_inner).remove(&peer_id) else {
warn!("Tried to remove a peer that wasn't in the map.");
continue;
};
let peers = p2p.peers.read().unwrap_or_else(PoisonError::into_inner);
let Some(peer) = peers.get(&identity) else {
warn!("Tried to remove a peer that wasn't in the P2P system.");
continue;
};
peer.disconnected_from(id);
},
_ => {}
},
Ok(event) = internal_rx.recv_async() => match event {
InternalEvent::RegisterListener { id, ipv4, addr, result } => {
match swarm.listen_on(socketaddr_to_multiaddr(&addr)) {
Ok(libp2p_listener_id) => {
let this = match ipv4 {
true => &mut ipv4_listener,
false => &mut ipv6_listener,
};
// TODO: Diff the `addr` & if it's changed actually update it
if this.is_none() {
*this = Some((libp2p_listener_id, addr));
p2p.register_listener_addr(id, addr);
}
let _ = result.send(Ok(()));
},
Err(e) => {
let _ = result.send(Err(e.to_string()));
},
}
},
InternalEvent::UnregisterListener { id, ipv4, result } => {
let this = match ipv4 {
true => &mut ipv4_listener,
false => &mut ipv6_listener,
};
if let Some((addr_id, addr)) = this.take() {
if swarm.remove_listener(addr_id) {
p2p.unregister_listener_addr(id, addr);
}
}
let _ = result.send(Ok(()));
},
InternalEvent::RegisterRelays { relays, result } => {
// TODO: We should only add some of the relays - This is discussion in P2P documentation about the Relay
let mut err = None;
for relay in &relays {
let peer_id = match PeerId::from_str(&relay.peer_id) {
Ok(peer_id) => peer_id,
Err(err) => {
error!("Failed to parse Relay peer ID '{}': {err:?}", relay.peer_id);
continue;
},
};
let addrs = relay
.addrs
.iter()
.map(socketaddr_to_multiaddr)
.collect::<Vec<_>>();
for addr in addrs {
swarm
.behaviour_mut()
.autonat
.add_server(peer_id, Some(addr.clone()));
swarm.add_peer_address(peer_id, addr);
}
match swarm.listen_on(
Multiaddr::empty()
.with(Protocol::Memory(40))
.with(Protocol::P2p(peer_id))
.with(Protocol::P2pCircuit)
) {
Ok(listener_id) => {
for addr in &relay.addrs {
registered_relays.insert(*addr, listener_id);
}
},
Err(e) => {
err = Some(format!("Failed to listen on relay server '{}': {e}", relay.id));
break;
},
}
}
if let Some(err) = err {
let _ = result.send(Err(err));
continue;
}
// Cleanup connections to relays that are no longer in the config
// We intentionally do this after establishing new connections so we don't have a gap in connectivity
for (addr, listener_id) in &registered_relays {
if relays.iter().any(|e| e.addrs.contains(addr)) {
continue;
}
swarm.remove_listener(*listener_id);
}
relay_config = relays;
result.send(Ok(())).ok();
},
InternalEvent::RegisterPeerAddr { addrs } => {
manual_addrs = addrs;
interval.reset_immediately();
}
},
Some(req) = connect_rx.recv() => {
let mut control = control.clone();
let self_remote_identity = p2p.identity().to_remote_identity();
let map = map.clone();
let p2p = p2p.clone();
let peer_id = remote_identity_to_libp2p_peerid(&req.to);
let addrs = get_addrs(peer_id, &relay_config, req.addrs.iter());
tokio::spawn(async move {
match control.open_stream_with_addrs(
peer_id,
PROTOCOL,
addrs,
).await {
Ok(mut stream) => {
map.write().unwrap_or_else(PoisonError::into_inner).insert(peer_id, req.to);
// We are in mode `0` so we send a 0 before the remote identity.
let mut buf = [0; REMOTE_IDENTITY_LEN + 1];
buf[1..].copy_from_slice(&self_remote_identity.get_bytes());
match stream.write_all(&buf).await {
Ok(_) => {
debug!("Established outbound stream with '{}'", req.to);
let remote_metadata = match send_metadata(&mut stream, &p2p).await {
Ok(metadata) => metadata,
Err(e) => {
let _ = req.tx.send(Err(e));
return;
},
};
p2p.connected_to_outgoing(id, remote_metadata, req.to);
let _ = req.tx.send(Ok(UnicastStream::new(req.to, stream.compat())));
},
Err(e) => {
let _ = req.tx.send(Err(e.to_string()));
},
}
},
Err(e) => {
let _ = req.tx.send(Err(e.to_string()));
},
}
});
}
Some((addr, socket_addr)) = manual_peers_dial_rx.recv() => {
let opts = DialOpts::unknown_peer_id()
.address(socketaddr_to_multiaddr(&socket_addr))
.build();
manual_addr_dial_attempts.insert(opts.connection_id(), (addr, socket_addr));
match swarm.dial(opts) {
Ok(_) => debug!("Dialling manual peer '{socket_addr}'"),
Err(err) => warn!("Failed to dial manual peer '{socket_addr}': {err}"),
}
}
_ = interval.tick() => {
let addrs = manual_addrs.clone();
let manual_peers_dial_tx = manual_peers_dial_tx.clone();
// Off loop we resolve the IP's and message them back to the main loop, for it to dial as the `swarm` can't be moved.
tokio::spawn(async move {
join_all(addrs.into_iter().map(|addr| {
let manual_peers_dial_tx = manual_peers_dial_tx.clone();
async move {
// TODO: We should probs track these errors for the UI
let Ok(socket_addr) = parse_manual_addr(addr.clone())
.map_err(|err| {
warn!("Failed to parse manual peer address '{addr}': {err}");
}) else {
return;
};
manual_peers_dial_tx.send((addr, socket_addr)).await.ok();
}
})).await;
});
}
}
}
}
async fn send_metadata(
stream: &mut Stream,
p2p: &Arc<P2P>,
) -> Result<HashMap<String, String>, String> {
{
let metadata = p2p.metadata().clone();
let result = rmp_serde::encode::to_vec_named(&metadata)
.map_err(|err| format!("Error encoding metadata: {err:?}"))?;
stream
.write_all(&(result.len() as u64).to_le_bytes())
.await
.map_err(|err| format!("Error writing metadata length: {err:?}"))?;
stream
.write_all(&result)
.await
.map_err(|err| format!("Error writing metadata: {err:?}"))?;
}
let mut len = [0; 8];
stream
.read_exact(&mut len)
.await
.map_err(|err| format!("Error reading metadata length: {err:?}"))?;
let len = u64::from_le_bytes(len);
if len > 1000 {
return Err("Error metadata too large".into());
}
let mut buf = vec![0; len as usize];
stream
.read_exact(&mut buf)
.await
.map_err(|err| format!("Error reading metadata length: {err:?}"))?;
rmp_serde::decode::from_slice(&buf).map_err(|err| format!("Error decoding metadata: {err:?}"))
}
async fn read_metadata(
stream: &mut Stream,
p2p: &Arc<P2P>,
) -> Result<HashMap<String, String>, String> {
let metadata = {
let mut len = [0; 8];
stream
.read_exact(&mut len)
.await
.map_err(|err| format!("Error reading metadata length: {err:?}"))?;
let len = u64::from_le_bytes(len);
if len > 1000 {
return Err("Error metadata too large".into());
}
let mut buf = vec![0; len as usize];
stream
.read_exact(&mut buf)
.await
.map_err(|err| format!("Error reading metadata length: {err:?}"))?;
rmp_serde::decode::from_slice(&buf)
.map_err(|err| format!("Error decoding metadata: {err:?}"))?
};
{
let metadata = p2p.metadata().clone();
let result = rmp_serde::encode::to_vec_named(&metadata)
.map_err(|err| format!("Error encoding metadata: {err:?}"))?;
stream
.write_all(&(result.len() as u64).to_le_bytes())
.await
.map_err(|err| format!("Error writing metadata length: {err:?}"))?;
stream
.write_all(&result)
.await
.map_err(|err| format!("Error writing metadata: {err:?}"))?;
}
Ok(metadata)
}
fn get_addrs<'a>(
peer_id: PeerId,
relay_config: &[RelayServerEntry],
addrs: impl Iterator<Item = &'a PeerConnectionCandidate> + 'a,
) -> Vec<Multiaddr> {
addrs
.flat_map(|v| match v {
PeerConnectionCandidate::SocketAddr(addr) => vec![socketaddr_to_multiaddr(addr)],
PeerConnectionCandidate::Manual(addr) => vec![socketaddr_to_multiaddr(addr)],
PeerConnectionCandidate::Relay => relay_config
.iter()
.filter_map(|e| match PeerId::from_str(&e.peer_id) {
Ok(peer_id) => Some(e.addrs.iter().map(move |addr| (peer_id, addr))),
Err(err) => {
error!("Failed to parse peer ID '{}': {err:?}", e.peer_id);
None
}
})
.flatten()
.map(|(relay_peer_id, addr)| {
let mut addr = socketaddr_to_multiaddr(addr);
addr.push(Protocol::P2p(relay_peer_id));
addr.push(Protocol::P2pCircuit);
addr.push(Protocol::P2p(peer_id));
addr
})
.collect::<Vec<_>>(),
})
.collect::<Vec<_>>()
}
/// Parse the user's input into and do DNS resolution if required.
///
/// `dns_lookup::lookup_host` does allow IP addresses but not socket addresses (ports) so we split them out and handle them separately.
///
fn parse_manual_addr(addr: String) -> io::Result<SocketAddr> {
let mut addr = addr.split(':').peekable();
match (addr.next(), addr.next(), addr.peek()) {
(Some(host), None, _) => dns_lookup::lookup_host(host).and_then(|addr| {
addr.into_iter()
.next()
.map(|ip| SocketAddr::new(ip, 7373))
.ok_or(io::Error::new(ErrorKind::Other, "Invalid address"))
}),
(Some(host), Some(port), None) => {
let port = port
.parse::<u16>()
.map_err(|_| io::Error::new(ErrorKind::Other, "Invalid port number"))?;
dns_lookup::lookup_host(host).and_then(|addr| {
addr.into_iter()
.next()
.map(|ip| SocketAddr::new(ip, port))
.ok_or(io::Error::new(ErrorKind::Other, "Invalid address"))
})
}
(_, _, _) => Err(io::Error::new(ErrorKind::Other, "Invalid address")),
}
}

View file

@ -7,7 +7,7 @@ use libp2p::{identity::Keypair, multiaddr::Protocol, Multiaddr, PeerId};
use crate::{Identity, RemoteIdentity};
#[must_use]
pub(crate) fn socketaddr_to_quic_multiaddr(m: &SocketAddr) -> Multiaddr {
pub(crate) fn socketaddr_to_multiaddr(m: &SocketAddr) -> Multiaddr {
let mut addr = Multiaddr::empty();
match m {
SocketAddr::V4(ip) => addr.push(Protocol::Ip4(*ip.ip())),
@ -18,6 +18,21 @@ pub(crate) fn socketaddr_to_quic_multiaddr(m: &SocketAddr) -> Multiaddr {
addr
}
#[must_use]
pub(crate) fn multiaddr_to_socketaddr(m: &Multiaddr) -> Option<SocketAddr> {
let mut iter = m.iter();
let ip = match iter.next()? {
Protocol::Ip4(ip) => ip.into(),
Protocol::Ip6(ip) => ip.into(),
_ => return None,
};
let port = match iter.next()? {
Protocol::Tcp(port) | Protocol::Udp(port) => port,
_ => return None,
};
Some(SocketAddr::new(ip, port))
}
// This is sketchy, but it makes the whole system a lot easier to work with
// We are assuming the libp2p `PublicKey` is the same format as our `RemoteIdentity` type.
// This is *acktually* true but they reserve the right to change it at any point.

View file

@ -1,21 +1,18 @@
//! Rust Peer to Peer Networking Library
#![warn(clippy::all, clippy::unwrap_used, clippy::panic)]
pub(crate) mod hooks;
pub(crate) mod hook;
pub mod hooks;
mod identity;
mod mdns;
mod p2p;
mod peer;
mod quic;
mod smart_guards;
mod stream;
pub use hooks::{HookEvent, HookId, ListenerId, ShutdownGuard};
pub use hook::{HookEvent, HookId, ListenerId, ShutdownGuard};
pub use identity::{Identity, IdentityErr, RemoteIdentity};
pub use mdns::Mdns;
pub use p2p::{Listener, P2P};
pub use peer::{ConnectionRequest, Peer, PeerConnectionCandidate};
pub use quic::{Libp2pPeerId, QuicTransport, RelayServerEntry};
pub use smart_guards::SmartWriteGuard;
pub use stream::UnicastStream;

View file

@ -9,11 +9,11 @@ use flume::Sender;
use hash_map_diff::hash_map_diff;
use libp2p::futures::future::join_all;
use stable_vec::StableVec;
use tokio::{sync::oneshot, time::timeout};
use tokio::time::timeout;
use tracing::info;
use crate::{
hooks::{HandlerFn, Hook, HookEvent, ListenerData, ListenerId, ShutdownGuard},
hook::{HandlerFn, Hook, HookEvent, ListenerData, ListenerId, ShutdownGuard},
smart_guards::SmartWriteGuard,
HookId, Identity, Peer, PeerConnectionCandidate, RemoteIdentity, UnicastStream,
};
@ -134,11 +134,12 @@ impl P2P {
})
.clone();
{
let mut state: std::sync::RwLockWriteGuard<'_, crate::peer::State> =
peer.state.write().unwrap_or_else(PoisonError::into_inner);
state.discovered.insert(hook_id, addrs.clone());
}
let addrs = {
let mut state = peer.state.write().unwrap_or_else(PoisonError::into_inner);
let a = state.discovered.entry(hook_id).or_default();
a.extend(addrs);
a.clone()
};
peer.metadata_mut().extend(metadata);
@ -162,14 +163,25 @@ impl P2P {
peer
}
pub fn connected_to(
pub fn connected_to_incoming(
self: Arc<Self>,
listener: ListenerId,
metadata: HashMap<String, String>,
stream: UnicastStream,
shutdown_tx: oneshot::Sender<()>,
) -> Arc<Peer> {
let identity = stream.remote_identity();
let peer = self
.clone()
.connected_to_outgoing(listener, metadata, stream.remote_identity());
let _ = self.handler_tx.send(stream);
peer
}
pub fn connected_to_outgoing(
self: Arc<Self>,
listener: ListenerId,
metadata: HashMap<String, String>,
identity: RemoteIdentity,
) -> Arc<Peer> {
let mut peers = self.peers.write().unwrap_or_else(PoisonError::into_inner);
let peer = peers.entry(identity);
let was_peer_inserted = matches!(peer, Entry::Vacant(_));
@ -182,7 +194,7 @@ impl P2P {
{
let mut state = peer.state.write().unwrap_or_else(PoisonError::into_inner);
state.active_connections.insert(listener, shutdown_tx);
state.active_connections.insert(listener);
}
peer.metadata_mut().extend(metadata);
@ -201,8 +213,6 @@ impl P2P {
});
}
let _ = self.handler_tx.send(stream);
peer
}
@ -332,14 +342,21 @@ impl P2P {
let mut peers_to_remove = HashSet::new(); // We are mutate while iterating
for (identity, peer) in peers.iter_mut() {
let mut state = peer.state.write().unwrap_or_else(PoisonError::into_inner);
if let Some(active_connection) =
state.active_connections.remove(&ListenerId(id.0))
{
let _ = active_connection.send(());
if state.active_connections.remove(&ListenerId(id.0)) {
hooks.iter().for_each(|(_, hook)| {
hook.send(HookEvent::PeerDisconnectedWith(
ListenerId(id.0),
peer.identity(),
));
});
}
state.connection_methods.remove(&ListenerId(id.0));
state.discovered.remove(&id);
hooks.iter().for_each(|(_, hook)| {
hook.send(HookEvent::PeerExpiredBy(id, peer.identity()));
});
if state.connection_methods.is_empty() && state.discovered.is_empty() {
peers_to_remove.insert(*identity);
}

View file

@ -28,13 +28,14 @@ pub struct Peer {
pub enum PeerConnectionCandidate {
SocketAddr(SocketAddr),
Relay,
Manual(SocketAddr),
// Custom(String),
}
#[derive(Debug, Default)]
pub(crate) struct State {
/// Active connections with the remote
pub(crate) active_connections: HashMap<ListenerId, oneshot::Sender<()>>,
pub(crate) active_connections: HashSet<ListenerId>,
/// Methods for establishing an active connections with the remote
/// These should be inject by `Listener::acceptor` which is called when a new peer is discovered.
pub(crate) connection_methods: HashMap<ListenerId, mpsc::Sender<ConnectionRequest>>,
@ -148,7 +149,7 @@ impl Peer {
.read()
.unwrap_or_else(PoisonError::into_inner)
.active_connections
.contains_key(&ListenerId(hook_id.0))
.contains(&ListenerId(hook_id.0))
}
pub fn is_connected_with(&self, listener_id: ListenerId) -> bool {
@ -156,7 +157,7 @@ impl Peer {
.read()
.unwrap_or_else(PoisonError::into_inner)
.active_connections
.contains_key(&listener_id)
.contains(&listener_id)
}
pub fn connection_methods(&self) -> HashSet<ListenerId> {
@ -179,6 +180,20 @@ impl Peer {
.collect()
}
pub fn addrs(&self) -> HashSet<SocketAddr> {
self.state
.read()
.unwrap_or_else(PoisonError::into_inner)
.discovered
.values()
.flatten()
.filter_map(|addr| match addr {
PeerConnectionCandidate::SocketAddr(addr) => Some(*addr),
_ => None,
})
.collect()
}
/// Construct a new Quic stream to the peer.
pub async fn new_stream(&self) -> Result<UnicastStream, NewStreamError> {
let (addrs, connect_tx) = {
@ -239,12 +254,28 @@ impl Peer {
.insert(hook, addrs);
}
pub fn listener_available(&self, listener: ListenerId, tx: mpsc::Sender<ConnectionRequest>) {
pub fn listener_available(
self: Arc<Self>,
listener: ListenerId,
tx: mpsc::Sender<ConnectionRequest>,
) {
self.state
.write()
.unwrap_or_else(PoisonError::into_inner)
.connection_methods
.insert(listener, tx);
let Some(p2p) = self.p2p.upgrade() else {
return;
};
p2p.hooks
.read()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.for_each(|(_, hook)| {
hook.send(HookEvent::PeerDiscoveredBy(listener.into(), self.clone()));
});
}
pub fn undiscover_peer(&self, hook_id: HookId) {

View file

@ -1,4 +0,0 @@
pub(super) mod transport;
pub(super) mod utils;
pub use transport::{Libp2pPeerId, QuicTransport, RelayServerEntry};

View file

@ -1,512 +0,0 @@
use std::{
collections::HashMap,
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr,
sync::{Arc, Mutex, PoisonError, RwLock},
time::Duration,
};
use flume::{bounded, Receiver, Sender};
use libp2p::{
autonat, dcutr,
futures::{AsyncReadExt, AsyncWriteExt, StreamExt},
multiaddr::Protocol,
noise, relay,
swarm::{NetworkBehaviour, SwarmEvent},
yamux, Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
time::timeout,
};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tracing::{debug, error, warn};
use uuid::Uuid;
use crate::{
identity::REMOTE_IDENTITY_LEN,
quic::utils::{
identity_to_libp2p_keypair, remote_identity_to_libp2p_peerid, socketaddr_to_quic_multiaddr,
},
ConnectionRequest, HookEvent, ListenerId, PeerConnectionCandidate, RemoteIdentity,
UnicastStream, P2P,
};
const PROTOCOL: StreamProtocol = StreamProtocol::new("/sdp2p/1");
/// [libp2p::PeerId] for debugging purposes only.
#[derive(Debug)]
#[allow(dead_code)]
pub struct Libp2pPeerId(libp2p::PeerId);
#[derive(Debug)]
enum InternalEvent {
RegisterListener {
id: ListenerId,
ipv4: bool,
addr: SocketAddr,
result: oneshot::Sender<Result<(), String>>,
},
UnregisterListener {
id: ListenerId,
ipv4: bool,
result: oneshot::Sender<Result<(), String>>,
},
RegisterRelays {
relays: Vec<RelayServerEntry>,
result: oneshot::Sender<Result<(), String>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelayServerEntry {
id: Uuid,
peer_id: String,
addrs: Vec<SocketAddr>,
}
#[derive(NetworkBehaviour)]
struct MyBehaviour {
stream: libp2p_stream::Behaviour,
// TODO: Can this be optional?
relay: relay::client::Behaviour,
// TODO: Can this be optional?
autonat: autonat::Behaviour,
// TODO: Can this be optional?
dcutr: dcutr::Behaviour,
}
#[derive(Debug, Error)]
pub enum QuicTransportError {
#[error("Failed to modify the SwarmBuilder: {0}")]
SwarmBuilderCreation(String),
#[error("Internal response channel closed: {0}")]
SendChannelClosed(String),
#[error("Internal response channel closed: {0}")]
ReceiveChannelClosed(#[from] oneshot::error::RecvError),
#[error("Failed internal event: {0}")]
InternalEvent(String),
#[error("Failed to create the Listener: {0}")]
ListenerSetup(std::io::Error),
}
/// Transport using Quic to establish a connection between peers.
/// This uses `libp2p` internally.
#[derive(Debug)]
pub struct QuicTransport {
id: ListenerId,
p2p: Arc<P2P>,
internal_tx: Sender<InternalEvent>,
relay_config: Mutex<Vec<RelayServerEntry>>,
}
impl QuicTransport {
/// Spawn the `QuicTransport` and register it with the P2P system.
/// Be aware spawning this does nothing unless you call `Self::set_ipv4_enabled`/`Self::set_ipv6_enabled` to enable the listeners.
pub fn spawn(p2p: Arc<P2P>) -> Result<(Self, Libp2pPeerId), QuicTransportError> {
let keypair = identity_to_libp2p_keypair(p2p.identity());
let libp2p_peer_id = Libp2pPeerId(keypair.public().to_peer_id());
let (tx, rx) = bounded(15);
let (internal_tx, internal_rx) = bounded(15);
let (connect_tx, connect_rx) = mpsc::channel(15);
let id = p2p.register_listener("libp2p-quic", tx, move |listener_id, peer, _addrs| {
// TODO: I don't love this always being registered. Really it should only show up if the other device is online (do a ping-type thing)???
peer.listener_available(listener_id, connect_tx.clone());
});
let swarm = SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_quic()
.with_relay_client(noise::Config::new, yamux::Config::default)
.map_err(|err| QuicTransportError::SwarmBuilderCreation(err.to_string()))?
.with_behaviour(|keypair, relay_behaviour| MyBehaviour {
stream: libp2p_stream::Behaviour::new(),
relay: relay_behaviour,
autonat: autonat::Behaviour::new(keypair.public().to_peer_id(), Default::default()),
dcutr: dcutr::Behaviour::new(keypair.public().to_peer_id()),
})
.map_err(|err| QuicTransportError::SwarmBuilderCreation(err.to_string()))?
.with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(60)))
.build();
tokio::spawn(start(p2p.clone(), id, swarm, rx, internal_rx, connect_rx));
Ok((
Self {
id,
p2p,
internal_tx,
relay_config: Mutex::new(Vec::new()),
},
libp2p_peer_id,
))
}
/// Configure the relay servers to use.
/// This method will replace any existing relay servers.
pub async fn set_relay_config(&self, relays: Vec<RelayServerEntry>) {
let (tx, rx) = oneshot::channel();
let event = InternalEvent::RegisterRelays {
relays: relays.clone(),
result: tx,
};
let Ok(_) = self.internal_tx.send(event) else {
return;
};
match rx.await {
Ok(_) => {
*self
.relay_config
.lock()
.unwrap_or_else(PoisonError::into_inner) = relays;
}
Err(e) => error!("Failed to register relay config as the event loop has died: {e}"),
}
}
pub fn get_relay_config(&self) -> Vec<RelayServerEntry> {
self.relay_config
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
// `None` on the port means disabled. Use `0` for random port.
pub async fn set_ipv4_enabled(&self, port: Option<u16>) -> Result<(), QuicTransportError> {
self.setup_listener(
port.map(|p| SocketAddr::from((Ipv4Addr::UNSPECIFIED, p))),
true,
)
.await
}
pub async fn set_ipv6_enabled(&self, port: Option<u16>) -> Result<(), QuicTransportError> {
self.setup_listener(
port.map(|p| SocketAddr::from((Ipv6Addr::UNSPECIFIED, p))),
false,
)
.await
}
async fn setup_listener(
&self,
addr: Option<SocketAddr>,
ipv4: bool,
) -> Result<(), QuicTransportError> {
let (tx, rx) = oneshot::channel();
let event = if let Some(mut addr) = addr {
if addr.port() == 0 {
addr.set_port(
TcpListener::bind(addr)
.await
.map_err(QuicTransportError::ListenerSetup)?
.local_addr()
.map_err(QuicTransportError::ListenerSetup)?
.port(),
);
}
InternalEvent::RegisterListener {
id: self.id,
ipv4,
addr,
result: tx,
}
} else {
InternalEvent::UnregisterListener {
id: self.id,
ipv4,
result: tx,
}
};
self.internal_tx
.send(event)
.map_err(|e| QuicTransportError::SendChannelClosed(e.to_string()))?;
rx.await
.map_err(QuicTransportError::ReceiveChannelClosed)
.and_then(|r| r.map_err(QuicTransportError::InternalEvent))
}
pub async fn shutdown(self) {
self.p2p.unregister_hook(self.id.into()).await;
}
}
async fn start(
p2p: Arc<P2P>,
id: ListenerId,
mut swarm: Swarm<MyBehaviour>,
rx: Receiver<HookEvent>,
internal_rx: Receiver<InternalEvent>,
mut connect_rx: mpsc::Receiver<ConnectionRequest>,
) {
let mut ipv4_listener = None;
let mut ipv6_listener = None;
let mut control = swarm.behaviour().stream.new_control();
#[allow(clippy::unwrap_used)] // TODO: Error handling
let mut incoming = control.accept(PROTOCOL).unwrap();
let map = Arc::new(RwLock::new(HashMap::new()));
let mut relay_config = Vec::new();
loop {
tokio::select! {
Ok(event) = rx.recv_async() => match event {
HookEvent::PeerExpiredBy(_, identity) => {
let Some(peer) = p2p.peers.read().unwrap_or_else(PoisonError::into_inner).get(&identity).cloned() else {
continue;
};
let peer_id = remote_identity_to_libp2p_peerid(&identity);
let addrs = {
let state = peer.state.read().unwrap_or_else(PoisonError::into_inner);
get_addrs(peer_id, &relay_config, state.discovered.values().flatten())
};
let mut control = control.clone();
tokio::spawn(async move {
match timeout(Duration::from_secs(5), control.open_stream_with_addrs(
peer_id,
PROTOCOL,
addrs
)).await {
Ok(Ok(_)) => {}
Err(_) | Ok(Err(_)) => peer.disconnected_from(id),
};
});
},
HookEvent::Shutdown { _guard } => {
let connected_peers = swarm.connected_peers().cloned().collect::<Vec<_>>();
for peer_id in connected_peers {
let _ = swarm.disconnect_peer_id(peer_id);
}
if let Some((id, _)) = ipv4_listener.take() {
let _ = swarm.remove_listener(id);
}
if let Some((id, _)) = ipv6_listener.take() {
let _ = swarm.remove_listener(id);
}
// TODO: We don't break the event loop so libp2p can be polled to keep cleaning up.
// break;
},
_ => {},
},
Some((peer_id, mut stream)) = incoming.next() => {
let p2p = p2p.clone();
let map = map.clone();
tokio::spawn(async move {
let mut actual = [0; REMOTE_IDENTITY_LEN];
match stream.read_exact(&mut actual).await {
Ok(_) => {},
Err(e) => {
warn!("Failed to read remote identity with libp2p::PeerId({peer_id:?}): {e:?}");
return;
},
}
let identity = match RemoteIdentity::from_bytes(&actual) {
Ok(i) => i,
Err(e) => {
warn!("Failed to parse remote identity with libp2p::PeerId({peer_id:?}): {e:?}");
return;
},
};
// We need to go `PeerId -> RemoteIdentity` but as `PeerId` is a hash that's impossible.
// So to make this work the connection initiator will send their remote identity.
// It is however untrusted as they could send anything, so we convert it to a PeerId and check it matches the PeerId for this connection.
// If it matches, we are certain they own the private key as libp2p takes care of ensuring the PeerId is trusted.
let remote_identity_peer_id = remote_identity_to_libp2p_peerid(&identity);
if peer_id != remote_identity_peer_id {
warn!("Derived remote identity '{remote_identity_peer_id:?}' does not match libp2p::PeerId({peer_id:?})");
return;
}
map.write().unwrap_or_else(PoisonError::into_inner).insert(peer_id, identity);
// TODO: Sync metadata
let metadata = HashMap::new();
let stream = UnicastStream::new(identity, stream.compat());
let (shutdown_tx, shutdown_rx) = oneshot::channel();
p2p.connected_to(
id,
metadata,
stream,
shutdown_tx,
);
debug!("established inbound stream with '{}'", identity);
let _todo = shutdown_rx; // TODO: Handle `shutdown_rx`
});
},
event = swarm.select_next_some() => if let SwarmEvent::ConnectionClosed { peer_id, num_established: 0, .. } = event {
let Some(identity) = map.write().unwrap_or_else(PoisonError::into_inner).remove(&peer_id) else {
warn!("Tried to remove a peer that wasn't in the map.");
continue;
};
let peers = p2p.peers.read().unwrap_or_else(PoisonError::into_inner);
let Some(peer) = peers.get(&identity) else {
warn!("Tried to remove a peer that wasn't in the P2P system.");
continue;
};
peer.disconnected_from(id);
},
Ok(event) = internal_rx.recv_async() => match event {
InternalEvent::RegisterListener { id, ipv4, addr, result } => {
match swarm.listen_on(socketaddr_to_quic_multiaddr(&addr)) {
Ok(libp2p_listener_id) => {
let this = match ipv4 {
true => &mut ipv4_listener,
false => &mut ipv6_listener,
};
// TODO: Diff the `addr` & if it's changed actually update it
if this.is_none() {
*this = Some((libp2p_listener_id, addr));
p2p.register_listener_addr(id, addr);
}
let _ = result.send(Ok(()));
},
Err(e) => {
let _ = result.send(Err(e.to_string()));
},
}
},
InternalEvent::UnregisterListener { id, ipv4, result } => {
let this = match ipv4 {
true => &mut ipv4_listener,
false => &mut ipv6_listener,
};
if let Some((addr_id, addr)) = this.take() {
if swarm.remove_listener(addr_id) {
p2p.unregister_listener_addr(id, addr);
}
}
let _ = result.send(Ok(()));
},
InternalEvent::RegisterRelays { relays, result } => {
// TODO: Replace any existing relays
// TODO: Only add some of the relays???
for relay in &relays {
let peer_id = match PeerId::from_str(&relay.peer_id) {
Ok(peer_id) => peer_id,
Err(err) => {
error!("Failed to parse Relay peer ID '{}': {err:?}", relay.peer_id);
continue;
},
};
let addrs = relay
.addrs
.iter()
.map(socketaddr_to_quic_multiaddr)
.collect::<Vec<_>>();
for addr in addrs {
swarm
.behaviour_mut()
.autonat
.add_server(peer_id, Some(addr.clone()));
swarm.add_peer_address(peer_id, addr);
}
// TODO: Only do this if autonat fails
match swarm.listen_on(
Multiaddr::empty()
.with(Protocol::Memory(40))
.with(Protocol::P2p(peer_id))
.with(Protocol::P2pCircuit)
) {
Ok(_) => {},
Err(e) => {
error!("Failed to listen on relay server '{}': {e}", relay.id);
// TODO: Try again if this fails
},
}
}
relay_config = relays;
// TODO: Proper error handling
result.send(Ok(())).ok();
},
},
Some(req) = connect_rx.recv() => {
let mut control = control.clone();
let self_remote_identity = p2p.identity().to_remote_identity();
let map = map.clone();
let peer_id = remote_identity_to_libp2p_peerid(&req.to);
let addrs = get_addrs(peer_id, &relay_config, req.addrs.iter());
tokio::spawn(async move {
match control.open_stream_with_addrs(
peer_id,
PROTOCOL,
addrs,
).await {
Ok(mut stream) => {
map.write().unwrap_or_else(PoisonError::into_inner).insert(peer_id, req.to);
match stream.write_all(&self_remote_identity.get_bytes()).await {
Ok(_) => {
debug!("Established outbound stream with '{}'", req.to);
let _ = req.tx.send(Ok(UnicastStream::new(req.to, stream.compat())));
},
Err(e) => {
let _ = req.tx.send(Err(e.to_string()));
},
}
},
Err(e) => {
let _ = req.tx.send(Err(e.to_string()));
},
}
});
}
}
}
}
fn get_addrs<'a>(
peer_id: PeerId,
relay_config: &[RelayServerEntry],
addrs: impl Iterator<Item = &'a PeerConnectionCandidate> + 'a,
) -> Vec<Multiaddr> {
addrs
.flat_map(|v| match v {
PeerConnectionCandidate::SocketAddr(addr) => vec![socketaddr_to_quic_multiaddr(addr)],
PeerConnectionCandidate::Relay => relay_config
.iter()
.filter_map(|e| match PeerId::from_str(&e.peer_id) {
Ok(peer_id) => Some(e.addrs.iter().map(move |addr| (peer_id, addr))),
Err(err) => {
error!("Failed to parse peer ID '{}': {err:?}", e.peer_id);
None
}
})
.flatten()
.map(|(relay_peer_id, addr)| {
let mut addr = socketaddr_to_quic_multiaddr(addr);
addr.push(Protocol::P2p(relay_peer_id));
addr.push(Protocol::P2pCircuit);
addr.push(Protocol::P2p(peer_id));
addr
})
.collect::<Vec<_>>(),
})
.collect::<Vec<_>>()
}

View file

@ -0,0 +1,36 @@
---
title: Discovery
index: 25
---
# Discovery
Discovery is the process of finding other nodes to connect with.
We do this through the following 3 systems:
- Manual entry by the user
- [Local Network Discovery](/docs/developers/p2p/local-network-discovery) via mDNS
- [Relay](/docs/developers/p2p/relay) via mDNS
## Overview of methods
From a technical perspective all of these methods operate very differently so it's important to understand the differences between them when making changes to the P2P system.
The relay and manual entry method both require the P2P system to be given an upfront list of peers to connect to, whereas the mDNS system is able to discover them itself. For the relay these peers comes from the instances table in the database and for manual entry these peers comes from the node configuration file.
A quirk manual entry is that when we attempt a connection we don't know the remote nodes identity or metadata prior to connecting. We instead establish a full connection to ask for remote nodes information (`RemoteIdentity` and metadata) after which we treat it as discovered.
This table summarises the differences between the methods:
| | mDNS | Relay | Manual |
|-------------------------------------------------------------------------|---------|----------|-----------|
| Requires upfront knowledge of existence of peer | No | Yes | Yes |
| Knows connection info (metadata, RemoteIdentity) ahead of connection | Yes | Yes | No |
## Manually provided peers
The user can manually provide a set of [`SocketAddr`](https://doc.rust-lang.org/std/net/enum.SocketAddr.html)'s or [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name)'s and the P2P system will attempt to connect to them. If a domain is provided the P2P system will resolve it to an IP address and then attempt to connect to that address.
This feature primarily exists for usage with Docker as mDNS discovery does not work correctly, however it could be useful for working around difficult network setups. It's important to note that you *must* use [port forwarding](https://en.wikipedia.org/wiki/Port_forwarding) and set a static port for the node when using this feature.
When you add a manual peer it will *not* show up in the nodes list until the P2P system is able to establish a connection. This is because without having established a connection the system is unable to determine the remote nodes identity or metadata which is required for it to be considered discovered.

View file

@ -0,0 +1,91 @@
---
title: Local Network Discovery
index: 26
---
# Local Network Discovery
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/src/hooks/mdns.rs)
Our local network discovery uses [DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763.html) which itself is built around [Multicast DNS (mDNS)](https://datatracker.ietf.org/doc/html/rfc6762). This is a really well established technology and is used in [Spotify Connect](https://support.spotify.com/au/article/spotify-connect/), [Apple Airplay](https://www.apple.com/au/airplay/) and many other services you use every day.
We make use of the [mdns-sd](https://docs.rs/mdns-sd) crate.
## Service Structure
The following is an example of what would be broadcast from a single Spacedrive node:
```toml
# {remote_identity_of_self}._sd._udp.local.
name=Oscars Laptop # Shown to the user to select a device
operating_system=macos # Used for UI purposes
device_model=MacBook Pro # Used for UI purposes
version=0.0.1 # Spacedrive version
# For each library that's active on the Spacedrive node:
# {library_uuid}={remote_identity_of_self}
d66ed0c3-03ac-4f9b-a374-a927830dfd5b=0l9vTOWu+5aJs0cyWxdfJEGtloEepGRAXcEuDeTDRPk
```
Within `sd-core` this is defined in two parts. The [`PeerMetadata` struct](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/metadata.rs#L9) takes care of the node metadata and libraries are inserted by the [`libraries_hook`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/libraries.rs#L13).
## Modes
<Notice
type="note"
text="This section discusses 'Contacts Only' which is not yet fully implemented (refer to ENG-1197)."
/>
Within Spacedrive's settings the user is able to choose between three modes for local network discovery:
- **Contacts only**: Only devices that are in your contacts list will be able to see your device.
- **Enabled**: All devices on the local network will be able to see your device.
- **Disabled**: No devices on the local network will be able to see your device.
**Enabled** and **Disabled** are implemented by spawning and shutting down the [`sd_p2p::Mdns`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/crates/p2p/src/mdns.rs#L17) service as required within `sd-core`.
**Contacts only** the mDNS service will not contain the [`PeerMetadata`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/metadata.rs#L9) fields and instead will contain a hash of the users Spacedrive identifier. If a Spacedrive node detects another node in the local network with a hash in it's contacts, it can make a request to the node and if the remote node also has the current node in it's contacts, it will respond with the full metadata.
## Integration with Spacedrive accounts
P2P is currently *not* integrated with Spacedrive accounts and we will integrate it in the future for better security.
Right now we use a remote identity to identify the remote device, however tihs is not very user friendly. If a [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)-style attack is preformed the remote identity will show up with the attacker's device but this isn't going to be particularly noticable by the user.
To combat this issue we can integrate with Spacedrive accounts so the user can be presented with the users name and verified email. This allows the user to prove the remote device is who they expect in a more user friendly way.
The Spacedrive account information will need to be put into the peer metadata and we can use cryptographic signatures to verify the account is linked with the remote device without a network connection.
This issue is tracked as [ENG-1758](https://linear.app/spacedriveapp/issue/ENG-1758/p2p-🤝-spacedrive-accounts)
## Problems with Docker
Docker can be problematic with P2P due to it applying another level of [NAT](https://en.wikipedia.org/wiki/Network_address_translation). This shouldn't cause any issues for usage with the relay but it makes mDNS cease to work.
It is possible to [expose the mDNS daemon](https://medium.com/@andrejtaneski/using-mdns-from-a-docker-container-b516a408a66b) of the host machine into the container, and we could potentially implement something similar, however it's unclear if [`mdns-sd`](https://docs.rs/mdns-sd) uses the OS's daemon and if `avahi` is used by all host Linux distributions we want to support.
When `sd-server` is run from Docker the `Dockerfile` sets the environment variable `SD_DOCKER=true` which is picked up by the core and it exposes P2P on port `7373` instead of a random port like the default configuration.
This allows the administrator to use the `-p 7373:7373` flag when running the container to expose the P2P port to the host machine. This can then be paired with manually entering the IP address and port of the node into the P2P settings of the other node and a connection can be established. Although this is suboptimal, it serves as an alternative for the time being.
This issue is tracked as [ENG-1343](https://linear.app/spacedriveapp/issue/ENG-1343/docker-support-for-p2p).
## Problems with mobile
mDNS discovery does not work on mobile at the moment. To prevent this affecting other devices, we patch [`if-watch`](https://docs.rs/if-watch) using the fork [spacedriveapp/if-watch](https://github.com/spacedriveapp/if-watch). This fork basically implements a *no-op* for mobile so that the core is able to compile.
This issue is tracked as [ENG-1108](https://linear.app/spacedriveapp/issue/ENG-1108/mdns-working-on-ios).
## Problems on Linux
It was reported on Discord that opening Spacedrive would cause excessive network activity. This is possibly a bug with the mDNS system; it was not able to be reproduced on macOS.
This issue is tracked as [ENG-1319](https://linear.app/spacedriveapp/issue/ENG-1319/excessive-mdns-pings).
## Tracking
When information about the device is exposed to the local network we introduce the risk that this information is used for tracking users.
The intention is for the contacts only mode to mitigate this risk as the device will only be discoverable by other devices that are in the users contacts and all information will be unintelligible.
Apple outline some information about how they combat this for AirDrop [here](https://support.apple.com/en-au/guide/security/sec2261183f4/web) and we can do something similar.

View file

@ -0,0 +1,22 @@
---
title: overview
index: 20
---
# Peer-to-peer
Our peer-to-peer technology works at the heart of Spacedrive allowing all of your devices to seamlessly communicate and share data. This documentation outlines the system's design and how to use it.
## Terminology
- **Node**: An application running Spacedrive's network stack.
- This could be the Spacedrive app or the P2P relay.
- If you have multiple Spacedrive installations open on your computer, each one is an independent node.
- **Library**: A logical collection of your data within Spacedrive.
- Conceptually, a library is the conflict resolved state of one or more **instances**, although a lot of the time we don't strictly treat it that way.
- **Instance**: An instance of a library running on a particular node.
- An instance correlates directly to each SQLite file.
- You could *technically* have more than one instance for a library on a single node, although our core would fall apart as we identify traffic by library.
- [`Identity`](https://github.com/spacedriveapp/spacedrive/blob/518d5836f6585a5f597c3ae5a0d27d084adc0a63/crates/p2p/src/identity.rs#L29) - A public/private keypair which represents the library or node.
- [`RemoteIdentity`](https://github.com/spacedriveapp/spacedrive/blob/518d5836f6585a5f597c3ae5a0d27d084adc0a63/crates/p2p/src/identity.rs#L70) - A public key which represents the library or node.
- [`PeerId`](https://docs.rs/libp2p/latest/libp2p/struct.PeerId.html) - The identifier libp2p uses. Can be derived from a `RemoteIdentity`.

View file

@ -0,0 +1,57 @@
---
title: Protocols
index: 29
---
# Protocols
## Ping
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/core/src/p2p/operations/ping.rs)
We have the implementation of a basic ping protocol. This is not actually used within Spacedrive but acts a reference for implementing a new protocol.
## Spacedrop
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/core/src/p2p/operations/spacedrop.rs)
Spacedrop is a system for sending files quickly to other peers. It is intended for sending to peers that have not been paired into the library. It is great for sending a file to a friend on your same network running Spacedrive but you can use the regular file manager for sharing a file without another node in your library.
This protocol works but some of the following are missing features:
- Pause/resumable transfers
- Transfer a folder - [ENG-1297](https://linear.app/spacedriveapp/issue/ENG-1297/spacedrop-create-folder-button-in-save-dialog-for-multiple-file)
- Usage with `sd-server` will result in bugs if you have multiple web clients - [ENG-1034](https://linear.app/spacedriveapp/issue/ENG-1034/spacedrop-on-multi-user-server-will-break) and [ENG-1522](https://linear.app/spacedriveapp/issue/ENG-1522/spacedrop-on-web)
The following are known bugs:
- [ENG-1298](https://linear.app/spacedriveapp/issue/ENG-1298/spacedrop-cancel-prior-to-toast)
- [ENG-1035](https://linear.app/spacedriveapp/issue/ENG-1035/spacedrop-show-toast-while-waiting-for-remote-to-acceptdeny-response)
- [ENG-1203](https://linear.app/spacedriveapp/issue/ENG-1203/spacedrop-ui-handle-timeouts)
- [ENG-1211](https://linear.app/spacedriveapp/issue/ENG-1211/spacedrop-what-if-file-content-changes-while-sending)
## rspc
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/core/src/p2p/operations/rspc.rs)
This protocol was an experiment to expose the rspc router of a node over P2P. Although it works this is a security nightmare so it has been disabled by default and hidden behind the `wipP2P` feature flag.
How to test this feature:
- Enable the `wipP2P` feature flag
- Enable the feature within the network page of settings
- Ensure "Enable remote acccess" is enabled on both nodes
- Click the "rspc remote" button on the node you want to connect to.
- If the connection fails you will be presented with a white screen, otherwise you will be given a library selection and once seleted you will be given Spacedrive UI running on the remote node.
Major problems with this feature:
- This protocol doesn't have any security (hence it being disabled by default). It's also a nightmare to secure as it's full access (including to do filesystem actions) or no access. - [ENG-1646](https://linear.app/spacedriveapp/issue/ENG-1646/rspc-over-p2p-handle-condition-in-ui-of-remote-offline-node)
- The rspc websocket connection established over the P2P system is leaked so it will never be cleaned up. Fixing this would require changes to rspc. - [ENG-1647](https://linear.app/spacedriveapp/issue/ENG-1647/stop-leaking-rspc-p2p-websockets)
- Any usage of rspc outside the React context will still be using the local node's rspc router. We don't do this often but we definitely do it. - [ENG-1648](https://linear.app/spacedriveapp/issue/ENG-1648/prevent-any-usage-of-rspc-out-of-the-react-context)
From my ([oscartbeaumont](https://github.com/oscartbeaumont)'s) perspective this was a cool experiment but not something we should ship because getting it's nightmare to get security right.
## Sync
Unimplemented
In an earlier version of the P2P system we had a method for sending sync messages to other nodes over the peer to peer connection, however this was removed during some refactoring of the sync system.
The code for it could be taken from [here](https://github.com/spacedriveapp/spacedrive/blob/aa72c083c2e5f6cf33f3c1fb66283e5fe0d1ba3b/core/src/p2p/pairing/mod.rs) and upgraded to account for changes to the sync and P2P system to bring back this functionality.

View file

@ -0,0 +1,102 @@
---
title: relay
index: 27
---
# Relay
To establish connections outside of your local network we rely on an external relay to help with coordinating connections and also to proxy traffic between peers if the network conditions are not favourable.
## Implementation
We make use of [libp2p](https://libp2p.io)'s [Direct Connection Upgrade through Relay](https://github.com/libp2p/specs/blob/master/relay/DCUtR.md) and [Circuit Relay](https://github.com/libp2p/specs/blob/master/relay/README.md) protocols for our relay system.
[Client Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/src/hooks/quic/transport.rs)
·
[Server Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/apps/p2p-relay)
## Relay discovery
Each client will regularly make requests to [https://app.spacedrive.com/api/p2p/relays](https://app.spacedrive.com/api/p2p/relays) to get the list of currently active relay servers.
Each relay server will register itself with the discovery server automatically when started. This requires an authentication token so it can only be done by Spacedrive ran servers.
We store the relays in Redis with a TTL. This is so if the relay server is shutdown and does not do its regular check-in, it will be automatically removed from the pool.
## How it works
We register a listen for each relay that is returned from the discovery server. When a connection is established we will attempt to connect to the relay server. We also attempt to establish connections with peers that we already know about through the active libraries.
Currently we connect to every relay server that is returned from the discovery server. This is obviously not ideal but if two nodes were to connect to the different relay servers we would need some way of communicating between them (which is a complicated problem to solve).
The issue of connecting to every relay server is tracked as [ENG-1672](https://linear.app/spacedriveapp/issue/ENG-1672/mesh-relays).
## Authentication
Currently the relay service is completly unauthenticated. To prevent abuse we are planning to restrict the relays to Spacedrive accounts.
libp2p doesn't have a ready-made solution for this as it's heavily designed around the [IPFS](https://ipfs.tech) usecase which is all open. This will likely require a custom network behavior to be implemented in libp2p which will be a decent undertaking.
This issue is tracked as [ENG-1652](https://linear.app/spacedriveapp/issue/ENG-1652/relay-authentication).
## Billing
Currently the relay service has no method of tracking usage based on the connected peers.
libp2p doesn't have a ready-made solution for this as it's heavily designed around the [IPFS](https://ipfs.tech) use case, which is all open. This will likely require a custom network behavior to be implemented in libp2p which will be a decent undertaking.
This issue is tracked as [ENG-1667](https://linear.app/spacedriveapp/issue/ENG-1667/relay-metering).
## Rate limiting
We should rate limit connection being opened with the Relay to ensure denial of service attacks for not possible.
libp2p has a built-in [RateLimiter](https://docs.rs/libp2p/latest/libp2p/relay/trait.RateLimiter.html) trait which we can implement. The rate limiting information should be stored to Redis so it shared between all relays.
## Alternative design
Our relay system is currently built on top of [libp2p](https://libp2p.io)'s system for relays. Given all of the limitations of the current design discussed above I don't think libp2p's relay system was really designed for private relays so it could be worth dropping it entirely and investigating another solution.
I have done some digging into [WebRTC](https://webrtc.org) (specially [STUN](https://en.wikipedia.org/wiki/STUN) and [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT)) and it does seem like a really solid alternative.
Given the core of the `sd_p2p` crate is decoupled from libp2p we could easily implement an alternative connection system based on WebRTC while keeping libp2p for the quic-based transport for local networks.
The major advantage to using WebRTC would be the ability to use a SaSS solution for hosting the relay infrastructure. WebRTC is based on [STUN](https://en.wikipedia.org/wiki/STUN) and [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) which are very ubiquitous protocols. The following is a comparison of some webrtc services:
| | Pricing (per GB ) | Has Billing API |
|-------------------------------------------------------------------|-------------------|-----------------|
| [Cloudflare Calls](https://developers.cloudflare.com/calls/turn/) | 0.05$ | No |
| [Twilio](https://www.twilio.com/stun-turn) | 0.40$ to 0.80$ | No |
| [Metered](https://www.metered.ca/stun-turn) | 0.40$ to 0.10$ | Yes |
WebRTC also has a built in system for authentication via the SDP object that needs to be exchanged between peers for a valid connection to be established. For an explaination of webrtc checkout [this Fireship video](https://www.youtube.com/watch?v=WmR9IMUD_CY).
libp2p *does* have a [WebRTC transport](https://docs.rs/libp2p-webrtc/0.7.1-alpha) but it seems to be only for browser to server communication not server to server like we require so I don't think it would be viable for our usecase.
### Security
The relay works through encrypted communication so we can not read the data that is being relayed. The relay servers currently must be owned by Spacedrive to work, however it's possible we can allow the community to run their own relays in the future.
### Hosting the Relay server
<Notice
type="note"
text="Be careful running this on your local machine as it will expose your public IP address to all Spacedrive users."
/>
1. Set up the server using the following command:
```bash
cargo run -p sd-p2p-relay init
# You will be prompted to enter the p2p secret.
# It can be found in the `spacedrive-api` Vercel project as the `P2P_SECRET` environment variable.
```
2. Now that you have set up the server, you can run the relay server using the following command:
```bash
cargo run -p sd-p2p-relay
```
*Note that you will need to ensure port `7373` is exposed through your firewall for this to work.*

View file

@ -0,0 +1,34 @@
---
title: sd-p2p
index: 22
---
# `sd_p2p` crate
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p)
The P2P crate was designed from the ground up to be modular.
The `P2P` struct is the core of the system, but doesn't actually do any P2P functionality. It's a state manager and event bus which exposes a hook system for other components of the P2P system to register themselves.
This modular design helps with separation of concerns which significantly helps with comprehending the entire system and streamlines testing.
## What are hooks?
A hook is very similar to an actor. It's a component which can be registered with the P2P system and it is allowed to listen and react to events.
A hook allows for processing events from the P2P system and also ensures when the P2P system shuts down, the hook is also shutdown.
There are special hooks called listeners. These are implemented as a superset of a regular hook and are able to create and accept connections.
## Default hooks?
The `sd_p2p` crate comes with a few default hooks:
- `Mdns` - Local network discovery using mDNS
- `Quic` - Quic transport layer built on top of `libp2p`
Spacedrive implements some of it's own hooks within the `core/src/p2p` directory to deal with libraries correctly.
## Lazy vs eager connection
The P2P system is designed to lazily connect to peers. This is intentional to preserve battery life and reduce network usage. When the clients attempts to connect to a remote peer it will establish a connection and automatically close it after a period of inactivity.

View file

@ -0,0 +1,27 @@
---
title: sd-p2p-block
index: 24
---
# `sd_p2p_block`
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/block)
A file block protocol based on [SyncThing Block Exchange Protocol v1](https://docs.syncthing.net/specs/bep-v1.html).
The goal of this protocol is to take bytes in and reliabily and quickly transfer them to the other side.
## Example
```rust
# TODO
```
TODO - Outline my idea for a better implementation.
https://linear.app/spacedriveapp/issue/ENG-1760/block-protocol-v2
https://linear.app/spacedriveapp/issue/ENG-1292/spaceblock-abstract-name-from-spaceblockrequest
https://linear.app/spacedriveapp/issue/ENG-1312/spaceblock-file-checksum
https://linear.app/spacedriveapp/issue/ENG-563/spaceblock-error-handling
https://linear.app/spacedriveapp/issue/ENG-567/spaceblock-cancel-transfer
https://linear.app/spacedriveapp/issue/ENG-572/spaceblock-file-name-overflow

View file

@ -0,0 +1,75 @@
---
title: sd-p2p-proto
index: 23
---
# `sd_p2p_proto`
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/proto)
This crate provides utilities for implementing asynchronous deserializers and matching synchronous serializers. The goal of these implementations is to rapidly send and receive Rust structs over the network.
This crate allows for creating implementations faster than other common options, at the cost of some developer experience.
Before building, the performance of both [msgpack](https://docs.rs/rmp-serde) and [bincode](https://docs.rs/bincode) was compared against manual implementations using `AsyncRead`. It was found that over the network using asynchronous deserialization was faster.
This logically follows as if you use a synchronous serializer, you will do the following:
- Send the total length of the message
- Allocate a buffer for the message
- Wait asynchronously for the buffer to be filled
- Synchronously copy from the buffer into each of the struct fields
When using an asynchronous serializer you can skip sending the messages length and allocating the intermediate buffer as we can rely on the known length of each field while decoding - this is a win for performance and memory usage.
This crate provides utilities to make the implementations less error prone. However, long term it would be great to replace this with a derive macro similar to how crates like [serde](https://serde.rs) work.
From my ([oscartbeaumont](https://github.com/oscartbeaumont)'s) research no crate exists that meets these requirements. I attempted the implementation of one called [binario](https://github.com/oscartbeaumont/binario), however it is still incomplete as juggling async and lifetimes is pretty challenging. The recent stablisation of [RPITIT](https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html) would likely make this much easier if it were to be implemented today.
This issue is tracked as [ENG-431](https://linear.app/spacedriveapp/issue/ENG-431/binary-handling-abstraction).
## Example
### UUID
```rust
let uuid: uuid::Uuid = todo!();
// Encode
let mut bytes = vec![];
encode::uuid(&mut bytes, uuid);
// Decode
let stream: impl AsyncRead + Unpin = todo!(); // This will commonly be a `sd_p2p::UnicastStream`
let uuid = decode::uuid(&mut stream).await.unwrap();
```
### String
```rust
let string = format!("Hello, World!");
// Encode
let mut bytes = vec![];
encode::string(&mut bytes, string);
// Decode
let stream: impl AsyncRead + Unpin = todo!(); // This will commonly be a `sd_p2p::UnicastStream`
let string = decode::string(&mut stream).await.unwrap();
```
### Buffer
This may seem redundant but it is required for dynamically sized buffers as it is not possible to know the length of the buffer in advance so when decoding, so we must send the length of the buffer too.
```rust
let buf = b"Hello, World!".to_vec();
// Encode
let mut bytes = vec![];
encode::buf(&mut bytes, buf);
// Decode
let stream: impl AsyncRead + Unpin = todo!(); // This will commonly be a `sd_p2p::UnicastStream`
let buf = decode::buf(&mut stream).await.unwrap();
```

View file

@ -0,0 +1,18 @@
---
title: sd-p2p-tunnel
index: 24
---
# `sd-p2p-tunnel`
[Implementation](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/tunnel)
TODO
## Example
```rust
# TODO
```
TODO - https://linear.app/spacedriveapp/issue/ENG-753/spacetunnel-encryption

View file

@ -0,0 +1,16 @@
---
title: Transport layer
index: 28
---
# Transport Layer
We use [QUIC](https://en.wikipedia.org/wiki/QUIC) as our transport layer for peer to peer communication. QUIC is the perfect protocol for our use cases as it's fast, has built in stream multiplexing and has TLS built in so we can encryption out of the box.
## TLS authentication
Quic comes with built in TLS authentication with we make use of. Each node is issued a keypair ([`Identity`](https://github.com/spacedriveapp/spacedrive/blob/518d5836f6585a5f597c3ae5a0d27d084adc0a63/crates/p2p/src/identity.rs#L29)) which is stored in the node configuration.
This certificate ensures the communication between our node and the remote node can't be intercepted or tampered with, however it provides no assurances about the identity of the remote node.
An attacker could still do an [MITM](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) by sitting in the middle and presenting it's certificate to each side. To combat this we also have library certificates that allow us to verify and encrypt the libraries traffic so it can only be decoded by another node within the library.

View file

@ -0,0 +1,77 @@
---
title: usage
index: 21
---
# Usage
This is a high-level guide of how to build features within Spacedrive on top of the peer-to-peer system. I would recommend referring to this [example PR](#todo) alongside this guide as a practical reference.
Start by adding a new variant to [`Header` enum](https://github.com/spacedriveapp/spacedrive/blob/main/core/src/p2p/protocol.rs) and adjusting the `Header::from_stream` and `Header::to_bytes` implementation to support it.
Next create a new file for the features code in [`core/src/p2p/operations`](https://github.com/spacedriveapp/spacedrive/tree/main/core/src/p2p/operations) like the following:
```rust
use std::{error::Error, sync::Arc};
use sd_p2p::{RemoteIdentity, UnicastStream, P2P};
use tokio::io::AsyncWriteExt;
use tracing::debug;
use crate::p2p::Header;
/// This method can be called to send a ping to a remote peer.
/// The P2P system will take care of finding the peer and establishing a connection.
pub async fn ping(p2p: Arc<P2P>, identity: RemoteIdentity) -> Result<(), Box<dyn Error>> {
let peer = p2p
.peers()
.get(&identity)
.ok_or("Peer not found, has it been discovered?")?
.clone();
let mut stream = peer.new_stream().await?;
stream.write_all(&Header::NameOfYourNewHeaderVariant.to_bytes()).await?;
Ok(())
}
/// This method is called when a ping `Header` is found on the incoming request.
/// You must call this from the `match header` on the incoming handler.
pub(crate) async fn receiver(stream: UnicastStream) {
debug!("Received communication from peer '{}'", stream.remote_identity());
}
```
Next you need to setup an incoming handler [here](https://github.com/spacedriveapp/spacedrive/blob/4a62d268efea7dd6ff573531b1e2b2970c7ba562/core/src/p2p/manager.rs#L306) to define how your new `Header` variant should be handled when received. It should look something like:
```rust
match header {
...
Header::NameOfYourNewHeaderVariant => operations::name_of_your_new_file::receiver(stream).await;
}
```
Finally, you can use the `UnicastStream` stream which implements [`AsyncRead`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html) + [`AsyncWrite`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html) to send data back and forth between peers to implement any application functionality.
## Version compatibility and breaking changes
It is the responsibility of the developer to ensure the protocol does not go through any breaking changes, as this would cause communication errors when multiple devices are running different versions of the software.
However, sometimes a breaking change may be required so we keep track of the Spacedrive version of each node within the peer metadata which can be used to coordinate breaking changes.
In the sending code you will already have access to the `Peer` so you can access the metadata directly. If your in the receiver code you can use the following to get the `Peer`:
```rust
let peer = p2p.peers().get(&stream.remote_identity()).unwrap();
```
Then you can access the version from the metadata like so:
```rust
// If your in the receiver method you've got the `peer` if not you can get it from the P2P system:
let metadata = PeerMetadata::from_hashmap(&peer.metadata()).unwrap();
// You could use the `semver` crate to compare versions
let is_running_version_0_1_0 = metadata.version.as_deref() == Some("0.1.0");
```

View file

@ -1,208 +0,0 @@
---
title: p2p
index: 14
---
# Peer-to-peer
Our peer-to-peer technology works at the heart of Spacedrive allowing all of your devices to seamlessly communicate and share data. This documentation outlines
## Implementing features with P2P
TODO:
- From frontend, to backend
- Including authentication
- Versioning/making breaking change
- Show using `sd_p2p_tunnel`
## Underlying technology
### Terminology
- **Node**: An application running Spacedrive's network stack.
- This could be the Spacedrive app or the P2P relay.
- If you have multiple Spacedrive installations open on your computer, each one is an independant node.
- **Library**: A logical collection of your data within Spacedrive.
- From a theorical perspective, a library is just the conflict resolved state of one or more **instances** although a lot of the time we don't stricly treat it that way.
- **Instance**: An instance of a library running on a particular node.
- An instance correlates directly to each SQLite file.
- You could *technically* have more than one instance for a library on a single node, although our core would fall apart as we identify traffic by library.
- [`Identity`](https://github.com/spacedriveapp/spacedrive/blob/518d5836f6585a5f597c3ae5a0d27d084adc0a63/crates/p2p/src/identity.rs#L29) - A public/private keypair which represents the library or node.
- [`RemoteIdentity`](https://github.com/spacedriveapp/spacedrive/blob/518d5836f6585a5f597c3ae5a0d27d084adc0a63/crates/p2p/src/identity.rs#L70) - A public key which represents the library or node.
- [`PeerId`](https://docs.rs/libp2p/latest/libp2p/struct.PeerId.html) - The identifier libp2p uses. Can be derived from a `RemoteIdentity`.
### `sd_p2p` crate
The P2P crate was designed from the group up to be modular.
The `P2P` struct is the core of the system, and suprisingly doesn't actually do any P2P functionality. It's a state manager and event bus along with providing a hook system for other components of the P2P system to register themselves.
This modular design helps with separting the concern which helps with comprehending the entire system and makes it easier for testing.
The `sd_p2p` crate provides a hook for:
- `Mdns` - Local network discovery
- `Quic` - Quic transport layer built on top of `libp2p`
#### What are hooks?
A hook is very similar to an actor. It's a component which can be registered with the P2P system.
A hook allows for processing events from the P2P system and also ensures when the P2P system shuts down, the hook is also shutdown.
Their are special hooks called listeners. These are implemented as a superset of a regular hook and are able to create and accept connections.
Subcrates:
- [`sd_p2p_block`](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/block) - Block protocol based on [SyncThing Block Exchange Protocol v1](https://docs.syncthing.net/specs/bep-v1.html)
- [`sd_p2p_proto`](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/proto) - Utilities for zero fluff encoding and decoding.
- [`sd_p2p_tunnel`](https://github.com/spacedriveapp/spacedrive/tree/main/crates/p2p/crates/tunnel) - Encrypt a stream of data between two nodes
#### `sd_p2p_proto`
This crate provides utilities for implementing asynchronous deserializers and matching synchronous serializers. The goal of these implementations is to really quickly send and receive Rust structs over the network.
This crate allows for creating implementations faster than other common options, at the cost of some developer experience.
Before building this I originally compared the performance of both [msgpack](https://docs.rs/rmp-serde) and [bincode](https://docs.rs/bincode) against manual implementations using `AsyncRead` and I found that over the network using asynchronous deserialization was faster.
This makes logically makes sense as if you want to use a synchronous serializer you will do the following:
- Send the total length of the message
- Allocate a buffer for the message
- Wait asynchronously for the buffer to be filled
- Synchronously copy from the buffer into each of the struct fields
When using an asynchronous serializer you can skip sending the messages length and allocating the intermediate buffer as we can rely on the known length of each field while decoding and this is a win for performance and memory usage.
This crate provides utilities to make the implementations less error prone, however long term it would be great to replace this with a derive macro similar to how crates like [serde](https://serde.rs) work.
From my research no crate exists that meets these requirements. It is also a difficult problem because your juggling lifetimes and async which is rough. I attempted an implementation called [binario](https://github.com/oscartbeaumont/binario), however it is still incomplete so we never adopted it. I suspect Rust's recent stablisation of [RPITIT](https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html) would make this much easier.
### Local Network Discovery
Our local network discovery uses [DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763.html) which itself is built around [Multicast DNS (mDNS)](https://datatracker.ietf.org/doc/html/rfc6762). This is a really well established technology and is used in [Spotify Connect](https://support.spotify.com/au/article/spotify-connect/), [Apple Airplay](https://www.apple.com/au/airplay/) and many other services you use every day.
#### Service Structure
The following is an example of what would be broadcast from a single Spacedrive node:
```toml
# {remote_identity_of_self}._sd._udp.local.
name=Oscars Laptop # Shown to the user to select a device
operating_system=macos # Used for UI purposes
device_model=MacBook Pro # Used for UI purposes
version=0.0.1 # Spacedrive version
# For each library that's active on the Spacedrive node:
# {library_uuid}={remote_identity_of_self}
d66ed0c3-03ac-4f9b-a374-a927830dfd5b=0l9vTOWu+5aJs0cyWxdfJEGtloEepGRAXcEuDeTDRPk
```
Within `sd-core` this is defined in two parts. The [`PeerMetadata` struct](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/metadata.rs#L9) takes care of the node metadata and libraries are inserted by the [`libraries_hook`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/libraries.rs#L13).
#### Modes
<Notice
type="note"
text="This section discusses 'Contacts Only' which is not yet fully implemented (refer to ENG-1197)."
/>
Within Spacedrive's settings the user is able to choose between three modes for local network discovery:
- **Contacts only**: Only devices that are in your contacts list will be able to see your device.
- **Enabled**: All devices on the local network will be able to see your device.
- **Disabled**: No devices on the local network will be able to see your device.
**Enabled** and **Disabled** are implemented by spawning and shutting down the [`sd_p2p::Mdns`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/crates/p2p/src/mdns.rs#L17) service as required within `sd-core`.
**Contacts only** the mDNS service will not contain the [`PeerMetadata`](https://github.com/spacedriveapp/spacedrive/blob/44478207e72495b3777e294660d78939711b544f/core/src/p2p/metadata.rs#L9) fields and instead will contain a hash of the users Spacedrive identifier. If a Spacedrive node detects another node in the local network with a hash in it's contacts, it can make a request to the node and if the remote node also has the current node in it's contacts, it will respond with the full metadata.
#### Implementation
We make use of the [mdns-sd](https://docs.rs/mdns-sd) crate.
### Manual connection
The user can manually provide a set of [`SocketAddr`](https://doc.rust-lang.org/std/net/enum.SocketAddr.html)'s and the P2P system to attempt to connect to.
This feature primarily exists for usage in combination with Docker but it could be useful for working around difficult network setups.
#### Implementation
TODO - TODO
#### Problems with Docker
TODO - MDNS daemon
TODO - Docker and why it's a pain mDNS. Explain the current stuff i've done with it.
### Transport layer
TODO - Quic
TODO - Explain authentication
### Relay
TODO
### Direction Connect via Relay
TODO
#### Authentication
TODO - How we gonna restrict this???
#### Billing
TODO - How we gonna bill for this???
### Design Decisions
TODO
### Things I would do differently?
TODO
### Crates
TODO
#### Security
##### Threat model
TODO - Risks of sharing IP's using discovery, risks of compromised relay, risks of compromised local network during pairing
##### Authentication
TODO
##### Authorization
TODO
##### Tracking
TODO - Link to Apple stuff
#### Version compatibility and breaking changes
TODO - Compatibility across versions of Spacedrive
#### libp2p
TODO - Why libp2p fork?, Why libp2p can be problematic for what we do
TODO - How we transpose our certificates to libp2p certificates
#### Major issues
TODO - mDNS issues on Linux
TODO - The double up of service discovery when using local and relay
TODO - Question? Why does remote_identity_of_self show up in metadata and the mDNS record itself.
{/* TODO */}
TODO - Request flow. Eg. incoming goes from Quic to mpsc to the users code, to the handlers.
TODO - Resumable uploads/transfers

View file

@ -13,22 +13,10 @@ export default function DebugSection() {
return (
<Section name="Debug">
<div className="space-y-0.5">
<SidebarLink to="debug/sync">
<Icon component={ArrowsClockwise} />
Sync
</SidebarLink>
<SidebarLink to="debug/cloud">
<Icon component={Cloud} />
Cloud
</SidebarLink>
<SidebarLink to="debug/cache">
<Icon component={Database} />
Cache
</SidebarLink>
<SidebarLink to="debug/p2p/overview">
<Icon component={ShareNetwork} />
P2P
</SidebarLink>
</div>
</Section>
);

View file

@ -3,18 +3,4 @@ import { RouteObject } from 'react-router';
export const debugRoutes = [
{ path: 'cloud', lazy: () => import('./cloud') },
{ path: 'actors', lazy: () => import('./actors') },
{
path: 'p2p',
lazy: () => import('./p2p'),
children: [
{
path: 'overview',
lazy: () => import('./p2p').then((m) => ({ Component: m.Overview }))
},
{
path: 'remote',
lazy: () => import('./p2p').then((m) => ({ Component: m.RemotePeers }))
}
]
}
] satisfies RouteObject[];

View file

@ -1,132 +0,0 @@
import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router';
import {
useBridgeMutation,
useBridgeQuery,
useConnectedPeers,
useDiscoveredPeers
} from '@sd/client';
import { Button, toast } from '@sd/ui';
import { useZodRouteParams, useZodSearchParams } from '~/hooks';
export const Component = () => {
const navigate = useNavigate();
// TODO: Handle if P2P is disabled
// const node = useBridgeQuery(['nodeState']);
// {node.data?.p2p_enabled === false ? (
// <h1 className="text-red-500">P2P is disabled. Please enable it in settings!</h1>
// ) : (
// <Page />
// )}
return (
<div>
<div className="flex space-x-4">
<Button variant="accent" onClick={() => navigate('overview')}>
Overview
</Button>
<Button variant="accent" onClick={() => navigate('remote')}>
Remote Peers
</Button>
</div>
<div className="p-4">
<Outlet />
</div>
</div>
);
};
export function Overview() {
const p2pState = useBridgeQuery(['p2p.state'], {
refetchInterval: 1000
});
const result = useBridgeQuery(['library.list']);
const connectedPeers = useConnectedPeers();
const discoveredPeers = useDiscoveredPeers();
const libraries = result.data || [];
const debugConnect = useBridgeMutation(['p2p.debugConnect'], {
onSuccess: () => {
toast.success('Connected!');
},
onError: (e) => {
toast.error(`Error connecting '${e.message}'`);
}
});
return (
<div className="flex flex-col space-y-8">
<div className="flex justify-around">
<div>
<h1 className="mt-4">Discovered:</h1>
{discoveredPeers.size === 0 && <p className="pl-2">None</p>}
{[...discoveredPeers.entries()].map(([id, _node]) => (
<div key={id} className="flex space-x-2">
<p>{id}</p>
<Button
variant="accent"
onClick={() => debugConnect.mutate(id)}
disabled={debugConnect.isLoading}
>
Connect
</Button>
</div>
))}
</div>
<div>
<h1 className="mt-4">Connected to:</h1>
{connectedPeers.size === 0 && <p className="pl-2">None</p>}
{[...connectedPeers.entries()].map(([id, node]) => (
<div key={id} className="flex space-x-2">
<p>{id}</p>
</div>
))}
</div>
</div>
<div>
<p>Current nodes libraries:</p>
{libraries.map((v) => (
<div key={v.uuid} className="pb-2 pl-3">
<p>
{v.config.name} - {v.uuid}
</p>
<div className="pl-8">
<p>Instance: {`${v.config.instance_id}/${v.instance_id}`}</p>
<p>Instance PK: {`${v.instance_public_key}`}</p>
</div>
</div>
))}
</div>
<div>
<p>NLM State:</p>
<pre>{JSON.stringify(p2pState.data || {}, undefined, 2)}</pre>
</div>
</div>
);
}
export function RemotePeers() {
const peers = useDiscoveredPeers();
const navigate = useNavigate();
return (
<>
<h1>Nodes:</h1>
{peers.size === 0 ? (
<p>No peers found...</p>
) : (
<ul>
{[...peers.entries()].map(([id, _node]) => (
<li key={id}>
{id}
<Button onClick={() => navigate(`/remote/${id}/browse`)}>
Open Library Browser
</Button>
</li>
))}
</ul>
)}
</>
);
}

View file

@ -5,6 +5,7 @@ import {
Database,
FlyingSaucer,
GearSix,
GlobeSimple,
HardDrive,
Key,
KeyReturn,
@ -82,6 +83,10 @@ export default () => {
<Icon component={PaintBrush} />
{t('appearance')}
</SidebarLink>
<SidebarLink to="client/network">
<Icon component={GlobeSimple} />
{t('network')}
</SidebarLink>
<SidebarLink to="client/backups" disabled={!isBackupsEnabled}>
<Icon component={Database} />
{t('backups')}

View file

@ -1,6 +1,7 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { FormProvider } from 'react-hook-form';
import {
ListenerState,
useBridgeMutation,
useBridgeQuery,
useConnectedPeers,
@ -8,7 +9,7 @@ import {
useFeatureFlag,
useZodForm
} from '@sd/client';
import { Button, Card, Input, Select, SelectOption, Slider, Switch, tw, z } from '@sd/ui';
import { Button, Card, Input, Select, SelectOption, Slider, Switch, Tooltip, tw, z } from '@sd/ui';
import { Icon } from '~/components';
import { useDebouncedFormWatch, useLocale } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@ -16,13 +17,27 @@ import { usePlatform } from '~/util/Platform';
import { Heading } from '../Layout';
import Setting from '../Setting';
const NodePill = tw.div`px-1.5 py-[2px] rounded text-xs font-medium bg-app-selected`;
export const NodePill = tw.div`px-1.5 py-[2px] rounded text-xs font-medium bg-app-selected`;
const NodeSettingLabel = tw.div`mb-1 text-xs font-medium`;
const u16 = () => z.number().min(0).max(65535);
function RenderListenerPill(props: PropsWithChildren<{ listener?: ListenerState }>) {
if (props.listener?.type === 'Error') {
return (
<Tooltip label={`Error: ${props.listener.error}`}>
<NodePill className="bg-red-700">{props.children}</NodePill>
</Tooltip>
);
} else if (props.listener?.type === 'Listening') {
return <NodePill>{props.children}</NodePill>;
}
return null;
}
export const Component = () => {
const node = useBridgeQuery(['nodeState']);
const listeners = useBridgeQuery(['p2p.listeners'], {
refetchInterval: 1000
});
const platform = usePlatform();
const debugState = useDebugState();
const editNode = useBridgeMutation('nodes.edit');
@ -36,20 +51,6 @@ export const Component = () => {
schema: z
.object({
name: z.string().min(1).max(250).optional(),
p2p_port: z.discriminatedUnion('type', [
z.object({ type: z.literal('random') }),
z.object({ type: z.literal('discrete'), value: u16() })
]),
p2p_ipv4_enabled: z.boolean().optional(),
p2p_ipv6_enabled: z.boolean().optional(),
p2p_discovery: z
.union([
z.literal('Everyone'),
z.literal('ContactsOnly'),
z.literal('Disabled')
])
.optional(),
p2p_remote_access: z.boolean().optional(),
image_labeler_version: z.string().optional(),
background_processing_percentage: z.coerce
.number({
@ -63,17 +64,11 @@ export const Component = () => {
reValidateMode: 'onChange',
defaultValues: {
name: node.data?.name,
p2p_port: node.data?.p2p.port || { type: 'random' },
p2p_ipv4_enabled: node.data?.p2p.ipv4 || true,
p2p_ipv6_enabled: node.data?.p2p.ipv6 || true,
p2p_discovery: node.data?.p2p.discovery || 'Everyone',
p2p_remote_access: node.data?.p2p.remote_access || false,
image_labeler_version: node.data?.image_labeler_version ?? undefined,
background_processing_percentage:
node.data?.preferences.thumbnailer.background_processing_percentage || 50
}
});
const p2p_port = form.watch('p2p_port');
const watchBackgroundProcessingPercentage = form.watch('background_processing_percentage');
@ -81,12 +76,13 @@ export const Component = () => {
if (await form.trigger()) {
await editNode.mutateAsync({
name: value.name || null,
p2p_port: (value.p2p_port as any) ?? null,
p2p_ipv4_enabled: value.p2p_ipv4_enabled ?? null,
p2p_ipv6_enabled: value.p2p_ipv6_enabled ?? null,
p2p_discovery: value.p2p_discovery ?? null,
p2p_remote_access: value.p2p_remote_access ?? null,
p2p_port: null,
p2p_disabled: null,
p2p_ipv6_disabled: null,
p2p_relay_disabled: null,
p2p_discovery: null,
p2p_remote_access: null,
p2p_manual_peers: null,
image_labeler_version: value.image_labeler_version ?? null
});
@ -100,14 +96,6 @@ export const Component = () => {
node.refetch();
});
form.watch((data) => {
if (data.p2p_port?.type == 'discrete' && Number(data.p2p_port.value) > 65535) {
form.setValue('p2p_port', { type: 'discrete', value: 65535 });
}
});
const isP2PWipFeatureEnabled = useFeatureFlag('wipP2P');
return (
<FormProvider {...form}>
<Heading
@ -120,16 +108,15 @@ export const Component = () => {
<div className="flex flex-row items-center justify-between">
<span className="font-semibold">{t('local_node')}</span>
<div className="flex flex-row space-x-1">
<NodePill>
{connectedPeers.size} {t('peers')}
</NodePill>
{/* {node.data?.p2p_enabled === true ? (
<NodePill className="!bg-accent text-white">
{t('running')}
</NodePill>
) : (
<NodePill className="text-white">{t('disabled')}</NodePill>
)} */}
<RenderListenerPill listener={listeners.data?.ipv4}>
IPv4
</RenderListenerPill>
<RenderListenerPill listener={listeners.data?.ipv6}>
IPv6
</RenderListenerPill>
<RenderListenerPill listener={listeners.data?.relay}>
Relay
</RenderListenerPill>
</div>
</div>
@ -267,145 +254,6 @@ export const Component = () => {
/>
</div>
</Setting> */}
<div className="flex flex-col gap-4">
<h1 className="mb-3 text-lg font-bold text-ink">{t('networking')}</h1>
<Setting
mini
title={t('enable_networking')}
description={
<>
<p className="text-sm text-gray-400">
{t('enable_networking_description')}
</p>
<p className="mb-2 text-sm text-gray-400">
{t('enable_networking_description_required')}
</p>
</>
}
>
<Switch
size="md"
checked={form.watch('p2p_ipv4_enabled') && form.watch('p2p_ipv6_enabled')}
onCheckedChange={(checked) => {
form.setValue('p2p_ipv4_enabled', checked);
form.setValue('p2p_ipv6_enabled', checked);
}}
/>
</Setting>
{form.watch('p2p_ipv4_enabled') && form.watch('p2p_ipv6_enabled') ? (
<>
<Setting
mini
title={t('networking_port')}
description={t('networking_port_description')}
>
<div className="flex h-[30px] gap-2">
<Select
value={p2p_port.type}
containerClassName="h-[30px]"
className="h-full"
onChange={(type) => {
form.setValue('p2p_port', {
type: type as any
});
}}
>
<SelectOption value="random">{t('random')}</SelectOption>
<SelectOption value="discrete">{t('custom')}</SelectOption>
</Select>
<Input
value={p2p_port.type === 'discrete' ? p2p_port.value : 0}
className={clsx(
'w-[66px]',
p2p_port.type === 'random' ? 'opacity-50' : 'opacity-100'
)}
disabled={p2p_port.type === 'random'}
onChange={(e) => {
form.setValue('p2p_port', {
type: 'discrete',
value: Number(e.target.value.replace(/[^0-9]/g, ''))
});
}}
/>
</div>
</Setting>
<Setting
mini
title={t('ipv6')}
description={
<p className="text-sm text-gray-400">{t('ipv6_description')}</p>
}
>
<Switch
size="md"
checked={form.watch('p2p_ipv6_enabled')}
onCheckedChange={(checked) =>
form.setValue('p2p_ipv6_enabled', checked)
}
/>
</Setting>
<Setting
mini
title={t('p2p_visibility')}
description={
<p className="text-sm text-gray-400">
{t('p2p_visibility_description')}
</p>
}
>
<Select
value={form.watch('p2p_discovery') || 'Everyone'}
containerClassName="h-[30px]"
className="h-full"
onChange={(type) => form.setValue('p2p_discovery', type)}
>
<SelectOption value="Everyone">
{t('p2p_visibility_everyone')}
</SelectOption>
{isP2PWipFeatureEnabled ? (
<SelectOption value="ContactsOnly">
{t('p2p_visibility_contacts_only')}
</SelectOption>
) : null}
<SelectOption value="Disabled">
{t('p2p_visibility_disabled')}
</SelectOption>
</Select>
</Setting>
{isP2PWipFeatureEnabled && (
<>
<Setting
mini
title={t('remote_access')}
description={
<>
<p className="text-sm text-gray-400">
{t('remote_access_description')}
</p>
<p className="text-sm text-yellow-500">
WARNING: This protocol has no security at the moment
and effectively gives root access!
</p>
</>
}
>
<Switch
size="md"
checked={form.watch('p2p_remote_access')}
onCheckedChange={(checked) =>
form.setValue('p2p_remote_access', checked)
}
/>
</Setting>
</>
)}
</>
) : null}
</div>
</FormProvider>
);
};

View file

@ -8,5 +8,7 @@ export default [
{ path: 'keybindings', lazy: () => import('./keybindings') },
{ path: 'extensions', lazy: () => import('./extensions') },
{ path: 'privacy', lazy: () => import('./privacy') },
{ path: 'backups', lazy: () => import('./backups') }
{ path: 'backups', lazy: () => import('./backups') },
{ path: 'network', lazy: () => import('./network/index') },
{ path: 'network/debug', lazy: () => import('./network/debug') },
] satisfies RouteObject[];

View file

@ -0,0 +1,36 @@
import { useBridgeQuery } from '@sd/client';
import { useLocale } from '~/hooks';
import { Heading } from '../../Layout';
export const Component = () => {
const { t } = useLocale();
const p2pState = useBridgeQuery(['p2p.state'], {
refetchInterval: 1000
});
const result = useBridgeQuery(['library.list']);
return (
<div>
<Heading
title={t('network_settings_advanced')}
description={t('network_settings_advanced_description')}
/>
<pre>{JSON.stringify(p2pState.data || {}, undefined, 2)}</pre>
<div className="h-8" />
<pre>
{JSON.stringify(
result.data?.map((lib) => ({
id: lib.uuid,
name: lib.config.name,
instance: `${lib.config.instance_id}/${lib.instance_id}`,
instanceRemotePk: `${lib.instance_public_key}`
})) || {},
undefined,
2
)}
</pre>
</div>
);
};

View file

@ -0,0 +1,500 @@
import clsx from 'clsx';
import { PropsWithChildren, useState } from 'react';
import { FormProvider } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { Link } from 'react-router-dom';
import { match } from 'ts-pattern';
import { z } from 'zod';
import {
ListenerState,
useBridgeMutation,
useBridgeQuery,
useConnectedPeers,
useFeatureFlag,
usePeers,
useZodForm
} from '@sd/client';
import { Button, Card, Input, Select, SelectOption, Switch, toast, Tooltip } from '@sd/ui';
import { Icon } from '~/components';
import { useDebouncedFormWatch, useLocale } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { Heading } from '../../Layout';
import Setting from '../../Setting';
import { NodePill } from '../general';
const u16 = () => z.number().min(0).max(65535);
const socketAddrRegex =
/^(([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})|([a-zA-Z.]+))(:[0-9]{1,5})?$/;
function RenderListenerPill(props: PropsWithChildren<{ listener?: ListenerState }>) {
if (props.listener?.type === 'Error') {
return (
<Tooltip label={`Error: ${props.listener.error}`}>
<NodePill className="bg-red-700">{props.children}</NodePill>
</Tooltip>
);
} else if (props.listener?.type === 'Listening') {
return <NodePill className="bg-green-700">{props.children}</NodePill>;
}
return <NodePill>{props.children}</NodePill>;
}
export const Component = () => {
const node = useBridgeQuery(['nodeState']);
const listeners = useBridgeQuery(['p2p.listeners'], {
refetchInterval: 1000
});
const editNode = useBridgeMutation('nodes.edit');
const connectedPeers = useConnectedPeers();
const [newSocket, setNewSocket] = useState<string>('');
const { t } = useLocale();
const form = useZodForm({
schema: z
.object({
port: z.discriminatedUnion('type', [
z.object({ type: z.literal('random') }),
z.object({ type: z.literal('discrete'), value: u16() })
]),
disabled: z.boolean().optional(),
ipv6_disabled: z.boolean().optional(),
relay_disabled: z.boolean().optional(),
discovery: z
.union([
z.literal('Everyone'),
z.literal('ContactsOnly'),
z.literal('Disabled')
])
.optional(),
enable_remote_access: z.boolean().optional(),
p2p_manual_peers: z.array(z.string()).optional()
})
.strict(),
reValidateMode: 'onChange',
defaultValues: {
port: node.data?.p2p.port || { type: 'random' },
disabled: node.data?.p2p.disabled || false,
ipv6_disabled: node.data?.p2p.disable_ipv6 || false,
relay_disabled: node.data?.p2p.disable_relay || false,
discovery: node.data?.p2p.discovery || 'Everyone',
enable_remote_access: node.data?.p2p.disable_relay || false,
p2p_manual_peers: node.data?.p2p.manual_peers || []
}
});
useDebouncedFormWatch(form, async (value) => {
if (await form.trigger()) {
await editNode.mutateAsync({
name: null,
p2p_port: (value.port as any) ?? null,
p2p_disabled: value.disabled ?? null,
p2p_ipv6_disabled: value.ipv6_disabled ?? null,
p2p_relay_disabled: value.relay_disabled ?? null,
p2p_discovery: value.discovery ?? null,
p2p_remote_access: value.enable_remote_access ?? null,
p2p_manual_peers: value.p2p_manual_peers?.flatMap((v) => (v ? [v] : [])) ?? null,
image_labeler_version: null
});
}
node.refetch();
});
const port = form.watch('port');
form.watch((data) => {
if (data.port?.type == 'discrete' && Number(data.port.value) > 65535) {
form.setValue('port', { type: 'discrete', value: 65535 });
}
});
const isP2PWipFeatureEnabled = useFeatureFlag('wipP2P');
const isNewSocketInvalid = socketAddrRegex.test(newSocket) === false;
return (
<FormProvider {...form}>
<Heading
title={t('network_settings')}
description={t('network_settings_description')}
rightArea={
<Link to="./debug" className="text-xs">
Advanced
</Link>
}
/>
<Card className="flex flex-col px-5 pb-4">
<div className="my-2 flex w-full flex-col">
<div className="flex flex-row items-center justify-between">
<span className="font-semibold">{node.data?.name}</span>
<div className="flex flex-row space-x-1">
<RenderListenerPill listener={listeners.data?.ipv4}>
IPv4
</RenderListenerPill>
<RenderListenerPill listener={listeners.data?.ipv6}>
IPv6
</RenderListenerPill>
{match(
node.data?.p2p.disabled
? 'Disabled'
: node.data?.p2p.discovery || 'Disabled'
)
.with('Disabled', () => <NodePill>LAN</NodePill>)
.with('ContactsOnly', () => (
<Tooltip label="Only discoverable by contacts">
<NodePill className="bg-orange-700">LAN</NodePill>
</Tooltip>
))
.with('Everyone', () => (
<NodePill className="bg-green-700">LAN</NodePill>
))
.exhaustive()}
<RenderListenerPill listener={listeners.data?.relay}>
Relay
</RenderListenerPill>
</div>
</div>
</div>
<div>
<p>Remote Identity: {node.data?.identity}</p>
</div>
</Card>
<Setting
mini
title={t('enable_networking')}
description={
<>
<p className="text-sm text-gray-400">
{t('enable_networking_description')}
</p>
<p className="mb-2 text-sm text-gray-400">
{t('enable_networking_description_required')}
</p>
</>
}
>
<Switch
size="md"
checked={!form.watch('disabled')}
onCheckedChange={(checked) => form.setValue('disabled', !checked)}
/>
</Setting>
{!form.watch('disabled') ? (
<>
<Setting
mini
title={t('networking_port')}
description={t('networking_port_description')}
>
<div className="flex h-[30px] gap-2">
{node.data?.is_in_docker === true ? (
<Tooltip label="This port is hardcoded in the container but configurable via the Docker `-p` option">
<Input value="7373" disabled />
</Tooltip>
) : (
<>
<Select
value={port.type}
containerClassName="h-[30px]"
className="h-full"
onChange={(type) => {
form.setValue('port', {
type: type as any
});
}}
>
<SelectOption value="random">{t('random')}</SelectOption>
<SelectOption value="discrete">{t('custom')}</SelectOption>
</Select>
<Input
value={port.type === 'discrete' ? port.value : 0}
className={clsx(
'w-[66px]',
port.type === 'random' ? 'opacity-50' : 'opacity-100'
)}
disabled={port.type === 'random'}
onChange={(e) => {
form.setValue('port', {
type: 'discrete',
value: Number(e.target.value.replace(/[^0-9]/g, ''))
});
}}
/>
</>
)}
</div>
</Setting>
<Setting
mini
title={t('ipv6')}
description={
<p className="text-sm text-gray-400">{t('ipv6_description')}</p>
}
>
<Switch
size="md"
checked={!form.watch('ipv6_disabled')}
onCheckedChange={(checked) => form.setValue('ipv6_disabled', !checked)}
/>
</Setting>
<Setting
mini
title={t('p2p_visibility')}
description={
<p className="text-sm text-gray-400">
{t('p2p_visibility_description')}
</p>
}
>
<Select
value={form.watch('discovery') || 'Everyone'}
containerClassName="h-[30px]"
className="h-full"
onChange={(type) => form.setValue('discovery', type)}
>
<SelectOption value="Everyone">
{t('p2p_visibility_everyone')}
</SelectOption>
{isP2PWipFeatureEnabled ? (
<SelectOption value="ContactsOnly">
{t('p2p_visibility_contacts_only')}
</SelectOption>
) : null}
<SelectOption value="Disabled">
{t('p2p_visibility_disabled')}
</SelectOption>
</Select>
</Setting>
<Setting
mini
title={t('enable_relay')}
description={
<>
<p className="text-sm text-gray-400">
{t('enable_relay_description')}
</p>
</>
}
>
<Switch
size="md"
checked={!form.watch('relay_disabled')}
onCheckedChange={(checked) => form.setValue('relay_disabled', !checked)}
/>
</Setting>
{isP2PWipFeatureEnabled && (
<>
<Setting
mini
title={t('remote_access')}
description={
<>
<p className="text-sm text-gray-400">
{t('remote_access_description')}
</p>
<p className="text-sm text-yellow-500">
WARNING: This protocol has no security at the moment and
effectively gives root access!
</p>
</>
}
>
<Switch
size="md"
checked={form.watch('enable_remote_access')}
onCheckedChange={(checked) =>
form.setValue('enable_remote_access', checked)
}
/>
</Setting>
</>
)}
<Setting
mini
title={t('manual_peers')}
description={
<>
{t('manual_peers_description')
.split('\n')
.map((line, index) => (
<p key={index} className="text-sm text-gray-400">
{line}
</p>
))}
</>
}
></Setting>
<div className="grid space-y-2">
{form.watch('p2p_manual_peers')?.map((socket) => (
<Card key={socket} className="flex justify-between hover:bg-app-box/70">
<div className="flex">
<Icon
size={24}
name="Node"
className="mr-3 size-10 self-center"
/>
<div className="grid min-w-[110px] grid-cols-1">
<h1 className="truncate pt-0.5 text-sm font-semibold">
{socket}
</h1>
</div>
</div>
<div className="flex items-center">
<div>
<Button
variant="colored"
className="border-red-500 bg-red-500 focus:ring-1 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-app-selected"
onClick={() => {
form.setValue(
'p2p_manual_peers',
(
form.getValues('p2p_manual_peers') || []
).filter((v) => v !== socket)
);
}}
>
{t('delete')}
</Button>
</div>
</div>
</Card>
))}
<div className="flex space-x-2">
<Input
className="flex-1"
placeholder="129.168.0.2:1234"
value={newSocket}
onChange={(e) => setNewSocket(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isNewSocketInvalid) {
form.setValue('p2p_manual_peers', [
...(form.getValues('p2p_manual_peers') || []),
newSocket
]);
setNewSocket('');
}
}}
/>
<Button
variant="outline"
disabled={isNewSocketInvalid}
onClick={() => {
form.setValue('p2p_manual_peers', [
...(form.getValues('p2p_manual_peers') || []),
newSocket
]);
setNewSocket('');
}}
>
Submit
</Button>
</div>
</div>
<NodesPanel />
</>
) : null}
</FormProvider>
);
};
function NodesPanel() {
const { t } = useLocale();
const navigate = useNavigate();
const peers = usePeers();
const platform = usePlatform();
const isP2PWipFeatureEnabled = useFeatureFlag('wipP2P');
const debugConnect = useBridgeMutation(['p2p.debugConnect'], {
onSuccess: () => toast.success('Connected!'),
onError: (e) => toast.error(`Error connecting '${e.message}'`)
});
return (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold text-ink">{t('nodes')}</h1>
{peers.size === 0 ? (
<p className="text-sm text-gray-400">{t('no_nodes_found')}</p>
) : (
<div className="grid grid-cols-1 gap-2">
{[...peers.entries()].map(([id, peer]) => (
<Card key={id} className="hover:bg-app-box/70">
<Icon size={24} name="Node" className="mr-3 size-10 self-center" />
<div className="grid min-w-[110px] grid-cols-1">
<Tooltip label={id}>
<h1 className="truncate pt-0.5 text-sm font-semibold">
{peer.metadata.name}
</h1>
</Tooltip>
<h2 className="truncate pt-0.5 text-sm font-semibold">
Spacedrive {peer.metadata.version}{' '}
{peer.metadata.operating_system
? `- ${peer.metadata.operating_system}`
: ''}
</h2>
</div>
<div className="grow"></div>
<div className="flex items-center justify-center space-x-2">
{isP2PWipFeatureEnabled && (
<Button
onClick={() =>
platform.confirm(
'Warning: This will only work if rspc remote is enabled on the remote node and the node is online!',
(result) => {
if (result) navigate(`/remote/${id}/browse`);
}
)
}
>
rspc remote
</Button>
)}
<Button
variant="accent"
onClick={() => debugConnect.mutate(id)}
disabled={debugConnect.isLoading}
>
Connect
</Button>
<NodePill>
{peer.discovery === 'Manual'
? 'Manual'
: peer.discovery === 'Local'
? 'LAN'
: 'Relay'}
</NodePill>
<NodePill
className={
peer.connection !== 'Disconnected' ? 'bg-green-400' : ''
}
>
{peer.connection}
</NodePill>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View file

@ -1,39 +1,60 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useBridgeQuery } from '@sd/client';
import { toast } from '@sd/ui';
import { useLocale } from '~/hooks';
const errorMessages = {
ipv4_ipv6: 'ipv4_ipv6_listeners_error',
ipv4: 'ipv4_listeners_error',
ipv6: 'ipv6_listeners_error',
relay: 'relay_listeners_error'
};
export function useP2PErrorToast() {
const listeners = useBridgeQuery(['p2p.listeners']);
const didShowError = useRef(false);
const { t } = useLocale();
useEffect(() => {
if (!listeners.data) return;
if (didShowError.current) return;
if (!listeners.data || didShowError.current) return;
const getErrorBody = (type: keyof typeof errorMessages, error: string) => (
<div>
<p>{t(errorMessages[type])}</p>
<p>{error}</p>
</div>
);
let body: JSX.Element | undefined;
if (listeners.data.ipv4.type === 'Error' && listeners.data.ipv6.type === 'Error') {
body = (
<div>
<p>{t('ipv4_ipv6_listeners_error')}</p>
<p>{listeners.data.ipv4.error}</p>
</div>
);
} else if (listeners.data.ipv4.type === 'Error') {
body = (
<div>
<p>{t('ipv4_listeners_error')}</p>
<p>{listeners.data.ipv4.error}</p>
</div>
);
} else if (listeners.data.ipv6.type === 'Error') {
body = (
<div>
<p>{t('ipv6_listeners_error')}</p>
<p>{listeners.data.ipv6.error}</p>
</div>
switch (true) {
case listeners.data.ipv4.type === 'Error' && listeners.data.ipv6.type === 'Error':
body = getErrorBody('ipv4_ipv6', listeners.data.ipv4.error);
break;
case listeners.data.ipv4.type === 'Error':
body = getErrorBody('ipv4', listeners.data.ipv4.error);
break;
case listeners.data.ipv6.type === 'Error':
body = getErrorBody('ipv6', listeners.data.ipv6.error);
break;
case listeners.data.relay.type === 'Error':
body = getErrorBody('relay', listeners.data.relay.error);
break;
default:
break;
}
if (body) {
toast.error(
{
title: t('networking_error'),
body
},
{
id: 'p2p-listener-error'
}
);
didShowError.current = true;
}
if (body) {
@ -49,7 +70,7 @@ export function useP2PErrorToast() {
didShowError.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listeners.data]);
}, [listeners.data, t]);
return null;
}

View file

@ -123,6 +123,7 @@
"current_directory": "Current Directory",
"current_directory_with_descendants": "Current Directory With Descendants",
"custom": "Custom",
"docker": "Docker",
"cut": "Cut",
"cut_object": "Cut object",
"cut_success": "Items cut",
@ -192,6 +193,8 @@
"enable_networking": "Enable Networking",
"enable_networking_description": "Allow your node to communicate with other Spacedrive nodes around you.",
"enable_networking_description_required": "Required for library sync or Spacedrop!",
"enable_relay": "Enable Relay",
"enable_relay_description": "Enable the relay server to allow your devices to communicate over the public internet.",
"enable_sync": "Enable Sync",
"enable_sync_description": "Generate sync operations for all the existing data in this library, and configure Spacedrive to generate sync operations when things happen in future.",
"enabled": "Enabled",
@ -281,6 +284,10 @@
"general": "General",
"general_settings": "General Settings",
"general_settings_description": "General settings related to this client.",
"network_settings": "Network Settings",
"network_settings_description": "Settings related to networking and connectivity.",
"network_settings_advanced": "Advanced Network Overview",
"network_settings_advanced_description": "Advanced information about your current network setup.",
"general_shortcut_description": "General usage shortcuts",
"generatePreviewMedia_label": "Generate preview media for this Location",
"generate_checksums": "Generate Checksums",
@ -335,6 +342,7 @@
"ipv6": "IPv6 networking",
"ipv6_description": "Allow peer-to-peer communication using IPv6 networking",
"ipv6_listeners_error": "Error creating the IPv6 listeners. Please check your firewall settings!",
"relay_listeners_error": "Error creating the relay listener. Please check your firewall settings!",
"is": "is",
"is_not": "is not",
"item": "item",
@ -628,6 +636,8 @@
"spacedrop_disabled": "Disabled",
"spacedrop_everyone": "Everyone",
"spacedrop_rejected": "Spacedrop rejected",
"manual_peers": "Manually add peers",
"manual_peers_description": "Add peers manually by entering their IP address and port.\nThis is useful when automatic discovery is not possible.",
"square_thumbnails": "Square Thumbnails",
"star_on_github": "Star on GitHub",
"start": "Start",

View file

@ -168,11 +168,11 @@ export type CRDTOperationData = { c: { [key in string]: JsonValue } } | { u: { f
export type CameraData = { device_make: string | null; device_model: string | null; color_space: string | null; color_profile: ColorProfile | null; focal_length: number | null; shutter_speed: number | null; flash: Flash | null; orientation: Orientation; lens_make: string | null; lens_model: string | null; bit_depth: number | null; zoom: number | null; iso: number | null; software: string | null; serial_number: string | null; lens_serial_number: string | null; contrast: number | null; saturation: number | null; sharpness: number | null; composite: Composite | null }
export type ChangeNodeNameArgs = { name: string | null; p2p_port: Port | null; p2p_ipv4_enabled: boolean | null; p2p_ipv6_enabled: boolean | null; p2p_discovery: P2PDiscoveryState | null; p2p_remote_access: boolean | null; image_labeler_version: string | null }
export type ChangeNodeNameArgs = { name: string | null; p2p_port: Port | null; p2p_disabled: boolean | null; p2p_ipv6_disabled: boolean | null; p2p_relay_disabled: boolean | null; p2p_discovery: P2PDiscoveryState | null; p2p_remote_access: boolean | null; p2p_manual_peers: string[] | null; image_labeler_version: string | null }
export type Chapter = { id: number; start: [number, number]; end: [number, number]; time_base_den: number; time_base_num: number; metadata: Metadata }
export type CloudInstance = { id: string; uuid: string; identity: RemoteIdentity; nodeId: string; metadata: { [key in string]: string } }
export type CloudInstance = { id: string; uuid: string; identity: RemoteIdentity; nodeId: string; nodeRemoteIdentity: string; metadata: { [key in string]: string } }
export type CloudLibrary = { id: string; uuid: string; name: string; instances: CloudInstance[]; ownerId: string }
@ -228,7 +228,7 @@ export type DefaultLocations = { desktop: boolean; documents: boolean; downloads
* The method used for the discovery of this peer.
* *Technically* you can have multiple under the hood but this simplifies things for the UX.
*/
export type DiscoveryMethod = "Relay" | "Local"
export type DiscoveryMethod = "Relay" | "Local" | "Manual"
export type DiskType = "SSD" | "HDD" | "Removable"
@ -421,7 +421,7 @@ instance_id: number;
*/
cloud_id?: string | null; generate_sync_operations?: boolean; version: LibraryConfigVersion }
export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10"
export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11"
export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: RemoteIdentity; config: LibraryConfig }
@ -431,9 +431,9 @@ export type LibraryPreferences = { location?: { [key in string]: LocationSetting
export type LightScanArgs = { location_id: number; sub_path: string }
export type ListenerState = { type: "Listening" } | { type: "Error"; error: string } | { type: "Disabled" }
export type ListenerState = { type: "Listening" } | { type: "Error"; error: string } | { type: "NotListening" }
export type Listeners = { ipv4: ListenerState; ipv6: ListenerState }
export type Listeners = { ipv4: ListenerState; ipv6: ListenerState; relay: ListenerState }
export type Location = { id: number; pub_id: number[]; name: string | null; path: string | null; total_capacity: number | null; available_capacity: number | null; size_in_bytes: number[] | null; is_archived: boolean | null; generate_preview_media: boolean | null; sync_preview_media: boolean | null; hidden: boolean | null; date_created: string | null; scan_state: number; instance_id: number | null }
@ -472,7 +472,18 @@ export type MediaLocation = { latitude: number; longitude: number; pluscode: Plu
export type Metadata = { album: string | null; album_artist: string | null; artist: string | null; comment: string | null; composer: string | null; copyright: string | null; creation_time: string | null; date: string | null; disc: number | null; encoder: string | null; encoded_by: string | null; filename: string | null; genre: string | null; language: string | null; performer: string | null; publisher: string | null; service_name: string | null; service_provider: string | null; title: string | null; track: number | null; variant_bit_rate: number | null; custom: { [key in string]: string } }
export type NodeConfigP2P = { discovery?: P2PDiscoveryState; port: Port; ipv4: boolean; ipv6: boolean; remote_access: boolean }
export type NodeConfigP2P = { discovery?: P2PDiscoveryState; port: Port; disabled: boolean; disable_ipv6: boolean; disable_relay: boolean; enable_remote_access: boolean;
/**
* A list of peer addresses to try and manually connect to, instead of relying on discovery.
*
* All of these are valid values:
* - `localhost`
* - `otbeaumont.me` or `otbeaumont.me:3000`
* - `127.0.0.1` or `127.0.0.1:300`
* - `[::1]` or `[::1]:3000`
* which is why we use `String` not `SocketAddr`
*/
manual_peers?: string[] }
export type NodePreferences = { thumbnailer: ThumbnailerPreferences }
@ -484,7 +495,7 @@ id: string;
/**
* name is the display name of the current node. This is set by the user and is shown in the UI. // TODO: Length validation so it can fit in DNS record
*/
name: string; identity: RemoteIdentity; p2p: NodeConfigP2P; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; device_model: string | null }
name: string; identity: RemoteIdentity; p2p: NodeConfigP2P; features: BackendFeature[]; preferences: NodePreferences; image_labeler_version: string | null }) & { data_path: string; device_model: string | null; is_in_docker: boolean }
export type NonIndexedPathItem = { path: string; name: string; extension: string; kind: number; is_dir: boolean; date_created: string; date_modified: string; size_in_bytes_bytes: number[]; hidden: boolean }
@ -541,7 +552,7 @@ export type Orientation = "Normal" | "CW90" | "CW180" | "CW270" | "MirroredVerti
export type P2PDiscoveryState = "Everyone" | "ContactsOnly" | "Disabled"
export type P2PEvent = { type: "PeerChange"; identity: RemoteIdentity; connection: ConnectionMethod; discovery: DiscoveryMethod; metadata: PeerMetadata } | { type: "PeerDelete"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedOut"; id: string } | { type: "SpacedropRejected"; id: string }
export type P2PEvent = { type: "PeerChange"; identity: RemoteIdentity; connection: ConnectionMethod; discovery: DiscoveryMethod; metadata: PeerMetadata; addrs: string[] } | { type: "PeerDelete"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedOut"; id: string } | { type: "SpacedropRejected"; id: string }
export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; device_model: HardwareModel | null; version: string | null }

View file

@ -15,6 +15,7 @@ type Peer = {
connection: ConnectionMethod;
discovery: DiscoveryMethod;
metadata: PeerMetadata;
addrs: string[];
};
type Context = {
@ -38,7 +39,8 @@ export function P2PContextProvider({ children }: PropsWithChildren) {
peers.set(data.identity, {
connection: data.connection,
discovery: data.discovery,
metadata: data.metadata
metadata: data.metadata,
addrs: data.addrs
});
setPeers([peers]);
} else if (data.type === 'PeerDelete') {