diff --git a/Cargo.lock b/Cargo.lock index 806b3a4..d0e9d6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/src/data_store.rs b/client/src/data_store.rs index c69b590..dc59b06 100644 --- a/client/src/data_store.rs +++ b/client/src/data_store.rs @@ -135,7 +135,7 @@ mod tests { static ref BASE_PEERS: Vec = 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 = vec![Cidr { diff --git a/docker-tests/run-docker-tests.sh b/docker-tests/run-docker-tests.sh index 38b75ef..dafdc72 100755 --- a/docker-tests/run-docker-tests.sh +++ b/docker-tests/run-docker-tests.sh @@ -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 diff --git a/docker-tests/start-server.sh b/docker-tests/start-server.sh index c6e29af..05a9cac 100755 --- a/docker-tests/start-server.sh +++ b/docker-tests/start-server.sh @@ -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 diff --git a/server/src/api/admin/cidr.rs b/server/src/api/admin/cidr.rs index c035683..5b7ee1d 100644 --- a/server/src/api/admin/cidr.rs +++ b/server/src/api/admin/cidr.rs @@ -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( diff --git a/server/src/api/admin/peer.rs b/server/src/api/admin/peer.rs index cd32770..98eb3a2 100644 --- a/server/src/api/admin/peer.rs +++ b/server/src/api/admin/peer.rs @@ -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 = serde_json::from_reader(whole_body.reader())?; - let peer_names = peers.iter().map(|p| &p.contents.name).collect::>(); + let peer_names = peers.iter().map(|p| &*p.contents.name).collect::>(); // 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 diff --git a/server/src/api/user.rs b/server/src/api/user.rs index e37948e..ddb0b27 100644 --- a/server/src/api/user.rs +++ b/server/src/api/user.rs @@ -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::>(); + let mut peer_names = peers.iter().map(|p| &*p.contents.name).collect::>(); 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::>(); + let mut peer_names = peers.iter().map(|p| &*p.contents.name).collect::>(); 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(()) + } } diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 55087a4..60a7912 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -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(()) +} diff --git a/server/src/db/peer.rs b/server/src/db/peer.rs index 4e755fc..3e1a487 100644 --- a/server/src/db/peer.rs +++ b/server/src/db/peer.rs @@ -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 { 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>(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 { 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 { 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, 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::>()?) } + + pub fn delete_expired_invites(conn: &Connection) -> Result { + 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) + } } diff --git a/server/src/endpoints.rs b/server/src/endpoints.rs deleted file mode 100644 index 852e8c5..0000000 --- a/server/src/endpoints.rs +++ /dev/null @@ -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>>, - stop_tx: SyncSender<()>, -} - -impl std::ops::Deref for Endpoints { - type Target = RwLock>; - - fn deref(&self) -> &Self::Target { - &self.endpoints - } -} - -impl Endpoints { - pub fn new(iface: &InterfaceName) -> Result { - 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(()); - } -} diff --git a/server/src/error.rs b/server/src/error.rs index e3edd2c..16541c4 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -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, .. }, ..)) diff --git a/server/src/initialize.rs b/server/src/initialize.rs index 227d93c..39c4422 100644 --- a/server/src/initialize.rs +++ b/server/src/initialize.rs @@ -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>( 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>( pub struct InitializeOpts { /// The network name (ex: evilcorp) #[structopt(long)] - pub network_name: Option, + pub network_name: Option, /// 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()? }; diff --git a/server/src/main.rs b/server/src/main.rs index 16762ca..841f47b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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>; +pub type Endpoints = Arc>>; #[derive(Clone)] pub struct Context { pub db: Db, - pub endpoints: Arc, + pub endpoints: Arc>>, pub interface: InterfaceName, pub public_key: Key, } @@ -206,7 +206,7 @@ async fn main() -> Result<(), Box> { } }, 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() diff --git a/server/src/test.rs b/server/src/test.rs index 3a6804e..943b543 100644 --- a/server/src/test.rs +++ b/server/src/test.rs @@ -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>, - endpoints: Arc, + 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 { + pub fn new() -> Result { 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 { +pub fn create_cidr(db: &Connection, name: &str, cidr_str: &str) -> Result { let cidr = DatabaseCidr::create( db, CidrContents { @@ -214,11 +213,11 @@ pub fn peer_contents( ip_str: &str, cidr_id: i64, is_admin: bool, -) -> Result { +) -> Result { 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 { +pub fn admin_peer_contents(name: &str, ip_str: &str) -> Result { peer_contents(name, ip_str, ADMIN_CIDR_ID, true) } -pub fn infra_peer_contents(name: &str, ip_str: &str) -> Result { +pub fn infra_peer_contents(name: &str, ip_str: &str) -> Result { peer_contents(name, ip_str, INFRA_CIDR_ID, false) } -pub fn developer_peer_contents(name: &str, ip_str: &str) -> Result { +pub fn developer_peer_contents(name: &str, ip_str: &str) -> Result { peer_contents(name, ip_str, DEVELOPER_CIDR_ID, false) } -pub fn user_peer_contents(name: &str, ip_str: &str) -> Result { +pub fn user_peer_contents(name: &str, ip_str: &str) -> Result { peer_contents(name, ip_str, USER_CIDR_ID, false) } diff --git a/shared/src/prompts.rs b/shared/src/prompts.rs index 630b5e9..e278db6 100644 --- a/shared/src/prompts.rs +++ b/shared/src/prompts.rs @@ -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( diff --git a/shared/src/types.rs b/shared/src/types.rs index d7f9d0f..f3fb01f 100644 --- a/shared/src/types.rs +++ b/shared/src/types.rs @@ -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 { - 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, + pub name: Option, /// 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, + + /// Invite expiration period (eg. "30d", "7w", "2h", "60m", "1000s") + #[structopt(long)] + pub invite_expires: Option, } #[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, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] @@ -481,6 +489,93 @@ pub struct State { pub cidrs: Vec, } +#[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 { + 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 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 { + 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 { fn with_path>(self, path: P) -> Result; fn with_str>(self, context: S) -> Result; @@ -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 =