Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node: New relay configuration for nodes
Merged did:key:z6MksFqX...wzpT opened 1 year ago

Relay now has three values: “always”, “never” and “auto”.

The first two operate like the previous true/false. The new (default) value turns relaying on for nodes with public (external) addresses and off for nodes without. This should reduce redundant traffic on the network, that was causing the rate limiters to trigger.

9 files changed +172 -117 abf89438 a9b94b0a
modified build/build
@@ -10,15 +10,15 @@ main() {
  export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)

  if ! command -v rad > /dev/null; then
-
    echo "fatal: rad is not installed" ; exit 1
+
    echo "fatal: rad is not installed" >&2 ; exit 1
  fi

  if ! command -v podman > /dev/null; then
-
    echo "fatal: podman is not installed" ; exit 1
+
    echo "fatal: podman is not installed" >&2 ; exit 1
  fi

  if ! command -v sha256sum > /dev/null; then
-
    echo "fatal: sha256sum is not installed" ; exit 1
+
    echo "fatal: sha256sum is not installed" >&2 ; exit 1
  fi

  rev="$(git rev-parse --short HEAD)"
@@ -29,7 +29,7 @@ main() {
  image=radicle-build-$version

  if [ ! -f "$keypath" ]; then
-
    echo "fatal: no key found at $keypath" ; exit 1
+
    echo "fatal: no key found at $keypath" >&2 ; exit 1
  fi
  # Authenticate user for signing
  rad auth
@@ -41,6 +41,8 @@ main() {
  echo "Building image ($image).."
  podman --cgroup-manager=cgroupfs build \
    --env SOURCE_DATE_EPOCH \
+
    --env TZ \
+
    --env LC_ALL \
    --env GIT_COMMIT_TIME=$SOURCE_DATE_EPOCH \
    --env GIT_HEAD=$rev \
    --env RADICLE_VERSION=$version \
@@ -86,4 +88,4 @@ echo
build/checksums
echo

-
echo "Build ran successfully."
+
echo "Build successful."
modified radicle-cli/examples/rad-config.md
@@ -29,7 +29,7 @@ $ rad config
    "tor": null,
    "network": "main",
    "log": "INFO",
-
    "relay": true,
+
    "relay": "auto",
    "limits": {
      "routingMaxSize": 1000,
      "routingMaxAge": 604800,
modified radicle-cli/examples/rad-sync.md
@@ -11,13 +11,13 @@ If we check the sync status, we see that our peers are out of sync.

```
$ rad sync status --sort-by alias
-
╭──────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   Node                      Address                  Status        Tip       Timestamp │
-
├──────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   alice   (you)             alice.radicle.xyz:8776                 f209c9f   [  ...  ] │
-
│ ●   bob     z6Mkt67…v4N1tRk   bob.radicle.xyz:8776     out-of-sync   f209c9f   [  ...  ] │
-
│ ●   eve     z6Mkux1…nVhib7Z   eve.radicle.xyz:8776     out-of-sync   f209c9f   [  ...  ] │
-
╰──────────────────────────────────────────────────────────────────────────────────────────╯
+
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   Node                      Address                      Status        Tip       Timestamp │
+
├──────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   alice   (you)             alice.radicle.example:8776                 f209c9f   [  ...  ] │
+
│ ●   bob     z6Mkt67…v4N1tRk   bob.radicle.example:8776     out-of-sync   f209c9f   [  ...  ] │
+
│ ●   eve     z6Mkux1…nVhib7Z   eve.radicle.example:8776     out-of-sync   f209c9f   [  ...  ] │
+
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
```

Now let's run `rad sync`. This will announce the issue refs to the network and
@@ -33,13 +33,13 @@ Now, when we run `rad sync status` again, we can see that `bob` and

```
$ rad sync status --sort-by alias
-
╭─────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   Node                      Address                  Status   Tip       Timestamp │
-
├─────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   alice   (you)             alice.radicle.xyz:8776            a9ce0d1   [  ...  ] │
-
│ ●   bob     z6Mkt67…v4N1tRk   bob.radicle.xyz:8776     synced   a9ce0d1   [  ...  ] │
-
│ ●   eve     z6Mkux1…nVhib7Z   eve.radicle.xyz:8776     synced   a9ce0d1   [  ...  ] │
-
╰─────────────────────────────────────────────────────────────────────────────────────╯
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   Node                      Address                      Status   Tip       Timestamp │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   alice   (you)             alice.radicle.example:8776            a9ce0d1   [  ...  ] │
+
│ ●   bob     z6Mkt67…v4N1tRk   bob.radicle.example:8776     synced   a9ce0d1   [  ...  ] │
+
│ ●   eve     z6Mkux1…nVhib7Z   eve.radicle.example:8776     synced   a9ce0d1   [  ...  ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
```

If we try to sync again after the nodes have synced, we will already
modified radicle-cli/tests/commands.rs
@@ -39,6 +39,7 @@ mod config {
    pub fn seed(alias: &'static str) -> Config {
        Config {
            network: Network::Test,
+
            relay: node::config::Relay::Always,
            limits: Limits {
                rate: RateLimits {
                    inbound: RateLimit {
@@ -52,20 +53,27 @@ mod config {
                },
                ..Limits::default()
            },
+
            external_addresses: vec![node::Address::from_str(&format!(
+
                "{alias}.radicle.example:8776"
+
            ))
+
            .unwrap()],
            ..node(alias)
        }
    }

-
    /// Test node config.
-
    pub fn node(alias: &'static str) -> Config {
+
    /// Relay node config.
+
    pub fn relay(alias: &'static str) -> Config {
        Config {
-
            external_addresses: vec![
-
                node::Address::from_str(&format!("{alias}.radicle.xyz:8776")).unwrap(),
-
            ],
-
            ..Config::test(Alias::new(alias))
+
            relay: node::config::Relay::Always,
+
            ..node(alias)
        }
    }

+
    /// Test node config.
+
    pub fn node(alias: &'static str) -> Config {
+
        Config::test(Alias::new(alias))
+
    }
+

    /// Test profile config.
    pub fn profile(alias: &'static str) -> profile::Config {
        Environment::config(Alias::new(alias))
@@ -965,9 +973,9 @@ fn rad_review_by_hunk() {
#[test]
fn rad_patch_delete() {
    let mut environment = Environment::new();
-
    let alice = environment.node(Config::test(Alias::new("alice")));
-
    let bob = environment.node(Config::test(Alias::new("bob")));
-
    let seed = environment.node(Config::test(Alias::new("seed")));
+
    let alice = environment.node(config::relay("alice"));
+
    let bob = environment.node(config::relay("bob"));
+
    let seed = environment.node(config::relay("seed"));
    let working = environment.tmp().join("working");
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();

@@ -1332,9 +1340,9 @@ fn rad_clone_connect() {
#[test]
fn rad_sync_without_node() {
    let mut environment = Environment::new();
-
    let alice = environment.node(Config::test(Alias::new("alice")));
-
    let bob = environment.node(Config::test(Alias::new("bob")));
-
    let mut eve = environment.node(Config::test(Alias::new("eve")));
+
    let alice = environment.node(config::seed("alice"));
+
    let bob = environment.node(config::seed("bob"));
+
    let mut eve = environment.node(config::seed("eve"));

    let rid = RepoId::from_urn("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
    eve.policies.seed(&rid, Scope::All).unwrap();
@@ -1738,9 +1746,9 @@ fn test_cob_deletion() {
fn rad_sync() {
    let mut environment = Environment::new();
    let working = environment.tmp().join("working");
-
    let alice = environment.node(config::node("alice"));
-
    let bob = environment.node(config::node("bob"));
-
    let eve = environment.node(config::node("eve"));
+
    let alice = environment.node(config::seed("alice"));
+
    let bob = environment.node(config::seed("bob"));
+
    let eve = environment.node(config::seed("eve"));
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();

    fixtures::repository(working.join("acme"));
@@ -1784,12 +1792,12 @@ fn rad_sync() {
//
fn test_replication_via_seed() {
    let mut environment = Environment::new();
-
    let alice = environment.node(Config::test(Alias::new("alice")));
-
    let bob = environment.node(Config::test(Alias::new("bob")));
+
    let alice = environment.node(config::relay("alice"));
+
    let bob = environment.node(config::relay("bob"));
    let seed = environment.node(Config {
        policy: Policy::Allow,
        scope: Scope::All,
-
        ..Config::test(Alias::new("seed"))
+
        ..config::relay("seed")
    });
    let working = environment.tmp().join("working");
    let rid = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
@@ -1874,9 +1882,9 @@ fn test_replication_via_seed() {
#[test]
fn rad_remote() {
    let mut environment = Environment::new();
-
    let alice = environment.node(Config::test(Alias::new("alice")));
-
    let bob = environment.node(Config::test(Alias::new("bob")));
-
    let eve = environment.node(Config::test(Alias::new("eve")));
+
    let alice = environment.node(config::relay("alice"));
+
    let bob = environment.node(config::relay("bob"));
+
    let eve = environment.node(config::relay("eve"));
    let working = environment.tmp().join("working");
    let home = alice.home.clone();
    let rid = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
modified radicle-httpd/src/api/v1/profile.rs
@@ -92,7 +92,7 @@ mod routes {
                  "tor": null,
                  "network": "main",
                  "log": "INFO",
-
                  "relay": true,
+
                  "relay": "auto",
                  "limits": {
                    "routingMaxSize": 1000,
                    "routingMaxAge": 604800,
modified radicle-node/src/service.rs
@@ -25,7 +25,7 @@ use radicle::node;
use radicle::node::address;
use radicle::node::address::Store as _;
use radicle::node::address::{AddressBook, KnownAddress};
-
use radicle::node::config::PeerConfig;
+
use radicle::node::config::{PeerConfig, Relay};
use radicle::node::refs::Store as _;
use radicle::node::routing::Store as _;
use radicle::node::seed;
@@ -1334,7 +1334,7 @@ where
        &mut self,
        relayer_addr: &Address,
        announcement: &Announcement,
-
    ) -> Result<bool, session::Error> {
+
    ) -> Result<Relay, session::Error> {
        if !announcement.verify() {
            return Err(session::Error::Misbehavior);
        }
@@ -1346,7 +1346,7 @@ where

        // Ignore our own announcements, in case the relayer sent one by mistake.
        if announcer == self.nid() {
-
            return Ok(false);
+
            return Ok(Relay::Never);
        }
        let now = self.clock;
        let timestamp = message.timestamp();
@@ -1358,7 +1358,7 @@ where
        {
            self.config.relay
        } else {
-
            false
+
            Relay::Never
        };

        // Don't allow messages from too far in the future.
@@ -1379,12 +1379,12 @@ where
                Ok(node) => {
                    if node.is_none() {
                        debug!(target: "service", "Ignoring announcement from unknown node {announcer} (t={timestamp})");
-
                        return Ok(false);
+
                        return Ok(Relay::Never);
                    }
                }
                Err(e) => {
                    error!(target: "service", "Error looking up node in address book: {e}");
-
                    return Ok(false);
+
                    return Ok(Relay::Never);
                }
            }
        }
@@ -1394,12 +1394,12 @@ where
            Ok(fresh) => {
                if !fresh {
                    debug!(target: "service", "Ignoring stale announcement from {announcer} (t={timestamp})");
-
                    return Ok(false);
+
                    return Ok(Relay::Never);
                }
            }
            Err(e) => {
                error!(target: "service", "Error updating gossip entry from {announcer}: {e}");
-
                return Ok(false);
+
                return Ok(Relay::Never);
            }
        }

@@ -1419,12 +1419,12 @@ where
                    Ok(synced) => {
                        if synced.is_empty() {
                            trace!(target: "service", "No routes updated by inventory announcement from {announcer}");
-
                            return Ok(false);
+
                            return Ok(Relay::Never);
                        }
                    }
                    Err(e) => {
                        error!(target: "service", "Error processing inventory from {announcer}: {e}");
-
                        return Ok(false);
+
                        return Ok(Relay::Never);
                    }
                }

@@ -1473,7 +1473,7 @@ where
                // Empty announcements can be safely ignored.
                let Some(refs) = NonEmpty::from_vec(message.refs.to_vec()) else {
                    debug!(target: "service", "Skipping fetch, no refs in announcement for {} (t={timestamp})", message.rid);
-
                    return Ok(false);
+
                    return Ok(Relay::Never);
                };
                // We update inventories when receiving ref announcements, as these could come
                // from a new repository being initialized.
@@ -1526,7 +1526,7 @@ where
                        "Ignoring refs announcement from {announcer}: repository {} isn't seeded (t={timestamp})",
                        message.rid
                    );
-
                    return Ok(false);
+
                    return Ok(Relay::Never);
                }
                // Refs can be relayed by peers who don't have the data in storage,
                // therefore we only check whether we are connected to the *announcer*,
@@ -1600,7 +1600,7 @@ where
                }
            }
        }
-
        Ok(false)
+
        Ok(Relay::Never)
    }

    pub fn handle_info(&mut self, remote: NodeId, info: &Info) -> Result<(), session::Error> {
@@ -1652,8 +1652,15 @@ where
                let relayer_addr = peer.addr.clone();
                let announcer = ann.node;

-
                // Returning true here means that the message should be relayed.
-
                if self.handle_announcement(&relayer_addr, &ann)? {
+
                let relay = match self.handle_announcement(&relayer_addr, &ann)? {
+
                    // In "auto" mode, we only relay if we're a public seed node.
+
                    // This reduces traffic for private nodes, as well as message redundancy.
+
                    Relay::Auto => !self.config.external_addresses.is_empty(),
+
                    Relay::Never => false,
+
                    Relay::Always => true,
+
                };
+

+
                if relay {
                    // Choose peers we should relay this message to.
                    // 1. Don't relay to the peer who sent us this message.
                    // 2. Don't relay to the peer who signed this announcement.
modified radicle-node/src/tests/e2e.rs
@@ -18,6 +18,19 @@ use crate::storage::git::transport;
use crate::test::environment::{converge, Environment, Node};
use crate::test::logger;

+
mod config {
+
    use super::*;
+
    use radicle::node::config::{Config, Relay};
+

+
    /// Relay node config.
+
    pub fn relay(alias: &'static str) -> Config {
+
        Config {
+
            relay: Relay::Always,
+
            ..Config::test(Alias::new(alias))
+
        }
+
    }
+
}
+

#[test]
//
//     alice -- bob
@@ -27,8 +40,8 @@ fn test_inventory_sync_basic() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));

    alice.project("alice", "");
    bob.project("bob", "");
@@ -51,9 +64,9 @@ fn test_inventory_sync_bridge() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let mut eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
+
    let mut eve = Node::init(tmp.path(), config::relay("eve"));

    alice.project("alice", "");
    bob.project("bob", "");
@@ -81,9 +94,9 @@ fn test_inventory_sync_ring() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let mut eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
+
    let mut eve = Node::init(tmp.path(), config::relay("eve"));
    let mut carol = Node::init(tmp.path(), Config::test(Alias::new("carol")));

    alice.project("alice", "");
@@ -118,9 +131,9 @@ fn test_inventory_sync_star() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let mut eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
+
    let mut eve = Node::init(tmp.path(), config::relay("eve"));
    let mut carol = Node::init(tmp.path(), Config::test(Alias::new("carol")));
    let mut dave = Node::init(tmp.path(), Config::test(Alias::new("dave")));

@@ -150,8 +163,8 @@ fn test_replication() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = bob.project("acme", "");

    let mut alice = alice.spawn();
@@ -210,8 +223,8 @@ fn test_replication_ref_in_sigrefs() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));

    let acme = bob.project("acme", "");
    // Delete one of the signed refs.
@@ -251,8 +264,8 @@ fn test_replication_ref_in_sigrefs() {
#[test]
fn test_replication_invalid() {
    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
    let carol = MockSigner::default();
    let acme = bob.project("acme", "");
    let repo = bob.storage.repository_mut(acme).unwrap();
@@ -304,8 +317,8 @@ fn test_migrated_clone() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = alice.project("acme", "");

    let mut alice = alice.spawn();
@@ -357,8 +370,8 @@ fn test_dont_fetch_owned_refs() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = alice.project("acme", "");

    let mut alice = alice.spawn();
@@ -384,8 +397,8 @@ fn test_fetch_followed_remotes() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = alice.project("acme", "");
    let mut signers = Vec::with_capacity(5);
    {
@@ -439,8 +452,8 @@ fn test_missing_remote() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = alice.project("acme", "");

    let mut alice = alice.spawn();
@@ -468,8 +481,8 @@ fn test_fetch_preserve_owned_refs() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = alice.project("acme", "");
    let mut alice = alice.spawn();
    let mut bob = bob.spawn();
@@ -514,8 +527,8 @@ fn test_clone() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = bob.project("acme", "");

    let mut alice = alice.spawn();
@@ -572,8 +585,8 @@ fn test_fetch_up_to_date() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = bob.project("acme", "");

    let mut alice = alice.spawn();
@@ -601,8 +614,8 @@ fn test_fetch_unseeded() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
    let acme = bob.project("acme", "");

    let mut alice = alice.spawn();
@@ -631,8 +644,8 @@ fn test_large_fetch() {

    let env = Environment::new();
    let scale = env.scale();
-
    let mut alice = Node::init(&env.tmp(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(&env.tmp(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(&env.tmp(), config::relay("alice"));
+
    let bob = Node::init(&env.tmp(), config::relay("bob"));

    let tmp = tempfile::tempdir().unwrap();
    let (repo, _) = fixtures::repository(tmp.path());
@@ -681,14 +694,16 @@ fn test_concurrent_fetches() {
        &env.tmp(),
        service::Config {
            limits: limits.clone(),
-
            ..service::Config::test(Alias::new("alice"))
+
            relay: radicle::node::config::Relay::Always,
+
            ..config::relay("alice")
        },
    );
    let mut bob = Node::init(
        &env.tmp(),
        service::Config {
            limits,
-
            ..service::Config::test(Alias::new("bob"))
+
            relay: radicle::node::config::Relay::Always,
+
            ..config::relay("bob")
        },
    );

@@ -773,8 +788,8 @@ fn test_connection_crossing() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));

    let alice = alice.spawn();
    let bob = bob.spawn();
@@ -841,9 +856,9 @@ fn test_non_fastforward_sigrefs() {

    let tmp = tempfile::tempdir().unwrap();

-
    let alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let mut bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let alice = Node::init(tmp.path(), config::relay("alice"));
+
    let mut bob = Node::init(tmp.path(), config::relay("bob"));
+
    let eve = Node::init(tmp.path(), config::relay("eve"));

    let rid = bob.project("acme", "");

@@ -949,9 +964,9 @@ fn test_outdated_sigrefs() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
+
    let eve = Node::init(tmp.path(), config::relay("eve"));

    let rid = alice.project("acme", "");

@@ -1045,9 +1060,9 @@ fn test_outdated_delegate_sigrefs() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
+
    let eve = Node::init(tmp.path(), config::relay("eve"));

    let rid = alice.project("acme", "");

@@ -1133,8 +1148,8 @@ fn missing_default_branch() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));

    let rid = alice.project("acme", "");

@@ -1185,9 +1200,9 @@ fn test_background_foreground_fetch() {

    let tmp = tempfile::tempdir().unwrap();

-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
-
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
+
    let eve = Node::init(tmp.path(), config::relay("eve"));

    let rid = alice.project("acme", "");

@@ -1273,10 +1288,10 @@ fn test_catchup_on_refs_announcements() {
    logger::init(log::Level::Debug);

    let tmp = tempfile::tempdir().unwrap();
-
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
-
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let mut alice = Node::init(tmp.path(), config::relay("alice"));
+
    let bob = Node::init(tmp.path(), config::relay("bob"));
    let bob_id = bob.id;
-
    let seed = Node::init(tmp.path(), Config::test(Alias::new("seed")));
+
    let seed = Node::init(tmp.path(), config::relay("seed"));
    let acme = alice.project("acme", "");

    let mut alice = alice.spawn();
modified radicle/src/node/config.rs
@@ -234,6 +234,19 @@ impl Default for PeerConfig {
    }
}

+
/// Relay configuration.
+
#[derive(Debug, Copy, Clone, Default, serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub enum Relay {
+
    /// Always relay messages.
+
    Always,
+
    /// Never relay messages.
+
    Never,
+
    /// Relay messages when applicable.
+
    #[default]
+
    Auto,
+
}
+

/// Tor configuration.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
@@ -276,9 +289,9 @@ pub struct Config {
    #[serde(default = "defaults::log")]
    #[serde(with = "crate::serde_ext::string")]
    pub log: log::Level,
-
    /// Whether or not our node should relay inventories.
-
    #[serde(default = "crate::serde_ext::bool::yes")]
-
    pub relay: bool,
+
    /// Whether or not our node should relay messages.
+
    #[serde(default, deserialize_with = "crate::serde_ext::ok_or_default")]
+
    pub relay: Relay,
    /// Configured service limits.
    #[serde(default)]
    pub limits: Limits,
@@ -310,7 +323,7 @@ impl Config {
            external_addresses: vec![],
            network: Network::default(),
            tor: None,
-
            relay: true,
+
            relay: Relay::default(),
            limits: Limits::default(),
            workers: DEFAULT_WORKERS,
            log: defaults::log(),
modified radicle/src/serde_ext.rs
@@ -110,6 +110,16 @@ pub fn is_default<T: Default + PartialEq>(t: &T) -> bool {
    t == &T::default()
}

+
/// Deserialize a value, but if it fails, return the default value.
+
pub fn ok_or_default<'de, T, D>(deserializer: D) -> Result<T, D::Error>
+
where
+
    T: serde::Deserialize<'de> + Default,
+
    D: serde::Deserializer<'de>,
+
{
+
    let v: serde_json::Value = serde::Deserialize::deserialize(deserializer)?;
+
    Ok(T::deserialize(v).unwrap_or_default())
+
}
+

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