diff --git a/tailscale-localapi/Cargo.toml b/tailscale-localapi/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..ebeb974f3d7d53b86d321d92f24a1877b46e4de4
--- /dev/null
+++ b/tailscale-localapi/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "tailscale-localapi"
+description = "Client for the Tailscale local API"
+version = "0.3.0"
+authors = ["John Downey"]
+edition = "2021"
+license = "MIT"
+documentation = "https://docs.rs/tailscale-localapi"
+homepage = "https://github.com/jtdowney/tailscale-localapi"
+
+[dependencies]
+async-trait = "0.1.73"
+base64 = "0.21.2"
+chrono = { version = "0.4.19", features = ["serde"] }
+http = "0.2.6"
+hyper = { version = "0.14.18", features = ["client", "http1"] }
+rustls-pemfile = "1"
+serde = { version = "1", features = ["derive"] }
+serde-aux = "4"
+serde_json = "1"
+thiserror = "1"
+tokio = { version = "1", features = ["net", "rt"] }
+cidr = {version = "0.2.2", features = ["serde"]}
+
+[dev-dependencies]
+libc = "0.2.147"
diff --git a/tailscale-localapi/LICENSE b/tailscale-localapi/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..ad4a20d473e39187146d5e821f1de06a0b71372f
--- /dev/null
+++ b/tailscale-localapi/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2023 John Downey
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tailscale-localapi/README.md b/tailscale-localapi/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..78f72322422b07fcf1d067c41dcc633c2a1e0b1f
--- /dev/null
+++ b/tailscale-localapi/README.md
@@ -0,0 +1,19 @@
+# tailscale-localapi
+
+This is a rust crate designed to interact with the [Tailscale](https://tailscale.com) local API. On Linux and other Unix-like systems, this is through a unix socket. On macOS and Windows, this is through a local TCP port and a password. The Tailscale localapi is large but so far this crate does:
+
+1. Get the status of the node and the tailnet (similar to `tailscale status`)
+2. Get a certificate and key for the node (similar to `tailscale cert`)
+3. Get whois information for a given IP address in the tailnet
+
+## Limitations
+
+This crate uses hyper and requires tokio and async rust.
+
+## Example
+
+```rust
+let socket_path = "/var/run/tailscale/tailscaled.sock";
+let client = tailscale_localapi::LocalApi::new_with_socket_path(socket_path);
+dbg!(client.status().await.unwrap());
+```
diff --git a/tailscale-localapi/src/adapters/mod.rs b/tailscale-localapi/src/adapters/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..31c4e41f94356e4ff77e4e2437bc8b6f6dac1d9e
--- /dev/null
+++ b/tailscale-localapi/src/adapters/mod.rs
@@ -0,0 +1,15 @@
+use async_trait::async_trait;
+use http::{Response, Uri};
+use hyper::Body;
+
+use crate::error::Error;
+
+pub mod tcp;
+pub mod unix;
+
+/// Abstract trait for the tailscale API client
+#[async_trait]
+pub trait LocalApiAdapter: Clone {
+    async fn get(&self, uri: Uri) -> Result<Response<Body>, Error>;
+    async fn patch(&self, uri: Uri, body: Body) -> Result<Response<Body>, Error>;
+}
diff --git a/tailscale-localapi/src/adapters/tcp.rs b/tailscale-localapi/src/adapters/tcp.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b9da945ced3f40d23c1ce999c2fefeda70ae35d5
--- /dev/null
+++ b/tailscale-localapi/src/adapters/tcp.rs
@@ -0,0 +1,89 @@
+use std::net::Ipv4Addr;
+
+use async_trait::async_trait;
+use base64::Engine;
+use http::{
+    header::{AUTHORIZATION, HOST},
+    Request, Response, Uri,
+};
+use hyper::Body;
+use tokio::net::TcpSocket;
+
+use crate::error::Error;
+
+use super::LocalApiAdapter;
+
+/// Client that connects to the local tailscaled over TCP with a password. This
+/// is used on Windows and macOS when sandboxing is enabled.
+#[derive(Clone)]
+pub struct TcpWithPasswordAdapter {
+    port: u16,
+    password: String,
+}
+
+impl TcpWithPasswordAdapter {
+    pub fn new<S: Into<String>>(port: u16, password: S) -> Self {
+        TcpWithPasswordAdapter {
+            port,
+            password: password.into(),
+        }
+    }
+
+    async fn request(&self, request: Request<Body>) -> Result<Response<Body>, Error> {
+        let stream = TcpSocket::new_v4()?
+            .connect((Ipv4Addr::LOCALHOST, self.port).into())
+            .await?;
+        let (mut request_sender, connection) = hyper::client::conn::handshake(stream).await?;
+
+        tokio::spawn(async move {
+            if let Err(e) = connection.await {
+                eprintln!("Error in connection: {}", e);
+            }
+        });
+
+        let response = request_sender.send_request(request).await?;
+        if response.status() == 200 {
+            Ok(response)
+        } else {
+            Err(Error::UnprocessableEntity)
+        }
+    }
+
+    fn get_request_builder(&self) -> http::request::Builder {
+        Request::builder()
+            .header(HOST, "local-tailscaled.sock")
+            .header(
+                AUTHORIZATION,
+                format!(
+                    "Basic {}",
+                    base64::engine::general_purpose::STANDARD_NO_PAD
+                        .encode(format!(":{}", self.password))
+                ),
+            )
+    }
+}
+
+#[async_trait]
+impl LocalApiAdapter for TcpWithPasswordAdapter {
+    async fn get(&self, uri: Uri) -> Result<Response<Body>, Error> {
+        let request = self
+            .get_request_builder()
+            .method("GET")
+            .uri(uri)
+            .body(Body::empty())?;
+
+        let response = self.request(request).await?;
+        Ok(response)
+    }
+
+    async fn patch(&self, uri: Uri, body: Body) -> Result<Response<Body>, Error> {
+        let request = self
+            .get_request_builder()
+            .method("PATCH")
+            .uri(uri)
+            .body(body)?;
+
+        let response = self.request(request).await?;
+        Ok(response)
+    }
+}
diff --git a/tailscale-localapi/src/adapters/unix.rs b/tailscale-localapi/src/adapters/unix.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8578aad2fd321b4566c780576de0ff4430ee6e6e
--- /dev/null
+++ b/tailscale-localapi/src/adapters/unix.rs
@@ -0,0 +1,72 @@
+use std::path::{Path, PathBuf};
+
+use async_trait::async_trait;
+use http::{header::HOST, Request, Response, Uri};
+use hyper::Body;
+use tokio::net::UnixStream;
+
+use crate::error::Error;
+
+use super::LocalApiAdapter;
+
+/// Client that connects to the local tailscaled over a unix socket. This is
+/// used on Linux and other Unix-like systems.
+#[derive(Clone)]
+pub struct UnixStreamAdapter {
+    socket_path: PathBuf,
+}
+
+impl UnixStreamAdapter {
+    pub fn new<P: AsRef<Path>>(socket_path: P) -> Self {
+        let socket_path = socket_path.as_ref().to_path_buf();
+
+        UnixStreamAdapter { socket_path }
+    }
+
+    async fn request(&self, request: Request<Body>) -> Result<Response<Body>, Error> {
+        let stream = UnixStream::connect(&self.socket_path).await?;
+        let (mut request_sender, connection) = hyper::client::conn::handshake(stream).await?;
+
+        tokio::spawn(async move {
+            if let Err(e) = connection.await {
+                eprintln!("Error in connection: {}", e);
+            }
+        });
+
+        let response = request_sender.send_request(request).await?;
+        if response.status() == 200 {
+            Ok(response)
+        } else {
+            Err(Error::UnprocessableEntity)
+        }
+    }
+
+    fn get_request_builder(&self) -> http::request::Builder {
+        Request::builder().header(HOST, "local-tailscaled.sock")
+    }
+}
+
+#[async_trait]
+impl LocalApiAdapter for UnixStreamAdapter {
+    async fn get(&self, uri: Uri) -> Result<Response<Body>, Error> {
+        let request = self
+            .get_request_builder()
+            .method("GET")
+            .uri(uri)
+            .body(Body::empty())?;
+
+        let response = self.request(request).await?;
+        Ok(response)
+    }
+
+    async fn patch(&self, uri: Uri, body: Body) -> Result<Response<Body>, Error> {
+        let request = self
+            .get_request_builder()
+            .method("PATCH")
+            .uri(uri)
+            .body(body)?;
+
+        let response = self.request(request).await?;
+        Ok(response)
+    }
+}
diff --git a/tailscale-localapi/src/error.rs b/tailscale-localapi/src/error.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6e2f6eed607b031bd22a683aa61acd8bd392e1aa
--- /dev/null
+++ b/tailscale-localapi/src/error.rs
@@ -0,0 +1,17 @@
+
+/// Error type for this crate
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+    #[error("connection failed")]
+    IoError(#[from] std::io::Error),
+    #[error("request failed")]
+    HyperError(#[from] hyper::Error),
+    #[error("http error")]
+    HttpError(#[from] hyper::http::Error),
+    #[error("unprocessible entity")]
+    UnprocessableEntity,
+    #[error("unable to parse json")]
+    ParsingError(#[from] serde_json::Error),
+    #[error("unable to parse certificate or key")]
+    UnknownCertificateOrKey,
+}
diff --git a/tailscale-localapi/src/lib.rs b/tailscale-localapi/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0d23a4c7aa87c8abe105a127b8973ecb33ef9553
--- /dev/null
+++ b/tailscale-localapi/src/lib.rs
@@ -0,0 +1,149 @@
+#![deny(rust_2018_idioms, nonstandard_style)]
+#![warn(future_incompatible)]
+
+use std::{io::Read, net::SocketAddr, path::Path};
+
+use adapters::{tcp::TcpWithPasswordAdapter, unix::UnixStreamAdapter, LocalApiAdapter};
+use error::Error;
+use http::Uri;
+use hyper::{body::Buf, Body};
+pub use types::*;
+
+pub mod adapters;
+/// Definitions of types used in the tailscale API
+pub mod error;
+pub mod types;
+
+/// Client for the local tailscaled socket
+#[derive(Clone)]
+pub struct LocalApi<T: LocalApiAdapter> {
+    /// Path to the tailscaled socket
+    client: T,
+}
+
+impl LocalApi<UnixStreamAdapter> {
+    /// Create a new client for the local tailscaled from the path to the
+    /// socket.
+    pub fn new_with_socket_path<P: AsRef<Path>>(socket_path: P) -> Self {
+        Self {
+            client: UnixStreamAdapter::new(socket_path),
+        }
+    }
+}
+
+impl LocalApi<TcpWithPasswordAdapter> {
+    /// Create a new client for the local tailscaled from the TCP port and
+    /// password.
+    pub fn new_with_port_and_password<S: Into<String>>(port: u16, password: S) -> Self {
+        Self {
+            client: TcpWithPasswordAdapter::new(port, password),
+        }
+    }
+}
+
+impl<T: LocalApiAdapter> LocalApi<T> {
+    /// Get the certificate and key for a domain. The domain should be one of
+    /// the valid domains for the local node.
+    pub async fn certificate_pair(
+        &self,
+        domain: &str,
+    ) -> Result<(PrivateKey, Vec<Certificate>), Error> {
+        let response = self
+            .client
+            .get(
+                format!("/localapi/v0/cert/{domain}?type=pair")
+                    .parse()
+                    .unwrap(),
+            )
+            .await?;
+
+        let body = hyper::body::aggregate(response.into_body()).await?;
+        let items = rustls_pemfile::read_all(&mut body.reader())?;
+        let (certificates, mut private_keys) = items
+            .into_iter()
+            .map(|item| match item {
+                rustls_pemfile::Item::ECKey(data)
+                | rustls_pemfile::Item::PKCS8Key(data)
+                | rustls_pemfile::Item::RSAKey(data) => Ok((false, data)),
+                rustls_pemfile::Item::X509Certificate(data) => Ok((true, data)),
+                _ => Err(Error::UnknownCertificateOrKey),
+            })
+            .collect::<Result<Vec<_>, Error>>()?
+            .into_iter()
+            .partition::<Vec<(bool, Vec<u8>)>, _>(|&(cert, _)| cert);
+
+        let certificates = certificates
+            .into_iter()
+            .map(|(_, data)| Certificate(data))
+            .collect();
+        let (_, private_key_data) = private_keys.pop().ok_or(Error::UnknownCertificateOrKey)?;
+        let private_key = PrivateKey(private_key_data);
+
+        Ok((private_key, certificates))
+    }
+
+    /// Get the status of the local node.
+    pub async fn status(&self) -> Result<Status, Error> {
+        let response = self
+            .client
+            .get(Uri::from_static("/localapi/v0/status"))
+            .await?;
+        let body = hyper::body::aggregate(response.into_body()).await?;
+        let status = serde_json::de::from_reader(body.reader())?;
+
+        Ok(status)
+    }
+
+    pub async fn get_prefs(&self) -> Result<Prefs, Error> {
+        let response = self
+            .client
+            .get(Uri::from_static("/localapi/v0/prefs"))
+            .await?;
+        let body = hyper::body::aggregate(response.into_body()).await?;
+
+        let mut temp = String::new();
+        body.reader().read_to_string(&mut temp)?;
+        println!("{}", temp);
+
+        let response = self
+            .client
+            .get(Uri::from_static("/localapi/v0/prefs"))
+            .await?;
+        let body = hyper::body::aggregate(response.into_body()).await?;
+
+        let prefs = serde_json::de::from_reader(body.reader())?;
+
+        Ok(prefs)
+    }
+
+    pub async fn edit_prefs(&self, prefs: &MaskedPrefs) -> Result<Prefs, Error> {
+        let response = self
+            .client
+            .patch(
+                Uri::from_static("/localapi/v0/prefs"),
+                Body::from(serde_json::ser::to_string(prefs)?),
+            )
+            .await?;
+        let body = hyper::body::aggregate(response.into_body()).await?;
+
+        let prefs = serde_json::de::from_reader(body.reader())?;
+
+        Ok(prefs)
+    }
+
+    /// Request whois information for an address in the tailnet.
+    pub async fn whois(&self, address: SocketAddr) -> Result<Whois, Error> {
+        let response = self
+            .client
+            .get(
+                format!("/localapi/v0/whois?addr={address}")
+                    .parse()
+                    .unwrap(),
+            )
+            .await?;
+        let body = hyper::body::aggregate(response.into_body()).await?;
+        let whois = serde_json::de::from_reader(body.reader())?;
+
+        Ok(whois)
+    }
+}
diff --git a/tailscale-localapi/src/types.rs b/tailscale-localapi/src/types.rs
new file mode 100644
index 0000000000000000000000000000000000000000..38e425d47557344f03048aa15058a15160cb7fee
--- /dev/null
+++ b/tailscale-localapi/src/types.rs
@@ -0,0 +1,359 @@
+use std::{
+    collections::HashMap,
+    net::{IpAddr, SocketAddr},
+};
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Deserializer, Serialize};
+use serde::de::{Error};
+use serde_aux::prelude::*;
+
+/// State of the backend
+#[derive(Deserialize, Debug)]
+#[non_exhaustive]
+pub enum BackendState {
+    NoState,
+    NeedsLogin,
+    NeedsMachineAuth,
+    Stopped,
+    Starting,
+    Running,
+}
+
+/// Status of a peer
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct PeerStatus {
+    #[serde(rename = "ID")]
+    pub id: String,
+    pub public_key: String,
+    #[serde(rename = "HostName")]
+    pub hostname: String,
+    #[serde(rename = "DNSName")]
+    pub dnsname: String,
+    #[serde(rename = "OS")]
+    pub os: String,
+    #[serde(rename = "UserID")]
+    pub user_id: i64,
+    #[serde(
+        rename = "TailscaleIPs",
+        deserialize_with = "deserialize_default_from_null"
+    )]
+    pub tailscale_ips: Vec<IpAddr>,
+    #[serde(default, deserialize_with = "deserialize_default_from_null")]
+    pub tags: Vec<String>,
+    #[serde(default, deserialize_with = "deserialize_default_from_null")]
+    pub primary_routes: Vec<String>,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub addrs: Vec<String>,
+    pub cur_addr: String,
+    pub relay: String,
+    pub rx_bytes: i64,
+    pub tx_bytes: i64,
+    pub created: DateTime<Utc>,
+    pub last_write: DateTime<Utc>,
+    pub last_seen: DateTime<Utc>,
+    pub last_handshake: DateTime<Utc>,
+    pub online: bool,
+    #[serde(default)]
+    pub keep_alive: bool,
+    pub exit_node: bool,
+    pub exit_node_option: bool,
+    pub active: bool,
+    #[serde(
+        rename = "PeerAPIURL",
+        deserialize_with = "deserialize_default_from_null"
+    )]
+    pub peer_api_url: Vec<String>,
+    #[serde(default, deserialize_with = "deserialize_default_from_null")]
+    pub capabilities: Vec<String>,
+    #[serde(
+        default,
+        rename = "sshHostKeys",
+        deserialize_with = "deserialize_default_from_null"
+    )]
+    pub ssh_hostkeys: Vec<String>,
+    #[serde(default)]
+    pub sharee_node: bool,
+    pub in_network_map: bool,
+    pub in_magic_sock: bool,
+    pub in_engine: bool,
+}
+
+/// Status of the current tailnet.
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct TailnetStatus {
+    pub name: String,
+    #[serde(rename = "MagicDNSSuffix")]
+    pub magic_dns_suffix: String,
+    #[serde(rename = "MagicDNSEnabled")]
+    pub magic_dns_enabled: bool,
+}
+
+/// Status of the local tailscaled.
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "PascalCase")]
+pub struct Status {
+    pub version: String,
+    pub backend_state: BackendState,
+    #[serde(rename = "AuthURL")]
+    pub auth_url: String,
+    #[serde(rename = "TailscaleIPs")]
+    pub tailscale_ips: Vec<IpAddr>,
+    #[serde(rename = "Self")]
+    pub self_status: PeerStatus,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub health: Vec<String>,
+    pub current_tailnet: Option<TailnetStatus>,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub cert_domains: Vec<String>,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub peer: HashMap<String, PeerStatus>,
+    pub user: HashMap<i64, UserProfile>,
+}
+
+/// Service protocol
+#[derive(Deserialize, Debug, Copy, Clone)]
+#[non_exhaustive]
+pub enum ServiceProto {
+    #[serde(rename = "tcp")]
+    Tcp,
+    #[serde(rename = "udp")]
+    Udp,
+    #[serde(rename = "peerapi4")]
+    PeerAPI4,
+    #[serde(rename = "peerapi6")]
+    PeerAPI6,
+    #[serde(rename = "peerapi-dns-proxy")]
+    PeerAPIDNS,
+}
+
+/// Service running on a node
+#[derive(Deserialize, Debug, Clone)]
+#[serde(rename_all = "PascalCase")]
+pub struct Service {
+    pub proto: ServiceProto,
+    pub port: u16,
+    pub description: Option<String>,
+}
+
+/// Host information
+#[derive(Deserialize, Debug, Clone)]
+#[serde(rename_all = "PascalCase")]
+pub struct Hostinfo {
+    #[serde(rename = "OS")]
+    pub os: Option<String>,
+    #[serde(rename = "OSVersion")]
+    pub os_version: Option<String>,
+    pub hostname: Option<String>,
+    pub services: Option<Vec<Service>>,
+    #[serde(default, rename = "sshHostKeys")]
+    pub ssh_hostkeys: Option<Vec<String>>,
+}
+
+/// Node in the tailnet
+#[derive(Deserialize, Debug, Clone)]
+#[serde(rename_all = "PascalCase")]
+pub struct Node {
+    #[serde(rename = "ID")]
+    pub id: i64,
+    #[serde(rename = "StableID")]
+    pub stable_id: String,
+    pub name: String,
+    pub user: i64,
+    pub sharer: Option<i64>,
+    pub key: String,
+    pub key_expiry: DateTime<Utc>,
+    pub machine: String,
+    pub disco_key: String,
+    pub addresses: Vec<String>,
+    #[serde(rename = "AllowedIPs")]
+    pub allowed_ips: Vec<String>,
+    pub endpoints: Option<Vec<SocketAddr>>,
+    #[serde(rename = "DERP")]
+    pub derp: Option<String>,
+    pub hostinfo: Hostinfo,
+    pub created: DateTime<Utc>,
+    #[serde(default)]
+    pub tags: Vec<String>,
+    #[serde(default)]
+    pub primary_routes: Vec<String>,
+    pub last_seen: Option<DateTime<Utc>>,
+    pub online: Option<bool>,
+    pub keep_alive: Option<bool>,
+    pub machine_authorized: Option<bool>, // TODO: Check the upstream code if this has changed to MachineStatus
+    #[serde(default)]
+    pub capabilities: Vec<String>,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub computed_name: String,
+    #[serde(deserialize_with = "deserialize_default_from_null")]
+    pub computed_name_with_host: String,
+}
+
+/// User profile.
+#[derive(Deserialize, Debug, Clone)]
+#[serde(rename_all = "PascalCase")]
+pub struct UserProfile {
+    #[serde(rename = "ID")]
+    pub id: i64,
+    pub login_name: String,
+    pub display_name: String,
+    #[serde(rename = "ProfilePicURL")]
+    pub profile_pic_url: String,
+}
+
+/// Whois response
+#[derive(Deserialize, Debug, Clone)]
+#[serde(rename_all = "PascalCase")]
+pub struct Whois {
+    pub node: Node,
+    pub user_profile: UserProfile,
+    #[serde(default)]
+    pub caps: Vec<String>,
+}
+
+/// DER encoded X.509 certificate for the node. This can either be the leaf
+/// certificate or part of the certificate chain.
+pub struct Certificate(pub Vec<u8>);
+
+/// DER encoded private key for the node
+pub struct PrivateKey(pub Vec<u8>);
+
+type NetfilterMode = u8;
+type StableNodeID = String;
+
+#[derive(Debug, Deserialize, Serialize, Clone, Default)]
+#[serde(rename_all = "PascalCase")]
+pub struct Prefs {
+    // ControlURL is the URL of the control server to use.
+    #[serde(rename = "ControlURL")]
+    pub control_url: String,
+    // RouteAll specifies whether to accept subnets advertised by other nodes on the Tailscale network.
+    pub route_all: bool,
+    // AllowSingleHosts specifies whether to install routes for each node IP on the tailscale network.
+    pub allow_single_hosts: bool,
+    // ExitNodeID and ExitNodeIP specify the node that should be used as an exit node for internet traffic.
+    #[serde(rename = "ExitNodeID")]
+    pub exit_node_id: Option<StableNodeID>,
+    #[serde(rename = "ExitNodeIP", deserialize_with = "deserialize_ip_option")]
+    pub exit_node_ip: Option<std::net::IpAddr>,
+    // ExitNodeAllowLANAccess indicates whether locally accessible subnets should be routed directly or via the exit node.
+    #[serde(rename = "ExitNodeAllowLANAccess")]
+    pub exit_node_allow_lan_access: bool,
+    // CorpDNS specifies whether to install the Tailscale network's DNS configuration, if it exists.
+    #[serde(rename = "CorpDNS")]
+    pub corp_dns: bool,
+    // RunSSH is whether this node should run an SSH server.
+    #[serde(rename = "RunSSH")]
+    pub run_ssh: bool,
+    // RunWebClient is whether this node should run a web client.
+    pub run_web_client: Option<bool>,
+    // WantRunning indicates whether networking should be active on this node.
+    pub want_running: bool,
+    // LoggedOut indicates whether the user intends to be logged out.
+    pub logged_out: bool,
+    // ShieldsUp indicates whether to block all incoming connections.
+    pub shields_up: bool,
+    // AdvertiseTags specifies groups that this node wants to join, for purposes of ACL enforcement.
+    pub advertise_tags: Option<Vec<String>>,
+    // Hostname is the hostname to use for identifying the node.
+    pub hostname: String,
+    // NotepadURLs is a debugging setting that opens OAuth URLs in notepad.exe on Windows.
+    #[serde(rename = "NotepadURLs")]
+    pub notepad_urls: bool,
+    // ForceDaemon specifies whether a platform should keep running after the GUI ends and/or the user logs out.
+    pub force_daemon: Option<bool>,
+    // Egg is an optional debug flag.
+    pub egg: Option<bool>,
+    // The following block of options only have an effect on Linux.
+    // AdvertiseRoutes specifies CIDR prefixes to advertise into the Tailscale network.
+    pub advertise_routes: Option<Vec<cidr::IpCidr>>,
+    // NoSNAT specifies whether to source NAT traffic going to destinations in AdvertiseRoutes.
+    #[serde(rename = "NoSNAT")]
+    pub no_snat: Option<bool>,
+    // NetfilterMode specifies how much to manage netfilter rules for Tailscale.
+    pub netfilter_mode: NetfilterMode,
+    // OperatorUser is the local machine user name who is allowed to operate tailscaled without being root or using sudo.
+    pub operator_user: Option<String>,
+    // ProfileName is the desired name of the profile.
+    pub profile_name: Option<String>,
+    // AutoUpdate sets the auto-update preferences for the node agent.
+    pub auto_update: Option<AutoUpdatePrefs>,
+    // AppConnector sets the app connector preferences for the node agent.
+    pub app_connector: Option<AppConnectorPrefs>,
+    // PostureChecking enables the collection of information used for device posture checks.
+    pub posture_checking: Option<bool>,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, Default)]
+#[serde(rename_all = "PascalCase")]
+pub struct AutoUpdatePrefs {
+    // Check specifies whether background checks for updates are enabled.
+    check: bool,
+    // Apply specifies whether background auto-updates are enabled.
+    apply: bool,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, Default)]
+#[serde(rename_all = "PascalCase")]
+pub struct AppConnectorPrefs {
+    // Advertise specifies whether the app connector subsystem is advertising this node as a connector.
+    advertise: bool,
+}
+
+#[derive(Debug, Deserialize, Serialize, Clone, Default)]
+#[serde(rename_all = "PascalCase")]
+pub struct MaskedPrefs {
+    #[serde(flatten)]
+    pub prefs: Prefs,
+
+    #[serde(rename = "ControlURLSet")]
+    pub control_url_set: Option<bool>,
+    pub route_all_set: Option<bool>,
+    pub allow_single_hosts_set: Option<bool>,
+    #[serde(rename = "ExitNodeIDSet")]
+    pub exit_node_id_set: Option<bool>,
+    #[serde(rename = "ExitNodeIPSet")]
+    pub exit_node_ip_set: Option<bool>,
+    #[serde(rename = "ExitNodeAllowLANAccessSet")]
+    pub exit_node_allow_lan_access_set: Option<bool>,
+    #[serde(rename = "CorpDNSSet")]
+    pub corp_dns_set: Option<bool>,
+    #[serde(rename = "RunSSHSet")]
+    pub run_ssh_set: Option<bool>,
+    pub run_web_client_set: Option<bool>,
+    pub want_running_set: Option<bool>,
+    pub logged_out_set: Option<bool>,
+    pub shields_up_set: Option<bool>,
+    pub advertise_tags_set: Option<bool>,
+    pub hostname_set: Option<bool>,
+    #[serde(rename = "NotepadURLsSet")]
+    pub notepad_urls_set: Option<bool>,
+    pub force_daemon_set: Option<bool>,
+    pub egg_set: Option<bool>,
+    pub advertise_routes_set: Option<bool>,
+    #[serde(rename = "NoSNATSet")]
+    pub no_snat_set: Option<bool>,
+    pub netfilter_mode_set: Option<bool>,
+    pub operator_user_set: Option<bool>,
+    pub profile_name_set: Option<bool>,
+    pub auto_update_set: Option<bool>,
+    pub app_connector_set: Option<bool>,
+    pub posture_checking_set: Option<bool>,
+}
+
+fn deserialize_ip_option<'de, D>(deserializer: D) -> Result<Option<IpAddr>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let s: String = Deserialize::deserialize(deserializer)?;
+    if s.is_empty() {
+        Ok(None)
+    } else {
+        match s.parse() {
+            Ok(ip) => Ok(Some(ip)),
+            Err(_) => Err(Error::custom("Error deserializng IP")),
+        }
+    }
+}