Files
ferrix/crates/web/src/api.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 f9ef781d51 Check roles in OPEN and prevent route leaks with Only to Customer (RFC 9234)
A peer's configured role now does three more things:

- Our OPEN carries the BGP Role capability with what we are to the peer.
  A peer whose own says it is something that does not pair with that, or
  that sends two different roles, is refused with OPEN Message Error /
  Role Mismatch. A peer that says nothing is accepted unless require_role
  is set. A role on a peer in our own AS is a configuration error.
- Routes from a provider, a peer or a route server are marked Only to
  Customer on the way in if they are not already, and our routes are
  marked on the way out to a customer, a peer or a route server client.
- A marked route from a customer or route server client, or from a peer
  with someone else's mark, is a leak and is refused. No policy setting
  turns that off.

The packet crate gains the Only to Customer attribute (malformed:
treat-as-withdraw), and the Role Mismatch subcode is corrected from 8 to
11.

The status page shows whether the peer confirmed its role.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 14:27:36 +02:00

731 lines
26 KiB
Rust

//! JSON handlers and their view types. Everything here is read-only: the
//! daemon is configured through its configuration file.
use std::collections::{BTreeMap, HashMap};
use std::net::IpAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use bgp_packet::community;
use bgp_packet::message::{NlriNextHop, OriginPathAttribute, PathAttribute};
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::table::{PeerIndex, RibTable, Route};
use bgp_rib::{LocalRoute, RibStopped};
use bgp_rpki::{CacheState, Update};
use bgp_server::policy::PeerRole;
use bgp_server::rpki::PathCheck;
use bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
use serde::{Deserialize, Serialize};
use crate::AppState;
// ---------------------------------------------------------------------------
// Views
// ---------------------------------------------------------------------------
fn unix_secs(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Serialize)]
pub struct StatusView {
pub router_id: String,
pub local_asn: u32,
pub version: String,
pub hostname: String,
pub started_at_unix: u64,
pub uptime_secs: u64,
pub sessions: usize,
pub local_routes: usize,
/// Routes learned from peers, counting each peer's separately.
pub received_routes: usize,
/// Prefixes with at least one such route, and so with a best route.
pub received_prefixes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PeerView {
/// Name from the configuration file; absent for unconfigured inbound peers.
pub name: Option<String>,
pub peer_addr: String,
pub peer_asn: u32,
/// `Established`, `Connecting`, `Backoff` or `Idle`.
pub state: String,
/// `inbound` or `outbound`: which side opened the live TCP connection.
pub direction: Option<String>,
pub local_addr: Option<String>,
pub established_at_unix: Option<u64>,
pub uptime_secs: Option<u64>,
pub four_octet_asn: Option<bool>,
/// The address families negotiated with the peer, e.g. `IPv4 unicast`.
pub address_families: Option<Vec<String>>,
/// The families for which the peer sends us several routes for a
/// prefix (ADD-PATH, RFC 7911), and those for which we send it path
/// identifiers.
pub add_path_receive: Option<Vec<String>>,
pub add_path_send: Option<Vec<String>>,
/// How many of the peer's routes are in the routing table.
pub routes: Option<u64>,
/// How many routes the peer announced on this session that its import
/// policy refused.
pub rejected_routes: Option<u64>,
/// How many of the peer's routes are held out of the table as invalid
/// by the RPKI's data, to be taken if that changes.
pub held_routes: Option<u64>,
/// What the peer is to us, if configured: `provider`, `customer`,
/// `peer`, `route server` or `route server client`.
pub role: Option<&'static str>,
/// Whether the peer's OPEN said the same (RFC 9234). `false` for a
/// peer that does not say; one that says otherwise gets no session.
pub role_confirmed: Option<bool>,
/// Why the last connection attempt or session ended, while in Backoff.
pub last_error: Option<String>,
pub retry_at_unix: Option<u64>,
}
impl From<&SessionHandle> for PeerView {
fn from(s: &SessionHandle) -> Self {
Self {
name: s.name.clone(),
peer_addr: s.peer.addr.to_string(),
peer_asn: s.peer.asn,
state: "Established".into(),
direction: Some(s.direction.as_str().into()),
local_addr: Some(s.local_addr.to_string()),
established_at_unix: Some(unix_secs(s.established_at)),
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
four_octet_asn: Some(s.four_octet_asn),
address_families: Some(s.families.iter().map(|f| f.to_string()).collect()),
add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()),
add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()),
routes: Some(s.counters.routes()),
rejected_routes: Some(s.counters.rejected()),
held_routes: Some(s.counters.held()),
role: s.role.map(PeerRole::as_str),
role_confirmed: s.role.map(|_| s.role_confirmed),
last_error: None,
retry_at_unix: None,
}
}
}
impl PeerView {
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
let (state_name, last_error, retry_at_unix) = match state {
DialState::Idle => ("Idle", None, None),
DialState::Connecting => ("Connecting", None, None),
DialState::Backoff { until, last_error } => {
("Backoff", Some(last_error.clone()), Some(unix_secs(*until)))
}
};
Self {
name: Some(peer.name.clone()),
peer_addr: peer.endpoint.to_string(),
peer_asn: peer.remote_asn,
state: state_name.into(),
direction: None,
local_addr: None,
established_at_unix: None,
uptime_secs: None,
four_octet_asn: None,
address_families: None,
add_path_receive: None,
add_path_send: None,
routes: None,
rejected_routes: None,
held_routes: None,
role: peer.role.map(PeerRole::as_str),
role_confirmed: None,
last_error,
retry_at_unix,
}
}
}
/// Configured peers first (with their live session when established), then
/// any unconfigured inbound sessions.
pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
let sessions = registry.list();
let mut claimed = std::collections::HashSet::new();
let mut views: Vec<PeerView> = registry
.configured_peers()
.iter()
.map(|(peer, state)| {
match sessions
.iter()
.find(|s| s.peer.addr.ip() == peer.endpoint.ip() && s.peer.asn == peer.remote_asn)
{
Some(session) => {
claimed.insert(session.peer);
PeerView {
name: Some(peer.name.clone()),
..PeerView::from(session)
}
}
None => PeerView::from_configured(peer, state),
}
})
.collect();
let mut rest: Vec<PeerView> = sessions
.iter()
.filter(|s| !claimed.contains(&s.peer))
.map(PeerView::from)
.collect();
rest.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
views.extend(rest);
views
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalRouteView {
pub prefix: String,
pub origin: String,
pub prepend: u8,
pub med: Option<u32>,
pub communities: Vec<String>,
pub large_communities: Vec<String>,
pub announced_at_unix: u64,
}
impl From<&LocalRoute> for LocalRouteView {
fn from(r: &LocalRoute) -> Self {
Self {
prefix: r.prefix.to_string(),
origin: origin_name(r.attrs.origin).to_string(),
prepend: r.attrs.prepend,
med: r.attrs.med,
communities: r
.attrs
.communities
.iter()
.copied()
.map(community::format_standard)
.collect(),
large_communities: r
.attrs
.large_communities
.iter()
.map(community::format_large)
.collect(),
announced_at_unix: unix_secs(r.announced_at),
}
}
}
/// Human-oriented digest of a path attribute list.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AttrSummary {
pub origin: Option<String>,
/// AS_PATH flattened in order; AS_SETs are included in place.
pub as_path: Vec<u32>,
pub next_hop: Option<String>,
pub med: Option<u32>,
pub local_pref: Option<u32>,
pub communities: Vec<String>,
pub large_communities: Vec<String>,
}
pub fn summarize(attributes: &[PathAttribute], next_hop: Option<&NlriNextHop>) -> AttrSummary {
let mut s = AttrSummary {
// A multiprotocol route has its next hop here; an IPv4 route has it
// in the NEXT_HOP attribute below.
next_hop: next_hop.map(ToString::to_string),
..Default::default()
};
for attr in attributes {
match attr {
PathAttribute::Origin(o) => s.origin = Some(origin_name(*o).to_string()),
PathAttribute::AsPath(p) => {
s.as_path = p
.segments
.iter()
.flat_map(|seg| seg.path.iter().copied())
.collect()
}
PathAttribute::NextHop(nh) => s.next_hop = Some(nh.0.to_string()),
PathAttribute::MultiExitDisc(m) => s.med = Some(m.0),
PathAttribute::LocalPref(l) => s.local_pref = Some(l.0),
PathAttribute::Communities(c) => {
s.communities =
c.0.iter()
.copied()
.map(community::format_standard)
.collect()
}
PathAttribute::LargeCommunities(l) => {
s.large_communities = l.communities.iter().map(community::format_large).collect()
}
_ => {}
}
}
s
}
#[derive(Debug, Clone, Serialize)]
pub struct ReceivedRouteView {
/// The peer's configured name, if it has one.
pub peer_name: Option<String>,
pub peer_addr: String,
pub peer_asn: u32,
pub prefix: String,
/// The peer's identifier for the route: zero unless the peer sends
/// several routes for a prefix (ADD-PATH, RFC 7911).
pub path_id: u32,
/// Whether this is the route chosen for its prefix.
pub best: bool,
/// What route origin validation says of the route: `valid` or
/// `not found`. (An invalid route is not in the table, unless its
/// peer's are taken regardless; then `invalid`.) Absent where
/// validation is not configured.
pub rpki: Option<&'static str>,
/// What the ASPAs say of the route's path: `valid` or `unknown`
/// (`invalid` only if the peer's are taken regardless). Absent where
/// validation is not configured, for a peer whose role is not, and
/// for one in our own AS.
pub aspa: Option<&'static str>,
pub summary: AttrSummary,
/// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop
/// is in the summary and its prefixes are routes of their own.
pub attributes: Vec<PathAttribute>,
}
/// Who the routes in the table are from: the table knows peers by number,
/// the sessions know which number is whose.
pub(crate) struct PeerNames {
peers: HashMap<PeerIndex, (PeerId, Option<String>, Option<PathCheck>)>,
/// What routes are validated against, where they are.
rpki: Option<Update>,
}
impl PeerNames {
pub(crate) fn of(state: &AppState) -> Self {
Self {
peers: state
.registry
.list()
.into_iter()
.map(|session| {
// How the peer's paths are verified, or would be if
// its invalid routes were not taken regardless.
let check = session
.role
.filter(|_| !session.ibgp)
.map(|role| PathCheck::new(role, session.peer.asn));
(session.rib_peer, (session.peer, session.name, check))
})
.collect(),
rpki: state.rpki.as_ref().map(|rpki| rpki.current()),
}
}
/// The peers that `wanted` names: by configured name, or by address.
fn matching(&self, wanted: &str) -> Vec<PeerIndex> {
let address: Option<IpAddr> = wanted.parse().ok();
self.peers
.iter()
.filter(|(_, (peer, name, _))| {
name.as_deref() == Some(wanted) || address == Some(peer.addr.ip())
})
.map(|(index, _)| *index)
.collect()
}
/// A view of one route. `None` if the route's session has just ended:
/// the route is on its way out of the table.
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> {
let (peer, name, check) = self.peers.get(&route.peer)?;
let origin = Some(route.attrs.key().origin_as).filter(|origin| *origin != 0);
// What a session stored was accepted by the decoder, and parses.
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
Some(ReceivedRouteView {
peer_name: name.clone(),
peer_addr: peer.addr.to_string(),
peer_asn: peer.asn,
prefix: route.prefix.to_string(),
path_id: route.path_id,
best: route.best,
rpki: self
.rpki
.as_ref()
.map(|rpki| rpki.vrps.validate(&route.prefix, origin).as_str()),
aspa: self.rpki.as_ref().zip(*check).map(|(rpki, check)| {
let mut path = Vec::new();
let has_set = route.attrs.as_path(&mut path);
check.verify(&rpki.aspas, &path, has_set).as_str()
}),
summary: summarize(&attributes, next_hop.as_ref()),
attributes,
})
}
}
fn origin_name(o: OriginPathAttribute) -> &'static str {
match o {
OriginPathAttribute::IGP => "IGP",
OriginPathAttribute::EGP => "EGP",
OriginPathAttribute::INCOMPLETE => "INCOMPLETE",
}
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// A table could not be read because its actor has stopped. The daemon is
/// on its way down; saying so beats answering with an empty table.
pub struct Unavailable(RibStopped);
impl From<RibStopped> for Unavailable {
fn from(e: RibStopped) -> Self {
Self(e)
}
}
impl IntoResponse for Unavailable {
fn into_response(self) -> Response {
(StatusCode::SERVICE_UNAVAILABLE, self.0.to_string()).into_response()
}
}
pub async fn status(State(state): State<AppState>) -> Result<Json<StatusView>, Unavailable> {
Ok(Json(StatusView {
router_id: state.info.router_id.to_string(),
local_asn: state.info.local_asn,
version: state.info.version.clone(),
hostname: state.info.hostname.clone(),
started_at_unix: unix_secs(state.info.started_at),
uptime_secs: state.info.started.elapsed().as_secs(),
sessions: state.registry.list().len(),
local_routes: state.local_rib.len().await?,
received_routes: state.rib.len(),
received_prefixes: state.rib.prefix_count(),
}))
}
/// The configuration file the daemon was started with.
#[derive(Debug, Clone, Serialize)]
pub struct ConfigView {
pub path: Option<String>,
/// Raw file contents as read at startup; later edits to the file are not
/// reflected until the daemon restarts.
pub text: Option<String>,
}
pub async fn config(State(state): State<AppState>) -> Json<ConfigView> {
let (path, text) = state.info.config.clone().unzip();
Json(ConfigView { path, text })
}
pub async fn peers(State(state): State<AppState>) -> Json<Vec<PeerView>> {
Json(peer_views(&state.registry))
}
pub async fn local_routes(
State(state): State<AppState>,
) -> Result<Json<Vec<LocalRouteView>>, Unavailable> {
let mut routes: Vec<LocalRouteView> = state
.local_rib
.snapshot()
.await?
.iter()
.map(LocalRouteView::from)
.collect();
routes.sort_by(|a, b| a.prefix.cmp(&b.prefix));
Ok(Json(routes))
}
/// How many routes [`received_routes`] returns unless asked for another
/// number. The table can hold hundreds of millions.
const DEFAULT_ROUTE_LIMIT: usize = 1000;
/// The most routes [`received_routes`] returns, whatever is asked for:
/// each is copied out of the table and written as JSON.
const MAX_ROUTE_LIMIT: usize = 100_000;
/// Which routes to return. At most one of `prefix`, `address` and `within`
/// says where to look; `peer` and `best` narrow what is found there.
#[derive(Debug, Default, Deserialize)]
pub struct RouteQuery {
/// At most this many routes.
limit: Option<usize>,
/// The routes for exactly this prefix, e.g. `193.0.0.0/21`.
prefix: Option<String>,
/// The routes for every prefix that covers this address, the most
/// specific prefix first: what a packet to the address could follow.
address: Option<String>,
/// The routes for this prefix and every prefix inside it.
within: Option<String>,
/// Only routes from this peer: its configured name, or its address.
peer: Option<String>,
/// Only the best route of each prefix.
#[serde(default)]
best: bool,
}
/// Why a query was not answered.
pub enum QueryError {
/// The query makes no sense; the text says how.
Bad(String),
/// Answering it failed here.
Failed,
}
impl IntoResponse for QueryError {
fn into_response(self) -> Response {
match self {
Self::Bad(why) => (StatusCode::BAD_REQUEST, why).into_response(),
Self::Failed => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
}
/// Where in the table a query looks.
enum Scope {
/// Everywhere.
All,
Exactly(IpPrefix),
Covering(IpPrefix),
Within(IpPrefix),
}
impl Scope {
fn of(query: &RouteQuery) -> Result<Self, QueryError> {
let prefix = |text: &str| {
IpPrefix::try_from(text)
.map_err(|e| QueryError::Bad(format!("{text:?} is not a prefix: {e}")))
};
match (&query.prefix, &query.address, &query.within) {
(None, None, None) => Ok(Self::All),
(Some(text), None, None) => Ok(Self::Exactly(prefix(text)?)),
(None, Some(text), None) => {
let address: IpAddr = text
.parse()
.map_err(|_| QueryError::Bad(format!("{text:?} is not an IP address")))?;
// The address as a prefix as long as an address is.
let host = match address {
IpAddr::V4(v4) => IpPrefix::new(IpVersion::Ipv4, v4.octets(), 32),
IpAddr::V6(v6) => IpPrefix::new(IpVersion::Ipv6, v6.octets(), 128),
};
host.map(Self::Covering)
.map_err(|e| QueryError::Bad(e.to_string()))
}
(None, None, Some(text)) => Ok(Self::Within(prefix(text)?)),
_ => Err(QueryError::Bad(
"give at most one of prefix, address and within".into(),
)),
}
}
}
/// The routes a query asks for, in the order to show them.
fn find_routes(
table: &RibTable,
names: &PeerNames,
scope: &Scope,
query: &RouteQuery,
limit: usize,
) -> Vec<ReceivedRouteView> {
let peers = query.peer.as_deref().map(|peer| names.matching(peer));
let wanted = |peer: PeerIndex, best: bool| {
(!query.best || best) && peers.as_ref().is_none_or(|peers| peers.contains(&peer))
};
let mut routes = match scope {
Scope::Exactly(prefix) => table.routes(prefix),
// From the longest prefix that could cover the address to the
// shortest: one lookup for each length.
Scope::Covering(host) => (0..=host.length())
.rev()
.flat_map(|length| table.routes(&host.shortened(length)))
.collect(),
Scope::Within(outer) => table.routes_where(limit, |route| {
outer.contains(&route.prefix) && wanted(route.peer, route.best)
}),
Scope::All => table.routes_where(limit, |route| wanted(route.peer, route.best)),
};
routes.retain(|route| wanted(route.peer, route.best));
routes.truncate(limit);
// For each prefix the best route first. The prefixes in address
// order; for a lookup by address, which finds one prefix of each
// length, the most specific first.
if matches!(scope, Scope::Covering(_)) {
routes.sort_by_key(|route| (std::cmp::Reverse(route.prefix.length()), !route.best));
} else {
routes.sort_by_key(|route| (sort_key(&route.prefix), !route.best));
}
routes
.iter()
.filter_map(|route| names.view(route))
.collect()
}
/// Orders prefixes by IP version, then address, then shorter before
/// longer.
fn sort_key(prefix: &IpPrefix) -> (u16, [u8; 16], u8) {
let mut address = [0u8; 16];
address[..prefix.bytes().len()].copy_from_slice(prefix.bytes());
(prefix.address_family() as u16, address, prefix.length())
}
/// Routes learned from peers. Without a query, all the routes of as many
/// prefixes as fit in the limit; see [`RouteQuery`] for looking up
/// particular ones. `/api/status` has the totals.
pub async fn received_routes(
State(state): State<AppState>,
Query(query): Query<RouteQuery>,
) -> Result<Json<Vec<ReceivedRouteView>>, QueryError> {
let limit = query
.limit
.unwrap_or(DEFAULT_ROUTE_LIMIT)
.min(MAX_ROUTE_LIMIT);
let scope = Scope::of(&query)?;
let names = PeerNames::of(&state);
// Looking through the table, copying, parsing and sorting many routes
// takes long enough to keep off the threads that sessions run on.
let routes =
tokio::task::spawn_blocking(move || find_routes(&state.rib, &names, &scope, &query, limit))
.await
// Only if making the views panicked.
.map_err(|_| QueryError::Failed)?;
Ok(Json(routes))
}
/// Validation with the RPKI: where the data comes from and how fresh it
/// is.
#[derive(Debug, Clone, Serialize)]
pub struct RpkiView {
/// Whether validation is configured at all.
pub enabled: bool,
/// The cache in use, or last tried.
pub cache: Option<String>,
/// `connecting`, `synchronising` or `synchronised`.
pub state: Option<&'static str>,
pub session_id: Option<u16>,
pub serial: Option<u32>,
pub protocol_version: Option<u8>,
pub vrps_ipv4: usize,
pub vrps_ipv6: usize,
/// How many ASes have an ASPA. A cache that speaks a version of the
/// protocol before 2 has none to give.
pub aspas: usize,
pub updated_at_unix: Option<u64>,
/// When the data is dropped unless a cache confirms it first.
pub expires_at_unix: Option<u64>,
pub last_error: Option<String>,
}
pub async fn rpki(State(state): State<AppState>) -> Json<RpkiView> {
let Some(status) = state.rpki.as_ref().map(|rpki| rpki.status()) else {
return Json(RpkiView {
enabled: false,
cache: None,
state: None,
session_id: None,
serial: None,
protocol_version: None,
vrps_ipv4: 0,
vrps_ipv6: 0,
aspas: 0,
updated_at_unix: None,
expires_at_unix: None,
last_error: None,
});
};
Json(RpkiView {
enabled: true,
cache: status.cache.map(|cache| cache.to_string()),
state: Some(match status.state {
CacheState::Connecting => "connecting",
CacheState::Synchronising => "synchronising",
CacheState::Synchronised => "synchronised",
}),
session_id: status.session_id,
serial: status.serial,
protocol_version: Some(status.protocol_version),
vrps_ipv4: status.vrps_ipv4,
vrps_ipv6: status.vrps_ipv6,
aspas: status.aspas,
updated_at_unix: status.updated_at.map(unix_secs),
expires_at_unix: status.expires_at.map(unix_secs),
last_error: status.last_error,
})
}
/// What one peer announced that was refused.
#[derive(Debug, Clone, Serialize)]
pub struct RefusalsView {
/// The peer's configured name, or its address if it has none.
pub peer: String,
pub peer_addr: String,
/// How many routes were refused since the daemon started.
pub total: u64,
/// The same, by reason.
pub by_reason: BTreeMap<&'static str, u64>,
/// The latest of them, newest first.
pub recent: Vec<RefusedRouteView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RefusedRouteView {
pub prefix: String,
pub path_id: u32,
pub reason: &'static str,
pub as_path: Vec<u32>,
pub at_unix: u64,
}
#[derive(Debug, Default, Deserialize)]
pub struct RefusalQuery {
/// Only this peer: its configured name, or its address.
peer: Option<String>,
}
/// The routes that peers announced and their import policies refused: for
/// each peer that had any, how many and why, and the latest of them.
pub async fn rejected_routes(
State(state): State<AppState>,
Query(query): Query<RefusalQuery>,
) -> Json<Vec<RefusalsView>> {
let mut peers: Vec<RefusalsView> = state
.refusals
.peers()
.into_iter()
.filter(|(address, record)| {
query
.peer
.as_deref()
.is_none_or(|wanted| record.label() == wanted || wanted.parse() == Ok(*address))
})
.map(|(address, record)| RefusalsView {
peer: record.label().to_string(),
peer_addr: address.to_string(),
total: record.total(),
by_reason: record
.by_reason()
.into_iter()
.map(|(reason, count)| (reason.as_str(), count))
.collect(),
recent: record
.recent()
.into_iter()
.map(|route| RefusedRouteView {
prefix: route.prefix.to_string(),
path_id: route.path_id,
reason: route.reason.as_str(),
as_path: route.as_path.to_vec(),
at_unix: unix_secs(route.at),
})
.collect(),
})
.collect();
peers.sort_by(|a, b| a.peer.cmp(&b.peer));
Json(peers)
}