Skip to main content

dinio_sdk/models/
client_identity.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::error::{Error, Result};
8
9/// A client that Relay recognises (by source IP, DoT hostname or DoH path), so that
10/// sinkhole lists can be linked to it.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ClientIdentity {
13    /// Identity id.
14    pub uuid: Uuid,
15    /// `IP`, `Hostname`, `Path` or `None` (kept as a string for forward compatibility);
16    /// see [`ClientIdentityType`] and [`ClientIdentity::kind`].
17    pub id_type: String,
18    /// The identifying value, unique across the whole platform: the client's IP address
19    /// for `IP`/`None`; for `Hostname`/`Path` a value Core generated (16 characters of
20    /// `a-z2-7`), the DoT hostname label or DoH path part the client is configured with.
21    pub identify: String,
22    /// The owner's label for the identity, 1–64 characters; Core only, Relay never sees
23    /// it. Not unique.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub name: Option<String>,
26    /// Owning user.
27    pub user_id: Uuid,
28    /// The owner's switch; a disabled identity is not recognised by Relay.
29    #[serde(default = "default_true")]
30    pub enabled: bool,
31    /// Suspended by an Admin. The owner can still read and delete it, but changes and
32    /// new sinkhole links answer [`ErrorCode::Suspended`](crate::ErrorCode::Suspended).
33    #[serde(default)]
34    pub suspended: bool,
35    /// The owning user is suspended; the identity then behaves as suspended too.
36    #[serde(default)]
37    pub owner_suspended: bool,
38    /// Unix seconds.
39    pub created_at: i64,
40    /// Unix seconds.
41    pub updated_at: i64,
42}
43
44fn default_true() -> bool {
45    true
46}
47
48impl ClientIdentity {
49    /// `id_type` in its typed form, if this SDK version knows it.
50    pub fn kind(&self) -> Option<ClientIdentityType> {
51        self.id_type.parse().ok()
52    }
53
54    /// Whether Core refuses changes to the identity: suspended itself or through its
55    /// owner.
56    pub fn is_suspended(&self) -> bool {
57        self.suspended || self.owner_suspended
58    }
59
60    /// The name if it has one, else `identify`: how to show the identity to a person.
61    pub fn label(&self) -> &str {
62        self.name.as_deref().unwrap_or(&self.identify)
63    }
64}
65
66/// How Relay recognises a client (`ClientIdentity.id_type`,
67/// `../core/src/api/enum_labels.ts`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum ClientIdentityType {
70    /// Plain DNS over UDP/TCP, by the client's source IP; `identify` is an IP address.
71    #[serde(rename = "IP")]
72    Ip,
73    /// DNS over TLS (DoT), by the hostname the client connects to; Core generates
74    /// `identify`, the label of that hostname.
75    Hostname,
76    /// DNS over HTTPS (DoH), by a part of the request path; Core generates `identify`,
77    /// that part.
78    Path,
79    /// Handled by Core like [`Ip`](Self::Ip): `identify` is an IP address.
80    None,
81}
82
83impl ClientIdentityType {
84    /// Whether Core generates `identify` for this type (`Hostname`, `Path`) instead of
85    /// taking it from the caller (`Ip`, `None`: the client's IP address).
86    pub fn generates_identify(self) -> bool {
87        matches!(self, Self::Hostname | Self::Path)
88    }
89
90    /// The label Core uses (`IP`, `Hostname`, `Path`, `None`).
91    pub fn as_wire(self) -> &'static str {
92        match self {
93            Self::Ip => "IP",
94            Self::Hostname => "Hostname",
95            Self::Path => "Path",
96            Self::None => "None",
97        }
98    }
99}
100
101impl fmt::Display for ClientIdentityType {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.write_str(self.as_wire())
104    }
105}
106
107impl FromStr for ClientIdentityType {
108    type Err = Error;
109
110    /// Case-insensitive wire label (`ip`, `Hostname`, `PATH`, `none`).
111    fn from_str(text: &str) -> Result<Self> {
112        match text.trim().to_ascii_lowercase().as_str() {
113            "ip" => Ok(Self::Ip),
114            "hostname" => Ok(Self::Hostname),
115            "path" => Ok(Self::Path),
116            "none" => Ok(Self::None),
117            _ => Err(Error::InvalidInput(format!(
118                "unknown client identity type {text:?} (IP, Hostname or Path)"
119            ))),
120        }
121    }
122}
123
124/// `POST /client-identities` body.
125#[derive(Debug, Clone, Serialize)]
126pub struct NewClientIdentity {
127    /// The client's IP address, required for `Ip`/`None`. Must be `None` for
128    /// `Hostname`/`Path`: Core generates those (see
129    /// [`ClientIdentityType::generates_identify`]).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub identify: Option<String>,
132    /// How Relay recognises the client. See
133    /// [`ops::infer_client_identity_type`](crate::ops::infer_client_identity_type).
134    pub id_type: ClientIdentityType,
135    /// Optional label, 1–64 characters; Core trims it.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub name: Option<String>,
138}
139
140/// `PATCH /client-identities/:uuid` body; `None` fields stay unchanged.
141///
142/// `identify` can be set only for `Ip`/`None`. Changing the type to `Hostname`/`Path`
143/// makes Core generate a new `identify`; changing it back to `Ip`/`None` needs one. For a
144/// new generated value without a type change, see
145/// [`ClientIdentities::regenerate_identify`](crate::api::ClientIdentities::regenerate_identify).
146#[derive(Debug, Clone, Default, Serialize)]
147pub struct ClientIdentityPatch {
148    /// New IP address (`Ip`/`None` only).
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub identify: Option<String>,
151    /// New type.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub id_type: Option<ClientIdentityType>,
154    /// `Some(Some(name))` sets the label (1–64 characters), `Some(None)` removes it.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub name: Option<Option<String>>,
157    /// Turns the identity on or off on Relay (the owner's switch).
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub enabled: Option<bool>,
160}
161
162impl ClientIdentityPatch {
163    /// Whether the patch changes nothing.
164    pub fn is_empty(&self) -> bool {
165        self.identify.is_none()
166            && self.id_type.is_none()
167            && self.name.is_none()
168            && self.enabled.is_none()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn wire_labels() {
178        let new = NewClientIdentity {
179            identify: Some("203.0.113.10".into()),
180            id_type: ClientIdentityType::Ip,
181            name: None,
182        };
183        assert_eq!(
184            serde_json::to_value(&new).unwrap(),
185            serde_json::json!({ "identify": "203.0.113.10", "id_type": "IP" })
186        );
187        let generated = NewClientIdentity {
188            identify: None,
189            id_type: ClientIdentityType::Path,
190            name: Some("phone".into()),
191        };
192        assert_eq!(
193            serde_json::to_value(&generated).unwrap(),
194            serde_json::json!({ "id_type": "Path", "name": "phone" })
195        );
196        assert_eq!(
197            "ip".parse::<ClientIdentityType>().unwrap(),
198            ClientIdentityType::Ip
199        );
200        assert_eq!(
201            "PATH".parse::<ClientIdentityType>().unwrap(),
202            ClientIdentityType::Path
203        );
204        assert!("dot".parse::<ClientIdentityType>().is_err());
205    }
206
207    #[test]
208    fn unknown_type_deserializes() {
209        let ci: ClientIdentity = serde_json::from_value(serde_json::json!({
210            "uuid": "00000000-0000-0000-0000-000000000000",
211            "id_type": "Mac", "identify": "aa:bb",
212            "user_id": "00000000-0000-0000-0000-000000000001",
213            "enabled": true, "suspended": false, "owner_suspended": true,
214            "created_at": 1, "updated_at": 1
215        }))
216        .unwrap();
217        assert_eq!(ci.kind(), None);
218        assert!(ci.is_suspended());
219        assert_eq!(ci.label(), "aa:bb");
220    }
221
222    #[test]
223    fn patch_clears_name_with_null() {
224        let patch = ClientIdentityPatch {
225            name: Some(None),
226            enabled: Some(false),
227            ..Default::default()
228        };
229        assert!(!patch.is_empty());
230        assert_eq!(
231            serde_json::to_value(&patch).unwrap(),
232            serde_json::json!({ "name": null, "enabled": false })
233        );
234    }
235}