unifi-cli 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_cli-0.4.3/.github/workflows/dependencies.yml +25 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/release.yml +8 -1
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/CHANGELOG.md +10 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Cargo.lock +1 -1
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Cargo.toml +1 -1
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/PKG-INFO +33 -8
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/README.md +32 -7
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/client.rs +263 -0
- unifi_cli-0.4.3/src/api/mod.rs +14 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/tests.rs +160 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/types.rs +490 -0
- unifi_cli-0.4.3/src/commands/dns.rs +434 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/mod.rs +1 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/fields.rs +14 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/lib.rs +4 -2
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/main.rs +268 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/schema.rs +61 -5
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/cli_contract.rs +82 -1
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/mock_server.rs +398 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/spec_compliance.rs +17 -2
- unifi_cli-0.4.2/.github/workflows/upd.yml +0 -19
- unifi_cli-0.4.2/src/api/mod.rs +0 -13
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/clispec.yml +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.gitignore +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.mise.toml +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/LICENSE +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Makefile +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/docs/releases.md +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/prek.toml +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/pyproject.toml +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/clients.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/devices.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/events.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/networks.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/port_forwards.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/ports.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/protect.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/system.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/wan.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/output.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/tui.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/unifi_cli_alias.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/fixtures/clispec-v0.3.json +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/no_real_network_data.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/package_alias.rs +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/unifi_cli/__init__.py +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/unifi_cli/__main__.py +0 -0
- {unifi_cli-0.4.2 → unifi_cli-0.4.3}/vership.toml +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Dependency health
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "51 6 * * 1"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
packages: read
|
|
12
|
+
pull-requests: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
update:
|
|
16
|
+
uses: rvben/upd/.github/workflows/dependency-health.yml@ecb605d32c4edc8dee4091aeb30b3d37ebdd290a # v0.10.0
|
|
17
|
+
with:
|
|
18
|
+
langs: rust,actions
|
|
19
|
+
lock: true
|
|
20
|
+
min-age: 7d
|
|
21
|
+
max-bump: minor
|
|
22
|
+
validation-command: make check
|
|
23
|
+
fail-on-blocked: false
|
|
24
|
+
branch: deps/upd
|
|
25
|
+
commit-message: "chore(deps): refresh dependencies"
|
|
@@ -270,6 +270,7 @@ jobs:
|
|
|
270
270
|
runs-on: ubuntu-latest
|
|
271
271
|
permissions:
|
|
272
272
|
contents: write
|
|
273
|
+
id-token: write
|
|
273
274
|
needs: [build, sdist]
|
|
274
275
|
steps:
|
|
275
276
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
@@ -283,10 +284,16 @@ jobs:
|
|
|
283
284
|
with:
|
|
284
285
|
enable-cache: false
|
|
285
286
|
|
|
287
|
+
- name: Authenticate to crates.io via Trusted Publishing
|
|
288
|
+
id: crates_io_auth
|
|
289
|
+
if: ${{ inputs.dry_run != true && inputs.skip_crates_io != true }}
|
|
290
|
+
# v1.0.5
|
|
291
|
+
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18
|
|
292
|
+
|
|
286
293
|
- name: Publish to crates.io
|
|
287
294
|
if: ${{ inputs.dry_run != true && inputs.skip_crates_io != true }}
|
|
288
295
|
env:
|
|
289
|
-
CARGO_REGISTRY_TOKEN: ${{
|
|
296
|
+
CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }}
|
|
290
297
|
run: cargo publish --locked
|
|
291
298
|
|
|
292
299
|
- name: Test crates.io publish (dry run)
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.3](https://github.com/rvben/unifi-cli/compare/v0.4.2...v0.4.3) - 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **dns**: manage static DNS records ([149edf3](https://github.com/rvben/unifi-cli/commit/149edf379d1df00e79ea8c2b783c9895e4d7f74e))
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **dns**: stop update from destroying records on v2 controllers ([445be02](https://github.com/rvben/unifi-cli/commit/445be02b5ab59dd04382ac93787afc407b7d4bd9))
|
|
14
|
+
|
|
5
15
|
## [0.4.2](https://github.com/rvben/unifi-cli/compare/v0.4.1...v0.4.2) - 2026-08-26
|
|
6
16
|
|
|
7
17
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Environment :: Console
|
|
6
6
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -137,16 +137,16 @@ to trust the controller and saves `accept_invalid_certs = true` for you.
|
|
|
137
137
|
### Destructive commands
|
|
138
138
|
|
|
139
139
|
`clients block`, `clients unblock`, `clients kick`, `devices restart`,
|
|
140
|
-
`devices upgrade`, `ports cycle
|
|
141
|
-
act. On a terminal you get a yes/no question naming the target;
|
|
142
|
-
2 with `kind: confirmation_required` and sends nothing. When
|
|
143
|
-
terminal there is nobody to ask, so they refuse with the same
|
|
144
|
-
pass `--yes`, which skips the question everywhere.
|
|
140
|
+
`devices upgrade`, `ports cycle`, `protect rtsps delete` and `dns delete` all
|
|
141
|
+
ask before they act. On a terminal you get a yes/no question naming the target;
|
|
142
|
+
declining exits 2 with `kind: confirmation_required` and sends nothing. When
|
|
143
|
+
stdin is not a terminal there is nobody to ask, so they refuse with the same
|
|
144
|
+
error unless you pass `--yes`, which skips the question everywhere.
|
|
145
145
|
|
|
146
146
|
`unifi schema` marks exactly these commands `confirmation_required: true`, so a
|
|
147
147
|
caller can tell them apart from mutating commands that act immediately
|
|
148
|
-
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`
|
|
149
|
-
hardcoding the list.
|
|
148
|
+
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`, `dns create`,
|
|
149
|
+
`dns update`) without hardcoding the list.
|
|
150
150
|
|
|
151
151
|
## TUI dashboard
|
|
152
152
|
|
|
@@ -283,6 +283,31 @@ unifi port-forwards list
|
|
|
283
283
|
unifi port-forwards show plex
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
+
### DNS records
|
|
287
|
+
|
|
288
|
+
Static DNS records the controller answers locally. Network 10.1+ uses the
|
|
289
|
+
official DNS-policies Integration API; older controllers fall back to the v2
|
|
290
|
+
`static-dns` API. Domain-forward policies are not listed.
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
unifi dns list
|
|
294
|
+
unifi dns list --type A
|
|
295
|
+
unifi dns show nas.example.com
|
|
296
|
+
unifi dns create nas.example.com 192.0.2.10
|
|
297
|
+
unifi dns create printer.example.com 2001:db8::10 --type AAAA
|
|
298
|
+
unifi dns create media.example.com nas.example.com --type CNAME
|
|
299
|
+
unifi dns update nas.example.com --value 192.0.2.11
|
|
300
|
+
unifi dns delete nas.example.com
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`dns delete` is destructive and follows the same confirmation rules as
|
|
304
|
+
`clients block`. Create and update act immediately.
|
|
305
|
+
|
|
306
|
+
The v2 API has no PUT, so `dns update` is refused on a controller that falls
|
|
307
|
+
back to it. Editing a record there means `dns delete` followed by
|
|
308
|
+
`dns create`, which keeps the delete explicit and confirmed instead of hiding
|
|
309
|
+
it inside an update that cannot undo it when the create fails.
|
|
310
|
+
|
|
286
311
|
### Events
|
|
287
312
|
|
|
288
313
|
```bash
|
|
@@ -116,16 +116,16 @@ to trust the controller and saves `accept_invalid_certs = true` for you.
|
|
|
116
116
|
### Destructive commands
|
|
117
117
|
|
|
118
118
|
`clients block`, `clients unblock`, `clients kick`, `devices restart`,
|
|
119
|
-
`devices upgrade`, `ports cycle
|
|
120
|
-
act. On a terminal you get a yes/no question naming the target;
|
|
121
|
-
2 with `kind: confirmation_required` and sends nothing. When
|
|
122
|
-
terminal there is nobody to ask, so they refuse with the same
|
|
123
|
-
pass `--yes`, which skips the question everywhere.
|
|
119
|
+
`devices upgrade`, `ports cycle`, `protect rtsps delete` and `dns delete` all
|
|
120
|
+
ask before they act. On a terminal you get a yes/no question naming the target;
|
|
121
|
+
declining exits 2 with `kind: confirmation_required` and sends nothing. When
|
|
122
|
+
stdin is not a terminal there is nobody to ask, so they refuse with the same
|
|
123
|
+
error unless you pass `--yes`, which skips the question everywhere.
|
|
124
124
|
|
|
125
125
|
`unifi schema` marks exactly these commands `confirmation_required: true`, so a
|
|
126
126
|
caller can tell them apart from mutating commands that act immediately
|
|
127
|
-
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`
|
|
128
|
-
hardcoding the list.
|
|
127
|
+
(`devices locate`, `clients set-fixed-ip`, `protect rtsps create`, `dns create`,
|
|
128
|
+
`dns update`) without hardcoding the list.
|
|
129
129
|
|
|
130
130
|
## TUI dashboard
|
|
131
131
|
|
|
@@ -262,6 +262,31 @@ unifi port-forwards list
|
|
|
262
262
|
unifi port-forwards show plex
|
|
263
263
|
```
|
|
264
264
|
|
|
265
|
+
### DNS records
|
|
266
|
+
|
|
267
|
+
Static DNS records the controller answers locally. Network 10.1+ uses the
|
|
268
|
+
official DNS-policies Integration API; older controllers fall back to the v2
|
|
269
|
+
`static-dns` API. Domain-forward policies are not listed.
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
unifi dns list
|
|
273
|
+
unifi dns list --type A
|
|
274
|
+
unifi dns show nas.example.com
|
|
275
|
+
unifi dns create nas.example.com 192.0.2.10
|
|
276
|
+
unifi dns create printer.example.com 2001:db8::10 --type AAAA
|
|
277
|
+
unifi dns create media.example.com nas.example.com --type CNAME
|
|
278
|
+
unifi dns update nas.example.com --value 192.0.2.11
|
|
279
|
+
unifi dns delete nas.example.com
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`dns delete` is destructive and follows the same confirmation rules as
|
|
283
|
+
`clients block`. Create and update act immediately.
|
|
284
|
+
|
|
285
|
+
The v2 API has no PUT, so `dns update` is refused on a controller that falls
|
|
286
|
+
back to it. Editing a record there means `dns delete` followed by
|
|
287
|
+
`dns create`, which keeps the delete explicit and confirmed instead of hiding
|
|
288
|
+
it inside an update that cannot undo it when the create fails.
|
|
289
|
+
|
|
265
290
|
### Events
|
|
266
291
|
|
|
267
292
|
```bash
|
|
@@ -118,10 +118,17 @@ fn normalize_base_url(host: &str) -> Result<String, ApiError> {
|
|
|
118
118
|
Ok(candidate)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
122
|
+
enum DnsApiKind {
|
|
123
|
+
Integration,
|
|
124
|
+
LegacyV2,
|
|
125
|
+
}
|
|
126
|
+
|
|
121
127
|
pub struct UnifiClient {
|
|
122
128
|
http: reqwest::Client,
|
|
123
129
|
base_url: String,
|
|
124
130
|
site_id: Option<String>,
|
|
131
|
+
dns_api: Option<DnsApiKind>,
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
impl UnifiClient {
|
|
@@ -153,6 +160,7 @@ impl UnifiClient {
|
|
|
153
160
|
http,
|
|
154
161
|
base_url,
|
|
155
162
|
site_id: None,
|
|
163
|
+
dns_api: None,
|
|
156
164
|
})
|
|
157
165
|
}
|
|
158
166
|
|
|
@@ -256,6 +264,53 @@ impl UnifiClient {
|
|
|
256
264
|
json_or_unsupported(resp, &endpoint).await
|
|
257
265
|
}
|
|
258
266
|
|
|
267
|
+
async fn send(
|
|
268
|
+
&self,
|
|
269
|
+
method: reqwest::Method,
|
|
270
|
+
path: &str,
|
|
271
|
+
body: Option<&serde_json::Value>,
|
|
272
|
+
) -> Result<reqwest::Response, ApiError> {
|
|
273
|
+
let url = format!("{}{path}", self.base_url);
|
|
274
|
+
let mut req = self.http.request(method, &url);
|
|
275
|
+
if let Some(body) = body {
|
|
276
|
+
req = req.json(body);
|
|
277
|
+
}
|
|
278
|
+
let resp = req.send().await?;
|
|
279
|
+
let status = resp.status().as_u16();
|
|
280
|
+
if !resp.status().is_success() {
|
|
281
|
+
let body = resp.text().await.unwrap_or_default();
|
|
282
|
+
return Err(error_for_status(status, body));
|
|
283
|
+
}
|
|
284
|
+
Ok(resp)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async fn send_json<T: DeserializeOwned>(
|
|
288
|
+
&self,
|
|
289
|
+
method: reqwest::Method,
|
|
290
|
+
path: &str,
|
|
291
|
+
body: Option<&serde_json::Value>,
|
|
292
|
+
) -> Result<T, ApiError> {
|
|
293
|
+
let resp = self.send(method, path, body).await?;
|
|
294
|
+
json_or_unsupported(resp, path).await
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async fn send_empty(
|
|
298
|
+
&self,
|
|
299
|
+
method: reqwest::Method,
|
|
300
|
+
path: &str,
|
|
301
|
+
body: Option<&serde_json::Value>,
|
|
302
|
+
) -> Result<(), ApiError> {
|
|
303
|
+
self.send(method, path, body).await?;
|
|
304
|
+
Ok(())
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// True when a DNS endpoint is not there: the legacy markers, plus the
|
|
308
|
+
/// `Unsupported` that `json_or_unsupported` raises when UniFi OS proxies
|
|
309
|
+
/// an unknown path to the web UI.
|
|
310
|
+
fn is_absent_dns_endpoint(err: &ApiError) -> bool {
|
|
311
|
+
is_absent_legacy_endpoint(err) || matches!(err, ApiError::Unsupported { .. })
|
|
312
|
+
}
|
|
313
|
+
|
|
259
314
|
// Paginate through all results from Integration API
|
|
260
315
|
async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
|
|
261
316
|
let mut all = Vec::new();
|
|
@@ -477,6 +532,214 @@ impl UnifiClient {
|
|
|
477
532
|
.ok_or_else(|| ApiError::NotFound(format!("Port forward '{identifier}' not found")))
|
|
478
533
|
}
|
|
479
534
|
|
|
535
|
+
// Static DNS records.
|
|
536
|
+
//
|
|
537
|
+
// Network 10.1+ serves these as Integration DNS policies:
|
|
538
|
+
// `/proxy/network/integration/v1/sites/{siteId}/dns/policies`. Network 8.2+
|
|
539
|
+
// serves the same records at `/proxy/network/v2/api/site/{site}/static-dns`.
|
|
540
|
+
// Domain-forward policies live on the Integration collection too and are
|
|
541
|
+
// not static records, so they are dropped.
|
|
542
|
+
|
|
543
|
+
async fn ensure_dns_api(&mut self) -> Result<DnsApiKind, ApiError> {
|
|
544
|
+
if let Some(kind) = self.dns_api {
|
|
545
|
+
return Ok(kind);
|
|
546
|
+
}
|
|
547
|
+
match self.probe_integration_dns().await {
|
|
548
|
+
Ok(()) => {
|
|
549
|
+
self.dns_api = Some(DnsApiKind::Integration);
|
|
550
|
+
Ok(DnsApiKind::Integration)
|
|
551
|
+
}
|
|
552
|
+
Err(err) if Self::is_absent_dns_endpoint(&err) => {
|
|
553
|
+
match self.list_legacy_static_dns().await {
|
|
554
|
+
Ok(_) => {
|
|
555
|
+
self.dns_api = Some(DnsApiKind::LegacyV2);
|
|
556
|
+
Ok(DnsApiKind::LegacyV2)
|
|
557
|
+
}
|
|
558
|
+
Err(legacy_err) if Self::is_absent_dns_endpoint(&legacy_err) => {
|
|
559
|
+
Err(ApiError::Unsupported {
|
|
560
|
+
endpoint: "/proxy/network/integration/v1/sites/{siteId}/dns/policies"
|
|
561
|
+
.into(),
|
|
562
|
+
reason: UnsupportedReason::Removed,
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
Err(legacy_err) => Err(legacy_err),
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
Err(err) => Err(err),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async fn probe_integration_dns(&mut self) -> Result<(), ApiError> {
|
|
573
|
+
let site_id = self.ensure_site_id().await?.to_string();
|
|
574
|
+
let _: PaginatedResponse<IntegrationDnsPolicy> = self
|
|
575
|
+
.get_integration(&format!(
|
|
576
|
+
"/proxy/network/integration/v1/sites/{site_id}/dns/policies?offset=0&limit=1"
|
|
577
|
+
))
|
|
578
|
+
.await?;
|
|
579
|
+
Ok(())
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async fn list_integration_dns_policies(&mut self) -> Result<Vec<StaticDnsRecord>, ApiError> {
|
|
583
|
+
let site_id = self.ensure_site_id().await?.to_string();
|
|
584
|
+
let policies: Vec<IntegrationDnsPolicy> = self
|
|
585
|
+
.paginate_all(&format!(
|
|
586
|
+
"/proxy/network/integration/v1/sites/{site_id}/dns/policies"
|
|
587
|
+
))
|
|
588
|
+
.await?;
|
|
589
|
+
Ok(policies
|
|
590
|
+
.iter()
|
|
591
|
+
.filter_map(IntegrationDnsPolicy::to_static_record)
|
|
592
|
+
.collect())
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async fn list_legacy_static_dns(&self) -> Result<Vec<StaticDnsRecord>, ApiError> {
|
|
596
|
+
let records: Vec<LegacyStaticDns> = self
|
|
597
|
+
.send_json(
|
|
598
|
+
reqwest::Method::GET,
|
|
599
|
+
"/proxy/network/v2/api/site/default/static-dns",
|
|
600
|
+
None,
|
|
601
|
+
)
|
|
602
|
+
.await?;
|
|
603
|
+
Ok(records
|
|
604
|
+
.iter()
|
|
605
|
+
.filter_map(LegacyStaticDns::to_static_record)
|
|
606
|
+
.collect())
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
pub async fn list_static_dns(&mut self) -> Result<Vec<StaticDnsRecord>, ApiError> {
|
|
610
|
+
match self.ensure_dns_api().await? {
|
|
611
|
+
DnsApiKind::Integration => self.list_integration_dns_policies().await,
|
|
612
|
+
DnsApiKind::LegacyV2 => self.list_legacy_static_dns().await,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
pub async fn get_static_dns(&mut self, identifier: &str) -> Result<StaticDnsRecord, ApiError> {
|
|
617
|
+
let records = self.list_static_dns().await?;
|
|
618
|
+
resolve_static_dns(&records, identifier).cloned()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
pub async fn create_static_dns(
|
|
622
|
+
&mut self,
|
|
623
|
+
write: &StaticDnsWrite,
|
|
624
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
625
|
+
match self.ensure_dns_api().await? {
|
|
626
|
+
DnsApiKind::Integration => self.create_integration_dns(write).await,
|
|
627
|
+
DnsApiKind::LegacyV2 => self.create_legacy_static_dns(write).await,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
pub async fn update_static_dns(
|
|
632
|
+
&mut self,
|
|
633
|
+
identifier: &str,
|
|
634
|
+
write: &StaticDnsWrite,
|
|
635
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
636
|
+
let existing = self.get_static_dns(identifier).await?;
|
|
637
|
+
match self.dns_api {
|
|
638
|
+
Some(DnsApiKind::Integration) => self.update_integration_dns(&existing.id, write).await,
|
|
639
|
+
Some(DnsApiKind::LegacyV2) => {
|
|
640
|
+
// The v2 API has no PUT. Deleting the record and recreating it
|
|
641
|
+
// would lose it outright whenever the create fails, and the
|
|
642
|
+
// caller asked to edit a record, not to risk it. Deleting is
|
|
643
|
+
// still available as its own confirmed command.
|
|
644
|
+
Err(ApiError::Unsupported {
|
|
645
|
+
endpoint: "/proxy/network/v2/api/site/default/static-dns".into(),
|
|
646
|
+
reason: UnsupportedReason::NoUpdateEndpoint,
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
None => unreachable!("get_static_dns selects an API"),
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pub async fn delete_static_dns(
|
|
654
|
+
&mut self,
|
|
655
|
+
identifier: &str,
|
|
656
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
657
|
+
let existing = self.get_static_dns(identifier).await?;
|
|
658
|
+
match self.dns_api {
|
|
659
|
+
Some(DnsApiKind::Integration) => {
|
|
660
|
+
self.delete_integration_dns(&existing.id).await?;
|
|
661
|
+
}
|
|
662
|
+
Some(DnsApiKind::LegacyV2) => {
|
|
663
|
+
self.delete_legacy_static_dns(&existing.id).await?;
|
|
664
|
+
}
|
|
665
|
+
None => unreachable!("get_static_dns selects an API"),
|
|
666
|
+
}
|
|
667
|
+
Ok(existing)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async fn create_integration_dns(
|
|
671
|
+
&mut self,
|
|
672
|
+
write: &StaticDnsWrite,
|
|
673
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
674
|
+
let site_id = self.ensure_site_id().await?.to_string();
|
|
675
|
+
let body = write.integration_body().map_err(ApiError::Other)?;
|
|
676
|
+
let created: IntegrationDnsPolicy = self
|
|
677
|
+
.send_json(
|
|
678
|
+
reqwest::Method::POST,
|
|
679
|
+
&format!("/proxy/network/integration/v1/sites/{site_id}/dns/policies"),
|
|
680
|
+
Some(&body),
|
|
681
|
+
)
|
|
682
|
+
.await?;
|
|
683
|
+
created.to_static_record().ok_or_else(|| {
|
|
684
|
+
ApiError::Other("Controller created a DNS policy that is not a static record".into())
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async fn update_integration_dns(
|
|
689
|
+
&mut self,
|
|
690
|
+
id: &str,
|
|
691
|
+
write: &StaticDnsWrite,
|
|
692
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
693
|
+
let site_id = self.ensure_site_id().await?.to_string();
|
|
694
|
+
let body = write.integration_body().map_err(ApiError::Other)?;
|
|
695
|
+
let updated: IntegrationDnsPolicy = self
|
|
696
|
+
.send_json(
|
|
697
|
+
reqwest::Method::PUT,
|
|
698
|
+
&format!("/proxy/network/integration/v1/sites/{site_id}/dns/policies/{id}"),
|
|
699
|
+
Some(&body),
|
|
700
|
+
)
|
|
701
|
+
.await?;
|
|
702
|
+
updated.to_static_record().ok_or_else(|| {
|
|
703
|
+
ApiError::Other("Controller updated a DNS policy that is not a static record".into())
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async fn delete_integration_dns(&mut self, id: &str) -> Result<(), ApiError> {
|
|
708
|
+
let site_id = self.ensure_site_id().await?.to_string();
|
|
709
|
+
self.send_empty(
|
|
710
|
+
reqwest::Method::DELETE,
|
|
711
|
+
&format!("/proxy/network/integration/v1/sites/{site_id}/dns/policies/{id}"),
|
|
712
|
+
None,
|
|
713
|
+
)
|
|
714
|
+
.await
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async fn create_legacy_static_dns(
|
|
718
|
+
&self,
|
|
719
|
+
write: &StaticDnsWrite,
|
|
720
|
+
) -> Result<StaticDnsRecord, ApiError> {
|
|
721
|
+
let body = write.legacy_body().map_err(ApiError::Other)?;
|
|
722
|
+
let created: LegacyStaticDns = self
|
|
723
|
+
.send_json(
|
|
724
|
+
reqwest::Method::POST,
|
|
725
|
+
"/proxy/network/v2/api/site/default/static-dns",
|
|
726
|
+
Some(&body),
|
|
727
|
+
)
|
|
728
|
+
.await?;
|
|
729
|
+
created.to_static_record().ok_or_else(|| {
|
|
730
|
+
ApiError::Other("Controller created an unreadable static DNS record".into())
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async fn delete_legacy_static_dns(&self, id: &str) -> Result<(), ApiError> {
|
|
735
|
+
self.send_empty(
|
|
736
|
+
reqwest::Method::DELETE,
|
|
737
|
+
&format!("/proxy/network/v2/api/site/default/static-dns/{id}"),
|
|
738
|
+
None,
|
|
739
|
+
)
|
|
740
|
+
.await
|
|
741
|
+
}
|
|
742
|
+
|
|
480
743
|
pub async fn list_wan_interfaces(&self) -> Result<Vec<NamedWanInterface>, ApiError> {
|
|
481
744
|
let gateways: Vec<GatewayWanStatus> = self.get_legacy("/stat/device").await?;
|
|
482
745
|
let gateway = gateways
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mod client;
|
|
2
|
+
pub mod types;
|
|
3
|
+
|
|
4
|
+
pub use client::ClientOptions;
|
|
5
|
+
pub use client::ProtectSession;
|
|
6
|
+
pub use client::UnifiClient;
|
|
7
|
+
pub use client::error_for_status;
|
|
8
|
+
pub use types::{
|
|
9
|
+
ApiError, Client, DEFAULT_DNS_TTL_SECONDS, Device, DeviceWithPorts, DnsRecordType, Event,
|
|
10
|
+
HealthSubsystem, HostSystem, LastConnection, LegacyClient, LegacyDevice, LegacyResponse,
|
|
11
|
+
NamedWanInterface, Network, PortEntry, PortForward, StaticDnsRecord, StaticDnsWrite, SysInfo,
|
|
12
|
+
UnsupportedReason, WanInterface, format_bytes, format_mac, format_uptime, normalize_mac,
|
|
13
|
+
resolve_static_dns,
|
|
14
|
+
};
|
|
@@ -894,3 +894,163 @@ fn port_entry_tolerates_absent_last_connection() {
|
|
|
894
894
|
assert_eq!(p.autoneg, None);
|
|
895
895
|
assert_eq!(p.is_uplink, None);
|
|
896
896
|
}
|
|
897
|
+
|
|
898
|
+
#[test]
|
|
899
|
+
fn dns_record_type_accepts_integration_and_legacy_spellings() {
|
|
900
|
+
assert_eq!(DnsRecordType::parse("A").unwrap(), DnsRecordType::A);
|
|
901
|
+
assert_eq!(DnsRecordType::parse("a_record").unwrap(), DnsRecordType::A);
|
|
902
|
+
assert_eq!(DnsRecordType::parse("AAAA").unwrap(), DnsRecordType::Aaaa);
|
|
903
|
+
assert!(DnsRecordType::parse("FORWARD_DOMAIN").is_err());
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
#[test]
|
|
907
|
+
fn integration_forward_domain_is_not_a_static_record() {
|
|
908
|
+
let policy: IntegrationDnsPolicy = serde_json::from_value(serde_json::json!({
|
|
909
|
+
"type": "FORWARD_DOMAIN",
|
|
910
|
+
"id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
|
911
|
+
"enabled": true,
|
|
912
|
+
"domain": "corp.example.com",
|
|
913
|
+
"ipAddress": "192.0.2.53"
|
|
914
|
+
}))
|
|
915
|
+
.unwrap();
|
|
916
|
+
assert!(policy.to_static_record().is_none());
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#[test]
|
|
920
|
+
fn integration_a_record_maps_to_the_cli_shape() {
|
|
921
|
+
let policy: IntegrationDnsPolicy = serde_json::from_value(serde_json::json!({
|
|
922
|
+
"type": "A_RECORD",
|
|
923
|
+
"id": "11111111-2222-3333-4444-555555555555",
|
|
924
|
+
"enabled": true,
|
|
925
|
+
"domain": "nas.example.com",
|
|
926
|
+
"ipv4Address": "192.0.2.10",
|
|
927
|
+
"ttlSeconds": 14400
|
|
928
|
+
}))
|
|
929
|
+
.unwrap();
|
|
930
|
+
let record = policy.to_static_record().unwrap();
|
|
931
|
+
assert_eq!(record.name, "nas.example.com");
|
|
932
|
+
assert_eq!(record.record_type, DnsRecordType::A);
|
|
933
|
+
assert_eq!(record.value, "192.0.2.10");
|
|
934
|
+
assert_eq!(record.ttl, Some(14400));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
#[test]
|
|
938
|
+
fn integration_srv_owner_is_rebuilt_from_split_fields() {
|
|
939
|
+
let policy: IntegrationDnsPolicy = serde_json::from_value(serde_json::json!({
|
|
940
|
+
"type": "SRV_RECORD",
|
|
941
|
+
"id": "11111111-2222-3333-4444-555555555555",
|
|
942
|
+
"enabled": true,
|
|
943
|
+
"domain": "example.com",
|
|
944
|
+
"service": "_ldap",
|
|
945
|
+
"protocol": "_tcp",
|
|
946
|
+
"serverDomain": "dir.example.com",
|
|
947
|
+
"priority": 10,
|
|
948
|
+
"weight": 20,
|
|
949
|
+
"port": 389
|
|
950
|
+
}))
|
|
951
|
+
.unwrap();
|
|
952
|
+
let record = policy.to_static_record().unwrap();
|
|
953
|
+
assert_eq!(record.name, "_ldap._tcp.example.com");
|
|
954
|
+
assert_eq!(record.value, "dir.example.com");
|
|
955
|
+
assert_eq!(record.port, Some(389));
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#[test]
|
|
959
|
+
fn mx_write_omits_ttl_on_the_integration_body() {
|
|
960
|
+
let write = StaticDnsWrite {
|
|
961
|
+
name: "example.com".into(),
|
|
962
|
+
record_type: DnsRecordType::Mx,
|
|
963
|
+
value: "mail.example.com".into(),
|
|
964
|
+
ttl: None,
|
|
965
|
+
enabled: true,
|
|
966
|
+
priority: Some(10),
|
|
967
|
+
weight: None,
|
|
968
|
+
port: None,
|
|
969
|
+
};
|
|
970
|
+
let body = write.integration_body().unwrap();
|
|
971
|
+
assert!(body.get("ttlSeconds").is_none(), "{body}");
|
|
972
|
+
assert_eq!(body["mailServerDomain"], "mail.example.com");
|
|
973
|
+
assert_eq!(body["priority"], 10);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
#[test]
|
|
977
|
+
fn resolve_static_dns_prefers_id_and_conflicts_on_duplicate_names() {
|
|
978
|
+
let records = vec![
|
|
979
|
+
StaticDnsRecord {
|
|
980
|
+
id: "id-1".into(),
|
|
981
|
+
name: "dup.example.com".into(),
|
|
982
|
+
record_type: DnsRecordType::A,
|
|
983
|
+
value: "192.0.2.10".into(),
|
|
984
|
+
ttl: None,
|
|
985
|
+
enabled: true,
|
|
986
|
+
priority: None,
|
|
987
|
+
weight: None,
|
|
988
|
+
port: None,
|
|
989
|
+
},
|
|
990
|
+
StaticDnsRecord {
|
|
991
|
+
id: "id-2".into(),
|
|
992
|
+
name: "dup.example.com".into(),
|
|
993
|
+
record_type: DnsRecordType::A,
|
|
994
|
+
value: "192.0.2.11".into(),
|
|
995
|
+
ttl: None,
|
|
996
|
+
enabled: true,
|
|
997
|
+
priority: None,
|
|
998
|
+
weight: None,
|
|
999
|
+
port: None,
|
|
1000
|
+
},
|
|
1001
|
+
];
|
|
1002
|
+
assert_eq!(resolve_static_dns(&records, "id-2").unwrap().id, "id-2");
|
|
1003
|
+
match resolve_static_dns(&records, "dup.example.com") {
|
|
1004
|
+
Err(ApiError::Conflict(message)) => {
|
|
1005
|
+
assert!(message.contains("id-1"), "{message}");
|
|
1006
|
+
assert!(message.contains("id-2"), "{message}");
|
|
1007
|
+
}
|
|
1008
|
+
other => panic!("expected conflict, got {other:?}"),
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
#[test]
|
|
1013
|
+
fn update_from_a_legacy_mx_record_drops_the_ttl_it_cannot_send() {
|
|
1014
|
+
let existing = StaticDnsRecord {
|
|
1015
|
+
id: "aaaaaaaaaaaaaaaaaaaaaaaa".into(),
|
|
1016
|
+
name: "example.com".into(),
|
|
1017
|
+
record_type: DnsRecordType::Mx,
|
|
1018
|
+
value: "mail.example.com".into(),
|
|
1019
|
+
ttl: Some(300),
|
|
1020
|
+
enabled: true,
|
|
1021
|
+
priority: Some(10),
|
|
1022
|
+
weight: None,
|
|
1023
|
+
port: None,
|
|
1024
|
+
};
|
|
1025
|
+
let write = StaticDnsWrite::from_record(&existing);
|
|
1026
|
+
assert_eq!(write.ttl, None);
|
|
1027
|
+
write
|
|
1028
|
+
.validate()
|
|
1029
|
+
.expect("an untouched MX record must still validate");
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
#[test]
|
|
1033
|
+
fn legacy_body_keeps_a_trailing_dot_in_txt_data() {
|
|
1034
|
+
let write = StaticDnsWrite {
|
|
1035
|
+
name: "example.com".into(),
|
|
1036
|
+
record_type: DnsRecordType::Txt,
|
|
1037
|
+
value: "v=spf1 -all.".into(),
|
|
1038
|
+
ttl: None,
|
|
1039
|
+
enabled: true,
|
|
1040
|
+
priority: None,
|
|
1041
|
+
weight: None,
|
|
1042
|
+
port: None,
|
|
1043
|
+
};
|
|
1044
|
+
assert_eq!(write.legacy_body().unwrap()["value"], "v=spf1 -all.");
|
|
1045
|
+
let cname = StaticDnsWrite {
|
|
1046
|
+
name: "media.example.com".into(),
|
|
1047
|
+
record_type: DnsRecordType::Cname,
|
|
1048
|
+
value: "nas.example.com.".into(),
|
|
1049
|
+
ttl: None,
|
|
1050
|
+
enabled: true,
|
|
1051
|
+
priority: None,
|
|
1052
|
+
weight: None,
|
|
1053
|
+
port: None,
|
|
1054
|
+
};
|
|
1055
|
+
assert_eq!(cname.legacy_body().unwrap()["value"], "nas.example.com");
|
|
1056
|
+
}
|