Repository: tonarino/innernet (language: rust)

Issue:
Consider ditching warp and using another framework
While I like the ideas behind `warp`, it causes compilation/linker slowness because of its extensive use of generics, and it makes some middleware more difficult to handle than it needs to be.

At this point, I'm considering just using `hyper` directly, since our API is not particularly complicated.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 76f0db59..806b3a44 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,7 +1,5 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
-
 [[package]]
 name = "ahash"
 version = "0.4.7"
@@ -80,15 +78,6 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
 
-[[package]]
-name = "block-buffer"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
-dependencies = [
- "generic-array",
-]
-
 [[package]]
 name = "byteorder"
 version = "1.4.3"
@@ -200,81 +189,6 @@ dependencies = [
  "winapi",
 ]
 
-[[package]]
-name = "cpufeatures"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cd5a7748210e7ec1a9696610b1015e6e31fbf58f77a160801f124bd1c36592a"
-
-[[package]]
-name = "crossbeam"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd01a6eb3daaafa260f6fc94c3a6c36390abc2080e38e3e34ced87393fb77d80"
-dependencies = [
- "cfg-if",
- "crossbeam-channel",
- "crossbeam-deque",
- "crossbeam-epoch",
- "crossbeam-queue",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-channel"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4"
-dependencies = [
- "cfg-if",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94af6efb46fef72616855b036a624cf27ba656ffc9be1b9a3c931cfc7749a9a9"
-dependencies = [
- "cfg-if",
- "crossbeam-epoch",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-epoch"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52fb27eab85b17fbb9f6fd667089e07d6a2eb8743d02639ee7f6a7a7729c9c94"
-dependencies = [
- "cfg-if",
- "crossbeam-utils",
- "lazy_static",
- "memoffset",
- "scopeguard",
-]
-
-[[package]]
-name = "crossbeam-queue"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f6cb3c7f5b8e51bc3ebb73a2327ad4abdbd119dc13223f14f961d2f38486756"
-dependencies = [
- "cfg-if",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4feb231f0d4d6af81aed15928e58ecf5816aa62a2393e2c82f46973e92a9a278"
-dependencies = [
- "autocfg",
- "cfg-if",
- "lazy_static",
-]
-
 [[package]]
 name = "curve25519-dalek"
 version = "3.0.2"
@@ -287,16 +201,6 @@ dependencies = [
  "zeroize",
 ]
 
-[[package]]
-name = "dashmap"
-version = "4.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c"
-dependencies = [
- "cfg-if",
- "num_cpus",
-]
-
 [[package]]
 name = "dialoguer"
 version = "0.8.0"
@@ -365,20 +269,6 @@ dependencies = [
  "percent-encoding",
 ]
 
-[[package]]
-name = "futures"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d5813545e459ad3ca1bff9915e9ad7f1a47dc6a91b627ce321d5863b7dd253"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-io",
- "futures-sink",
- "futures-task",
- "futures-util",
-]
-
 [[package]]
 name = "futures-channel"
 version = "0.3.14"
@@ -386,7 +276,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ce79c6a52a299137a6013061e0cf0e688fce5d7f1bc60125f520912fdb29ec25"
 dependencies = [
  "futures-core",
- "futures-sink",
 ]
 
 [[package]]
@@ -395,18 +284,6 @@ version = "0.3.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "098cd1c6dda6ca01650f1a37a794245eb73181d0d4d4e955e2f3c37db7af1815"
 
-[[package]]
-name = "futures-io"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "365a1a1fb30ea1c03a830fdb2158f5236833ac81fa0ad12fe35b29cddc35cb04"
-
-[[package]]
-name = "futures-sink"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c5629433c555de3d82861a7a4e3794a4c40040390907cfbfd7143a92a426c23"
-
 [[package]]
 name = "futures-task"
 version = "0.3.14"
@@ -420,7 +297,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3c144ad54d60f23927f0a6b6d816e4271278b64f005ad65e4e35291d2de9c025"
 dependencies = [
  "futures-core",
- "futures-sink",
  "futures-task",
  "pin-project-lite",
  "pin-utils",
@@ -453,25 +329,6 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
 
-[[package]]
-name = "h2"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "825343c4eef0b63f541f8903f395dc5beb362a979b5799a84062527ef1e37726"
-dependencies = [
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "futures-util",
- "http",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
 [[package]]
 name = "hashbrown"
 version = "0.9.1"
@@ -490,31 +347,6 @@ dependencies = [
  "hashbrown",
 ]
 
-[[package]]
-name = "headers"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0b7591fb62902706ae8e7aaff416b1b0fa2c0fd0878b46dc13baa3712d8a855"
-dependencies = [
- "base64",
- "bitflags",
- "bytes",
- "headers-core",
- "http",
- "mime",
- "sha-1",
- "time",
-]
-
-[[package]]
-name = "headers-core"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429"
-dependencies = [
- "http",
-]
-
 [[package]]
 name = "heck"
 version = "0.3.2"
@@ -599,7 +431,6 @@ dependencies = [
  "futures-channel",
  "futures-core",
  "futures-util",
- "h2",
  "http",
  "http-body",
  "httparse",
@@ -624,16 +455,6 @@ dependencies = [
  "unicode-normalization",
 ]
 
-[[package]]
-name = "indexmap"
-version = "1.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3"
-dependencies = [
- "autocfg",
- "hashbrown",
-]
-
 [[package]]
 name = "indoc"
 version = "1.0.3"
@@ -724,31 +545,6 @@ version = "2.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc"
 
-[[package]]
-name = "memoffset"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83fb6581e8ed1f85fd45c116db8405483899489e38406156c25eb743554361d"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "mime"
-version = "0.3.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
-
-[[package]]
-name = "mime_guess"
-version = "2.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212"
-dependencies = [
- "mime",
- "unicase",
-]
-
 [[package]]
 name = "mio"
 version = "0.7.11"
@@ -806,12 +602,6 @@ version = "1.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3"
 
-[[package]]
-name = "opaque-debug"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
-
 [[package]]
 name = "parking_lot"
 version = "0.11.1"
@@ -1053,12 +843,6 @@ version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
 
-[[package]]
-name = "scoped-tls"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2"
-
 [[package]]
 name = "scopeguard"
 version = "1.1.0"
@@ -1096,26 +880,13 @@ dependencies = [
  "serde",
 ]
 
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
 [[package]]
 name = "server"
 version = "1.2.0"
 dependencies = [
  "anyhow",
+ "bytes",
  "colored",
- "crossbeam",
- "dashmap",
  "dialoguer",
  "hyper",
  "indoc",
@@ -1139,23 +910,9 @@ dependencies = [
  "toml",
  "ureq",
  "url",
- "warp",
  "wgctrl",
 ]
 
-[[package]]
-name = "sha-1"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659df5fc3ce22274daac600ffb845300bd2125bcfaec047823075afdab81c00"
-dependencies = [
- "block-buffer",
- "cfg-if",
- "cpufeatures",
- "digest",
- "opaque-debug",
-]
-
 [[package]]
 name = "shared"
 version = "1.2.0"
@@ -1180,12 +937,6 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42a568c8f2cd051a4d283bd6eb0343ac214c1b0f1ac19f93e1175b2dee38c73d"
 
-[[package]]
-name = "slab"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
-
 [[package]]
 name = "smallvec"
 version = "1.6.1"
@@ -1323,16 +1074,6 @@ dependencies = [
  "syn",
 ]
 
-[[package]]
-name = "time"
-version = "0.1.43"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438"
-dependencies = [
- "libc",
- "winapi",
-]
-
 [[package]]
 name = "tinyvec"
 version = "1.2.0"
@@ -1355,9 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "83f0c8e7c0addab50b663055baf787d0af7f413a46e6e7fb9559a4e4db7137a5"
 dependencies = [
  "autocfg",
- "bytes",
  "libc",
- "memchr",
  "mio",
  "num_cpus",
  "pin-project-lite",
@@ -1375,31 +1114,6 @@ dependencies = [
  "syn",
 ]
 
-[[package]]
-name = "tokio-stream"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e177a5d8c3bf36de9ebe6d58537d8879e964332f93fb3339e43f618c81361af0"
-dependencies = [
- "futures-core",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.6.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "940a12c99365c31ea8dd9ba04ec1be183ffe4920102bb7122c2f515437601e8e"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "log",
- "pin-project-lite",
- "tokio",
-]
-
 [[package]]
 name = "toml"
 version = "0.5.8"
@@ -1422,7 +1136,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d"
 dependencies = [
  "cfg-if",
- "log",
  "pin-project-lite",
  "tracing-core",
 ]
@@ -1448,15 +1161,6 @@ version = "1.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
 
-[[package]]
-name = "unicase"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
-dependencies = [
- "version_check",
-]
-
 [[package]]
 name = "unicode-bidi"
 version = "0.3.5"
@@ -1554,32 +1258,6 @@ dependencies = [
  "try-lock",
 ]
 
-[[package]]
-name = "warp"
-version = "0.3.1"
-source = "git+https://github.com/tonarino/warp#bd70fb6249810eb63e9acdfe2dcc79e41ab53304"
-dependencies = [
- "bytes",
- "futures",
- "headers",
- "http",
- "hyper",
- "log",
- "mime",
- "mime_guess",
- "percent-encoding",
- "pin-project",
- "scoped-tls",
- "serde",
- "serde_json",
- "serde_urlencoded",
- "tokio",
- "tokio-stream",
- "tokio-util",
- "tower-service",
- "tracing",
-]
-
 [[package]]
 name = "wasi"
 version = "0.10.2+wasi-snapshot-preview1"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 1120136a..87ace1d1 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -13,11 +13,10 @@ name = "innernet-server"
 path = "src/main.rs"
 
 [dependencies]
+bytes = "1"
 colored = "2"
-crossbeam = "0.8"
-dashmap = "4"
 dialoguer = "0.8"
-hyper = "0.14"
+hyper = { version = "0.14", default-features = false, features = ["http1", "server", "runtime", "stream"] }
 indoc = "1"
 ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129
 libc = "0.2"
@@ -33,11 +32,10 @@ shared = { path = "../shared" }
 structopt = "0.3"
 subtle = "2"
 thiserror = "1"
-tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
 toml = "0.5"
 ureq = { version = "2", default-features = false }
 url = "2"
-warp = { git = "https://github.com/tonarino/warp", default-features = false } # pending https://github.com/seanmonstar/warp/issues/830
 wgctrl = { path = "../wgctrl-rs" }
 
 [target.'cfg(target_os = "linux")'.dependencies]
diff --git a/server/src/api/admin/association.rs b/server/src/api/admin/association.rs
index 9f15e0ea..fb037d91 100644
--- a/server/src/api/admin/association.rs
+++ b/server/src/api/admin/association.rs
@@ -2,82 +2,60 @@
 //!
 //! A peer belongs to one parent CIDR, and can by default see all peers within that parent.
 
-use crate::{db::DatabaseAssociation, form_body, with_admin_session, AdminSession, Context};
+use std::collections::VecDeque;
+
+use crate::{
+    db::DatabaseAssociation,
+    util::{form_body, json_response, status_response},
+    ServerError, Session,
+};
+use hyper::{Body, Method, Request, Response, StatusCode};
 use shared::AssociationContents;
-use warp::{http::StatusCode, Filter};
 
-pub mod routes {
-    use super::*;
-
-    pub fn all(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path("associations").and(
-            list(context.clone())
-                .or(create(context.clone()))
-                .or(delete(context)),
-        )
-    }
-
-    pub fn list(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::get())
-            .and(with_admin_session(context))
-            .and_then(handlers::list)
-    }
-
-    pub fn create(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::post())
-            .and(form_body())
-            .and(with_admin_session(context))
-            .and_then(handlers::create)
-    }
-
-    pub fn delete(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path::param()
-            .and(warp::path::end())
-            .and(warp::delete())
-            .and(with_admin_session(context))
-            .and_then(handlers::delete)
+pub async fn routes(
+    req: Request<Body>,
+    mut components: VecDeque<String>,
+    session: Session,
+) -> Result<Response<Body>, ServerError> {
+    match (req.method(), components.pop_front().as_deref()) {
+        (&Method::GET, None) => handlers::list(session).await,
+        (&Method::POST, None) => {
+            let form = form_body(req).await?;
+            handlers::create(form, session).await
+        },
+        (&Method::DELETE, Some(id)) => {
+            let id: i64 = id.parse().map_err(|_| ServerError::NotFound)?;
+            handlers::delete(id, session).await
+        },
+        _ => Err(ServerError::NotFound),
     }
 }
 
 mod handlers {
-
     use super::*;
 
     pub async fn create(
         contents: AssociationContents,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+        session: Session,
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
 
         DatabaseAssociation::create(&conn, contents)?;
 
-        Ok(StatusCode::CREATED)
+        status_response(StatusCode::CREATED)
     }
 
-    pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn list(session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let auths = DatabaseAssociation::list(&conn)?;
 
-        Ok(warp::reply::json(&auths))
+        json_response(&auths)
     }
 
-    pub async fn delete(
-        id: i64,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn delete(id: i64, session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         DatabaseAssociation::delete(&conn, id)?;
 
-        Ok(StatusCode::NO_CONTENT)
+        status_response(StatusCode::NO_CONTENT)
     }
 }
diff --git a/server/src/api/admin/cidr.rs b/server/src/api/admin/cidr.rs
index 39a6b02f..c0356834 100644
--- a/server/src/api/admin/cidr.rs
+++ b/server/src/api/admin/cidr.rs
@@ -1,86 +1,60 @@
-use crate::{db::DatabaseCidr, form_body, with_admin_session, AdminSession, Context};
-use shared::CidrContents;
-use warp::{
-    http::{response::Response, StatusCode},
-    Filter,
-};
-
-pub mod routes {
-    use super::*;
-
-    pub fn all(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path("cidrs").and(
-            list(context.clone())
-                .or(create(context.clone()))
-                .or(delete(context)),
-        )
-    }
-
-    pub fn list(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::get())
-            .and(with_admin_session(context))
-            .and_then(handlers::list)
-    }
+use std::collections::VecDeque;
 
-    pub fn create(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::post())
-            .and(form_body())
-            .and(with_admin_session(context))
-            .and_then(handlers::create)
-    }
+use crate::{
+    db::DatabaseCidr,
+    util::{form_body, json_response, status_response},
+    ServerError, Session,
+};
+use hyper::{Body, Method, Request, Response, StatusCode};
+use shared::CidrContents;
 
-    pub fn delete(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::param()
-            .and(warp::path::end())
-            .and(warp::delete())
-            .and(with_admin_session(context))
-            .and_then(handlers::delete)
+pub async fn routes(
+    req: Request<Body>,
+    mut components: VecDeque<String>,
+    session: Session,
+) -> Result<Response<Body>, ServerError> {
+    match (req.method(), components.pop_front().as_deref()) {
+        (&Method::GET, None) => handlers::list(session).await,
+        (&Method::POST, None) => {
+            let form = form_body(req).await?;
+            handlers::create(form, session).await
+        },
+        (&Method::DELETE, Some(id)) => {
+            let id: i64 = id.parse().map_err(|_| ServerError::NotFound)?;
+            handlers::delete(id, session).await
+        },
+        _ => Err(ServerError::NotFound),
     }
 }
 
 mod handlers {
+    use crate::util::json_status_response;
+
     use super::*;
 
     pub async fn create(
         contents: CidrContents,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+        session: Session,
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
 
         let cidr = DatabaseCidr::create(&conn, contents)?;
 
-        let response = Response::builder()
-            .status(StatusCode::CREATED)
-            .body(serde_json::to_string(&cidr).unwrap())
-            .unwrap();
-        Ok(response)
+        json_status_response(&cidr, StatusCode::CREATED)
     }
 
-    pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn list(session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let cidrs = DatabaseCidr::list(&conn)?;
 
-        Ok(warp::reply::json(&cidrs))
+        json_response(&cidrs)
     }
 
-    pub async fn delete(
-        id: i64,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn delete(id: i64, session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         DatabaseCidr::delete(&conn, id)?;
 
-        Ok(StatusCode::NO_CONTENT)
+        status_response(StatusCode::NO_CONTENT)
     }
 }
 
@@ -89,6 +63,7 @@ mod tests {
     use super::*;
     use crate::{test, DatabasePeer};
     use anyhow::Result;
+    use bytes::Buf;
     use shared::Cidr;
 
     #[tokio::test]
@@ -103,17 +78,14 @@ mod tests {
             parent: Some(test::ROOT_CIDR_ID),
         };
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
 
         assert_eq!(res.status(), 201);
 
-        let cidr_res: Cidr = serde_json::from_slice(&res.body())?;
+        let whole_body = hyper::body::aggregate(res).await?;
+        let cidr_res: Cidr = serde_json::from_reader(whole_body.reader())?;
         assert_eq!(contents, cidr_res.contents);
 
         let new_cidrs = DatabaseCidr::list(&server.db().lock())?;
@@ -132,15 +104,12 @@ mod tests {
             parent: Some(test::ROOT_CIDR_ID),
         };
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert!(res.status().is_success());
-        let cidr_res: Cidr = serde_json::from_slice(&res.body())?;
+        let whole_body = hyper::body::aggregate(res).await?;
+        let cidr_res: Cidr = serde_json::from_reader(whole_body.reader())?;
 
         let contents = CidrContents {
             name: "experimental".to_string(),
@@ -148,10 +117,7 @@ mod tests {
             parent: Some(cidr_res.id),
         };
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert!(!res.status().is_success());
 
@@ -168,12 +134,8 @@ mod tests {
             parent: Some(test::ROOT_CIDR_ID),
         };
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::USER1_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::USER1_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert!(!res.status().is_success());
 
@@ -189,12 +151,8 @@ mod tests {
             cidr: test::EXPERIMENTAL_CIDR.parse()?,
             parent: Some(test::ROOT_CIDR_ID),
         };
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert!(res.status().is_success());
 
@@ -204,12 +162,8 @@ mod tests {
             parent: Some(test::ROOT_CIDR_ID),
         };
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert!(!res.status().is_success());
 
@@ -225,12 +179,8 @@ mod tests {
             cidr: "10.80.1.0/21".parse()?,
             parent: Some(test::ROOT_CIDR_ID),
         };
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/cidrs")
-            .body(serde_json::to_string(&contents)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/cidrs", &contents)
             .await;
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
@@ -258,31 +208,32 @@ mod tests {
             },
         )?;
 
-        let filter = crate::routes(server.context());
-
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/cidrs/{}", experimental_cidr.id),
+            )
             .await;
         // Should fail because child CIDR exists.
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/cidrs/{}", experimental_subcidr.id))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/cidrs/{}", experimental_subcidr.id),
+            )
             .await;
         // Deleting child "leaf" CIDR should fail because peer exists inside it.
         assert_eq!(res.status(), StatusCode::NO_CONTENT);
 
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/cidrs/{}", experimental_cidr.id),
+            )
             .await;
         // Now deleting parent CIDR should work because child is gone.
         assert_eq!(res.status(), StatusCode::NO_CONTENT);
@@ -312,13 +263,12 @@ mod tests {
             )?,
         )?;
 
-        let filter = crate::routes(server.context());
-
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/cidrs/{}", experimental_cidr.id))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/cidrs/{}", experimental_cidr.id),
+            )
             .await;
         // Deleting CIDR should fail because peer exists inside it.
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
diff --git a/server/src/api/admin/mod.rs b/server/src/api/admin/mod.rs
index 77b9ebf6..2f523f37 100644
--- a/server/src/api/admin/mod.rs
+++ b/server/src/api/admin/mod.rs
@@ -1,17 +1,26 @@
-use warp::Filter;
+use std::collections::VecDeque;
 
-use crate::Context;
+use hyper::{Body, Request, Response};
+
+use crate::{ServerError, Session};
 
 pub mod association;
 pub mod cidr;
 pub mod peer;
 
-pub fn routes(
-    context: Context,
-) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-    warp::path("admin").and(
-        association::routes::all(context.clone())
-            .or(cidr::routes::all(context.clone()))
-            .or(peer::routes::all(context.clone())),
-    )
+pub async fn routes(
+    req: Request<Body>,
+    mut components: VecDeque<String>,
+    session: Session,
+) -> Result<Response<Body>, ServerError> {
+    if !session.admin_capable() {
+        return Err(ServerError::Unauthorized);
+    }
+
+    match components.pop_front().as_deref() {
+        Some("associations") => association::routes(req, components, session).await,
+        Some("cidrs") => cidr::routes(req, components, session).await,
+        Some("peers") => peer::routes(req, components, session).await,
+        _ => Err(ServerError::NotFound),
+    }
 }
diff --git a/server/src/api/admin/peer.rs b/server/src/api/admin/peer.rs
index 5c356ab4..cd32770c 100644
--- a/server/src/api/admin/peer.rs
+++ b/server/src/api/admin/peer.rs
@@ -1,82 +1,47 @@
+use std::collections::VecDeque;
+
 use crate::{
-    api::inject_endpoints, db::DatabasePeer, with_admin_session, AdminSession, Context, ServerError,
+    api::inject_endpoints,
+    db::DatabasePeer,
+    util::{form_body, json_response, json_status_response, status_response},
+    ServerError, Session,
 };
+use hyper::{Body, Method, Request, Response, StatusCode};
 use shared::PeerContents;
-use warp::{
-    http::{response::Response, StatusCode},
-    Filter,
-};
 use wgctrl::DeviceConfigBuilder;
 
-pub mod routes {
-    use crate::form_body;
-
-    use super::*;
-
-    pub fn all(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path("peers").and(
-            list(context.clone())
-                .or(list(context.clone()))
-                .or(create(context.clone()))
-                .or(update(context.clone()))
-                .or(delete(context)),
-        )
-    }
-
-    // POST /v1/admin/peers
-    pub fn create(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::post())
-            .and(form_body())
-            .and(with_admin_session(context))
-            .and_then(handlers::create)
-    }
-
-    // PUT /v1/admin/peers/:id
-    pub fn update(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::param()
-            .and(warp::path::end())
-            .and(warp::put())
-            .and(form_body())
-            .and(with_admin_session(context))
-            .and_then(handlers::update)
-    }
-
-    // GET /v1/admin/peers
-    pub fn list(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::end()
-            .and(warp::get())
-            .and(with_admin_session(context))
-            .and_then(handlers::list)
-    }
-
-    // DELETE /v1/admin/peers/:id
-    pub fn delete(
-        context: Context,
-    ) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-        warp::path::param()
-            .and(warp::path::end())
-            .and(warp::delete())
-            .and(with_admin_session(context))
-            .and_then(handlers::delete)
+pub async fn routes(
+    req: Request<Body>,
+    mut components: VecDeque<String>,
+    session: Session,
+) -> Result<Response<Body>, ServerError> {
+    match (req.method(), components.pop_front().as_deref()) {
+        (&Method::GET, None) => handlers::list(session).await,
+        (&Method::POST, None) => {
+            let form = form_body(req).await?;
+            handlers::create(form, session).await
+        },
+        (&Method::PUT, Some(id)) => {
+            let id: i64 = id.parse().map_err(|_| ServerError::NotFound)?;
+            let form = form_body(req).await?;
+            handlers::update(id, form, session).await
+        },
+        (&Method::DELETE, Some(id)) => {
+            let id: i64 = id.parse().map_err(|_| ServerError::NotFound)?;
+            handlers::delete(id, session).await
+        },
+        _ => Err(ServerError::NotFound),
     }
 }
 
 mod handlers {
+
     use super::*;
 
     pub async fn create(
         form: PeerContents,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+        session: Session,
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
 
         let peer = DatabasePeer::create(&conn, form)?;
@@ -91,43 +56,37 @@ mod handlers {
             log::info!("updated WireGuard interface, adding {}", &*peer);
         }
 
-        let response = Response::builder()
-            .status(StatusCode::CREATED)
-            .body(serde_json::to_string(&*peer).unwrap());
-        Ok(response)
+        json_status_response(&*peer, StatusCode::CREATED)
     }
 
     pub async fn update(
         id: i64,
         form: PeerContents,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+        session: Session,
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let mut peer = DatabasePeer::get(&conn, id)?;
         peer.update(&conn, form)?;
 
-        Ok(StatusCode::NO_CONTENT)
+        status_response(StatusCode::NO_CONTENT)
     }
 
     /// List all peers, including disabled ones. This is an admin-only endpoint.
-    pub async fn list(session: AdminSession) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn list(session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let mut peers = DatabasePeer::list(&conn)?
             .into_iter()
             .map(|peer| peer.inner)
             .collect::<Vec<_>>();
         inject_endpoints(&session, &mut peers);
-        Ok(warp::reply::json(&peers))
+        json_response(&peers)
     }
 
-    pub async fn delete(
-        id: i64,
-        session: AdminSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn delete(id: i64, session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         DatabasePeer::disable(&conn, id)?;
 
-        Ok(StatusCode::NO_CONTENT)
+        status_response(StatusCode::NO_CONTENT)
     }
 }
 
@@ -136,6 +95,7 @@ mod tests {
     use super::*;
     use crate::test;
     use anyhow::Result;
+    use bytes::Buf;
     use shared::Peer;
 
     #[tokio::test]
@@ -146,17 +106,15 @@ mod tests {
 
         let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
 
         assert_eq!(res.status(), StatusCode::CREATED);
         // The response contains the new peer information.
-        let peer_res: Peer = serde_json::from_slice(&res.body())?;
+        let whole_body = hyper::body::aggregate(res).await?;
+        let peer_res: Peer = serde_json::from_reader(whole_body.reader())?;
+
         assert_eq!(peer, peer_res.contents);
 
         // The number of peer entries in the database increased by 1.
@@ -172,12 +130,8 @@ mod tests {
 
         let peer = test::developer_peer_contents("devel oper", "10.80.64.4")?;
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
 
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
@@ -193,12 +147,8 @@ mod tests {
         // Try to add a peer with a name that is already taken.
         let peer = test::developer_peer_contents("developer2", "10.80.64.4")?;
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
 
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
@@ -219,12 +169,8 @@ mod tests {
         // Try to add a peer with an IP that is already taken.
         let peer = test::developer_peer_contents("developer3", "10.80.64.3")?;
 
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
 
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
@@ -239,37 +185,27 @@ mod tests {
     #[tokio::test]
     async fn test_add_peer_with_outside_cidr_range_ip() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
 
         let old_peers = DatabasePeer::list(&server.db().lock())?;
 
         // Try to add IP outside of the CIDR network.
         let peer = test::developer_peer_contents("developer3", "10.80.65.4")?;
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
         // Try to use the network address as peer IP.
         let peer = test::developer_peer_contents("developer3", "10.80.64.0")?;
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
         // Try to use the broadcast address as peer IP.
         let peer = test::developer_peer_contents("developer3", "10.80.64.255")?;
         let res = server
-            .post_request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::ADMIN_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
         assert_eq!(res.status(), StatusCode::BAD_REQUEST);
 
@@ -287,12 +223,8 @@ mod tests {
         let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
 
         // Try to create a new developer peer from a user peer.
-        let filter = crate::routes(server.context());
         let res = server
-            .post_request_from_ip(test::USER1_PEER_IP)
-            .path("/v1/admin/peers")
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(test::USER1_PEER_IP, "POST", "/v1/admin/peers", &peer)
             .await;
 
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
@@ -311,12 +243,13 @@ mod tests {
         };
 
         // Try to create a new developer peer from a user peer.
-        let filter = crate::routes(server.context());
         let res = server
-            .put_request_from_ip(test::ADMIN_PEER_IP)
-            .path(&format!("/v1/admin/peers/{}", test::DEVELOPER1_PEER_ID))
-            .body(serde_json::to_string(&change)?)
-            .reply(&filter)
+            .form_request(
+                test::ADMIN_PEER_IP,
+                "PUT",
+                &format!("/v1/admin/peers/{}", test::DEVELOPER1_PEER_ID),
+                &change,
+            )
             .await;
 
         assert_eq!(res.status(), StatusCode::NO_CONTENT);
@@ -333,12 +266,13 @@ mod tests {
         let peer = test::developer_peer_contents("developer3", "10.80.64.4")?;
 
         // Try to create a new developer peer from a user peer.
-        let filter = crate::routes(server.context());
         let res = server
-            .put_request_from_ip(test::USER1_PEER_IP)
-            .path(&format!("/v1/admin/peers/{}", test::ADMIN_PEER_ID))
-            .body(serde_json::to_string(&peer)?)
-            .reply(&filter)
+            .form_request(
+                test::USER1_PEER_IP,
+                "PUT",
+                &format!("/v1/admin/peers/{}", test::DEVELOPER1_PEER_ID),
+                &peer,
+            )
             .await;
 
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
@@ -349,16 +283,14 @@ mod tests {
     #[tokio::test]
     async fn test_list_all_peers_from_admin() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .path("/v1/admin/peers")
-            .reply(&filter)
+            .request(test::ADMIN_PEER_IP, "GET", "/v1/admin/peers")
             .await;
 
         assert_eq!(res.status(), StatusCode::OK);
 
-        let peers: Vec<Peer> = serde_json::from_slice(&res.body())?;
+        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<_>>();
         // An admin peer should see all the peers.
         assert_eq!(
@@ -379,11 +311,8 @@ mod tests {
     #[tokio::test]
     async fn test_list_all_peers_from_non_admin() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
         let res = server
-            .request_from_ip(test::DEVELOPER1_PEER_IP)
-            .path("/v1/admin/peers")
-            .reply(&filter)
+            .request(test::DEVELOPER1_PEER_IP, "GET", "/v1/admin/peers")
             .await;
 
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
@@ -394,15 +323,14 @@ mod tests {
     #[tokio::test]
     async fn test_delete() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
-
         let old_peers = DatabasePeer::list(&server.db().lock())?;
 
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/peers/{}", test::USER1_PEER_ID),
+            )
             .await;
 
         assert!(res.status().is_success());
@@ -418,15 +346,15 @@ mod tests {
     #[tokio::test]
     async fn test_delete_from_non_admin() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
 
         let old_peers = DatabasePeer::list(&server.db().lock())?;
 
         let res = server
-            .request_from_ip(test::DEVELOPER1_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID))
-            .reply(&filter)
+            .request(
+                test::DEVELOPER1_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/peers/{}", test::USER1_PEER_ID),
+            )
             .await;
 
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
@@ -441,13 +369,13 @@ mod tests {
     #[tokio::test]
     async fn test_delete_unknown_id() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
 
         let res = server
-            .request_from_ip(test::ADMIN_PEER_IP)
-            .method("DELETE")
-            .path(&format!("/v1/admin/peers/{}", test::USER1_PEER_ID + 100))
-            .reply(&filter)
+            .request(
+                test::ADMIN_PEER_IP,
+                "DELETE",
+                &format!("/v1/admin/peers/{}", test::USER1_PEER_ID + 100),
+            )
             .await;
 
         // Trying to delete a peer of non-existing ID will result in error.
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 41b17c89..afed99e5 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -10,7 +10,7 @@ pub mod user;
 pub fn inject_endpoints(session: &Session, peers: &mut Vec<Peer>) {
     for mut peer in peers {
         if peer.contents.endpoint.is_none() {
-            if let Some(endpoint) = session.context.endpoints.get(&peer.public_key) {
+            if let Some(endpoint) = session.context.endpoints.read().get(&peer.public_key) {
                 peer.contents.endpoint = Some(endpoint.to_owned().into());
             }
         }
diff --git a/server/src/api/user.rs b/server/src/api/user.rs
index dd296f21..e37948e3 100644
--- a/server/src/api/user.rs
+++ b/server/src/api/user.rs
@@ -1,57 +1,42 @@
+use std::collections::VecDeque;
+
 use crate::{
     api::inject_endpoints,
     db::{DatabaseCidr, DatabasePeer},
-    form_body, with_session, with_unredeemed_session, Context, ServerError, Session,
-    UnredeemedSession,
+    util::{form_body, json_response, status_response},
+    ServerError, Session,
 };
-use hyper::StatusCode;
+use hyper::{Body, Method, Request, Response, StatusCode};
 use shared::{EndpointContents, PeerContents, RedeemContents, State, REDEEM_TRANSITION_WAIT};
-use warp::Filter;
 use wgctrl::DeviceConfigBuilder;
 
-pub fn routes(
-    context: Context,
-) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-    warp::path("user").and(
-        routes::state(context.clone())
-            .or(routes::redeem(context.clone()))
-            .or(routes::override_endpoint(context.clone())),
-    )
-}
-
-pub mod routes {
-    use super::*;
-
-    pub fn state(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path("state")
-            .and(warp::path::end())
-            .and(warp::get())
-            .and(with_session(context))
-            .and_then(handlers::state)
-    }
-
-    pub fn redeem(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path("redeem")
-            .and(warp::path::end())
-            .and(warp::post())
-            .and(form_body())
-            .and(with_unredeemed_session(context))
-            .and_then(handlers::redeem)
-    }
-
-    pub fn override_endpoint(
-        context: Context,
-    ) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-        warp::path("endpoint")
-            .and(warp::path::end())
-            .and(warp::put())
-            .and(form_body())
-            .and(with_session(context))
-            .and_then(handlers::endpoint)
+pub async fn routes(
+    req: Request<Body>,
+    mut components: VecDeque<String>,
+    session: Session,
+) -> Result<Response<Body>, ServerError> {
+    match (req.method(), components.pop_front().as_deref()) {
+        (&Method::GET, Some("state")) => {
+            if !session.user_capable() {
+                return Err(ServerError::Unauthorized);
+            }
+            handlers::state(session).await
+        },
+        (&Method::POST, Some("redeem")) => {
+            if !session.redeemable() {
+                return Err(ServerError::Unauthorized);
+            }
+            let form = form_body(req).await?;
+            handlers::redeem(form, session).await
+        },
+        (&Method::PUT, Some("endpoint")) => {
+            if !session.user_capable() {
+                return Err(ServerError::Unauthorized);
+            }
+            let form = form_body(req).await?;
+            handlers::endpoint(form, session).await
+        },
+        _ => Err(ServerError::NotFound),
     }
 }
 
@@ -62,7 +47,7 @@ mod handlers {
     ///
     /// This endpoint returns the visible CIDRs and Peers, providing all the necessary
     /// information for the peer to create connections to all of them.
-    pub async fn state(session: Session) -> Result<impl warp::Reply, warp::Rejection> {
+    pub async fn state(session: Session) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
 
@@ -74,8 +59,7 @@ mod handlers {
             .map(|p| p.inner)
             .collect();
         inject_endpoints(&session, &mut peers);
-
-        Ok(warp::reply::json(&State { cidrs, peers }))
+        json_response(State { peers, cidrs })
     }
 
     /// Redeems an invitation. An invitation includes a WireGuard keypair generated by either the server
@@ -88,8 +72,8 @@ mod handlers {
     /// it is called and succeeds, it cannot be called again.
     pub async fn redeem(
         form: RedeemContents,
-        session: UnredeemedSession,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+        session: Session,
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let mut selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
 
@@ -97,12 +81,14 @@ mod handlers {
             .map_err(|_| ServerError::WireGuard)?;
 
         if selected_peer.is_redeemed {
-            Ok(StatusCode::GONE)
+            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.clone();
+                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
@@ -129,7 +115,7 @@ mod handlers {
                         .ok();
                 });
             }
-            Ok(StatusCode::NO_CONTENT)
+            status_response(StatusCode::NO_CONTENT)
         }
     }
 
@@ -144,7 +130,7 @@ mod handlers {
     pub async fn endpoint(
         contents: EndpointContents,
         session: Session,
-    ) -> Result<impl warp::Reply, warp::Rejection> {
+    ) -> Result<Response<Body>, ServerError> {
         let conn = session.context.db.lock();
         let mut selected_peer = DatabasePeer::get(&conn, session.peer.id)?;
         selected_peer.update(
@@ -155,7 +141,7 @@ mod handlers {
             },
         )?;
 
-        Ok(StatusCode::NO_CONTENT)
+        status_response(StatusCode::NO_CONTENT)
     }
 }
 
@@ -164,22 +150,20 @@ mod tests {
     use super::*;
     use crate::{db::DatabaseAssociation, test};
     use anyhow::Result;
+    use bytes::Buf;
     use shared::{AssociationContents, CidrContents, EndpointContents};
-    use warp::http::StatusCode;
 
     #[tokio::test]
     async fn test_get_state_from_developer1() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
         let res = server
-            .request_from_ip(test::DEVELOPER1_PEER_IP)
-            .path("/v1/user/state")
-            .reply(&filter)
+            .request(test::DEVELOPER1_PEER_IP, "GET", "/v1/user/state")
             .await;
 
         assert_eq!(res.status(), StatusCode::OK);
 
-        let State { peers, .. } = serde_json::from_slice(&res.body())?;
+        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<_>>();
         peer_names.sort();
         // Developers should see only peers in infra CIDR and developer CIDR.
@@ -194,15 +178,14 @@ mod tests {
     #[tokio::test]
     async fn test_override_endpoint() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
         assert_eq!(
             server
-                .put_request_from_ip(test::DEVELOPER1_PEER_IP)
-                .path("/v1/user/endpoint")
-                .body(serde_json::to_string(&EndpointContents::Set(
-                    "1.1.1.1:51820".parse().unwrap()
-                ))?)
-                .reply(&filter)
+                .form_request(
+                    test::DEVELOPER1_PEER_IP,
+                    "PUT",
+                    "/v1/user/endpoint",
+                    &EndpointContents::Set("1.1.1.1:51820".parse().unwrap())
+                )
                 .await
                 .status(),
             StatusCode::NO_CONTENT
@@ -211,10 +194,12 @@ mod tests {
         println!("{}", serde_json::to_string(&EndpointContents::Unset)?);
         assert_eq!(
             server
-                .put_request_from_ip(test::DEVELOPER1_PEER_IP)
-                .path("/v1/user/endpoint")
-                .body(serde_json::to_string(&EndpointContents::Unset)?)
-                .reply(&filter)
+                .form_request(
+                    test::DEVELOPER1_PEER_IP,
+                    "PUT",
+                    "/v1/user/endpoint",
+                    &EndpointContents::Unset,
+                )
                 .await
                 .status(),
             StatusCode::NO_CONTENT
@@ -222,10 +207,12 @@ mod tests {
 
         assert_eq!(
             server
-                .put_request_from_ip(test::DEVELOPER1_PEER_IP)
-                .path("/v1/user/endpoint")
-                .body("endpoint=blah")
-                .reply(&filter)
+                .form_request(
+                    test::DEVELOPER1_PEER_IP,
+                    "PUT",
+                    "/v1/user/endpoint",
+                    "endpoint=blah",
+                )
                 .await
                 .status(),
             StatusCode::BAD_REQUEST
@@ -237,14 +224,9 @@ mod tests {
     #[tokio::test]
     async fn test_list_peers_from_unknown_ip() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
 
         // Request comes from an unknown IP.
-        let res = server
-            .request_from_ip("10.80.80.80")
-            .path("/v1/user/state")
-            .reply(&filter)
-            .await;
+        let res = server.request("10.80.80.80", "GET", "/v1/user/state").await;
 
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
 
@@ -254,7 +236,6 @@ mod tests {
     #[tokio::test]
     async fn test_list_peers_for_developer_subcidr() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = crate::routes(server.context());
         {
             let db = server.db.lock();
             let cidr = DatabaseCidr::create(
@@ -301,13 +282,10 @@ mod tests {
         }
 
         for ip in &[test::DEVELOPER1_PEER_IP, test::EXPERIMENT_SUBCIDR_PEER_IP] {
-            let res = server
-                .request_from_ip(ip)
-                .path("/v1/user/state")
-                .reply(&filter)
-                .await;
+            let res = server.request(ip, "GET", "/v1/user/state").await;
             assert_eq!(res.status(), StatusCode::OK);
-            let State { peers, .. } = serde_json::from_slice(&res.body())?;
+            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<_>>();
             peer_names.sort();
             // Developers should see only peers in infra CIDR and developer CIDR.
@@ -347,13 +325,9 @@ mod tests {
         peer_contents.is_redeemed = false;
         let _experiment_peer = DatabasePeer::create(&server.db().lock(), peer_contents)?;
 
-        let filter = crate::routes(server.context());
-
         // Step 1: Ensure that before redeeming, other endpoints aren't yet accessible.
         let res = server
-            .request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
-            .path("/v1/user/state")
-            .reply(&filter)
+            .request(test::EXPERIMENT_SUBCIDR_PEER_IP, "GET", "/v1/user/state")
             .await;
         assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
 
@@ -362,27 +336,29 @@ mod tests {
             public_key: "YBVIgpfLbi/knrMCTEb0L6eVy0daiZnJJQkxBK9s+2I=".into(),
         };
         let res = server
-            .post_request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
-            .path("/v1/user/redeem")
-            .body(serde_json::to_string(&body)?)
-            .reply(&filter)
+            .form_request(
+                test::EXPERIMENT_SUBCIDR_PEER_IP,
+                "POST",
+                "/v1/user/redeem",
+                &body,
+            )
             .await;
         assert!(res.status().is_success());
 
         // Step 3: Ensure that a second attempt at redemption DOESN'T work.
         let res = server
-            .post_request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
-            .path("/v1/user/redeem")
-            .body(serde_json::to_string(&body)?)
-            .reply(&filter)
+            .form_request(
+                test::EXPERIMENT_SUBCIDR_PEER_IP,
+                "POST",
+                "/v1/user/redeem",
+                &body,
+            )
             .await;
         assert!(res.status().is_client_error());
 
         // Step 3: Ensure that after redemption, fetching state works.
         let res = server
-            .request_from_ip(test::EXPERIMENT_SUBCIDR_PEER_IP)
-            .path("/v1/user/state")
-            .reply(&filter)
+            .request(test::EXPERIMENT_SUBCIDR_PEER_IP, "GET", "/v1/user/state")
             .await;
         assert_eq!(res.status(), StatusCode::OK);
         Ok(())
diff --git a/server/src/db/peer.rs b/server/src/db/peer.rs
index 517282c6..4e755fcc 100644
--- a/server/src/db/peer.rs
+++ b/server/src/db/peer.rs
@@ -201,9 +201,9 @@ impl DatabasePeer {
                 cidr_id,
                 public_key,
                 endpoint,
+                persistent_keepalive_interval,
                 is_admin,
                 is_disabled,
-                persistent_keepalive_interval,
                 is_redeemed,
             },
         }
@@ -223,7 +223,7 @@ impl DatabasePeer {
         Ok(result)
     }
 
-    pub fn get_from_ip(conn: &Connection, ip: IpAddr) -> Result<Self, ServerError> {
+    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
diff --git a/server/src/endpoints.rs b/server/src/endpoints.rs
index e3d8083f..852e8c51 100644
--- a/server/src/endpoints.rs
+++ b/server/src/endpoints.rs
@@ -1,16 +1,25 @@
-use crossbeam::channel::{self, select};
-use dashmap::DashMap;
+use parking_lot::RwLock;
 use wgctrl::{DeviceInfo, InterfaceName};
 
-use std::{io, net::SocketAddr, sync::Arc, thread, time::Duration};
+use std::{
+    collections::HashMap,
+    io,
+    net::SocketAddr,
+    sync::{
+        mpsc::{sync_channel, SyncSender, TryRecvError},
+        Arc,
+    },
+    thread,
+    time::Duration,
+};
 
 pub struct Endpoints {
-    pub endpoints: Arc<DashMap<String, SocketAddr>>,
-    stop_tx: channel::Sender<()>,
+    pub endpoints: Arc<RwLock<HashMap<String, SocketAddr>>>,
+    stop_tx: SyncSender<()>,
 }
 
 impl std::ops::Deref for Endpoints {
-    type Target = DashMap<String, SocketAddr>;
+    type Target = RwLock<HashMap<String, SocketAddr>>;
 
     fn deref(&self) -> &Self::Target {
         &self.endpoints
@@ -19,30 +28,28 @@ impl std::ops::Deref for Endpoints {
 
 impl Endpoints {
     pub fn new(iface: &InterfaceName) -> Result<Self, io::Error> {
-        let endpoints = Arc::new(DashMap::new());
-        let (stop_tx, stop_rx) = channel::bounded(1);
+        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 {
-                select! {
-                    recv(stop_rx) -> _ => {
-                        break;
-                    },
-                    default => {
-                        if let Ok(info) = DeviceInfo::get_by_name(&iface) {
-                            for peer in info.peers {
-                                if let Some(endpoint) = peer.config.endpoint {
-                                    thread_endpoints.insert(peer.config.public_key.to_base64(), endpoint);
-                                }
-                            }
+                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));
                     }
                 }
+
+                thread::sleep(Duration::from_secs(1));
             });
         }
         Ok(Self { endpoints, stop_tx })
diff --git a/server/src/error.rs b/server/src/error.rs
index 234e53e6..e3edd2c3 100644
--- a/server/src/error.rs
+++ b/server/src/error.rs
@@ -1,5 +1,7 @@
+use std::convert::TryFrom;
+
+use hyper::{http, Body, Response, StatusCode};
 use thiserror::Error;
-use warp::{http::StatusCode, reject::Rejection};
 
 #[derive(Error, Debug)]
 pub enum ServerError {
@@ -20,17 +22,15 @@ pub enum ServerError {
 
     #[error("internal I/O error")]
     Io(#[from] std::io::Error),
-}
 
-impl warp::reject::Reject for ServerError {}
+    #[error("JSON parsing/serialization error")]
+    Json(#[from] serde_json::Error),
 
-pub async fn handle_rejection(err: Rejection) -> Result<StatusCode, warp::Rejection> {
-    eprintln!("rejection: {:?}", err);
-    if let Some(error) = err.find::<ServerError>() {
-        Ok(error.into())
-    } else {
-        Err(err)
-    }
+    #[error("Generic HTTP error")]
+    Http(#[from] http::Error),
+
+    #[error("Generic Hyper error")]
+    Hyper(#[from] hyper::Error),
 }
 
 impl<'a> From<&'a ServerError> for StatusCode {
@@ -39,7 +39,7 @@ impl<'a> From<&'a ServerError> for StatusCode {
         match error {
             Unauthorized => StatusCode::UNAUTHORIZED,
             NotFound => StatusCode::NOT_FOUND,
-            InvalidQuery => StatusCode::BAD_REQUEST,
+            InvalidQuery | Json(_) => StatusCode::BAD_REQUEST,
             // Special-case the constraint violation situation.
             Database(rusqlite::Error::SqliteFailure(libsqlite3_sys::Error { code, .. }, ..))
                 if *code == libsqlite3_sys::ErrorCode::ConstraintViolation =>
@@ -47,7 +47,19 @@ impl<'a> From<&'a ServerError> for StatusCode {
                 StatusCode::BAD_REQUEST
             },
             Database(rusqlite::Error::QueryReturnedNoRows) => StatusCode::NOT_FOUND,
-            WireGuard | Io(_) | Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
+            WireGuard | Io(_) | Database(_) | Http(_) | Hyper(_) => {
+                StatusCode::INTERNAL_SERVER_ERROR
+            },
         }
     }
 }
+
+impl TryFrom<ServerError> for Response<Body> {
+    type Error = http::Error;
+
+    fn try_from(e: ServerError) -> Result<Self, Self::Error> {
+        Response::builder()
+            .status(StatusCode::from(&e))
+            .body(Body::empty())
+    }
+}
diff --git a/server/src/initialize.rs b/server/src/initialize.rs
index 8f71797b..227d93cf 100644
--- a/server/src/initialize.rs
+++ b/server/src/initialize.rs
@@ -105,7 +105,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
     })?;
 
     let name: String = if let Some(name) = opts.network_name {
-        name.clone()
+        name
     } else {
         println!("Here you'll specify the network CIDR, which will encompass the entire network.");
         Input::with_theme(&theme)
@@ -115,7 +115,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
     };
 
     let root_cidr: IpNetwork = if let Some(cidr) = opts.network_cidr {
-        cidr.clone()
+        cidr
     } else {
         Input::with_theme(&theme)
             .with_prompt("Network CIDR")
@@ -127,7 +127,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
     let name = name.parse()?;
 
     let endpoint: Endpoint = if let Some(endpoint) = opts.external_endpoint {
-        endpoint.clone()
+        endpoint
     } else {
         let external_ip: Option<IpAddr> = ureq::get("http://4.icanhazip.com")
             .call()
diff --git a/server/src/main.rs b/server/src/main.rs
index bf0eeb28..16762cad 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -1,15 +1,15 @@
 use colored::*;
 use dialoguer::Confirm;
-use error::handle_rejection;
-use hyper::{server::conn::AddrStream, Body, Request};
+use hyper::{http, server::conn::AddrStream, Body, Request, Response};
 use indoc::printdoc;
 use ipnetwork::IpNetwork;
 use parking_lot::Mutex;
 use rusqlite::Connection;
-use serde::{de::DeserializeOwned, Deserialize, Serialize};
+use serde::{Deserialize, Serialize};
 use shared::{AddCidrOpts, AddPeerOpts, IoErrorContext, INNERNET_PUBKEY_HEADER};
 use std::{
-    convert::Infallible,
+    collections::VecDeque,
+    convert::TryInto,
     env,
     fs::File,
     io::prelude::*,
@@ -20,7 +20,6 @@ use std::{
 };
 use structopt::StructOpt;
 use subtle::ConstantTimeEq;
-use warp::{filters, Filter};
 use wgctrl::{DeviceConfigBuilder, DeviceInfo, InterfaceName, Key, PeerConfigBuilder};
 
 pub mod api;
@@ -29,6 +28,7 @@ pub mod endpoints;
 pub mod error;
 #[cfg(test)]
 mod test;
+pub mod util;
 
 mod initialize;
 
@@ -95,21 +95,17 @@ pub struct Session {
     pub peer: DatabasePeer,
 }
 
-pub struct AdminSession(Session);
-impl Deref for AdminSession {
-    type Target = Session;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
+impl Session {
+    pub fn admin_capable(&self) -> bool {
+        self.peer.is_admin && self.user_capable()
     }
-}
 
-pub struct UnredeemedSession(Session);
-impl Deref for UnredeemedSession {
-    type Target = Session;
+    pub fn user_capable(&self) -> bool {
+        !self.peer.is_disabled && self.peer.is_redeemed
+    }
 
-    fn deref(&self) -> &Self::Target {
-        &self.0
+    pub fn redeemable(&self) -> bool {
+        !self.peer.is_disabled && !self.peer.is_redeemed
     }
 }
 
@@ -231,7 +227,11 @@ fn open_database_connection(
         .into());
     }
 
-    Ok(Connection::open(&database_path)?)
+    let conn = Connection::open(&database_path)?;
+    // Foreign key constraints aren't on in SQLite by default. Enable.
+    conn.pragma_update(None, "foreign_keys", &1)?;
+
+    Ok(conn)
 }
 
 fn add_peer(
@@ -304,11 +304,39 @@ fn add_cidr(
     Ok(())
 }
 
+fn uninstall(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Error> {
+    if Confirm::with_theme(&*prompts::THEME)
+        .with_prompt(&format!(
+            "Permanently delete network \"{}\"?",
+            interface.as_str_lossy().yellow()
+        ))
+        .default(false)
+        .interact()?
+    {
+        println!("{} bringing down interface (if up).", "[*]".dimmed());
+        wg::down(interface).ok();
+        let config = conf.config_path(interface);
+        let data = conf.database_path(interface);
+        std::fs::remove_file(&config)
+            .with_path(&config)
+            .map_err(|e| println!("[!] {}", e.to_string().yellow()))
+            .ok();
+        std::fs::remove_file(&data)
+            .with_path(&data)
+            .map_err(|e| println!("[!] {}", e.to_string().yellow()))
+            .ok();
+        println!(
+            "{} network {} is uninstalled.",
+            "[*]".dimmed(),
+            interface.as_str_lossy().yellow()
+        );
+    }
+    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)?;
-    // Foreign key constraints aren't on in SQLite by default. Enable.
-    conn.pragma_update(None, "foreign_keys", &1)?;
 
     let peers = DatabasePeer::list(&conn)?;
     let peer_configs = peers
@@ -343,55 +371,23 @@ async fn serve(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Err
     };
 
     log::info!("innernet-server {} starting.", VERSION);
-    let routes = routes(context.clone()).with(warp::log("warp")).boxed();
 
     let listener = get_listener((config.address, config.listen_port).into(), interface)?;
 
-    let warp_svc = warp::service(routes);
     let make_svc = hyper::service::make_service_fn(move |socket: &AddrStream| {
         let remote_addr = socket.remote_addr();
-        let warp_svc = warp_svc.clone();
+        let context = context.clone();
         async move {
-            let svc = hyper::service::service_fn(move |req: Request<Body>| {
-                let warp_svc = warp_svc.clone();
-                async move { warp_svc.call_with_addr(req, Some(remote_addr)).await }
-            });
-            Ok::<_, Infallible>(svc)
+            Ok::<_, http::Error>(hyper::service::service_fn(move |req: Request<Body>| {
+                hyper_service(req, context.clone(), remote_addr)
+            }))
         }
     });
 
-    hyper::Server::from_tcp(listener)?.serve(make_svc).await?;
+    let server = hyper::Server::from_tcp(listener)?.serve(make_svc);
 
-    Ok(())
-}
+    server.await?;
 
-fn uninstall(interface: &InterfaceName, conf: &ServerConfig) -> Result<(), Error> {
-    if Confirm::with_theme(&*prompts::THEME)
-        .with_prompt(&format!(
-            "Permanently delete network \"{}\"?",
-            interface.as_str_lossy().yellow()
-        ))
-        .default(false)
-        .interact()?
-    {
-        println!("{} bringing down interface (if up).", "[*]".dimmed());
-        wg::down(interface).ok();
-        let config = conf.config_path(interface);
-        let data = conf.database_path(interface);
-        std::fs::remove_file(&config)
-            .with_path(&config)
-            .map_err(|e| println!("[!] {}", e.to_string().yellow()))
-            .ok();
-        std::fs::remove_file(&data)
-            .with_path(&data)
-            .map_err(|e| println!("[!] {}", e.to_string().yellow()))
-            .ok();
-        println!(
-            "{} network {} is uninstalled.",
-            "[*]".dimmed(),
-            interface.as_str_lossy().yellow()
-        );
-    }
     Ok(())
 }
 
@@ -423,103 +419,68 @@ fn get_listener(addr: SocketAddr, _interface: &InterfaceName) -> Result<TcpListe
     Ok(listener)
 }
 
-pub fn routes(
-    context: Context,
-) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
-    warp::path("v1")
-        .and(api::admin::routes(context.clone()).or(api::user::routes(context)))
-        .recover(handle_rejection)
-}
-
-pub fn form_body<T>() -> impl Filter<Extract = (T,), Error = warp::Rejection> + Clone
-where
-    T: DeserializeOwned + Send,
-{
-    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
-}
-
-pub fn with_unredeemed_session(
-    context: Context,
-) -> impl Filter<Extract = (UnredeemedSession,), Error = warp::Rejection> + Clone {
-    filters::addr::remote()
-        .and(filters::header::header(INNERNET_PUBKEY_HEADER))
-        .and_then(move |addr: Option<SocketAddr>, pubkey: String| {
-            get_session(
-                context.clone(),
-                addr.map(|addr| addr.ip()),
-                pubkey,
-                false,
-                false,
-            )
-        })
-        .map(|session| UnredeemedSession(session))
-}
-
-pub fn with_session(
+pub(crate) async fn hyper_service(
+    req: Request<Body>,
     context: Context,
-) -> impl Filter<Extract = (Session,), Error = warp::Rejection> + Clone {
-    filters::addr::remote()
-        .and(filters::header::header(INNERNET_PUBKEY_HEADER))
-        .and_then(move |addr: Option<SocketAddr>, pubkey: String| {
-            get_session(
-                context.clone(),
-                addr.map(|addr| addr.ip()),
-                pubkey,
-                false,
-                true,
-            )
-        })
+    remote_addr: SocketAddr,
+) -> Result<Response<Body>, http::Error> {
+    // Break the path into components.
+    let components: VecDeque<_> = req
+        .uri()
+        .path()
+        .trim_start_matches('/')
+        .split('/')
+        .map(String::from)
+        .collect();
+
+    routes(req, context, remote_addr, components)
+        .await
+        .or_else(TryInto::try_into)
 }
 
-pub fn with_admin_session(
+async fn routes(
+    req: Request<Body>,
     context: Context,
-) -> impl Filter<Extract = (AdminSession,), Error = warp::Rejection> + Clone {
-    filters::addr::remote()
-        .and(filters::header::header(INNERNET_PUBKEY_HEADER))
-        .and_then(move |addr: Option<SocketAddr>, pubkey: String| {
-            get_session(
-                context.clone(),
-                addr.map(|addr| addr.ip()),
-                pubkey,
-                true,
-                true,
-            )
-        })
-        .map(|session| AdminSession(session))
-}
-
-async fn get_session(
-    context: Context,
-    addr: Option<IpAddr>,
-    pubkey: String,
-    admin_only: bool,
-    redeemed_only: bool,
-) -> Result<Session, warp::Rejection> {
-    _get_session(context, addr, pubkey, admin_only, redeemed_only)
-        .map_err(|_| warp::reject::custom(ServerError::Unauthorized))
+    remote_addr: SocketAddr,
+    mut components: VecDeque<String>,
+) -> Result<Response<Body>, ServerError> {
+    // Must be "/v1/[something]"
+    if components.pop_front().as_deref() != Some("v1") {
+        Err(ServerError::NotFound)
+    } else {
+        let session = get_session(&req, context, remote_addr.ip())?;
+        let component = components.pop_front();
+        match component.as_deref() {
+            Some("user") => api::user::routes(req, components, session).await,
+            Some("admin") => api::admin::routes(req, components, session).await,
+            _ => Err(ServerError::NotFound),
+        }
+    }
 }
 
-fn _get_session(
+fn get_session(
+    req: &Request<Body>,
     context: Context,
-    addr: Option<IpAddr>,
-    pubkey: String,
-    admin_only: bool,
-    redeemed_only: bool,
-) -> Result<Session, Error> {
-    let pubkey = Key::from_base64(&pubkey)?;
+    addr: IpAddr,
+) -> Result<Session, ServerError> {
+    let pubkey = req
+        .headers()
+        .get(INNERNET_PUBKEY_HEADER)
+        .ok_or(ServerError::Unauthorized)?;
+    let pubkey = pubkey.to_str().map_err(|_| ServerError::Unauthorized)?;
+    let pubkey = Key::from_base64(&pubkey).map_err(|_| ServerError::Unauthorized)?;
     if pubkey.0.ct_eq(&context.public_key.0).into() {
-        let addr = addr.ok_or(ServerError::NotFound)?;
-        let peer = DatabasePeer::get_from_ip(&context.db.lock(), addr)?;
+        let peer = DatabasePeer::get_from_ip(&context.db.lock(), addr).map_err(|e| match e {
+            rusqlite::Error::QueryReturnedNoRows => ServerError::Unauthorized,
+            e => ServerError::Database(e),
+        })?;
 
-        if !peer.is_disabled
-            && (!admin_only || peer.is_admin)
-            && (!redeemed_only || peer.is_redeemed)
-        {
+        if !peer.is_disabled {
             return Ok(Session { context, peer });
         }
     }
 
-    Err(ServerError::Unauthorized.into())
+    Err(ServerError::Unauthorized)
 }
 
 #[cfg(test)]
@@ -527,8 +488,8 @@ mod tests {
     use super::*;
     use crate::test;
     use anyhow::Result;
+    use hyper::StatusCode;
     use std::path::Path;
-    use warp::http::StatusCode;
 
     #[test]
     fn test_init_wizard() -> Result<()> {
@@ -543,17 +504,17 @@ mod tests {
     #[tokio::test]
     async fn test_with_session_disguised_with_headers() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = routes(server.context());
 
-        // Request from an unknown IP, trying to disguise as an admin using HTTP headers.
-        let res = server
-            .request_from_ip("10.80.80.80")
-            .path("/v1/admin/peers")
+        let req = Request::builder()
+            .uri(format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP))
             .header("Forwarded", format!("for={}", test::ADMIN_PEER_IP))
             .header("X-Forwarded-For", test::ADMIN_PEER_IP)
             .header("X-Real-IP", test::ADMIN_PEER_IP)
-            .reply(&filter)
-            .await;
+            .body(Body::empty())
+            .unwrap();
+
+        // Request from an unknown IP, trying to disguise as an admin using HTTP headers.
+        let res = server.raw_request("10.80.80.80", req).await;
 
         // addr::remote() filter only look at remote_addr from TCP socket.
         // HTTP headers are not considered. This also means that innernet
@@ -566,17 +527,16 @@ mod tests {
     #[tokio::test]
     async fn test_incorrect_public_key() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = routes(server.context());
 
         let key = Key::generate_private().generate_public();
 
         // Request from an unknown IP, trying to disguise as an admin using HTTP headers.
-        let res = server
-            .request_from_ip("10.80.80.80")
-            .path("/v1/admin/peers")
+        let req = Request::builder()
+            .uri(format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP))
             .header(shared::INNERNET_PUBKEY_HEADER, key.to_base64())
-            .reply(&filter)
-            .await;
+            .body(Body::empty())
+            .unwrap();
+        let res = server.raw_request("10.80.80.80", req).await;
 
         // addr::remote() filter only look at remote_addr from TCP socket.
         // HTTP headers are not considered. This also means that innernet
@@ -589,15 +549,13 @@ mod tests {
     #[tokio::test]
     async fn test_unparseable_public_key() -> Result<()> {
         let server = test::Server::new()?;
-        let filter = routes(server.context());
 
-        // Request from an unknown IP, trying to disguise as an admin using HTTP headers.
-        let res = server
-            .request_from_ip("10.80.80.80")
-            .path("/v1/admin/peers")
-            .header(shared::INNERNET_PUBKEY_HEADER, "")
-            .reply(&filter)
-            .await;
+        let req = Request::builder()
+            .uri(format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP))
+            .header(shared::INNERNET_PUBKEY_HEADER, "!!!")
+            .body(Body::empty())
+            .unwrap();
+        let res = server.raw_request("10.80.80.80", req).await;
 
         // addr::remote() filter only look at remote_addr from TCP socket.
         // HTTP headers are not considered. This also means that innernet
diff --git a/server/src/util.rs b/server/src/util.rs
new file mode 100644
index 00000000..16da730c
--- /dev/null
+++ b/server/src/util.rs
@@ -0,0 +1,45 @@
+use bytes::Buf;
+use hyper::{header, Body, Request, Response, StatusCode};
+use serde::{de::DeserializeOwned, Serialize};
+
+use crate::ServerError;
+
+pub async fn form_body<F: DeserializeOwned>(req: Request<Body>) -> Result<F, ServerError> {
+    let content_len: usize = req
+        .headers()
+        .get(header::CONTENT_LENGTH)
+        .and_then(|header| header.to_str().ok())
+        .and_then(|header| header.parse().ok())
+        .ok_or(ServerError::InvalidQuery)?;
+
+    if content_len > 16 * 1024 {
+        return Err(ServerError::InvalidQuery);
+    }
+
+    let whole_body = hyper::body::aggregate(req).await?;
+
+    serde_json::from_reader(whole_body.reader()).map_err(Into::into)
+}
+
+pub fn json_response<F: Serialize>(form: F) -> Result<Response<Body>, ServerError> {
+    let json = serde_json::to_string(&form)?;
+    Ok(Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "application/json")
+        .body(Body::from(json))?)
+}
+
+pub fn json_status_response<F: Serialize>(
+    form: F,
+    status: StatusCode,
+) -> Result<Response<Body>, ServerError> {
+    let json = serde_json::to_string(&form)?;
+    Ok(Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, "application/json")
+        .body(Body::from(json))?)
+}
+
+pub fn status_response(status: StatusCode) -> Result<Response<Body>, ServerError> {
+    Ok(Response::builder().status(status).body(Body::empty())?)
+}
diff --git a/shared/src/prompts.rs b/shared/src/prompts.rs
index 032143fe..3f4d4f72 100644
--- a/shared/src/prompts.rs
+++ b/shared/src/prompts.rs
@@ -23,6 +23,7 @@ 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(())
@@ -119,8 +120,8 @@ pub fn choose_association<'a>(
 }
 
 pub fn add_association(cidrs: &[Cidr]) -> Result<Option<(&Cidr, &Cidr)>, Error> {
-    let cidr1 = choose_cidr(&cidrs[..], "First CIDR")?;
-    let cidr2 = choose_cidr(&cidrs[..], "Second CIDR")?;
+    let cidr1 = choose_cidr(cidrs, "First CIDR")?;
+    let cidr2 = choose_cidr(cidrs, "Second CIDR")?;
 
     Ok(
         if Confirm::with_theme(&*THEME)
@@ -178,7 +179,7 @@ pub fn add_peer(
     let mut available_ip = None;
     let candidate_ips = cidr.iter().filter(|ip| cidr.is_assignable(*ip));
     for ip in candidate_ips {
-        if peers.iter().find(|peer| peer.ip == ip).is_none() {
+        if !peers.iter().any(|peer| peer.ip == ip) {
             available_ip = Some(ip);
             break;
         }
@@ -388,7 +389,7 @@ pub fn ask_endpoint(external_ip: Option<IpAddr>) -> Result<Endpoint, Error> {
     endpoint_builder
         .with_prompt("External endpoint")
         .interact()
-        .map_err(|e| Error::from(e))
+        .map_err(Into::into)
 }
 
 pub fn override_endpoint(unset: bool) -> Result<Option<Option<Endpoint>>, Error> {
diff --git a/shared/src/types.rs b/shared/src/types.rs
index 4d2f146c..139dede2 100644
--- a/shared/src/types.rs
+++ b/shared/src/types.rs
@@ -136,11 +136,11 @@ pub enum EndpointContents {
     Unset,
 }
 
-impl Into<Option<Endpoint>> for EndpointContents {
-    fn into(self) -> Option<Endpoint> {
-        match self {
-            Self::Set(addr) => Some(addr),
-            Self::Unset => None,
+impl From<EndpointContents> for Option<Endpoint> {
+    fn from(endpoint: EndpointContents) -> Self {
+        match endpoint {
+            EndpointContents::Set(addr) => Some(addr),
+            EndpointContents::Unset => None,
         }
     }
 }
diff --git a/wgctrl-rs/src/backends/userspace.rs b/wgctrl-rs/src/backends/userspace.rs
index 88d31f3c..8d863ef9 100644
--- a/wgctrl-rs/src/backends/userspace.rs
+++ b/wgctrl-rs/src/backends/userspace.rs
@@ -259,7 +259,7 @@ pub fn get_by_name(name: &InterfaceName) -> Result<DeviceInfo, io::Error> {
 /// available.
 fn get_userspace_implementation() -> String {
     std::env::var("WG_USERSPACE_IMPLEMENTATION")
-        .or(std::env::var("WG_QUICK_USERSPACE_IMPLEMENTATION"))
+        .or_else(|_| std::env::var("WG_QUICK_USERSPACE_IMPLEMENTATION"))
         .unwrap_or_else(|_| "wireguard-go".to_string())
 }
 


Test patch:
diff --git a/server/src/test.rs b/server/src/test.rs
index 88948e45..3a6804e1 100644
--- a/server/src/test.rs
+++ b/server/src/test.rs
@@ -6,12 +6,13 @@ use crate::{
     Context, ServerConfig,
 };
 use anyhow::{anyhow, Result};
+use hyper::{header::HeaderValue, http, Body, Request, Response};
 use parking_lot::Mutex;
 use rusqlite::Connection;
+use serde::Serialize;
 use shared::{Cidr, CidrContents, PeerContents};
 use std::{net::SocketAddr, path::PathBuf, sync::Arc};
 use tempfile::TempDir;
-use warp::test::RequestBuilder;
 use wgctrl::{InterfaceName, Key, KeyPair};
 
 pub const ROOT_CIDR: &str = "10.80.0.0/15";
@@ -144,23 +145,50 @@ impl Server {
         self.conf.config_path(&self.interface)
     }
 
-    pub fn request_from_ip(&self, ip_str: &str) -> RequestBuilder {
+    pub async fn raw_request(&self, ip_str: &str, req: Request<Body>) -> Response<Body> {
         let port = 54321u16;
-        warp::test::request()
-            .remote_addr(SocketAddr::new(ip_str.parse().unwrap(), port))
-            .header(shared::INNERNET_PUBKEY_HEADER, self.public_key.to_base64())
+        crate::hyper_service(
+            req,
+            self.context(),
+            SocketAddr::new(ip_str.parse().unwrap(), port),
+        )
+        .await
+        .unwrap()
     }
 
-    pub fn post_request_from_ip(&self, ip_str: &str) -> RequestBuilder {
-        self.request_from_ip(ip_str)
-            .method("POST")
-            .header("Content-Type", "application/json")
+    fn base_request_builder(&self, verb: &str, path: &str) -> http::request::Builder {
+        Request::builder()
+            .uri(format!("http://{}{}", WG_MANAGE_PEER_IP, path))
+            .method(verb)
+            .header(
+                shared::INNERNET_PUBKEY_HEADER,
+                HeaderValue::from_str(&self.public_key.to_base64()).unwrap(),
+            )
+    }
+
+    pub async fn request(&self, ip_str: &str, verb: &str, path: &str) -> Response<Body> {
+        let req = self
+            .base_request_builder(verb, path)
+            .body(Body::empty())
+            .unwrap();
+        self.raw_request(ip_str, req).await
     }
 
-    pub fn put_request_from_ip(&self, ip_str: &str) -> RequestBuilder {
-        self.request_from_ip(ip_str)
-            .method("PUT")
+    pub async fn form_request<F: Serialize>(
+        &self,
+        ip_str: &str,
+        verb: &str,
+        path: &str,
+        form: F,
+    ) -> Response<Body> {
+        let json = serde_json::to_string(&form).unwrap();
+        let req = self
+            .base_request_builder(verb, path)
             .header("Content-Type", "application/json")
+            .header("Content-Length", json.len().to_string())
+            .body(Body::from(json))
+            .unwrap();
+        self.raw_request(ip_str, req).await
     }
 }