Skip to main content

isoloom_spec/syntax/
ports.rs

1//! A machine's ports: a map keyed by name, each port a number (TCP) or `{ port, protocol,
2//! publish, dormant }`. Messages and checks reach them by name
3//! (`machines.web.ports.site.published`), so reordering never breaks a reference.
4
5use std::fmt;
6use std::num::NonZeroU16;
7
8use schemars::JsonSchema;
9use serde::de::{self, MapAccess, Visitor};
10use serde::{Deserialize, Deserializer, Serialize};
11
12/// What a port speaks: TCP or UDP, and for TCP whether probes and checks speak HTTP or TLS.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "lowercase")]
15#[non_exhaustive]
16pub enum PortProtocol {
17    /// TCP, the default: probed by connecting.
18    #[default]
19    Tcp,
20    /// UDP (DNS, SNMP, TFTP): nothing waits for it and no derived check probes it.
21    Udp,
22    /// HTTP over TCP: derived checks request a page.
23    Http,
24    /// HTTPS over TCP: derived checks request a page over TLS, certificates unchecked.
25    Https,
26    /// TLS over TCP (not HTTP): probes speak TLS, certificates unchecked.
27    Tls,
28}
29
30impl PortProtocol {
31    /// Whether it runs over TCP (everything but UDP).
32    #[must_use]
33    pub fn is_tcp(self) -> bool {
34        self != PortProtocol::Udp
35    }
36
37    /// Whether it speaks HTTP (`http`, `https`).
38    #[must_use]
39    pub fn is_http(self) -> bool {
40        matches!(self, PortProtocol::Http | PortProtocol::Https)
41    }
42
43    /// Whether it speaks TLS (`https`, `tls`).
44    #[must_use]
45    pub fn is_tls(self) -> bool {
46        matches!(self, PortProtocol::Https | PortProtocol::Tls)
47    }
48}
49
50/// Whether and how a port is reachable from the user's machine (loopback only).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Publish {
53    /// `true`: a free host port, the port's own number when it is free.
54    Free,
55    /// A number: that host port when it is free, else a free one (labs never collide).
56    Preferred(NonZeroU16),
57    /// `{ host, fixed: true }`: that host port or nothing (an app that calls
58    /// `localhost:<port>` itself); the environment can't start while another holds it.
59    Fixed(NonZeroU16),
60}
61
62// What the schema shows and the reader returns, before it means a `Publish`.
63/// `publish:` as written, for the schema.
64#[derive(JsonSchema)]
65#[serde(untagged)]
66enum PublishInput {
67    /// `true`: a free host port, preferably the port's own number.
68    Free(bool),
69    /// The preferred host port.
70    Preferred(NonZeroU16),
71    /// A host port, and whether it is that one or nothing.
72    Host {
73        /// The host port.
74        host: NonZeroU16,
75        /// `true`: that port or nothing; `false` (the default): preferred.
76        #[serde(default)]
77        fixed: bool,
78    },
79}
80
81impl PublishInput {
82    /// What it asks for: `false` is not published.
83    fn publish(self) -> Option<Publish> {
84        match self {
85            PublishInput::Free(free) => free.then_some(Publish::Free),
86            PublishInput::Preferred(host) | PublishInput::Host { host, fixed: false } => Some(Publish::Preferred(host)),
87            PublishInput::Host { host, fixed: true } => Some(Publish::Fixed(host)),
88        }
89    }
90}
91
92impl<'de> Deserialize<'de> for PublishInput {
93    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<PublishInput, D::Error> {
94        struct V;
95        impl<'de> Visitor<'de> for V {
96            type Value = PublishInput;
97            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
98                f.write_str("`true`, a host port, or `{ host, fixed: true }`")
99            }
100            fn visit_bool<E: de::Error>(self, v: bool) -> Result<PublishInput, E> {
101                Ok(PublishInput::Free(v))
102            }
103            fn visit_u64<E: de::Error>(self, v: u64) -> Result<PublishInput, E> {
104                host_port(v).map(PublishInput::Preferred)
105            }
106            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<PublishInput, A::Error> {
107                /// `{ host, fixed }`, read.
108                #[derive(Deserialize)]
109                #[serde(deny_unknown_fields)]
110                struct HostFields {
111                    host: NonZeroU16,
112                    #[serde(default)]
113                    fixed: bool,
114                }
115                let h = HostFields::deserialize(de::value::MapAccessDeserializer::new(map))?;
116                Ok(PublishInput::Host { host: h.host, fixed: h.fixed })
117            }
118        }
119        d.deserialize_any(V)
120    }
121}
122
123/// A port a machine answers on.
124#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
125#[serde(from = "PortInput")]
126pub struct Port {
127    /// The port the machine listens on. Left out only on a catalogue service's machine, whose
128    /// entry gives it.
129    pub port: Option<NonZeroU16>,
130    /// What it speaks.
131    pub protocol: PortProtocol,
132    /// Whether it is published on the user's machine, and how.
133    pub publish: Option<Publish>,
134    /// It only answers later, once something wakes it (the bind shell an exploit opens):
135    /// nothing waits for it and no derived check expects it to answer.
136    pub dormant: bool,
137}
138
139/// A port written in full.
140#[derive(Debug, Deserialize, JsonSchema)]
141#[serde(deny_unknown_fields)]
142struct PortFull {
143    /// The port the machine listens on (1 to 65535). On a catalogue service's machine, its
144    /// entry gives it (change it through `with.port`).
145    #[serde(default)]
146    port: Option<NonZeroU16>,
147    /// `tcp` (the default), `udp`, or TCP that speaks `http`, `https` or `tls`: probes and
148    /// derived checks speak it.
149    #[serde(default)]
150    protocol: PortProtocol,
151    /// Reachable from the user's machine (loopback only): `true` for a free host port
152    /// (preferably the same number), a number for the preferred host port, or `{ host, fixed:
153    /// true }` for that host port or nothing.
154    #[serde(default, deserialize_with = "publish")]
155    #[schemars(with = "Option<PublishInput>")]
156    publish: Option<Publish>,
157    /// Only answers once something wakes it: nothing waits for it.
158    #[serde(default)]
159    dormant: bool,
160}
161
162// What the schema shows and the reader returns.
163/// A port as written: its number (TCP), or in full.
164#[derive(JsonSchema)]
165#[serde(untagged)]
166enum PortInput {
167    /// The port's number: TCP, not published.
168    Number(NonZeroU16),
169    /// The port in full.
170    Full(PortFull),
171}
172
173impl<'de> Deserialize<'de> for PortInput {
174    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<PortInput, D::Error> {
175        struct V;
176        impl<'de> Visitor<'de> for V {
177            type Value = PortInput;
178            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
179                f.write_str("a port number, or `{ port, protocol, publish, dormant }`")
180            }
181            fn visit_u64<E: de::Error>(self, v: u64) -> Result<PortInput, E> {
182                let port = u16::try_from(v)
183                    .ok()
184                    .and_then(NonZeroU16::new)
185                    .ok_or_else(|| E::custom("a port is 1 to 65535"))?;
186                Ok(PortInput::Number(port))
187            }
188            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<PortInput, A::Error> {
189                PortFull::deserialize(de::value::MapAccessDeserializer::new(map)).map(PortInput::Full)
190            }
191        }
192        d.deserialize_any(V)
193    }
194}
195
196impl From<PortInput> for Port {
197    fn from(written: PortInput) -> Port {
198        match written {
199            PortInput::Number(port) => Port {
200                port: Some(port),
201                protocol: PortProtocol::Tcp,
202                publish: None,
203                dormant: false,
204            },
205            PortInput::Full(f) => Port {
206                port: f.port,
207                protocol: f.protocol,
208                publish: f.publish,
209                dormant: f.dormant,
210            },
211        }
212    }
213}
214
215/// `publish:`: `true`, a number, or `{ host, fixed }` (`false`: not published).
216fn publish<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Publish>, D::Error> {
217    PublishInput::deserialize(d).map(PublishInput::publish)
218}
219
220fn host_port<E: de::Error>(v: u64) -> Result<NonZeroU16, E> {
221    u16::try_from(v)
222        .ok()
223        .and_then(NonZeroU16::new)
224        .ok_or_else(|| E::custom("a host port is 1 to 65535"))
225}
226
227impl JsonSchema for Port {
228    fn schema_name() -> std::borrow::Cow<'static, str> {
229        "Port".into()
230    }
231
232    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
233        PortInput::json_schema(g)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use indexmap::IndexMap;
240
241    use super::*;
242    use crate::syntax::names::PortName;
243
244    fn ports(yaml: &str) -> Result<IndexMap<PortName, Port>, String> {
245        serde_yaml_ng::from_str(yaml).map_err(|e| e.to_string())
246    }
247
248    #[test]
249    fn a_protocol_says_what_it_runs_over_and_speaks() {
250        let all = [PortProtocol::Tcp, PortProtocol::Udp, PortProtocol::Http, PortProtocol::Https, PortProtocol::Tls];
251        let tcp: Vec<bool> = all.iter().map(|p| p.is_tcp()).collect();
252        let http: Vec<bool> = all.iter().map(|p| p.is_http()).collect();
253        let tls: Vec<bool> = all.iter().map(|p| p.is_tls()).collect();
254        assert_eq!(tcp, [true, false, true, true, true]);
255        assert_eq!(http, [false, false, true, true, false]);
256        assert_eq!(tls, [false, false, false, true, true]);
257    }
258
259    #[test]
260    fn a_port_or_a_publish_of_the_wrong_shape_says_what_is_expected() {
261        let port = ports("{ a: [1] }").unwrap_err();
262        assert!(port.contains("a port number, or `{ port, protocol, publish, dormant }`"), "{port}");
263        let publish = ports("{ a: { port: 1, publish: [1] } }").unwrap_err();
264        assert!(publish.contains("`true`, a host port, or `{ host, fixed: true }`"), "{publish}");
265        assert!(ports("{ a: { port: 1, publish: 0 } }").unwrap_err().contains("a host port is 1 to 65535"));
266    }
267
268    #[test]
269    fn a_port_is_a_number_or_written_in_full() {
270        let p = ports("{ ssh: 22, site: { port: 8000, protocol: http, publish: 8080 }, shell: { port: 4444, dormant: true } }").unwrap();
271        let get = |n: &str| &p[&n.parse::<PortName>().unwrap()];
272        assert_eq!((get("ssh").port.map(NonZeroU16::get), get("ssh").protocol), (Some(22), PortProtocol::Tcp));
273        assert_eq!(get("site").publish, Some(Publish::Preferred(NonZeroU16::new(8080).unwrap())));
274        assert!(get("shell").dormant && get("site").protocol.is_http() && !get("site").protocol.is_tls());
275    }
276
277    #[test]
278    fn publish_is_true_a_number_or_fixed() {
279        let p = ports("{ a: { port: 1, publish: true }, b: { port: 2, publish: { host: 3206, fixed: true } }, c: { port: 3, publish: false } }").unwrap();
280        let publish: Vec<_> = p.values().map(|p| p.publish).collect();
281        assert_eq!(publish, [Some(Publish::Free), Some(Publish::Fixed(NonZeroU16::new(3206).unwrap())), None]);
282    }
283
284    #[test]
285    fn mistakes_are_named() {
286        assert!(ports("{ a: 0 }").unwrap_err().contains("1 to 65535"));
287        assert!(ports("{ a: { port: 1, protocol: quic } }").unwrap_err().contains("unknown variant"));
288        assert!(ports("{ a: { port: 1, http: true } }").unwrap_err().contains("unknown field `http`"));
289        assert!(ports("{ 80: 80 }").unwrap_err().contains("port's name"));
290    }
291}