You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/29 03:14:03 UTC

(maka) 01/01: feat(runtime-host): discover coordination relays automatically

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-peer-relay-discovery
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 55aaf3e204101b937f8dc77949e8d2a8465a5ee8
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Aug 29 11:02:17 2026 +0800

    feat(runtime-host): discover coordination relays automatically
    
    Use a bounded IPFS Amino discovery client to supply public Circuit Relay v2 candidates while the main peer endpoint retains reservation authority. Preserve manual relay precedence and publish only accepted reservations.
    
    Generated-by: OpenAI Codex
---
 apps/desktop/src/main/runtime-host-management.ts   |  21 +-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |   6 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |   2 +
 .../src/renderer/locales/settings-projects-copy.ts |   6 +
 .../settings/runtime-host-management-dialog.tsx    |  25 ++
 .../src/renderer/styles/settings/runtime-host.css  |  13 +
 docs/runtime-host-remote-access.md                 |  11 +-
 docs/runtime-host-remote-access.zh-CN.md           |   8 +-
 native/runtime-host-peer/Cargo.lock                |  61 +++
 native/runtime-host-peer/Cargo.toml                |   2 +
 native/runtime-host-peer/src/bindings.rs           |  14 +-
 native/runtime-host-peer/src/engine.rs             | 466 ++++++++++++++++++++-
 .../src/engine/relay_discovery.rs                  | 337 +++++++++++++++
 .../cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv    |   5 +
 .../cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt  | 118 +++++-
 .../__tests__/runtime-host-service-manager.test.ts |   2 +
 packages/cli/src/cli-core.ts                       |   8 +
 packages/cli/src/runtime-host-cli.ts               |  24 +-
 .../src/runtime-host-peer-management-command.ts    |   5 +
 packages/cli/src/runtime-host-service-command.ts   |   1 +
 packages/cli/src/runtime-host-setup-command.ts     |   1 +
 .../src/__tests__/peer-management-frame.test.ts    |   1 +
 .../runtime-host/src/__tests__/peer-native.test.ts |   2 +
 packages/runtime-host/src/client/peer-client.ts    |   8 +-
 .../src/operator/managed-deployment.ts             |   1 +
 .../src/operator/peer-management-frame.ts          |   1 +
 packages/runtime-host/src/peer-mesh/owner.ts       |   4 +
 packages/runtime-host/src/server/peer-listener.ts  |   1 +
 packages/runtime-host/src/transport/peer-native.ts |   9 +
 scripts/generate-runtime-host-peer-notices.mjs     |   6 +-
 31 files changed, 1145 insertions(+), 26 deletions(-)

diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index c1e82c737..5d25ff9d2 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -317,6 +317,7 @@ export function createDesktopRuntimeHostManagement(input: {
       ...(status.peerId ? { peerId: status.peerId } : {}),
       routeHints: status.routeHints,
       coordinationRelays: status.coordinationRelays,
+      automaticRelayDiscovery: status.automaticRelayDiscovery ?? true,
       profilePresent: profile.exists,
       profileEnabled: profile.enabled,
       clientAvailable: input.directPeerClientAvailable,
@@ -356,6 +357,7 @@ export function createDesktopRuntimeHostManagement(input: {
       state: 'unsupported',
       routeHints: [],
       coordinationRelays: [],
+      automaticRelayDiscovery: true,
       profilePresent: profile.exists,
       profileEnabled: profile.enabled,
       clientAvailable: input.directPeerClientAvailable,
@@ -390,11 +392,15 @@ export function createDesktopRuntimeHostManagement(input: {
     profileIdValue: unknown,
     enabledValue: unknown,
     coordinationRelaysValue: unknown,
+    automaticRelayDiscoveryValue: unknown,
   ): Promise<DesktopRuntimeHostDirectPeerSnapshot> => {
     if (typeof enabledValue !== 'boolean') {
       throw new Error('Runtime Host direct-peer state is invalid');
     }
     const coordinationRelays = requireCoordinationRelays(coordinationRelaysValue);
+    if (typeof automaticRelayDiscoveryValue !== 'boolean') {
+      throw new Error('Runtime Host relay discovery state is invalid');
+    }
     const { profileId, managed, transport, expectedTarget, available } =
       await peerManagementTarget(profileIdValue);
     if (!available) {
@@ -410,6 +416,7 @@ export function createDesktopRuntimeHostManagement(input: {
       operatorPath: managed.control.operatorPath,
       action: enabledValue ? 'enable' : 'disable',
       ...(enabledValue ? { coordinationRelays } : {}),
+      ...(enabledValue ? { automaticRelayDiscovery: automaticRelayDiscoveryValue } : {}),
       expectedTarget,
     });
     if (response.kind !== 'result') {
@@ -829,8 +836,18 @@ export function createDesktopRuntimeHostManagement(input: {
     getDirectPeer(profileId));
   input.ipcMain.handle(
     channels.configureDirectPeer,
-    (_event, profileId: unknown, enabled: unknown, coordinationRelays: unknown) =>
-      configureDirectPeer(profileId, enabled, coordinationRelays),
+    (
+      _event,
+      profileId: unknown,
+      enabled: unknown,
+      coordinationRelays: unknown,
+      automaticRelayDiscovery: unknown,
+    ) => configureDirectPeer(
+      profileId,
+      enabled,
+      coordinationRelays,
+      automaticRelayDiscovery,
+    ),
   );
 
   return {
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index daa5b2875..ab25d36fe 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -174,6 +174,7 @@ export interface DesktopRuntimeHostSshPeerManagementInput {
   readonly operatorPath: string;
   readonly action: Extract<RuntimeHostPeerManagementAction, 'enable' | 'disable' | 'status'>;
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
   readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget'];
   readonly signal?: AbortSignal;
 }
@@ -1348,6 +1349,11 @@ function runtimeHostPeerManagementRemoteCommand(
         ? ['--clear-coordination-relays']
         : input.coordinationRelays.flatMap((relay) => ['--coordination-relay', relay])
       : []),
+    ...(input.action === 'enable' && input.automaticRelayDiscovery !== undefined
+      ? [input.automaticRelayDiscovery
+          ? '--automatic-relay-discovery'
+          : '--no-automatic-relay-discovery']
+      : []),
     ...managedServiceTargetArgs(input.expectedTarget),
   ].map(quotePosix).join(' ');
   return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(`exec ${command}`)}`;
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index e5f9f1e5f..85c431907 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -514,6 +514,7 @@ export interface DesktopRuntimeHostDirectPeerSnapshot {
   readonly peerId?: string;
   readonly routeHints: readonly string[];
   readonly coordinationRelays: readonly string[];
+  readonly automaticRelayDiscovery: boolean;
   readonly profilePresent: boolean;
   readonly profileEnabled: boolean;
   readonly clientAvailable: boolean;
@@ -731,6 +732,7 @@ export interface MakaBridge {
       profileId: string,
       enabled: boolean,
       coordinationRelays: readonly string[],
+      automaticRelayDiscovery: boolean,
     ): Promise<DesktopRuntimeHostDirectPeerSnapshot>;
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
     rotateCredential(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 63c734436..c21d120bf 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1349,12 +1349,14 @@ const makaBridge = {
       profileId: string,
       enabled: boolean,
       coordinationRelays: readonly string[],
+      automaticRelayDiscovery: boolean,
     ) {
       return ipcRenderer.invoke(
         'runtime-host-management:configure-direct-peer',
         profileId,
         enabled,
         coordinationRelays,
+        automaticRelayDiscovery,
       );
     },
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot> {
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 84ed7af67..cc008179f 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -134,6 +134,8 @@ export type SettingsProjectsCopy = {
     directPeerRoutes: string;
     directPeerCoordinationRelays: string;
     directPeerCoordinationRelaysPlaceholder: string;
+    directPeerAutomaticRelayDiscovery: string;
+    directPeerAutomaticRelayDiscoveryHelp: string;
     directPeerEnable: string;
     directPeerDisable: string;
     directPeerAddProfile: string;
@@ -423,6 +425,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       directPeerRoutes: '可用路径',
       directPeerCoordinationRelays: '连接协调节点(可选)',
       directPeerCoordinationRelaysPlaceholder: '多个地址用逗号分隔',
+      directPeerAutomaticRelayDiscovery: '自动发现协调节点',
+      directPeerAutomaticRelayDiscoveryHelp: '通过公共 IPFS 网络尽力发现可用节点;手动填写的节点仍会优先使用。',
       directPeerEnable: '启用并添加',
       directPeerDisable: '停用',
       directPeerAddProfile: '添加到 Desktop',
@@ -712,6 +716,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       directPeerRoutes: 'Routes',
       directPeerCoordinationRelays: 'Connection coordination peers (optional)',
       directPeerCoordinationRelaysPlaceholder: 'Separate multiple addresses with commas',
+      directPeerAutomaticRelayDiscovery: 'Discover coordination peers automatically',
+      directPeerAutomaticRelayDiscoveryHelp: 'Best-effort discovery through the public IPFS network; manually configured peers remain preferred.',
       directPeerEnable: 'Enable and add',
       directPeerDisable: 'Disable',
       directPeerAddProfile: 'Add to Desktop',
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
index 951d391be..df7bc303e 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -21,6 +21,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
 import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
 import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
 import { Text } from '@astryxdesign/core/Text';
+import { Switch } from '@astryxdesign/core/Switch';
 import {
   Badge,
   Banner,
@@ -101,6 +102,7 @@ export function RuntimeHostManagementDialog(props: {
   const [directPeer, setDirectPeer] = useState<DesktopRuntimeHostDirectPeerSnapshot>();
   const [directPeerError, setDirectPeerError] = useState<string>();
   const [coordinationRelays, setCoordinationRelays] = useState('');
+  const [automaticRelayDiscovery, setAutomaticRelayDiscovery] = useState(true);
   const nextDirectoryRootId = useRef(1);
   const logsRef = useRef<HTMLPreElement>(null);
 
@@ -213,6 +215,7 @@ export function RuntimeHostManagementDialog(props: {
   function applyDirectPeer(snapshot: DesktopRuntimeHostDirectPeerSnapshot): void {
     setDirectPeer(snapshot);
     setCoordinationRelays(snapshot.coordinationRelays.join(', '));
+    setAutomaticRelayDiscovery(snapshot.automaticRelayDiscovery);
     setDirectPeerError(undefined);
   }
 
@@ -243,6 +246,7 @@ export function RuntimeHostManagementDialog(props: {
           profile.id,
           enabled,
           relays,
+          automaticRelayDiscovery,
         ),
       );
     } catch (failure) {
@@ -741,6 +745,27 @@ export function RuntimeHostManagementDialog(props: {
                             }
                             onChange={setCoordinationRelays}
                           />
+                          <div className="settingsRuntimeHostDirectPeerDiscovery">
+                            <div>
+                              <Text type="body" weight="semibold">
+                                {copy.directPeerAutomaticRelayDiscovery}
+                              </Text>
+                              <Text type="supporting" color="secondary">
+                                {copy.directPeerAutomaticRelayDiscoveryHelp}
+                              </Text>
+                            </div>
+                            <Switch
+                              label={copy.directPeerAutomaticRelayDiscovery}
+                              isLabelHidden
+                              value={automaticRelayDiscovery}
+                              isDisabled={
+                                loading ||
+                                directPeer.profileEnabled ||
+                                directPeer.state === 'enabled'
+                              }
+                              onChange={setAutomaticRelayDiscovery}
+                            />
+                          </div>
                           <div className="settingsRuntimeHostUpdatePolicyActions">
                             <Button
                               variant="secondary"
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index 4425f94df..66794f89d 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -177,6 +177,19 @@
   justify-content: flex-end;
 }
 
+.settingsRuntimeHostDirectPeerDiscovery {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--space-3);
+}
+
+.settingsRuntimeHostDirectPeerDiscovery > div {
+  display: grid;
+  gap: var(--space-1);
+  min-width: 0;
+}
+
 .settingsRuntimeHostManagementLogs {
   overflow-wrap: anywhere;
   color: var(--foreground-secondary);
diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md
index c98fe30ff..a7648a5d8 100644
--- a/docs/runtime-host-remote-access.md
+++ b/docs/runtime-host-remote-access.md
@@ -131,8 +131,15 @@ service uninstall removes its key while retaining the State Root. Pass
 `peer enable --clear-coordination-relays` to remove every configured coordination relay.
 
 This direct-only path is experimental and may fail on restrictive NAT or UDP-blocked networks. It
-does not replace an existing TLS, SSH, or overlay-network fallback and does not use a public relay
-unless one is explicitly configured with `peer enable --coordination-relay`.
+does not replace an existing TLS, SSH, or overlay-network fallback. By default, the Host uses a
+bounded client-only view of the public IPFS DHT to discover Circuit Relay v2 candidates and fills a
+target of two accepted reservations after accounting for manual relays. Manually configured relays
+remain preferred. Disable or restore this
+best-effort discovery with `peer enable --no-automatic-relay-discovery` or
+`peer enable --automatic-relay-discovery`; disabling it leaves manual relays intact. Public peers can
+observe the discovery connection and may refuse or drop reservations. Only accepted reservations
+are advertised to Mesh peers, and Maka still requires the application stream to upgrade to a direct
+connection instead of carrying Session traffic through the relay.
 
 ### Direct TLS
 
diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md
index a90aa2149..075dfd0bf 100644
--- a/docs/runtime-host-remote-access.zh-CN.md
+++ b/docs/runtime-host-remote-access.zh-CN.md
@@ -117,8 +117,12 @@ PeerId 和 listener 配置;`peer rotate` 会明确更换 PeerId;卸载 servi
 Root。执行 `peer enable --clear-coordination-relays` 可以删除所有已配置的 coordination relay。
 
 Direct-only 路径仍是实验能力,在受限 NAT 或禁用 UDP 的网络中可能失败。它不会替代已有的 TLS、SSH
-或 overlay network fallback;除非用户在 `peer enable` 时明确传入 `--coordination-relay`,否则不会使用
-公共 relay。
+或 overlay network fallback。Host 默认通过公共 IPFS DHT 的有界 client-only 视图发现 Circuit Relay v2
+候选;扣除手动 relay 后,自动池会补足两个已接受 reservation 的目标。手动配置的 relay 始终优先。使用
+`peer enable --no-automatic-relay-discovery` 可以关闭这项尽力而为的发现,使用
+`peer enable --automatic-relay-discovery` 可以重新开启;关闭不会删除手动 relay。公网 Peer 能观察到发现
+连接,也可能拒绝或中止 reservation。只有已接受的 reservation 才会向 Mesh Peer 发布,Maka 仍要求
+application stream 升级为直连,不会通过 relay 传输 Session traffic。
 
 ### Direct TLS
 
diff --git a/native/runtime-host-peer/Cargo.lock b/native/runtime-host-peer/Cargo.lock
index 12bcd90ac..ca3fffa36 100644
--- a/native/runtime-host-peer/Cargo.lock
+++ b/native/runtime-host-peer/Cargo.lock
@@ -88,6 +88,12 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "asn1_der"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156"
+
 [[package]]
 name = "async-io"
 version = "2.6.0"
@@ -401,6 +407,12 @@ version = "0.8.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
 
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
 [[package]]
 name = "crypto-common"
 version = "0.1.7"
@@ -886,6 +898,12 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
 
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
 [[package]]
 name = "hickory-proto"
 version = "0.25.2"
@@ -1299,6 +1317,7 @@ dependencies = [
  "libp2p-dns",
  "libp2p-identify",
  "libp2p-identity",
+ "libp2p-kad",
  "libp2p-mdns",
  "libp2p-metrics",
  "libp2p-noise",
@@ -1427,18 +1446,47 @@ version = "0.2.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c"
 dependencies = [
+ "asn1_der",
  "bs58",
  "ed25519-dalek",
  "hkdf",
  "multihash",
  "prost",
  "rand 0.8.8",
+ "ring",
  "sha2",
  "thiserror 2.0.20",
  "tracing",
  "zeroize",
 ]
 
+[[package]]
+name = "libp2p-kad"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "either",
+ "fnv",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "rand 0.8.8",
+ "sha2",
+ "smallvec",
+ "thiserror 2.0.20",
+ "tracing",
+ "uint",
+ "web-time",
+]
+
 [[package]]
 name = "libp2p-mdns"
 version = "0.48.0"
@@ -1469,6 +1517,7 @@ dependencies = [
  "libp2p-dcutr",
  "libp2p-identify",
  "libp2p-identity",
+ "libp2p-kad",
  "libp2p-ping",
  "libp2p-relay",
  "libp2p-swarm",
@@ -2947,6 +2996,18 @@ version = "1.20.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
 
+[[package]]
+name = "uint"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90"
+dependencies = [
+ "byteorder",
+ "crunchy",
+ "hex",
+ "static_assertions",
+]
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
diff --git a/native/runtime-host-peer/Cargo.toml b/native/runtime-host-peer/Cargo.toml
index 5b086ce9d..01e367934 100644
--- a/native/runtime-host-peer/Cargo.toml
+++ b/native/runtime-host-peer/Cargo.toml
@@ -32,11 +32,13 @@ libp2p = { version = "0.56", features = [
   "dcutr",
   "dns",
   "identify",
+  "kad",
   "macros",
   "noise",
   "ping",
   "quic",
   "relay",
+  "rsa",
   "tcp",
   "tokio",
   "yamux",
diff --git a/native/runtime-host-peer/src/bindings.rs b/native/runtime-host-peer/src/bindings.rs
index ced2d6847..9379a8b35 100644
--- a/native/runtime-host-peer/src/bindings.rs
+++ b/native/runtime-host-peer/src/bindings.rs
@@ -19,7 +19,7 @@
 
 use std::{
     path::PathBuf,
-    sync::{Arc, Mutex},
+    sync::{Arc, Mutex, RwLock},
     time::Duration,
 };
 
@@ -39,6 +39,7 @@ pub struct StartPeerEndpointOptions {
     pub expected_peer_id: Option<String>,
     pub listen_addresses: Option<Vec<String>>,
     pub coordination_relays: Option<Vec<String>>,
+    pub automatic_relay_discovery: Option<bool>,
 }
 
 #[napi(object)]
@@ -60,6 +61,7 @@ pub struct PeerIdentitySignature {
 pub struct PeerEndpoint {
     peer_id: String,
     listen_addresses: Vec<String>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
     commands: mpsc::Sender<EngineCommand>,
     incoming: Arc<AsyncMutex<mpsc::Receiver<engine::PeerStream>>>,
     mesh_incoming: Arc<AsyncMutex<mpsc::Receiver<engine::PeerStream>>>,
@@ -79,6 +81,14 @@ impl PeerEndpoint {
         self.listen_addresses.clone()
     }
 
+    #[napi(getter)]
+    pub fn active_coordination_relays(&self) -> Vec<String> {
+        self.active_coordination_relays
+            .read()
+            .map(|addresses| addresses.iter().map(ToString::to_string).collect())
+            .unwrap_or_default()
+    }
+
     #[napi]
     pub async fn connect(&self, options: ConnectPeerOptions) -> Result<PeerStream> {
         connect_peer(self, options, engine::StreamKind::Application).await
@@ -283,6 +293,7 @@ pub fn start_peer_endpoint(options: StartPeerEndpointOptions) -> Result<PeerEndp
             options.coordination_relays.unwrap_or_default(),
             "coordination relay",
         )?,
+        automatic_relay_discovery: options.automatic_relay_discovery.unwrap_or(false),
     })
     .map_err(peer_error)?;
     Ok(PeerEndpoint {
@@ -292,6 +303,7 @@ pub fn start_peer_endpoint(options: StartPeerEndpointOptions) -> Result<PeerEndp
             .into_iter()
             .map(|address| address.to_string())
             .collect(),
+        active_coordination_relays: started.active_coordination_relays,
         commands: started.commands,
         incoming: Arc::new(AsyncMutex::new(started.incoming)),
         mesh_incoming: Arc::new(AsyncMutex::new(started.mesh_incoming)),
diff --git a/native/runtime-host-peer/src/engine.rs b/native/runtime-host-peer/src/engine.rs
index 7b33d11a9..bc52a3608 100644
--- a/native/runtime-host-peer/src/engine.rs
+++ b/native/runtime-host-peer/src/engine.rs
@@ -20,6 +20,7 @@
 use std::{
     collections::{HashMap, HashSet},
     path::PathBuf,
+    sync::{Arc, RwLock},
     thread,
     time::{Duration, Instant},
 };
@@ -43,6 +44,7 @@ mod address;
 mod application_stream;
 mod identity_store;
 mod peer_stream;
+mod relay_discovery;
 
 use address::{
     address_with_expected_peer, address_with_peer, coordination_relay_peer_id, is_relayed_address,
@@ -64,7 +66,11 @@ const MAX_ESTABLISHED_CONNECTIONS: u32 = 1024;
 const MAX_CONNECTIONS_PER_PEER: u32 = 4;
 const LISTENER_ADDRESS_QUIET_PERIOD: Duration = Duration::from_millis(250);
 const COORDINATION_RETRY_INTERVAL: Duration = Duration::from_secs(1);
+const AUTOMATIC_RELAY_COOLDOWN: Duration = Duration::from_secs(30);
 const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
+const TARGET_COORDINATION_RESERVATIONS: usize = 2;
+const MAX_AUTOMATIC_RELAY_CANDIDATES: usize = 8;
+const MAX_PUBLISHED_ADDRESSES_PER_RELAY: usize = 4;
 
 #[derive(Clone)]
 pub struct StartOptions {
@@ -72,11 +78,13 @@ pub struct StartOptions {
     pub expected_peer_id: Option<PeerId>,
     pub listen_addresses: Vec<Multiaddr>,
     pub coordination_relays: Vec<Multiaddr>,
+    pub automatic_relay_discovery: bool,
 }
 
 pub struct StartedEndpoint {
     pub peer_id: PeerId,
     pub listen_addresses: Vec<Multiaddr>,
+    pub active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
     pub commands: mpsc::Sender<EngineCommand>,
     pub incoming: mpsc::Receiver<PeerStream>,
     pub mesh_incoming: mpsc::Receiver<PeerStream>,
@@ -178,11 +186,14 @@ struct DirectConnectState {
 
 struct CoordinationRelay {
     addresses: Vec<Multiaddr>,
+    reservation_addresses: Vec<Multiaddr>,
     connections: HashSet<ConnectionId>,
     pending_connection: Option<ConnectionId>,
     identify_received: bool,
     identify_sent: bool,
     reserve: bool,
+    automatic: bool,
+    reservation_accepted: bool,
     client_references: usize,
     reservation_listener: Option<ListenerId>,
     next_connection_attempt: Instant,
@@ -194,11 +205,14 @@ impl Default for CoordinationRelay {
         let now = Instant::now();
         Self {
             addresses: Vec::new(),
+            reservation_addresses: Vec::new(),
             connections: HashSet::new(),
             pending_connection: None,
             identify_received: false,
             identify_sent: false,
             reserve: false,
+            automatic: false,
+            reservation_accepted: false,
             client_references: 0,
             reservation_listener: None,
             next_connection_attempt: now,
@@ -216,7 +230,14 @@ impl CoordinationRelay {
         self.identify_received = false;
         self.identify_sent = false;
         self.next_connection_attempt = now;
-        self.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
+        self.next_reservation_attempt = now
+            + if self.automatic {
+                AUTOMATIC_RELAY_COOLDOWN
+            } else {
+                COORDINATION_RETRY_INTERVAL
+            };
+        self.reservation_accepted = false;
+        self.reservation_addresses.clear();
         self.reservation_listener.take()
     }
 
@@ -225,7 +246,14 @@ impl CoordinationRelay {
             return false;
         }
         self.reservation_listener = None;
-        self.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
+        self.reservation_accepted = false;
+        self.reservation_addresses.clear();
+        self.next_reservation_attempt = now
+            + if self.automatic {
+                AUTOMATIC_RELAY_COOLDOWN
+            } else {
+                COORDINATION_RETRY_INTERVAL
+            };
         true
     }
 }
@@ -291,6 +319,8 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
     let (incoming_tx, incoming_rx) = mpsc::channel(INCOMING_STREAM_CAPACITY);
     let (mesh_incoming_tx, mesh_incoming_rx) = mpsc::channel(MESH_INCOMING_STREAM_CAPACITY);
     let (terminal_tx, terminal_rx) = mpsc::channel(1);
+    let active_coordination_relays = Arc::new(RwLock::new(Vec::new()));
+    let active_coordination_relays_for_thread = Arc::clone(&active_coordination_relays);
     let thread = thread::Builder::new()
         .name("maka-runtime-host-peer".to_owned())
         .spawn(move || {
@@ -300,6 +330,7 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
                 incoming_tx,
                 mesh_incoming_tx,
                 ready_tx.clone(),
+                active_coordination_relays_for_thread,
             );
             if let Err(error) = result {
                 let _ = ready_tx.send(Err(error.clone()));
@@ -313,6 +344,7 @@ pub fn start(options: StartOptions) -> Result<StartedEndpoint, PeerError> {
     Ok(StartedEndpoint {
         peer_id: ready.0,
         listen_addresses: ready.1,
+        active_coordination_relays,
         commands: command_tx,
         incoming: incoming_rx,
         mesh_incoming: mesh_incoming_rx,
@@ -327,6 +359,7 @@ fn run_endpoint(
     incoming_tx: mpsc::Sender<PeerStream>,
     mesh_incoming_tx: mpsc::Sender<PeerStream>,
     ready_tx: std::sync::mpsc::SyncSender<Result<(PeerId, Vec<Multiaddr>), PeerError>>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
 ) -> Result<(), PeerError> {
     let runtime = tokio::runtime::Builder::new_multi_thread()
         .enable_all()
@@ -339,6 +372,7 @@ fn run_endpoint(
         incoming_tx,
         mesh_incoming_tx,
         ready_tx,
+        active_coordination_relays,
     ))
 }
 
@@ -348,6 +382,7 @@ async fn run_endpoint_async(
     incoming_tx: mpsc::Sender<PeerStream>,
     mesh_incoming_tx: mpsc::Sender<PeerStream>,
     ready_tx: std::sync::mpsc::SyncSender<Result<(PeerId, Vec<Multiaddr>), PeerError>>,
+    active_coordination_relays: Arc<RwLock<Vec<Multiaddr>>>,
 ) -> Result<(), PeerError> {
     let key = match options.expected_peer_id {
         Some(expected) => {
@@ -388,7 +423,14 @@ async fn run_endpoint_async(
         coordination_relay_peer_id(relay)?;
     }
     for relay in &options.coordination_relays {
-        register_coordination_relay(&mut coordination_relays, relay, local_peer_id, true, false)?;
+        register_coordination_relay(
+            &mut coordination_relays,
+            relay,
+            local_peer_id,
+            true,
+            false,
+            false,
+        )?;
     }
     maintain_coordination_relays(&mut swarm, &mut coordination_relays, false, Instant::now());
 
@@ -439,6 +481,9 @@ async fn run_endpoint_async(
     let mut external_candidate_ready = startup_external_candidate_ready;
     let mut deadline_tick = tokio::time::interval(Duration::from_millis(100));
     deadline_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+    let mut discovered_relays = options
+        .automatic_relay_discovery
+        .then(relay_discovery::spawn);
 
     loop {
         tokio::select! {
@@ -549,6 +594,34 @@ async fn run_endpoint_async(
                     // Dropping the stream applies bounded backpressure to Mesh control callers.
                 }
             }
+            Some(candidate) = async {
+                match &mut discovered_relays {
+                    Some(receiver) => receiver.recv().await,
+                    None => std::future::pending().await,
+                }
+            } => {
+                discovery_debug(format_args!("candidate {}", candidate.peer_id));
+                register_automatic_relay_candidate(
+                    &mut coordination_relays,
+                    candidate,
+                    local_peer_id,
+                );
+                rebalance_automatic_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    Instant::now(),
+                );
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
+                );
+                maintain_coordination_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    external_candidate_ready,
+                    Instant::now(),
+                );
+            }
             Some(completed) = stream_completed_rx.recv() => {
                 match completed.kind {
                     StreamCompletion::Application(connection_id) => {
@@ -644,6 +717,16 @@ async fn run_endpoint_async(
                     &mut coordination_relays,
                     &mut direct,
                     &mut external_candidate_ready,
+                    &active_coordination_relays,
+                );
+                rebalance_automatic_relays(
+                    &mut swarm,
+                    &mut coordination_relays,
+                    Instant::now(),
+                );
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
                 );
                 maintain_coordination_relays(
                     &mut swarm,
@@ -665,6 +748,11 @@ async fn run_endpoint_async(
             }
             _ = deadline_tick.tick() => {
                 let now = Instant::now();
+                rebalance_automatic_relays(&mut swarm, &mut coordination_relays, now);
+                publish_active_coordination_relays(
+                    &coordination_relays,
+                    &active_coordination_relays,
+                );
                 maintain_coordination_relays(
                     &mut swarm,
                     &mut coordination_relays,
@@ -831,6 +919,7 @@ fn start_connect(
             local_peer_id,
             false,
             referenced.insert(relay_peer),
+            false,
         )?;
     }
     maintain_coordination_relays(swarm, coordination_relays, false, Instant::now());
@@ -883,6 +972,7 @@ fn handle_swarm_event(
     coordination_relays: &mut HashMap<PeerId, CoordinationRelay>,
     direct: &mut DirectConnectState,
     external_candidate_ready: &mut bool,
+    active_coordination_relays: &Arc<RwLock<Vec<Multiaddr>>>,
 ) {
     match event {
         SwarmEvent::ConnectionEstablished {
@@ -926,12 +1016,27 @@ fn handle_swarm_event(
             if let Some(relay) = coordination_relays.get_mut(&peer_id) {
                 relay.connections.remove(&connection_id);
             }
+            let mut reservation_changed = false;
+            let mut discard_automatic = false;
             if !swarm.is_connected(&peer_id)
                 && let Some(relay) = coordination_relays.get_mut(&peer_id)
                 && relay.is_active()
-                && let Some(listener) = relay.connection_lost(Instant::now())
             {
-                swarm.remove_listener(listener);
+                let was_accepted = relay.reservation_accepted;
+                discovery_debug(format_args!(
+                    "connection to {peer_id} closed; reservation accepted={was_accepted}"
+                ));
+                if let Some(listener) = relay.connection_lost(Instant::now()) {
+                    swarm.remove_listener(listener);
+                }
+                reservation_changed = true;
+                discard_automatic = relay.automatic && !was_accepted;
+            }
+            if discard_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
+            if reservation_changed {
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
             }
         }
         SwarmEvent::OutgoingConnectionError { connection_id, .. } => {
@@ -939,12 +1044,19 @@ fn handle_swarm_event(
             for connect in direct.pending.values_mut() {
                 connect.dials.remove(&connection_id);
             }
-            for relay in coordination_relays.values_mut() {
+            let mut failed_automatic = None;
+            for (peer_id, relay) in coordination_relays.iter_mut() {
                 if relay.pending_connection == Some(connection_id) {
                     relay.pending_connection = None;
+                    if relay.automatic && !relay.reservation_accepted {
+                        failed_automatic = Some(*peer_id);
+                    }
                     break;
                 }
             }
+            if let Some(peer_id) = failed_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
         }
         SwarmEvent::Behaviour(BehaviourEvent::Dcutr(dcutr::Event {
             remote_peer_id,
@@ -958,6 +1070,31 @@ fn handle_swarm_event(
             }
         }
         SwarmEvent::Behaviour(BehaviourEvent::Dcutr(_)) => {}
+        SwarmEvent::Behaviour(BehaviourEvent::RelayClient(
+            relay::client::Event::ReservationReqAccepted { relay_peer_id, .. },
+        )) => {
+            discovery_debug(format_args!("reservation accepted by {relay_peer_id}"));
+            if let Some(relay) = coordination_relays.get_mut(&relay_peer_id) {
+                relay.reservation_accepted = true;
+            }
+            publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+        }
+        SwarmEvent::NewListenAddr {
+            listener_id,
+            address,
+        } if is_relayed_address(&address) => {
+            if let Some(base_address) = reservation_base_address(address) {
+                for relay in coordination_relays.values_mut() {
+                    if relay.reservation_listener == Some(listener_id) {
+                        if !relay.reservation_addresses.contains(&base_address) {
+                            relay.reservation_addresses.push(base_address);
+                        }
+                        break;
+                    }
+                }
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+            }
+        }
         SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received {
             peer_id,
             ..
@@ -999,12 +1136,32 @@ fn handle_swarm_event(
                 );
             }
         }
-        SwarmEvent::ListenerClosed { listener_id, .. } => {
-            for relay in coordination_relays.values_mut() {
+        SwarmEvent::ListenerClosed {
+            listener_id,
+            reason,
+            ..
+        } => {
+            discovery_debug(format_args!(
+                "reservation listener {listener_id:?} closed: {reason:?}"
+            ));
+            let mut changed = false;
+            let mut rejected_automatic = None;
+            for (peer_id, relay) in coordination_relays.iter_mut() {
+                let was_accepted = relay.reservation_accepted;
                 if relay.listener_closed(listener_id, Instant::now()) {
+                    if relay.automatic && !was_accepted {
+                        rejected_automatic = Some(*peer_id);
+                    }
+                    changed = true;
                     break;
                 }
             }
+            if let Some(peer_id) = rejected_automatic {
+                discard_automatic_relay_candidate(swarm, coordination_relays, peer_id);
+            }
+            if changed {
+                publish_active_coordination_relays(coordination_relays, active_coordination_relays);
+            }
         }
         _ => {}
     }
@@ -1023,6 +1180,7 @@ fn handle_startup_event(
         coordination_relays,
         &mut DirectConnectState::default(),
         external_candidate_ready,
+        &Arc::new(RwLock::new(Vec::new())),
     );
 }
 
@@ -1032,6 +1190,7 @@ fn register_coordination_relay(
     local_peer_id: PeerId,
     reserve: bool,
     add_client_reference: bool,
+    automatic: bool,
 ) -> Result<(), PeerError> {
     let relay_peer = coordination_relay_peer_id(address)?;
     if relay_peer == local_peer_id {
@@ -1041,6 +1200,11 @@ fn register_coordination_relay(
         ));
     }
     let relay = relays.entry(relay_peer).or_default();
+    if automatic && !relay.reserve {
+        relay.automatic = true;
+    } else if reserve {
+        relay.automatic = false;
+    }
     relay.reserve |= reserve;
     if add_client_reference {
         relay.client_references += 1;
@@ -1051,6 +1215,204 @@ fn register_coordination_relay(
     Ok(())
 }
 
+fn register_automatic_relay_candidate(
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    candidate: relay_discovery::RelayCandidate,
+    local_peer_id: PeerId,
+) {
+    if candidate.peer_id == local_peer_id {
+        return;
+    }
+    if !relays.contains_key(&candidate.peer_id)
+        && relays.values().filter(|relay| relay.automatic).count() >= MAX_AUTOMATIC_RELAY_CANDIDATES
+    {
+        return;
+    }
+    for address in candidate.addresses {
+        let _ = register_coordination_relay(relays, &address, local_peer_id, false, false, true);
+    }
+}
+
+fn rebalance_automatic_relays(
+    swarm: &mut Swarm<Behaviour>,
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    now: Instant,
+) {
+    let manual_reservations = relays
+        .values()
+        .filter(|relay| !relay.automatic && relay.reservation_accepted)
+        .count();
+    let desired = TARGET_COORDINATION_RESERVATIONS.saturating_sub(manual_reservations);
+    let mut automatic = relays
+        .iter()
+        .filter(|(_, relay)| relay.automatic)
+        .map(|(peer_id, relay)| (*peer_id, relay.reservation_accepted, relay.reserve))
+        .collect::<Vec<_>>();
+    automatic.sort_unstable_by_key(|(peer_id, accepted, reserved)| {
+        (!*accepted, !*reserved, peer_id.to_string())
+    });
+
+    let mut selected = 0;
+    for (peer_id, accepted, _) in automatic {
+        let relay = relays
+            .get_mut(&peer_id)
+            .expect("automatic relay was collected from the same map");
+        let should_reserve = selected < desired
+            && (accepted
+                || relay.reservation_listener.is_some()
+                || relay.next_reservation_attempt <= now);
+        if should_reserve {
+            relay.reserve = true;
+            selected += 1;
+            continue;
+        }
+        relay.reserve = false;
+        relay.reservation_accepted = false;
+        relay.reservation_addresses.clear();
+        if let Some(listener) = relay.reservation_listener.take() {
+            discovery_debug(format_args!(
+                "removing reservation listener for deselected candidate {peer_id}"
+            ));
+            swarm.remove_listener(listener);
+        }
+        if relay.client_references == 0 {
+            for connection_id in relay.connections.drain() {
+                let _ = swarm.close_connection(connection_id);
+            }
+        }
+    }
+}
+
+fn publish_active_coordination_relays(
+    relays: &HashMap<PeerId, CoordinationRelay>,
+    snapshot: &Arc<RwLock<Vec<Multiaddr>>>,
+) {
+    let mut addresses = relays
+        .values()
+        .filter(|relay| relay.reservation_accepted)
+        .flat_map(|relay| published_reservation_addresses(&relay.reservation_addresses))
+        .collect::<Vec<_>>();
+    addresses.sort_unstable_by_key(ToString::to_string);
+    addresses.dedup();
+    if let Ok(mut current) = snapshot.write() {
+        *current = addresses;
+    }
+}
+
+fn published_reservation_addresses(addresses: &[Multiaddr]) -> Vec<Multiaddr> {
+    let mut addresses = addresses.to_vec();
+    addresses.sort_unstable_by_key(|address| (relay_route_class(address), address.to_string()));
+    let mut classes = HashSet::new();
+    addresses
+        .into_iter()
+        .filter(|address| classes.insert(relay_route_class(address)))
+        .take(MAX_PUBLISHED_ADDRESSES_PER_RELAY)
+        .collect()
+}
+
+fn relay_route_class(address: &Multiaddr) -> (u8, u8) {
+    let mut protocols = address.iter();
+    let host = match protocols.next() {
+        Some(Protocol::Ip4(_)) => 0,
+        Some(Protocol::Ip6(_)) => 1,
+        Some(Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_)) => 2,
+        _ => 3,
+    };
+    let transport = match protocols.next() {
+        Some(Protocol::Udp(_)) => 0,
+        Some(Protocol::Tcp(_)) => 1,
+        _ => 2,
+    };
+    (host, transport)
+}
+
+fn reservation_base_address(mut address: Multiaddr) -> Option<Multiaddr> {
+    if !matches!(address.pop(), Some(Protocol::P2p(_)))
+        || !matches!(address.pop(), Some(Protocol::P2pCircuit))
+        || coordination_relay_peer_id(&address).is_err()
+        || !supported_public_relay_address(&address)
+    {
+        return None;
+    }
+    Some(address)
+}
+
+fn supported_public_relay_address(address: &Multiaddr) -> bool {
+    let mut protocols = address.iter();
+    let host_supported = match protocols.next() {
+        Some(Protocol::Ip4(address)) => public_ipv4(address),
+        Some(Protocol::Ip6(address)) => public_ipv6(address),
+        Some(Protocol::Dns(_) | Protocol::Dns4(_) | Protocol::Dns6(_)) => true,
+        _ => false,
+    };
+    if !host_supported {
+        return false;
+    }
+    match protocols.next() {
+        Some(Protocol::Tcp(_)) => {
+            matches!(protocols.next(), Some(Protocol::P2p(_))) && protocols.next().is_none()
+        }
+        Some(Protocol::Udp(_)) => {
+            matches!(protocols.next(), Some(Protocol::QuicV1))
+                && matches!(protocols.next(), Some(Protocol::P2p(_)))
+                && protocols.next().is_none()
+        }
+        _ => false,
+    }
+}
+
+fn public_ipv4(address: std::net::Ipv4Addr) -> bool {
+    let [first, second, third, _] = address.octets();
+    !(first == 0
+        || first == 10
+        || first == 127
+        || first >= 224
+        || (first == 100 && (64..=127).contains(&second))
+        || (first == 169 && second == 254)
+        || (first == 172 && (16..=31).contains(&second))
+        || (first == 192 && second == 0 && third == 0)
+        || (first == 192 && second == 0 && third == 2)
+        || (first == 192 && second == 168)
+        || (first == 198 && (second == 18 || second == 19))
+        || (first == 198 && second == 51 && third == 100)
+        || (first == 203 && second == 0 && third == 113))
+}
+
+fn public_ipv6(address: std::net::Ipv6Addr) -> bool {
+    if let Some(address) = address.to_ipv4_mapped() {
+        return public_ipv4(address);
+    }
+    let segments = address.segments();
+    !address.is_unspecified()
+        && !address.is_loopback()
+        && !address.is_multicast()
+        && segments[0] & 0xfe00 != 0xfc00
+        && segments[0] & 0xffc0 != 0xfe80
+        && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
+}
+
+fn discard_automatic_relay_candidate(
+    swarm: &mut Swarm<Behaviour>,
+    relays: &mut HashMap<PeerId, CoordinationRelay>,
+    peer_id: PeerId,
+) {
+    let Some(relay) = relays.get(&peer_id) else {
+        return;
+    };
+    if !relay.automatic || relay.reservation_accepted || relay.client_references > 0 {
+        return;
+    }
+    let mut relay = relays
+        .remove(&peer_id)
+        .expect("automatic relay candidate was read from the same map");
+    if let Some(listener) = relay.reservation_listener.take() {
+        swarm.remove_listener(listener);
+    }
+    for connection_id in relay.connections.drain() {
+        let _ = swarm.close_connection(connection_id);
+    }
+}
+
 fn release_coordination_relays(
     swarm: &mut Swarm<Behaviour>,
     relays: &mut HashMap<PeerId, CoordinationRelay>,
@@ -1141,13 +1503,27 @@ fn request_coordination_reservation(
     }
     relay.next_reservation_attempt = now + COORDINATION_RETRY_INTERVAL;
     for address in &relay.addresses {
-        if let Ok(listener) = swarm.listen_on(address.clone().with(Protocol::P2pCircuit)) {
-            relay.reservation_listener = Some(listener);
-            break;
+        match swarm.listen_on(address.clone().with(Protocol::P2pCircuit)) {
+            Ok(listener) => {
+                discovery_debug(format_args!("requesting reservation from {peer_id}"));
+                relay.reservation_listener = Some(listener);
+                break;
+            }
+            Err(error) => {
+                discovery_debug(format_args!(
+                    "reservation request for {peer_id} failed: {error}"
+                ));
+            }
         }
     }
 }
 
+fn discovery_debug(message: std::fmt::Arguments<'_>) {
+    if std::env::var_os("MAKA_PEER_DISCOVERY_DEBUG").is_some() {
+        eprintln!("[peer-relay-pool] {message}");
+    }
+}
+
 fn retry_connect_routes(
     swarm: &mut Swarm<Behaviour>,
     direct: &mut DirectConnectState,
@@ -1366,6 +1742,7 @@ mod tests {
                     .expect("test listen address"),
             ],
             coordination_relays: Vec::new(),
+            automatic_relay_discovery: false,
         }
     }
 
@@ -1448,6 +1825,12 @@ mod tests {
         let mut relay = CoordinationRelay {
             identify_received: true,
             identify_sent: true,
+            reservation_accepted: true,
+            reservation_addresses: vec![
+                "/ip4/192.0.2.1/tcp/4001"
+                    .parse()
+                    .expect("valid reservation address"),
+            ],
             reservation_listener: Some(listener),
             next_connection_attempt: now + Duration::from_secs(30),
             next_reservation_attempt: now + Duration::from_secs(30),
@@ -1456,6 +1839,8 @@ mod tests {
 
         assert!(relay.listener_closed(listener, now));
         assert!(relay.reservation_listener.is_none());
+        assert!(!relay.reservation_accepted);
+        assert!(relay.reservation_addresses.is_empty());
 
         relay.reservation_listener = Some(listener);
         assert_eq!(relay.connection_lost(now), Some(listener));
@@ -1464,6 +1849,43 @@ mod tests {
         assert_eq!(relay.next_connection_attempt, now);
     }
 
+    #[test]
+    fn active_coordination_routes_only_publish_accepted_reservations() {
+        let accepted_peer = PeerId::random();
+        let pending_peer = PeerId::random();
+        let accepted_address: Multiaddr = format!("/ip4/192.0.2.1/tcp/4001/p2p/{accepted_peer}")
+            .parse()
+            .expect("valid accepted relay address");
+        let pending_address: Multiaddr = format!("/ip4/192.0.2.2/tcp/4001/p2p/{pending_peer}")
+            .parse()
+            .expect("valid pending relay address");
+        let relays = HashMap::from([
+            (
+                accepted_peer,
+                CoordinationRelay {
+                    reservation_accepted: true,
+                    reservation_addresses: vec![accepted_address.clone()],
+                    ..CoordinationRelay::default()
+                },
+            ),
+            (
+                pending_peer,
+                CoordinationRelay {
+                    reservation_addresses: vec![pending_address],
+                    ..CoordinationRelay::default()
+                },
+            ),
+        ]);
+        let snapshot = Arc::new(RwLock::new(Vec::new()));
+
+        publish_active_coordination_relays(&relays, &snapshot);
+
+        assert_eq!(
+            *snapshot.read().expect("read snapshot"),
+            vec![accepted_address]
+        );
+    }
+
     #[test]
     fn coordination_relay_requires_one_terminal_peer_identity() {
         let relay = PeerId::random();
@@ -1481,4 +1903,26 @@ mod tests {
             .expect("valid relayed address");
         assert!(coordination_relay_peer_id(&tunneled).is_err());
     }
+
+    #[test]
+    fn automatic_relay_routes_only_publish_supported_public_transports() {
+        let relay = PeerId::random();
+        let public_quic: Multiaddr = format!("/ip4/1.1.1.1/udp/4001/quic-v1/p2p/{relay}")
+            .parse()
+            .expect("valid public QUIC address");
+        let private_tcp: Multiaddr = format!("/ip4/192.168.1.2/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid private TCP address");
+        let unsupported_websocket: Multiaddr = format!("/ip4/1.1.1.1/tcp/4001/ws/p2p/{relay}")
+            .parse()
+            .expect("valid WebSocket address");
+        let mapped_private: Multiaddr = format!("/ip6/::ffff:127.0.0.1/tcp/4001/p2p/{relay}")
+            .parse()
+            .expect("valid IPv4-mapped private address");
+
+        assert!(supported_public_relay_address(&public_quic));
+        assert!(!supported_public_relay_address(&private_tcp));
+        assert!(!supported_public_relay_address(&unsupported_websocket));
+        assert!(!supported_public_relay_address(&mapped_private));
+    }
 }
diff --git a/native/runtime-host-peer/src/engine/relay_discovery.rs b/native/runtime-host-peer/src/engine/relay_discovery.rs
new file mode 100644
index 000000000..bd6d75e2e
--- /dev/null
+++ b/native/runtime-host-peer/src/engine/relay_discovery.rs
@@ -0,0 +1,337 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use std::{
+    collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
+    time::{Duration, Instant},
+};
+
+use futures::StreamExt;
+use libp2p::{
+    Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder, connection_limits, identify, identity,
+    kad::{self, store::MemoryStore},
+    noise, ping,
+    swarm::{NetworkBehaviour, SwarmEvent, dial_opts::DialOpts},
+    tcp, yamux,
+};
+use tokio::sync::mpsc;
+
+use super::{address::address_with_peer, supported_public_relay_address};
+
+const AMINO_PROTOCOL: &str = "/ipfs/kad/1.0.0";
+const IDENTIFY_PROTOCOL: &str = "/maka/runtime-host/relay-discovery/1";
+const RELAY_HOP_PROTOCOL: &str = "/libp2p/circuit/relay/0.2.0/hop";
+const DISCOVERY_INTERVAL: Duration = Duration::from_secs(30);
+const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
+const MAX_PENDING_PROBES: usize = 8;
+const MAX_KNOWN_PEERS: usize = 256;
+const PROBE_REVISIT_INTERVAL: Duration = Duration::from_secs(10 * 60);
+
+const AMINO_BOOTSTRAPS: &[&str] = &[
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
+    "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
+    "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
+    "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
+    "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
+];
+
+#[derive(Debug)]
+pub(super) struct RelayCandidate {
+    pub peer_id: PeerId,
+    pub addresses: Vec<Multiaddr>,
+}
+
+#[derive(NetworkBehaviour)]
+struct Behaviour {
+    connection_limits: connection_limits::Behaviour,
+    kad: kad::Behaviour<MemoryStore>,
+    identify: identify::Behaviour,
+    ping: ping::Behaviour,
+}
+
+pub(super) fn spawn() -> mpsc::Receiver<RelayCandidate> {
+    let (sender, receiver) = mpsc::channel(32);
+    tokio::spawn(async move {
+        match build_swarm() {
+            Ok(mut swarm) => run(&mut swarm, sender).await,
+            Err(error) => eprintln!("[peer-relay-discovery] unavailable: {error}"),
+        }
+    });
+    receiver
+}
+
+fn build_swarm() -> Result<Swarm<Behaviour>, Box<dyn std::error::Error + Send + Sync>> {
+    let key = identity::Keypair::generate_ed25519();
+    let local_peer_id = key.public().to_peer_id();
+    let swarm = SwarmBuilder::with_existing_identity(key)
+        .with_tokio()
+        .with_tcp(
+            tcp::Config::default().nodelay(true),
+            noise::Config::new,
+            yamux::Config::default,
+        )?
+        .with_quic()
+        .with_dns()?
+        .with_behaviour(move |key| {
+            let mut config = kad::Config::new(StreamProtocol::new(AMINO_PROTOCOL));
+            config.set_query_timeout(Duration::from_secs(15));
+            let mut kad =
+                kad::Behaviour::with_config(local_peer_id, MemoryStore::new(local_peer_id), config);
+            kad.set_mode(Some(kad::Mode::Client));
+            Behaviour {
+                connection_limits: connection_limits::Behaviour::new(
+                    connection_limits::ConnectionLimits::default()
+                        .with_max_pending_outgoing(Some(MAX_PENDING_PROBES as u32))
+                        .with_max_established_outgoing(Some(32))
+                        .with_max_established(Some(32))
+                        .with_max_established_per_peer(Some(1)),
+                ),
+                kad,
+                identify: identify::Behaviour::new(identify::Config::new(
+                    IDENTIFY_PROTOCOL.to_owned(),
+                    key.public(),
+                )),
+                ping: ping::Behaviour::new(ping::Config::new()),
+            }
+        })?
+        .with_swarm_config(|config| config.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
+        .build();
+    Ok(swarm)
+}
+
+async fn run(swarm: &mut Swarm<Behaviour>, sender: mpsc::Sender<RelayCandidate>) {
+    let mut addresses = HashMap::<PeerId, Vec<Multiaddr>>::new();
+    let mut known = HashMap::<PeerId, Instant>::new();
+    let mut pending = HashSet::<PeerId>::new();
+    let mut queue = VecDeque::<PeerId>::new();
+
+    for value in AMINO_BOOTSTRAPS {
+        let Ok(address) = value.parse::<Multiaddr>() else {
+            continue;
+        };
+        let Some(peer_id) = terminal_peer_id(&address) else {
+            continue;
+        };
+        remember(
+            peer_id,
+            vec![address],
+            &mut addresses,
+            &mut known,
+            &mut queue,
+            Instant::now(),
+        );
+        for address in addresses.get(&peer_id).into_iter().flatten() {
+            swarm
+                .behaviour_mut()
+                .kad
+                .add_address(&peer_id, address.clone());
+        }
+    }
+    let _ = swarm.behaviour_mut().kad.bootstrap();
+
+    let mut walk = tokio::time::interval(DISCOVERY_INTERVAL);
+    walk.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+    loop {
+        start_probes(swarm, &addresses, &mut queue, &mut pending);
+        tokio::select! {
+            _ = walk.tick() => {
+                expire_known_peers(
+                    &mut addresses,
+                    &mut known,
+                    &pending,
+                    Instant::now(),
+                );
+                swarm.behaviour_mut().kad.get_closest_peers(PeerId::random().to_bytes());
+            }
+            event = swarm.select_next_some() => match event {
+                SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::OutboundQueryProgressed {
+                    result: kad::QueryResult::GetClosestPeers(result),
+                    ..
+                })) => {
+                    let peers = match result {
+                        Ok(result) => result.peers,
+                        Err(kad::GetClosestPeersError::Timeout { peers, .. }) => peers,
+                    };
+                    debug(format_args!("DHT walk returned {} peers", peers.len()));
+                    for peer in peers {
+                        remember(
+                            peer.peer_id,
+                            peer.addrs,
+                            &mut addresses,
+                            &mut known,
+                            &mut queue,
+                            Instant::now(),
+                        );
+                    }
+                }
+                SwarmEvent::Behaviour(BehaviourEvent::Kad(kad::Event::RoutingUpdated {
+                    peer,
+                    addresses: discovered,
+                    ..
+                })) => {
+                    remember(
+                        peer,
+                        discovered.into_vec(),
+                        &mut addresses,
+                        &mut known,
+                        &mut queue,
+                        Instant::now(),
+                    );
+                }
+                SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Received {
+                    peer_id,
+                    info,
+                    ..
+                })) => {
+                    pending.remove(&peer_id);
+                    let mut candidate_addresses = info.listen_addrs;
+                    if let Some(discovered) = addresses.get(&peer_id) {
+                        candidate_addresses.extend(discovered.iter().cloned());
+                    }
+                    candidate_addresses = canonical_addresses(peer_id, candidate_addresses)
+                        .into_iter()
+                        .filter(has_literal_ip_host)
+                        .collect();
+                    debug(format_args!(
+                        "identified {peer_id}; relay={} addresses={}",
+                        info.protocols.iter().any(|protocol| protocol.as_ref() == RELAY_HOP_PROTOCOL),
+                        candidate_addresses.len(),
+                    ));
+                    if info.protocols.iter().any(|protocol| protocol.as_ref() == RELAY_HOP_PROTOCOL)
+                        && !candidate_addresses.is_empty()
+                        && sender.send(RelayCandidate {
+                            peer_id,
+                            addresses: candidate_addresses,
+                        }).await.is_err()
+                    {
+                        return;
+                    }
+                }
+                SwarmEvent::OutgoingConnectionError { peer_id: Some(peer_id), error, .. } => {
+                    debug(format_args!("dial to {peer_id} failed: {error}"));
+                    pending.remove(&peer_id);
+                }
+                SwarmEvent::ConnectionClosed { peer_id, .. } => {
+                    pending.remove(&peer_id);
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+fn debug(message: std::fmt::Arguments<'_>) {
+    if std::env::var_os("MAKA_PEER_DISCOVERY_DEBUG").is_some() {
+        eprintln!("[peer-relay-discovery] {message}");
+    }
+}
+
+fn remember(
+    peer_id: PeerId,
+    discovered: Vec<Multiaddr>,
+    addresses: &mut HashMap<PeerId, Vec<Multiaddr>>,
+    known: &mut HashMap<PeerId, Instant>,
+    queue: &mut VecDeque<PeerId>,
+    now: Instant,
+) {
+    if !known.contains_key(&peer_id) && known.len() >= MAX_KNOWN_PEERS {
+        return;
+    }
+    let discovered = canonical_addresses(peer_id, discovered);
+    if discovered.is_empty() {
+        return;
+    }
+    let entry = addresses.entry(peer_id).or_default();
+    for address in discovered {
+        if !entry.contains(&address) {
+            entry.push(address);
+        }
+    }
+    if let Entry::Vacant(entry) = known.entry(peer_id) {
+        entry.insert(now);
+        queue.push_back(peer_id);
+    }
+}
+
+fn expire_known_peers(
+    addresses: &mut HashMap<PeerId, Vec<Multiaddr>>,
+    known: &mut HashMap<PeerId, Instant>,
+    pending: &HashSet<PeerId>,
+    now: Instant,
+) {
+    known.retain(|peer_id, first_seen| {
+        pending.contains(peer_id) || now.duration_since(*first_seen) < PROBE_REVISIT_INTERVAL
+    });
+    addresses.retain(|peer_id, _| known.contains_key(peer_id));
+}
+
+fn start_probes(
+    swarm: &mut Swarm<Behaviour>,
+    addresses: &HashMap<PeerId, Vec<Multiaddr>>,
+    queue: &mut VecDeque<PeerId>,
+    pending: &mut HashSet<PeerId>,
+) {
+    while pending.len() < MAX_PENDING_PROBES {
+        let Some(peer_id) = queue.pop_front() else {
+            break;
+        };
+        let Some(addresses) = addresses.get(&peer_id) else {
+            continue;
+        };
+        let options = DialOpts::peer_id(peer_id)
+            .addresses(addresses.clone())
+            .build();
+        if swarm.dial(options).is_ok() {
+            pending.insert(peer_id);
+        }
+    }
+}
+
+fn canonical_addresses(peer_id: PeerId, addresses: Vec<Multiaddr>) -> Vec<Multiaddr> {
+    let mut result = Vec::new();
+    for address in addresses {
+        if address
+            .iter()
+            .any(|protocol| matches!(protocol, libp2p::multiaddr::Protocol::P2pCircuit))
+        {
+            continue;
+        }
+        let address = address_with_peer(address, peer_id);
+        if supported_public_relay_address(&address) && !result.contains(&address) {
+            result.push(address);
+        }
+    }
+    result
+}
+
+fn terminal_peer_id(address: &Multiaddr) -> Option<PeerId> {
+    match address.iter().last()? {
+        libp2p::multiaddr::Protocol::P2p(peer_id) => Some(peer_id),
+        _ => None,
+    }
+}
+
+fn has_literal_ip_host(address: &Multiaddr) -> bool {
+    matches!(
+        address.iter().next(),
+        Some(libp2p::multiaddr::Protocol::Ip4(_) | libp2p::multiaddr::Protocol::Ip6(_))
+    )
+}
diff --git a/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv b/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
index b5b200eea..16be61345 100644
--- a/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
+++ b/packages/cli/RUNTIME_HOST_PEER_DEPENDENCIES.rust.tsv
@@ -4,6 +4,7 @@ arrayref@0.3.9		X
 asn1-rs@0.7.2	X				X			
 asn1-rs-derive@0.6.0	X				X			
 asn1-rs-impl@0.2.0	X				X			
+asn1_der@0.7.7		X			X			
 async-trait@0.1.92	X				X			
 asynchronous-codec@0.7.0					X			
 autocfg@1.5.1	X				X			
@@ -30,6 +31,7 @@ critical-section@1.2.0	X				X
 crossbeam-channel@0.5.16	X				X			
 crossbeam-epoch@0.9.20	X				X			
 crossbeam-utils@0.8.22	X				X			
+crunchy@0.2.4					X			
 crypto-common@0.1.7	X				X			
 ctor@1.0.13	X				X			
 curve25519-dalek@4.1.3			X					
@@ -71,6 +73,7 @@ getrandom@0.4.3	X				X
 hashbrown@0.15.5	X				X			
 hashlink@0.10.0	X				X			
 heck@0.5.0	X				X			
+hex@0.4.3	X				X			
 hickory-proto@0.25.2	X				X			
 hickory-resolver@0.25.2	X				X			
 hkdf@0.12.4	X				X			
@@ -101,6 +104,7 @@ libp2p-dcutr@0.14.1					X
 libp2p-dns@0.44.0					X			
 libp2p-identify@0.47.0					X			
 libp2p-identity@0.2.14					X			
+libp2p-kad@0.48.0					X			
 libp2p-noise@0.46.1					X			
 libp2p-ping@0.47.0					X			
 libp2p-quic@0.13.1					X			
@@ -221,6 +225,7 @@ tracing@0.1.44					X
 tracing-attributes@0.1.31					X			
 tracing-core@0.1.36					X			
 typenum@1.20.1	X				X			
+uint@0.10.1	X				X			
 unicode-ident@1.0.24	X				X	X		
 unicode-segmentation@1.13.3	X				X			
 unsigned-varint@0.7.2					X			
diff --git a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
index b6cab8d19..3af1c6add 100644
--- a/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
+++ b/packages/cli/RUNTIME_HOST_PEER_THIRD_PARTY_NOTICES.txt
@@ -5,8 +5,8 @@ Generated by scripts/generate-runtime-host-peer-notices.mjs from the exact
 four-target production dependency inventory. Do not edit this file by hand.
 
 Manifest: native/runtime-host-peer/Cargo.toml
-Cargo.lock SHA-256: 07524c9f4eac723344e6fd8746a2f7cfed692feaed1785ecbfd45e0ecbe20981
-Inventory SHA-256: eb4b2941cf0ed87153aff3ac5d32697dde3ba105e388ff96b9584c65882834f1
+Cargo.lock SHA-256: f475d62519bef1d83c2e33b17f1b1eeffe7a03370b041dabea887b7f57aa43ff
+Inventory SHA-256: 4078d1a287187fbfe21e4937305dabf6e81afac6f2f45388b47a04f311cd8223
 
 Packages
 --------
@@ -44,6 +44,12 @@ SPDX license: MIT/Apache-2.0
 Source: https://github.com/rusticata/asn1-rs.git
 License text: 59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018 (Apache-2.0.txt)
 
+asn1_der@0.7.7
+--------------
+SPDX license: BSD-2-Clause OR MIT
+Source: https://github.com/KizzyCode/asn1_der-rust
+License text: b70b1cc58b6d09afc7bfba35c45cc8613d121563acc5b9cbc519ae3b9a10af8d (MIT.txt)
+
 async-trait@0.1.92
 ------------------
 SPDX license: MIT OR Apache-2.0
@@ -219,6 +225,12 @@ Source: https://github.com/crossbeam-rs/crossbeam
 License text: 769f80b5bcb42ed0af4e4d2fd74e1ac9bf843cb80c5a29219d1ef3544428a6bb (LICENSE-APACHE)
 License text: 5a7d13c6710cdec29e74eb3172b80712049c34099ab7259661ada051fc90777b (LICENSE-MIT)
 
+crunchy@0.2.4
+-------------
+SPDX license: MIT
+Source: https://github.com/eira-fransham/crunchy
+License text: e35e8f8a37b546c01c7eb82d11d7554bb6292a29fc6147c21765d1e9c40b2bae (LICENSE)
+
 crypto-common@0.1.7
 -------------------
 SPDX license: MIT OR Apache-2.0
@@ -497,6 +509,13 @@ Source: https://github.com/withoutboats/heck
 License text: 769f80b5bcb42ed0af4e4d2fd74e1ac9bf843cb80c5a29219d1ef3544428a6bb (LICENSE-APACHE)
 License text: 033a9383ff21d1e4811d98dd2e0081b0ba0d9c9a31d6c7ba9e63e3488479bf29 (LICENSE-MIT)
 
+hex@0.4.3
+---------
+SPDX license: MIT OR Apache-2.0
+Source: https://github.com/KokaKiwi/rust-hex
+License text: c5accbbd8546e94c34aed24afe689a617627d18eed5a6c48277e48db57c23851 (LICENSE-APACHE)
+License text: 9dde4a6ad496606322ae0b61b70dc7123fa231be543caa0896d567e34d20fbf8 (LICENSE-MIT)
+
 hickory-proto@0.25.2
 --------------------
 SPDX license: MIT OR Apache-2.0
@@ -689,6 +708,12 @@ SPDX license: MIT
 Source: https://github.com/libp2p/rust-libp2p
 License text: 80f847ab8b3d1802dcf33aad57b13d3e5cbae4969fb5990a5ff24ac7ae98ddb6 (MIT.txt)
 
+libp2p-kad@0.48.0
+-----------------
+SPDX license: MIT
+Source: https://github.com/libp2p/rust-libp2p
+License text: 4104e268c110e0948f82580e90eeb8a4106c388c4c0f6a1b7ed2e4dd1360872a (MIT.txt)
+
 libp2p-noise@0.46.1
 -------------------
 SPDX license: MIT
@@ -1483,6 +1508,12 @@ License text: db11fec9946737df39ca3898d9cd8c10ec6f6c3a884a6802b0ad0b81b4e8f23a (
 License text: 516b24e051bf5630880ebbd55c40a25ce9552ebaf8970a53e8976eb70e522406 (LICENSE-APACHE)
 License text: 87ebb37988efc3285a41635c6a9fd38147e1e581b5f0556a284d5ab8fa5cdd4c (LICENSE-MIT)
 
+uint@0.10.1
+-----------
+SPDX license: MIT OR Apache-2.0
+Source: https://github.com/paritytech/parity-common
+License text: 59013a5c8d3a19c26a457579105915a5d51bb0c09d579f8cdedf12e4203c3018 (Apache-2.0.txt)
+
 unicode-ident@1.0.24
 --------------------
 SPDX license: (MIT OR Apache-2.0) AND Unicode-3.0
@@ -3831,6 +3862,7 @@ Used by:
 - libp2p-core@0.43.2 (MIT.txt)
 - libp2p-dns@0.44.0 (MIT.txt)
 - libp2p-identify@0.47.0 (MIT.txt)
+- libp2p-kad@0.48.0 (MIT.txt)
 - libp2p-noise@0.46.1 (MIT.txt)
 - libp2p-ping@0.47.0 (MIT.txt)
 - libp2p-quic@0.13.1 (MIT.txt)
@@ -5215,6 +5247,7 @@ Used by:
 - if-watch@3.2.2 (Apache-2.0.txt)
 - sha2@0.10.9 (LICENSE-APACHE)
 - signature@2.2.0 (LICENSE-APACHE)
+- uint@0.10.1 (Apache-2.0.txt)
 - yamux@0.12.1 (Apache-2.0.txt)
 - yamux@0.13.10 (Apache-2.0.txt)
 - yasna@0.5.2 (Apache-2.0.txt)
@@ -9450,6 +9483,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+9dde4a6ad496606322ae0b61b70dc7123fa231be543caa0896d567e34d20fbf8
+----------------------------------------------------------------
+Used by:
+- hex@0.4.3 (LICENSE-MIT)
+
+Copyright (c) 2013-2014 The Rust Project Developers.
+Copyright (c) 2015-2020 The rust-hex Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 9e0027807b0c548ac1df1bd85fe0d72da6665498b06ebf8a64d5cb841f39fcef
 ----------------------------------------------------------------
 Used by:
@@ -9998,6 +10057,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+b70b1cc58b6d09afc7bfba35c45cc8613d121563acc5b9cbc519ae3b9a10af8d
+----------------------------------------------------------------
+Used by:
+- asn1_der@0.7.7 (MIT.txt)
+
+Copyright notice: not included in the published crate
+Cargo authors: KizzyCode Software Labs./Keziah Biermann <de...@kizzycode.de>
+Source: https://github.com/KizzyCode/asn1_der-rust
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 b8777f2b9ad6f8ef48e077876144281fb9683f77b5944b40d720706e01a18d80
 ----------------------------------------------------------------
 Used by:
@@ -10405,6 +10491,7 @@ c5accbbd8546e94c34aed24afe689a617627d18eed5a6c48277e48db57c23851
 ----------------------------------------------------------------
 Used by:
 - enum-as-inner@0.6.1 (LICENSE-APACHE)
+- hex@0.4.3 (LICENSE-APACHE)
 - resolv-conf@0.7.6 (LICENSE-APACHE)
 
                                  Apache License
@@ -12319,6 +12406,33 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 
+e35e8f8a37b546c01c7eb82d11d7554bb6292a29fc6147c21765d1e9c40b2bae
+----------------------------------------------------------------
+Used by:
+- crunchy@0.2.4 (LICENSE)
+
+The MIT License (MIT)
+
+Copyright 2017-2023 Eira Fransham.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
 e915669a595b11a200873df8286561881b0e04932f6412a585db6297ba0bc97c
 ----------------------------------------------------------------
 Used by:
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 2db5fee81..f284ffafc 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -160,6 +160,7 @@ describe('managed Runtime Host service', () => {
         '--listen',
         '/ip4/0.0.0.0/udp/44001/quic-v1',
         '--clear-coordination-relays',
+        '--no-automatic-relay-discovery',
         '--allow-interrupt-active-tasks',
         '--expected-service-id',
         'b'.repeat(64),
@@ -179,6 +180,7 @@ describe('managed Runtime Host service', () => {
         framed: true,
         listenAddresses: ['/ip4/0.0.0.0/udp/44001/quic-v1'],
         coordinationRelays: [],
+        automaticRelayDiscovery: false,
         allowInterruptActiveTasks: true,
         managedRootId: 'a'.repeat(64),
         operatorDeploymentId: '00000000-0000-4000-8000-000000000001',
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index f2d9e286e..aec8eb924 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -213,6 +213,13 @@ function helpText(cliCommand: string): string {
     '  --websocket-path <path>       Persist the upgrade path (default: /runtime-host)',
     '  --json                        Emit framed machine-readable progress and result records',
     '',
+    'Managed Runtime Host direct-peer options:',
+    '  --listen <multiaddr>          Persist a listener address (repeatable)',
+    '  --coordination-relay <addr>   Prefer a Circuit Relay v2 address (repeatable)',
+    '  --clear-coordination-relays   Remove every manually configured relay',
+    '  --automatic-relay-discovery   Enable best-effort public relay discovery',
+    '  --no-automatic-relay-discovery  Disable public discovery and retain manual relays',
+    '',
     'Runtime Host access issue options:',
     '  --root <path>                 Select the canonical data root',
     '  --kind <kind>                 remote-owner or capability-provider',
@@ -317,6 +324,7 @@ export async function runMakaCli(
                   expectedPeerId: peer.peerId,
                   listenAddresses: peer.listenAddresses,
                   coordinationRelays: peer.coordinationRelays,
+                  automaticRelayDiscovery: peer.automaticRelayDiscovery,
                   meshDataRoot: join(config.deploymentRoot, 'peer-mesh'),
                 },
               }
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index ca9ea69d1..3898aace5 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -165,6 +165,7 @@ export type RuntimeHostCliCommand =
       operatorDeploymentId: string;
       listenAddresses: string[];
       coordinationRelays?: string[];
+      automaticRelayDiscovery?: boolean;
       expectedTarget?: RuntimeHostManagedServiceTarget;
       allowInterruptActiveTasks?: true;
     }
@@ -892,6 +893,7 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
   const listenAddresses: string[] = [];
   const coordinationRelays: string[] = [];
   let clearCoordinationRelays = false;
+  let automaticRelayDiscovery: boolean | undefined;
   let allowInterruptActiveTasks = false;
   const options = parseManagedServiceOptions(argv.slice(1), {
     allowConfiguration: false,
@@ -905,6 +907,18 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
         if (allowInterruptActiveTasks) return error('Duplicate --allow-interrupt-active-tasks');
         allowInterruptActiveTasks = true;
       },
+      '--automatic-relay-discovery': () => {
+        if (automaticRelayDiscovery !== undefined) {
+          return error('Relay discovery mode was specified more than once');
+        }
+        automaticRelayDiscovery = true;
+      },
+      '--no-automatic-relay-discovery': () => {
+        if (automaticRelayDiscovery !== undefined) {
+          return error('Relay discovery mode was specified more than once');
+        }
+        automaticRelayDiscovery = false;
+      },
     },
     valueOptions: {
       '--client-data-root': (value) => {
@@ -932,11 +946,12 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
   }
   if (
     action !== 'enable' &&
-    (listenAddresses.length > 0 || coordinationRelays.length > 0 || clearCoordinationRelays)
+    (listenAddresses.length > 0 ||
+      coordinationRelays.length > 0 ||
+      clearCoordinationRelays ||
+      automaticRelayDiscovery !== undefined)
   ) {
-    return error(
-      '--listen, --coordination-relay, and --clear-coordination-relays are only valid with peer enable',
-    );
+    return error('Peer listener options are only valid with peer enable');
   }
   if (allowInterruptActiveTasks && action !== 'enable' && action !== 'disable') {
     return error('--allow-interrupt-active-tasks is only valid with peer enable or peer disable');
@@ -969,6 +984,7 @@ function parseServicePeerCommand(argv: string[]): RuntimeHostCliCommand {
       : coordinationRelays.length > 0
         ? { coordinationRelays }
         : {}),
+    ...(automaticRelayDiscovery === undefined ? {} : { automaticRelayDiscovery }),
     ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}),
     ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
   };
diff --git a/packages/cli/src/runtime-host-peer-management-command.ts b/packages/cli/src/runtime-host-peer-management-command.ts
index d80b0b748..6618eedc0 100644
--- a/packages/cli/src/runtime-host-peer-management-command.ts
+++ b/packages/cli/src/runtime-host-peer-management-command.ts
@@ -73,6 +73,7 @@ export interface RuntimeHostPeerManagementCliOptions {
   readonly operatorDeploymentId: string;
   readonly listenAddresses: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
   readonly expectedTarget?: RuntimeHostManagedServiceTarget;
   readonly allowInterruptActiveTasks?: boolean;
 }
@@ -312,6 +313,8 @@ async function prepareCanonicalPeer(
     coordinationRelays: [
       ...new Set(options.coordinationRelays ?? current?.coordinationRelays ?? []),
     ],
+    automaticRelayDiscovery:
+      options.automaticRelayDiscovery ?? current?.automaticRelayDiscovery ?? true,
   };
 }
 
@@ -340,6 +343,7 @@ async function readCanonicalPeerStatus(
       serviceState: result.service.state,
       routeHints: [],
       coordinationRelays: [],
+      automaticRelayDiscovery: true,
     };
   }
   return {
@@ -349,6 +353,7 @@ async function readCanonicalPeerStatus(
     rootId: config.root.id,
     routeHints: expandWildcardListenAddresses(peer.listenAddresses),
     coordinationRelays: [...peer.coordinationRelays],
+    automaticRelayDiscovery: peer.automaticRelayDiscovery,
   };
 }
 
diff --git a/packages/cli/src/runtime-host-service-command.ts b/packages/cli/src/runtime-host-service-command.ts
index a84e335b8..53059736a 100644
--- a/packages/cli/src/runtime-host-service-command.ts
+++ b/packages/cli/src/runtime-host-service-command.ts
@@ -49,6 +49,7 @@ export interface RuntimeHostServiceCliOptions {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly meshDataRoot?: string;
   };
 }
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index 55c912172..275213356 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -651,6 +651,7 @@ async function prepareSupervisedDirectPeer(
     coordinationRelays: [
       ...(options.directPeer?.coordinationRelays ?? current?.coordinationRelays ?? []),
     ],
+    automaticRelayDiscovery: current?.automaticRelayDiscovery ?? true,
   };
 }
 
diff --git a/packages/runtime-host/src/__tests__/peer-management-frame.test.ts b/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
index 2bd158ffb..40ea81a05 100644
--- a/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-management-frame.test.ts
@@ -36,6 +36,7 @@ test('peer management frames preserve a bounded machine-readable descriptor', ()
       rootId: 'a'.repeat(64),
       routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
       coordinationRelays: [],
+      automaticRelayDiscovery: true,
     },
   };
   assert.deepEqual(
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index 1568f677b..7dad8c138 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -60,6 +60,7 @@ module.exports = {
     return {
       peerId: 'client',
       listenAddresses: [],
+      activeCoordinationRelays: [],
       connect: ({ requestId, peerId, routeHints, coordinationRelays }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays });
         if (peerId === 'ready') return Promise.resolve(stream);
@@ -198,6 +199,7 @@ module.exports = {
   startPeerEndpoint: () => ({
     peerId: 'peer',
     listenAddresses: [],
+    activeCoordinationRelays: [],
     connect: async () => stream,
     connectMeshControl: async () => stream,
     cancelConnect: async () => true,
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index 6464bfdfb..59020d745 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -76,6 +76,7 @@ export function createRuntimeHostPeerClientFromEnvironment(
   options: {
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly routeResolver?: RuntimeHostPeerRouteResolver;
   } = {},
 ): RuntimeHostPeerClient {
@@ -96,6 +97,7 @@ export function createRuntimeHostPeerClient(input: {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
   readonly routeResolver?: RuntimeHostPeerRouteResolver;
 }): RuntimeHostPeerClient {
   return new RuntimeHostPeerClientImpl(input);
@@ -107,6 +109,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
   readonly #expectedPeerId: string | undefined;
   readonly #listenAddresses: readonly string[] | undefined;
   readonly #coordinationRelays: readonly string[] | undefined;
+  readonly #automaticRelayDiscovery: boolean;
   readonly #routeResolver: RuntimeHostPeerRouteResolver | undefined;
   #endpoint: RuntimeHostPeerNativeEndpoint | undefined;
   #draining: Promise<void> | undefined;
@@ -125,6 +128,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
     readonly routeResolver?: RuntimeHostPeerRouteResolver;
   }) {
     this.#nativePath = input.nativePath;
@@ -132,6 +136,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     this.#expectedPeerId = input.expectedPeerId;
     this.#listenAddresses = input.listenAddresses;
     this.#coordinationRelays = input.coordinationRelays;
+    this.#automaticRelayDiscovery = input.automaticRelayDiscovery ?? false;
     this.#routeResolver = input.routeResolver;
   }
 
@@ -144,7 +149,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     return Object.freeze({
       peerId: endpoint.peerId,
       listenAddresses: Object.freeze([...endpoint.listenAddresses]),
-      coordinationRelays: Object.freeze([...(this.#coordinationRelays ?? [])]),
+      coordinationRelays: Object.freeze([...endpoint.activeCoordinationRelays]),
     });
   }
 
@@ -328,6 +333,7 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
       ...(this.#expectedPeerId ? { expectedPeerId: this.#expectedPeerId } : {}),
       ...(this.#listenAddresses ? { listenAddresses: this.#listenAddresses } : {}),
       ...(this.#coordinationRelays ? { coordinationRelays: this.#coordinationRelays } : {}),
+      automaticRelayDiscovery: this.#automaticRelayDiscovery,
     });
     this.#endpoint = endpoint;
     this.#draining = this.#drainInbound(endpoint);
diff --git a/packages/runtime-host/src/operator/managed-deployment.ts b/packages/runtime-host/src/operator/managed-deployment.ts
index 9aaa6d297..69de37ee7 100644
--- a/packages/runtime-host/src/operator/managed-deployment.ts
+++ b/packages/runtime-host/src/operator/managed-deployment.ts
@@ -176,6 +176,7 @@ const managedDeploymentConfigSchema = z
             peerId: boundedText(256),
             listenAddresses: z.array(boundedText(2_048)).min(1).max(16),
             coordinationRelays: z.array(boundedText(2_048)).max(16),
+            automaticRelayDiscovery: z.boolean().default(true),
           })
           .strict()
           .optional(),
diff --git a/packages/runtime-host/src/operator/peer-management-frame.ts b/packages/runtime-host/src/operator/peer-management-frame.ts
index d216179ec..6c53370da 100644
--- a/packages/runtime-host/src/operator/peer-management-frame.ts
+++ b/packages/runtime-host/src/operator/peer-management-frame.ts
@@ -37,6 +37,7 @@ const STATUS_SCHEMA = z
       .optional(),
     routeHints: z.array(ADDRESS_SCHEMA).max(16),
     coordinationRelays: z.array(ADDRESS_SCHEMA).max(16),
+    automaticRelayDiscovery: z.boolean().optional(),
   })
   .strict();
 
diff --git a/packages/runtime-host/src/peer-mesh/owner.ts b/packages/runtime-host/src/peer-mesh/owner.ts
index 8c62ce623..3f1560154 100644
--- a/packages/runtime-host/src/peer-mesh/owner.ts
+++ b/packages/runtime-host/src/peer-mesh/owner.ts
@@ -41,6 +41,7 @@ export async function openRuntimeHostPeerMeshOwner(input: {
   readonly dataRoot: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }): Promise<RuntimeHostPeerMeshOwner> {
   let mesh: PeerMeshNode | undefined;
   let resolverMesh: PeerMeshNode | undefined;
@@ -55,6 +56,9 @@ export async function openRuntimeHostPeerMeshOwner(input: {
       ...(input.expectedPeerId ? { expectedPeerId: input.expectedPeerId } : {}),
       ...(input.listenAddresses ? { listenAddresses: input.listenAddresses } : {}),
       ...(input.coordinationRelays ? { coordinationRelays: input.coordinationRelays } : {}),
+      ...(input.automaticRelayDiscovery === undefined
+        ? {}
+        : { automaticRelayDiscovery: input.automaticRelayDiscovery }),
       routeResolver: {
         resolveRoutes: (peerId) => resolverMesh?.resolveRoutes(peerId),
       },
diff --git a/packages/runtime-host/src/server/peer-listener.ts b/packages/runtime-host/src/server/peer-listener.ts
index 5e538bbab..11db805ab 100644
--- a/packages/runtime-host/src/server/peer-listener.ts
+++ b/packages/runtime-host/src/server/peer-listener.ts
@@ -42,6 +42,7 @@ export interface RuntimeHostPeerListenerConfiguration {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }
 
 export type RuntimeHostPeerListenerEndpointOptions =
diff --git a/packages/runtime-host/src/transport/peer-native.ts b/packages/runtime-host/src/transport/peer-native.ts
index 0b1106264..c9f362dc9 100644
--- a/packages/runtime-host/src/transport/peer-native.ts
+++ b/packages/runtime-host/src/transport/peer-native.ts
@@ -62,6 +62,7 @@ export interface RuntimeHostPeerIdentityProof {
 export interface RuntimeHostPeerNativeEndpoint {
   readonly peerId: string;
   readonly listenAddresses: readonly string[];
+  readonly activeCoordinationRelays: readonly string[];
   connect(options: {
     readonly requestId: number;
     readonly peerId: string;
@@ -100,6 +101,7 @@ interface RuntimeHostPeerNativeModule {
     readonly expectedPeerId?: string;
     readonly listenAddresses?: readonly string[];
     readonly coordinationRelays?: readonly string[];
+    readonly automaticRelayDiscovery?: boolean;
   }): unknown;
 }
 
@@ -170,6 +172,7 @@ export function startRuntimeHostPeerEndpoint(input: {
   readonly expectedPeerId?: string;
   readonly listenAddresses?: readonly string[];
   readonly coordinationRelays?: readonly string[];
+  readonly automaticRelayDiscovery?: boolean;
 }): RuntimeHostPeerNativeEndpoint {
   try {
     const endpoint = loadNativeModule(input.nativePath).startPeerEndpoint({
@@ -177,6 +180,9 @@ export function startRuntimeHostPeerEndpoint(input: {
       ...(input.expectedPeerId ? { expectedPeerId: input.expectedPeerId } : {}),
       ...(input.listenAddresses ? { listenAddresses: input.listenAddresses } : {}),
       ...(input.coordinationRelays ? { coordinationRelays: input.coordinationRelays } : {}),
+      ...(input.automaticRelayDiscovery === undefined
+        ? {}
+        : { automaticRelayDiscovery: input.automaticRelayDiscovery }),
     });
     if (!isPeerNativeEndpoint(endpoint)) {
       throw new RuntimeHostPeerError(
@@ -442,6 +448,9 @@ function isPeerNativeEndpoint(value: unknown): value is RuntimeHostPeerNativeEnd
     'listenAddresses' in value &&
     Array.isArray(value.listenAddresses) &&
     value.listenAddresses.every((address) => typeof address === 'string') &&
+    'activeCoordinationRelays' in value &&
+    Array.isArray(value.activeCoordinationRelays) &&
+    value.activeCoordinationRelays.every((address) => typeof address === 'string') &&
     'connect' in value &&
     typeof value.connect === 'function' &&
     'connectMeshControl' in value &&
diff --git a/scripts/generate-runtime-host-peer-notices.mjs b/scripts/generate-runtime-host-peer-notices.mjs
index ce9a7bb0f..5cf6b719d 100644
--- a/scripts/generate-runtime-host-peer-notices.mjs
+++ b/scripts/generate-runtime-host-peer-notices.mjs
@@ -104,7 +104,11 @@ function declaredLicenseFallback(pkg, source) {
       text: apache2LicenseText,
     };
   }
-  if (pkg.license === 'MIT') {
+  if (
+    pkg.license
+      .split(/\s+OR\s+/u)
+      .some((alternative) => alternative.replaceAll(/[()]/gu, '').trim() === 'MIT')
+  ) {
     const authors = pkg.authors.length > 0 ? pkg.authors.join(', ') : 'not included';
     return {
       name: 'MIT.txt',


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org