Skip to main content

arcbox_helper/
client.rs

1//! High-level client for communicating with the arcbox-helper daemon.
2//!
3//! Wraps the raw tarpc `HelperServiceClient` with ergonomic methods and
4//! a unified error type. Consumers (arcbox-core, arcbox-daemon) use this
5//! instead of managing tarpc connections directly.
6
7use crate::HelperServiceClient;
8use crate::error::HelperError;
9
10/// Errors from helper client operations.
11#[derive(Debug, thiserror::Error)]
12pub enum ClientError {
13    /// Cannot connect to the helper socket (daemon not running).
14    #[error("helper not reachable: {0}")]
15    Connection(#[from] std::io::Error),
16    /// tarpc transport or RPC-level failure.
17    #[error("helper rpc failed: {0}")]
18    Rpc(#[from] tarpc::client::RpcError),
19    /// The helper returned a version string that cannot be interpreted safely.
20    #[error("unrecognized helper version {0:?}")]
21    UnrecognizedVersion(String),
22    /// The helper's RPC wire major or minimum version is incompatible.
23    #[error("helper {installed} is incompatible; required {required}")]
24    IncompatibleVersion {
25        /// Version line returned by the helper.
26        installed: String,
27        /// Minimum compatible helper version.
28        required: &'static str,
29    },
30    /// The helper executed the operation but it returned a structured error.
31    #[error(transparent)]
32    Helper(#[from] HelperError),
33}
34
35/// Client for the arcbox-helper privileged daemon.
36pub struct Client {
37    inner: HelperServiceClient,
38}
39
40impl Client {
41    /// Connects to the helper daemon via Unix socket.
42    ///
43    /// Uses launchd-managed socket by default (`/var/run/arcbox-helper.sock`),
44    /// overridable via `ARCBOX_HELPER_SOCKET` env var. The connection is
45    /// rejected before any mutation when the helper wire version is
46    /// incompatible with this client.
47    pub async fn connect() -> Result<Self, ClientError> {
48        let inner = crate::connect().await?;
49        let client = Self { inner };
50        client.ensure_compatible().await?;
51        Ok(client)
52    }
53
54    /// Connects to the helper daemon at an explicit socket path.
55    ///
56    /// Unlike [`connect()`](Self::connect), this does not read the
57    /// `ARCBOX_HELPER_SOCKET` env var, making it safe for parallel tests. It
58    /// enforces the same wire-version check as [`connect()`](Self::connect).
59    pub async fn connect_to(path: &str) -> Result<Self, ClientError> {
60        let transport = tarpc::serde_transport::unix::connect(
61            path,
62            tarpc::tokio_serde::formats::Bincode::default,
63        )
64        .await?;
65        let inner =
66            crate::HelperServiceClient::new(tarpc::client::Config::default(), transport).spawn();
67        let client = Self { inner };
68        client.ensure_compatible().await?;
69        Ok(client)
70    }
71
72    /// Connects only long enough to report the installed helper version.
73    ///
74    /// This intentionally skips compatibility enforcement so diagnostics can
75    /// explain why an old helper must be replaced. It never exposes a client
76    /// capable of sending mutation RPCs.
77    pub async fn probe_version() -> Result<String, ClientError> {
78        let inner = crate::connect().await?;
79        Self { inner }.version().await
80    }
81
82    /// Adds a host route for `subnet` via `iface`.
83    pub async fn route_add(&self, subnet: &str, iface: &str) -> Result<(), ClientError> {
84        Ok(self
85            .inner
86            .route_add(tarpc::context::current(), subnet.into(), iface.into())
87            .await??)
88    }
89
90    /// Removes the host route for `subnet`.
91    pub async fn route_remove(&self, subnet: &str) -> Result<(), ClientError> {
92        Ok(self
93            .inner
94            .route_remove(tarpc::context::current(), subnet.into())
95            .await??)
96    }
97
98    /// Installs a DNS resolver file for `domain` on port `port`.
99    pub async fn dns_install(&self, domain: &str, port: u16) -> Result<(), ClientError> {
100        Ok(self
101            .inner
102            .dns_install(tarpc::context::current(), domain.into(), port)
103            .await??)
104    }
105
106    /// Removes the DNS resolver file for `domain`.
107    pub async fn dns_uninstall(&self, domain: &str) -> Result<(), ClientError> {
108        Ok(self
109            .inner
110            .dns_uninstall(tarpc::context::current(), domain.into())
111            .await??)
112    }
113
114    /// Checks if a DNS resolver file is installed for `domain`.
115    pub async fn dns_status(&self, domain: &str) -> Result<bool, ClientError> {
116        Ok(self
117            .inner
118            .dns_status(tarpc::context::current(), domain.into())
119            .await??)
120    }
121
122    /// Appends the fixed `127.0.0.1 ArcBox` alias to `/etc/hosts`.
123    pub async fn hosts_alias_install(&self) -> Result<(), ClientError> {
124        Ok(self
125            .inner
126            .hosts_alias_install(tarpc::context::current())
127            .await??)
128    }
129
130    /// Removes the ArcBox alias line from `/etc/hosts`.
131    pub async fn hosts_alias_uninstall(&self) -> Result<(), ClientError> {
132        Ok(self
133            .inner
134            .hosts_alias_uninstall(tarpc::context::current())
135            .await??)
136    }
137
138    /// Checks whether the ArcBox `/etc/hosts` alias is installed.
139    pub async fn hosts_alias_status(&self) -> Result<bool, ClientError> {
140        Ok(self
141            .inner
142            .hosts_alias_status(tarpc::context::current())
143            .await??)
144    }
145
146    /// Creates the `/var/run/docker.sock` → `target` symlink.
147    pub async fn socket_link(&self, target: &str) -> Result<(), ClientError> {
148        Ok(self
149            .inner
150            .socket_link(tarpc::context::current(), target.into())
151            .await??)
152    }
153
154    /// Removes the `/var/run/docker.sock` symlink.
155    pub async fn socket_unlink(&self) -> Result<(), ClientError> {
156        Ok(self
157            .inner
158            .socket_unlink(tarpc::context::current())
159            .await??)
160    }
161
162    /// Creates `/usr/local/bin/{name}` → `target` symlink.
163    pub async fn cli_link(&self, name: &str, target: &str) -> Result<(), ClientError> {
164        Ok(self
165            .inner
166            .cli_link(tarpc::context::current(), name.into(), target.into())
167            .await??)
168    }
169
170    /// Removes `/usr/local/bin/{name}` symlink if ArcBox-owned.
171    pub async fn cli_unlink(&self, name: &str) -> Result<(), ClientError> {
172        Ok(self
173            .inner
174            .cli_unlink(tarpc::context::current(), name.into())
175            .await??)
176    }
177
178    /// Returns the helper daemon version.
179    pub async fn version(&self) -> Result<String, ClientError> {
180        Ok(self.inner.version(tarpc::context::current()).await?)
181    }
182
183    async fn ensure_compatible(&self) -> Result<(), ClientError> {
184        let version = self.version().await?;
185        let installed = arcbox_constants::helper::parse_helper_version(&version)
186            .ok_or_else(|| ClientError::UnrecognizedVersion(version.clone()))?;
187        let required = arcbox_constants::helper::MIN_HELPER_VERSION;
188        let minimum = arcbox_constants::helper::parse_semver_triple(required)
189            .expect("MIN_HELPER_VERSION is covered by a unit test");
190
191        if arcbox_constants::helper::helper_version_satisfies(installed, minimum) {
192            Ok(())
193        } else {
194            Err(ClientError::IncompatibleVersion {
195                installed: version,
196                required,
197            })
198        }
199    }
200}