nss_docker_ng/
lib.rs

1extern crate debug_print;
2extern crate docker_api;
3
4use debug_print::debug_eprintln;
5use docker_api::Docker;
6use libnss::host::{AddressFamily, Addresses, Host, HostHooks};
7use libnss::interop::Response;
8use libnss::libnss_host_hooks;
9use std::error::Error;
10use std::net::{IpAddr, Ipv4Addr};
11use std::str::FromStr;
12
13#[cfg(test)]
14use mocktopus::macros::mockable;
15
16static SUFFIX: &str = ".docker";
17static DOCKER_URI: &str = "unix:///var/run/docker.sock";
18static CONTAINER_SUBDOMAINS_ALLOWED_LABEL: &str =
19    ".com.github.petski.nss-docker-ng.container-subdomains-allowed";
20
21struct DockerNG;
22libnss_host_hooks!(docker_ng, DockerNG);
23
24impl HostHooks for DockerNG {
25    fn get_all_entries() -> Response<Vec<Host>> {
26        Response::NotFound // TODO: Implement me, see https://github.com/petski/nss-docker-ng/issues/1
27    }
28
29    fn get_host_by_name(name: &str, family: AddressFamily) -> Response<Host> {
30        match get_host_by_name(name, family) {
31            Ok(Some(host)) => Response::Success(host),
32            Ok(None) => Response::NotFound,
33            Err(_e) => {
34                debug_eprintln!("get_host_by_name '{}' failed: {}", name, _e);
35                Response::Unavail
36            }
37        }
38    }
39
40    fn get_host_by_addr(_: IpAddr) -> Response<Host> {
41        Response::NotFound // TODO: Implement me, see https://github.com/petski/nss-docker-ng/issues/2
42    }
43}
44
45#[cfg_attr(test, mockable)]
46pub fn get_docker_uri() -> String {
47    DOCKER_URI.to_string()
48}
49
50#[tokio::main]
51async fn get_host_by_name(
52    query: &str,
53    family: AddressFamily,
54) -> Result<Option<Host>, Box<dyn Error>> {
55    // Check if the query ends with the expected suffix and if the address family is IPv4
56    if !(query.ends_with(SUFFIX) &&
57        family == AddressFamily::IPv4 && // TODO you no v6? See https://github.com/petski/nss-docker-ng/issues/3
58        query.len() > SUFFIX.len())
59    {
60        return Ok(None);
61    }
62
63    // Initialize Docker API client
64    let mut docker = Docker::new(get_docker_uri())?;
65    docker.adjust_api_version().await?;
66
67    // Strip suffix from query
68    let query_stripped = &query[..query.len() - SUFFIX.len()];
69
70    // Fetch container information
71    let inspect_result = 'block: {
72        match docker.containers().get(query_stripped).inspect().await {
73            Ok(query_stripped_result) => query_stripped_result,
74            Err(_e) => {
75                debug_eprintln!("Failed to inspect container '{}': {}", query_stripped, _e);
76
77                if let Some((last_dot_index, _)) = query_stripped.match_indices('.').last() {
78                    let query_stripped_main_domain =
79                        &query_stripped[(last_dot_index + '.'.len_utf8())..];
80                    match docker
81                        .containers()
82                        .get(query_stripped_main_domain)
83                        .inspect()
84                        .await
85                    {
86                        Ok(query_stripped_main_domain_result) => {
87                            match query_stripped_main_domain_result.config.as_ref().and_then(
88                                |config| {
89                                    config.labels.as_ref().and_then(|labels| {
90                                        labels.get(CONTAINER_SUBDOMAINS_ALLOWED_LABEL)
91                                    })
92                                },
93                            ) {
94                                Some(label_value) => {
95                                    if label_value.eq("true")
96                                        || label_value.eq("True")
97                                        || label_value.eq("1")
98                                    {
99                                        break 'block query_stripped_main_domain_result;
100                                    } else {
101                                        return Ok(None);
102                                    }
103                                }
104                                None => return Ok(None),
105                            }
106                        }
107                        Err(_e) => return Ok(None),
108                    }
109                }
110                return Ok(None);
111            }
112        }
113    };
114
115    // Name of the container (remove leading "/" if necessary)
116    let name = match inspect_result.name {
117        Some(mut name) => {
118            if name.starts_with('/') {
119                name.remove(0);
120            }
121            name
122        }
123        None => return Err("No Name".into()),
124    };
125
126    // From https://docs.docker.com/engine/api/v1.44/#tag/Container/operation/ContainerInspect:
127    // > Network mode to use for this container. Supported standard values are: bridge, host, none, and
128    // > container:<name|id>. Any other value is taken as a custom network's name to which this container
129    // > should connect to
130    let mut network_mode = match inspect_result.host_config.as_ref().and_then(|host_config| {
131        host_config
132            .get("NetworkMode")
133            .and_then(|value| value.as_str())
134    }) {
135        Some(network_mode) => {
136            debug_eprintln!("Container '{}' has NetworkMode '{}'", name, network_mode);
137            network_mode
138        }
139        None => return Err("Could not find NetworkMode".into()),
140    };
141
142    // NetworkMode host, none, and those starting with "container:" don't have an IP address
143    if ["none", "host"].contains(&network_mode) || network_mode.starts_with("container:") {
144        debug_eprintln!(
145            "Container '{}' is in NetworkMode {}, no IP here",
146            name,
147            network_mode
148        );
149        return Ok(None);
150    }
151
152    // Extract networks from network settings
153    let networks = match inspect_result
154        .network_settings
155        .and_then(|settings| settings.networks)
156    {
157        Some(networks) => {
158            if networks.is_empty() {
159                return Err("Found 0 networks".into());
160            }
161            debug_eprintln!("Found {} network(s) for '{}'", networks.keys().len(), name);
162            networks
163        }
164        None => return Err("Found no networks".into()),
165    };
166
167    // The documentation on https://docs.docker.com/engine/api/v1.44/#tag/Container/operation/ContainerInspect
168    // is incomplete. There is another NetworkMode "default":
169    // > which is bridge for Docker Engine, and overlay for Swarm.
170    //
171    // See: https://github.com/docker/docker-py/issues/986
172    if network_mode == "default" && !networks.contains_key("default") {
173        network_mode = "bridge"; // TODO add swarm support. Is this really used/needed?
174    }
175
176    // Get the end point settings for the network with the name in network_mode
177    let end_point_settings = match networks.get(network_mode) {
178        Some(end_point_settings) => end_point_settings,
179        None => return Err(format!("Network '{}' not found", network_mode).into()),
180    };
181
182    let ip_address = match &end_point_settings.ip_address {
183        Some(ip_address) => {
184            if ip_address.is_empty() {
185                return Err("IP address is an empty string".into());
186            }
187            ip_address
188        }
189        None => return Err("Endpoint has no IP address".into()),
190    };
191
192    return match Ipv4Addr::from_str(ip_address) {
193        Ok(ip) => {
194            let id = match inspect_result.id.as_ref() {
195                Some(id) => id,
196                None => return Err("No Id".into()),
197            };
198
199            let mut aliases = vec![[id[..12].to_string(), SUFFIX.to_string()].join("")];
200
201            if name.ne(query_stripped) {
202                aliases.push([query_stripped.to_string(), SUFFIX.to_string()].join(""))
203            }
204
205            Ok(Some(Host {
206                name: [name.to_string(), SUFFIX.to_string()].join(""),
207                addresses: Addresses::V4(vec![ip]),
208                aliases,
209            }))
210        }
211        Err(_e) => {
212            return Err(format!("Failed to parse IP address '{}': {}", ip_address, _e).into());
213        }
214    };
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use mockito::{Mock, Server, ServerOpts};
221    use mocktopus::mocking::{MockResult, Mockable};
222
223    #[test]
224    fn test_get_host_by_name() {
225        assert_eq!(
226            DockerNG::get_host_by_name(".foo", AddressFamily::IPv4),
227            Response::NotFound
228        );
229
230        assert_eq!(
231            DockerNG::get_host_by_name("foo.docker", AddressFamily::IPv6),
232            Response::NotFound
233        );
234
235        assert_eq!(
236            DockerNG::get_host_by_name(".docker", AddressFamily::IPv4),
237            Response::NotFound
238        );
239
240        let (_server, _mocks) = init_mocking_features();
241
242        assert_eq!(
243            DockerNG::get_host_by_name("sunny-default-bridge.docker", AddressFamily::IPv4),
244            Response::Success(Host {
245                name: "sunny-default-bridge.docker".to_string(),
246                aliases: vec!["c0ffeec0ffee.docker".to_string()],
247                addresses: Addresses::V4(vec![Ipv4Addr::new(172, 29, 0, 2)]),
248            })
249        );
250
251        assert_eq!(
252            DockerNG::get_host_by_name(
253                "sunny-default-bridge-container-subdomains-allowed.docker",
254                AddressFamily::IPv4
255            ),
256            Response::Success(Host {
257                name: "sunny-default-bridge-container-subdomains-allowed.docker".to_string(),
258                aliases: vec!["c0ffeec0ffee.docker".to_string()],
259                addresses: Addresses::V4(vec![Ipv4Addr::new(172, 29, 0, 2)]),
260            })
261        );
262
263        assert_eq!(
264            DockerNG::get_host_by_name(
265                "mega.very.sunny-default-bridge-container-subdomains-allowed.docker",
266                AddressFamily::IPv4
267            ),
268            Response::Success(Host {
269                name: "sunny-default-bridge-container-subdomains-allowed.docker".to_string(),
270                aliases: vec![
271                    "c0ffeec0ffee.docker".to_string(),
272                    "mega.very.sunny-default-bridge-container-subdomains-allowed.docker"
273                        .to_string()
274                ],
275                addresses: Addresses::V4(vec![Ipv4Addr::new(172, 29, 0, 2)]),
276            })
277        );
278
279        assert_eq!(
280            DockerNG::get_host_by_name("rainy-404.docker", AddressFamily::IPv4),
281            Response::NotFound
282        );
283
284        assert_eq!(
285            DockerNG::get_host_by_name("rainy-no-name.docker", AddressFamily::IPv4),
286            Response::Unavail
287        );
288
289        assert_eq!(
290            DockerNG::get_host_by_name("rainy-no-network-mode.docker", AddressFamily::IPv4),
291            Response::Unavail
292        );
293
294        assert_eq!(
295            DockerNG::get_host_by_name("rainy-network-mode-none.docker", AddressFamily::IPv4),
296            Response::NotFound
297        );
298
299        assert_eq!(
300            DockerNG::get_host_by_name("rainy-network-mode-host.docker", AddressFamily::IPv4),
301            Response::NotFound
302        );
303
304        assert_eq!(
305            DockerNG::get_host_by_name("rainy-network-mode-container.docker", AddressFamily::IPv4),
306            Response::NotFound
307        );
308
309        assert_eq!(
310            DockerNG::get_host_by_name("rainy-zero-networks.docker", AddressFamily::IPv4),
311            Response::Unavail
312        );
313
314        assert_eq!(
315            DockerNG::get_host_by_name("rainy-no-networks.docker", AddressFamily::IPv4),
316            Response::Unavail
317        );
318
319        assert_eq!(
320            DockerNG::get_host_by_name("rainy-network-not-exists.docker", AddressFamily::IPv4),
321            Response::Unavail
322        );
323
324        assert_eq!(
325            DockerNG::get_host_by_name("rainy-ip-address-empty.docker", AddressFamily::IPv4),
326            Response::Unavail
327        );
328
329        assert_eq!(
330            DockerNG::get_host_by_name("rainy-no-ip-address.docker", AddressFamily::IPv4),
331            Response::Unavail
332        );
333
334        assert_eq!(
335            DockerNG::get_host_by_name("rainy-unparseable-ip-address.docker", AddressFamily::IPv4),
336            Response::Unavail
337        );
338
339        assert_eq!(
340            DockerNG::get_host_by_name("rainy-no-id.docker", AddressFamily::IPv4),
341            Response::Unavail
342        );
343
344        clear_mocking_features();
345    }
346
347    /*
348     * Returns a server and its mocks based on https://github.com/lipanski/mockito
349     */
350    fn init_mocking_features() -> (Server, [Mock; 17]) {
351        let mut server = Server::new_with_opts(ServerOpts {
352            assert_on_drop: true,
353            ..Default::default()
354        });
355
356        let url = server.url();
357
358        // Mock it
359        get_docker_uri.mock_safe(move || MockResult::Return(url.to_owned()));
360
361        let _version_mock = server
362            .mock("GET", "/version")
363            .expect(16)
364            .with_body_from_file("tests/resources/v1.44/version.body")
365            .create();
366
367        let _inspect_mock_sunny_default_bridge = server
368            .mock("GET", "/v1.44/containers/sunny-default-bridge/json")
369            .with_body_from_file("tests/resources/v1.44/containers/sunny-default-bridge/json.body")
370            .create();
371
372        let _inspect_mock_mega_very_sunny_default_bridge_container_subdomains_allowed = server
373            .mock("GET", "/v1.44/containers/mega.very.sunny-default-bridge-container-subdomains-allowed/json")
374            .with_status(404)
375            .with_body_from_file("tests/resources/v1.44/containers/mega.very.sunny-default-bridge-container-subdomains-allowed/json.body")
376            .create();
377
378        let _inspect_mock_sunny_default_bridge_container_subdomains_allowed = server
379            .mock("GET", "/v1.44/containers/sunny-default-bridge-container-subdomains-allowed/json")
380            .expect(2)
381            .with_body_from_file("tests/resources/v1.44/containers/sunny-default-bridge-container-subdomains-allowed/json.body")
382            .create();
383
384        let _inspect_mock_rainy_404 = server
385            .mock("GET", "/v1.44/containers/rainy-404/json")
386            .with_status(404)
387            .with_body_from_file("tests/resources/v1.44/containers/rainy-404/json.body")
388            .create();
389
390        let _inspect_mock_rainy_no_name = server
391            .mock("GET", "/v1.44/containers/rainy-no-name/json")
392            .with_body_from_file("tests/resources/v1.44/containers/rainy-no-name/json.body")
393            .create();
394
395        let _inspect_mock_rainy_no_network_mode = server
396            .mock("GET", "/v1.44/containers/rainy-no-network-mode/json")
397            .with_body_from_file("tests/resources/v1.44/containers/rainy-no-network-mode/json.body")
398            .create();
399
400        let _inspect_mock_rainy_network_mode_none = server
401            .mock("GET", "/v1.44/containers/rainy-network-mode-none/json")
402            .with_body_from_file(
403                "tests/resources/v1.44/containers/rainy-network-mode-none/json.body",
404            )
405            .create();
406
407        let _inspect_mock_rainy_network_mode_host = server
408            .mock("GET", "/v1.44/containers/rainy-network-mode-host/json")
409            .with_body_from_file(
410                "tests/resources/v1.44/containers/rainy-network-mode-host/json.body",
411            )
412            .create();
413
414        let _inspect_mock_rainy_network_mode_container = server
415            .mock("GET", "/v1.44/containers/rainy-network-mode-container/json")
416            .with_body_from_file(
417                "tests/resources/v1.44/containers/rainy-network-mode-container/json.body",
418            )
419            .create();
420
421        let _inspect_mock_rainy_zero_networks = server
422            .mock("GET", "/v1.44/containers/rainy-zero-networks/json")
423            .with_body_from_file("tests/resources/v1.44/containers/rainy-zero-networks/json.body")
424            .create();
425
426        let _inspect_mock_rainy_no_networks = server
427            .mock("GET", "/v1.44/containers/rainy-no-networks/json")
428            .with_body_from_file("tests/resources/v1.44/containers/rainy-no-networks/json.body")
429            .create();
430
431        let _inspect_mock_rainy_network_not_exists = server
432            .mock("GET", "/v1.44/containers/rainy-network-not-exists/json")
433            .with_body_from_file(
434                "tests/resources/v1.44/containers/rainy-network-not-exists/json.body",
435            )
436            .create();
437
438        let _inspect_mock_rainy_ip_address_empty = server
439            .mock("GET", "/v1.44/containers/rainy-ip-address-empty/json")
440            .with_body_from_file(
441                "tests/resources/v1.44/containers/rainy-ip-address-empty/json.body",
442            )
443            .create();
444
445        let _inspect_mock_rainy_no_ip_address = server
446            .mock("GET", "/v1.44/containers/rainy-no-ip-address/json")
447            .with_body_from_file("tests/resources/v1.44/containers/rainy-no-ip-address/json.body")
448            .create();
449
450        let _inspect_mock_rainy_unparseable_ip_address = server
451            .mock("GET", "/v1.44/containers/rainy-unparseable-ip-address/json")
452            .with_body_from_file(
453                "tests/resources/v1.44/containers/rainy-unparseable-ip-address/json.body",
454            )
455            .create();
456
457        let _inspect_mock_rainy_no_id = server
458            .mock("GET", "/v1.44/containers/rainy-no-id/json")
459            .with_body_from_file("tests/resources/v1.44/containers/rainy-no-id/json.body")
460            .create();
461
462        (
463            server,
464            [
465                _version_mock,
466                _inspect_mock_sunny_default_bridge,
467                _inspect_mock_mega_very_sunny_default_bridge_container_subdomains_allowed,
468                _inspect_mock_sunny_default_bridge_container_subdomains_allowed,
469                _inspect_mock_rainy_404,
470                _inspect_mock_rainy_no_name,
471                _inspect_mock_rainy_no_network_mode,
472                _inspect_mock_rainy_network_mode_none,
473                _inspect_mock_rainy_network_mode_host,
474                _inspect_mock_rainy_network_mode_container,
475                _inspect_mock_rainy_zero_networks,
476                _inspect_mock_rainy_no_networks,
477                _inspect_mock_rainy_network_not_exists,
478                _inspect_mock_rainy_ip_address_empty,
479                _inspect_mock_rainy_no_ip_address,
480                _inspect_mock_rainy_unparseable_ip_address,
481                _inspect_mock_rainy_no_id,
482            ],
483        )
484    }
485
486    fn clear_mocking_features() {
487        get_docker_uri.clear_mock();
488    }
489}