summaryrefslogtreecommitdiff
path: root/cloudinit/net/networkd.py
blob: 4fd8a9b893fd8b4704963149ddf08e0e9316c0b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/env python3
# vi: ts=4 expandtab
#
# Copyright (C) 2021-2022 VMware Inc.
#
# Author: Shreenidhi Shedi <yesshedi@gmail.com>
#
# This file is part of cloud-init. See LICENSE file for license information.

from collections import OrderedDict
from typing import Optional

from cloudinit import log as logging
from cloudinit import subp, util
from cloudinit.net import renderer
from cloudinit.net.network_state import NetworkState

LOG = logging.getLogger(__name__)


class CfgParser:
    def __init__(self):
        self.conf_dict = OrderedDict(
            {
                "Match": [],
                "Link": [],
                "Network": [],
                "DHCPv4": [],
                "DHCPv6": [],
                "Address": [],
                "Route": {},
            }
        )

    def update_section(self, sec, key, val):
        for k in self.conf_dict.keys():
            if k == sec:
                self.conf_dict[k].append(key + "=" + str(val))
                # remove duplicates from list
                self.conf_dict[k] = list(dict.fromkeys(self.conf_dict[k]))
                self.conf_dict[k].sort()

    def update_route_section(self, sec, rid, key, val):
        """
        For each route section we use rid as a key, this allows us to isolate
        this route from others on subsequent calls.
        """
        for k in self.conf_dict.keys():
            if k == sec:
                if rid not in self.conf_dict[k]:
                    self.conf_dict[k][rid] = []
                self.conf_dict[k][rid].append(key + "=" + str(val))
                # remove duplicates from list
                self.conf_dict[k][rid] = list(
                    dict.fromkeys(self.conf_dict[k][rid])
                )
                self.conf_dict[k][rid].sort()

    def get_final_conf(self):
        contents = ""
        for k, v in sorted(self.conf_dict.items()):
            if not v:
                continue
            if k == "Address":
                for e in sorted(v):
                    contents += "[" + k + "]\n"
                    contents += e + "\n"
                    contents += "\n"
            elif k == "Route":
                for n in sorted(v):
                    contents += "[" + k + "]\n"
                    for e in sorted(v[n]):
                        contents += e + "\n"
                        contents += "\n"
            else:
                contents += "[" + k + "]\n"
                for e in sorted(v):
                    contents += e + "\n"
                contents += "\n"

        return contents

    def dump_data(self, target_fn):
        if not target_fn:
            LOG.warning("Target file not given")
            return

        contents = self.get_final_conf()
        LOG.debug("Final content: %s", contents)
        util.write_file(target_fn, contents)


class Renderer(renderer.Renderer):
    """
    Renders network information in /etc/systemd/network

    This Renderer is currently experimental and doesn't support all the
    use cases supported by the other renderers yet.
    """

    def __init__(self, config=None):
        if not config:
            config = {}
        self.resolve_conf_fn = config.get(
            "resolve_conf_fn", "/etc/systemd/resolved.conf"
        )
        self.network_conf_dir = config.get(
            "network_conf_dir", "/etc/systemd/network/"
        )

    def generate_match_section(self, iface, cfg: CfgParser):
        sec = "Match"
        match_dict = {
            "name": "Name",
            "driver": "Driver",
            "mac_address": "MACAddress",
        }

        if not iface:
            return

        for k, v in match_dict.items():
            if k in iface and iface[k]:
                cfg.update_section(sec, v, iface[k])

        return iface["name"]

    def generate_link_section(self, iface, cfg: CfgParser):
        sec = "Link"

        if not iface:
            return

        if "mtu" in iface and iface["mtu"]:
            cfg.update_section(sec, "MTUBytes", iface["mtu"])

    def parse_routes(self, rid, conf, cfg: CfgParser):
        """
        Parse a route and use rid as a key in order to isolate the route from
        others in the route dict.
        """
        sec = "Route"
        route_cfg_map = {
            "gateway": "Gateway",
            "network": "Destination",
            "metric": "Metric",
        }

        # prefix is derived using netmask by network_state
        prefix = ""
        if "prefix" in conf:
            prefix = "/" + str(conf["prefix"])

        for k, v in conf.items():
            if k not in route_cfg_map:
                continue
            if k == "network":
                v += prefix
            cfg.update_route_section(sec, rid, route_cfg_map[k], v)

    def parse_subnets(self, iface, cfg: CfgParser):
        dhcp = "no"
        sec = "Network"
        rid = 0
        for e in iface.get("subnets", []):
            t = e["type"]
            if t == "dhcp4" or t == "dhcp":
                if dhcp == "no":
                    dhcp = "ipv4"
                elif dhcp == "ipv6":
                    dhcp = "yes"
            elif t == "dhcp6":
                if dhcp == "no":
                    dhcp = "ipv6"
                elif dhcp == "ipv4":
                    dhcp = "yes"
            if "routes" in e and e["routes"]:
                for i in e["routes"]:
                    # Use "r" as a dict key prefix for this route to isolate
                    # it from other sources of routes
                    self.parse_routes(f"r{rid}", i, cfg)
                    rid = rid + 1
            if "address" in e:
                subnet_cfg_map = {
                    "address": "Address",
                    "gateway": "Gateway",
                    "dns_nameservers": "DNS",
                    "dns_search": "Domains",
                }
                for k, v in e.items():
                    if k == "address":
                        if "prefix" in e:
                            v += "/" + str(e["prefix"])
                        cfg.update_section("Address", subnet_cfg_map[k], v)
                    elif k == "gateway":
                        # Use "a" as a dict key prefix for this route to
                        # isolate it from other sources of routes
                        cfg.update_route_section(
                            "Route", f"a{rid}", subnet_cfg_map[k], v
                        )
                        rid = rid + 1
                    elif k == "dns_nameservers" or k == "dns_search":
                        cfg.update_section(sec, subnet_cfg_map[k], " ".join(v))

        cfg.update_section(sec, "DHCP", dhcp)

        if dhcp in ["ipv6", "yes"] and isinstance(
            iface.get("accept-ra", ""), bool
        ):
            cfg.update_section(sec, "IPv6AcceptRA", iface["accept-ra"])

        return dhcp

    # This is to accommodate extra keys present in VMware config
    def dhcp_domain(self, d, cfg: CfgParser):
        for item in ["dhcp4domain", "dhcp6domain"]:
            if item not in d:
                continue
            ret = str(d[item]).casefold()
            try:
                ret = util.translate_bool(ret)
                ret = "yes" if ret else "no"
            except ValueError:
                if ret != "route":
                    LOG.warning("Invalid dhcp4domain value - %s", ret)
                    ret = "no"
            if item == "dhcp4domain":
                section = "DHCPv4"
            else:
                section = "DHCPv6"
            cfg.update_section(section, "UseDomains", ret)

    def parse_dns(self, iface, cfg: CfgParser, ns: NetworkState):
        sec = "Network"

        dns_cfg_map = {
            "search": "Domains",
            "nameservers": "DNS",
            "addresses": "DNS",
        }

        dns = iface.get("dns")
        if not dns and ns.version == 1:
            dns = {
                "search": ns.dns_searchdomains,
                "nameservers": ns.dns_nameservers,
            }
        elif not dns and ns.version == 2:
            return

        for k, v in dns_cfg_map.items():
            if k in dns and dns[k]:
                cfg.update_section(sec, v, " ".join(dns[k]))

    def parse_dhcp_overrides(self, cfg: CfgParser, device, dhcp, version):
        dhcp_config_maps = {
            "UseDNS": "use-dns",
            "UseDomains": "use-domains",
            "UseHostname": "use-hostname",
            "UseNTP": "use-ntp",
        }

        if version == "4":
            dhcp_config_maps.update(
                {
                    "SendHostname": "send-hostname",
                    "Hostname": "hostname",
                    "RouteMetric": "route-metric",
                    "UseMTU": "use-mtu",
                    "UseRoutes": "use-routes",
                }
            )

        if f"dhcp{version}-overrides" in device and dhcp in [
            "yes",
            f"ipv{version}",
        ]:
            dhcp_overrides = device[f"dhcp{version}-overrides"]
            for k, v in dhcp_config_maps.items():
                if v in dhcp_overrides:
                    cfg.update_section(f"DHCPv{version}", k, dhcp_overrides[v])

    def create_network_file(self, link, conf, nwk_dir):
        net_fn_owner = "systemd-network"

        LOG.debug("Setting Networking Config for %s", link)

        net_fn = nwk_dir + "10-cloud-init-" + link + ".network"
        util.write_file(net_fn, conf)
        util.chownbyname(net_fn, net_fn_owner, net_fn_owner)

    def render_network_state(
        self,
        network_state: NetworkState,
        templates: Optional[dict] = None,
        target=None,
    ) -> None:
        network_dir = self.network_conf_dir
        if target:
            network_dir = subp.target_path(target) + network_dir

        util.ensure_dir(network_dir)

        ret_dict = self._render_content(network_state)
        for k, v in ret_dict.items():
            self.create_network_file(k, v, network_dir)

    def _render_content(self, ns: NetworkState) -> dict:
        ret_dict = {}
        for iface in ns.iter_interfaces():
            cfg = CfgParser()

            link = self.generate_match_section(iface, cfg)
            self.generate_link_section(iface, cfg)
            dhcp = self.parse_subnets(iface, cfg)
            self.parse_dns(iface, cfg, ns)

            rid = 0
            for route in ns.iter_routes():
                # Use "c" as a dict key prefix for this route to isolate it
                # from other sources of routes
                self.parse_routes(f"c{rid}", route, cfg)
                rid = rid + 1

            if ns.version == 2:
                name: Optional[str] = iface["name"]
                # network state doesn't give dhcp domain info
                # using ns.config as a workaround here

                # Check to see if this interface matches against an interface
                # from the network state that specified a set-name directive.
                # If there is a device with a set-name directive and it has
                # set-name value that matches the current name, then update the
                # current name to the device's name. That will be the value in
                # the ns.config['ethernets'] dict below.
                for dev_name, dev_cfg in ns.config["ethernets"].items():
                    if "set-name" in dev_cfg:
                        if dev_cfg.get("set-name") == name:
                            name = dev_name
                            break
                if name in ns.config["ethernets"]:
                    device = ns.config["ethernets"][name]

                    # dhcp{version}domain are extra keys only present in
                    # VMware config
                    self.dhcp_domain(device, cfg)
                    for version in ["4", "6"]:
                        if (
                            f"dhcp{version}domain" in device
                            and "use-domains"
                            in device.get(f"dhcp{version}-overrides", {})
                        ):
                            exception = (
                                f"{name} has both dhcp{version}domain"
                                f" and dhcp{version}-overrides.use-domains"
                                f" configured. Use one"
                            )
                            raise Exception(exception)

                        self.parse_dhcp_overrides(cfg, device, dhcp, version)

            ret_dict.update({link: cfg.get_final_conf()})

        return ret_dict


def available(target=None):
    expected = ["ip", "systemctl"]
    search = ["/usr/sbin", "/bin"]
    for p in expected:
        if not subp.which(p, search=search, target=target):
            return False
    return True


def network_state_to_networkd(ns: NetworkState):
    renderer = Renderer({})
    return renderer._render_content(ns)