@cc-remote/iroh 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.yarnrc.yml +3 -0
- package/Cargo.toml +30 -0
- package/README.md +38 -0
- package/index.d.ts +478 -0
- package/index.js +603 -0
- package/package.json +66 -0
- package/src/endpoint.rs +1018 -0
- package/src/key.rs +173 -0
- package/src/lib.rs +59 -0
- package/src/net.rs +106 -0
- package/src/path.rs +167 -0
- package/src/relay.rs +194 -0
- package/src/services.rs +167 -0
- package/src/ticket.rs +46 -0
- package/src/watch.rs +111 -0
- package/test/endpoint.mjs +176 -0
- package/test/key.mjs +71 -0
- package/test/relay.mjs +40 -0
- package/test/services.mjs +47 -0
- package/tsconfig.json +11 -0
- package/typedoc.json +9 -0
package/src/services.rs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
use std::time::Duration;
|
|
2
|
+
|
|
3
|
+
use iroh_services::{Client, ClientBuilder};
|
|
4
|
+
use napi::bindgen_prelude::*;
|
|
5
|
+
use napi_derive::napi;
|
|
6
|
+
|
|
7
|
+
use crate::Endpoint;
|
|
8
|
+
|
|
9
|
+
/// Build options for [`ServicesClient`].
|
|
10
|
+
#[derive(Debug, Default)]
|
|
11
|
+
#[napi(object)]
|
|
12
|
+
pub struct ServicesOptions {
|
|
13
|
+
/// Encoded API secret string (`services1...`).
|
|
14
|
+
pub api_secret: Option<String>,
|
|
15
|
+
/// If true, read the API secret from `IROH_SERVICES_API_SECRET`.
|
|
16
|
+
pub api_secret_from_env: Option<bool>,
|
|
17
|
+
/// Unencrypted PEM-encoded OpenSSH ed25519 private key.
|
|
18
|
+
pub ssh_key_pem: Option<String>,
|
|
19
|
+
/// Optional endpoint name to register cloud-side.
|
|
20
|
+
pub name: Option<String>,
|
|
21
|
+
/// Metrics push interval (ms). `0` disables interval pushes.
|
|
22
|
+
pub metrics_interval_ms: Option<i64>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Flattened summary of an `iroh_services` diagnostics report.
|
|
26
|
+
#[derive(Debug, Clone)]
|
|
27
|
+
#[napi(object)]
|
|
28
|
+
pub struct DiagnosticsSummary {
|
|
29
|
+
pub endpoint_id: String,
|
|
30
|
+
pub direct_addrs: Vec<String>,
|
|
31
|
+
pub iroh_version: String,
|
|
32
|
+
pub iroh_services_version: String,
|
|
33
|
+
pub has_net_report: bool,
|
|
34
|
+
pub upnp: Option<bool>,
|
|
35
|
+
pub pcp: Option<bool>,
|
|
36
|
+
pub nat_pmp: Option<bool>,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
impl From<iroh_services::net_diagnostics::DiagnosticsReport> for DiagnosticsSummary {
|
|
40
|
+
fn from(r: iroh_services::net_diagnostics::DiagnosticsReport) -> Self {
|
|
41
|
+
let (upnp, pcp, nat_pmp) = match r.portmap_probe {
|
|
42
|
+
Some(p) => (Some(p.upnp), Some(p.pcp), Some(p.nat_pmp)),
|
|
43
|
+
None => (None, None, None),
|
|
44
|
+
};
|
|
45
|
+
Self {
|
|
46
|
+
endpoint_id: r.endpoint_id.to_string(),
|
|
47
|
+
direct_addrs: r.direct_addrs.into_iter().map(|s| s.to_string()).collect(),
|
|
48
|
+
iroh_version: r.iroh_version,
|
|
49
|
+
iroh_services_version: r.iroh_services_version,
|
|
50
|
+
has_net_report: r.net_report.is_some(),
|
|
51
|
+
upnp,
|
|
52
|
+
pcp,
|
|
53
|
+
nat_pmp,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Client for services.iroh.computer.
|
|
59
|
+
#[napi]
|
|
60
|
+
pub struct ServicesClient {
|
|
61
|
+
inner: Client,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[napi]
|
|
65
|
+
impl ServicesClient {
|
|
66
|
+
/// Build a new client bound to the given endpoint.
|
|
67
|
+
#[napi(factory)]
|
|
68
|
+
pub async fn create(endpoint: &Endpoint, options: ServicesOptions) -> Result<ServicesClient> {
|
|
69
|
+
let mut builder: ClientBuilder = Client::builder(endpoint.raw());
|
|
70
|
+
|
|
71
|
+
let creds = [
|
|
72
|
+
options.api_secret.is_some(),
|
|
73
|
+
options.api_secret_from_env.unwrap_or(false),
|
|
74
|
+
options.ssh_key_pem.is_some(),
|
|
75
|
+
]
|
|
76
|
+
.into_iter()
|
|
77
|
+
.filter(|x| *x)
|
|
78
|
+
.count();
|
|
79
|
+
if creds != 1 {
|
|
80
|
+
return Err(anyhow::anyhow!(
|
|
81
|
+
"ServicesOptions requires exactly one of api_secret / api_secret_from_env / ssh_key_pem"
|
|
82
|
+
)
|
|
83
|
+
.into());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if let Some(secret) = options.api_secret {
|
|
87
|
+
builder = builder
|
|
88
|
+
.api_secret_from_str(&secret)
|
|
89
|
+
.map_err(|e| anyhow::anyhow!("invalid api secret: {e:?}"))?;
|
|
90
|
+
} else if options.api_secret_from_env.unwrap_or(false) {
|
|
91
|
+
builder = builder
|
|
92
|
+
.api_secret_from_env()
|
|
93
|
+
.map_err(|e| anyhow::anyhow!("api secret env var: {e:?}"))?;
|
|
94
|
+
} else if let Some(pem) = options.ssh_key_pem {
|
|
95
|
+
builder = builder
|
|
96
|
+
.ssh_key(&pem)
|
|
97
|
+
.map_err(|e| anyhow::anyhow!("invalid ssh key: {e:?}"))?;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if let Some(name) = options.name {
|
|
101
|
+
builder = builder
|
|
102
|
+
.name(name)
|
|
103
|
+
.map_err(|e| anyhow::anyhow!("invalid name: {e:?}"))?;
|
|
104
|
+
}
|
|
105
|
+
if let Some(ms) = options.metrics_interval_ms {
|
|
106
|
+
if ms == 0 {
|
|
107
|
+
builder = builder.disable_metrics_interval();
|
|
108
|
+
} else {
|
|
109
|
+
builder = builder.metrics_interval(Duration::from_millis(ms as u64));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let inner = builder
|
|
114
|
+
.build()
|
|
115
|
+
.await
|
|
116
|
+
.map_err(|e| anyhow::anyhow!("services build failed: {e:?}"))?;
|
|
117
|
+
Ok(ServicesClient { inner })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Read the current endpoint name from the local client.
|
|
121
|
+
#[napi]
|
|
122
|
+
pub async fn name(&self) -> Result<Option<String>> {
|
|
123
|
+
self.inner
|
|
124
|
+
.name()
|
|
125
|
+
.await
|
|
126
|
+
.map_err(|e| anyhow::anyhow!("{e:?}").into())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Set the endpoint name cloud-side.
|
|
130
|
+
#[napi]
|
|
131
|
+
pub async fn set_name(&self, name: String) -> Result<()> {
|
|
132
|
+
self.inner
|
|
133
|
+
.set_name(name)
|
|
134
|
+
.await
|
|
135
|
+
.map_err(|e| anyhow::anyhow!("{e:?}").into())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Ping the remote service.
|
|
139
|
+
#[napi]
|
|
140
|
+
pub async fn ping(&self) -> Result<()> {
|
|
141
|
+
self.inner
|
|
142
|
+
.ping()
|
|
143
|
+
.await
|
|
144
|
+
.map(|_| ())
|
|
145
|
+
.map_err(|e| anyhow::anyhow!("{e:?}").into())
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Force a metrics flush.
|
|
149
|
+
#[napi]
|
|
150
|
+
pub async fn push_metrics(&self) -> Result<()> {
|
|
151
|
+
self.inner
|
|
152
|
+
.push_metrics()
|
|
153
|
+
.await
|
|
154
|
+
.map_err(|e| anyhow::anyhow!("{e:?}").into())
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Run a network-diagnostics report, optionally submitting it.
|
|
158
|
+
#[napi]
|
|
159
|
+
pub async fn submit_network_diagnostics(&self, send: bool) -> Result<DiagnosticsSummary> {
|
|
160
|
+
let report = self
|
|
161
|
+
.inner
|
|
162
|
+
.net_diagnostics(send)
|
|
163
|
+
.await
|
|
164
|
+
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
|
165
|
+
Ok(report.into())
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/ticket.rs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
use iroh_tickets::Ticket as _;
|
|
2
|
+
use napi::bindgen_prelude::*;
|
|
3
|
+
use napi_derive::napi;
|
|
4
|
+
|
|
5
|
+
use crate::EndpointAddr;
|
|
6
|
+
|
|
7
|
+
/// A token containing information for establishing a connection to an endpoint.
|
|
8
|
+
#[derive(Debug, Clone)]
|
|
9
|
+
#[napi]
|
|
10
|
+
pub struct EndpointTicket(iroh_tickets::endpoint::EndpointTicket);
|
|
11
|
+
|
|
12
|
+
impl From<iroh_tickets::endpoint::EndpointTicket> for EndpointTicket {
|
|
13
|
+
fn from(t: iroh_tickets::endpoint::EndpointTicket) -> Self {
|
|
14
|
+
EndpointTicket(t)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[napi]
|
|
19
|
+
impl EndpointTicket {
|
|
20
|
+
/// Wrap an [`EndpointAddr`] into a ticket.
|
|
21
|
+
#[napi(factory)]
|
|
22
|
+
pub fn from_addr(addr: &EndpointAddr) -> Result<Self> {
|
|
23
|
+
let inner: iroh::EndpointAddr = addr.try_into()?;
|
|
24
|
+
Ok(iroh_tickets::endpoint::EndpointTicket::new(inner).into())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Parse a ticket from its base32 string form.
|
|
28
|
+
#[napi(factory)]
|
|
29
|
+
pub fn from_string(s: String) -> Result<Self> {
|
|
30
|
+
let ticket = iroh_tickets::endpoint::EndpointTicket::decode_string(&s)
|
|
31
|
+
.map_err(anyhow::Error::from)?;
|
|
32
|
+
Ok(EndpointTicket(ticket))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The [`EndpointAddr`] embedded in this ticket.
|
|
36
|
+
#[napi]
|
|
37
|
+
pub fn endpoint_addr(&self) -> EndpointAddr {
|
|
38
|
+
self.0.endpoint_addr().clone().into()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Base32 string form.
|
|
42
|
+
#[napi]
|
|
43
|
+
pub fn to_string(&self) -> String {
|
|
44
|
+
self.0.to_string()
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/watch.rs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
use n0_future::{StreamExt, task::AbortOnDropHandle};
|
|
2
|
+
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
|
3
|
+
use napi_derive::napi;
|
|
4
|
+
use tokio::sync::Mutex;
|
|
5
|
+
|
|
6
|
+
use crate::{EndpointAddr, PathEvent, PathSnapshot};
|
|
7
|
+
|
|
8
|
+
/// Handle to a running watcher task. Call `stop()` (or drop) to unregister.
|
|
9
|
+
#[napi]
|
|
10
|
+
pub struct WatchHandle {
|
|
11
|
+
task: Mutex<Option<AbortOnDropHandle<()>>>,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl WatchHandle {
|
|
15
|
+
pub(crate) fn new(task: AbortOnDropHandle<()>) -> Self {
|
|
16
|
+
Self {
|
|
17
|
+
task: Mutex::new(Some(task)),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[napi]
|
|
23
|
+
impl WatchHandle {
|
|
24
|
+
/// Stop the watcher, aborting the background task.
|
|
25
|
+
#[napi]
|
|
26
|
+
pub async fn stop(&self) {
|
|
27
|
+
self.task.lock().await.take();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub(crate) fn spawn_watch_addr(
|
|
32
|
+
endpoint: iroh::Endpoint,
|
|
33
|
+
cb: ThreadsafeFunction<EndpointAddr>,
|
|
34
|
+
) -> WatchHandle {
|
|
35
|
+
let task = n0_future::task::spawn(async move {
|
|
36
|
+
use iroh::Watcher;
|
|
37
|
+
let mut stream = endpoint.watch_addr().stream();
|
|
38
|
+
while let Some(addr) = stream.next().await {
|
|
39
|
+
let mapped: EndpointAddr = addr.into();
|
|
40
|
+
cb.call(Ok(mapped), ThreadsafeFunctionCallMode::NonBlocking);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
WatchHandle::new(AbortOnDropHandle::new(task))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub(crate) fn spawn_home_relay_watch(
|
|
47
|
+
endpoint: iroh::Endpoint,
|
|
48
|
+
cb: ThreadsafeFunction<Vec<String>>,
|
|
49
|
+
) -> WatchHandle {
|
|
50
|
+
let task = n0_future::task::spawn(async move {
|
|
51
|
+
use iroh::Watcher;
|
|
52
|
+
let mut stream = endpoint.home_relay_status().stream();
|
|
53
|
+
while let Some(statuses) = stream.next().await {
|
|
54
|
+
let urls: Vec<String> = statuses.into_iter().map(|s| s.url().to_string()).collect();
|
|
55
|
+
cb.call(Ok(urls), ThreadsafeFunctionCallMode::NonBlocking);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
WatchHandle::new(AbortOnDropHandle::new(task))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub(crate) fn spawn_network_change_watch(
|
|
62
|
+
endpoint: iroh::Endpoint,
|
|
63
|
+
cb: ThreadsafeFunction<()>,
|
|
64
|
+
) -> WatchHandle {
|
|
65
|
+
let task = n0_future::task::spawn(async move {
|
|
66
|
+
loop {
|
|
67
|
+
endpoint.network_change().await;
|
|
68
|
+
cb.call(Ok(()), ThreadsafeFunctionCallMode::NonBlocking);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
WatchHandle::new(AbortOnDropHandle::new(task))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub(crate) fn spawn_paths_watch(
|
|
75
|
+
conn: iroh::endpoint::Connection,
|
|
76
|
+
cb: ThreadsafeFunction<Vec<PathSnapshot>>,
|
|
77
|
+
) -> WatchHandle {
|
|
78
|
+
let task = n0_future::task::spawn(async move {
|
|
79
|
+
let mut stream = conn.paths_stream();
|
|
80
|
+
while let Some(snapshot) = stream.next().await {
|
|
81
|
+
let mapped: Vec<PathSnapshot> = snapshot
|
|
82
|
+
.iter()
|
|
83
|
+
.map(|p| PathSnapshot {
|
|
84
|
+
id: p.id().to_string(),
|
|
85
|
+
is_selected: p.is_selected(),
|
|
86
|
+
remote_addr: crate::path::transport_addr_to_string(p.remote_addr()),
|
|
87
|
+
is_ip: p.is_ip(),
|
|
88
|
+
is_relay: p.is_relay(),
|
|
89
|
+
rtt_ms: p.rtt().as_millis() as i64,
|
|
90
|
+
stats: p.stats().into(),
|
|
91
|
+
})
|
|
92
|
+
.collect();
|
|
93
|
+
cb.call(Ok(mapped), ThreadsafeFunctionCallMode::NonBlocking);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
WatchHandle::new(AbortOnDropHandle::new(task))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pub(crate) fn spawn_path_events_watch(
|
|
100
|
+
conn: iroh::endpoint::Connection,
|
|
101
|
+
cb: ThreadsafeFunction<PathEvent>,
|
|
102
|
+
) -> WatchHandle {
|
|
103
|
+
let task = n0_future::task::spawn(async move {
|
|
104
|
+
let mut stream = conn.path_events();
|
|
105
|
+
while let Some(event) = stream.next().await {
|
|
106
|
+
let mapped: PathEvent = event.into();
|
|
107
|
+
cb.call(Ok(mapped), ThreadsafeFunctionCallMode::NonBlocking);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
WatchHandle::new(AbortOnDropHandle::new(task))
|
|
111
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { test, suite } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import pkg from '../index.js'
|
|
5
|
+
const { Endpoint, EndpointTicket, RelayMode, presetMinimal } = pkg
|
|
6
|
+
|
|
7
|
+
const ALPN = Array.from(Buffer.from('iroh-ffi/test/0', 'utf8'))
|
|
8
|
+
|
|
9
|
+
// A "preset" in JS is any function that configures an EndpointBuilder.
|
|
10
|
+
async function bindMinimal() {
|
|
11
|
+
const b = Endpoint.builder()
|
|
12
|
+
presetMinimal(b)
|
|
13
|
+
return await b.bind()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function bindServer() {
|
|
17
|
+
const b = Endpoint.builder()
|
|
18
|
+
b.applyN0()
|
|
19
|
+
b.alpns([ALPN])
|
|
20
|
+
b.relayMode(RelayMode.disabled())
|
|
21
|
+
return await b.bind()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function bindClient() {
|
|
25
|
+
const b = Endpoint.builder()
|
|
26
|
+
b.applyN0()
|
|
27
|
+
b.relayMode(RelayMode.disabled())
|
|
28
|
+
return await b.bind()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
suite('endpoint', () => {
|
|
32
|
+
test('builder + preset: id, addr, sockets, secretKey, close', async () => {
|
|
33
|
+
const ep = await bindMinimal()
|
|
34
|
+
const id = ep.id()
|
|
35
|
+
assert.ok(id.toString().length > 0)
|
|
36
|
+
|
|
37
|
+
const addr = ep.addr()
|
|
38
|
+
assert.ok(addr.id().equals(id))
|
|
39
|
+
|
|
40
|
+
assert.ok(ep.boundSockets().length > 0)
|
|
41
|
+
assert.deepEqual(ep.secretKey().public().toBytes(), id.toBytes())
|
|
42
|
+
|
|
43
|
+
await ep.close()
|
|
44
|
+
assert.ok(ep.isClosed())
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('builder.bind() consumes — second call errors', async () => {
|
|
48
|
+
const b = Endpoint.builder()
|
|
49
|
+
presetMinimal(b)
|
|
50
|
+
const ep = await b.bind()
|
|
51
|
+
await ep.close()
|
|
52
|
+
await assert.rejects(() => b.bind(), /already consumed/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('custom preset function', async () => {
|
|
56
|
+
// A user-defined preset is just a function over the builder.
|
|
57
|
+
const myPreset = (b) => {
|
|
58
|
+
b.applyMinimal()
|
|
59
|
+
b.alpns([ALPN])
|
|
60
|
+
}
|
|
61
|
+
const b = Endpoint.builder()
|
|
62
|
+
myPreset(b)
|
|
63
|
+
const ep = await b.bind()
|
|
64
|
+
assert.ok(ep.id().toString().length > 0)
|
|
65
|
+
await ep.close()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('endpoint ticket round trip', async () => {
|
|
69
|
+
const ep = await bindMinimal()
|
|
70
|
+
const addr = ep.addr()
|
|
71
|
+
|
|
72
|
+
const ticket = EndpointTicket.fromAddr(addr)
|
|
73
|
+
const str = ticket.toString()
|
|
74
|
+
assert.ok(str.startsWith('endpoint'))
|
|
75
|
+
|
|
76
|
+
const parsed = EndpointTicket.fromString(str)
|
|
77
|
+
assert.ok(parsed.endpointAddr().id().equals(addr.id()))
|
|
78
|
+
|
|
79
|
+
await ep.close()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('endpoint ticket rejects garbage', () => {
|
|
83
|
+
assert.throws(() => EndpointTicket.fromString('not-a-ticket'))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('connect / echo / datagram round trip', async () => {
|
|
87
|
+
const server = await bindServer()
|
|
88
|
+
const serverAddr = server.addr()
|
|
89
|
+
const serverId = server.id()
|
|
90
|
+
|
|
91
|
+
const serverTask = (async () => {
|
|
92
|
+
const incoming = await server.acceptNext()
|
|
93
|
+
assert.ok(incoming)
|
|
94
|
+
const accepting = await incoming.accept()
|
|
95
|
+
const conn = await accepting.connect()
|
|
96
|
+
assert.deepEqual(conn.alpn(), ALPN)
|
|
97
|
+
const bi = await conn.acceptBi()
|
|
98
|
+
const recv = bi.recv
|
|
99
|
+
const send = bi.send
|
|
100
|
+
const msg = await recv.readToEnd(64)
|
|
101
|
+
await send.writeAll(msg)
|
|
102
|
+
await send.finish()
|
|
103
|
+
const dg = await conn.readDatagram()
|
|
104
|
+
conn.sendDatagram(dg)
|
|
105
|
+
await conn.closed()
|
|
106
|
+
})()
|
|
107
|
+
|
|
108
|
+
const client = await bindClient()
|
|
109
|
+
const conn = await client.connect(serverAddr, ALPN)
|
|
110
|
+
assert.ok(conn.remoteId().equals(serverId))
|
|
111
|
+
assert.ok(conn.paths().length > 0)
|
|
112
|
+
|
|
113
|
+
const bi = await conn.openBi()
|
|
114
|
+
await bi.send.writeAll(Array.from(Buffer.from('hello iroh')))
|
|
115
|
+
await bi.send.finish()
|
|
116
|
+
const echoed = await bi.recv.readToEnd(64)
|
|
117
|
+
assert.equal(Buffer.from(echoed).toString('utf8'), 'hello iroh')
|
|
118
|
+
|
|
119
|
+
conn.sendDatagram(Array.from(Buffer.from('ping')))
|
|
120
|
+
const pong = await conn.readDatagram()
|
|
121
|
+
assert.equal(Buffer.from(pong).toString('utf8'), 'ping')
|
|
122
|
+
|
|
123
|
+
const stats = conn.stats()
|
|
124
|
+
assert.ok(stats.udpTxDatagrams > 0)
|
|
125
|
+
|
|
126
|
+
conn.close(0n, Array.from(Buffer.from('bye')))
|
|
127
|
+
await serverTask
|
|
128
|
+
await client.close()
|
|
129
|
+
await server.close()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('relay-only mode binds without IP transports', async () => {
|
|
133
|
+
// A normal n0 endpoint binds at least one local UDP socket for direct
|
|
134
|
+
// (IP) connectivity.
|
|
135
|
+
const direct = await Endpoint.bind({ alpns: [ALPN] }, RelayMode.disabled())
|
|
136
|
+
assert.ok(
|
|
137
|
+
direct.boundSockets().length > 0,
|
|
138
|
+
'default endpoint should bind IP sockets',
|
|
139
|
+
)
|
|
140
|
+
await direct.close()
|
|
141
|
+
|
|
142
|
+
// With relayOnly: true the IP transports are cleared, so the endpoint
|
|
143
|
+
// has no bound IP sockets — all traffic is forced over relays.
|
|
144
|
+
const relayOnly = await Endpoint.bind({ alpns: [ALPN], relayOnly: true })
|
|
145
|
+
assert.equal(
|
|
146
|
+
relayOnly.boundSockets().length,
|
|
147
|
+
0,
|
|
148
|
+
'relay-only endpoint should have no direct IP sockets',
|
|
149
|
+
)
|
|
150
|
+
assert.ok(relayOnly.id().toString().length > 0)
|
|
151
|
+
await relayOnly.close()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('unidirectional stream', async () => {
|
|
155
|
+
const server = await bindServer()
|
|
156
|
+
const serverAddr = server.addr()
|
|
157
|
+
|
|
158
|
+
const serverTask = (async () => {
|
|
159
|
+
const incoming = await server.acceptNext()
|
|
160
|
+
const conn = await (await incoming.accept()).connect()
|
|
161
|
+
const recv = await conn.acceptUni()
|
|
162
|
+
const msg = await recv.readToEnd(32)
|
|
163
|
+
assert.equal(Buffer.from(msg).toString('utf8'), 'unidirectional')
|
|
164
|
+
})()
|
|
165
|
+
|
|
166
|
+
const client = await bindClient()
|
|
167
|
+
const conn = await client.connect(serverAddr, ALPN)
|
|
168
|
+
const send = await conn.openUni()
|
|
169
|
+
await send.writeAll(Array.from(Buffer.from('unidirectional')))
|
|
170
|
+
await send.finish()
|
|
171
|
+
|
|
172
|
+
await serverTask
|
|
173
|
+
await client.close()
|
|
174
|
+
await server.close()
|
|
175
|
+
})
|
|
176
|
+
})
|
package/test/key.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { test, suite } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import pkg from '../index.js'
|
|
5
|
+
const { EndpointId, SecretKey, Signature } = pkg
|
|
6
|
+
|
|
7
|
+
const keyStr = '523c7996bad77424e96786cf7a7205115337a5b4565cd25506a0f297b191a5ea'
|
|
8
|
+
const fmtStr = '523c7996ba'
|
|
9
|
+
const bytes = Array.from(new Uint8Array([
|
|
10
|
+
0x52, 0x3c, 0x79, 0x96, 0xba, 0xd7, 0x74, 0x24,
|
|
11
|
+
0xe9, 0x67, 0x86, 0xcf, 0x7a, 0x72, 0x05, 0x11,
|
|
12
|
+
0x53, 0x37, 0xa5, 0xb4, 0x56, 0x5c, 0xd2, 0x55,
|
|
13
|
+
0x06, 0xa0, 0xf2, 0x97, 0xb1, 0x91, 0xa5, 0xea,
|
|
14
|
+
]))
|
|
15
|
+
|
|
16
|
+
suite('endpoint id', () => {
|
|
17
|
+
test('from string', () => {
|
|
18
|
+
const id = EndpointId.fromString(keyStr)
|
|
19
|
+
assert.equal(id.toString(), keyStr)
|
|
20
|
+
assert.deepEqual(id.toBytes(), bytes)
|
|
21
|
+
assert.equal(id.fmtShort(), fmtStr)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('from bytes', () => {
|
|
25
|
+
const id = EndpointId.fromBytes(bytes)
|
|
26
|
+
assert.equal(id.toString(), keyStr)
|
|
27
|
+
assert.deepEqual(id.toBytes(), bytes)
|
|
28
|
+
assert.equal(id.fmtShort(), fmtStr)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('equality', () => {
|
|
32
|
+
assert.ok(EndpointId.fromString(keyStr).equals(EndpointId.fromBytes(bytes)))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('rejects bad bytes', () => {
|
|
36
|
+
assert.throws(() => EndpointId.fromBytes([1, 2, 3]))
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
suite('secret key', () => {
|
|
41
|
+
test('bytes round trip', () => {
|
|
42
|
+
const secret = SecretKey.generate()
|
|
43
|
+
const raw = secret.toBytes()
|
|
44
|
+
assert.equal(raw.length, 32)
|
|
45
|
+
const secret2 = SecretKey.fromBytes(raw)
|
|
46
|
+
assert.deepEqual(secret.toBytes(), secret2.toBytes())
|
|
47
|
+
assert.deepEqual(secret.public().toBytes(), secret2.public().toBytes())
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('sign / verify round trip', () => {
|
|
51
|
+
const secret = SecretKey.generate()
|
|
52
|
+
const pub = secret.public()
|
|
53
|
+
const msg = Array.from(Buffer.from('hello iroh', 'utf8'))
|
|
54
|
+
const sig = secret.sign(msg)
|
|
55
|
+
|
|
56
|
+
const raw = sig.toBytes()
|
|
57
|
+
assert.equal(raw.length, 64)
|
|
58
|
+
const sig2 = Signature.fromBytes(raw)
|
|
59
|
+
assert.deepEqual(sig2.toBytes(), raw)
|
|
60
|
+
|
|
61
|
+
pub.verify(msg, sig)
|
|
62
|
+
pub.verify(msg, sig2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('verify rejects tampered message', () => {
|
|
66
|
+
const secret = SecretKey.generate()
|
|
67
|
+
const pub = secret.public()
|
|
68
|
+
const sig = secret.sign(Array.from(Buffer.from('original')))
|
|
69
|
+
assert.throws(() => pub.verify(Array.from(Buffer.from('tampered')), sig))
|
|
70
|
+
})
|
|
71
|
+
})
|
package/test/relay.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { test, suite } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import pkg from '../index.js'
|
|
5
|
+
const { RelayMap, RelayMode } = pkg
|
|
6
|
+
|
|
7
|
+
suite('relay map', () => {
|
|
8
|
+
test('crud', () => {
|
|
9
|
+
const map = RelayMap.empty()
|
|
10
|
+
assert.ok(map.isEmpty())
|
|
11
|
+
|
|
12
|
+
const cfg = {
|
|
13
|
+
url: 'https://relay.example.org/',
|
|
14
|
+
quicPort: 7842,
|
|
15
|
+
authToken: 'hunter2',
|
|
16
|
+
}
|
|
17
|
+
map.insert(cfg)
|
|
18
|
+
assert.equal(map.len(), 1)
|
|
19
|
+
assert.ok(map.contains('https://relay.example.org/'))
|
|
20
|
+
|
|
21
|
+
const got = map.get('https://relay.example.org/')
|
|
22
|
+
assert.equal(got.url, cfg.url)
|
|
23
|
+
assert.equal(got.quicPort, cfg.quicPort)
|
|
24
|
+
assert.equal(got.authToken, cfg.authToken)
|
|
25
|
+
|
|
26
|
+
assert.ok(map.remove('https://relay.example.org/'))
|
|
27
|
+
assert.ok(map.isEmpty())
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
suite('relay mode', () => {
|
|
32
|
+
test('constructors', () => {
|
|
33
|
+
RelayMode.disabled()
|
|
34
|
+
RelayMode.defaultMode()
|
|
35
|
+
RelayMode.staging()
|
|
36
|
+
const map = RelayMap.fromUrls(['https://r1.example.org/'])
|
|
37
|
+
RelayMode.custom(map)
|
|
38
|
+
RelayMode.customFromUrls(['https://r2.example.org/'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test, suite } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
|
|
4
|
+
import pkg from '../index.js'
|
|
5
|
+
const { Endpoint, ServicesClient, presetMinimal } = pkg
|
|
6
|
+
|
|
7
|
+
// Well-formed (but fake) API secret — the remote does not exist, but the
|
|
8
|
+
// client connects lazily so construction still succeeds. Validates the
|
|
9
|
+
// options -> builder -> client plumbing without network.
|
|
10
|
+
const FAKE_API_SECRET =
|
|
11
|
+
'servicesaaqaobyha4dqobyha4dqobyha4dqobyha4dqobyha4dqobyha4dqob' +
|
|
12
|
+
'75c4sdqwvay5nwj63yzvqc7iozsh66x53lcpcy5vyc5ledl2pwdaaa'
|
|
13
|
+
|
|
14
|
+
async function endpoint() {
|
|
15
|
+
const b = Endpoint.builder()
|
|
16
|
+
presetMinimal(b)
|
|
17
|
+
return await b.bind()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
suite('services client', () => {
|
|
21
|
+
test('boots with fake secret', async () => {
|
|
22
|
+
const ep = await endpoint()
|
|
23
|
+
const client = await ServicesClient.create(ep, { apiSecret: FAKE_API_SECRET })
|
|
24
|
+
assert.ok(client)
|
|
25
|
+
await ep.close()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('rejects no credentials', async () => {
|
|
29
|
+
const ep = await endpoint()
|
|
30
|
+
await assert.rejects(ServicesClient.create(ep, {}))
|
|
31
|
+
await ep.close()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects two credentials', async () => {
|
|
35
|
+
const ep = await endpoint()
|
|
36
|
+
await assert.rejects(
|
|
37
|
+
ServicesClient.create(ep, { apiSecret: FAKE_API_SECRET, apiSecretFromEnv: true }),
|
|
38
|
+
)
|
|
39
|
+
await ep.close()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('rejects malformed secret', async () => {
|
|
43
|
+
const ep = await endpoint()
|
|
44
|
+
await assert.rejects(ServicesClient.create(ep, { apiSecret: 'not-a-valid-ticket' }))
|
|
45
|
+
await ep.close()
|
|
46
|
+
})
|
|
47
|
+
})
|
package/tsconfig.json
ADDED