1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
use reqwest::Method;
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use super::{
NodeMetric, NodeMtlsCa, NodeProbeResult, NodeRequest, NodeUpdateChannel, NodeUpdateResult,
NodeView, RemoteInboundOption,
};
use crate::{
Client, Error, HistoryBucket, MetricPoint, Result, WebCertificateFiles,
client::AuthenticationScope, response::ApiResponse,
};
const ROOT: &str = "panel/api/nodes";
/// Remote node registration, health, discovery, update, and mTLS operations.
#[derive(Clone, Copy, Debug)]
pub struct NodesApi<'client> {
client: &'client Client,
}
impl<'client> NodesApi<'client> {
pub(crate) const fn new(client: &'client Client) -> Self {
Self { client }
}
/// Lists direct nodes and read-only transitive projections.
///
/// # Errors
///
/// Returns an error when retrieval or decoding fails.
pub async fn list(self) -> Result<Vec<NodeView>> {
self.get_object("list").await
}
/// Gets one direct node by its panel-local database ID.
///
/// # Errors
///
/// Returns an error when the node is absent or cannot be decoded.
pub async fn get(self, id: i64) -> Result<NodeView> {
self.get_object(&format!("get/{id}")).await
}
/// Fetches TLS certificate/key paths that exist on the remote node.
///
/// # Errors
///
/// Returns an error when the node is disabled, unavailable, or its response
/// cannot be decoded.
pub async fn web_certificate_files(self, id: i64) -> Result<WebCertificateFiles> {
self.get_object(&format!("webCert/{id}")).await
}
/// Registers a node after the controller verifies reachability.
///
/// API-token credentials are required unless mTLS is selected.
///
/// # Errors
///
/// Returns an error when validation, probing, creation, or decoding fails.
pub async fn create(self, request: &NodeRequest) -> Result<NodeView> {
self.post_object("add", request).await
}
/// Fully replaces writable node settings.
///
/// A request built with [`NodeView::to_request`] retains the stored token.
///
/// # Errors
///
/// Returns an error when validation, probing, or persistence fails.
pub async fn update(self, id: i64, request: &NodeRequest) -> Result<()> {
self.post_empty(&format!("update/{id}"), Some(request))
.await
}
/// Deletes a direct node without migrating its assigned inbounds.
///
/// # Errors
///
/// Returns an error when deletion fails.
pub async fn delete(self, id: i64) -> Result<()> {
self.post_empty::<()>(&format!("del/{id}"), None).await
}
/// Enables or disables node synchronization.
///
/// # Errors
///
/// Returns an error when the state cannot be persisted.
pub async fn set_enabled(self, id: i64, enabled: bool) -> Result<()> {
#[derive(Serialize)]
struct Body {
enable: bool,
}
self.post_empty(&format!("setEnable/{id}"), Some(&Body { enable: enabled }))
.await
}
/// Probes unsaved node connection details without persisting them.
///
/// Connectivity failures are returned as [`NodeProbeResult`] with an
/// offline status rather than as an SDK error.
///
/// # Errors
///
/// Returns an error for request validation, transport, envelope, or decode
/// failures.
pub async fn test_connection(self, request: &NodeRequest) -> Result<NodeProbeResult> {
self.post_object("test", request).await
}
/// Fetches the HTTPS leaf certificate's base64 SHA-256 fingerprint.
///
/// The panel intentionally skips verification for this bootstrap request;
/// callers should compare or explicitly pin the returned fingerprint.
///
/// # Errors
///
/// Returns an error when the candidate is invalid, is not HTTPS, or cannot
/// be contacted.
pub async fn certificate_fingerprint(self, request: &NodeRequest) -> Result<String> {
self.post_object("certFingerprint", request).await
}
/// Lists remote inbounds available for selective synchronization.
///
/// # Errors
///
/// Returns an error when the node cannot be contacted or decoded.
pub async fn remote_inbounds(self, request: &NodeRequest) -> Result<Vec<RemoteInboundOption>> {
self.post_object("inbounds", request).await
}
/// Probes a saved node and updates its cached heartbeat state.
///
/// Connectivity failures are returned as an offline result.
///
/// # Errors
///
/// Returns an error when the node cannot be loaded or the response cannot
/// be decoded.
pub async fn probe(self, id: i64) -> Result<NodeProbeResult> {
self.post_object_without_body(&format!("probe/{id}")).await
}
/// Starts the official panel updater on each selected eligible node.
///
/// Offline, disabled, and missing nodes remain successful at the envelope
/// level and carry a per-node error in [`NodeUpdateResult`].
///
/// # Errors
///
/// Returns an error when no nodes are selected or dispatch itself fails.
pub async fn update_panels(
self,
ids: &[i64],
channel: NodeUpdateChannel,
) -> Result<Vec<NodeUpdateResult>> {
#[derive(Serialize)]
struct Body<'a> {
ids: &'a [i64],
dev: bool,
}
self.post_object(
"updatePanel",
&Body {
ids,
dev: channel.is_development(),
},
)
.await
}
/// Returns up to 60 aggregated points for one node metric.
///
/// # Errors
///
/// Returns an error when retrieval or decoding fails.
pub async fn history(
self,
id: i64,
metric: NodeMetric,
bucket: HistoryBucket,
) -> Result<Vec<MetricPoint>> {
self.get_object(&format!(
"history/{id}/{}/{}",
metric.as_str(),
bucket.seconds()
))
.await
}
/// Returns this panel's public node-auth CA, lazily creating it if needed.
///
/// # Errors
///
/// Returns an error when CA or master client-certificate generation fails.
pub async fn mtls_ca(self) -> Result<NodeMtlsCa> {
self.post_object_without_body("mtls/ca").await
}
/// Replaces the CA trusted for incoming node-API client certificates.
///
/// An empty PEM disables incoming node mTLS. The change applies after the
/// panel restarts.
///
/// # Errors
///
/// Returns an error when a non-empty value is not a valid PEM certificate
/// or persistence fails.
pub async fn set_mtls_trust_ca(self, ca_pem: &str) -> Result<()> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Body<'a> {
ca_cert: &'a str,
}
self.post_empty("mtls/trustCA", Some(&Body { ca_cert: ca_pem }))
.await
}
/// Validates the stored master mTLS credential and drops cached pools.
///
/// Subsequent node requests rebuild their transport with the rotated
/// client certificate.
///
/// # Errors
///
/// Returns an error when the stored credential is invalid or reload fails.
pub async fn reload_mtls_client(self) -> Result<()> {
self.post_empty::<()>("mtls/reloadClient", None).await
}
async fn get_object<T>(self, suffix: &str) -> Result<T>
where
T: DeserializeOwned,
{
let path = format!("{ROOT}/{suffix}");
let envelope = self
.client
.execute::<T, ()>(Method::GET, &path, None, AuthenticationScope::PanelApi)
.await?;
self.required_object(Method::GET, &path, envelope)
}
async fn post_object<T, B>(self, suffix: &str, body: &B) -> Result<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let path = format!("{ROOT}/{suffix}");
let envelope = self
.client
.execute(
Method::POST,
&path,
Some(body),
AuthenticationScope::PanelApi,
)
.await?;
self.required_object(Method::POST, &path, envelope)
}
async fn post_object_without_body<T>(self, suffix: &str) -> Result<T>
where
T: DeserializeOwned,
{
let path = format!("{ROOT}/{suffix}");
let envelope = self
.client
.execute::<T, ()>(Method::POST, &path, None, AuthenticationScope::PanelApi)
.await?;
self.required_object(Method::POST, &path, envelope)
}
async fn post_empty<B>(self, suffix: &str, body: Option<&B>) -> Result<()>
where
B: Serialize + ?Sized,
{
let path = format!("{ROOT}/{suffix}");
self.client
.execute::<Value, B>(Method::POST, &path, body, AuthenticationScope::PanelApi)
.await?;
Ok(())
}
fn required_object<T>(self, method: Method, path: &str, envelope: ApiResponse<T>) -> Result<T> {
let url = self.client.endpoint(path)?;
envelope.obj.ok_or_else(|| Error::MissingObject {
method,
url: Box::new(url),
})
}
}