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.
Files changed (49) hide show
  1. unifi_cli-0.4.3/.github/workflows/dependencies.yml +25 -0
  2. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/release.yml +8 -1
  3. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/CHANGELOG.md +10 -0
  4. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Cargo.lock +1 -1
  5. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Cargo.toml +1 -1
  6. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/PKG-INFO +33 -8
  7. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/README.md +32 -7
  8. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/client.rs +263 -0
  9. unifi_cli-0.4.3/src/api/mod.rs +14 -0
  10. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/tests.rs +160 -0
  11. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/api/types.rs +490 -0
  12. unifi_cli-0.4.3/src/commands/dns.rs +434 -0
  13. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/mod.rs +1 -0
  14. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/fields.rs +14 -0
  15. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/lib.rs +4 -2
  16. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/main.rs +268 -0
  17. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/schema.rs +61 -5
  18. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/cli_contract.rs +82 -1
  19. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/mock_server.rs +398 -0
  20. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/spec_compliance.rs +17 -2
  21. unifi_cli-0.4.2/.github/workflows/upd.yml +0 -19
  22. unifi_cli-0.4.2/src/api/mod.rs +0 -13
  23. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/ci.yml +0 -0
  24. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.github/workflows/clispec.yml +0 -0
  25. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.gitignore +0 -0
  26. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/.mise.toml +0 -0
  27. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/LICENSE +0 -0
  28. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/Makefile +0 -0
  29. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/docs/releases.md +0 -0
  30. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/prek.toml +0 -0
  31. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/pyproject.toml +0 -0
  32. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/clients.rs +0 -0
  33. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/devices.rs +0 -0
  34. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/events.rs +0 -0
  35. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/networks.rs +0 -0
  36. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/port_forwards.rs +0 -0
  37. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/ports.rs +0 -0
  38. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/protect.rs +0 -0
  39. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/system.rs +0 -0
  40. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/commands/wan.rs +0 -0
  41. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/output.rs +0 -0
  42. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/tui.rs +0 -0
  43. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/src/unifi_cli_alias.rs +0 -0
  44. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/fixtures/clispec-v0.3.json +0 -0
  45. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/no_real_network_data.rs +0 -0
  46. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/tests/package_alias.rs +0 -0
  47. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/unifi_cli/__init__.py +0 -0
  48. {unifi_cli-0.4.2 → unifi_cli-0.4.3}/unifi_cli/__main__.py +0 -0
  49. {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: ${{ secrets.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
@@ -2786,7 +2786,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.4.2"
2789
+ version = "0.4.3"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.4.2
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` and `protect rtsps delete` all ask before they
141
- act. On a terminal you get a yes/no question naming the target; declining exits
142
- 2 with `kind: confirmation_required` and sends nothing. When stdin is not a
143
- terminal there is nobody to ask, so they refuse with the same error unless you
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`) without
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` and `protect rtsps delete` all ask before they
120
- act. On a terminal you get a yes/no question naming the target; declining exits
121
- 2 with `kind: confirmation_required` and sends nothing. When stdin is not a
122
- terminal there is nobody to ask, so they refuse with the same error unless you
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`) without
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
+ }