Repository: tonarino/innernet (language: rust)

Issue:
Support invitation expiration
If you have made a mistake in adding a peer and the invitation has not yet been used you may want to delete the peer in order to recreate it instead of modifying the sqlite database.


Discussion / hints:
Do you think this might be best solved with just adding an expiration time to invites, and the server can then automatically prune old invite allocations?
That probably works best for this use case.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 806b3a44..d0e9d6ec 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 c69b5905..dc59b06e 100644
--- a/client/src/data_store.rs
+++ b/client/src/data_store.rs
@@ -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 {
diff --git a/server/src/api/admin/cidr.rs b/server/src/api/admin/cidr.rs
index c0356834..5b7ee1d9 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 cd32770c..98eb3a2a 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<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
diff --git a/server/src/api/user.rs b/server/src/api/user.rs
index e37948e3..ddb0b27b 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)?;
-
-            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)
+        selected_peer.redeem(&conn, &form.public_key)?;
+
+        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)
     }
 
     /// 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(())
+    }
 }
diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs
index 55087a42..60a79120 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 4e755fcc..3e1a487a 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<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)
+    }
 }
diff --git a/server/src/endpoints.rs b/server/src/endpoints.rs
deleted file mode 100644
index 852e8c51..00000000
--- 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<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(());
-    }
-}
diff --git a/server/src/error.rs b/server/src/error.rs
index e3edd2c3..16541c4b 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 227d93cf..39c44228 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<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()?
     };
 
diff --git a/server/src/main.rs b/server/src/main.rs
index 16762cad..841f47b0 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<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()
diff --git a/shared/src/prompts.rs b/shared/src/prompts.rs
index 630b5e98..e278db69 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 d7f9d0fd..f3fb01f1 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<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 =


Test patch:
diff --git a/docker-tests/run-docker-tests.sh b/docker-tests/run-docker-tests.sh
index 38b75ef6..dafdc72f 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 c6e29afe..05a9cac4 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/test.rs b/server/src/test.rs
index 3a6804e1..943b543d 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<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)
 }