client, server: invite expirations

The server now expects a UNIX timestamp after which the invitation will be expired. If a peer invite hasn't been redeemed after it expires, the server will clean up old entries and allow the IP to be re-allocated for a new invite.

Closes #24
pull/72/head
Jake McGinty 2021-05-09 00:32:51 +09:00 committed by GitHub
parent 76500b3778
commit 2ce552cc36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 410 additions and 229 deletions

8
Cargo.lock generated
View File

@ -792,9 +792,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.5.3"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5f1ceb7f74abbce32601642fcf8e8508a8a8991e0621c7d750295b9095702b"
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
dependencies = [
"aho-corasick",
"memchr",
@ -1220,9 +1220,9 @@ dependencies = [
[[package]]
name = "url"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
dependencies = [
"form_urlencoded",
"idna",

View File

@ -135,7 +135,7 @@ mod tests {
static ref BASE_PEERS: Vec<Peer> = vec![Peer {
id: 0,
contents: PeerContents {
name: "blah".to_string(),
name: "blah".parse().unwrap(),
ip: "10.0.0.1".parse().unwrap(),
cidr_id: 1,
public_key: "abc".to_string(),
@ -144,6 +144,7 @@ mod tests {
is_disabled: false,
is_redeemed: true,
persistent_keepalive_interval: None,
invite_expires: None,
}
}];
static ref BASE_CIDRS: Vec<Cidr> = vec![Cidr {

View File

@ -38,6 +38,7 @@ SERVER_CONTAINER=$(cmd docker run -itd --rm \
--cap-add NET_ADMIN \
innernet-server)
info "server started as $SERVER_CONTAINER"
info "Waiting for server to initialize."
cmd sleep 10
@ -49,6 +50,7 @@ PEER1_CONTAINER=$(cmd docker create --rm -it \
--env INTERFACE=evilcorp \
--cap-add NET_ADMIN \
innernet)
info "peer1 started as $PEER1_CONTAINER"
cmd docker cp "$tmp_dir/peer1.toml" "$PEER1_CONTAINER:/app/invite.toml"
cmd docker start "$PEER1_CONTAINER"
sleep 5
@ -75,6 +77,7 @@ cmd docker exec "$PEER1_CONTAINER" innernet \
--admin false \
--auto-ip \
--save-config "/app/peer2.toml" \
--invite-expires "30d" \
--yes
cmd docker cp "$PEER1_CONTAINER:/app/peer2.toml" "$tmp_dir"
@ -85,6 +88,7 @@ PEER2_CONTAINER=$(docker create --rm -it \
--cap-add NET_ADMIN \
--env INTERFACE=evilcorp \
innernet)
info "peer2 started as $PEER2_CONTAINER"
cmd docker cp "$tmp_dir/peer2.toml" "$PEER2_CONTAINER:/app/invite.toml"
cmd docker start "$PEER2_CONTAINER"
sleep 10

View File

@ -7,8 +7,19 @@ innernet-server new \
--external-endpoint "172.18.1.1:51820" \
--listen-port 51820
innernet-server add-cidr evilcorp --name "humans" --cidr "10.66.1.0/24" --parent "evilcorp" --yes
innernet-server add-cidr evilcorp \
--name "humans" \
--cidr "10.66.1.0/24" \
--parent "evilcorp" \
--yes
innernet-server add-peer evilcorp --name "admin" --cidr "humans" --admin true --auto-ip --save-config "peer1.toml" --yes
innernet-server add-peer evilcorp \
--name "admin" \
--cidr "humans" \
--admin true \
--auto-ip \
--save-config "peer1.toml" \
--invite-expires "30d" \
--yes
innernet-server serve evilcorp

View File

@ -64,10 +64,10 @@ mod tests {
use crate::{test, DatabasePeer};
use anyhow::Result;
use bytes::Buf;
use shared::Cidr;
use shared::{Cidr, Error};
#[tokio::test]
async fn test_cidr_add() -> Result<()> {
async fn test_cidr_add() -> Result<(), Error> {
let server = test::Server::new()?;
let old_cidrs = DatabaseCidr::list(&server.db().lock())?;
@ -95,7 +95,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_name_uniqueness() -> Result<()> {
async fn test_cidr_name_uniqueness() -> Result<(), Error> {
let server = test::Server::new()?;
let contents = CidrContents {
@ -125,7 +125,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_create_auth() -> Result<()> {
async fn test_cidr_create_auth() -> Result<(), Error> {
let server = test::Server::new()?;
let contents = CidrContents {
@ -143,7 +143,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_bad_parent() -> Result<()> {
async fn test_cidr_bad_parent() -> Result<(), Error> {
let server = test::Server::new()?;
let contents = CidrContents {
@ -171,7 +171,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_overlap() -> Result<()> {
async fn test_cidr_overlap() -> Result<(), Error> {
let server = test::Server::new()?;
let contents = CidrContents {
@ -188,7 +188,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_delete_fail_with_child_cidr() -> Result<()> {
async fn test_cidr_delete_fail_with_child_cidr() -> Result<(), Error> {
let server = test::Server::new()?;
let experimental_cidr = DatabaseCidr::create(
@ -241,7 +241,7 @@ mod tests {
}
#[tokio::test]
async fn test_cidr_delete_fail_with_peer_inside() -> Result<()> {
async fn test_cidr_delete_fail_with_peer_inside() -> Result<(), Error> {
let server = test::Server::new()?;
let experimental_cidr = DatabaseCidr::create(

View File

@ -94,12 +94,11 @@ mod handlers {
mod tests {
use super::*;
use crate::test;
use anyhow::Result;
use bytes::Buf;
use shared::Peer;
use shared::{Error, Peer};
#[tokio::test]
async fn test_add_peer() -> Result<()> {
async fn test_add_peer() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -125,21 +124,13 @@ mod tests {
}
#[tokio::test]
async fn test_add_peer_with_invalid_name() -> Result<()> {
let server = test::Server::new()?;
let peer = test::developer_peer_contents("devel oper", "10.80.64.4")?;
let res = server
.form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
.await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
async fn test_add_peer_with_invalid_name() -> Result<(), Error> {
assert!(test::developer_peer_contents("devel oper", "10.80.64.4").is_err());
Ok(())
}
#[tokio::test]
async fn test_add_peer_with_duplicate_name() -> Result<()> {
async fn test_add_peer_with_duplicate_name() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -161,7 +152,7 @@ mod tests {
}
#[tokio::test]
async fn test_add_peer_with_duplicate_ip() -> Result<()> {
async fn test_add_peer_with_duplicate_ip() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -183,7 +174,7 @@ mod tests {
}
#[tokio::test]
async fn test_add_peer_with_outside_cidr_range_ip() -> Result<()> {
async fn test_add_peer_with_outside_cidr_range_ip() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -217,7 +208,7 @@ mod tests {
}
#[tokio::test]
async fn test_add_peer_from_non_admin() -> Result<()> {
async fn test_add_peer_from_non_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
@ -233,12 +224,12 @@ mod tests {
}
#[tokio::test]
async fn test_update_peer_from_admin() -> Result<()> {
async fn test_update_peer_from_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?;
let change = PeerContents {
name: "new-peer-name".to_string(),
name: "new-peer-name".parse()?,
..old_peer.contents.clone()
};
@ -255,12 +246,12 @@ mod tests {
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let new_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?;
assert_eq!(new_peer.name, "new-peer-name");
assert_eq!(&*new_peer.name, "new-peer-name");
Ok(())
}
#[tokio::test]
async fn test_update_peer_from_non_admin() -> Result<()> {
async fn test_update_peer_from_non_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
@ -281,7 +272,7 @@ mod tests {
}
#[tokio::test]
async fn test_list_all_peers_from_admin() -> Result<()> {
async fn test_list_all_peers_from_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let res = server
.request(test::ADMIN_PEER_IP, "GET", "/v1/admin/peers")
@ -291,7 +282,7 @@ mod tests {
let whole_body = hyper::body::aggregate(res).await?;
let peers: Vec<Peer> = serde_json::from_reader(whole_body.reader())?;
let peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
let peer_names = peers.iter().map(|p| &*p.contents.name).collect::<Vec<_>>();
// An admin peer should see all the peers.
assert_eq!(
&[
@ -309,7 +300,7 @@ mod tests {
}
#[tokio::test]
async fn test_list_all_peers_from_non_admin() -> Result<()> {
async fn test_list_all_peers_from_non_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let res = server
.request(test::DEVELOPER1_PEER_IP, "GET", "/v1/admin/peers")
@ -321,7 +312,7 @@ mod tests {
}
#[tokio::test]
async fn test_delete() -> Result<()> {
async fn test_delete() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -344,7 +335,7 @@ mod tests {
}
#[tokio::test]
async fn test_delete_from_non_admin() -> Result<()> {
async fn test_delete_from_non_admin() -> Result<(), Error> {
let server = test::Server::new()?;
let old_peers = DatabasePeer::list(&server.db().lock())?;
@ -367,7 +358,7 @@ mod tests {
}
#[tokio::test]
async fn test_delete_unknown_id() -> Result<()> {
async fn test_delete_unknown_id() -> Result<(), Error> {
let server = test::Server::new()?;
let res = server

View File

@ -80,43 +80,37 @@ mod handlers {
let old_public_key = wgctrl::Key::from_base64(&selected_peer.public_key)
.map_err(|_| ServerError::WireGuard)?;
if selected_peer.is_redeemed {
Ok(Response::builder()
.status(StatusCode::GONE)
.body(Body::empty())?)
} else {
selected_peer.redeem(&conn, &form.public_key)?;
selected_peer.redeem(&conn, &form.public_key)?;
if cfg!(not(test)) {
let interface = session.context.interface;
if cfg!(not(test)) {
let interface = session.context.interface;
// If we were to modify the WireGuard interface immediately, the HTTP response wouldn't
// get through. Instead, we need to wait a reasonable amount for the HTTP response to
// flush, then update the interface.
//
// The client is also expected to wait the same amount of time after receiving a success
// response from /redeem.
//
// This might be avoidable if we were able to run code after we were certain the response
// had flushed over the TCP socket, but that isn't easily accessible from this high-level
// web framework.
tokio::task::spawn(async move {
tokio::time::sleep(*REDEEM_TRANSITION_WAIT).await;
log::info!(
"WireGuard: adding new peer {}, removing old pubkey {}",
&*selected_peer,
old_public_key.to_base64()
);
DeviceConfigBuilder::new()
.remove_peer_by_key(&old_public_key)
.add_peer((&*selected_peer).into())
.apply(&interface)
.map_err(|e| log::error!("{:?}", e))
.ok();
});
}
status_response(StatusCode::NO_CONTENT)
// If we were to modify the WireGuard interface immediately, the HTTP response wouldn't
// get through. Instead, we need to wait a reasonable amount for the HTTP response to
// flush, then update the interface.
//
// The client is also expected to wait the same amount of time after receiving a success
// response from /redeem.
//
// This might be avoidable if we were able to run code after we were certain the response
// had flushed over the TCP socket, but that isn't easily accessible from this high-level
// web framework.
tokio::task::spawn(async move {
tokio::time::sleep(*REDEEM_TRANSITION_WAIT).await;
log::info!(
"WireGuard: adding new peer {}, removing old pubkey {}",
&*selected_peer,
old_public_key.to_base64()
);
DeviceConfigBuilder::new()
.remove_peer_by_key(&old_public_key)
.add_peer((&*selected_peer).into())
.apply(&interface)
.map_err(|e| log::error!("{:?}", e))
.ok();
});
}
status_response(StatusCode::NO_CONTENT)
}
/// Redeems an invitation. An invitation includes a WireGuard keypair generated by either the server
@ -147,14 +141,15 @@ mod handlers {
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime};
use super::*;
use crate::{db::DatabaseAssociation, test};
use anyhow::Result;
use bytes::Buf;
use shared::{AssociationContents, CidrContents, EndpointContents};
use shared::{AssociationContents, CidrContents, EndpointContents, Error};
#[tokio::test]
async fn test_get_state_from_developer1() -> Result<()> {
async fn test_get_state_from_developer1() -> Result<(), Error> {
let server = test::Server::new()?;
let res = server
.request(test::DEVELOPER1_PEER_IP, "GET", "/v1/user/state")
@ -164,7 +159,7 @@ mod tests {
let whole_body = hyper::body::aggregate(res).await?;
let State { peers, .. } = serde_json::from_reader(whole_body.reader())?;
let mut peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
let mut peer_names = peers.iter().map(|p| &*p.contents.name).collect::<Vec<_>>();
peer_names.sort();
// Developers should see only peers in infra CIDR and developer CIDR.
assert_eq!(
@ -176,7 +171,7 @@ mod tests {
}
#[tokio::test]
async fn test_override_endpoint() -> Result<()> {
async fn test_override_endpoint() -> Result<(), Error> {
let server = test::Server::new()?;
assert_eq!(
server
@ -222,7 +217,7 @@ mod tests {
}
#[tokio::test]
async fn test_list_peers_from_unknown_ip() -> Result<()> {
async fn test_list_peers_from_unknown_ip() -> Result<(), Error> {
let server = test::Server::new()?;
// Request comes from an unknown IP.
@ -234,7 +229,7 @@ mod tests {
}
#[tokio::test]
async fn test_list_peers_for_developer_subcidr() -> Result<()> {
async fn test_list_peers_for_developer_subcidr() -> Result<(), Error> {
let server = test::Server::new()?;
{
let db = server.db.lock();
@ -286,7 +281,7 @@ mod tests {
assert_eq!(res.status(), StatusCode::OK);
let whole_body = hyper::body::aggregate(res).await?;
let State { peers, .. } = serde_json::from_reader(whole_body.reader())?;
let mut peer_names = peers.iter().map(|p| &p.contents.name).collect::<Vec<_>>();
let mut peer_names = peers.iter().map(|p| &*p.contents.name).collect::<Vec<_>>();
peer_names.sort();
// Developers should see only peers in infra CIDR and developer CIDR.
assert_eq!(
@ -304,7 +299,7 @@ mod tests {
}
#[tokio::test]
async fn test_redeem() -> Result<()> {
async fn test_redeem() -> Result<(), Error> {
let server = test::Server::new()?;
let experimental_cidr = DatabaseCidr::create(
@ -323,6 +318,7 @@ mod tests {
false,
)?;
peer_contents.is_redeemed = false;
peer_contents.invite_expires = Some(SystemTime::now() + Duration::from_secs(100));
let _experiment_peer = DatabasePeer::create(&server.db().lock(), peer_contents)?;
// Step 1: Ensure that before redeeming, other endpoints aren't yet accessible.
@ -363,4 +359,49 @@ mod tests {
assert_eq!(res.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn test_redeem_expired() -> Result<(), Error> {
let server = test::Server::new()?;
let experimental_cidr = DatabaseCidr::create(
&server.db().lock(),
CidrContents {
name: "experimental".to_string(),
cidr: test::EXPERIMENTAL_CIDR.parse()?,
parent: Some(test::ROOT_CIDR_ID),
},
)?;
let mut peer_contents = test::peer_contents(
"experiment-peer",
test::EXPERIMENT_SUBCIDR_PEER_IP,
experimental_cidr.id,
false,
)?;
peer_contents.is_redeemed = false;
peer_contents.invite_expires = Some(SystemTime::now() - Duration::from_secs(1));
let _experiment_peer = DatabasePeer::create(&server.db().lock(), peer_contents)?;
// Step 1: Ensure that before redeeming, other endpoints aren't yet accessible.
let res = server
.request(test::EXPERIMENT_SUBCIDR_PEER_IP, "GET", "/v1/user/state")
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
// Step 2: Ensure that redemption works.
let body = RedeemContents {
public_key: "YBVIgpfLbi/knrMCTEb0L6eVy0daiZnJJQkxBK9s+2I=".into(),
};
let res = server
.form_request(
test::EXPERIMENT_SUBCIDR_PEER_IP,
"POST",
"/v1/user/redeem",
&body,
)
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
Ok(())
}
}

View File

@ -5,3 +5,30 @@ pub mod peer;
pub use association::DatabaseAssociation;
pub use cidr::DatabaseCidr;
pub use peer::DatabasePeer;
use rusqlite::params;
const INVITE_EXPIRATION_VERSION: usize = 1;
pub const CURRENT_VERSION: usize = INVITE_EXPIRATION_VERSION;
pub fn auto_migrate(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
let old_version: usize = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if old_version < INVITE_EXPIRATION_VERSION {
conn.execute(
"ALTER TABLE peers ADD COLUMN invite_expires INTEGER",
params![],
)?;
}
conn.pragma_update(None, "user_version", &CURRENT_VERSION)?;
if old_version != CURRENT_VERSION {
log::info!(
"migrated db version from {} to {}",
old_version,
CURRENT_VERSION
);
}
Ok(())
}

View File

@ -7,6 +7,7 @@ use shared::{Peer, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS};
use std::{
net::IpAddr,
ops::{Deref, DerefMut},
time::{Duration, SystemTime},
};
use structopt::lazy_static;
@ -20,6 +21,7 @@ pub static CREATE_TABLE_SQL: &str = "CREATE TABLE peers (
is_admin INTEGER DEFAULT 0 NOT NULL, /* Admin capabilities are per-peer, not per-CIDR. */
is_disabled INTEGER DEFAULT 0 NOT NULL, /* Is the peer disabled? (peers cannot be deleted) */
is_redeemed INTEGER DEFAULT 0 NOT NULL, /* Has the peer redeemed their invite yet? */
invite_expires INTEGER, /* The UNIX time that an invited peer can no longer redeem. */
FOREIGN KEY (cidr_id)
REFERENCES cidrs (id)
ON UPDATE RESTRICT
@ -68,6 +70,7 @@ impl DatabasePeer {
is_admin,
is_disabled,
is_redeemed,
invite_expires,
..
} = &contents;
log::info!("creating peer {:?}", contents);
@ -88,10 +91,15 @@ impl DatabasePeer {
return Err(ServerError::InvalidQuery);
}
let invite_expires = invite_expires
.map(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.flatten()
.map(|t| t.as_secs());
conn.execute(
"INSERT INTO peers (name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO peers (name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed, invite_expires) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
name,
&**name,
ip.to_string(),
cidr_id,
&public_key,
@ -99,6 +107,7 @@ impl DatabasePeer {
is_admin,
is_disabled,
is_redeemed,
invite_expires,
],
)?;
let id = conn.last_insert_rowid();
@ -137,7 +146,7 @@ impl DatabasePeer {
is_disabled = ?4
WHERE id = ?5",
params![
new_contents.name,
&*new_contents.name,
new_contents
.endpoint
.as_ref()
@ -163,6 +172,14 @@ impl DatabasePeer {
}
pub fn redeem(&mut self, conn: &Connection, pubkey: &str) -> Result<(), ServerError> {
if self.is_redeemed {
return Err(ServerError::Gone);
}
if matches!(self.invite_expires, Some(time) if time < SystemTime::now()) {
return Err(ServerError::Unauthorized);
}
match conn.execute(
"UPDATE peers SET is_redeemed = 1, public_key = ?1 WHERE id = ?2 AND is_redeemed = 0",
params![pubkey, self.id],
@ -178,7 +195,10 @@ impl DatabasePeer {
fn from_row(row: &rusqlite::Row) -> Result<Self, rusqlite::Error> {
let id = row.get(0)?;
let name = row.get(1)?;
let name = row
.get::<_, String>(1)?
.parse()
.map_err(|_| rusqlite::Error::ExecuteReturnedResults)?;
let ip: IpAddr = row
.get::<_, String>(2)?
.parse()
@ -191,6 +211,10 @@ impl DatabasePeer {
let is_admin = row.get(6)?;
let is_disabled = row.get(7)?;
let is_redeemed = row.get(8)?;
let invite_expires = row
.get::<_, Option<u64>>(9)?
.map(|unixtime| SystemTime::UNIX_EPOCH + Duration::from_secs(unixtime));
let persistent_keepalive_interval = Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS);
Ok(Peer {
@ -205,6 +229,7 @@ impl DatabasePeer {
is_admin,
is_disabled,
is_redeemed,
invite_expires,
},
}
.into())
@ -213,7 +238,7 @@ impl DatabasePeer {
pub fn get(conn: &Connection, id: i64) -> Result<Self, ServerError> {
let result = conn.query_row(
"SELECT
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed, invite_expires
FROM peers
WHERE id = ?1",
params![id],
@ -226,7 +251,7 @@ impl DatabasePeer {
pub fn get_from_ip(conn: &Connection, ip: IpAddr) -> Result<Self, rusqlite::Error> {
let result = conn.query_row(
"SELECT
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed
id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed, invite_expires
FROM peers
WHERE ip = ?1",
params![ip.to_string()],
@ -264,7 +289,7 @@ impl DatabasePeer {
UNION
SELECT id FROM cidrs, associated_subcidrs WHERE cidrs.parent=associated_subcidrs.cidr_id
)
SELECT DISTINCT peers.id, peers.name, peers.ip, peers.cidr_id, peers.public_key, peers.endpoint, peers.is_admin, peers.is_disabled, peers.is_redeemed
SELECT DISTINCT peers.id, peers.name, peers.ip, peers.cidr_id, peers.public_key, peers.endpoint, peers.is_admin, peers.is_disabled, peers.is_redeemed, peers.invite_expires
FROM peers
JOIN associated_subcidrs ON peers.cidr_id=associated_subcidrs.cidr_id
WHERE peers.is_disabled = 0 AND peers.is_redeemed = 1;",
@ -277,10 +302,23 @@ impl DatabasePeer {
pub fn list(conn: &Connection) -> Result<Vec<Self>, ServerError> {
let mut stmt = conn.prepare_cached(
"SELECT id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed FROM peers",
"SELECT id, name, ip, cidr_id, public_key, endpoint, is_admin, is_disabled, is_redeemed, invite_expires FROM peers",
)?;
let peer_iter = stmt.query_map(params![], Self::from_row)?;
Ok(peer_iter.collect::<Result<_, _>>()?)
}
pub fn delete_expired_invites(conn: &Connection) -> Result<usize, ServerError> {
let unix_now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("Something is horribly wrong with system time.");
let deleted = conn.execute(
"DELETE FROM peers
WHERE is_redeemed = 0 AND invite_expires < ?1",
params![unix_now.as_secs()],
)?;
Ok(deleted)
}
}

View File

@ -1,63 +0,0 @@
use parking_lot::RwLock;
use wgctrl::{DeviceInfo, InterfaceName};
use std::{
collections::HashMap,
io,
net::SocketAddr,
sync::{
mpsc::{sync_channel, SyncSender, TryRecvError},
Arc,
},
thread,
time::Duration,
};
pub struct Endpoints {
pub endpoints: Arc<RwLock<HashMap<String, SocketAddr>>>,
stop_tx: SyncSender<()>,
}
impl std::ops::Deref for Endpoints {
type Target = RwLock<HashMap<String, SocketAddr>>;
fn deref(&self) -> &Self::Target {
&self.endpoints
}
}
impl Endpoints {
pub fn new(iface: &InterfaceName) -> Result<Self, io::Error> {
let endpoints = Arc::new(RwLock::new(HashMap::new()));
let (stop_tx, stop_rx) = sync_channel(1);
let iface = iface.to_owned();
let thread_endpoints = endpoints.clone();
log::info!("spawning endpoint watch thread.");
if cfg!(not(test)) {
thread::spawn(move || loop {
if matches!(stop_rx.try_recv(), Ok(_) | Err(TryRecvError::Disconnected)) {
break;
}
if let Ok(info) = DeviceInfo::get_by_name(&iface) {
for peer in info.peers {
if let Some(endpoint) = peer.config.endpoint {
thread_endpoints
.write()
.insert(peer.config.public_key.to_base64(), endpoint);
}
}
}
thread::sleep(Duration::from_secs(1));
});
}
Ok(Self { endpoints, stop_tx })
}
}
impl Drop for Endpoints {
fn drop(&mut self) {
let _ = self.stop_tx.send(());
}
}

View File

@ -14,6 +14,9 @@ pub enum ServerError {
#[error("invalid query")]
InvalidQuery,
#[error("endpoint gone")]
Gone,
#[error("internal database error")]
Database(#[from] rusqlite::Error),
@ -39,6 +42,7 @@ impl<'a> From<&'a ServerError> for StatusCode {
match error {
Unauthorized => StatusCode::UNAUTHORIZED,
NotFound => StatusCode::NOT_FOUND,
Gone => StatusCode::GONE,
InvalidQuery | Json(_) => StatusCode::BAD_REQUEST,
// Special-case the constraint violation situation.
Database(rusqlite::Error::SqliteFailure(libsqlite3_sys::Error { code, .. }, ..))

View File

@ -4,8 +4,7 @@ use dialoguer::{theme::ColorfulTheme, Input};
use indoc::printdoc;
use rusqlite::{params, Connection};
use shared::{
prompts::{self, hostname_validator},
CidrContents, Endpoint, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS,
prompts, CidrContents, Endpoint, Hostname, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS,
};
use wgctrl::KeyPair;
@ -17,6 +16,8 @@ fn create_database<P: AsRef<Path>>(
conn.execute(db::peer::CREATE_TABLE_SQL, params![])?;
conn.execute(db::association::CREATE_TABLE_SQL, params![])?;
conn.execute(db::cidr::CREATE_TABLE_SQL, params![])?;
conn.pragma_update(None, "user_version", &db::CURRENT_VERSION)?;
Ok(conn)
}
@ -24,7 +25,7 @@ fn create_database<P: AsRef<Path>>(
pub struct InitializeOpts {
/// The network name (ex: evilcorp)
#[structopt(long)]
pub network_name: Option<String>,
pub network_name: Option<Hostname>,
/// The network CIDR (ex: 10.42.0.0/16)
#[structopt(long)]
@ -78,7 +79,7 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(),
let _me = DatabasePeer::create(
&conn,
PeerContents {
name: SERVER_NAME.into(),
name: SERVER_NAME.parse()?,
ip: db_init_data.our_ip,
cidr_id: server_cidr.id,
public_key: db_init_data.public_key_base64,
@ -87,6 +88,7 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(),
is_disabled: false,
is_redeemed: true,
persistent_keepalive_interval: Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS),
invite_expires: None,
},
)
.map_err(|_| "failed to create innernet peer.".to_string())?;
@ -104,13 +106,12 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
)
})?;
let name: String = if let Some(name) = opts.network_name {
let name: Hostname = if let Some(name) = opts.network_name {
name
} else {
println!("Here you'll specify the network CIDR, which will encompass the entire network.");
Input::with_theme(&theme)
.with_prompt("Network name")
.validate_with(hostname_validator)
.interact()?
};

View File

@ -3,12 +3,12 @@ use dialoguer::Confirm;
use hyper::{http, server::conn::AddrStream, Body, Request, Response};
use indoc::printdoc;
use ipnetwork::IpNetwork;
use parking_lot::Mutex;
use parking_lot::{Mutex, RwLock};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use shared::{AddCidrOpts, AddPeerOpts, IoErrorContext, INNERNET_PUBKEY_HEADER};
use std::{
collections::VecDeque,
collections::{HashMap, VecDeque},
convert::TryInto,
env,
fs::File,
@ -17,6 +17,7 @@ use std::{
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use structopt::StructOpt;
use subtle::ConstantTimeEq;
@ -24,7 +25,6 @@ use wgctrl::{DeviceConfigBuilder, DeviceInfo, InterfaceName, Key, PeerConfigBuil
pub mod api;
pub mod db;
pub mod endpoints;
pub mod error;
#[cfg(test)]
mod test;
@ -33,7 +33,6 @@ pub mod util;
mod initialize;
use db::{DatabaseCidr, DatabasePeer};
pub use endpoints::Endpoints;
pub use error::ServerError;
use initialize::InitializeOpts;
use shared::{prompts, wg, CidrTree, Error, Interface, SERVER_CONFIG_DIR, SERVER_DATABASE_DIR};
@ -81,11 +80,12 @@ enum Command {
}
pub type Db = Arc<Mutex<Connection>>;
pub type Endpoints = Arc<RwLock<HashMap<String, SocketAddr>>>;
#[derive(Clone)]
pub struct Context {
pub db: Db,
pub endpoints: Arc<Endpoints>,
pub endpoints: Arc<RwLock<HashMap<String, SocketAddr>>>,
pub interface: InterfaceName,
pub public_key: Key,
}
@ -206,7 +206,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
},
Command::Uninstall { interface } => uninstall(&interface, &conf)?,
Command::Serve { interface } => serve(&interface, &conf).await?,
Command::Serve { interface } => serve(*interface, &conf).await?,
Command::AddPeer { interface, args } => add_peer(&interface, &conf, args)?,
Command::AddCidr { interface, args } => add_cidr(&interface, &conf, args)?,
}
@ -230,7 +230,7 @@ fn open_database_connection(
let conn = Connection::open(&database_path)?;
// Foreign key constraints aren't on in SQLite by default. Enable.
conn.pragma_update(None, "foreign_keys", &1)?;
db::auto_migrate(&conn)?;
Ok(conn)
}
@ -334,9 +334,45 @@ fn uninstall(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Error
Ok(())
}
async fn serve(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Error> {
let config = ConfigFile::from_file(conf.config_path(interface))?;
let conn = open_database_connection(interface, conf)?;
fn spawn_endpoint_refresher(interface: InterfaceName) -> Endpoints {
let endpoints = Arc::new(RwLock::new(HashMap::new()));
tokio::task::spawn({
let endpoints = endpoints.clone();
async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
if let Ok(info) = DeviceInfo::get_by_name(&interface) {
for peer in info.peers {
if let Some(endpoint) = peer.config.endpoint {
endpoints
.write()
.insert(peer.config.public_key.to_base64(), endpoint);
}
}
}
}
}
});
endpoints
}
fn spawn_expired_invite_sweeper(db: Db) {
tokio::task::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
match DatabasePeer::delete_expired_invites(&db.lock()) {
Ok(deleted) => log::info!("Deleted {} expired peer invitations.", deleted),
Err(e) => log::error!("Failed to delete expired peer invitations: {}", e),
}
}
});
}
async fn serve(interface: InterfaceName, conf: &ServerConfig) -> Result<(), Error> {
let config = ConfigFile::from_file(conf.config_path(&interface))?;
let conn = open_database_connection(&interface, conf)?;
let peers = DatabasePeer::list(&conn)?;
let peer_configs = peers
@ -346,7 +382,7 @@ async fn serve(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Err
log::info!("bringing up interface.");
wg::up(
interface,
&interface,
&config.private_key,
IpNetwork::new(config.address, config.network_cidr_prefix)?,
Some(config.listen_port),
@ -357,22 +393,23 @@ async fn serve(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Err
.add_peers(&peer_configs)
.apply(&interface)?;
let endpoints = Arc::new(Endpoints::new(&interface)?);
log::info!("{} peers added to wireguard interface.", peers.len());
let public_key = wgctrl::Key::from_base64(&config.private_key)?.generate_public();
let db = Arc::new(Mutex::new(conn));
let endpoints = spawn_endpoint_refresher(interface);
spawn_expired_invite_sweeper(db.clone());
let context = Context {
db,
interface: *interface,
interface,
endpoints,
public_key,
};
log::info!("innernet-server {} starting.", VERSION);
let listener = get_listener((config.address, config.listen_port).into(), interface)?;
let listener = get_listener((config.address, config.listen_port).into(), &interface)?;
let make_svc = hyper::service::make_service_fn(move |socket: &AddrStream| {
let remote_addr = socket.remote_addr();
@ -492,7 +529,7 @@ mod tests {
use std::path::Path;
#[test]
fn test_init_wizard() -> Result<()> {
fn test_init_wizard() -> Result<(), Error> {
// This runs init_wizard().
let server = test::Server::new()?;
@ -502,7 +539,7 @@ mod tests {
}
#[tokio::test]
async fn test_with_session_disguised_with_headers() -> Result<()> {
async fn test_with_session_disguised_with_headers() -> Result<(), Error> {
let server = test::Server::new()?;
let req = Request::builder()
@ -525,7 +562,7 @@ mod tests {
}
#[tokio::test]
async fn test_incorrect_public_key() -> Result<()> {
async fn test_incorrect_public_key() -> Result<(), Error> {
let server = test::Server::new()?;
let key = Key::generate_private().generate_public();
@ -547,7 +584,7 @@ mod tests {
}
#[tokio::test]
async fn test_unparseable_public_key() -> Result<()> {
async fn test_unparseable_public_key() -> Result<(), Error> {
let server = test::Server::new()?;
let req = Request::builder()

View File

@ -1,17 +1,16 @@
#![allow(dead_code)]
use crate::{
db::{DatabaseCidr, DatabasePeer},
endpoints::Endpoints,
initialize::{init_wizard, InitializeOpts},
Context, ServerConfig,
Context, Db, Endpoints, ServerConfig,
};
use anyhow::{anyhow, Result};
use anyhow::anyhow;
use hyper::{header::HeaderValue, http, Body, Request, Response};
use parking_lot::Mutex;
use parking_lot::{Mutex, RwLock};
use rusqlite::Connection;
use serde::Serialize;
use shared::{Cidr, CidrContents, PeerContents};
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use shared::{Cidr, CidrContents, Error, PeerContents};
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc};
use tempfile::TempDir;
use wgctrl::{InterfaceName, Key, KeyPair};
@ -44,8 +43,8 @@ pub const USER1_PEER_ID: i64 = 5;
pub const USER2_PEER_ID: i64 = 6;
pub struct Server {
pub db: Arc<Mutex<Connection>>,
endpoints: Arc<Endpoints>,
pub db: Db,
endpoints: Endpoints,
interface: InterfaceName,
conf: ServerConfig,
public_key: Key,
@ -54,7 +53,7 @@ pub struct Server {
}
impl Server {
pub fn new() -> Result<Self> {
pub fn new() -> Result<Self, Error> {
let test_dir = tempfile::tempdir()?;
let test_dir_path = test_dir.path();
@ -68,7 +67,7 @@ impl Server {
};
let opts = InitializeOpts {
network_name: Some(interface.clone()),
network_name: Some(interface.parse()?),
network_cidr: Some(ROOT_CIDR.parse()?),
external_endpoint: Some("155.155.155.155:54321".parse().unwrap()),
listen_port: Some(54321),
@ -116,7 +115,7 @@ impl Server {
);
let db = Arc::new(Mutex::new(db));
let endpoints = Arc::new(Endpoints::new(&interface)?);
let endpoints = Arc::new(RwLock::new(HashMap::new()));
Ok(Self {
conf,
@ -192,7 +191,7 @@ impl Server {
}
}
pub fn create_cidr(db: &Connection, name: &str, cidr_str: &str) -> Result<Cidr> {
pub fn create_cidr(db: &Connection, name: &str, cidr_str: &str) -> Result<Cidr, Error> {
let cidr = DatabaseCidr::create(
db,
CidrContents {
@ -214,11 +213,11 @@ pub fn peer_contents(
ip_str: &str,
cidr_id: i64,
is_admin: bool,
) -> Result<PeerContents> {
) -> Result<PeerContents, Error> {
let public_key = KeyPair::generate().public;
Ok(PeerContents {
name: name.to_string(),
name: name.parse()?,
ip: ip_str.parse()?,
cidr_id,
public_key: public_key.to_base64(),
@ -227,21 +226,22 @@ pub fn peer_contents(
persistent_keepalive_interval: None,
is_disabled: false,
is_redeemed: true,
invite_expires: None,
})
}
pub fn admin_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
pub fn admin_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents, Error> {
peer_contents(name, ip_str, ADMIN_CIDR_ID, true)
}
pub fn infra_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
pub fn infra_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents, Error> {
peer_contents(name, ip_str, INFRA_CIDR_ID, false)
}
pub fn developer_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
pub fn developer_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents, Error> {
peer_contents(name, ip_str, DEVELOPER_CIDR_ID, false)
}
pub fn user_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents> {
pub fn user_peer_contents(name: &str, ip_str: &str) -> Result<PeerContents, Error> {
peer_contents(name, ip_str, USER_CIDR_ID, false)
}

View File

@ -7,29 +7,14 @@ use colored::*;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use ipnetwork::IpNetwork;
use lazy_static::lazy_static;
use regex::Regex;
use std::net::{IpAddr, SocketAddr};
use std::{
net::{IpAddr, SocketAddr},
time::SystemTime,
};
use wgctrl::{InterfaceName, KeyPair};
lazy_static! {
pub static ref THEME: ColorfulTheme = ColorfulTheme::default();
/// Regex to match the requirements of hostname(7), needed to have peers also be reachable hostnames.
/// Note that the full length also must be maximum 63 characters, which this regex does not check.
static ref PEER_NAME_REGEX: Regex = Regex::new(r"^([a-z0-9]-?)*[a-z0-9]$").unwrap();
}
pub fn is_valid_hostname(name: &str) -> bool {
name.len() < 64 && PEER_NAME_REGEX.is_match(name)
}
#[allow(clippy::ptr_arg)]
pub fn hostname_validator(name: &String) -> Result<(), &'static str> {
if is_valid_hostname(name) {
Ok(())
} else {
Err("not a valid hostname")
}
}
/// Bring up a prompt to create a new CIDR. Returns the peer request.
@ -200,10 +185,7 @@ pub fn add_peer(
let name = if let Some(ref name) = args.name {
name.clone()
} else {
Input::with_theme(&*THEME)
.with_prompt("Name")
.validate_with(hostname_validator)
.interact()?
Input::with_theme(&*THEME).with_prompt("Name").interact()?
};
let is_admin = if let Some(is_admin) = args.admin {
@ -215,6 +197,15 @@ pub fn add_peer(
.interact()?
};
let invite_expires = if let Some(ref invite_expires) = args.invite_expires {
invite_expires.clone()
} else {
Input::with_theme(&*THEME)
.with_prompt("Invite expires after")
.default("14d".parse()?)
.interact()?
};
let default_keypair = KeyPair::generate();
let peer_request = PeerContents {
name,
@ -226,6 +217,7 @@ pub fn add_peer(
is_disabled: false,
is_redeemed: false,
persistent_keepalive_interval: Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS),
invite_expires: Some(SystemTime::now() + invite_expires.into()),
};
Ok(

View File

@ -1,5 +1,6 @@
use crate::prompts::hostname_validator;
use ipnetwork::IpNetwork;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::{
fmt::{self, Display, Formatter},
@ -7,6 +8,7 @@ use std::{
ops::Deref,
path::Path,
str::FromStr,
time::{Duration, SystemTime},
vec,
};
use structopt::StructOpt;
@ -22,8 +24,9 @@ impl FromStr for Interface {
type Err = String;
fn from_str(name: &str) -> Result<Self, Self::Err> {
let name = name.to_string();
hostname_validator(&name)?;
if !Hostname::is_valid(name) {
return Err("interface name is not a valid hostname".into());
}
let name = name
.parse()
.map_err(|e: InvalidInterfaceName| e.to_string())?;
@ -284,7 +287,7 @@ pub struct InstallOpts {
pub struct AddPeerOpts {
/// Name of new peer
#[structopt(long)]
pub name: Option<String>,
pub name: Option<Hostname>,
/// Specify desired IP of new peer (within parent CIDR)
#[structopt(long, conflicts_with = "auto-ip")]
@ -309,6 +312,10 @@ pub struct AddPeerOpts {
/// Save the config to the given location
#[structopt(long)]
pub save_config: Option<String>,
/// Invite expiration period (eg. "30d", "7w", "2h", "60m", "1000s")
#[structopt(long)]
pub invite_expires: Option<Timestring>,
}
#[derive(Debug, Clone, PartialEq, StructOpt)]
@ -341,7 +348,7 @@ pub struct AddAssociationOpts {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct PeerContents {
pub name: String,
pub name: Hostname,
pub ip: IpAddr,
pub cidr_id: i64,
pub public_key: String,
@ -350,6 +357,7 @@ pub struct PeerContents {
pub is_admin: bool,
pub is_disabled: bool,
pub is_redeemed: bool,
pub invite_expires: Option<SystemTime>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@ -481,6 +489,93 @@ pub struct State {
pub cidrs: Vec<Cidr>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Timestring {
timestring: String,
seconds: u64,
}
impl Display for Timestring {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.timestring)
}
}
impl FromStr for Timestring {
type Err = &'static str;
fn from_str(timestring: &str) -> Result<Self, Self::Err> {
if timestring.len() < 2 {
Err("timestring isn't long enough!")
} else {
let (n, suffix) = timestring.split_at(timestring.len() - 1);
let n: u64 = n.parse().map_err(|_| {
"invalid timestring (a number followed by a time unit character, eg. '15m')"
})?;
let multiplier = match suffix {
"s" => Ok(1),
"m" => Ok(60),
"h" => Ok(60 * 60),
"d" => Ok(60 * 60 * 24),
"w" => Ok(60 * 60 * 24 * 7),
_ => Err("invalid timestring suffix (must be one of 's', 'm', 'h', 'd', or 'w')"),
}?;
Ok(Self {
timestring: timestring.to_string(),
seconds: n * multiplier,
})
}
}
}
impl From<Timestring> for Duration {
fn from(timestring: Timestring) -> Self {
Duration::from_secs(timestring.seconds)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hostname(String);
lazy_static! {
/// Regex to match the requirements of hostname(7), needed to have peers also be reachable hostnames.
/// Note that the full length also must be maximum 63 characters, which this regex does not check.
static ref HOSTNAME_REGEX: Regex = Regex::new(r"^([a-z0-9]-?)*[a-z0-9]$").unwrap();
}
impl Hostname {
pub fn is_valid(name: &str) -> bool {
name.len() < 64 && HOSTNAME_REGEX.is_match(name)
}
}
impl FromStr for Hostname {
type Err = &'static str;
fn from_str(name: &str) -> Result<Self, Self::Err> {
if Self::is_valid(name) {
Ok(Self(name.to_string()))
} else {
Err("invalid hostname")
}
}
}
impl Deref for Hostname {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Display for Hostname {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub trait IoErrorContext<T> {
fn with_path<P: AsRef<Path>>(self, path: P) -> Result<T, WrappedIoError>;
fn with_str<S: Into<String>>(self, context: S) -> Result<T, WrappedIoError>;
@ -526,7 +621,7 @@ mod tests {
let peer = Peer {
id: 1,
contents: PeerContents {
name: "peer1".to_owned(),
name: "peer1".parse().unwrap(),
ip,
cidr_id: 1,
public_key: PUBKEY.to_owned(),
@ -535,6 +630,7 @@ mod tests {
is_admin: false,
is_disabled: false,
is_redeemed: true,
invite_expires: None,
},
};
let builder =
@ -552,7 +648,7 @@ mod tests {
let peer = Peer {
id: 1,
contents: PeerContents {
name: "peer1".to_owned(),
name: "peer1".parse().unwrap(),
ip,
cidr_id: 1,
public_key: PUBKEY.to_owned(),
@ -561,6 +657,7 @@ mod tests {
is_admin: false,
is_disabled: false,
is_redeemed: true,
invite_expires: None,
},
};
let builder =