summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukas Märdian <luk@slyon.de>2021-10-20 13:22:07 +0200
committerLukas Märdian <luk@slyon.de>2021-10-20 13:22:07 +0200
commit8d1727ff671179f971d6ad00d029356947c930b1 (patch)
tree1443e50d328d4009113a7bdcdadde6f5e9f6f0d4
Import netplan.io_0.103.orig.tar.gz
[dgit import orig netplan.io_0.103.orig.tar.gz]
-rw-r--r--.github/pull_request_template.md12
-rw-r--r--.github/workflows/build.yml33
-rw-r--r--.github/workflows/check-coverage.yml41
-rw-r--r--.github/workflows/codeql-analysis.yml74
-rw-r--r--.gitignore12
-rw-r--r--CONTRIBUTING56
-rw-r--r--COPYING674
-rw-r--r--Makefile144
-rw-r--r--README.md28
-rw-r--r--TODO41
-rw-r--r--dbus/io.netplan.Netplan.conf20
-rw-r--r--dbus/io.netplan.Netplan.service.in5
-rw-r--r--doc/example-config61
-rw-r--r--doc/manpage-footer.md3
-rw-r--r--doc/manpage-header.md21
-rw-r--r--doc/netplan-apply.md62
-rw-r--r--doc/netplan-dbus.md43
-rw-r--r--doc/netplan-generate.md88
-rw-r--r--doc/netplan-get.md39
-rw-r--r--doc/netplan-set.md42
-rw-r--r--doc/netplan-try.md64
-rw-r--r--doc/netplan.md1455
-rw-r--r--examples/bonding.yaml12
-rw-r--r--examples/bonding_router.yaml46
-rw-r--r--examples/bridge.yaml11
-rw-r--r--examples/bridge_vlan.yaml15
-rw-r--r--examples/dbus_config_scenario.txt41
-rw-r--r--examples/dhcp.yaml6
-rw-r--r--examples/dhcp_wired8021x.yaml11
-rw-r--r--examples/direct_connect_gateway.yaml9
-rw-r--r--examples/direct_connect_gateway_ipv6.yaml11
-rw-r--r--examples/ipv6_tunnel.yaml20
-rw-r--r--examples/loopback_interface.yaml8
-rw-r--r--examples/modem.yaml15
-rw-r--r--examples/network_manager.yaml3
-rw-r--r--examples/openvswitch.yaml45
-rw-r--r--examples/route_metric.yaml11
-rw-r--r--examples/source_routing.yaml28
-rw-r--r--examples/sriov.yaml14
-rw-r--r--examples/sriov_vlan.yaml18
-rw-r--r--examples/static.yaml13
-rw-r--r--examples/static_multiaddress.yaml11
-rw-r--r--examples/static_singlenic_multiip_multigateway.yaml19
-rw-r--r--examples/vlan.yaml27
-rw-r--r--examples/windows_dhcp_server.yaml6
-rw-r--r--examples/wireguard.yaml31
-rw-r--r--examples/wireless.yaml16
-rw-r--r--examples/wpa_enterprise.yaml26
-rw-r--r--netplan.completions45
-rw-r--r--netplan/__init__.py20
-rw-r--r--netplan/cli/__init__.py16
-rw-r--r--netplan/cli/commands/__init__.py36
-rw-r--r--netplan/cli/commands/apply.py334
-rw-r--r--netplan/cli/commands/generate.py85
-rw-r--r--netplan/cli/commands/get.py67
-rw-r--r--netplan/cli/commands/info.py65
-rw-r--r--netplan/cli/commands/ip.py153
-rw-r--r--netplan/cli/commands/migrate.py416
-rw-r--r--netplan/cli/commands/set.py169
-rw-r--r--netplan/cli/commands/try_command.py184
-rw-r--r--netplan/cli/core.py50
-rw-r--r--netplan/cli/ovs.py178
-rw-r--r--netplan/cli/sriov.py334
-rw-r--r--netplan/cli/utils.py297
-rw-r--r--netplan/configmanager.py320
-rw-r--r--netplan/terminal.py157
-rw-r--r--rpm/netplan.spec131
-rw-r--r--snap/snapcraft.yaml42
-rw-r--r--src/dbus.c798
-rw-r--r--src/error.c174
-rw-r--r--src/error.h31
-rw-r--r--src/generate.c295
-rw-r--r--src/netplan.c965
-rw-r--r--src/netplan.h145
-rwxr-xr-xsrc/netplan.script23
-rw-r--r--src/networkd.c1131
-rw-r--r--src/networkd.h26
-rw-r--r--src/nm.c999
-rw-r--r--src/nm.h24
-rw-r--r--src/openvswitch.c484
-rw-r--r--src/openvswitch.h24
-rw-r--r--src/parse-nm.c737
-rw-r--r--src/parse-nm.h22
-rw-r--r--src/parse.c2790
-rw-r--r--src/parse.h517
-rw-r--r--src/sriov.c40
-rw-r--r--src/sriov.h21
-rw-r--r--src/util.c329
-rw-r--r--src/util.h41
-rw-r--r--src/validation.c486
-rw-r--r--src/validation.h37
-rwxr-xr-xtests/cli.py692
-rw-r--r--tests/dbus/__init__.py0
-rw-r--r--tests/dbus/test_dbus.py762
-rw-r--r--tests/generator/__init__.py17
-rw-r--r--tests/generator/base.py446
-rw-r--r--tests/generator/test_args.py184
-rw-r--r--tests/generator/test_auth.py555
-rw-r--r--tests/generator/test_bonds.py812
-rw-r--r--tests/generator/test_bridges.py733
-rw-r--r--tests/generator/test_common.py1690
-rw-r--r--tests/generator/test_dhcp_overrides.py426
-rw-r--r--tests/generator/test_errors.py976
-rw-r--r--tests/generator/test_ethernets.py715
-rw-r--r--tests/generator/test_modems.py426
-rw-r--r--tests/generator/test_ovs.py1021
-rw-r--r--tests/generator/test_passthrough.py286
-rw-r--r--tests/generator/test_routing.py1333
-rw-r--r--tests/generator/test_tunnels.py1409
-rw-r--r--tests/generator/test_vlans.py306
-rw-r--r--tests/generator/test_wifis.py692
-rw-r--r--tests/integration/__init__.py17
-rw-r--r--tests/integration/base.py484
-rw-r--r--tests/integration/bonds.py675
-rw-r--r--tests/integration/bridges.py348
-rw-r--r--tests/integration/ethernets.py336
-rw-r--r--tests/integration/ovs.py557
-rw-r--r--tests/integration/regressions.py90
-rw-r--r--tests/integration/routing.py339
-rwxr-xr-xtests/integration/run.py85
-rw-r--r--tests/integration/scenarios.py123
-rw-r--r--tests/integration/tunnels.py221
-rw-r--r--tests/integration/vlans.py103
-rw-r--r--tests/integration/wifi.py158
-rw-r--r--tests/parser/__init__.py17
-rw-r--r--tests/parser/base.py169
-rw-r--r--tests/parser/test_keyfile.py1018
-rw-r--r--tests/test_cli_get_set.py386
-rw-r--r--tests/test_cli_units.py41
-rw-r--r--tests/test_configmanager.py251
-rw-r--r--tests/test_libnetplan.py153
-rw-r--r--tests/test_ovs.py148
-rw-r--r--tests/test_sriov.py648
-rw-r--r--tests/test_terminal.py84
-rw-r--r--tests/test_utils.py295
-rwxr-xr-xtests/validate_docs.sh47
136 files changed, 37003 insertions, 0 deletions
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..435976e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,12 @@
+
+## Description
+
+
+## Checklist
+
+- [ ] Runs `make check` successfully.
+- [ ] Retains 100% code coverage (`make check-coverage`).
+- [ ] New/changed keys in YAML format are documented.
+- [ ] \(Optional\) Adds example YAML for new feature.
+- [ ] \(Optional\) Closes an open bug in Launchpad.
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..5ecc0d9
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,33 @@
+name: Build
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+# events but only for the master branch
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ # This workflow contains a single job called "build"
+ build:
+ # The type of runner that the job will run on
+ runs-on: ubuntu-20.04
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ # Installs the build dependencies
+ - name: Install build depends
+ run: |
+ sudo sed -i '/deb-src/s/^# //' /etc/apt/sources.list
+ sudo apt update
+ #sudo apt install lcov python3-coverage curl
+ sudo apt build-dep netplan.io
+
+ # Runs the build
+ - name: Run build
+ run: make
diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml
new file mode 100644
index 0000000..c102a62
--- /dev/null
+++ b/.github/workflows/check-coverage.yml
@@ -0,0 +1,41 @@
+name: Unit tests & Coverage
+
+# Controls when the action will run. Triggers the workflow on push or pull request
+# events but only for the master branch
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ '**' ]
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+ # This workflow contains a single job called "test-and-coverage"
+ test-and-coverage:
+ # The type of runner that the job will run on
+ runs-on: ubuntu-20.04
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ # Installs the build dependencies
+ - name: Install build depends
+ run: |
+ sudo sed -i '/deb-src/s/^# //' /etc/apt/sources.list
+ sudo apt update
+ sudo apt install lcov python3-coverage curl
+ sudo apt build-dep netplan.io
+
+ # Runs the unit tests with coverage
+ - name: Run unit tests
+ run: make coverage
+
+ # Checks the coverage diff to the master branch
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v1
+ with:
+ name: check-coverage
+ fail_ci_if_error: true
+ verbose: true
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..2c5869e
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,74 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [ master ]
+ schedule:
+ - cron: '17 21 * * 2'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'cpp', 'python' ]
+ # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+ # Learn more:
+ # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Installs the build dependencies
+ - name: Install build depends
+ run: |
+ sudo sed -i '/deb-src/s/^# //' /etc/apt/sources.list
+ sudo apt update
+ sudo apt build-dep netplan.io
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+
+ # ℹ️ Command-line programs to run using the OS shell.
+ # 📚 https://git.io/JvXDl
+
+ # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2eda3d5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+generate
+netplan-dbus
+test-coverage
+doc/*.html
+doc/*.[1-9]
+__pycache__
+*.pyc
+.coverage
+.vscode
+src/_features.h
+netplan/_features.py
+dbus/io.netplan.Netplan.service
diff --git a/CONTRIBUTING b/CONTRIBUTING
new file mode 100644
index 0000000..9712d67
--- /dev/null
+++ b/CONTRIBUTING
@@ -0,0 +1,56 @@
+# Contributing to netplan.io
+
+Thanks for taking the time to contribute to netplan!
+
+Here are the guidelines for contributing to the development of netplan. These
+are guidelines, not hard and fast rules; but please exercise judgement. Feel
+free to propose changes to this document.
+
+#### Table Of Contents
+
+[Code of Conduct](#code-of-conduct)
+
+[What should I know before I get started](#what-should-i-know-before-i-get-started)
+ * [Did you find a bug?](#did-you-find-a-bug)
+ * [Code Quality](#code-quality)
+ * [Conventions](#conventions)
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by the
+[Ubuntu Code of Conduct](https://www.ubuntu.com/community/code-of-conduct).
+By participating, you are expected to uphold this code. Please report
+unacceptable behavior to
+[netplan-developers@lists.launchpad.net](mailto:netplan-developers@lists.launchpad.net).
+
+## What should I know before I get started?
+
+### Did you find a bug?
+
+If you've found a bug, please make sure to report it on Launchpad against the
+[netplan](http://bugs.launchpad.net/netplan) project. We do use these bug reports
+to make it easier to backport features to supported releases of Ubuntu: having
+an existing bug report, with people who understand well how the bug appears
+helps immensely in making sure the feature or bug fix is well tested when it is
+being released to supported Ubuntu releases.
+
+### Code quality
+
+We want to maintain the quality of the code in netplan to the highest possible
+degree. As such, we do insist on keeping a code coverage with unit tests to 100%
+coverage if it is possible. If not, please make sure to explain why when submitting
+a pull request, and expect reviewers to challenge you on that decision and suggest
+a course of action.
+
+### Conventions
+
+The netplan project mixes C and python code. Generator code is generally all
+written in C, while the UI / command-line interface is written in python. Please
+look at the surrounding code, and make a best effort to follow the general style
+used in the code. We do insist on proper indentation (4 spaces), but we will
+not block good features and bug fixes on purely style issues. Please exercise
+your best judgement: if it looks odd or too clever to you, chances are it will
+look odd or too clever to code reviewers. In that case, you may be asked for
+some styles changes in a pull request. Similary, if you see code that you
+find hard to understand, we do encourage that you submit pull requests that
+help make the code easier to understand and maintain.
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<http://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..8645aee
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,144 @@
+NETPLAN_SOVER=0.0
+
+BUILDFLAGS = \
+ -g \
+ -fPIC \
+ -std=c99 \
+ -D_XOPEN_SOURCE=500 \
+ -DSBINDIR=\"$(SBINDIR)\" \
+ -Wall \
+ -Werror \
+ $(NULL)
+
+SYSTEMD_GENERATOR_DIR=$(shell pkg-config --variable=systemdsystemgeneratordir systemd)
+SYSTEMD_UNIT_DIR=$(shell pkg-config --variable=systemdsystemunitdir systemd)
+BASH_COMPLETIONS_DIR=$(shell pkg-config --variable=completionsdir bash-completion || echo "/etc/bash_completion.d")
+
+GCOV ?= gcov
+ROOTPREFIX ?=
+PREFIX ?= /usr
+LIBDIR ?= $(PREFIX)/lib
+ROOTLIBEXECDIR ?= $(ROOTPREFIX)/lib
+LIBEXECDIR ?= $(PREFIX)/lib
+SBINDIR ?= $(PREFIX)/sbin
+DATADIR ?= $(PREFIX)/share
+DOCDIR ?= $(DATADIR)/doc
+MANDIR ?= $(DATADIR)/man
+INCLUDEDIR ?= $(PREFIX)/include
+
+PYCODE = netplan/ $(wildcard src/*.py) $(wildcard tests/*.py) $(wildcard tests/generator/*.py) $(wildcard tests/dbus/*.py)
+
+# Order: Fedora/Mageia/openSUSE || Debian/Ubuntu || null
+PYFLAKES3 ?= $(shell which pyflakes-3 || which pyflakes3 || echo true)
+PYCODESTYLE3 ?= $(shell which pycodestyle-3 || which pycodestyle || which pep8 || echo true)
+NOSETESTS3 ?= $(shell which nosetests-3 || which nosetests3 || echo true)
+
+default: netplan/_features.py generate netplan-dbus dbus/io.netplan.Netplan.service doc/netplan.html doc/netplan.5 doc/netplan-generate.8 doc/netplan-apply.8 doc/netplan-try.8 doc/netplan-dbus.8 doc/netplan-get.8 doc/netplan-set.8
+
+%.o: src/%.c
+ $(CC) $(BUILDFLAGS) $(CFLAGS) $(LDFLAGS) -c $^ `pkg-config --cflags --libs glib-2.0 gio-2.0 yaml-0.1 uuid`
+
+libnetplan.so.$(NETPLAN_SOVER): parse.o netplan.o util.o validation.o error.o parse-nm.o
+ $(CC) -shared -Wl,-soname,libnetplan.so.$(NETPLAN_SOVER) $(BUILDFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ `pkg-config --libs glib-2.0 gio-2.0 yaml-0.1`
+ ln -snf libnetplan.so.$(NETPLAN_SOVER) libnetplan.so
+
+generate: libnetplan.so.$(NETPLAN_SOVER) nm.o networkd.o openvswitch.o generate.o sriov.o
+ $(CC) $(BUILDFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ -L. -lnetplan `pkg-config --cflags --libs glib-2.0 gio-2.0 yaml-0.1 uuid`
+
+netplan-dbus: src/dbus.c src/_features.h parse.o util.o validation.o error.o
+ $(CC) $(BUILDFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $(patsubst %.h,,$^) `pkg-config --cflags --libs libsystemd glib-2.0 gio-2.0 yaml-0.1`
+
+src/_features.h: src/[^_]*.[hc]
+ printf "#include <stddef.h>\nstatic const char *feature_flags[] __attribute__((__unused__)) = {\n" > $@
+ awk 'match ($$0, /netplan-feature:.*/ ) { $$0=substr($$0, RSTART, RLENGTH); print "\""$$2"\"," }' $^ >> $@
+ echo "NULL, };" >> $@
+
+netplan/_features.py: src/[^_]*.[hc]
+ echo "# Generated file" > $@
+ echo "NETPLAN_FEATURE_FLAGS = [" >> $@
+ awk 'match ($$0, /netplan-feature:.*/ ) { $$0=substr($$0, RSTART, RLENGTH); print " \""$$2"\"," }' $^ >> $@
+ echo "]" >> $@
+
+clean:
+ rm -f netplan/_features.py src/_features.h
+ rm -f generate doc/*.html doc/*.[1-9]
+ rm -f *.o *.so*
+ rm -f netplan-dbus dbus/*.service
+ rm -f *.gcda *.gcno generate.info
+ rm -rf test-coverage .coverage coverage.xml
+ find . | grep -E "(__pycache__|\.pyc)" | xargs rm -rf
+
+check: default linting
+ tests/cli.py
+ LD_LIBRARY_PATH=. $(NOSETESTS3) -v --with-coverage
+ tests/validate_docs.sh
+
+linting:
+ $(PYFLAKES3) $(PYCODE)
+ $(PYCODESTYLE3) --max-line-length=130 $(PYCODE)
+
+coverage: | pre-coverage c-coverage python-coverage
+
+pre-coverage:
+ rm -f .coverage
+ $(MAKE) CFLAGS="-g -O0 --coverage" clean check
+ mkdir -p test-coverage/C test-coverage/python
+
+check-coverage: coverage
+ @if grep headerCovTableEntryHi test-coverage/C/index.html | grep -qv '100.*%'; then \
+ echo "FAIL: Test coverage not 100%!" >&2; exit 1; \
+ fi
+ python3-coverage report --omit=/usr* --show-missing --fail-under=100
+
+c-coverage:
+ lcov --directory . --capture --gcov-tool=$(GCOV) -o generate.info
+ lcov --remove generate.info "/usr*" -o generate.info
+ genhtml -o test-coverage/C/ -t "generate test coverage" generate.info
+
+python-coverage:
+ python3-coverage html -d test-coverage/python --omit=/usr* || true
+ python3-coverage xml --omit=/usr* || true
+
+install: default
+ mkdir -p $(DESTDIR)/$(SBINDIR) $(DESTDIR)/$(ROOTLIBEXECDIR)/netplan $(DESTDIR)/$(SYSTEMD_GENERATOR_DIR) $(DESTDIR)/$(LIBDIR)
+ mkdir -p $(DESTDIR)/$(MANDIR)/man5 $(DESTDIR)/$(MANDIR)/man8
+ mkdir -p $(DESTDIR)/$(DOCDIR)/netplan/examples
+ mkdir -p $(DESTDIR)/$(DATADIR)/netplan/netplan
+ mkdir -p $(DESTDIR)/$(INCLUDEDIR)/netplan
+ install -m 755 generate $(DESTDIR)/$(ROOTLIBEXECDIR)/netplan/
+ find netplan/ -name '*.py' -exec install -Dm 644 "{}" "$(DESTDIR)/$(DATADIR)/netplan/{}" \;
+ install -m 755 src/netplan.script $(DESTDIR)/$(DATADIR)/netplan/
+ ln -srf $(DESTDIR)/$(DATADIR)/netplan/netplan.script $(DESTDIR)/$(SBINDIR)/netplan
+ ln -srf $(DESTDIR)/$(ROOTLIBEXECDIR)/netplan/generate $(DESTDIR)/$(SYSTEMD_GENERATOR_DIR)/netplan
+ # lib
+ install -m 644 *.so.* $(DESTDIR)/$(LIBDIR)/
+ ln -snf libnetplan.so.$(NETPLAN_SOVER) $(DESTDIR)/$(LIBDIR)/libnetplan.so
+ # headers, dev data
+ install -m 644 src/*.h $(DESTDIR)/$(INCLUDEDIR)/netplan/
+ # TODO: install pkg-config once available
+ # docs, data
+ install -m 644 doc/*.html $(DESTDIR)/$(DOCDIR)/netplan/
+ install -m 644 examples/*.yaml $(DESTDIR)/$(DOCDIR)/netplan/examples/
+ install -m 644 doc/*.5 $(DESTDIR)/$(MANDIR)/man5/
+ install -m 644 doc/*.8 $(DESTDIR)/$(MANDIR)/man8/
+ install -T -D -m 644 netplan.completions $(DESTDIR)/$(BASH_COMPLETIONS_DIR)/netplan
+ # dbus
+ mkdir -p $(DESTDIR)/$(DATADIR)/dbus-1/system.d $(DESTDIR)/$(DATADIR)/dbus-1/system-services
+ install -m 755 netplan-dbus $(DESTDIR)/$(ROOTLIBEXECDIR)/netplan/
+ install -m 644 dbus/io.netplan.Netplan.conf $(DESTDIR)/$(DATADIR)/dbus-1/system.d/
+ install -m 644 dbus/io.netplan.Netplan.service $(DESTDIR)/$(DATADIR)/dbus-1/system-services/
+
+%.service: %.service.in
+ sed -e "s#@ROOTLIBEXECDIR@#$(ROOTLIBEXECDIR)#" $< > $@
+
+
+%.html: %.md
+ pandoc -s --toc -o $@ $<
+
+doc/netplan.5: doc/manpage-header.md doc/netplan.md doc/manpage-footer.md
+ pandoc -s -o $@ $^
+
+%.8: %.md
+ pandoc -s -o $@ $^
+
+.PHONY: clean
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..31d422d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,28 @@
+# netplan - Backend-agnostic network configuration in YAML
+
+[![Build](https://github.com/canonical/netplan/workflows/Build/badge.svg?branch=master)](https://github.com/canonical/netplan/actions?query=branch%3Amaster+workflow%3ABuild)
+[![Codecov](https://codecov.io/gh/canonical/netplan/branch/master/graph/badge.svg)](https://codecov.io/gh/canonical/netplan)
+
+
+# Website
+
+http://netplan.io
+
+# Documentation
+
+An overview of the architecture can be found at [netplan.io/design](https://netplan.io/design)
+
+The full documentation for netplan is available in the [doc/netplan.md file](../master/doc/netplan.md)
+
+# Bug reports
+
+Please file bug reports in [Launchpad](https://bugs.launchpad.net/netplan/+filebug).
+
+# Contact us
+
+Please join us on IRC in #netplan at [Libera.Chat](https://libera.chat/).
+
+Our mailing list is [here](https://lists.launchpad.net/netplan-developers/).
+
+Email the list at [netplan-developers@lists.launchpad.net](mailto:netplan-developers@lists.launchpad.net).
+
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..610b28a
--- /dev/null
+++ b/TODO
@@ -0,0 +1,41 @@
+- improve IPv6 RA handling
+
+- support tunnel device types
+
+- support ethtool/sysctl knobs (TSO, LRO, txqueuelen)
+
+- inspecting current network config via "netplan show $interface" for a
+ collated view of each interface's yaml.
+
+- debugging config generation via "netplan diff [backend|system]":
+ - netplan diff system: compare generated config with current ip addr output
+ - netplan diff backend: compare generated config with current config for backend
+
+- support other devices types from networkd/NetworkManager:
+ - infiniband
+ - veth
+
+- better handle VLAN Q-in-Q (mostly generation tweaks + patching backends)
+
+- support device aliases (eth0 + eth0.1; add eth0 to multiple bridges)
+ - workaround for two bridges is to use eth0 and vlan1
+
+- make errors translatable
+
+- "netplan save" to capture kernel state into netplan YAML.
+
+- better parsing/validation for time-based values (ie. bond, bridge params)
+
+- openvswitch integration
+
+- wpa enterprise support
+
+- better parsing/validation for all schema
+
+- improve exit codes / behavior on error
+
+- integrate 'netplan try' in tmux/screen
+
+- replace nose for python tests with something else, preserving coverage reports
+
+- add automated integration tests for WPA Enterprise / 802.1x that can run self-contained
diff --git a/dbus/io.netplan.Netplan.conf b/dbus/io.netplan.Netplan.conf
new file mode 100644
index 0000000..c607d35
--- /dev/null
+++ b/dbus/io.netplan.Netplan.conf
@@ -0,0 +1,20 @@
+<!DOCTYPE busconfig PUBLIC
+ "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+
+ <policy user="root">
+ <allow own="io.netplan.Netplan"/>
+ </policy>
+
+ <policy context="default">
+ <allow send_destination="io.netplan.Netplan"
+ send_interface="io.netplan.Netplan"/>
+ <allow send_destination="io.netplan.Netplan"
+ send_interface="io.netplan.Netplan.Config"/>
+ <allow send_destination="io.netplan.Netplan"
+ send_interface="org.freedesktop.DBus.Introspectable"/>
+ </policy>
+
+</busconfig>
+
diff --git a/dbus/io.netplan.Netplan.service.in b/dbus/io.netplan.Netplan.service.in
new file mode 100644
index 0000000..dafd487
--- /dev/null
+++ b/dbus/io.netplan.Netplan.service.in
@@ -0,0 +1,5 @@
+[D-BUS Service]
+Name=io.netplan.Netplan
+Exec=@ROOTLIBEXECDIR@/netplan/netplan-dbus
+User=root
+AssumedAppArmorLabel=unconfined
diff --git a/doc/example-config b/doc/example-config
new file mode 100644
index 0000000..13ab91b
--- /dev/null
+++ b/doc/example-config
@@ -0,0 +1,61 @@
+network:
+ version: 2
+ # if specified, can only realistically have that value, as networkd cannot
+ # render wifi/3G. This would be shipped as a separate snippet by desktop images.
+ #renderer: NetworkManager
+ ethernets:
+ # opaque ID for physical interfaces, only referred to by other stanzas
+ id0:
+ match:
+ macaddress: 00:11:22:33:44:55
+ wakeonlan: true
+ dhcp4: true
+ addresses:
+ - 192.168.14.2/24
+ - "2001:1::1/64"
+ routes:
+ - to: default
+ via: 192.168.14.1
+ - to: default
+ via: "2001:1::2"
+ - to: 11.22.0.0/16
+ via: 192.168.14.3
+ metric: 100
+ nameservers:
+ search: [foo.local, bar.local]
+ addresses: [8.8.8.8]
+ lom:
+ match:
+ driver: ixgbe
+ # you are responsible for setting tight enough match rules
+ # that only match one device if you use set-name
+ set-name: lom1
+ dhcp6: true
+ switchports:
+ # all cards on second PCI bus; unconfigured by themselves, will be added
+ # to br0 below (note: globbing is not supported by NetworkManager)
+ match:
+ name: enp2*
+ mtu: 1280
+ wifis:
+ all-wlans:
+ # useful on a system where you know there is only ever going to be one device
+ match: {}
+ access-points:
+ "Joe's home":
+ # mode defaults to "managed" (client)
+ password: "s3kr1t"
+ # this creates an AP on wlp1s0 using hostapd; no match rules, thus ID is
+ # the interface name
+ wlp1s0:
+ access-points:
+ "guest":
+ mode: ap
+ # no WPA config implies default of open
+ bridges:
+ # the key name is the name for virtual (created) interfaces; no match: and
+ # set-name: allowed
+ br0:
+ # IDs of the components; switchports expands into multiple interfaces
+ interfaces: [wlp1s0, switchports]
+ dhcp4: true
diff --git a/doc/manpage-footer.md b/doc/manpage-footer.md
new file mode 100644
index 0000000..ec1dda7
--- /dev/null
+++ b/doc/manpage-footer.md
@@ -0,0 +1,3 @@
+# SEE ALSO
+
+ **netplan-generate**(8), **netplan-apply**(8), **netplan-try**(8), **netplan-get**(8), **netplan-set**(8), **netplan-dbus**(8), **systemd-networkd**(8), **NetworkManager**(8)
diff --git a/doc/manpage-header.md b/doc/manpage-header.md
new file mode 100644
index 0000000..0f5231e
--- /dev/null
+++ b/doc/manpage-header.md
@@ -0,0 +1,21 @@
+---
+title: netplan
+section: 5
+author:
+- Mathieu Trudel-Lapierre (<cyphermox@ubuntu.com>)
+- Martin Pitt (<martin.pitt@ubuntu.com>)
+...
+
+# NAME
+
+netplan - YAML network configuration abstraction for various backends
+
+# SYNOPSIS
+
+**netplan** [ *COMMAND* | help ]
+
+# COMMANDS
+
+See **netplan help** for a list of available commands on this system.
+
+# DESCRIPTION
diff --git a/doc/netplan-apply.md b/doc/netplan-apply.md
new file mode 100644
index 0000000..153acb1
--- /dev/null
+++ b/doc/netplan-apply.md
@@ -0,0 +1,62 @@
+---
+title: netplan-apply
+section: 8
+author:
+- Daniel Axtens (<daniel.axtens@canonical.com>)
+...
+
+# NAME
+
+netplan-apply - apply configuration from netplan YAML files to a running system
+
+# SYNOPSIS
+
+ **netplan** [--debug] **apply** -h | --help
+
+ **netplan** [--debug] **apply**
+
+# DESCRIPTION
+
+**netplan apply** applies the current netplan configuration to a running system.
+
+The process works as follows:
+
+ 1. The backend configuration is generated from netplan YAML files.
+
+ 2. The appropriate backends (**systemd-networkd**(8) or
+ **NetworkManager**(8)) are invoked to bring up configured interfaces.
+
+ 3. **netplan apply** iterates through interfaces that are still down, unbinding
+ them from their drivers, and rebinding them. This gives **udev**(7) renaming
+ rules the opportunity to run.
+
+ 4. If any devices have been rebound, the appropriate backends are re-invoked in
+ case more matches can be done.
+
+For information about the generation step, see
+**netplan-generate**(8). For details of the configuration file format,
+see **netplan**(5).
+
+# OPTIONS
+
+ -h, --help
+: Print basic help.
+
+ --debug
+: Print debugging output during the process.
+
+# KNOWN ISSUES
+
+**netplan apply** will not remove virtual devices such as bridges
+and bonds that have been created, even if they are no longer described
+in the netplan configuration.
+
+This can be resolved by manually removing the virtual device (for
+example ``ip link delete dev bond0``) and then running **netplan
+apply**, or by rebooting.
+
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-generate**(8), **netplan-try**(8), **udev**(7),
+ **systemd-networkd.service**(8), **NetworkManager**(8)
diff --git a/doc/netplan-dbus.md b/doc/netplan-dbus.md
new file mode 100644
index 0000000..2193a73
--- /dev/null
+++ b/doc/netplan-dbus.md
@@ -0,0 +1,43 @@
+---
+title: netplan-dbus
+section: 8
+author:
+- Lukas Märdian (<lukas.maerdian@canonical.com>)
+...
+
+# NAME
+
+netplan-dbus - daemon to access netplan's functionality via a DBus API
+
+# SYNOPSIS
+
+ **netplan-dbus**
+
+# DESCRIPTION
+
+**netplan-dbus** is a DBus daemon, providing ``io.netplan.Netplan`` on the system bus. The ``/io/netplan/Netplan`` object provides an ``io.netplan.Netplan`` interface, offering the following methods:
+
+ * ``Apply() -> b``: calls **netplan apply** and returns a success or failure status.
+ * ``Generate() -> b``: calls **netplan generate** and returns a success or failure status.
+ * ``Info() -> a(sv)``: returns a dict "Features -> as", containing an array of all available feature flags.
+ * ``Config() -> o``: prepares a new config object as ``/io/netplan/Netplan/config/<ID>``, by copying the current state from ``/{etc,run,lib}/netplan/*.yaml``
+
+The ``/io/netplan/Netplan/config/<ID>`` objects provide a ``io.netplan.Netplan.Config`` interface, offering the following methods:
+
+ * ``Get() -> s``: calls **netplan get --root-dir=/tmp/netplan-config-ID all** and returns the merged YAML config of the the given config object's state
+ * ``Set(s:CONFIG_DELTA, s:ORIGIN_HINT) -> b``: calls **netplan set --root-dir=/tmp/netplan-config-ID --origin-hint=ORIGIN_HINT CONFIG_DELTA**
+
+ CONFIG_DELTA can be something like: ``network.ethernets.eth0.dhcp4=true`` and ORIGIN_HINT can be something like: ``70-snapd`` (it will then write the config to ``70-snapd.yaml``). Once ``Set()`` is called on a config object, all other current and future config objects are being invalidated and cannot ``Set()`` or ``Try()/Apply()`` anymore, due to this pending dirty state. After the dirty config object is rejected via ``Cancel()``, the other config objects are valid again. If the dirty config object is accepted via ``Apply()``, newly created config objects will be valid, while the older states will stay invalid.
+
+ * ``Try(u:TIMEOUT_SEC) -> b``: replaces the main netplan configuration with this config object's state and calls **netplan try --timeout=TIMEOUT_SEC**
+ * ``Cancel() -> b``: rejects a currently running ``Try()`` attempt on this config object and/or discards the config object
+ * ``Apply() -> b``: replaces the main netplan configuration with this config object's state and calls **netplan apply**
+
+For information about the Apply()/Try()/Get()/Set() functionality, see
+**netplan-apply**(8)/**netplan-try**(8)/**netplan-get**(8)/**netplan-set**(8)
+accordingly. For details of the configuration file format, see **netplan**(5).
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-apply**(8), **netplan-try**(8), **netplan-get**(8),
+ **netplan-set**(8)
diff --git a/doc/netplan-generate.md b/doc/netplan-generate.md
new file mode 100644
index 0000000..5ba5ee1
--- /dev/null
+++ b/doc/netplan-generate.md
@@ -0,0 +1,88 @@
+---
+title: netplan-generate
+section: 8
+author:
+- Daniel Axtens (<daniel.axtens@canonical.com>)
+...
+
+# NAME
+
+netplan-generate - generate backend configuration from netplan YAML files
+
+# SYNOPSIS
+
+ **netplan** [--debug] **generate** -h | --help
+
+ **netplan** [--debug] **generate** [--root-dir _ROOT_DIR_] [--mapping _MAPPING_]
+
+# DESCRIPTION
+
+netplan generate converts netplan YAML into configuration files
+understood by the backends (**systemd-networkd**(8) or
+**NetworkManager**(8)). It *does not* apply the generated
+configuration.
+
+You will not normally need to run this directly as it is run by
+**netplan apply**, **netplan try**, or at boot.
+
+Only if executed during the systemd ``initializing`` phase
+(i.e. "Early bootup, before ``basic.target`` is reached"), will
+it attempt to start/apply the newly created service units.
+**Requires feature: generate-just-in-time**
+
+For details of the configuration file format, see **netplan**(5).
+
+# OPTIONS
+
+ -h, --help
+: Print basic help.
+
+ --debug
+: Print debugging output during the process.
+
+ --root-dir _ROOT_DIR_
+: Instead of looking in /{lib,etc,run}/netplan, look in
+ /_ROOT_DIR_/{lib,etc,run}/netplan
+
+ --mapping _MAPPING_
+: Instead of generating output files, parse the configuration files
+ and print some internal information about the device specified in
+ _MAPPING_.
+
+# HANDLING MULTIPLE FILES
+
+There are 3 locations that netplan generate considers:
+
+ * /lib/netplan/*.yaml
+ * /etc/netplan/*.yaml
+ * /run/netplan/*.yaml
+
+If there are multiple files with exactly the same name, then only one
+will be read. A file in /run/netplan will shadow - completely replace
+- a file with the same name in /etc/netplan. A file in /etc/netplan
+will itself shadow a file in /lib/netplan.
+
+Or in other words, /run/netplan is top priority, then /etc/netplan,
+with /lib/netplan having the lowest priority.
+
+If there are files with different names, then they are considered in
+lexicographical order - regardless of the directory they are in. Later
+files add to or override earlier files. For example,
+/run/netplan/10-foo.yaml would be updated by /lib/netplan/20-abc.yaml.
+
+If you have two files with the same key/setting, the following rules
+apply:
+
+ * If the values are YAML boolean or scalar values (numbers and
+ strings) the old value is overwritten by the new value.
+
+ * If the values are sequences, the sequences are concatenated - the
+ new values are appended to the old list.
+
+ * If the values are mappings, netplan will examine the elements
+ of the mappings in turn using these rules.
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-apply**(8), **netplan-try**(8),
+ **systemd-networkd**(8), **NetworkManager**(8)
diff --git a/doc/netplan-get.md b/doc/netplan-get.md
new file mode 100644
index 0000000..5c9b978
--- /dev/null
+++ b/doc/netplan-get.md
@@ -0,0 +1,39 @@
+---
+title: netplan-get
+section: 8
+author:
+- Lukas Märdian (lukas.maerdian@canonical.com)
+...
+
+# NAME
+
+netplan-get - read merged netplan YAML configuration
+
+# SYNOPSIS
+
+ **netplan** [--debug] **get** -h | --help
+
+ **netplan** [--debug] **get** [--root-dir=ROOT_DIR] [key]
+
+# DESCRIPTION
+
+**netplan get [key]** reads all YAML files from ``/{etc,lib,run}/netplan/*.yaml`` and returns a merged view of the current configuration
+
+You can specify ``all`` as a key (the default) to get the full YAML tree or extract a subtree by specifying a nested key like: ``[network.]ethernets.eth0``.
+
+For details of the configuration file format, see **netplan**(5).
+
+# OPTIONS
+
+ -h, --help
+: Print basic help.
+
+ --debug
+: Print debugging output during the process.
+
+ --root-dir
+: Read YAML files from this root instead of /
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-set**(8), **netplan-dbus**(8)
diff --git a/doc/netplan-set.md b/doc/netplan-set.md
new file mode 100644
index 0000000..f2c912d
--- /dev/null
+++ b/doc/netplan-set.md
@@ -0,0 +1,42 @@
+---
+title: netplan-set
+section: 8
+author:
+- Lukas Märdian (lukas.maerdian@canonical.com)
+...
+
+# NAME
+
+netplan-set - write netplan YAML configuration snippets to file
+
+# SYNOPSIS
+
+ **netplan** [--debug] **set** -h | --help
+
+ **netplan** [--debug] **set** [--root-dir=ROOT_DIR] [--origin-hint=ORIGIN_HINT] [key=value]
+
+# DESCRIPTION
+
+**netplan set [key=value]** writes a given key/value pair or YAML subtree into a YAML file in ``/etc/netplan/`` and validates its format.
+
+You can specify a single value as: ``"[network.]ethernets.eth0.dhcp4=[1.2.3.4/24, 5.6.7.8/24]"`` or a full subtree as: ``"[network.]ethernets.eth0={dhcp4: true, dhcp6: true}"``.
+
+For details of the configuration file format, see **netplan**(5).
+
+# OPTIONS
+
+ -h, --help
+: Print basic help.
+
+ --debug
+: Print debugging output during the process.
+
+ --root-dir
+: Write YAML files into this root instead of /
+
+ --origin-hint
+: Specify a name for the config file, e.g.: ``70-netplan-set`` => ``/etc/netplan/70-netplan-set.yaml``
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-get**(8), **netplan-dbus**(8)
diff --git a/doc/netplan-try.md b/doc/netplan-try.md
new file mode 100644
index 0000000..0167913
--- /dev/null
+++ b/doc/netplan-try.md
@@ -0,0 +1,64 @@
+---
+title: netplan-try
+section: 8
+author:
+- Daniel Axtens (<daniel.axtens@canonical.com>)
+...
+
+# NAME
+
+netplan-try - try a configuration, optionally rolling it back
+
+# SYNOPSIS
+
+ **netplan** [--debug] **try** -h | --help
+
+ **netplan** [--debug] **try** [--config-file _CONFIG_FILE_] [--timeout _TIMEOUT_]
+
+# DESCRIPTION
+
+**netplan try** takes a **netplan**(5) configuration, applies it, and
+automatically rolls it back if the user does not confirm the
+configuration within a time limit.
+
+A configuration can be confirmed or rejected interactively or by sending the
+SIGUSR1 or SIGINT signals.
+
+This may be especially useful on remote systems, to prevent an
+administrator being permanently locked out of systems in the case of a
+network configuration error.
+
+# OPTIONS
+
+ -h, --help
+: Print basic help.
+
+ --debug
+: Print debugging output during the process.
+
+ --config-file _CONFIG_FILE_
+: In addition to the usual configuration, apply _CONFIG_FILE_. It must
+ be a YAML file in the **netplan**(5) format.
+
+ --timeout _TIMEOUT_
+: Wait for _TIMEOUT_ seconds before reverting. Defaults to 120
+ seconds. Note that some network configurations (such as STP) may take
+ over a minute to settle.
+
+# KNOWN ISSUES
+
+**netplan try** uses similar procedures to **netplan apply**, so some
+of the same caveats apply around virtual devices.
+
+There are also some known bugs: if **netplan try** times out or is
+cancelled, make sure to verify if the network configuration has in
+fact been reverted.
+
+As with **netplan apply**, a reboot should fix any issues. However, be
+sure to verify that the config on disk is in the state you expect
+before rebooting!
+
+# SEE ALSO
+
+ **netplan**(5), **netplan-generate**(8), **netplan-apply**(8)
+
diff --git a/doc/netplan.md b/doc/netplan.md
new file mode 100644
index 0000000..1050dff
--- /dev/null
+++ b/doc/netplan.md
@@ -0,0 +1,1455 @@
+## Introduction
+Distribution installers, cloud instantiation, image builds for particular
+devices, or any other way to deploy an operating system put its desired
+network configuration into YAML configuration file(s). During
+early boot, the netplan "network renderer" runs which reads
+``/{lib,etc,run}/netplan/*.yaml`` and writes configuration to ``/run`` to hand
+off control of devices to the specified networking daemon.
+
+ - Configured devices get handled by systemd-networkd by default,
+ unless explicitly marked as managed by a specific renderer (NetworkManager)
+ - Devices not covered by the network config do not get touched at all.
+ - Usable in initramfs (few dependencies and fast)
+ - No persistent generated config, only original YAML config
+ - Parser supports multiple config files to allow applications like libvirt or lxd
+ to package up expected network config (``virbr0``, ``lxdbr0``), or to change the
+ global default policy to use NetworkManager for everything.
+ - Retains the flexibility to change backends/policy later or adjust to
+ removing NetworkManager, as generated configuration is ephemeral.
+
+## General structure
+netplan's configuration files use the
+[YAML](<http://yaml.org/spec/1.1/current.html>) format. All
+``/{lib,etc,run}/netplan/*.yaml`` are considered. Lexicographically later files
+(regardless of in which directory they are) amend (new mapping keys) or
+override (same mapping keys) previous ones. A file in ``/run/netplan``
+completely shadows a file with same name in ``/etc/netplan``, and a file in
+either of those directories shadows a file with the same name in
+``/lib/netplan``.
+
+The top-level node in a netplan configuration file is a ``network:`` mapping
+that contains ``version: 2`` (the YAML currently being used by curtin, MaaS,
+etc. is version 1), and then device definitions grouped by their type, such as
+``ethernets:``, ``modems:``, ``wifis:``, or ``bridges:``. These are the types that our
+renderer can understand and are supported by our backends.
+
+Each type block contains device definitions as a map where the keys (called
+"configuration IDs") are defined as below.
+
+## Device configuration IDs
+The key names below the per-device-type definition maps (like ``ethernets:``)
+are called "ID"s. They must be unique throughout the entire set of
+configuration files. Their primary purpose is to serve as anchor names for
+composite devices, for example to enumerate the members of a bridge that is
+currently being defined.
+
+(Since 0.97) If an interface is defined with an ID in a configuration file; it will
+be brought up by the applicable renderer. To not have netplan touch an interface
+at all, it should be completely omitted from the netplan configuration files.
+
+There are two physically/structurally different classes of device definitions,
+and the ID field has a different interpretation for each:
+
+Physical devices
+
+: (Examples: ethernet, modem, wifi) These can dynamically come and go between
+ reboots and even during runtime (hotplugging). In the generic case, they
+ can be selected by ``match:`` rules on desired properties, such as name/name
+ pattern, MAC address, driver, or device paths. In general these will match
+ any number of devices (unless they refer to properties which are unique
+ such as the full path or MAC address), so without further knowledge about
+ the hardware these will always be considered as a group.
+
+ It is valid to specify no match rules at all, in which case the ID field is
+ simply the interface name to be matched. This is mostly useful if you want
+ to keep simple cases simple, and it's how network device configuration has
+ been done for a long time.
+
+ If there are ``match``: rules, then the ID field is a purely opaque name
+ which is only being used for references from definitions of compound
+ devices in the config.
+
+
+Virtual devices
+
+: (Examples: veth, bridge, bond) These are fully under the control of the
+ config file(s) and the network stack. I. e. these devices are being created
+ instead of matched. Thus ``match:`` and ``set-name:`` are not applicable for
+ these, and the ID field is the name of the created virtual device.
+
+## Common properties for physical device types
+
+``match`` (mapping)
+
+: This selects a subset of available physical devices by various hardware
+ properties. The following configuration will then apply to all matching
+ devices, as soon as they appear. *All* specified properties must match.
+
+ ``name`` (scalar)
+ : Current interface name. Globs are supported, and the primary use case
+ for matching on names, as selecting one fixed name can be more easily
+ achieved with having no ``match:`` at all and just using the ID (see
+ above).
+ (``NetworkManager``: as of v1.14.0)
+
+ ``macaddress`` (scalar)
+ : Device's MAC address in the form "XX:XX:XX:XX:XX:XX". Globs are not
+ allowed.
+
+ ``driver`` (scalar)
+ : Kernel driver name, corresponding to the ``DRIVER`` udev property.
+ Globs are supported. Matching on driver is *only* supported with
+ networkd.
+
+ Examples:
+
+ - all cards on second PCI bus:
+
+ match:
+ name: enp2*
+
+ - fixed MAC address:
+
+ match:
+ macaddress: 11:22:33:AA:BB:FF
+
+ - first card of driver ``ixgbe``:
+
+ match:
+ driver: ixgbe
+ name: en*s0
+
+``set-name`` (scalar)
+
+: When matching on unique properties such as path or MAC, or with additional
+ assumptions such as "there will only ever be one wifi device",
+ match rules can be written so that they only match one device. Then this
+ property can be used to give that device a more specific/desirable/nicer
+ name than the default from udev’s ifnames. Any additional device that
+ satisfies the match rules will then fail to get renamed and keep the
+ original kernel name (and dmesg will show an error).
+
+``wakeonlan`` (bool)
+
+: Enable wake on LAN. Off by default.
+
+ **Note:** This will not work reliably for devices matched by name
+ only and rendered by networkd, due to interactions with device
+ renaming in udev. Match devices by MAC when setting wake on LAN.
+
+``emit-lldp`` (bool) – since **0.99**
+
+: (networkd backend only) Whether to emit LLDP packets. Off by default.
+
+``openvswitch`` (mapping) – since **0.100**
+
+: This provides additional configuration for the network device for openvswitch.
+ If openvswitch is not available on the system, netplan treats the presence of
+ openvswitch configuration as an error.
+
+ Any supported network device that is declared with the ``openvswitch`` mapping
+ (or any bond/bridge that includes an interface with an openvswitch configuration)
+ will be created in openvswitch instead of the defined renderer.
+ In the case of a ``vlan`` definition declared the same way, netplan will create
+ a fake VLAN bridge in openvswitch with the requested vlan properties.
+
+ ``external-ids`` (mapping) – since **0.100**
+ : Passed-through directly to OpenVSwitch
+
+ ``other-config`` (mapping) – since **0.100**
+ : Passed-through directly to OpenVSwitch
+
+ ``lacp`` (scalar) – since **0.100**
+ : Valid for bond interfaces. Accepts ``active``, ``passive`` or ``off`` (the default).
+
+ ``fail-mode`` (scalar) – since **0.100**
+ : Valid for bridge interfaces. Accepts ``secure`` or ``standalone`` (the default).
+
+ ``mcast-snooping`` (bool) – since **0.100**
+ : Valid for bridge interfaces. False by default.
+
+ ``protocols`` (sequence of scalars) – since **0.100**
+ : Valid for bridge interfaces or the network section. List of protocols to be used when
+ negotiating a connection with the controller. Accepts ``OpenFlow10``, ``OpenFlow11``,
+ ``OpenFlow12``, ``OpenFlow13``, ``OpenFlow14``, ``OpenFlow15`` and ``OpenFlow16``.
+
+ ``rstp`` (bool) – since **0.100**
+ : Valid for bridge interfaces. False by default.
+
+ ``controller`` (mapping) – since **0.100**
+ : Valid for bridge interfaces. Specify an external OpenFlow controller.
+
+ ``addresses`` (sequence of scalars)
+ : Set the list of addresses to use for the controller targets. The
+ syntax of these addresses is as defined in ovs-vsctl(8). Example:
+ addresses: ``[tcp:127.0.0.1:6653, "ssl:[fe80::1234%eth0]:6653"]``
+
+ ``connection-mode`` (scalar)
+ : Set the connection mode for the controller. Supported options are
+ ``in-band`` and ``out-of-band``. The default is ``in-band``.
+
+ ``ports`` (sequence of sequence of scalars) – since **0.100**
+ : OpenvSwitch patch ports. Each port is declared as a pair of names
+ which can be referenced as interfaces in dependent virtual devices
+ (bonds, bridges).
+
+ Example:
+
+ openvswitch:
+ ports:
+ - [patch0-1, patch1-0]
+
+ ``ssl`` (mapping) – since **0.100**
+ : Valid for global ``openvswitch`` settings. Options for configuring SSL
+ server endpoint for the switch.
+
+ ``ca-cert`` (scalar)
+ : Path to a file containing the CA certificate to be used.
+
+ ``certificate`` (scalar)
+ : Path to a file containing the server certificate.
+
+ ``private-key`` (scalar)
+ : Path to a file containing the private key for the server.
+
+## Common properties for all device types
+
+``renderer`` (scalar)
+
+: Use the given networking backend for this definition. Currently supported are
+ ``networkd`` and ``NetworkManager``. This property can be specified globally
+ in ``network:``, for a device type (in e. g. ``ethernets:``) or
+ for a particular device definition. Default is ``networkd``.
+
+ (Since 0.99) The ``renderer`` property has one additional acceptable value for vlan
+ objects (i. e. defined in ``vlans:``): ``sriov``. If a vlan is defined with the
+ ``sriov`` renderer for an SR-IOV Virtual Function interface, this causes netplan to
+ set up a hardware VLAN filter for it. There can be only one defined per VF.
+
+``dhcp4`` (bool)
+
+: Enable DHCP for IPv4. Off by default.
+
+``dhcp6`` (bool)
+
+: Enable DHCP for IPv6. Off by default. This covers both stateless DHCP -
+ where the DHCP server supplies information like DNS nameservers but not the
+ IP address - and stateful DHCP, where the server provides both the address
+ and the other information.
+
+ If you are in an IPv6-only environment with completely stateless
+ autoconfiguration (SLAAC with RDNSS), this option can be set to cause the
+ interface to be brought up. (Setting accept-ra alone is not sufficient.)
+ Autoconfiguration will still honour the contents of the router advertisement
+ and only use DHCP if requested in the RA.
+
+ Note that **``rdnssd``**(8) is required to use RDNSS with networkd. No extra
+ software is required for NetworkManager.
+
+``ipv6-mtu`` (scalar) – since **0.98**
+: Set the IPv6 MTU (only supported with `networkd` backend). Note
+ that needing to set this is an unusual requirement.
+
+ **Requires feature: ipv6-mtu**
+
+``ipv6-privacy`` (bool)
+
+: Enable IPv6 Privacy Extensions (RFC 4941) for the specified interface, and
+ prefer temporary addresses. Defaults to false - no privacy extensions. There
+ is currently no way to have a private address but prefer the public address.
+
+``link-local`` (sequence of scalars)
+
+: Configure the link-local addresses to bring up. Valid options are 'ipv4'
+ and 'ipv6', which respectively allow enabling IPv4 and IPv6 link local
+ addressing. If this field is not defined, the default is to enable only
+ IPv6 link-local addresses. If the field is defined but configured as an
+ empty set, IPv6 link-local addresses are disabled as well as IPv4 link-
+ local addresses.
+
+ This feature enables or disables link-local addresses for a protocol, but
+ the actual implementation differs per backend. On networkd, this directly
+ changes the behavior and may add an extra address on an interface. When
+ using the NetworkManager backend, enabling link-local has no effect if the
+ interface also has DHCP enabled.
+
+ Example to enable only IPv4 link-local: ``link-local: [ ipv4 ]``
+ Example to enable all link-local addresses: ``link-local: [ ipv4, ipv6 ]``
+ Example to disable all link-local addresses: ``link-local: [ ]``
+
+``critical`` (bool)
+
+: Designate the connection as "critical to the system", meaning that special
+ care will be taken by to not release the assigned IP when the daemon is
+ restarted. (not recognized by NetworkManager)
+
+``dhcp-identifier`` (scalar)
+
+: (networkd backend only) Sets the source of DHCPv4 client identifier. If ``mac``
+ is specified, the MAC address of the link is used. If this option is omitted,
+ or if ``duid`` is specified, networkd will generate an RFC4361-compliant client
+ identifier for the interface by combining the link's IAID and DUID.
+
+ ``dhcp4-overrides`` (mapping)
+
+ : (networkd backend only) Overrides default DHCP behavior; see the
+ ``DHCP Overrides`` section below.
+
+ ``dhcp6-overrides`` (mapping)
+
+ : (networkd backend only) Overrides default DHCP behavior; see the
+ ``DHCP Overrides`` section below.
+
+``accept-ra`` (bool)
+
+: Accept Router Advertisement that would have the kernel configure IPv6 by itself.
+ When enabled, accept Router Advertisements. When disabled, do not respond to
+ Router Advertisements. If unset use the host kernel default setting.
+
+``addresses`` (sequence of scalars and mappings)
+
+: Add static addresses to the interface in addition to the ones received
+ through DHCP or RA. Each sequence entry is in CIDR notation, i. e. of the
+ form ``addr/prefixlen``. ``addr`` is an IPv4 or IPv6 address as recognized
+ by **``inet_pton``**(3) and ``prefixlen`` the number of bits of the subnet.
+
+ For virtual devices (bridges, bonds, vlan) if there is no address
+ configured and DHCP is disabled, the interface may still be brought online,
+ but will not be addressable from the network.
+
+ In addition to the addresses themselves one can specify configuration
+ parameters as mappings. Current supported options are:
+
+ ``lifetime`` (scalar) – since **0.100**
+ : Default: ``forever``. This can be ``forever`` or ``0`` and corresponds
+ to the ``PreferredLifetime`` option in ``systemd-networkd``'s Address
+ section. Currently supported on the ``networkd`` backend only.
+
+ ``label`` (scalar) – since **0.100**
+ : An IP address label, equivalent to the ``ip address label``
+ command. Currently supported on the ``networkd`` backend only.
+
+ Example: ``addresses: [192.168.14.2/24, "2001:1::1/64"]``
+
+ Example:
+
+ ethernets:
+ eth0:
+ addresses:
+ - 10.0.0.15/24:
+ lifetime: 0
+ label: "maas"
+ - "2001:1::1/64"
+
+``ipv6-address-generation`` (scalar) – since **0.99**
+
+: Configure method for creating the address for use with RFC4862 IPv6
+ Stateless Address Autoconfiguration (only supported with `NetworkManager`
+ backend). Possible values are ``eui64`` or ``stable-privacy``.
+
+``ipv6-address-token`` (scalar) – since **0.100**
+
+: Define an IPv6 address token for creating a static interface identifier for
+ IPv6 Stateless Address Autoconfiguration. This is mutually exclusive with
+ ``ipv6-address-generation``.
+
+``gateway4``, ``gateway6`` (scalar)
+
+: Deprecated, see ``Default routes``.
+ Set default gateway for IPv4/6, for manual address configuration. This
+ requires setting ``addresses`` too. Gateway IPs must be in a form
+ recognized by **``inet_pton``**(3). There should only be a single gateway
+ per IP address family set in your global config, to make it unambiguous.
+ If you need multiple default routes, please define them via
+ ``routing-policy``.
+
+ Example for IPv4: ``gateway4: 172.16.0.1``
+ Example for IPv6: ``gateway6: "2001:4::1"``
+
+``nameservers`` (mapping)
+
+: Set DNS servers and search domains, for manual address configuration. There
+are two supported fields: ``addresses:`` is a list of IPv4 or IPv6 addresses
+similar to ``gateway*``, and ``search:`` is a list of search domains.
+
+ Example:
+
+ ethernets:
+ id0:
+ [...]
+ nameservers:
+ search: [lab, home]
+ addresses: [8.8.8.8, "FEDC::1"]
+
+``macaddress`` (scalar)
+
+: Set the device's MAC address. The MAC address must be in the form
+ "XX:XX:XX:XX:XX:XX".
+
+ **Note:** This will not work reliably for devices matched by name
+ only and rendered by networkd, due to interactions with device
+ renaming in udev. Match devices by MAC when setting MAC addresses.
+
+ Example:
+
+ ethernets:
+ id0:
+ match:
+ macaddress: 52:54:00:6b:3c:58
+ [...]
+ macaddress: 52:54:00:6b:3c:59
+
+``mtu`` (scalar)
+
+: Set the Maximum Transmission Unit for the interface. The default is 1500.
+ Valid values depend on your network interface.
+
+ **Note:** This will not work reliably for devices matched by name
+ only and rendered by networkd, due to interactions with device
+ renaming in udev. Match devices by MAC when setting MTU.
+
+``optional`` (bool)
+
+: An optional device is not required for booting. Normally, networkd will
+ wait some time for device to become configured before proceeding with
+ booting. However, if a device is marked as optional, networkd will not wait
+ for it. This is *only* supported by networkd, and the default is false.
+
+ Example:
+
+ ethernets:
+ eth7:
+ # this is plugged into a test network that is often
+ # down - don't wait for it to come up during boot.
+ dhcp4: true
+ optional: true
+
+``optional-addresses`` (sequence of scalars)
+
+: Specify types of addresses that are not required for a device to be
+ considered online. This changes the behavior of backends at boot time to
+ avoid waiting for addresses that are marked optional, and thus consider
+ the interface as "usable" sooner. This does not disable these addresses,
+ which will be brought up anyway.
+
+ Example:
+
+ ethernets:
+ eth7:
+ dhcp4: true
+ dhcp6: true
+ optional-addresses: [ ipv4-ll, dhcp6 ]
+
+``activation-mode`` (scalar) – since **0.103**
+
+: Allows specifying the management policy of the selected interface. By
+ default, netplan brings up any configured interface if possible. Using the
+ ``activation-mode`` setting users can override that behavior by either
+ specifying ``manual``, to hand over control over the interface state to the
+ administrator or (for networkd backend *only*) ``off`` to force the link
+ in a down state at all times. Any interface with ``activation-mode``
+ defined is implicitly considered ``optional``.
+ Supported officially as of ``networkd`` v248+.
+
+ Example:
+
+ ethernets:
+ eth1:
+ # this interface will not be put into an UP state automatically
+ dhcp4: true
+ activation-mode: manual
+
+``routes`` (sequence of mappings)
+
+: Configure static routing for the device; see the ``Routing`` section below.
+
+``routing-policy`` (sequence of mappings)
+
+: Configure policy routing for the device; see the ``Routing`` section below.
+
+## DHCP Overrides
+Several DHCP behavior overrides are available. Most currently only have any
+effect when using the ``networkd`` backend, with the exception of ``use-routes``
+and ``route-metric``.
+
+Overrides only have an effect if the corresponding ``dhcp4`` or ``dhcp6`` is
+set to ``true``.
+
+If both ``dhcp4`` and ``dhcp6`` are ``true``, the ``networkd`` backend requires
+that ``dhcp4-overrides`` and ``dhcp6-overrides`` contain the same keys and
+values. If the values do not match, an error will be shown and the network
+configuration will not be applied.
+
+When using the NetworkManager backend, different values may be specified for
+``dhcp4-overrides`` and ``dhcp6-overrides``, and will be applied to the DHCP
+client processes as specified in the netplan YAML.
+
+``dhcp4-overrides``, ``dhcp6-overrides`` (mapping)
+
+: The ``dhcp4-overrides`` and ``dhcp6-overrides`` mappings override the
+ default DHCP behavior.
+
+ ``use-dns`` (bool)
+ : Default: ``true``. When ``true``, the DNS servers received from the
+ DHCP server will be used and take precedence over any statically
+ configured ones. Currently only has an effect on the ``networkd``
+ backend.
+
+ ``use-ntp`` (bool)
+ : Default: ``true``. When ``true``, the NTP servers received from the
+ DHCP server will be used by systemd-timesyncd and take precedence
+ over any statically configured ones. Currently only has an effect on
+ the ``networkd`` backend.
+
+ ``send-hostname`` (bool)
+ : Default: ``true``. When ``true``, the machine's hostname will be sent
+ to the DHCP server. Currently only has an effect on the ``networkd``
+ backend.
+
+ ``use-hostname`` (bool)
+ : Default: ``true``. When ``true``, the hostname received from the DHCP
+ server will be set as the transient hostname of the system. Currently
+ only has an effect on the ``networkd`` backend.
+
+ ``use-mtu`` (bool)
+ : Default: ``true``. When ``true``, the MTU received from the DHCP
+ server will be set as the MTU of the network interface. When ``false``,
+ the MTU advertised by the DHCP server will be ignored. Currently only
+ has an effect on the ``networkd`` backend.
+
+ ``hostname`` (scalar)
+ : Use this value for the hostname which is sent to the DHCP server,
+ instead of machine's hostname. Currently only has an effect on the
+ ``networkd`` backend.
+
+ ``use-routes`` (bool)
+ : Default: ``true``. When ``true``, the routes received from the DHCP
+ server will be installed in the routing table normally. When set to
+ ``false``, routes from the DHCP server will be ignored: in this case,
+ the user is responsible for adding static routes if necessary for
+ correct network operation. This allows users to avoid installing a
+ default gateway for interfaces configured via DHCP. Available for
+ both the ``networkd`` and ``NetworkManager`` backends.
+
+ ``route-metric`` (scalar)
+ : Use this value for default metric for automatically-added routes.
+ Use this to prioritize routes for devices by setting a lower metric
+ on a preferred interface. Available for both the ``networkd`` and
+ ``NetworkManager`` backends.
+
+ ``use-domains`` (scalar) – since **0.98**
+ : Takes a boolean, or the special value "route". When true, the domain
+ name received from the DHCP server will be used as DNS search domain
+ over this link, similar to the effect of the Domains= setting. If set
+ to "route", the domain name received from the DHCP server will be
+ used for routing DNS queries only, but not for searching, similar to
+ the effect of the Domains= setting when the argument is prefixed with
+ "~".
+
+ **Requires feature: dhcp-use-domains**
+
+
+## Routing
+Complex routing is possible with netplan. Standard static routes as well
+as policy routing using routing tables are supported via the ``networkd``
+backend.
+
+These options are available for all types of interfaces.
+
+### Default routes
+
+The most common need for routing concerns the definition of default routes to
+reach the wider Internet. Those default routes can only defined once per IP family
+and routing table. A typical example would look like the following:
+
+```yaml
+eth0:
+ [...]
+ routes:
+ - to: default # could be 0/0 or 0.0.0.0/0 optionally
+ via: 10.0.0.1
+ metric: 100
+ on-link: true
+ - to: default # could be ::/0 optionally
+ via: cf02:de:ad:be:ef::2
+eth1:
+ [...]
+ routes:
+ - to: default
+ via: 172.134.67.1
+ metric: 100
+ on-link: true
+ table: 76 # Not on the main routing table, does not conflict with the eth0 default route
+```
+
+``routes`` (mapping)
+
+: The ``routes`` block defines standard static routes for an interface.
+ At least ``to`` and ``via`` must be specified.
+
+ For ``from``, ``to``, and ``via``, both IPv4 and IPv6 addresses are
+ recognized, and must be in the form ``addr/prefixlen`` or ``addr``.
+
+ ``from`` (scalar)
+ : Set a source IP address for traffic going through the route.
+ (``NetworkManager``: as of v1.8.0)
+
+ ``to`` (scalar)
+ : Destination address for the route.
+
+ ``via`` (scalar)
+ : Address to the gateway to use for this route.
+
+ ``on-link`` (bool)
+ : When set to "true", specifies that the route is directly connected
+ to the interface.
+ (``NetworkManager``: as of v1.12.0 for IPv4 and v1.18.0 for IPv6)
+
+ ``metric`` (scalar)
+ : The relative priority of the route. Must be a positive integer value.
+
+ ``type`` (scalar)
+ : The type of route. Valid options are "unicast" (default),
+ "unreachable", "blackhole" or "prohibit".
+
+ ``scope`` (scalar)
+ : The route scope, how wide-ranging it is to the network. Possible
+ values are "global", "link", or "host". ``NetworkManager`` does
+ not support setting a scope.
+
+ ``table`` (scalar)
+ : The table number to use for the route. In some scenarios, it may be
+ useful to set routes in a separate routing table. It may also be used
+ to refer to routing policy rules which also accept a ``table``
+ parameter. Allowed values are positive integers starting from 1.
+ Some values are already in use to refer to specific routing tables:
+ see ``/etc/iproute2/rt_tables``.
+ (``NetworkManager``: as of v1.10.0)
+
+ ``mtu`` (scalar) – since **0.101**
+ : The MTU to be used for the route, in bytes. Must be a positive integer
+ value.
+
+ ``congestion-window`` (scalar) – since **0.102**
+ : The congestion window to be used for the route, represented by number
+ of segments. Must be a positive integer value.
+
+ ``advertised-receive-window`` (scalar) – since **0.102**
+ : The receive window to be advertised for the route, represented by
+ number of segments. Must be a positive integer value.
+
+``routing-policy`` (mapping)
+
+: The ``routing-policy`` block defines extra routing policy for a network,
+ where traffic may be handled specially based on the source IP, firewall
+ marking, etc.
+
+ For ``from``, ``to``, both IPv4 and IPv6 addresses are recognized, and
+ must be in the form ``addr/prefixlen`` or ``addr``.
+
+ ``from`` (scalar)
+ : Set a source IP address to match traffic for this policy rule.
+
+ ``to`` (scalar)
+ : Match on traffic going to the specified destination.
+
+ ``table`` (scalar)
+ : The table number to match for the route. In some scenarios, it may be
+ useful to set routes in a separate routing table. It may also be used
+ to refer to routes which also accept a ``table`` parameter.
+ Allowed values are positive integers starting from 1.
+ Some values are already in use to refer to specific routing tables:
+ see ``/etc/iproute2/rt_tables``.
+
+ ``priority`` (scalar)
+ : Specify a priority for the routing policy rule, to influence the order
+ in which routing rules are processed. A higher number means lower
+ priority: rules are processed in order by increasing priority number.
+
+ ``mark`` (scalar)
+ : Have this routing policy rule match on traffic that has been marked
+ by the iptables firewall with this value. Allowed values are positive
+ integers starting from 1.
+
+ ``type-of-service`` (scalar)
+ : Match this policy rule based on the type of service number applied to
+ the traffic.
+
+## Authentication
+Netplan supports advanced authentication settings for ethernet and wifi
+interfaces, as well as individual wifi networks, by means of the ``auth`` block.
+
+``auth`` (mapping)
+
+: Specifies authentication settings for a device of type ``ethernets:``, or
+ an ``access-points:`` entry on a ``wifis:`` device.
+
+ The ``auth`` block supports the following properties:
+
+ ``key-management`` (scalar)
+ : The supported key management modes are ``none`` (no key management);
+ ``psk`` (WPA with pre-shared key, common for home wifi); ``eap`` (WPA
+ with EAP, common for enterprise wifi); and ``802.1x`` (used primarily
+ for wired Ethernet connections).
+
+ ``password`` (scalar)
+ : The password string for EAP, or the pre-shared key for WPA-PSK.
+
+ The following properties can be used if ``key-management`` is ``eap``
+ or ``802.1x``:
+
+ ``method`` (scalar)
+ : The EAP method to use. The supported EAP methods are ``tls`` (TLS),
+ ``peap`` (Protected EAP), and ``ttls`` (Tunneled TLS).
+
+ ``identity`` (scalar)
+ : The identity to use for EAP.
+
+ ``anonymous-identity`` (scalar)
+ : The identity to pass over the unencrypted channel if the chosen EAP
+ method supports passing a different tunnelled identity.
+
+ ``ca-certificate`` (scalar)
+ : Path to a file with one or more trusted certificate authority (CA)
+ certificates.
+
+ ``client-certificate`` (scalar)
+ : Path to a file containing the certificate to be used by the client
+ during authentication.
+
+ ``client-key`` (scalar)
+ : Path to a file containing the private key corresponding to
+ ``client-certificate``.
+
+ ``client-key-password`` (scalar)
+ : Password to use to decrypt the private key specified in
+ ``client-key`` if it is encrypted.
+
+ ``phase2-auth`` (scalar) – since **0.99**
+ : Phase 2 authentication mechanism.
+
+
+## Properties for device type ``ethernets:``
+Ethernet device definitions, beyond common ones described above, also support
+some additional properties that can be used for SR-IOV devices.
+
+``link`` (scalar) – since **0.99**
+
+: (SR-IOV devices only) The ``link`` property declares the device as a
+ Virtual Function of the selected Physical Function device, as identified
+ by the given netplan id.
+
+Example:
+
+ ethernets:
+ enp1: {...}
+ enp1s16f1:
+ link: enp1
+
+``virtual-function-count`` (scalar) – since **0.99**
+
+: (SR-IOV devices only) In certain special cases VFs might need to be
+ configured outside of netplan. For such configurations ``virtual-function-count``
+ can be optionally used to set an explicit number of Virtual Functions for
+ the given Physical Function. If unset, the default is to create only as many
+ VFs as are defined in the netplan configuration. This should be used for special
+ cases only.
+
+ **Requires feature: sriov**
+
+## Properties for device type ``modems:``
+GSM/CDMA modem configuration is only supported for the ``NetworkManager``
+backend. ``systemd-networkd`` does not support modems.
+
+**Requires feature: modems**
+
+``apn`` (scalar) – since **0.99**
+
+: Set the carrier APN (Access Point Name). This can be omitted if
+ ``auto-config`` is enabled.
+
+``auto-config`` (bool) – since **0.99**
+
+: Specify whether to try and autoconfigure the modem by doing a lookup of
+ the carrier against the Mobile Broadband Provider database. This may not
+ work for all carriers.
+
+``device-id`` (scalar) – since **0.99**
+
+: Specify the device ID (as given by the WWAN management service) of the
+ modem to match. This can be found using ``mmcli``.
+
+``network-id`` (scalar) – since **0.99**
+
+: Specify the Network ID (GSM LAI format). If this is specified, the device
+ will not roam networks.
+
+``number`` (scalar) – since **0.99**
+
+: The number to dial to establish the connection to the mobile broadband
+ network. (Deprecated for GSM)
+
+``password`` (scalar) – since **0.99**
+
+: Specify the password used to authenticate with the carrier network. This
+ can be omitted if ``auto-config`` is enabled.
+
+``pin`` (scalar) – since **0.99**
+
+: Specify the SIM PIN to allow it to operate if a PIN is set.
+
+``sim-id`` (scalar) – since **0.99**
+
+: Specify the SIM unique identifier (as given by the WWAN management service)
+ which this connection applies to. If given, the connection will apply to
+ any device also allowed by ``device-id`` which contains a SIM card matching
+ the given identifier.
+
+``sim-operator-id`` (scalar) – since **0.99**
+
+: Specify the MCC/MNC string (such as "310260" or "21601") which identifies
+ the carrier that this connection should apply to. If given, the connection
+ will apply to any device also allowed by ``device-id`` and ``sim-id``
+ which contains a SIM card provisioned by the given operator.
+
+``username`` (scalar) – since **0.99**
+
+: Specify the username used to authentiate with the carrier network. This
+ can be omitted if ``auto-config`` is enabled.
+
+## Properties for device type ``wifis:``
+Note that ``systemd-networkd`` does not natively support wifi, so you need
+wpasupplicant installed if you let the ``networkd`` renderer handle wifi.
+
+``access-points`` (mapping)
+
+: This provides pre-configured connections to NetworkManager. Note that
+ users can of course select other access points/SSIDs. The keys of the
+ mapping are the SSIDs, and the values are mappings with the following
+ supported properties:
+
+ ``password`` (scalar)
+ : Enable WPA2 authentication and set the passphrase for it. If neither
+ this nor an ``auth`` block are given, the network is assumed to be
+ open. The setting
+
+ password: "S3kr1t"
+
+ is equivalent to
+
+ auth:
+ key-management: psk
+ password: "S3kr1t"
+
+ ``mode`` (scalar)
+ : Possible access point modes are ``infrastructure`` (the default),
+ ``ap`` (create an access point to which other devices can connect),
+ and ``adhoc`` (peer to peer networks without a central access point).
+ ``ap`` is only supported with NetworkManager.
+
+ ``bssid`` (scalar) – since **0.99**
+ : If specified, directs the device to only associate with the given
+ access point.
+
+ ``band`` (scalar) – since **0.99**
+ : Possible bands are ``5GHz`` (for 5GHz 802.11a) and ``2.4GHz``
+ (for 2.4GHz 802.11), do not restrict the 802.11 frequency band of the
+ network if unset (the default).
+
+ ``channel`` (scalar) – since **0.99**
+ : Wireless channel to use for the Wi-Fi connection. Because channel
+ numbers overlap between bands, this property takes effect only if
+ the ``band`` property is also set.
+
+ ``hidden`` (bool) – since **0.100**
+ : Set to ``true`` to change the SSID scan technique for connecting to
+ hidden WiFi networks. Note this may have slower performance compared
+ to ``false`` (the default) when connecting to publicly broadcast
+ SSIDs.
+
+``wakeonwlan`` (sequence of scalars) – since **0.99**
+
+: This enables WakeOnWLan on supported devices. Not all drivers support all
+ options. May be any combination of ``any``, ``disconnect``, ``magic_pkt``,
+ ``gtk_rekey_failure``, ``eap_identity_req``, ``four_way_handshake``,
+ ``rfkill_release`` or ``tcp`` (NetworkManager only). Or the exclusive
+ ``default`` flag (the default).
+
+## Properties for device type ``bridges:``
+
+``interfaces`` (sequence of scalars)
+
+: All devices matching this ID list will be added to the bridge. This may
+ be an empty list, in which case the bridge will be brought online with
+ no member interfaces.
+
+ Example:
+
+ ethernets:
+ switchports:
+ match: {name: "enp2*"}
+ [...]
+ bridges:
+ br0:
+ interfaces: [switchports]
+
+``parameters`` (mapping)
+
+: Customization parameters for special bridging options. Time intervals
+ may need to be expressed as a number of seconds or milliseconds: the
+ default value type is specified below. If necessary, time intervals can
+ be qualified using a time suffix (such as "s" for seconds, "ms" for
+ milliseconds) to allow for more control over its behavior.
+
+ ``ageing-time`` (scalar)
+ : Set the period of time to keep a MAC address in the forwarding
+ database after a packet is received. This maps to the AgeingTimeSec=
+ property when the networkd renderer is used. If no time suffix is
+ specified, the value will be interpreted as seconds.
+
+ ``priority`` (scalar)
+ : Set the priority value for the bridge. This value should be a
+ number between ``0`` and ``65535``. Lower values mean higher
+ priority. The bridge with the higher priority will be elected as
+ the root bridge.
+
+ ``port-priority`` (scalar)
+ : Set the port priority to <priority>. The priority value is
+ a number between ``0`` and ``63``. This metric is used in the
+ designated port and root port selection algorithms.
+
+ ``forward-delay`` (scalar)
+ : Specify the period of time the bridge will remain in Listening and
+ Learning states before getting to the Forwarding state. This field
+ maps to the ForwardDelaySec= property for the networkd renderer.
+ If no time suffix is specified, the value will be interpreted as
+ seconds.
+
+ ``hello-time`` (scalar)
+ : Specify the interval between two hello packets being sent out from
+ the root and designated bridges. Hello packets communicate
+ information about the network topology. When the networkd renderer
+ is used, this maps to the HelloTimeSec= property. If no time suffix
+ is specified, the value will be interpreted as seconds.
+
+ ``max-age`` (scalar)
+ : Set the maximum age of a hello packet. If the last hello packet is
+ older than that value, the bridge will attempt to become the root
+ bridge. This maps to the MaxAgeSec= property when the networkd
+ renderer is used. If no time suffix is specified, the value will be
+ interpreted as seconds.
+
+ ``path-cost`` (scalar)
+ : Set the cost of a path on the bridge. Faster interfaces should have
+ a lower cost. This allows a finer control on the network topology
+ so that the fastest paths are available whenever possible.
+
+ ``stp`` (bool)
+ : Define whether the bridge should use Spanning Tree Protocol. The
+ default value is "true", which means that Spanning Tree should be
+ used.
+
+
+## Properties for device type ``bonds:``
+
+``interfaces`` (sequence of scalars)
+
+: All devices matching this ID list will be added to the bond.
+
+ Example:
+
+ ethernets:
+ switchports:
+ match: {name: "enp2*"}
+ [...]
+ bonds:
+ bond0:
+ interfaces: [switchports]
+
+``parameters`` (mapping)
+
+: Customization parameters for special bonding options. Time intervals
+ may need to be expressed as a number of seconds or milliseconds: the
+ default value type is specified below. If necessary, time intervals can
+ be qualified using a time suffix (such as "s" for seconds, "ms" for
+ milliseconds) to allow for more control over its behavior.
+
+ ``mode`` (scalar)
+ : Set the bonding mode used for the interfaces. The default is
+ ``balance-rr`` (round robin). Possible values are ``balance-rr``,
+ ``active-backup``, ``balance-xor``, ``broadcast``, ``802.3ad``,
+ ``balance-tlb``, and ``balance-alb``.
+ For OpenVSwitch ``active-backup`` and the additional modes
+ ``balance-tcp`` and ``balance-slb`` are supported.
+
+ ``lacp-rate`` (scalar)
+ : Set the rate at which LACPDUs are transmitted. This is only useful
+ in 802.3ad mode. Possible values are ``slow`` (30 seconds, default),
+ and ``fast`` (every second).
+
+ ``mii-monitor-interval`` (scalar)
+ : Specifies the interval for MII monitoring (verifying if an interface
+ of the bond has carrier). The default is ``0``; which disables MII
+ monitoring. This is equivalent to the MIIMonitorSec= field for the
+ networkd backend. If no time suffix is specified, the value will be
+ interpreted as milliseconds.
+
+ ``min-links`` (scalar)
+ : The minimum number of links up in a bond to consider the bond
+ interface to be up.
+
+ ``transmit-hash-policy`` (scalar)
+ : Specifies the transmit hash policy for the selection of slaves. This
+ is only useful in balance-xor, 802.3ad and balance-tlb modes.
+ Possible values are ``layer2``, ``layer3+4``, ``layer2+3``,
+ ``encap2+3``, and ``encap3+4``.
+
+ ``ad-select`` (scalar)
+ : Set the aggregation selection mode. Possible values are ``stable``,
+ ``bandwidth``, and ``count``. This option is only used in 802.3ad
+ mode.
+
+ ``all-slaves-active`` (bool)
+ : If the bond should drop duplicate frames received on inactive ports,
+ set this option to ``false``. If they should be delivered, set this
+ option to ``true``. The default value is false, and is the desirable
+ behavior in most situations.
+
+ ``arp-interval`` (scalar)
+ : Set the interval value for how frequently ARP link monitoring should
+ happen. The default value is ``0``, which disables ARP monitoring.
+ For the networkd backend, this maps to the ARPIntervalSec= property.
+ If no time suffix is specified, the value will be interpreted as
+ milliseconds.
+
+ ``arp-ip-targets`` (sequence of scalars)
+ : IPs of other hosts on the link which should be sent ARP requests in
+ order to validate that a slave is up. This option is only used when
+ ``arp-interval`` is set to a value other than ``0``. At least one IP
+ address must be given for ARP link monitoring to function. Only IPv4
+ addresses are supported. You can specify up to 16 IP addresses. The
+ default value is an empty list.
+
+ ``arp-validate`` (scalar)
+ : Configure how ARP replies are to be validated when using ARP link
+ monitoring. Possible values are ``none``, ``active``, ``backup``,
+ and ``all``.
+
+ ``arp-all-targets`` (scalar)
+ : Specify whether to use any ARP IP target being up as sufficient for
+ a slave to be considered up; or if all the targets must be up. This
+ is only used for ``active-backup`` mode when ``arp-validate`` is
+ enabled. Possible values are ``any`` and ``all``.
+
+ ``up-delay`` (scalar)
+ : Specify the delay before enabling a link once the link is physically
+ up. The default value is ``0``. This maps to the UpDelaySec= property
+ for the networkd renderer. This option is only valid for the miimon
+ link monitor. If no time suffix is specified, the value will be
+ interpreted as milliseconds.
+
+ ``down-delay`` (scalar)
+ : Specify the delay before disabling a link once the link has been
+ lost. The default value is ``0``. This maps to the DownDelaySec=
+ property for the networkd renderer. This option is only valid for the
+ miimon link monitor. If no time suffix is specified, the value will
+ be interpreted as milliseconds.
+
+ ``fail-over-mac-policy`` (scalar)
+ : Set whether to set all slaves to the same MAC address when adding
+ them to the bond, or how else the system should handle MAC addresses.
+ The possible values are ``none``, ``active``, and ``follow``.
+
+ ``gratuitous-arp`` (scalar)
+ : Specify how many ARP packets to send after failover. Once a link is
+ up on a new slave, a notification is sent and possibly repeated if
+ this value is set to a number greater than ``1``. The default value
+ is ``1`` and valid values are between ``1`` and ``255``. This only
+ affects ``active-backup`` mode.
+
+ For historical reasons, the misspelling ``gratuitious-arp`` is also
+ accepted and has the same function.
+
+ ``packets-per-slave`` (scalar)
+ : In ``balance-rr`` mode, specifies the number of packets to transmit
+ on a slave before switching to the next. When this value is set to
+ ``0``, slaves are chosen at random. Allowable values are between
+ ``0`` and ``65535``. The default value is ``1``. This setting is
+ only used in ``balance-rr`` mode.
+
+ ``primary-reselect-policy`` (scalar)
+ : Set the reselection policy for the primary slave. On failure of the
+ active slave, the system will use this policy to decide how the new
+ active slave will be chosen and how recovery will be handled. The
+ possible values are ``always``, ``better``, and ``failure``.
+
+ ``resend-igmp`` (scalar)
+ : In modes ``balance-rr``, ``active-backup``, ``balance-tlb`` and
+ ``balance-alb``, a failover can switch IGMP traffic from one
+ slave to another.
+
+ This parameter specifies how many IGMP membership reports
+ are issued on a failover event. Values range from 0 to 255. 0
+ disables sending membership reports. Otherwise, the first
+ membership report is sent on failover and subsequent reports
+ are sent at 200ms intervals.
+
+ ``learn-packet-interval`` (scalar)
+ : Specify the interval between sending learning packets to
+ each slave. The value range is between ``1`` and ``0x7fffffff``.
+ The default value is ``1``. This option only affects ``balance-tlb``
+ and ``balance-alb`` modes. Using the networkd renderer, this field
+ maps to the LearnPacketIntervalSec= property. If no time suffix is
+ specified, the value will be interpreted as seconds.
+
+ ``primary`` (scalar)
+ : Specify a device to be used as a primary slave, or preferred device
+ to use as a slave for the bond (ie. the preferred device to send
+ data through), whenever it is available. This only affects
+ ``active-backup``, ``balance-alb``, and ``balance-tlb`` modes.
+
+
+## Properties for device type ``tunnels:``
+
+Tunnels allow traffic to pass as if it was between systems on the same local
+network, although systems may be far from each other but reachable via the
+Internet. They may be used to support IPv6 traffic on a network where the ISP
+does not provide the service, or to extend and "connect" separate local
+networks. Please see https://en.wikipedia.org/wiki/Tunneling_protocol for
+more general information about tunnels.
+
+``mode`` (scalar)
+
+: Defines the tunnel mode. Valid options are ``sit``, ``gre``, ``ip6gre``,
+ ``ipip``, ``ipip6``, ``ip6ip6``, ``vti``, ``vti6`` and ``wireguard``.
+ Additionally, the ``networkd`` backend also supports ``gretap`` and
+ ``ip6gretap`` modes.
+ In addition, the ``NetworkManager`` backend supports ``isatap`` tunnels.
+
+``local`` (scalar)
+
+: Defines the address of the local endpoint of the tunnel.
+
+``remote`` (scalar)
+
+: Defines the address of the remote endpoint of the tunnel.
+
+``ttl`` (scalar) – since **0.103**
+
+: Defines the TTL of the tunnel.
+
+``key`` (scalar or mapping)
+
+: Define keys to use for the tunnel. The key can be a number or a dotted
+ quad (an IPv4 address). For ``wireguard`` it can be a base64-encoded
+ private key or (as of ``networkd`` v242+) an absolute path to a file,
+ containing the private key (since 0.100).
+ It is used for identification of IP transforms. This is only required
+ for ``vti`` and ``vti6`` when using the networkd backend, and for
+ ``gre`` or ``ip6gre`` tunnels when using the NetworkManager backend.
+
+ This field may be used as a scalar (meaning that a single key is
+ specified and to be used for input, output and private key), or as a
+ mapping, where you can further specify ``input``/``output``/``private``.
+
+ ``input`` (scalar)
+ : The input key for the tunnel
+
+ ``output`` (scalar)
+ : The output key for the tunnel
+
+ ``private`` (scalar) – since **0.100**
+ : A base64-encoded private key required for Wireguard tunnels. When the
+ ``systemd-networkd`` backend (v242+) is used, this can also be an
+ absolute path to a file containing the private key.
+
+``keys`` (scalar or mapping)
+
+: Alternate name for the ``key`` field. See above.
+
+Examples:
+
+ tunnels:
+ tun0:
+ mode: gre
+ local: ...
+ remote: ...
+ keys:
+ input: 1234
+ output: 5678
+
+ tunnels:
+ tun0:
+ mode: vti6
+ local: ...
+ remote: ...
+ key: 59568549
+
+ tunnels:
+ wg0:
+ mode: wireguard
+ addresses: [...]
+ peers:
+ - keys:
+ public: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=
+ shared: /path/to/shared.key
+ ...
+ key: mNb7OIIXTdgW4khM7OFlzJ+UPs7lmcWHV7xjPgakMkQ=
+
+ tunnels:
+ wg0:
+ mode: wireguard
+ addresses: [...]
+ peers:
+ - keys:
+ public: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=
+ ...
+ keys:
+ private: /path/to/priv.key
+
+
+Wireguard specific keys:
+
+``mark`` (scalar) – since **0.100**
+
+: Firewall mark for outgoing WireGuard packets from this interface,
+ optional.
+
+``port`` (scalar) – since **0.100**
+
+: UDP port to listen at or ``auto``. Optional, defaults to ``auto``.
+
+``peers`` (sequence of mappings) – since **0.100**
+
+: A list of peers, each having keys documented below.
+
+Example:
+
+ tunnels:
+ wg0:
+ mode: wireguard
+ key: /path/to/private.key
+ mark: 42
+ port: 5182
+ peers:
+ - keys:
+ public: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=
+ allowed-ips: [0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]
+ keepalive: 23
+ endpoint: 1.2.3.4:5
+ - keys:
+ public: M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+ shared: /some/shared.key
+ allowed-ips: [10.10.10.20/24]
+ keepalive: 22
+ endpoint: 5.4.3.2:1
+
+``endpoint`` (scalar) – since **0.100**
+
+: Remote endpoint IPv4/IPv6 address or a hostname, followed by a colon
+ and a port number.
+
+``allowed-ips`` (sequence of scalars) – since **0.100**
+
+: A list of IP (v4 or v6) addresses with CIDR masks from which this peer
+ is allowed to send incoming traffic and to which outgoing traffic for
+ this peer is directed. The catch-all 0.0.0.0/0 may be specified for
+ matching all IPv4 addresses, and ::/0 may be specified for matching
+ all IPv6 addresses.
+
+``keepalive`` (scalar) – since **0.100**
+
+: An interval in seconds, between 1 and 65535 inclusive, of how often to
+ send an authenticated empty packet to the peer for the purpose of
+ keeping a stateful firewall or NAT mapping valid persistently. Optional.
+
+``keys`` (mapping) – since **0.100**
+
+: Define keys to use for the Wireguard peers.
+
+ This field can be used as a mapping, where you can further specify the
+ ``public`` and ``shared`` keys.
+
+ ``public`` (scalar) – since **0.100**
+ : A base64-encoded public key, required for Wireguard peers.
+
+ ``shared`` (scalar) – since **0.100**
+ : A base64-encoded preshared key. Optional for Wireguard peers.
+ When the ``systemd-networkd`` backend (v242+) is used, this can
+ also be an absolute path to a file containing the preshared key.
+
+## Properties for device type ``vlans:``
+
+``id`` (scalar)
+
+: VLAN ID, a number between 0 and 4094.
+
+``link`` (scalar)
+
+: netplan ID of the underlying device definition on which this VLAN gets
+ created.
+
+Example:
+
+ ethernets:
+ eno1: {...}
+ vlans:
+ en-intra:
+ id: 1
+ link: eno1
+ dhcp4: yes
+ en-vpn:
+ id: 2
+ link: eno1
+ addresses: ...
+
+## Properties for device type ``nm-devices:``
+
+The ``nm-devices`` device type is for internal use only and should not be used in normal configuration files. It enables a fallback mode for unsupported settings, using the ``passthrough`` mapping.
+
+
+## Backend-specific configuration parameters
+
+In addition to the other fields available to configure interfaces, some
+backends may require to record some of their own parameters in netplan,
+especially if the netplan definitions are generated automatically by the
+consumer of that backend. Currently, this is only used with ``NetworkManager``.
+
+``networkmanager`` (mapping) – since **0.99**
+
+: Keeps the NetworkManager-specific configuration parameters used by the
+ daemon to recognize connections.
+
+ ``name`` (scalar) – since **0.99**
+ : Set the display name for the connection.
+
+ ``uuid`` (scalar) – since **0.99**
+ : Defines the UUID (unique identifier) for this connection, as
+ generated by NetworkManager itself.
+
+ ``stable-id`` (scalar) – since **0.99**
+ : Defines the stable ID (a different form of a connection name) used
+ by NetworkManager in case the name of the connection might otherwise
+ change, such as when sharing connections between users.
+
+ ``device`` (scalar) – since **0.99**
+ : Defines the interface name for which this connection applies.
+
+ ``passthrough`` (mapping) – since **0.102**
+ : Can be used as a fallback mechanism to missing keyfile settings.
+
+## Examples
+Configure an ethernet device with networkd, identified by its name, and enable
+DHCP:
+
+ network:
+ version: 2
+ ethernets:
+ eno1:
+ dhcp4: true
+
+This is an example of a static-configured interface with multiple IPv4 addresses
+and multiple gateways with networkd, with equal route metric levels, and static
+DNS nameservers (Google DNS for this example):
+
+ network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eno1:
+ addresses:
+ - 10.0.0.10/24
+ - 11.0.0.11/24
+ nameservers:
+ addresses:
+ - 8.8.8.8
+ - 8.8.4.4
+ routes:
+ - to: 0.0.0.0/0
+ via: 10.0.0.1
+ metric: 100
+ - to: 0.0.0.0/0
+ via: 11.0.0.1
+ metric: 100
+
+This is a complex example which shows most available features:
+
+ network:
+ version: 2
+ # if specified, can only realistically have that value, as networkd cannot
+ # render wifi/3G.
+ renderer: NetworkManager
+ ethernets:
+ # opaque ID for physical interfaces, only referred to by other stanzas
+ id0:
+ match:
+ macaddress: 00:11:22:33:44:55
+ wakeonlan: true
+ dhcp4: true
+ addresses:
+ - 192.168.14.2/24
+ - 192.168.14.3/24
+ - "2001:1::1/64"
+ nameservers:
+ search: [foo.local, bar.local]
+ addresses: [8.8.8.8]
+ routes:
+ - to: default
+ via: 192.168.14.1
+ - to: default
+ via: "2001:1::2"
+ - to: 0.0.0.0/0
+ via: 11.0.0.1
+ table: 70
+ on-link: true
+ metric: 3
+ routing-policy:
+ - to: 10.0.0.0/8
+ from: 192.168.14.2/24
+ table: 70
+ priority: 100
+ - to: 20.0.0.0/8
+ from: 192.168.14.3/24
+ table: 70
+ priority: 50
+ # only networkd can render on-link routes and routing policies
+ renderer: networkd
+ lom:
+ match:
+ driver: ixgbe
+ # you are responsible for setting tight enough match rules
+ # that only match one device if you use set-name
+ set-name: lom1
+ dhcp6: true
+ switchports:
+ # all cards on second PCI bus unconfigured by
+ # themselves, will be added to br0 below
+ match:
+ name: enp2*
+ mtu: 1280
+ wifis:
+ all-wlans:
+ # useful on a system where you know there is
+ # only ever going to be one device
+ match: {}
+ access-points:
+ "Joe's home":
+ # mode defaults to "infrastructure" (client)
+ password: "s3kr1t"
+ # this creates an AP on wlp1s0 using hostapd
+ # no match rules, thus the ID is the interface name
+ wlp1s0:
+ access-points:
+ "guest":
+ mode: ap
+ # no WPA config implies default of open
+ bridges:
+ # the key name is the name for virtual (created) interfaces
+ # no match: and set-name: allowed
+ br0:
+ # IDs of the components; switchports expands into multiple interfaces
+ interfaces: [wlp1s0, switchports]
+ dhcp4: true
+
+<!--- vim: ft=markdown
+-->
diff --git a/examples/bonding.yaml b/examples/bonding.yaml
new file mode 100644
index 0000000..26adaf8
--- /dev/null
+++ b/examples/bonding.yaml
@@ -0,0 +1,12 @@
+network:
+ version: 2
+ renderer: networkd
+ bonds:
+ bond0:
+ dhcp4: yes
+ interfaces:
+ - enp3s0
+ - enp4s0
+ parameters:
+ mode: active-backup
+ primary: enp3s0
diff --git a/examples/bonding_router.yaml b/examples/bonding_router.yaml
new file mode 100644
index 0000000..f20eadb
--- /dev/null
+++ b/examples/bonding_router.yaml
@@ -0,0 +1,46 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp1s0:
+ dhcp4: no
+ enp2s0:
+ dhcp4: no
+ enp3s0:
+ dhcp4: no
+ optional: true
+ enp4s0:
+ dhcp4: no
+ optional: true
+ enp5s0:
+ dhcp4: no
+ optional: true
+ enp6s0:
+ dhcp4: no
+ optional: true
+ bonds:
+ bond-lan:
+ interfaces: [enp2s0, enp3s0]
+ addresses: [192.168.93.2/24]
+ parameters:
+ mode: 802.3ad
+ mii-monitor-interval: 1
+ bond-wan:
+ interfaces: [enp1s0, enp4s0]
+ addresses: [192.168.1.252/24]
+ nameservers:
+ search: [local]
+ addresses: [8.8.8.8, 8.8.4.4]
+ parameters:
+ mode: active-backup
+ mii-monitor-interval: 1
+ gratuitious-arp: 5
+ routes:
+ - to: default
+ via: 192.168.1.1
+ bond-conntrack:
+ interfaces: [enp5s0, enp6s0]
+ addresses: [192.168.254.2/24]
+ parameters:
+ mode: balance-rr
+ mii-monitor-interval: 1
diff --git a/examples/bridge.yaml b/examples/bridge.yaml
new file mode 100644
index 0000000..dbfcae5
--- /dev/null
+++ b/examples/bridge.yaml
@@ -0,0 +1,11 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp3s0:
+ dhcp4: no
+ bridges:
+ br0:
+ dhcp4: yes
+ interfaces:
+ - enp3s0
diff --git a/examples/bridge_vlan.yaml b/examples/bridge_vlan.yaml
new file mode 100644
index 0000000..b917b84
--- /dev/null
+++ b/examples/bridge_vlan.yaml
@@ -0,0 +1,15 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp0s25:
+ dhcp4: true
+ bridges:
+ br0:
+ addresses: [ 10.3.99.25/24 ]
+ interfaces: [ vlan15 ]
+ vlans:
+ vlan15:
+ accept-ra: no
+ id: 15
+ link: enp0s25
diff --git a/examples/dbus_config_scenario.txt b/examples/dbus_config_scenario.txt
new file mode 100644
index 0000000..d1ec15e
--- /dev/null
+++ b/examples/dbus_config_scenario.txt
@@ -0,0 +1,41 @@
+# Example interaction with Netplan's DBus config API
+
+## Copy the current state from /{etc,run,lib}/netplan/*.yaml by creating a new config object
+$ busctl call io.netplan.Netplan /io/netplan/Netplan io.netplan.Netplan Config
+o "/io/netplan/Netplan/config/ULJIU0"
+
+## Read the merged YAML configuration
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Get
+s "network:\n ethernets:\n eth0:\n dhcp4: true\n renderer: networkd\n version: 2\n"
+
+## Write a new config snippet into 70-snapd.yaml
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Set ss "ethernets.eth0={dhcp4: false, dhcp6: true}" "70-snapd"
+b true
+
+## Check the newly written configuration
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Get
+s "network:\n ethernets:\n eth0:\n dhcp4: false\n dhcp6: true\n renderer: networkd\n version: 2\n"
+
+## Try to apply the current config object's state
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Try u 20
+b true
+
+## Accept the Try() state within the 20 seconds timeout, if not it will be auto-rejected
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Apply
+b true
+
+[SIGNAL] io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 io.netplan.Netplan.Config Changed() is triggered
+[OBJECT] io.netplan.Netplan /io/netplan/Netplan/config/ULJIU0 is removed from the bus
+
+## Create a new config object and get the merged YAML config
+$ busctl call io.netplan.Netplan /io/netplan/Netplan io.netplan.Netplan Config
+o "/io/netplan/Netplan/config/KC0IU0
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/KC0IU0 io.netplan.Netplan.Config Get
+s "network:\n ethernets:\n eth0:\n dhcp4: false\n dhcp6: true\n renderer: networkd\n version: 2\n"
+
+## Reject that config object again
+$ busctl call io.netplan.Netplan /io/netplan/Netplan/config/KC0IU0 io.netplan.Netplan.Config Cancel
+b true
+
+[SIGNAL] io.netplan.Netplan /io/netplan/Netplan/config/KC0IU0 io.netplan.Netplan.Config Changed() is triggered
+[OBJECT] io.netplan.Netplan /io/netplan/Netplan/config/KC0IU0 is removed from the bus
diff --git a/examples/dhcp.yaml b/examples/dhcp.yaml
new file mode 100644
index 0000000..f7f85ef
--- /dev/null
+++ b/examples/dhcp.yaml
@@ -0,0 +1,6 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp3s0:
+ dhcp4: true
diff --git a/examples/dhcp_wired8021x.yaml b/examples/dhcp_wired8021x.yaml
new file mode 100644
index 0000000..9f401dd
--- /dev/null
+++ b/examples/dhcp_wired8021x.yaml
@@ -0,0 +1,11 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp3s0:
+ dhcp4: true
+ auth:
+ key-management: 802.1x
+ method: ttls
+ identity: fluffy@cisco.com
+ password: hash:83...11
diff --git a/examples/direct_connect_gateway.yaml b/examples/direct_connect_gateway.yaml
new file mode 100644
index 0000000..6eac9cd
--- /dev/null
+++ b/examples/direct_connect_gateway.yaml
@@ -0,0 +1,9 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ addresses: [ "10.10.10.1/24" ]
+ routes:
+ - to: 0.0.0.0/0
+ via: 9.9.9.9
+ on-link: true
diff --git a/examples/direct_connect_gateway_ipv6.yaml b/examples/direct_connect_gateway_ipv6.yaml
new file mode 100644
index 0000000..3f821d3
--- /dev/null
+++ b/examples/direct_connect_gateway_ipv6.yaml
@@ -0,0 +1,11 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ addresses: [ "2001:cafe:face:beef::dead:dead/64" ]
+ routes:
+ - to: "2001:cafe:face::1/128"
+ scope: link
+ - to: "::/0"
+ via: "2001:cafe:face::1"
+ on-link: true
diff --git a/examples/ipv6_tunnel.yaml b/examples/ipv6_tunnel.yaml
new file mode 100644
index 0000000..222691b
--- /dev/null
+++ b/examples/ipv6_tunnel.yaml
@@ -0,0 +1,20 @@
+network:
+ version: 2
+ ethernets:
+ eth0:
+ addresses:
+ - 1.1.1.1/24
+ - "2001:cafe:face::1/64"
+ routes:
+ - to: default
+ via: 1.1.1.254
+ tunnels:
+ he-ipv6:
+ mode: sit
+ remote: 2.2.2.2
+ local: 1.1.1.1
+ addresses:
+ - "2001:dead:beef::2/64"
+ routes:
+ - to: default
+ via: "2001:dead:beef::1"
diff --git a/examples/loopback_interface.yaml b/examples/loopback_interface.yaml
new file mode 100644
index 0000000..734f091
--- /dev/null
+++ b/examples/loopback_interface.yaml
@@ -0,0 +1,8 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ lo:
+ match:
+ name: lo
+ addresses: [ 7.7.7.7/32 ]
diff --git a/examples/modem.yaml b/examples/modem.yaml
new file mode 100644
index 0000000..043d74a
--- /dev/null
+++ b/examples/modem.yaml
@@ -0,0 +1,15 @@
+network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ cdc-wdm1:
+ mtu: 1600
+ apn: ISP.CINGULAR
+ username: ISP@CINGULARGPRS.COM
+ password: CINGULAR1
+ number: "*99#"
+ network-id: 24005
+ device-id: da812de91eec16620b06cd0ca5cbc7ea25245222
+ pin: 2345
+ sim-id: 89148000000060671234
+ sim-operator-id: 310260
diff --git a/examples/network_manager.yaml b/examples/network_manager.yaml
new file mode 100644
index 0000000..b654768
--- /dev/null
+++ b/examples/network_manager.yaml
@@ -0,0 +1,3 @@
+network:
+ version: 2
+ renderer: NetworkManager
diff --git a/examples/openvswitch.yaml b/examples/openvswitch.yaml
new file mode 100644
index 0000000..678e155
--- /dev/null
+++ b/examples/openvswitch.yaml
@@ -0,0 +1,45 @@
+network:
+ version: 2
+ openvswitch:
+ protocols: [OpenFlow13, OpenFlow14, OpenFlow15]
+ ports:
+ - [patch0-1, patch1-0]
+ ssl:
+ ca-cert: /some/ca-cert.pem
+ certificate: /another/cert.pem
+ private-key: /private/key.pem
+ external-ids:
+ somekey: somevalue
+ other-config:
+ key: value
+ ethernets:
+ eth0:
+ addresses: [10.5.32.26/20]
+ openvswitch:
+ external-ids:
+ iface-id: mylocaliface
+ other-config:
+ disable-in-band: false
+ eth1: {}
+ bonds:
+ bond0:
+ interfaces: [patch1-0, eth1]
+ openvswitch:
+ lacp: passive
+ parameters:
+ mode: balance-tcp
+ bridges:
+ ovs0:
+ addresses: [10.5.48.11/20]
+ interfaces: [patch0-1, eth0, bond0]
+ openvswitch:
+ protocols: [OpenFlow10, OpenFlow11, OpenFlow12]
+ controller:
+ addresses: [unix:/var/run/openvswitch/ovs0.mgmt]
+ connection-mode: out-of-band
+ fail-mode: secure
+ mcast-snooping: true
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true
diff --git a/examples/route_metric.yaml b/examples/route_metric.yaml
new file mode 100644
index 0000000..20bf48c
--- /dev/null
+++ b/examples/route_metric.yaml
@@ -0,0 +1,11 @@
+network:
+ version: 2
+ ethernets:
+ enred:
+ dhcp4: yes
+ dhcp4-overrides:
+ route-metric: 100
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ route-metric: 200
diff --git a/examples/source_routing.yaml b/examples/source_routing.yaml
new file mode 100644
index 0000000..f56ae6b
--- /dev/null
+++ b/examples/source_routing.yaml
@@ -0,0 +1,28 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ ens3:
+ addresses:
+ - 192.168.3.30/24
+ dhcp4: no
+ routes:
+ - to: 192.168.3.0/24
+ via: 192.168.3.1
+ table: 101
+ routing-policy:
+ - from: 192.168.3.0/24
+ table: 101
+ ens5:
+ addresses:
+ - 192.168.5.24/24
+ dhcp4: no
+ routes:
+ - to: default
+ via: 192.168.5.1
+ - to: 192.168.5.0/24
+ via: 192.168.5.1
+ table: 102
+ routing-policy:
+ - from: 192.168.5.0/24
+ table: 102
diff --git a/examples/sriov.yaml b/examples/sriov.yaml
new file mode 100644
index 0000000..67de132
--- /dev/null
+++ b/examples/sriov.yaml
@@ -0,0 +1,14 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eno1:
+ mtu: 9000
+ enp1s16f1:
+ link: eno1
+ addresses : [ "10.15.98.25/24" ]
+ vf1:
+ match:
+ name: enp1s16f[2-3]
+ link: eno1
+ addresses : [ "10.15.99.25/24" ]
diff --git a/examples/sriov_vlan.yaml b/examples/sriov_vlan.yaml
new file mode 100644
index 0000000..2c664d7
--- /dev/null
+++ b/examples/sriov_vlan.yaml
@@ -0,0 +1,18 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eno1:
+ mtu: 9000
+ enp1s16f1:
+ link: eno1
+ addresses : [ "10.15.98.25/24" ]
+ vlans:
+ vlan1:
+ id: 15
+ link: enp1s16f1
+ addresses: [ "10.3.99.5/24" ]
+ vlan2_hw:
+ id: 10
+ link: enp1s16f1
+ renderer: sriov
diff --git a/examples/static.yaml b/examples/static.yaml
new file mode 100644
index 0000000..e4c0678
--- /dev/null
+++ b/examples/static.yaml
@@ -0,0 +1,13 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp3s0:
+ addresses:
+ - 10.10.10.2/24
+ nameservers:
+ search: [mydomain, otherdomain]
+ addresses: [10.10.10.1, 1.1.1.1]
+ routes:
+ - to: default
+ via: 10.10.10.1
diff --git a/examples/static_multiaddress.yaml b/examples/static_multiaddress.yaml
new file mode 100644
index 0000000..de2be06
--- /dev/null
+++ b/examples/static_multiaddress.yaml
@@ -0,0 +1,11 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp3s0:
+ addresses:
+ - 10.100.1.38/24
+ - 10.100.1.39/24
+ routes:
+ - to: default
+ via: 10.100.1.1
diff --git a/examples/static_singlenic_multiip_multigateway.yaml b/examples/static_singlenic_multiip_multigateway.yaml
new file mode 100644
index 0000000..c9b8de4
--- /dev/null
+++ b/examples/static_singlenic_multiip_multigateway.yaml
@@ -0,0 +1,19 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eno1:
+ addresses:
+ - 10.0.0.10/24
+ - 11.0.0.11/24
+ nameservers:
+ addresses:
+ - 8.8.8.8
+ - 8.8.4.4
+ routes:
+ - to: 0.0.0.0/0
+ via: 10.0.0.1
+ metric: 100
+ - to: 0.0.0.0/0
+ via: 11.0.0.1
+ metric: 100
diff --git a/examples/vlan.yaml b/examples/vlan.yaml
new file mode 100644
index 0000000..24af0b2
--- /dev/null
+++ b/examples/vlan.yaml
@@ -0,0 +1,27 @@
+network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ mainif:
+ match:
+ macaddress: "de:ad:be:ef:ca:fe"
+ set-name: mainif
+ addresses: [ "10.3.0.5/23" ]
+ nameservers:
+ addresses: [ "8.8.8.8", "8.8.4.4" ]
+ search: [ example.com ]
+ routes:
+ - to: default
+ via: 10.3.0.1
+ vlans:
+ vlan15:
+ id: 15
+ link: mainif
+ addresses: [ "10.3.99.5/24" ]
+ vlan10:
+ id: 10
+ link: mainif
+ addresses: [ "10.3.98.5/24" ]
+ nameservers:
+ addresses: [ "127.0.0.1" ]
+ search: [ domain1.example.com, domain2.example.com ]
diff --git a/examples/windows_dhcp_server.yaml b/examples/windows_dhcp_server.yaml
new file mode 100644
index 0000000..b4a178d
--- /dev/null
+++ b/examples/windows_dhcp_server.yaml
@@ -0,0 +1,6 @@
+network:
+ version: 2
+ ethernets:
+ enp3s0:
+ dhcp4: yes
+ dhcp-identifier: mac
diff --git a/examples/wireguard.yaml b/examples/wireguard.yaml
new file mode 100644
index 0000000..6b745d1
--- /dev/null
+++ b/examples/wireguard.yaml
@@ -0,0 +1,31 @@
+network:
+ version: 2
+ tunnels:
+ wg0: #server
+ mode: wireguard
+ addresses: [10.10.10.20/24]
+ key: 4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=
+ mark: 42
+ port: 51820
+ peers:
+ - keys:
+ public: M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+ shared: 7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=
+ allowed-ips: [20.20.20.10/24]
+ routes:
+ - to: default
+ via: 10.10.10.21
+ wg1: #client
+ mode: wireguard
+ addresses: [20.20.20.10/24]
+ key: KPt9BzQjejRerEv8RMaFlpsD675gNexELOQRXt/AcH0=
+ peers:
+ - endpoint: 10.10.10.20:51820
+ allowed-ips: [0.0.0.0/0]
+ keys:
+ public: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=
+ shared: 7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=
+ keepalive: 21
+ routes:
+ - to: default
+ via: 20.20.20.11
diff --git a/examples/wireless.yaml b/examples/wireless.yaml
new file mode 100644
index 0000000..a7d82ad
--- /dev/null
+++ b/examples/wireless.yaml
@@ -0,0 +1,16 @@
+network:
+ version: 2
+ renderer: networkd
+ wifis:
+ wlp2s0b1:
+ dhcp4: no
+ dhcp6: no
+ addresses: [192.168.0.21/24]
+ nameservers:
+ addresses: [192.168.0.1, 8.8.8.8]
+ access-points:
+ "network_ssid_name":
+ password: "**********"
+ routes:
+ - to: default
+ via: 192.168.0.1
diff --git a/examples/wpa_enterprise.yaml b/examples/wpa_enterprise.yaml
new file mode 100644
index 0000000..0602e21
--- /dev/null
+++ b/examples/wpa_enterprise.yaml
@@ -0,0 +1,26 @@
+network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ auth:
+ key-management: eap
+ method: ttls
+ anonymous-identity: "@internal.example.com"
+ identity: "joe@internal.example.com"
+ password: "v3ryS3kr1t"
+ university:
+ auth:
+ key-management: eap
+ method: tls
+ anonymous-identity: "@cust.example.com"
+ identity: "cert-joe@cust.example.com"
+ ca-certificate: /etc/ssl/cust-cacrt.pem
+ client-certificate: /etc/ssl/cust-crt.pem
+ client-key: /etc/ssl/cust-key.pem
+ client-key-password: "d3cryptPr1v4t3K3y"
+ open-network:
+ auth:
+ key-management: none
+ dhcp4: yes
diff --git a/netplan.completions b/netplan.completions
new file mode 100644
index 0000000..0561128
--- /dev/null
+++ b/netplan.completions
@@ -0,0 +1,45 @@
+# netplan(1) completion -*- shell-script -*-
+
+_compgen_help()
+{
+ local options=$1
+ shift
+
+ compgen -W '${options} help' $@
+}
+
+_netplan()
+{
+ local cur prev words cword
+ _init_completion || return
+
+ case $prev in
+ netplan)
+ COMPREPLY=( $( _compgen_help 'apply generate ifupdown-migrate ip' "$cur" ) )
+ return
+ ;;
+ apply|generate)
+ return
+ ;;
+ ifupdown-migrate)
+ return
+ ;;
+ ip)
+ COMPREPLY=( $( _compgen_help 'leases' -- "$cur" ) )
+ return
+ ;;
+ leases)
+ if [ "${COMP_WORDS[COMP_CWORD-2]}" = "ip" ]; then
+ _available_interfaces -a
+ fi
+ return
+ ;;
+ esac
+
+ if [[ $cur == -* ]]; then
+ COMPREPLY=( $( compgen -W '$( _parse_help "$1" )' -- "$cur" ) )
+ fi
+} &&
+complete -F _netplan netplan
+
+# ex: ts=4 sw=4 et filetype=sh
diff --git a/netplan/__init__.py b/netplan/__init__.py
new file mode 100644
index 0000000..6e4e922
--- /dev/null
+++ b/netplan/__init__.py
@@ -0,0 +1,20 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from netplan.cli.core import Netplan
+
+__all__ = [Netplan]
diff --git a/netplan/cli/__init__.py b/netplan/cli/__init__.py
new file mode 100644
index 0000000..7f084b2
--- /dev/null
+++ b/netplan/cli/__init__.py
@@ -0,0 +1,16 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/netplan/cli/commands/__init__.py b/netplan/cli/commands/__init__.py
new file mode 100644
index 0000000..0a5a229
--- /dev/null
+++ b/netplan/cli/commands/__init__.py
@@ -0,0 +1,36 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from netplan.cli.commands.apply import NetplanApply
+from netplan.cli.commands.generate import NetplanGenerate
+from netplan.cli.commands.ip import NetplanIp
+from netplan.cli.commands.migrate import NetplanMigrate
+from netplan.cli.commands.try_command import NetplanTry
+from netplan.cli.commands.info import NetplanInfo
+from netplan.cli.commands.set import NetplanSet
+from netplan.cli.commands.get import NetplanGet
+
+__all__ = [
+ 'NetplanApply',
+ 'NetplanGenerate',
+ 'NetplanIp',
+ 'NetplanMigrate',
+ 'NetplanTry',
+ 'NetplanInfo',
+ 'NetplanSet',
+ 'NetplanGet',
+]
diff --git a/netplan/cli/commands/apply.py b/netplan/cli/commands/apply.py
new file mode 100644
index 0000000..b1d4b9c
--- /dev/null
+++ b/netplan/cli/commands/apply.py
@@ -0,0 +1,334 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018-2020 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+# Author: Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan apply command line'''
+
+import logging
+import os
+import sys
+import glob
+import subprocess
+import shutil
+import netifaces
+
+import netplan.cli.utils as utils
+from netplan.configmanager import ConfigManager, ConfigurationError
+from netplan.cli.sriov import apply_sriov_config
+from netplan.cli.ovs import apply_ovs_cleanup
+
+
+OVS_CLEANUP_SERVICE = 'netplan-ovs-cleanup.service'
+
+
+class NetplanApply(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='apply',
+ description='Apply current netplan config to running system',
+ leaf=True)
+ self.sriov_only = False
+ self.only_ovs_cleanup = False
+
+ def run(self): # pragma: nocover (covered in autopkgtest)
+ self.parser.add_argument('--sriov-only', action='store_true',
+ help='Only apply SR-IOV related configuration and exit')
+ self.parser.add_argument('--only-ovs-cleanup', action='store_true',
+ help='Only clean up old OpenVSwitch interfaces and exit')
+
+ self.func = self.command_apply
+
+ self.parse_args()
+ self.run_command()
+
+ def command_apply(self, run_generate=True, sync=False, exit_on_error=True): # pragma: nocover (covered in autopkgtest)
+ config_manager = ConfigManager()
+
+ # For certain use-cases, we might want to only apply specific configuration.
+ # If we only need SR-IOV configuration, do that and exit early.
+ if self.sriov_only:
+ NetplanApply.process_sriov_config(config_manager, exit_on_error)
+ return
+ # If we only need OpenVSwitch cleanup, do that and exit early.
+ elif self.only_ovs_cleanup:
+ NetplanApply.process_ovs_cleanup(config_manager, False, False, exit_on_error)
+ return
+
+ # if we are inside a snap, then call dbus to run netplan apply instead
+ if "SNAP" in os.environ:
+ # TODO: maybe check if we are inside a classic snap and don't do
+ # this if we are in a classic snap?
+ busctl = shutil.which("busctl")
+ if busctl is None:
+ raise RuntimeError("missing busctl utility")
+ # XXX: DO NOT TOUCH or change this API call, it is used by snapd to communicate
+ # using core20 netplan binary/client/CLI on core18 base systems. Any change
+ # must be agreed upon with the snapd team, so we don't break support for
+ # base systems running older netplan versions.
+ # https://github.com/snapcore/snapd/pull/5915
+ res = subprocess.call([busctl, "call", "--quiet", "--system",
+ "io.netplan.Netplan", # the service
+ "/io/netplan/Netplan", # the object
+ "io.netplan.Netplan", # the interface
+ "Apply", # the method
+ ])
+
+ if res != 0:
+ if exit_on_error:
+ sys.exit(res)
+ elif res == 130:
+ raise PermissionError(
+ "failed to communicate with dbus service")
+ else:
+ raise RuntimeError(
+ "failed to communicate with dbus service: error %s" % res)
+ else:
+ return
+
+ ovs_cleanup_service = '/run/systemd/system/netplan-ovs-cleanup.service'
+ old_files_networkd = bool(glob.glob('/run/systemd/network/*netplan-*'))
+ old_ovs_glob = glob.glob('/run/systemd/system/netplan-ovs-*')
+ # Ignore netplan-ovs-cleanup.service, as it can always be there
+ if ovs_cleanup_service in old_ovs_glob:
+ old_ovs_glob.remove(ovs_cleanup_service)
+ old_files_ovs = bool(old_ovs_glob)
+ old_nm_glob = glob.glob('/run/NetworkManager/system-connections/netplan-*')
+ nm_ifaces = utils.nm_interfaces(old_nm_glob, netifaces.interfaces())
+ old_files_nm = bool(old_nm_glob)
+
+ generator_call = []
+ generate_out = None
+ if 'NETPLAN_PROFILE' in os.environ:
+ generator_call.extend(['valgrind', '--leak-check=full'])
+ generate_out = subprocess.STDOUT
+
+ generator_call.append(utils.get_generator_path())
+ if run_generate and subprocess.call(generator_call, stderr=generate_out) != 0:
+ if exit_on_error:
+ sys.exit(os.EX_CONFIG)
+ else:
+ raise ConfigurationError("the configuration could not be generated")
+
+ devices = netifaces.interfaces()
+
+ # Re-start service when
+ # 1. We have configuration files for it
+ # 2. Previously we had config files for it but not anymore
+ # Ideally we should compare the content of the *netplan-* files before and
+ # after generation to minimize the number of re-starts, but the conditions
+ # above works too.
+ restart_networkd = bool(glob.glob('/run/systemd/network/*netplan-*'))
+ if not restart_networkd and old_files_networkd:
+ restart_networkd = True
+ restart_ovs_glob = glob.glob('/run/systemd/system/netplan-ovs-*')
+ # Ignore netplan-ovs-cleanup.service, as it can always be there
+ if ovs_cleanup_service in restart_ovs_glob:
+ restart_ovs_glob.remove(ovs_cleanup_service)
+ restart_ovs = bool(restart_ovs_glob)
+ if not restart_ovs and old_files_ovs:
+ # OVS is managed via systemd units
+ restart_networkd = True
+
+ restart_nm_glob = glob.glob('/run/NetworkManager/system-connections/netplan-*')
+ nm_ifaces.update(utils.nm_interfaces(restart_nm_glob, devices))
+ restart_nm = bool(restart_nm_glob)
+ if not restart_nm and old_files_nm:
+ restart_nm = True
+
+ # stop backends
+ if restart_networkd:
+ logging.debug('netplan generated networkd configuration changed, reloading networkd')
+ # Running 'systemctl daemon-reload' will re-run the netplan systemd generator,
+ # so let's make sure we only run it iff we're willing to run 'netplan generate'
+ if run_generate:
+ utils.systemctl_daemon_reload()
+ # Clean up any old netplan related OVS ports/bonds/bridges, if applicable
+ NetplanApply.process_ovs_cleanup(config_manager, old_files_ovs, restart_ovs, exit_on_error)
+ wpa_services = ['netplan-wpa-*.service']
+ # Historically (up to v0.98) we had netplan-wpa@*.service files, in case of an
+ # upgraded system, we need to make sure to stop those.
+ if utils.systemctl_is_active('netplan-wpa@*.service'):
+ wpa_services.insert(0, 'netplan-wpa@*.service')
+ utils.systemctl('stop', wpa_services, sync=sync)
+ else:
+ logging.debug('no netplan generated networkd configuration exists')
+
+ if restart_nm:
+ logging.debug('netplan generated NM configuration changed, restarting NM')
+ if utils.nm_running():
+ # restarting NM does not cause new config to be applied, need to shut down devices first
+ for device in devices:
+ if device not in nm_ifaces:
+ continue # do not touch this interface
+ # ignore failures here -- some/many devices might not be managed by NM
+ try:
+ utils.nmcli(['device', 'disconnect', device])
+ except subprocess.CalledProcessError:
+ pass
+
+ utils.systemctl_network_manager('stop', sync=sync)
+ else:
+ logging.debug('no netplan generated NM configuration exists')
+
+ # Refresh devices now; restarting a backend might have made something appear.
+ devices = netifaces.interfaces()
+
+ # evaluate config for extra steps we need to take (like renaming)
+ # for now, only applies to non-virtual (real) devices.
+ config_manager.parse()
+ changes = NetplanApply.process_link_changes(devices, config_manager)
+
+ # if the interface is up, we can still apply some .link file changes
+ # but we cannot apply the interface rename via udev, as it won't touch
+ # the interface name, if it was already renamed once (e.g. during boot),
+ # because of the NamePolicy=keep default:
+ # https://www.freedesktop.org/software/systemd/man/systemd.net-naming-scheme.html
+ devices = netifaces.interfaces()
+ for device in devices:
+ logging.debug('netplan triggering .link rules for %s', device)
+ try:
+ subprocess.check_call(['udevadm', 'test-builtin',
+ 'net_setup_link',
+ '/sys/class/net/' + device],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+ except subprocess.CalledProcessError:
+ logging.debug('Ignoring device without syspath: %s', device)
+
+ # apply some more changes manually
+ for iface, settings in changes.items():
+ # rename non-critical network interfaces
+ if settings.get('name'):
+ # bring down the interface, using its current (matched) interface name
+ subprocess.check_call(['ip', 'link', 'set', 'dev', iface, 'down'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+ # rename the interface to the name given via 'set-name'
+ subprocess.check_call(['ip', 'link', 'set',
+ 'dev', iface,
+ 'name', settings.get('name')],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+
+ subprocess.check_call(['udevadm', 'settle'])
+
+ # apply any SR-IOV related changes, if applicable
+ NetplanApply.process_sriov_config(config_manager, exit_on_error)
+
+ # (re)start backends
+ if restart_networkd:
+ netplan_wpa = [os.path.basename(f) for f in glob.glob('/run/systemd/system/*.wants/netplan-wpa-*.service')]
+ # exclude the special 'netplan-ovs-cleanup.service' unit
+ netplan_ovs = [os.path.basename(f) for f in glob.glob('/run/systemd/system/*.wants/netplan-ovs-*.service')
+ if not f.endswith('/' + OVS_CLEANUP_SERVICE)]
+ # Run 'systemctl start' command synchronously, to avoid race conditions
+ # with 'oneshot' systemd service units, e.g. netplan-ovs-*.service.
+ utils.networkctl_reconfigure(utils.networkd_interfaces())
+ # 1st: execute OVS cleanup, to avoid races while applying OVS config
+ utils.systemctl('start', [OVS_CLEANUP_SERVICE], sync=True)
+ # 2nd: start all other services
+ utils.systemctl('start', netplan_wpa + netplan_ovs, sync=True)
+ if restart_nm:
+ # Flush all IP addresses of NM managed interfaces, to avoid NM creating
+ # new, non netplan-* connection profiles, using the existing IPs.
+ for iface in utils.nm_interfaces(restart_nm_glob, devices):
+ utils.ip_addr_flush(iface)
+ utils.systemctl_network_manager('start', sync=sync)
+
+ @staticmethod
+ def is_composite_member(composites, phy):
+ """
+ Is this physical interface a member of a 'composite' virtual
+ interface? (bond, bridge)
+ """
+ for composite in composites:
+ for _, settings in composite.items():
+ if not type(settings) is dict:
+ continue
+ members = settings.get('interfaces', [])
+ for iface in members:
+ if iface == phy:
+ return True
+
+ return False
+
+ @staticmethod
+ def process_link_changes(interfaces, config_manager): # pragma: nocover (covered in autopkgtest)
+ """
+ Go through the pending changes and pick what needs special handling.
+ Only applies to non-critical interfaces which can be safely updated.
+ """
+
+ changes = {}
+ phys = dict(config_manager.physical_interfaces)
+ composite_interfaces = [config_manager.bridges, config_manager.bonds]
+
+ # Find physical interfaces which need a rename
+ # But do not rename virtual interfaces
+ for phy, settings in phys.items():
+ if not settings or not isinstance(settings, dict):
+ continue # Skip special values, like "renderer: ..."
+ newname = settings.get('set-name')
+ if not newname:
+ continue # Skip if no new name needs to be set
+ match = settings.get('match')
+ if not match:
+ continue # Skip if no match for current name is given
+ if NetplanApply.is_composite_member(composite_interfaces, phy):
+ logging.debug('Skipping composite member {}'.format(phy))
+ # do not rename members of virtual devices. MAC addresses
+ # may be the same for all interface members.
+ continue
+ # Find current name of the interface, according to match conditions and globs (name, mac, driver)
+ current_iface_name = utils.find_matching_iface(interfaces, match)
+ if not current_iface_name:
+ logging.warning('Cannot find unique matching interface for {}: {}'.format(phy, match))
+ continue
+ if current_iface_name == newname:
+ # Skip interface if it already has the correct name
+ logging.debug('Skipping correctly named interface: {}'.format(newname))
+ continue
+ if settings.get('critical', False):
+ # Skip interfaces defined as critical, as we should not take them down in order to rename
+ logging.warning('Cannot rename {} ({} -> {}) at runtime (needs reboot), due to being critical'
+ .format(phy, current_iface_name, newname))
+ continue
+
+ # record the interface rename change
+ changes[current_iface_name] = {'name': newname}
+
+ logging.debug('Link changes: {}'.format(changes))
+ return changes
+
+ @staticmethod
+ def process_sriov_config(config_manager, exit_on_error=True): # pragma: nocover (covered in autopkgtest)
+ try:
+ apply_sriov_config(config_manager)
+ except (ConfigurationError, RuntimeError) as e:
+ logging.error(str(e))
+ if exit_on_error:
+ sys.exit(1)
+
+ @staticmethod
+ def process_ovs_cleanup(config_manager, ovs_old, ovs_current, exit_on_error=True): # pragma: nocover (autopkgtest)
+ try:
+ apply_ovs_cleanup(config_manager, ovs_old, ovs_current)
+ except (OSError, RuntimeError) as e:
+ logging.error(str(e))
+ if exit_on_error:
+ sys.exit(1)
diff --git a/netplan/cli/commands/generate.py b/netplan/cli/commands/generate.py
new file mode 100644
index 0000000..4900d8f
--- /dev/null
+++ b/netplan/cli/commands/generate.py
@@ -0,0 +1,85 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan generate command line'''
+
+import logging
+import os
+import sys
+import subprocess
+import shutil
+
+import netplan.cli.utils as utils
+
+
+class NetplanGenerate(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='generate',
+ description='Generate backend specific configuration files'
+ ' from /etc/netplan/*.yaml',
+ leaf=True)
+
+ def run(self):
+ self.parser.add_argument('--root-dir',
+ help='Search for and generate configuration files in this root directory instead of /')
+ self.parser.add_argument('--mapping',
+ help='Display the netplan device ID/backend/interface name mapping and exit.')
+
+ self.func = self.command_generate
+
+ self.parse_args()
+ self.run_command()
+
+ def command_generate(self):
+ # if we are inside a snap, then call dbus to run netplan apply instead
+ if "SNAP" in os.environ:
+ # TODO: maybe check if we are inside a classic snap and don't do
+ # this if we are in a classic snap?
+ busctl = shutil.which("busctl")
+ if busctl is None:
+ raise RuntimeError("missing busctl utility") # pragma: nocover
+ # XXX: DO NOT TOUCH or change this API call, it is used by snapd to communicate
+ # using core20 netplan binary/client/CLI on core18 base systems. Any change
+ # must be agreed upon with the snapd team, so we don't break support for
+ # base systems running older netplan versions.
+ # https://github.com/snapcore/snapd/pull/10212
+ res = subprocess.call([busctl, "call", "--quiet", "--system",
+ "io.netplan.Netplan", # the service
+ "/io/netplan/Netplan", # the object
+ "io.netplan.Netplan", # the interface
+ "Generate", # the method
+ ])
+
+ if res != 0:
+ if res == 130:
+ raise PermissionError(
+ "failed to communicate with dbus service")
+ else:
+ raise RuntimeError(
+ "failed to communicate with dbus service: error %s" % res)
+ else:
+ return
+
+ argv = [utils.get_generator_path()]
+ if self.root_dir:
+ argv += ['--root-dir', self.root_dir]
+ if self.mapping:
+ argv += ['--mapping', self.mapping]
+ logging.debug('command generate: running %s', argv)
+ # FIXME: os.execv(argv[0], argv) would be better but fails coverage
+ sys.exit(subprocess.call(argv))
diff --git a/netplan/cli/commands/get.py b/netplan/cli/commands/get.py
new file mode 100644
index 0000000..85fd2f6
--- /dev/null
+++ b/netplan/cli/commands/get.py
@@ -0,0 +1,67 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan get command line'''
+
+import yaml
+import re
+
+import netplan.cli.utils as utils
+from netplan.configmanager import ConfigManager
+
+
+class NetplanGet(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='get',
+ description='Get a setting by specifying a nested key like "ethernets.eth0.addresses", or "all"',
+ leaf=True)
+
+ def run(self):
+ self.parser.add_argument('key', type=str, nargs='?', default='all', help='The nested key in dotted format')
+ self.parser.add_argument('--root-dir', default='/',
+ help='Read configuration files from this root directory instead of /')
+
+ self.func = self.command_get
+
+ self.parse_args()
+ self.run_command()
+
+ def command_get(self):
+ config_manager = ConfigManager(prefix=self.root_dir)
+ config_manager.parse()
+ tree = config_manager.tree
+
+ if self.key != 'all':
+ # The 'network.' prefix is optional for netsted keys, its always assumed to be there
+ if not self.key.startswith('network.') and not self.key == 'network':
+ self.key = 'network.' + self.key
+ # Split at '.' but not at '\.' via negative lookbehind expression
+ for k in re.split(r'(?<!\\)\.', self.key):
+ k = k.replace('\\.', '.') # Unescape interface-ids, containing dots
+ if k in tree.keys():
+ tree = tree[k]
+ if not isinstance(tree, dict):
+ break
+ else:
+ tree = None
+ break
+
+ out = yaml.dump(tree, default_flow_style=False)[:-1] # Remove trailing '\n'
+ if not isinstance(tree, dict) and not isinstance(tree, list):
+ out = out[:-4] # Remove yaml.dump's '\n...' on primitive values
+ print(out)
diff --git a/netplan/cli/commands/info.py b/netplan/cli/commands/info.py
new file mode 100644
index 0000000..7d1e8f3
--- /dev/null
+++ b/netplan/cli/commands/info.py
@@ -0,0 +1,65 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2019 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan info command line'''
+
+import netplan.cli.utils as utils
+import netplan._features
+
+
+class NetplanInfo(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='info',
+ description='Show available features',
+ leaf=True)
+
+ def run(self): # pragma: nocover (covered in autopkgtest)
+ format_group = self.parser.add_mutually_exclusive_group(required=False)
+ format_group.add_argument('--json', dest='version_format', action='store_const',
+ const='json',
+ help='Output version and features in JSON format')
+ format_group.add_argument('--yaml', dest='version_format', action='store_const',
+ const='yaml',
+ help='Output version and features in YAML format')
+
+ self.func = self.command_info
+ self.parse_args()
+ self.run_command()
+
+ def command_info(self):
+
+ netplan_version = {
+ 'netplan.io': {
+ 'website': 'https://netplan.io/',
+ }
+ }
+
+ flags = netplan._features.NETPLAN_FEATURE_FLAGS
+ netplan_version['netplan.io'].update({'features': flags})
+
+ # Default to output in YAML format.
+ if self.version_format is None:
+ self.version_format = 'yaml'
+
+ if self.version_format == 'json':
+ import json
+ print(json.dumps(netplan_version, indent=2))
+
+ elif self.version_format == 'yaml':
+ import yaml
+ print(yaml.dump(netplan_version, indent=2, default_flow_style=False))
diff --git a/netplan/cli/commands/ip.py b/netplan/cli/commands/ip.py
new file mode 100644
index 0000000..b7a7f29
--- /dev/null
+++ b/netplan/cli/commands/ip.py
@@ -0,0 +1,153 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan ip command line'''
+
+import logging
+import os
+import sys
+import subprocess
+from subprocess import CalledProcessError
+
+import netplan.cli.utils as utils
+
+lease_path = {
+ 'networkd': {
+ 'pattern': 'run/systemd/netif/leases/{lease_id}',
+ 'method': 'ifindex',
+ },
+ 'NetworkManager': {
+ 'pattern': 'var/lib/NetworkManager/dhclient-{lease_id}-{interface}.lease',
+ 'method': 'nm_connection',
+ },
+}
+
+
+class NetplanIp(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='ip',
+ description='Retrieve IP information from the system',
+ leaf=False)
+
+ def run(self):
+ self.command_leases = NetplanIpLeases()
+
+ # subcommand: leases
+ p_ip_leases = self.subparsers.add_parser('leases',
+ help='Display IP leases',
+ add_help=False)
+ p_ip_leases.set_defaults(func=self.command_leases.run, commandclass=self.command_leases)
+
+ self.parse_args()
+ self.run_command()
+
+
+class NetplanIpLeases(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='ip leases',
+ description='Display IP leases',
+ leaf=True)
+
+ def run(self):
+ self.parser.add_argument('interface',
+ help='Interface for which to display IP lease settings.')
+ self.parser.add_argument('--root-dir',
+ help='Search for configuration files in this root directory instead of /')
+
+ self.func = self.command_ip_leases
+
+ self.parse_args()
+ self.run_command()
+
+ def command_ip_leases(self):
+
+ if self.interface == 'help': # pragma: nocover (covered in autopkgtest)
+ self.print_usage()
+
+ def find_lease_file(mapping):
+ def lease_method_ifindex():
+ ifindex_f = os.path.join('/sys/class/net', self.interface, 'ifindex')
+ try:
+ with open(ifindex_f) as f:
+ return f.readlines()[0].strip()
+ except Exception as e:
+ logging.debug('Cannot read file %s: %s', ifindex_f, str(e))
+ raise
+
+ def lease_method_nm_connection(): # pragma: nocover (covered in autopkgtest)
+ # FIXME: handle older versions of NM where 'nmcli dev show' doesn't exist
+ try:
+ nmcli_dev_out = subprocess.Popen(['nmcli', 'dev', 'show', self.interface],
+ env={'LC_ALL': 'C'},
+ stdout=subprocess.PIPE)
+ for line in nmcli_dev_out.stdout:
+ line = line.decode('utf-8')
+ if 'GENERAL.CONNECTION' in line:
+ conn_id = line.split(':')[1].rstrip().strip()
+ nmcli_con_out = subprocess.Popen(['nmcli', 'con', 'show', 'id', conn_id],
+ env={'LC_ALL': 'C'},
+ stdout=subprocess.PIPE)
+ for line in nmcli_con_out.stdout:
+ line = line.decode('utf-8')
+ if 'connection.uuid' in line:
+ return line.split(':')[1].rstrip().strip()
+ except Exception as e:
+ raise Exception('Could not find a NetworkManager connection for the interface: %s' % str(e))
+ raise Exception('Could not find a NetworkManager connection for the interface')
+
+ lease_pattern = lease_path[mapping['backend']]['pattern']
+ lease_method = lease_path[mapping['backend']]['method']
+
+ try:
+ lease_id = eval("lease_method_" + lease_method)()
+
+ # We found something to build the path to the lease file with,
+ # at this point we may have something to look at; but if not,
+ # we'll rely on open() throwing an error.
+ # This might happen if networkd doesn't use DHCP for the interface,
+ # for instance.
+ with open(os.path.join('/',
+ os.path.abspath(self.root_dir) if self.root_dir else "",
+ lease_pattern.format(interface=self.interface,
+ lease_id=lease_id))) as f:
+ for line in f.readlines():
+ print(line.rstrip())
+ except Exception as e:
+ print("No lease found for interface '%s': %s" % (self.interface, str(e)),
+ file=sys.stderr)
+ sys.exit(1)
+
+ argv = [utils.get_generator_path()]
+ if self.root_dir:
+ argv += ['--root-dir', self.root_dir]
+ argv += ['--mapping', self.interface]
+
+ # Extract out of the generator our mapping in a dict.
+ logging.debug('command ip leases: running %s', argv)
+ try:
+ out = subprocess.check_output(argv, universal_newlines=True)
+ except CalledProcessError: # pragma: nocover (better be covered in autopkgtest)
+ sys.exit(1)
+ mapping = {}
+ mapping_s = out.split(',')
+ for keyvalue in mapping_s:
+ key, value = keyvalue.strip().split('=')
+ mapping[key] = value
+
+ find_lease_file(mapping)
diff --git a/netplan/cli/commands/migrate.py b/netplan/cli/commands/migrate.py
new file mode 100644
index 0000000..7133b11
--- /dev/null
+++ b/netplan/cli/commands/migrate.py
@@ -0,0 +1,416 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan migrate command line'''
+
+import logging
+import os
+import sys
+import re
+from glob import glob
+import yaml
+from collections import OrderedDict
+import ipaddress
+
+import netplan.cli.utils as utils
+
+
+class NetplanMigrate(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='migrate',
+ description='Migration of /etc/network/interfaces to netplan',
+ leaf=True,
+ testing=True)
+
+ def parse_dns_options(self, if_options, if_config):
+ """Parse dns options (dns-nameservers and dns-search) from if_options
+ (an interface options dict) into the interface configuration if_config
+ Mutates the arguments in place.
+ """
+ if 'dns-nameservers' in if_options:
+ if 'nameservers' not in if_config:
+ if_config['nameservers'] = {}
+ if 'addresses' not in if_config['nameservers']:
+ if_config['nameservers']['addresses'] = []
+ for ns in if_options['dns-nameservers'].split(' '):
+ # allow multiple spaces in the dns-nameservers entry
+ if not ns:
+ continue
+ # validate?
+ if_config['nameservers']['addresses'] += [ns]
+ del if_options['dns-nameservers']
+ if 'dns-search' in if_options:
+ if 'nameservers' not in if_config:
+ if_config['nameservers'] = {}
+ if 'search' not in if_config['nameservers']:
+ if_config['nameservers']['search'] = []
+ for domain in if_options['dns-search'].split(' '):
+ # allow multiple spaces in the dns-search entry
+ if not domain:
+ continue
+ if_config['nameservers']['search'] += [domain]
+ del if_options['dns-search']
+
+ def parse_mtu(self, iface, if_options, if_config):
+ """Parse out the MTU. Operates the same way as parse_dns_options
+ iface is the name of the interface, used only to print error messages
+ """
+
+ if 'mtu' in if_options:
+ try:
+ mtu = int(if_options['mtu'])
+ except ValueError:
+ logging.error('%s: cannot parse "%s" as an MTU', iface, if_options['mtu'])
+ sys.exit(2)
+
+ if 'mtu' in if_config and not if_config['mtu'] == mtu:
+ logging.error('%s: tried to set MTU=%d, but already have MTU=%d', iface, mtu, if_config['mtu'])
+ sys.exit(2)
+
+ if_config['mtu'] = mtu
+ del if_options['mtu']
+
+ def parse_hwaddress(self, iface, if_options, if_config):
+ """Parse out the manually configured MAC.
+ Operates the same way as parse_dns_options
+ iface is the name of the interface, used only to print error messages
+ """
+
+ if 'hwaddress' in if_options:
+ if 'macaddress' in if_config and not if_config['macaddress'] == if_options['hwaddress']:
+ logging.error('%s: tried to set MAC %s, but already have MAC %s', iface,
+ if_options['hwaddress'], if_config['macaddress'])
+ sys.exit(2)
+
+ if_config['macaddress'] = if_options['hwaddress']
+ del if_options['hwaddress']
+
+ def run(self):
+ self.parser.add_argument('--root-dir',
+ help='Search for and generate configuration files in this root directory instead of /')
+ self.parser.add_argument('--dry-run', action='store_true',
+ help='Print converted netplan configuration to stdout instead of writing/changing files')
+ self.func = self.command_migrate
+
+ self.parse_args()
+ self.run_command()
+
+ def command_migrate(self):
+ netplan_config = {}
+ try:
+ ifaces, auto_ifaces = self.parse_ifupdown(self.root_dir or '')
+ except ValueError as e:
+ logging.error(str(e))
+ sys.exit(2)
+ for iface, family_config in ifaces.items():
+ for family, config in family_config.items():
+ logging.debug('Converting %s family %s %s', iface, family, config)
+ if iface not in auto_ifaces:
+ logging.error('%s: non-automatic interfaces are not supported', iface)
+ sys.exit(2)
+ if config['method'] == 'loopback':
+ # both systemd and modern ifupdown set up lo automatically
+ logging.debug('Ignoring loopback interface %s', iface)
+ elif config['method'] == 'dhcp':
+ c = netplan_config.setdefault('network', {}).setdefault('ethernets', {}).setdefault(iface, {})
+
+ self.parse_dns_options(config['options'], c)
+ self.parse_hwaddress(iface, config['options'], c)
+
+ if config['options']:
+ logging.error('%s: option(s) %s are not supported for dhcp method',
+ iface, ", ".join(config['options'].keys()))
+ sys.exit(2)
+ if family == 'inet':
+ c['dhcp4'] = True
+ else:
+ assert family == 'inet6'
+ c['dhcp6'] = True
+
+ elif config['method'] == 'static':
+ c = netplan_config.setdefault('network', {}).setdefault('ethernets', {}).setdefault(iface, {})
+
+ if 'addresses' not in c:
+ c['addresses'] = []
+
+ self.parse_dns_options(config['options'], c)
+ self.parse_mtu(iface, config['options'], c)
+ self.parse_hwaddress(iface, config['options'], c)
+
+ # ipv4
+ if family == 'inet':
+ # Already handled: mtu, hwaddress
+ # Supported: address netmask gateway
+ # Not supported yet: metric(?)
+ # No YAML support: pointopoint scope broadcast
+ supported_opts = set(['address', 'netmask', 'gateway'])
+ unsupported_opts = set(['broadcast', 'metric', 'pointopoint', 'scope'])
+
+ opts = set(config['options'].keys())
+ bad_opts = opts - supported_opts
+ if bad_opts:
+ for unsupported in bad_opts.intersection(unsupported_opts):
+ logging.error('%s: unsupported %s option "%s"', iface, family, unsupported)
+ sys.exit(2)
+ for unknown in bad_opts - unsupported_opts:
+ logging.error('%s: unknown %s option "%s"', iface, family, unknown)
+ sys.exit(2)
+
+ # the address may contain a /prefix suffix, or
+ # the netmask property may be used. It's not clear
+ # what happens if both are supplied.
+ if 'address' not in config['options']:
+ logging.error('%s: no address supplied in static method', iface)
+ sys.exit(2)
+
+ if '/' in config['options']['address']:
+ addr_spec = config['options']['address'].split('/')[0]
+ net_spec = config['options']['address']
+ else:
+ if 'netmask' not in config['options']:
+ logging.error('%s: address does not specify prefix length, and netmask not specified',
+ iface)
+ sys.exit(2)
+ addr_spec = config['options']['address']
+ net_spec = config['options']['address'] + '/' + config['options']['netmask']
+
+ try:
+ ipaddr = ipaddress.IPv4Address(addr_spec)
+ except ipaddress.AddressValueError as a:
+ logging.error('%s: error parsing "%s" as an IPv4 address: %s', iface, addr_spec, a)
+ sys.exit(2)
+
+ try:
+ ipnet = ipaddress.IPv4Network(net_spec, strict=False)
+ except ipaddress.NetmaskValueError as a:
+ logging.error('%s: error parsing "%s" as an IPv4 network: %s', iface, net_spec, a)
+ sys.exit(2)
+
+ c['addresses'] += [str(ipaddr) + '/' + str(ipnet.prefixlen)]
+
+ if 'gateway' in config['options']:
+ # validate?
+ c['gateway4'] = config['options']['gateway']
+
+ # ipv6
+ else:
+ assert family == 'inet6'
+
+ # Already handled: mtu, hwaddress
+ # supported: address netmask gateway
+ # partially supported: accept_ra (0/1 supported, 2 has no YAML rep)
+ # unsupported: metric(?)
+ # no YAML representation: media autoconf privext scope
+ # preferred-lifetime dad-attempts dad-interval
+ supported_opts = set(['address', 'netmask', 'gateway', 'accept_ra'])
+ unsupported_opts = set(['metric', 'media', 'autoconf', 'privext',
+ 'scope', 'preferred-lifetime', 'dad-attempts', 'dad-interval'])
+
+ opts = set(config['options'].keys())
+ bad_opts = opts - supported_opts
+ if bad_opts:
+ for unsupported in bad_opts.intersection(unsupported_opts):
+ logging.error('%s: unsupported %s option "%s"', iface, family, unsupported)
+ sys.exit(2)
+ for unknown in bad_opts - unsupported_opts:
+ logging.error('%s: unknown %s option "%s"', iface, family, unknown)
+ sys.exit(2)
+
+ # the address may contain a /prefix suffix, or
+ # the netmask property may be used. It's not clear
+ # what happens if both are supplied.
+ if 'address' not in config['options']:
+ logging.error('%s: no address supplied in static method', iface)
+ sys.exit(2)
+
+ if '/' in config['options']['address']:
+ addr_spec = config['options']['address'].split('/')[0]
+ net_spec = config['options']['address']
+ else:
+ if 'netmask' not in config['options']:
+ logging.error('%s: address does not specify prefix length, and netmask not specified',
+ iface)
+ sys.exit(2)
+ addr_spec = config['options']['address']
+ net_spec = config['options']['address'] + '/' + config['options']['netmask']
+
+ try:
+ ipaddr = ipaddress.IPv6Address(addr_spec)
+ except ipaddress.AddressValueError as a:
+ logging.error('%s: error parsing "%s" as an IPv6 address: %s', iface, addr_spec, a)
+ sys.exit(2)
+
+ try:
+ ipnet = ipaddress.IPv6Network(net_spec, strict=False)
+ except ipaddress.NetmaskValueError as a:
+ logging.error('%s: error parsing "%s" as an IPv6 network: %s', iface, net_spec, a)
+ sys.exit(2)
+
+ c['addresses'] += [str(ipaddr) + '/' + str(ipnet.prefixlen)]
+
+ if 'gateway' in config['options']:
+ # validate?
+ c['gateway6'] = config['options']['gateway']
+
+ if 'accept_ra' in config['options']:
+ if config['options']['accept_ra'] == '0':
+ c['accept_ra'] = False
+ elif config['options']['accept_ra'] == '1':
+ c['accept_ra'] = True
+ elif config['options']['accept_ra'] == '2':
+ logging.error('%s: netplan does not support accept_ra=2', iface)
+ sys.exit(2)
+ else:
+ logging.error('%s: unexpected accept_ra value "%s"', iface,
+ config['options']['accept_ra'])
+ sys.exit(2)
+
+ else: # pragma nocover
+ # this should be unreachable
+ logging.error('%s: method %s is not supported', iface, config['method'])
+ sys.exit(2)
+
+ if_config = os.path.join(self.root_dir or '/', 'etc/network/interfaces')
+
+ if netplan_config:
+ netplan_config['network']['version'] = 2
+ netplan_yaml = yaml.dump(netplan_config)
+ if self.dry_run:
+ print(netplan_yaml)
+ else:
+ dest = os.path.join(self.root_dir or '/', 'etc/netplan/10-ifupdown.yaml')
+ try:
+ os.makedirs(os.path.dirname(dest))
+ except FileExistsError:
+ pass
+ try:
+ with open(dest, 'x') as f:
+ f.write(netplan_yaml)
+ except FileExistsError:
+ logging.error('%s already exists; remove it if you want to run the migration again', dest)
+ sys.exit(3)
+ logging.info('migration complete, wrote %s', dest)
+ else:
+ logging.info('ifupdown does not configure any interfaces, nothing to migrate')
+
+ if not self.dry_run:
+ logging.info('renaming %s to %s.netplan-converted', if_config, if_config)
+ os.rename(if_config, if_config + '.netplan-converted')
+
+ def _ifupdown_lines_from_file(self, rootdir, path):
+ '''Return normalized lines from ifupdown config
+
+ This resolves "source" and "source-directory" includes.
+ '''
+ def expand_source_arg(rootdir, curdir, line):
+ arg = line.split()[1]
+ if arg.startswith('/'):
+ return rootdir + arg
+ else:
+ return curdir + '/' + arg
+
+ lines = []
+ rootdir_len = len(rootdir) + 1
+ try:
+ with open(rootdir + '/' + path) as f:
+ logging.debug('reading %s', f.name)
+ for line in f:
+ # normalize, strip empty lines and comments
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+
+ if line.startswith('source-directory '):
+ valid_re = re.compile('^[a-zA-Z0-9_-]+$')
+ d = expand_source_arg(rootdir, os.path.dirname(f.name), line)
+ for f in os.listdir(d):
+ if valid_re.match(f):
+ lines += self._ifupdown_lines_from_file(rootdir, os.path.join(d[rootdir_len:], f))
+ elif line.startswith('source '):
+ for f in glob(expand_source_arg(rootdir, os.path.dirname(f.name), line)):
+ lines += self._ifupdown_lines_from_file(rootdir, f[rootdir_len:])
+ else:
+ lines.append(line)
+ except FileNotFoundError:
+ logging.debug('%s/%s does not exist, ignoring', rootdir, path)
+ return lines
+
+ def parse_ifupdown(self, rootdir='/'):
+ '''Parse ifupdown configuration.
+
+ Return (iface_name → family → {method, options}, auto_ifaces: set) tuple
+ on successful parsing, or a ValueError when encountering an invalid file or
+ ifupdown features which are not supported (such as "mapping").
+
+ options is itself a dictionary option_name → value.
+ '''
+ # expected number of fields for every possible keyword, excluding the keyword itself
+ fieldlen = {'auto': 1, 'allow-auto': 1, 'allow-hotplug': 1, 'mapping': 1, 'no-scripts': 1, 'iface': 3}
+
+ # read and normalize all lines from config, with resolving includes
+ lines = self._ifupdown_lines_from_file(rootdir, '/etc/network/interfaces')
+
+ ifaces = OrderedDict()
+ auto = set()
+ in_options = None # interface name if parsing options lines after iface stanza
+ in_family = None
+
+ # we now have resolved all includes and normalized lines
+ for line in lines:
+ fields = line.split()
+
+ try:
+ # does the line start with a known stanza field?
+ exp_len = fieldlen[fields[0]]
+ logging.debug('line fields %s (expected length: %i)', fields, exp_len)
+ in_options = None # stop option line parsing of iface stanza
+ in_family = None
+ except KeyError:
+ # no known stanza field, are we in an iface stanza and parsing options?
+ if in_options:
+ logging.debug('in_options %s, parsing as option: %s', in_options, line)
+ ifaces[in_options][in_family]['options'][fields[0]] = line.split(maxsplit=1)[1]
+ continue
+ else:
+ raise ValueError('Unknown stanza type %s' % fields[0])
+
+ # do we have the expected #parameters?
+ if len(fields) != exp_len + 1:
+ raise ValueError('Expected %i fields for stanza type %s but got %i' %
+ (exp_len, fields[0], len(fields) - 1))
+
+ # we have a valid stanza line now, handle them
+ if fields[0] in ('auto', 'allow-auto', 'allow-hotplug'):
+ auto.add(fields[1])
+ elif fields[0] == 'mapping':
+ raise ValueError('mapping stanza is not supported')
+ elif fields[0] == 'no-scripts':
+ pass # ignore these
+ elif fields[0] == 'iface':
+ if fields[2] not in ('inet', 'inet6'):
+ raise ValueError('Unknown address family %s' % fields[2])
+ if fields[3] not in ('loopback', 'static', 'dhcp'):
+ raise ValueError('Unsupported method %s' % fields[3])
+ in_options = fields[1]
+ in_family = fields[2]
+ ifaces.setdefault(fields[1], OrderedDict())[in_family] = {'method': fields[3], 'options': {}}
+ else:
+ raise NotImplementedError('stanza type %s is not implemented' % fields[0]) # pragma nocover
+
+ logging.debug('final parsed interfaces: %s; auto ifaces: %s', ifaces, auto)
+ return (ifaces, auto)
diff --git a/netplan/cli/commands/set.py b/netplan/cli/commands/set.py
new file mode 100644
index 0000000..3bf7dc6
--- /dev/null
+++ b/netplan/cli/commands/set.py
@@ -0,0 +1,169 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan set command line'''
+
+import os
+import yaml
+import tempfile
+import re
+import logging
+import shutil
+
+import netplan.cli.utils as utils
+from netplan.configmanager import ConfigManager
+
+FALLBACK_HINT = '70-netplan-set'
+GLOBAL_KEYS = ['renderer', 'version']
+
+
+class NetplanSet(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='set',
+ description='Add new setting by specifying a dotted key=value pair like ethernets.eth0.dhcp4=true',
+ leaf=True)
+
+ def run(self):
+ self.parser.add_argument('key_value', type=str,
+ help='The nested key=value pair in dotted format. Value can be NULL to delete a key.')
+ self.parser.add_argument('--origin-hint', type=str,
+ help='Can be used to help choose a name for the overwrite YAML file. \
+ A .yaml suffix will be appended automatically.')
+ self.parser.add_argument('--root-dir', default='/',
+ help='Overwrite configuration files in this root directory instead of /')
+
+ self.func = self.command_set
+
+ self.parse_args()
+ self.run_command()
+
+ def split_tree_by_hint(self, set_tree) -> (str, dict):
+ network = set_tree.get('network', {})
+ # A mapping of 'origin-hint' -> YAML tree (one subtree per netdef)
+ subtrees = dict()
+ for devtype in network:
+ if devtype in GLOBAL_KEYS:
+ continue # special handling of global keys down below
+ for netdef in network.get(devtype, []):
+ hint = FALLBACK_HINT
+ filename = utils.netplan_get_filename_by_id(netdef, self.root_dir)
+ if filename:
+ hint = os.path.basename(filename)[:-5] # strip prefix and .yaml
+ netdef_tree = {'network': {devtype: {netdef: network.get(devtype).get(netdef)}}}
+ # Merge all netdef trees which are going to be written to the same file/hint
+ subtrees[hint] = self.merge(subtrees.get(hint, {}), netdef_tree)
+
+ # Merge GLOBAL_KEYS into one of the available subtrees
+ # Write to same file (if only one hint/subtree is available)
+ # Write to FALLBACK_HINT if multiple hints/subtrees are available, as we do not know where it is supposed to go
+ if any(network.get(key) for key in GLOBAL_KEYS):
+ # Write to the same file, if we have only one file-hint or to FALLBACK_HINT otherwise
+ hint = list(subtrees)[0] if len(subtrees) == 1 else FALLBACK_HINT
+ for key in GLOBAL_KEYS:
+ tree = {'network': {key: network.get(key)}}
+ subtrees[hint] = self.merge(subtrees.get(hint, {}), tree)
+
+ # return a list of (str:hint, dict:subtree) tuples
+ return subtrees.items()
+
+ def command_set(self):
+ if self.origin_hint is not None and len(self.origin_hint) == 0:
+ raise Exception('Invalid/empty origin-hint')
+ split = self.key_value.split('=', 1)
+ if len(split) != 2:
+ raise Exception('Invalid value specified')
+ key, value = split
+ set_tree = self.parse_key(key, yaml.safe_load(value))
+
+ hints = [(self.origin_hint, set_tree)]
+ # Override YAML config in each individual netdef file if origin-hint is not set
+ if self.origin_hint is None:
+ hints = self.split_tree_by_hint(set_tree)
+
+ for hint, subtree in hints:
+ self.write_file(subtree, hint + '.yaml', self.root_dir)
+
+ def parse_key(self, key, value):
+ # The 'network.' prefix is optional for netsted keys, its always assumed to be there
+ if not key.startswith('network.') and not key == 'network':
+ key = 'network.' + key
+ # Split at '.' but not at '\.' via negative lookbehind expression
+ split = re.split(r'(?<!\\)\.', key)
+ tree = {}
+ i = 1
+ t = tree
+ for part in split:
+ part = part.replace('\\.', '.') # Unescape interface-ids, containing dots
+ val = {}
+ if i == len(split):
+ val = value
+ t = t.setdefault(part, val)
+ i += 1
+ return tree
+
+ def merge(self, a, b, path=None):
+ """
+ Merges tree/dict 'b' into tree/dict 'a'
+ """
+ if path is None:
+ path = []
+ for key in b:
+ if key in a:
+ if isinstance(a[key], dict) and isinstance(b[key], dict):
+ self.merge(a[key], b[key], path + [str(key)])
+ elif b[key] is None:
+ del a[key]
+ else:
+ # Overwrite existing key with new key/value from 'set' command
+ a[key] = b[key]
+ else:
+ a[key] = b[key]
+ return a
+
+ def write_file(self, set_tree, name, rootdir='/'):
+ tmproot = tempfile.TemporaryDirectory(prefix='netplan-set_')
+ path = os.path.join('etc', 'netplan')
+ os.makedirs(os.path.join(tmproot.name, path))
+
+ config = {'network': {}}
+ absp = os.path.join(rootdir, path, name)
+ if os.path.isfile(absp):
+ with open(absp, 'r') as f:
+ config = yaml.safe_load(f)
+
+ new_tree = self.merge(config, set_tree)
+ stripped = ConfigManager.strip_tree(new_tree)
+ logging.debug('Writing file {}: {}'.format(name, stripped))
+ if 'network' in stripped and list(stripped['network'].keys()) == ['version']:
+ # Clear file if only 'network: {version: 2}' is left
+ os.remove(absp)
+ elif 'network' in stripped:
+ tmpp = os.path.join(tmproot.name, path, name)
+ with open(tmpp, 'w+') as f:
+ new_yaml = yaml.dump(stripped, indent=2, default_flow_style=False)
+ f.write(new_yaml)
+ # Validate the newly created file, by parsing it via libnetplan
+ utils.netplan_parse(tmpp)
+ # Valid, move it to final destination
+ shutil.copy2(tmpp, absp)
+ os.remove(tmpp)
+ elif os.path.isfile(absp):
+ # Clear file if the last/only key got removed
+ os.remove(absp)
+ else:
+ raise Exception('Invalid input: {}'.format(set_tree))
diff --git a/netplan/cli/commands/try_command.py b/netplan/cli/commands/try_command.py
new file mode 100644
index 0000000..198992f
--- /dev/null
+++ b/netplan/cli/commands/try_command.py
@@ -0,0 +1,184 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan try command line'''
+
+import os
+import time
+import signal
+import sys
+import logging
+import subprocess
+
+from netplan.configmanager import ConfigManager
+import netplan.cli.utils as utils
+from netplan.cli.commands.apply import NetplanApply
+import netplan.terminal
+
+# Keep a timeout long enough to allow the network to converge, 60 seconds may
+# be slightly short given some complex configs, i.e. if STP must reconverge.
+DEFAULT_INPUT_TIMEOUT = 120
+
+
+class NetplanTry(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='try',
+ description='Try to apply a new netplan config to running '
+ 'system, with automatic rollback',
+ leaf=True)
+ self.configuration_changed = False
+ self.new_interfaces = None
+ self._config_manager = None
+ self.t_settings = None
+ self.t = None
+
+ @property
+ def config_manager(self): # pragma: nocover (called by later commands)
+ if not self._config_manager:
+ self._config_manager = ConfigManager()
+ return self._config_manager
+
+ def run(self): # pragma: nocover (requires user input)
+ self.parser.add_argument('--config-file',
+ help='Apply the config file in argument in addition to current configuration.')
+ self.parser.add_argument('--timeout',
+ type=int, default=DEFAULT_INPUT_TIMEOUT,
+ help="Maximum number of seconds to wait for the user's confirmation")
+
+ self.func = self.command_try
+
+ self.parse_args()
+ self.run_command()
+
+ def command_try(self): # pragma: nocover (requires user input)
+ if not self.is_revertable():
+ sys.exit(os.EX_CONFIG)
+
+ try:
+ fd = sys.stdin.fileno()
+ self.t = netplan.terminal.Terminal(fd)
+ self.t.save(self.t_settings)
+
+ # we really don't want to be interrupted while doing backup/revert operations
+ signal.signal(signal.SIGINT, self._signal_handler)
+ signal.signal(signal.SIGUSR1, self._signal_handler)
+
+ self.backup()
+ self.setup()
+
+ NetplanApply().command_apply(run_generate=True, sync=True, exit_on_error=False)
+
+ self.t.get_confirmation_input(timeout=self.timeout)
+ except netplan.terminal.InputRejected:
+ print("\nReverting.")
+ self.revert()
+ except netplan.terminal.InputAccepted:
+ print("\nConfiguration accepted.")
+ except Exception as e:
+ print("\nAn error occurred: %s" % e)
+ print("\nReverting.")
+ self.revert()
+ finally:
+ if self.t:
+ self.t.reset(self.t_settings)
+ self.cleanup()
+
+ def backup(self): # pragma: nocover (requires user input)
+ backup_config_dir = False
+ if self.config_file:
+ backup_config_dir = True
+ self.config_manager.backup(backup_config_dir=backup_config_dir)
+
+ def setup(self): # pragma: nocover (requires user input)
+ if self.config_file:
+ dest_dir = os.path.join("/", "etc", "netplan")
+ dest_name = os.path.basename(self.config_file).rstrip('.yaml')
+ dest_suffix = time.time()
+ dest_path = os.path.join(dest_dir, "{}.{}.yaml".format(dest_name, dest_suffix))
+ self.config_manager.add({self.config_file: dest_path})
+ self.configuration_changed = True
+
+ def revert(self): # pragma: nocover (requires user input)
+ self.config_manager.revert()
+ NetplanApply().command_apply(run_generate=False, sync=True, exit_on_error=False)
+ for ifname in self.new_interfaces:
+ if ifname not in self.config_manager.bonds and \
+ ifname not in self.config_manager.bridges and \
+ ifname not in self.config_manager.vlans:
+ logging.debug("{} will not be removed: not a virtual interface".format(ifname))
+ continue
+ try:
+ cmd = ['ip', 'link', 'del', ifname]
+ subprocess.check_call(cmd)
+ except subprocess.CalledProcessError:
+ logging.warn("Could not revert (remove) new interface '{}'".format(ifname))
+
+ def cleanup(self): # pragma: nocover (requires user input)
+ self.config_manager.cleanup()
+
+ def is_revertable(self): # pragma: nocover (requires user input)
+ '''
+ Check if the configuration is revertable, if it doesn't contain bits
+ that we know are likely to render the system unstable if we apply it,
+ or if we revert.
+
+ Returns True if the parsed config is "revertable", meaning that we
+ can actually rely on backends to re-apply /all/ of the relevant
+ configuration to interfaces when their config changes.
+
+ Returns False if the parsed config contains options that are known
+ to not cleanly revert via the backend.
+ '''
+
+ # Parse; including any new config file passed on the command-line:
+ # new config might include things we can't revert.
+ extra_config = []
+ if self.config_file:
+ extra_config.append(self.config_file)
+ self.config_manager.parse(extra_config=extra_config)
+ self.new_interfaces = self.config_manager.new_interfaces
+
+ logging.debug("New interfaces: {}".format(self.new_interfaces))
+
+ revert_unsupported = []
+
+ # Bridges and bonds are special. They typically include (or could include)
+ # more than one device in them, and they can be set with special parameters
+ # to tweak their behavior, which are really hard to "revert", especially
+ # as systemd-networkd doesn't necessarily touch them when config changes.
+ multi_iface = {}
+ multi_iface.update(self.config_manager.bridges)
+ multi_iface.update(self.config_manager.bonds)
+ for ifname, settings in multi_iface.items():
+ if settings and 'parameters' in settings:
+ reason = "reverting custom parameters for bridges and bonds is not supported"
+ revert_unsupported.append((ifname, reason))
+
+ if revert_unsupported:
+ for ifname, reason in revert_unsupported:
+ print("{}: {}".format(ifname, reason))
+ print("\nPlease carefully review the configuration and use 'netplan apply' directly.")
+ return False
+ return True
+
+ def _signal_handler(self, sig, frame): # pragma: nocover (requires user input)
+ if sig == signal.SIGUSR1:
+ raise netplan.terminal.InputAccepted()
+ else:
+ if self.configuration_changed:
+ raise netplan.terminal.InputRejected()
diff --git a/netplan/cli/core.py b/netplan/cli/core.py
new file mode 100644
index 0000000..3d6c392
--- /dev/null
+++ b/netplan/cli/core.py
@@ -0,0 +1,50 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Martin Pitt <martin.pitt@ubuntu.com>
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan command line'''
+
+import logging
+import os
+
+import netplan.cli.utils as utils
+
+
+class Netplan(utils.NetplanCommand):
+
+ def __init__(self):
+ super().__init__(command_id='',
+ description='Network configuration in YAML',
+ leaf=False)
+
+ def parse_args(self):
+ import netplan.cli.commands
+
+ self._import_subcommands(netplan.cli.commands)
+
+ super().parse_args()
+
+ def main(self):
+ self.parse_args()
+
+ if self.debug:
+ logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(message)s')
+ os.environ['G_MESSAGES_DEBUG'] = 'all'
+ else:
+ logging.basicConfig(level=logging.INFO, format='%(message)s')
+
+ self.run_command()
diff --git a/netplan/cli/ovs.py b/netplan/cli/ovs.py
new file mode 100644
index 0000000..d8466fc
--- /dev/null
+++ b/netplan/cli/ovs.py
@@ -0,0 +1,178 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+import os
+import subprocess
+import re
+
+OPENVSWITCH_OVS_VSCTL = '/usr/bin/ovs-vsctl'
+# Defaults for non-optional settings, as defined here:
+# http://www.openvswitch.org/ovs-vswitchd.conf.db.5.pdf
+DEFAULTS = {
+ # Mandatory columns:
+ 'mcast_snooping_enable': 'false',
+ 'rstp_enable': 'false',
+}
+GLOBALS = {
+ # Global commands:
+ 'set-ssl': ('del-ssl', 'get-ssl'),
+ 'set-fail-mode': ('del-fail-mode', 'get-fail-mode'),
+ 'set-controller': ('del-controller', 'get-controller'),
+}
+
+
+def _del_col(type, iface, column, value):
+ """Cleanup values from a column (i.e. "column=value")"""
+ default = DEFAULTS.get(column)
+ if default is None:
+ # removes the exact value only if it was set by netplan
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, 'remove', type, iface, column, value])
+ elif default and default != value:
+ # reset to default, if its not the default already
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, 'set', type, iface, '%s=%s' % (column, default)])
+
+
+def _del_dict(type, iface, column, key, value):
+ """Cleanup values from a dictionary (i.e. "column:key=value")"""
+ # removes the exact value only if it was set by netplan
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, 'remove', type, iface, column, key, _escape_colon(value)])
+
+
+# for ovsdb remove: column key's value can not contain bare ':', need to escape with '\'
+def _escape_colon(literal):
+ return re.sub(r'([^\\]):', r'\g<1>\:', literal)
+
+
+def _del_global(type, iface, key, value):
+ """Cleanup commands from the global namespace"""
+ del_cmd, get_cmd = GLOBALS.get(key, (None, None))
+ if del_cmd == 'del-ssl':
+ iface = None
+
+ if del_cmd:
+ args_get = [OPENVSWITCH_OVS_VSCTL, get_cmd]
+ args_del = [OPENVSWITCH_OVS_VSCTL, del_cmd]
+ if iface:
+ args_get.append(iface)
+ args_del.append(iface)
+ # Check the current value of a global command and compare it to the tag-value, e.g.:
+ # * get-ssl: netplan/global/set-ssl=/private/key.pem,/another/cert.pem,/some/ca-cert.pem
+ # Private key: /private/key.pem
+ # Certificate: /another/cert.pem
+ # CA Certificate: /some/ca-cert.pem
+ # Bootstrap: false
+ # * get-fail-mode: netplan/global/set-fail-mode=secure
+ # secure
+ # * get-controller: netplan/global/set-controller=tcp:127.0.0.1:1337,unix:/some/socket
+ # tcp:127.0.0.1:1337
+ # unix:/some/socket
+ out = subprocess.check_output(args_get, universal_newlines=True)
+ # Clean it only if the exact same value(s) were set by netplan.
+ # Don't touch it if other values were set by another integration.
+ if all(item in out for item in value.split(',')):
+ subprocess.check_call(args_del)
+ else:
+ raise Exception('Reset command unkown for:', key)
+
+
+def clear_setting(type, iface, setting, value):
+ """Check if this setting is in a dict or a colum and delete accordingly"""
+ split = setting.split('/', 2)
+ col = split[1]
+ if col == 'global' and len(split) > 2:
+ _del_global(type, iface, split[2], value)
+ elif len(split) > 2:
+ _del_dict(type, iface, split[1], split[2], value)
+ else:
+ _del_col(type, iface, split[1], value)
+ # Cleanup the tag itself (i.e. "netplan/column[/key]")
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, 'remove', type, iface, 'external-ids', setting])
+
+
+def is_ovs_interface(iface, interfaces):
+ assert isinstance(interfaces, dict)
+ if not isinstance(interfaces.get(iface), dict):
+ logging.debug('Ignoring special key: {} ({})'.format(iface, interfaces.get(iface)))
+ return False
+ elif interfaces.get(iface, {}).get('openvswitch') is not None:
+ return True
+ else:
+ return any(is_ovs_interface(i, interfaces) for i in interfaces.get(iface, {}).get('interfaces', []))
+
+
+def apply_ovs_cleanup(config_manager, ovs_old, ovs_current): # pragma: nocover (covered in autopkgtest)
+ """
+ Query OpenVSwitch state through 'ovs-vsctl' and filter for netplan=true
+ tagged ports/bonds and bridges. Delete interfaces which are not defined
+ in the current configuration.
+ Also filter for individual settings tagged netplan/<column>[/<key]=value
+ in external-ids and clear them if they have been set by netplan.
+ """
+ config_manager.parse()
+ ovs_ifaces = set()
+ for i in config_manager.interfaces.keys():
+ if (is_ovs_interface(i, config_manager.interfaces)):
+ ovs_ifaces.add(i)
+
+ # Tear down old OVS interfaces, not defined in the current config.
+ # Use 'del-br' on the Interface table, to delete any netplan created VLAN fake bridges.
+ # Use 'del-bond-iface' on the Interface table, to delete netplan created patch port interfaces
+ if os.path.isfile(OPENVSWITCH_OVS_VSCTL):
+ # Step 1: Delete all interfaces, which are not part of the current OVS config
+ for t in (('Port', 'del-port'), ('Bridge', 'del-br'), ('Interface', 'del-br')):
+ out = subprocess.check_output([OPENVSWITCH_OVS_VSCTL, '--columns=name,external-ids',
+ '-f', 'csv', '-d', 'bare', '--no-headings', 'list', t[0]],
+ universal_newlines=True)
+ for line in out.splitlines():
+ if 'netplan=true' in line:
+ iface = line.split(',')[0]
+ # Skip cleanup if this OVS interface is part of the current netplan OVS config
+ if iface in ovs_ifaces:
+ continue
+ if t[0] == 'Interface' and subprocess.run([OPENVSWITCH_OVS_VSCTL, 'iface-to-br', iface]).returncode > 0:
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, '--if-exists', 'del-bond-iface', iface])
+ else:
+ subprocess.check_call([OPENVSWITCH_OVS_VSCTL, '--if-exists', t[1], iface])
+
+ # Step 2: Clean up the settings of the remaining interfaces
+ for t in ('Port', 'Bridge', 'Interface', 'Open_vSwitch', 'Controller'):
+ cols = 'name,external-ids'
+ if t == 'Open_vSwitch':
+ cols = 'external-ids'
+ elif t == 'Controller':
+ cols = '_uuid,external-ids' # handle _uuid as if it would be the iface 'name'
+ out = subprocess.check_output([OPENVSWITCH_OVS_VSCTL, '--columns=%s' % cols,
+ '-f', 'csv', '-d', 'bare', '--no-headings', 'list', t],
+ universal_newlines=True)
+ for line in out.splitlines():
+ if 'netplan/' in line:
+ iface = '.'
+ extids = line
+ if t != 'Open_vSwitch':
+ iface, extids = line.split(',', 1)
+ # Check each line (interface) if it contains any netplan tagged settings, e.g.:
+ # ovs0,"iface-id=myhostname netplan=true netplan/external-ids/iface-id=myhostname"
+ # ovs1,"netplan=true netplan/global/set-fail-mode=standalone netplan/mcast_snooping_enable=false"
+ for entry in extids.strip('"').split(' '):
+ if entry.startswith('netplan/') and '=' in entry:
+ setting, val = entry.split('=', 1)
+ clear_setting(t, iface, setting, val)
+
+ # Show the warning only if we are or have been working with OVS definitions
+ elif ovs_old or ovs_current:
+ logging.warning('ovs-vsctl is missing, cannot tear down old OpenVSwitch interfaces')
diff --git a/netplan/cli/sriov.py b/netplan/cli/sriov.py
new file mode 100644
index 0000000..43e0259
--- /dev/null
+++ b/netplan/cli/sriov.py
@@ -0,0 +1,334 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+import os
+import subprocess
+
+from collections import defaultdict
+
+import netplan.cli.utils as utils
+from netplan.configmanager import ConfigurationError
+
+import netifaces
+
+
+def _get_target_interface(interfaces, config_manager, pf_link, pfs):
+ if pf_link not in pfs:
+ # handle the match: syntax, get the actual device name
+ pf_dev = config_manager.ethernets[pf_link]
+ pf_match = pf_dev.get('match')
+ if pf_match:
+ # now here it's a bit tricky
+ set_name = pf_dev.get('set-name')
+ if set_name and set_name in interfaces:
+ # if we had a match: stanza and set-name: this means we should
+ # assume that, if found, the interface has already been
+ # renamed - use the new name
+ pfs[pf_link] = set_name
+ else:
+ # no set-name (or interfaces not yet renamed) so we need to do
+ # the matching ourselves
+ by_name = pf_match.get('name')
+ by_mac = pf_match.get('macaddress')
+ by_driver = pf_match.get('driver')
+
+ for interface in interfaces:
+ if ((by_name and not utils.is_interface_matching_name(interface, by_name)) or
+ (by_mac and not utils.is_interface_matching_macaddress(interface, by_mac)) or
+ (by_driver and not utils.is_interface_matching_driver_name(interface, by_driver))):
+ continue
+ # we have a matching PF
+ # store the matching interface in the dictionary of
+ # active PFs, but error out if we matched more than one
+ if pf_link in pfs:
+ raise ConfigurationError('matched more than one interface for a PF device: %s' % pf_link)
+ pfs[pf_link] = interface
+ else:
+ # no match field, assume entry name is the interface name
+ if pf_link in interfaces:
+ pfs[pf_link] = pf_link
+
+ return pfs.get(pf_link, None)
+
+
+def get_vf_count_and_functions(interfaces, config_manager,
+ vf_counts, vfs, pfs):
+ """
+ Go through the list of netplan ethernet devices and identify which are
+ PFs and VFs, matching the former with actual networking interfaces.
+ Count how many VFs each PF will need.
+ """
+ explicit_counts = {}
+ for ethernet, settings in config_manager.ethernets.items():
+ if not settings:
+ continue
+ if ethernet == 'renderer':
+ continue
+
+ # we now also support explicitly stating how many VFs should be
+ # allocated for a PF
+ explicit_num = settings.get('virtual-function-count')
+ if explicit_num:
+ pf = _get_target_interface(interfaces, config_manager, ethernet, pfs)
+ if pf:
+ explicit_counts[pf] = explicit_num
+ continue
+
+ pf_link = settings.get('link')
+ if pf_link and pf_link in config_manager.ethernets:
+ _get_target_interface(interfaces, config_manager, pf_link, pfs)
+
+ if pf_link in pfs:
+ vf_counts[pfs[pf_link]] += 1
+ else:
+ logging.warning('could not match physical interface for the defined PF: %s' % pf_link)
+ # continue looking for other VFs
+ continue
+
+ # we can't yet perform matching on VFs as those are only
+ # created later - but store, for convenience, all the valid
+ # VFs that we encounter so far
+ vfs[ethernet] = None
+
+ # sanity check: since we can explicitly state the VF count, make sure
+ # that this number isn't smaller than the actual number of VFs declared
+ # the explicit number also overrides the number of actual VFs
+ for pf, count in explicit_counts.items():
+ if pf in vf_counts and vf_counts[pf] > count:
+ raise ConfigurationError(
+ 'more VFs allocated than the explicit size declared: %s > %s' % (vf_counts[pf], count))
+ vf_counts[pf] = count
+
+
+def set_numvfs_for_pf(pf, vf_count):
+ """
+ Allocate the required number of VFs for the selected PF.
+ """
+ if vf_count > 256:
+ raise ConfigurationError(
+ 'cannot allocate more VFs for PF %s than the SR-IOV maximum: %s > 256' % (pf, vf_count))
+
+ devdir = os.path.join('/sys/class/net', pf, 'device')
+ numvfs_path = os.path.join(devdir, 'sriov_numvfs')
+ totalvfs_path = os.path.join(devdir, 'sriov_totalvfs')
+ try:
+ with open(totalvfs_path) as f:
+ vf_max = int(f.read().strip())
+ except IOError as e:
+ raise RuntimeError('failed parsing sriov_totalvfs for %s: %s' % (pf, str(e)))
+ except ValueError:
+ raise RuntimeError('invalid sriov_totalvfs value for %s' % pf)
+
+ if vf_count > vf_max:
+ raise ConfigurationError(
+ 'cannot allocate more VFs for PF %s than supported: %s > %s (sriov_totalvfs)' % (pf, vf_count, vf_max))
+
+ try:
+ with open(numvfs_path, 'w') as f:
+ f.write(str(vf_count))
+ except IOError as e:
+ bail = True
+ if e.errno == 16: # device or resource busy
+ logging.warning('device or resource busy while setting sriov_numvfs for %s, trying workaround' % pf)
+ try:
+ # doing this in two open/close sequences so that
+ # it's as close to writing via shell as possible
+ with open(numvfs_path, 'w') as f:
+ f.write('0')
+ with open(numvfs_path, 'w') as f:
+ f.write(str(vf_count))
+ except IOError as e_inner:
+ e = e_inner
+ else:
+ bail = False
+ if bail:
+ raise RuntimeError('failed setting sriov_numvfs to %s for %s: %s' % (vf_count, pf, str(e)))
+
+ return True
+
+
+def perform_hardware_specific_quirks(pf):
+ """
+ Perform any hardware-specific quirks for the given SR-IOV device to make
+ sure all the VF-count changes are applied.
+ """
+ devdir = os.path.join('/sys/class/net', pf, 'device')
+ try:
+ with open(os.path.join(devdir, 'vendor')) as f:
+ device_id = f.read().strip()[2:]
+ with open(os.path.join(devdir, 'device')) as f:
+ vendor_id = f.read().strip()[2:]
+ except IOError as e:
+ raise RuntimeError('could not determine vendor and device ID of %s: %s' % (pf, str(e)))
+
+ combined_id = ':'.join([vendor_id, device_id])
+ quirk_devices = () # TODO: add entries to the list
+ if combined_id in quirk_devices:
+ # some devices need special handling, so this is the place
+
+ # Currently this part is empty, but has been added as a preemptive
+ # measure, as apparently a lot of SR-IOV cards have issues with
+ # dynamically allocating VFs. Some cards seem to require a full
+ # kernel module reload cycle after changing the sriov_numvfs value
+ # for the changes to come into effect.
+ # Any identified card/vendor can then be special-cased here, if
+ # needed.
+ pass
+
+
+def apply_vlan_filter_for_vf(pf, vf, vlan_name, vlan_id, prefix='/'):
+ """
+ Apply the hardware VLAN filtering for the selected VF.
+ """
+
+ # this is more complicated, because to do this, we actually need to have
+ # the vf index - just knowing the vf interface name is not enough
+ vf_index = None
+ # the prefix argument is here only for unit testing purposes
+ vf_devdir = os.path.join(prefix, 'sys/class/net', vf, 'device')
+ vf_dev_id = os.path.basename(os.readlink(vf_devdir))
+ pf_devdir = os.path.join(prefix, 'sys/class/net', pf, 'device')
+ for f in os.listdir(pf_devdir):
+ if 'virtfn' in f:
+ dev_path = os.path.join(pf_devdir, f)
+ dev_id = os.path.basename(os.readlink(dev_path))
+ if dev_id == vf_dev_id:
+ vf_index = f[6:]
+ break
+
+ if not vf_index:
+ raise RuntimeError(
+ 'could not determine the VF index for %s while configuring vlan %s' % (vf, vlan_name))
+
+ # now, create the VLAN filter
+ # TODO: would be best if we did this directl via python, without calling
+ # the iproute tooling
+ try:
+ subprocess.check_call(['ip', 'link', 'set',
+ 'dev', pf,
+ 'vf', vf_index,
+ 'vlan', str(vlan_id)],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+ except subprocess.CalledProcessError:
+ raise RuntimeError(
+ 'failed setting SR-IOV VLAN filter for vlan %s (ip link set command failed)' % vlan_name)
+
+
+def apply_sriov_config(config_manager):
+ """
+ Go through all interfaces, identify which ones are SR-IOV VFs, create
+ them and perform all other necessary setup.
+ """
+ config_manager.parse()
+ interfaces = netifaces.interfaces()
+
+ # for sr-iov devices, we identify VFs by them having a link: field
+ # pointing to an PF. So let's browse through all ethernet devices,
+ # find all that are VFs and count how many of those are linked to
+ # particular PFs, as we need to then set the numvfs for each.
+ vf_counts = defaultdict(int)
+ # we also store all matches between VF/PF netplan entry names and
+ # interface that they're currently matching to
+ vfs = {}
+ pfs = {}
+
+ get_vf_count_and_functions(
+ interfaces, config_manager, vf_counts, vfs, pfs)
+
+ # setup the required number of VFs per PF
+ # at the same time store which PFs got changed in case the NICs
+ # require some special quirks for the VF number to change
+ vf_count_changed = []
+ if vf_counts:
+ for pf, vf_count in vf_counts.items():
+ if not set_numvfs_for_pf(pf, vf_count):
+ continue
+
+ vf_count_changed.append(pf)
+
+ if vf_count_changed:
+ # some cards need special treatment when we want to change the
+ # number of enabled VFs
+ for pf in vf_count_changed:
+ perform_hardware_specific_quirks(pf)
+
+ # also, since the VF number changed, the interfaces list also
+ # changed, so we need to refresh it
+ interfaces = netifaces.interfaces()
+
+ # now in theory we should have all the new VFs set up and existing;
+ # this is needed because we will have to now match the defined VF
+ # entries to existing interfaces, otherwise we won't be able to set
+ # filtered VLANs for those.
+ # XXX: does matching those even make sense?
+ for vf in vfs:
+ settings = config_manager.ethernets.get(vf)
+ match = settings.get('match')
+ if match:
+ # right now we only match by name, as I don't think matching per
+ # driver and/or macaddress makes sense
+ by_name = match.get('name')
+ # by_mac = match.get('macaddress')
+ # by_driver = match.get('driver')
+ # TODO: print warning if other matches are provided
+
+ for interface in interfaces:
+ if by_name and not utils.is_interface_matching_name(interface, by_name):
+ continue
+ if vf in vfs and vfs[vf]:
+ raise ConfigurationError('matched more than one interface for a VF device: %s' % vf)
+ vfs[vf] = interface
+ else:
+ if vf in interfaces:
+ vfs[vf] = vf
+
+ filtered_vlans_set = set()
+ for vlan, settings in config_manager.vlans.items():
+ # there is a special sriov vlan renderer that one can use to mark
+ # a selected vlan to be done in hardware (VLAN filtering)
+ if settings.get('renderer') == 'sriov':
+ # this only works for SR-IOV VF interfaces
+ link = settings.get('link')
+ vlan_id = settings.get('id')
+ if not vlan_id:
+ raise ConfigurationError(
+ 'no id property defined for SR-IOV vlan %s' % vlan)
+
+ vf = vfs.get(link)
+ if not vf:
+ # it is possible this is not an error, for instance when
+ # the configuration has been defined 'for the future'
+ # XXX: but maybe we should error out here as well?
+ logging.warning(
+ 'SR-IOV vlan defined for %s but link %s is either not a VF or has no matches' % (vlan, link))
+ continue
+
+ # get the parent pf interface
+ # first we fetch the related vf netplan entry
+ vf_parent_entry = config_manager.ethernets.get(link).get('link')
+ # and finally, get the matched pf interface
+ pf = pfs.get(vf_parent_entry)
+
+ if vf in filtered_vlans_set:
+ raise ConfigurationError(
+ 'interface %s for netplan device %s (%s) already has an SR-IOV vlan defined' % (vf, link, vlan))
+
+ # TODO: make sure that we don't apply the filter twice
+ apply_vlan_filter_for_vf(pf, vf, vlan, vlan_id)
+ filtered_vlans_set.add(vf)
diff --git a/netplan/cli/utils.py b/netplan/cli/utils.py
new file mode 100644
index 0000000..0a04692
--- /dev/null
+++ b/netplan/cli/utils.py
@@ -0,0 +1,297 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018-2020 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+# Author: Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import os
+import logging
+import fnmatch
+import argparse
+import subprocess
+import netifaces
+import re
+import ctypes
+import ctypes.util
+
+NM_SERVICE_NAME = 'NetworkManager.service'
+NM_SNAP_SERVICE_NAME = 'snap.network-manager.networkmanager.service'
+
+
+class _GError(ctypes.Structure):
+ _fields_ = [("domain", ctypes.c_uint32), ("code", ctypes.c_int), ("message", ctypes.c_char_p)]
+
+
+lib = ctypes.CDLL(ctypes.util.find_library('netplan'))
+lib.netplan_parse_yaml.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.POINTER(_GError))]
+lib.netplan_get_filename_by_id.restype = ctypes.c_char_p
+
+
+def netplan_parse(path):
+ # Clear old NetplanNetDefinitions from libnetplan memory
+ lib.netplan_clear_netdefs()
+ err = ctypes.POINTER(_GError)()
+ ret = bool(lib.netplan_parse_yaml(path.encode(), ctypes.byref(err)))
+ if not ret:
+ raise Exception(err.contents.message.decode('utf-8'))
+ lib.netplan_finish_parse(ctypes.byref(err))
+ if err:
+ raise Exception(err.contents.message.decode('utf-8'))
+ return True
+
+
+def netplan_get_filename_by_id(netdef_id, rootdir):
+ res = lib.netplan_get_filename_by_id(netdef_id.encode(), rootdir.encode())
+ return res.decode('utf-8') if res else None
+
+
+def get_generator_path():
+ return os.environ.get('NETPLAN_GENERATE_PATH', '/lib/netplan/generate')
+
+
+def is_nm_snap_enabled():
+ return subprocess.call(['systemctl', '--quiet', 'is-enabled', NM_SNAP_SERVICE_NAME], stderr=subprocess.DEVNULL) == 0
+
+
+def nmcli(args): # pragma: nocover (covered in autopkgtest)
+ binary_name = 'nmcli'
+
+ if is_nm_snap_enabled():
+ binary_name = 'network-manager.nmcli'
+
+ subprocess.check_call([binary_name] + args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+def nm_running(): # pragma: nocover (covered in autopkgtest)
+ '''Check if NetworkManager is running'''
+
+ try:
+ nmcli(['general'])
+ return True
+ except (OSError, subprocess.SubprocessError):
+ return False
+
+
+def nm_interfaces(paths, devices):
+ pat = re.compile('^interface-name=(.*)$')
+ interfaces = set()
+ for path in paths:
+ with open(path, 'r') as f:
+ for line in f:
+ m = pat.match(line)
+ if m:
+ # Expand/match globbing of interface names, to real devices
+ interfaces.update(set(fnmatch.filter(devices, m.group(1))))
+ break # skip to next file
+ return interfaces
+
+
+def systemctl_network_manager(action, sync=False):
+ # If the network-manager snap is installed use its service
+ # name rather than the one of the deb packaged NetworkManager
+ if is_nm_snap_enabled():
+ return systemctl(action, [NM_SNAP_SERVICE_NAME], sync)
+ return systemctl(action, [NM_SERVICE_NAME], sync) # pragma: nocover (covered in autopkgtest)
+
+
+def systemctl(action, services, sync=False):
+ if len(services) >= 1:
+ command = ['systemctl', action]
+
+ if not sync:
+ command.append('--no-block')
+
+ command.extend(services)
+
+ subprocess.check_call(command)
+
+
+def networkd_interfaces():
+ interfaces = set()
+ out = subprocess.check_output(['networkctl', '--no-pager', '--no-legend'], universal_newlines=True)
+ for line in out.splitlines():
+ s = line.strip().split(' ')
+ if s[0].isnumeric() and s[-1] not in ['unmanaged', 'linger']:
+ interfaces.add(s[1])
+ return interfaces
+
+
+def networkctl_reconfigure(interfaces):
+ subprocess.check_call(['networkctl', 'reload'])
+ if len(interfaces) >= 1:
+ subprocess.check_call(['networkctl', 'reconfigure'] + list(interfaces))
+
+
+def systemctl_is_active(unit_pattern):
+ '''Return True if at least one matching unit is running'''
+ if subprocess.call(['systemctl', '--quiet', 'is-active', unit_pattern]) == 0:
+ return True
+ return False
+
+
+def systemctl_daemon_reload():
+ '''Reload systemd unit files from disk and re-calculate its dependencies'''
+ subprocess.check_call(['systemctl', 'daemon-reload'])
+
+
+def ip_addr_flush(iface):
+ '''Flush all IP addresses of a given interface via iproute2'''
+ subprocess.check_call(['ip', 'addr', 'flush', iface], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+def get_interface_driver_name(interface, only_down=False): # pragma: nocover (covered in autopkgtest)
+ devdir = os.path.join('/sys/class/net', interface)
+ if only_down:
+ try:
+ with open(os.path.join(devdir, 'operstate')) as f:
+ state = f.read().strip()
+ if state != 'down':
+ logging.debug('device %s operstate is %s, not changing', interface, state)
+ return None
+ except IOError as e:
+ logging.error('Cannot determine operstate of %s: %s', interface, str(e))
+ return None
+
+ try:
+ driver = os.path.realpath(os.path.join(devdir, 'device', 'driver'))
+ driver_name = os.path.basename(driver)
+ except IOError as e:
+ logging.debug('Cannot replug %s: cannot read link %s/device: %s', interface, devdir, str(e))
+ return None
+
+ return driver_name
+
+
+def get_interface_macaddress(interface):
+ # return an empty list (and string) if no LL data can be found
+ link = netifaces.ifaddresses(interface).get(netifaces.AF_LINK, [{}])[0]
+ return link.get('addr', '')
+
+
+def is_interface_matching_name(interface, match_name):
+ # globs are supported
+ return fnmatch.fnmatchcase(interface, match_name)
+
+
+def is_interface_matching_driver_name(interface, match_driver):
+ driver_name = get_interface_driver_name(interface)
+ # globs are supported
+ return fnmatch.fnmatchcase(driver_name, match_driver)
+
+
+def is_interface_matching_macaddress(interface, match_mac):
+ macaddress = get_interface_macaddress(interface)
+ # exact, case insensitive match. globs are not supported
+ return match_mac.lower() == macaddress.lower()
+
+
+def find_matching_iface(interfaces, match):
+ assert isinstance(match, dict)
+
+ # Filter for match.name glob, fallback to '*'
+ name_glob = match.get('name') if match.get('name', False) else '*'
+ matches = fnmatch.filter(interfaces, name_glob)
+
+ # Filter for match.macaddress (exact match)
+ if len(matches) > 1 and match.get('macaddress'):
+ matches = list(filter(lambda iface: is_interface_matching_macaddress(iface, match.get('macaddress')), matches))
+
+ # Filter for match.driver glob
+ if len(matches) > 1 and match.get('driver'):
+ matches = list(filter(lambda iface: is_interface_matching_driver_name(iface, match.get('driver')), matches))
+
+ # Return current name of unique matched interface, if available
+ if len(matches) != 1:
+ logging.info(matches)
+ return None
+ return matches[0]
+
+
+class NetplanCommand(argparse.Namespace):
+
+ def __init__(self, command_id, description, leaf=True, testing=False):
+ self.command_id = command_id
+ self.description = description
+ self.leaf_command = leaf
+ self.testing = testing
+ self._args = None
+ self.debug = False
+ self.commandclass = None
+ self.subcommands = {}
+ self.subcommand = None
+ self.func = None
+
+ self.parser = argparse.ArgumentParser(prog="%s %s" % (sys.argv[0], command_id),
+ description=description,
+ add_help=True)
+ self.parser.add_argument('--debug', action='store_true',
+ help='Enable debug messages')
+ if not leaf:
+ self.subparsers = self.parser.add_subparsers(title='Available commands',
+ metavar='', dest='subcommand')
+ p_help = self.subparsers.add_parser('help',
+ description='Show this help message',
+ help='Show this help message')
+ p_help.set_defaults(func=self.print_usage)
+
+ def update(self, args):
+ self._args = args
+
+ def parse_args(self):
+ ns, self._args = self.parser.parse_known_args(args=self._args, namespace=self)
+
+ if not self.subcommand and not self.leaf_command:
+ print('You need to specify a command', file=sys.stderr)
+ self.print_usage()
+
+ def run_command(self):
+ if self.commandclass:
+ self.commandclass.update(self._args)
+
+ # TODO: (cyphermox) this is actually testable in tests/cli.py; add it.
+ if self.leaf_command and 'help' in self._args: # pragma: nocover (covered in autopkgtest)
+ self.print_usage()
+
+ self.func()
+
+ def print_usage(self):
+ self.parser.print_help(file=sys.stderr)
+ sys.exit(os.EX_USAGE)
+
+ def _add_subparser_from_class(self, name, commandclass):
+ instance = commandclass()
+
+ self.subcommands[name] = {}
+ self.subcommands[name]['class'] = name
+ self.subcommands[name]['instance'] = instance
+
+ if instance.testing:
+ if not os.environ.get('ENABLE_TEST_COMMANDS', None):
+ return
+
+ p = self.subparsers.add_parser(instance.command_id,
+ description=instance.description,
+ help=instance.description,
+ add_help=False)
+ p.set_defaults(func=instance.run, commandclass=instance)
+ self.subcommands[name]['parser'] = p
+
+ def _import_subcommands(self, submodules):
+ import inspect
+ for name, obj in inspect.getmembers(submodules):
+ if inspect.isclass(obj) and issubclass(obj, NetplanCommand):
+ self._add_subparser_from_class(name, obj)
diff --git a/netplan/configmanager.py b/netplan/configmanager.py
new file mode 100644
index 0000000..9278d04
--- /dev/null
+++ b/netplan/configmanager.py
@@ -0,0 +1,320 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan configuration manager'''
+
+import glob
+import logging
+import os
+import shutil
+import sys
+import tempfile
+import yaml
+
+
+class ConfigManager(object):
+
+ def __init__(self, prefix="/", extra_files={}):
+ self.prefix = prefix
+ self.tempdir = tempfile.mkdtemp(prefix='netplan_')
+ self.temp_etc = os.path.join(self.tempdir, "etc")
+ self.temp_run = os.path.join(self.tempdir, "run")
+ self.extra_files = extra_files
+ self.config = {}
+ self.new_interfaces = set()
+
+ @property
+ def network(self):
+ return self.config['network']
+
+ @property
+ def interfaces(self):
+ interfaces = {}
+ interfaces.update(self.ovs_ports)
+ interfaces.update(self.ethernets)
+ interfaces.update(self.modems)
+ interfaces.update(self.wifis)
+ interfaces.update(self.bridges)
+ interfaces.update(self.bonds)
+ interfaces.update(self.tunnels)
+ interfaces.update(self.vlans)
+ return interfaces
+
+ @property
+ def physical_interfaces(self):
+ interfaces = {}
+ interfaces.update(self.ethernets)
+ interfaces.update(self.modems)
+ interfaces.update(self.wifis)
+ return interfaces
+
+ @property
+ def ovs_ports(self):
+ return self.network['ovs_ports']
+
+ @property
+ def openvswitch(self):
+ return self.network['openvswitch']
+
+ @property
+ def ethernets(self):
+ return self.network['ethernets']
+
+ @property
+ def modems(self):
+ return self.network['modems']
+
+ @property
+ def wifis(self):
+ return self.network['wifis']
+
+ @property
+ def bridges(self):
+ return self.network['bridges']
+
+ @property
+ def bonds(self):
+ return self.network['bonds']
+
+ @property
+ def tunnels(self):
+ return self.network['tunnels']
+
+ @property
+ def vlans(self):
+ return self.network['vlans']
+
+ @property
+ def nm_devices(self):
+ return self.network['nm-devices']
+
+ @property
+ def version(self):
+ return self.network['version']
+
+ @property
+ def renderer(self):
+ return self.network['renderer']
+
+ @property
+ def tree(self):
+ return self.strip_tree(self.config)
+
+ @staticmethod
+ def strip_tree(data):
+ '''clear empty branches'''
+ new_data = {}
+ for k, v in data.items():
+ if isinstance(v, dict):
+ v = ConfigManager.strip_tree(v)
+ if v not in (u'', None, {}):
+ new_data[k] = v
+ return new_data
+
+ def parse(self, extra_config=[]):
+ """
+ Parse all our config files to return an object that describes the system's
+ entire configuration, so that it can later be interrogated.
+
+ Returns a dict that contains the entire, collated and merged YAML.
+ """
+ # TODO: Clean this up, there's no solid reason why we should parse YAML
+ # in two different spots; here and in parse.c. We'd do better by
+ # parsing things once, in C form, and having some small glue
+ # Cpython code to call on the right methods and return an object
+ # that is meaningful for the Python code; but minimal parsing in
+ # pure Python will do for now. ~cyphermox
+
+ # /run/netplan shadows /etc/netplan/, which shadows /lib/netplan
+ names_to_paths = {}
+ for yaml_dir in ['lib', 'etc', 'run']:
+ for yaml_file in glob.glob(os.path.join(self.prefix, yaml_dir, 'netplan', '*.yaml')):
+ names_to_paths[os.path.basename(yaml_file)] = yaml_file
+
+ files = [names_to_paths[name] for name in sorted(names_to_paths.keys())]
+
+ self.config['network'] = {
+ 'ovs_ports': {},
+ 'openvswitch': {},
+ 'ethernets': {},
+ 'modems': {},
+ 'wifis': {},
+ 'bridges': {},
+ 'bonds': {},
+ 'tunnels': {},
+ 'vlans': {},
+ 'nm-devices': {},
+ 'version': None,
+ 'renderer': None
+ }
+ for yaml_file in files:
+ self._merge_yaml_config(yaml_file)
+
+ for yaml_file in extra_config:
+ self.new_interfaces |= self._merge_yaml_config(yaml_file)
+
+ logging.debug("Merged config:\n{}".format(yaml.dump(self.tree, default_flow_style=False)))
+
+ def add(self, config_dict):
+ for config_file in config_dict:
+ self._copy_file(config_file, config_dict[config_file])
+ self.extra_files.update(config_dict)
+
+ def backup(self, backup_config_dir=True):
+ if backup_config_dir:
+ self._copy_tree(os.path.join(self.prefix, "etc/netplan"),
+ os.path.join(self.temp_etc, "netplan"))
+ self._copy_tree(os.path.join(self.prefix, "run/NetworkManager/system-connections"),
+ os.path.join(self.temp_run, "NetworkManager", "system-connections"),
+ missing_ok=True)
+ self._copy_tree(os.path.join(self.prefix, "run/systemd/network"),
+ os.path.join(self.temp_run, "systemd", "network"),
+ missing_ok=True)
+
+ def revert(self):
+ try:
+ for extra_file in dict(self.extra_files):
+ os.unlink(self.extra_files[extra_file])
+ del self.extra_files[extra_file]
+ temp_nm_path = "{}/NetworkManager/system-connections".format(self.temp_run)
+ temp_networkd_path = "{}/systemd/network".format(self.temp_run)
+ if os.path.exists(temp_nm_path):
+ shutil.rmtree(os.path.join(self.prefix, "run/NetworkManager/system-connections"))
+ self._copy_tree(temp_nm_path,
+ os.path.join(self.prefix, "run/NetworkManager/system-connections"))
+ if os.path.exists(temp_networkd_path):
+ shutil.rmtree(os.path.join(self.prefix, "run/systemd/network"))
+ self._copy_tree(temp_networkd_path,
+ os.path.join(self.prefix, "run/systemd/network"))
+ except Exception as e: # pragma: nocover (only relevant to filesystem failures)
+ # If we reach here, we're in big trouble. We may have wiped out
+ # file NM or networkd are using, and we most likely removed the
+ # "new" config -- or at least our copy of it.
+ # Given that we're in some halfway done revert; warn the user
+ # aggressively and drop everything; leaving any remaining backups
+ # around for the user to handle themselves.
+ logging.error("Something really bad happened while reverting config: {}".format(e))
+ logging.error("You should verify the netplan YAML in /etc/netplan and probably run 'netplan apply' again.")
+ sys.exit(-1)
+
+ def cleanup(self):
+ shutil.rmtree(self.tempdir)
+
+ def _copy_file(self, src, dst):
+ shutil.copy(src, dst)
+
+ def _copy_tree(self, src, dst, missing_ok=False):
+ try:
+ shutil.copytree(src, dst)
+ except FileNotFoundError:
+ if missing_ok:
+ pass
+ else:
+ raise
+
+ def _merge_ovs_ports_config(self, orig, new):
+ new_interfaces = set()
+ ports = dict()
+ if 'ports' in new:
+ for p1, p2 in new.get('ports'):
+ # Spoof an interface config for patch ports, which are usually
+ # just strings. Add 'peer' and mark it via 'openvswitch' key.
+ ports[p1] = {'peer': p2, 'openvswitch': {}}
+ ports[p2] = {'peer': p1, 'openvswitch': {}}
+ changed_ifaces = list(ports.keys())
+
+ for ifname in changed_ifaces:
+ iface = ports.pop(ifname)
+ if ifname in orig:
+ logging.debug("{} exists in {}".format(ifname, orig))
+ orig[ifname].update(iface)
+ else:
+ logging.debug("{} not found in {}".format(ifname, orig))
+ orig[ifname] = iface
+ new_interfaces.add(ifname)
+
+ return new_interfaces
+
+ def _merge_interface_config(self, orig, new):
+ new_interfaces = set()
+ changed_ifaces = list(new.keys())
+
+ for ifname in changed_ifaces:
+ iface = new.pop(ifname)
+ if ifname in orig:
+ logging.debug("{} exists in {}".format(ifname, orig))
+ orig[ifname].update(iface)
+ else:
+ logging.debug("{} not found in {}".format(ifname, orig))
+ orig[ifname] = iface
+ new_interfaces.add(ifname)
+
+ return new_interfaces
+
+ def _merge_yaml_config(self, yaml_file):
+ new_interfaces = set()
+
+ try:
+ with open(yaml_file) as f:
+ yaml_data = yaml.load(f, Loader=yaml.CSafeLoader)
+ network = None
+ if yaml_data is not None:
+ network = yaml_data.get('network')
+ if network:
+ if 'openvswitch' in network:
+ new = self._merge_ovs_ports_config(self.ovs_ports, network.get('openvswitch'))
+ new_interfaces |= new
+ self.network['openvswitch'] = network.get('openvswitch')
+ if 'ethernets' in network:
+ new = self._merge_interface_config(self.ethernets, network.get('ethernets'))
+ new_interfaces |= new
+ if 'modems' in network:
+ new = self._merge_interface_config(self.modems, network.get('modems'))
+ new_interfaces |= new
+ if 'wifis' in network:
+ new = self._merge_interface_config(self.wifis, network.get('wifis'))
+ new_interfaces |= new
+ if 'bridges' in network:
+ new = self._merge_interface_config(self.bridges, network.get('bridges'))
+ new_interfaces |= new
+ if 'bonds' in network:
+ new = self._merge_interface_config(self.bonds, network.get('bonds'))
+ new_interfaces |= new
+ if 'tunnels' in network:
+ new = self._merge_interface_config(self.tunnels, network.get('tunnels'))
+ new_interfaces |= new
+ if 'vlans' in network:
+ new = self._merge_interface_config(self.vlans, network.get('vlans'))
+ new_interfaces |= new
+ if 'nm-devices' in network:
+ new = self._merge_interface_config(self.nm_devices, network.get('nm-devices'))
+ new_interfaces |= new
+ if 'version' in network:
+ self.network['version'] = network.get('version')
+ if 'renderer' in network:
+ self.network['renderer'] = network.get('renderer')
+ return new_interfaces
+ except (IOError, yaml.YAMLError): # pragma: nocover (filesystem failures/invalid YAML)
+ logging.error('Error while loading {}, aborting.'.format(yaml_file))
+ sys.exit(1)
+
+
+class ConfigurationError(Exception):
+ """
+ Configuration could not be parsed or has otherwise failed to apply
+ """
+ pass
diff --git a/netplan/terminal.py b/netplan/terminal.py
new file mode 100644
index 0000000..2dd5967
--- /dev/null
+++ b/netplan/terminal.py
@@ -0,0 +1,157 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Terminal / input handling
+"""
+
+import fcntl
+import os
+import termios
+import select
+import sys
+
+
+class Terminal(object):
+ """
+ Do minimal terminal mangling to prompt users for input
+ """
+
+ def __init__(self, fd):
+ self.fd = fd
+ self.orig_flags = None
+ self.orig_term = None
+ self.save()
+
+ def enable_echo(self):
+ if sys.stdin.isatty():
+ attrs = termios.tcgetattr(self.fd)
+ attrs[3] = attrs[3] | termios.ICANON
+ attrs[3] = attrs[3] | termios.ECHO
+ termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
+
+ def disable_echo(self):
+ if sys.stdin.isatty():
+ attrs = termios.tcgetattr(self.fd)
+ attrs[3] = attrs[3] & ~termios.ICANON
+ attrs[3] = attrs[3] & ~termios.ECHO
+ termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
+
+ def enable_nonblocking_io(self):
+ flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ def disable_nonblocking_io(self):
+ flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
+
+ def get_confirmation_input(self, timeout=120, message=None): # pragma: nocover (requires user input)
+ """
+ Get a "confirmation" input from the user, for at most (timeout)
+ seconds. Optionally, customize the message to be displayed.
+
+ timeout -- timeout to wait for input (default 120)
+ message -- optional customized message ("Press ENTER to (message)")
+
+ raises:
+ InputAccepted -- the user confirmed the changes
+ InputRejected -- the user rejected the changes
+ """
+ print("Do you want to keep these settings?\n\n")
+
+ settings = dict()
+ self.save(settings)
+ self.disable_echo()
+ self.enable_nonblocking_io()
+
+ if not message:
+ message = "accept the new configuration"
+
+ print("Press ENTER before the timeout to {}\n\n".format(message))
+ timeout_now = timeout
+ while (timeout_now > 0):
+ print("Changes will revert in {:>{}} seconds".format(timeout_now, len(str(timeout))), end='\r')
+
+ # wait at most 1 second for usable input from stdin
+ select.select([sys.stdin], [], [], 1)
+ try:
+ # retrieve any input from the terminal. select() either has
+ # timed out with no input, or found something we can retrieve.
+ c = sys.stdin.read()
+ if (c == '\n'):
+ self.reset(settings)
+ # Yay, user has accepted the changes!
+ raise InputAccepted()
+ except TypeError:
+ # read() above is non-blocking, if there is nothing to read it
+ # will return TypeError, which we should ignore -- on to the
+ # next iteration until timeout.
+ pass
+ timeout_now -= 1
+
+ # We reached the timeout for our loop, now revert our change for
+ # non-blocking I/O and signal the caller the changes were essentially
+ # rejected.
+ self.reset(settings)
+ raise InputRejected()
+
+ def save(self, dest=None):
+ """
+ Save the terminal's current attributes and flags
+
+ Optional argument:
+ - dest: if set, save settings to this dict
+ """
+ orig_flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
+ orig_term = None
+ if sys.stdin.isatty():
+ orig_term = termios.tcgetattr(self.fd)
+ if dest is not None:
+ dest.update({'flags': orig_flags,
+ 'term': orig_term})
+ else:
+ self.orig_flags = orig_flags
+ self.orig_term = orig_term
+
+ def reset(self, orig=None):
+ """
+ Reset the terminal to its original attributes and flags
+
+ Optional argument:
+ - orig: if set, reset to settings from this dict
+ """
+ orig_term = None
+ orig_flags = None
+ if orig is not None:
+ orig_term = orig.get('term')
+ orig_flags = orig.get('flags')
+ else:
+ orig_term = self.orig_term
+ orig_flags = self.orig_flags
+ if sys.stdin.isatty():
+ termios.tcsetattr(self.fd, termios.TCSAFLUSH, orig_term)
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, orig_flags)
+
+
+class InputAccepted(Exception):
+ """ Denotes has accepted input"""
+ pass
+
+
+class InputRejected(Exception):
+ """ Denotes that the user has rejected input"""
+ pass
diff --git a/rpm/netplan.spec b/rpm/netplan.spec
new file mode 100644
index 0000000..c3edab1
--- /dev/null
+++ b/rpm/netplan.spec
@@ -0,0 +1,131 @@
+# Ensure hardened build on EL7
+%global _hardened_build 1
+
+# Ubuntu calls their own software netplan.io in the archive due to name conflicts
+%global ubuntu_name netplan.io
+
+# If the definition isn't available for python3_pkgversion, define it
+%{?!python3_pkgversion:%global python3_pkgversion 3}
+
+# If this isn't defined, define it
+%{?!_systemdgeneratordir:%global _systemdgeneratordir /usr/lib/systemd/system-generators}
+
+# Force auto-byte-compilation to Python 3
+%global __python %{__python3}
+
+
+Name: netplan
+Version: 0.95
+Release: 0%{?dist}
+Summary: Network configuration tool using YAML
+Group: System Environment/Base
+License: GPLv3
+URL: http://netplan.io/
+Source0: https://github.com/canonical/%{name}/archive/%{version}/%{version}.tar.gz
+
+BuildRequires: gcc
+BuildRequires: make
+BuildRequires: pkgconfig(bash-completion)
+BuildRequires: pkgconfig(systemd)
+BuildRequires: pkgconfig(glib-2.0)
+BuildRequires: pkgconfig(yaml-0.1)
+BuildRequires: pkgconfig(uuid)
+BuildRequires: %{_bindir}/pandoc
+BuildRequires: python%{python3_pkgversion}-devel
+# For tests
+BuildRequires: %{_sbindir}/ip
+BuildRequires: python%{python3_pkgversion}-coverage
+BuildRequires: python%{python3_pkgversion}-netifaces
+BuildRequires: python%{python3_pkgversion}-nose
+BuildRequires: python%{python3_pkgversion}-pycodestyle
+BuildRequires: python%{python3_pkgversion}-pyflakes
+BuildRequires: python%{python3_pkgversion}-PyYAML
+
+# /usr/sbin/netplan is a Python 3 script that requires netifaces and PyYAML
+Requires: python%{python3_pkgversion}-netifaces
+Requires: python%{python3_pkgversion}-PyYAML
+# 'ip' command is used in netplan apply subcommand
+Requires: %{_sbindir}/ip
+
+# netplan supports either systemd or NetworkManager as backends to configure the network
+Requires: systemd
+
+%if 0%{?el7}
+# systemd-networkd is a separate subpackage in EL7
+Requires: systemd-networkd
+%endif
+
+%if 0%{?fedora} || 0%{?rhel} >= 8
+# NetworkManager is preferred, but wpa_supplicant can be used directly for Wi-Fi networks
+Suggests: (NetworkManager or wpa_supplicant)
+%endif
+
+# Provide the package name that Ubuntu uses for it too...
+Provides: %{ubuntu_name} = %{version}-%{release}
+Provides: %{ubuntu_name}%{?_isa} = %{version}-%{release}
+
+%description
+netplan reads network configuration from /etc/netplan/*.yaml which are written by administrators,
+installers, cloud image instantiations, or other OS deployments. During early boot, it generates
+backend specific configuration files in /run to hand off control of devices to a particular
+networking daemon.
+
+Currently supported backends are systemd-networkd and NetworkManager.
+
+
+%prep
+%autosetup -p1
+
+# Drop -Werror to avoid the following error:
+# /usr/include/glib-2.0/glib/glib-autocleanups.h:28:3: error: 'ip_str' may be used uninitialized in this function [-Werror=maybe-uninitialized]
+sed -e "s/-Werror//g" -i Makefile
+
+
+%build
+%make_build CFLAGS="%{optflags}"
+
+
+%install
+%make_install ROOTPREFIX=%{_prefix}
+
+# Pre-create the config directory
+mkdir -p %{buildroot}%{_sysconfdir}/%{name}
+
+
+%check
+make check
+
+
+%files
+%license COPYING
+%doc debian/changelog
+%doc %{_docdir}/%{name}/
+%{_sbindir}/%{name}
+%{_datadir}/%{name}/
+%{_unitdir}/%{name}*.service
+%{_systemdgeneratordir}/%{name}
+%{_mandir}/man5/%{name}.5*
+%{_mandir}/man8/%{name}*.8*
+%dir %{_sysconfdir}/%{name}
+%{_prefix}/lib/%{name}/
+%{_datadir}/bash-completion/completions/%{name}
+
+
+%changelog
+* Fri Dec 14 2018 Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com> - 0.95
+- Update to 0.95
+
+* Sat Oct 13 2018 Neal Gompa <ngompa13@gmail.com> - 0.40.3-0
+- Rebase to 0.40.3
+
+* Tue Mar 13 2018 Neal Gompa <ngompa13@gmail.com> - 0.34-0.1
+- Update to 0.34
+
+* Wed Mar 7 2018 Neal Gompa <ngompa13@gmail.com> - 0.33-0.1
+- Rebase to 0.33
+
+* Sat Nov 4 2017 Neal Gompa <ngompa13@gmail.com> - 0.30-1
+- Rebase to 0.30
+
+* Sun Jul 2 2017 Neal Gompa <ngompa13@gmail.com> - 0.23~17.04.1-1
+- Initial packaging
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
new file mode 100644
index 0000000..c3f5ae6
--- /dev/null
+++ b/snap/snapcraft.yaml
@@ -0,0 +1,42 @@
+name: netplan
+version: git
+summary: Backend-agnostic network configuration in YAML
+description: |
+ Netplan is a utility for easily configuring networking on a linux system.
+ You simply create a YAML description of the required network interfaces and
+ what each should be configured to do. From this description Netplan will
+ generate all the necessary configuration for your chosen renderer tool.
+grade: devel
+confinement: classic
+
+apps:
+ netplan:
+ command: usr/sbin/netplan
+ environment:
+ PYTHONPATH: $PYTHONPATH:$SNAP/usr/lib/python3/dist-packages
+
+parts:
+ netplan:
+ source: https://github.com/canonical/netplan.git
+ plugin: make
+ build-packages:
+ - bash-completion
+ - libglib2.0-dev
+ - libyaml-dev
+ - uuid-dev
+ - pandoc
+ - pkg-config
+ - python3
+ - python3-coverage
+ - python3-yaml
+ - python3-netifaces
+ - python3-nose
+ - pyflakes3
+ - pep8
+ - systemd
+ stage-packages:
+ - iproute2
+ - python3
+ - python3-netifaces
+ - python3-yaml
+ - systemd
diff --git a/src/dbus.c b/src/dbus.c
new file mode 100644
index 0000000..f0aa53a
--- /dev/null
+++ b/src/dbus.c
@@ -0,0 +1,798 @@
+#include <errno.h>
+#include <stdbool.h>
+#include <unistd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <signal.h>
+#include <glob.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <gio/gio.h>
+#include <systemd/sd-bus.h>
+#include <systemd/sd-event.h>
+
+#include "_features.h"
+#include "util.h"
+
+typedef struct {
+ sd_bus_slot *slot;
+ gboolean invalidated;
+} NetplanConfigData;
+
+typedef struct {
+ sd_bus *bus;
+ sd_event_source *try_es;
+ GPid try_pid; /* semaphore. There can only be one 'netplan try' child process at a time */
+ const char *config_id; /* current config ID, during any io.netplan.Netplan.Config calls */
+ char *handler_id; /* copy of pending config ID, during io.netplan.Netplan.Config.Try() */
+ char *config_dirty; /* Currently pending Set() config object id */
+ GHashTable *config_data; /* data of to the /io/netplan/Netplan/config/<ID> objects */
+} NetplanData;
+
+static const char* NETPLAN_SUBDIRS[3] = {"etc", "run", "lib"};
+static const char* NETPLAN_GLOBAL_CONFIG = "BACKUP";
+static char* NETPLAN_ROOT = "/"; /* Can be modified for testing netplan-dbus */
+
+static void
+invalidate_other_config(gpointer key, gpointer value, gpointer user_data)
+{
+ const char *id = key;
+ const char *current_config_id = user_data;
+ NetplanConfigData *cd = value;
+
+ if (current_config_id == NULL)
+ cd->invalidated = FALSE;
+ else if (g_strcmp0(id, current_config_id))
+ cd->invalidated = TRUE;
+}
+
+static int
+terminate_try_child_process(int status, NetplanData *d, const char *config_id)
+{
+ sd_bus_message *msg = NULL;
+ g_autofree gchar *path = NULL;
+ int r = 0;
+
+ if (!WIFEXITED(status))
+ fprintf(stderr, "'netplan try' exited with status: %d\n", WEXITSTATUS(status)); // LCOV_EXCL_LINE
+
+ /* Cleanup current 'netplan try' child process */
+ sd_event_source_unref(d->try_es);
+ d->try_es = NULL;
+ g_spawn_close_pid (d->try_pid);
+ d->try_pid = -1; /* unlock semaphore */
+
+ /* Send .Changed() signal on DBus */
+ if (config_id) {
+ path = g_strdup_printf("/io/netplan/Netplan/config/%s", config_id);
+ r = sd_bus_message_new_signal(d->bus, &msg, path,
+ "io.netplan.Netplan.Config", "Changed");
+ }
+
+ if (r < 0) {
+ // LCOV_EXCL_START
+ fprintf(stderr, "Could not create .Changed() signal: %s\n", strerror(-r));
+ return r;
+ // LCOV_EXCL_STOP
+ }
+
+ r = sd_bus_send(d->bus, msg, NULL);
+ if (r < 0)
+ fprintf(stderr, "Could not send .Changed() signal: %s\n", strerror(-r)); // LCOV_EXCL_LINE
+ sd_bus_message_unrefp(&msg);
+ return r;
+}
+
+static int
+_try_accept(bool accept, sd_bus_message *m, NetplanData *d, sd_bus_error *ret_error)
+{
+ g_autoptr(GError) error = NULL;
+ int status = -1;
+ int signal = SIGUSR1;
+ if (!accept) signal = SIGINT;
+
+ /* Do not send the accept/reject signal, if this call is for another config state */
+ if (d->handler_id != NULL && g_strcmp0(d->config_id, d->handler_id))
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "Another 'netplan try' process is already running");
+
+ /* ATTENTION: There might be a race here:
+ * When this accept/reject method is called at the same time as the 'netplan try'
+ * python process is reverting and closing itself. Not sure what to do about it...
+ * Maybe this needs to be fixed in python code, so that the
+ * 'netplan.terminal.InputRejected' exception (i.e. self-revert) cannot be
+ * interrupted by another exception/signal */
+
+ /* Send confirm (SIGUSR1) or cancel (SIGINT) signal to 'netplan try' process.
+ * Wait for the child process to stop, synchronously.
+ * Check return code/errors. */
+ kill(d->try_pid, signal);
+ waitpid(d->try_pid, &status, 0);
+ g_spawn_check_exit_status(status, &error);
+ if (error != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "netplan try failed: %s", error->message); // LCOV_EXCL_LINE
+
+ terminate_try_child_process(status, d, d->config_id);
+ return sd_bus_reply_method_return(m, "b", true);
+}
+
+static int
+_copy_yaml_state(char *src_root, char *dst_root, sd_bus_error *ret_error)
+{
+ glob_t gl;
+ g_autoptr(GError) err = NULL;
+ int r = find_yaml_glob(src_root, &gl);
+ if (!!r)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed glob for YAML files\n");
+ // LCOV_EXCL_STOP
+
+ /* Copy all *.yaml files from "/SRC_ROOT/{etc,run,lib}/netplan/" to
+ * "/DST_ROOT/{etc,run,lib}/netplan/" */
+ GFile *source = NULL;
+ GFile *dest = NULL;
+ gchar *dest_path = NULL;
+ size_t len = strlen(src_root);
+ for (size_t i = 0; i < gl.gl_pathc; ++i) {
+ dest_path = g_strjoin(NULL, dst_root, (gl.gl_pathv[i])+len, NULL);
+ source = g_file_new_for_path(gl.gl_pathv[i]);
+ dest = g_file_new_for_path(dest_path);
+ g_file_copy(source, dest, G_FILE_COPY_OVERWRITE
+ |G_FILE_COPY_NOFOLLOW_SYMLINKS
+ |G_FILE_COPY_ALL_METADATA,
+ NULL, NULL, NULL, &err);
+ if (err != NULL) {
+ // LCOV_EXCL_START
+ r = sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to copy file %s -> %s: %s\n",
+ g_file_get_path(source), g_file_get_path(dest),
+ err->message);
+ g_object_unref(source);
+ g_object_unref(dest);
+ g_free(dest_path);
+ globfree(&gl);
+ return r;
+ // LCOV_EXCL_STOP
+ }
+ g_object_unref(source);
+ g_object_unref(dest);
+ g_free(dest_path);
+ }
+ globfree(&gl);
+ return r;
+}
+
+static bool
+_clear_tmp_state(const char *config_id, NetplanData *d)
+{
+ g_autofree gchar *rootdir = NULL;
+ /* Remove tmp YAML files */
+ rootdir = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), config_id);
+ unlink_glob(rootdir, "/{etc,run,lib}/netplan/*.yaml");
+
+ /* Remove tmp state directories */
+ char *subdir = NULL;
+ for (int i = 0; i < 3; i++) {
+ subdir = g_strdup_printf("%s/%s/netplan", rootdir, NETPLAN_SUBDIRS[i]);
+ rmdir(subdir);
+ g_free(subdir);
+ subdir = g_strdup_printf("%s/%s", rootdir, NETPLAN_SUBDIRS[i]);
+ rmdir(subdir);
+ g_free(subdir);
+ }
+ rmdir(rootdir);
+
+ /* No cleanup of DBus object needed, if config_id points to NETPLAN_GLOBAL_CONFIG (backup) */
+ if (config_id != NETPLAN_GLOBAL_CONFIG) {
+ /* Clear config object from DBus, by unref the appropriate slot */
+ NetplanConfigData *cd = g_hash_table_lookup(d->config_data, config_id);
+ sd_bus_slot_unref(cd->slot); /* Clear value/slot */
+ g_free(cd); /* Clear value/struct */
+ g_hash_table_remove(d->config_data, config_id); /* Clear key */
+ d->config_dirty = NULL;
+ /* TODO: HashTable error handling */
+ }
+
+ return TRUE;
+}
+
+/**
+ * io.netplan.Netplan methods
+ */
+
+static int
+method_apply(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *stdout = NULL;
+ g_autofree gchar *stderr = NULL;
+ gint exit_status = 0;
+ NetplanData *d = userdata;
+
+ /* Accept the current 'netplan try', if active.
+ * Otherwise execute 'netplan apply' directly. */
+ if (d->try_pid > 0)
+ return _try_accept(TRUE, m, userdata, ret_error);
+
+ gchar *argv[] = {SBINDIR "/" "netplan", "apply", NULL};
+
+ // for tests only: allow changing what netplan to run
+ if (getenv("DBUS_TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("DBUS_TEST_NETPLAN_CMD");
+
+ g_spawn_sync("/", argv, NULL, 0, NULL, NULL, &stdout, &stderr, &exit_status, &err);
+ // LCOV_EXCL_START
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "cannot run netplan apply: %s", err->message);
+ g_spawn_check_exit_status(exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "netplan apply failed: %s\nstdout: '%s'\nstderr: '%s'",
+ err->message, stdout, stderr);
+ // LCOV_EXCL_STOP
+
+ return sd_bus_reply_method_return(m, "b", true);
+}
+
+static int
+method_generate(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *stdout = NULL;
+ g_autofree gchar *stderr = NULL;
+ gint exit_status = 0;
+
+ gchar *argv[] = {SBINDIR "/" "netplan", "generate", NULL};
+
+ // for tests only: allow changing what netplan to run
+ if (getenv("DBUS_TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("DBUS_TEST_NETPLAN_CMD");
+
+ g_spawn_sync("/", argv, NULL, 0, NULL, NULL, &stdout, &stderr, &exit_status, &err);
+ // LCOV_EXCL_START
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "cannot run netplan generate: %s", err->message);
+ g_spawn_check_exit_status(exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "netplan generate failed: %s\nstdout: '%s'\nstderr: '%s'",
+ err->message, stdout, stderr);
+ // LCOV_EXCL_STOP
+
+ return sd_bus_reply_method_return(m, "b", true);
+}
+
+static int
+method_info(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ sd_bus_message *reply = NULL;
+ gint exit_status = 0;
+
+ exit_status = sd_bus_message_new_method_return(m, &reply);
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_open_container(reply, 'a', "(sv)");
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_open_container(reply, 'r', "sv");
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_append(reply, "s", "Features");
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_open_container(reply, 'v', "as");
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_append_strv(reply, (char**)feature_flags);
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_close_container(reply);
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_close_container(reply);
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ exit_status = sd_bus_message_close_container(reply);
+ if (exit_status < 0)
+ return exit_status; // LCOV_EXCL_LINE
+
+ return sd_bus_send(NULL, reply, NULL);
+}
+
+static int
+method_get(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *stdout = NULL;
+ g_autofree gchar *stderr = NULL;
+ g_autofree gchar *root_dir = NULL;
+ gint exit_status = 0;
+
+ if (d->config_id)
+ root_dir = g_strdup_printf("--root-dir=%s/netplan-config-%s", g_get_tmp_dir(), d->config_id);
+ gchar *argv[] = {SBINDIR "/" "netplan", "get", "all", root_dir, NULL};
+
+ // for tests only: allow changing what netplan to run
+ if (getenv("DBUS_TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("DBUS_TEST_NETPLAN_CMD");
+
+ g_spawn_sync("/", argv, NULL, 0, NULL, NULL, &stdout, &stderr, &exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "cannot run netplan get: %s", err->message); // LCOV_EXCL_LINE
+
+ g_spawn_check_exit_status(exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "netplan get failed: %s\nstdout: '%s'\nstderr: '%s'", err->message, stdout, stderr); // LCOV_EXCL_LINE
+
+ return sd_bus_reply_method_return(m, "s", stdout);
+}
+
+static int
+method_set(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *stdout = NULL;
+ g_autofree gchar *stderr = NULL;
+ g_autofree gchar *origin = NULL;
+ g_autofree gchar *root_dir = NULL;
+ gint exit_status = 0;
+ char *args[2] = {NULL, NULL};
+ char *config_delta = NULL;
+ char *origin_hint = NULL;
+ guint cur_arg = 0;
+
+ if (sd_bus_message_read(m, "ss", &config_delta, &origin_hint) < 0)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "cannot extract config_delta or origin_hint"); // LCOV_EXCL_LINE
+
+ if (!!strcmp(origin_hint, "")) {
+ origin = g_strdup_printf("--origin-hint=%s", origin_hint);
+ args[cur_arg] = origin;
+ cur_arg++;
+ }
+
+ if (d->config_id) {
+ root_dir = g_strdup_printf("--root-dir=%s/netplan-config-%s", g_get_tmp_dir(), d->config_id);
+ args[cur_arg] = root_dir;
+ cur_arg++;
+ }
+ gchar *argv[] = {SBINDIR "/" "netplan", "set", config_delta, args[0], args[1], NULL};
+
+ // for tests only: allow changing what netplan to run
+ if (getenv("DBUS_TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("DBUS_TEST_NETPLAN_CMD");
+
+ g_spawn_sync("/", argv, NULL, 0, NULL, NULL, &stdout, &stderr, &exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "cannot run netplan set %s: %s", config_delta, err->message); // LCOV_EXCL_LINE
+
+ g_spawn_check_exit_status(exit_status, &err);
+ if (err != NULL)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "netplan set failed: %s\nstdout: '%s'\nstderr: '%s'", err->message, stdout, stderr); // LCOV_EXCL_LINE
+
+ return sd_bus_reply_method_return(m, "b", true);
+}
+
+static int
+netplan_try_cancelled_cb(sd_event_source *es, const siginfo_t *si, void* userdata)
+{
+ NetplanData *d = userdata;
+ g_autofree gchar *state_dir = NULL;
+ int r = 0;
+ if (d->handler_id) {
+ /* Delete GLOBAL state */
+ unlink_glob(NETPLAN_ROOT, "/{etc,run,lib}/netplan/*.yaml");
+ /* Restore GLOBAL backup config state to main rootdir */
+ state_dir = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), NETPLAN_GLOBAL_CONFIG);
+ _copy_yaml_state(state_dir, NETPLAN_ROOT, NULL);
+
+ /* Un-invalidate all other current config objects */
+ if (!g_strcmp0(d->handler_id, d->config_dirty))
+ g_hash_table_foreach(d->config_data, invalidate_other_config, NULL);
+
+ /* Clear GLOBAL backup and config state */
+ _clear_tmp_state(NETPLAN_GLOBAL_CONFIG, d);
+ _clear_tmp_state(d->handler_id, d);
+ }
+
+ r = terminate_try_child_process(si->si_status, d, d->handler_id);
+ /* free and reset handler_id, i.e. copy of config state ID */
+ g_free(d->handler_id);
+ d->handler_id = NULL; /* unlock pending config ID */
+ return r;
+}
+
+static int
+method_try(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *timeout = NULL;
+ gint child_stdin = -1; /* child process needs an input to function correctly */
+ guint seconds = 0;
+ int r = -1;
+ NetplanData *d = userdata;
+
+ if (sd_bus_message_read_basic (m, 'u', &seconds) < 0)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED, "cannot extract timeout_seconds"); // LCOV_EXCL_LINE
+ if (seconds > 0)
+ timeout = g_strdup_printf("--timeout=%u", seconds);
+ gchar *argv[] = {SBINDIR "/" "netplan", "try", timeout, NULL};
+
+ // for tests only: allow changing what netplan to run
+ if (getenv("DBUS_TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("DBUS_TEST_NETPLAN_CMD");
+
+ /* Launch 'netplan try' child process, lock 'try_pid' to real PID */
+ g_spawn_async_with_pipes("/", argv, NULL,
+ G_SPAWN_DO_NOT_REAP_CHILD|G_SPAWN_STDOUT_TO_DEV_NULL,
+ NULL, NULL, &d->try_pid, &child_stdin, NULL, NULL, &err);
+ if (err)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "cannot run netplan try: %s", err->message);
+ // LCOV_EXCL_STOP
+
+ /* Register an event handler, trigged when the child process exits */
+ if (d->config_id)
+ d->handler_id = g_strdup(d->config_id); /* to free in event handler */
+ r = sd_event_add_child(sd_bus_get_event(d->bus), &d->try_es, d->try_pid,
+ WEXITED, netplan_try_cancelled_cb, d);
+ if (r < 0)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "cannot watch 'netplan try' child: %s", strerror(-r));
+ // LCOV_EXCL_STOP
+
+ return sd_bus_reply_method_return(m, "b", true);
+}
+
+/**
+ * io.netplan.Netplan.Config methods
+ */
+
+/* netplan-feature: dbus-config */
+static int
+method_config_apply(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ g_autofree gchar *state_dir = NULL;
+ int r = 0;
+ /* trim 27 chars (i.e. "/io/netplan/Netplan/config/") from path to get the config ID */
+ d->config_id = sd_bus_message_get_path(m) + 27;
+ NetplanConfigData *cd = g_hash_table_lookup(d->config_data, d->config_id);
+ if (cd->invalidated)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "This config was invalidated by another config object\n");
+ /* Invalidate all other current config objects */
+ g_hash_table_foreach(d->config_data, invalidate_other_config, (void*)d->config_id);
+ d->config_dirty = g_strdup(d->config_id);
+
+ if (d->try_pid < 0) {
+ /* Delete GLOBAL state */
+ unlink_glob(NETPLAN_ROOT, "/{etc,run,lib}/netplan/*.yaml");
+ /* Copy current config state to GLOBAL */
+ state_dir = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), d->config_id);
+ _copy_yaml_state(state_dir, NETPLAN_ROOT, ret_error);
+ d->handler_id = g_strdup(d->config_id);
+ }
+
+ r = method_apply(m, d, ret_error);
+ _clear_tmp_state(d->config_id, d);
+
+ /* unlock current config ID and handler ID */
+ d->config_id = NULL;
+ g_free(d->handler_id);
+ d->handler_id = NULL;
+ return r;
+}
+
+static int
+method_config_get(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ /* trim 27 chars (i.e. "/io/netplan/Netplan/config/") from path to get the config ID */
+ d->config_id = sd_bus_message_get_path(m) + 27;
+ int r = method_get(m, userdata, ret_error);
+ /* Reset config_id for next method call */
+ d->config_id = NULL;
+ return r;
+}
+
+static int
+method_config_set(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ /* trim 27 chars (i.e. "/io/netplan/Netplan/config/") from path to get the config ID */
+ d->config_id = sd_bus_message_get_path(m) + 27;
+ NetplanConfigData *cd = g_hash_table_lookup(d->config_data, d->config_id);
+ if (cd->invalidated)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "This config was invalidated by another config object\n");
+ int r = method_set(m, d, ret_error);
+ /* Invalidate all other current config objects */
+ g_hash_table_foreach(d->config_data, invalidate_other_config, (void*)d->config_id);
+ d->config_dirty = g_strdup(d->config_id);
+ /* Reset config_id for next method call */
+ d->config_id = NULL;
+ return r;
+}
+
+static int
+method_config_try(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ g_autofree gchar *path = NULL;
+ g_autofree gchar *state_dir = NULL;
+ const char *config_id = sd_bus_message_get_path(m) + 27;
+ if (d->try_pid > 0)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Another Try() is currently in progress: PID %d\n", d->try_pid);
+ NetplanConfigData *cd = g_hash_table_lookup(d->config_data, config_id);
+ if (cd->invalidated)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "This config was invalidated by another config object\n");
+
+ int r = 0;
+ /* Lock current child process temporarily until we have a real PID */
+ d->try_pid = G_MAXINT;
+ d->config_id = config_id;
+
+ /* Backup GLOBAL state */
+ path = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), NETPLAN_GLOBAL_CONFIG);
+ /* Create {etc,run,lib} subdirs with owner r/w permissions */
+ char *subdir = NULL;
+ for (int i = 0; i < 3; i++) {
+ subdir = g_strdup_printf("%s/%s/netplan", path, NETPLAN_SUBDIRS[i]);
+ r = g_mkdir_with_parents(subdir, 0700);
+ if (r < 0)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to create '%s': %s\n", subdir, strerror(errno));
+ // LCOV_EXCL_STOP
+ g_free(subdir);
+ }
+
+ /* Copy main *.yaml files from /{etc,run,lib}/netplan/ to GLOBAL backup dir */
+ _copy_yaml_state(NETPLAN_ROOT, path, ret_error);
+
+ /* Clear main *.yaml files */
+ unlink_glob(NETPLAN_ROOT, "/{etc,run,lib}/netplan/*.yaml");
+
+ /* Copy current config *.yaml state to main rootdir (i.e. /etc/netplan/) */
+ state_dir = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), d->config_id);
+ _copy_yaml_state(state_dir, NETPLAN_ROOT, ret_error);
+
+ /* Exec try */
+ r = method_try(m, userdata, ret_error);
+ d->config_id = NULL;
+ return r;
+}
+
+static int
+method_config_cancel(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ g_autofree gchar *state_dir = NULL;
+ int r = 0;
+ /* trim 27 chars (i.e. "/io/netplan/Netplan/config/") from path to get the config ID */
+ d->config_id = sd_bus_message_get_path(m) + 27;
+ if (!g_strcmp0(d->config_id, d->config_dirty))
+ /* Un-invalidate all other current config objects */
+ g_hash_table_foreach(d->config_data, invalidate_other_config, NULL);
+
+ /* Cancel the current 'netplan try' process */
+ if (d->try_pid > 0)
+ r = _try_accept(FALSE, m, d, ret_error);
+ else
+ r = sd_bus_reply_method_return(m, "b", true);
+
+ if (d->handler_id && !g_strcmp0(d->config_id, d->handler_id)) {
+ /* Delete GLOBAL state */
+ unlink_glob(NETPLAN_ROOT, "/{etc,run,lib}/netplan/*.yaml");
+ /* Restore GLOBAL backup config state to main rootdir */
+ state_dir = g_strdup_printf("%s/netplan-config-%s", g_get_tmp_dir(), NETPLAN_GLOBAL_CONFIG);
+ _copy_yaml_state(state_dir, NETPLAN_ROOT, ret_error);
+
+ /* Clear GLOBAL backup and config state */
+ _clear_tmp_state(NETPLAN_GLOBAL_CONFIG, d);
+
+ /* Clear pending Try() handler ID */
+ g_free(d->handler_id);
+ d->handler_id = NULL;
+ }
+
+ /* Clear tmp state */
+ _clear_tmp_state(d->config_id, d);
+ d->config_id = NULL;
+ return r;
+}
+
+static const sd_bus_vtable config_vtable[] = {
+ SD_BUS_VTABLE_START(0),
+ SD_BUS_METHOD("Apply", "", "b", method_config_apply, 0),
+ SD_BUS_METHOD("Get", "", "s", method_config_get, 0),
+ SD_BUS_METHOD("Set", "ss", "b", method_config_set, 0),
+ SD_BUS_METHOD("Try", "u", "b", method_config_try, 0),
+ SD_BUS_METHOD("Cancel", "", "b", method_config_cancel, 0),
+ SD_BUS_VTABLE_END
+};
+
+/**
+ * Link between io.netplan.Netplan and io.netplan.Netplan.Config
+ */
+
+static int
+method_config(sd_bus_message *m, void *userdata, sd_bus_error *ret_error)
+{
+ NetplanData *d = userdata;
+ sd_bus_slot *slot = NULL;
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar *path = NULL;
+ int r = 0;
+
+ /* Create temp. directory, according to "netplan-config-XXXXXX" template */
+ path = g_dir_make_tmp("netplan-config-XXXXXX", &err);
+ if (err != NULL)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to create temp dir: %s\n", err->message);
+ // LCOV_EXCL_STOP
+
+ /* Extract the last 6 randomly generated chars (i.e. "XXXXXX" from template) */
+ const char *id = path + strlen(path) - 6;
+ const char *obj_path = g_strdup_printf("/io/netplan/Netplan/config/%s", id);
+ r = sd_bus_add_object_vtable(d->bus, &slot, obj_path,
+ "io.netplan.Netplan.Config", config_vtable, d);
+ // LCOV_EXCL_START
+ if (r < 0)
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to add 'config' object: %s\n", strerror(-r));
+ NetplanConfigData *cd = g_new0(NetplanConfigData, 1);
+ cd->slot = slot;
+ /* Cannot Set()/Apply() if another Set() is currently pending */
+ cd->invalidated = d->config_dirty ? TRUE : FALSE;
+ if (!g_hash_table_insert(d->config_data, g_strdup(id), cd))
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to add object data to HashTable\n");
+ // LCOV_EXCL_STOP
+
+ /* Create {etc,run,lib} subdirs with owner r/w permissions */
+ char *subdir = NULL;
+ for (int i = 0; i < 3; i++) {
+ subdir = g_strdup_printf("%s/%s/netplan", path, NETPLAN_SUBDIRS[i]);
+ r = g_mkdir_with_parents(subdir, 0700);
+ if (r < 0)
+ // LCOV_EXCL_START
+ return sd_bus_error_setf(ret_error, SD_BUS_ERROR_FAILED,
+ "Failed to create '%s': %s\n", subdir, strerror(errno));
+ // LCOV_EXCL_STOP
+ g_free(subdir);
+ }
+
+ /* Copy all *.yaml files from /{etc,run,lib}/netplan/ to temp dir */
+ _copy_yaml_state(NETPLAN_ROOT, path, ret_error);
+
+ return sd_bus_reply_method_return(m, "o", obj_path);
+}
+
+static const sd_bus_vtable netplan_vtable[] = {
+ SD_BUS_VTABLE_START(0),
+ SD_BUS_METHOD("Apply", "", "b", method_apply, 0),
+ SD_BUS_METHOD("Generate", "", "b", method_generate, 0),
+ SD_BUS_METHOD("Info", "", "a(sv)", method_info, 0),
+ SD_BUS_METHOD("Config", "", "o", method_config, 0),
+ SD_BUS_VTABLE_END
+};
+
+/**
+ * DBus setup
+ */
+
+static int
+terminate_mainloop_cb(sd_event_source *es, const struct signalfd_siginfo *si, void* userdata) {
+ sd_event *event = userdata;
+ /* Gracefully terminate the mainloop, to write GCOV output */
+ sd_event_exit(event, 0);
+ return 0;
+}
+
+int
+main(int argc, char *argv[])
+{
+ sd_bus_slot *slot = NULL;
+ sd_bus *bus = NULL;
+ sd_event *event = NULL;
+ NetplanData *data = g_new0(NetplanData, 1);
+ sigset_t mask;
+ int r;
+
+ // for tests only: allow changing which rootdir to use to copy files around
+ if (getenv("DBUS_TEST_NETPLAN_ROOT") != 0)
+ NETPLAN_ROOT = getenv("DBUS_TEST_NETPLAN_ROOT");
+
+ /* TODO: consider sd_bus_default(&bus) for easier testing on session/user bus */
+ r = sd_bus_open_system(&bus);
+ if (r < 0) {
+ // LCOV_EXCL_START
+ fprintf(stderr, "Failed to connect to system bus: %s\n", strerror(-r));
+ goto finish;
+ // LCOV_EXCL_STOP
+ }
+
+ r = sd_event_new(&event);
+ if (r < 0) {
+ // LCOV_EXCL_START
+ fprintf(stderr, "Failed to create event loop: %s\n", strerror(-r));
+ goto finish;
+ // LCOV_EXCL_STOP
+ }
+
+ /* Initialize the userdata */
+ data->bus = bus;
+ data->try_pid = -1;
+ data->config_id = NULL;
+ data->handler_id = NULL;
+ data->config_dirty = NULL;
+ /* TODO: define a proper free/cleanup function for sd_bus_slot_unref() */
+ data->config_data = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
+
+ r = sd_bus_add_object_vtable(bus, &slot,
+ "/io/netplan/Netplan", /* object path */
+ "io.netplan.Netplan", /* interface name */
+ netplan_vtable,
+ data);
+ if (r < 0) {
+ // LCOV_EXCL_START
+ fprintf(stderr, "Failed to issue method call: %s\n", strerror(-r));
+ goto finish;
+ // LCOV_EXCL_STOP
+ }
+
+ r = sd_bus_request_name(bus, "io.netplan.Netplan", 0);
+ if (r < 0) {
+ fprintf(stderr, "Failed to acquire service name: %s\n", strerror(-r));
+ goto finish;
+ }
+
+ r = sd_bus_attach_event(bus, event, SD_EVENT_PRIORITY_NORMAL);
+ if (r < 0) {
+ // LCOV_EXCL_START
+ fprintf(stderr, "Failed to attach event loop: %s\n", strerror(-r));
+ goto finish;
+ // LCOV_EXCL_STOP
+ }
+
+ /* Mask the SIGCHLD signal, so we can listen to it via mainloop */
+ sigemptyset(&mask);
+ sigaddset(&mask, SIGCHLD);
+ sigaddset(&mask, SIGTERM);
+ sigprocmask(SIG_BLOCK, &mask, NULL);
+
+ /* Start the event loop, wait for requests */
+ sd_event_add_signal(event, NULL, SIGTERM, terminate_mainloop_cb, event);
+ r = sd_event_loop(event);
+ if (r < 0)
+ fprintf(stderr, "Failed mainloop: %s\n", strerror(-r)); // LCOV_EXCL_LINE
+finish:
+ g_free(data);
+ sd_event_unref(event);
+ sd_bus_slot_unref(slot);
+ sd_bus_unref(bus);
+ /* TODO: unref all slots from HashTable */
+
+ return r < 0 ? EXIT_FAILURE : EXIT_SUCCESS;
+}
diff --git a/src/error.c b/src/error.c
new file mode 100644
index 0000000..0c34e17
--- /dev/null
+++ b/src/error.c
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2019 Canonical, Ltd.
+ * Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <gio/gio.h>
+
+#include <yaml.h>
+
+#include "parse.h"
+
+
+/****************************************************
+ * Loading and error handling
+ ****************************************************/
+
+static void
+write_error_marker(GString *message, int column)
+{
+ int i;
+
+ for (i = 0; (column > 0 && i < column); i++)
+ g_string_append_printf(message, " ");
+
+ g_string_append_printf(message, "^");
+}
+
+static char *
+get_syntax_error_context(const int line_num, const int column, GError **error)
+{
+ GString *message = NULL;
+ GFile *cur_file = g_file_new_for_path(current_file);
+ GFileInputStream *file_stream;
+ GDataInputStream *stream;
+ gsize len;
+ gchar* line = NULL;
+
+ message = g_string_sized_new(200);
+ file_stream = g_file_read(cur_file, NULL, error);
+ stream = g_data_input_stream_new (G_INPUT_STREAM(file_stream));
+ g_object_unref(file_stream);
+
+ for (int i = 0; i < line_num + 1; i++) {
+ g_free(line);
+ line = g_data_input_stream_read_line(stream, &len, NULL, error);
+ }
+ g_string_append_printf(message, "%s\n", line);
+
+ write_error_marker(message, column);
+
+ g_object_unref(stream);
+ g_object_unref(cur_file);
+
+ return g_string_free(message, FALSE);
+}
+
+static char *
+get_parser_error_context(const yaml_parser_t *parser, GError **error)
+{
+ GString *message = NULL;
+ unsigned char* line = parser->buffer.pointer;
+ unsigned char* current = line;
+
+ message = g_string_sized_new(200);
+
+ while (current > parser->buffer.start) {
+ current--;
+ if (*current == '\n') {
+ line = current + 1;
+ break;
+ }
+ }
+ if (current <= parser->buffer.start)
+ line = parser->buffer.start;
+ current = line + 1;
+ while (current <= parser->buffer.last) {
+ if (*current == '\n') {
+ *current = '\0';
+ break;
+ }
+ current++;
+ }
+
+ g_string_append_printf(message, "%s\n", line);
+
+ write_error_marker(message, parser->problem_mark.column);
+
+ return g_string_free(message, FALSE);
+}
+
+gboolean
+parser_error(const yaml_parser_t* parser, const char* yaml, GError** error)
+{
+ char *error_context = get_parser_error_context(parser, error);
+ if ((char)*parser->buffer.pointer == '\t')
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "%s:%zu:%zu: Invalid YAML: tabs are not allowed for indent:\n%s",
+ yaml,
+ parser->problem_mark.line + 1,
+ parser->problem_mark.column + 1,
+ error_context);
+ else if (((char)*parser->buffer.pointer == ' ' || (char)*parser->buffer.pointer == '\0')
+ && !parser->token_available)
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "%s:%zu:%zu: Invalid YAML: aliases are not supported:\n%s",
+ yaml,
+ parser->problem_mark.line + 1,
+ parser->problem_mark.column + 1,
+ error_context);
+ else if (parser->state == YAML_PARSE_BLOCK_MAPPING_KEY_STATE)
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "%s:%zu:%zu: Invalid YAML: inconsistent indentation:\n%s",
+ yaml,
+ parser->problem_mark.line + 1,
+ parser->problem_mark.column + 1,
+ error_context);
+ else {
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "%s:%zu:%zu: Invalid YAML: %s:\n%s",
+ yaml,
+ parser->problem_mark.line + 1,
+ parser->problem_mark.column + 1,
+ parser->problem,
+ error_context);
+ }
+ g_free(error_context);
+
+ return FALSE;
+}
+
+/**
+ * Put a YAML specific error message for @node into @error.
+ */
+gboolean
+yaml_error(const yaml_node_t* node, GError** error, const char* msg, ...)
+{
+ va_list argp;
+ char* s;
+ char* error_context = NULL;
+
+ va_start(argp, msg);
+ g_vasprintf(&s, msg, argp);
+ if (node != NULL) {
+ error_context = get_syntax_error_context(node->start_mark.line, node->start_mark.column, error);
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "%s:%zu:%zu: Error in network definition: %s\n%s",
+ current_file,
+ node->start_mark.line + 1,
+ node->start_mark.column + 1,
+ s,
+ error_context);
+ } else {
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_PARSE,
+ "Error in network definition: %s", s);
+ }
+ g_free(s);
+ va_end(argp);
+ return FALSE;
+}
+
diff --git a/src/error.h b/src/error.h
new file mode 100644
index 0000000..68061d8
--- /dev/null
+++ b/src/error.h
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2019 Canonical, Ltd.
+ * Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <gio/gio.h>
+
+#include <yaml.h>
+
+
+gboolean
+parser_error(const yaml_parser_t* parser, const char* yaml, GError** error);
+
+gboolean
+yaml_error(const yaml_node_t* node, GError** error, const char* msg, ...);
diff --git a/src/generate.c b/src/generate.c
new file mode 100644
index 0000000..cccd47a
--- /dev/null
+++ b/src/generate.c
@@ -0,0 +1,295 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <glob.h>
+#include <unistd.h>
+#include <errno.h>
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <glib-object.h>
+#include <gio/gio.h>
+
+#include "util.h"
+#include "parse.h"
+#include "networkd.h"
+#include "nm.h"
+#include "openvswitch.h"
+#include "sriov.h"
+
+static gchar* rootdir;
+static gchar** files;
+static gboolean any_networkd;
+static gboolean any_sriov;
+static gchar* mapping_iface;
+
+static GOptionEntry options[] = {
+ {"root-dir", 'r', 0, G_OPTION_ARG_FILENAME, &rootdir, "Search for and generate configuration files in this root directory instead of /"},
+ {G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_FILENAME_ARRAY, &files, "Read configuration from this/these file(s) instead of /etc/netplan/*.yaml", "[config file ..]"},
+ {"mapping", 0, 0, G_OPTION_ARG_STRING, &mapping_iface, "Only show the device to backend mapping for the specified interface."},
+ {NULL}
+};
+
+static void
+reload_udevd(void)
+{
+ const gchar *argv[] = { "/bin/udevadm", "control", "--reload", NULL };
+ g_spawn_sync(NULL, (gchar**)argv, NULL, G_SPAWN_STDERR_TO_DEV_NULL, NULL, NULL, NULL, NULL, NULL, NULL);
+};
+
+// LCOV_EXCL_START
+/* covered via 'cloud-init' integration test */
+static gboolean
+check_called_just_in_time()
+{
+ const gchar *argv[] = { "/bin/systemctl", "is-system-running", NULL };
+ gchar *output = NULL;
+ g_spawn_sync(NULL, (gchar**)argv, NULL, G_SPAWN_STDERR_TO_DEV_NULL, NULL, NULL, &output, NULL, NULL, NULL);
+ if (output != NULL && strstr(output, "initializing") != NULL) {
+ g_free(output);
+ const gchar *argv2[] = { "/bin/systemctl", "is-active", "network.target", NULL };
+ gint exit_code = 0;
+ g_spawn_sync(NULL, (gchar**)argv2, NULL, G_SPAWN_STDERR_TO_DEV_NULL, NULL, NULL, NULL, NULL, &exit_code, NULL);
+ /* return TRUE, if network.target is not yet active */
+ return !g_spawn_check_exit_status(exit_code, NULL);
+ }
+ g_free(output);
+ return FALSE;
+};
+
+static void
+start_unit_jit(gchar *unit)
+{
+ const gchar *argv[] = { "/bin/systemctl", "start", "--no-block", "--no-ask-password", unit, NULL };
+ g_spawn_sync(NULL, (gchar**)argv, NULL, G_SPAWN_DEFAULT, NULL, NULL, NULL, NULL, NULL, NULL);
+};
+// LCOV_EXCL_STOP
+
+static void
+nd_iterator_list(gpointer value, gpointer user_data)
+{
+ NetplanNetDefinition* def = (NetplanNetDefinition*) value;
+ if (write_networkd_conf(def, (const char*) user_data))
+ any_networkd = TRUE;
+
+ write_ovs_conf(def, (const char*) user_data);
+ write_nm_conf(def, (const char*) user_data);
+ if (def->sriov_explicit_vf_count < G_MAXUINT || def->sriov_link)
+ any_sriov = TRUE;
+}
+
+
+static int
+find_interface(gchar* interface)
+{
+ GPtrArray *found;
+ GFileInfo *info;
+ GFile *driver_file;
+ gchar *driver_path;
+ gchar *driver = NULL;
+ gpointer key, value;
+ GHashTableIter iter;
+ int ret = EXIT_FAILURE;
+
+ found = g_ptr_array_new ();
+
+ /* Try to get the driver name for the interface... */
+ driver_path = g_strdup_printf("/sys/class/net/%s/device/driver", interface);
+ driver_file = g_file_new_for_path (driver_path);
+ info = g_file_query_info (driver_file,
+ G_FILE_ATTRIBUTE_STANDARD_SYMLINK_TARGET,
+ 0, NULL, NULL);
+ if (info != NULL) {
+ /* testing for driver matching is done via autopkgtest */
+ // LCOV_EXCL_START
+ driver = g_path_get_basename (g_file_info_get_symlink_target (info));
+ g_object_unref (info);
+ // LCOV_EXCL_STOP
+ }
+ g_object_unref (driver_file);
+ g_free (driver_path);
+
+ g_hash_table_iter_init (&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ NetplanNetDefinition *nd = (NetplanNetDefinition *) value;
+ if (!g_strcmp0(nd->set_name, interface))
+ g_ptr_array_add (found, (gpointer) nd);
+ else if (!g_strcmp0(nd->id, interface))
+ g_ptr_array_add (found, (gpointer) nd);
+ else if (!g_strcmp0(nd->match.original_name, interface))
+ g_ptr_array_add (found, (gpointer) nd);
+ }
+ if (found->len == 0 && driver != NULL) {
+ /* testing for driver matching is done via autopkgtest */
+ // LCOV_EXCL_START
+ g_hash_table_iter_init (&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ NetplanNetDefinition *nd = (NetplanNetDefinition *) value;
+ if (!g_strcmp0(nd->match.driver, driver))
+ g_ptr_array_add (found, (gpointer) nd);
+ }
+ // LCOV_EXCL_STOP
+ }
+
+ if (driver)
+ g_free (driver); // LCOV_EXCL_LINE
+
+ if (found->len != 1) {
+ goto exit_find;
+ }
+ else {
+ const NetplanNetDefinition *nd = (NetplanNetDefinition *)g_ptr_array_index (found, 0);
+ g_printf("id=%s, backend=%s, set_name=%s, match_name=%s, match_mac=%s, match_driver=%s\n",
+ nd->id,
+ netplan_backend_to_name[nd->backend],
+ nd->set_name,
+ nd->match.original_name,
+ nd->match.mac,
+ nd->match.driver);
+ }
+
+ ret = EXIT_SUCCESS;
+
+exit_find:
+ g_ptr_array_free (found, TRUE);
+ return ret;
+}
+
+int main(int argc, char** argv)
+{
+ GError* error = NULL;
+ GOptionContext* opt_context;
+ /* are we being called as systemd generator? */
+ gboolean called_as_generator = (strstr(argv[0], "systemd/system-generators/") != NULL);
+ g_autofree char* generator_run_stamp = NULL;
+ glob_t gl;
+
+ /* Parse CLI options */
+ opt_context = g_option_context_new(NULL);
+ if (called_as_generator)
+ g_option_context_set_help_enabled(opt_context, FALSE);
+ g_option_context_set_summary(opt_context, "Generate backend network configuration from netplan YAML definition.");
+ g_option_context_set_description(opt_context,
+ "This program reads the specified netplan YAML definition file(s)\n"
+ "or, if none are given, /etc/netplan/*.yaml.\n"
+ "It then generates the corresponding systemd-networkd, NetworkManager,\n"
+ "and udev configuration files in /run.");
+ g_option_context_add_main_entries(opt_context, options, NULL);
+
+ if (!g_option_context_parse(opt_context, &argc, &argv, &error)) {
+ g_fprintf(stderr, "failed to parse options: %s\n", error->message);
+ return 1;
+ }
+
+ if (called_as_generator) {
+ if (files == NULL || g_strv_length(files) != 3 || files[0] == NULL) {
+ g_fprintf(stderr, "%s can not be called directly, use 'netplan generate'.", argv[0]);
+ return 1;
+ }
+ generator_run_stamp = g_build_path(G_DIR_SEPARATOR_S, files[0], "netplan.stamp", NULL);
+ if (g_access(generator_run_stamp, F_OK) == 0) {
+ g_fprintf(stderr, "netplan generate already ran, remove %s to force re-run\n", generator_run_stamp);
+ return 0;
+ }
+ }
+
+ /* Read all input files */
+ if (files && !called_as_generator) {
+ for (gchar** f = files; f && *f; ++f)
+ process_input_file(*f);
+ } else if (!process_yaml_hierarchy(rootdir))
+ return 1; // LCOV_EXCL_LINE
+
+ netdefs = netplan_finish_parse(&error);
+ if (error) {
+ g_fprintf(stderr, "%s\n", error->message);
+ exit(1);
+ }
+
+ /* Clean up generated config from previous runs */
+ cleanup_networkd_conf(rootdir);
+ cleanup_nm_conf(rootdir);
+ cleanup_ovs_conf(rootdir);
+ cleanup_sriov_conf(rootdir);
+
+ if (mapping_iface && netdefs)
+ return find_interface(mapping_iface);
+
+ /* Generate backend specific configuration files from merged data. */
+ write_ovs_conf_finish(rootdir); // OVS cleanup unit is always written
+ if (netdefs) {
+ g_debug("Generating output files..");
+ g_list_foreach (netdefs_ordered, nd_iterator_list, rootdir);
+ write_nm_conf_finish(rootdir);
+ if (any_sriov) write_sriov_conf_finish(rootdir);
+ /* We may have written .rules & .link files, thus we must
+ * invalidate udevd cache of its config as by default it only
+ * invalidates cache at most every 3 seconds. Not sure if this
+ * should live in `generate' or `apply', but it is confusing
+ * when udevd ignores just-in-time created rules files.
+ */
+ reload_udevd();
+ }
+
+ /* Disable /usr/lib/NetworkManager/conf.d/10-globally-managed-devices.conf
+ * (which restricts NM to wifi and wwan) if global renderer is NM */
+ if (netplan_get_global_backend() == NETPLAN_BACKEND_NM)
+ g_string_free_to_file(g_string_new(NULL), rootdir, "/run/NetworkManager/conf.d/10-globally-managed-devices.conf", NULL);
+
+ if (called_as_generator) {
+ /* Ensure networkd starts if we have any configuration for it */
+ if (any_networkd)
+ enable_networkd(files[0]);
+
+ /* Leave a stamp file so that we don't regenerate the configuration
+ * multiple times and userspace can wait for it to finish */
+ FILE* f = fopen(generator_run_stamp, "w");
+ g_assert(f != NULL);
+ fclose(f);
+ } else if (check_called_just_in_time()) {
+ /* netplan-feature: generate-just-in-time */
+ /* When booting with cloud-init, network configuration
+ * might be provided just-in-time. Specifically after
+ * system-generators were executed, but before
+ * network.target is started. In such case, auxiliary
+ * units that netplan enables have not been included in
+ * the initial boot transaction. Detect such scenario and
+ * add all netplan units to the initial boot transaction.
+ */
+ // LCOV_EXCL_START
+ /* covered via 'cloud-init' integration test */
+ if (any_networkd) {
+ start_unit_jit("systemd-networkd.socket");
+ start_unit_jit("systemd-networkd-wait-online.service");
+ start_unit_jit("systemd-networkd.service");
+ }
+ g_autofree char* glob_run = g_strjoin(NULL, rootdir ?: "", G_DIR_SEPARATOR_S,
+ "run/systemd/system/netplan-*.service", NULL);
+ if (!glob(glob_run, 0, NULL, &gl)) {
+ for (size_t i = 0; i < gl.gl_pathc; ++i) {
+ gchar *unit_name = g_path_get_basename(gl.gl_pathv[i]);
+ start_unit_jit(unit_name);
+ g_free(unit_name);
+ }
+ }
+ // LCOV_EXCL_STOP
+ }
+
+ return 0;
+}
diff --git a/src/netplan.c b/src/netplan.c
new file mode 100644
index 0000000..d941a03
--- /dev/null
+++ b/src/netplan.c
@@ -0,0 +1,965 @@
+/*
+ * Copyright (C) 2021 Canonical, Ltd.
+ * Author: Lukas Märdian <slyon@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+#include <yaml.h>
+
+#include "netplan.h"
+#include "parse.h"
+
+gchar *tmp = NULL;
+
+static gboolean
+write_match(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ YAML_SCALAR_PLAIN(event, emitter, "match");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "name", def->match.original_name);
+ YAML_STRING(event, emitter, "macaddress", def->match.mac)
+ YAML_STRING(event, emitter, "driver", def->match.driver)
+ YAML_MAPPING_CLOSE(event, emitter);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_auth(yaml_event_t* event, yaml_emitter_t* emitter, NetplanAuthenticationSettings auth)
+{
+ YAML_SCALAR_PLAIN(event, emitter, "auth");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "key-management", netplan_auth_key_management_type_to_str[auth.key_management]);
+ YAML_STRING(event, emitter, "method", netplan_auth_eap_method_to_str[auth.eap_method]);
+ YAML_STRING(event, emitter, "anonymous-identity", auth.anonymous_identity);
+ YAML_STRING(event, emitter, "identity", auth.identity);
+ YAML_STRING(event, emitter, "ca-certificate", auth.ca_certificate);
+ YAML_STRING(event, emitter, "client-certificate", auth.client_certificate);
+ YAML_STRING(event, emitter, "client-key", auth.client_key);
+ YAML_STRING(event, emitter, "client-key-password", auth.client_key_password);
+ YAML_STRING(event, emitter, "phase2-auth", auth.phase2_auth);
+ YAML_STRING(event, emitter, "password", auth.password);
+ YAML_MAPPING_CLOSE(event, emitter);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_bond_params(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ if (def->bond_params.mode
+ || def->bond_params.monitor_interval
+ || def->bond_params.up_delay
+ || def->bond_params.down_delay
+ || def->bond_params.lacp_rate
+ || def->bond_params.transmit_hash_policy
+ || def->bond_params.selection_logic
+ || def->bond_params.arp_validate
+ || def->bond_params.arp_all_targets
+ || def->bond_params.fail_over_mac_policy
+ || def->bond_params.primary_reselect_policy
+ || def->bond_params.learn_interval
+ || def->bond_params.arp_interval
+ || def->bond_params.primary_slave
+ || def->bond_params.min_links
+ || def->bond_params.all_slaves_active
+ || def->bond_params.gratuitous_arp
+ || def->bond_params.packets_per_slave
+ || def->bond_params.resend_igmp
+ || def->bond_params.arp_ip_targets) {
+ YAML_SCALAR_PLAIN(event, emitter, "parameters");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "mode", def->bond_params.mode);
+ YAML_STRING(event, emitter, "mii-monitor-interval", def->bond_params.monitor_interval);
+ YAML_STRING(event, emitter, "up-delay", def->bond_params.up_delay);
+ YAML_STRING(event, emitter, "down-delay", def->bond_params.down_delay);
+ YAML_STRING(event, emitter, "lacp-rate", def->bond_params.lacp_rate);
+ YAML_STRING(event, emitter, "transmit-hash-policy", def->bond_params.transmit_hash_policy);
+ YAML_STRING(event, emitter, "ad-select", def->bond_params.selection_logic);
+ YAML_STRING(event, emitter, "arp-validate", def->bond_params.arp_validate);
+ YAML_STRING(event, emitter, "arp-all-targets", def->bond_params.arp_all_targets);
+ YAML_STRING(event, emitter, "fail-over-mac-policy", def->bond_params.fail_over_mac_policy);
+ YAML_STRING(event, emitter, "primary-reselect-policy", def->bond_params.primary_reselect_policy);
+ YAML_STRING(event, emitter, "learn-packet-interval", def->bond_params.learn_interval);
+ YAML_STRING(event, emitter, "arp-interval", def->bond_params.arp_interval);
+ YAML_STRING(event, emitter, "primary", def->bond_params.primary_slave);
+ if (def->bond_params.min_links)
+ YAML_UINT(event, emitter, "min-links", def->bond_params.min_links);
+ if (def->bond_params.all_slaves_active)
+ YAML_STRING_PLAIN(event, emitter, "all-slaves-active", "true");
+ if (def->bond_params.gratuitous_arp)
+ YAML_UINT(event, emitter, "gratuitous-arp", def->bond_params.gratuitous_arp);
+ if (def->bond_params.packets_per_slave)
+ YAML_UINT(event, emitter, "packets-per-slave", def->bond_params.packets_per_slave);
+ if (def->bond_params.resend_igmp)
+ YAML_UINT(event, emitter, "resend-igmp", def->bond_params.resend_igmp);
+ if (def->bond_params.arp_ip_targets) {
+ YAML_SCALAR_PLAIN(event, emitter, "arp-ip-targets");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < def->bond_params.arp_ip_targets->len; ++i)
+ YAML_SCALAR_PLAIN(event, emitter, g_array_index(def->bond_params.arp_ip_targets, char*, i));
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_bridge_params(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def, const GArray *interfaces)
+{
+ if (def->custom_bridging) {
+ gboolean has_path_cost = FALSE;
+ gboolean has_port_priority = FALSE;
+ for (unsigned i = 0; i < interfaces->len; ++i) {
+ NetplanNetDefinition *nd = g_array_index(interfaces, NetplanNetDefinition*, i);
+ has_path_cost = has_path_cost || !!nd->bridge_params.path_cost;
+ has_port_priority = has_port_priority || !!nd->bridge_params.port_priority;
+ if (has_path_cost && has_port_priority)
+ break; /* no need to continue this check */
+ }
+
+ YAML_SCALAR_PLAIN(event, emitter, "parameters");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "ageing-time", def->bridge_params.ageing_time);
+ YAML_STRING(event, emitter, "forward-delay", def->bridge_params.forward_delay);
+ YAML_STRING(event, emitter, "hello-time", def->bridge_params.hello_time);
+ YAML_STRING(event, emitter, "max-age", def->bridge_params.max_age);
+ if (def->bridge_params.priority)
+ YAML_UINT(event, emitter, "priority", def->bridge_params.priority);
+ if (!def->bridge_params.stp)
+ YAML_STRING_PLAIN(event, emitter, "stp", "false");
+
+ if (has_port_priority) {
+ YAML_SCALAR_PLAIN(event, emitter, "port-priority");
+ YAML_MAPPING_OPEN(event, emitter);
+ for (unsigned i = 0; i < interfaces->len; ++i) {
+ NetplanNetDefinition *nd = g_array_index(interfaces, NetplanNetDefinition*, i);
+ if (nd->bridge_params.port_priority) {
+ YAML_UINT(event, emitter, nd->id, nd->bridge_params.port_priority);
+ }
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+
+ if (has_path_cost) {
+ YAML_SCALAR_PLAIN(event, emitter, "path-cost");
+ YAML_MAPPING_OPEN(event, emitter);
+ for (unsigned i = 0; i < interfaces->len; ++i) {
+ NetplanNetDefinition *nd = g_array_index(interfaces, NetplanNetDefinition*, i);
+ if (nd->bridge_params.path_cost) {
+ YAML_UINT(event, emitter, nd->id, nd->bridge_params.path_cost);
+ }
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_modem_params(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ /* some modem settings to auto-detect GSM vs CDMA connections */
+ if (def->modem_params.auto_config)
+ YAML_STRING_PLAIN(event, emitter, "auto-config", "true");
+ YAML_STRING(event, emitter, "apn", def->modem_params.apn);
+ YAML_STRING(event, emitter, "device-id", def->modem_params.device_id);
+ YAML_STRING(event, emitter, "network-id", def->modem_params.network_id);
+ YAML_STRING(event, emitter, "pin", def->modem_params.pin);
+ YAML_STRING(event, emitter, "sim-id", def->modem_params.sim_id);
+ YAML_STRING(event, emitter, "sim-operator-id", def->modem_params.sim_operator_id);
+ YAML_STRING(event, emitter, "username", def->modem_params.username);
+ YAML_STRING(event, emitter, "password", def->modem_params.password);
+ YAML_STRING(event, emitter, "number", def->modem_params.number);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+typedef struct {
+ yaml_event_t* event;
+ yaml_emitter_t* emitter;
+} _passthrough_handler_data;
+
+static void
+_passthrough_handler(GQuark key_id, gpointer value, gpointer user_data)
+{
+ _passthrough_handler_data *d = user_data;
+ const gchar* key = g_quark_to_string(key_id);
+ YAML_STRING(d->event, d->emitter, key, value);
+error: return; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_backend_settings(yaml_event_t* event, yaml_emitter_t* emitter, NetplanBackendSettings s) {
+ if (s.nm.uuid || s.nm.name || s.nm.passthrough) {
+ YAML_SCALAR_PLAIN(event, emitter, "networkmanager");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "uuid", s.nm.uuid);
+ YAML_STRING(event, emitter, "name", s.nm.name);
+ if (s.nm.passthrough) {
+ YAML_SCALAR_PLAIN(event, emitter, "passthrough");
+ YAML_MAPPING_OPEN(event, emitter);
+ _passthrough_handler_data d;
+ d.event = event;
+ d.emitter = emitter;
+ g_datalist_foreach(&s.nm.passthrough, _passthrough_handler, &d);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_access_points(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ NetplanWifiAccessPoint* ap = NULL;
+ GHashTableIter iter;
+ gpointer key, value;
+ YAML_SCALAR_PLAIN(event, emitter, "access-points");
+ YAML_MAPPING_OPEN(event, emitter);
+ g_hash_table_iter_init(&iter, def->access_points);
+ while (g_hash_table_iter_next(&iter, &key, &value)) {
+ ap = value;
+ YAML_SCALAR_QUOTED(event, emitter, ap->ssid);
+ YAML_MAPPING_OPEN(event, emitter);
+ if (ap->hidden)
+ YAML_STRING_PLAIN(event, emitter, "hidden", "true");
+ YAML_STRING(event, emitter, "bssid", ap->bssid);
+ if (ap->band == NETPLAN_WIFI_BAND_5) {
+ YAML_STRING(event, emitter, "band", "5GHz");
+ } else if (ap->band == NETPLAN_WIFI_BAND_24) {
+ YAML_STRING(event, emitter, "band", "2.4GHz");
+ }
+ if (ap->channel)
+ YAML_UINT(event, emitter, "channel", ap->channel);
+ if (ap->has_auth)
+ write_auth(event, emitter, ap->auth);
+ if (ap->mode != NETPLAN_WIFI_MODE_INFRASTRUCTURE)
+ YAML_STRING(event, emitter, "mode", netplan_wifi_mode_to_str[ap->mode]);
+ if (!write_backend_settings(event, emitter, ap->backend_settings)) goto error;
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_addresses(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ YAML_SCALAR_PLAIN(event, emitter, "addresses");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ if (def->address_options) {
+ for (unsigned i = 0; i < def->address_options->len; ++i) {
+ NetplanAddressOptions *opts = g_array_index(def->address_options, NetplanAddressOptions*, i);
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_SCALAR_QUOTED(event, emitter, opts->address);
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "label", opts->label);
+ YAML_STRING(event, emitter, "lifetime", opts->lifetime);
+ YAML_MAPPING_CLOSE(event, emitter);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ }
+ if (def->ip4_addresses) {
+ for (unsigned i = 0; i < def->ip4_addresses->len; ++i)
+ YAML_SCALAR_QUOTED(event, emitter, g_array_index(def->ip4_addresses, char*, i));
+ }
+ if (def->ip6_addresses) {
+ for (unsigned i = 0; i < def->ip6_addresses->len; ++i)
+ YAML_SCALAR_QUOTED(event, emitter, g_array_index(def->ip6_addresses, char*, i));
+ }
+
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_nameservers(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ YAML_SCALAR_PLAIN(event, emitter, "nameservers");
+ YAML_MAPPING_OPEN(event, emitter);
+ if (def->ip4_nameservers || def->ip6_nameservers){
+ YAML_SCALAR_PLAIN(event, emitter, "addresses");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ if (def->ip4_nameservers) {
+ for (unsigned i = 0; i < def->ip4_nameservers->len; ++i)
+ YAML_SCALAR_PLAIN(event, emitter, g_array_index(def->ip4_nameservers, char*, i));
+ }
+ if (def->ip6_nameservers) {
+ for (unsigned i = 0; i < def->ip6_nameservers->len; ++i)
+ YAML_SCALAR_PLAIN(event, emitter, g_array_index(def->ip6_nameservers, char*, i));
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ if (def->search_domains){
+ YAML_SCALAR_PLAIN(event, emitter, "search");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ if (def->search_domains) {
+ for (unsigned i = 0; i < def->search_domains->len; ++i)
+ YAML_SCALAR_PLAIN(event, emitter, g_array_index(def->search_domains, char*, i));
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_dhcp_overrides(yaml_event_t* event, yaml_emitter_t* emitter, const char* key, const NetplanDHCPOverrides data)
+{
+ if ( !data.use_dns
+ || !data.use_ntp
+ || !data.send_hostname
+ || !data.use_hostname
+ || !data.use_mtu
+ || !data.use_routes
+ || data.use_domains
+ || data.hostname
+ || data.metric != NETPLAN_METRIC_UNSPEC) {
+ YAML_SCALAR_PLAIN(event, emitter, key);
+ YAML_MAPPING_OPEN(event, emitter);
+ if (!data.use_dns)
+ YAML_STRING_PLAIN(event, emitter, "use-dns", "false");
+ if (!data.use_ntp)
+ YAML_STRING_PLAIN(event, emitter, "use-ntp", "false");
+ if (!data.send_hostname)
+ YAML_STRING_PLAIN(event, emitter, "send-hostname", "false");
+ if (!data.use_hostname)
+ YAML_STRING_PLAIN(event, emitter, "use-hostname", "false");
+ if (!data.use_mtu)
+ YAML_STRING_PLAIN(event, emitter, "use-mtu", "false");
+ if (!data.use_routes)
+ YAML_STRING_PLAIN(event, emitter, "use-routes", "false");
+ if (data.use_domains)
+ YAML_STRING(event, emitter, "use-domains", data.use_domains);
+ if (data.hostname)
+ YAML_STRING(event, emitter, "hostname", data.hostname);
+ if (data.metric != NETPLAN_METRIC_UNSPEC)
+ YAML_UINT(event, emitter, "route-metric", data.metric);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_tunnel_settings(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ YAML_STRING(event, emitter, "mode", netplan_tunnel_mode_to_str[def->tunnel.mode]);
+ YAML_STRING(event, emitter, "local", def->tunnel.local_ip);
+ YAML_STRING(event, emitter, "remote", def->tunnel.remote_ip);
+ if (def->tunnel.fwmark)
+ YAML_UINT(event, emitter, "mark", def->tunnel.fwmark);
+ if (def->tunnel.port)
+ YAML_UINT(event, emitter, "port", def->tunnel.port);
+ if (def->tunnel_ttl)
+ YAML_UINT(event, emitter, "ttl", def->tunnel_ttl);
+
+ if (def->tunnel.input_key || def->tunnel.output_key || def->tunnel.private_key) {
+ if ( g_strcmp0(def->tunnel.input_key, def->tunnel.output_key) == 0
+ && g_strcmp0(def->tunnel.input_key, def->tunnel.private_key) == 0) {
+ /* use short form if all keys are the same */
+ YAML_STRING(event, emitter, "key", def->tunnel.input_key);
+ } else {
+ YAML_SCALAR_PLAIN(event, emitter, "keys");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "input", def->tunnel.input_key);
+ YAML_STRING(event, emitter, "output", def->tunnel.output_key);
+ YAML_STRING(event, emitter, "private", def->tunnel.private_key);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ }
+
+ /* Wireguard peers */
+ if (def->wireguard_peers && def->wireguard_peers->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "peers");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < def->wireguard_peers->len; ++i) {
+ NetplanWireguardPeer *peer = g_array_index(def->wireguard_peers, NetplanWireguardPeer*, i);
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "endpoint", peer->endpoint);
+ if (peer->keepalive)
+ YAML_UINT(event, emitter, "keepalive", peer->keepalive);
+ if (peer->public_key || peer->preshared_key) {
+ YAML_SCALAR_PLAIN(event, emitter, "keys");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "public", peer->public_key);
+ YAML_STRING(event, emitter, "shared", peer->preshared_key);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ if (peer->allowed_ips && peer->allowed_ips->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "allowed-ips");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < peer->allowed_ips->len; ++i) {
+ char *ip = g_array_index(peer->allowed_ips, char*, i);
+ YAML_SCALAR_QUOTED(event, emitter, ip);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+write_routes(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ if (def->routes && def->routes->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "routes");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < def->routes->len; ++i) {
+ YAML_MAPPING_OPEN(event, emitter);
+ NetplanIPRoute *r = g_array_index(def->routes, NetplanIPRoute*, i);
+ if (r->type && g_strcmp0(r->type, "unicast") != 0)
+ YAML_STRING(event, emitter, "type", r->type);
+ if (r->scope && g_strcmp0(r->scope, "global") != 0)
+ YAML_STRING(event, emitter, "scope", r->scope);
+ if (r->metric != NETPLAN_METRIC_UNSPEC)
+ YAML_UINT(event, emitter, "metric", r->metric);
+ if (r->table != NETPLAN_ROUTE_TABLE_UNSPEC)
+ YAML_UINT(event, emitter, "table", r->table);
+ if (r->mtubytes)
+ YAML_UINT(event, emitter, "mtu", r->mtubytes);
+ if (r->congestion_window)
+ YAML_UINT(event, emitter, "congestion-window", r->congestion_window);
+ if (r->advertised_receive_window)
+ YAML_UINT(event, emitter, "advertised-receive-window", r->advertised_receive_window);
+ if (r->onlink)
+ YAML_STRING(event, emitter, "on-link", "true");
+ if (r->from)
+ YAML_STRING(event, emitter, "from", r->from);
+ if (r->to)
+ YAML_STRING(event, emitter, "to", r->to);
+ if (r->via)
+ YAML_STRING(event, emitter, "via", r->via);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ if (def->ip_rules && def->ip_rules->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "routing-policy");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < def->ip_rules->len; ++i) {
+ NetplanIPRule *r = g_array_index(def->ip_rules, NetplanIPRule*, i);
+ YAML_MAPPING_OPEN(event, emitter);
+ if (r->table != NETPLAN_ROUTE_TABLE_UNSPEC)
+ YAML_UINT(event, emitter, "table", r->table);
+ if (r->priority != NETPLAN_IP_RULE_PRIO_UNSPEC)
+ YAML_UINT(event, emitter, "priority", r->priority);
+ if (r->tos != NETPLAN_IP_RULE_TOS_UNSPEC)
+ YAML_UINT(event, emitter, "type-of-service", r->tos);
+ if (r->fwmark != NETPLAN_IP_RULE_FW_MARK_UNSPEC)
+ YAML_UINT(event, emitter, "mark", r->fwmark);
+ if (r->from)
+ YAML_STRING(event, emitter, "from", r->from);
+ if (r->to)
+ YAML_STRING(event, emitter, "to", r->to);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+static gboolean
+has_openvswitch(const NetplanOVSSettings* ovs, NetplanBackend backend, GHashTable *ovs_ports) {
+ return (ovs_ports && g_hash_table_size(ovs_ports) > 0)
+ || (ovs->external_ids && g_hash_table_size(ovs->external_ids) > 0)
+ || (ovs->other_config && g_hash_table_size(ovs->other_config) > 0)
+ || ovs->lacp
+ || ovs->fail_mode
+ || ovs->mcast_snooping
+ || ovs->rstp
+ || ovs->protocols
+ || (ovs->ssl.ca_certificate || ovs->ssl.client_certificate || ovs->ssl.client_key)
+ || (ovs->controller.connection_mode || ovs->controller.addresses)
+ || backend == NETPLAN_BACKEND_OVS;
+}
+
+static gboolean
+write_openvswitch(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanOVSSettings* ovs, NetplanBackend backend, GHashTable *ovs_ports)
+{
+ GHashTableIter iter;
+ gpointer key, value;
+
+ if (has_openvswitch(ovs, backend, ovs_ports)) {
+ YAML_SCALAR_PLAIN(event, emitter, "openvswitch");
+ YAML_MAPPING_OPEN(event, emitter);
+
+ if (ovs_ports && g_hash_table_size(ovs_ports) > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "ports");
+ YAML_SEQUENCE_OPEN(event, emitter);
+
+ g_hash_table_iter_init(&iter, ovs_ports);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ YAML_SEQUENCE_OPEN(event, emitter);
+ YAML_SCALAR_PLAIN(event, emitter, key);
+ YAML_SCALAR_PLAIN(event, emitter, value);
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ g_hash_table_iter_remove(&iter);
+ }
+
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ if (ovs->external_ids && g_hash_table_size(ovs->external_ids) > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "external-ids");
+ YAML_MAPPING_OPEN(event, emitter);
+ g_hash_table_iter_init(&iter, ovs->external_ids);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ YAML_STRING(event, emitter, key, value);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ if (ovs->other_config && g_hash_table_size(ovs->other_config) > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "other-config");
+ YAML_MAPPING_OPEN(event, emitter);
+ g_hash_table_iter_init(&iter, ovs->other_config);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ YAML_STRING(event, emitter, key, value);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_STRING(event, emitter, "lacp", ovs->lacp);
+ YAML_STRING(event, emitter, "fail-mode", ovs->fail_mode);
+ if (ovs->mcast_snooping)
+ YAML_STRING_PLAIN(event, emitter, "mcast-snooping", "true");
+ if (ovs->rstp)
+ YAML_STRING_PLAIN(event, emitter, "rstp", "true");
+ if (ovs->protocols && ovs->protocols->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "protocols");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < ovs->protocols->len; ++i) {
+ const gchar *proto = g_array_index(ovs->protocols, gchar*, i);
+ YAML_SCALAR_PLAIN(event, emitter, proto);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ if (ovs->ssl.ca_certificate || ovs->ssl.client_certificate || ovs->ssl.client_key) {
+ YAML_SCALAR_PLAIN(event, emitter, "ssl");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "ca-cert", ovs->ssl.ca_certificate);
+ YAML_STRING(event, emitter, "certificate", ovs->ssl.client_certificate);
+ YAML_STRING(event, emitter, "private-key", ovs->ssl.client_key);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ if (ovs->controller.connection_mode || ovs->controller.addresses) {
+ YAML_SCALAR_PLAIN(event, emitter, "controller");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING(event, emitter, "connection-mode", ovs->controller.connection_mode);
+ if (ovs->controller.addresses) {
+ YAML_SCALAR_PLAIN(event, emitter, "addresses");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < ovs->controller.addresses->len; ++i) {
+ const gchar *addr = g_array_index(ovs->controller.addresses, gchar*, i);
+ YAML_SCALAR_QUOTED(event, emitter, addr);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+
+ return TRUE;
+error: return FALSE; // LCOV_EXCL_LINE
+}
+
+void
+_serialize_yaml(yaml_event_t* event, yaml_emitter_t* emitter, const NetplanNetDefinition* def)
+{
+ GArray* tmp_arr = NULL;
+ GHashTableIter iter;
+ gpointer key, value;
+
+ YAML_SCALAR_PLAIN(event, emitter, def->id);
+ YAML_MAPPING_OPEN(event, emitter);
+ if (def->type == NETPLAN_DEF_TYPE_VLAN && def->sriov_vlan_filter) {
+ YAML_STRING_PLAIN(event, emitter, "renderer", "sriov");
+ } else if (def->backend == NETPLAN_BACKEND_NM) {
+ YAML_STRING_PLAIN(event, emitter, "renderer", "NetworkManager");
+ } else if (def->backend == NETPLAN_BACKEND_NETWORKD) {
+ YAML_STRING_PLAIN(event, emitter, "renderer", "networkd");
+ }
+
+ if (def->has_match)
+ write_match(event, emitter, def);
+
+ /* Do not try to handle "unknown" connection types (full fallback/passthrough) */
+ if (def->type == NETPLAN_DEF_TYPE_NM)
+ goto only_passthrough;
+
+ if (def->optional)
+ YAML_STRING_PLAIN(event, emitter, "optional", "true");
+ if (def->critical)
+ YAML_STRING_PLAIN(event, emitter, "critical", "true");
+
+ if (def->ip4_addresses || def->ip6_addresses || def->address_options)
+ write_addresses(event, emitter, def);
+ if (def->ip4_nameservers || def->ip6_nameservers || def->search_domains)
+ write_nameservers(event, emitter, def);
+
+ YAML_STRING_PLAIN(event, emitter, "gateway4", def->gateway4);
+ YAML_STRING_PLAIN(event, emitter, "gateway6", def->gateway6);
+
+ if (g_strcmp0(def->dhcp_identifier, "duid") != 0)
+ YAML_STRING(event, emitter, "dhcp-identifier", def->dhcp_identifier);
+ if (def->dhcp4) {
+ YAML_STRING_PLAIN(event, emitter, "dhcp4", "true");
+ write_dhcp_overrides(event, emitter, "dhcp4-overrides", def->dhcp4_overrides);
+ }
+ if (def->dhcp6) {
+ YAML_STRING_PLAIN(event, emitter, "dhcp6", "true");
+ write_dhcp_overrides(event, emitter, "dhcp6-overrides", def->dhcp6_overrides);
+ }
+ if (def->accept_ra == NETPLAN_RA_MODE_ENABLED) {
+ YAML_STRING_PLAIN(event, emitter, "accept-ra", "true");
+ } else if (def->accept_ra == NETPLAN_RA_MODE_DISABLED) {
+ YAML_STRING_PLAIN(event, emitter, "accept-ra", "false");
+ }
+
+ YAML_STRING(event, emitter, "macaddress", def->set_mac);
+ YAML_STRING(event, emitter, "set-name", def->set_name);
+ YAML_STRING(event, emitter, "ipv6-address-generation", netplan_addr_gen_mode_to_str[def->ip6_addr_gen_mode]);
+ YAML_STRING(event, emitter, "ipv6-address-token", def->ip6_addr_gen_token);
+ if (def->ip6_privacy)
+ YAML_STRING_PLAIN(event, emitter, "ipv6-privacy", "true");
+ if (def->ipv6_mtubytes)
+ YAML_UINT(event, emitter, "ipv6-mtu", def->ipv6_mtubytes);
+ if (def->mtubytes)
+ YAML_UINT(event, emitter, "mtu", def->mtubytes);
+ if (def->emit_lldp)
+ YAML_STRING_PLAIN(event, emitter, "emit-lldp", "true");
+
+ if (def->has_auth)
+ write_auth(event, emitter, def->auth);
+ /* activation-mode */
+ if (def->activation_mode)
+ YAML_STRING(event, emitter, "activation-mode", def->activation_mode);
+
+ /* SR-IOV */
+ if (def->sriov_link)
+ YAML_STRING(event, emitter, "link", def->sriov_link->id);
+ if (def->sriov_explicit_vf_count < G_MAXUINT)
+ YAML_UINT(event, emitter, "virtual-function-count", def->sriov_explicit_vf_count);
+
+ /* Search interfaces */
+ if (def->type == NETPLAN_DEF_TYPE_BRIDGE || def->type == NETPLAN_DEF_TYPE_BOND) {
+ tmp_arr = g_array_new(FALSE, FALSE, sizeof(NetplanNetDefinition*));
+ g_hash_table_iter_init(&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ NetplanNetDefinition *nd = (NetplanNetDefinition *) value;
+ if (g_strcmp0(nd->bond, def->id) == 0 || g_strcmp0(nd->bridge, def->id) == 0)
+ g_array_append_val(tmp_arr, nd);
+ }
+ if (tmp_arr->len > 0) {
+ YAML_SCALAR_PLAIN(event, emitter, "interfaces");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ for (unsigned i = 0; i < tmp_arr->len; ++i) {
+ NetplanNetDefinition *nd = g_array_index(tmp_arr, NetplanNetDefinition*, i);
+ YAML_SCALAR_PLAIN(event, emitter, nd->id);
+ }
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+ write_bond_params(event, emitter, def);
+ write_bridge_params(event, emitter, def, tmp_arr);
+ g_array_free(tmp_arr, TRUE);
+ }
+
+ /* Routes */
+ if (def->routes || def->ip_rules) {
+ write_routes(event, emitter, def);
+ }
+
+ /* VLAN settings */
+ if (def->type == NETPLAN_DEF_TYPE_VLAN) {
+ if (def->vlan_id != G_MAXUINT)
+ YAML_UINT(event, emitter, "id", def->vlan_id);
+ if (def->vlan_link)
+ YAML_STRING_PLAIN(event, emitter, "link", def->vlan_link->id);
+ }
+
+ /* Tunnel settings */
+ if (def->type == NETPLAN_DEF_TYPE_TUNNEL) {
+ write_tunnel_settings(event, emitter, def);
+ }
+
+ /* wake-on-lan */
+ if (def->wake_on_lan)
+ YAML_STRING_PLAIN(event, emitter, "wakeonlan", "true");
+
+ if (def->wowlan && def->wowlan != NETPLAN_WIFI_WOWLAN_DEFAULT) {
+ YAML_SCALAR_PLAIN(event, emitter, "wakeonwlan");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ /* XXX: make sure to extend if NetplanWifiWowlanFlag is extended */
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_ANY)
+ YAML_SCALAR_PLAIN(event, emitter, "any");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_DISCONNECT)
+ YAML_SCALAR_PLAIN(event, emitter, "disconnect");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_MAGIC)
+ YAML_SCALAR_PLAIN(event, emitter, "magic_pkt");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_GTK_REKEY_FAILURE)
+ YAML_SCALAR_PLAIN(event, emitter, "gtk_rekey_failure");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_EAP_IDENTITY_REQ)
+ YAML_SCALAR_PLAIN(event, emitter, "eap_identity_req");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_4WAY_HANDSHAKE)
+ YAML_SCALAR_PLAIN(event, emitter, "four_way_handshake");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_RFKILL_RELEASE)
+ YAML_SCALAR_PLAIN(event, emitter, "rfkill_release");
+ if (def->wowlan & NETPLAN_WIFI_WOWLAN_TCP)
+ YAML_SCALAR_PLAIN(event, emitter, "tcp");
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ if (def->optional_addresses) {
+ YAML_SCALAR_PLAIN(event, emitter, "optional-addresses");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ if (def->optional_addresses & NETPLAN_OPTIONAL_IPV4_LL)
+ YAML_SCALAR_PLAIN(event, emitter, "ipv4-ll")
+ if (def->optional_addresses & NETPLAN_OPTIONAL_IPV6_RA)
+ YAML_SCALAR_PLAIN(event, emitter, "ipv6-ra")
+ if (def->optional_addresses & NETPLAN_OPTIONAL_DHCP4)
+ YAML_SCALAR_PLAIN(event, emitter, "dhcp4")
+ if (def->optional_addresses & NETPLAN_OPTIONAL_DHCP6)
+ YAML_SCALAR_PLAIN(event, emitter, "dhcp6")
+ if (def->optional_addresses & NETPLAN_OPTIONAL_STATIC)
+ YAML_SCALAR_PLAIN(event, emitter, "static")
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ /* Generate "link-local" if it differs from the default: "[ ipv6 ]" */
+ if (!(def->linklocal.ipv6 && !def->linklocal.ipv4)) {
+ YAML_SCALAR_PLAIN(event, emitter, "link-local");
+ YAML_SEQUENCE_OPEN(event, emitter);
+ if (def->linklocal.ipv4)
+ YAML_SCALAR_PLAIN(event, emitter, "ipv4");
+ if (def->linklocal.ipv6)
+ YAML_SCALAR_PLAIN(event, emitter, "ipv6");
+ YAML_SEQUENCE_CLOSE(event, emitter);
+ }
+
+ write_openvswitch(event, emitter, &def->ovs_settings, def->backend, NULL);
+
+ if (def->type == NETPLAN_DEF_TYPE_MODEM)
+ write_modem_params(event, emitter, def);
+
+ if (def->type == NETPLAN_DEF_TYPE_WIFI)
+ if (!write_access_points(event, emitter, def)) goto error;
+
+ /* Handle devices in full fallback/passthrough mode (i.e. 'nm-devices') */
+only_passthrough:
+ if (!write_backend_settings(event, emitter, def->backend_settings)) goto error;
+
+ /* Close remaining mappings */
+ YAML_MAPPING_CLOSE(event, emitter);
+ return;
+
+ // LCOV_EXCL_START
+error:
+ g_warning("Error generating YAML: %s", emitter->problem);
+ return;
+ // LCOV_EXCL_STOP
+}
+
+/**
+ * Generate the Netplan YAML configuration for the selected netdef
+ * @def: NetplanNetDefinition (as pointer), the data to be serialized
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ */
+void
+write_netplan_conf(const NetplanNetDefinition* def, const char* rootdir)
+{
+ g_autofree gchar *filename = NULL;
+ g_autofree gchar *path = NULL;
+
+ /* NetworkManager produces one file per connection profile
+ * It's 90-* to be higher priority than the default 70-netplan-set.yaml */
+ if (def->backend_settings.nm.uuid)
+ filename = g_strconcat("90-NM-", def->backend_settings.nm.uuid, ".yaml", NULL);
+ else
+ filename = g_strconcat("10-netplan-", def->id, ".yaml", NULL);
+ path = g_build_path(G_DIR_SEPARATOR_S, rootdir ?: G_DIR_SEPARATOR_S, "etc", "netplan", filename, NULL);
+
+ /* Start rendering YAML output */
+ yaml_emitter_t emitter_data;
+ yaml_event_t event_data;
+ yaml_emitter_t* emitter = &emitter_data;
+ yaml_event_t* event = &event_data;
+ FILE *output = fopen(path, "wb");
+
+ YAML_OUT_START(event, emitter, output);
+ /* build the netplan boilerplate YAML structure */
+ YAML_SCALAR_PLAIN(event, emitter, "network");
+ YAML_MAPPING_OPEN(event, emitter);
+ YAML_STRING_PLAIN(event, emitter, "version", "2");
+
+ if (netplan_def_type_to_str[def->type]) {
+ YAML_SCALAR_PLAIN(event, emitter, netplan_def_type_to_str[def->type]);
+ YAML_MAPPING_OPEN(event, emitter);
+ _serialize_yaml(event, emitter, def);
+ YAML_MAPPING_CLOSE(event, emitter);
+ }
+
+ /* Close remaining mappings */
+ YAML_MAPPING_CLOSE(event, emitter);
+
+ /* Tear down the YAML emitter */
+ YAML_OUT_STOP(event, emitter);
+ fclose(output);
+ return;
+
+ // LCOV_EXCL_START
+error:
+ g_warning("Error generating YAML: %s", emitter->problem);
+ yaml_emitter_delete(emitter);
+ fclose(output);
+ // LCOV_EXCL_STOP
+}
+
+gboolean
+contains_netdef_type(gpointer key, gpointer value, gpointer user_data)
+{
+ NetplanNetDefinition *nd = value;
+ NetplanDefType *type = user_data;
+ return nd->type == *type;
+}
+
+/**
+ * Generate the Netplan YAML configuration for all currently parsed netdefs
+ * @file_hint: Name hint for the generated output YAML file
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ */
+void
+write_netplan_conf_full(const char* file_hint, const char* rootdir)
+{
+ g_autofree gchar *path = NULL;
+ GHashTable *ovs_ports = NULL;
+ GHashTableIter iter;
+ gpointer key, value;
+
+ gboolean global_values = ( (netplan_get_global_backend() != NETPLAN_BACKEND_NONE)
+ || has_openvswitch(&ovs_settings_global, NETPLAN_BACKEND_NONE, NULL));
+
+ if (global_values || (netdefs && g_hash_table_size(netdefs) > 0)) {
+ path = g_build_path(G_DIR_SEPARATOR_S, rootdir ?: G_DIR_SEPARATOR_S, "etc", "netplan", file_hint, NULL);
+
+ /* Start rendering YAML output */
+ yaml_emitter_t emitter_data;
+ yaml_event_t event_data;
+ yaml_emitter_t* emitter = &emitter_data;
+ yaml_event_t* event = &event_data;
+ FILE *output = fopen(path, "wb");
+
+ YAML_OUT_START(event, emitter, output);
+ /* build the netplan boilerplate YAML structure */
+ YAML_SCALAR_PLAIN(event, emitter, "network");
+ YAML_MAPPING_OPEN(event, emitter);
+ /* We support version 2 only, currently */
+ YAML_STRING_PLAIN(event, emitter, "version", "2");
+
+ if (netplan_get_global_backend() == NETPLAN_BACKEND_NM) {
+ YAML_STRING_PLAIN(event, emitter, "renderer", "NetworkManager");
+ } else if (netplan_get_global_backend() == NETPLAN_BACKEND_NETWORKD) {
+ YAML_STRING_PLAIN(event, emitter, "renderer", "networkd");
+ }
+
+ /* Go through the netdefs type-by-type */
+ if (netdefs && g_hash_table_size(netdefs) > 0) {
+ for (unsigned i = 0; i < NETPLAN_DEF_TYPE_MAX_; ++i) {
+ /* Per-netdef config */
+ if (g_hash_table_find(netdefs, contains_netdef_type, &i)) {
+ if (netplan_def_type_to_str[i]) {
+ YAML_SCALAR_PLAIN(event, emitter, netplan_def_type_to_str[i]);
+ YAML_MAPPING_OPEN(event, emitter);
+ g_hash_table_iter_init(&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ NetplanNetDefinition *def = (NetplanNetDefinition *) value;
+ if (def->type == i)
+ _serialize_yaml(event, emitter, def);
+ }
+ YAML_MAPPING_CLOSE(event, emitter);
+ } else if (i == NETPLAN_DEF_TYPE_PORT) {
+ g_hash_table_iter_init(&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value)) {
+ NetplanNetDefinition *def = (NetplanNetDefinition *) value;
+ if (def->type == i) {
+ if (!ovs_ports)
+ ovs_ports = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
+ /* Insert each port:peer combination only once */
+ if (!g_hash_table_lookup(ovs_ports, def->id))
+ g_hash_table_insert(ovs_ports, g_strdup(def->peer), g_strdup(def->id));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ write_openvswitch(event, emitter, &ovs_settings_global, NETPLAN_BACKEND_NONE, ovs_ports);
+
+ /* Close remaining mappings */
+ YAML_MAPPING_CLOSE(event, emitter);
+
+ /* Tear down the YAML emitter */
+ YAML_OUT_STOP(event, emitter);
+ fclose(output);
+ return;
+
+ // LCOV_EXCL_START
+error:
+ g_warning("Error generating YAML: %s", emitter->problem);
+ yaml_emitter_delete(emitter);
+ fclose(output);
+ // LCOV_EXCL_STOP
+ } else {
+ g_debug("No data/netdefs to serialize into YAML.");
+ }
+}
+
+/* XXX: implement the following functions, once needed:
+void write_netplan_conf_finish(const char* rootdir)
+void cleanup_netplan_conf(const char* rootdir)
+*/
+
+/**
+ * Helper function for testing only
+ */
+void
+_write_netplan_conf(const char* netdef_id, const char* rootdir)
+{
+ GHashTable* ht = NULL;
+ const NetplanNetDefinition* def = NULL;
+ ht = netplan_finish_parse(NULL);
+ def = g_hash_table_lookup(ht, netdef_id);
+ write_netplan_conf(def, rootdir);
+}
diff --git a/src/netplan.h b/src/netplan.h
new file mode 100644
index 0000000..7c5706a
--- /dev/null
+++ b/src/netplan.h
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2021 Canonical, Ltd.
+ * Author: Lukas Märdian <slyon@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "parse.h"
+
+#define YAML_MAPPING_OPEN(event_ptr, emitter_ptr) \
+{ \
+ yaml_mapping_start_event_initialize(event_ptr, NULL, (yaml_char_t *)YAML_MAP_TAG, 1, YAML_BLOCK_MAPPING_STYLE); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+#define YAML_MAPPING_CLOSE(event_ptr, emitter_ptr) \
+{ \
+ yaml_mapping_end_event_initialize(event_ptr); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+#define YAML_SEQUENCE_OPEN(event_ptr, emitter_ptr) \
+{ \
+ yaml_sequence_start_event_initialize(event_ptr, NULL, (yaml_char_t *)YAML_SEQ_TAG, 1, YAML_BLOCK_SEQUENCE_STYLE); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+#define YAML_SEQUENCE_CLOSE(event_ptr, emitter_ptr) \
+{ \
+ yaml_sequence_end_event_initialize(event_ptr); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+#define YAML_SCALAR_PLAIN(event_ptr, emitter_ptr, scalar) \
+{ \
+ yaml_scalar_event_initialize(event_ptr, NULL, (yaml_char_t *)YAML_STR_TAG, (yaml_char_t *)scalar, strlen(scalar), 1, 0, YAML_PLAIN_SCALAR_STYLE); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+/* Implicit plain and quoted tags, double quoted style */
+#define YAML_SCALAR_QUOTED(event_ptr, emitter_ptr, scalar) \
+{ \
+ yaml_scalar_event_initialize(event_ptr, NULL, (yaml_char_t *)YAML_STR_TAG, (yaml_char_t *)scalar, strlen(scalar), 1, 1, YAML_DOUBLE_QUOTED_SCALAR_STYLE); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+}
+#define YAML_STRING(event_ptr, emitter_ptr, key, value_ptr) \
+{ \
+ if (value_ptr) { \
+ YAML_SCALAR_PLAIN(event_ptr, emitter_ptr, key); \
+ YAML_SCALAR_QUOTED(event_ptr, emitter_ptr, value_ptr); \
+ } \
+}
+#define YAML_STRING_PLAIN(event_ptr, emitter_ptr, key, value_ptr) \
+{ \
+ if (value_ptr) { \
+ YAML_SCALAR_PLAIN(event_ptr, emitter_ptr, key); \
+ YAML_SCALAR_PLAIN(event_ptr, emitter_ptr, value_ptr); \
+ } \
+}
+#define YAML_UINT(event_ptr, emitter_ptr, key, value) \
+{ \
+ tmp = g_strdup_printf("%u", value); \
+ YAML_STRING_PLAIN(event_ptr, emitter_ptr, key, tmp); \
+ g_free(tmp); \
+}
+
+/* open YAML emitter, document, stream and initial mapping */
+#define YAML_OUT_START(event_ptr, emitter_ptr, file) \
+{ \
+ yaml_emitter_initialize(emitter_ptr); \
+ yaml_emitter_set_output_file(emitter_ptr, file); \
+ yaml_stream_start_event_initialize(event_ptr, YAML_UTF8_ENCODING); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+ yaml_document_start_event_initialize(event_ptr, NULL, NULL, NULL, 1); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+ YAML_MAPPING_OPEN(event_ptr, emitter_ptr); \
+}
+/* close initial YAML mapping, document, stream and emitter */
+#define YAML_OUT_STOP(event_ptr, emitter_ptr) \
+{ \
+ YAML_MAPPING_CLOSE(event_ptr, emitter_ptr); \
+ yaml_document_end_event_initialize(event_ptr, 1); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+ yaml_stream_end_event_initialize(event_ptr); \
+ if (!yaml_emitter_emit(emitter_ptr, event_ptr)) goto error; \
+ yaml_emitter_delete(emitter_ptr); \
+}
+
+static const char* const netplan_def_type_to_str[NETPLAN_DEF_TYPE_MAX_] = {
+ [NETPLAN_DEF_TYPE_NONE] = NULL,
+ [NETPLAN_DEF_TYPE_ETHERNET] = "ethernets",
+ [NETPLAN_DEF_TYPE_WIFI] = "wifis",
+ [NETPLAN_DEF_TYPE_MODEM] = "modems",
+ [NETPLAN_DEF_TYPE_BRIDGE] = "bridges",
+ [NETPLAN_DEF_TYPE_BOND] = "bonds",
+ [NETPLAN_DEF_TYPE_VLAN] = "vlans",
+ [NETPLAN_DEF_TYPE_TUNNEL] = "tunnels",
+ [NETPLAN_DEF_TYPE_PORT] = NULL,
+ [NETPLAN_DEF_TYPE_NM] = "nm-devices",
+};
+
+static const char* const netplan_auth_key_management_type_to_str[NETPLAN_AUTH_KEY_MANAGEMENT_MAX] = {
+ [NETPLAN_AUTH_KEY_MANAGEMENT_NONE] = "none",
+ [NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK] = "psk",
+ [NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP] = "eap",
+ [NETPLAN_AUTH_KEY_MANAGEMENT_8021X] = "802.1x",
+};
+
+static const char* const netplan_auth_eap_method_to_str[NETPLAN_AUTH_EAP_METHOD_MAX] = {
+ [NETPLAN_AUTH_EAP_NONE] = NULL,
+ [NETPLAN_AUTH_EAP_TLS] = "tls",
+ [NETPLAN_AUTH_EAP_PEAP] = "peap",
+ [NETPLAN_AUTH_EAP_TTLS] = "ttls",
+};
+
+static const char* const netplan_tunnel_mode_to_str[NETPLAN_TUNNEL_MODE_MAX_] = {
+ [NETPLAN_TUNNEL_MODE_UNKNOWN] = NULL,
+ [NETPLAN_TUNNEL_MODE_IPIP] = "ipip",
+ [NETPLAN_TUNNEL_MODE_GRE] = "gre",
+ [NETPLAN_TUNNEL_MODE_SIT] = "sit",
+ [NETPLAN_TUNNEL_MODE_ISATAP] = "isatap",
+ [NETPLAN_TUNNEL_MODE_VTI] = "vti",
+ [NETPLAN_TUNNEL_MODE_IP6IP6] = "ip6ip6",
+ [NETPLAN_TUNNEL_MODE_IPIP6] = "ipip6",
+ [NETPLAN_TUNNEL_MODE_IP6GRE] = "ip6gre",
+ [NETPLAN_TUNNEL_MODE_VTI6] = "vti6",
+ [NETPLAN_TUNNEL_MODE_GRETAP] = "gretap",
+ [NETPLAN_TUNNEL_MODE_IP6GRETAP] = "ip6gretap",
+ [NETPLAN_TUNNEL_MODE_WIREGUARD] = "wireguard",
+};
+
+static const char* const netplan_addr_gen_mode_to_str[NETPLAN_ADDRGEN_MAX] = {
+ [NETPLAN_ADDRGEN_DEFAULT] = NULL,
+ [NETPLAN_ADDRGEN_EUI64] = "eui64",
+ [NETPLAN_ADDRGEN_STABLEPRIVACY] = "stable-privacy"
+};
+
+void write_netplan_conf(const NetplanNetDefinition* def, const char* rootdir);
diff --git a/src/netplan.script b/src/netplan.script
new file mode 100755
index 0000000..3c131f6
--- /dev/null
+++ b/src/netplan.script
@@ -0,0 +1,23 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+'''netplan command line'''
+
+from netplan import Netplan
+
+netplan = Netplan()
+netplan.main()
diff --git a/src/networkd.c b/src/networkd.c
new file mode 100644
index 0000000..8884286
--- /dev/null
+++ b/src/networkd.c
@@ -0,0 +1,1131 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <ctype.h>
+#include <errno.h>
+#include <sys/stat.h>
+
+#include <glib.h>
+#include <glib/gprintf.h>
+
+#include "networkd.h"
+#include "parse.h"
+#include "util.h"
+#include "validation.h"
+
+/**
+ * Append WiFi frequencies to wpa_supplicant's freq_list=
+ */
+static void
+wifi_append_freq(gpointer key, gpointer value, gpointer user_data)
+{
+ GString* s = user_data;
+ g_string_append_printf(s, "%d ", GPOINTER_TO_INT(value));
+}
+
+/**
+ * append wowlan_triggers= string for wpa_supplicant.conf
+ */
+static void
+append_wifi_wowlan_flags(NetplanWifiWowlanFlag flag, GString* str) {
+ if (flag & NETPLAN_WIFI_WOWLAN_TYPES[0].flag || flag >= NETPLAN_WIFI_WOWLAN_TCP) {
+ g_fprintf(stderr, "ERROR: unsupported wowlan_triggers mask: 0x%x\n", flag);
+ exit(1);
+ }
+ for (unsigned i = 0; NETPLAN_WIFI_WOWLAN_TYPES[i].name != NULL; ++i) {
+ if (flag & NETPLAN_WIFI_WOWLAN_TYPES[i].flag) {
+ g_string_append_printf(str, "%s ", NETPLAN_WIFI_WOWLAN_TYPES[i].name);
+ }
+ }
+ /* replace trailing space with newline */
+ str = g_string_overwrite(str, str->len-1, "\n");
+}
+
+/**
+ * Append [Match] section of @def to @s.
+ */
+static void
+append_match_section(const NetplanNetDefinition* def, GString* s, gboolean match_rename)
+{
+ /* Note: an empty [Match] section is interpreted as matching all devices,
+ * which is what we want for the simple case that you only have one device
+ * (of the given type) */
+
+ g_string_append(s, "[Match]\n");
+ if (def->match.driver)
+ g_string_append_printf(s, "Driver=%s\n", def->match.driver);
+ if (def->match.mac)
+ g_string_append_printf(s, "MACAddress=%s\n", def->match.mac);
+ /* name matching is special: if the .link renames the interface, the
+ * .network has to use the renamed one, otherwise the original one */
+ if (!match_rename && def->match.original_name)
+ g_string_append_printf(s, "OriginalName=%s\n", def->match.original_name);
+ if (match_rename) {
+ if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL)
+ g_string_append_printf(s, "Name=%s\n", def->id);
+ else if (def->set_name)
+ g_string_append_printf(s, "Name=%s\n", def->set_name);
+ else if (def->match.original_name)
+ g_string_append_printf(s, "Name=%s\n", def->match.original_name);
+ }
+
+ /* Workaround for bugs LP: #1804861 and LP: #1888726: something outputs
+ * netplan config that includes using the MAC of the first phy member of a
+ * bond as default value for the MAC of the bond device itself. This is
+ * evil, it's an optional field and networkd knows what to do if the MAC
+ * isn't specified; but work around this by adding an arbitrary additional
+ * match condition on Path= for the phys. This way, hopefully setting a MTU
+ * on the phy does not bleed over to bond/bridge and any further virtual
+ * devices (VLANs?) on top of it.
+ * Make sure to add the extra match only if we're matching by MAC
+ * already and dealing with a bond, bridge or vlan.
+ */
+ if (def->bond || def->bridge || def->has_vlans) {
+ /* update if we support new device types */
+ if (def->match.mac)
+ g_string_append(s, "Type=!vlan bond bridge\n");
+ }
+}
+
+static void
+write_bridge_params(GString* s, const NetplanNetDefinition* def)
+{
+ GString *params = NULL;
+
+ if (def->custom_bridging) {
+ params = g_string_sized_new(200);
+
+ if (def->bridge_params.ageing_time)
+ g_string_append_printf(params, "AgeingTimeSec=%s\n", def->bridge_params.ageing_time);
+ if (def->bridge_params.priority)
+ g_string_append_printf(params, "Priority=%u\n", def->bridge_params.priority);
+ if (def->bridge_params.forward_delay)
+ g_string_append_printf(params, "ForwardDelaySec=%s\n", def->bridge_params.forward_delay);
+ if (def->bridge_params.hello_time)
+ g_string_append_printf(params, "HelloTimeSec=%s\n", def->bridge_params.hello_time);
+ if (def->bridge_params.max_age)
+ g_string_append_printf(params, "MaxAgeSec=%s\n", def->bridge_params.max_age);
+ g_string_append_printf(params, "STP=%s\n", def->bridge_params.stp ? "true" : "false");
+
+ g_string_append_printf(s, "\n[Bridge]\n%s", params->str);
+
+ g_string_free(params, TRUE);
+ }
+}
+
+static void
+write_tunnel_params(GString* s, const NetplanNetDefinition* def)
+{
+ GString *params = NULL;
+
+ params = g_string_sized_new(200);
+
+ g_string_printf(params, "Independent=true\n");
+ if (def->tunnel.mode == NETPLAN_TUNNEL_MODE_IPIP6 || def->tunnel.mode == NETPLAN_TUNNEL_MODE_IP6IP6)
+ g_string_append_printf(params, "Mode=%s\n", tunnel_mode_to_string(def->tunnel.mode));
+ g_string_append_printf(params, "Local=%s\n", def->tunnel.local_ip);
+ g_string_append_printf(params, "Remote=%s\n", def->tunnel.remote_ip);
+ if (def->tunnel_ttl)
+ g_string_append_printf(params, "TTL=%u\n", def->tunnel_ttl);
+ if (def->tunnel.input_key)
+ g_string_append_printf(params, "InputKey=%s\n", def->tunnel.input_key);
+ if (def->tunnel.output_key)
+ g_string_append_printf(params, "OutputKey=%s\n", def->tunnel.output_key);
+
+ g_string_append_printf(s, "\n[Tunnel]\n%s", params->str);
+ g_string_free(params, TRUE);
+}
+
+static void
+write_wireguard_params(GString* s, const NetplanNetDefinition* def)
+{
+ GString *params = NULL;
+ params = g_string_sized_new(200);
+
+ g_assert(def->tunnel.private_key);
+ /* The "PrivateKeyFile=" setting is available as of systemd-netwokrd v242+
+ * Base64 encoded PrivateKey= or absolute PrivateKeyFile= fields are mandatory.
+ *
+ * The key was already validated via validate_tunnel_grammar(), but we need
+ * to differentiate between base64 key VS absolute path key-file. And a base64
+ * string could (theoretically) start with '/', so we use is_wireguard_key()
+ * as well to check for more specific characteristics (if needed). */
+ if (def->tunnel.private_key[0] == '/' && !is_wireguard_key(def->tunnel.private_key))
+ g_string_append_printf(params, "PrivateKeyFile=%s\n", def->tunnel.private_key);
+ else
+ g_string_append_printf(params, "PrivateKey=%s\n", def->tunnel.private_key);
+
+ if (def->tunnel.port)
+ g_string_append_printf(params, "ListenPort=%u\n", def->tunnel.port);
+ /* This is called FirewallMark= as of systemd v243, but we keep calling it FwMark= for
+ backwards compatibility. FwMark= is still supported, but deprecated:
+ https://github.com/systemd/systemd/pull/12478 */
+ if (def->tunnel.fwmark)
+ g_string_append_printf(params, "FwMark=%u\n", def->tunnel.fwmark);
+
+ g_string_append_printf(s, "\n[WireGuard]\n%s", params->str);
+ g_string_free(params, TRUE);
+
+ for (guint i = 0; i < def->wireguard_peers->len; i++) {
+ NetplanWireguardPeer *peer = g_array_index (def->wireguard_peers, NetplanWireguardPeer*, i);
+ GString *peer_s = g_string_sized_new(200);
+
+ g_string_append_printf(peer_s, "PublicKey=%s\n", peer->public_key);
+ g_string_append(peer_s, "AllowedIPs=");
+ for (guint i = 0; i < peer->allowed_ips->len; ++i) {
+ if (i > 0 )
+ g_string_append_c(peer_s, ',');
+ g_string_append_printf(peer_s, "%s", g_array_index(peer->allowed_ips, char*, i));
+ }
+ g_string_append_c(peer_s, '\n');
+
+ if (peer->keepalive)
+ g_string_append_printf(peer_s, "PersistentKeepalive=%d\n", peer->keepalive);
+ if (peer->endpoint)
+ g_string_append_printf(peer_s, "Endpoint=%s\n", peer->endpoint);
+ /* The key was already validated via validate_tunnel_grammar(), but we need
+ * to differentiate between base64 key VS absolute path key-file. And a base64
+ * string could (theoretically) start with '/', so we use is_wireguard_key()
+ * as well to check for more specific characteristics (if needed). */
+ if (peer->preshared_key) {
+ if (peer->preshared_key[0] == '/' && !is_wireguard_key(peer->preshared_key))
+ g_string_append_printf(peer_s, "PresharedKeyFile=%s\n", peer->preshared_key);
+ else
+ g_string_append_printf(peer_s, "PresharedKey=%s\n", peer->preshared_key);
+ }
+
+ g_string_append_printf(s, "\n[WireGuardPeer]\n%s", peer_s->str);
+ g_string_free(peer_s, TRUE);
+ }
+}
+
+static void
+write_link_file(const NetplanNetDefinition* def, const char* rootdir, const char* path)
+{
+ GString* s = NULL;
+ mode_t orig_umask;
+
+ /* Don't write .link files for virtual devices; they use .netdev instead.
+ * Don't write .link files for MODEM devices, as they aren't supported by networkd.
+ */
+ if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL || def->type == NETPLAN_DEF_TYPE_MODEM)
+ return;
+
+ /* do we need to write a .link file? */
+ if (!def->set_name && !def->wake_on_lan && !def->mtubytes)
+ return;
+
+ /* build file contents */
+ s = g_string_sized_new(200);
+ append_match_section(def, s, FALSE);
+
+ g_string_append(s, "\n[Link]\n");
+ if (def->set_name)
+ g_string_append_printf(s, "Name=%s\n", def->set_name);
+ /* FIXME: Should this be turned from bool to str and support multiple values? */
+ g_string_append_printf(s, "WakeOnLan=%s\n", def->wake_on_lan ? "magic" : "off");
+ if (def->mtubytes)
+ g_string_append_printf(s, "MTUBytes=%u\n", def->mtubytes);
+
+ orig_umask = umask(022);
+ g_string_free_to_file(s, rootdir, path, ".link");
+ umask(orig_umask);
+}
+
+
+static gboolean
+interval_has_suffix(const char* param) {
+ gchar* endptr;
+
+ g_ascii_strtoull(param, &endptr, 10);
+ if (*endptr == '\0')
+ return FALSE;
+
+ return TRUE;
+}
+
+
+static void
+write_bond_parameters(const NetplanNetDefinition* def, GString* s)
+{
+ GString* params = NULL;
+
+ params = g_string_sized_new(200);
+
+ if (def->bond_params.mode)
+ g_string_append_printf(params, "\nMode=%s", def->bond_params.mode);
+ if (def->bond_params.lacp_rate)
+ g_string_append_printf(params, "\nLACPTransmitRate=%s", def->bond_params.lacp_rate);
+ if (def->bond_params.monitor_interval) {
+ g_string_append(params, "\nMIIMonitorSec=");
+ if (interval_has_suffix(def->bond_params.monitor_interval))
+ g_string_append(params, def->bond_params.monitor_interval);
+ else
+ g_string_append_printf(params, "%sms", def->bond_params.monitor_interval);
+ }
+ if (def->bond_params.min_links)
+ g_string_append_printf(params, "\nMinLinks=%d", def->bond_params.min_links);
+ if (def->bond_params.transmit_hash_policy)
+ g_string_append_printf(params, "\nTransmitHashPolicy=%s", def->bond_params.transmit_hash_policy);
+ if (def->bond_params.selection_logic)
+ g_string_append_printf(params, "\nAdSelect=%s", def->bond_params.selection_logic);
+ if (def->bond_params.all_slaves_active)
+ g_string_append_printf(params, "\nAllSlavesActive=%d", def->bond_params.all_slaves_active);
+ if (def->bond_params.arp_interval) {
+ g_string_append(params, "\nARPIntervalSec=");
+ if (interval_has_suffix(def->bond_params.arp_interval))
+ g_string_append(params, def->bond_params.arp_interval);
+ else
+ g_string_append_printf(params, "%sms", def->bond_params.arp_interval);
+ }
+ if (def->bond_params.arp_ip_targets && def->bond_params.arp_ip_targets->len > 0) {
+ g_string_append_printf(params, "\nARPIPTargets=");
+ for (unsigned i = 0; i < def->bond_params.arp_ip_targets->len; ++i) {
+ if (i > 0)
+ g_string_append_printf(params, " ");
+ g_string_append_printf(params, "%s", g_array_index(def->bond_params.arp_ip_targets, char*, i));
+ }
+ }
+ if (def->bond_params.arp_validate)
+ g_string_append_printf(params, "\nARPValidate=%s", def->bond_params.arp_validate);
+ if (def->bond_params.arp_all_targets)
+ g_string_append_printf(params, "\nARPAllTargets=%s", def->bond_params.arp_all_targets);
+ if (def->bond_params.up_delay) {
+ g_string_append(params, "\nUpDelaySec=");
+ if (interval_has_suffix(def->bond_params.up_delay))
+ g_string_append(params, def->bond_params.up_delay);
+ else
+ g_string_append_printf(params, "%sms", def->bond_params.up_delay);
+ }
+ if (def->bond_params.down_delay) {
+ g_string_append(params, "\nDownDelaySec=");
+ if (interval_has_suffix(def->bond_params.down_delay))
+ g_string_append(params, def->bond_params.down_delay);
+ else
+ g_string_append_printf(params, "%sms", def->bond_params.down_delay);
+ }
+ if (def->bond_params.fail_over_mac_policy)
+ g_string_append_printf(params, "\nFailOverMACPolicy=%s", def->bond_params.fail_over_mac_policy);
+ if (def->bond_params.gratuitous_arp)
+ g_string_append_printf(params, "\nGratuitousARP=%d", def->bond_params.gratuitous_arp);
+ /* TODO: add unsolicited_na, not documented as supported by NM. */
+ if (def->bond_params.packets_per_slave)
+ g_string_append_printf(params, "\nPacketsPerSlave=%d", def->bond_params.packets_per_slave);
+ if (def->bond_params.primary_reselect_policy)
+ g_string_append_printf(params, "\nPrimaryReselectPolicy=%s", def->bond_params.primary_reselect_policy);
+ if (def->bond_params.resend_igmp)
+ g_string_append_printf(params, "\nResendIGMP=%d", def->bond_params.resend_igmp);
+ if (def->bond_params.learn_interval)
+ g_string_append_printf(params, "\nLearnPacketIntervalSec=%s", def->bond_params.learn_interval);
+
+ if (params->len)
+ g_string_append_printf(s, "\n[Bond]%s\n", params->str);
+
+ g_string_free(params, TRUE);
+}
+
+static void
+write_netdev_file(const NetplanNetDefinition* def, const char* rootdir, const char* path)
+{
+ GString* s = NULL;
+ mode_t orig_umask;
+
+ g_assert(def->type >= NETPLAN_DEF_TYPE_VIRTUAL);
+
+ if (def->type == NETPLAN_DEF_TYPE_VLAN && def->sriov_vlan_filter) {
+ g_debug("%s is defined as a hardware SR-IOV filtered VLAN, postponing creation", def->id);
+ return;
+ }
+
+ /* build file contents */
+ s = g_string_sized_new(200);
+ g_string_append_printf(s, "[NetDev]\nName=%s\n", def->id);
+
+ if (def->set_mac)
+ g_string_append_printf(s, "MACAddress=%s\n", def->set_mac);
+ if (def->mtubytes)
+ g_string_append_printf(s, "MTUBytes=%u\n", def->mtubytes);
+
+ switch (def->type) {
+ case NETPLAN_DEF_TYPE_BRIDGE:
+ g_string_append(s, "Kind=bridge\n");
+ write_bridge_params(s, def);
+ break;
+
+ case NETPLAN_DEF_TYPE_BOND:
+ g_string_append(s, "Kind=bond\n");
+ write_bond_parameters(def, s);
+ break;
+
+ case NETPLAN_DEF_TYPE_VLAN:
+ g_string_append_printf(s, "Kind=vlan\n\n[VLAN]\nId=%u\n", def->vlan_id);
+ break;
+
+ case NETPLAN_DEF_TYPE_TUNNEL:
+ switch(def->tunnel.mode) {
+ case NETPLAN_TUNNEL_MODE_GRE:
+ case NETPLAN_TUNNEL_MODE_GRETAP:
+ case NETPLAN_TUNNEL_MODE_IPIP:
+ case NETPLAN_TUNNEL_MODE_IP6GRE:
+ case NETPLAN_TUNNEL_MODE_IP6GRETAP:
+ case NETPLAN_TUNNEL_MODE_SIT:
+ case NETPLAN_TUNNEL_MODE_VTI:
+ case NETPLAN_TUNNEL_MODE_VTI6:
+ case NETPLAN_TUNNEL_MODE_WIREGUARD:
+ g_string_append_printf(s, "Kind=%s\n",
+ tunnel_mode_to_string(def->tunnel.mode));
+ break;
+
+ case NETPLAN_TUNNEL_MODE_IP6IP6:
+ case NETPLAN_TUNNEL_MODE_IPIP6:
+ g_string_append(s, "Kind=ip6tnl\n");
+ break;
+
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+ if (def->tunnel.mode == NETPLAN_TUNNEL_MODE_WIREGUARD)
+ write_wireguard_params(s, def);
+ else
+ write_tunnel_params(s, def);
+ break;
+
+ default: g_assert_not_reached(); // LCOV_EXCL_LINE
+ }
+
+ /* these do not contain secrets and need to be readable by
+ * systemd-networkd - LP: #1736965 */
+ orig_umask = umask(022);
+ g_string_free_to_file(s, rootdir, path, ".netdev");
+ umask(orig_umask);
+}
+
+static void
+write_route(NetplanIPRoute* r, GString* s)
+{
+ const char *to;
+ g_string_append_printf(s, "\n[Route]\n");
+
+ if (g_strcmp0(r->to, "default") == 0)
+ to = get_global_network(r->family);
+ else
+ to = r->to;
+ g_string_append_printf(s, "Destination=%s\n", to);
+
+ if (r->via)
+ g_string_append_printf(s, "Gateway=%s\n", r->via);
+ if (r->from)
+ g_string_append_printf(s, "PreferredSource=%s\n", r->from);
+
+ if (g_strcmp0(r->scope, "global") != 0)
+ g_string_append_printf(s, "Scope=%s\n", r->scope);
+ if (g_strcmp0(r->type, "unicast") != 0)
+ g_string_append_printf(s, "Type=%s\n", r->type);
+ if (r->onlink)
+ g_string_append_printf(s, "GatewayOnlink=true\n");
+ if (r->metric != NETPLAN_METRIC_UNSPEC)
+ g_string_append_printf(s, "Metric=%d\n", r->metric);
+ if (r->table != NETPLAN_ROUTE_TABLE_UNSPEC)
+ g_string_append_printf(s, "Table=%d\n", r->table);
+ if (r->mtubytes != NETPLAN_MTU_UNSPEC)
+ g_string_append_printf(s, "MTUBytes=%u\n", r->mtubytes);
+ if (r->congestion_window != NETPLAN_CONGESTION_WINDOW_UNSPEC)
+ g_string_append_printf(s, "InitialCongestionWindow=%u\n", r->congestion_window);
+ if (r->advertised_receive_window != NETPLAN_ADVERTISED_RECEIVE_WINDOW_UNSPEC)
+ g_string_append_printf(s, "InitialAdvertisedReceiveWindow=%u\n", r->advertised_receive_window);
+}
+
+static void
+write_ip_rule(NetplanIPRule* r, GString* s)
+{
+ g_string_append_printf(s, "\n[RoutingPolicyRule]\n");
+
+ if (r->from)
+ g_string_append_printf(s, "From=%s\n", r->from);
+ if (r->to)
+ g_string_append_printf(s, "To=%s\n", r->to);
+
+ if (r->table != NETPLAN_ROUTE_TABLE_UNSPEC)
+ g_string_append_printf(s, "Table=%d\n", r->table);
+ if (r->priority != NETPLAN_IP_RULE_PRIO_UNSPEC)
+ g_string_append_printf(s, "Priority=%d\n", r->priority);
+ if (r->fwmark != NETPLAN_IP_RULE_FW_MARK_UNSPEC)
+ g_string_append_printf(s, "FirewallMark=%d\n", r->fwmark);
+ if (r->tos != NETPLAN_IP_RULE_TOS_UNSPEC)
+ g_string_append_printf(s, "TypeOfService=%d\n", r->tos);
+}
+
+static void
+write_addr_option(NetplanAddressOptions* o, GString* s)
+{
+ g_string_append_printf(s, "\n[Address]\n");
+ g_assert(o->address);
+ g_string_append_printf(s, "Address=%s\n", o->address);
+
+ if (o->lifetime)
+ g_string_append_printf(s, "PreferredLifetime=%s\n", o->lifetime);
+ if (o->label)
+ g_string_append_printf(s, "Label=%s\n", o->label);
+}
+
+#define DHCP_OVERRIDES_ERROR \
+ "ERROR: %s: networkd requires that %s has the same value in both " \
+ "dhcp4_overrides and dhcp6_overrides\n"
+
+static void
+combine_dhcp_overrides(const NetplanNetDefinition* def, NetplanDHCPOverrides* combined_dhcp_overrides)
+{
+ /* if only one of dhcp4 or dhcp6 is enabled, those overrides are used */
+ if (def->dhcp4 && !def->dhcp6) {
+ *combined_dhcp_overrides = def->dhcp4_overrides;
+ } else if (!def->dhcp4 && def->dhcp6) {
+ *combined_dhcp_overrides = def->dhcp6_overrides;
+ } else {
+ /* networkd doesn't support separately configuring dhcp4 and dhcp6, so
+ * we enforce that they are the same.
+ */
+ if (def->dhcp4_overrides.use_dns != def->dhcp6_overrides.use_dns) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-dns");
+ exit(1);
+ }
+ if (g_strcmp0(def->dhcp4_overrides.use_domains, def->dhcp6_overrides.use_domains) != 0){
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-domains");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.use_ntp != def->dhcp6_overrides.use_ntp) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-ntp");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.send_hostname != def->dhcp6_overrides.send_hostname) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "send-hostname");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.use_hostname != def->dhcp6_overrides.use_hostname) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-hostname");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.use_mtu != def->dhcp6_overrides.use_mtu) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-mtu");
+ exit(1);
+ }
+ if (g_strcmp0(def->dhcp4_overrides.hostname, def->dhcp6_overrides.hostname) != 0) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "hostname");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.metric != def->dhcp6_overrides.metric) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "route-metric");
+ exit(1);
+ }
+ if (def->dhcp4_overrides.use_routes != def->dhcp6_overrides.use_routes) {
+ g_fprintf(stderr, DHCP_OVERRIDES_ERROR, def->id, "use-routes");
+ exit(1);
+ }
+ /* Just use dhcp4_overrides now, since we know they are the same. */
+ *combined_dhcp_overrides = def->dhcp4_overrides;
+ }
+}
+
+/**
+ * Write the needed networkd .network configuration for the selected netplan definition.
+ */
+void
+write_network_file(const NetplanNetDefinition* def, const char* rootdir, const char* path)
+{
+ GString* network = NULL;
+ GString* link = NULL;
+ GString* s = NULL;
+ mode_t orig_umask;
+ gboolean is_optional = def->optional;
+
+ if (def->type == NETPLAN_DEF_TYPE_VLAN && def->sriov_vlan_filter) {
+ g_debug("%s is defined as a hardware SR-IOV filtered VLAN, postponing creation", def->id);
+ return;
+ }
+
+ /* Prepare the [Link] section of the .network file. */
+ link = g_string_sized_new(200);
+
+ /* Prepare the [Network] section */
+ network = g_string_sized_new(200);
+
+ /* The ActivationPolicy setting is available in systemd v248+ */
+ if (def->activation_mode) {
+ const char* mode;
+ if (g_strcmp0(def->activation_mode, "manual") == 0)
+ mode = "manual";
+ else /* "off" */
+ mode = "always-down";
+ g_string_append_printf(link, "ActivationPolicy=%s\n", mode);
+ /* When activation-mode is used we default to being optional.
+ * Otherwise systemd might wait indefinitely for the interface to
+ * become online.
+ */
+ is_optional = TRUE;
+ }
+
+ if (is_optional || def->optional_addresses) {
+ if (is_optional) {
+ g_string_append(link, "RequiredForOnline=no\n");
+ }
+ for (unsigned i = 0; NETPLAN_OPTIONAL_ADDRESS_TYPES[i].name != NULL; ++i) {
+ if (def->optional_addresses & NETPLAN_OPTIONAL_ADDRESS_TYPES[i].flag) {
+ g_string_append_printf(link, "OptionalAddresses=%s\n", NETPLAN_OPTIONAL_ADDRESS_TYPES[i].name);
+ }
+ }
+ }
+
+ if (def->mtubytes)
+ g_string_append_printf(link, "MTUBytes=%u\n", def->mtubytes);
+ if (def->set_mac)
+ g_string_append_printf(link, "MACAddress=%s\n", def->set_mac);
+
+ if (def->emit_lldp)
+ g_string_append(network, "EmitLLDP=true\n");
+
+ if (def->dhcp4 && def->dhcp6)
+ g_string_append(network, "DHCP=yes\n");
+ else if (def->dhcp4)
+ g_string_append(network, "DHCP=ipv4\n");
+ else if (def->dhcp6)
+ g_string_append(network, "DHCP=ipv6\n");
+
+ /* Set link local addressing -- this does not apply to bond and bridge
+ * member interfaces, which always get it disabled.
+ */
+ if (!def->bond && !def->bridge && (def->linklocal.ipv4 || def->linklocal.ipv6)) {
+ if (def->linklocal.ipv4 && def->linklocal.ipv6)
+ g_string_append(network, "LinkLocalAddressing=yes\n");
+ else if (def->linklocal.ipv4)
+ g_string_append(network, "LinkLocalAddressing=ipv4\n");
+ else if (def->linklocal.ipv6)
+ g_string_append(network, "LinkLocalAddressing=ipv6\n");
+ } else {
+ g_string_append(network, "LinkLocalAddressing=no\n");
+ }
+
+ if (def->ip4_addresses)
+ for (unsigned i = 0; i < def->ip4_addresses->len; ++i)
+ g_string_append_printf(network, "Address=%s\n", g_array_index(def->ip4_addresses, char*, i));
+ if (def->ip6_addresses)
+ for (unsigned i = 0; i < def->ip6_addresses->len; ++i)
+ g_string_append_printf(network, "Address=%s\n", g_array_index(def->ip6_addresses, char*, i));
+ if (def->ip6_addr_gen_token) {
+ g_string_append_printf(network, "IPv6Token=static:%s\n", def->ip6_addr_gen_token);
+ } else if (def->ip6_addr_gen_mode > NETPLAN_ADDRGEN_EUI64) {
+ /* EUI-64 mode is enabled by default, if no IPv6Token= is specified */
+ /* TODO: Enable stable-privacy mode for networkd, once PR#16618 has been released:
+ * https://github.com/systemd/systemd/pull/16618 */
+ g_fprintf(stderr, "ERROR: %s: ipv6-address-generation mode is not supported by networkd\n", def->id);
+ exit(1);
+ }
+ if (def->accept_ra == NETPLAN_RA_MODE_ENABLED)
+ g_string_append_printf(network, "IPv6AcceptRA=yes\n");
+ else if (def->accept_ra == NETPLAN_RA_MODE_DISABLED)
+ g_string_append_printf(network, "IPv6AcceptRA=no\n");
+ if (def->ip6_privacy)
+ g_string_append(network, "IPv6PrivacyExtensions=yes\n");
+ if (def->gateway4)
+ g_string_append_printf(network, "Gateway=%s\n", def->gateway4);
+ if (def->gateway6)
+ g_string_append_printf(network, "Gateway=%s\n", def->gateway6);
+ if (def->ip4_nameservers)
+ for (unsigned i = 0; i < def->ip4_nameservers->len; ++i)
+ g_string_append_printf(network, "DNS=%s\n", g_array_index(def->ip4_nameservers, char*, i));
+ if (def->ip6_nameservers)
+ for (unsigned i = 0; i < def->ip6_nameservers->len; ++i)
+ g_string_append_printf(network, "DNS=%s\n", g_array_index(def->ip6_nameservers, char*, i));
+ if (def->search_domains) {
+ g_string_append_printf(network, "Domains=%s", g_array_index(def->search_domains, char*, 0));
+ for (unsigned i = 1; i < def->search_domains->len; ++i)
+ g_string_append_printf(network, " %s", g_array_index(def->search_domains, char*, i));
+ g_string_append(network, "\n");
+ }
+
+ if (def->ipv6_mtubytes) {
+ g_string_append_printf(network, "IPv6MTUBytes=%d\n", def->ipv6_mtubytes);
+ }
+
+ if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL)
+ g_string_append(network, "ConfigureWithoutCarrier=yes\n");
+
+ if (def->bridge && def->backend != NETPLAN_BACKEND_OVS) {
+ g_string_append_printf(network, "Bridge=%s\n", def->bridge);
+
+ if (def->bridge_params.path_cost || def->bridge_params.port_priority)
+ g_string_append_printf(network, "\n[Bridge]\n");
+ if (def->bridge_params.path_cost)
+ g_string_append_printf(network, "Cost=%u\n", def->bridge_params.path_cost);
+ if (def->bridge_params.port_priority)
+ g_string_append_printf(network, "Priority=%u\n", def->bridge_params.port_priority);
+ }
+ if (def->bond && def->backend != NETPLAN_BACKEND_OVS) {
+ g_string_append_printf(network, "Bond=%s\n", def->bond);
+
+ if (def->bond_params.primary_slave)
+ g_string_append_printf(network, "PrimarySlave=true\n");
+ }
+
+ if (def->has_vlans && def->backend != NETPLAN_BACKEND_OVS) {
+ /* iterate over all netdefs to find VLANs attached to us */
+ GList *l = netdefs_ordered;
+ const NetplanNetDefinition* nd;
+ for (; l != NULL; l = l->next) {
+ nd = l->data;
+ if (nd->vlan_link == def && !nd->sriov_vlan_filter)
+ g_string_append_printf(network, "VLAN=%s\n", nd->id);
+ }
+ }
+
+ if (def->routes != NULL) {
+ for (unsigned i = 0; i < def->routes->len; ++i) {
+ NetplanIPRoute* cur_route = g_array_index (def->routes, NetplanIPRoute*, i);
+ write_route(cur_route, network);
+ }
+ }
+ if (def->ip_rules != NULL) {
+ for (unsigned i = 0; i < def->ip_rules->len; ++i) {
+ NetplanIPRule* cur_rule = g_array_index (def->ip_rules, NetplanIPRule*, i);
+ write_ip_rule(cur_rule, network);
+ }
+ }
+
+ if (def->address_options) {
+ for (unsigned i = 0; i < def->address_options->len; ++i) {
+ NetplanAddressOptions* opts = g_array_index(def->address_options, NetplanAddressOptions*, i);
+ write_addr_option(opts, network);
+ }
+ }
+
+ if (def->dhcp4 || def->dhcp6 || def->critical) {
+ /* NetworkManager compatible route metrics */
+ g_string_append(network, "\n[DHCP]\n");
+ }
+
+ if (def->critical)
+ g_string_append_printf(network, "CriticalConnection=true\n");
+
+ if (def->dhcp4 || def->dhcp6) {
+ if (g_strcmp0(def->dhcp_identifier, "duid") != 0)
+ g_string_append_printf(network, "ClientIdentifier=%s\n", def->dhcp_identifier);
+
+ NetplanDHCPOverrides combined_dhcp_overrides;
+ combine_dhcp_overrides(def, &combined_dhcp_overrides);
+
+ if (combined_dhcp_overrides.metric == NETPLAN_METRIC_UNSPEC) {
+ g_string_append_printf(network, "RouteMetric=%i\n", (def->type == NETPLAN_DEF_TYPE_WIFI ? 600 : 100));
+ } else {
+ g_string_append_printf(network, "RouteMetric=%u\n",
+ combined_dhcp_overrides.metric);
+ }
+
+ /* Only set MTU from DHCP if use-mtu dhcp-override is not false. */
+ if (!combined_dhcp_overrides.use_mtu) {
+ /* isc-dhcp dhclient compatible UseMTU, networkd default is to
+ * not accept MTU, which breaks clouds */
+ g_string_append_printf(network, "UseMTU=false\n");
+ } else {
+ g_string_append_printf(network, "UseMTU=true\n");
+ }
+
+ /* Only write DHCP options that differ from the networkd default. */
+ if (!combined_dhcp_overrides.use_routes)
+ g_string_append_printf(network, "UseRoutes=false\n");
+ if (!combined_dhcp_overrides.use_dns)
+ g_string_append_printf(network, "UseDNS=false\n");
+ if (combined_dhcp_overrides.use_domains)
+ g_string_append_printf(network, "UseDomains=%s\n", combined_dhcp_overrides.use_domains);
+ if (!combined_dhcp_overrides.use_ntp)
+ g_string_append_printf(network, "UseNTP=false\n");
+ if (!combined_dhcp_overrides.send_hostname)
+ g_string_append_printf(network, "SendHostname=false\n");
+ if (!combined_dhcp_overrides.use_hostname)
+ g_string_append_printf(network, "UseHostname=false\n");
+ if (combined_dhcp_overrides.hostname)
+ g_string_append_printf(network, "Hostname=%s\n", combined_dhcp_overrides.hostname);
+ }
+
+ if (network->len > 0 || link->len > 0) {
+ s = g_string_sized_new(200);
+ append_match_section(def, s, TRUE);
+
+ if (link->len > 0)
+ g_string_append_printf(s, "\n[Link]\n%s", link->str);
+ if (network->len > 0)
+ g_string_append_printf(s, "\n[Network]\n%s", network->str);
+
+ g_string_free(link, TRUE);
+ g_string_free(network, TRUE);
+
+ /* these do not contain secrets and need to be readable by
+ * systemd-networkd - LP: #1736965 */
+ orig_umask = umask(022);
+ g_string_free_to_file(s, rootdir, path, ".network");
+ umask(orig_umask);
+ }
+}
+
+static void
+write_rules_file(const NetplanNetDefinition* def, const char* rootdir)
+{
+ GString* s = NULL;
+ g_autofree char* path = g_strjoin(NULL, "run/udev/rules.d/99-netplan-", def->id, ".rules", NULL);
+ mode_t orig_umask;
+
+ /* do we need to write a .rules file?
+ * It's only required for reliably setting the name of a physical device
+ * until systemd issue #9006 is resolved. */
+ if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL)
+ return;
+
+ /* Matching by name does not work.
+ *
+ * As far as I can tell, if you match by the name coming out of
+ * initrd, systemd complains that a link file is matching on a
+ * renamed name. If you match by the unstable kernel name, the
+ * device no longer has that name when udevd reads the file, so
+ * the rule doesn't fire. So only support mac and driver. */
+ if (!def->set_name || (!def->match.mac && !def->match.driver))
+ return;
+
+ /* build file contents */
+ s = g_string_sized_new(200);
+
+ g_string_append(s, "SUBSYSTEM==\"net\", ACTION==\"add\", ");
+
+ if (def->match.driver) {
+ g_string_append_printf(s,"DRIVERS==\"%s\", ", def->match.driver);
+ } else {
+ g_string_append(s, "DRIVERS==\"?*\", ");
+ }
+
+ if (def->match.mac)
+ g_string_append_printf(s, "ATTR{address}==\"%s\", ", def->match.mac);
+
+ g_string_append_printf(s, "NAME=\"%s\"\n", def->set_name);
+
+ orig_umask = umask(022);
+ g_string_free_to_file(s, rootdir, path, NULL);
+ umask(orig_umask);
+}
+
+static void
+append_wpa_auth_conf(GString* s, const NetplanAuthenticationSettings* auth, const char* id)
+{
+ switch (auth->key_management) {
+ case NETPLAN_AUTH_KEY_MANAGEMENT_NONE:
+ g_string_append(s, " key_mgmt=NONE\n");
+ break;
+
+ case NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK:
+ g_string_append(s, " key_mgmt=WPA-PSK\n");
+ break;
+
+ case NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP:
+ g_string_append(s, " key_mgmt=WPA-EAP\n");
+ break;
+
+ case NETPLAN_AUTH_KEY_MANAGEMENT_8021X:
+ g_string_append(s, " key_mgmt=IEEE8021X\n");
+ break;
+
+ default: break; // LCOV_EXCL_LINE
+ }
+
+ switch (auth->eap_method) {
+ case NETPLAN_AUTH_EAP_NONE:
+ break;
+
+ case NETPLAN_AUTH_EAP_TLS:
+ g_string_append(s, " eap=TLS\n");
+ break;
+
+ case NETPLAN_AUTH_EAP_PEAP:
+ g_string_append(s, " eap=PEAP\n");
+ break;
+
+ case NETPLAN_AUTH_EAP_TTLS:
+ g_string_append(s, " eap=TTLS\n");
+ break;
+
+ default: break; // LCOV_EXCL_LINE
+ }
+
+ if (auth->identity) {
+ g_string_append_printf(s, " identity=\"%s\"\n", auth->identity);
+ }
+ if (auth->anonymous_identity) {
+ g_string_append_printf(s, " anonymous_identity=\"%s\"\n", auth->anonymous_identity);
+ }
+ if (auth->password) {
+ if (auth->key_management == NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK) {
+ size_t len = strlen(auth->password);
+ if (len == 64) {
+ /* must be a hex-digit key representation */
+ for (unsigned i = 0; i < 64; ++i)
+ if (!isxdigit(auth->password[i])) {
+ g_fprintf(stderr, "ERROR: %s: PSK length of 64 is only supported for hex-digit representation\n", id);
+ exit(1);
+ }
+ /* this is required to be unquoted */
+ g_string_append_printf(s, " psk=%s\n", auth->password);
+ } else if (len < 8 || len > 63) {
+ /* per wpa_supplicant spec, passphrase needs to be between 8
+ and 63 characters */
+ g_fprintf(stderr, "ERROR: %s: ASCII passphrase must be between 8 and 63 characters (inclusive)\n", id);
+ exit(1);
+ } else {
+ g_string_append_printf(s, " psk=\"%s\"\n", auth->password);
+ }
+ } else {
+ if (strncmp(auth->password, "hash:", 5) == 0) {
+ g_string_append_printf(s, " password=%s\n", auth->password);
+ } else {
+ g_string_append_printf(s, " password=\"%s\"\n", auth->password);
+ }
+ }
+ }
+ if (auth->ca_certificate) {
+ g_string_append_printf(s, " ca_cert=\"%s\"\n", auth->ca_certificate);
+ }
+ if (auth->client_certificate) {
+ g_string_append_printf(s, " client_cert=\"%s\"\n", auth->client_certificate);
+ }
+ if (auth->client_key) {
+ g_string_append_printf(s, " private_key=\"%s\"\n", auth->client_key);
+ }
+ if (auth->client_key_password) {
+ g_string_append_printf(s, " private_key_passwd=\"%s\"\n", auth->client_key_password);
+ }
+ if (auth->phase2_auth) {
+ g_string_append_printf(s, " phase2=\"auth=%s\"\n", auth->phase2_auth);
+ }
+
+}
+
+/* netplan-feature: generated-supplicant */
+static void
+write_wpa_unit(const NetplanNetDefinition* def, const char* rootdir)
+{
+ g_autofree gchar *stdouth = NULL;
+
+ stdouth = systemd_escape(def->id);
+
+ GString* s = g_string_new("[Unit]\n");
+ g_autofree char* path = g_strjoin(NULL, "/run/systemd/system/netplan-wpa-", stdouth, ".service", NULL);
+ g_string_append_printf(s, "Description=WPA supplicant for netplan %s\n", stdouth);
+ g_string_append(s, "DefaultDependencies=no\n");
+ g_string_append_printf(s, "Requires=sys-subsystem-net-devices-%s.device\n", stdouth);
+ g_string_append_printf(s, "After=sys-subsystem-net-devices-%s.device\n", stdouth);
+ g_string_append(s, "Before=network.target\nWants=network.target\n\n");
+ g_string_append(s, "[Service]\nType=simple\n");
+ g_string_append_printf(s, "ExecStart=/sbin/wpa_supplicant -c /run/netplan/wpa-%s.conf -i%s", stdouth, stdouth);
+
+ if (def->type != NETPLAN_DEF_TYPE_WIFI) {
+ g_string_append(s, " -Dwired\n");
+ }
+ g_string_free_to_file(s, rootdir, path, NULL);
+}
+
+static void
+write_wpa_conf(const NetplanNetDefinition* def, const char* rootdir)
+{
+ GHashTableIter iter;
+ GString* s = g_string_new("ctrl_interface=/run/wpa_supplicant\n\n");
+ g_autofree char* path = g_strjoin(NULL, "run/netplan/wpa-", def->id, ".conf", NULL);
+ mode_t orig_umask;
+
+ g_debug("%s: Creating wpa_supplicant configuration file %s", def->id, path);
+ if (def->type == NETPLAN_DEF_TYPE_WIFI) {
+ if (def->wowlan && def->wowlan > NETPLAN_WIFI_WOWLAN_DEFAULT) {
+ g_string_append(s, "wowlan_triggers=");
+ append_wifi_wowlan_flags(def->wowlan, s);
+ }
+ NetplanWifiAccessPoint* ap;
+ g_hash_table_iter_init(&iter, def->access_points);
+ while (g_hash_table_iter_next(&iter, NULL, (gpointer) &ap)) {
+ g_string_append_printf(s, "network={\n ssid=\"%s\"\n", ap->ssid);
+ if (ap->bssid) {
+ g_string_append_printf(s, " bssid=%s\n", ap->bssid);
+ }
+ if (ap->hidden) {
+ g_string_append(s, " scan_ssid=1\n");
+ }
+ if (ap->band == NETPLAN_WIFI_BAND_24) {
+ // initialize 2.4GHz frequency hashtable
+ if(!wifi_frequency_24)
+ wifi_get_freq24(1);
+ if (ap->channel) {
+ g_string_append_printf(s, " freq_list=%d\n", wifi_get_freq24(ap->channel));
+ } else {
+ g_string_append_printf(s, " freq_list=");
+ g_hash_table_foreach(wifi_frequency_24, wifi_append_freq, s);
+ // overwrite last whitespace with newline
+ s = g_string_overwrite(s, s->len-1, "\n");
+ }
+ } else if (ap->band == NETPLAN_WIFI_BAND_5) {
+ // initialize 5GHz frequency hashtable
+ if(!wifi_frequency_5)
+ wifi_get_freq5(7);
+ if (ap->channel) {
+ g_string_append_printf(s, " freq_list=%d\n", wifi_get_freq5(ap->channel));
+ } else {
+ g_string_append_printf(s, " freq_list=");
+ g_hash_table_foreach(wifi_frequency_5, wifi_append_freq, s);
+ // overwrite last whitespace with newline
+ s = g_string_overwrite(s, s->len-1, "\n");
+ }
+ }
+ switch (ap->mode) {
+ case NETPLAN_WIFI_MODE_INFRASTRUCTURE:
+ /* default in wpasupplicant */
+ break;
+ case NETPLAN_WIFI_MODE_ADHOC:
+ g_string_append(s, " mode=1\n");
+ break;
+ default:
+ g_fprintf(stderr, "ERROR: %s: %s: networkd does not support this wifi mode\n", def->id, ap->ssid);
+ exit(1);
+ }
+
+ /* wifi auth trumps netdef auth */
+ if (ap->has_auth) {
+ append_wpa_auth_conf(s, &ap->auth, ap->ssid);
+ }
+ else {
+ g_string_append(s, " key_mgmt=NONE\n");
+ }
+ g_string_append(s, "}\n");
+ }
+ }
+ else {
+ /* wired 802.1x auth or similar */
+ g_string_append(s, "network={\n");
+ append_wpa_auth_conf(s, &def->auth, def->id);
+ g_string_append(s, "}\n");
+ }
+
+ /* use tight permissions as this contains secrets */
+ orig_umask = umask(077);
+ g_string_free_to_file(s, rootdir, path, NULL);
+ umask(orig_umask);
+}
+
+/**
+ * Generate networkd configuration in @rootdir/run/systemd/network/ from the
+ * parsed #netdefs.
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ * Returns: TRUE if @def applies to networkd, FALSE otherwise.
+ */
+gboolean
+write_networkd_conf(const NetplanNetDefinition* def, const char* rootdir)
+{
+ g_autofree char* path_base = g_strjoin(NULL, "run/systemd/network/10-netplan-", def->id, NULL);
+
+ /* We want this for all backends when renaming, as *.link and *.rules files are
+ * evaluated by udev, not networkd itself or NetworkManager. */
+ write_link_file(def, rootdir, path_base);
+ write_rules_file(def, rootdir);
+
+ if (def->backend != NETPLAN_BACKEND_NETWORKD) {
+ g_debug("networkd: definition %s is not for us (backend %i)", def->id, def->backend);
+ return FALSE;
+ }
+
+ if (def->type == NETPLAN_DEF_TYPE_MODEM) {
+ g_fprintf(stderr, "ERROR: %s: networkd backend does not support GSM/CDMA modem configuration\n", def->id);
+ exit(1);
+ }
+
+ if (def->type == NETPLAN_DEF_TYPE_WIFI || def->has_auth) {
+ g_autofree char* link = g_strjoin(NULL, rootdir ?: "", "/run/systemd/system/systemd-networkd.service.wants/netplan-wpa-", def->id, ".service", NULL);
+ g_autofree char* slink = g_strjoin(NULL, "/run/systemd/system/netplan-wpa-", def->id, ".service", NULL);
+ if (def->type == NETPLAN_DEF_TYPE_WIFI && def->has_match) {
+ g_fprintf(stderr, "ERROR: %s: networkd backend does not support wifi with match:, only by interface name\n", def->id);
+ exit(1);
+ }
+
+ g_debug("Creating wpa_supplicant config");
+ write_wpa_conf(def, rootdir);
+
+ g_debug("Creating wpa_supplicant unit %s", slink);
+ write_wpa_unit(def, rootdir);
+
+ g_debug("Creating wpa_supplicant service enablement link %s", link);
+ safe_mkdir_p_dir(link);
+
+ if (symlink(slink, link) < 0 && errno != EEXIST) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to create enablement symlink: %m\n");
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+
+ }
+
+ if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL)
+ write_netdev_file(def, rootdir, path_base);
+ write_network_file(def, rootdir, path_base);
+ return TRUE;
+}
+
+/**
+ * Clean up all generated configurations in @rootdir from previous runs.
+ */
+void
+cleanup_networkd_conf(const char* rootdir)
+{
+ unlink_glob(rootdir, "/run/systemd/network/10-netplan-*");
+ unlink_glob(rootdir, "/run/netplan/wpa-*.conf");
+ unlink_glob(rootdir, "/run/systemd/system/systemd-networkd.service.wants/netplan-wpa-*.service");
+ unlink_glob(rootdir, "/run/systemd/system/netplan-wpa-*.service");
+ unlink_glob(rootdir, "/run/udev/rules.d/99-netplan-*");
+ /* Historically (up to v0.98) we had netplan-wpa@*.service files, in case of an
+ * upgraded system, we need to make sure to clean those up. */
+ unlink_glob(rootdir, "/run/systemd/system/systemd-networkd.service.wants/netplan-wpa@*.service");
+}
+
+/**
+ * Create enablement symlink for systemd-networkd.service.
+ */
+void
+enable_networkd(const char* generator_dir)
+{
+ g_autofree char* link = g_build_path(G_DIR_SEPARATOR_S, generator_dir, "multi-user.target.wants", "systemd-networkd.service", NULL);
+ g_debug("We created networkd configuration, adding %s enablement symlink", link);
+ safe_mkdir_p_dir(link);
+ if (symlink("../systemd-networkd.service", link) < 0 && errno != EEXIST) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to create enablement symlink: %m\n");
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+
+ g_autofree char* link2 = g_build_path(G_DIR_SEPARATOR_S, generator_dir, "network-online.target.wants", "systemd-networkd-wait-online.service", NULL);
+ safe_mkdir_p_dir(link2);
+ if (symlink("/lib/systemd/system/systemd-networkd-wait-online.service", link2) < 0 && errno != EEXIST) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to create enablement symlink: %m\n");
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+}
diff --git a/src/networkd.h b/src/networkd.h
new file mode 100644
index 0000000..41ab125
--- /dev/null
+++ b/src/networkd.h
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "parse.h"
+
+gboolean write_networkd_conf(const NetplanNetDefinition* def, const char* rootdir);
+void cleanup_networkd_conf(const char* rootdir);
+void enable_networkd(const char* generator_dir);
+
+void write_network_file(const NetplanNetDefinition* def, const char* rootdir, const char* path);
diff --git a/src/nm.c b/src/nm.c
new file mode 100644
index 0000000..aef15ac
--- /dev/null
+++ b/src/nm.c
@@ -0,0 +1,999 @@
+/*
+ * Copyright (C) 2016-2021 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ * Author: Lukas Märdian <slyon@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/stat.h>
+#include <arpa/inet.h>
+
+#include <glib.h>
+#include <glib/gprintf.h>
+#include <uuid.h>
+
+#include "nm.h"
+#include "parse.h"
+#include "util.h"
+#include "validation.h"
+#include "parse-nm.h"
+
+GString* udev_rules;
+
+/**
+ * Append NM device specifier of @def to @s.
+ */
+static void
+g_string_append_netdef_match(GString* s, const NetplanNetDefinition* def)
+{
+ g_assert(!def->match.driver || def->set_name);
+ if (def->match.mac || def->match.original_name || def->set_name || def->type >= NETPLAN_DEF_TYPE_VIRTUAL) {
+ if (def->match.mac) {
+ g_string_append_printf(s, "mac:%s,", def->match.mac);
+ }
+ /* MAC could change, e.g. for bond slaves. Ignore by interface-name as well */
+ if (def->match.original_name || def->set_name || def->type >= NETPLAN_DEF_TYPE_VIRTUAL) {
+ /* we always have the renamed name here */
+ g_string_append_printf(s, "interface-name:%s,",
+ (def->type >= NETPLAN_DEF_TYPE_VIRTUAL) ? def->id
+ : (def->set_name ?: def->match.original_name));
+ }
+ } else {
+ /* no matches → match all devices of that type */
+ switch (def->type) {
+ case NETPLAN_DEF_TYPE_ETHERNET:
+ g_string_append(s, "type:ethernet,");
+ break;
+ /* This cannot be reached with just NM and networkd backends, as
+ * networkd does not support wifi and thus we'll never blacklist a
+ * wifi device from NM. This would become relevant with another
+ * wifi-supporting backend, but until then this just spoils 100%
+ * code coverage.
+ case NETPLAN_DEF_TYPE_WIFI:
+ g_string_append(s, "type:wifi");
+ break;
+ */
+
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+ }
+}
+
+/**
+ * Infer if this is a modem netdef of type GSM.
+ * This is done by checking for certain modem_params, which are only
+ * applicable to GSM connections.
+ */
+static const gboolean
+modem_is_gsm(const NetplanNetDefinition* def)
+{
+ if ( def->modem_params.apn
+ || def->modem_params.auto_config
+ || def->modem_params.device_id
+ || def->modem_params.network_id
+ || def->modem_params.pin
+ || def->modem_params.sim_id
+ || def->modem_params.sim_operator_id)
+ return TRUE;
+
+ return FALSE;
+}
+
+/**
+ * Return NM "type=" string.
+ */
+static const char*
+type_str(const NetplanNetDefinition* def)
+{
+ const NetplanDefType type = def->type;
+ switch (type) {
+ case NETPLAN_DEF_TYPE_ETHERNET:
+ return "ethernet";
+ case NETPLAN_DEF_TYPE_MODEM:
+ if (modem_is_gsm(def))
+ return "gsm";
+ else
+ return "cdma";
+ case NETPLAN_DEF_TYPE_WIFI:
+ return "wifi";
+ case NETPLAN_DEF_TYPE_BRIDGE:
+ return "bridge";
+ case NETPLAN_DEF_TYPE_BOND:
+ return "bond";
+ case NETPLAN_DEF_TYPE_VLAN:
+ return "vlan";
+ case NETPLAN_DEF_TYPE_TUNNEL:
+ if (def->tunnel.mode == NETPLAN_TUNNEL_MODE_WIREGUARD)
+ return "wireguard";
+ return "ip-tunnel";
+ case NETPLAN_DEF_TYPE_NM:
+ /* needs to be overriden by passthrough "connection.type" setting */
+ return NULL;
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+}
+
+/**
+ * Return NM wifi "mode=" string.
+ */
+static const char*
+wifi_mode_str(const NetplanWifiMode mode)
+{
+ switch (mode) {
+ case NETPLAN_WIFI_MODE_INFRASTRUCTURE:
+ return "infrastructure";
+ case NETPLAN_WIFI_MODE_ADHOC:
+ return "adhoc";
+ case NETPLAN_WIFI_MODE_AP:
+ return "ap";
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+}
+
+/**
+ * Return NM wifi "band=" string.
+ */
+static const char*
+wifi_band_str(const NetplanWifiBand band)
+{
+ switch (band) {
+ case NETPLAN_WIFI_BAND_5:
+ return "a";
+ case NETPLAN_WIFI_BAND_24:
+ return "bg";
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+}
+
+/**
+ * Return NM addr-gen-mode string.
+ */
+static const char*
+addr_gen_mode_str(const NetplanAddrGenMode mode)
+{
+ switch (mode) {
+ case NETPLAN_ADDRGEN_EUI64:
+ return "0";
+ case NETPLAN_ADDRGEN_STABLEPRIVACY:
+ return "1";
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+}
+
+static void
+write_search_domains(const NetplanNetDefinition* def, const char* group, GKeyFile *kf)
+{
+ if (def->search_domains) {
+ const gchar* list[def->search_domains->len];
+ for (unsigned i = 0; i < def->search_domains->len; ++i)
+ list[i] = g_array_index(def->search_domains, char*, i);
+ g_key_file_set_string_list(kf, group, "dns-search", list, def->search_domains->len);
+ }
+}
+
+static void
+write_routes(const NetplanNetDefinition* def, GKeyFile *kf, int family)
+{
+ const gchar* group = NULL;
+ gchar* tmp_key = NULL;
+ GString* tmp_val = NULL;
+
+ if (family == AF_INET)
+ group = "ipv4";
+ else if (family == AF_INET6)
+ group = "ipv6";
+ g_assert(group != NULL);
+
+ if (def->routes != NULL) {
+ for (unsigned i = 0, j = 1; i < def->routes->len; ++i) {
+ const NetplanIPRoute *cur_route = g_array_index(def->routes, NetplanIPRoute*, i);
+ const char *destination;
+
+ if (cur_route->family != family)
+ continue;
+
+ if (g_strcmp0(cur_route->to, "default") == 0)
+ destination = get_global_network(family);
+ else
+ destination = cur_route->to;
+
+ if (cur_route->type && g_ascii_strcasecmp(cur_route->type, "unicast") != 0) {
+ g_fprintf(stderr, "ERROR: %s: NetworkManager only supports unicast routes\n", def->id);
+ exit(1);
+ }
+
+ if (!g_strcmp0(cur_route->scope, "global")) {
+ /* For IPv6 addresses, kernel and NetworkManager don't support a scope.
+ * For IPv4 addresses, NetworkManager determines the scope of addresses on its own
+ * ("link" for addresses without gateway, "global" for addresses with next-hop). */
+ g_debug("%s: NetworkManager does not support setting a scope for routes, it will auto-detect them.", def->id);
+ } else if (cur_route->scope) {
+ /* Error out if scope is not set to its default value of 'global' */
+ g_fprintf(stderr, "ERROR: %s: NetworkManager does not support setting a scope for routes\n", def->id);
+ exit(1);
+ }
+
+ tmp_key = g_strdup_printf("route%d", j);
+ tmp_val = g_string_new(NULL);
+ g_string_printf(tmp_val, "%s,%s", destination, cur_route->via);
+ if (cur_route->metric != NETPLAN_METRIC_UNSPEC)
+ g_string_append_printf(tmp_val, ",%d", cur_route->metric);
+ g_key_file_set_string(kf, group, tmp_key, tmp_val->str);
+ g_free(tmp_key);
+ g_string_free(tmp_val, TRUE);
+
+ if ( cur_route->onlink
+ || cur_route->advertised_receive_window
+ || cur_route->congestion_window
+ || cur_route->mtubytes
+ || cur_route->table != NETPLAN_ROUTE_TABLE_UNSPEC
+ || cur_route->from) {
+ tmp_key = g_strdup_printf("route%d_options", j);
+ tmp_val = g_string_new(NULL);
+ if (cur_route->onlink) {
+ /* onlink for IPv6 addresses is only supported since nm-1.18.0. */
+ g_string_append_printf(tmp_val, "onlink=true,");
+ }
+ if (cur_route->advertised_receive_window != NETPLAN_ADVERTISED_RECEIVE_WINDOW_UNSPEC)
+ g_string_append_printf(tmp_val, "initrwnd=%u,", cur_route->advertised_receive_window);
+ if (cur_route->congestion_window != NETPLAN_CONGESTION_WINDOW_UNSPEC)
+ g_string_append_printf(tmp_val, "initcwnd=%u,", cur_route->congestion_window);
+ if (cur_route->mtubytes != NETPLAN_MTU_UNSPEC)
+ g_string_append_printf(tmp_val, "mtu=%u,", cur_route->mtubytes);
+ if (cur_route->table != NETPLAN_ROUTE_TABLE_UNSPEC)
+ g_string_append_printf(tmp_val, "table=%u,", cur_route->table);
+ if (cur_route->from)
+ g_string_append_printf(tmp_val, "src=%s,", cur_route->from);
+ tmp_val->str[tmp_val->len - 1] = '\0'; //remove trailing comma
+ g_key_file_set_string(kf, group, tmp_key, tmp_val->str);
+ g_free(tmp_key);
+ g_string_free(tmp_val, TRUE);
+ }
+ j++;
+ }
+ }
+}
+
+static void
+write_bond_parameters(const NetplanNetDefinition* def, GKeyFile *kf)
+{
+ GString* tmp_val = NULL;
+ if (def->bond_params.mode)
+ g_key_file_set_string(kf, "bond", "mode", def->bond_params.mode);
+ if (def->bond_params.lacp_rate)
+ g_key_file_set_string(kf, "bond", "lacp_rate", def->bond_params.lacp_rate);
+ if (def->bond_params.monitor_interval)
+ g_key_file_set_string(kf, "bond", "miimon", def->bond_params.monitor_interval);
+ if (def->bond_params.min_links)
+ g_key_file_set_integer(kf, "bond", "min_links", def->bond_params.min_links);
+ if (def->bond_params.transmit_hash_policy)
+ g_key_file_set_string(kf, "bond", "xmit_hash_policy", def->bond_params.transmit_hash_policy);
+ if (def->bond_params.selection_logic)
+ g_key_file_set_string(kf, "bond", "ad_select", def->bond_params.selection_logic);
+ if (def->bond_params.all_slaves_active)
+ g_key_file_set_integer(kf, "bond", "all_slaves_active", def->bond_params.all_slaves_active);
+ if (def->bond_params.arp_interval)
+ g_key_file_set_string(kf, "bond", "arp_interval", def->bond_params.arp_interval);
+ if (def->bond_params.arp_ip_targets) {
+ tmp_val = g_string_new(NULL);
+ for (unsigned i = 0; i < def->bond_params.arp_ip_targets->len; ++i) {
+ if (i > 0)
+ g_string_append_printf(tmp_val, ",");
+ g_string_append_printf(tmp_val, "%s", g_array_index(def->bond_params.arp_ip_targets, char*, i));
+ }
+ g_key_file_set_string(kf, "bond", "arp_ip_target", tmp_val->str);
+ g_string_free(tmp_val, TRUE);
+ }
+ if (def->bond_params.arp_validate)
+ g_key_file_set_string(kf, "bond", "arp_validate", def->bond_params.arp_validate);
+ if (def->bond_params.arp_all_targets)
+ g_key_file_set_string(kf, "bond", "arp_all_targets", def->bond_params.arp_all_targets);
+ if (def->bond_params.up_delay)
+ g_key_file_set_string(kf, "bond", "updelay", def->bond_params.up_delay);
+ if (def->bond_params.down_delay)
+ g_key_file_set_string(kf, "bond", "downdelay", def->bond_params.down_delay);
+ if (def->bond_params.fail_over_mac_policy)
+ g_key_file_set_string(kf, "bond", "fail_over_mac", def->bond_params.fail_over_mac_policy);
+ if (def->bond_params.gratuitous_arp) {
+ g_key_file_set_integer(kf, "bond", "num_grat_arp", def->bond_params.gratuitous_arp);
+ /* Work around issue in NM where unset unsolicited_na will overwrite num_grat_arp:
+ * https://github.com/NetworkManager/NetworkManager/commit/42b0bef33c77a0921590b2697f077e8ea7805166 */
+ g_key_file_set_integer(kf, "bond", "num_unsol_na", def->bond_params.gratuitous_arp);
+ }
+ if (def->bond_params.packets_per_slave)
+ g_key_file_set_integer(kf, "bond", "packets_per_slave", def->bond_params.packets_per_slave);
+ if (def->bond_params.primary_reselect_policy)
+ g_key_file_set_string(kf, "bond", "primary_reselect", def->bond_params.primary_reselect_policy);
+ if (def->bond_params.resend_igmp)
+ g_key_file_set_integer(kf, "bond", "resend_igmp", def->bond_params.resend_igmp);
+ if (def->bond_params.learn_interval)
+ g_key_file_set_string(kf, "bond", "lp_interval", def->bond_params.learn_interval);
+ if (def->bond_params.primary_slave)
+ g_key_file_set_string(kf, "bond", "primary", def->bond_params.primary_slave);
+}
+
+static void
+write_bridge_params(const NetplanNetDefinition* def, GKeyFile *kf)
+{
+ if (def->custom_bridging) {
+ if (def->bridge_params.ageing_time)
+ g_key_file_set_string(kf, "bridge", "ageing-time", def->bridge_params.ageing_time);
+ if (def->bridge_params.priority)
+ g_key_file_set_uint64(kf, "bridge", "priority", def->bridge_params.priority);
+ if (def->bridge_params.forward_delay)
+ g_key_file_set_string(kf, "bridge", "forward-delay", def->bridge_params.forward_delay);
+ if (def->bridge_params.hello_time)
+ g_key_file_set_string(kf, "bridge", "hello-time", def->bridge_params.hello_time);
+ if (def->bridge_params.max_age)
+ g_key_file_set_string(kf, "bridge", "max-age", def->bridge_params.max_age);
+ g_key_file_set_boolean(kf, "bridge", "stp", def->bridge_params.stp);
+ }
+}
+
+static void
+write_wireguard_params(const NetplanNetDefinition* def, GKeyFile *kf)
+{
+ gchar* tmp_group = NULL;
+ g_assert(def->tunnel.private_key);
+
+ /* The key was already validated via validate_tunnel_grammar(), but we need
+ * to differentiate between base64 key VS absolute path key-file. And a base64
+ * string could (theoretically) start with '/', so we use is_wireguard_key()
+ * as well to check for more specific characteristics (if needed). */
+ if (def->tunnel.private_key[0] == '/' && !is_wireguard_key(def->tunnel.private_key)) {
+ g_fprintf(stderr, "%s: private key needs to be base64 encoded when using the NM backend\n", def->id);
+ exit(1);
+ } else
+ g_key_file_set_string(kf, "wireguard", "private-key", def->tunnel.private_key);
+
+ if (def->tunnel.port)
+ g_key_file_set_uint64(kf, "wireguard", "listen-port", def->tunnel.port);
+ if (def->tunnel.fwmark)
+ g_key_file_set_uint64(kf, "wireguard", "fwmark", def->tunnel.fwmark);
+
+ for (guint i = 0; i < def->wireguard_peers->len; i++) {
+ NetplanWireguardPeer *peer = g_array_index (def->wireguard_peers, NetplanWireguardPeer*, i);
+ g_assert(peer->public_key);
+ tmp_group = g_strdup_printf("wireguard-peer.%s", peer->public_key);
+
+ if (peer->keepalive)
+ g_key_file_set_integer(kf, tmp_group, "persistent-keepalive", peer->keepalive);
+ if (peer->endpoint)
+ g_key_file_set_string(kf, tmp_group, "endpoint", peer->endpoint);
+
+ /* The key was already validated via validate_tunnel_grammar(), but we need
+ * to differentiate between base64 key VS absolute path key-file. And a base64
+ * string could (theoretically) start with '/', so we use is_wireguard_key()
+ * as well to check for more specific characteristics (if needed). */
+ if (peer->preshared_key) {
+ if (peer->preshared_key[0] == '/' && !is_wireguard_key(peer->preshared_key)) {
+ g_fprintf(stderr, "%s: shared key needs to be base64 encoded when using the NM backend\n", def->id);
+ exit(1);
+ } else {
+ g_key_file_set_value(kf, tmp_group, "preshared-key", peer->preshared_key);
+ g_key_file_set_uint64(kf, tmp_group, "preshared-key-flags", 0);
+ }
+ }
+ if (peer->allowed_ips && peer->allowed_ips->len > 0) {
+ const gchar* list[peer->allowed_ips->len];
+ for (guint j = 0; j < peer->allowed_ips->len; ++j)
+ list[j] = g_array_index(peer->allowed_ips, char*, j);
+ g_key_file_set_string_list(kf, tmp_group, "allowed-ips", list, peer->allowed_ips->len);
+ }
+ g_free(tmp_group);
+ }
+}
+
+static void
+write_tunnel_params(const NetplanNetDefinition* def, GKeyFile *kf)
+{
+ g_key_file_set_integer(kf, "ip-tunnel", "mode", def->tunnel.mode);
+ g_key_file_set_string(kf, "ip-tunnel", "local", def->tunnel.local_ip);
+ g_key_file_set_string(kf, "ip-tunnel", "remote", def->tunnel.remote_ip);
+ if (def->tunnel_ttl)
+ g_key_file_set_uint64(kf, "ip-tunnel", "ttl", def->tunnel_ttl);
+ if (def->tunnel.input_key)
+ g_key_file_set_string(kf, "ip-tunnel", "input-key", def->tunnel.input_key);
+ if (def->tunnel.output_key)
+ g_key_file_set_string(kf, "ip-tunnel", "output-key", def->tunnel.output_key);
+}
+
+static void
+write_dot1x_auth_parameters(const NetplanAuthenticationSettings* auth, GKeyFile *kf)
+{
+ if (auth->eap_method == NETPLAN_AUTH_EAP_NONE)
+ return;
+
+ switch (auth->eap_method) {
+ case NETPLAN_AUTH_EAP_TLS:
+ g_key_file_set_string(kf, "802-1x", "eap", "tls");
+ break;
+ case NETPLAN_AUTH_EAP_PEAP:
+ g_key_file_set_string(kf, "802-1x", "eap", "peap");
+ break;
+ case NETPLAN_AUTH_EAP_TTLS:
+ g_key_file_set_string(kf, "802-1x", "eap", "ttls");
+ break;
+ default: break; // LCOV_EXCL_LINE
+ }
+
+ if (auth->identity)
+ g_key_file_set_string(kf, "802-1x", "identity", auth->identity);
+ if (auth->anonymous_identity)
+ g_key_file_set_string(kf, "802-1x", "anonymous-identity", auth->anonymous_identity);
+ if (auth->password && auth->key_management != NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK)
+ g_key_file_set_string(kf, "802-1x", "password", auth->password);
+ if (auth->ca_certificate)
+ g_key_file_set_string(kf, "802-1x", "ca-cert", auth->ca_certificate);
+ if (auth->client_certificate)
+ g_key_file_set_string(kf, "802-1x", "client-cert", auth->client_certificate);
+ if (auth->client_key)
+ g_key_file_set_string(kf, "802-1x", "private-key", auth->client_key);
+ if (auth->client_key_password)
+ g_key_file_set_string(kf, "802-1x", "private-key-password", auth->client_key_password);
+ if (auth->phase2_auth)
+ g_key_file_set_string(kf, "802-1x", "phase2-auth", auth->phase2_auth);
+}
+
+static void
+write_wifi_auth_parameters(const NetplanAuthenticationSettings* auth, GKeyFile *kf)
+{
+ if (auth->key_management == NETPLAN_AUTH_KEY_MANAGEMENT_NONE)
+ return;
+
+ switch (auth->key_management) {
+ case NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK:
+ g_key_file_set_string(kf, "wifi-security", "key-mgmt", "wpa-psk");
+ if (auth->password)
+ g_key_file_set_string(kf, "wifi-security", "psk", auth->password);
+ break;
+ case NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP:
+ g_key_file_set_string(kf, "wifi-security", "key-mgmt", "wpa-eap");
+ break;
+ case NETPLAN_AUTH_KEY_MANAGEMENT_8021X:
+ g_key_file_set_string(kf, "wifi-security", "key-mgmt", "ieee8021x");
+ break;
+ default: break; // LCOV_EXCL_LINE
+ }
+
+ write_dot1x_auth_parameters(auth, kf);
+}
+
+static void
+maybe_generate_uuid(NetplanNetDefinition* def)
+{
+ if (uuid_is_null(def->uuid))
+ uuid_generate(def->uuid);
+}
+
+/**
+ * Special handling for passthrough mode: read key-value pairs from
+ * "backend_settings.nm.passthrough" and inject them into the keyfile as-is.
+ */
+static void
+write_fallback_key_value(GQuark key_id, gpointer value, gpointer user_data)
+{
+ GKeyFile *kf = user_data;
+ gchar* val = value;
+ /* Group name may contain dots, but key name may not.
+ * The "tc" group is a special case, where it is the other way around, e.g.:
+ * tc->qdisc.root
+ * tc->tfilter.ffff: */
+ const gchar* key = g_quark_to_string(key_id);
+ gchar **group_key = g_strsplit(key, ".", -1);
+ guint len = g_strv_length(group_key);
+ g_autofree gchar* old_key = NULL;
+ gboolean has_key = FALSE;
+ g_autofree gchar* k = NULL;
+ g_autofree gchar* group = NULL;
+ if (!g_strcmp0(group_key[0], "tc") && len > 2) {
+ k = g_strconcat(group_key[1], ".", group_key[2], NULL);
+ group = g_strdup(group_key[0]);
+ } else {
+ k = group_key[len-1];
+ group_key[len-1] = NULL; //remove key from array
+ group = g_strjoinv(".", group_key); //re-combine group parts
+ }
+
+ has_key = g_key_file_has_key(kf, group, k, NULL);
+ old_key = g_key_file_get_string(kf, group, k, NULL);
+ g_key_file_set_string(kf, group, k, val);
+ /* delete the dummy key, if this was just an empty group */
+ if (!g_strcmp0(k, NETPLAN_NM_EMPTY_GROUP))
+ g_key_file_remove_key(kf, group, k, NULL);
+ else if (!has_key) {
+ g_debug("NetworkManager: passing through fallback key: %s.%s=%s", group, k, val);
+ g_key_file_set_comment(kf, group, k, "Netplan: passthrough setting", NULL);
+ } else if (!!g_strcmp0(val, old_key)) {
+ g_debug("NetworkManager: fallback override: %s.%s=%s", group, k, val);
+ g_key_file_set_comment(kf, group, k, "Netplan: passthrough override", NULL);
+ }
+
+ g_strfreev(group_key);
+}
+
+/**
+ * Generate NetworkManager configuration in @rootdir/run/NetworkManager/ for a
+ * particular NetplanNetDefinition and NetplanWifiAccessPoint, as NM requires a separate
+ * connection file for each SSID.
+ * @def: The NetplanNetDefinition for which to create a connection
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ * @ap: The access point for which to create a connection. Must be %NULL for
+ * non-wifi types.
+ */
+static void
+write_nm_conf_access_point(NetplanNetDefinition* def, const char* rootdir, const NetplanWifiAccessPoint* ap)
+{
+ g_autoptr(GKeyFile) kf = NULL;
+ g_autoptr(GError) error = NULL;
+ g_autofree gchar* conf_path = NULL;
+ g_autofree gchar* full_path = NULL;
+ g_autofree gchar* nd_nm_id = NULL;
+ const gchar* nm_type = NULL;
+ gchar* tmp_key = NULL;
+ mode_t orig_umask;
+ char uuidstr[37];
+ const char *match_interface_name = NULL;
+
+ if (def->type == NETPLAN_DEF_TYPE_WIFI)
+ g_assert(ap);
+ else
+ g_assert(ap == NULL);
+
+ if (def->type == NETPLAN_DEF_TYPE_VLAN && def->sriov_vlan_filter) {
+ g_debug("%s is defined as a hardware SR-IOV filtered VLAN, postponing creation", def->id);
+ return;
+ }
+
+ kf = g_key_file_new();
+ if (ap && ap->backend_settings.nm.name)
+ g_key_file_set_string(kf, "connection", "id", ap->backend_settings.nm.name);
+ else if (def->backend_settings.nm.name)
+ g_key_file_set_string(kf, "connection", "id", def->backend_settings.nm.name);
+ else {
+ /* Auto-generate a name for the connection profile, if not specified */
+ if (ap)
+ nd_nm_id = g_strdup_printf("netplan-%s-%s", def->id, ap->ssid);
+ else
+ nd_nm_id = g_strdup_printf("netplan-%s", def->id);
+ g_key_file_set_string(kf, "connection", "id", nd_nm_id);
+ }
+
+ nm_type = type_str(def);
+ if (nm_type)
+ g_key_file_set_string(kf, "connection", "type", nm_type);
+
+ if (ap && ap->backend_settings.nm.uuid)
+ g_key_file_set_string(kf, "connection", "uuid", ap->backend_settings.nm.uuid);
+ else if (def->backend_settings.nm.uuid)
+ g_key_file_set_string(kf, "connection", "uuid", def->backend_settings.nm.uuid);
+ /* VLAN devices refer to us as their parent; if our ID is not a name but we
+ * have matches, parent= must be the connection UUID, so put it into the
+ * connection */
+ if (def->has_vlans && def->has_match) {
+ maybe_generate_uuid(def);
+ uuid_unparse(def->uuid, uuidstr);
+ g_key_file_set_string(kf, "connection", "uuid", uuidstr);
+ }
+
+ if (def->activation_mode) {
+ /* XXX: For now NetworkManager only supports the "manual" activation
+ * mode */
+ if (!!g_strcmp0(def->activation_mode, "manual")) {
+ g_fprintf(stderr, "ERROR: %s: NetworkManager definitions do not support activation-mode %s\n", def->id, def->activation_mode);
+ exit(1);
+ }
+ /* "manual" */
+ g_key_file_set_boolean(kf, "connection", "autoconnect", FALSE);
+ }
+
+ if (def->type < NETPLAN_DEF_TYPE_VIRTUAL) {
+ /* physical (existing) devices use matching; driver matching is not
+ * supported, MAC matching is done below (different keyfile section),
+ * so only match names here */
+ if (def->set_name)
+ g_key_file_set_string(kf, "connection", "interface-name", def->set_name);
+ else if (!def->has_match)
+ g_key_file_set_string(kf, "connection", "interface-name", def->id);
+ else if (def->match.original_name) {
+ if (strpbrk(def->match.original_name, "*[]?"))
+ match_interface_name = def->match.original_name;
+ else
+ g_key_file_set_string(kf, "connection", "interface-name", def->match.original_name);
+ }
+ /* else matches on something other than the name, do not restrict interface-name */
+ } else {
+ /* virtual (created) devices set a name */
+ if (strlen(def->id) > 15)
+ g_debug("interface-name longer than 15 characters is not supported");
+ else
+ g_key_file_set_string(kf, "connection", "interface-name", def->id);
+
+ if (def->type == NETPLAN_DEF_TYPE_BRIDGE)
+ write_bridge_params(def, kf);
+ }
+ if (def->type == NETPLAN_DEF_TYPE_MODEM) {
+ const char* modem_type = modem_is_gsm(def) ? "gsm" : "cdma";
+
+ /* Use NetworkManager's auto configuration feature if no APN, username, or password is specified */
+ if (def->modem_params.auto_config || (!def->modem_params.apn &&
+ !def->modem_params.username && !def->modem_params.password)) {
+ g_key_file_set_boolean(kf, modem_type, "auto-config", TRUE);
+ } else {
+ if (def->modem_params.apn)
+ g_key_file_set_string(kf, modem_type, "apn", def->modem_params.apn);
+ if (def->modem_params.password)
+ g_key_file_set_string(kf, modem_type, "password", def->modem_params.password);
+ if (def->modem_params.username)
+ g_key_file_set_string(kf, modem_type, "username", def->modem_params.username);
+ }
+
+ if (def->modem_params.device_id)
+ g_key_file_set_string(kf, modem_type, "device-id", def->modem_params.device_id);
+ if (def->mtubytes)
+ g_key_file_set_uint64(kf, modem_type, "mtu", def->mtubytes);
+ if (def->modem_params.network_id)
+ g_key_file_set_string(kf, modem_type, "network-id", def->modem_params.network_id);
+ if (def->modem_params.number)
+ g_key_file_set_string(kf, modem_type, "number", def->modem_params.number);
+ if (def->modem_params.pin)
+ g_key_file_set_string(kf, modem_type, "pin", def->modem_params.pin);
+ if (def->modem_params.sim_id)
+ g_key_file_set_string(kf, modem_type, "sim-id", def->modem_params.sim_id);
+ if (def->modem_params.sim_operator_id)
+ g_key_file_set_string(kf, modem_type, "sim-operator-id", def->modem_params.sim_operator_id);
+ }
+ if (def->bridge) {
+ g_key_file_set_string(kf, "connection", "slave-type", "bridge");
+ g_key_file_set_string(kf, "connection", "master", def->bridge);
+
+ if (def->bridge_params.path_cost)
+ g_key_file_set_uint64(kf, "bridge-port", "path-cost", def->bridge_params.path_cost);
+ if (def->bridge_params.port_priority)
+ g_key_file_set_uint64(kf, "bridge-port", "priority", def->bridge_params.port_priority);
+ }
+ if (def->bond) {
+ g_key_file_set_string(kf, "connection", "slave-type", "bond");
+ g_key_file_set_string(kf, "connection", "master", def->bond);
+ }
+
+ if (def->ipv6_mtubytes) {
+ g_fprintf(stderr, "ERROR: %s: NetworkManager definitions do not support ipv6-mtu\n", def->id);
+ exit(1);
+ }
+
+ if (def->type < NETPLAN_DEF_TYPE_VIRTUAL) {
+ if (def->type == NETPLAN_DEF_TYPE_ETHERNET)
+ g_key_file_set_integer(kf, "ethernet", "wake-on-lan", def->wake_on_lan ? 1 : 0);
+
+ const char* con_type = NULL;
+ switch (def->type) {
+ case NETPLAN_DEF_TYPE_WIFI:
+ con_type = "wifi";
+ case NETPLAN_DEF_TYPE_MODEM:
+ /* Avoid adding an [ethernet] section into the [gsm/cdma] description. */
+ break;
+ default:
+ con_type = "ethernet";
+ }
+
+ if (con_type) {
+ if (!def->set_name && def->match.mac)
+ g_key_file_set_string(kf, con_type, "mac-address", def->match.mac);
+ if (def->set_mac)
+ g_key_file_set_string(kf, con_type, "cloned-mac-address", def->set_mac);
+ if (def->mtubytes)
+ g_key_file_set_uint64(kf, con_type, "mtu", def->mtubytes);
+ if (def->wowlan && def->wowlan > NETPLAN_WIFI_WOWLAN_DEFAULT)
+ g_key_file_set_uint64(kf, con_type, "wake-on-wlan", def->wowlan);
+ }
+ } else {
+ if (def->set_mac)
+ g_key_file_set_string(kf, "ethernet", "cloned-mac-address", def->set_mac);
+ if (def->mtubytes)
+ g_key_file_set_uint64(kf, "ethernet", "mtu", def->mtubytes);
+ }
+
+ if (def->type == NETPLAN_DEF_TYPE_VLAN) {
+ g_assert(def->vlan_id < G_MAXUINT);
+ g_assert(def->vlan_link != NULL);
+ g_key_file_set_uint64(kf, "vlan", "id", def->vlan_id);
+ if (def->vlan_link->has_match) {
+ /* we need to refer to the parent's UUID as we don't have an
+ * interface name with match: */
+ maybe_generate_uuid(def->vlan_link);
+ uuid_unparse(def->vlan_link->uuid, uuidstr);
+ g_key_file_set_string(kf, "vlan", "parent", uuidstr);
+ } else {
+ /* if we have an interface name, use that as parent */
+ g_key_file_set_string(kf, "vlan", "parent", def->vlan_link->id);
+ }
+ }
+
+ if (def->type == NETPLAN_DEF_TYPE_BOND)
+ write_bond_parameters(def, kf);
+
+ if (def->type == NETPLAN_DEF_TYPE_TUNNEL) {
+ if (def->tunnel.mode == NETPLAN_TUNNEL_MODE_WIREGUARD)
+ write_wireguard_params(def, kf);
+ else
+ write_tunnel_params(def, kf);
+ }
+
+ if (match_interface_name) {
+ const gchar* list[1] = {match_interface_name};
+ g_key_file_set_string_list(kf, "match", "interface-name", list, 1);
+ }
+
+ if (ap && ap->mode == NETPLAN_WIFI_MODE_AP)
+ g_key_file_set_string(kf, "ipv4", "method", "shared");
+ else if (def->dhcp4)
+ g_key_file_set_string(kf, "ipv4", "method", "auto");
+ else if (def->ip4_addresses)
+ /* This requires adding at least one address (done below) */
+ g_key_file_set_string(kf, "ipv4", "method", "manual");
+ else if (def->type == NETPLAN_DEF_TYPE_TUNNEL)
+ /* sit tunnels will not start in link-local apparently */
+ g_key_file_set_string(kf, "ipv4", "method", "disabled");
+ else
+ /* Without any address, this is the only available mode */
+ g_key_file_set_string(kf, "ipv4", "method", "link-local");
+
+ if (def->ip4_addresses) {
+ for (unsigned i = 0; i < def->ip4_addresses->len; ++i) {
+ tmp_key = g_strdup_printf("address%i", i+1);
+ g_key_file_set_string(kf, "ipv4", tmp_key, g_array_index(def->ip4_addresses, char*, i));
+ g_free(tmp_key);
+ }
+ }
+ if (def->gateway4)
+ g_key_file_set_string(kf, "ipv4", "gateway", def->gateway4);
+ if (def->ip4_nameservers) {
+ const gchar* list[def->ip4_nameservers->len];
+ for (unsigned i = 0; i < def->ip4_nameservers->len; ++i)
+ list[i] = g_array_index(def->ip4_nameservers, char*, i);
+ g_key_file_set_string_list(kf, "ipv4", "dns", list, def->ip4_nameservers->len);
+ }
+
+ /* We can only write search domains and routes if we have an address */
+ if (def->ip4_addresses || def->dhcp4) {
+ write_search_domains(def, "ipv4", kf);
+ write_routes(def, kf, AF_INET);
+ }
+
+ if (!def->dhcp4_overrides.use_routes) {
+ g_key_file_set_boolean(kf, "ipv4", "ignore-auto-routes", TRUE);
+ g_key_file_set_boolean(kf, "ipv4", "never-default", TRUE);
+ }
+
+ if (def->dhcp4 && def->dhcp4_overrides.metric != NETPLAN_METRIC_UNSPEC)
+ g_key_file_set_uint64(kf, "ipv4", "route-metric", def->dhcp4_overrides.metric);
+
+ if (def->dhcp6 || def->ip6_addresses || def->gateway6 || def->ip6_nameservers || def->ip6_addr_gen_mode) {
+ g_key_file_set_string(kf, "ipv6", "method", def->dhcp6 ? "auto" : "manual");
+
+ if (def->ip6_addresses) {
+ for (unsigned i = 0; i < def->ip6_addresses->len; ++i) {
+ tmp_key = g_strdup_printf("address%i", i+1);
+ g_key_file_set_string(kf, "ipv6", tmp_key, g_array_index(def->ip6_addresses, char*, i));
+ g_free(tmp_key);
+ }
+ }
+ if (def->ip6_addr_gen_token) {
+ /* Token implies EUI-64, i.e mode=0 */
+ g_key_file_set_integer(kf, "ipv6", "addr-gen-mode", 0);
+ g_key_file_set_string(kf, "ipv6", "token", def->ip6_addr_gen_token);
+ } else if (def->ip6_addr_gen_mode)
+ g_key_file_set_string(kf, "ipv6", "addr-gen-mode", addr_gen_mode_str(def->ip6_addr_gen_mode));
+ if (def->ip6_privacy)
+ g_key_file_set_integer(kf, "ipv6", "ip6-privacy", 2);
+ if (def->gateway6)
+ g_key_file_set_string(kf, "ipv6", "gateway", def->gateway6);
+ if (def->ip6_nameservers) {
+ const gchar* list[def->ip6_nameservers->len];
+ for (unsigned i = 0; i < def->ip6_nameservers->len; ++i)
+ list[i] = g_array_index(def->ip6_nameservers, char*, i);
+ g_key_file_set_string_list(kf, "ipv6", "dns", list, def->ip6_nameservers->len);
+ }
+ /* nm-settings(5) specifies search-domain for both [ipv4] and [ipv6] --
+ * We need to specify it here for the IPv6-only case - see LP: #1786726 */
+ write_search_domains(def, "ipv6", kf);
+
+ /* We can only write valid routes if there is a DHCPv6 or static IPv6 address */
+ write_routes(def, kf, AF_INET6);
+
+ if (!def->dhcp6_overrides.use_routes) {
+ g_key_file_set_boolean(kf, "ipv6", "ignore-auto-routes", TRUE);
+ g_key_file_set_boolean(kf, "ipv6", "never-default", TRUE);
+ }
+
+ if (def->dhcp6_overrides.metric != NETPLAN_METRIC_UNSPEC)
+ g_key_file_set_uint64(kf, "ipv6", "route-metric", def->dhcp6_overrides.metric);
+ }
+ else
+ g_key_file_set_string(kf, "ipv6", "method", "ignore");
+
+ if (def->backend_settings.nm.passthrough) {
+ g_debug("NetworkManager: using keyfile passthrough mode");
+ /* Write all key-value pairs from the hashtable into the keyfile,
+ * potentially overriding existing values, if not fully supported. */
+ g_datalist_foreach(&def->backend_settings.nm.passthrough, write_fallback_key_value, kf);
+ }
+
+ if (ap) {
+ g_autofree char* escaped_ssid = g_uri_escape_string(ap->ssid, NULL, TRUE);
+ conf_path = g_strjoin(NULL, "run/NetworkManager/system-connections/netplan-", def->id, "-", escaped_ssid, ".nmconnection", NULL);
+
+ g_key_file_set_string(kf, "wifi", "ssid", ap->ssid);
+ if (ap->mode < NETPLAN_WIFI_MODE_OTHER)
+ g_key_file_set_string(kf, "wifi", "mode", wifi_mode_str(ap->mode));
+ if (ap->bssid)
+ g_key_file_set_string(kf, "wifi", "bssid", ap->bssid);
+ if (ap->hidden)
+ g_key_file_set_boolean(kf, "wifi", "hidden", TRUE);
+ if (ap->band == NETPLAN_WIFI_BAND_5 || ap->band == NETPLAN_WIFI_BAND_24) {
+ g_key_file_set_string(kf, "wifi", "band", wifi_band_str(ap->band));
+ /* Channel is only unambiguous, if band is set. */
+ if (ap->channel) {
+ /* Validate WiFi channel */
+ if (ap->band == NETPLAN_WIFI_BAND_5)
+ wifi_get_freq5(ap->channel);
+ else
+ wifi_get_freq24(ap->channel);
+ g_key_file_set_uint64(kf, "wifi", "channel", ap->channel);
+ }
+ }
+ if (ap->has_auth) {
+ write_wifi_auth_parameters(&ap->auth, kf);
+ }
+ if (ap->backend_settings.nm.passthrough) {
+ g_debug("NetworkManager: using AP keyfile passthrough mode");
+ /* Write all key-value pairs from the hashtable into the keyfile,
+ * potentially overriding existing values, if not fully supported.
+ * AP passthrough values have higher priority than ND passthrough,
+ * because they are more specific and bound to the current SSID's
+ * NM connection profile. */
+ g_datalist_foreach((GData**)&ap->backend_settings.nm.passthrough, write_fallback_key_value, kf);
+ }
+ } else {
+ conf_path = g_strjoin(NULL, "run/NetworkManager/system-connections/netplan-", def->id, ".nmconnection", NULL);
+ if (def->has_auth) {
+ write_dot1x_auth_parameters(&def->auth, kf);
+ }
+ }
+
+ /* NM connection files might contain secrets, and NM insists on tight permissions */
+ full_path = g_strjoin(G_DIR_SEPARATOR_S, rootdir ?: "", conf_path, NULL);
+ orig_umask = umask(077);
+ safe_mkdir_p_dir(full_path);
+ if (!g_key_file_save_to_file(kf, full_path, &error)) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "ERROR: cannot create file %s: %s\n", full_path, error->message);
+ exit(1);
+ // LCOV_EXCL_STO
+ }
+ umask(orig_umask);
+}
+
+/**
+ * Generate NetworkManager configuration in @rootdir/run/NetworkManager/ for a
+ * particular NetplanNetDefinition.
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ */
+void
+write_nm_conf(NetplanNetDefinition* def, const char* rootdir)
+{
+ if (def->backend != NETPLAN_BACKEND_NM) {
+ g_debug("NetworkManager: definition %s is not for us (backend %i)", def->id, def->backend);
+ return;
+ }
+
+ if (def->match.driver && !def->set_name) {
+ g_fprintf(stderr, "ERROR: %s: NetworkManager definitions do not support matching by driver\n", def->id);
+ exit(1);
+ }
+
+ if (def->address_options) {
+ g_fprintf(stderr, "ERROR: %s: NetworkManager does not support address options\n", def->id);
+ exit(1);
+ }
+
+ if (def->type == NETPLAN_DEF_TYPE_WIFI) {
+ GHashTableIter iter;
+ gpointer key;
+ const NetplanWifiAccessPoint* ap;
+ g_assert(def->access_points);
+ g_hash_table_iter_init(&iter, def->access_points);
+ while (g_hash_table_iter_next(&iter, &key, (gpointer) &ap))
+ write_nm_conf_access_point(def, rootdir, ap);
+ } else {
+ g_assert(def->access_points == NULL);
+ write_nm_conf_access_point(def, rootdir, NULL);
+ }
+}
+
+static void
+nd_append_non_nm_ids(gpointer data, gpointer str)
+{
+ const NetplanNetDefinition* nd = data;
+
+ if (nd->backend != NETPLAN_BACKEND_NM) {
+ if (nd->match.driver) {
+ /* TODO: NetworkManager supports (non-globbing) "driver:..." matching nowadays */
+ /* NM cannot match on drivers, so ignore these via udev rules */
+ if (!udev_rules)
+ udev_rules = g_string_new(NULL);
+ g_string_append_printf(udev_rules, "ACTION==\"add|change\", SUBSYSTEM==\"net\", ENV{ID_NET_DRIVER}==\"%s\", ENV{NM_UNMANAGED}=\"1\"\n", nd->match.driver);
+ } else {
+ g_string_append_netdef_match((GString*) str, nd);
+ }
+ }
+}
+
+void
+write_nm_conf_finish(const char* rootdir)
+{
+ GString *s = NULL;
+ gsize len;
+
+ if (!netdefs || g_hash_table_size(netdefs) == 0)
+ return;
+
+ /* Set all devices not managed by us to unmanaged, so that NM does not
+ * auto-connect and interferes */
+ s = g_string_new("[keyfile]\n# devices managed by networkd\nunmanaged-devices+=");
+ len = s->len;
+ g_list_foreach(netdefs_ordered, nd_append_non_nm_ids, s);
+ if (s->len > len)
+ g_string_free_to_file(s, rootdir, "run/NetworkManager/conf.d/netplan.conf", NULL);
+ else
+ g_string_free(s, TRUE);
+
+ /* write generated udev rules */
+ if (udev_rules)
+ g_string_free_to_file(udev_rules, rootdir, "run/udev/rules.d/90-netplan.rules", NULL);
+}
+
+/**
+ * Clean up all generated configurations in @rootdir from previous runs.
+ */
+void
+cleanup_nm_conf(const char* rootdir)
+{
+ g_autofree char* confpath = g_strjoin(NULL, rootdir ?: "", "/run/NetworkManager/conf.d/netplan.conf", NULL);
+ g_autofree char* global_manage_path = g_strjoin(NULL, rootdir ?: "", "/run/NetworkManager/conf.d/10-globally-managed-devices.conf", NULL);
+ unlink(confpath);
+ unlink(global_manage_path);
+ unlink_glob(rootdir, "/run/NetworkManager/system-connections/netplan-*");
+}
diff --git a/src/nm.h b/src/nm.h
new file mode 100644
index 0000000..9f8f9ca
--- /dev/null
+++ b/src/nm.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "parse.h"
+
+void write_nm_conf(NetplanNetDefinition* def, const char* rootdir);
+void write_nm_conf_finish(const char* rootdir);
+void cleanup_nm_conf(const char* rootdir);
diff --git a/src/openvswitch.c b/src/openvswitch.c
new file mode 100644
index 0000000..2088fbe
--- /dev/null
+++ b/src/openvswitch.c
@@ -0,0 +1,484 @@
+/*
+ * Copyright (C) 2020 Canonical, Ltd.
+ * Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@ubuntu.com>
+ * Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <unistd.h>
+#include <errno.h>
+
+#include <glib.h>
+#include <glib/gprintf.h>
+
+#include "openvswitch.h"
+#include "networkd.h"
+#include "parse.h"
+#include "util.h"
+
+static void
+write_ovs_systemd_unit(const char* id, const GString* cmds, const char* rootdir, gboolean physical, gboolean cleanup, const char* dependency)
+{
+ g_autofree gchar* id_escaped = NULL;
+ g_autofree char* link = g_strjoin(NULL, rootdir ?: "", "/run/systemd/system/systemd-networkd.service.wants/netplan-ovs-", id, ".service", NULL);
+ g_autofree char* path = g_strjoin(NULL, "/run/systemd/system/netplan-ovs-", id, ".service", NULL);
+
+ GString* s = g_string_new("[Unit]\n");
+ g_string_append_printf(s, "Description=OpenVSwitch configuration for %s\n", id);
+ g_string_append(s, "DefaultDependencies=no\n");
+ /* run any ovs-netplan unit only after openvswitch-switch.service is ready */
+ g_string_append_printf(s, "Wants=ovsdb-server.service\n");
+ g_string_append_printf(s, "After=ovsdb-server.service\n");
+ if (physical) {
+ id_escaped = systemd_escape((char*) id);
+ g_string_append_printf(s, "Requires=sys-subsystem-net-devices-%s.device\n", id_escaped);
+ g_string_append_printf(s, "After=sys-subsystem-net-devices-%s.device\n", id_escaped);
+ }
+ if (!cleanup) {
+ g_string_append_printf(s, "After=netplan-ovs-cleanup.service\n");
+ } else {
+ /* The netplan-ovs-cleanup unit shall not run on systems where openvswitch is not installed. */
+ g_string_append(s, "ConditionFileIsExecutable=" OPENVSWITCH_OVS_VSCTL "\n");
+ }
+ g_string_append(s, "Before=network.target\nWants=network.target\n");
+ if (dependency) {
+ g_string_append_printf(s, "Requires=netplan-ovs-%s.service\n", dependency);
+ g_string_append_printf(s, "After=netplan-ovs-%s.service\n", dependency);
+ }
+
+ g_string_append(s, "\n[Service]\nType=oneshot\n");
+ g_string_append(s, cmds->str);
+
+ g_string_free_to_file(s, rootdir, path, NULL);
+
+ safe_mkdir_p_dir(link);
+ if (symlink(path, link) < 0 && errno != EEXIST) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to create enablement symlink: %m\n");
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+}
+
+#define append_systemd_cmd(s, command, ...) \
+{ \
+ g_string_append(s, "ExecStart="); \
+ g_string_append_printf(s, command, __VA_ARGS__); \
+ g_string_append(s, "\n"); \
+}
+
+static char*
+netplan_type_to_table_name(const NetplanDefType type)
+{
+ switch (type) {
+ case NETPLAN_DEF_TYPE_BRIDGE:
+ return "Bridge";
+ case NETPLAN_DEF_TYPE_BOND:
+ case NETPLAN_DEF_TYPE_PORT:
+ return "Port";
+ default: /* For regular interfaces and others */
+ return "Interface";
+ }
+}
+
+static gboolean
+netplan_type_is_physical(const NetplanDefType type)
+{
+ switch (type) {
+ case NETPLAN_DEF_TYPE_ETHERNET:
+ // case NETPLAN_DEF_TYPE_WIFI:
+ // case NETPLAN_DEF_TYPE_MODEM:
+ return TRUE;
+ default:
+ return FALSE;
+ }
+}
+
+static void
+write_ovs_tag_setting(const gchar* id, const char* type, const char* col, const char* key, const char* value, GString* cmds)
+{
+ g_assert(col);
+ g_assert(value);
+ g_autofree char *clean_value = g_strdup(value);
+ /* Replace " " -> "," if value contains spaces */
+ if (strchr(value, ' ')) {
+ char **split = g_strsplit(value, " ", -1);
+ g_free(clean_value);
+ clean_value = g_strjoinv(",", split);
+ g_strfreev(split);
+ }
+
+ GString* s = g_string_new("external-ids:netplan/");
+ g_string_append_printf(s, "%s", col);
+ if (key)
+ g_string_append_printf(s, "/%s", key);
+ g_string_append_printf(s, "=%s", clean_value);
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set %s %s %s", type, id, s->str);
+ g_string_free(s, TRUE);
+}
+
+static void
+write_ovs_additional_data(GHashTable *data, const char* type, const gchar* id, GString* cmds, const char* setting)
+{
+ GHashTableIter iter;
+ gchar* key;
+ gchar* value;
+
+ g_hash_table_iter_init(&iter, data);
+ while (g_hash_table_iter_next(&iter, (gpointer) &key, (gpointer) &value)) {
+ /* XXX: we need to check what happens when an invalid key=value pair
+ gets supplied here. We might want to handle this somehow. */
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set %s %s %s:%s=%s",
+ type, id, setting, key, value);
+ write_ovs_tag_setting(id, type, setting, key, value, cmds);
+ }
+}
+
+static void
+setup_patch_port(GString* s, const NetplanNetDefinition* def)
+{
+ /* Execute the setup commands to create an OVS patch port atomically within
+ * the same command where this virtual interface is created. Either as a
+ * Port+Interface of an OVS bridge or as a Interface of an OVS bond. This
+ * avoids delays in the PatchPort creation and thus potential races. */
+ g_assert(def->type == NETPLAN_DEF_TYPE_PORT);
+ g_string_append_printf(s, " -- set Interface %s type=patch options:peer=%s",
+ def->id, def->peer);
+}
+
+static char*
+write_ovs_bond_interfaces(const NetplanNetDefinition* def, GString* cmds)
+{
+ NetplanNetDefinition* tmp_nd;
+ GHashTableIter iter;
+ gchar* key;
+ guint i = 0;
+ GString* s = NULL;
+ GString* patch_ports = g_string_new("");
+
+ if (!def->bridge) {
+ g_fprintf(stderr, "Bond %s needs to be a slave of an OpenVSwitch bridge\n", def->id);
+ exit(1);
+ }
+
+ s = g_string_new(OPENVSWITCH_OVS_VSCTL " --may-exist add-bond");
+ g_string_append_printf(s, " %s %s", def->bridge, def->id);
+
+ g_hash_table_iter_init(&iter, netdefs);
+ while (g_hash_table_iter_next(&iter, (gpointer) &key, (gpointer) &tmp_nd)) {
+ if (!g_strcmp0(def->id, tmp_nd->bond)) {
+ /* Append and count bond interfaces */
+ g_string_append_printf(s, " %s", tmp_nd->id);
+ i++;
+ if (tmp_nd->type == NETPLAN_DEF_TYPE_PORT)
+ setup_patch_port(patch_ports, tmp_nd);
+ }
+ }
+ if (i < 2) {
+ g_fprintf(stderr, "Bond %s needs to have at least 2 slave interfaces\n", def->id);
+ exit(1);
+ }
+
+ g_string_append(s, patch_ports->str);
+ g_string_free(patch_ports, TRUE);
+ append_systemd_cmd(cmds, s->str, def->bridge, def->id);
+ g_string_free(s, TRUE);
+ return def->bridge;
+}
+
+static void
+write_ovs_tag_netplan(const gchar* id, const char* type, GString* cmds)
+{
+ /* Mark this bridge/port/interface as created by netplan */
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set %s %s external-ids:netplan=true",
+ type, id);
+}
+
+static void
+write_ovs_bond_mode(const NetplanNetDefinition* def, GString* cmds)
+{
+ char* value = NULL;
+ /* OVS supports only "active-backup", "balance-tcp" and "balance-slb":
+ * http://www.openvswitch.org/support/dist-docs/ovs-vswitchd.conf.db.5.txt */
+ if (!strcmp(def->bond_params.mode, "active-backup") ||
+ !strcmp(def->bond_params.mode, "balance-tcp") ||
+ !strcmp(def->bond_params.mode, "balance-slb")) {
+ value = def->bond_params.mode;
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Port %s bond_mode=%s", def->id, value);
+ write_ovs_tag_setting(def->id, "Port", "bond_mode", NULL, value, cmds);
+ } else {
+ g_fprintf(stderr, "%s: bond mode '%s' not supported by openvswitch\n",
+ def->id, def->bond_params.mode);
+ exit(1);
+ }
+}
+
+static void
+write_ovs_bridge_interfaces(const NetplanNetDefinition* def, GString* cmds)
+{
+ NetplanNetDefinition* tmp_nd;
+ GHashTableIter iter;
+ gchar* key;
+
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " --may-exist add-br %s", def->id);
+
+ g_hash_table_iter_init(&iter, netdefs);
+ while (g_hash_table_iter_next(&iter, (gpointer) &key, (gpointer) &tmp_nd)) {
+ /* OVS bonds will connect to their OVS bridge and create the interface/port themselves */
+ if ((tmp_nd->type != NETPLAN_DEF_TYPE_BOND || tmp_nd->backend != NETPLAN_BACKEND_OVS)
+ && !g_strcmp0(def->id, tmp_nd->bridge)) {
+ GString * patch_ports = g_string_new("");
+ if (tmp_nd->type == NETPLAN_DEF_TYPE_PORT)
+ setup_patch_port(patch_ports, tmp_nd);
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " --may-exist add-port %s %s%s",
+ def->id, tmp_nd->id, patch_ports->str);
+ g_string_free(patch_ports, TRUE);
+ }
+ }
+}
+
+static void
+write_ovs_protocols(const NetplanOVSSettings* ovs_settings, const gchar* bridge, GString* cmds)
+{
+ g_assert(bridge);
+ GString* s = g_string_new(g_array_index(ovs_settings->protocols, char*, 0));
+
+ for (unsigned i = 1; i < ovs_settings->protocols->len; ++i)
+ g_string_append_printf(s, ",%s", g_array_index(ovs_settings->protocols, char*, i));
+
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Bridge %s protocols=%s", bridge, s->str);
+ write_ovs_tag_setting(bridge, "Bridge", "protocols", NULL, s->str, cmds);
+ g_string_free(s, TRUE);
+}
+
+static gboolean
+check_ovs_ssl(gchar* target)
+{
+ /* Check if target needs ssl */
+ if (g_str_has_prefix(target, "ssl:") || g_str_has_prefix(target, "pssl:")) {
+ /* Check if SSL is configured in ovs_settings_global.ssl */
+ if (!ovs_settings_global.ssl.ca_certificate || !ovs_settings_global.ssl.client_certificate ||
+ !ovs_settings_global.ssl.client_key) {
+ g_fprintf(stderr, "ERROR: openvswitch bridge controller target '%s' needs SSL configuration, but global 'openvswitch.ssl' settings are not set\n", target);
+ exit(1);
+ }
+ return TRUE;
+ }
+ return FALSE;
+}
+
+static void
+write_ovs_bridge_controller_targets(const NetplanOVSController* controller, const gchar* bridge, GString* cmds)
+{
+ gchar* target = g_array_index(controller->addresses, char*, 0);
+ gboolean needs_ssl = check_ovs_ssl(target);
+ GString* s = g_string_new(target);
+
+ for (unsigned i = 1; i < controller->addresses->len; ++i) {
+ target = g_array_index(controller->addresses, char*, i);
+ if (!needs_ssl)
+ needs_ssl = check_ovs_ssl(target);
+ g_string_append_printf(s, " %s", target);
+ }
+
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set-controller %s %s", bridge, s->str);
+ write_ovs_tag_setting(bridge, "Bridge", "global", "set-controller", s->str, cmds);
+ g_string_free(s, TRUE);
+}
+
+/**
+ * Generate the OpenVSwitch systemd units for configuration of the selected netdef
+ * @rootdir: If not %NULL, generate configuration in this root directory
+ * (useful for testing).
+ */
+void
+write_ovs_conf(const NetplanNetDefinition* def, const char* rootdir)
+{
+ GString* cmds = g_string_new(NULL);
+ gchar* dependency = NULL;
+ const char* type = netplan_type_to_table_name(def->type);
+ g_autofree char* base_config_path = NULL;
+ char* value = NULL;
+
+ /* TODO: maybe dynamically query the ovs-vsctl tool path? */
+
+ /* For OVS specific settings, we expect the backend to be set to OVS.
+ * The OVS backend is implicitly set, if an interface contains an empty "openvswitch: {}"
+ * key, or an "openvswitch:" key, containing more than "external-ids" and/or "other-config". */
+ if (def->backend == NETPLAN_BACKEND_OVS) {
+ switch (def->type) {
+ case NETPLAN_DEF_TYPE_BOND:
+ dependency = write_ovs_bond_interfaces(def, cmds);
+ write_ovs_tag_netplan(def->id, type, cmds);
+ /* Set LACP mode, default to "off" */
+ value = def->ovs_settings.lacp? def->ovs_settings.lacp : "off";
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Port %s lacp=%s", def->id, value);
+ write_ovs_tag_setting(def->id, type, "lacp", NULL, value, cmds);
+ if (def->bond_params.mode) {
+ write_ovs_bond_mode(def, cmds);
+ }
+ break;
+
+ case NETPLAN_DEF_TYPE_BRIDGE:
+ write_ovs_bridge_interfaces(def, cmds);
+ write_ovs_tag_netplan(def->id, type, cmds);
+ /* Set fail-mode, default to "standalone" */
+ value = def->ovs_settings.fail_mode? def->ovs_settings.fail_mode : "standalone";
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set-fail-mode %s %s", def->id, value);
+ write_ovs_tag_setting(def->id, type, "global", "set-fail-mode", value, cmds);
+ /* Enable/disable mcast-snooping */
+ value = def->ovs_settings.mcast_snooping? "true" : "false";
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Bridge %s mcast_snooping_enable=%s", def->id, value);
+ write_ovs_tag_setting(def->id, type, "mcast_snooping_enable", NULL, value, cmds);
+ /* Enable/disable rstp */
+ value = def->ovs_settings.rstp? "true" : "false";
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Bridge %s rstp_enable=%s", def->id, value);
+ write_ovs_tag_setting(def->id, type, "rstp_enable", NULL, value, cmds);
+ /* Set protocols */
+ if (def->ovs_settings.protocols && def->ovs_settings.protocols->len > 0) {
+ write_ovs_protocols(&(def->ovs_settings), def->id, cmds);
+ } else if (ovs_settings_global.protocols && ovs_settings_global.protocols->len > 0) {
+ write_ovs_protocols(&(ovs_settings_global), def->id, cmds);
+ }
+ /* Set controller target addresses */
+ if (def->ovs_settings.controller.addresses && def->ovs_settings.controller.addresses->len > 0) {
+ write_ovs_bridge_controller_targets(&(def->ovs_settings.controller), def->id, cmds);
+ /* Set controller connection mode, only applicable if at least one controller target address was set */
+ if (def->ovs_settings.controller.connection_mode) {
+ value = def->ovs_settings.controller.connection_mode;
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set Controller %s connection-mode=%s", def->id, value);
+ write_ovs_tag_setting(def->id, "Controller", "connection-mode", NULL, value, cmds);
+ }
+ }
+ break;
+
+ case NETPLAN_DEF_TYPE_PORT:
+ g_assert(def->peer);
+ dependency = def->bridge?: def->bond;
+ if (!dependency) {
+ g_fprintf(stderr, "%s: OpenVSwitch patch port needs to be assigned to a bridge/bond\n", def->id);
+ exit(1);
+ }
+ /* There is no OVS Port which we could tag netplan=true if this
+ * patch port is assigned as an OVS bond interface. Tag the
+ * Interface instead, to clean it up from a bond. */
+ if (def->bond)
+ write_ovs_tag_netplan(def->id, "Interface", cmds);
+ else
+ write_ovs_tag_netplan(def->id, type, cmds);
+ break;
+
+ case NETPLAN_DEF_TYPE_VLAN:
+ g_assert(def->vlan_link);
+ dependency = def->vlan_link->id;
+ /* Create a fake VLAN bridge */
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " --may-exist add-br %s %s %i", def->id, def->vlan_link->id, def->vlan_id)
+ write_ovs_tag_netplan(def->id, type, cmds);
+ break;
+
+ default:
+ g_fprintf(stderr, "%s: This device type is not supported with the OpenVSwitch backend\n", def->id);
+ exit(1);
+ break;
+ }
+
+ /* Try writing out a base config */
+ base_config_path = g_strjoin(NULL, "run/systemd/network/10-netplan-", def->id, NULL);
+ write_network_file(def, rootdir, base_config_path);
+ } else {
+ /* Other interfaces must be part of an OVS bridge or bond to carry additional data */
+ if ( (def->ovs_settings.external_ids && g_hash_table_size(def->ovs_settings.external_ids) > 0)
+ || (def->ovs_settings.other_config && g_hash_table_size(def->ovs_settings.other_config) > 0)) {
+ dependency = def->bridge?: def->bond;
+ if (!dependency) {
+ g_fprintf(stderr, "%s: Interface needs to be assigned to an OVS bridge/bond to carry external-ids/other-config\n", def->id);
+ exit(1);
+ }
+ } else {
+ g_debug("openvswitch: definition %s is not for us (backend %i)", def->id, def->backend);
+ return;
+ }
+ }
+
+ /* Set "external-ids" and "other-config" after NETPLAN_BACKEND_OVS interfaces, as bonds,
+ * bridges, etc. might just be created before.*/
+
+ /* Common OVS settings can be specified even for non-OVS interfaces */
+ if (def->ovs_settings.external_ids && g_hash_table_size(def->ovs_settings.external_ids) > 0) {
+ write_ovs_additional_data(def->ovs_settings.external_ids, type,
+ def->id, cmds, "external-ids");
+ }
+
+ if (def->ovs_settings.other_config && g_hash_table_size(def->ovs_settings.other_config) > 0) {
+ write_ovs_additional_data(def->ovs_settings.other_config, type,
+ def->id, cmds, "other-config");
+ }
+
+ /* If we need to configure anything for this netdef, write the required systemd unit */
+ if (cmds->len > 0)
+ write_ovs_systemd_unit(def->id, cmds, rootdir, netplan_type_is_physical(def->type), FALSE, dependency);
+ g_string_free(cmds, TRUE);
+}
+
+/**
+ * Finalize the OpenVSwitch configuration (global config)
+ */
+void
+write_ovs_conf_finish(const char* rootdir)
+{
+ GString* cmds = g_string_new(NULL);
+
+ /* Global external-ids and other-config settings */
+ if (ovs_settings_global.external_ids && g_hash_table_size(ovs_settings_global.external_ids) > 0) {
+ write_ovs_additional_data(ovs_settings_global.external_ids, "open_vswitch",
+ ".", cmds, "external-ids");
+ }
+
+ if (ovs_settings_global.other_config && g_hash_table_size(ovs_settings_global.other_config) > 0) {
+ write_ovs_additional_data(ovs_settings_global.other_config, "open_vswitch",
+ ".", cmds, "other-config");
+ }
+
+ if (ovs_settings_global.ssl.client_key && ovs_settings_global.ssl.client_certificate &&
+ ovs_settings_global.ssl.ca_certificate) {
+ GString* value = g_string_new(NULL);
+ g_string_printf(value, "%s %s %s",
+ ovs_settings_global.ssl.client_key,
+ ovs_settings_global.ssl.client_certificate,
+ ovs_settings_global.ssl.ca_certificate);
+ append_systemd_cmd(cmds, OPENVSWITCH_OVS_VSCTL " set-ssl %s", value->str);
+ write_ovs_tag_setting(".", "open_vswitch", "global", "set-ssl", value->str, cmds);
+ g_string_free(value, TRUE);
+ }
+
+ if (cmds->len > 0)
+ write_ovs_systemd_unit("global", cmds, rootdir, FALSE, FALSE, NULL);
+ g_string_free(cmds, TRUE);
+
+ /* Clear all netplan=true tagged ports/bonds and bridges, via 'netplan apply --only-ovs-cleanup' */
+ cmds = g_string_new(NULL);
+ append_systemd_cmd(cmds, SBINDIR "/netplan apply %s", "--only-ovs-cleanup");
+ write_ovs_systemd_unit("cleanup", cmds, rootdir, FALSE, TRUE, NULL);
+ g_string_free(cmds, TRUE);
+}
+
+/**
+ * Clean up all generated configurations in @rootdir from previous runs.
+ */
+void
+cleanup_ovs_conf(const char* rootdir)
+{
+ unlink_glob(rootdir, "/run/systemd/system/systemd-networkd.service.wants/netplan-ovs-*.service");
+ unlink_glob(rootdir, "/run/systemd/system/netplan-ovs-*.service");
+}
diff --git a/src/openvswitch.h b/src/openvswitch.h
new file mode 100644
index 0000000..69bd6ee
--- /dev/null
+++ b/src/openvswitch.h
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 Canonical, Ltd.
+ * Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "parse.h"
+
+void write_ovs_conf(const NetplanNetDefinition* def, const char* rootdir);
+void write_ovs_conf_finish(const char* rootdir);
+void cleanup_ovs_conf(const char* rootdir);
diff --git a/src/parse-nm.c b/src/parse-nm.c
new file mode 100644
index 0000000..9b09e34
--- /dev/null
+++ b/src/parse-nm.c
@@ -0,0 +1,737 @@
+/*
+ * Copyright (C) 2021 Canonical, Ltd.
+ * Author: Lukas Märdian <slyon@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+#include <yaml.h>
+#include <arpa/inet.h>
+
+#include "netplan.h"
+#include "parse-nm.h"
+#include "parse.h"
+#include "util.h"
+
+/**
+ * NetworkManager writes the alias for '802-3-ethernet' (ethernet),
+ * '802-11-wireless' (wifi) and '802-11-wireless-security' (wifi-security)
+ * by default, so we only need to check for those. See:
+ * https://bugzilla.gnome.org/show_bug.cgi?id=696940
+ * https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/commit/c36200a225aefb2a3919618e75682646899b82c0
+ */
+static const NetplanDefType
+type_from_str(const char* type_str)
+{
+ if (!g_strcmp0(type_str, "ethernet") || !g_strcmp0(type_str, "802-3-ethernet"))
+ return NETPLAN_DEF_TYPE_ETHERNET;
+ else if (!g_strcmp0(type_str, "wifi") || !g_strcmp0(type_str, "802-11-wireless"))
+ return NETPLAN_DEF_TYPE_WIFI;
+ else if (!g_strcmp0(type_str, "gsm") || !g_strcmp0(type_str, "cdma"))
+ return NETPLAN_DEF_TYPE_MODEM;
+ else if (!g_strcmp0(type_str, "bridge"))
+ return NETPLAN_DEF_TYPE_BRIDGE;
+ else if (!g_strcmp0(type_str, "bond"))
+ return NETPLAN_DEF_TYPE_BOND;
+ else if (!g_strcmp0(type_str, "vlan"))
+ return NETPLAN_DEF_TYPE_VLAN;
+ else if (!g_strcmp0(type_str, "ip-tunnel") || !g_strcmp0(type_str, "wireguard"))
+ return NETPLAN_DEF_TYPE_TUNNEL;
+ /* Unsupported type, needs to be specified via passthrough */
+ return NETPLAN_DEF_TYPE_NM;
+}
+
+static const NetplanWifiMode
+ap_type_from_str(const char* type_str)
+{
+ if (!g_strcmp0(type_str, "infrastructure"))
+ return NETPLAN_WIFI_MODE_INFRASTRUCTURE;
+ else if (!g_strcmp0(type_str, "ap"))
+ return NETPLAN_WIFI_MODE_AP;
+ else if (!g_strcmp0(type_str, "adhoc"))
+ return NETPLAN_WIFI_MODE_ADHOC;
+ /* Unsupported mode, like "mesh" */
+ return NETPLAN_WIFI_MODE_OTHER;
+}
+
+static void
+_kf_clear_key(GKeyFile* kf, const gchar* group, const gchar* key)
+{
+ gsize len = 1;
+ g_key_file_remove_key(kf, group, key, NULL);
+ g_strfreev(g_key_file_get_keys(kf, group, &len, NULL));
+ /* clear group if this was the last key */
+ if (len == 0)
+ g_key_file_remove_group(kf, group, NULL);
+}
+
+static gboolean
+kf_matches(GKeyFile* kf, const gchar* group, const gchar* key, const gchar* match)
+{
+ g_autofree gchar *kf_value = g_key_file_get_string(kf, group, key, NULL);
+ return g_strcmp0(kf_value, match) == 0;
+}
+
+static void
+set_true_on_match(GKeyFile* kf, const gchar* group, const gchar* key, const gchar* match, const void* dataptr)
+{
+ g_assert(dataptr);
+ if (kf_matches(kf, group, key, match)) {
+ *((gboolean*) dataptr) = TRUE;
+ _kf_clear_key(kf, group, key);
+ }
+}
+
+static void
+handle_generic_bool(GKeyFile* kf, const gchar* group, const gchar* key, gboolean* dataptr)
+{
+ g_assert(dataptr);
+ *dataptr = g_key_file_get_boolean(kf, group, key, NULL);
+ _kf_clear_key(kf, group, key);
+}
+
+static void
+handle_generic_str(GKeyFile* kf, const gchar* group, const gchar* key, char** dataptr)
+{
+ g_assert(dataptr);
+ g_assert(!*dataptr);
+ *dataptr = g_key_file_get_string(kf, group, key, NULL);
+ if (*dataptr)
+ _kf_clear_key(kf, group, key);
+}
+
+static void
+handle_generic_uint(GKeyFile* kf, const gchar* group, const gchar* key, guint* dataptr, guint default_value)
+{
+ g_assert(dataptr);
+ if (g_key_file_has_key(kf, group, key, NULL)) {
+ guint data = g_key_file_get_uint64(kf, group, key, NULL);
+ if (data != default_value)
+ *dataptr = data;
+ _kf_clear_key(kf, group, key);
+ }
+}
+
+static void
+handle_common(GKeyFile* kf, NetplanNetDefinition* nd, const gchar* group) {
+ handle_generic_str(kf, group, "cloned-mac-address", &nd->set_mac);
+ handle_generic_uint(kf, group, "mtu", &nd->mtubytes, NETPLAN_MTU_UNSPEC);
+ handle_generic_str(kf, group, "mac-address", &nd->match.mac);
+ if (nd->match.mac)
+ nd->has_match = TRUE;
+}
+
+static void
+handle_bridge_uint(GKeyFile* kf, const gchar* key, NetplanNetDefinition* nd, char** dataptr) {
+ if (g_key_file_get_uint64(kf, "bridge", key, NULL)) {
+ nd->custom_bridging = TRUE;
+ *dataptr = g_strdup_printf("%lu", g_key_file_get_uint64(kf, "bridge", key, NULL));
+ _kf_clear_key(kf, "bridge", key);
+ }
+}
+
+static void
+parse_addresses(GKeyFile* kf, const gchar* group, GArray** ip_arr)
+{
+ g_assert(ip_arr);
+ if (kf_matches(kf, group, "method", "manual")) {
+ gboolean unhandled_data = FALSE;
+ gchar *key = NULL;
+ gchar *kf_value = NULL;
+ gchar **split = NULL;
+ for (unsigned i = 1;; ++i) {
+ key = g_strdup_printf("address%u", i);
+ kf_value = g_key_file_get_string(kf, group, key, NULL);
+ if (!kf_value) {
+ g_free(key);
+ break;
+ }
+ if (!*ip_arr)
+ *ip_arr = g_array_new(FALSE, FALSE, sizeof(char*));
+ split = g_strsplit(kf_value, ",", 2);
+ g_free(kf_value);
+ /* Append "address/prefix" */
+ if (split[0]) {
+ /* no need to free 's', this will stay in the netdef */
+ gchar* s = g_strdup(split[0]);
+ g_array_append_val(*ip_arr, s);
+ }
+ if (!split[1])
+ _kf_clear_key(kf, group, key);
+ else
+ /* XXX: how to handle additional values (like "gateway") in split[n]? */
+ unhandled_data = TRUE;
+ g_strfreev(split);
+ g_free(key);
+ }
+ /* clear keyfile once all data was handled */
+ if (!unhandled_data)
+ _kf_clear_key(kf, group, "method");
+ }
+}
+
+static void
+parse_routes(GKeyFile* kf, const gchar* group, GArray** routes_arr)
+{
+ g_assert(routes_arr);
+ NetplanIPRoute *route = NULL;
+ gchar *key = NULL;
+ gchar *kf_value = NULL;
+ gchar *options_key = NULL;
+ gchar *options_kf_value = NULL;
+ gchar **split = NULL;
+ for (unsigned i = 1;; ++i) {
+ gboolean unhandled_data = FALSE;
+ key = g_strdup_printf("route%u", i);
+ kf_value = g_key_file_get_string(kf, group, key, NULL);
+ options_key = g_strdup_printf("route%u_options", i);
+ options_kf_value = g_key_file_get_string(kf, group, options_key, NULL);
+ if (!options_kf_value)
+ g_free(options_key);
+ if (!kf_value) {
+ g_free(key);
+ break;
+ }
+ if (!*routes_arr)
+ *routes_arr = g_array_new(FALSE, TRUE, sizeof(NetplanIPRoute*));
+ route = g_new0(NetplanIPRoute, 1);
+ route->type = g_strdup("unicast");
+ route->scope = g_strdup("global");
+ route->family = G_MAXUINT; /* 0 is a valid family ID */
+ route->metric = NETPLAN_METRIC_UNSPEC; /* 0 is a valid metric */
+ g_debug("%s: adding new route (kf)", key);
+
+ if (g_strcmp0(group, "ipv4") == 0)
+ route->family = AF_INET;
+ else if (g_strcmp0(group, "ipv6") == 0)
+ route->family = AF_INET6;
+
+ split = g_strsplit(kf_value, ",", 3);
+ /* Append "to" (address/prefix) */
+ if (split[0])
+ route->to = g_strdup(split[0]); //no need to free, will stay in netdef
+ /* Append gateway/via IP */
+ if (split[0] && split[1])
+ route->via = g_strdup(split[1]); //no need to free, will stay in netdef
+ /* Append metric */
+ if (split[0] && split[1] && split[2] && strtoul(split[2], NULL, 10) != NETPLAN_METRIC_UNSPEC)
+ route->metric = strtoul(split[2], NULL, 10);
+ g_strfreev(split);
+
+ /* Parse route options */
+ if (options_kf_value) {
+ g_debug("%s: adding new route_options (kf)", options_key);
+ split = g_strsplit(options_kf_value, ",", -1);
+ for (unsigned i = 0; split[i]; ++i) {
+ g_debug("processing route_option: %s", split[i]);
+ gchar **kv = g_strsplit(split[i], "=", 2);
+ if (g_strcmp0(kv[0], "onlink") == 0)
+ route->onlink = (g_strcmp0(kv[1], "true") == 0);
+ else if (g_strcmp0(kv[0], "initrwnd") == 0)
+ route->advertised_receive_window = strtoul(kv[1], NULL, 10);
+ else if (g_strcmp0(kv[0], "initcwnd") == 0)
+ route->congestion_window = strtoul(kv[1], NULL, 10);
+ else if (g_strcmp0(kv[0], "mtu") == 0)
+ route->mtubytes = strtoul(kv[1], NULL, 10);
+ else if (g_strcmp0(kv[0], "table") == 0)
+ route->table = strtoul(kv[1], NULL, 10);
+ else if (g_strcmp0(kv[0], "src") == 0)
+ route->from = g_strdup(kv[1]); //no need to free, will stay in netdef
+ else
+ unhandled_data = TRUE;
+ g_strfreev(kv);
+ }
+ g_strfreev(split);
+
+ if (!unhandled_data)
+ _kf_clear_key(kf, group, options_key);
+ g_free(options_key);
+ g_free(options_kf_value);
+ }
+
+ /* Add route to array, clear keyfile */
+ g_array_append_val(*routes_arr, route);
+ if (!unhandled_data)
+ _kf_clear_key(kf, group, key);
+ g_free(key);
+ g_free(kf_value);
+ }
+}
+
+static void
+parse_dhcp_overrides(GKeyFile* kf, const gchar* group, NetplanDHCPOverrides* dataptr)
+{
+ g_assert(dataptr);
+ if ( g_key_file_get_boolean(kf, group, "ignore-auto-routes", NULL)
+ && g_key_file_get_boolean(kf, group, "never-default", NULL)) {
+ (*dataptr).use_routes = FALSE;
+ _kf_clear_key(kf, group, "ignore-auto-routes");
+ _kf_clear_key(kf, group, "never-default");
+ }
+ handle_generic_uint(kf, group, "route-metric", &(*dataptr).metric, NETPLAN_METRIC_UNSPEC);
+}
+
+static void
+parse_search_domains(GKeyFile* kf, const gchar* group, GArray** domains_arr)
+{
+ g_assert(domains_arr);
+ gsize len = 0;
+ gchar **split = g_key_file_get_string_list(kf, group, "dns-search", &len, NULL);
+ if (split) {
+ if (len == 0) {
+ _kf_clear_key(kf, group, "dns-search");
+ return;
+ }
+ if (!*domains_arr)
+ *domains_arr = g_array_new(FALSE, FALSE, sizeof(char*));
+ for(unsigned i = 0; split[i]; ++i) {
+ char* s = g_strdup(split[i]); //no need to free, will stay in netdef
+ g_array_append_val(*domains_arr, s);
+ }
+ _kf_clear_key(kf, group, "dns-search");
+ g_strfreev(split);
+ }
+}
+
+static void
+parse_nameservers(GKeyFile* kf, const gchar* group, GArray** nameserver_arr)
+{
+ g_assert(nameserver_arr);
+ gchar **split = g_key_file_get_string_list(kf, group, "dns", NULL, NULL);
+ if (split) {
+ if (!*nameserver_arr)
+ *nameserver_arr = g_array_new(FALSE, FALSE, sizeof(char*));
+ for(unsigned i = 0; split[i]; ++i) {
+ if (strlen(split[i]) > 0) {
+ gchar* s = g_strdup(split[i]); //no need to free, will stay in netdef
+ g_array_append_val(*nameserver_arr, s);
+ }
+ }
+ _kf_clear_key(kf, group, "dns");
+ g_strfreev(split);
+ }
+}
+
+static void
+parse_dot1x_auth(GKeyFile* kf, NetplanAuthenticationSettings* auth)
+{
+ g_assert(auth);
+ g_autofree gchar* method = g_key_file_get_string(kf, "802-1x", "eap", NULL);
+
+ if (method && g_strcmp0(method, "tls") == 0) {
+ auth->eap_method = NETPLAN_AUTH_EAP_TLS;
+ _kf_clear_key(kf, "802-1x", "eap");
+ } else if (method && g_strcmp0(method, "peap") == 0) {
+ auth->eap_method = NETPLAN_AUTH_EAP_PEAP;
+ _kf_clear_key(kf, "802-1x", "eap");
+ } else if (method && g_strcmp0(method, "ttls") == 0) {
+ auth->eap_method = NETPLAN_AUTH_EAP_TTLS;
+ _kf_clear_key(kf, "802-1x", "eap");
+ }
+
+ handle_generic_str(kf, "802-1x", "identity", &auth->identity);
+ handle_generic_str(kf, "802-1x", "anonymous-identity", &auth->anonymous_identity);
+ if (!auth->password)
+ handle_generic_str(kf, "802-1x", "password", &auth->password);
+ handle_generic_str(kf, "802-1x", "ca-cert", &auth->ca_certificate);
+ handle_generic_str(kf, "802-1x", "client-cert", &auth->client_certificate);
+ handle_generic_str(kf, "802-1x", "private-key", &auth->client_key);
+ handle_generic_str(kf, "802-1x", "private-key-password", &auth->client_key_password);
+ handle_generic_str(kf, "802-1x", "phase2-auth", &auth->phase2_auth);
+}
+
+static void
+parse_bond_arp_ip_targets(GKeyFile* kf, GArray **targets_arr)
+{
+ g_assert(targets_arr);
+ g_autofree gchar *v = g_key_file_get_string(kf, "bond", "arp_ip_target", NULL);
+ if (v) {
+ gchar** split = g_strsplit(v, ",", -1);
+ for (unsigned i = 0; split[i]; ++i) {
+ if (!*targets_arr)
+ *targets_arr = g_array_new(FALSE, FALSE, sizeof(char *));
+ gchar *s = g_strdup(split[i]);
+ g_array_append_val(*targets_arr, s);
+ }
+ _kf_clear_key(kf, "bond", "arp_ip_target");
+ g_strfreev(split);
+ }
+}
+
+/* Read the key-value pairs from the keyfile and pass them through to a map */
+static void
+read_passthrough(GKeyFile* kf, GData** list)
+{
+ gchar **groups = NULL;
+ gchar **keys = NULL;
+ gchar *group_key = NULL;
+ gchar *value = NULL;
+ gsize klen = 0;
+ gsize glen = 0;
+
+ if (!*list)
+ g_datalist_init(list);
+ groups = g_key_file_get_groups(kf, &glen);
+ if (groups) {
+ for (unsigned i = 0; i < glen; ++i) {
+ klen = 0;
+ keys = g_key_file_get_keys(kf, groups[i], &klen, NULL);
+ if (klen == 0) {
+ /* empty group */
+ g_datalist_set_data_full(list, g_strconcat(groups[i], ".", NETPLAN_NM_EMPTY_GROUP, NULL), g_strdup(""), g_free);
+ continue;
+ }
+ for (unsigned j = 0; j < klen; ++j) {
+ value = g_key_file_get_string(kf, groups[i], keys[j], NULL);
+ if (!value) {
+ // LCOV_EXCL_START
+ g_warning("netplan: Keyfile: cannot read value of %s.%s", groups[i], keys[j]);
+ continue;
+ // LCOV_EXCL_STOP
+ }
+ group_key = g_strconcat(groups[i], ".", keys[j], NULL);
+ g_datalist_set_data_full(list, group_key, value, g_free);
+ /* no need to free group_key and value: they stay in the list */
+ }
+ g_strfreev(keys);
+ }
+ g_strfreev(groups);
+ }
+}
+
+/**
+ * Parse keyfile into a NetplanNetDefinition struct
+ * @filename: full path to the NetworkManager keyfile
+ */
+gboolean
+netplan_parse_keyfile(const char* filename, GError** error)
+{
+ g_autofree gchar *nd_id = NULL;
+ g_autofree gchar *uuid = NULL;
+ g_autofree gchar *type = NULL;
+ g_autofree gchar* wifi_mode = NULL;
+ g_autofree gchar* ssid = NULL;
+ g_autofree gchar* netdef_id = NULL;
+ gchar *tmp_str = NULL;
+ NetplanNetDefinition* nd = NULL;
+ NetplanWifiAccessPoint* ap = NULL;
+ g_autoptr(GKeyFile) kf = g_key_file_new();
+ NetplanDefType nd_type = NETPLAN_DEF_TYPE_NONE;
+ if (!g_key_file_load_from_file(kf, filename, G_KEY_FILE_NONE, error)) {
+ g_warning("netplan: cannot load keyfile");
+ return FALSE;
+ }
+
+ ssid = g_key_file_get_string(kf, "wifi", "ssid", NULL);
+ if (!ssid)
+ ssid = g_key_file_get_string(kf, "802-11-wireless", "ssid", NULL);
+
+ netdef_id = netplan_get_id_from_nm_filename(filename, ssid);
+ uuid = g_key_file_get_string(kf, "connection", "uuid", NULL);
+ if (!uuid) {
+ g_warning("netplan: Keyfile: cannot find connection.uuid");
+ return FALSE;
+ }
+
+ type = g_key_file_get_string(kf, "connection", "type", NULL);
+ if (!type) {
+ g_warning("netplan: Keyfile: cannot find connection.type");
+ return FALSE;
+ }
+ nd_type = type_from_str(type);
+
+ tmp_str = g_key_file_get_string(kf, "connection", "interface-name", NULL);
+ /* Use previously existing netdef IDs, if available, to override connections
+ * Else: generate a "NM-<UUID>" ID */
+ if (netdef_id) {
+ nd_id = g_strdup(netdef_id);
+ if (g_strcmp0(netdef_id, tmp_str) == 0)
+ _kf_clear_key(kf, "connection", "interface-name");
+ } else if (tmp_str && nd_type >= NETPLAN_DEF_TYPE_VIRTUAL && nd_type < NETPLAN_DEF_TYPE_NM) {
+ /* netdef ID equals "interface-name" for virtual devices (bridge/bond/...) */
+ nd_id = g_strdup(tmp_str);
+ _kf_clear_key(kf, "connection", "interface-name");
+ } else
+ nd_id = g_strconcat("NM-", uuid, NULL);
+ g_free(tmp_str);
+ nd = netplan_netdef_new(nd_id, nd_type, NETPLAN_BACKEND_NM);
+
+ /* Handle uuid & NM name/id */
+ nd->backend_settings.nm.uuid = g_strdup(uuid);
+ _kf_clear_key(kf, "connection", "uuid");
+ nd->backend_settings.nm.name = g_key_file_get_string(kf, "connection", "id", NULL);
+ if (nd->backend_settings.nm.name)
+ _kf_clear_key(kf, "connection", "id");
+
+ if (nd_type == NETPLAN_DEF_TYPE_NM)
+ goto only_passthrough; //do not try to handle any keys for connections types unknown to netplan
+
+ /* remove supported values from passthrough, which have been handled */
+ if ( nd_type == NETPLAN_DEF_TYPE_ETHERNET
+ || nd_type == NETPLAN_DEF_TYPE_WIFI
+ || nd_type == NETPLAN_DEF_TYPE_MODEM
+ || nd_type == NETPLAN_DEF_TYPE_BRIDGE
+ || nd_type == NETPLAN_DEF_TYPE_BOND
+ || nd_type == NETPLAN_DEF_TYPE_VLAN)
+ _kf_clear_key(kf, "connection", "type");
+
+ /* Handle match: Netplan usually defines a connection per interface, while
+ * NM connection profiles are usually applied to any interface of matching
+ * type (like wifi/ethernet/...). */
+ if (nd->type < NETPLAN_DEF_TYPE_VIRTUAL) {
+ nd->match.original_name = g_key_file_get_string(kf, "connection", "interface-name", NULL);
+ if (nd->match.original_name)
+ _kf_clear_key(kf, "connection", "interface-name");
+ /* Set match, even if it is empty, so the NM renderer will not force
+ * the netdef ID as interface-name */
+ nd->has_match = TRUE;
+ }
+
+ /* DHCPv4/v6 */
+ set_true_on_match(kf, "ipv4", "method", "auto", &nd->dhcp4);
+ set_true_on_match(kf, "ipv6", "method", "auto", &nd->dhcp6);
+ parse_dhcp_overrides(kf, "ipv4", &nd->dhcp4_overrides);
+ parse_dhcp_overrides(kf, "ipv6", &nd->dhcp6_overrides);
+
+ /* Manual IPv4/6 addresses */
+ parse_addresses(kf, "ipv4", &nd->ip4_addresses);
+ parse_addresses(kf, "ipv6", &nd->ip6_addresses);
+
+ /* Default gateways */
+ handle_generic_str(kf, "ipv4", "gateway", &nd->gateway4);
+ handle_generic_str(kf, "ipv6", "gateway", &nd->gateway6);
+
+ /* Routes */
+ parse_routes(kf, "ipv4", &nd->routes);
+ parse_routes(kf, "ipv6", &nd->routes);
+
+ /* DNS: XXX: How to differentiate ip4/ip6 search_domains? */
+ parse_search_domains(kf, "ipv4", &nd->search_domains);
+ parse_search_domains(kf, "ipv6", &nd->search_domains);
+ parse_nameservers(kf, "ipv4", &nd->ip4_nameservers);
+ parse_nameservers(kf, "ipv6", &nd->ip6_nameservers);
+
+ /* IP6 addr-gen
+ * Different than suggested by the docs, NM stores 'addr-gen-mode' as string */
+ tmp_str = g_key_file_get_string(kf, "ipv6", "addr-gen-mode", NULL);
+ if (tmp_str) {
+ if (g_strcmp0(tmp_str, "stable-privacy") == 0) {
+ nd->ip6_addr_gen_mode = NETPLAN_ADDRGEN_STABLEPRIVACY;
+ _kf_clear_key(kf, "ipv6", "addr-gen-mode");
+ } else if (g_strcmp0(tmp_str, "eui64") == 0) {
+ nd->ip6_addr_gen_mode = NETPLAN_ADDRGEN_EUI64;
+ _kf_clear_key(kf, "ipv6", "addr-gen-mode");
+ }
+ }
+ g_free(tmp_str);
+ handle_generic_str(kf, "ipv6", "token", &nd->ip6_addr_gen_token);
+
+ /* Modem parameters
+ * NM differentiates between GSM and CDMA connections, while netplan
+ * combines them as "modems". We need to parse a basic set of parameters
+ * to enable the generator (in nm.c) to detect GSM vs CDMA connections,
+ * using its modem_is_gsm() util. */
+ handle_generic_bool(kf, "gsm", "auto-config", &nd->modem_params.auto_config);
+ handle_generic_str(kf, "gsm", "apn", &nd->modem_params.apn);
+ handle_generic_str(kf, "gsm", "device-id", &nd->modem_params.device_id);
+ handle_generic_str(kf, "gsm", "network-id", &nd->modem_params.network_id);
+ handle_generic_str(kf, "gsm", "pin", &nd->modem_params.pin);
+ handle_generic_str(kf, "gsm", "sim-id", &nd->modem_params.sim_id);
+ handle_generic_str(kf, "gsm", "sim-operator-id", &nd->modem_params.sim_operator_id);
+
+ /* GSM & CDMA */
+ handle_generic_uint(kf, "cdma", "mtu", &nd->mtubytes, NETPLAN_MTU_UNSPEC);
+ handle_generic_uint(kf, "gsm", "mtu", &nd->mtubytes, NETPLAN_MTU_UNSPEC);
+ handle_generic_str(kf, "gsm", "number", &nd->modem_params.number);
+ if (!nd->modem_params.number)
+ handle_generic_str(kf, "cdma", "number", &nd->modem_params.number);
+ handle_generic_str(kf, "gsm", "password", &nd->modem_params.password);
+ if (!nd->modem_params.password)
+ handle_generic_str(kf, "cdma", "password", &nd->modem_params.password);
+ handle_generic_str(kf, "gsm", "username", &nd->modem_params.username);
+ if (!nd->modem_params.username)
+ handle_generic_str(kf, "cdma", "username", &nd->modem_params.username);
+
+ /* Ethernets */
+ if (g_key_file_has_group(kf, "ethernet")) {
+ /* wake-on-lan, do not clear passthrough as we do not fully support this setting */
+ if (!g_key_file_has_key(kf, "ethernet", "wake-on-lan", NULL)) {
+ nd->wake_on_lan = TRUE; //NM's default is "1"
+ } else {
+ guint value = g_key_file_get_uint64(kf, "ethernet", "wake-on-lan", NULL);
+ //XXX: fix delta between options in NM (0x1, 0x2, 0x4, ...) and netplan (bool)
+ nd->wake_on_lan = value > 0; // netplan only knows about "off" or "on"
+ if (value == 0)
+ _kf_clear_key(kf, "ethernet", "wake-on-lan"); // value "off" is supported
+ }
+
+ handle_common(kf, nd, "ethernet");
+ }
+
+ /* Wifis */
+ if (g_key_file_has_group(kf, "wifi")) {
+ if (g_key_file_get_uint64(kf, "wifi", "wake-on-wlan", NULL)) {
+ nd->wowlan = g_key_file_get_uint64(kf, "wifi", "wake-on-wlan", NULL);
+ _kf_clear_key(kf, "wifi", "wake-on-wlan");
+ } else {
+ nd->wowlan = NETPLAN_WIFI_WOWLAN_DEFAULT;
+ }
+
+ handle_common(kf, nd, "wifi");
+ }
+
+ /* Cleanup some implicit keys */
+ tmp_str = g_key_file_get_string(kf, "ipv6", "method", NULL);
+ if (tmp_str && g_strcmp0(tmp_str, "ignore") == 0 &&
+ !(nd->dhcp6 || nd->ip6_addresses || nd->gateway6 ||
+ nd->ip6_nameservers || nd->ip6_addr_gen_mode))
+ _kf_clear_key(kf, "ipv6", "method");
+ g_free(tmp_str);
+
+ tmp_str = g_key_file_get_string(kf, "ipv4", "method", NULL);
+ if (tmp_str && g_strcmp0(tmp_str, "link-local") == 0 &&
+ !(nd->dhcp4 || nd->ip4_addresses || nd->gateway4 ||
+ nd->ip4_nameservers))
+ _kf_clear_key(kf, "ipv4", "method");
+ g_free(tmp_str);
+
+ /* Vlan: XXX: find a way to parse the "link:" (parent) connection */
+ handle_generic_uint(kf, "vlan", "id", &nd->vlan_id, G_MAXUINT);
+
+ /* Bridge: XXX: find a way to parse the bridge-port.priority & bridge-port.path-cost values */
+ handle_generic_uint(kf, "bridge", "priority", &nd->bridge_params.priority, 0);
+ if (nd->bridge_params.priority)
+ nd->custom_bridging = TRUE;
+ handle_bridge_uint(kf, "ageing-time", nd, &nd->bridge_params.ageing_time);
+ handle_bridge_uint(kf, "hello-time", nd, &nd->bridge_params.hello_time);
+ handle_bridge_uint(kf, "forward-delay", nd, &nd->bridge_params.forward_delay);
+ handle_bridge_uint(kf, "max-age", nd, &nd->bridge_params.max_age);
+ /* STP needs to be handled last, for its different default value in custom_bridging mode */
+ if (g_key_file_has_key(kf, "bridge", "stp", NULL)) {
+ nd->custom_bridging = TRUE;
+ handle_generic_bool(kf, "bridge", "stp", &nd->bridge_params.stp);
+ } else if(nd->custom_bridging) {
+ nd->bridge_params.stp = TRUE; //set default value if not specified otherwise
+ }
+
+ /* Bonds */
+ handle_generic_str(kf, "bond", "mode", &nd->bond_params.mode);
+ handle_generic_str(kf, "bond", "lacp_rate", &nd->bond_params.lacp_rate);
+ handle_generic_str(kf, "bond", "miimon", &nd->bond_params.monitor_interval);
+ handle_generic_str(kf, "bond", "xmit_hash_policy", &nd->bond_params.transmit_hash_policy);
+ handle_generic_str(kf, "bond", "ad_select", &nd->bond_params.selection_logic);
+ handle_generic_str(kf, "bond", "arp_interval", &nd->bond_params.arp_interval);
+ handle_generic_str(kf, "bond", "arp_validate", &nd->bond_params.arp_validate);
+ handle_generic_str(kf, "bond", "arp_all_targets", &nd->bond_params.arp_all_targets);
+ handle_generic_str(kf, "bond", "updelay", &nd->bond_params.up_delay);
+ handle_generic_str(kf, "bond", "downdelay", &nd->bond_params.down_delay);
+ handle_generic_str(kf, "bond", "fail_over_mac", &nd->bond_params.fail_over_mac_policy);
+ handle_generic_str(kf, "bond", "primary_reselect", &nd->bond_params.primary_reselect_policy);
+ handle_generic_str(kf, "bond", "lp_interval", &nd->bond_params.learn_interval);
+ handle_generic_str(kf, "bond", "primary", &nd->bond_params.primary_slave);
+ handle_generic_uint(kf, "bond", "min_links", &nd->bond_params.min_links, 0);
+ handle_generic_uint(kf, "bond", "resend_igmp", &nd->bond_params.resend_igmp, 0);
+ handle_generic_uint(kf, "bond", "packets_per_slave", &nd->bond_params.packets_per_slave, 0);
+ handle_generic_uint(kf, "bond", "num_grat_arp", &nd->bond_params.gratuitous_arp, 0);
+ /* num_unsol_na might overwrite num_grat_arp, but we're fine if they are equal:
+ * https://github.com/NetworkManager/NetworkManager/commit/42b0bef33c77a0921590b2697f077e8ea7805166 */
+ if (g_key_file_get_uint64(kf, "bond", "num_unsol_na", NULL) == nd->bond_params.gratuitous_arp)
+ _kf_clear_key(kf, "bond", "num_unsol_na");
+ handle_generic_bool(kf, "bond", "all_slaves_active", &nd->bond_params.all_slaves_active);
+ parse_bond_arp_ip_targets(kf, &nd->bond_params.arp_ip_targets);
+
+ /* Special handling for WiFi "access-points:" mapping */
+ if (nd->type == NETPLAN_DEF_TYPE_WIFI) {
+ ap = g_new0(NetplanWifiAccessPoint, 1);
+ ap->ssid = g_key_file_get_string(kf, "wifi", "ssid", NULL);
+ if (!ap->ssid) {
+ g_warning("netplan: Keyfile: cannot find SSID for WiFi connection");
+ return FALSE;
+ } else
+ _kf_clear_key(kf, "wifi", "ssid");
+
+ wifi_mode = g_key_file_get_string(kf, "wifi", "mode", NULL);
+ if (wifi_mode) {
+ ap->mode = ap_type_from_str(wifi_mode);
+ if (ap->mode != NETPLAN_WIFI_MODE_OTHER)
+ _kf_clear_key(kf, "wifi", "mode");
+ }
+
+ tmp_str = g_key_file_get_string(kf, "ipv4", "method", NULL);
+ if (tmp_str && g_strcmp0(tmp_str, "shared") == 0) {
+ ap->mode = NETPLAN_WIFI_MODE_AP;
+ _kf_clear_key(kf, "ipv4", "method");
+ }
+ g_free(tmp_str);
+
+ handle_generic_bool(kf, "wifi", "hidden", &ap->hidden);
+ handle_generic_str(kf, "wifi", "bssid", &ap->bssid);
+
+ /* Wifi band & channel */
+ tmp_str = g_key_file_get_string(kf, "wifi", "band", NULL);
+ if (tmp_str && g_strcmp0(tmp_str, "a") == 0) {
+ ap->band = NETPLAN_WIFI_BAND_5;
+ _kf_clear_key(kf, "wifi", "band");
+ } else if (tmp_str && g_strcmp0(tmp_str, "bg") == 0) {
+ ap->band = NETPLAN_WIFI_BAND_24;
+ _kf_clear_key(kf, "wifi", "band");
+ }
+ g_free(tmp_str);
+ handle_generic_uint(kf, "wifi", "channel", &ap->channel, 0);
+
+ /* Wifi security */
+ tmp_str = g_key_file_get_string(kf, "wifi-security", "key-mgmt", NULL);
+ if (tmp_str && g_strcmp0(tmp_str, "wpa-psk") == 0) {
+ ap->auth.key_management = NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK;
+ ap->has_auth = TRUE;
+ _kf_clear_key(kf, "wifi-security", "key-mgmt");
+ } else if (tmp_str && g_strcmp0(tmp_str, "wpa-eap") == 0) {
+ ap->auth.key_management = NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP;
+ ap->has_auth = TRUE;
+ _kf_clear_key(kf, "wifi-security", "key-mgmt");
+ } else if (tmp_str && g_strcmp0(tmp_str, "ieee8021x") == 0) {
+ ap->auth.key_management = NETPLAN_AUTH_KEY_MANAGEMENT_8021X;
+ ap->has_auth = TRUE;
+ _kf_clear_key(kf, "wifi-security", "key-mgmt");
+ }
+ g_free(tmp_str);
+
+ handle_generic_str(kf, "wifi-security", "psk", &ap->auth.password);
+ if (ap->auth.password)
+ ap->has_auth = TRUE;
+
+ parse_dot1x_auth(kf, &ap->auth);
+ if (ap->auth.eap_method != NETPLAN_AUTH_EAP_NONE)
+ ap->has_auth = TRUE;
+
+ if (!nd->access_points)
+ nd->access_points = g_hash_table_new(g_str_hash, g_str_equal);
+ g_hash_table_insert(nd->access_points, ap->ssid, ap);
+
+ /* Last: handle passthrough for everything left in the keyfile
+ * Also, transfer backend_settings from netdef to AP */
+ ap->backend_settings.nm.uuid = nd->backend_settings.nm.uuid;
+ ap->backend_settings.nm.name = nd->backend_settings.nm.name;
+ /* No need to clear nm.uuid & nm.name from def->backend_settings,
+ * as we have only one AP. */
+ read_passthrough(kf, &ap->backend_settings.nm.passthrough);
+ } else {
+only_passthrough:
+ /* Last: handle passthrough for everything left in the keyfile */
+ read_passthrough(kf, &nd->backend_settings.nm.passthrough);
+ }
+
+ g_key_file_free(kf);
+ return TRUE;
+}
diff --git a/src/parse-nm.h b/src/parse-nm.h
new file mode 100644
index 0000000..53973f7
--- /dev/null
+++ b/src/parse-nm.h
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2021 Canonical, Ltd.
+ * Author: Lukas Märdian <slyon@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#define NETPLAN_NM_EMPTY_GROUP "_"
+
+gboolean netplan_parse_keyfile(const char* filename, GError** error);
diff --git a/src/parse.c b/src/parse.c
new file mode 100644
index 0000000..09cd1e2
--- /dev/null
+++ b/src/parse.c
@@ -0,0 +1,2790 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ * Lukas Märdian <lukas.maerdian@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdarg.h>
+#include <errno.h>
+#include <regex.h>
+#include <arpa/inet.h>
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <gio/gio.h>
+
+#include <yaml.h>
+
+#include "parse.h"
+#include "util.h"
+#include "error.h"
+#include "validation.h"
+
+/* convenience macro to put the offset of a NetplanNetDefinition field into "void* data" */
+#define access_point_offset(field) GUINT_TO_POINTER(offsetof(NetplanWifiAccessPoint, field))
+#define addr_option_offset(field) GUINT_TO_POINTER(offsetof(NetplanAddressOptions, field))
+#define auth_offset(field) GUINT_TO_POINTER(offsetof(NetplanAuthenticationSettings, field))
+#define ip_rule_offset(field) GUINT_TO_POINTER(offsetof(NetplanIPRule, field))
+#define netdef_offset(field) GUINT_TO_POINTER(offsetof(NetplanNetDefinition, field))
+#define ovs_settings_offset(field) GUINT_TO_POINTER(offsetof(NetplanOVSSettings, field))
+#define route_offset(field) GUINT_TO_POINTER(offsetof(NetplanIPRoute, field))
+#define wireguard_peer_offset(field) GUINT_TO_POINTER(offsetof(NetplanWireguardPeer, field))
+
+/* convenience macro to avoid strdup'ing a string into a field if it's already set. */
+#define set_str_if_null(dst, src) { if (dst) {\
+ g_assert_cmpstr(src, ==, dst); \
+} else { \
+ dst = g_strdup(src); \
+} }
+
+/* NetplanNetDefinition that is currently being processed */
+static NetplanNetDefinition* cur_netdef;
+
+/* NetplanWifiAccessPoint that is currently being processed */
+static NetplanWifiAccessPoint* cur_access_point;
+
+/* NetplanAuthenticationSettings that are currently being processed */
+static NetplanAuthenticationSettings* cur_auth;
+
+/* NetplanWireguardPeer that is currently being processed */
+static NetplanWireguardPeer* cur_wireguard_peer;
+
+static NetplanAddressOptions* cur_addr_option;
+
+static NetplanIPRoute* cur_route;
+static NetplanIPRule* cur_ip_rule;
+
+/* Filename of the currently parsed YAML file */
+const char* cur_filename;
+
+static NetplanBackend backend_global, backend_cur_type;
+
+/* global OpenVSwitch settings */
+NetplanOVSSettings ovs_settings_global;
+
+/* Global ID → NetplanNetDefinition* map for all parsed config files */
+GHashTable* netdefs;
+
+/* Contains the same objects as 'netdefs' but ordered by dependency */
+GList* netdefs_ordered;
+
+/* Set of IDs in currently parsed YAML file, for being able to detect
+ * "duplicate ID within one file" vs. allowing a drop-in to override/amend an
+ * existing definition */
+static GHashTable* ids_in_file;
+
+/* Global variables, defined in this file */
+int missing_ids_found;
+const char* current_file;
+GHashTable* missing_id;
+
+/**
+ * Load YAML file name into a yaml_document_t.
+ *
+ * Returns: TRUE on success, FALSE if the document is malformed; @error gets set then.
+ */
+static gboolean
+load_yaml(const char* yaml, yaml_document_t* doc, GError** error)
+{
+ FILE* fyaml = NULL;
+ yaml_parser_t parser;
+ gboolean ret = TRUE;
+
+ current_file = yaml;
+
+ fyaml = g_fopen(yaml, "r");
+ if (!fyaml) {
+ g_set_error(error, G_FILE_ERROR, errno, "Cannot open %s: %s", yaml, g_strerror(errno));
+ return FALSE;
+ }
+
+ yaml_parser_initialize(&parser);
+ yaml_parser_set_input_file(&parser, fyaml);
+ if (!yaml_parser_load(&parser, doc)) {
+ ret = parser_error(&parser, yaml, error);
+ }
+
+ yaml_parser_delete(&parser);
+ fclose(fyaml);
+ return ret;
+}
+
+#define YAML_VARIABLE_NODE YAML_NO_NODE
+
+/**
+ * Raise a GError about a type mismatch and return FALSE.
+ */
+static gboolean
+assert_type_fn(yaml_node_t* node, yaml_node_type_t expected_type, GError** error)
+{
+ if (node->type == expected_type)
+ return TRUE;
+
+ switch (expected_type) {
+ case YAML_VARIABLE_NODE:
+ /* Special case, defer sanity checking to the next handlers */
+ return TRUE;
+ break;
+ case YAML_SCALAR_NODE:
+ yaml_error(node, error, "expected scalar");
+ break;
+ case YAML_SEQUENCE_NODE:
+ yaml_error(node, error, "expected sequence");
+ break;
+ case YAML_MAPPING_NODE:
+ yaml_error(node, error, "expected mapping (check indentation)");
+ break;
+
+ // LCOV_EXCL_START
+ default:
+ g_assert_not_reached();
+ // LCOV_EXCL_STOP
+ }
+ return FALSE;
+}
+
+#define assert_type(n,t) { if (!assert_type_fn(n,t,error)) return FALSE; }
+
+static inline const char*
+scalar(const yaml_node_t* node)
+{
+ return (const char*) node->data.scalar.value;
+}
+
+static void
+add_missing_node(const yaml_node_t* node)
+{
+ NetplanMissingNode* missing;
+
+ /* Let's capture the current netdef we were playing with along with the
+ * actual yaml_node_t that errors (that is an identifier not previously
+ * seen by the compiler). We can use it later to write an sensible error
+ * message and point the user in the right direction. */
+ missing = g_new0(NetplanMissingNode, 1);
+ missing->netdef_id = cur_netdef->id;
+ missing->node = node;
+
+ g_debug("recording missing yaml_node_t %s", scalar(node));
+ g_hash_table_insert(missing_id, (gpointer)scalar(node), missing);
+}
+
+/**
+ * Check that node contains a valid ID/interface name. Raise GError if not.
+ */
+static gboolean
+assert_valid_id(yaml_node_t* node, GError** error)
+{
+ static regex_t re;
+ static gboolean re_inited = FALSE;
+
+ assert_type(node, YAML_SCALAR_NODE);
+
+ if (!re_inited) {
+ g_assert(regcomp(&re, "^[[:alnum:][:punct:]]+$", REG_EXTENDED|REG_NOSUB) == 0);
+ re_inited = TRUE;
+ }
+
+ if (regexec(&re, scalar(node), 0, NULL, 0) != 0)
+ return yaml_error(node, error, "Invalid name '%s'", scalar(node));
+ return TRUE;
+}
+
+static void
+initialize_dhcp_overrides(NetplanDHCPOverrides* overrides)
+{
+ overrides->use_dns = TRUE;
+ overrides->use_domains = NULL;
+ overrides->use_ntp = TRUE;
+ overrides->send_hostname = TRUE;
+ overrides->use_hostname = TRUE;
+ overrides->use_mtu = TRUE;
+ overrides->use_routes = TRUE;
+ overrides->hostname = NULL;
+ overrides->metric = NETPLAN_METRIC_UNSPEC;
+}
+
+static void
+initialize_ovs_settings(NetplanOVSSettings* ovs_settings)
+{
+ ovs_settings->mcast_snooping = FALSE;
+ ovs_settings->rstp = FALSE;
+}
+
+NetplanNetDefinition*
+netplan_netdef_new(const char* id, NetplanDefType type, NetplanBackend backend)
+{
+ /* create new network definition */
+ cur_netdef = g_new0(NetplanNetDefinition, 1);
+ cur_netdef->type = type;
+ cur_netdef->backend = backend ?: NETPLAN_BACKEND_NONE;
+ cur_netdef->id = g_strdup(id);
+
+ /* Set some default values */
+ cur_netdef->vlan_id = G_MAXUINT; /* 0 is a valid ID */
+ cur_netdef->tunnel.mode = NETPLAN_TUNNEL_MODE_UNKNOWN;
+ cur_netdef->dhcp_identifier = g_strdup("duid"); /* keep networkd's default */
+ /* systemd-networkd defaults to IPv6 LL enabled; keep that default */
+ cur_netdef->linklocal.ipv6 = TRUE;
+ cur_netdef->sriov_vlan_filter = FALSE;
+ cur_netdef->sriov_explicit_vf_count = G_MAXUINT; /* 0 is a valid number of VFs */
+
+ /* DHCP override defaults */
+ initialize_dhcp_overrides(&cur_netdef->dhcp4_overrides);
+ initialize_dhcp_overrides(&cur_netdef->dhcp6_overrides);
+
+ /* OpenVSwitch defaults */
+ initialize_ovs_settings(&cur_netdef->ovs_settings);
+
+ if (!netdefs)
+ netdefs = g_hash_table_new(g_str_hash, g_str_equal);
+ g_hash_table_insert(netdefs, cur_netdef->id, cur_netdef);
+ netdefs_ordered = g_list_append(netdefs_ordered, cur_netdef);
+ return cur_netdef;
+}
+
+/****************************************************
+ * Data types and functions for interpreting YAML nodes
+ ****************************************************/
+
+typedef gboolean (*node_handler) (yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error);
+
+typedef struct mapping_entry_handler_s {
+ /* mapping key (must be scalar) */
+ const char* key;
+ /* expected type of the mapped value */
+ yaml_node_type_t type;
+ /* handler for the value of this key */
+ node_handler handler;
+ /* if type == YAML_MAPPING_NODE and handler is NULL, use process_mapping()
+ * on this handler map as handler */
+ const struct mapping_entry_handler_s* map_handlers;
+ /* user_data */
+ const void* data;
+} mapping_entry_handler;
+
+/**
+ * Return the #mapping_entry_handler that matches @key, or NULL if not found.
+ */
+static const mapping_entry_handler*
+get_handler(const mapping_entry_handler* handlers, const char* key)
+{
+ for (unsigned i = 0; handlers[i].key != NULL; ++i) {
+ if (g_strcmp0(handlers[i].key, key) == 0)
+ return &handlers[i];
+ }
+ return NULL;
+}
+
+/**
+ * Call handlers for all entries in a YAML mapping.
+ * @doc: The yaml_document_t
+ * @node: The yaml_node_t to process, must be a #YAML_MAPPING_NODE
+ * @handlers: Array of mapping_entry_handler with allowed keys
+ * @error: Gets set on data type errors or unknown keys
+ *
+ * Returns: TRUE on success, FALSE on error (@error gets set then).
+ */
+static gboolean
+process_mapping(yaml_document_t* doc, yaml_node_t* node, const mapping_entry_handler* handlers, GList** out_values, GError** error)
+{
+ yaml_node_pair_t* entry;
+
+ assert_type(node, YAML_MAPPING_NODE);
+
+ for (entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+ const mapping_entry_handler* h;
+
+ g_assert(error == NULL || *error == NULL);
+
+ key = yaml_document_get_node(doc, entry->key);
+ value = yaml_document_get_node(doc, entry->value);
+ assert_type(key, YAML_SCALAR_NODE);
+ h = get_handler(handlers, scalar(key));
+ if (!h)
+ return yaml_error(key, error, "unknown key '%s'", scalar(key));
+ assert_type(value, h->type);
+ if (out_values)
+ *out_values = g_list_prepend(*out_values, g_strdup(scalar(key)));
+ if (h->map_handlers) {
+ g_assert(h->handler == NULL);
+ g_assert(h->type == YAML_MAPPING_NODE);
+ if (!process_mapping(doc, value, h->map_handlers, NULL, error))
+ return FALSE;
+ } else {
+ if (!h->handler(doc, value, h->data, error))
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/*************************************************************
+ * Generic helper functions to extract data from scalar nodes.
+ *************************************************************/
+
+/**
+ * Handler for setting a guint field from a scalar node, inside a given struct
+ * @entryptr: pointer to the begining of the to-be-modified data structure
+ * @data: offset into entryptr struct where the guint field to write is located
+ */
+static gboolean
+handle_generic_guint(yaml_document_t* doc, yaml_node_t* node, const void* entryptr, const void* data, GError** error)
+{
+ g_assert(entryptr);
+ guint offset = GPOINTER_TO_UINT(data);
+ guint64 v;
+ gchar* endptr;
+
+ v = g_ascii_strtoull(scalar(node), &endptr, 10);
+ if (*endptr != '\0' || v > G_MAXUINT)
+ return yaml_error(node, error, "invalid unsigned int value '%s'", scalar(node));
+
+ *((guint*) ((void*) entryptr + offset)) = (guint) v;
+ return TRUE;
+}
+
+/**
+ * Handler for setting a string field from a scalar node, inside a given struct
+ * @entryptr: pointer to the beginning of the to-be-modified data structure
+ * @data: offset into entryptr struct where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_generic_str(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ g_assert(entryptr);
+ guint offset = GPOINTER_TO_UINT(data);
+ char** dest = (char**) ((void*) entryptr + offset);
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+ return TRUE;
+}
+
+/*
+ * Handler for setting a MAC address field from a scalar node, inside a given struct
+ * @entryptr: pointer to the beginning of the to-be-modified data structure
+ * @data: offset into entryptr struct where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_generic_mac(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ g_assert(entryptr);
+ static regex_t re;
+ static gboolean re_inited = FALSE;
+
+ g_assert(node->type == YAML_SCALAR_NODE);
+
+ if (!re_inited) {
+ g_assert(regcomp(&re, "^[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]$", REG_EXTENDED|REG_NOSUB) == 0);
+ re_inited = TRUE;
+ }
+
+ if (regexec(&re, scalar(node), 0, NULL, 0) != 0)
+ return yaml_error(node, error, "Invalid MAC address '%s', must be XX:XX:XX:XX:XX:XX", scalar(node));
+
+ return handle_generic_str(doc, node, entryptr, data, error);
+}
+
+/*
+ * Handler for setting a boolean field from a scalar node, inside a given struct
+ * @entryptr: pointer to the beginning of the to-be-modified data structure
+ * @data: offset into entryptr struct where the boolean field to write is located
+ */
+static gboolean
+handle_generic_bool(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ g_assert(entryptr);
+ guint offset = GPOINTER_TO_UINT(data);
+ gboolean v;
+
+ if (g_ascii_strcasecmp(scalar(node), "true") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "on") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "yes") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "y") == 0)
+ v = TRUE;
+ else if (g_ascii_strcasecmp(scalar(node), "false") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "off") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "no") == 0 ||
+ g_ascii_strcasecmp(scalar(node), "n") == 0)
+ v = FALSE;
+ else
+ return yaml_error(node, error, "invalid boolean value '%s'", scalar(node));
+
+ *((gboolean*) ((void*) entryptr + offset)) = v;
+ return TRUE;
+}
+
+/*
+ * Handler for setting a HashTable field from a mapping node, inside a given struct
+ * @entryptr: pointer to the beginning of the to-be-modified data structure
+ * @data: offset into entryptr struct where the boolean field to write is located
+*/
+static gboolean
+handle_generic_map(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ GHashTable** map = (GHashTable**) ((void*) entryptr + offset);
+ if (!*map)
+ *map = g_hash_table_new(g_str_hash, g_str_equal);
+
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+
+ key = yaml_document_get_node(doc, entry->key);
+ value = yaml_document_get_node(doc, entry->value);
+
+ assert_type(key, YAML_SCALAR_NODE);
+ assert_type(value, YAML_SCALAR_NODE);
+
+ /* TODO: make sure we free all the memory here */
+ if (!g_hash_table_insert(*map, g_strdup(scalar(key)), g_strdup(scalar(value))))
+ return yaml_error(node, error, "duplicate map entry '%s'", scalar(key));
+ }
+
+ return TRUE;
+}
+
+/*
+ * Handler for setting a DataList field from a mapping node, inside a given struct
+ * @entryptr: pointer to the beginning of the to-be-modified data structure
+ * @data: offset into entryptr struct where the boolean field to write is located
+*/
+static gboolean
+handle_generic_datalist(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ GData** list = (GData**) ((void*) entryptr + offset);
+ if (!*list)
+ g_datalist_init(list);
+
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+
+ key = yaml_document_get_node(doc, entry->key);
+ value = yaml_document_get_node(doc, entry->value);
+
+ assert_type(key, YAML_SCALAR_NODE);
+ assert_type(value, YAML_SCALAR_NODE);
+
+ g_datalist_set_data_full(list, g_strdup(scalar(key)), g_strdup(scalar(value)), g_free);
+ }
+
+ return TRUE;
+}
+
+/**
+ * Generic handler for setting a cur_netdef string field from a scalar node
+ * @data: offset into NetplanNetDefinition where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_netdef_str(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_str(doc, node, cur_netdef, data, error);
+}
+
+/**
+ * Generic handler for setting a cur_netdef ID/iface name field from a scalar node
+ * @data: offset into NetplanNetDefinition where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_netdef_id(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (!assert_valid_id(node, error))
+ return FALSE;
+ return handle_netdef_str(doc, node, data, error);
+}
+
+/**
+ * Generic handler for setting a cur_netdef ID/iface name field referring to an
+ * existing ID from a scalar node. This handler also includes a special case
+ * handler for OVS VLANs, switching the backend implicitly to OVS for such
+ * interfaces
+ * @data: offset into NetplanNetDefinition where the NetplanNetDefinition* field to write is
+ * located
+ */
+static gboolean
+handle_netdef_id_ref(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ NetplanNetDefinition* ref = NULL;
+
+ ref = g_hash_table_lookup(netdefs, scalar(node));
+ if (!ref) {
+ add_missing_node(node);
+ } else {
+ *((NetplanNetDefinition**) ((void*) cur_netdef + offset)) = ref;
+
+ if (cur_netdef->type == NETPLAN_DEF_TYPE_VLAN && ref->backend == NETPLAN_BACKEND_OVS) {
+ g_debug("%s: VLAN defined for openvswitch interface, choosing OVS backend", cur_netdef->id);
+ cur_netdef->backend = NETPLAN_BACKEND_OVS;
+ }
+ }
+ return TRUE;
+}
+
+
+/**
+ * Generic handler for setting a cur_netdef MAC address field from a scalar node
+ * @data: offset into NetplanNetDefinition where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_netdef_mac(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_mac(doc, node, cur_netdef, data, error);
+}
+
+/**
+ * Generic handler for setting a cur_netdef gboolean field from a scalar node
+ * @data: offset into NetplanNetDefinition where the gboolean field to write is located
+ */
+static gboolean
+handle_netdef_bool(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_bool(doc, node, cur_netdef, data, error);
+}
+
+/**
+ * Generic handler for setting a cur_netdef guint field from a scalar node
+ * @data: offset into NetplanNetDefinition where the guint field to write is located
+ */
+static gboolean
+handle_netdef_guint(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_guint(doc, node, cur_netdef, data, error);
+}
+
+static gboolean
+handle_netdef_ip4(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ char** dest = (char**) ((void*) cur_netdef + offset);
+ g_autofree char* addr = NULL;
+ char* prefix_len;
+
+ /* these addresses can't have /prefix_len */
+ addr = g_strdup(scalar(node));
+ prefix_len = strrchr(addr, '/');
+
+ /* FIXME: stop excluding this from coverage; refactor address handling instead */
+ // LCOV_EXCL_START
+ if (prefix_len)
+ return yaml_error(node, error,
+ "invalid address: a single IPv4 address (without /prefixlength) is required");
+
+ /* is it an IPv4 address? */
+ if (!is_ip4_address(addr))
+ return yaml_error(node, error,
+ "invalid IPv4 address: %s", scalar(node));
+ // LCOV_EXCL_STOP
+
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+
+ return TRUE;
+}
+
+static gboolean
+handle_netdef_ip6(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ char** dest = (char**) ((void*) cur_netdef + offset);
+ g_autofree char* addr = NULL;
+ char* prefix_len;
+
+ /* these addresses can't have /prefix_len */
+ addr = g_strdup(scalar(node));
+ prefix_len = strrchr(addr, '/');
+
+ /* FIXME: stop excluding this from coverage; refactor address handling instead */
+ // LCOV_EXCL_START
+ if (prefix_len)
+ return yaml_error(node, error,
+ "invalid address: a single IPv6 address (without /prefixlength) is required");
+
+ /* is it an IPv6 address? */
+ if (!is_ip6_address(addr))
+ return yaml_error(node, error,
+ "invalid IPv6 address: %s", scalar(node));
+ // LCOV_EXCL_STOP
+
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+
+ return TRUE;
+}
+
+static gboolean
+handle_netdef_addrgen(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_netdef);
+ if (strcmp(scalar(node), "eui64") == 0)
+ cur_netdef->ip6_addr_gen_mode = NETPLAN_ADDRGEN_EUI64;
+ else if (strcmp(scalar(node), "stable-privacy") == 0)
+ cur_netdef->ip6_addr_gen_mode = NETPLAN_ADDRGEN_STABLEPRIVACY;
+ else
+ return yaml_error(node, error, "unknown ipv6-address-generation '%s'", scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_netdef_addrtok(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_netdef);
+ gboolean ret = handle_netdef_str(doc, node, data, error);
+ if (!is_ip6_address(cur_netdef->ip6_addr_gen_token))
+ return yaml_error(node, error, "invalid ipv6-address-token '%s'", scalar(node));
+ return ret;
+}
+
+static gboolean
+handle_netdef_map(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_netdef);
+ return handle_generic_map(doc, node, cur_netdef, data, error);
+}
+
+static gboolean
+handle_netdef_datalist(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_netdef);
+ return handle_generic_datalist(doc, node, cur_netdef, data, error);
+}
+
+/****************************************************
+ * Grammar and handlers for network config "match" entry
+ ****************************************************/
+
+static const mapping_entry_handler match_handlers[] = {
+ {"driver", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(match.driver)},
+ {"macaddress", YAML_SCALAR_NODE, handle_netdef_mac, NULL, netdef_offset(match.mac)},
+ {"name", YAML_SCALAR_NODE, handle_netdef_id, NULL, netdef_offset(match.original_name)},
+ {NULL}
+};
+
+/****************************************************
+ * Grammar and handlers for network config "auth" entry
+ ****************************************************/
+
+static gboolean
+handle_auth_str(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_auth);
+ guint offset = GPOINTER_TO_UINT(data);
+ char** dest = (char**) ((void*) cur_auth + offset);
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_auth_key_management(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_auth);
+ if (strcmp(scalar(node), "none") == 0)
+ cur_auth->key_management = NETPLAN_AUTH_KEY_MANAGEMENT_NONE;
+ else if (strcmp(scalar(node), "psk") == 0)
+ cur_auth->key_management = NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK;
+ else if (strcmp(scalar(node), "eap") == 0)
+ cur_auth->key_management = NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP;
+ else if (strcmp(scalar(node), "802.1x") == 0)
+ cur_auth->key_management = NETPLAN_AUTH_KEY_MANAGEMENT_8021X;
+ else
+ return yaml_error(node, error, "unknown key management type '%s'", scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_auth_method(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_auth);
+ if (strcmp(scalar(node), "tls") == 0)
+ cur_auth->eap_method = NETPLAN_AUTH_EAP_TLS;
+ else if (strcmp(scalar(node), "peap") == 0)
+ cur_auth->eap_method = NETPLAN_AUTH_EAP_PEAP;
+ else if (strcmp(scalar(node), "ttls") == 0)
+ cur_auth->eap_method = NETPLAN_AUTH_EAP_TTLS;
+ else
+ return yaml_error(node, error, "unknown EAP method '%s'", scalar(node));
+ return TRUE;
+}
+
+static const mapping_entry_handler auth_handlers[] = {
+ {"key-management", YAML_SCALAR_NODE, handle_auth_key_management},
+ {"method", YAML_SCALAR_NODE, handle_auth_method},
+ {"identity", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(identity)},
+ {"anonymous-identity", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(anonymous_identity)},
+ {"password", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(password)},
+ {"ca-certificate", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(ca_certificate)},
+ {"client-certificate", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(client_certificate)},
+ {"client-key", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(client_key)},
+ {"client-key-password", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(client_key_password)},
+ {"phase2-auth", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(phase2_auth)},
+ {NULL}
+};
+
+/****************************************************
+ * Grammar and handlers for network device definition
+ ****************************************************/
+
+static NetplanBackend
+get_default_backend_for_type(NetplanDefType type)
+{
+ if (backend_global != NETPLAN_BACKEND_NONE)
+ return backend_global;
+
+ /* networkd can handle all device types at the moment, so nothing
+ * type-specific */
+ return NETPLAN_BACKEND_NETWORKD;
+}
+
+static gboolean
+handle_access_point_str(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_str(doc, node, cur_access_point, data, error);
+}
+
+static gboolean
+handle_access_point_datalist(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_access_point);
+ return handle_generic_datalist(doc, node, cur_access_point, data, error);
+}
+
+static gboolean
+handle_access_point_guint(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_guint(doc, node, cur_access_point, data, error);
+}
+
+static gboolean
+handle_access_point_mac(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_mac(doc, node, cur_access_point, data, error);
+}
+
+static gboolean
+handle_access_point_bool(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_bool(doc, node, cur_access_point, data, error);
+}
+
+static gboolean
+handle_access_point_password(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_access_point);
+ /* shortcut for WPA-PSK */
+ cur_access_point->has_auth = TRUE;
+ cur_access_point->auth.key_management = NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK;
+ g_free(cur_access_point->auth.password);
+ cur_access_point->auth.password = g_strdup(scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_access_point_auth(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ gboolean ret;
+
+ g_assert(cur_access_point);
+ cur_access_point->has_auth = TRUE;
+
+ cur_auth = &cur_access_point->auth;
+ ret = process_mapping(doc, node, auth_handlers, NULL, error);
+ cur_auth = NULL;
+
+ return ret;
+}
+
+static gboolean
+handle_access_point_mode(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_access_point);
+ if (strcmp(scalar(node), "infrastructure") == 0)
+ cur_access_point->mode = NETPLAN_WIFI_MODE_INFRASTRUCTURE;
+ else if (strcmp(scalar(node), "adhoc") == 0)
+ cur_access_point->mode = NETPLAN_WIFI_MODE_ADHOC;
+ else if (strcmp(scalar(node), "ap") == 0)
+ cur_access_point->mode = NETPLAN_WIFI_MODE_AP;
+ else
+ return yaml_error(node, error, "unknown wifi mode '%s'", scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_access_point_band(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_assert(cur_access_point);
+ if (strcmp(scalar(node), "5GHz") == 0 || strcmp(scalar(node), "5G") == 0)
+ cur_access_point->band = NETPLAN_WIFI_BAND_5;
+ else if (strcmp(scalar(node), "2.4GHz") == 0 || strcmp(scalar(node), "2.4G") == 0)
+ cur_access_point->band = NETPLAN_WIFI_BAND_24;
+ else
+ return yaml_error(node, error, "unknown wifi band '%s'", scalar(node));
+ return TRUE;
+}
+
+/* Keep in sync with ap_nm_backend_settings_handlers */
+static const mapping_entry_handler nm_backend_settings_handlers[] = {
+ {"name", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(backend_settings.nm.name)},
+ {"uuid", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(backend_settings.nm.uuid)},
+ {"stable-id", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(backend_settings.nm.stable_id)},
+ {"device", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(backend_settings.nm.device)},
+ /* Fallback mode, to support all NM settings of the NetworkManager netplan backend */
+ {"passthrough", YAML_MAPPING_NODE, handle_netdef_datalist, NULL, netdef_offset(backend_settings.nm.passthrough)},
+ {NULL}
+};
+
+/* Keep in sync with nm_backend_settings_handlers */
+static const mapping_entry_handler ap_nm_backend_settings_handlers[] = {
+ {"name", YAML_SCALAR_NODE, handle_access_point_str, NULL, access_point_offset(backend_settings.nm.name)},
+ {"uuid", YAML_SCALAR_NODE, handle_access_point_str, NULL, access_point_offset(backend_settings.nm.uuid)},
+ {"stable-id", YAML_SCALAR_NODE, handle_access_point_str, NULL, access_point_offset(backend_settings.nm.stable_id)},
+ {"device", YAML_SCALAR_NODE, handle_access_point_str, NULL, access_point_offset(backend_settings.nm.device)},
+ /* Fallback mode, to support all NM settings of the NetworkManager netplan backend */
+ {"passthrough", YAML_MAPPING_NODE, handle_access_point_datalist, NULL, access_point_offset(backend_settings.nm.passthrough)},
+ {NULL}
+};
+
+
+static const mapping_entry_handler wifi_access_point_handlers[] = {
+ {"band", YAML_SCALAR_NODE, handle_access_point_band},
+ {"bssid", YAML_SCALAR_NODE, handle_access_point_mac, NULL, access_point_offset(bssid)},
+ {"hidden", YAML_SCALAR_NODE, handle_access_point_bool, NULL, access_point_offset(hidden)},
+ {"channel", YAML_SCALAR_NODE, handle_access_point_guint, NULL, access_point_offset(channel)},
+ {"mode", YAML_SCALAR_NODE, handle_access_point_mode},
+ {"password", YAML_SCALAR_NODE, handle_access_point_password},
+ {"auth", YAML_MAPPING_NODE, handle_access_point_auth},
+ {"networkmanager", YAML_MAPPING_NODE, NULL, ap_nm_backend_settings_handlers},
+ {NULL}
+};
+
+/**
+ * Parse scalar node's string into a netdef_backend.
+ */
+static gboolean
+parse_renderer(yaml_node_t* node, NetplanBackend* backend, GError** error)
+{
+ if (strcmp(scalar(node), "networkd") == 0)
+ *backend = NETPLAN_BACKEND_NETWORKD;
+ else if (strcmp(scalar(node), "NetworkManager") == 0)
+ *backend = NETPLAN_BACKEND_NM;
+ else
+ return yaml_error(node, error, "unknown renderer '%s'", scalar(node));
+ return TRUE;
+}
+
+static gboolean
+handle_netdef_renderer(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (cur_netdef->type == NETPLAN_DEF_TYPE_VLAN) {
+ if (strcmp(scalar(node), "sriov") == 0) {
+ cur_netdef->sriov_vlan_filter = TRUE;
+ return TRUE;
+ }
+ }
+
+ return parse_renderer(node, &cur_netdef->backend, error);
+}
+
+static gboolean
+handle_accept_ra(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ gboolean ret = handle_generic_bool(doc, node, cur_netdef, data, error);
+ if (cur_netdef->accept_ra)
+ cur_netdef->accept_ra = NETPLAN_RA_MODE_ENABLED;
+ else
+ cur_netdef->accept_ra = NETPLAN_RA_MODE_DISABLED;
+ return ret;
+}
+
+static gboolean
+handle_activation_mode(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (g_strcmp0(scalar(node), "manual") && g_strcmp0(scalar(node), "off"))
+ return yaml_error(node, error, "Value of 'activation-mode' needs to be 'manual' or 'off'");
+
+ return handle_netdef_str(doc, node, data, error);
+}
+
+static gboolean
+handle_match(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ cur_netdef->has_match = TRUE;
+ return process_mapping(doc, node, match_handlers, NULL, error);
+}
+
+struct NetplanWifiWowlanType NETPLAN_WIFI_WOWLAN_TYPES[] = {
+ {"default", NETPLAN_WIFI_WOWLAN_DEFAULT},
+ {"any", NETPLAN_WIFI_WOWLAN_ANY},
+ {"disconnect", NETPLAN_WIFI_WOWLAN_DISCONNECT},
+ {"magic_pkt", NETPLAN_WIFI_WOWLAN_MAGIC},
+ {"gtk_rekey_failure", NETPLAN_WIFI_WOWLAN_GTK_REKEY_FAILURE},
+ {"eap_identity_req", NETPLAN_WIFI_WOWLAN_EAP_IDENTITY_REQ},
+ {"four_way_handshake", NETPLAN_WIFI_WOWLAN_4WAY_HANDSHAKE},
+ {"rfkill_release", NETPLAN_WIFI_WOWLAN_RFKILL_RELEASE},
+ {"tcp", NETPLAN_WIFI_WOWLAN_TCP},
+ {NULL},
+};
+
+static gboolean
+handle_wowlan(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+ int found = FALSE;
+
+ for (unsigned i = 0; NETPLAN_WIFI_WOWLAN_TYPES[i].name != NULL; ++i) {
+ if (g_ascii_strcasecmp(scalar(entry), NETPLAN_WIFI_WOWLAN_TYPES[i].name) == 0) {
+ cur_netdef->wowlan |= NETPLAN_WIFI_WOWLAN_TYPES[i].flag;
+ found = TRUE;
+ break;
+ }
+ }
+ if (!found)
+ return yaml_error(node, error, "invalid value for wakeonwlan: '%s'", scalar(entry));
+ }
+ if (cur_netdef->wowlan > NETPLAN_WIFI_WOWLAN_DEFAULT && cur_netdef->wowlan & NETPLAN_WIFI_WOWLAN_TYPES[0].flag)
+ return yaml_error(node, error, "'default' is an exclusive flag for wakeonwlan");
+ return TRUE;
+}
+
+static gboolean
+handle_auth(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ gboolean ret;
+
+ cur_netdef->has_auth = TRUE;
+
+ cur_auth = &cur_netdef->auth;
+ ret = process_mapping(doc, node, auth_handlers, NULL, error);
+ cur_auth = NULL;
+
+ return ret;
+}
+
+static gboolean
+handle_address_option_lifetime(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (g_ascii_strcasecmp(scalar(node), "0") != 0 &&
+ g_ascii_strcasecmp(scalar(node), "forever") != 0) {
+ return yaml_error(node, error, "invalid lifetime value '%s'", scalar(node));
+ }
+ return handle_generic_str(doc, node, cur_addr_option, data, error);
+}
+
+static gboolean
+handle_address_option_label(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_str(doc, node, cur_addr_option, data, error);
+}
+
+const mapping_entry_handler address_option_handlers[] = {
+ {"lifetime", YAML_SCALAR_NODE, handle_address_option_lifetime, NULL, addr_option_offset(lifetime)},
+ {"label", YAML_SCALAR_NODE, handle_address_option_label, NULL, addr_option_offset(label)},
+ {NULL}
+};
+
+/*
+ * Handler for setting an array of IP addresses from a sequence node, inside a given struct
+ * @entryptr: pointer to the beginning of the do-be-modified data structure
+ * @data: offset into entryptr struct where the array to write is located
+ */
+static gboolean
+handle_generic_addresses(yaml_document_t* doc, yaml_node_t* node, gboolean check_zero_prefix, GArray** ip4, GArray** ip6, GError** error)
+{
+ g_assert(ip4);
+ g_assert(ip6);
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ g_autofree char* addr = NULL;
+ char* prefix_len;
+ guint64 prefix_len_num;
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ yaml_node_t *key = NULL;
+ yaml_node_t *value = NULL;
+
+ if (entry->type != YAML_SCALAR_NODE && entry->type != YAML_MAPPING_NODE) {
+ return yaml_error(entry, error, "expected either scalar or mapping (check indentation)");
+ }
+
+ if (entry->type == YAML_MAPPING_NODE) {
+ key = yaml_document_get_node(doc, entry->data.mapping.pairs.start->key);
+ value = yaml_document_get_node(doc, entry->data.mapping.pairs.start->value);
+ entry = key;
+ }
+ assert_type(entry, YAML_SCALAR_NODE);
+
+ /* split off /prefix_len */
+ addr = g_strdup(scalar(entry));
+ prefix_len = strrchr(addr, '/');
+ if (!prefix_len)
+ return yaml_error(node, error, "address '%s' is missing /prefixlength", scalar(entry));
+ *prefix_len = '\0';
+ prefix_len++; /* skip former '/' into first char of prefix */
+ prefix_len_num = g_ascii_strtoull(prefix_len, NULL, 10);
+
+ if (value) {
+ if (!is_ip4_address(addr) && !is_ip6_address(addr))
+ return yaml_error(node, error, "malformed address '%s', must be X.X.X.X/NN or X:X:X:X:X:X:X:X/NN", scalar(entry));
+
+ if (!cur_netdef->address_options)
+ cur_netdef->address_options = g_array_new(FALSE, FALSE, sizeof(NetplanAddressOptions*));
+
+ for (unsigned i = 0; i < cur_netdef->address_options->len; ++i) {
+ NetplanAddressOptions* opts = g_array_index(cur_netdef->address_options, NetplanAddressOptions*, i);
+ /* check for multi-pass parsing, return early if options for this address already exist */
+ if (!g_strcmp0(scalar(key), opts->address))
+ return TRUE;
+ }
+
+ cur_addr_option = g_new0(NetplanAddressOptions, 1);
+ cur_addr_option->address = g_strdup(scalar(key));
+
+ if (!process_mapping(doc, value, address_option_handlers, NULL, error))
+ return FALSE;
+
+ g_array_append_val(cur_netdef->address_options, cur_addr_option);
+ continue;
+ }
+
+ /* is it an IPv4 address? */
+ if (is_ip4_address(addr)) {
+ if ((check_zero_prefix && prefix_len_num == 0) || prefix_len_num > 32)
+ return yaml_error(node, error, "invalid prefix length in address '%s'", scalar(entry));
+
+ if (!*ip4)
+ *ip4 = g_array_new(FALSE, FALSE, sizeof(char*));
+
+ /* Do not append the same IP (on multiple passes), if it is already contained */
+ for (unsigned i = 0; i < (*ip4)->len; ++i)
+ if (!g_strcmp0(scalar(entry), g_array_index(*ip4, char*, i)))
+ goto skip_ip4;
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(*ip4, s);
+skip_ip4:
+ continue;
+ }
+
+ /* is it an IPv6 address? */
+ if (is_ip6_address(addr)) {
+ if ((check_zero_prefix && prefix_len_num == 0) || prefix_len_num > 128)
+ return yaml_error(node, error, "invalid prefix length in address '%s'", scalar(entry));
+ if (!*ip6)
+ *ip6 = g_array_new(FALSE, FALSE, sizeof(char*));
+
+ /* Do not append the same IP (on multiple passes), if it is already contained */
+ for (unsigned i = 0; i < (*ip6)->len; ++i)
+ if (!g_strcmp0(scalar(entry), g_array_index(*ip6, char*, i)))
+ goto skip_ip6;
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(*ip6, s);
+skip_ip6:
+ continue;
+ }
+
+ return yaml_error(node, error, "malformed address '%s', must be X.X.X.X/NN or X:X:X:X:X:X:X:X/NN", scalar(entry));
+ }
+
+ return TRUE;
+}
+
+static gboolean
+handle_addresses(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ return handle_generic_addresses(doc, node, TRUE, &(cur_netdef->ip4_addresses), &(cur_netdef->ip6_addresses), error);
+}
+
+static gboolean
+handle_gateway4(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (!is_ip4_address(scalar(node)))
+ return yaml_error(node, error, "invalid IPv4 address '%s'", scalar(node));
+ set_str_if_null(cur_netdef->gateway4, scalar(node));
+ g_warning("`gateway4` has been deprecated, use default routes instead.\n"
+ "See the 'Default routes' section of the documentation for more details.");
+ return TRUE;
+}
+
+static gboolean
+handle_gateway6(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (!is_ip6_address(scalar(node)))
+ return yaml_error(node, error, "invalid IPv6 address '%s'", scalar(node));
+ set_str_if_null(cur_netdef->gateway6, scalar(node));
+ g_warning("`gateway6` has been deprecated, use default routes instead.\n"
+ "See the 'Default routes' section of the documentation for more details.");
+ return TRUE;
+}
+
+static gboolean
+handle_wifi_access_points(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+
+ key = yaml_document_get_node(doc, entry->key);
+ assert_type(key, YAML_SCALAR_NODE);
+ value = yaml_document_get_node(doc, entry->value);
+ assert_type(value, YAML_MAPPING_NODE);
+
+ g_assert(cur_access_point == NULL);
+ cur_access_point = g_new0(NetplanWifiAccessPoint, 1);
+ cur_access_point->ssid = g_strdup(scalar(key));
+ g_debug("%s: adding wifi AP '%s'", cur_netdef->id, cur_access_point->ssid);
+
+ if (!cur_netdef->access_points)
+ cur_netdef->access_points = g_hash_table_new(g_str_hash, g_str_equal);
+ if (!g_hash_table_insert(cur_netdef->access_points, cur_access_point->ssid, cur_access_point)) {
+ /* Even in the error case, NULL out cur_access_point. Otherwise we
+ * have an assert failure if we do a multi-pass parse. */
+ gboolean ret;
+
+ ret = yaml_error(key, error, "%s: Duplicate access point SSID '%s'", cur_netdef->id, cur_access_point->ssid);
+ cur_access_point = NULL;
+ return ret;
+ }
+
+ if (!process_mapping(doc, value, wifi_access_point_handlers, NULL, error)) {
+ cur_access_point = NULL;
+ return FALSE;
+ }
+
+ cur_access_point = NULL;
+ }
+ return TRUE;
+}
+
+/**
+ * Handler for bridge "interfaces:" list. We don't store that list in cur_netdef,
+ * but set cur_netdef's ID in all listed interfaces' "bond" or "bridge" field.
+ * @data: ignored
+ */
+static gboolean
+handle_bridge_interfaces(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ /* all entries must refer to already defined IDs */
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ NetplanNetDefinition *component;
+
+ assert_type(entry, YAML_SCALAR_NODE);
+ component = g_hash_table_lookup(netdefs, scalar(entry));
+ if (!component) {
+ add_missing_node(entry);
+ } else {
+ if (component->bridge && g_strcmp0(component->bridge, cur_netdef->id) != 0)
+ return yaml_error(node, error, "%s: interface '%s' is already assigned to bridge %s",
+ cur_netdef->id, scalar(entry), component->bridge);
+ if (component->bond)
+ return yaml_error(node, error, "%s: interface '%s' is already assigned to bond %s",
+ cur_netdef->id, scalar(entry), component->bond);
+ set_str_if_null(component->bridge, cur_netdef->id);
+ if (component->backend == NETPLAN_BACKEND_OVS) {
+ g_debug("%s: Bridge contains openvswitch interface, choosing OVS backend", cur_netdef->id);
+ cur_netdef->backend = NETPLAN_BACKEND_OVS;
+ }
+ }
+ }
+
+ return TRUE;
+}
+
+/**
+ * Handler for bond "mode" types.
+ * @data: offset into NetplanNetDefinition where the const char* field to write is
+ * located
+ */
+static gboolean
+handle_bond_mode(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (!(strcmp(scalar(node), "balance-rr") == 0 ||
+ strcmp(scalar(node), "active-backup") == 0 ||
+ strcmp(scalar(node), "balance-xor") == 0 ||
+ strcmp(scalar(node), "broadcast") == 0 ||
+ strcmp(scalar(node), "802.3ad") == 0 ||
+ strcmp(scalar(node), "balance-tlb") == 0 ||
+ strcmp(scalar(node), "balance-alb") == 0 ||
+ strcmp(scalar(node), "balance-tcp") == 0 || // only supported for OVS
+ strcmp(scalar(node), "balance-slb") == 0)) // only supported for OVS
+ return yaml_error(node, error, "unknown bond mode '%s'", scalar(node));
+
+ /* Implicitly set NETPLAN_BACKEND_OVS if ovs-only mode selected */
+ if (!strcmp(scalar(node), "balance-tcp") ||
+ !strcmp(scalar(node), "balance-slb")) {
+ g_debug("%s: mode '%s' only supported with openvswitch, choosing this backend",
+ cur_netdef->id, scalar(node));
+ cur_netdef->backend = NETPLAN_BACKEND_OVS;
+ }
+
+ return handle_netdef_str(doc, node, data, error);
+}
+
+/**
+ * Handler for bond "interfaces:" list.
+ * @data: ignored
+ */
+static gboolean
+handle_bond_interfaces(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ /* all entries must refer to already defined IDs */
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ NetplanNetDefinition *component;
+
+ assert_type(entry, YAML_SCALAR_NODE);
+ component = g_hash_table_lookup(netdefs, scalar(entry));
+ if (!component) {
+ add_missing_node(entry);
+ } else {
+ if (component->bridge)
+ return yaml_error(node, error, "%s: interface '%s' is already assigned to bridge %s",
+ cur_netdef->id, scalar(entry), component->bridge);
+ if (component->bond && g_strcmp0(component->bond, cur_netdef->id) != 0)
+ return yaml_error(node, error, "%s: interface '%s' is already assigned to bond %s",
+ cur_netdef->id, scalar(entry), component->bond);
+ component->bond = g_strdup(cur_netdef->id);
+ if (component->backend == NETPLAN_BACKEND_OVS) {
+ g_debug("%s: Bond contains openvswitch interface, choosing OVS backend", cur_netdef->id);
+ cur_netdef->backend = NETPLAN_BACKEND_OVS;
+ }
+ }
+ }
+
+ return TRUE;
+}
+
+
+static gboolean
+handle_nameservers_search(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+ if (!cur_netdef->search_domains)
+ cur_netdef->search_domains = g_array_new(FALSE, FALSE, sizeof(char*));
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->search_domains, s);
+ }
+ return TRUE;
+}
+
+static gboolean
+handle_nameservers_addresses(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+
+ /* is it an IPv4 address? */
+ if (is_ip4_address(scalar(entry))) {
+ if (!cur_netdef->ip4_nameservers)
+ cur_netdef->ip4_nameservers = g_array_new(FALSE, FALSE, sizeof(char*));
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->ip4_nameservers, s);
+ continue;
+ }
+
+ /* is it an IPv6 address? */
+ if (is_ip6_address(scalar(entry))) {
+ if (!cur_netdef->ip6_nameservers)
+ cur_netdef->ip6_nameservers = g_array_new(FALSE, FALSE, sizeof(char*));
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->ip6_nameservers, s);
+ continue;
+ }
+
+ return yaml_error(node, error, "malformed address '%s', must be X.X.X.X or X:X:X:X:X:X:X:X", scalar(entry));
+ }
+
+ return TRUE;
+}
+
+static gboolean
+handle_link_local(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ gboolean ipv4 = FALSE;
+ gboolean ipv6 = FALSE;
+
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+
+ assert_type(entry, YAML_SCALAR_NODE);
+
+ if (g_ascii_strcasecmp(scalar(entry), "ipv4") == 0)
+ ipv4 = TRUE;
+ else if (g_ascii_strcasecmp(scalar(entry), "ipv6") == 0)
+ ipv6 = TRUE;
+ else
+ return yaml_error(node, error, "invalid value for link-local: '%s'", scalar(entry));
+ }
+
+ cur_netdef->linklocal.ipv4 = ipv4;
+ cur_netdef->linklocal.ipv6 = ipv6;
+
+ return TRUE;
+}
+
+struct NetplanOptionalAddressType NETPLAN_OPTIONAL_ADDRESS_TYPES[] = {
+ {"ipv4-ll", NETPLAN_OPTIONAL_IPV4_LL},
+ {"ipv6-ra", NETPLAN_OPTIONAL_IPV6_RA},
+ {"dhcp4", NETPLAN_OPTIONAL_DHCP4},
+ {"dhcp6", NETPLAN_OPTIONAL_DHCP6},
+ {"static", NETPLAN_OPTIONAL_STATIC},
+ {NULL},
+};
+
+static gboolean
+handle_optional_addresses(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+ int found = FALSE;
+
+ for (unsigned i = 0; NETPLAN_OPTIONAL_ADDRESS_TYPES[i].name != NULL; ++i) {
+ if (g_ascii_strcasecmp(scalar(entry), NETPLAN_OPTIONAL_ADDRESS_TYPES[i].name) == 0) {
+ cur_netdef->optional_addresses |= NETPLAN_OPTIONAL_ADDRESS_TYPES[i].flag;
+ found = TRUE;
+ break;
+ }
+ }
+ if (!found) {
+ return yaml_error(node, error, "invalid value for optional-addresses: '%s'", scalar(entry));
+ }
+ }
+ return TRUE;
+}
+
+static int
+get_ip_family(const char* address)
+{
+ g_autofree char *ip_str;
+ char *prefix_len;
+
+ ip_str = g_strdup(address);
+ prefix_len = strrchr(ip_str, '/');
+ if (prefix_len)
+ *prefix_len = '\0';
+
+ if (is_ip4_address(ip_str))
+ return AF_INET;
+
+ if (is_ip6_address(ip_str))
+ return AF_INET6;
+
+ return -1;
+}
+
+static gboolean
+check_and_set_family(int family, guint* dest)
+{
+ if (*dest != -1 && *dest != family)
+ return FALSE;
+
+ *dest = family;
+
+ return TRUE;
+}
+
+/* TODO: (cyphermox) Refactor the functions below. There's a lot of room for reuse. */
+
+static gboolean
+handle_routes_bool(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_route);
+ return handle_generic_bool(doc, node, cur_route, data, error);
+}
+
+static gboolean
+handle_routes_scope(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_route->scope)
+ g_free(cur_route->scope);
+ cur_route->scope = g_strdup(scalar(node));
+
+ if (g_ascii_strcasecmp(cur_route->scope, "global") == 0 ||
+ g_ascii_strcasecmp(cur_route->scope, "link") == 0 ||
+ g_ascii_strcasecmp(cur_route->scope, "host") == 0)
+ return TRUE;
+
+ return yaml_error(node, error, "invalid route scope '%s'", cur_route->scope);
+}
+
+static gboolean
+handle_routes_type(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_route->type)
+ g_free(cur_route->type);
+ cur_route->type = g_strdup(scalar(node));
+
+ if (g_ascii_strcasecmp(cur_route->type, "unicast") == 0 ||
+ g_ascii_strcasecmp(cur_route->type, "unreachable") == 0 ||
+ g_ascii_strcasecmp(cur_route->type, "blackhole") == 0 ||
+ g_ascii_strcasecmp(cur_route->type, "prohibit") == 0)
+ return TRUE;
+
+ return yaml_error(node, error, "invalid route type '%s'", cur_route->type);
+}
+
+static gboolean
+handle_routes_ip(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ int family = get_ip_family(scalar(node));
+ char** dest = (char**) ((void*) cur_route + offset);
+
+ if (family < 0)
+ return yaml_error(node, error, "invalid IP family '%d'", family);
+
+ if (!check_and_set_family(family, &cur_route->family))
+ return yaml_error(node, error, "IP family mismatch in route to %s", scalar(node));
+
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+
+ return TRUE;
+}
+
+static gboolean
+handle_routes_destination(yaml_document_t *doc, yaml_node_t *node, const void *data, GError **error)
+{
+ const char *addr = scalar(node);
+ if (g_strcmp0(addr, "default") != 0)
+ return handle_routes_ip(doc, node, route_offset(to), error);
+ set_str_if_null(cur_route->to, addr);
+ return TRUE;
+}
+
+static gboolean
+handle_ip_rule_ip(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ guint offset = GPOINTER_TO_UINT(data);
+ int family = get_ip_family(scalar(node));
+ char** dest = (char**) ((void*) cur_ip_rule + offset);
+
+ if (family < 0)
+ return yaml_error(node, error, "invalid IP family '%d'", family);
+
+ if (!check_and_set_family(family, &cur_ip_rule->family))
+ return yaml_error(node, error, "IP family mismatch in route to %s", scalar(node));
+
+ g_free(*dest);
+ *dest = g_strdup(scalar(node));
+
+ return TRUE;
+}
+
+static gboolean
+handle_ip_rule_guint(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_ip_rule);
+ return handle_generic_guint(doc, node, cur_ip_rule, data, error);
+}
+
+static gboolean
+handle_routes_guint(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_route);
+ return handle_generic_guint(doc, node, cur_route, data, error);
+}
+
+static gboolean
+handle_ip_rule_tos(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ gboolean ret = handle_generic_guint(doc, node, cur_ip_rule, data, error);
+ if (cur_ip_rule->tos > 255)
+ return yaml_error(node, error, "invalid ToS (must be between 0 and 255): %s", scalar(node));
+ return ret;
+}
+
+/****************************************************
+ * Grammar and handlers for network config "bridge_params" entry
+ ****************************************************/
+
+static gboolean
+handle_bridge_path_cost(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+ guint v;
+ gchar* endptr;
+ NetplanNetDefinition *component;
+ guint* ref_ptr;
+
+ key = yaml_document_get_node(doc, entry->key);
+ assert_type(key, YAML_SCALAR_NODE);
+ value = yaml_document_get_node(doc, entry->value);
+ assert_type(value, YAML_SCALAR_NODE);
+
+ component = g_hash_table_lookup(netdefs, scalar(key));
+ if (!component) {
+ add_missing_node(key);
+ } else {
+ ref_ptr = ((guint*) ((void*) component + GPOINTER_TO_UINT(data)));
+ if (*ref_ptr)
+ return yaml_error(node, error, "%s: interface '%s' already has a path cost of %u",
+ cur_netdef->id, scalar(key), *ref_ptr);
+
+ v = g_ascii_strtoull(scalar(value), &endptr, 10);
+ if (*endptr != '\0' || v > G_MAXUINT)
+ return yaml_error(node, error, "invalid unsigned int value '%s'", scalar(value));
+
+ g_debug("%s: adding path '%s' of cost: %d", cur_netdef->id, scalar(key), v);
+
+ *ref_ptr = v;
+ }
+ }
+ return TRUE;
+}
+
+static gboolean
+handle_bridge_port_priority(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+ guint v;
+ gchar* endptr;
+ NetplanNetDefinition *component;
+ guint* ref_ptr;
+
+ key = yaml_document_get_node(doc, entry->key);
+ assert_type(key, YAML_SCALAR_NODE);
+ value = yaml_document_get_node(doc, entry->value);
+ assert_type(value, YAML_SCALAR_NODE);
+
+ component = g_hash_table_lookup(netdefs, scalar(key));
+ if (!component) {
+ add_missing_node(key);
+ } else {
+ ref_ptr = ((guint*) ((void*) component + GPOINTER_TO_UINT(data)));
+ if (*ref_ptr)
+ return yaml_error(node, error, "%s: interface '%s' already has a port priority of %u",
+ cur_netdef->id, scalar(key), *ref_ptr);
+
+ v = g_ascii_strtoull(scalar(value), &endptr, 10);
+ if (*endptr != '\0' || v > 63)
+ return yaml_error(node, error, "invalid port priority value (must be between 0 and 63): %s",
+ scalar(value));
+
+ g_debug("%s: adding port '%s' of priority: %d", cur_netdef->id, scalar(key), v);
+
+ *ref_ptr = v;
+ }
+ }
+ return TRUE;
+}
+
+static const mapping_entry_handler bridge_params_handlers[] = {
+ {"ageing-time", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bridge_params.ageing_time)},
+ {"forward-delay", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bridge_params.forward_delay)},
+ {"hello-time", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bridge_params.hello_time)},
+ {"max-age", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bridge_params.max_age)},
+ {"path-cost", YAML_MAPPING_NODE, handle_bridge_path_cost, NULL, netdef_offset(bridge_params.path_cost)},
+ {"port-priority", YAML_MAPPING_NODE, handle_bridge_port_priority, NULL, netdef_offset(bridge_params.port_priority)},
+ {"priority", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bridge_params.priority)},
+ {"stp", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(bridge_params.stp)},
+ {NULL}
+};
+
+static gboolean
+handle_bridge(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ cur_netdef->custom_bridging = TRUE;
+ cur_netdef->bridge_params.stp = TRUE;
+ return process_mapping(doc, node, bridge_params_handlers, NULL, error);
+}
+
+/****************************************************
+ * Grammar and handlers for network config "routes" entry
+ ****************************************************/
+
+static const mapping_entry_handler routes_handlers[] = {
+ {"from", YAML_SCALAR_NODE, handle_routes_ip, NULL, route_offset(from)},
+ {"on-link", YAML_SCALAR_NODE, handle_routes_bool, NULL, route_offset(onlink)},
+ {"scope", YAML_SCALAR_NODE, handle_routes_scope},
+ {"table", YAML_SCALAR_NODE, handle_routes_guint, NULL, route_offset(table)},
+ {"to", YAML_SCALAR_NODE, handle_routes_destination},
+ {"type", YAML_SCALAR_NODE, handle_routes_type},
+ {"via", YAML_SCALAR_NODE, handle_routes_ip, NULL, route_offset(via)},
+ {"metric", YAML_SCALAR_NODE, handle_routes_guint, NULL, route_offset(metric)},
+ {"mtu", YAML_SCALAR_NODE, handle_routes_guint, NULL, route_offset(mtubytes)},
+ {"congestion-window", YAML_SCALAR_NODE, handle_routes_guint, NULL, route_offset(congestion_window)},
+ {"advertised-receive-window", YAML_SCALAR_NODE, handle_routes_guint, NULL, route_offset(advertised_receive_window)},
+ {NULL}
+};
+
+static gboolean
+handle_routes(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (!cur_netdef->routes)
+ cur_netdef->routes = g_array_new(FALSE, TRUE, sizeof(NetplanIPRoute*));
+
+ /* Avoid adding the same routes in a 2nd parsing pass by comparing
+ * the array size to the YAML sequence size. Skip if they are equal. */
+ guint item_count = node->data.sequence.items.top - node->data.sequence.items.start;
+ if (cur_netdef->routes->len == item_count) {
+ g_debug("%s: all routes have already been added", cur_netdef->id);
+ return TRUE;
+ }
+
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_MAPPING_NODE);
+
+ g_assert(cur_route == NULL);
+ cur_route = g_new0(NetplanIPRoute, 1);
+ cur_route->type = g_strdup("unicast");
+ cur_route->scope = g_strdup("global");
+ cur_route->family = G_MAXUINT; /* 0 is a valid family ID */
+ cur_route->metric = NETPLAN_METRIC_UNSPEC; /* 0 is a valid metric */
+ cur_route->table = NETPLAN_ROUTE_TABLE_UNSPEC;
+ g_debug("%s: adding new route", cur_netdef->id);
+
+ if (!process_mapping(doc, entry, routes_handlers, NULL, error))
+ goto err;
+
+ if ( ( g_ascii_strcasecmp(cur_route->scope, "link") == 0
+ || g_ascii_strcasecmp(cur_route->scope, "host") == 0)
+ && !cur_route->to) {
+ yaml_error(node, error, "link and host routes must specify a 'to' IP");
+ goto err;
+ } else if ( g_ascii_strcasecmp(cur_route->type, "unicast") == 0
+ && g_ascii_strcasecmp(cur_route->scope, "global") == 0
+ && (!cur_route->to || !cur_route->via)) {
+ yaml_error(node, error, "unicast route must include both a 'to' and 'via' IP");
+ goto err;
+ } else if (g_ascii_strcasecmp(cur_route->type, "unicast") != 0 && !cur_route->to) {
+ yaml_error(node, error, "non-unicast routes must specify a 'to' IP");
+ goto err;
+ }
+
+ g_array_append_val(cur_netdef->routes, cur_route);
+ cur_route = NULL;
+ }
+ return TRUE;
+
+err:
+ if (cur_route) {
+ g_free(cur_route);
+ cur_route = NULL;
+ }
+ return FALSE;
+}
+
+static const mapping_entry_handler ip_rules_handlers[] = {
+ {"from", YAML_SCALAR_NODE, handle_ip_rule_ip, NULL, ip_rule_offset(from)},
+ {"mark", YAML_SCALAR_NODE, handle_ip_rule_guint, NULL, ip_rule_offset(fwmark)},
+ {"priority", YAML_SCALAR_NODE, handle_ip_rule_guint, NULL, ip_rule_offset(priority)},
+ {"table", YAML_SCALAR_NODE, handle_ip_rule_guint, NULL, ip_rule_offset(table)},
+ {"to", YAML_SCALAR_NODE, handle_ip_rule_ip, NULL, ip_rule_offset(to)},
+ {"type-of-service", YAML_SCALAR_NODE, handle_ip_rule_tos, NULL, ip_rule_offset(tos)},
+ {NULL}
+};
+
+static gboolean
+handle_ip_rules(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+
+ cur_ip_rule = g_new0(NetplanIPRule, 1);
+ cur_ip_rule->family = G_MAXUINT; /* 0 is a valid family ID */
+ cur_ip_rule->priority = NETPLAN_IP_RULE_PRIO_UNSPEC;
+ cur_ip_rule->table = NETPLAN_ROUTE_TABLE_UNSPEC;
+ cur_ip_rule->tos = NETPLAN_IP_RULE_TOS_UNSPEC;
+ cur_ip_rule->fwmark = NETPLAN_IP_RULE_FW_MARK_UNSPEC;
+
+ if (process_mapping(doc, entry, ip_rules_handlers, NULL, error)) {
+ if (!cur_netdef->ip_rules) {
+ cur_netdef->ip_rules = g_array_new(FALSE, FALSE, sizeof(NetplanIPRule*));
+ }
+
+ g_array_append_val(cur_netdef->ip_rules, cur_ip_rule);
+ }
+
+ if (!cur_ip_rule->from && !cur_ip_rule->to)
+ return yaml_error(node, error, "IP routing policy must include either a 'from' or 'to' IP");
+
+ cur_ip_rule = NULL;
+
+ if (error && *error)
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/****************************************************
+ * Grammar and handlers for bond parameters
+ ****************************************************/
+
+static gboolean
+handle_arp_ip_targets(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (!cur_netdef->bond_params.arp_ip_targets) {
+ cur_netdef->bond_params.arp_ip_targets = g_array_new(FALSE, FALSE, sizeof(char *));
+ }
+
+ /* Avoid adding the same arp_ip_targets in a 2nd parsing pass by comparing
+ * the array size to the YAML sequence size. Skip if they are equal. */
+ guint item_count = node->data.sequence.items.top - node->data.sequence.items.start;
+ if (cur_netdef->bond_params.arp_ip_targets->len == item_count) {
+ g_debug("%s: all arp ip targets have already been added", cur_netdef->id);
+ return TRUE;
+ }
+
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ g_autofree char* addr = NULL;
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+
+ addr = g_strdup(scalar(entry));
+
+ /* is it an IPv4 address? */
+ if (is_ip4_address(addr)) {
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->bond_params.arp_ip_targets, s);
+ continue;
+ }
+
+ return yaml_error(node, error, "malformed address '%s', must be X.X.X.X or X:X:X:X:X:X:X:X", scalar(entry));
+ }
+
+ return TRUE;
+}
+
+static gboolean
+handle_bond_primary_slave(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ NetplanNetDefinition *component;
+ char** ref_ptr;
+
+ component = g_hash_table_lookup(netdefs, scalar(node));
+ if (!component) {
+ add_missing_node(node);
+ } else {
+ /* If this is not the primary pass, the primary slave might already be equally set. */
+ if (!g_strcmp0(cur_netdef->bond_params.primary_slave, scalar(node))) {
+ return TRUE;
+ } else if (cur_netdef->bond_params.primary_slave)
+ return yaml_error(node, error, "%s: bond already has a primary slave: %s",
+ cur_netdef->id, cur_netdef->bond_params.primary_slave);
+
+ ref_ptr = ((char**) ((void*) component + GPOINTER_TO_UINT(data)));
+ *ref_ptr = g_strdup(scalar(node));
+ cur_netdef->bond_params.primary_slave = g_strdup(scalar(node));
+ }
+
+ return TRUE;
+}
+
+static const mapping_entry_handler bond_params_handlers[] = {
+ {"mode", YAML_SCALAR_NODE, handle_bond_mode, NULL, netdef_offset(bond_params.mode)},
+ {"lacp-rate", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.lacp_rate)},
+ {"mii-monitor-interval", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.monitor_interval)},
+ {"min-links", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bond_params.min_links)},
+ {"transmit-hash-policy", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.transmit_hash_policy)},
+ {"ad-select", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.selection_logic)},
+ {"all-slaves-active", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(bond_params.all_slaves_active)},
+ {"arp-interval", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.arp_interval)},
+ /* TODO: arp_ip_targets */
+ {"arp-ip-targets", YAML_SEQUENCE_NODE, handle_arp_ip_targets},
+ {"arp-validate", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.arp_validate)},
+ {"arp-all-targets", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.arp_all_targets)},
+ {"up-delay", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.up_delay)},
+ {"down-delay", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.down_delay)},
+ {"fail-over-mac-policy", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.fail_over_mac_policy)},
+ {"gratuitous-arp", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bond_params.gratuitous_arp)},
+ /* Handle the old misspelling */
+ {"gratuitious-arp", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bond_params.gratuitous_arp)},
+ /* TODO: unsolicited_na */
+ {"packets-per-slave", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bond_params.packets_per_slave)},
+ {"primary-reselect-policy", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.primary_reselect_policy)},
+ {"resend-igmp", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(bond_params.resend_igmp)},
+ {"learn-packet-interval", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(bond_params.learn_interval)},
+ {"primary", YAML_SCALAR_NODE, handle_bond_primary_slave, NULL, netdef_offset(bond_params.primary_slave)},
+ {NULL}
+};
+
+static gboolean
+handle_bonding(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ return process_mapping(doc, node, bond_params_handlers, NULL, error);
+}
+
+static gboolean
+handle_dhcp_identifier(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->dhcp_identifier)
+ g_free(cur_netdef->dhcp_identifier);
+ cur_netdef->dhcp_identifier = g_strdup(scalar(node));
+
+ if (g_ascii_strcasecmp(cur_netdef->dhcp_identifier, "duid") == 0 ||
+ g_ascii_strcasecmp(cur_netdef->dhcp_identifier, "mac") == 0)
+ return TRUE;
+
+ return yaml_error(node, error, "invalid DHCP client identifier type '%s'", cur_netdef->dhcp_identifier);
+}
+
+/****************************************************
+ * Grammar and handlers for tunnels
+ ****************************************************/
+
+const char*
+tunnel_mode_to_string(NetplanTunnelMode mode)
+{
+ return netplan_tunnel_mode_table[mode];
+}
+
+static gboolean
+handle_tunnel_addr(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_autofree char* addr = NULL;
+ char* prefix_len;
+
+ /* split off /prefix_len */
+ addr = g_strdup(scalar(node));
+ prefix_len = strrchr(addr, '/');
+ if (prefix_len)
+ return yaml_error(node, error, "address '%s' should not include /prefixlength", scalar(node));
+
+ /* is it an IPv4 address? */
+ if (is_ip4_address(addr))
+ return handle_netdef_ip4(doc, node, data, error);
+
+ /* is it an IPv6 address? */
+ if (is_ip6_address(addr))
+ return handle_netdef_ip6(doc, node, data, error);
+
+ return yaml_error(node, error, "malformed address '%s', must be X.X.X.X or X:X:X:X:X:X:X:X", scalar(node));
+}
+
+static gboolean
+handle_tunnel_mode(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ const char *key = scalar(node);
+ NetplanTunnelMode i;
+
+ // Skip over unknown (0) tunnel mode.
+ for (i = 1; i < NETPLAN_TUNNEL_MODE_MAX_; ++i) {
+ if (g_strcmp0(netplan_tunnel_mode_table[i], key) == 0) {
+ cur_netdef->tunnel.mode = i;
+ return TRUE;
+ }
+ }
+
+ return yaml_error(node, error, "%s: tunnel mode '%s' is not supported", cur_netdef->id, key);
+}
+
+static const mapping_entry_handler tunnel_keys_handlers[] = {
+ {"input", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(tunnel.input_key)},
+ {"output", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(tunnel.output_key)},
+ {"private", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(tunnel.private_key)},
+ {NULL}
+};
+
+static gboolean
+handle_tunnel_key_mapping(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ gboolean ret = FALSE;
+
+ /* We overload the 'key[s]' setting for tunnels; such that it can either be a
+ * single scalar with the same key to use for both input, output and private
+ * keys, or a mapping where one can specify each. */
+ if (node->type == YAML_SCALAR_NODE) {
+ ret = handle_netdef_str(doc, node, netdef_offset(tunnel.input_key), error);
+ if (ret)
+ ret = handle_netdef_str(doc, node, netdef_offset(tunnel.output_key), error);
+ if (ret)
+ ret = handle_netdef_str(doc, node, netdef_offset(tunnel.private_key), error);
+ } else if (node->type == YAML_MAPPING_NODE)
+ ret = process_mapping(doc, node, tunnel_keys_handlers, NULL, error);
+ else
+ return yaml_error(node, error, "invalid type for 'key[s]': must be a scalar or mapping");
+
+ return ret;
+}
+
+/**
+ * Handler for setting a NetplanWireguardPeer string field from a scalar node
+ * @data: pointer to the const char* field to write
+ */
+static gboolean
+handle_wireguard_peer_str(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_wireguard_peer);
+ return handle_generic_str(doc, node, cur_wireguard_peer, data, error);
+}
+
+/**
+ * Handler for setting a NetplanWireguardPeer string field from a scalar node
+ * @data: pointer to the guint field to write
+ */
+static gboolean
+handle_wireguard_peer_guint(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ g_assert(cur_wireguard_peer);
+ return handle_generic_guint(doc, node, cur_wireguard_peer, data, error);
+}
+
+static gboolean
+handle_wireguard_allowed_ips(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ return handle_generic_addresses(doc, node, FALSE, &(cur_wireguard_peer->allowed_ips),
+ &(cur_wireguard_peer->allowed_ips), error);
+}
+
+static gboolean
+handle_wireguard_endpoint(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ g_autofree char* endpoint = NULL;
+ char* port;
+ char* address;
+ guint64 port_num;
+
+ endpoint = g_strdup(scalar(node));
+ /* absolute minimal length of endpoint is 3 chars: 'h:8' */
+ if (strlen(endpoint) < 3) {
+ return yaml_error(node, error, "invalid endpoint address or hostname '%s'", scalar(node));
+ }
+ if (endpoint[0] == '[') {
+ /* this is an ipv6 endpoint in [ad:rr:ee::ss]:port form */
+ char *endbrace = strrchr(endpoint, ']');
+ if (!endbrace)
+ return yaml_error(node, error, "invalid address in endpoint '%s'", scalar(node));
+ address = endpoint + 1;
+ *endbrace = '\0';
+ port = strrchr(endbrace + 1, ':');
+ } else {
+ address = endpoint;
+ port = strrchr(endpoint, ':');
+ }
+ /* split off :port */
+ if (!port)
+ return yaml_error(node, error, "endpoint '%s' is missing :port", scalar(node));
+ *port = '\0';
+ port++; /* skip former ':' into first char of port */
+ port_num = g_ascii_strtoull(port, NULL, 10);
+ if (port_num > 65535)
+ return yaml_error(node, error, "invalid port in endpoint '%s'", scalar(node));
+ if (is_ip4_address(address) || is_ip6_address(address) || is_hostname(address)) {
+ return handle_wireguard_peer_str(doc, node, wireguard_peer_offset(endpoint), error);
+ }
+ return yaml_error(node, error, "invalid endpoint address or hostname '%s'", scalar(node));
+}
+
+static const mapping_entry_handler wireguard_peer_keys_handlers[] = {
+ {"public", YAML_SCALAR_NODE, handle_wireguard_peer_str, NULL, wireguard_peer_offset(public_key)},
+ {"shared", YAML_SCALAR_NODE, handle_wireguard_peer_str, NULL, wireguard_peer_offset(preshared_key)},
+ {NULL}
+};
+
+static gboolean
+handle_wireguard_peer_key_mapping(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ return process_mapping(doc, node, wireguard_peer_keys_handlers, NULL, error);
+}
+
+const mapping_entry_handler wireguard_peer_handlers[] = {
+ {"keys", YAML_MAPPING_NODE, handle_wireguard_peer_key_mapping},
+ {"keepalive", YAML_SCALAR_NODE, handle_wireguard_peer_guint, NULL, wireguard_peer_offset(keepalive)},
+ {"endpoint", YAML_SCALAR_NODE, handle_wireguard_endpoint},
+ {"allowed-ips", YAML_SEQUENCE_NODE, handle_wireguard_allowed_ips},
+ {NULL}
+};
+
+static gboolean
+handle_wireguard_peers(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ if (!cur_netdef->wireguard_peers)
+ cur_netdef->wireguard_peers = g_array_new(FALSE, TRUE, sizeof(NetplanWireguardPeer*));
+
+ /* Avoid adding the same peers in a 2nd parsing pass by comparing
+ * the array size to the YAML sequence size. Skip if they are equal. */
+ guint item_count = node->data.sequence.items.top - node->data.sequence.items.start;
+ if (cur_netdef->wireguard_peers->len == item_count) {
+ g_debug("%s: all wireguard peers have already been added", cur_netdef->id);
+ return TRUE;
+ }
+
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_MAPPING_NODE);
+
+ g_assert(cur_wireguard_peer == NULL);
+ cur_wireguard_peer = g_new0(NetplanWireguardPeer, 1);
+ cur_wireguard_peer->allowed_ips = g_array_new(FALSE, FALSE, sizeof(char*));
+ g_debug("%s: adding new wireguard peer", cur_netdef->id);
+
+ g_array_append_val(cur_netdef->wireguard_peers, cur_wireguard_peer);
+ if (!process_mapping(doc, entry, wireguard_peer_handlers, NULL, error)) {
+ cur_wireguard_peer = NULL;
+ return FALSE;
+ }
+ cur_wireguard_peer = NULL;
+ }
+ return TRUE;
+}
+
+/****************************************************
+ * Grammar and handlers for network devices
+ ****************************************************/
+
+static gboolean
+handle_ovs_bond_lacp(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BOND)
+ return yaml_error(node, error, "Key 'lacp' is only valid for interface type 'openvswitch bond'");
+
+ if (g_strcmp0(scalar(node), "active") && g_strcmp0(scalar(node), "passive") && g_strcmp0(scalar(node), "off"))
+ return yaml_error(node, error, "Value of 'lacp' needs to be 'active', 'passive' or 'off");
+
+ return handle_netdef_str(doc, node, data, error);
+}
+
+static gboolean
+handle_ovs_bridge_bool(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE)
+ return yaml_error(node, error, "Key is only valid for interface type 'openvswitch bridge'");
+
+ return handle_netdef_bool(doc, node, data, error);
+}
+
+static gboolean
+handle_ovs_bridge_fail_mode(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE)
+ return yaml_error(node, error, "Key 'fail-mode' is only valid for interface type 'openvswitch bridge'");
+
+ if (g_strcmp0(scalar(node), "standalone") && g_strcmp0(scalar(node), "secure"))
+ return yaml_error(node, error, "Value of 'fail-mode' needs to be 'standalone' or 'secure'");
+
+ return handle_netdef_str(doc, node, data, error);
+}
+
+static gboolean
+handle_ovs_protocol(yaml_document_t* doc, yaml_node_t* node, void* entryptr, const void* data, GError** error)
+{
+ const char* supported[] = {
+ "OpenFlow10", "OpenFlow11", "OpenFlow12", "OpenFlow13", "OpenFlow14", "OpenFlow15", "OpenFlow16", NULL
+ };
+ unsigned i = 0;
+ guint offset = GPOINTER_TO_UINT(data);
+ GArray** protocols = (GArray**) ((void*) entryptr + offset);
+
+ for (yaml_node_item_t *iter = node->data.sequence.items.start; iter < node->data.sequence.items.top; iter++) {
+ yaml_node_t *entry = yaml_document_get_node(doc, *iter);
+ assert_type(entry, YAML_SCALAR_NODE);
+
+ for (i = 0; supported[i] != NULL; ++i)
+ if (!g_strcmp0(scalar(entry), supported[i]))
+ break;
+
+ if (supported[i] == NULL)
+ return yaml_error(node, error, "Unsupported OVS 'protocol' value: %s", scalar(entry));
+
+ if (!*protocols)
+ *protocols = g_array_new(FALSE, FALSE, sizeof(char*));
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(*protocols, s);
+ }
+
+ return TRUE;
+}
+
+static gboolean
+handle_ovs_bridge_protocol(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE)
+ return yaml_error(node, error, "Key 'protocols' is only valid for interface type 'openvswitch bridge'");
+
+ return handle_ovs_protocol(doc, node, cur_netdef, data, error);
+}
+
+static gboolean
+handle_ovs_bridge_controller_connection_mode(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE)
+ return yaml_error(node, error, "Key 'controller.connection-mode' is only valid for interface type 'openvswitch bridge'");
+
+ if (g_strcmp0(scalar(node), "in-band") && g_strcmp0(scalar(node), "out-of-band"))
+ return yaml_error(node, error, "Value of 'connection-mode' needs to be 'in-band' or 'out-of-band'");
+
+ return handle_netdef_str(doc, node, data, error);
+}
+
+static gboolean
+handle_ovs_bridge_controller_addresses(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE)
+ return yaml_error(node, error, "Key 'controller.addresses' is only valid for interface type 'openvswitch bridge'");
+
+ for (yaml_node_item_t *i = node->data.sequence.items.start; i < node->data.sequence.items.top; i++) {
+ gchar** vec = NULL;
+ gboolean is_host = FALSE;
+ gboolean is_port = FALSE;
+ gboolean is_unix = FALSE;
+
+ yaml_node_t *entry = yaml_document_get_node(doc, *i);
+ assert_type(entry, YAML_SCALAR_NODE);
+ /* We always need at least one colon */
+ if (!g_strrstr(scalar(entry), ":"))
+ return yaml_error(node, error, "Unsupported OVS controller target: %s", scalar(entry));
+
+ vec = g_strsplit (scalar(entry), ":", 2);
+
+ is_host = !g_strcmp0(vec[0], "tcp") || !g_strcmp0(vec[0], "ssl");
+ is_port = !g_strcmp0(vec[0], "ptcp") || !g_strcmp0(vec[0], "pssl");
+ is_unix = !g_strcmp0(vec[0], "unix") || !g_strcmp0(vec[0], "punix");
+
+ if (!cur_netdef->ovs_settings.controller.addresses)
+ cur_netdef->ovs_settings.controller.addresses = g_array_new(FALSE, FALSE, sizeof(char*));
+
+ /* Format: [p]unix:file */
+ if (is_unix && vec[1] != NULL && vec[2] == NULL) {
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->ovs_settings.controller.addresses, s);
+ g_strfreev(vec);
+ continue;
+ /* Format tcp:host[:port] or ssl:host[:port] */
+ } else if (is_host && validate_ovs_target(TRUE, vec[1])) {
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->ovs_settings.controller.addresses, s);
+ g_strfreev(vec);
+ continue;
+ /* Format ptcp:[port][:host] or pssl:[port][:host] */
+ } else if (is_port && validate_ovs_target(FALSE, vec[1])) {
+ char* s = g_strdup(scalar(entry));
+ g_array_append_val(cur_netdef->ovs_settings.controller.addresses, s);
+ g_strfreev(vec);
+ continue;
+ }
+
+ g_strfreev(vec);
+ return yaml_error(node, error, "Unsupported OVS controller target: %s", scalar(entry));
+ }
+
+ return TRUE;
+}
+
+static const mapping_entry_handler ovs_controller_handlers[] = {
+ {"addresses", YAML_SEQUENCE_NODE, handle_ovs_bridge_controller_addresses, NULL, netdef_offset(ovs_settings.controller.addresses)},
+ {"connection-mode", YAML_SCALAR_NODE, handle_ovs_bridge_controller_connection_mode, NULL, netdef_offset(ovs_settings.controller.connection_mode)},
+ {NULL},
+};
+
+static const mapping_entry_handler ovs_backend_settings_handlers[] = {
+ {"external-ids", YAML_MAPPING_NODE, handle_netdef_map, NULL, netdef_offset(ovs_settings.external_ids)},
+ {"other-config", YAML_MAPPING_NODE, handle_netdef_map, NULL, netdef_offset(ovs_settings.other_config)},
+ {"lacp", YAML_SCALAR_NODE, handle_ovs_bond_lacp, NULL, netdef_offset(ovs_settings.lacp)},
+ {"fail-mode", YAML_SCALAR_NODE, handle_ovs_bridge_fail_mode, NULL, netdef_offset(ovs_settings.fail_mode)},
+ {"mcast-snooping", YAML_SCALAR_NODE, handle_ovs_bridge_bool, NULL, netdef_offset(ovs_settings.mcast_snooping)},
+ {"rstp", YAML_SCALAR_NODE, handle_ovs_bridge_bool, NULL, netdef_offset(ovs_settings.rstp)},
+ {"protocols", YAML_SEQUENCE_NODE, handle_ovs_bridge_protocol, NULL, netdef_offset(ovs_settings.protocols)},
+ {"controller", YAML_MAPPING_NODE, NULL, ovs_controller_handlers},
+ {NULL}
+};
+
+static gboolean
+handle_ovs_backend(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ GList* values = NULL;
+ gboolean ret = process_mapping(doc, node, ovs_backend_settings_handlers, &values, error);
+ guint len = g_list_length(values);
+
+ if (cur_netdef->type != NETPLAN_DEF_TYPE_BOND && cur_netdef->type != NETPLAN_DEF_TYPE_BRIDGE) {
+ GList *other_config = g_list_find_custom(values, "other-config", (GCompareFunc) strcmp);
+ GList *external_ids = g_list_find_custom(values, "external-ids", (GCompareFunc) strcmp);
+ /* Non-bond/non-bridge interfaces might still be handled by the networkd backend */
+ if (len == 1 && (other_config || external_ids))
+ return ret;
+ else if (len == 2 && other_config && external_ids)
+ return ret;
+ }
+ g_list_free_full(values, g_free);
+
+ /* Set the renderer for this device to NETPLAN_BACKEND_OVS, implicitly.
+ * But only if empty "openvswitch: {}" or "openvswitch:" with more than
+ * "other-config" or "external-ids" keys is given. */
+ cur_netdef->backend = NETPLAN_BACKEND_OVS;
+ return ret;
+}
+
+static const mapping_entry_handler nameservers_handlers[] = {
+ {"search", YAML_SEQUENCE_NODE, handle_nameservers_search},
+ {"addresses", YAML_SEQUENCE_NODE, handle_nameservers_addresses},
+ {NULL}
+};
+
+/* Handlers for DHCP overrides. */
+#define COMMON_DHCP_OVERRIDES_HANDLERS(overrides) \
+ {"hostname", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(overrides.hostname)}, \
+ {"route-metric", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(overrides.metric)}, \
+ {"send-hostname", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.send_hostname)}, \
+ {"use-dns", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.use_dns)}, \
+ {"use-domains", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(overrides.use_domains)}, \
+ {"use-hostname", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.use_hostname)}, \
+ {"use-mtu", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.use_mtu)}, \
+ {"use-ntp", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.use_ntp)}, \
+ {"use-routes", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(overrides.use_routes)}
+
+static const mapping_entry_handler dhcp4_overrides_handlers[] = {
+ COMMON_DHCP_OVERRIDES_HANDLERS(dhcp4_overrides),
+ {NULL},
+};
+
+static const mapping_entry_handler dhcp6_overrides_handlers[] = {
+ COMMON_DHCP_OVERRIDES_HANDLERS(dhcp6_overrides),
+ {NULL},
+};
+
+/* Handlers shared by all link types */
+#define COMMON_LINK_HANDLERS \
+ {"accept-ra", YAML_SCALAR_NODE, handle_accept_ra, NULL, netdef_offset(accept_ra)}, \
+ {"activation-mode", YAML_SCALAR_NODE, handle_activation_mode, NULL, netdef_offset(activation_mode)}, \
+ {"addresses", YAML_SEQUENCE_NODE, handle_addresses}, \
+ {"critical", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(critical)}, \
+ {"dhcp4", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(dhcp4)}, \
+ {"dhcp6", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(dhcp6)}, \
+ {"dhcp-identifier", YAML_SCALAR_NODE, handle_dhcp_identifier}, \
+ {"dhcp4-overrides", YAML_MAPPING_NODE, NULL, dhcp4_overrides_handlers}, \
+ {"dhcp6-overrides", YAML_MAPPING_NODE, NULL, dhcp6_overrides_handlers}, \
+ {"gateway4", YAML_SCALAR_NODE, handle_gateway4}, \
+ {"gateway6", YAML_SCALAR_NODE, handle_gateway6}, \
+ {"ipv6-address-generation", YAML_SCALAR_NODE, handle_netdef_addrgen}, \
+ {"ipv6-address-token", YAML_SCALAR_NODE, handle_netdef_addrtok, NULL, netdef_offset(ip6_addr_gen_token)}, \
+ {"ipv6-mtu", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(ipv6_mtubytes)}, \
+ {"ipv6-privacy", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(ip6_privacy)}, \
+ {"link-local", YAML_SEQUENCE_NODE, handle_link_local}, \
+ {"macaddress", YAML_SCALAR_NODE, handle_netdef_mac, NULL, netdef_offset(set_mac)}, \
+ {"mtu", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(mtubytes)}, \
+ {"nameservers", YAML_MAPPING_NODE, NULL, nameservers_handlers}, \
+ {"optional", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(optional)}, \
+ {"optional-addresses", YAML_SEQUENCE_NODE, handle_optional_addresses}, \
+ {"renderer", YAML_SCALAR_NODE, handle_netdef_renderer}, \
+ {"routes", YAML_SEQUENCE_NODE, handle_routes}, \
+ {"routing-policy", YAML_SEQUENCE_NODE, handle_ip_rules}
+
+#define COMMON_BACKEND_HANDLERS \
+ {"networkmanager", YAML_MAPPING_NODE, NULL, nm_backend_settings_handlers}, \
+ {"openvswitch", YAML_MAPPING_NODE, handle_ovs_backend}
+
+/* Handlers for physical links */
+#define PHYSICAL_LINK_HANDLERS \
+ {"match", YAML_MAPPING_NODE, handle_match}, \
+ {"set-name", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(set_name)}, \
+ {"wakeonlan", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(wake_on_lan)}, \
+ {"wakeonwlan", YAML_SEQUENCE_NODE, handle_wowlan, NULL, netdef_offset(wowlan)}, \
+ {"emit-lldp", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(emit_lldp)}
+
+static const mapping_entry_handler ethernet_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ PHYSICAL_LINK_HANDLERS,
+ {"auth", YAML_MAPPING_NODE, handle_auth},
+ {"link", YAML_SCALAR_NODE, handle_netdef_id_ref, NULL, netdef_offset(sriov_link)},
+ {"virtual-function-count", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(sriov_explicit_vf_count)},
+ {NULL}
+};
+
+static const mapping_entry_handler wifi_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ PHYSICAL_LINK_HANDLERS,
+ {"access-points", YAML_MAPPING_NODE, handle_wifi_access_points},
+ {"auth", YAML_MAPPING_NODE, handle_auth},
+ {NULL}
+};
+
+static const mapping_entry_handler bridge_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ {"interfaces", YAML_SEQUENCE_NODE, handle_bridge_interfaces, NULL, NULL},
+ {"parameters", YAML_MAPPING_NODE, handle_bridge},
+ {NULL}
+};
+
+static const mapping_entry_handler bond_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ {"interfaces", YAML_SEQUENCE_NODE, handle_bond_interfaces, NULL, NULL},
+ {"parameters", YAML_MAPPING_NODE, handle_bonding},
+ {NULL}
+};
+
+static const mapping_entry_handler vlan_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ {"id", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(vlan_id)},
+ {"link", YAML_SCALAR_NODE, handle_netdef_id_ref, NULL, netdef_offset(vlan_link)},
+ {NULL}
+};
+
+static const mapping_entry_handler modem_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ PHYSICAL_LINK_HANDLERS,
+ {"apn", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.apn)},
+ {"auto-config", YAML_SCALAR_NODE, handle_netdef_bool, NULL, netdef_offset(modem_params.auto_config)},
+ {"device-id", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.device_id)},
+ {"network-id", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.network_id)},
+ {"number", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.number)},
+ {"password", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.password)},
+ {"pin", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.pin)},
+ {"sim-id", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.sim_id)},
+ {"sim-operator-id", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.sim_operator_id)},
+ {"username", YAML_SCALAR_NODE, handle_netdef_str, NULL, netdef_offset(modem_params.username)},
+};
+
+static const mapping_entry_handler tunnel_def_handlers[] = {
+ COMMON_LINK_HANDLERS,
+ COMMON_BACKEND_HANDLERS,
+ {"mode", YAML_SCALAR_NODE, handle_tunnel_mode},
+ {"local", YAML_SCALAR_NODE, handle_tunnel_addr, NULL, netdef_offset(tunnel.local_ip)},
+ {"remote", YAML_SCALAR_NODE, handle_tunnel_addr, NULL, netdef_offset(tunnel.remote_ip)},
+ {"ttl", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(tunnel_ttl)},
+
+ /* Handle key/keys for clarity in config: this can be either a scalar or
+ * mapping of multiple keys (input and output)
+ */
+ {"key", YAML_NO_NODE, handle_tunnel_key_mapping},
+ {"keys", YAML_NO_NODE, handle_tunnel_key_mapping},
+
+ /* wireguard */
+ {"mark", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(tunnel.fwmark)},
+ {"port", YAML_SCALAR_NODE, handle_netdef_guint, NULL, netdef_offset(tunnel.port)},
+ {"peers", YAML_SEQUENCE_NODE, handle_wireguard_peers},
+ {NULL}
+};
+
+/****************************************************
+ * Grammar and handlers for network node
+ ****************************************************/
+
+static gboolean
+handle_network_version(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ long mangled_version;
+
+ mangled_version = strtol(scalar(node), NULL, 10);
+
+ if (mangled_version < NETPLAN_VERSION_MIN || mangled_version >= NETPLAN_VERSION_MAX)
+ return yaml_error(node, error, "Only version 2 is supported");
+ return TRUE;
+}
+
+static gboolean
+handle_network_renderer(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ return parse_renderer(node, &backend_global, error);
+}
+
+static gboolean
+handle_network_ovs_settings_global(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_generic_map(doc, node, &ovs_settings_global, data, error);
+}
+
+static gboolean
+handle_network_ovs_settings_global_protocol(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ return handle_ovs_protocol(doc, node, &ovs_settings_global, data, error);
+}
+
+static gboolean
+handle_network_ovs_settings_global_ports(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ yaml_node_t* port = NULL;
+ yaml_node_t* peer = NULL;
+ yaml_node_t* pair = NULL;
+ yaml_node_item_t *item = NULL;
+ NetplanNetDefinition *component = NULL;
+
+ for (yaml_node_item_t *iter = node->data.sequence.items.start; iter < node->data.sequence.items.top; iter++) {
+ pair = yaml_document_get_node(doc, *iter);
+ assert_type(pair, YAML_SEQUENCE_NODE);
+
+ item = pair->data.sequence.items.start;
+ /* A peer port definition must contain exactly 2 ports */
+ if (item+2 != pair->data.sequence.items.top) {
+ return yaml_error(pair, error, "An openvswitch peer port sequence must have exactly two entries");
+ }
+
+ port = yaml_document_get_node(doc, *item);
+ assert_type(port, YAML_SCALAR_NODE);
+ peer = yaml_document_get_node(doc, *(item+1));
+ assert_type(peer, YAML_SCALAR_NODE);
+
+ /* Create port 1 netdef */
+ component = netdefs ? g_hash_table_lookup(netdefs, scalar(port)) : NULL;
+ if (!component) {
+ component = netplan_netdef_new(scalar(port), NETPLAN_DEF_TYPE_PORT, NETPLAN_BACKEND_OVS);
+ if (g_hash_table_remove(missing_id, scalar(port)))
+ missing_ids_found++;
+ }
+
+ if (component->peer && g_strcmp0(component->peer, scalar(peer)))
+ return yaml_error(port, error, "openvswitch port '%s' is already assigned to peer '%s'",
+ component->id, component->peer);
+ component->peer = g_strdup(scalar(peer));
+
+ /* Create port 2 (peer) netdef */
+ component = NULL;
+ component = netdefs ? g_hash_table_lookup(netdefs, scalar(peer)) : NULL;
+ if (!component) {
+ component = netplan_netdef_new(scalar(peer), NETPLAN_DEF_TYPE_PORT, NETPLAN_BACKEND_OVS);
+ if (g_hash_table_remove(missing_id, scalar(peer)))
+ missing_ids_found++;
+ }
+
+ if (component->peer && g_strcmp0(component->peer, scalar(port)))
+ return yaml_error(peer, error, "openvswitch port '%s' is already assigned to peer '%s'",
+ component->id, component->peer);
+ component->peer = g_strdup(scalar(port));
+ }
+ return TRUE;
+}
+
+/**
+ * Callback for a net device type entry like "ethernets:" in "network:"
+ * @data: netdef_type (as pointer)
+ */
+static gboolean
+handle_network_type(yaml_document_t* doc, yaml_node_t* node, const void* data, GError** error)
+{
+ for (yaml_node_pair_t* entry = node->data.mapping.pairs.start; entry < node->data.mapping.pairs.top; entry++) {
+ yaml_node_t* key, *value;
+ const mapping_entry_handler* handlers;
+
+ key = yaml_document_get_node(doc, entry->key);
+ if (!assert_valid_id(key, error))
+ return FALSE;
+ /* globbing is not allowed for IDs */
+ if (strpbrk(scalar(key), "*[]?"))
+ return yaml_error(key, error, "Definition ID '%s' must not use globbing", scalar(key));
+
+ value = yaml_document_get_node(doc, entry->value);
+
+ /* special-case "renderer:" key to set the per-type backend */
+ if (strcmp(scalar(key), "renderer") == 0) {
+ if (!parse_renderer(value, &backend_cur_type, error))
+ return FALSE;
+ continue;
+ }
+
+ assert_type(value, YAML_MAPPING_NODE);
+
+ /* At this point we've seen a new starting definition, if it has been
+ * already mentioned in another netdef, removing it from our "missing"
+ * list. */
+ if(g_hash_table_remove(missing_id, scalar(key)))
+ missing_ids_found++;
+
+ cur_netdef = netdefs ? g_hash_table_lookup(netdefs, scalar(key)) : NULL;
+ if (cur_netdef) {
+ /* already exists, overriding/amending previous definition */
+ if (cur_netdef->type != GPOINTER_TO_UINT(data))
+ return yaml_error(key, error, "Updated definition '%s' changes device type", scalar(key));
+ } else {
+ cur_netdef = netplan_netdef_new(scalar(key), GPOINTER_TO_UINT(data), backend_cur_type);
+ }
+ g_assert(cur_filename);
+ cur_netdef->filename = g_strdup(cur_filename);
+
+ // XXX: breaks multi-pass parsing.
+ //if (!g_hash_table_add(ids_in_file, cur_netdef->id))
+ // return yaml_error(key, error, "Duplicate net definition ID '%s'", cur_netdef->id);
+
+ /* and fill it with definitions */
+ switch (cur_netdef->type) {
+ case NETPLAN_DEF_TYPE_BOND: handlers = bond_def_handlers; break;
+ case NETPLAN_DEF_TYPE_BRIDGE: handlers = bridge_def_handlers; break;
+ case NETPLAN_DEF_TYPE_ETHERNET: handlers = ethernet_def_handlers; break;
+ case NETPLAN_DEF_TYPE_MODEM: handlers = modem_def_handlers; break;
+ case NETPLAN_DEF_TYPE_TUNNEL: handlers = tunnel_def_handlers; break;
+ case NETPLAN_DEF_TYPE_VLAN: handlers = vlan_def_handlers; break;
+ case NETPLAN_DEF_TYPE_WIFI: handlers = wifi_def_handlers; break;
+ case NETPLAN_DEF_TYPE_NM:
+ g_warning("netplan: %s: handling NetworkManager passthrough device, settings are not fully supported.", cur_netdef->id);
+ handlers = ethernet_def_handlers;
+ break;
+ default: g_assert_not_reached(); // LCOV_EXCL_LINE
+ }
+ if (!process_mapping(doc, value, handlers, NULL, error))
+ return FALSE;
+
+ /* validate definition-level conditions */
+ if (!validate_netdef_grammar(cur_netdef, value, error))
+ return FALSE;
+
+ /* convenience shortcut: physical device without match: means match
+ * name on ID */
+ if (cur_netdef->type < NETPLAN_DEF_TYPE_VIRTUAL && !cur_netdef->has_match)
+ set_str_if_null(cur_netdef->match.original_name, cur_netdef->id);
+ }
+ backend_cur_type = NETPLAN_BACKEND_NONE;
+ return TRUE;
+}
+
+static const mapping_entry_handler ovs_global_ssl_handlers[] = {
+ {"ca-cert", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(ca_certificate)},
+ {"certificate", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(client_certificate)},
+ {"private-key", YAML_SCALAR_NODE, handle_auth_str, NULL, auth_offset(client_key)},
+ {NULL}
+};
+
+static gboolean
+handle_ovs_global_ssl(yaml_document_t* doc, yaml_node_t* node, const void* _, GError** error)
+{
+ gboolean ret;
+
+ cur_auth = &(ovs_settings_global.ssl);
+ ret = process_mapping(doc, node, ovs_global_ssl_handlers, NULL, error);
+ cur_auth = NULL;
+
+ return ret;
+}
+
+static const mapping_entry_handler ovs_network_settings_handlers[] = {
+ {"external-ids", YAML_MAPPING_NODE, handle_network_ovs_settings_global, NULL, ovs_settings_offset(external_ids)},
+ {"other-config", YAML_MAPPING_NODE, handle_network_ovs_settings_global, NULL, ovs_settings_offset(other_config)},
+ {"protocols", YAML_SEQUENCE_NODE, handle_network_ovs_settings_global_protocol, NULL, ovs_settings_offset(protocols)},
+ {"ports", YAML_SEQUENCE_NODE, handle_network_ovs_settings_global_ports},
+ {"ssl", YAML_MAPPING_NODE, handle_ovs_global_ssl},
+ {NULL}
+};
+
+static const mapping_entry_handler network_handlers[] = {
+ {"bonds", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_BOND)},
+ {"bridges", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_BRIDGE)},
+ {"ethernets", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_ETHERNET)},
+ {"renderer", YAML_SCALAR_NODE, handle_network_renderer},
+ {"tunnels", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_TUNNEL)},
+ {"version", YAML_SCALAR_NODE, handle_network_version},
+ {"vlans", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_VLAN)},
+ {"wifis", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_WIFI)},
+ {"modems", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_MODEM)},
+ {"nm-devices", YAML_MAPPING_NODE, handle_network_type, NULL, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_NM)},
+ {"openvswitch", YAML_MAPPING_NODE, NULL, ovs_network_settings_handlers},
+ {NULL}
+};
+
+/****************************************************
+ * Grammar and handlers for root node
+ ****************************************************/
+
+static const mapping_entry_handler root_handlers[] = {
+ {"network", YAML_MAPPING_NODE, NULL, network_handlers},
+ {NULL}
+};
+
+/**
+ * Handle multiple-pass parsing of the yaml document.
+ */
+static gboolean
+process_document(yaml_document_t* doc, GError** error)
+{
+ gboolean ret;
+ int previously_found;
+ int still_missing;
+
+ g_assert(missing_id == NULL);
+ missing_id = g_hash_table_new_full(g_str_hash, g_str_equal, NULL, g_free);
+
+ do {
+ g_debug("starting new processing pass");
+
+ previously_found = missing_ids_found;
+ missing_ids_found = 0;
+
+ g_clear_error(error);
+
+ ret = process_mapping(doc, yaml_document_get_root_node(doc), root_handlers, NULL, error);
+
+ still_missing = g_hash_table_size(missing_id);
+
+ if (still_missing > 0 && missing_ids_found == previously_found)
+ break;
+ } while (still_missing > 0 || missing_ids_found > 0);
+
+ if (g_hash_table_size(missing_id) > 0) {
+ GHashTableIter iter;
+ gpointer key, value;
+ NetplanMissingNode *missing;
+
+ g_clear_error(error);
+
+ /* Get the first missing identifier we can get from our list, to
+ * approximate early failure and give the user a meaningful error. */
+ g_hash_table_iter_init (&iter, missing_id);
+ g_hash_table_iter_next (&iter, &key, &value);
+ missing = (NetplanMissingNode*) value;
+
+ return yaml_error(missing->node, error, "%s: interface '%s' is not defined",
+ missing->netdef_id,
+ key);
+ }
+
+ g_hash_table_destroy(missing_id);
+ missing_id = NULL;
+ return ret;
+}
+
+/**
+ * Parse given YAML file and create/update global "netdefs" list.
+ */
+gboolean
+netplan_parse_yaml(const char* filename, GError** error)
+{
+ yaml_document_t doc;
+ gboolean ret;
+
+ if (!load_yaml(filename, &doc, error))
+ return FALSE;
+
+ /* empty file? */
+ if (yaml_document_get_root_node(&doc) == NULL)
+ return TRUE;
+
+ g_assert(ids_in_file == NULL);
+ ids_in_file = g_hash_table_new(g_str_hash, NULL);
+
+ cur_filename = filename;
+ ret = process_document(&doc, error);
+
+ cur_filename = NULL;
+ cur_netdef = NULL;
+ yaml_document_delete(&doc);
+ g_hash_table_destroy(ids_in_file);
+ ids_in_file = NULL;
+ return ret;
+}
+
+static void
+finish_iterator(gpointer key, gpointer value, gpointer user_data)
+{
+ GError **error = (GError **)user_data;
+ NetplanNetDefinition* nd = value;
+
+ /* Take more steps to make sure we always have a backend set for netdefs */
+ if (nd->backend == NETPLAN_BACKEND_NONE) {
+ nd->backend = get_default_backend_for_type(nd->type);
+ g_debug("%s: setting default backend to %i", nd->id, nd->backend);
+ }
+
+ /* Do a final pass of validation for backend-specific conditions */
+ if (validate_backend_rules(nd, error))
+ g_debug("Configuration is valid");
+}
+
+/**
+ * Post-processing after parsing all config files
+ */
+GHashTable *
+netplan_finish_parse(GError** error)
+{
+ if (netdefs) {
+ GError *recoverable = NULL;
+ g_debug("We have some netdefs, pass them through a final round of validation");
+ if (!validate_default_route_consistency(netdefs, &recoverable)) {
+ g_warning("Problem encountered while validating default route consistency."
+ "Please set up multiple routing tables and use `routing-policy` instead.\n"
+ "Error: %s", (recoverable) ? recoverable->message : "");
+ g_clear_error(&recoverable);
+ }
+ g_hash_table_foreach(netdefs, finish_iterator, error);
+ }
+
+ if (error && *error)
+ return NULL;
+
+ return netdefs;
+}
+
+/**
+ * Return current global backend.
+ */
+NetplanBackend
+netplan_get_global_backend()
+{
+ return backend_global;
+}
+
+/**
+ * Clear NetplanNetDefinition hashtable
+ */
+guint
+netplan_clear_netdefs()
+{
+ guint n = 0;
+ if(netdefs) {
+ n = g_hash_table_size(netdefs);
+ /* FIXME: make sure that any dynamically allocated netdef data is freed */
+ if (n > 0)
+ g_hash_table_remove_all(netdefs);
+ netdefs = NULL;
+ }
+ if(netdefs_ordered) {
+ g_clear_list(&netdefs_ordered, g_free);
+ netdefs_ordered = NULL;
+ }
+ backend_global = NETPLAN_BACKEND_NONE;
+ ovs_settings_global = (NetplanOVSSettings){0};
+ return n;
+}
+
+void
+process_input_file(const char* f)
+{
+ GError* error = NULL;
+
+ g_debug("Processing input file %s..", f);
+ if (!netplan_parse_yaml(f, &error)) {
+ g_fprintf(stderr, "%s\n", error->message);
+ exit(1);
+ }
+}
+
+gboolean
+process_yaml_hierarchy(const char* rootdir)
+{
+ glob_t gl;
+ /* Files with asciibetically higher names override/append settings from
+ * earlier ones (in all config dirs); files in /run/netplan/
+ * shadow files in /etc/netplan/ which shadow files in /lib/netplan/.
+ * To do that, we put all found files in a hash table, then sort it by
+ * file name, and add the entries from /run after the ones from /etc
+ * and those after the ones from /lib. */
+ if (find_yaml_glob(rootdir, &gl) != 0)
+ return FALSE; // LCOV_EXCL_LINE
+ /* keys are strdup()ed, free them; values point into the glob_t, don't free them */
+ g_autoptr(GHashTable) configs = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL);
+ g_autoptr(GList) config_keys = NULL;
+
+ for (size_t i = 0; i < gl.gl_pathc; ++i)
+ g_hash_table_insert(configs, g_path_get_basename(gl.gl_pathv[i]), gl.gl_pathv[i]);
+
+ config_keys = g_list_sort(g_hash_table_get_keys(configs), (GCompareFunc) strcmp);
+
+ for (GList* i = config_keys; i != NULL; i = i->next)
+ process_input_file(g_hash_table_lookup(configs, i->data));
+ return TRUE;
+}
diff --git a/src/parse.h b/src/parse.h
new file mode 100644
index 0000000..dc24880
--- /dev/null
+++ b/src/parse.h
@@ -0,0 +1,517 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <uuid.h>
+#include <yaml.h>
+
+#define NETPLAN_VERSION_MIN 2
+#define NETPLAN_VERSION_MAX 3
+
+
+/* file that is currently being processed, for useful error messages */
+extern const char* current_file;
+
+/* List of "seen" ids not found in netdefs yet by the parser.
+ * These are removed when it exists in this list and we reach the point of
+ * creating a netdef for that id; so by the time we're done parsing the yaml
+ * document it should be empty. */
+extern GHashTable *missing_id;
+extern int missing_ids_found;
+
+/****************************************************
+ * Parsed definitions
+ ****************************************************/
+
+typedef enum {
+ NETPLAN_DEF_TYPE_NONE,
+ /* physical devices */
+ NETPLAN_DEF_TYPE_ETHERNET,
+ NETPLAN_DEF_TYPE_WIFI,
+ NETPLAN_DEF_TYPE_MODEM,
+ /* virtual devices */
+ NETPLAN_DEF_TYPE_VIRTUAL,
+ NETPLAN_DEF_TYPE_BRIDGE = NETPLAN_DEF_TYPE_VIRTUAL,
+ NETPLAN_DEF_TYPE_BOND,
+ NETPLAN_DEF_TYPE_VLAN,
+ NETPLAN_DEF_TYPE_TUNNEL,
+ NETPLAN_DEF_TYPE_PORT,
+ /* Type fallback/passthrough */
+ NETPLAN_DEF_TYPE_NM,
+ NETPLAN_DEF_TYPE_MAX_
+} NetplanDefType;
+
+typedef enum {
+ NETPLAN_BACKEND_NONE,
+ NETPLAN_BACKEND_NETWORKD,
+ NETPLAN_BACKEND_NM,
+ NETPLAN_BACKEND_OVS,
+ NETPLAN_BACKEND_MAX_,
+} NetplanBackend;
+
+static const char* const netplan_backend_to_name[NETPLAN_BACKEND_MAX_] = {
+ [NETPLAN_BACKEND_NONE] = "none",
+ [NETPLAN_BACKEND_NETWORKD] = "networkd",
+ [NETPLAN_BACKEND_NM] = "NetworkManager",
+ [NETPLAN_BACKEND_OVS] = "OpenVSwitch",
+};
+
+typedef enum {
+ NETPLAN_RA_MODE_KERNEL,
+ NETPLAN_RA_MODE_ENABLED,
+ NETPLAN_RA_MODE_DISABLED,
+} NetplanRAMode;
+
+typedef enum {
+ NETPLAN_OPTIONAL_IPV4_LL = 1<<0,
+ NETPLAN_OPTIONAL_IPV6_RA = 1<<1,
+ NETPLAN_OPTIONAL_DHCP4 = 1<<2,
+ NETPLAN_OPTIONAL_DHCP6 = 1<<3,
+ NETPLAN_OPTIONAL_STATIC = 1<<4,
+} NetplanOptionalAddressFlag;
+
+typedef enum {
+ NETPLAN_ADDRGEN_DEFAULT,
+ NETPLAN_ADDRGEN_EUI64,
+ NETPLAN_ADDRGEN_STABLEPRIVACY,
+ NETPLAN_ADDRGEN_MAX,
+} NetplanAddrGenMode;
+
+struct NetplanOptionalAddressType {
+ char* name;
+ NetplanOptionalAddressFlag flag;
+};
+
+extern struct NetplanOptionalAddressType NETPLAN_OPTIONAL_ADDRESS_TYPES[];
+
+/* Tunnel mode enum; sync with NetworkManager's DBUS API */
+/* TODO: figure out whether networkd's GRETAP and NM's ISATAP
+ * are the same thing.
+ */
+typedef enum {
+ NETPLAN_TUNNEL_MODE_UNKNOWN = 0,
+ NETPLAN_TUNNEL_MODE_IPIP = 1,
+ NETPLAN_TUNNEL_MODE_GRE = 2,
+ NETPLAN_TUNNEL_MODE_SIT = 3,
+ NETPLAN_TUNNEL_MODE_ISATAP = 4, // NM only.
+ NETPLAN_TUNNEL_MODE_VTI = 5,
+ NETPLAN_TUNNEL_MODE_IP6IP6 = 6,
+ NETPLAN_TUNNEL_MODE_IPIP6 = 7,
+ NETPLAN_TUNNEL_MODE_IP6GRE = 8,
+ NETPLAN_TUNNEL_MODE_VTI6 = 9,
+
+ /* systemd-only, apparently? */
+ NETPLAN_TUNNEL_MODE_GRETAP = 101,
+ NETPLAN_TUNNEL_MODE_IP6GRETAP = 102,
+ NETPLAN_TUNNEL_MODE_WIREGUARD = 103,
+
+ NETPLAN_TUNNEL_MODE_MAX_,
+} NetplanTunnelMode;
+
+static const char* const
+netplan_tunnel_mode_table[NETPLAN_TUNNEL_MODE_MAX_] = {
+ [NETPLAN_TUNNEL_MODE_UNKNOWN] = "unknown",
+ [NETPLAN_TUNNEL_MODE_IPIP] = "ipip",
+ [NETPLAN_TUNNEL_MODE_GRE] = "gre",
+ [NETPLAN_TUNNEL_MODE_SIT] = "sit",
+ [NETPLAN_TUNNEL_MODE_ISATAP] = "isatap",
+ [NETPLAN_TUNNEL_MODE_VTI] = "vti",
+ [NETPLAN_TUNNEL_MODE_IP6IP6] = "ip6ip6",
+ [NETPLAN_TUNNEL_MODE_IPIP6] = "ipip6",
+ [NETPLAN_TUNNEL_MODE_IP6GRE] = "ip6gre",
+ [NETPLAN_TUNNEL_MODE_VTI6] = "vti6",
+
+ [NETPLAN_TUNNEL_MODE_GRETAP] = "gretap",
+ [NETPLAN_TUNNEL_MODE_IP6GRETAP] = "ip6gretap",
+ [NETPLAN_TUNNEL_MODE_WIREGUARD] = "wireguard",
+};
+
+typedef enum {
+ NETPLAN_WIFI_WOWLAN_DEFAULT = 1<<0,
+ NETPLAN_WIFI_WOWLAN_ANY = 1<<1,
+ NETPLAN_WIFI_WOWLAN_DISCONNECT = 1<<2,
+ NETPLAN_WIFI_WOWLAN_MAGIC = 1<<3,
+ NETPLAN_WIFI_WOWLAN_GTK_REKEY_FAILURE = 1<<4,
+ NETPLAN_WIFI_WOWLAN_EAP_IDENTITY_REQ = 1<<5,
+ NETPLAN_WIFI_WOWLAN_4WAY_HANDSHAKE = 1<<6,
+ NETPLAN_WIFI_WOWLAN_RFKILL_RELEASE = 1<<7,
+ NETPLAN_WIFI_WOWLAN_TCP = 1<<8,
+} NetplanWifiWowlanFlag;
+
+struct NetplanWifiWowlanType {
+ char* name;
+ NetplanWifiWowlanFlag flag;
+};
+
+extern struct NetplanWifiWowlanType NETPLAN_WIFI_WOWLAN_TYPES[];
+
+typedef enum {
+ NETPLAN_AUTH_KEY_MANAGEMENT_NONE,
+ NETPLAN_AUTH_KEY_MANAGEMENT_WPA_PSK,
+ NETPLAN_AUTH_KEY_MANAGEMENT_WPA_EAP,
+ NETPLAN_AUTH_KEY_MANAGEMENT_8021X,
+ NETPLAN_AUTH_KEY_MANAGEMENT_MAX,
+} NetplanAuthKeyManagementType;
+
+typedef enum {
+ NETPLAN_AUTH_EAP_NONE,
+ NETPLAN_AUTH_EAP_TLS,
+ NETPLAN_AUTH_EAP_PEAP,
+ NETPLAN_AUTH_EAP_TTLS,
+ NETPLAN_AUTH_EAP_METHOD_MAX,
+} NetplanAuthEAPMethod;
+
+typedef struct missing_node {
+ char* netdef_id;
+ const yaml_node_t* node;
+} NetplanMissingNode;
+
+typedef struct authentication_settings {
+ NetplanAuthKeyManagementType key_management;
+ NetplanAuthEAPMethod eap_method;
+ char* identity;
+ char* anonymous_identity;
+ char* password;
+ char* ca_certificate;
+ char* client_certificate;
+ char* client_key;
+ char* client_key_password;
+ char* phase2_auth; /* netplan-feature: auth-phase2 */
+} NetplanAuthenticationSettings;
+
+/* Fields below are valid for dhcp4 and dhcp6 unless otherwise noted. */
+typedef struct dhcp_overrides {
+ gboolean use_dns;
+ gboolean use_ntp;
+ gboolean send_hostname;
+ gboolean use_hostname;
+ gboolean use_mtu;
+ gboolean use_routes;
+ char* use_domains; /* netplan-feature: dhcp-use-domains */
+ char* hostname;
+ guint metric;
+} NetplanDHCPOverrides;
+
+typedef struct ovs_controller {
+ char* connection_mode;
+ GArray* addresses;
+} NetplanOVSController;
+
+typedef struct ovs_settings {
+ GHashTable* external_ids;
+ GHashTable* other_config;
+ char* lacp;
+ char* fail_mode;
+ gboolean mcast_snooping;
+ GArray* protocols;
+ gboolean rstp;
+ NetplanOVSController controller;
+ NetplanAuthenticationSettings ssl;
+} NetplanOVSSettings;
+
+typedef union {
+ struct NetplanNMSettings {
+ char *name;
+ char *uuid;
+ char *stable_id;
+ char *device;
+ GData* passthrough;
+ } nm;
+ struct NetplanNetworkdSettings {
+ char *unit;
+ } networkd;
+} NetplanBackendSettings;
+
+/**
+ * Represent a configuration stanza
+ */
+
+struct net_definition;
+
+typedef struct net_definition NetplanNetDefinition;
+
+struct net_definition {
+ NetplanDefType type;
+ NetplanBackend backend;
+ char* id;
+ /* only necessary for NetworkManager connection UUIDs in some cases */
+ uuid_t uuid;
+
+ /* status options */
+ gboolean optional;
+ NetplanOptionalAddressFlag optional_addresses;
+ gboolean critical;
+
+ /* addresses */
+ gboolean dhcp4;
+ gboolean dhcp6;
+ char* dhcp_identifier;
+ NetplanDHCPOverrides dhcp4_overrides;
+ NetplanDHCPOverrides dhcp6_overrides;
+ NetplanRAMode accept_ra;
+ GArray* ip4_addresses;
+ GArray* ip6_addresses;
+ GArray* address_options;
+ gboolean ip6_privacy;
+ guint ip6_addr_gen_mode;
+ char* ip6_addr_gen_token;
+ char* gateway4;
+ char* gateway6;
+ GArray* ip4_nameservers;
+ GArray* ip6_nameservers;
+ GArray* search_domains;
+ GArray* routes;
+ GArray* ip_rules;
+ GArray* wireguard_peers;
+ struct {
+ gboolean ipv4;
+ gboolean ipv6;
+ } linklocal;
+
+ /* master ID for slave devices */
+ char* bridge;
+ char* bond;
+
+ /* peer ID for OVS patch ports */
+ char* peer;
+
+ /* vlan */
+ guint vlan_id;
+ NetplanNetDefinition* vlan_link;
+ gboolean has_vlans;
+
+ /* Configured custom MAC address */
+ char* set_mac;
+
+ /* interface mtu */
+ guint mtubytes;
+ /* ipv6 mtu */
+ /* netplan-feature: ipv6-mtu */
+ guint ipv6_mtubytes;
+
+ /* these properties are only valid for physical interfaces (type < ND_VIRTUAL) */
+ char* set_name;
+ struct {
+ char* driver;
+ char* mac;
+ char* original_name;
+ } match;
+ gboolean has_match;
+ gboolean wake_on_lan;
+ NetplanWifiWowlanFlag wowlan;
+ gboolean emit_lldp;
+
+ /* these properties are only valid for NETPLAN_DEF_TYPE_WIFI */
+ GHashTable* access_points; /* SSID → NetplanWifiAccessPoint* */
+
+ struct {
+ char* mode;
+ char* lacp_rate;
+ char* monitor_interval;
+ guint min_links;
+ char* transmit_hash_policy;
+ char* selection_logic;
+ gboolean all_slaves_active;
+ char* arp_interval;
+ GArray* arp_ip_targets;
+ char* arp_validate;
+ char* arp_all_targets;
+ char* up_delay;
+ char* down_delay;
+ char* fail_over_mac_policy;
+ guint gratuitous_arp;
+ /* TODO: unsolicited_na */
+ guint packets_per_slave;
+ char* primary_reselect_policy;
+ guint resend_igmp;
+ char* learn_interval;
+ char* primary_slave;
+ } bond_params;
+
+ /* netplan-feature: modems */
+ struct {
+ char* apn;
+ gboolean auto_config;
+ char* device_id;
+ char* network_id;
+ char* number;
+ char* password;
+ char* pin;
+ char* sim_id;
+ char* sim_operator_id;
+ char* username;
+ } modem_params;
+
+ struct {
+ char* ageing_time;
+ guint priority;
+ guint port_priority;
+ char* forward_delay;
+ char* hello_time;
+ char* max_age;
+ guint path_cost;
+ gboolean stp;
+ } bridge_params;
+ gboolean custom_bridging;
+
+ struct {
+ NetplanTunnelMode mode;
+ char *local_ip;
+ char *remote_ip;
+ char *input_key;
+ char *output_key;
+ char *private_key; /* used for wireguard */
+ guint fwmark;
+ guint port;
+ } tunnel;
+
+ NetplanAuthenticationSettings auth;
+ gboolean has_auth;
+
+ /* these properties are only valid for SR-IOV NICs */
+ /* netplan-feature: sriov */
+ struct net_definition* sriov_link;
+ gboolean sriov_vlan_filter;
+ guint sriov_explicit_vf_count;
+
+ /* these properties are only valid for OpenVSwitch */
+ /* netplan-feature: openvswitch */
+ NetplanOVSSettings ovs_settings;
+
+ NetplanBackendSettings backend_settings;
+
+ char* filename;
+ /* it cannot be in the tunnel struct: https://github.com/canonical/netplan/pull/206 */
+ guint tunnel_ttl;
+
+ /* netplan-feature: activation-mode */
+ char* activation_mode;
+};
+
+typedef enum {
+ NETPLAN_WIFI_MODE_INFRASTRUCTURE,
+ NETPLAN_WIFI_MODE_ADHOC,
+ NETPLAN_WIFI_MODE_AP,
+ NETPLAN_WIFI_MODE_OTHER,
+ NETPLAN_WIFI_MODE_MAX_
+} NetplanWifiMode;
+
+static const char* const netplan_wifi_mode_to_str[NETPLAN_WIFI_MODE_MAX_] = {
+ [NETPLAN_WIFI_MODE_INFRASTRUCTURE] = "infrastructure",
+ [NETPLAN_WIFI_MODE_ADHOC] = "adhoc",
+ [NETPLAN_WIFI_MODE_AP] = "ap",
+ [NETPLAN_WIFI_MODE_OTHER] = NULL,
+};
+
+typedef struct {
+ char *endpoint;
+ char *public_key;
+ char *preshared_key;
+ GArray *allowed_ips;
+ guint keepalive;
+} NetplanWireguardPeer;
+
+typedef enum {
+ NETPLAN_WIFI_BAND_DEFAULT,
+ NETPLAN_WIFI_BAND_5,
+ NETPLAN_WIFI_BAND_24
+} NetplanWifiBand;
+
+typedef struct {
+ char* address;
+ char* lifetime;
+ char* label;
+} NetplanAddressOptions;
+
+typedef struct {
+ NetplanWifiMode mode;
+ char* ssid;
+ NetplanWifiBand band;
+ char* bssid;
+ gboolean hidden;
+ guint channel;
+
+ NetplanAuthenticationSettings auth;
+ gboolean has_auth;
+
+ NetplanBackendSettings backend_settings;
+} NetplanWifiAccessPoint;
+
+#define NETPLAN_ADVERTISED_RECEIVE_WINDOW_UNSPEC 0
+#define NETPLAN_CONGESTION_WINDOW_UNSPEC 0
+#define NETPLAN_MTU_UNSPEC 0
+#define NETPLAN_METRIC_UNSPEC G_MAXUINT
+#define NETPLAN_ROUTE_TABLE_UNSPEC 0
+#define NETPLAN_IP_RULE_PRIO_UNSPEC G_MAXUINT
+#define NETPLAN_IP_RULE_FW_MARK_UNSPEC 0
+#define NETPLAN_IP_RULE_TOS_UNSPEC G_MAXUINT
+
+typedef struct {
+ guint family;
+ char* type;
+ char* scope;
+ guint table;
+
+ char* from;
+ char* to;
+ char* via;
+
+ gboolean onlink;
+
+ /* valid metrics are valid positive integers.
+ * invalid metrics are represented by METRIC_UNSPEC */
+ guint metric;
+
+ guint mtubytes;
+ guint congestion_window;
+ guint advertised_receive_window;
+} NetplanIPRoute;
+
+typedef struct {
+ guint family;
+
+ char* from;
+ char* to;
+
+ /* table: Valid values are 1 <= x <= 4294967295) */
+ guint table;
+ guint priority;
+ /* fwmark: Valid values are 1 <= x <= 4294967295) */
+ guint fwmark;
+ /* type-of-service: between 0 and 255 */
+ guint tos;
+} NetplanIPRule;
+
+/* Written/updated by parse_yaml(): char* id → net_definition */
+extern GHashTable* netdefs;
+extern GList* netdefs_ordered;
+extern NetplanOVSSettings ovs_settings_global;
+
+/****************************************************
+ * Functions
+ ****************************************************/
+
+gboolean netplan_parse_yaml(const char* filename, GError** error);
+GHashTable* netplan_finish_parse(GError** error);
+guint netplan_clear_netdefs();
+NetplanBackend netplan_get_global_backend();
+const char* tunnel_mode_to_string(NetplanTunnelMode mode);
+NetplanNetDefinition* netplan_netdef_new(const char* id, NetplanDefType type, NetplanBackend renderer);
+
+void process_input_file(const char* f);
+gboolean process_yaml_hierarchy(const char* rootdir);
diff --git a/src/sriov.c b/src/sriov.c
new file mode 100644
index 0000000..60f9800
--- /dev/null
+++ b/src/sriov.c
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2020 Canonical, Ltd.
+ * Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <unistd.h>
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <glib-object.h>
+
+#include "util.h"
+
+void
+write_sriov_conf_finish(const char* rootdir)
+{
+ /* For now we execute apply --sriov-only everytime there is a new
+ SR-IOV device appearing, which is fine as it's relatively fast */
+ GString *udev_rule = g_string_new("ACTION==\"add\", SUBSYSTEM==\"net\", ATTRS{sriov_totalvfs}==\"?*\", RUN+=\"/usr/sbin/netplan apply --sriov-only\"\n");
+ g_string_free_to_file(udev_rule, rootdir, "run/udev/rules.d/99-sriov-netplan-setup.rules", NULL);
+}
+
+void
+cleanup_sriov_conf(const char* rootdir)
+{
+ g_autofree char* rulepath = g_strjoin(NULL, rootdir ?: "", "/run/udev/rules.d/99-sriov-netplan-setup.rules", NULL);
+ unlink(rulepath);
+}
diff --git a/src/sriov.h b/src/sriov.h
new file mode 100644
index 0000000..7cd5896
--- /dev/null
+++ b/src/sriov.h
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2020 Canonical, Ltd.
+ * Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+void write_sriov_conf_finish(const char* rootdir);
+void cleanup_sriov_conf(const char* rootdir);
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..a4c0dba
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <unistd.h>
+#include <arpa/inet.h>
+
+#include <glib.h>
+#include <glib/gprintf.h>
+
+#include "util.h"
+#include "netplan.h"
+
+GHashTable* wifi_frequency_24;
+GHashTable* wifi_frequency_5;
+
+/**
+ * Create the parent directories of given file path. Exit program on failure.
+ */
+void
+safe_mkdir_p_dir(const char* file_path)
+{
+ g_autofree char* dir = g_path_get_dirname(file_path);
+
+ if (g_mkdir_with_parents(dir, 0755) < 0) {
+ g_fprintf(stderr, "ERROR: cannot create directory %s: %m\n", dir);
+ exit(1);
+ }
+}
+
+/**
+ * Write a GString to a file and free it. Create necessary parent directories
+ * and exit with error message on error.
+ * @s: #GString whose contents to write. Will be fully freed afterwards.
+ * @rootdir: optional rootdir (@NULL means "/")
+ * @path: path of file to write (@rootdir will be prepended)
+ * @suffix: optional suffix to append to path
+ */
+void g_string_free_to_file(GString* s, const char* rootdir, const char* path, const char* suffix)
+{
+ g_autofree char* full_path = NULL;
+ g_autofree char* path_suffix = NULL;
+ g_autofree char* contents = g_string_free(s, FALSE);
+ GError* error = NULL;
+
+ path_suffix = g_strjoin(NULL, path, suffix, NULL);
+ full_path = g_build_path(G_DIR_SEPARATOR_S, rootdir ?: G_DIR_SEPARATOR_S, path_suffix, NULL);
+ safe_mkdir_p_dir(full_path);
+ if (!g_file_set_contents(full_path, contents, -1, &error)) {
+ /* the mkdir() just succeeded, there is no sensible
+ * method to test this without root privileges, bind mounts, and
+ * simulating ENOSPC */
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "ERROR: cannot create file %s: %s\n", path, error->message);
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+}
+
+/**
+ * Remove all files matching given glob.
+ */
+void
+unlink_glob(const char* rootdir, const char* _glob)
+{
+ glob_t gl;
+ int rc;
+ g_autofree char* rglob = g_strjoin(NULL, rootdir ?: "", G_DIR_SEPARATOR_S, _glob, NULL);
+
+ rc = glob(rglob, GLOB_BRACE, NULL, &gl);
+ if (rc != 0 && rc != GLOB_NOMATCH) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to glob for %s: %m\n", rglob);
+ return;
+ // LCOV_EXCL_STOP
+ }
+
+ for (size_t i = 0; i < gl.gl_pathc; ++i)
+ unlink(gl.gl_pathv[i]);
+ globfree(&gl);
+}
+
+/**
+ * Return a glob of all *.yaml files in /{lib,etc,run}/netplan/ (in this order)
+ */
+int find_yaml_glob(const char* rootdir, glob_t* out_glob)
+{
+ int rc;
+ g_autofree char* rglob = g_strjoin(NULL, rootdir ?: "", G_DIR_SEPARATOR_S, "{lib,etc,run}/netplan/*.yaml", NULL);
+ rc = glob(rglob, GLOB_BRACE, NULL, out_glob);
+ if (rc != 0 && rc != GLOB_NOMATCH) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to glob for %s: %m\n", rglob);
+ return 1;
+ // LCOV_EXCL_STOP
+ }
+
+ return 0;
+}
+
+/**
+ * Get the frequency of a given 2.4GHz WiFi channel
+ */
+int
+wifi_get_freq24(int channel)
+{
+ if (channel < 1 || channel > 14) {
+ g_fprintf(stderr, "ERROR: invalid 2.4GHz WiFi channel: %d\n", channel);
+ exit(1);
+ }
+
+ if (!wifi_frequency_24) {
+ wifi_frequency_24 = g_hash_table_new(g_direct_hash, g_direct_equal);
+ /* Initialize 2.4GHz frequencies, as of:
+ * https://en.wikipedia.org/wiki/List_of_WLAN_channels#2.4_GHz_(802.11b/g/n/ax) */
+ for (unsigned i = 0; i < 13; i++) {
+ g_hash_table_insert(wifi_frequency_24, GINT_TO_POINTER(i+1),
+ GINT_TO_POINTER(2412+i*5));
+ }
+ g_hash_table_insert(wifi_frequency_24, GINT_TO_POINTER(14),
+ GINT_TO_POINTER(2484));
+ }
+ return GPOINTER_TO_INT(g_hash_table_lookup(wifi_frequency_24,
+ GINT_TO_POINTER(channel)));
+}
+
+/**
+ * Get the frequency of a given 5GHz WiFi channel
+ */
+int
+wifi_get_freq5(int channel)
+{
+ int channels[] = { 7, 8, 9, 11, 12, 16, 32, 34, 36, 38, 40, 42, 44, 46, 48,
+ 50, 52, 54, 56, 58, 60, 62, 64, 68, 96, 100, 102, 104,
+ 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126,
+ 128, 132, 134, 136, 138, 140, 142, 144, 149, 151, 153,
+ 155, 157, 159, 161, 165, 169, 173 };
+ gboolean found = FALSE;
+ for (unsigned i = 0; i < sizeof(channels) / sizeof(int); i++) {
+ if (channel == channels[i]) {
+ found = TRUE;
+ break;
+ }
+ }
+ if (!found) {
+ g_fprintf(stderr, "ERROR: invalid 5GHz WiFi channel: %d\n", channel);
+ exit(1);
+ }
+ if (!wifi_frequency_5) {
+ wifi_frequency_5 = g_hash_table_new(g_direct_hash, g_direct_equal);
+ /* Initialize 5GHz frequencies, as of:
+ * https://en.wikipedia.org/wiki/List_of_WLAN_channels#5.0_GHz_(802.11j)_WLAN
+ * Skipping channels 183-196. They are valid only in Japan with registration needed */
+ for (unsigned i = 0; i < sizeof(channels) / sizeof(int); i++) {
+ g_hash_table_insert(wifi_frequency_5, GINT_TO_POINTER(channels[i]),
+ GINT_TO_POINTER(5000+channels[i]*5));
+ }
+ }
+ return GPOINTER_TO_INT(g_hash_table_lookup(wifi_frequency_5,
+ GINT_TO_POINTER(channel)));
+}
+
+/**
+ * Systemd-escape the given string. The caller is responsible for freeing
+ * the allocated escaped string.
+ */
+gchar*
+systemd_escape(char* string)
+{
+ g_autoptr(GError) err = NULL;
+ g_autofree gchar* stderrh = NULL;
+ gint exit_status = 0;
+ gchar *escaped;
+
+ gchar *argv[] = {"bin" "/" "systemd-escape", string, NULL};
+ g_spawn_sync("/", argv, NULL, 0, NULL, NULL, &escaped, &stderrh, &exit_status, &err);
+ g_spawn_check_exit_status(exit_status, &err);
+ if (err != NULL) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "failed to ask systemd to escape %s; exit %d\nstdout: '%s'\nstderr: '%s'", string, exit_status, escaped, stderrh);
+ exit(1);
+ // LCOV_EXCL_STOP
+ }
+ g_strstrip(escaped);
+
+ return escaped;
+}
+
+gboolean
+netplan_delete_connection(const char* id, const char* rootdir)
+{
+ g_autofree gchar* filename = NULL;
+ g_autofree gchar* del = NULL;
+ g_autoptr(GError) error = NULL;
+ NetplanNetDefinition* nd = NULL;
+
+ /* parse all YAML files */
+ if (!process_yaml_hierarchy(rootdir))
+ return FALSE; // LCOV_EXCL_LINE
+
+ netdefs = netplan_finish_parse(&error);
+ if (!netdefs) {
+ // LCOV_EXCL_START
+ g_fprintf(stderr, "netplan_delete_connection: %s\n", error->message);
+ return FALSE;
+ // LCOV_EXCL_STOP
+ }
+
+ /* find filename for specified netdef ID */
+ nd = g_hash_table_lookup(netdefs, id);
+ if (!nd) {
+ g_warning("netplan_delete_connection: Cannot delete %s, does not exist.", id);
+ return FALSE;
+ }
+
+ filename = g_path_get_basename(nd->filename);
+ filename[strlen(filename) - 5] = '\0'; //stip ".yaml" suffix
+ del = g_strdup_printf("network.%s.%s=NULL", netplan_def_type_to_str[nd->type], id);
+ netplan_clear_netdefs();
+
+ /* TODO: refactor logic to actually be inside the library instead of spawning another process */
+ const gchar *argv[] = { SBINDIR "/" "netplan", "set", del, "--origin-hint" , filename, NULL, NULL, NULL };
+ if (rootdir) {
+ argv[5] = "--root-dir";
+ argv[6] = rootdir;
+ }
+ if (getenv("TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("TEST_NETPLAN_CMD");
+ return g_spawn_sync(NULL, (gchar**)argv, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL);
+}
+
+gboolean
+netplan_generate(const char* rootdir)
+{
+ /* TODO: refactor logic to actually be inside the library instead of spawning another process */
+ const gchar *argv[] = { SBINDIR "/" "netplan", "generate", NULL , NULL, NULL };
+ if (rootdir) {
+ argv[2] = "--root-dir";
+ argv[3] = rootdir;
+ }
+ if (getenv("TEST_NETPLAN_CMD") != 0)
+ argv[0] = getenv("TEST_NETPLAN_CMD");
+ return g_spawn_sync(NULL, (gchar**)argv, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL);
+}
+
+/**
+ * Extract the netplan netdef ID from a NetworkManager connection profile (keyfile),
+ * generated by netplan. Used by the NetworkManager YAML backend.
+ */
+gchar*
+netplan_get_id_from_nm_filename(const char* filename, const char* ssid)
+{
+ g_autofree gchar* escaped_ssid = NULL;
+ g_autofree gchar* suffix = NULL;
+ const char* nm_prefix = "/run/NetworkManager/system-connections/netplan-";
+ const char* pos = g_strrstr(filename, nm_prefix);
+ const char* start = NULL;
+ const char* end = NULL;
+ gsize id_len = 0;
+
+ if (!pos)
+ return NULL;
+
+ if (ssid) {
+ escaped_ssid = g_uri_escape_string(ssid, NULL, TRUE);
+ suffix = g_strdup_printf("-%s.nmconnection", escaped_ssid);
+ end = g_strrstr(filename, suffix);
+ } else
+ end = g_strrstr(filename, ".nmconnection");
+
+ if (!end)
+ return NULL;
+
+ /* Move pointer to start of netplan ID inside filename string */
+ start = pos + strlen(nm_prefix);
+ id_len = end - start;
+ return g_strndup(start, id_len);
+}
+
+/**
+ * Get the filename from which the given netdef has been parsed.
+ * @rootdir: ID of the netdef to be looked up
+ * @rootdir: parse files from this root directory
+ */
+gchar*
+netplan_get_filename_by_id(const char* netdef_id, const char* rootdir)
+{
+ gchar* filename = NULL;
+ netplan_clear_netdefs();
+ if (!process_yaml_hierarchy(rootdir))
+ return NULL; // LCOV_EXCL_LINE
+ GHashTable* netdefs = netplan_finish_parse(NULL);
+ if (!netdefs)
+ return NULL;
+ NetplanNetDefinition* nd = g_hash_table_lookup(netdefs, netdef_id);
+ if (!nd)
+ return NULL;
+ filename = g_strdup(nd->filename);
+ netplan_clear_netdefs();
+ return filename;
+}
+
+/**
+ * Get a static string describing the default global network
+ * for a given address family.
+ */
+const char *
+get_global_network(int ip_family)
+{
+ g_assert(ip_family == AF_INET || ip_family == AF_INET6);
+ if (ip_family == AF_INET)
+ return "0.0.0.0/0";
+ else
+ return "::/0";
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..f34c601
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2016 Canonical, Ltd.
+ * Author: Martin Pitt <martin.pitt@ubuntu.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#define __USE_MISC
+#include <glob.h>
+#pragma once
+
+extern GHashTable* wifi_frequency_24;
+extern GHashTable* wifi_frequency_5;
+
+void safe_mkdir_p_dir(const char* file_path);
+void g_string_free_to_file(GString* s, const char* rootdir, const char* path, const char* suffix);
+void unlink_glob(const char* rootdir, const char* _glob);
+int find_yaml_glob(const char* rootdir, glob_t* out_glob);
+
+const char *get_global_network(int ip_family);
+
+int wifi_get_freq24(int channel);
+int wifi_get_freq5(int channel);
+
+gchar* systemd_escape(char* string);
+gboolean netplan_delete_connection(const char* id, const char* rootdir);
+gboolean netplan_generate(const char* rootdir);
+gchar* netplan_get_id_from_nm_filename(const char* filename, const char* ssid);
+gchar* netplan_get_filename_by_id(const char* netdef_id, const char* rootdir);
+
+#define OPENVSWITCH_OVS_VSCTL "/usr/bin/ovs-vsctl"
diff --git a/src/validation.c b/src/validation.c
new file mode 100644
index 0000000..a0dca68
--- /dev/null
+++ b/src/validation.c
@@ -0,0 +1,486 @@
+/*
+ * Copyright (C) 2019 Canonical, Ltd.
+ * Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <glib.h>
+#include <glib/gstdio.h>
+#include <gio/gio.h>
+#include <arpa/inet.h>
+#include <regex.h>
+
+#include <yaml.h>
+
+#include "parse.h"
+#include "error.h"
+#include "util.h"
+
+
+/* Check sanity for address types */
+
+gboolean
+is_ip4_address(const char* address)
+{
+ struct in_addr a4;
+ int ret;
+
+ ret = inet_pton(AF_INET, address, &a4);
+ g_assert(ret >= 0);
+ if (ret > 0)
+ return TRUE;
+
+ return FALSE;
+}
+
+gboolean
+is_ip6_address(const char* address)
+{
+ struct in6_addr a6;
+ int ret;
+
+ ret = inet_pton(AF_INET6, address, &a6);
+ g_assert(ret >= 0);
+ if (ret > 0)
+ return TRUE;
+
+ return FALSE;
+}
+
+gboolean
+is_hostname(const char *hostname)
+{
+ static const gchar *pattern = "^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])$";
+ return g_regex_match_simple(pattern, hostname, G_REGEX_CASELESS, G_REGEX_MATCH_NOTEMPTY);
+}
+
+gboolean
+is_wireguard_key(const char* key)
+{
+ /* Check if this is (most likely) a 265bit, base64 encoded wireguard key */
+ if (strlen(key) == 44 && key[43] == '=' && key[42] != '=') {
+ static const gchar *pattern = "^(?:[A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=)+$";
+ return g_regex_match_simple(pattern, key, 0, G_REGEX_MATCH_NOTEMPTY);
+ }
+ return FALSE;
+}
+
+/* Check sanity of OpenVSwitch controller targets */
+gboolean
+validate_ovs_target(gboolean host_first, gchar* s) {
+ static guint dport = 6653; // the default port
+ g_autofree gchar* host = NULL;
+ g_autofree gchar* port = NULL;
+ gchar** vec = NULL;
+
+ /* Format tcp:host[:port] or ssl:host[:port] */
+ if (host_first) {
+ g_assert(s != NULL);
+ // IP6 host, indicated by bracketed notation ([..IPv6..])
+ if (s[0] == '[') {
+ gchar* tmp = NULL;
+ tmp = s+1; //get rid of leading '['
+ // append default port to unify parsing
+ if (!g_strrstr(tmp, "]:"))
+ vec = g_strsplit(g_strdup_printf("%s:%u", tmp, dport), "]:", 2);
+ else
+ vec = g_strsplit(tmp, "]:", 2);
+ // IP4 host
+ } else {
+ // append default port to unify parsing
+ if (!g_strrstr(s, ":"))
+ vec = g_strsplit(g_strdup_printf("%s:%u", s, dport), ":", 2);
+ else
+ vec = g_strsplit(s, ":", 2);
+ }
+ // host and port are always set
+ host = g_strdup(vec[0]); //set host alias
+ port = g_strdup(vec[1]); //set port alias
+ g_assert(vec[2] == NULL);
+ g_strfreev(vec);
+ /* Format ptcp:[port][:host] or pssl:[port][:host] */
+ } else {
+ // special case: "ptcp:" (no port, no host)
+ if (!g_strcmp0(s, ""))
+ port = g_strdup_printf("%u", dport);
+ else {
+ vec = g_strsplit(s, ":", 2);
+ port = g_strdup(vec[0]);
+ host = g_strdup(vec[1]);
+ // get rid of leading & trailing IPv6 brackets
+ if (host && host[0] == '[') {
+ char **split = g_strsplit_set(host, "[]", 3);
+ g_free(host);
+ host = g_strjoinv("", split);
+ g_strfreev(split);
+ }
+ g_strfreev(vec);
+ }
+ }
+
+ g_assert(port != NULL);
+ // special case where IPv6 notation contains '%iface' name
+ if (host && g_strrstr(host, "%")) {
+ gchar** split = g_strsplit (host, "%", 2);
+ g_free(host);
+ host = g_strdup(split[0]); // designated scope for IPv6 link-level addresses
+ g_assert(split[1] != NULL && split[2] == NULL);
+ g_strfreev(split);
+ }
+
+ if (atoi(port) > 0 && atoi(port) <= 65535) {
+ if (!host)
+ return TRUE;
+ else if (host && (is_ip4_address(host) || is_ip6_address(host)))
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/************************************************
+ * Validation for grammar and backend rules.
+ ************************************************/
+static gboolean
+validate_tunnel_key(yaml_node_t* node, gchar* key, GError** error)
+{
+ /* Tunnel key should be a number or dotted quad, except for wireguard. */
+ gchar* endptr;
+ guint64 v = g_ascii_strtoull(key, &endptr, 10);
+ if (*endptr != '\0' || v > G_MAXUINT) {
+ /* Not a simple uint, try for a dotted quad */
+ if (!is_ip4_address(key))
+ return yaml_error(node, error, "invalid tunnel key '%s'", key);
+ }
+ return TRUE;
+}
+
+static gboolean
+validate_tunnel_grammar(NetplanNetDefinition* nd, yaml_node_t* node, GError** error)
+{
+ if (nd->tunnel.mode == NETPLAN_TUNNEL_MODE_UNKNOWN)
+ return yaml_error(node, error, "%s: missing 'mode' property for tunnel", nd->id);
+
+ if (nd->tunnel.mode == NETPLAN_TUNNEL_MODE_WIREGUARD) {
+ if (!nd->tunnel.private_key)
+ return yaml_error(node, error, "%s: missing 'key' property (private key) for wireguard", nd->id);
+ if (nd->tunnel.private_key[0] != '/' && !is_wireguard_key(nd->tunnel.private_key))
+ return yaml_error(node, error, "%s: invalid wireguard private key", nd->id);
+ if (!nd->wireguard_peers || nd->wireguard_peers->len == 0)
+ return yaml_error(node, error, "%s: at least one peer is required.", nd->id);
+ for (guint i = 0; i < nd->wireguard_peers->len; i++) {
+ NetplanWireguardPeer *peer = g_array_index (nd->wireguard_peers, NetplanWireguardPeer*, i);
+
+ if (!peer->public_key)
+ return yaml_error(node, error, "%s: keys.public is required.", nd->id);
+ if (!is_wireguard_key(peer->public_key))
+ return yaml_error(node, error, "%s: invalid wireguard public key", nd->id);
+ if (peer->preshared_key && peer->preshared_key[0] != '/' && !is_wireguard_key(peer->preshared_key))
+ return yaml_error(node, error, "%s: invalid wireguard shared key", nd->id);
+ if (!peer->allowed_ips || peer->allowed_ips->len == 0)
+ return yaml_error(node, error, "%s: 'to' is required to define the allowed IPs.", nd->id);
+ if (peer->keepalive > 65535)
+ return yaml_error(node, error, "%s: keepalive must be 0-65535 inclusive.", nd->id);
+ }
+ return TRUE;
+ } else {
+ if (nd->tunnel.input_key && !validate_tunnel_key(node, nd->tunnel.input_key, error))
+ return FALSE;
+ if (nd->tunnel.output_key && !validate_tunnel_key(node, nd->tunnel.output_key, error))
+ return FALSE;
+ }
+
+ /* Validate local/remote IPs */
+ if (!nd->tunnel.local_ip)
+ return yaml_error(node, error, "%s: missing 'local' property for tunnel", nd->id);
+ if (!nd->tunnel.remote_ip)
+ return yaml_error(node, error, "%s: missing 'remote' property for tunnel", nd->id);
+ if (nd->tunnel_ttl && nd->tunnel_ttl > 255)
+ return yaml_error(node, error, "%s: 'ttl' property for tunnel must be in range [1...255]", nd->id);
+
+ switch(nd->tunnel.mode) {
+ case NETPLAN_TUNNEL_MODE_IPIP6:
+ case NETPLAN_TUNNEL_MODE_IP6IP6:
+ case NETPLAN_TUNNEL_MODE_IP6GRE:
+ case NETPLAN_TUNNEL_MODE_IP6GRETAP:
+ case NETPLAN_TUNNEL_MODE_VTI6:
+ if (!is_ip6_address(nd->tunnel.local_ip))
+ return yaml_error(node, error, "%s: 'local' must be a valid IPv6 address for this tunnel type", nd->id);
+ if (!is_ip6_address(nd->tunnel.remote_ip))
+ return yaml_error(node, error, "%s: 'remote' must be a valid IPv6 address for this tunnel type", nd->id);
+ break;
+
+ default:
+ if (!is_ip4_address(nd->tunnel.local_ip))
+ return yaml_error(node, error, "%s: 'local' must be a valid IPv4 address for this tunnel type", nd->id);
+ if (!is_ip4_address(nd->tunnel.remote_ip))
+ return yaml_error(node, error, "%s: 'remote' must be a valid IPv4 address for this tunnel type", nd->id);
+ break;
+ }
+
+ return TRUE;
+}
+
+static gboolean
+validate_tunnel_backend_rules(NetplanNetDefinition* nd, yaml_node_t* node, GError** error)
+{
+ /* Backend-specific validation rules for tunnels */
+ switch (nd->backend) {
+ case NETPLAN_BACKEND_NETWORKD:
+ switch (nd->tunnel.mode) {
+ case NETPLAN_TUNNEL_MODE_VTI:
+ case NETPLAN_TUNNEL_MODE_VTI6:
+ case NETPLAN_TUNNEL_MODE_WIREGUARD:
+ break;
+
+ /* TODO: Remove this exception and fix ISATAP handling with the
+ * networkd backend.
+ * systemd-networkd has grown ISATAP support in 918049a.
+ */
+ case NETPLAN_TUNNEL_MODE_ISATAP:
+ return yaml_error(node, error,
+ "%s: %s tunnel mode is not supported by networkd",
+ nd->id,
+ g_ascii_strup(tunnel_mode_to_string(nd->tunnel.mode), -1));
+ break;
+
+ default:
+ if (nd->tunnel.input_key)
+ return yaml_error(node, error, "%s: 'input-key' is not required for this tunnel type", nd->id);
+ if (nd->tunnel.output_key)
+ return yaml_error(node, error, "%s: 'output-key' is not required for this tunnel type", nd->id);
+ break;
+ }
+ break;
+
+ case NETPLAN_BACKEND_NM:
+ switch (nd->tunnel.mode) {
+ case NETPLAN_TUNNEL_MODE_GRE:
+ case NETPLAN_TUNNEL_MODE_IP6GRE:
+ case NETPLAN_TUNNEL_MODE_WIREGUARD:
+ break;
+
+ case NETPLAN_TUNNEL_MODE_GRETAP:
+ case NETPLAN_TUNNEL_MODE_IP6GRETAP:
+ return yaml_error(node, error,
+ "%s: %s tunnel mode is not supported by NetworkManager",
+ nd->id,
+ g_ascii_strup(tunnel_mode_to_string(nd->tunnel.mode), -1));
+ break;
+
+ default:
+ if (nd->tunnel.input_key)
+ return yaml_error(node, error, "%s: 'input-key' is not required for this tunnel type", nd->id);
+ if (nd->tunnel.output_key)
+ return yaml_error(node, error, "%s: 'output-key' is not required for this tunnel type", nd->id);
+ break;
+ }
+ break;
+
+ default: break; //LCOV_EXCL_LINE
+ }
+
+ return TRUE;
+}
+
+gboolean
+validate_netdef_grammar(NetplanNetDefinition* nd, yaml_node_t* node, GError** error)
+{
+ int missing_id_count = g_hash_table_size(missing_id);
+ gboolean valid = FALSE;
+
+ g_assert(nd->type != NETPLAN_DEF_TYPE_NONE);
+
+ /* Skip all validation if we're missing some definition IDs (devices).
+ * The ones we have yet to see may be necessary for validation to succeed,
+ * we can complete it on the next parser pass. */
+ if (missing_id_count > 0)
+ return TRUE;
+
+ /* set-name: requires match: */
+ if (nd->set_name && !nd->has_match)
+ return yaml_error(node, error, "%s: 'set-name:' requires 'match:' properties", nd->id);
+
+ if (nd->type == NETPLAN_DEF_TYPE_WIFI && nd->access_points == NULL)
+ return yaml_error(node, error, "%s: No access points defined", nd->id);
+
+ if (nd->type == NETPLAN_DEF_TYPE_VLAN) {
+ if (!nd->vlan_link)
+ return yaml_error(node, error, "%s: missing 'link' property", nd->id);
+ nd->vlan_link->has_vlans = TRUE;
+ if (nd->vlan_id == G_MAXUINT)
+ return yaml_error(node, error, "%s: missing 'id' property", nd->id);
+ if (nd->vlan_id > 4094)
+ return yaml_error(node, error, "%s: invalid id '%u' (allowed values are 0 to 4094)", nd->id, nd->vlan_id);
+ }
+
+ if (nd->type == NETPLAN_DEF_TYPE_TUNNEL) {
+ valid = validate_tunnel_grammar(nd, node, error);
+ if (!valid)
+ goto netdef_grammar_error;
+ }
+
+ if (nd->ip6_addr_gen_mode != NETPLAN_ADDRGEN_DEFAULT && nd->ip6_addr_gen_token)
+ return yaml_error(node, error, "%s: ipv6-address-generation and ipv6-address-token are mutually exclusive", nd->id);
+
+ if (nd->backend == NETPLAN_BACKEND_OVS) {
+ // LCOV_EXCL_START
+ if (!g_file_test(OPENVSWITCH_OVS_VSCTL, G_FILE_TEST_EXISTS)) {
+ /* Tested via integration test */
+ return yaml_error(node, error, "%s: The 'ovs-vsctl' tool is required to setup OpenVSwitch interfaces.", nd->id);
+ }
+ // LCOV_EXCL_STOP
+ }
+
+ if (nd->type == NETPLAN_DEF_TYPE_NM && (!nd->backend_settings.nm.passthrough || !g_datalist_get_data(&nd->backend_settings.nm.passthrough, "connection.type")))
+ return yaml_error(node, error, "%s: network type 'nm-devices:' needs to provide a 'connection.type' via passthrough", nd->id);
+
+ valid = TRUE;
+
+netdef_grammar_error:
+ return valid;
+}
+
+gboolean
+validate_backend_rules(NetplanNetDefinition* nd, GError** error)
+{
+ gboolean valid = FALSE;
+ /* Set a dummy, NULL yaml_node_t for error reporting */
+ yaml_node_t* node = NULL;
+
+ g_assert(nd->type != NETPLAN_DEF_TYPE_NONE);
+
+ if (nd->type == NETPLAN_DEF_TYPE_TUNNEL) {
+ valid = validate_tunnel_backend_rules(nd, node, error);
+ if (!valid)
+ goto backend_rules_error;
+ }
+
+ valid = TRUE;
+
+backend_rules_error:
+ return valid;
+}
+
+struct _defroute_entry {
+ int family;
+ int table;
+ int metric;
+ const char *netdef_id;
+};
+
+static void
+defroute_err(struct _defroute_entry *entry, const char *new_netdef_id, GError **error) {
+ char table_name[128] = {};
+ char metric_name[128] = {};
+
+ g_assert(entry->family == AF_INET || entry->family == AF_INET6);
+
+ // XXX: handle 254 as an alias for main ?
+ if (entry->table == NETPLAN_ROUTE_TABLE_UNSPEC)
+ strncpy(table_name, "table: main", sizeof(table_name) - 1);
+ else
+ snprintf(table_name, sizeof(table_name) - 1, "table: %d", entry->table);
+
+ if (entry->metric == NETPLAN_METRIC_UNSPEC)
+ strncpy(metric_name, "metric: default", sizeof(metric_name) - 1);
+ else
+ snprintf(metric_name, sizeof(metric_name) - 1, "metric: %d", entry->metric);
+
+ g_set_error(error, G_MARKUP_ERROR, G_MARKUP_ERROR_INVALID_CONTENT,
+ "Conflicting default route declarations for %s (%s, %s), first declared in %s but also in %s",
+ (entry->family == AF_INET) ? "IPv4" : "IPv6",
+ table_name,
+ metric_name,
+ entry->netdef_id,
+ new_netdef_id);
+}
+
+static gboolean
+check_defroute(struct _defroute_entry *candidate,
+ GSList **entries,
+ GError **error)
+{
+ struct _defroute_entry *entry;
+ GSList *it;
+
+ g_assert(entries != NULL);
+ it = *entries;
+
+ while (it) {
+ struct _defroute_entry *e = it->data;
+ if (e->family == candidate->family &&
+ e->table == candidate->table &&
+ e->metric == candidate->metric) {
+ defroute_err(e, candidate->netdef_id, error);
+ return FALSE;
+ }
+ it = it->next;
+ }
+ entry = g_malloc(sizeof(*entry));
+ *entry = *candidate;
+ *entries = g_slist_prepend(*entries, entry);
+ return TRUE;
+}
+
+gboolean
+validate_default_route_consistency(GHashTable *netdefs, GError ** error)
+{
+ struct _defroute_entry candidate = {};
+ GSList *defroutes = NULL;
+ gboolean ret = TRUE;
+ gpointer key, value;
+ GHashTableIter iter;
+
+ g_hash_table_iter_init (&iter, netdefs);
+ while (g_hash_table_iter_next (&iter, &key, &value))
+ {
+ NetplanNetDefinition *nd = value;
+ candidate.netdef_id = key;
+ candidate.metric = NETPLAN_METRIC_UNSPEC;
+ candidate.table = NETPLAN_ROUTE_TABLE_UNSPEC;
+ if (nd->gateway4) {
+ candidate.family = AF_INET;
+ if (!check_defroute(&candidate, &defroutes, error)) {
+ ret = FALSE;
+ break;
+ }
+ }
+ if (nd->gateway6) {
+ candidate.family = AF_INET6;
+ if (!check_defroute(&candidate, &defroutes, error)) {
+ ret = FALSE;
+ break;
+ }
+ }
+
+ if (!nd->routes)
+ continue;
+
+ for (size_t i = 0; i < nd->routes->len; i++) {
+ NetplanIPRoute* r = g_array_index(nd->routes, NetplanIPRoute*, i);
+ char *suffix = strrchr(r->to, '/');
+ if (g_strcmp0(suffix, "/0") == 0 || g_strcmp0(r->to, "default") == 0) {
+ candidate.family = r->family;
+ candidate.table = r->table;
+ candidate.metric = r->metric;
+ if (!check_defroute(&candidate, &defroutes, error)) {
+ ret = FALSE;
+ break;
+ }
+ }
+ }
+ }
+ g_slist_free_full(defroutes, g_free);
+ return ret;
+}
diff --git a/src/validation.h b/src/validation.h
new file mode 100644
index 0000000..3f6e527
--- /dev/null
+++ b/src/validation.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2019 Canonical, Ltd.
+ * Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 3.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "parse.h"
+#include <glib.h>
+
+
+gboolean is_ip4_address(const char* address);
+gboolean is_ip6_address(const char* address);
+gboolean is_hostname(const char* hostname);
+gboolean is_wireguard_key(const char* hostname);
+gboolean validate_ovs_target(gboolean host_first, gchar* s);
+
+gboolean
+validate_netdef_grammar(NetplanNetDefinition* nd, yaml_node_t* node, GError** error);
+
+gboolean
+validate_backend_rules(NetplanNetDefinition* nd, GError** error);
+
+gboolean
+validate_default_route_consistency(GHashTable* netdefs, GError** error);
diff --git a/tests/cli.py b/tests/cli.py
new file mode 100755
index 0000000..ee00ecc
--- /dev/null
+++ b/tests/cli.py
@@ -0,0 +1,692 @@
+#!/usr/bin/python3
+# Blackbox tests of netplan CLI. These are run during "make check" and don't
+# touch the system configuration at all.
+#
+# Copyright (C) 2016 Canonical, Ltd.
+# Author: Martin Pitt <martin.pitt@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import sys
+import subprocess
+import unittest
+import tempfile
+import shutil
+
+import yaml
+
+rootdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+exe_cli = [os.path.join(rootdir, 'src', 'netplan.script')]
+if shutil.which('python3-coverage'):
+ exe_cli = ['python3-coverage', 'run', '--append', '--'] + exe_cli
+
+# Make sure we can import our development netplan.
+os.environ.update({'PYTHONPATH': '.'})
+os.environ.update({'LD_LIBRARY_PATH': '.:{}'.format(os.environ.get('LD_LIBRARY_PATH'))})
+
+
+def _load_yaml(text):
+ return yaml.load(text, Loader=yaml.SafeLoader)
+
+
+class TestArgs(unittest.TestCase):
+ '''Generic argument parsing tests'''
+
+ def test_global_help(self):
+ out = subprocess.check_output(exe_cli + ['--help'])
+ self.assertIn(b'Available commands', out)
+ self.assertIn(b'generate', out)
+ self.assertIn(b'--debug', out)
+
+ def test_command_help(self):
+ out = subprocess.check_output(exe_cli + ['generate', '--help'])
+ self.assertIn(b'--root-dir', out)
+
+ def test_no_command(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ p = subprocess.Popen(exe_cli, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertEqual(out, b'')
+ self.assertIn(b'need to specify a command', err)
+ self.assertNotEqual(p.returncode, 0)
+
+
+class TestGenerate(unittest.TestCase):
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+
+ def test_no_config(self):
+ p = subprocess.Popen(exe_cli + ['generate', '--root-dir', self.workdir.name], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertEqual(out, b'')
+ self.assertEqual(os.listdir(self.workdir.name), ['run'])
+
+ def test_with_empty_config(self):
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ open(os.path.join(c, 'a.yaml'), 'w').close()
+ with open(os.path.join(c, 'b.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ enlol: {dhcp4: yes}''')
+ out = subprocess.check_output(exe_cli + ['generate', '--root-dir', self.workdir.name], stderr=subprocess.STDOUT)
+ self.assertEqual(out, b'')
+ self.assertEqual(os.listdir(os.path.join(self.workdir.name, 'run', 'systemd', 'network')),
+ ['10-netplan-enlol.network'])
+
+ def test_with_config(self):
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ enlol: {dhcp4: yes}''')
+ out = subprocess.check_output(exe_cli + ['generate', '--root-dir', self.workdir.name])
+ self.assertEqual(out, b'')
+ self.assertEqual(os.listdir(os.path.join(self.workdir.name, 'run', 'systemd', 'network')),
+ ['10-netplan-enlol.network'])
+
+ def test_mapping_for_unknown_iface(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ enlol: {dhcp4: yes}''')
+ p = subprocess.Popen(exe_cli +
+ ['generate', '--root-dir', self.workdir.name, '--mapping', 'nonexistent'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertNotEqual(p.returncode, 0)
+ self.assertNotIn(b'nonexistent', out)
+
+ def test_mapping_for_interface(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ enlol: {dhcp4: yes}''')
+ out = subprocess.check_output(exe_cli +
+ ['generate', '--root-dir', self.workdir.name, '--mapping', 'enlol'])
+ self.assertNotEqual(b'', out)
+ self.assertIn('enlol', out.decode('utf-8'))
+
+ def test_mapping_for_renamed_iface(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ myif:
+ match:
+ name: enlol
+ set-name: renamediface
+ dhcp4: yes
+''')
+ out = subprocess.check_output(exe_cli +
+ ['generate', '--root-dir', self.workdir.name, '--mapping', 'renamediface'])
+ self.assertNotEqual(b'', out)
+ self.assertIn('renamediface', out.decode('utf-8'))
+
+
+class TestIfupdownMigrate(unittest.TestCase):
+
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ self.ifaces_path = os.path.join(self.workdir.name, 'etc/network/interfaces')
+ self.converted_path = os.path.join(self.workdir.name, 'etc/netplan/10-ifupdown.yaml')
+
+ def test_system(self):
+ os.environ.update({"ENABLE_TEST_COMMANDS": "1"})
+ rc = subprocess.call(exe_cli + ['migrate', '--dry-run'],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ # may succeed or fail, but should not crash
+ self.assertIn(rc, [0, 2])
+
+ def do_test(self, iface_file, expect_success=True, dry_run=True, dropins=None):
+ os.environ.update({"ENABLE_TEST_COMMANDS": "1"})
+ if iface_file is not None:
+ os.makedirs(os.path.dirname(self.ifaces_path))
+ with open(self.ifaces_path, 'w') as f:
+ f.write(iface_file)
+ if dropins:
+ for fname, contents in dropins.items():
+ path = os.path.join(os.path.dirname(self.ifaces_path), fname)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, 'w') as f:
+ f.write(contents)
+
+ argv = exe_cli + ['--debug', 'migrate', '--root-dir', self.workdir.name]
+ if dry_run:
+ argv.append('--dry-run')
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ if expect_success:
+ self.assertEqual(p.returncode, 0, err.decode())
+ else:
+ self.assertIn(p.returncode, [2, 3], err.decode())
+ return (out, err)
+
+ #
+ # configs which can be converted
+ #
+
+ def test_no_config(self):
+ (out, err) = self.do_test(None)
+ self.assertEqual(out, b'')
+ self.assertEqual(os.listdir(self.workdir.name), [])
+
+ def test_only_empty_include(self):
+ out = self.do_test('''# default interfaces file
+source-directory /etc/network/interfaces.d''')[0]
+ self.assertFalse(os.path.exists(self.converted_path))
+ self.assertEqual(out, b'')
+
+ def test_loopback_only(self):
+ (out, err) = self.do_test('auto lo\n#ignore me\niface lo inet loopback')
+ self.assertEqual(out, b'')
+ self.assertIn(b'nothing to migrate\n', err)
+
+ def test_dhcp4(self):
+ out = self.do_test('auto en1\niface en1 inet dhcp')[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_dhcp6(self):
+ out = self.do_test('auto en1\niface en1 inet6 dhcp')[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp6': True}}}}, out.decode())
+
+ def test_dhcp4_and_6(self):
+ out = self.do_test('auto lo\niface lo inet loopback\n\n'
+ 'auto en1\niface en1 inet dhcp\niface en1 inet6 dhcp')[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True, 'dhcp6': True}}}}, out.decode())
+
+ def test_includedir_rel(self):
+ out = self.do_test('iface lo inet loopback\nauto lo\nsource-directory interfaces.d',
+ dropins={'interfaces.d/std': 'auto en1\niface en1 inet dhcp',
+ 'interfaces.d/std.bak': 'some_bogus dontreadme'})[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_includedir_abs(self):
+ out = self.do_test('iface lo inet loopback\nauto lo\nsource-directory /etc/network/defs/my',
+ dropins={'defs/my/std': 'auto en1\niface en1 inet dhcp',
+ 'defs/my/std.bak': 'some_bogus dontreadme'})[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_include_rel(self):
+ out = self.do_test('iface lo inet loopback\nauto lo\nsource interfaces.d/*.cfg',
+ dropins={'interfaces.d/std.cfg': 'auto en1\niface en1 inet dhcp',
+ 'interfaces.d/std.cfgold': 'some_bogus dontreadme'})[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_include_abs(self):
+ out = self.do_test('iface lo inet loopback\nauto lo\nsource /etc/network/*.cfg',
+ dropins={'std.cfg': 'auto en1\niface en1 inet dhcp',
+ 'std.cfgold': 'some_bogus dontreadme'})[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_allow(self):
+ out = self.do_test('allow-hotplug en1\niface en1 inet dhcp\n'
+ 'allow-auto en2\niface en2 inet dhcp')[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True},
+ 'en2': {'dhcp4': True}}}}, out.decode())
+
+ def test_no_scripts(self):
+ out = self.do_test('auto en1\niface en1 inet dhcp\nno-scripts en1')[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}}, out.decode())
+
+ def test_write_file_noconfig(self):
+ (out, err) = self.do_test('auto lo\niface lo inet loopback', dry_run=False)
+ self.assertFalse(os.path.exists(self.converted_path))
+ # should disable original ifupdown config
+ self.assertFalse(os.path.exists(self.ifaces_path))
+ self.assertTrue(os.path.exists(self.ifaces_path + '.netplan-converted'))
+
+ def test_write_file_haveconfig(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet dhcp', dry_run=False)
+ with open(self.converted_path) as f:
+ config = _load_yaml(f)
+ self.assertEqual(config, {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'dhcp4': True}}}})
+
+ # should disable original ifupdown config
+ self.assertFalse(os.path.exists(self.ifaces_path))
+ self.assertTrue(os.path.exists(self.ifaces_path + '.netplan-converted'))
+
+ def test_write_file_prev_run(self):
+ os.makedirs(os.path.dirname(self.converted_path))
+ with open(self.converted_path, 'w') as f:
+ f.write('canary')
+ (out, err) = self.do_test('auto en1\niface en1 inet dhcp', dry_run=False, expect_success=False)
+ with open(self.converted_path) as f:
+ self.assertEqual(f.read(), 'canary')
+
+ # should not disable original ifupdown config
+ self.assertTrue(os.path.exists(self.ifaces_path))
+
+ #
+ # static
+ #
+
+ def test_static_ipv4_prefix(self):
+ out = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["1.2.3.4/8"]}}}}, out.decode())
+
+ def test_static_ipv4_netmask(self):
+ out = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4\nnetmask 255.0.0.0', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["1.2.3.4/8"]}}}}, out.decode())
+
+ def test_static_ipv4_no_address(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\nnetmask 1.2.3.4', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'no address supplied', err)
+
+ def test_static_ipv4_no_network(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'does not specify prefix length, and netmask not specified', err)
+
+ def test_static_ipv4_invalid_addr(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.400/8', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "1.2.3.400" as an IPv4 address', err)
+
+ def test_static_ipv4_invalid_netmask(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4\nnetmask 123.123.123.0', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "1.2.3.4/123.123.123.0" as an IPv4 network', err)
+
+ def test_static_ipv4_invalid_prefixlen(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/42', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "1.2.3.4/42" as an IPv4 network', err)
+
+ def test_static_ipv4_unsupported_option(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/24\nmetric 1280', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'unsupported inet option "metric"', err)
+
+ def test_static_ipv4_unknown_option(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/24\nxyzzy 1280', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'unknown inet option "xyzzy"', err)
+
+ def test_static_ipv6_prefix(self):
+ out = self.do_test('auto en1\niface en1 inet6 static\naddress fc00:0123:4567:89ab:cdef::1234/64', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["fc00:123:4567:89ab:cdef::1234/64"]}}}}, out.decode())
+
+ def test_static_ipv6_netmask(self):
+ out = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234\nnetmask 64', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["fc00:123:4567:89ab:cdef::1234/64"]}}}}, out.decode())
+
+ def test_static_ipv6_no_address(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\nnetmask 64', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'no address supplied', err)
+
+ def test_static_ipv6_no_network(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'does not specify prefix length, and netmask not specified', err)
+
+ def test_static_ipv6_invalid_addr(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::12345/64', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "fc00:0123:4567:89ab:cdef::12345" as an IPv6 address', err)
+
+ def test_static_ipv6_invalid_netmask(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234\nnetmask 129', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "fc00:0123:4567:89ab:cdef::1234/129" as an IPv6 network', err)
+
+ def test_static_ipv6_invalid_prefixlen(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/129', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'error parsing "fc00:0123:4567:89ab:cdef::1234/129" as an IPv6 network', err)
+
+ def test_static_ipv6_unsupported_option(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\nmetric 1280', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'unsupported inet6 option "metric"', err)
+
+ def test_static_ipv6_unknown_option(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\nxyzzy 1280', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'unknown inet6 option "xyzzy"', err)
+
+ def test_static_ipv6_accept_ra_0(self):
+ out = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\naccept_ra 0', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["fc00:123:4567:89ab:cdef::1234/64"],
+ 'accept_ra': False}}}}, out.decode())
+
+ def test_static_ipv6_accept_ra_1(self):
+ out = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\naccept_ra 1', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["fc00:123:4567:89ab:cdef::1234/64"],
+ 'accept_ra': True}}}}, out.decode())
+
+ def test_static_ipv6_accept_ra_2(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\naccept_ra 2', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'netplan does not support accept_ra=2', err)
+
+ def test_static_ipv6_accept_ra_unexpected(self):
+ out, err = self.do_test('auto en1\niface en1 inet6 static\n'
+ 'address fc00:0123:4567:89ab:cdef::1234/64\naccept_ra fish', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'unexpected accept_ra value "fish"', err)
+
+ def test_static_gateway(self):
+ out = self.do_test("""auto en1
+iface en1 inet static
+ address 1.2.3.4
+ netmask 255.0.0.0
+ gateway 1.1.1.1
+iface en1 inet6 static
+ address fc00:0123:4567:89ab:cdef::1234/64
+ gateway fc00:0123:4567:89ab::1""", dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1':
+ {'addresses': ["1.2.3.4/8", "fc00:123:4567:89ab:cdef::1234/64"],
+ 'gateway4': "1.1.1.1",
+ 'gateway6': "fc00:0123:4567:89ab::1"}}}}, out.decode())
+
+ def test_static_dns(self):
+ out = self.do_test("""auto en1
+iface en1 inet static
+ address 1.2.3.4
+ netmask 255.0.0.0
+ dns-nameservers 1.2.1.1 1.2.2.1
+ dns-search weird.network
+iface en1 inet6 static
+ address fc00:0123:4567:89ab:cdef::1234/64
+ dns-nameservers fc00:0123:4567:89ab:1::1 fc00:0123:4567:89ab:2::1""", dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1':
+ {'addresses': ["1.2.3.4/8", "fc00:123:4567:89ab:cdef::1234/64"],
+ 'nameservers': {
+ 'search': ['weird.network'],
+ 'addresses': ['1.2.1.1', '1.2.2.1',
+ 'fc00:0123:4567:89ab:1::1', 'fc00:0123:4567:89ab:2::1']
+ }}}}}, out.decode())
+
+ def test_static_dns2(self):
+ out = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\ndns-search foo foo.bar', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["1.2.3.4/8"],
+ 'nameservers': {
+ 'search': ['foo', 'foo.bar']
+ }}}}}, out.decode())
+
+ def test_static_mtu(self):
+ out = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\nmtu 1280', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["1.2.3.4/8"],
+ 'mtu': 1280}}}}, out.decode())
+
+ def test_static_invalid_mtu(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\nmtu fish', expect_success=False)
+ self.assertEqual(b'', out)
+ self.assertIn(b'cannot parse "fish" as an MTU', err)
+
+ def test_static_two_different_mtus(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\nmtu 1280\n'
+ 'iface en1 inet6 static\naddress 2001::1/64\nmtu 9000', expect_success=False)
+ self.assertEqual(b'', out)
+ self.assertIn(b'tried to set MTU=9000, but already have MTU=1280', err)
+
+ def test_static_hwaddress(self):
+ out = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\nhwaddress 52:54:00:6b:3c:59', dry_run=True)[0]
+ self.assertEqual(_load_yaml(out), {'network': {
+ 'version': 2,
+ 'ethernets': {'en1': {'addresses': ["1.2.3.4/8"],
+ 'macaddress': '52:54:00:6b:3c:59'}}}}, out.decode())
+
+ def test_static_two_different_macs(self):
+ out, err = self.do_test('auto en1\niface en1 inet static\naddress 1.2.3.4/8\nhwaddress 52:54:00:6b:3c:59\n'
+ 'iface en1 inet6 static\naddress 2001::1/64\nhwaddress 52:54:00:6b:3c:58', expect_success=False)
+ self.assertEqual(b'', out)
+ self.assertIn(b'tried to set MAC 52:54:00:6b:3c:58, but already have MAC 52:54:00:6b:3c:59', err)
+
+ #
+ # configs which are not supported
+ #
+
+ def test_noauto(self):
+ (out, err) = self.do_test('iface en1 inet dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'non-automatic interfaces are not supported', err)
+
+ def test_dhcp_options(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet dhcp\nup myhook', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'option(s) up are not supported for dhcp method', err)
+
+ def test_mapping(self):
+ (out, err) = self.do_test('mapping en*\n script /some/path/mapscheme\nmap HOME en1-home\n\n'
+ 'auto map1\niface map1 inet dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'mapping stanza is not supported', err)
+
+ def test_unknown_allow(self):
+ (out, err) = self.do_test('allow-foo en1\niface en1 inet dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Unknown stanza type allow-foo', err)
+
+ def test_unknown_stanza(self):
+ (out, err) = self.do_test('foo en1\niface en1 inet dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Unknown stanza type foo', err)
+
+ def test_unknown_family(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet7 dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Unknown address family inet7', err)
+
+ def test_unknown_method(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet mangle', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Unsupported method mangle', err)
+
+ def test_too_few_fields(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Expected 3 fields for stanza type iface but got 2', err)
+
+ def test_too_many_fields(self):
+ (out, err) = self.do_test('auto en1\niface en1 inet dhcp foo', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'Expected 3 fields for stanza type iface but got 4', err)
+
+ def test_write_file_unsupported(self):
+ (out, err) = self.do_test('iface en1 inet dhcp', expect_success=False)
+ self.assertEqual(out, b'')
+ self.assertIn(b'non-automatic interfaces are not supported', err)
+ # should keep original ifupdown config
+ self.assertTrue(os.path.exists(self.ifaces_path))
+
+
+class TestInfo(unittest.TestCase):
+ '''Test netplan info'''
+
+ def test_info_defaults(self):
+ """
+ Check that 'netplan info' outputs at all, should include website URL
+ """
+ out = subprocess.check_output(exe_cli + ['info'])
+ self.assertIn(b'features:', out)
+
+ def test_info_yaml(self):
+ """
+ Verify that 'netplan info --yaml' output looks a bit like YAML
+ """
+ out = subprocess.check_output(exe_cli + ['info', '--yaml'])
+ self.assertIn(b'features:', out)
+
+ def test_info_json(self):
+ """
+ Verify that 'netplan info --json' output looks a bit like JSON
+ """
+ out = subprocess.check_output(exe_cli + ['info', '--json'])
+ self.assertIn(b'"features": [', out)
+
+
+class TestIp(unittest.TestCase):
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+
+ def test_valid_subcommand(self):
+ p = subprocess.Popen(exe_cli + ['ip'], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertEqual(out, b'')
+ self.assertIn(b'Available command', err)
+ self.assertNotEqual(p.returncode, 0)
+
+ def test_ip_leases_networkd(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ # match against loopback so as to successfully get a predictable
+ # ifindex
+ f.write('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enlol:
+ match:
+ name: lo
+ dhcp4: yes
+''')
+ fake_netif_lease_dir = os.path.join(self.workdir.name,
+ 'run', 'systemd', 'netif', 'leases')
+ os.makedirs(fake_netif_lease_dir)
+ with open(os.path.join(fake_netif_lease_dir, '1'), 'w') as f:
+ f.write('''THIS IS A FAKE NETIF LEASE FOR LO''')
+ out = subprocess.check_output(exe_cli +
+ ['ip', 'leases',
+ '--root-dir', self.workdir.name, 'lo'])
+ self.assertNotEqual(out, b'')
+ self.assertIn('FAKE NETIF', out.decode('utf-8'))
+
+ def test_ip_leases_nm(self):
+ unittest.skip("Cannot be tested offline due to calls required to nmcli."
+ "This is tested in integration tests.")
+
+ def test_ip_leases_no_networkd_lease(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ # match against loopback so as to successfully get a predictable
+ # ifindex
+ f.write('''network:
+ version: 2
+ ethernets:
+ enlol:
+ match:
+ name: lo
+ dhcp4: yes
+''')
+ p = subprocess.Popen(exe_cli +
+ ['ip', 'leases', '--root-dir', self.workdir.name, 'enlol'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertEqual(out, b'')
+ self.assertIn(b'No lease found', err)
+ self.assertNotEqual(p.returncode, 0)
+
+ def test_ip_leases_no_nm_lease(self):
+ os.environ['NETPLAN_GENERATE_PATH'] = os.path.join(rootdir, 'generate')
+ c = os.path.join(self.workdir.name, 'etc', 'netplan')
+ os.makedirs(c)
+ with open(os.path.join(c, 'a.yaml'), 'w') as f:
+ # match against loopback so as to successfully get a predictable
+ # ifindex
+ f.write('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enlol:
+ match:
+ name: lo
+ dhcp4: yes
+''')
+ p = subprocess.Popen(exe_cli +
+ ['ip', 'leases', '--root-dir', self.workdir.name, 'enlol'],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ (out, err) = p.communicate()
+ self.assertEqual(out, b'')
+ self.assertIn(b'No lease found', err)
+ self.assertNotEqual(p.returncode, 0)
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/dbus/__init__.py b/tests/dbus/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/dbus/__init__.py
diff --git a/tests/dbus/test_dbus.py b/tests/dbus/test_dbus.py
new file mode 100644
index 0000000..87abf22
--- /dev/null
+++ b/tests/dbus/test_dbus.py
@@ -0,0 +1,762 @@
+#
+# Copyright (C) 2019-2020 Canonical, Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+import time
+
+from tests.test_utils import MockCmd
+
+rootdir = os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__))))
+exe_cli = [os.path.join(rootdir, 'src', 'netplan.script')]
+if shutil.which('python3-coverage'):
+ exe_cli = ['python3-coverage', 'run', '--append', '--'] + exe_cli
+
+# Make sure we can import our development netplan.
+os.environ.update({'PYTHONPATH': '.'})
+NETPLAN_DBUS_CMD = os.path.join(os.path.dirname(__file__), "..", "..", "netplan-dbus")
+
+
+class TestNetplanDBus(unittest.TestCase):
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ os.makedirs(os.path.join(self.tmp, "etc", "netplan"), 0o700)
+ os.makedirs(os.path.join(self.tmp, "lib", "netplan"), 0o700)
+ os.makedirs(os.path.join(self.tmp, "run", "netplan"), 0o700)
+ # Create main test YAML in /etc/netplan/
+ test_file = os.path.join(self.tmp, 'etc', 'netplan', 'main_test.yaml')
+ with open(test_file, 'w') as f:
+ f.write("""network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true""")
+ self.addCleanup(shutil.rmtree, self.tmp)
+ self.mock_netplan_cmd = MockCmd("netplan")
+ self._create_mock_system_bus()
+ self._run_netplan_dbus_on_mock_bus()
+ self._mock_snap_env()
+ self.mock_busctl_cmd = MockCmd("busctl")
+
+ def _mock_snap_env(self):
+ os.environ["SNAP"] = "test-netplan-apply-snapd"
+
+ def _create_mock_system_bus(self):
+ env = {}
+ output = subprocess.check_output(["dbus-launch"], env={})
+ for s in output.decode("utf-8").split("\n"):
+ if s == "":
+ continue
+ k, v = s.split("=", 1)
+ env[k] = v
+ # override system bus with the fake one
+ os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = env["DBUS_SESSION_BUS_ADDRESS"]
+ self.addCleanup(os.kill, int(env["DBUS_SESSION_BUS_PID"]), 15)
+
+ def _run_netplan_dbus_on_mock_bus(self):
+ # run netplan-dbus in a fake system bus
+ os.environ["DBUS_TEST_NETPLAN_CMD"] = self.mock_netplan_cmd.path
+ os.environ["DBUS_TEST_NETPLAN_ROOT"] = self.tmp
+ p = subprocess.Popen(NETPLAN_DBUS_CMD,
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ time.sleep(1) # Give some time for our dbus daemon to be ready
+ self.addCleanup(self._cleanup_netplan_dbus, p)
+
+ def _cleanup_netplan_dbus(self, p):
+ p.terminate()
+ p.wait()
+ # netplan-dbus does not produce output
+ self.assertEqual(p.stdout.read(), b"")
+ self.assertEqual(p.stderr.read(), b"")
+
+ def _check_dbus_error(self, cmd, returncode=1):
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ p.wait()
+ self.assertEqual(p.returncode, returncode)
+ self.assertEqual(p.stdout.read().decode("utf-8"), "")
+ return p.stderr.read().decode("utf-8")
+
+ def _new_config_object(self):
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "Config",
+ ]
+ # Create new config object / config state
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertIn(b'o "/io/netplan/Netplan/config/', out)
+ cid = out.decode('utf-8').split('/')[-1].replace('"\n', '')
+ # Verify that the state folders were created in /tmp
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ self.assertTrue(os.path.isdir(tmpdir))
+ self.assertTrue(os.path.isdir(os.path.join(tmpdir, 'etc', 'netplan')))
+ self.assertTrue(os.path.isdir(os.path.join(tmpdir, 'run', 'netplan')))
+ self.assertTrue(os.path.isdir(os.path.join(tmpdir, 'lib', 'netplan')))
+ # Return random config ID
+ return cid
+
+ def test_netplan_apply_in_snap_uses_dbus(self):
+ p = subprocess.Popen(
+ exe_cli + ["apply"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ self.assertEqual(p.stdout.read(), b"")
+ self.assertEqual(p.stderr.read(), b"")
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "apply"],
+ ])
+
+ def test_netplan_apply_in_snap_calls_busctl(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ p = subprocess.Popen(
+ exe_cli + ["apply"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ self.assertEqual(p.stdout.read(), b"")
+ self.assertEqual(p.stderr.read(), b"")
+ self.assertEquals(self.mock_busctl_cmd.calls(), [
+ ["busctl", "call", "--quiet", "--system",
+ "io.netplan.Netplan", # the service
+ "/io/netplan/Netplan", # the object
+ "io.netplan.Netplan", # the interface
+ "Apply", # the method
+ ],
+ ])
+
+ def test_netplan_apply_in_snap_calls_busctl_ret130(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ self.mock_busctl_cmd.set_returncode(130)
+ p = subprocess.Popen(
+ exe_cli + ["apply"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ # exit_on_error is True by default, so we check the returncode directly
+ self.assertEqual(p.returncode, 130)
+
+ def test_netplan_apply_in_snap_calls_busctl_err(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ self.mock_busctl_cmd.set_returncode(1)
+ p = subprocess.Popen(
+ exe_cli + ["apply"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ # exit_on_error is True by default, so we check the returncode directly
+ self.assertEqual(p.returncode, 1)
+
+ def test_netplan_generate_in_snap_calls_busctl(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ p = subprocess.Popen(
+ exe_cli + ["generate"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ self.assertEqual(p.stdout.read(), b"")
+ self.assertEqual(p.stderr.read(), b"")
+ self.assertEquals(self.mock_busctl_cmd.calls(), [
+ ["busctl", "call", "--quiet", "--system",
+ "io.netplan.Netplan", # the service
+ "/io/netplan/Netplan", # the object
+ "io.netplan.Netplan", # the interface
+ "Generate", # the method
+ ],
+ ])
+
+ def test_netplan_generate_in_snap_calls_busctl_ret130(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ self.mock_busctl_cmd.set_returncode(130)
+ p = subprocess.Popen(
+ exe_cli + ["generate"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ self.assertIn(b"PermissionError: failed to communicate with dbus service", p.stderr.read())
+
+ def test_netplan_generate_in_snap_calls_busctl_err(self):
+ newenv = os.environ.copy()
+ busctlDir = os.path.dirname(self.mock_busctl_cmd.path)
+ newenv["PATH"] = busctlDir+":"+os.environ["PATH"]
+ self.mock_busctl_cmd.set_returncode(1)
+ p = subprocess.Popen(
+ exe_cli + ["generate"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ env=newenv)
+ p.wait(10)
+ self.assertIn(b"RuntimeError: failed to communicate with dbus service: error 1", p.stderr.read())
+
+ def test_netplan_dbus_noroot(self):
+ # Process should fail instantly, if not: kill it after 5 sec
+ r = subprocess.run(NETPLAN_DBUS_CMD, timeout=5, capture_output=True)
+ self.assertEquals(r.returncode, 1)
+ self.assertIn(b'Failed to acquire service name', r.stderr)
+
+ def test_netplan_dbus_happy(self):
+ BUSCTL_NETPLAN_APPLY = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "Apply",
+ ]
+ output = subprocess.check_output(BUSCTL_NETPLAN_APPLY)
+ self.assertEqual(output.decode("utf-8"), "b true\n")
+ # one call to netplan apply in total
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "apply"],
+ ])
+
+ # and again!
+ output = subprocess.check_output(BUSCTL_NETPLAN_APPLY)
+ self.assertEqual(output.decode("utf-8"), "b true\n")
+ # and another call to netplan apply
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "apply"],
+ ["netplan", "apply"],
+ ])
+
+ def test_netplan_dbus_generate(self):
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "Generate",
+ ]
+ output = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(output.decode("utf-8"), "b true\n")
+ # one call to netplan apply in total
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "generate"],
+ ])
+
+ def test_netplan_dbus_info(self):
+ BUSCTL_NETPLAN_INFO = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "Info",
+ ]
+ output = subprocess.check_output(BUSCTL_NETPLAN_INFO)
+ self.assertIn("Features", output.decode("utf-8"))
+
+ def test_netplan_dbus_config(self):
+ # Create test YAML
+ test_file_lib = os.path.join(self.tmp, 'lib', 'netplan', 'lib_test.yaml')
+ with open(test_file_lib, 'w') as f:
+ f.write('TESTING-lib')
+ test_file_run = os.path.join(self.tmp, 'run', 'netplan', 'run_test.yaml')
+ with open(test_file_run, 'w') as f:
+ f.write('TESTING-run')
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'main_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'lib', 'netplan', 'lib_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'run', 'netplan', 'run_test.yaml')))
+
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ self.addClassCleanup(shutil.rmtree, tmpdir)
+
+ # Verify the object path has been created, by calling .Config.Get() on that object
+ # it would throw an error if it does not exist
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Get",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD, universal_newlines=True)
+ self.assertIn(r's ""', out) # No output as 'netplan get' is actually mocked
+ self.assertEquals(self.mock_netplan_cmd.calls(), [[
+ "netplan", "get", "all", "--root-dir={}".format(tmpdir)
+ ]])
+
+ # Verify all *.yaml files have been copied
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'etc', 'netplan', 'main_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'lib', 'netplan', 'lib_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'run', 'netplan', 'run_test.yaml')))
+
+ def test_netplan_dbus_no_such_command(self):
+ err = self._check_dbus_error([
+ "busctl", "call",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "NoSuchCommand"
+ ])
+ self.assertIn("Unknown method", err)
+
+ def test_netplan_dbus_config_set(self):
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ self.addCleanup(shutil.rmtree, tmpdir)
+
+ # Verify .Config.Set() on the config object
+ # No actual YAML file will be created, as the netplan command is mocked
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth42.dhcp6=true", "",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+ print(self.mock_netplan_cmd.calls(), flush=True)
+ self.assertEquals(self.mock_netplan_cmd.calls(), [[
+ "netplan", "set", "ethernets.eth42.dhcp6=true",
+ "--root-dir={}".format(tmpdir)
+ ]])
+
+ def test_netplan_dbus_config_get(self):
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ self.addCleanup(shutil.rmtree, tmpdir)
+
+ # Verify .Config.Get() on the config object
+ self.mock_netplan_cmd.set_output("network:\n eth42:\n dhcp6: true")
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Get",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD, universal_newlines=True)
+ self.assertIn(r's "network:\n eth42:\n dhcp6: true\n"', out)
+ self.assertEquals(self.mock_netplan_cmd.calls(), [[
+ "netplan", "get", "all", "--root-dir={}".format(tmpdir)
+ ]])
+
+ def test_netplan_dbus_config_cancel(self):
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+
+ # Verify .Config.Cancel() teardown of the config object and state dirs
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Cancel",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ time.sleep(1) # Give some time for 'Cancel' to clean up
+ self.assertFalse(os.path.isdir(tmpdir))
+
+ # Verify the object is gone from the bus
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD)
+ self.assertIn('Unknown object \'/io/netplan/Netplan/config/{}\''.format(cid), err)
+
+ def test_netplan_dbus_config_apply(self):
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ with open(os.path.join(tmpdir, 'etc', 'netplan', 'apply_test.yaml'), 'w') as f:
+ f.write('TESTING-apply')
+ with open(os.path.join(tmpdir, 'lib', 'netplan', 'apply_test.yaml'), 'w') as f:
+ f.write('TESTING-apply')
+ with open(os.path.join(tmpdir, 'run', 'netplan', 'apply_test.yaml'), 'w') as f:
+ f.write('TESTING-apply')
+
+ # Verify .Config.Apply() teardown of the config object and state dirs
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Apply",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+ self.assertEquals(self.mock_netplan_cmd.calls(), [["netplan", "apply"]])
+ time.sleep(1) # Give some time for 'Apply' to clean up
+ self.assertFalse(os.path.isdir(tmpdir))
+
+ # Verify the new YAML files were copied over
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'apply_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'run', 'netplan', 'apply_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'lib', 'netplan', 'apply_test.yaml')))
+
+ # Verify the object is gone from the bus
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD)
+ self.assertIn('Unknown object \'/io/netplan/Netplan/config/{}\''.format(cid), err)
+
+ def test_netplan_dbus_config_try_cancel(self):
+ # self-terminate after 30 dsec = 3 sec, if not cancelled before
+ self.mock_netplan_cmd.set_timeout(30)
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ backup = '/tmp/netplan-config-BACKUP'
+ with open(os.path.join(tmpdir, 'etc', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+ with open(os.path.join(tmpdir, 'lib', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+ with open(os.path.join(tmpdir, 'run', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+
+ # Verify .Config.Try() setup of the config object and state dirs
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "3",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ # Verify the temp state still exists
+ self.assertTrue(os.path.isdir(tmpdir))
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'etc', 'netplan', 'try_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'run', 'netplan', 'try_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(tmpdir, 'lib', 'netplan', 'try_test.yaml')))
+
+ # Verify the backup has been created
+ self.assertTrue(os.path.isdir(backup))
+ self.assertTrue(os.path.isfile(os.path.join(backup, 'etc', 'netplan', 'main_test.yaml')))
+
+ # Verify the new YAML files were copied over
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'try_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'run', 'netplan', 'try_test.yaml')))
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'lib', 'netplan', 'try_test.yaml')))
+
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Cancel",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD2)
+ self.assertEqual(b'b true\n', out)
+ time.sleep(1) # Give some time for 'Cancel' to clean up
+
+ # Verify the backup andconfig state dir are gone
+ self.assertFalse(os.path.isdir(backup))
+ self.assertFalse(os.path.isdir(tmpdir))
+
+ # Verify the backup has been restored
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'main_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'try_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'run', 'netplan', 'try_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'lib', 'netplan', 'try_test.yaml')))
+
+ # Verify the config object is gone from the bus
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('Unknown object \'/io/netplan/Netplan/config/{}\''.format(cid), err)
+
+ # Verify 'netplan try' has been called
+ self.assertEquals(self.mock_netplan_cmd.calls(), [["netplan", "try", "--timeout=3"]])
+
+ def test_netplan_dbus_config_try_cb(self):
+ self.mock_netplan_cmd.set_timeout(1) # actually self-terminate after 0.1 sec
+ cid = self._new_config_object()
+ tmpdir = '/tmp/netplan-config-{}'.format(cid)
+ backup = '/tmp/netplan-config-BACKUP'
+ with open(os.path.join(tmpdir, 'etc', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+ with open(os.path.join(tmpdir, 'lib', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+ with open(os.path.join(tmpdir, 'run', 'netplan', 'try_test.yaml'), 'w') as f:
+ f.write('TESTING-try')
+
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "1",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+ time.sleep(1.5) # Give some time for the timeout to happen
+
+ # Verify the backup andconfig state dir are gone
+ self.assertFalse(os.path.isdir(backup))
+ self.assertFalse(os.path.isdir(tmpdir))
+
+ # Verify the backup has been restored
+ self.assertTrue(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'main_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'etc', 'netplan', 'try_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'run', 'netplan', 'try_test.yaml')))
+ self.assertFalse(os.path.isfile(os.path.join(self.tmp, 'lib', 'netplan', 'try_test.yaml')))
+
+ # Verify the config object is gone from the bus
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD)
+ self.assertIn('Unknown object \'/io/netplan/Netplan/config/{}\''.format(cid), err)
+
+ # Verify 'netplan try' has been called
+ self.assertEquals(self.mock_netplan_cmd.calls(), [["netplan", "try", "--timeout=1"]])
+
+ def test_netplan_dbus_config_try_apply(self):
+ self.mock_netplan_cmd.set_timeout(30) # 30 dsec = 3 sec
+ cid = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "3",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan",
+ "io.netplan.Netplan",
+ "Apply",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('Another \'netplan try\' process is already running', err)
+
+ def test_netplan_dbus_config_try_config_try(self):
+ self.mock_netplan_cmd.set_timeout(50) # 50 dsec = 5 sec
+ cid = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "3",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ cid2 = self._new_config_object()
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "5",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('Another Try() is currently in progress: PID ', err)
+
+ def test_netplan_dbus_config_set_invalidate(self):
+ self.mock_netplan_cmd.set_timeout(30) # 30 dsec = 3 sec
+ cid = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=true", "70-snapd",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+ # Calling Set() on the same config object still works
+ BUSCTL_NETPLAN_CMD1 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=yes", "70-snapd",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD1)
+ self.assertEqual(b'b true\n', out)
+
+ cid2 = self._new_config_object()
+ # Calling Set() on another config object fails
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=false", "70-snapd",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('This config was invalidated by another config object', err)
+ # Calling Try() on another config object fails
+ BUSCTL_NETPLAN_CMD3 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "3",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD3)
+ self.assertIn('This config was invalidated by another config object', err)
+ # Calling Apply() on another config object fails
+ BUSCTL_NETPLAN_CMD4 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Apply",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD4)
+ self.assertIn('This config was invalidated by another config object', err)
+
+ # Calling Apply() on the same config object still works
+ BUSCTL_NETPLAN_CMD5 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Apply",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD5)
+ self.assertEqual(b'b true\n', out)
+
+ # Verify that Set()/Apply() was only called by one config object
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "set", "ethernets.eth0.dhcp4=true", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid)],
+ ["netplan", "set", "ethernets.eth0.dhcp4=yes", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid)],
+ ["netplan", "apply"]
+ ])
+
+ # Now it works again
+ cid3 = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid3),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=false", "70-snapd",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid3),
+ "io.netplan.Netplan.Config",
+ "Apply",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ def test_netplan_dbus_config_set_uninvalidate(self):
+ self.mock_netplan_cmd.set_timeout(2)
+ cid = self._new_config_object()
+ cid2 = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=true", "70-snapd",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ # Calling Set() on another config object fails
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=false", "70-snapd",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('This config was invalidated by another config object', err)
+
+ # Calling Cancel() clears the dirty state
+ BUSCTL_NETPLAN_CMD3 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Cancel",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD3)
+ self.assertEqual(b'b true\n', out)
+
+ # Calling Set() on the other config object works now
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD2)
+ self.assertEqual(b'b true\n', out)
+
+ # Verify the call stack
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "set", "ethernets.eth0.dhcp4=true", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid)],
+ ["netplan", "set", "ethernets.eth0.dhcp4=false", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid2)]
+ ])
+
+ def test_netplan_dbus_config_set_uninvalidate_timeout(self):
+ self.mock_netplan_cmd.set_timeout(1) # actually self-terminate process after 0.1 sec
+ cid = self._new_config_object()
+ cid2 = self._new_config_object()
+ BUSCTL_NETPLAN_CMD = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=true", "70-snapd",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD)
+ self.assertEqual(b'b true\n', out)
+
+ BUSCTL_NETPLAN_CMD1 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid),
+ "io.netplan.Netplan.Config",
+ "Try", "u", "1",
+ ]
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD1)
+ self.assertEqual(b'b true\n', out)
+
+ # Calling Set() on another config object fails
+ BUSCTL_NETPLAN_CMD2 = [
+ "busctl", "call", "--system",
+ "io.netplan.Netplan",
+ "/io/netplan/Netplan/config/{}".format(cid2),
+ "io.netplan.Netplan.Config",
+ "Set", "ss", "ethernets.eth0.dhcp4=false", "70-snapd",
+ ]
+ err = self._check_dbus_error(BUSCTL_NETPLAN_CMD2)
+ self.assertIn('This config was invalidated by another config object', err)
+
+ time.sleep(1.5) # Wait for the child process to self-terminate
+
+ # Calling Set() on the other config object works now
+ out = subprocess.check_output(BUSCTL_NETPLAN_CMD2)
+ self.assertEqual(b'b true\n', out)
+
+ # Verify the call stack
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "set", "ethernets.eth0.dhcp4=true", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid)],
+ ["netplan", "try", "--timeout=1"],
+ ["netplan", "set", "ethernets.eth0.dhcp4=false", "--origin-hint=70-snapd",
+ "--root-dir=/tmp/netplan-config-{}".format(cid2)]
+ ])
diff --git a/tests/generator/__init__.py b/tests/generator/__init__.py
new file mode 100644
index 0000000..81eadaa
--- /dev/null
+++ b/tests/generator/__init__.py
@@ -0,0 +1,17 @@
+#
+# __init__ for generator tests.
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/tests/generator/base.py b/tests/generator/base.py
new file mode 100644
index 0000000..d72974b
--- /dev/null
+++ b/tests/generator/base.py
@@ -0,0 +1,446 @@
+#
+# Blackbox tests of netplan generate that verify that the generated
+# configuration files look as expected. These are run during "make check" and
+# don't touch the system configuration at all.
+#
+# Copyright (C) 2016-2021 Canonical, Ltd.
+# Author: Martin Pitt <martin.pitt@ubuntu.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import random
+import glob
+import stat
+import string
+import tempfile
+import subprocess
+import unittest
+import ctypes
+import ctypes.util
+import yaml
+import difflib
+
+exe_generate = os.path.join(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))), 'generate')
+
+# make sure we point to libnetplan properly.
+os.environ.update({'LD_LIBRARY_PATH': '.:{}'.format(os.environ.get('LD_LIBRARY_PATH'))})
+
+# make sure we fail on criticals
+os.environ['G_DEBUG'] = 'fatal-criticals'
+
+lib = ctypes.CDLL(ctypes.util.find_library('netplan'))
+
+# common patterns for expected output
+ND_EMPTY = '[Match]\nName=%s\n\n[Network]\nLinkLocalAddressing=%s\nConfigureWithoutCarrier=yes\n'
+ND_WITHIP = '[Match]\nName=%s\n\n[Network]\nLinkLocalAddressing=ipv6\nAddress=%s\nConfigureWithoutCarrier=yes\n'
+ND_WIFI_DHCP4 = '[Match]\nName=%s\n\n[Network]\nDHCP=ipv4\nLinkLocalAddressing=ipv6\n\n[DHCP]\nRouteMetric=600\nUseMTU=true\n'
+ND_DHCP = '[Match]\nName=%s\n\n[Network]\nDHCP=%s\nLinkLocalAddressing=ipv6%s\n\n[DHCP]\nRouteMetric=100\nUseMTU=%s\n'
+ND_DHCP4 = ND_DHCP % ('%s', 'ipv4', '', 'true')
+ND_DHCP4_NOMTU = ND_DHCP % ('%s', 'ipv4', '', 'false')
+ND_DHCP6 = ND_DHCP % ('%s', 'ipv6', '', 'true')
+ND_DHCP6_NOMTU = ND_DHCP % ('%s', 'ipv6', '', 'false')
+ND_DHCP6_WOCARRIER = ND_DHCP % ('%s', 'ipv6', '\nConfigureWithoutCarrier=yes', 'true')
+ND_DHCPYES = ND_DHCP % ('%s', 'yes', '', 'true')
+ND_DHCPYES_NOMTU = ND_DHCP % ('%s', 'yes', '', 'false')
+_OVS_BASE = '[Unit]\nDescription=OpenVSwitch configuration for %(iface)s\nDefaultDependencies=no\n\
+Wants=ovsdb-server.service\nAfter=ovsdb-server.service\n'
+OVS_PHYSICAL = _OVS_BASE + 'Requires=sys-subsystem-net-devices-%(iface)s.device\nAfter=sys-subsystem-net-devices-%(iface)s\
+.device\nAfter=netplan-ovs-cleanup.service\nBefore=network.target\nWants=network.target\n%(extra)s'
+OVS_VIRTUAL = _OVS_BASE + 'After=netplan-ovs-cleanup.service\nBefore=network.target\nWants=network.target\n%(extra)s'
+OVS_BR_DEFAULT = 'ExecStart=/usr/bin/ovs-vsctl set Bridge %(iface)s external-ids:netplan=true\nExecStart=/usr/bin/ovs-vsctl \
+set-fail-mode %(iface)s standalone\nExecStart=/usr/bin/ovs-vsctl set Bridge %(iface)s external-ids:netplan/global/set-fail-mode=\
+standalone\nExecStart=/usr/bin/ovs-vsctl set Bridge %(iface)s mcast_snooping_enable=false\nExecStart=/usr/bin/ovs-vsctl set \
+Bridge %(iface)s external-ids:netplan/mcast_snooping_enable=false\nExecStart=/usr/bin/ovs-vsctl set Bridge %(iface)s \
+rstp_enable=false\nExecStart=/usr/bin/ovs-vsctl set Bridge %(iface)s external-ids:netplan/rstp_enable=false\n'
+OVS_BR_EMPTY = _OVS_BASE + 'After=netplan-ovs-cleanup.service\nBefore=network.target\nWants=network.target\n\n[Service]\n\
+Type=oneshot\nExecStart=/usr/bin/ovs-vsctl --may-exist add-br %(iface)s\n' + OVS_BR_DEFAULT
+OVS_CLEANUP = _OVS_BASE + 'ConditionFileIsExecutable=/usr/bin/ovs-vsctl\nBefore=network.target\nWants=network.target\n\n\
+[Service]\nType=oneshot\nExecStart=/usr/sbin/netplan apply --only-ovs-cleanup\n'
+UDEV_MAC_RULE = 'SUBSYSTEM=="net", ACTION=="add", DRIVERS=="%s", ATTR{address}=="%s", NAME="%s"\n'
+UDEV_NO_MAC_RULE = 'SUBSYSTEM=="net", ACTION=="add", DRIVERS=="%s", NAME="%s"\n'
+UDEV_SRIOV_RULE = 'ACTION=="add", SUBSYSTEM=="net", ATTRS{sriov_totalvfs}=="?*", RUN+="/usr/sbin/netplan apply --sriov-only"\n'
+ND_WITHIPGW = '[Match]\nName=%s\n\n[Network]\nLinkLocalAddressing=ipv6\nAddress=%s\nAddress=%s\nGateway=%s\n\
+ConfigureWithoutCarrier=yes\n'
+NM_WG = '[connection]\nid=netplan-wg0\ntype=wireguard\ninterface-name=wg0\n\n[wireguard]\nprivate-key=%s\nlisten-port=%s\n%s\
+\n\n[ipv4]\nmethod=manual\naddress1=15.15.15.15/24\ngateway=20.20.20.21\n\n[ipv6]\nmethod=manual\naddress1=\
+2001:de:ad:be:ef:ca:fe:1/128\n'
+ND_WG = '[NetDev]\nName=wg0\nKind=wireguard\n\n[WireGuard]\nPrivateKey%s\nListenPort=%s\n%s\n'
+ND_VLAN = '[NetDev]\nName=%s\nKind=vlan\n\n[VLAN]\nId=%d\n'
+
+
+class NetplanV2Normalizer():
+
+ def __init__(self):
+ self.YAML_FALSE = ['n', 'no', 'off', 'false']
+ self.YAML_TRUE = ['y', 'yes', 'on', 'true']
+ self.DEFAULT_STANZAS = [
+ 'dhcp4-overrides: {}', # 2nd level default (containing defaults itself)
+ 'dhcp6-overrides: {}', # 2nd level default (containing defaults itself)
+ 'hidden: false', # access-point
+ 'on-link: false', # route
+ 'stp: true', # paramters
+ 'type: unicast', # route
+ 'version: 2', # global
+ ]
+ self.DEFAULT_NETDEF = {
+ 'dhcp4': self.YAML_FALSE,
+ 'dhcp6': self.YAML_FALSE,
+ 'dhcp-identifier': ['duid'],
+ 'hidden': self.YAML_FALSE,
+ }
+ self.DEFAULT_DHCP = {
+ 'send-hostname': self.YAML_TRUE,
+ 'use-dns': self.YAML_TRUE,
+ 'use-hostname': self.YAML_TRUE,
+ 'use-mtu': self.YAML_TRUE,
+ 'use-ntp': self.YAML_TRUE,
+ 'use-routes': self.YAML_TRUE,
+ }
+
+ def _clear_mapping_defaults(self, keys, defaults, data):
+ potential_defaults = list(set(keys) & set(defaults.keys()))
+ for k in potential_defaults:
+ if any(map(str(data[k]).lower().__eq__, defaults[k])):
+ del data[k]
+
+ def normalize_yaml_line(self, line):
+ '''Process formatted YAML line by line (one setting/key per line)
+
+ Deleting default values and re-writing to default wording
+ '''
+ kv = line.replace('"', '').replace('\'', '').split(':', 1)
+ if len(kv) != 2 or kv[1].isspace() or kv[1] == '':
+ return line # no normalization needed; no value given
+
+ # normalize key
+ key = kv[0]
+ if 'gratuitious-arp' in key: # historically supported typo
+ kv[0] = key.replace('gratuitious-arp', 'gratuitous-arp')
+
+ # normalize value
+ val = kv[1].strip()
+ if val in self.YAML_FALSE:
+ kv[1] = 'false'
+ elif val in self.YAML_TRUE:
+ kv[1] = 'true'
+ elif val == '5G':
+ kv[1] = '5GHz'
+ elif val == '2.4G':
+ kv[1] = '2.4GHz'
+ else: # no normalization needed or known
+ kv[1] = val
+
+ return ': '.join(kv)
+
+ def normalize_yaml_tree(self, data, full_key=''):
+ '''Walk the YAML dict/tree @data and sort its sequences in place
+
+ Keeping track of the @full_key (path), e.g.: "network:ethernets:eth0:dhcp4"
+ And normalizing certain netplan special cases
+ '''
+ if isinstance(data, list):
+ scalars_only = not any(list(map(lambda elem: (isinstance(elem, dict) or isinstance(elem, list)), data)))
+ # sort sequence alphabetically
+ if scalars_only:
+ data.sort()
+ # remove duplicates (if needed)
+ unique = set(data)
+ if len(data) > len(unique):
+ rm_idx = set()
+ last_idx = 0
+ for elem in unique:
+ if data.count(elem) > 1:
+ idx = data.index(elem, last_idx)
+ rm_idx.add(idx)
+ last_idx = idx
+ for idx in rm_idx:
+ del data[idx]
+ elif isinstance(data, dict):
+ keys = data.keys()
+ # expand special short forms
+ if 'password' in keys and ':auth' not in full_key:
+ data['auth'] = {'key-management': 'psk', 'password': data['password']}
+ del data['password']
+ elif 'auth' in keys and data['auth'] == {}:
+ data['auth'] = {'key-management': 'none'}
+ # remove default stanza ("link-local: [ ipv6 ]"")
+ elif 'link-local' in keys and data['link-local'] == ['ipv6']:
+ del data['link-local']
+ # remove default stanza ("wakeonwlan: [ default ]")
+ elif 'wakeonwlan' in keys and data['wakeonwlan'] == ['default']:
+ del data['wakeonwlan']
+ # remove explicit openvswitch stanzas, they might not always be
+ # defined in the original YAML (due to being implicit)
+ elif ('openvswitch' in keys and data['openvswitch'] == {} and
+ any(map(full_key.__contains__, [':bonds:', ':bridges:', ':vlans:']))):
+ del data['openvswitch']
+ # remove default empty bond-parameters, those are not rendered by the YAML generator
+ elif 'parameters' in keys and data['parameters'] == {} and ':bonds:' in full_key:
+ del data['parameters']
+ # remove default mode=infrastructore from wifi APs, keeping the SSID
+ elif 'mode' in keys and ':wifis:' in full_key and 'infrastructure' in data['mode']:
+ del data['mode']
+ # ignore renderer: on other than global levels for now, as that
+ # information is currently not stored in the netdef data structure
+ elif ('renderer' in keys and len(full_key.split(':')) > 1 and
+ data['renderer'] in ['networkd', 'NetworkManager']):
+ del data['renderer']
+ # remove default values from the dhcp4/6-overrides mappings
+ elif full_key.endswith(':dhcp4-overrides') or full_key.endswith(':dhcp6-overrides'):
+ self._clear_mapping_defaults(keys, self.DEFAULT_DHCP, data)
+ # remove default values from netdef/interface mappings
+ elif len(full_key.split(':')) == 3: # netdef level
+ self._clear_mapping_defaults(keys, self.DEFAULT_NETDEF, data)
+
+ # continue to walk the dict
+ for key in data.keys():
+ full_key_next = ':'.join([str(full_key), str(key)]) if full_key != '' else key
+ self.normalize_yaml_tree(data[key], full_key_next)
+
+ def normalize_yaml(self, yaml_dict):
+ # 1st pass: normalize the YAML tree in place, sorting and removing some values
+ self.normalize_yaml_tree(yaml_dict)
+ # 2nd pass: sort the mapping keys and output a formatted yaml (one key per line)
+ formatted_yaml = yaml.dump(yaml_dict, sort_keys=True)
+ # 3rd pass: normalize the wording of certain keys/values per line
+ # and remove any line, containg only default values
+ output = []
+ for line in formatted_yaml.splitlines():
+ line = self.normalize_yaml_line(line)
+ if line.strip() in self.DEFAULT_STANZAS:
+ continue
+ output.append(line)
+ return output
+
+
+class TestBase(unittest.TestCase):
+
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ self.confdir = os.path.join(self.workdir.name, 'etc', 'netplan')
+ self.nm_enable_all_conf = os.path.join(
+ self.workdir.name, 'run', 'NetworkManager', 'conf.d', '10-globally-managed-devices.conf')
+ self.maxDiff = None
+
+ def validate_generated_yaml(self, yaml_input):
+ '''Validate a list of YAML input files one by one.
+
+ Go through the list @yaml_input one by one, parse the YAML and
+ re-generate the YAML output. Afterwards, normalize and compare the
+ original (and normalized) input with the generated (and normalized)
+ output.
+ '''
+ output = '_generated_test_output.yaml'
+ output_path = os.path.join(self.confdir, output)
+
+ for input in yaml_input:
+ lib.netplan_clear_netdefs() # clear previous netdefs
+ lib.netplan_parse_yaml(input.encode(), None)
+ lib.write_netplan_conf_full(output.encode(), self.workdir.name.encode())
+
+ input_yaml = None
+ output_yaml = None
+
+ # Read input YAML file, as defined by the self.generate('...') method
+ with open(input, 'r') as orig:
+ input_yaml = yaml.safe_load(orig.read())
+ # Consider 'network: {}' and 'network: {version: 2}' to be empty
+ if input_yaml is None or input_yaml == {'network': {}} or input_yaml == {'network': {'version': 2}}:
+ input_yaml = yaml.safe_load('')
+
+ # Read output of the YAML generator (if any)
+ if os.path.isfile(output_path):
+ with open(output_path, 'r') as generated:
+ output_yaml = yaml.safe_load(generated.read())
+ else:
+ output_yaml = yaml.safe_load('')
+
+ # Normalize input and output YAML
+ netplan_normalizer = NetplanV2Normalizer()
+ input_lines = netplan_normalizer.normalize_yaml(input_yaml)
+ output_lines = netplan_normalizer.normalize_yaml(output_yaml)
+
+ # Check if (normalized) input and (normalized) output are equal
+ yaml_files_differ = len(input_lines) != len(output_lines)
+ if not yaml_files_differ: # pragma: no cover (only execited in error case)
+ for i in range(len(input_lines)):
+ if input_lines[i] != output_lines[i]:
+ yaml_files_differ = True
+ break
+ if yaml_files_differ: # pragma: no cover (only execited in error case)
+ fromfile = 'original (%s)' % input
+ for line in difflib.unified_diff(input_lines, output_lines, fromfile, tofile='generated', lineterm=''):
+ print(line, flush=True)
+ self.fail('Re-generated YAML file does not match (adopt netplan.c YAML generator?)')
+
+ # Cleanup the generated file and data structures
+ lib.netplan_clear_netdefs()
+ if os.path.isfile(output_path):
+ os.remove(output_path)
+
+ def generate(self, yaml, expect_fail=False, extra_args=[], confs=None, skip_generated_yaml_validation=False):
+ '''Call generate with given YAML string as configuration
+
+ Return stderr output.
+ '''
+ yaml_input = []
+ conf = os.path.join(self.confdir, 'a.yaml')
+ os.makedirs(os.path.dirname(conf), exist_ok=True)
+ if yaml is not None:
+ with open(conf, 'w') as f:
+ f.write(yaml)
+ yaml_input.append(conf)
+ if confs:
+ for f, contents in confs.items():
+ path = os.path.join(self.confdir, f + '.yaml')
+ with open(path, 'w') as f:
+ f.write(contents)
+ yaml_input.append(path)
+
+ argv = [exe_generate, '--root-dir', self.workdir.name] + extra_args
+ if 'TEST_SHELL' in os.environ: # pragma nocover
+ print('Test is about to run:\n%s' % ' '.join(argv))
+ subprocess.call(['bash', '-i'], cwd=self.workdir.name)
+
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True)
+ (out, err) = p.communicate()
+ if expect_fail:
+ self.assertGreater(p.returncode, 0)
+ else:
+ self.assertEqual(p.returncode, 0, err)
+ self.assertEqual(out, '')
+ if not expect_fail and not skip_generated_yaml_validation:
+ yaml_input = list(set(yaml_input + extra_args))
+ yaml_input.sort()
+ self.validate_generated_yaml(yaml_input)
+ return err
+
+ def eth_name(self):
+ """Return a link name.
+
+ Use when you need a link name for a test but don't want to
+ encode a made up name in the test.
+ """
+ return 'eth' + ''.join(random.sample(string.ascii_letters + string.digits, k=4))
+
+ def assert_networkd(self, file_contents_map):
+ networkd_dir = os.path.join(self.workdir.name, 'run', 'systemd', 'network')
+ if not file_contents_map:
+ self.assertFalse(os.path.exists(networkd_dir))
+ return
+
+ self.assertEqual(set(os.listdir(self.workdir.name)) - {'lib'}, {'etc', 'run'})
+ self.assertEqual(set(os.listdir(networkd_dir)),
+ {'10-netplan-' + f for f in file_contents_map})
+ for fname, contents in file_contents_map.items():
+ with open(os.path.join(networkd_dir, '10-netplan-' + fname)) as f:
+ self.assertEqual(f.read(), contents)
+
+ def assert_additional_udev(self, file_contents_map):
+ udev_dir = os.path.join(self.workdir.name, 'run', 'udev', 'rules.d')
+ for fname, contents in file_contents_map.items():
+ with open(os.path.join(udev_dir, fname)) as f:
+ self.assertEqual(f.read(), contents)
+
+ def assert_networkd_udev(self, file_contents_map):
+ udev_dir = os.path.join(self.workdir.name, 'run', 'udev', 'rules.d')
+ if not file_contents_map:
+ # it can either not exist, or can only contain 90-netplan.rules
+ self.assertTrue((not os.path.exists(udev_dir)) or
+ (os.listdir(udev_dir) == ['90-netplan.rules']))
+ return
+
+ self.assertEqual(set(os.listdir(udev_dir)) - set(['90-netplan.rules']),
+ {'99-netplan-' + f for f in file_contents_map})
+ for fname, contents in file_contents_map.items():
+ with open(os.path.join(udev_dir, '99-netplan-' + fname)) as f:
+ self.assertEqual(f.read(), contents)
+
+ def get_network_config_for_link(self, link_name):
+ """Return the content of the .network file for `link_name`."""
+ networkd_dir = os.path.join(self.workdir.name, 'run', 'systemd', 'network')
+ with open(os.path.join(networkd_dir, '10-netplan-{}.network'.format(link_name))) as f:
+ return f.read()
+
+ def get_optional_addresses(self, eth_name):
+ config = self.get_network_config_for_link(eth_name)
+ r = set()
+ prefix = "OptionalAddresses="
+ for line in config.splitlines():
+ if line.startswith(prefix):
+ r.add(line[len(prefix):])
+ return r
+
+ def assert_nm(self, connections_map=None, conf=None):
+ # check config
+ conf_path = os.path.join(self.workdir.name, 'run', 'NetworkManager', 'conf.d', 'netplan.conf')
+ if conf:
+ with open(conf_path) as f:
+ self.assertEqual(f.read(), conf)
+ else:
+ if os.path.exists(conf_path):
+ with open(conf_path) as f: # pragma: nocover
+ self.fail('unexpected %s:\n%s' % (conf_path, f.read()))
+
+ # check connections
+ con_dir = os.path.join(self.workdir.name, 'run', 'NetworkManager', 'system-connections')
+ if connections_map:
+ self.assertEqual(set(os.listdir(con_dir)),
+ set(['netplan-' + n.split('.nmconnection')[0] + '.nmconnection' for n in connections_map]))
+ for fname, contents in connections_map.items():
+ extension = ''
+ if '.nmconnection' not in fname:
+ extension = '.nmconnection'
+ with open(os.path.join(con_dir, 'netplan-' + fname + extension)) as f:
+ self.assertEqual(f.read(), contents)
+ # NM connection files might contain secrets
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ else:
+ if os.path.exists(con_dir):
+ self.assertEqual(os.listdir(con_dir), [])
+
+ def assert_nm_udev(self, contents):
+ rule_path = os.path.join(self.workdir.name, 'run/udev/rules.d/90-netplan.rules')
+ if contents is None:
+ self.assertFalse(os.path.exists(rule_path))
+ return
+ with open(rule_path) as f:
+ self.assertEqual(f.read(), contents)
+
+ def assert_ovs(self, file_contents_map):
+ systemd_dir = os.path.join(self.workdir.name, 'run', 'systemd', 'system')
+ if not file_contents_map:
+ # in this case we assume no OVS configuration should be present
+ self.assertFalse(glob.glob(os.path.join(systemd_dir, '*netplan-ovs-*.service')))
+ return
+
+ self.assertEqual(set(os.listdir(self.workdir.name)) - {'lib'}, {'etc', 'run'})
+ ovs_systemd_dir = set(os.listdir(systemd_dir))
+ ovs_systemd_dir.remove('systemd-networkd.service.wants')
+ self.assertEqual(ovs_systemd_dir, {'netplan-ovs-' + f for f in file_contents_map})
+ for fname, contents in file_contents_map.items():
+ fname = 'netplan-ovs-' + fname
+ with open(os.path.join(systemd_dir, fname)) as f:
+ self.assertEqual(f.read(), contents)
+ if fname.endswith('.service'):
+ link_path = os.path.join(
+ systemd_dir, 'systemd-networkd.service.wants', fname)
+ self.assertTrue(os.path.islink(link_path))
+ link_target = os.readlink(link_path)
+ self.assertEqual(link_target,
+ os.path.join(
+ '/', 'run', 'systemd', 'system', fname))
diff --git a/tests/generator/test_args.py b/tests/generator/test_args.py
new file mode 100644
index 0000000..250d317
--- /dev/null
+++ b/tests/generator/test_args.py
@@ -0,0 +1,184 @@
+#
+# Command-line arguments handling tests for generator
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import subprocess
+
+from .base import TestBase, exe_generate, OVS_CLEANUP
+
+
+class TestConfigArgs(TestBase):
+ '''Config file argument handling'''
+
+ def test_no_files(self):
+ subprocess.check_call([exe_generate, '--root-dir', self.workdir.name])
+ self.assertEqual(os.listdir(self.workdir.name), ['run'])
+ self.assert_nm_udev(None)
+
+ def test_no_configs(self):
+ self.generate('network:\n version: 2')
+ # should not write any files
+ self.assertCountEqual(os.listdir(self.workdir.name), ['etc', 'run'])
+ self.assert_networkd(None)
+ self.assert_networkd_udev(None)
+ self.assert_nm(None)
+ self.assert_nm_udev(None)
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+
+ def test_empty_config(self):
+ self.generate('')
+ # should not write any files
+ self.assertCountEqual(os.listdir(self.workdir.name), ['etc', 'run'])
+ self.assert_networkd(None)
+ self.assert_networkd_udev(None)
+ self.assert_nm(None)
+ self.assert_nm_udev(None)
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+
+ def test_file_args(self):
+ conf = os.path.join(self.workdir.name, 'config')
+ with open(conf, 'w') as f:
+ f.write('network: {}')
+ # when specifying custom files, it should ignore the global config
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true''', extra_args=[conf])
+ # There is one systemd service unit 'netplan-ovs-cleanup.service' in /run,
+ # which will always be created
+ self.assertEqual(set(os.listdir(self.workdir.name)), {'config', 'etc', 'run'})
+ self.assert_networkd(None)
+ self.assert_networkd_udev(None)
+ self.assert_nm(None)
+ self.assert_nm_udev(None)
+
+ def test_file_args_notfound(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true''', expect_fail=True, extra_args=['/non/existing/config'])
+ self.assertEqual(err, 'Cannot open /non/existing/config: No such file or directory\n')
+ self.assertEqual(os.listdir(self.workdir.name), ['etc'])
+
+ def test_help(self):
+ conf = os.path.join(self.workdir.name, 'etc', 'netplan', 'a.yaml')
+ os.makedirs(os.path.dirname(conf))
+ with open(conf, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true''')
+
+ p = subprocess.Popen([exe_generate, '--root-dir', self.workdir.name, '--help'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
+ (out, err) = p.communicate()
+ self.assertEqual(err, '')
+ self.assertEqual(p.returncode, 0)
+ self.assertIn('Usage:', out)
+ self.assertEqual(os.listdir(self.workdir.name), ['etc'])
+
+ def test_unknown_cli_args(self):
+ p = subprocess.Popen([exe_generate, '--foo'],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
+ (out, err) = p.communicate()
+ self.assertIn('nknown option --foo', err)
+ self.assertNotEqual(p.returncode, 0)
+
+ def test_output_mkdir_error(self):
+ conf = os.path.join(self.workdir.name, 'config')
+ with open(conf, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true''')
+ err = self.generate('', extra_args=['--root-dir', '/proc/foo', conf], expect_fail=True)
+ # can be /proc/foor/run/systemd/{network,system}
+ self.assertIn('cannot create directory /proc/foo/run/systemd/', err)
+
+ def test_systemd_generator(self):
+ conf = os.path.join(self.confdir, 'a.yaml')
+ os.makedirs(os.path.dirname(conf))
+ with open(conf, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: true''')
+ outdir = os.path.join(self.workdir.name, 'out')
+ os.mkdir(outdir)
+
+ generator = os.path.join(self.workdir.name, 'systemd', 'system-generators', 'netplan')
+ os.makedirs(os.path.dirname(generator))
+ os.symlink(exe_generate, generator)
+
+ subprocess.check_call([generator, '--root-dir', self.workdir.name, outdir, outdir, outdir])
+ n = os.path.join(self.workdir.name, 'run', 'systemd', 'network', '10-netplan-eth0.network')
+ self.assertTrue(os.path.exists(n))
+ os.unlink(n)
+
+ # should auto-enable networkd and -wait-online
+ self.assertTrue(os.path.islink(os.path.join(
+ outdir, 'multi-user.target.wants', 'systemd-networkd.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ outdir, 'network-online.target.wants', 'systemd-networkd-wait-online.service')))
+
+ # should be a no-op the second time while the stamp exists
+ out = subprocess.check_output([generator, '--root-dir', self.workdir.name, outdir, outdir, outdir],
+ stderr=subprocess.STDOUT)
+ self.assertFalse(os.path.exists(n))
+ self.assertIn(b'netplan generate already ran', out)
+
+ # after removing the stamp it generates again, and not trip over the
+ # existing enablement symlink
+ os.unlink(os.path.join(outdir, 'netplan.stamp'))
+ subprocess.check_output([generator, '--root-dir', self.workdir.name, outdir, outdir, outdir])
+ self.assertTrue(os.path.exists(n))
+
+ def test_systemd_generator_noconf(self):
+ outdir = os.path.join(self.workdir.name, 'out')
+ os.mkdir(outdir)
+
+ generator = os.path.join(self.workdir.name, 'systemd', 'system-generators', 'netplan')
+ os.makedirs(os.path.dirname(generator))
+ os.symlink(exe_generate, generator)
+
+ subprocess.check_call([generator, '--root-dir', self.workdir.name, outdir, outdir, outdir])
+ # no enablement symlink here
+ self.assertEqual(os.listdir(outdir), ['netplan.stamp'])
+
+ def test_systemd_generator_badcall(self):
+ outdir = os.path.join(self.workdir.name, 'out')
+ os.mkdir(outdir)
+
+ generator = os.path.join(self.workdir.name, 'systemd', 'system-generators', 'netplan')
+ os.makedirs(os.path.dirname(generator))
+ os.symlink(exe_generate, generator)
+
+ try:
+ subprocess.check_output([generator, '--root-dir', self.workdir.name],
+ stderr=subprocess.STDOUT)
+ self.fail("direct systemd generator call is expected to fail, but succeeded.") # pragma: nocover
+ except subprocess.CalledProcessError as e:
+ self.assertEqual(e.returncode, 1)
+ self.assertIn(b'can not be called directly', e.output)
diff --git a/tests/generator/test_auth.py b/tests/generator/test_auth.py
new file mode 100644
index 0000000..7d9ff8f
--- /dev/null
+++ b/tests/generator/test_auth.py
@@ -0,0 +1,555 @@
+#
+# Tests for network authentication config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import stat
+
+from .base import TestBase, ND_DHCP4, ND_WIFI_DHCP4
+
+
+class TestNetworkd(TestBase):
+
+ def test_auth_wifi_detailed(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "s0s3kr1t"
+ "Luke's Home":
+ auth:
+ key-management: psk
+ password: "4lsos3kr1t"
+ "BobsHome":
+ password: "e03ce667c87bc81ca968d9120ca37f89eb09aec3c55b80386e5d772efd6b926e"
+ "BillsHome":
+ auth:
+ key-management: psk
+ password: "db3b0acf5653aeaddd5fe034fb9f07175b2864f847b005aaa2f09182d9411b04"
+ workplace:
+ auth:
+ key-management: eap
+ method: ttls
+ anonymous-identity: "@internal.example.com"
+ identity: "joe@internal.example.com"
+ password: "v3ryS3kr1t"
+ workplace2:
+ auth:
+ key-management: eap
+ method: peap
+ identity: "joe@internal.example.com"
+ password: "v3ryS3kr1t"
+ ca-certificate: /etc/ssl/work2-cacrt.pem
+ workplacehashed:
+ auth:
+ key-management: eap
+ method: ttls
+ anonymous-identity: "@internal.example.com"
+ identity: "joe@internal.example.com"
+ password: hash:9db1636cedc5948537e7bee0cc1e9590
+ customernet:
+ auth:
+ key-management: eap
+ method: tls
+ anonymous-identity: "@cust.example.com"
+ identity: "cert-joe@cust.example.com"
+ ca-certificate: /etc/ssl/cust-cacrt.pem
+ client-certificate: /etc/ssl/cust-crt.pem
+ client-key: /etc/ssl/cust-key.pem
+ client-key-password: "d3cryptPr1v4t3K3y"
+ opennet:
+ auth:
+ key-management: none
+ peer2peer:
+ mode: adhoc
+ auth: {}
+ dhcp4: yes
+ ''')
+
+ self.assert_networkd({'wl0.network': ND_WIFI_DHCP4 % 'wl0'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:wl0,''')
+ self.assert_nm_udev(None)
+
+ # generates wpa config and enables wpasupplicant unit
+ with open(os.path.join(self.workdir.name, 'run/netplan/wpa-wl0.conf')) as f:
+ new_config = f.read()
+ self.assertIn('ctrl_interface=/run/wpa_supplicant', new_config)
+ self.assertIn('''
+network={
+ ssid="peer2peer"
+ mode=1
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="Luke's Home"
+ key_mgmt=WPA-PSK
+ psk="4lsos3kr1t"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="BobsHome"
+ key_mgmt=WPA-PSK
+ psk=e03ce667c87bc81ca968d9120ca37f89eb09aec3c55b80386e5d772efd6b926e
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="BillsHome"
+ key_mgmt=WPA-PSK
+ psk=db3b0acf5653aeaddd5fe034fb9f07175b2864f847b005aaa2f09182d9411b04
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="workplace2"
+ key_mgmt=WPA-EAP
+ eap=PEAP
+ identity="joe@internal.example.com"
+ password="v3ryS3kr1t"
+ ca_cert="/etc/ssl/work2-cacrt.pem"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="workplace"
+ key_mgmt=WPA-EAP
+ eap=TTLS
+ identity="joe@internal.example.com"
+ anonymous_identity="@internal.example.com"
+ password="v3ryS3kr1t"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="workplacehashed"
+ key_mgmt=WPA-EAP
+ eap=TTLS
+ identity="joe@internal.example.com"
+ anonymous_identity="@internal.example.com"
+ password=hash:9db1636cedc5948537e7bee0cc1e9590
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="customernet"
+ key_mgmt=WPA-EAP
+ eap=TLS
+ identity="cert-joe@cust.example.com"
+ anonymous_identity="@cust.example.com"
+ ca_cert="/etc/ssl/cust-cacrt.pem"
+ client_cert="/etc/ssl/cust-crt.pem"
+ private_key="/etc/ssl/cust-key.pem"
+ private_key_passwd="d3cryptPr1v4t3K3y"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="opennet"
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="Joe's Home"
+ key_mgmt=WPA-PSK
+ psk="s0s3kr1t"
+}
+''', new_config)
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+
+ def test_auth_wired(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ auth:
+ key-management: 802.1x
+ method: tls
+ anonymous-identity: "@cust.example.com"
+ identity: "cert-joe@cust.example.com"
+ ca-certificate: /etc/ssl/cust-cacrt.pem
+ client-certificate: /etc/ssl/cust-crt.pem
+ client-key: /etc/ssl/cust-key.pem
+ client-key-password: "d3cryptPr1v4t3K3y"
+ phase2-auth: MSCHAPV2
+ dhcp4: yes
+ ''')
+
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth0,''')
+ self.assert_nm_udev(None)
+
+ # generates wpa config and enables wpasupplicant unit
+ with open(os.path.join(self.workdir.name, 'run/netplan/wpa-eth0.conf')) as f:
+ self.assertEqual(f.read(), '''ctrl_interface=/run/wpa_supplicant
+
+network={
+ key_mgmt=IEEE8021X
+ eap=TLS
+ identity="cert-joe@cust.example.com"
+ anonymous_identity="@cust.example.com"
+ ca_cert="/etc/ssl/cust-cacrt.pem"
+ client_cert="/etc/ssl/cust-crt.pem"
+ private_key="/etc/ssl/cust-key.pem"
+ private_key_passwd="d3cryptPr1v4t3K3y"
+ phase2="auth=MSCHAPV2"
+}
+''')
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-eth0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-eth0.service')))
+
+
+class TestNetworkManager(TestBase):
+
+ def test_auth_wifi_detailed(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "s0s3kr1t"
+ "Luke's Home":
+ auth:
+ key-management: psk
+ password: "4lsos3kr1t"
+ workplace:
+ auth:
+ key-management: eap
+ method: ttls
+ anonymous-identity: "@internal.example.com"
+ identity: "joe@internal.example.com"
+ password: "v3ryS3kr1t"
+ workplace2:
+ auth:
+ key-management: eap
+ method: peap
+ identity: "joe@internal.example.com"
+ password: "v3ryS3kr1t"
+ ca-certificate: /etc/ssl/work2-cacrt.pem
+ workplacehashed:
+ auth:
+ key-management: eap
+ method: ttls
+ anonymous-identity: "@internal.example.com"
+ identity: "joe@internal.example.com"
+ password: hash:9db1636cedc5948537e7bee0cc1e9590
+ customernet:
+ auth:
+ key-management: 802.1x
+ method: tls
+ anonymous-identity: "@cust.example.com"
+ identity: "cert-joe@cust.example.com"
+ ca-certificate: /etc/ssl/cust-cacrt.pem
+ client-certificate: /etc/ssl/cust-crt.pem
+ client-key: /etc/ssl/cust-key.pem
+ client-key-password: "d3cryptPr1v4t3K3y"
+ phase2-auth: MSCHAPV2
+ opennet:
+ auth:
+ key-management: none
+ peer2peer:
+ mode: adhoc
+ auth: {}
+ dhcp4: yes
+ ''')
+
+ self.assert_networkd({})
+ self.assert_nm({'wl0-Joe%27s%20Home': '''[connection]
+id=netplan-wl0-Joe's Home
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=Joe's Home
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=s0s3kr1t
+''',
+ 'wl0-Luke%27s%20Home': '''[connection]
+id=netplan-wl0-Luke's Home
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=Luke's Home
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=4lsos3kr1t
+''',
+ 'wl0-workplace': '''[connection]
+id=netplan-wl0-workplace
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=workplace
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-eap
+
+[802-1x]
+eap=ttls
+identity=joe@internal.example.com
+anonymous-identity=@internal.example.com
+password=v3ryS3kr1t
+''',
+ 'wl0-workplace2': '''[connection]
+id=netplan-wl0-workplace2
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=workplace2
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-eap
+
+[802-1x]
+eap=peap
+identity=joe@internal.example.com
+password=v3ryS3kr1t
+ca-cert=/etc/ssl/work2-cacrt.pem
+''',
+ 'wl0-workplacehashed': '''[connection]
+id=netplan-wl0-workplacehashed
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=workplacehashed
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-eap
+
+[802-1x]
+eap=ttls
+identity=joe@internal.example.com
+anonymous-identity=@internal.example.com
+password=hash:9db1636cedc5948537e7bee0cc1e9590
+''',
+ 'wl0-customernet': '''[connection]
+id=netplan-wl0-customernet
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=customernet
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=ieee8021x
+
+[802-1x]
+eap=tls
+identity=cert-joe@cust.example.com
+anonymous-identity=@cust.example.com
+ca-cert=/etc/ssl/cust-cacrt.pem
+client-cert=/etc/ssl/cust-crt.pem
+private-key=/etc/ssl/cust-key.pem
+private-key-password=d3cryptPr1v4t3K3y
+phase2-auth=MSCHAPV2
+''',
+ 'wl0-opennet': '''[connection]
+id=netplan-wl0-opennet
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=opennet
+mode=infrastructure
+''',
+ 'wl0-peer2peer': '''[connection]
+id=netplan-wl0-peer2peer
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=peer2peer
+mode=adhoc
+'''})
+ self.assert_nm_udev(None)
+
+ def test_auth_wired(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ auth:
+ key-management: 802.1x
+ method: tls
+ anonymous-identity: "@cust.example.com"
+ identity: "cert-joe@cust.example.com"
+ ca-certificate: /etc/ssl/cust-cacrt.pem
+ client-certificate: /etc/ssl/cust-crt.pem
+ client-key: /etc/ssl/cust-key.pem
+ client-key-password: "d3cryptPr1v4t3K3y"
+ dhcp4: yes
+ ''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[802-1x]
+eap=tls
+identity=cert-joe@cust.example.com
+anonymous-identity=@cust.example.com
+ca-cert=/etc/ssl/cust-cacrt.pem
+client-cert=/etc/ssl/cust-crt.pem
+private-key=/etc/ssl/cust-key.pem
+private-key-password=d3cryptPr1v4t3K3y
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+
+class TestConfigErrors(TestBase):
+
+ def test_auth_invalid_key_mgmt(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ auth:
+ key-management: bogus''', expect_fail=True)
+ self.assertIn("unknown key management type 'bogus'", err)
+
+ def test_auth_invalid_eap_method(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ auth:
+ method: bogus''', expect_fail=True)
+ self.assertIn("unknown EAP method 'bogus'", err)
+
+ def test_auth_networkd_wifi_psk_too_big(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "LoremipsumdolorsitametconsecteturadipiscingelitCrastemporvelitnunc"
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("ASCII passphrase must be between 8 and 63 characters (inclusive)", err)
+
+ def test_auth_networkd_wifi_psk_too_small(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "p4ss"
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("ASCII passphrase must be between 8 and 63 characters (inclusive)", err)
+
+ def test_auth_networkd_wifi_psk_64_non_hexdigit(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "LoremipsumdolorsitametconsecteturadipiscingelitCrastemporvelitnu"
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("PSK length of 64 is only supported for hex-digit representation", err)
diff --git a/tests/generator/test_bonds.py b/tests/generator/test_bonds.py
new file mode 100644
index 0000000..fea475e
--- /dev/null
+++ b/tests/generator/test_bonds.py
@@ -0,0 +1,812 @@
+#
+# Tests for bond devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase
+
+
+class TestNetworkd(TestBase):
+
+ def test_bond_dhcp6_no_accept_ra(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: no
+ accept-ra: no
+ bonds:
+ bond0:
+ interfaces: [engreen]
+ dhcp6: true
+ accept-ra: yes''')
+ self.assert_networkd({'bond0.network': '''[Match]
+Name=bond0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6AcceptRA=yes
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'bond0.netdev': '''[NetDev]
+Name=bond0
+Kind=bond
+''',
+ 'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=no
+IPv6AcceptRA=no
+Bond=bond0
+'''})
+
+ def test_bond_empty(self):
+ self.generate('''network:
+ version: 2
+ bonds:
+ bn0:
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:bn0,''')
+ self.assert_nm_udev(None)
+
+ def test_bond_components(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+ def test_bond_empty_parameters(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ parameters: {}
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+ def test_bond_with_parameters(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ parameters:
+ mode: 802.3ad
+ lacp-rate: 10
+ mii-monitor-interval: 10
+ min-links: 10
+ up-delay: 20
+ down-delay: 30
+ all-slaves-active: true
+ transmit-hash-policy: none
+ ad-select: none
+ arp-interval: 15
+ arp-validate: all
+ arp-all-targets: all
+ fail-over-mac-policy: none
+ gratuitious-arp: 10
+ packets-per-slave: 10
+ primary-reselect-policy: none
+ resend-igmp: 10
+ learn-packet-interval: 10
+ arp-ip-targets:
+ - 10.10.10.10
+ - 20.20.20.20
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n\n'
+ '[Bond]\n'
+ 'Mode=802.3ad\n'
+ 'LACPTransmitRate=10\n'
+ 'MIIMonitorSec=10ms\n'
+ 'MinLinks=10\n'
+ 'TransmitHashPolicy=none\n'
+ 'AdSelect=none\n'
+ 'AllSlavesActive=1\n'
+ 'ARPIntervalSec=15ms\n'
+ 'ARPIPTargets=10.10.10.10 20.20.20.20\n'
+ 'ARPValidate=all\n'
+ 'ARPAllTargets=all\n'
+ 'UpDelaySec=20ms\n'
+ 'DownDelaySec=30ms\n'
+ 'FailOverMACPolicy=none\n'
+ 'GratuitousARP=10\n'
+ 'PacketsPerSlave=10\n'
+ 'PrimaryReselectPolicy=none\n'
+ 'ResendIGMP=10\n'
+ 'LearnPacketIntervalSec=10\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+ def test_bond_with_parameters_all_suffix(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ parameters:
+ mode: 802.3ad
+ mii-monitor-interval: 10ms
+ up-delay: 20ms
+ down-delay: 30s
+ arp-interval: 15m
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n\n'
+ '[Bond]\n'
+ 'Mode=802.3ad\n'
+ 'MIIMonitorSec=10ms\n'
+ 'ARPIntervalSec=15m\n'
+ 'UpDelaySec=20ms\n'
+ 'DownDelaySec=30s\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+ def test_bond_primary_slave(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ parameters:
+ mode: active-backup
+ primary: eno1
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n\n'
+ '[Bond]\n'
+ 'Mode=active-backup\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\nPrimarySlave=true\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+ def test_bond_primary_slave_duplicate(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eno1: {}
+ enp65s0: {}
+ dummy2: {}
+ bonds:
+ bond0:
+ interfaces: [eno1, enp65s0]
+ parameters:
+ primary: enp65s0
+ mode: balance-tlb
+ vlans:
+ vbr-v10:
+ id: 10
+ link: vbr
+ bridges:
+ vbr:
+ interfaces: [dummy2]''', expect_fail=False)
+
+ self.assert_networkd({'eno1.network': '[Match]\nName=eno1\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'enp65s0.network': '''[Match]
+Name=enp65s0
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+PrimarySlave=true
+''',
+ 'dummy2.network': '[Match]\nName=dummy2\n\n[Network]\nLinkLocalAddressing=no\nBridge=vbr\n',
+ 'bond0.network': '[Match]\nName=bond0\n\n'
+ '[Network]\nLinkLocalAddressing=ipv6\nConfigureWithoutCarrier=yes\n',
+ 'bond0.netdev': '[NetDev]\nName=bond0\nKind=bond\n\n[Bond]\nMode=balance-tlb\n',
+ 'vbr-v10.network': '[Match]\nName=vbr-v10\n\n'
+ '[Network]\nLinkLocalAddressing=ipv6\nConfigureWithoutCarrier=yes\n',
+ 'vbr-v10.netdev': '[NetDev]\nName=vbr-v10\nKind=vlan\n\n[VLAN]\nId=10\n',
+ 'vbr.network': '[Match]\nName=vbr\n\n'
+ '[Network]\nLinkLocalAddressing=ipv6\nConfigureWithoutCarrier=yes\nVLAN=vbr-v10\n',
+ 'vbr.netdev': '[NetDev]\nName=vbr\nKind=bridge\n'})
+
+ def test_bond_with_gratuitous_spelling(self):
+ """Validate that the correct spelling of gratuitous also works"""
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bonds:
+ bn0:
+ parameters:
+ mode: active-backup
+ gratuitous-arp: 10
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'bn0.netdev': '[NetDev]\nName=bn0\nKind=bond\n\n'
+ '[Bond]\n'
+ 'Mode=active-backup\n'
+ 'GratuitousARP=10\n',
+ 'bn0.network': '''[Match]
+Name=bn0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBond=bn0\n'})
+
+
+class TestNetworkManager(TestBase):
+
+ def test_bond_empty(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ bonds:
+ bn0:
+ dhcp4: true''')
+
+ self.assert_nm({'bn0': '''[connection]
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_bond_components(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bonds:
+ bn0:
+ interfaces: [eno1, switchport]
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'bn0': '''[connection]
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bond_empty_params(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bonds:
+ bn0:
+ interfaces: [eno1, switchport]
+ parameters: {}
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'bn0': '''[connection]
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bond_with_params(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bonds:
+ bn0:
+ interfaces: [eno1, switchport]
+ parameters:
+ mode: 802.3ad
+ lacp-rate: 10
+ mii-monitor-interval: 10
+ min-links: 10
+ up-delay: 10
+ down-delay: 10
+ all-slaves-active: true
+ transmit-hash-policy: none
+ ad-select: none
+ arp-interval: 10
+ arp-validate: all
+ arp-all-targets: all
+ arp-ip-targets:
+ - 10.10.10.10
+ - 20.20.20.20
+ fail-over-mac-policy: none
+ gratuitious-arp: 10
+ packets-per-slave: 10
+ primary-reselect-policy: none
+ resend-igmp: 10
+ learn-packet-interval: 10
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'bn0': '''[connection]
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[bond]
+mode=802.3ad
+lacp_rate=10
+miimon=10
+min_links=10
+xmit_hash_policy=none
+ad_select=none
+all_slaves_active=1
+arp_interval=10
+arp_ip_target=10.10.10.10,20.20.20.20
+arp_validate=all
+arp_all_targets=all
+updelay=10
+downdelay=10
+fail_over_mac=none
+num_grat_arp=10
+num_unsol_na=10
+packets_per_slave=10
+primary_reselect=none
+resend_igmp=10
+lp_interval=10
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bond_primary_slave(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bonds:
+ bn0:
+ interfaces: [eno1, switchport]
+ parameters:
+ mode: active-backup
+ primary: eno1
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bond
+master=bn0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'bn0': '''[connection]
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[bond]
+mode=active-backup
+primary=eno1
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+
+class TestConfigErrors(TestBase):
+
+ def test_bond_invalid_mode(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ parameters:
+ mode: lacp
+ arp-ip-targets:
+ - 2001:dead:beef::1
+ dhcp4: true''', expect_fail=True)
+ self.assertIn("unknown bond mode 'lacp'", err)
+
+ def test_bond_invalid_arp_target(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ parameters:
+ arp-ip-targets:
+ - 2001:dead:beef::1
+ dhcp4: true''', expect_fail=True)
+
+ def test_bond_invalid_primary_slave(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ parameters:
+ primary: wigglewiggle
+ dhcp4: true''', expect_fail=True)
+
+ def test_bond_duplicate_primary_slave(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ eno2:
+ match:
+ name: eth1
+ bonds:
+ bond0:
+ interfaces: [eno1, eno2]
+ parameters:
+ primary: eno1
+ primary: eno2
+ dhcp4: true''', expect_fail=True)
+
+ def test_bond_multiple_assignments(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ bond1:
+ interfaces: [eno1]''', expect_fail=True)
+ self.assertIn("bond1: interface 'eno1' is already assigned to bond bond0", err)
+
+ def test_bond_bridge_cross_assignments1(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ bridges:
+ br1:
+ interfaces: [eno1]''', expect_fail=True)
+ self.assertIn("br1: interface 'eno1' is already assigned to bond bond0", err)
+
+ def test_bond_bridge_cross_assignments2(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ bridges:
+ br0:
+ interfaces: [eno1]
+ bonds:
+ bond1:
+ interfaces: [eno1]''', expect_fail=True)
+ self.assertIn("bond1: interface 'eno1' is already assigned to bridge br0", err)
+
+ def test_bond_bridge_nested_assignments(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ bonds:
+ bond0:
+ interfaces: [eno1]
+ bridges:
+ br1:
+ interfaces: [bond0]''')
diff --git a/tests/generator/test_bridges.py b/tests/generator/test_bridges.py
new file mode 100644
index 0000000..7898cbe
--- /dev/null
+++ b/tests/generator/test_bridges.py
@@ -0,0 +1,733 @@
+#
+# Tests for bridge devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import unittest
+
+from .base import TestBase
+
+
+class TestNetworkd(TestBase):
+
+ def test_bridge_set_mac(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ macaddress: 00:01:02:03:04:05
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.network': '''[Match]
+Name=br0
+
+[Link]
+MACAddress=00:01:02:03:04:05
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'br0.netdev': '[NetDev]\nName=br0\nMACAddress=00:01:02:03:04:05\nKind=bridge\n'})
+
+ def test_bridge_dhcp6_no_accept_ra(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: no
+ dhcp6: no
+ accept-ra: no
+ bridges:
+ br0:
+ interfaces: [engreen]
+ dhcp6: true
+ accept-ra: no''')
+ self.assert_networkd({'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6AcceptRA=no
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'br0.netdev': '''[NetDev]
+Name=br0
+Kind=bridge
+''',
+ 'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=no
+IPv6AcceptRA=no
+Bridge=br0
+'''})
+
+ def test_bridge_empty(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:br0,''')
+ self.assert_nm_udev(None)
+
+ def test_bridge_type_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ bridges:
+ renderer: networkd
+ br0:
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:br0,''')
+ self.assert_nm_udev(None)
+
+ def test_bridge_def_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ bridges:
+ renderer: NetworkManager
+ br0:
+ renderer: networkd
+ addresses: [1.2.3.4/12]
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+Address=1.2.3.4/12
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:br0,''')
+ self.assert_nm_udev(None)
+
+ def test_bridge_forward_declaration(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: [eno1, switchports]
+ dhcp4: true
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n'})
+
+ @unittest.skipIf("CODECOV_TOKEN" in os.environ, "Skip on codecov.io: GLib changed hashtable sorting")
+ def test_eth_bridge_nm_blacklist(self): # pragma: nocover
+ self.generate('''network:
+ renderer: networkd
+ ethernets:
+ eth42:
+ dhcp4: yes
+ ethbr:
+ match: {name: eth43}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ dhcp4: yes''')
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth42,interface-name:eth43,interface-name:mybr,''')
+
+ def test_bridge_components(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bridges:
+ br0:
+ interfaces: [eno1, switchports]
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n'})
+
+ def test_bridge_params(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute
+ bridges:
+ br0:
+ interfaces: [eno1, switchports]
+ parameters:
+ ageing-time: 50
+ forward-delay: 12
+ hello-time: 6
+ max-age: 24
+ priority: 1000
+ stp: true
+ path-cost:
+ eno1: 70
+ port-priority:
+ eno1: 14
+ dhcp4: true''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n\n'
+ '[Bridge]\nAgeingTimeSec=50\n'
+ 'Priority=1000\n'
+ 'ForwardDelaySec=12\n'
+ 'HelloTimeSec=6\n'
+ 'MaxAgeSec=24\n'
+ 'STP=true\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n\n'
+ '[Bridge]\nCost=70\nPriority=14\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n'})
+
+
+class TestNetworkManager(TestBase):
+
+ def test_bridge_empty(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ bridges:
+ br0:
+ dhcp4: true''')
+
+ self.assert_nm({'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bridge_type_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ bridges:
+ renderer: NetworkManager
+ br0:
+ dhcp4: true''')
+
+ self.assert_nm({'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bridge_set_mac(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ renderer: NetworkManager
+ br0:
+ macaddress: 00:01:02:03:04:05
+ dhcp4: true''')
+
+ self.assert_nm({'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ethernet]
+cloned-mac-address=00:01:02:03:04:05
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_bridge_def_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ bridges:
+ renderer: networkd
+ br0:
+ renderer: NetworkManager
+ addresses: [1.2.3.4/12]
+ dhcp4: true''')
+
+ self.assert_nm({'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=auto
+address1=1.2.3.4/12
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bridge_forward_declaration(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ bridges:
+ br0:
+ interfaces: [eno1, switchport]
+ dhcp4: true
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bridge
+master=br0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bridge
+master=br0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bridge_components(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bridges:
+ br0:
+ interfaces: [eno1, switchport]
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bridge
+master=br0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bridge
+master=br0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_bridge_params(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eno1: {}
+ switchport:
+ match:
+ name: enp2s1
+ bridges:
+ br0:
+ interfaces: [eno1, switchport]
+ parameters:
+ ageing-time: 50
+ priority: 1000
+ forward-delay: 12
+ hello-time: 6
+ max-age: 24
+ path-cost:
+ eno1: 70
+ port-priority:
+ eno1: 61
+ stp: true
+ dhcp4: true''')
+
+ self.assert_nm({'eno1': '''[connection]
+id=netplan-eno1
+type=ethernet
+interface-name=eno1
+slave-type=bridge
+master=br0
+
+[bridge-port]
+path-cost=70
+priority=61
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'switchport': '''[connection]
+id=netplan-switchport
+type=ethernet
+interface-name=enp2s1
+slave-type=bridge
+master=br0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'br0': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[bridge]
+ageing-time=50
+priority=1000
+forward-delay=12
+hello-time=6
+max-age=24
+stp=true
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+
+class TestNetplanYAMLv2(TestBase):
+ '''No asserts are needed.
+
+ The generate() method implicitly checks the (re-)generated YAML.
+ '''
+
+ def test_bridge_stp(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ parameters:
+ stp: no
+ dhcp4: true''')
+
+
+class TestConfigErrors(TestBase):
+
+ def test_bridge_unknown_iface(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: ['foo']''', expect_fail=True)
+ self.assertIn("br0: interface 'foo' is not defined", err)
+
+ def test_bridge_multiple_assignments(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ bridges:
+ br0:
+ interfaces: [eno1]
+ br1:
+ interfaces: [eno1]''', expect_fail=True)
+ self.assertIn("br1: interface 'eno1' is already assigned to bridge br0", err)
+
+ def test_bridge_invalid_dev_for_path_cost(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ path-cost:
+ eth0: 50
+ dhcp4: true''', expect_fail=True)
+
+ def test_bridge_path_cost_already_defined(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ path-cost:
+ eno1: 50
+ eno1: 40
+ dhcp4: true''', expect_fail=True)
+
+ def test_bridge_invalid_path_cost(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ path-cost:
+ eno1: aa
+ dhcp4: true''', expect_fail=True)
+
+ def test_bridge_invalid_dev_for_port_prio(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ port-priority:
+ eth0: 50
+ dhcp4: true''', expect_fail=True)
+
+ def test_bridge_port_prio_already_defined(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ port-priority:
+ eno1: 50
+ eno1: 40
+ dhcp4: true''', expect_fail=True)
+
+ def test_bridge_invalid_port_prio(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1:
+ match:
+ name: eth0
+ bridges:
+ br0:
+ interfaces: [eno1]
+ parameters:
+ port-priority:
+ eno1: 257
+ dhcp4: true''', expect_fail=True)
diff --git a/tests/generator/test_common.py b/tests/generator/test_common.py
new file mode 100644
index 0000000..7bdb4b4
--- /dev/null
+++ b/tests/generator/test_common.py
@@ -0,0 +1,1690 @@
+#
+# Common tests for netplan generator
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import textwrap
+
+from .base import TestBase, ND_DHCP4, ND_DHCP6, ND_DHCPYES, ND_EMPTY
+
+
+class TestNetworkd(TestBase):
+ '''networkd output'''
+
+ def test_optional(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ optional: true''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Link]
+RequiredForOnline=no
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_networkd_udev(None)
+
+ def config_with_optional_addresses(self, eth_name, optional_addresses):
+ return '''network:
+ version: 2
+ ethernets:
+ {}:
+ dhcp6: true
+ optional-addresses: {}'''.format(eth_name, optional_addresses)
+
+ def test_optional_addresses(self):
+ eth_name = self.eth_name()
+ self.generate(self.config_with_optional_addresses(eth_name, '["dhcp4"]'))
+ self.assertEqual(self.get_optional_addresses(eth_name), set(["dhcp4"]))
+
+ def test_optional_addresses_multiple(self):
+ eth_name = self.eth_name()
+ self.generate(self.config_with_optional_addresses(eth_name, '[dhcp4, ipv4-ll, ipv6-ra, dhcp6, dhcp4, static]'))
+ self.assertEqual(
+ self.get_optional_addresses(eth_name),
+ set(["ipv4-ll", "ipv6-ra", "dhcp4", "dhcp6", "static"]))
+
+ def test_optional_addresses_invalid(self):
+ eth_name = self.eth_name()
+ err = self.generate(self.config_with_optional_addresses(eth_name, '["invalid"]'), expect_fail=True)
+ self.assertIn('invalid value for optional-addresses', err)
+
+ def test_activation_mode_off(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ activation-mode: off''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Link]
+ActivationPolicy=always-down
+RequiredForOnline=no
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_networkd_udev(None)
+
+ def test_activation_mode_manual(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ activation-mode: manual''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Link]
+ActivationPolicy=manual
+RequiredForOnline=no
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_networkd_udev(None)
+
+ def test_mtu_all(self):
+ self.generate(textwrap.dedent("""
+ network:
+ version: 2
+ ethernets:
+ eth1:
+ mtu: 9000
+ dhcp4: n
+ ipv6-mtu: 2000
+ bonds:
+ bond0:
+ interfaces:
+ - eth1
+ mtu: 9000
+ vlans:
+ bond0.108:
+ link: bond0
+ id: 108"""))
+ self.assert_networkd({
+ 'bond0.108.netdev': '[NetDev]\nName=bond0.108\nKind=vlan\n\n[VLAN]\nId=108\n',
+ 'bond0.108.network': '''[Match]
+Name=bond0.108
+
+[Network]
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+''',
+ 'bond0.netdev': '[NetDev]\nName=bond0\nMTUBytes=9000\nKind=bond\n',
+ 'bond0.network': '''[Match]
+Name=bond0
+
+[Link]
+MTUBytes=9000
+
+[Network]
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+VLAN=bond0.108
+''',
+ 'eth1.link': '[Match]\nOriginalName=eth1\n\n[Link]\nWakeOnLan=off\nMTUBytes=9000\n',
+ 'eth1.network': '''[Match]
+Name=eth1
+
+[Link]
+MTUBytes=9000
+
+[Network]
+LinkLocalAddressing=no
+IPv6MTUBytes=2000
+Bond=bond0
+'''
+ })
+ self.assert_networkd_udev(None)
+
+ def test_eth_global_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eth0:
+ dhcp4: true''')
+
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth0,''')
+ self.assert_nm_udev(None)
+ # should not allow NM to manage everything
+ self.assertFalse(os.path.exists(self.nm_enable_all_conf))
+
+ def test_eth_type_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ renderer: networkd
+ eth0:
+ dhcp4: true''')
+
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth0,''')
+ # should allow NM to manage everything else
+ self.assertTrue(os.path.exists(self.nm_enable_all_conf))
+ self.assert_nm_udev(None)
+
+ def test_eth_def_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ renderer: NetworkManager
+ eth0:
+ renderer: networkd
+ dhcp4: true''')
+
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+ self.assert_networkd_udev(None)
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth0,''')
+ self.assert_nm_udev(None)
+
+ def test_eth_dhcp6(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0: {dhcp6: true}''')
+ self.assert_networkd({'eth0.network': ND_DHCP6 % 'eth0'})
+
+ def test_eth_dhcp6_no_accept_ra(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ accept-ra: n''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6AcceptRA=no
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_eth_dhcp6_accept_ra(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ accept-ra: yes''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6AcceptRA=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_eth_dhcp6_accept_ra_unset(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_eth_dhcp4_and_6(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0: {dhcp4: true, dhcp6: true}''')
+ self.assert_networkd({'eth0.network': ND_DHCPYES % 'eth0'})
+
+ def test_eth_manual_addresses(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+Address=2001:FFfe::1/64
+'''})
+
+ def test_eth_manual_addresses_dhcp(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+Address=2001:FFfe::1/64
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_eth_address_option_lifetime_zero(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24:
+ lifetime: 0
+ - 2001:FFfe::1/64''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:FFfe::1/64
+
+[Address]
+Address=192.168.14.2/24
+PreferredLifetime=0
+'''})
+
+ def test_eth_address_option_lifetime_forever(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24:
+ lifetime: forever
+ - 2001:FFfe::1/64''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:FFfe::1/64
+
+[Address]
+Address=192.168.14.2/24
+PreferredLifetime=forever
+'''})
+
+ def test_eth_address_option_label(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24:
+ label: test-label
+ - 2001:FFfe::1/64''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:FFfe::1/64
+
+[Address]
+Address=192.168.14.2/24
+Label=test-label
+'''})
+
+ def test_eth_address_option_multi_pass(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: [engreen]
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24:
+ label: test-label
+ - 2001:FFfe::1/64:
+ label: ip6''')
+
+ self.assert_networkd({
+ 'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=no
+Bridge=br0
+
+[Address]
+Address=192.168.14.2/24
+Label=test-label
+
+[Address]
+Address=2001:FFfe::1/64
+Label=ip6
+''',
+ 'br0.network': ND_EMPTY % ('br0', 'ipv6'),
+ 'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n'})
+
+ def test_bond_arp_ip_targets_multi_pass(self):
+ self.generate('''network:
+ bonds:
+ bond0:
+ interfaces:
+ - eno1
+ parameters:
+ arp-ip-targets:
+ - 10.10.10.10
+ - 20.20.20.20
+ ethernets:
+ eno1: {}
+ version: 2''')
+ self.assert_networkd({'bond0.netdev': '''[NetDev]
+Name=bond0
+Kind=bond
+
+[Bond]
+ARPIPTargets=10.10.10.10 20.20.20.20
+''',
+ 'bond0.network': '''[Match]
+Name=bond0
+
+[Network]
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+''',
+ 'eno1.network': '''[Match]
+Name=eno1
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+'''})
+
+ def test_dhcp_critical_true(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ critical: yes
+''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+
+[DHCP]
+CriticalConnection=true
+'''})
+
+ def test_dhcp_identifier_mac(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp-identifier: mac
+''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+ClientIdentifier=mac
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_dhcp_identifier_duid(self):
+ # This option should be silently ignored, since it's the default
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp-identifier: duid
+''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_eth_ipv6_privacy(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: true
+ ipv6-privacy: true''')
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6PrivacyExtensions=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_gateway4(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ gateway4: 192.168.14.1''')
+ self.assertIn("`gateway4` has been deprecated, use default routes instead.", err)
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+Gateway=192.168.14.1
+'''})
+
+ def test_gateway6(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["2001:FFfe::1/64"]
+ gateway6: 2001:FFfe::2''')
+ self.assertIn("`gateway6` has been deprecated, use default routes instead.", err)
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:FFfe::1/64
+Gateway=2001:FFfe::2
+'''})
+
+ def test_gateway_full(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24", "2001:FFfe::1/64"]
+ gateway4: 192.168.14.1
+ gateway6: "2001:FFfe::2"''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+Address=2001:FFfe::1/64
+Gateway=192.168.14.1
+Gateway=2001:FFfe::2
+'''})
+
+ def test_gateways_multi_pass(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: [engreen]
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24", "2001:FFfe::1/64"]
+ gateway4: 192.168.14.1
+ gateway6: "2001:FFfe::2"''')
+
+ self.assert_networkd({
+ 'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=no
+Address=192.168.14.2/24
+Address=2001:FFfe::1/64
+Gateway=192.168.14.1
+Gateway=2001:FFfe::2
+Bridge=br0
+''',
+ 'br0.network': ND_EMPTY % ('br0', 'ipv6'),
+ 'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n'})
+
+ def test_nameserver(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ nameservers:
+ addresses: [1.2.3.4, "1234::FFFF"]
+ enblue:
+ addresses: ["192.168.1.3/24"]
+ nameservers:
+ search: [lab, kitchen]
+ addresses: [8.8.8.8]''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+DNS=1.2.3.4
+DNS=1234::FFFF
+''',
+ 'enblue.network': '''[Match]
+Name=enblue
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.1.3/24
+DNS=8.8.8.8
+Domains=lab kitchen
+'''})
+
+ def test_link_local_all(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: [ ipv4, ipv6 ]
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_link_local_ipv4(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: [ ipv4 ]
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=ipv4
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_link_local_ipv6(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: [ ipv6 ]
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_link_local_disabled(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: [ ]
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=no
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_ip6_addr_gen_mode(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enblue:
+ dhcp6: yes
+ ipv6-address-generation: eui64''')
+ self.assert_networkd({'enblue.network': '''[Match]\nName=enblue\n
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+ def test_ip6_addr_gen_token(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ engreen:
+ dhcp6: yes
+ ipv6-address-token: ::2
+ enblue:
+ dhcp6: yes
+ ipv6-address-token: "::2"''')
+ self.assert_networkd({'engreen.network': '''[Match]\nName=engreen\n
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6Token=static:::2
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'enblue.network': '''[Match]\nName=enblue\n
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+IPv6Token=static:::2
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+
+class TestNetworkManager(TestBase):
+
+ def test_mtu_all(self):
+ self.generate(textwrap.dedent("""
+ network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth1:
+ mtu: 1280
+ dhcp4: n
+ bonds:
+ bond0:
+ interfaces:
+ - eth1
+ mtu: 9000
+ vlans:
+ bond0.108:
+ link: bond0
+ id: 108"""))
+ self.assert_nm({
+ 'bond0.108': '''[connection]
+id=netplan-bond0.108
+type=vlan
+interface-name=bond0.108
+
+[vlan]
+id=108
+parent=bond0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'bond0': '''[connection]
+id=netplan-bond0
+type=bond
+interface-name=bond0
+
+[ethernet]
+mtu=9000
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'eth1': '''[connection]
+id=netplan-eth1
+type=ethernet
+interface-name=eth1
+slave-type=bond
+master=bond0
+
+[ethernet]
+wake-on-lan=0
+mtu=1280
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ })
+
+ def test_activation_mode_off(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ activation-mode: off''', expect_fail=True)
+
+ def test_activation_mode_manual(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ activation-mode: manual''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+autoconnect=false
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_ipv6_mtu(self):
+ self.generate(textwrap.dedent("""
+ network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth1:
+ mtu: 9000
+ ipv6-mtu: 2000"""), expect_fail=True)
+
+ def test_eth_global_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ dhcp4: true''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_eth_type_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: NetworkManager
+ eth0:
+ dhcp4: true''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_eth_def_renderer(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: networkd
+ eth0:
+ renderer: NetworkManager''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_global_renderer_only(self):
+ self.generate(None, confs={'01-default-nm.yaml': 'network: {version: 2, renderer: NetworkManager}'})
+ # should allow NM to manage everything else
+ self.assertTrue(os.path.exists(self.nm_enable_all_conf))
+ # but not configure anything else
+ self.assert_nm(None, None)
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_eth_dhcp6(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0: {dhcp6: true}''')
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+'''})
+
+ def test_eth_dhcp4_and_6(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0: {dhcp4: true, dhcp6: true}''')
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+'''})
+
+ def test_ip6_addr_gen_mode(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp6: yes
+ ipv6-address-generation: stable-privacy
+ enblue:
+ dhcp6: yes
+ ipv6-address-generation: eui64''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+addr-gen-mode=1
+''',
+ 'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+addr-gen-mode=0
+'''})
+
+ def test_ip6_addr_gen_token(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp6: yes
+ ipv6-address-token: ::2
+ enblue:
+ dhcp6: yes
+ ipv6-address-token: "::2"''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+addr-gen-mode=0
+token=::2
+''',
+ 'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+addr-gen-mode=0
+token=::2
+'''})
+
+ def test_eth_manual_addresses(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.2/24
+ - 172.16.0.4/16
+ - 2001:FFfe::1/64''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+address2=172.16.0.4/16
+
+[ipv6]
+method=manual
+address1=2001:FFfe::1/64
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_eth_manual_addresses_dhcp(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: yes
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+address1=192.168.14.2/24
+
+[ipv6]
+method=manual
+address1=2001:FFfe::1/64
+'''})
+
+ def test_eth_ipv6_privacy(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0: {dhcp6: true, ipv6-privacy: true}''')
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+ip6-privacy=2
+'''})
+
+ def test_gateway(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24", "2001:FFfe::1/64"]
+ gateway4: 192.168.14.1
+ gateway6: 2001:FFfe::2''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+gateway=192.168.14.1
+
+[ipv6]
+method=manual
+address1=2001:FFfe::1/64
+gateway=2001:FFfe::2
+'''})
+
+ def test_nameserver(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ nameservers:
+ addresses: [1.2.3.4, 2.3.4.5, "1234::FFFF"]
+ search: [lab, kitchen]
+ enblue:
+ addresses: ["192.168.1.3/24"]
+ nameservers:
+ addresses: [8.8.8.8]''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+dns=1.2.3.4;2.3.4.5;
+dns-search=lab;kitchen;
+
+[ipv6]
+method=manual
+dns=1234::FFFF;
+dns-search=lab;kitchen;
+''',
+ 'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.1.3/24
+dns=8.8.8.8;
+
+[ipv6]
+method=ignore
+'''})
+
+
+class TestForwardDeclaration(TestBase):
+
+ def test_fwdecl_bridge_on_bond(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: ['bond0']
+ dhcp4: true
+ bonds:
+ bond0:
+ interfaces: ['eth0', 'eth1']
+ ethernets:
+ eth0:
+ match:
+ macaddress: 00:01:02:03:04:05
+ set-name: eth0
+ eth1:
+ match:
+ macaddress: 02:01:02:03:04:05
+ set-name: eth1
+''')
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'bond0.netdev': '[NetDev]\nName=bond0\nKind=bond\n',
+ 'bond0.network': '''[Match]
+Name=bond0
+
+[Network]
+LinkLocalAddressing=no
+ConfigureWithoutCarrier=yes
+Bridge=br0
+''',
+ 'eth0.link': '''[Match]
+MACAddress=00:01:02:03:04:05
+Type=!vlan bond bridge
+
+[Link]
+Name=eth0
+WakeOnLan=off
+''',
+ 'eth0.network': '''[Match]
+MACAddress=00:01:02:03:04:05
+Name=eth0
+Type=!vlan bond bridge
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+''',
+ 'eth1.link': '''[Match]
+MACAddress=02:01:02:03:04:05
+Type=!vlan bond bridge
+
+[Link]
+Name=eth1
+WakeOnLan=off
+''',
+ 'eth1.network': '''[Match]
+MACAddress=02:01:02:03:04:05
+Name=eth1
+Type=!vlan bond bridge
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+'''})
+
+ def test_fwdecl_feature_blend(self):
+ self.generate('''network:
+ version: 2
+ vlans:
+ vlan1:
+ link: 'br0'
+ id: 1
+ dhcp4: true
+ bridges:
+ br0:
+ interfaces: ['bond0', 'eth2']
+ parameters:
+ path-cost:
+ eth2: 1000
+ bond0: 8888
+ bonds:
+ bond0:
+ interfaces: ['eth0', 'br1']
+ ethernets:
+ eth0:
+ match:
+ macaddress: 00:01:02:03:04:05
+ set-name: eth0
+ bridges:
+ br1:
+ interfaces: ['eth1']
+ ethernets:
+ eth1:
+ match:
+ macaddress: 02:01:02:03:04:05
+ set-name: eth1
+ eth2:
+ match:
+ name: eth2
+''', skip_generated_yaml_validation=True)
+ # XXX: We need to skeip the generated YAML validation, as the pyYAML
+ # parser overrides the duplicate "ethernets"/"bridges" keys, while
+ # the netplan C YAML parser merges them into the netdef
+
+ self.assert_networkd({'vlan1.netdev': '[NetDev]\nName=vlan1\nKind=vlan\n\n'
+ '[VLAN]\nId=1\n',
+ 'vlan1.network': '''[Match]
+Name=vlan1
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n\n'
+ '[Bridge]\nSTP=true\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+VLAN=vlan1
+''',
+ 'bond0.netdev': '[NetDev]\nName=bond0\nKind=bond\n',
+ 'bond0.network': '''[Match]
+Name=bond0
+
+[Network]
+LinkLocalAddressing=no
+ConfigureWithoutCarrier=yes
+Bridge=br0
+
+[Bridge]
+Cost=8888
+''',
+ 'eth2.network': '[Match]\nName=eth2\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n\n'
+ '[Bridge]\nCost=1000\n',
+ 'br1.netdev': '[NetDev]\nName=br1\nKind=bridge\n',
+ 'br1.network': '''[Match]
+Name=br1
+
+[Network]
+LinkLocalAddressing=no
+ConfigureWithoutCarrier=yes
+Bond=bond0
+''',
+ 'eth0.link': '''[Match]
+MACAddress=00:01:02:03:04:05
+Type=!vlan bond bridge
+
+[Link]
+Name=eth0
+WakeOnLan=off
+''',
+ 'eth0.network': '''[Match]
+MACAddress=00:01:02:03:04:05
+Name=eth0
+Type=!vlan bond bridge
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+''',
+ 'eth1.link': '''[Match]
+MACAddress=02:01:02:03:04:05
+Type=!vlan bond bridge
+
+[Link]
+Name=eth1
+WakeOnLan=off
+''',
+ 'eth1.network': '''[Match]
+MACAddress=02:01:02:03:04:05
+Name=eth1
+Type=!vlan bond bridge
+
+[Network]
+LinkLocalAddressing=no
+Bridge=br1
+'''})
+
+
+class TestMerging(TestBase):
+ '''multiple *.yaml merging'''
+
+ def test_global_backend(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: y''',
+ confs={'backend': 'network:\n renderer: networkd'})
+
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:engreen,''')
+ self.assert_nm_udev(None)
+
+ def test_add_def(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: true''',
+ confs={'blue': '''network:
+ version: 2
+ ethernets:
+ enblue:
+ dhcp4: true'''})
+
+ self.assert_networkd({'enblue.network': ND_DHCP4 % 'enblue',
+ 'engreen.network': ND_DHCP4 % 'engreen'})
+ # Skip on codecov.io; GLib changed hashtable elements ordering between
+ # releases, so we can't depend on the exact order.
+ # TODO: (cyphermox) turn this into an "assert_in_nm()" function.
+ if "CODECOV_TOKEN" not in os.environ: # pragma: nocover
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:engreen,interface-name:enblue,''')
+ self.assert_nm_udev(None)
+
+ def test_change_def(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ wakeonlan: true
+ dhcp4: false''',
+ confs={'green-dhcp': '''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: true'''})
+
+ self.assert_networkd({'engreen.link': '[Match]\nOriginalName=engreen\n\n[Link]\nWakeOnLan=magic\n',
+ 'engreen.network': ND_DHCP4 % 'engreen'})
+
+ def test_cleanup_old_config(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen: {dhcp4: true}
+ enyellow: {renderer: NetworkManager}''',
+ confs={'blue': '''network:
+ version: 2
+ ethernets:
+ enblue:
+ dhcp4: true'''})
+
+ os.unlink(os.path.join(self.confdir, 'blue.yaml'))
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen: {dhcp4: true}''')
+
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:engreen,''')
+ self.assert_nm_udev(None)
+
+ def test_ref(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eno1: {}
+ switchports:
+ match:
+ driver: yayroute''',
+ confs={'bridges': '''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: [eno1, switchports]
+ dhcp4: true'''}, skip_generated_yaml_validation=True)
+ # XXX: We need to skip the generated YAML validation, as the 'bridges'
+ # conf is invalid in itself (missing eno1 & switchports defs) and
+ # can only be parsed if merged with the main YAML
+
+ self.assert_networkd({'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]
+Name=br0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+''',
+ 'eno1.network': '[Match]\nName=eno1\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'switchports.network': '[Match]\nDriver=yayroute\n\n'
+ '[Network]\nLinkLocalAddressing=no\nBridge=br0\n'})
+
+ def test_def_in_run(self):
+ rundir = os.path.join(self.workdir.name, 'run', 'netplan')
+ os.makedirs(rundir)
+ # override b.yaml definition for enred
+ with open(os.path.join(rundir, 'b.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {enred: {dhcp4: true}}''')
+
+ # append new definition for enblue
+ with open(os.path.join(rundir, 'c.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {enblue: {dhcp4: true}}''')
+
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen: {dhcp4: true}''', confs={'b': '''network:
+ version: 2
+ ethernets: {enred: {wakeonlan: true}}'''})
+
+ # b.yaml in /run/ should completely shadow b.yaml in /etc, thus no enred.link
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen',
+ 'enred.network': ND_DHCP4 % 'enred',
+ 'enblue.network': ND_DHCP4 % 'enblue'})
+
+ def test_def_in_lib(self):
+ libdir = os.path.join(self.workdir.name, 'lib', 'netplan')
+ rundir = os.path.join(self.workdir.name, 'run', 'netplan')
+ os.makedirs(libdir)
+ os.makedirs(rundir)
+ # b.yaml is in /etc/netplan too which should have precedence
+ with open(os.path.join(libdir, 'b.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {notme: {dhcp4: true}}''')
+
+ # /run should trump /lib too
+ with open(os.path.join(libdir, 'c.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {alsonot: {dhcp4: true}}''')
+ with open(os.path.join(rundir, 'c.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {enyellow: {dhcp4: true}}''')
+
+ # this should be considered
+ with open(os.path.join(libdir, 'd.yaml'), 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets: {enblue: {dhcp4: true}}''')
+
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen: {dhcp4: true}''', confs={'b': '''network:
+ version: 2
+ ethernets: {enred: {wakeonlan: true}}'''})
+
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen',
+ 'enred.link': '[Match]\nOriginalName=enred\n\n[Link]\nWakeOnLan=magic\n',
+ 'enred.network': '''[Match]
+Name=enred
+
+[Network]
+LinkLocalAddressing=ipv6
+''',
+ 'enyellow.network': ND_DHCP4 % 'enyellow',
+ 'enblue.network': ND_DHCP4 % 'enblue'})
diff --git a/tests/generator/test_dhcp_overrides.py b/tests/generator/test_dhcp_overrides.py
new file mode 100644
index 0000000..7d5bb61
--- /dev/null
+++ b/tests/generator/test_dhcp_overrides.py
@@ -0,0 +1,426 @@
+#
+# Tests for DHCP override handling in netplan generator
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import (TestBase, ND_DHCP4, ND_DHCP4_NOMTU, ND_DHCP6,
+ ND_DHCP6_NOMTU, ND_DHCPYES, ND_DHCPYES_NOMTU)
+
+
+class TestNetworkd(TestBase):
+
+ # Common tests for dhcp override booleans
+ def assert_dhcp_overrides_bool(self, override_name, networkd_name):
+ # dhcp4 yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+''' % override_name)
+ # silently ignored since yes is the default
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen'})
+
+ # dhcp6 yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: yes
+''' % override_name)
+ # silently ignored since yes is the default
+ self.assert_networkd({'engreen.network': ND_DHCP6 % 'engreen'})
+
+ # dhcp4 and dhcp6 both yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: yes
+''' % (override_name, override_name))
+ # silently ignored since yes is the default
+ self.assert_networkd({'engreen.network': ND_DHCPYES % 'engreen'})
+
+ # dhcp4 no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: no
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen' + '%s=false\n' % networkd_name})
+
+ # dhcp6 no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP6 % 'engreen' + '%s=false\n' % networkd_name})
+
+ # dhcp4 and dhcp6 both no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: no
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % (override_name, override_name))
+ self.assert_networkd({'engreen.network': ND_DHCPYES % 'engreen' + '%s=false\n' % networkd_name})
+
+ # mismatched values
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % (override_name, override_name), expect_fail=True)
+ self.assertEqual(err, 'ERROR: engreen: networkd requires that '
+ '%s has the same value in both dhcp4_overrides and dhcp6_overrides\n' % override_name)
+
+ # Common tests for dhcp override strings
+ def assert_dhcp_overrides_string(self, override_name, networkd_name):
+ # dhcp4 only
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: foo
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen' + '%s=foo\n' % networkd_name})
+
+ # dhcp6 only
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: foo
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP6 % 'engreen' + '%s=foo\n' % networkd_name})
+
+ # dhcp4 and dhcp6
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: foo
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: foo
+''' % (override_name, override_name))
+ self.assert_networkd({'engreen.network': ND_DHCPYES % 'engreen' + '%s=foo\n' % networkd_name})
+
+ # mismatched values
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: foo
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: bar
+''' % (override_name, override_name), expect_fail=True)
+ self.assertEqual(err, 'ERROR: engreen: networkd requires that '
+ '%s has the same value in both dhcp4_overrides and dhcp6_overrides\n' % override_name)
+
+ # Common tests for dhcp override booleans
+ def assert_dhcp_mtu_overrides_bool(self, override_name, networkd_name):
+ # dhcp4 yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen'})
+
+ # dhcp6 yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: yes
+''' % override_name)
+ # silently ignored since yes is the default
+ self.assert_networkd({'engreen.network': ND_DHCP6 % 'engreen'})
+
+ # dhcp4 and dhcp6 both yes
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: yes
+''' % (override_name, override_name))
+ # silently ignored since yes is the default
+ self.assert_networkd({'engreen.network': ND_DHCPYES % 'engreen'})
+
+ # dhcp4 no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: no
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP4_NOMTU % 'engreen'})
+
+ # dhcp6 no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % override_name)
+ self.assert_networkd({'engreen.network': ND_DHCP6_NOMTU % 'engreen'})
+
+ # dhcp4 and dhcp6 both no
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: no
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % (override_name, override_name))
+ self.assert_networkd({'engreen.network': ND_DHCPYES_NOMTU % 'engreen'})
+
+ # mismatched values
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: yes
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: no
+''' % (override_name, override_name), expect_fail=True)
+ self.assertEqual(err, 'ERROR: engreen: networkd requires that '
+ '%s has the same value in both dhcp4_overrides and dhcp6_overrides\n' % override_name)
+
+ def assert_dhcp_overrides_guint(self, override_name, networkd_name):
+ # dhcp4 only
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: 6000
+''' % override_name)
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=6000
+UseMTU=true
+'''})
+
+ # dhcp6 only
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: 6000
+''' % override_name)
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv6
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=6000
+UseMTU=true
+'''})
+
+ # dhcp4 and dhcp6
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: 6000
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: 6000
+''' % (override_name, override_name))
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=6000
+UseMTU=true
+'''})
+
+ # mismatched values
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ %s: 3333
+ dhcp6: yes
+ dhcp6-overrides:
+ %s: 5555
+''' % (override_name, override_name), expect_fail=True)
+ self.assertEqual(err, 'ERROR: engreen: networkd requires that '
+ '%s has the same value in both dhcp4_overrides and dhcp6_overrides\n' % override_name)
+
+ def test_dhcp_overrides_use_dns(self):
+ self.assert_dhcp_overrides_bool('use-dns', 'UseDNS')
+
+ def test_dhcp_overrides_use_domains(self):
+ self.assert_dhcp_overrides_string('use-domains', 'UseDomains')
+
+ def test_dhcp_overrides_use_ntp(self):
+ self.assert_dhcp_overrides_bool('use-ntp', 'UseNTP')
+
+ def test_dhcp_overrides_send_hostname(self):
+ self.assert_dhcp_overrides_bool('send-hostname', 'SendHostname')
+
+ def test_dhcp_overrides_use_hostname(self):
+ self.assert_dhcp_overrides_bool('use-hostname', 'UseHostname')
+
+ def test_dhcp_overrides_hostname(self):
+ self.assert_dhcp_overrides_string('hostname', 'Hostname')
+
+ def test_dhcp_overrides_use_mtu(self):
+ self.assert_dhcp_mtu_overrides_bool('use-mtu', 'UseMTU')
+
+ def test_dhcp_overrides_default_metric(self):
+ self.assert_dhcp_overrides_guint('route-metric', 'RouteMetric')
+
+ def test_dhcp_overrides_use_routes(self):
+ self.assert_dhcp_overrides_bool('use-routes', 'UseRoutes')
+
+
+class TestNetworkManager(TestBase):
+
+ def test_override_default_metric_v4(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp4-overrides:
+ route-metric: 3333
+''')
+ # silently ignored since yes is the default
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+route-metric=3333
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_override_default_metric_v6(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ dhcp6-overrides:
+ route-metric: 6666
+''')
+ # silently ignored since yes is the default
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+route-metric=6666
+'''})
diff --git a/tests/generator/test_errors.py b/tests/generator/test_errors.py
new file mode 100644
index 0000000..da91e4b
--- /dev/null
+++ b/tests/generator/test_errors.py
@@ -0,0 +1,976 @@
+#
+# Tests for common invalid syntax/errors in config
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase
+
+
+class TestConfigErrors(TestBase):
+ def test_malformed_yaml(self):
+ err = self.generate('network:\n version: %&', expect_fail=True)
+ self.assertIn('Invalid YAML', err)
+ self.assertIn('found character that cannot start any token', err)
+
+ def test_wrong_indent(self):
+ err = self.generate('network:\n version: 2\n foo: *', expect_fail=True)
+ self.assertIn('Invalid YAML', err)
+ self.assertIn('inconsistent indentation', err)
+
+ def test_yaml_expected_scalar(self):
+ err = self.generate('network:\n version: {}', expect_fail=True)
+ self.assertIn('expected scalar', err)
+
+ def test_yaml_expected_sequence(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ interfaces: {}''', expect_fail=True)
+ self.assertIn('expected sequence', err)
+
+ def test_yaml_expected_mapping(self):
+ err = self.generate('network:\n version', expect_fail=True)
+ self.assertIn('expected mapping', err)
+
+ def test_invalid_bool(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ id0:
+ wakeonlan: wut
+''', expect_fail=True)
+ self.assertIn("invalid boolean value 'wut'", err)
+
+ def test_invalid_version(self):
+ err = self.generate('network:\n version: 1', expect_fail=True)
+ self.assertIn('Only version 2 is supported', err)
+
+ def test_id_redef_type_mismatch(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ id0:
+ wakeonlan: true''',
+ confs={'redef': '''network:
+ version: 2
+ bridges:
+ id0:
+ wakeonlan: true'''}, expect_fail=True)
+ self.assertIn("Updated definition 'id0' changes device type", err)
+
+ def test_set_name_without_match(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ set-name: lom1
+''', expect_fail=True)
+ self.assertIn("def1: 'set-name:' requires 'match:' properties", err)
+
+ def test_virtual_set_name(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ set_name: br1''', expect_fail=True)
+ self.assertIn("unknown key 'set_name'", err)
+
+ def test_virtual_match(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ match:
+ driver: foo''', expect_fail=True)
+ self.assertIn("unknown key 'match'", err)
+
+ def test_virtual_wol(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ wakeonlan: true''', expect_fail=True)
+ self.assertIn("unknown key 'wakeonlan'", err)
+
+ def test_unknown_global_renderer(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: bogus
+''', expect_fail=True)
+ self.assertIn("unknown renderer 'bogus'", err)
+
+ def test_unknown_type_renderer(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ renderer: bogus
+''', expect_fail=True)
+ self.assertIn("unknown renderer 'bogus'", err)
+
+ def test_invalid_id(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ "eth 0":
+ dhcp4: true''', expect_fail=True)
+ self.assertIn("Invalid name 'eth 0'", err)
+
+ def test_invalid_name_match(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ name: |
+ fo o
+ bar
+ dhcp4: true''', expect_fail=True)
+ self.assertIn("Invalid name 'fo o\nbar\n'", err)
+
+ def test_invalid_mac_match(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ macaddress: 00:11:ZZ
+ dhcp4: true''', expect_fail=True)
+ self.assertIn("Invalid MAC address '00:11:ZZ', must be XX:XX:XX:XX:XX:XX", err)
+
+ def test_glob_in_id(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ en*:
+ dhcp4: true''', expect_fail=True)
+ self.assertIn("Definition ID 'en*' must not use globbing", err)
+
+ def test_wifi_duplicate_ssid(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ password: "s3kr1t"
+ workplace:
+ password: "c0mpany"
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("wl0: Duplicate access point SSID 'workplace'", err)
+
+ def test_wifi_no_ap(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn('wl0: No access points defined', err)
+
+ def test_wifi_empty_ap(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points: {}
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn('wl0: No access points defined', err)
+
+ def test_wifi_ap_unknown_key(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ something: false
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("unknown key 'something'", err)
+
+ def test_wifi_ap_unknown_mode(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ mode: bogus''', expect_fail=True)
+ self.assertIn("unknown wifi mode 'bogus'", err)
+
+ def test_wifi_ap_unknown_band(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ band: bogus''', expect_fail=True)
+ self.assertIn("unknown wifi band 'bogus'", err)
+
+ def test_wifi_ap_invalid_freq24(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ band: 2.4GHz
+ channel: 15''', expect_fail=True)
+ self.assertIn("ERROR: invalid 2.4GHz WiFi channel: 15", err)
+
+ def test_wifi_ap_invalid_freq5(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ band: 5GHz
+ channel: 14''', expect_fail=True)
+ self.assertIn("ERROR: invalid 5GHz WiFi channel: 14", err)
+
+ def test_wifi_invalid_hidden(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ hidden:
+ hidden: maybe''', expect_fail=True)
+ self.assertIn("invalid boolean value 'maybe'", err)
+
+ def test_invalid_ipv4_address(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ self.assertIn("malformed address '192.168.14/24', must be X.X.X.X/NN", err)
+
+ def test_missing_ipv4_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.1''', expect_fail=True)
+
+ self.assertIn("address '192.168.14.1' is missing /prefixlength", err)
+
+ def test_empty_ipv4_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.1/''', expect_fail=True)
+
+ self.assertIn("invalid prefix length in address '192.168.14.1/'", err)
+
+ def test_invalid_ipv4_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.14.1/33''', expect_fail=True)
+
+ self.assertIn("invalid prefix length in address '192.168.14.1/33'", err)
+
+ def test_invalid_ipv6_address(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 2001:G::1/64''', expect_fail=True)
+
+ self.assertIn("malformed address '2001:G::1/64', must be X.X.X.X/NN", err)
+
+ def test_missing_ipv6_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 2001::1''', expect_fail=True)
+ self.assertIn("address '2001::1' is missing /prefixlength", err)
+
+ def test_invalid_ipv6_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 2001::1/129''', expect_fail=True)
+ self.assertIn("invalid prefix length in address '2001::1/129'", err)
+
+ def test_empty_ipv6_prefixlen(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 2001::1/''', expect_fail=True)
+ self.assertIn("invalid prefix length in address '2001::1/'", err)
+
+ def test_invalid_addr_gen_mode(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ ipv6-address-generation: 0''', expect_fail=True)
+ self.assertIn("unknown ipv6-address-generation '0'", err)
+
+ def test_addr_gen_mode_not_supported(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ ipv6-address-generation: stable-privacy''', expect_fail=True)
+ self.assertIn("ERROR: engreen: ipv6-address-generation mode is not supported by networkd", err)
+
+ def test_addr_gen_mode_and_addr_gen_token(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ ipv6-address-token: "::2"
+ ipv6-address-generation: eui64''', expect_fail=True)
+ self.assertIn("engreen: ipv6-address-generation and ipv6-address-token are mutually exclusive", err)
+
+ def test_invalid_addr_gen_token(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ ipv6-address-token: INVALID''', expect_fail=True)
+ self.assertIn("invalid ipv6-address-token 'INVALID'", err)
+
+ def test_nm_devices_missing_passthrough(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ nm-devices:
+ engreen:
+ networkmanager:
+ passthrough:
+ connection.uuid: "123456"''', expect_fail=True)
+ self.assertIn("engreen: network type 'nm-devices:' needs to provide a 'connection.type' via passthrough", err)
+
+ def test_invalid_address_node_type(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [[192.168.1.15]]''', expect_fail=True)
+ self.assertIn("expected either scalar or mapping (check indentation)", err)
+
+ def test_invalid_address_option_value(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 0.0.0.0.0/24:
+ lifetime: 0''', expect_fail=True)
+ self.assertIn("malformed address '0.0.0.0.0/24', must be X.X.X.X/NN or X:X:X:X:X:X:X:X/NN", err)
+
+ def test_invalid_address_option_lifetime(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.1.15/24:
+ lifetime: 1''', expect_fail=True)
+ self.assertIn("invalid lifetime value '1'", err)
+
+ def test_invalid_nm_options(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses:
+ - 192.168.1.15/24:
+ lifetime: 0''', expect_fail=True)
+ self.assertIn('NetworkManager does not support address options', err)
+
+ def test_invalid_gateway4(self):
+ for a in ['300.400.1.1', '1.2.3', '192.168.14.1/24']:
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ gateway4: %s''' % a, expect_fail=True)
+ self.assertIn("invalid IPv4 address '%s'" % a, err)
+
+ def test_invalid_gateway6(self):
+ for a in ['1234', '1:::c', '1234::1/50']:
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ gateway6: %s''' % a, expect_fail=True)
+ self.assertIn("invalid IPv6 address '%s'" % a, err)
+
+ def test_multiple_ip4_gateways(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [192.168.22.78/24]
+ gateway4: 192.168.22.1
+ enblue:
+ addresses: [10.49.34.4/16]
+ gateway4: 10.49.2.38''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv4 (table: main, metric: default)", err)
+ self.assertIn("engreen", err)
+ self.assertIn("enblue", err)
+
+ def test_multiple_ip6_gateways(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [2001:FFfe::1/62]
+ gateway6: 2001:FFfe::2
+ enblue:
+ addresses: [2001:FFfe::33/62]
+ gateway6: 2001:FFfe::34''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv6 (table: main, metric: default)", err)
+ self.assertIn("engreen", err)
+ self.assertIn("enblue", err)
+
+ def test_gateway_and_default_route(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [10.49.34.4/16]
+ gateway4: 10.49.2.38
+ routes:
+ - to: default
+ via: 10.49.65.89''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv4 (table: main, metric: default)", err)
+ self.assertIn("engreen", err)
+
+ def test_multiple_default_routes_on_other_table(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [10.49.34.4/16]
+ routes:
+ - to: default
+ via: 10.49.65.89
+ enblue:
+ addresses: [10.50.35.3/16]
+ routes:
+ - to: default
+ via: 10.49.65.89
+ table: 23
+ enred:
+ addresses: [172.137.1.4/24]
+ routes:
+ - to: default
+ via: 172.137.1.1
+ table: 23
+ ''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv4 (table: 23, metric: default)", err)
+ self.assertIn("enblue", err)
+ self.assertIn("enred", err)
+ self.assertNotIn("engreen", err)
+
+ def test_multiple_default_routes_on_specific_metrics(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [10.49.34.4/16]
+ routes:
+ - to: default
+ via: 10.49.65.89
+ metric: 100
+ enblue:
+ addresses: [10.50.35.3/16]
+ routes:
+ - to: default
+ via: 10.49.65.89
+ metric: 600
+ enred:
+ addresses: [172.137.1.4/24]
+ routes:
+ - to: default
+ via: 172.137.1.1
+ metric: 600
+ ''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv4 (table: main, metric: 600)", err)
+ self.assertIn("enblue", err)
+ self.assertIn("enred", err)
+ self.assertNotIn("engreen", err)
+
+ def test_default_route_and_0(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: [10.49.34.4/16]
+ routes:
+ - to: default
+ via: 10.49.65.89
+ - to: 0.0.0.0/0
+ via: 10.49.65.67''', expect_fail=False)
+ self.assertIn("Problem encountered while validating default route consistency", err)
+ self.assertIn("Conflicting default route declarations for IPv4 (table: main, metric: default)", err)
+ self.assertIn("engreen", err)
+
+ def test_invalid_nameserver_ipv4(self):
+ for a in ['300.400.1.1', '1.2.3', '192.168.14.1/24']:
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ nameservers:
+ addresses: [%s]''' % a, expect_fail=True)
+ self.assertIn("malformed address '%s'" % a, err)
+
+ def test_invalid_nameserver_ipv6(self):
+ for a in ['1234', '1:::c', '1234::1/50']:
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ nameservers:
+ addresses: ["%s"]''' % a, expect_fail=True)
+ self.assertIn("malformed address '%s'" % a, err)
+
+ def test_vlan_missing_id(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets: {en1: {}}
+ vlans:
+ ena: {link: en1}''', expect_fail=True)
+ self.assertIn("missing 'id' property", err)
+
+ def test_vlan_invalid_id(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets: {en1: {}}
+ vlans:
+ ena: {id: a, link: en1}''', expect_fail=True)
+ self.assertIn("invalid unsigned int value 'a'", err)
+
+ err = self.generate('''network:
+ version: 2
+ ethernets: {en1: {}}
+ vlans:
+ ena: {id: 4095, link: en1}''', expect_fail=True)
+ self.assertIn("invalid id '4095'", err)
+
+ def test_vlan_missing_link(self):
+ err = self.generate('''network:
+ version: 2
+ vlans:
+ ena: {id: 1}''', expect_fail=True)
+ self.assertIn("ena: missing 'link' property", err)
+
+ def test_vlan_unknown_link(self):
+ err = self.generate('''network:
+ version: 2
+ vlans:
+ ena: {id: 1, link: en1}''', expect_fail=True)
+ self.assertIn("ena: interface 'en1' is not defined", err)
+
+ def test_vlan_unknown_renderer(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets: {en1: {}}
+ vlans:
+ ena: {id: 1, link: en1, renderer: foo}''', expect_fail=True)
+ self.assertIn("unknown renderer 'foo'", err)
+
+ def test_device_bad_route_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: badlocation
+ via: 192.168.14.20
+ metric: 100
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_bad_route_via(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 10.10.0.0/16
+ via: badgateway
+ metric: 100
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_bad_route_metric(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 10.10.0.0/16
+ via: 10.1.1.1
+ metric: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_bad_route_mtu(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 10.10.0.0/16
+ via: 10.1.1.1
+ mtu: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ self.assertIn("invalid unsigned int value '-1'", err)
+
+ def test_device_bad_route_congestion_window(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 10.10.0.0/16
+ via: 10.1.1.1
+ congestion-window: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ self.assertIn("invalid unsigned int value '-1'", err)
+
+ def test_device_bad_route_advertised_receive_window(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 10.10.0.0/16
+ via: 10.1.1.1
+ advertised-receive-window: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ self.assertIn("invalid unsigned int value '-1'", err)
+
+ def test_device_route_family_mismatch_ipv6_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 2001:dead:beef::0/16
+ via: 10.1.1.1
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_family_mismatch_ipv4_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ to: 10.10.10.0/24
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_missing_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_missing_via(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - to: 2001:dead:beef::2
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_type_missing_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ type: prohibit
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_scope_link_missing_to(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ scope: link
+ metric: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_invalid_onlink(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ to: 2000:cafe:cafe::1/24
+ on-link: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_invalid_table(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ to: 2000:cafe:cafe::1/24
+ table: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_invalid_type(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ to: 2000:cafe:cafe::1/24
+ type: thisisinvalidtype
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_route_invalid_scope(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routes:
+ - via: 2001:dead:beef::2
+ to: 2000:cafe:cafe::1/24
+ scope: linky
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_mismatched_addresses(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - from: 10.10.10.0/24
+ to: 2000:dead:beef::3/64
+ table: 50
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_missing_address(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - table: 50
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_invalid_tos(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - from: 10.10.10.0/24
+ type-of-service: 256
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_invalid_prio(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - from: 10.10.10.0/24
+ priority: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_invalid_table(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - from: 10.10.10.0/24
+ table: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_invalid_fwmark(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - from: 10.10.10.0/24
+ mark: -1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_device_ip_rule_invalid_address(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ routing-policy:
+ - to: 10.10.10.0/24
+ from: someinvalidaddress
+ mark: 1
+ addresses:
+ - 192.168.14.2/24
+ - 2001:FFfe::1/64''', expect_fail=True)
+
+ def test_invalid_dhcp_identifier(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp-identifier: invalid''', expect_fail=True)
+
+ def test_invalid_accept_ra(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ accept-ra: j''', expect_fail=True)
+ self.assertIn('invalid boolean', err)
+
+ def test_invalid_link_local_set(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: invalid''', expect_fail=True)
+
+ def test_invalid_link_local_value(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: yes
+ dhcp6: yes
+ link-local: [ invalid, ]''', expect_fail=True)
+
+ def test_invalid_yaml_tabs(self):
+ err = self.generate('''\t''', expect_fail=True)
+ self.assertIn("tabs are not allowed for indent", err)
+
+ def test_invalid_yaml_undefined_alias(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ *engreen:
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn("aliases are not supported", err)
+
+ def test_invalid_yaml_undefined_alias_at_eof(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: *yes''', expect_fail=True)
+ self.assertIn("aliases are not supported", err)
+
+ def test_invalid_activation_mode(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ activation-mode: invalid''', expect_fail=True)
+ self.assertIn("needs to be 'manual' or 'off'", err)
diff --git a/tests/generator/test_ethernets.py b/tests/generator/test_ethernets.py
new file mode 100644
index 0000000..ac8ffc8
--- /dev/null
+++ b/tests/generator/test_ethernets.py
@@ -0,0 +1,715 @@
+#
+# Tests for ethernet devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from .base import TestBase, ND_DHCP4, UDEV_MAC_RULE, UDEV_NO_MAC_RULE, UDEV_SRIOV_RULE
+
+
+class TestNetworkd(TestBase):
+
+ def test_eth_wol(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ wakeonlan: true
+ dhcp4: n''')
+
+ self.assert_networkd({'eth0.link': '[Match]\nOriginalName=eth0\n\n[Link]\nWakeOnLan=magic\n',
+ 'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_networkd_udev(None)
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:eth0,''')
+ self.assert_nm_udev(None)
+ # should not allow NM to manage everything
+ self.assertFalse(os.path.exists(self.nm_enable_all_conf))
+
+ def test_eth_lldp(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: n
+ emit-lldp: true''')
+
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+EmitLLDP=true
+LinkLocalAddressing=ipv6
+'''})
+
+ def test_eth_mtu(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1:
+ mtu: 1280
+ dhcp4: n''')
+
+ self.assert_networkd({'eth1.link': '[Match]\nOriginalName=eth1\n\n[Link]\nWakeOnLan=off\nMTUBytes=1280\n',
+ 'eth1.network': '''[Match]
+Name=eth1
+
+[Link]
+MTUBytes=1280
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_networkd_udev(None)
+
+ def test_eth_sriov_vlan_filterv_link(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ enp1:
+ dhcp4: n
+ enp1s16f1:
+ dhcp4: n
+ link: enp1''')
+
+ self.assert_networkd({'enp1.network': '''[Match]
+Name=enp1
+
+[Network]
+LinkLocalAddressing=ipv6
+''',
+ 'enp1s16f1.network': '''[Match]
+Name=enp1s16f1
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_additional_udev({'99-sriov-netplan-setup.rules': UDEV_SRIOV_RULE})
+
+ def test_eth_sriov_virtual_functions(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ enp1:
+ virtual-function-count: 8''')
+
+ self.assert_networkd({'enp1.network': '''[Match]
+Name=enp1
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_additional_udev({'99-sriov-netplan-setup.rules': UDEV_SRIOV_RULE})
+
+ def test_eth_match_by_driver_rename(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ driver: ixgbe
+ set-name: lom1''')
+
+ self.assert_networkd({'def1.link': '[Match]\nDriver=ixgbe\n\n[Link]\nName=lom1\nWakeOnLan=off\n',
+ 'def1.network': '''[Match]
+Driver=ixgbe
+Name=lom1
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_networkd_udev({'def1.rules': (UDEV_NO_MAC_RULE % ('ixgbe', 'lom1'))})
+ # NM cannot match by driver, so blacklisting needs to happen via udev
+ self.assert_nm(None, None)
+ self.assert_nm_udev('ACTION=="add|change", SUBSYSTEM=="net", ENV{ID_NET_DRIVER}=="ixgbe", ENV{NM_UNMANAGED}="1"\n')
+
+ def test_eth_match_by_mac_rename(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ macaddress: 11:22:33:44:55:66
+ set-name: lom1''')
+
+ self.assert_networkd({'def1.link': '[Match]\nMACAddress=11:22:33:44:55:66\n\n[Link]\nName=lom1\nWakeOnLan=off\n',
+ 'def1.network': '''[Match]
+MACAddress=11:22:33:44:55:66
+Name=lom1
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_networkd_udev({'def1.rules': (UDEV_MAC_RULE % ('?*', '11:22:33:44:55:66', 'lom1'))})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=mac:11:22:33:44:55:66,interface-name:lom1,''')
+ self.assert_nm_udev(None)
+
+ def test_eth_implicit_name_match_dhcp4(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: y''')
+
+ self.assert_networkd({'engreen.network': ND_DHCP4 % 'engreen'})
+ self.assert_networkd_udev(None)
+
+ def test_eth_match_dhcp4(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ driver: ixgbe
+ dhcp4: true''')
+
+ self.assert_networkd({'def1.network': '''[Match]
+Driver=ixgbe
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_networkd_udev(None)
+ self.assert_nm_udev('ACTION=="add|change", SUBSYSTEM=="net", ENV{ID_NET_DRIVER}=="ixgbe", ENV{NM_UNMANAGED}="1"\n')
+
+ def test_eth_match_name(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ name: green
+ dhcp4: true''')
+
+ self.assert_networkd({'def1.network': ND_DHCP4 % 'green'})
+ self.assert_networkd_udev(None)
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:green,''')
+ self.assert_nm_udev(None)
+
+ def test_eth_set_mac(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ name: green
+ macaddress: 00:01:02:03:04:05
+ dhcp4: true''')
+
+ self.assert_networkd({'def1.network': (ND_DHCP4 % 'green')
+ .replace('[Network]', '[Link]\nMACAddress=00:01:02:03:04:05\n\n[Network]')
+ })
+ self.assert_networkd_udev(None)
+
+ def test_eth_match_name_rename(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ name: green
+ set-name: blue
+ dhcp4: true''')
+
+ # the .network needs to match on the renamed name
+ self.assert_networkd({'def1.link': '[Match]\nOriginalName=green\n\n[Link]\nName=blue\nWakeOnLan=off\n',
+ 'def1.network': ND_DHCP4 % 'blue'})
+
+ # The udev rules engine does support renaming by name
+ self.assert_networkd_udev(None)
+
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:blue,''')
+
+ def test_eth_match_all_names(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match: {name: "*"}
+ dhcp4: true''')
+
+ self.assert_networkd({'def1.network': ND_DHCP4 % '*'})
+ self.assert_networkd_udev(None)
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:*,''')
+ self.assert_nm_udev(None)
+
+ def test_eth_match_all(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match: {}
+ dhcp4: true''')
+
+ self.assert_networkd({'def1.network': '[Match]\n\n[Network]\nDHCP=ipv4\nLinkLocalAddressing=ipv6\n\n'
+ '[DHCP]\nRouteMetric=100\nUseMTU=true\n'})
+ self.assert_networkd_udev(None)
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=type:ethernet,''')
+ self.assert_nm_udev(None)
+
+ def test_match_multiple(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ def1:
+ match:
+ name: en1s*
+ macaddress: 00:11:22:33:44:55
+ dhcp4: on''')
+ self.assert_networkd({'def1.network': '''[Match]
+MACAddress=00:11:22:33:44:55
+Name=en1s*
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=mac:00:11:22:33:44:55,interface-name:en1s*,''')
+
+
+class TestNetworkManager(TestBase):
+
+ def test_eth_wol(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ wakeonlan: true''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=1
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ # should allow NM to manage everything else
+ self.assertTrue(os.path.exists(self.nm_enable_all_conf))
+ self.assert_networkd({'eth0.link': '[Match]\nOriginalName=eth0\n\n[Link]\nWakeOnLan=magic\n'})
+ self.assert_nm_udev(None)
+
+ def test_eth_mtu(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth1:
+ mtu: 1280
+ dhcp4: n''')
+
+ self.assert_networkd({'eth1.link': '[Match]\nOriginalName=eth1\n\n[Link]\nWakeOnLan=off\nMTUBytes=1280\n'})
+ self.assert_nm({'eth1': '''[connection]
+id=netplan-eth1
+type=ethernet
+interface-name=eth1
+
+[ethernet]
+wake-on-lan=0
+mtu=1280
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_eth_sriov_link(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enp1:
+ dhcp4: n
+ enp1s16f1:
+ dhcp4: n
+ link: enp1''')
+
+ self.assert_networkd({})
+ self.assert_nm({'enp1': '''[connection]
+id=netplan-enp1
+type=ethernet
+interface-name=enp1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'enp1s16f1': '''[connection]
+id=netplan-enp1s16f1
+type=ethernet
+interface-name=enp1s16f1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_additional_udev({'99-sriov-netplan-setup.rules': UDEV_SRIOV_RULE})
+
+ def test_eth_sriov_virtual_functions(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enp1:
+ dhcp4: n
+ virtual-function-count: 8''')
+
+ self.assert_networkd({})
+ self.assert_nm({'enp1': '''[connection]
+id=netplan-enp1
+type=ethernet
+interface-name=enp1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_additional_udev({'99-sriov-netplan-setup.rules': UDEV_SRIOV_RULE})
+
+ def test_eth_set_mac(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ macaddress: 00:01:02:03:04:05
+ dhcp4: true''')
+
+ self.assert_networkd(None)
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+cloned-mac-address=00:01:02:03:04:05
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_eth_match_by_driver(self):
+ err = self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ driver: ixgbe''', expect_fail=True)
+ self.assertIn('NetworkManager definitions do not support matching by driver', err)
+
+ def test_eth_match_by_driver_rename(self):
+ # in this case udev will rename the device so that NM can use the name
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ driver: ixgbe
+ set-name: lom1''')
+
+ self.assert_networkd({'def1.link': '[Match]\nDriver=ixgbe\n\n[Link]\nName=lom1\nWakeOnLan=off\n'})
+ self.assert_networkd_udev({'def1.rules': (UDEV_NO_MAC_RULE % ('ixgbe', 'lom1'))})
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+interface-name=lom1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_nm_udev(None)
+
+ def test_eth_match_by_mac_rename(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ macaddress: 11:22:33:44:55:66
+ set-name: lom1''')
+
+ self.assert_networkd({'def1.link': '[Match]\nMACAddress=11:22:33:44:55:66\n\n[Link]\nName=lom1\nWakeOnLan=off\n'})
+ self.assert_networkd_udev({'def1.rules': (UDEV_MAC_RULE % ('?*', '11:22:33:44:55:66', 'lom1'))})
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+interface-name=lom1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_nm_udev(None)
+
+ def test_eth_implicit_name_match_dhcp4(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: true''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_eth_match_mac_dhcp4(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ macaddress: 11:22:33:44:55:66
+ dhcp4: true''')
+
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+mac-address=11:22:33:44:55:66
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_eth_match_name(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ name: green
+ dhcp4: true''')
+
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+interface-name=green
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_eth_match_name_rename(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ name: green
+ set-name: blue
+ dhcp4: true''')
+
+ # The udev rules engine does support renaming by name
+ self.assert_networkd_udev(None)
+
+ # NM needs to match on the renamed name
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+interface-name=blue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ # ... while udev renames it
+ self.assert_networkd({'def1.link': '[Match]\nOriginalName=green\n\n[Link]\nName=blue\nWakeOnLan=off\n'})
+ self.assert_nm_udev(None)
+
+ def test_eth_match_name_glob(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match: {name: "en*"}
+ dhcp4: true''')
+
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+
+[match]
+interface-name=en*;
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_eth_match_all(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match: {}
+ dhcp4: true''')
+
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_match_multiple(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ def1:
+ match:
+ name: engreen
+ macaddress: 00:11:22:33:44:55
+ dhcp4: yes''')
+ self.assert_nm({'def1': '''[connection]
+id=netplan-def1
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+mac-address=00:11:22:33:44:55
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
diff --git a/tests/generator/test_modems.py b/tests/generator/test_modems.py
new file mode 100644
index 0000000..acffe87
--- /dev/null
+++ b/tests/generator/test_modems.py
@@ -0,0 +1,426 @@
+#
+# Tests for gsm/cdma modem devices config generated via netplan
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase
+
+
+class TestNetworkd(TestBase):
+ '''networkd output'''
+
+ def test_not_supported(self):
+ # does not produce any output, but fails with:
+ # "networkd backend does not support GSM modem configuration"
+ err = self.generate('''network:
+ version: 2
+ modems:
+ mobilephone:
+ auto-config: true''', expect_fail=True)
+ self.assertIn("ERROR: mobilephone: networkd backend does not support GSM/CDMA modem configuration", err)
+
+ self.assert_networkd({})
+ self.assert_nm({})
+
+
+class TestNetworkManager(TestBase):
+ '''networkmanager output'''
+
+ def test_cdma_config(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ mtu: 1542
+ number: "#666"
+ username: test-user
+ password: s0s3kr1t''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=cdma
+interface-name=mobilephone
+
+[cdma]
+password=s0s3kr1t
+username=test-user
+mtu=1542
+number=#666
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_auto_config(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ auto-config: true''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_auto_config_implicit(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ number: "*99#"
+ mtu: 1600
+ pin: "1234"''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+mtu=1600
+number=*99#
+pin=1234
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_apn(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ apn: internet''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+apn=internet
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_apn_username_password(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ apn: internet
+ username: some-user
+ password: some-pass''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+apn=internet
+password=some-pass
+username=some-user
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_device_id(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ device-id: test''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+device-id=test
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_network_id(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ network-id: test''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+network-id=test
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_pin(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ pin: 1234''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+pin=1234
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_sim_id(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ sim-id: test''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+sim-id=test
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_sim_operator_id(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ sim-operator-id: test''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+sim-operator-id=test
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_gsm_example(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ cdc-wdm1:
+ mtu: 1600
+ apn: ISP.CINGULAR
+ username: ISP@CINGULARGPRS.COM
+ password: CINGULAR1
+ number: "*99#"
+ network-id: 24005
+ device-id: da812de91eec16620b06cd0ca5cbc7ea25245222
+ pin: 2345
+ sim-id: 89148000000060671234
+ sim-operator-id: 310260''')
+ self.assert_nm({'cdc-wdm1': '''[connection]
+id=netplan-cdc-wdm1
+type=gsm
+interface-name=cdc-wdm1
+
+[gsm]
+apn=ISP.CINGULAR
+password=CINGULAR1
+username=ISP@CINGULARGPRS.COM
+device-id=da812de91eec16620b06cd0ca5cbc7ea25245222
+mtu=1600
+network-id=24005
+number=*99#
+pin=2345
+sim-id=89148000000060671234
+sim-operator-id=310260
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_modem_nm_integration(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ modems:
+ mobilephone:
+ auto-config: true
+ networkmanager:
+ uuid: b22d8f0f-3f34-46bd-ac28-801fa87f1eb6''')
+ self.assert_nm({'mobilephone': '''[connection]
+id=netplan-mobilephone
+type=gsm
+uuid=b22d8f0f-3f34-46bd-ac28-801fa87f1eb6
+interface-name=mobilephone
+
+[gsm]
+auto-config=true
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_modem_nm_integration_gsm_cdma(self):
+ self.generate('''network:
+ version: 2
+ modems:
+ NM-a08c5805-7cf5-43f7-afb9-12cb30f6eca3:
+ renderer: NetworkManager
+ match: {}
+ apn: internet2.voicestream.com
+ networkmanager:
+ uuid: a08c5805-7cf5-43f7-afb9-12cb30f6eca3
+ name: "T-Mobile Funkadelic 2"
+ passthrough:
+ connection.type: "bluetooth"
+ gsm.apn: "internet2.voicestream.com"
+ gsm.device-id: "da812de91eec16620b06cd0ca5cbc7ea25245222"
+ gsm.username: "george.clinton.again"
+ gsm.sim-operator-id: "310260"
+ gsm.pin: "123456"
+ gsm.sim-id: "89148000000060671234"
+ gsm.password: "parliament2"
+ gsm.network-id: "254098"
+ ipv4.method: "auto"
+ ipv6.method: "auto"''')
+ self.assert_nm({'NM-a08c5805-7cf5-43f7-afb9-12cb30f6eca3': '''[connection]
+id=T-Mobile Funkadelic 2
+#Netplan: passthrough override
+type=bluetooth
+uuid=a08c5805-7cf5-43f7-afb9-12cb30f6eca3
+
+[gsm]
+apn=internet2.voicestream.com
+#Netplan: passthrough setting
+device-id=da812de91eec16620b06cd0ca5cbc7ea25245222
+#Netplan: passthrough setting
+username=george.clinton.again
+#Netplan: passthrough setting
+sim-operator-id=310260
+#Netplan: passthrough setting
+pin=123456
+#Netplan: passthrough setting
+sim-id=89148000000060671234
+#Netplan: passthrough setting
+password=parliament2
+#Netplan: passthrough setting
+network-id=254098
+
+[ipv4]
+#Netplan: passthrough override
+method=auto
+
+[ipv6]
+#Netplan: passthrough override
+method=auto
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
diff --git a/tests/generator/test_ovs.py b/tests/generator/test_ovs.py
new file mode 100644
index 0000000..e7084a9
--- /dev/null
+++ b/tests/generator/test_ovs.py
@@ -0,0 +1,1021 @@
+#
+# Common tests for netplan OpenVSwitch support
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@ubuntu.com>
+# Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase, ND_EMPTY, ND_WITHIP, ND_DHCP4, ND_DHCP6, \
+ OVS_PHYSICAL, OVS_VIRTUAL, \
+ OVS_BR_EMPTY, OVS_BR_DEFAULT, \
+ OVS_CLEANUP
+
+
+class TestOpenVSwitch(TestBase):
+ '''OVS output'''
+
+ def test_interface_external_ids_other_config(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true
+ dhcp6: true
+ eth1:
+ dhcp4: true
+ openvswitch:
+ other-config:
+ disable-in-band: false
+ bridges:
+ ovs0:
+ interfaces: [eth0, eth1]
+ openvswitch: {}
+''')
+ self.assert_ovs({'ovs0.service': OVS_VIRTUAL % {'iface': 'ovs0', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br ovs0
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port ovs0 eth1
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port ovs0 eth0
+''' + OVS_BR_DEFAULT % {'iface': 'ovs0'}},
+ 'eth0.service': OVS_PHYSICAL % {'iface': 'eth0', 'extra': '''\
+Requires=netplan-ovs-ovs0.service
+After=netplan-ovs-ovs0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Interface eth0 external-ids:iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set Interface eth0 external-ids:netplan/external-ids/iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set Interface eth0 other-config:disable-in-band=true
+ExecStart=/usr/bin/ovs-vsctl set Interface eth0 external-ids:netplan/other-config/disable-in-band=true
+'''},
+ 'eth1.service': OVS_PHYSICAL % {'iface': 'eth1', 'extra': '''\
+Requires=netplan-ovs-ovs0.service
+After=netplan-ovs-ovs0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Interface eth1 other-config:disable-in-band=false
+ExecStart=/usr/bin/ovs-vsctl set Interface eth1 external-ids:netplan/other-config/disable-in-band=false
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'ovs0.network': ND_EMPTY % ('ovs0', 'ipv6'),
+ 'eth0.network': (ND_DHCP6 % 'eth0')
+ .replace('LinkLocalAddressing=ipv6', 'LinkLocalAddressing=no\nBridge=ovs0'),
+ 'eth1.network': (ND_DHCP4 % 'eth1')
+ .replace('LinkLocalAddressing=ipv6', 'LinkLocalAddressing=no\nBridge=ovs0')})
+
+ def test_interface_invalid_external_ids_other_config(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true''', expect_fail=True)
+ self.assertIn('eth0: Interface needs to be assigned to an OVS bridge/bond to carry external-ids/other-config', err)
+
+ def test_global_external_ids_other_config(self):
+ self.generate('''network:
+ version: 2
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true
+ ethernets:
+ eth0:
+ dhcp4: yes
+''')
+ self.assert_ovs({'global.service': OVS_VIRTUAL % {'iface': 'global', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . external-ids:iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . external-ids:netplan/external-ids/iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . other-config:disable-in-band=true
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . external-ids:netplan/other-config/disable-in-band=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+
+ def test_global_set_protocols(self):
+ self.generate('''network:
+ version: 2
+ openvswitch:
+ protocols: [OpenFlow10, OpenFlow11, OpenFlow12]
+ bridges:
+ ovs0:
+ openvswitch: {}''')
+ self.assert_ovs({'ovs0.service': OVS_VIRTUAL % {'iface': 'ovs0', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br ovs0
+''' + OVS_BR_DEFAULT % {'iface': 'ovs0'} + '''\
+ExecStart=/usr/bin/ovs-vsctl set Bridge ovs0 protocols=OpenFlow10,OpenFlow11,OpenFlow12
+ExecStart=/usr/bin/ovs-vsctl set Bridge ovs0 external-ids:netplan/protocols=OpenFlow10,OpenFlow11,OpenFlow12
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'ovs0.network': ND_EMPTY % ('ovs0', 'ipv6')})
+
+ def test_duplicate_map_entry(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ iface-id: foobar
+ ethernets:
+ eth0:
+ dhcp4: yes
+''', expect_fail=True)
+ self.assertIn("duplicate map entry 'iface-id'", err)
+
+ def test_no_ovs_config(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp4: yes
+''')
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0'})
+
+ def test_bond_setup(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''')
+ self.assert_ovs({'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 eth1 eth2
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/external-ids/iface-id=myhostname
+'''},
+ 'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no')})
+
+ def test_bond_no_bridge(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ openvswitch: {}
+''', expect_fail=True)
+ self.assertIn("Bond bond0 needs to be a slave of an OpenVSwitch bridge", err)
+
+ def test_bond_not_enough_interfaces(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ bonds:
+ bond0:
+ interfaces: [eth1]
+ openvswitch: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''', expect_fail=True)
+ self.assertIn("Bond bond0 needs to have at least 2 slave interfaces", err)
+
+ def test_bond_lacp(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ openvswitch:
+ lacp: active
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''')
+ self.assert_ovs({'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 eth1 eth2
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=active
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=active
+'''},
+ 'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no')})
+
+ def test_bond_lacp_invalid(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ openvswitch:
+ lacp: invalid
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''', expect_fail=True)
+ self.assertIn("Value of 'lacp' needs to be 'active', 'passive' or 'off", err)
+
+ def test_bond_lacp_wrong_type(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth1:
+ openvswitch:
+ lacp: passive
+''', expect_fail=True)
+ self.assertIn("Key 'lacp' is only valid for interface type 'openvswitch bond'", err)
+
+ def test_bond_mode_implicit_params(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ parameters:
+ mode: balance-tcp # Sets OVS backend implicitly
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''')
+ self.assert_ovs({'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 eth1 eth2
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 bond_mode=balance-tcp
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/bond_mode=balance-tcp
+'''},
+ 'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no')})
+
+ def test_bond_mode_explicit_params(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ parameters:
+ mode: active-backup
+ openvswitch: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''')
+ self.assert_ovs({'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 eth1 eth2
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 bond_mode=active-backup
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/bond_mode=active-backup
+'''},
+ 'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no')})
+
+ def test_bond_mode_ovs_invalid(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ parameters:
+ mode: balance-rr
+ openvswitch: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ openvswitch: {}
+''', expect_fail=True)
+ self.assertIn("bond0: bond mode 'balance-rr' not supported by openvswitch", err)
+
+ def test_bridge_setup(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [eth1, eth2]
+ openvswitch: {}
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra':
+ '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br0 eth1
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br0 eth2
+''' + OVS_BR_DEFAULT % {'iface': 'br0'}},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24')})
+
+ def test_bridge_external_ids_other_config(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+''' + OVS_BR_DEFAULT % {'iface': 'br0'} + '''\
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/external-ids/iface-id=myhostname
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 other-config:disable-in-band=true
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/other-config/disable-in-band=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the bridge has been only configured for OVS
+ self.assert_networkd({'br0.network': ND_EMPTY % ('br0', 'ipv6')})
+
+ def test_bridge_non_default_parameters(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [eth1, eth2]
+ openvswitch:
+ fail-mode: secure
+ mcast-snooping: true
+ rstp: true
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra':
+ '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br0 eth1
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br0 eth2
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set-fail-mode br0 secure
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/global/set-fail-mode=secure
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 mcast_snooping_enable=true
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/mcast_snooping_enable=true
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 rstp_enable=true
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/rstp_enable=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'eth1.network': '[Match]\nName=eth1\n\n[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'eth2.network': '[Match]\nName=eth2\n\n[Network]\nLinkLocalAddressing=no\nBridge=br0\n',
+ 'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24')})
+
+ def test_bridge_fail_mode_invalid(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ fail-mode: glorious
+''', expect_fail=True)
+ self.assertIn("Value of 'fail-mode' needs to be 'standalone' or 'secure'", err)
+
+ def test_fail_mode_non_bridge(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch:
+ fail-mode: glorious
+''', expect_fail=True)
+ self.assertIn("Key 'fail-mode' is only valid for interface type 'openvswitch bridge'", err)
+
+ def test_rstp_non_bridge(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch:
+ rstp: true
+''', expect_fail=True)
+ self.assertIn("Key is only valid for interface type 'openvswitch bridge'", err)
+
+ def test_bridge_set_protocols(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ protocols: [OpenFlow10, OpenFlow11, OpenFlow15]
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra':
+ '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+''' + OVS_BR_DEFAULT % {'iface': 'br0'} + '''\
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 protocols=OpenFlow10,OpenFlow11,OpenFlow15
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/protocols=OpenFlow10,OpenFlow11,OpenFlow15
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'br0.network': ND_EMPTY % ('br0', 'ipv6')})
+
+ def test_bridge_set_protocols_invalid(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ protocols: [OpenFlow10, OpenFooBar13, OpenFlow15]
+''', expect_fail=True)
+ self.assertIn("Unsupported OVS 'protocol' value: OpenFooBar13", err)
+
+ def test_set_protocols_invalid_interface(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch:
+ protocols: [OpenFlow10, OpenFlow15]
+''', expect_fail=True)
+ self.assertIn("Key 'protocols' is only valid for interface type 'openvswitch bridge'", err)
+
+ def test_bridge_controller(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ addresses: ["ptcp:", "ptcp:1337", "ptcp:1337:[fe80::1234%eth0]", "pssl:1337:[fe80::1]", "ssl:10.10.10.1",\
+ tcp:127.0.0.1:1337, "tcp:[fe80::1234%eth0]", "tcp:[fe80::1]:1337", unix:/some/path, punix:other/path]
+ connection-mode: out-of-band
+ openvswitch:
+ ssl:
+ ca-cert: /another/path
+ certificate: /some/path
+ private-key: /key/path
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra':
+ '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+''' + OVS_BR_DEFAULT % {'iface': 'br0'} + '''\
+ExecStart=/usr/bin/ovs-vsctl set-controller br0 ptcp: ptcp:1337 ptcp:1337:[fe80::1234%eth0] pssl:1337:[fe80::1] ssl:10.10.10.1 \
+tcp:127.0.0.1:1337 tcp:[fe80::1234%eth0] tcp:[fe80::1]:1337 unix:/some/path punix:other/path
+ExecStart=/usr/bin/ovs-vsctl set Bridge br0 external-ids:netplan/global/set-controller=ptcp:,ptcp:1337,\
+ptcp:1337:[fe80::1234%eth0],pssl:1337:[fe80::1],ssl:10.10.10.1,tcp:127.0.0.1:1337,tcp:[fe80::1234%eth0],tcp:[fe80::1]:1337,\
+unix:/some/path,punix:other/path
+ExecStart=/usr/bin/ovs-vsctl set Controller br0 connection-mode=out-of-band
+ExecStart=/usr/bin/ovs-vsctl set Controller br0 external-ids:netplan/connection-mode=out-of-band
+'''},
+ 'global.service': OVS_VIRTUAL % {'iface': 'global', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set-ssl /key/path /some/path /another/path
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . external-ids:netplan/global/set-ssl=/key/path,/some/path,/another/path
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'br0.network': ND_EMPTY % ('br0', 'ipv6')})
+
+ def test_bridge_controller_invalid_target(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ addresses: [ptcp]
+''', expect_fail=True)
+ self.assertIn("Unsupported OVS controller target: ptcp", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_controller_invalid_target_ip(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ addresses: ["tcp:[fe80:1234%eth0]"]
+''', expect_fail=True)
+ self.assertIn("Unsupported OVS controller target: tcp:[fe80:1234%eth0]", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_controller_invalid_target_port(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ addresses: [ptcp:65536]
+''', expect_fail=True)
+ self.assertIn("Unsupported OVS controller target: ptcp:65536", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_controller_invalid_connection_mode(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ connection-mode: INVALID
+''', expect_fail=True)
+ self.assertIn("Value of 'connection-mode' needs to be 'in-band' or 'out-of-band'", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_controller_connection_mode_invalid_interface_type(self):
+ err = self.generate('''network:
+ version: 2
+ bonds:
+ mybond:
+ openvswitch:
+ controller:
+ connection-mode: in-band
+''', expect_fail=True)
+ self.assertIn("Key 'controller.connection-mode' is only valid for interface type 'openvswitch bridge'", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_controller_addresses_invalid_interface_type(self):
+ err = self.generate('''network:
+ version: 2
+ bonds:
+ mybond:
+ openvswitch:
+ controller:
+ addresses: [unix:/some/socket]
+''', expect_fail=True)
+ self.assertIn("Key 'controller.addresses' is only valid for interface type 'openvswitch bridge'", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_global_ssl(self):
+ self.generate('''network:
+ version: 2
+ openvswitch:
+ ssl:
+ ca-cert: /another/path
+ certificate: /some/path
+ private-key: /key/path
+''')
+ self.assert_ovs({'global.service': OVS_VIRTUAL % {'iface': 'global', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set-ssl /key/path /some/path /another/path
+ExecStart=/usr/bin/ovs-vsctl set open_vswitch . external-ids:netplan/global/set-ssl=/key/path,/some/path,/another/path
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({})
+
+ def test_missing_ssl(self):
+ err = self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ openvswitch:
+ controller:
+ addresses: [ssl:10.10.10.1]
+ openvswitch:
+ ssl: {}
+''', expect_fail=True)
+ self.assertIn("ERROR: openvswitch bridge controller target 'ssl:10.10.10.1' needs SSL configuration, but global \
+'openvswitch.ssl' settings are not set", err)
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({})
+
+ def test_global_ports(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patch0-1, patch1-0]
+''', expect_fail=True)
+ self.assertIn('patch0-1: OpenVSwitch patch port needs to be assigned to a bridge/bond', err)
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({})
+
+ def test_few_ports(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patch0-1]
+''', expect_fail=True)
+ self.assertIn("An openvswitch peer port sequence must have exactly two entries", err)
+ self.assertIn("- [patch0-1]", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_many_ports(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patch0-1, "patchx", patchy]
+''', expect_fail=True)
+ self.assertIn("An openvswitch peer port sequence must have exactly two entries", err)
+ self.assertIn("- [patch0-1, \"patchx\", patchy]", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_ovs_invalid_port(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patchx, patchy]
+ - [patchx, patchz]
+''', expect_fail=True)
+ self.assertIn("openvswitch port 'patchx' is already assigned to peer 'patchy'", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_ovs_invalid_peer(self):
+ err = self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patchx, patchy]
+ - [patchz, patchx]
+''', expect_fail=True)
+ self.assertIn("openvswitch port 'patchx' is already assigned to peer 'patchy'", err)
+ self.assert_ovs({})
+ self.assert_networkd({})
+
+ def test_bridge_auto_ovs_backend(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth1: {}
+ eth2: {}
+ bonds:
+ bond0:
+ interfaces: [eth1, eth2]
+ openvswitch: {}
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+''')
+ self.assert_ovs({'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 eth1 eth2
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=off
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no'),
+ 'eth1.network':
+ '''[Match]
+Name=eth1
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+''',
+ 'eth2.network':
+ '''[Match]
+Name=eth2
+
+[Network]
+LinkLocalAddressing=no
+Bond=bond0
+'''})
+
+ def test_bond_auto_ovs_backend(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0: {}
+ bonds:
+ bond0:
+ interfaces: [eth0, patchy]
+ bridges:
+ br0:
+ addresses: [192.170.1.1/24]
+ interfaces: [bond0]
+ br1:
+ addresses: [2001:FFfe::1/64]
+ interfaces: [patchx]
+ openvswitch:
+ ports:
+ - [patchx, patchy]
+''')
+ self.assert_ovs({'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'br1.service': OVS_VIRTUAL % {'iface': 'br1', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br1
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br1 patchx -- set Interface patchx type=patch options:peer=patchy
+''' + OVS_BR_DEFAULT % {'iface': 'br1'}},
+ 'bond0.service': OVS_VIRTUAL % {'iface': 'bond0', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-bond br0 bond0 patchy eth0 -- set Interface patchy type=patch options:peer=patchx
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan=true
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 lacp=off
+ExecStart=/usr/bin/ovs-vsctl set Port bond0 external-ids:netplan/lacp=off
+'''},
+ 'patchx.service': OVS_VIRTUAL % {'iface': 'patchx', 'extra':
+ '''Requires=netplan-ovs-br1.service
+After=netplan-ovs-br1.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Port patchx external-ids:netplan=true
+'''},
+ 'patchy.service': OVS_VIRTUAL % {'iface': 'patchy', 'extra':
+ '''Requires=netplan-ovs-bond0.service
+After=netplan-ovs-bond0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Interface patchy external-ids:netplan=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({'br0.network': ND_WITHIP % ('br0', '192.170.1.1/24'),
+ 'br1.network': ND_WITHIP % ('br1', '2001:FFfe::1/64'),
+ 'bond0.network': ND_EMPTY % ('bond0', 'no'),
+ 'patchx.network': ND_EMPTY % ('patchx', 'no'),
+ 'patchy.network': ND_EMPTY % ('patchy', 'no'),
+ 'eth0.network': '[Match]\nName=eth0\n\n[Network]\nLinkLocalAddressing=no\nBond=bond0\n'})
+
+ def test_patch_ports(self):
+ self.generate('''network:
+ version: 2
+ openvswitch:
+ ports:
+ - [patch0-1, patch1-0]
+ bridges:
+ br0:
+ addresses: [192.168.1.1/24]
+ interfaces: [patch0-1]
+ br1:
+ addresses: [192.168.1.2/24]
+ interfaces: [patch1-0]
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br0 patch0-1 -- set Interface patch0-1 type=patch options:peer=patch1-0
+''' + OVS_BR_DEFAULT % {'iface': 'br0'}},
+ 'br1.service': OVS_VIRTUAL % {'iface': 'br1', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br1
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port br1 patch1-0 -- set Interface patch1-0 type=patch options:peer=patch0-1
+''' + OVS_BR_DEFAULT % {'iface': 'br1'}},
+ 'patch0-1.service': OVS_VIRTUAL % {'iface': 'patch0-1', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Port patch0-1 external-ids:netplan=true
+'''},
+ 'patch1-0.service': OVS_VIRTUAL % {'iface': 'patch1-0', 'extra':
+ '''Requires=netplan-ovs-br1.service
+After=netplan-ovs-br1.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl set Port patch1-0 external-ids:netplan=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({'br0.network': ND_WITHIP % ('br0', '192.168.1.1/24'),
+ 'br1.network': ND_WITHIP % ('br1', '192.168.1.2/24'),
+ 'patch0-1.network': ND_EMPTY % ('patch0-1', 'no'),
+ 'patch1-0.network': ND_EMPTY % ('patch1-0', 'no')})
+
+ def test_fake_vlan_bridge_setup(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ addresses: [192.168.1.1/24]
+ openvswitch: {}
+ vlans:
+ br0.100:
+ id: 100
+ link: br0
+ openvswitch: {}
+''')
+ self.assert_ovs({'br0.service': OVS_VIRTUAL % {'iface': 'br0', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0
+''' + OVS_BR_DEFAULT % {'iface': 'br0'}},
+ 'br0.100.service': OVS_VIRTUAL % {'iface': 'br0.100', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0.100 br0 100
+ExecStart=/usr/bin/ovs-vsctl set Interface br0.100 external-ids:netplan=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'br0.network': ND_WITHIP % ('br0', '192.168.1.1/24'),
+ 'br0.100.network': ND_EMPTY % ('br0.100', 'ipv6')})
+
+ def test_implicit_fake_vlan_bridge_setup(self):
+ # Test if, when a VLAN is added to an OVS bridge, netplan will
+ # implicitly assume the vlan should be done via OVS as well
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0:
+ addresses: [192.168.1.1/24]
+ openvswitch: {}
+ vlans:
+ br0.100:
+ id: 100
+ link: br0
+''')
+ self.assert_ovs({'br0.service': OVS_BR_EMPTY % {'iface': 'br0'},
+ 'br0.100.service': OVS_VIRTUAL % {'iface': 'br0.100', 'extra':
+ '''Requires=netplan-ovs-br0.service
+After=netplan-ovs-br0.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br br0.100 br0 100
+ExecStart=/usr/bin/ovs-vsctl set Interface br0.100 external-ids:netplan=true
+'''},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'br0.network': ND_WITHIP % ('br0', '192.168.1.1/24'),
+ 'br0.100.network': ND_EMPTY % ('br0.100', 'ipv6')})
+
+ def test_invalid_device_type(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ eth0:
+ openvswitch: {}
+''', expect_fail=True)
+ self.assertIn('eth0: This device type is not supported with the OpenVSwitch backend', err)
+ self.assert_ovs({'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ self.assert_networkd({})
+
+ def test_bridge_non_ovs_bond(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ eth0: {}
+ eth1: {}
+ bonds:
+ non-ovs-bond:
+ interfaces: [eth0, eth1]
+ bridges:
+ ovs-br:
+ interfaces: [non-ovs-bond]
+ openvswitch: {}
+''')
+ self.assert_ovs({'ovs-br.service': OVS_VIRTUAL % {'iface': 'ovs-br', 'extra': '''
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-br ovs-br
+ExecStart=/usr/bin/ovs-vsctl --may-exist add-port ovs-br non-ovs-bond
+''' + OVS_BR_DEFAULT % {'iface': 'ovs-br'}},
+ 'cleanup.service': OVS_CLEANUP % {'iface': 'cleanup'}})
+ # Confirm that the networkd config is still sane
+ self.assert_networkd({'non-ovs-bond.network': ND_EMPTY % ('non-ovs-bond', 'no') + 'Bridge=ovs-br\n',
+ 'eth1.network': (ND_EMPTY % ('eth1', 'no')).replace('ConfigureWithoutCarrier=yes',
+ 'Bond=non-ovs-bond'),
+ 'eth0.network': (ND_EMPTY % ('eth0', 'no')).replace('ConfigureWithoutCarrier=yes',
+ 'Bond=non-ovs-bond'),
+ 'ovs-br.network': ND_EMPTY % ('ovs-br', 'ipv6'),
+ 'non-ovs-bond.netdev': '[NetDev]\nName=non-ovs-bond\nKind=bond\n'})
diff --git a/tests/generator/test_passthrough.py b/tests/generator/test_passthrough.py
new file mode 100644
index 0000000..817aaa0
--- /dev/null
+++ b/tests/generator/test_passthrough.py
@@ -0,0 +1,286 @@
+#
+# Tests for passthrough config generated via netplan
+#
+# Copyright (C) 2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase
+
+
+# No passthrough mode (yet) for systemd-networkd
+class TestNetworkd(TestBase):
+ pass
+
+
+class TestNetworkManager(TestBase):
+
+ def test_passthrough_basic(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ NM-87749f1d-334f-40b2-98d4-55db58965f5f:
+ renderer: NetworkManager
+ match: {}
+ networkmanager:
+ uuid: 87749f1d-334f-40b2-98d4-55db58965f5f
+ name: some NM id
+ passthrough:
+ connection.uuid: 87749f1d-334f-40b2-98d4-55db58965f5f
+ connection.type: ethernet
+ connection.permissions: ""''')
+
+ self.assert_nm({'NM-87749f1d-334f-40b2-98d4-55db58965f5f': '''[connection]
+id=some NM id
+type=ethernet
+uuid=87749f1d-334f-40b2-98d4-55db58965f5f
+#Netplan: passthrough setting
+permissions=
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_passthrough_wifi(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ NM-87749f1d-334f-40b2-98d4-55db58965f5f:
+ renderer: NetworkManager
+ match: {}
+ access-points:
+ "SOME-SSID":
+ networkmanager:
+ uuid: 87749f1d-334f-40b2-98d4-55db58965f5f
+ name: myid with spaces
+ passthrough:
+ connection.permissions: ""
+ wifi.ssid: SOME-SSID
+ "OTHER-SSID":
+ hidden: true''')
+
+ self.assert_nm({'NM-87749f1d-334f-40b2-98d4-55db58965f5f-SOME-SSID': '''[connection]
+id=myid with spaces
+type=wifi
+uuid=87749f1d-334f-40b2-98d4-55db58965f5f
+#Netplan: passthrough setting
+permissions=
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=SOME-SSID
+mode=infrastructure
+''',
+ 'NM-87749f1d-334f-40b2-98d4-55db58965f5f-OTHER-SSID': '''[connection]
+id=netplan-NM-87749f1d-334f-40b2-98d4-55db58965f5f-OTHER-SSID
+type=wifi
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=OTHER-SSID
+mode=infrastructure
+hidden=true
+'''})
+
+ def test_passthrough_type_nm_devices(self):
+ self.generate('''network:
+ nm-devices:
+ NM-87749f1d-334f-40b2-98d4-55db58965f5f:
+ renderer: NetworkManager
+ match: {}
+ networkmanager:
+ passthrough:
+ connection.uuid: 87749f1d-334f-40b2-98d4-55db58965f5f
+ connection.type: dummy''')
+
+ self.assert_nm({'NM-87749f1d-334f-40b2-98d4-55db58965f5f': '''[connection]
+id=netplan-NM-87749f1d-334f-40b2-98d4-55db58965f5f
+#Netplan: passthrough setting
+uuid=87749f1d-334f-40b2-98d4-55db58965f5f
+#Netplan: passthrough setting
+type=dummy
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_passthrough_dotted_group(self):
+ self.generate('''network:
+ nm-devices:
+ dotted-group-test:
+ renderer: NetworkManager
+ match: {}
+ networkmanager:
+ passthrough:
+ connection.type: "wireguard"
+ wireguard-peer.some-key.endpoint: 1.2.3.4''')
+
+ self.assert_nm({'dotted-group-test': '''[connection]
+id=netplan-dotted-group-test
+#Netplan: passthrough setting
+type=wireguard
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wireguard-peer.some-key]
+#Netplan: passthrough setting
+endpoint=1.2.3.4
+'''})
+
+ def test_passthrough_dotted_key(self):
+ self.generate('''network:
+ ethernets:
+ dotted-key-test:
+ renderer: NetworkManager
+ match: {}
+ networkmanager:
+ passthrough:
+ tc.qdisc.root: something
+ tc.qdisc.fff1: ":abc"
+ tc.filters.test: "test"''')
+
+ self.assert_nm({'dotted-key-test': '''[connection]
+id=netplan-dotted-key-test
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[tc]
+#Netplan: passthrough setting
+qdisc.root=something
+#Netplan: passthrough setting
+qdisc.fff1=:abc
+#Netplan: passthrough setting
+filters.test=test
+'''})
+
+ def test_passthrough_unsupported_setting(self):
+ self.generate('''network:
+ wifis:
+ test:
+ renderer: NetworkManager
+ match: {}
+ access-points:
+ "SOME-SSID": # implicit "mode: infrasturcutre"
+ networkmanager:
+ passthrough:
+ wifi.mode: "mesh"''')
+
+ self.assert_nm({'test-SOME-SSID': '''[connection]
+id=netplan-test-SOME-SSID
+type=wifi
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=SOME-SSID
+#Netplan: passthrough override
+mode=mesh
+'''})
+
+ def test_passthrough_empty_group(self):
+ self.generate('''network:
+ ethernets:
+ test:
+ renderer: NetworkManager
+ match: {}
+ networkmanager:
+ passthrough:
+ proxy._: ""''')
+
+ self.assert_nm({'test': '''[connection]
+id=netplan-test
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[proxy]
+'''})
+
+ def test_passthrough_interface_rename_existing_id(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ # This is the original netdef, generating "netplan-eth0.nmconnection"
+ eth0:
+ dhcp4: true
+ # This is the override netdef, modifying match.original_name (i.e. interface-name)
+ # it should still generate a "netplan-eth0.nmconnection" file (not netplan-eth33.nmconnection).
+ eth0:
+ renderer: NetworkManager
+ dhcp4: true
+ match:
+ name: "eth33"
+ networkmanager:
+ uuid: 626dd384-8b3d-3690-9511-192b2c79b3fd
+ name: "netplan-eth0"
+''')
+
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+uuid=626dd384-8b3d-3690-9511-192b2c79b3fd
+interface-name=eth33
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''})
diff --git a/tests/generator/test_routing.py b/tests/generator/test_routing.py
new file mode 100644
index 0000000..9b302a9
--- /dev/null
+++ b/tests/generator/test_routing.py
@@ -0,0 +1,1333 @@
+#
+# Routing / IP rule tests for netplan generator
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase
+
+
+class TestNetworkd(TestBase):
+
+ def test_route_invalid_family_to(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: abc/24
+ via: 192.168.14.20''', expect_fail=True)
+ self.assertIn("Error in network definition: invalid IP family '-1'", err)
+
+ def test_route_v4_single(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Metric=100
+'''})
+
+ def test_route_v4_single_mulit_parse(self):
+ self.generate('''network:
+ version: 2
+ bridges:
+ br0: {interfaces: [engreen]}
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=no
+Address=192.168.14.2/24
+Bridge=br0
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Metric=100
+''',
+ 'br0.netdev': '[NetDev]\nName=br0\nKind=bridge\n',
+ 'br0.network': '''[Match]\nName=br0\n
+[Network]
+LinkLocalAddressing=ipv6
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_route_v4_multiple(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 8.8.0.0/16
+ via: 192.168.1.1
+ - to: 10.10.10.8
+ via: 192.168.1.2
+ metric: 5000
+ - to: 11.11.11.0/24
+ via: 192.168.1.3
+ metric: 9999
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=8.8.0.0/16
+Gateway=192.168.1.1
+
+[Route]
+Destination=10.10.10.8
+Gateway=192.168.1.2
+Metric=5000
+
+[Route]
+Destination=11.11.11.0/24
+Gateway=192.168.1.3
+Metric=9999
+'''})
+
+ def test_route_v4_default(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.1.2/24"]
+ routes:
+ - to: default
+ via: 192.168.1.1
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.1.2/24
+
+[Route]
+Destination=0.0.0.0/0
+Gateway=192.168.1.1
+'''})
+
+ def test_route_v4_onlink(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ on-link: true
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+GatewayOnlink=true
+Metric=100
+'''})
+
+ def test_route_v4_onlink_no(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ on-link: n
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Metric=100
+'''})
+
+ def test_route_v4_scope(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ scope: link
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Scope=link
+Metric=100
+'''})
+
+ def test_route_v4_scope_redefine(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ scope: host
+ via: 192.168.14.20
+ scope: link
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Scope=link
+Metric=100
+'''})
+
+ def test_route_v4_type_blackhole(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ type: blackhole
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Type=blackhole
+Metric=100
+'''})
+
+ def test_route_v4_type_redefine(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ type: prohibit
+ via: 192.168.14.20
+ type: unicast
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Metric=100
+'''})
+
+ def test_route_v4_table(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ table: 201
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+Metric=100
+Table=201
+'''})
+
+ def test_route_v4_from(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ from: 192.168.14.2
+ metric: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+PreferredSource=192.168.14.2
+Metric=100
+'''})
+
+ def test_route_v4_mtu(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ mtu: 1500
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+MTUBytes=1500
+'''})
+
+ def test_route_v4_congestion_window(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ congestion-window: 16
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+InitialCongestionWindow=16
+'''})
+
+ def test_route_v4_advertised_receive_window(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ advertised-receive-window: 16
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=192.168.14.20
+InitialAdvertisedReceiveWindow=16
+'''})
+
+ def test_route_v6_single(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ enblue:
+ addresses: ["192.168.1.3/24"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1''')
+
+ self.assert_networkd({'enblue.network': '''[Match]
+Name=enblue
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.1.3/24
+
+[Route]
+Destination=2001:dead:beef::2/64
+Gateway=2001:beef:beef::1
+'''})
+
+ def test_route_v6_type(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1
+ type: prohibit''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=2001:dead:beef::2/64
+Gateway=2001:beef:beef::1
+Type=prohibit
+'''})
+
+ def test_route_v6_scope_host(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1
+ scope: host''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[Route]
+Destination=2001:dead:beef::2/64
+Gateway=2001:beef:beef::1
+Scope=host
+'''})
+
+ def test_route_v6_multiple(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ enblue:
+ addresses: ["192.168.1.3/24"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1
+ - to: 2001:f00f:f00f::fe/64
+ via: 2001:beef:feed::1
+ metric: 1024''')
+
+ self.assert_networkd({'enblue.network': '''[Match]
+Name=enblue
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.1.3/24
+
+[Route]
+Destination=2001:dead:beef::2/64
+Gateway=2001:beef:beef::1
+
+[Route]
+Destination=2001:f00f:f00f::fe/64
+Gateway=2001:beef:feed::1
+Metric=1024
+'''})
+
+ def test_route_v6_default(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ enblue:
+ addresses: ["2001:dead:beef::2/64"]
+ routes:
+ - to: default
+ via: 2001:beef:beef::1''')
+
+ self.assert_networkd({'enblue.network': '''[Match]
+Name=enblue
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:dead:beef::2/64
+
+[Route]
+Destination=::/0
+Gateway=2001:beef:beef::1
+'''})
+
+ def test_ip_rule_table(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routing-policy:
+ - to: 10.10.10.0/24
+ table: 100
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[RoutingPolicyRule]
+To=10.10.10.0/24
+Table=100
+'''})
+
+ def test_ip_rule_priority(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routing-policy:
+ - to: 10.10.10.0/24
+ priority: 99
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[RoutingPolicyRule]
+To=10.10.10.0/24
+Priority=99
+'''})
+
+ def test_ip_rule_fwmark(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routing-policy:
+ - from: 10.10.10.0/24
+ mark: 50
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[RoutingPolicyRule]
+From=10.10.10.0/24
+FirewallMark=50
+'''})
+
+ def test_ip_rule_tos(self):
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routing-policy:
+ - to: 10.10.10.0/24
+ type-of-service: 250
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=192.168.14.2/24
+
+[RoutingPolicyRule]
+To=10.10.10.0/24
+TypeOfService=250
+'''})
+
+ def test_use_routes(self):
+ """[networkd] Validate config generation when use-routes DHCP override is used"""
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp4-overrides:
+ use-routes: false
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+UseRoutes=false
+'''})
+
+ def test_default_metric(self):
+ """[networkd] Validate config generation when metric DHCP override is used"""
+ self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp6: true
+ dhcp4-overrides:
+ route-metric: 3333
+ dhcp6-overrides:
+ route-metric: 3333
+ enred:
+ dhcp4: true
+ dhcp6: true
+ ''')
+
+ self.assert_networkd({'engreen.network': '''[Match]
+Name=engreen
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=3333
+UseMTU=true
+''',
+ 'enred.network': '''[Match]
+Name=enred
+
+[Network]
+DHCP=yes
+LinkLocalAddressing=ipv6
+
+[DHCP]
+RouteMetric=100
+UseMTU=true
+'''})
+
+
+class TestNetworkManager(TestBase):
+
+ def test_route_v4_single(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ metric: 100
+ ''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.14.20,100
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_route_v4_multiple(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 8.8.0.0/16
+ via: 192.168.1.1
+ metric: 5000
+ - to: 10.10.10.8
+ via: 192.168.1.2
+ - to: 11.11.11.0/24
+ via: 192.168.1.3
+ metric: 9999
+ ''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=8.8.0.0/16,192.168.1.1,5000
+route2=10.10.10.8,192.168.1.2
+route3=11.11.11.0/24,192.168.1.3,9999
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_route_v4_default(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.1.2/24"]
+ routes:
+ - to: default
+ via: 192.168.1.1
+ ''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.1.2/24
+route1=0.0.0.0/0,192.168.1.1
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_route_v6_single(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enblue:
+ addresses: ["2001:f00f:f00f::2/64"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1''')
+
+ self.assert_nm({'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=manual
+address1=2001:f00f:f00f::2/64
+route1=2001:dead:beef::2/64,2001:beef:beef::1
+'''})
+
+ def test_route_v6_multiple(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enblue:
+ addresses: ["2001:f00f:f00f::2/64"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1
+ - to: 2001:dead:feed::2/64
+ via: 2001:beef:beef::2
+ metric: 1000''')
+
+ self.assert_nm({'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=manual
+address1=2001:f00f:f00f::2/64
+route1=2001:dead:beef::2/64,2001:beef:beef::1
+route2=2001:dead:feed::2/64,2001:beef:beef::2,1000
+'''})
+
+ def test_route_v6_default(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ enblue:
+ addresses: ["2001:dead:beef::2/64"]
+ routes:
+ - to: default
+ via: 2001:beef:beef::1''')
+
+ self.assert_nm({'enblue': '''[connection]
+id=netplan-enblue
+type=ethernet
+interface-name=enblue
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=manual
+address1=2001:dead:beef::2/64
+route1=::/0,2001:beef:beef::1
+'''})
+
+ def test_routes_mixed(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ addresses: ["192.168.14.2/24", "2001:f00f::2/128"]
+ routes:
+ - to: 2001:dead:beef::2/64
+ via: 2001:beef:beef::1
+ metric: 997
+ - to: 8.8.0.0/16
+ via: 192.168.1.1
+ metric: 5000
+ - to: 10.10.10.8
+ via: 192.168.1.2
+ - to: 11.11.11.0/24
+ via: 192.168.1.3
+ metric: 9999
+ - to: 2001:f00f:f00f::fe/64
+ via: 2001:beef:feed::1
+ ''')
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=8.8.0.0/16,192.168.1.1,5000
+route2=10.10.10.8,192.168.1.2
+route3=11.11.11.0/24,192.168.1.3,9999
+
+[ipv6]
+method=manual
+address1=2001:f00f::2/128
+route1=2001:dead:beef::2/64,2001:beef:beef::1,997
+route2=2001:f00f:f00f::fe/64,2001:beef:feed::1
+'''})
+
+ def test_route_from(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ from: 192.168.14.2
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.14.20
+route1_options=src=192.168.14.2
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_onlink(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ on-link: true
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=onlink=true
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_table(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ table: 31337
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=table=31337
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_mtu(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ mtu: 1500
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=mtu=1500
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_congestion_window(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ congestion-window: 16
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=initcwnd=16
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_advertised_receive_window(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ advertised-receive-window: 16
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=initrwnd=16
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_options(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ table: 31337
+ from: 192.168.14.2
+ on-link: true
+ ''')
+ self.assertEqual('', out)
+
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=192.168.14.2/24
+route1=10.10.10.0/24,192.168.1.20
+route1_options=onlink=true,table=31337,src=192.168.14.2
+
+[ipv6]
+method=ignore
+'''})
+ self.assert_networkd({})
+
+ def test_route_reject_scope(self):
+ out = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ scope: host
+ ''', expect_fail=True)
+ self.assertIn('ERROR: engreen: NetworkManager does not support setting a scope for routes', out)
+
+ self.assert_nm({})
+ self.assert_networkd({})
+
+ def test_route_reject_type(self):
+ err = self.generate('''network:
+ version: 2
+ ethernets:
+ engreen:
+ renderer: NetworkManager
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.1.20
+ type: blackhole
+ ''', expect_fail=True)
+ self.assertIn('NetworkManager only supports unicast routes', err)
+
+ self.assert_nm({})
+ self.assert_networkd({})
+
+ def test_use_routes_v4(self):
+ """[NetworkManager] Validate config when use-routes DHCP4 override is used"""
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp4-overrides:
+ use-routes: false
+ ''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+ignore-auto-routes=true
+never-default=true
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_use_routes_v6(self):
+ """[NetworkManager] Validate config when use-routes DHCP6 override is used"""
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp6: true
+ dhcp6-overrides:
+ use-routes: false
+ ''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+ignore-auto-routes=true
+never-default=true
+'''})
+
+ def test_default_metric_v4(self):
+ """[NetworkManager] Validate config when setting a default metric for DHCPv4"""
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp6: true
+ dhcp4-overrides:
+ route-metric: 4000
+ ''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+route-metric=4000
+
+[ipv6]
+method=auto
+'''})
+
+ def test_default_metric_v6(self):
+ """[NetworkManager] Validate config when setting a default metric for DHCPv6"""
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ engreen:
+ dhcp4: true
+ dhcp6: true
+ dhcp6-overrides:
+ route-metric: 5050
+ ''')
+ self.assert_nm({'engreen': '''[connection]
+id=netplan-engreen
+type=ethernet
+interface-name=engreen
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=auto
+route-metric=5050
+'''})
diff --git a/tests/generator/test_tunnels.py b/tests/generator/test_tunnels.py
new file mode 100644
index 0000000..534c2ba
--- /dev/null
+++ b/tests/generator/test_tunnels.py
@@ -0,0 +1,1409 @@
+#
+# Tests for tunnel devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .base import TestBase, ND_WITHIPGW, ND_EMPTY, NM_WG, ND_WG
+
+
+def prepare_config_for_mode(renderer, mode, key=None, ttl=None):
+ config = """network:
+ version: 2
+ renderer: {}
+""".format(renderer)
+
+ if mode == "ip6gre" \
+ or mode == "ip6ip6" \
+ or mode == "vti6" \
+ or mode == "ipip6" \
+ or mode == "ip6gretap":
+ local_ip = "fe80::dead:beef"
+ remote_ip = "2001:fe:ad:de:ad:be:ef:1"
+ else:
+ local_ip = "10.10.10.10"
+ remote_ip = "20.20.20.20"
+
+ append_ttl = '\n ttl: {}'.format(ttl) if ttl else ''
+ config += """
+ tunnels:
+ tun0:
+ mode: {}
+ local: {}
+ remote: {}{}
+ addresses: [ 15.15.15.15/24 ]
+ gateway4: 20.20.20.21
+""".format(mode, local_ip, remote_ip, append_ttl)
+
+ # Handle key/keys as str or dict as required by the test
+ if type(key) is str:
+ config += """
+ key: {}
+""".format(key)
+ elif type(key) is dict:
+ config += """
+ keys:
+ input: {}
+ output: {}
+""".format(key['input'], key['output'])
+
+ return config
+
+
+def prepare_wg_config(listen=None, privkey=None, fwmark=None, peers=[], renderer="networkd"):
+ config = '''network:
+ version: 2
+ renderer: %s
+ tunnels:
+ wg0:
+ mode: wireguard
+ addresses: [15.15.15.15/24, 2001:de:ad:be:ef:ca:fe:1/128]
+ gateway4: 20.20.20.21
+''' % renderer
+ if privkey is not None:
+ config += ' key: {}\n'.format(privkey)
+ if fwmark is not None:
+ config += ' mark: {}\n'.format(fwmark)
+ if listen is not None:
+ config += ' port: {}\n'.format(listen)
+ if len(peers) > 0:
+ config += ' peers:\n'
+ for peer in peers:
+ public_key = peer.get('public-key')
+ peer.pop('public-key', None)
+ shared_key = peer.get('shared-key')
+ peer.pop('shared-key', None)
+ pfx = ' - '
+ for k, v in peer.items():
+ config += '{}{}: {}\n'.format(pfx, k, v)
+ pfx = ' '
+ if public_key or shared_key:
+ config += '{}keys:\n'.format(pfx)
+ if public_key:
+ config += ' public: {}\n'.format(public_key)
+ if shared_key:
+ config += ' shared: {}\n'.format(shared_key)
+ return config
+
+
+class _CommonParserErrors():
+
+ def test_fail_invalid_private_key(self):
+ """[wireguard] Show an error for an invalid private key"""
+ config = prepare_wg_config(listen=12345, privkey='invalid.key',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: invalid wireguard private key", out)
+
+ def test_fail_invalid_public_key(self):
+ """[wireguard] Show an error for an invalid private key"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': '/invalid.key',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: invalid wireguard public key", out)
+
+ def test_fail_invalid_shared_key(self):
+ """[wireguard] Show an error for an invalid pre shared key"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'shared-key': 'invalid.key',
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: invalid wireguard shared key", out)
+
+ def test_fail_keepalive_2big(self):
+ """[wireguard] Show an error if keepalive is too big"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 100500,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: keepalive must be 0-65535 inclusive.", out)
+
+ def test_fail_keepalive_bogus(self):
+ """[wireguard] Show an error if keepalive is not an int"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 'bogus',
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid unsigned int value 'bogus'", out)
+
+ def test_fail_allowed_ips_prefix4(self):
+ """[wireguard] Show an error if ipv4 prefix is too big"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/200, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid prefix length in address", out)
+
+ def test_fail_allowed_ips_prefix6(self):
+ """[wireguard] Show an error if ipv6 prefix too big"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/224"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid prefix length in address", out)
+
+ def test_fail_allowed_ips_noprefix4(self):
+ """[wireguard] Show an error if ipv4 prefix is missing"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: address \'0.0.0.0\' is missing /prefixlength", out)
+
+ def test_fail_allowed_ips_noprefix6(self):
+ """[wireguard] Show an error if ipv6 prefix is missing"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: address '2001:fe:ad:de:ad:be:ef:1' is missing /prefixlength", out)
+
+ def test_fail_allowed_ips_bogus(self):
+ """[wireguard] Show an error if the address is completely bogus"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[302.302.302.302/24, "2001:fe:ad:de:ad:be:ef:1"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: malformed address \'302.302.302.302/24\', \
+must be X.X.X.X/NN or X:X:X:X:X:X:X:X/NN", out)
+
+ def test_fail_remote_no_port4(self):
+ """[wireguard] Show an error if ipv4 remote endpoint lacks a port"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: endpoint '1.2.3.4' is missing :port", out)
+
+ def test_fail_remote_no_port6(self):
+ """[wireguard] Show an error if ipv6 remote endpoint lacks a port"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': "2001:fe:ad:de:ad:be:ef:1"}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid endpoint address or hostname", out)
+
+ def test_fail_remote_no_port_hn(self):
+ """[wireguard] Show an error if fqdn remote endpoint lacks a port"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': 'fq.dn'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: endpoint 'fq.dn' is missing :port", out)
+
+ def test_fail_remote_big_port4(self):
+ """[wireguard] Show an error if ipv4 remote endpoint port is too big"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:100500'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid port in endpoint '1.2.3.4:100500", out)
+
+ def test_fail_ipv6_remote_noport(self):
+ """[wireguard] Show an error for v6 remote endpoint without port"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '"[2001:fe:ad:de:ad:be:ef:11]"'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("endpoint \'[2001:fe:ad:de:ad:be:ef:11]\' is missing :port", out)
+
+ def test_fail_ipv6_remote_nobrace(self):
+ """[wireguard] Show an error for v6 remote endpoint without closing brace"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '"[2001:fe:ad:de:ad:be:ef:11"'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("invalid address in endpoint '[2001:fe:ad:de:ad:be:ef:11'", out)
+
+ def test_fail_ipv6_remote_malformed(self):
+ """[wireguard] Show an error for malformed-v6 remote endpoint"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '"[2001:fe:badfilinad:be:ef]:11"'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("invalid endpoint address or hostname '[2001:fe:badfilinad:be:ef]:11", out)
+
+ def test_fail_short_remote(self):
+ """[wireguard] Show an error for too-short remote endpoint"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': 'ab'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid endpoint address or hostname 'ab'", out)
+
+ def test_fail_bogus_peer_key(self):
+ """[wireguard] Show an error for a bogus key in a peer"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'bogus': 'true',
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: unknown key 'bogus'", out)
+
+ def test_fail_missing_private_key(self):
+ """[wireguard] Show an error for a missing private key"""
+ config = prepare_wg_config(listen=12345,
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: missing 'key' property (private key) for wireguard", out)
+
+ def test_fail_no_peers(self):
+ """[wireguard] Show an error for missing peers"""
+ config = prepare_wg_config(listen=12345, privkey="4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=", renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: at least one peer is required.", out)
+
+ def test_fail_no_public_key(self):
+ """[wireguard] Show an error for missing public_key"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: keys.public is required.", out)
+
+ def test_fail_no_allowed_ips(self):
+ """[wireguard] Show an error for a missing allowed_ips"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: wg0: 'to' is required to define the allowed IPs.", out)
+
+
+class _CommonTests():
+
+ def test_simple(self):
+ """[wireguard] Validate generation of simple wireguard config"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ fwmark=42,
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'shared-key': '7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=',
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ self.generate(config)
+ if self.backend == 'networkd':
+ self.assert_networkd({'wg0.netdev': ND_WG % ('=4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''FwMark=42
+
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=1.2.3.4:5
+PresharedKey=7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8='''),
+ 'wg0.network': ND_WITHIPGW % ('wg0', '15.15.15.15/24', '2001:de:ad:be:ef:ca:fe:1/128',
+ '20.20.20.21')})
+ elif self.backend == 'NetworkManager':
+ self.assert_nm({'wg0.nmconnection': NM_WG % ('4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''fwmark=42
+
+[wireguard-peer.M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=]
+persistent-keepalive=23
+endpoint=1.2.3.4:5
+preshared-key=7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=
+preshared-key-flags=0
+allowed-ips=0.0.0.0/0;2001:fe:ad:de:ad:be:ef:1/24;''')})
+
+ def test_simple_multi_pass(self):
+ """[wireguard] Validate generation of a wireguard config, which is parsed multiple times"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ config = config.replace('tunnels:', 'bridges: {br0: {interfaces: [wg0]}}\n tunnels:')
+ self.generate(config)
+ if self.backend == 'networkd':
+ self.assert_networkd({'wg0.netdev': ND_WG % ('=4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=1.2.3.4:5'''),
+ 'wg0.network': (ND_WITHIPGW % ('wg0', '15.15.15.15/24', '2001:de:ad:be:ef:ca:fe:1/128',
+ '20.20.20.21') + 'Bridge=br0\n')
+ .replace('LinkLocalAddressing=ipv6', 'LinkLocalAddressing=no'),
+ 'br0.network': ND_EMPTY % ('br0', 'ipv6'),
+ 'br0.netdev': '''[NetDev]\nName=br0\nKind=bridge\n'''})
+ elif self.backend == 'NetworkManager':
+ self.assert_nm({'wg0.nmconnection': '''[connection]
+id=netplan-wg0
+type=wireguard
+interface-name=wg0
+slave-type=bridge
+master=br0
+
+[wireguard]
+private-key=4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=
+listen-port=12345
+
+[wireguard-peer.M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=]
+persistent-keepalive=23
+endpoint=1.2.3.4:5
+allowed-ips=0.0.0.0/0;2001:fe:ad:de:ad:be:ef:1/24;
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=manual
+address1=2001:de:ad:be:ef:ca:fe:1/128
+''',
+ 'br0.nmconnection': '''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_2peers(self):
+ """[wireguard] Validate generation of wireguard config with two peers"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '1.2.3.4:5'}, {
+ 'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG5=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ self.generate(config)
+ if self.backend == 'networkd':
+ self.assert_networkd({'wg0.netdev': ND_WG % ('=4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=1.2.3.4:5
+
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG5=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=1.2.3.4:5'''),
+ 'wg0.network': ND_WITHIPGW % ('wg0', '15.15.15.15/24', '2001:de:ad:be:ef:ca:fe:1/128',
+ '20.20.20.21')})
+ elif self.backend == 'NetworkManager':
+ self.assert_nm({'wg0.nmconnection': NM_WG % ('4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''
+[wireguard-peer.M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=]
+persistent-keepalive=23
+endpoint=1.2.3.4:5
+allowed-ips=0.0.0.0/0;2001:fe:ad:de:ad:be:ef:1/24;
+
+[wireguard-peer.M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG5=]
+persistent-keepalive=23
+endpoint=1.2.3.4:5
+allowed-ips=0.0.0.0/0;2001:fe:ad:de:ad:be:ef:1/24;''')})
+
+ def test_privatekeyfile(self):
+ """[wireguard] Validate generation of another simple wireguard config"""
+ config = prepare_wg_config(listen=12345, privkey='/tmp/test_private_key',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'shared-key': '/tmp/test_preshared_key',
+ 'endpoint': '1.2.3.4:5'}], renderer=self.backend)
+ if self.backend == 'networkd':
+ self.generate(config)
+ self.assert_networkd({'wg0.netdev': ND_WG % ('File=/tmp/test_private_key', '12345', '''
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=1.2.3.4:5
+PresharedKeyFile=/tmp/test_preshared_key'''),
+ 'wg0.network': ND_WITHIPGW % ('wg0', '15.15.15.15/24', '2001:de:ad:be:ef:ca:fe:1/128',
+ '20.20.20.21')})
+ elif self.backend == 'NetworkManager':
+ err = self.generate(config, expect_fail=True)
+ self.assertIn('wg0: private key needs to be base64 encoded when using the NM backend', err)
+
+ def test_ipv6_remote(self):
+ """[wireguard] Validate generation of wireguard config with v6 remote endpoint"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 23,
+ 'endpoint': '"[2001:fe:ad:de:ad:be:ef:11]:5"'}], renderer=self.backend)
+ self.generate(config)
+ if self.backend == 'networkd':
+ self.assert_networkd({'wg0.netdev': ND_WG % ('=4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''
+[WireGuardPeer]
+PublicKey=M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+AllowedIPs=0.0.0.0/0,2001:fe:ad:de:ad:be:ef:1/24
+PersistentKeepalive=23
+Endpoint=[2001:fe:ad:de:ad:be:ef:11]:5'''),
+ 'wg0.network': ND_WITHIPGW % ('wg0', '15.15.15.15/24', '2001:de:ad:be:ef:ca:fe:1/128',
+ '20.20.20.21')})
+ elif self.backend == 'NetworkManager':
+ self.assert_nm({'wg0.nmconnection': NM_WG % ('4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=', '12345', '''
+[wireguard-peer.M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=]
+persistent-keepalive=23
+endpoint=[2001:fe:ad:de:ad:be:ef:11]:5
+allowed-ips=0.0.0.0/0;2001:fe:ad:de:ad:be:ef:1/24;''')})
+
+
+# Execute the _CommonParserErrors only for one backend, to spare some test cycles
+class TestNetworkd(TestBase, _CommonTests, _CommonParserErrors):
+ backend = 'networkd'
+
+ def test_sit(self):
+ """[networkd] Validate generation of SIT tunnels"""
+ config = prepare_config_for_mode('networkd', 'sit')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=sit
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_sit_he(self):
+ """[networkd] Validate generation of SIT tunnels (HE example)"""
+ # Test specifically a config like one that would enable Hurricane
+ # Electric IPv6 tunnels.
+ config = '''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ eth0:
+ addresses:
+ - 1.1.1.1/24
+ - "2001:cafe:face::1/64" # provided by HE as routed /64
+ gateway4: 1.1.1.254
+ tunnels:
+ he-ipv6:
+ mode: sit
+ remote: 2.2.2.2
+ local: 1.1.1.1
+ addresses:
+ - "2001:dead:beef::2/64"
+ gateway6: "2001:dead:beef::1"
+'''
+ self.generate(config)
+ self.assert_networkd({'eth0.network': '''[Match]
+Name=eth0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=1.1.1.1/24
+Address=2001:cafe:face::1/64
+Gateway=1.1.1.254
+''',
+ 'he-ipv6.netdev': '''[NetDev]
+Name=he-ipv6
+Kind=sit
+
+[Tunnel]
+Independent=true
+Local=1.1.1.1
+Remote=2.2.2.2
+''',
+ 'he-ipv6.network': '''[Match]
+Name=he-ipv6
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=2001:dead:beef::2/64
+Gateway=2001:dead:beef::1
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti(self):
+ """[networkd] Validate generation of VTI tunnels"""
+ config = prepare_config_for_mode('networkd', 'vti')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=vti
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti_with_key_str(self):
+ """[networkd] Validate generation of VTI tunnels with input/output keys"""
+ config = prepare_config_for_mode('networkd', 'vti', key='1.1.1.1')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=vti
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+InputKey=1.1.1.1
+OutputKey=1.1.1.1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti_with_key_dict(self):
+ """[networkd] Validate generation of VTI tunnels with key dict"""
+ config = prepare_config_for_mode('networkd', 'vti', key={'input': 1234, 'output': 5678})
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=vti
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+InputKey=1234
+OutputKey=5678
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti_invalid_key(self):
+ """[networkd] Validate VTI tunnel generation key handling"""
+ config = prepare_config_for_mode('networkd', 'vti', key={'input': 42, 'output': 'invalid'})
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid tunnel key 'invalid'", out)
+
+ def test_vti6(self):
+ """[networkd] Validate generation of VTI6 tunnels"""
+ config = prepare_config_for_mode('networkd', 'vti6')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=vti6
+
+[Tunnel]
+Independent=true
+Local=fe80::dead:beef
+Remote=2001:fe:ad:de:ad:be:ef:1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti6_with_key(self):
+ """[networkd] Validate generation of VTI6 tunnels with input/output keys"""
+ config = prepare_config_for_mode('networkd', 'vti6', key='1.1.1.1')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=vti6
+
+[Tunnel]
+Independent=true
+Local=fe80::dead:beef
+Remote=2001:fe:ad:de:ad:be:ef:1
+InputKey=1.1.1.1
+OutputKey=1.1.1.1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_vti6_invalid_key(self):
+ """[networkd] Validate VTI6 tunnel generation key handling"""
+ config = prepare_config_for_mode('networkd', 'vti6', key='invalid')
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid tunnel key 'invalid'", out)
+
+ def test_ipip6(self):
+ """[networkd] Validate generation of IPIP6 tunnels"""
+ config = prepare_config_for_mode('networkd', 'ipip6')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=ip6tnl
+
+[Tunnel]
+Independent=true
+Mode=ipip6
+Local=fe80::dead:beef
+Remote=2001:fe:ad:de:ad:be:ef:1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_ipip(self):
+ """[networkd] Validate generation of IPIP tunnels"""
+ config = prepare_config_for_mode('networkd', 'ipip', ttl=64)
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=ipip
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+TTL=64
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_isatap(self):
+ """[networkd] Warning for ISATAP tunnel generation not supported"""
+ config = prepare_config_for_mode('networkd', 'isatap')
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: ISATAP tunnel mode is not supported", out)
+
+ def test_gre(self):
+ """[networkd] Validate generation of GRE tunnels"""
+ config = prepare_config_for_mode('networkd', 'gre')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=gre
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_ip6gre(self):
+ """[networkd] Validate generation of IP6GRE tunnels"""
+ config = prepare_config_for_mode('networkd', 'ip6gre')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=ip6gre
+
+[Tunnel]
+Independent=true
+Local=fe80::dead:beef
+Remote=2001:fe:ad:de:ad:be:ef:1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_gretap(self):
+ """[networkd] Validate generation of GRETAP tunnels"""
+ config = prepare_config_for_mode('networkd', 'gretap')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=gretap
+
+[Tunnel]
+Independent=true
+Local=10.10.10.10
+Remote=20.20.20.20
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+ def test_ip6gretap(self):
+ """[networkd] Validate generation of IP6GRETAP tunnels"""
+ config = prepare_config_for_mode('networkd', 'ip6gretap')
+ self.generate(config)
+ self.assert_networkd({'tun0.netdev': '''[NetDev]
+Name=tun0
+Kind=ip6gretap
+
+[Tunnel]
+Independent=true
+Local=fe80::dead:beef
+Remote=2001:fe:ad:de:ad:be:ef:1
+''',
+ 'tun0.network': '''[Match]
+Name=tun0
+
+[Network]
+LinkLocalAddressing=ipv6
+Address=15.15.15.15/24
+Gateway=20.20.20.21
+ConfigureWithoutCarrier=yes
+'''})
+
+
+class TestNetworkManager(TestBase, _CommonTests):
+ backend = 'NetworkManager'
+
+ def test_fail_invalid_private_key_file(self):
+ """[wireguard] Show an error for an invalid private key-file"""
+ config = prepare_wg_config(listen=12345, privkey='/invalid.key',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("wg0: private key needs to be base64 encoded when using the NM backend", out)
+
+ def test_fail_invalid_shared_key_file(self):
+ """[wireguard] Show an error for an invalid pre shared key-file"""
+ config = prepare_wg_config(listen=12345, privkey='4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=',
+ peers=[{'public-key': 'M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=',
+ 'allowed-ips': '[0.0.0.0/0, "2001:fe:ad:de:ad:be:ef:1/24"]',
+ 'keepalive': 14,
+ 'shared-key': '/invalid.key',
+ 'endpoint': '1.2.3.4:1005'}], renderer=self.backend)
+
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("wg0: shared key needs to be base64 encoded when using the NM backend", out)
+
+ def test_isatap(self):
+ """[NetworkManager] Validate ISATAP tunnel generation"""
+ config = prepare_config_for_mode('NetworkManager', 'isatap')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=4
+local=10.10.10.10
+remote=20.20.20.20
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_sit(self):
+ """[NetworkManager] Validate generation of SIT tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'sit')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=3
+local=10.10.10.10
+remote=20.20.20.20
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_sit_he(self):
+ """[NetworkManager] Validate generation of SIT tunnels (HE example)"""
+ # Test specifically a config like one that would enable Hurricane
+ # Electric IPv6 tunnels.
+ config = '''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ eth0:
+ addresses:
+ - 1.1.1.1/24
+ - "2001:cafe:face::1/64" # provided by HE as routed /64
+ gateway4: 1.1.1.254
+ tunnels:
+ he-ipv6:
+ mode: sit
+ remote: 2.2.2.2
+ local: 1.1.1.1
+ addresses:
+ - "2001:dead:beef::2/64"
+ gateway6: "2001:dead:beef::1"
+'''
+ self.generate(config)
+ self.assert_nm({'eth0': '''[connection]
+id=netplan-eth0
+type=ethernet
+interface-name=eth0
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=manual
+address1=1.1.1.1/24
+gateway=1.1.1.254
+
+[ipv6]
+method=manual
+address1=2001:cafe:face::1/64
+''',
+ 'he-ipv6': '''[connection]
+id=netplan-he-ipv6
+type=ip-tunnel
+interface-name=he-ipv6
+
+[ip-tunnel]
+mode=3
+local=1.1.1.1
+remote=2.2.2.2
+
+[ipv4]
+method=disabled
+
+[ipv6]
+method=manual
+address1=2001:dead:beef::2/64
+gateway=2001:dead:beef::1
+'''})
+
+ def test_vti(self):
+ """[NetworkManager] Validate generation of VTI tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'vti')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=5
+local=10.10.10.10
+remote=20.20.20.20
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_vti6(self):
+ """[NetworkManager] Validate generation of VTI6 tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'vti6')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=9
+local=fe80::dead:beef
+remote=2001:fe:ad:de:ad:be:ef:1
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_ip6ip6(self):
+ """[NetworkManager] Validate generation of IP6IP6 tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'ip6ip6')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=6
+local=fe80::dead:beef
+remote=2001:fe:ad:de:ad:be:ef:1
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_ipip(self):
+ """[NetworkManager] Validate generation of IPIP tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'ipip', ttl=64)
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=1
+local=10.10.10.10
+remote=20.20.20.20
+ttl=64
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_gre(self):
+ """[NetworkManager] Validate generation of GRE tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'gre')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=2
+local=10.10.10.10
+remote=20.20.20.20
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_gre_with_keys(self):
+ """[NetworkManager] Validate generation of GRE tunnels with keys"""
+ config = prepare_config_for_mode('NetworkManager', 'gre', key={'input': 1111, 'output': 5555})
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=2
+local=10.10.10.10
+remote=20.20.20.20
+input-key=1111
+output-key=5555
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_ip6gre(self):
+ """[NetworkManager] Validate generation of IP6GRE tunnels"""
+ config = prepare_config_for_mode('NetworkManager', 'ip6gre')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=8
+local=fe80::dead:beef
+remote=2001:fe:ad:de:ad:be:ef:1
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_ip6gre_with_key(self):
+ """[NetworkManager] Validate generation of IP6GRE tunnels with key"""
+ config = prepare_config_for_mode('NetworkManager', 'ip6gre', key='9999')
+ self.generate(config)
+ self.assert_nm({'tun0': '''[connection]
+id=netplan-tun0
+type=ip-tunnel
+interface-name=tun0
+
+[ip-tunnel]
+mode=8
+local=fe80::dead:beef
+remote=2001:fe:ad:de:ad:be:ef:1
+input-key=9999
+output-key=9999
+
+[ipv4]
+method=manual
+address1=15.15.15.15/24
+gateway=20.20.20.21
+
+[ipv6]
+method=ignore
+'''})
+
+
+class TestConfigErrors(TestBase):
+
+ def test_missing_mode(self):
+ """Fail if tunnel mode is missing"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ remote: 20.20.20.20
+ local: 10.10.10.10
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: missing 'mode' property for tunnel", out)
+
+ def test_invalid_mode(self):
+ """Ensure an invalid tunnel mode shows an error message"""
+ config = prepare_config_for_mode('networkd', 'invalid')
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: tunnel mode 'invalid' is not supported", out)
+
+ def test_invalid_mode_for_nm(self):
+ """Show an error if a mode is selected that can't be handled by the renderer"""
+ config = prepare_config_for_mode('NetworkManager', 'gretap')
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: GRETAP tunnel mode is not supported by NetworkManager", out)
+
+ def test_malformed_tunnel_ip(self):
+ """Fail if local/remote IP for tunnel are malformed"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ remote: 20.20.20.20
+ local: 10.10.1invalid
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: malformed address '10.10.1invalid', must be X.X.X.X or X:X:X:X:X:X:X:X", out)
+
+ def test_cidr_tunnel_ip(self):
+ """Fail if local/remote IP for tunnel include /prefix"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ remote: 20.20.20.20
+ local: 10.10.10.10/21
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: address '10.10.10.10/21' should not include /prefixlength", out)
+
+ def test_missing_local_ip(self):
+ """Fail if local IP is missing"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ remote: 20.20.20.20
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: missing 'local' property for tunnel", out)
+
+ def test_missing_remote_ip(self):
+ """Fail if remote IP is missing"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ local: 20.20.20.20
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: missing 'remote' property for tunnel", out)
+
+ def test_invalid_ttl(self):
+ """Fail if TTL not in range [1...255]"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 20.20.20.20
+ remote: 10.10.10.10
+ ttl: 300
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'ttl' property for tunnel must be in range [1...255]", out)
+
+ def test_wrong_local_ip_for_mode_v4(self):
+ """Show an error when an IPv6 local addr is used for an IPv4 tunnel mode"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ local: fe80::2
+ remote: 20.20.20.20
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'local' must be a valid IPv4 address for this tunnel type", out)
+
+ def test_wrong_remote_ip_for_mode_v4(self):
+ """Show an error when an IPv6 remote addr is used for an IPv4 tunnel mode"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ local: 10.10.10.10
+ remote: 2006::1
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'remote' must be a valid IPv4 address for this tunnel type", out)
+
+ def test_wrong_local_ip_for_mode_v6(self):
+ """Show an error when an IPv4 local addr is used for an IPv6 tunnel mode"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: ip6gre
+ local: 10.10.10.10
+ remote: 2001::3
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'local' must be a valid IPv6 address for this tunnel type", out)
+
+ def test_wrong_remote_ip_for_mode_v6(self):
+ """Show an error when an IPv4 remote addr is used for an IPv6 tunnel mode"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: ip6gre
+ local: 2001::face
+ remote: 20.20.20.20
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'remote' must be a valid IPv6 address for this tunnel type", out)
+
+ def test_malformed_keys(self):
+ """Show an error if tunnel keys stanza is malformed"""
+ config = '''network:
+ version: 2
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 10.10.10.10
+ remote: 20.20.20.20
+ keys:
+ - input: 1234
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: invalid type for 'key[s]': must be a scalar or mapping", out)
+
+ def test_networkd_invalid_input_key_use(self):
+ """[networkd] Show an error if input-key is used for a mode that does not support it"""
+ config = '''network:
+ version: 2
+ renderer: networkd
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 10.10.10.10
+ remote: 20.20.20.20
+ keys:
+ input: 1234
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'input-key' is not required for this tunnel type", out)
+
+ def test_networkd_invalid_output_key_use(self):
+ """[networkd] Show an error if output-key is used for a mode that does not support it"""
+ config = '''network:
+ version: 2
+ renderer: networkd
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 10.10.10.10
+ remote: 20.20.20.20
+ keys:
+ output: 1234
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'output-key' is not required for this tunnel type", out)
+
+ def test_nm_invalid_input_key_use(self):
+ """[NetworkManager] Show an error if input-key is used for a mode that does not support it"""
+ config = '''network:
+ version: 2
+ renderer: NetworkManager
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 10.10.10.10
+ remote: 20.20.20.20
+ keys:
+ input: 1234
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'input-key' is not required for this tunnel type", out)
+
+ def test_nm_invalid_output_key_use(self):
+ """[NetworkManager] Show an error if output-key is used for a mode that does not support it"""
+ config = '''network:
+ version: 2
+ renderer: NetworkManager
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 10.10.10.10
+ remote: 20.20.20.20
+ keys:
+ output: 1234
+'''
+ out = self.generate(config, expect_fail=True)
+ self.assertIn("Error in network definition: tun0: 'output-key' is not required for this tunnel type", out)
diff --git a/tests/generator/test_vlans.py b/tests/generator/test_vlans.py
new file mode 100644
index 0000000..63827fd
--- /dev/null
+++ b/tests/generator/test_vlans.py
@@ -0,0 +1,306 @@
+#
+# Tests for VLAN devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import re
+import unittest
+
+from .base import TestBase, ND_VLAN, ND_EMPTY, ND_WITHIP, ND_DHCP6_WOCARRIER
+
+
+class TestNetworkd(TestBase):
+
+ @unittest.skipIf("CODECOV_TOKEN" in os.environ, "Skipping on codecov.io: GLib changed hashtable elements order")
+ def test_vlan(self): # pragma: nocover
+ self.generate('''network:
+ version: 2
+ ethernets:
+ en1: {}
+ vlans:
+ enblue:
+ id: 1
+ link: en1
+ addresses: [1.2.3.4/24]
+ enred:
+ id: 3
+ link: en1
+ macaddress: aa:bb:cc:dd:ee:11
+ engreen: {id: 2, link: en1, dhcp6: true}''')
+
+ self.assert_networkd({'en1.network': '''[Match]
+Name=en1
+
+[Network]
+LinkLocalAddressing=ipv6
+VLAN=enblue
+VLAN=enred
+VLAN=engreen
+''',
+ 'enblue.netdev': ND_VLAN % ('enblue', 1),
+ 'engreen.netdev': ND_VLAN % ('engreen', 2),
+ 'enred.netdev': '''[NetDev]
+Name=enred
+MACAddress=aa:bb:cc:dd:ee:11
+Kind=vlan
+
+[VLAN]
+Id=3
+''',
+ 'enblue.network': ND_WITHIP % ('enblue', '1.2.3.4/24'),
+ 'enred.network': (ND_EMPTY % ('enred', 'ipv6'))
+ .replace('[Network]', '[Link]\nMACAddress=aa:bb:cc:dd:ee:11\n\n[Network]'),
+ 'engreen.network': (ND_DHCP6_WOCARRIER % 'engreen')})
+
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:en1,interface-name:enblue,interface-name:enred,interface-name:engreen,''')
+ self.assert_nm_udev(None)
+
+ def test_vlan_sriov(self):
+ # we need to make sure renderer: sriov vlans are not saved as part of
+ # the NM/networkd config
+ self.generate('''network:
+ version: 2
+ ethernets:
+ en1: {}
+ vlans:
+ enblue:
+ id: 1
+ link: en1
+ renderer: sriov
+ engreen: {id: 2, link: en1, dhcp6: true}''')
+
+ self.assert_networkd({'en1.network': '''[Match]
+Name=en1
+
+[Network]
+LinkLocalAddressing=ipv6
+VLAN=engreen
+''',
+ 'engreen.netdev': ND_VLAN % ('engreen', 2),
+ 'engreen.network': (ND_DHCP6_WOCARRIER % 'engreen')})
+
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:en1,interface-name:enblue,interface-name:engreen,''')
+ self.assert_nm_udev(None)
+
+ # see LP: #1888726
+ def test_vlan_parent_match(self):
+ self.generate('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ lan:
+ match: {macaddress: "11:22:33:44:55:66"}
+ set-name: lan
+ mtu: 9000
+ vlans:
+ vlan20: {id: 20, link: lan}''')
+
+ self.assert_networkd({'lan.network': '''[Match]
+MACAddress=11:22:33:44:55:66
+Name=lan
+Type=!vlan bond bridge
+
+[Link]
+MTUBytes=9000
+
+[Network]
+LinkLocalAddressing=ipv6
+VLAN=vlan20
+''',
+ 'lan.link': '''[Match]
+MACAddress=11:22:33:44:55:66
+Type=!vlan bond bridge
+
+[Link]
+Name=lan
+WakeOnLan=off
+MTUBytes=9000
+''',
+ 'vlan20.network': ND_EMPTY % ('vlan20', 'ipv6'),
+ 'vlan20.netdev': ND_VLAN % ('vlan20', 20)})
+
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=mac:11:22:33:44:55:66,interface-name:lan,interface-name:vlan20,''')
+ self.assert_nm_udev(None)
+
+
+class TestNetworkManager(TestBase):
+
+ def test_vlan(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ en1: {}
+ vlans:
+ enblue:
+ id: 1
+ link: en1
+ addresses: [1.2.3.4/24]
+ engreen: {id: 2, link: en1, dhcp6: true}''')
+
+ self.assert_networkd({})
+ self.assert_nm({'en1': '''[connection]
+id=netplan-en1
+type=ethernet
+interface-name=en1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'enblue': '''[connection]
+id=netplan-enblue
+type=vlan
+interface-name=enblue
+
+[vlan]
+id=1
+parent=en1
+
+[ipv4]
+method=manual
+address1=1.2.3.4/24
+
+[ipv6]
+method=ignore
+''',
+ 'engreen': '''[connection]
+id=netplan-engreen
+type=vlan
+interface-name=engreen
+
+[vlan]
+id=2
+parent=en1
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+'''})
+ self.assert_nm_udev(None)
+
+ def test_vlan_parent_match(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ en-v:
+ match: {macaddress: "11:22:33:44:55:66"}
+ vlans:
+ engreen: {id: 2, link: en-v, dhcp4: true}''')
+
+ self.assert_networkd({})
+
+ # get assigned UUID from en-v connection
+ with open(os.path.join(self.workdir.name, 'run/NetworkManager/system-connections/netplan-en-v.nmconnection')) as f:
+ m = re.search('uuid=([0-9a-fA-F-]{36})\n', f.read())
+ self.assertTrue(m)
+ uuid = m.group(1)
+ self.assertNotEquals(uuid, "00000000-0000-0000-0000-000000000000")
+
+ self.assert_nm({'en-v': '''[connection]
+id=netplan-en-v
+type=ethernet
+uuid=%s
+
+[ethernet]
+wake-on-lan=0
+mac-address=11:22:33:44:55:66
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''' % uuid,
+ 'engreen': '''[connection]
+id=netplan-engreen
+type=vlan
+interface-name=engreen
+
+[vlan]
+id=2
+parent=%s
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+''' % uuid})
+ self.assert_nm_udev(None)
+
+ def test_vlan_sriov(self):
+ # we need to make sure renderer: sriov vlans are not saved as part of
+ # the NM/networkd config
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ ethernets:
+ en1: {}
+ vlans:
+ enblue:
+ id: 1
+ link: en1
+ addresses: [1.2.3.4/24]
+ renderer: sriov
+ engreen: {id: 2, link: en1, dhcp6: true}''')
+
+ self.assert_networkd({})
+ self.assert_nm({'en1': '''[connection]
+id=netplan-en1
+type=ethernet
+interface-name=en1
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+''',
+ 'engreen': '''[connection]
+id=netplan-engreen
+type=vlan
+interface-name=engreen
+
+[vlan]
+id=2
+parent=en1
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=auto
+'''})
+ self.assert_nm_udev(None)
diff --git a/tests/generator/test_wifis.py b/tests/generator/test_wifis.py
new file mode 100644
index 0000000..513d788
--- /dev/null
+++ b/tests/generator/test_wifis.py
@@ -0,0 +1,692 @@
+#
+# Tests for VLAN devices config generated via netplan
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel.lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import stat
+
+from .base import TestBase, ND_WIFI_DHCP4
+
+
+class TestNetworkd(TestBase):
+
+ def test_wifi(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "s0s3kr1t"
+ bssid: 00:11:22:33:44:55
+ band: 2.4GHz
+ channel: 11
+ workplace:
+ password: "c0mpany1"
+ bssid: de:ad:be:ef:ca:fe
+ band: 5GHz
+ channel: 100
+ peer2peer:
+ mode: adhoc
+ hidden-y:
+ hidden: y
+ password: "0bscur1ty"
+ hidden-n:
+ hidden: n
+ password: "5ecur1ty"
+ channel-no-band:
+ channel: 7
+ band-no-channel:
+ band: 2.4G
+ band-no-channel2:
+ band: 5G
+ dhcp4: yes''')
+
+ self.assert_networkd({'wl0.network': ND_WIFI_DHCP4 % 'wl0'})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:wl0,''')
+ self.assert_nm_udev(None)
+
+ # generates wpa config and enables wpasupplicant unit
+ with open(os.path.join(self.workdir.name, 'run/netplan/wpa-wl0.conf')) as f:
+ new_config = f.read()
+
+ network = 'ssid="{}"\n freq_list='.format('band-no-channel2')
+ freqs_5GHz = [5610, 5310, 5620, 5320, 5630, 5640, 5340, 5035, 5040, 5045, 5055, 5060, 5660, 5680, 5670, 5080, 5690,
+ 5700, 5710, 5720, 5825, 5745, 5755, 5805, 5765, 5160, 5775, 5170, 5480, 5180, 5795, 5190, 5500, 5200,
+ 5510, 5210, 5520, 5220, 5530, 5230, 5540, 5240, 5550, 5250, 5560, 5260, 5570, 5270, 5580, 5280, 5590,
+ 5290, 5600, 5300, 5865, 5845, 5785]
+ freqs = new_config.split(network)
+ freqs = freqs[1].split('\n')[0]
+ self.assertEqual(len(freqs.split(' ')), len(freqs_5GHz))
+ for freq in freqs_5GHz:
+ self.assertRegexpMatches(new_config, '{}[ 0-9]*{}[ 0-9]*\n'.format(network, freq))
+
+ network = 'ssid="{}"\n freq_list='.format('band-no-channel')
+ freqs_24GHz = [2412, 2417, 2422, 2427, 2432, 2442, 2447, 2437, 2452, 2457, 2462, 2467, 2472, 2484]
+ freqs = new_config.split(network)
+ freqs = freqs[1].split('\n')[0]
+ self.assertEqual(len(freqs.split(' ')), len(freqs_24GHz))
+ for freq in freqs_24GHz:
+ self.assertRegexpMatches(new_config, '{}[ 0-9]*{}[ 0-9]*\n'.format(network, freq))
+
+ self.assertIn('''
+network={
+ ssid="channel-no-band"
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="peer2peer"
+ mode=1
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="hidden-y"
+ scan_ssid=1
+ key_mgmt=WPA-PSK
+ psk="0bscur1ty"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="hidden-n"
+ key_mgmt=WPA-PSK
+ psk="5ecur1ty"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="workplace"
+ bssid=de:ad:be:ef:ca:fe
+ freq_list=5500
+ key_mgmt=WPA-PSK
+ psk="c0mpany1"
+}
+''', new_config)
+ self.assertIn('''
+network={
+ ssid="Joe's Home"
+ bssid=00:11:22:33:44:55
+ freq_list=2462
+ key_mgmt=WPA-PSK
+ psk="s0s3kr1t"
+}
+''', new_config)
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+
+ def test_wifi_upgrade(self):
+ # pretend an old 'netplan-wpa@*.service' link still exists on an upgraded system
+ os.makedirs(os.path.join(self.workdir.name, 'lib/systemd/system'))
+ os.makedirs(os.path.join(self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants'))
+ with open(os.path.join(self.workdir.name, 'lib/systemd/system/netplan-wpa@.service'), 'w') as out:
+ out.write('''[Unit]
+Description=WPA supplicant for netplan %I
+DefaultDependencies=no
+Requires=sys-subsystem-net-devices-%i.device
+After=sys-subsystem-net-devices-%i.device
+Before=network.target
+Wants=network.target
+
+[Service]
+Type=simple
+ExecStart=/sbin/wpa_supplicant -c /run/netplan/wpa-%I.conf -i%I''')
+ os.symlink(os.path.join(self.workdir.name, 'lib/systemd/system/netplan-wpa@.service'),
+ os.path.join(self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa@wl0.service'))
+
+ # run generate, which should cleanup the old files/symlinks
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "s0s3kr1t"
+ dhcp4: yes''')
+
+ # verify new files/links exist, while old have been removed
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+ # old files/links
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'lib/systemd/system/netplan-wpa@.service')))
+ self.assertFalse(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa@wl0.service')))
+
+ # pretend another old systemd service file exists for wl1
+ os.symlink(os.path.join(self.workdir.name, 'lib/systemd/system/netplan-wpa@.service'),
+ os.path.join(self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa@wl1.service'))
+
+ # run generate again, to verify the historical netplan-wpa@.service links and wl0 links are gone
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl1:
+ access-points:
+ "Other Home":
+ password: "s0s3kr1t"
+ dhcp4: yes''')
+
+ # verify new files/links exist, while old have been removed
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl1.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl1.service')))
+ # old files/links
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'lib/systemd/system/netplan-wpa@.service')))
+ self.assertFalse(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa@wl1.service')))
+ self.assertFalse(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa@wl0.service')))
+ self.assertFalse(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertFalse(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+
+ def test_wifi_route(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ password: "c0mpany1"
+ dhcp4: yes
+ routes:
+ - to: 10.10.10.0/24
+ via: 8.8.8.8''')
+
+ self.assert_networkd({'wl0.network': '''[Match]
+Name=wl0
+
+[Network]
+DHCP=ipv4
+LinkLocalAddressing=ipv6
+
+[Route]
+Destination=10.10.10.0/24
+Gateway=8.8.8.8
+
+[DHCP]
+RouteMetric=600
+UseMTU=true
+'''})
+
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:wl0,''')
+ self.assert_nm_udev(None)
+
+ def test_wifi_match(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ somewifi:
+ match:
+ driver: foo
+ access-points:
+ workplace:
+ password: "c0mpany1"
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn('networkd backend does not support wifi with match:', err)
+
+ def test_wifi_ap(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ access-points:
+ workplace:
+ password: "c0mpany1"
+ mode: ap
+ dhcp4: yes''', expect_fail=True)
+ self.assertIn('wl0: workplace: networkd does not support this wifi mode', err)
+
+ def test_wifi_wowlan(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ wakeonwlan:
+ - any
+ - disconnect
+ - magic_pkt
+ - gtk_rekey_failure
+ - eap_identity_req
+ - four_way_handshake
+ - rfkill_release
+ access-points:
+ homenet: {mode: infrastructure}''')
+
+ self.assert_networkd({'wl0.network': '''[Match]
+Name=wl0
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:wl0,''')
+ self.assert_nm_udev(None)
+
+ # generates wpa config and enables wpasupplicant unit
+ with open(os.path.join(self.workdir.name, 'run/netplan/wpa-wl0.conf')) as f:
+ new_config = f.read()
+ self.assertIn('''
+wowlan_triggers=any disconnect magic_pkt gtk_rekey_failure eap_identity_req four_way_handshake rfkill_release
+network={
+ ssid="homenet"
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+
+ def test_wifi_wowlan_default(self):
+ self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ wakeonwlan: [default]
+ access-points:
+ homenet: {mode: infrastructure}''')
+
+ self.assert_networkd({'wl0.network': '''[Match]
+Name=wl0
+
+[Network]
+LinkLocalAddressing=ipv6
+'''})
+ self.assert_nm(None, '''[keyfile]
+# devices managed by networkd
+unmanaged-devices+=interface-name:wl0,''')
+ self.assert_nm_udev(None)
+
+ # generates wpa config and enables wpasupplicant unit
+ with open(os.path.join(self.workdir.name, 'run/netplan/wpa-wl0.conf')) as f:
+ new_config = f.read()
+ self.assertIn('''
+network={
+ ssid="homenet"
+ key_mgmt=NONE
+}
+''', new_config)
+ self.assertEqual(stat.S_IMODE(os.fstat(f.fileno()).st_mode), 0o600)
+ self.assertTrue(os.path.isfile(os.path.join(
+ self.workdir.name, 'run/systemd/system/netplan-wpa-wl0.service')))
+ self.assertTrue(os.path.islink(os.path.join(
+ self.workdir.name, 'run/systemd/system/systemd-networkd.service.wants/netplan-wpa-wl0.service')))
+
+
+class TestNetworkManager(TestBase):
+
+ def test_wifi_default(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ access-points:
+ "Joe's Home":
+ password: "s0s3kr1t"
+ bssid: 00:11:22:33:44:55
+ band: 2.4GHz
+ channel: 11
+ workplace:
+ password: "c0mpany1"
+ bssid: de:ad:be:ef:ca:fe
+ band: 5GHz
+ channel: 100
+ hidden-y:
+ hidden: y
+ password: "0bscur1ty"
+ hidden-n:
+ hidden: n
+ password: "5ecur1ty"
+ channel-no-band:
+ channel: 22
+ band-no-channel:
+ band: 5GHz
+ dhcp4: yes''')
+
+ self.assert_nm({'wl0-Joe%27s%20Home': '''[connection]
+id=netplan-wl0-Joe's Home
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=Joe's Home
+mode=infrastructure
+bssid=00:11:22:33:44:55
+band=bg
+channel=11
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=s0s3kr1t
+''',
+ 'wl0-workplace': '''[connection]
+id=netplan-wl0-workplace
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=workplace
+mode=infrastructure
+bssid=de:ad:be:ef:ca:fe
+band=a
+channel=100
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=c0mpany1
+''',
+ 'wl0-hidden-y': '''[connection]
+id=netplan-wl0-hidden-y
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=hidden-y
+mode=infrastructure
+hidden=true
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=0bscur1ty
+''',
+ 'wl0-hidden-n': '''[connection]
+id=netplan-wl0-hidden-n
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=hidden-n
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=5ecur1ty
+''',
+ 'wl0-channel-no-band': '''[connection]
+id=netplan-wl0-channel-no-band
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=channel-no-band
+mode=infrastructure
+''',
+ 'wl0-band-no-channel': '''[connection]
+id=netplan-wl0-band-no-channel
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=band-no-channel
+mode=infrastructure
+band=a
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_wifi_match_mac(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ all:
+ match:
+ macaddress: 11:22:33:44:55:66
+ access-points:
+ workplace: {}''')
+
+ self.assert_nm({'all-workplace': '''[connection]
+id=netplan-all-workplace
+type=wifi
+
+[wifi]
+mac-address=11:22:33:44:55:66
+ssid=workplace
+mode=infrastructure
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_wifi_match_all(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ all:
+ match: {}
+ access-points:
+ workplace: {mode: infrastructure}''')
+
+ self.assert_nm({'all-workplace': '''[connection]
+id=netplan-all-workplace
+type=wifi
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=workplace
+mode=infrastructure
+'''})
+
+ def test_wifi_ap(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ access-points:
+ homenet:
+ mode: ap
+ password: s0s3cret''')
+
+ self.assert_nm({'wl0-homenet': '''[connection]
+id=netplan-wl0-homenet
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=shared
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=homenet
+mode=ap
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=s0s3cret
+'''})
+ self.assert_networkd({})
+ self.assert_nm_udev(None)
+
+ def test_wifi_adhoc(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ access-points:
+ homenet:
+ mode: adhoc''')
+
+ self.assert_nm({'wl0-homenet': '''[connection]
+id=netplan-wl0-homenet
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=homenet
+mode=adhoc
+'''})
+
+ def test_wifi_wowlan(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ wakeonwlan: [any, tcp, four_way_handshake, magic_pkt]
+ access-points:
+ homenet: {mode: infrastructure}''')
+
+ self.assert_nm({'wl0-homenet': '''[connection]
+id=netplan-wl0-homenet
+type=wifi
+interface-name=wl0
+
+[wifi]
+wake-on-wlan=330
+ssid=homenet
+mode=infrastructure
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''})
+
+ def test_wifi_wowlan_default(self):
+ self.generate('''network:
+ version: 2
+ renderer: NetworkManager
+ wifis:
+ wl0:
+ wakeonwlan: [default]
+ access-points:
+ homenet: {mode: infrastructure}''')
+
+ self.assert_nm({'wl0-homenet': '''[connection]
+id=netplan-wl0-homenet
+type=wifi
+interface-name=wl0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=homenet
+mode=infrastructure
+'''})
+
+
+class TestConfigErrors(TestBase):
+
+ def test_wifi_invalid_wowlan(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ wakeonwlan: [bogus]
+ access-points:
+ homenet: {mode: infrastructure}''', expect_fail=True)
+ self.assertIn("Error in network definition: invalid value for wakeonwlan: 'bogus'", err)
+
+ def test_wifi_wowlan_unsupported(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ wakeonwlan: [tcp]
+ access-points:
+ homenet: {mode: infrastructure}''', expect_fail=True)
+ self.assertIn("ERROR: unsupported wowlan_triggers mask: 0x100", err)
+
+ def test_wifi_wowlan_exclusive(self):
+ err = self.generate('''network:
+ version: 2
+ wifis:
+ wl0:
+ wakeonwlan: [default, magic_pkt]
+ access-points:
+ homenet: {mode: infrastructure}''', expect_fail=True)
+ self.assertIn("Error in network definition: 'default' is an exclusive flag for wakeonwlan", err)
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..be79e88
--- /dev/null
+++ b/tests/integration/__init__.py
@@ -0,0 +1,17 @@
+#
+# Integration tests.
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/tests/integration/base.py b/tests/integration/base.py
new file mode 100644
index 0000000..5042bf4
--- /dev/null
+++ b/tests/integration/base.py
@@ -0,0 +1,484 @@
+#
+# System integration tests of netplan-generate. NM and networkd are
+# started on the generated configuration, using emulated ethernets (veth) and
+# Wifi (mac80211-hwsim). These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Martin Pitt <martin.pitt@ubuntu.com>
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import sys
+import re
+import time
+import subprocess
+import tempfile
+import unittest
+import shutil
+import gi
+import glob
+
+# make sure we point to libnetplan properly.
+os.environ.update({'LD_LIBRARY_PATH': '.:{}'.format(os.environ.get('LD_LIBRARY_PATH'))})
+
+test_backends = "networkd NetworkManager" if "NETPLAN_TEST_BACKENDS" not in os.environ else os.environ["NETPLAN_TEST_BACKENDS"]
+
+for program in ['wpa_supplicant', 'hostapd', 'dnsmasq']:
+ if subprocess.call(['which', program], stdout=subprocess.PIPE) != 0:
+ sys.stderr.write('%s is required for this test suite, but not available. Skipping\n' % program)
+ sys.exit(0)
+
+nm_uses_dnsmasq = b'dns=dnsmasq' in subprocess.check_output(['NetworkManager', '--print-config'])
+
+
+def resolved_in_use():
+ return os.path.isfile('/run/systemd/resolve/resolv.conf')
+
+
+class IntegrationTestsBase(unittest.TestCase):
+ '''Common functionality for network test cases
+
+ setUp() creates two test ethernet devices (self.dev_e_{ap,client} and
+ self.dev_e2_{ap,client}.
+
+ Each test should call self.setup_eth() with the desired configuration.
+ '''
+ @classmethod
+ def setUpClass(klass):
+ shutil.rmtree('/etc/netplan', ignore_errors=True)
+ os.makedirs('/etc/netplan', exist_ok=True)
+ # Try to keep autopkgtest's management network (eth0/ens3) up and
+ # configured. It should be running all the time, independently of netplan
+ os.makedirs('/etc/systemd/network', exist_ok=True)
+ with open('/etc/systemd/network/20-wired.network', 'w') as f:
+ f.write('[Match]\nName=eth0 en*\n\n[Network]\nDHCP=ipv4')
+
+ # ensure NM can manage our fake eths
+ os.makedirs('/run/udev/rules.d', exist_ok=True)
+ with open('/run/udev/rules.d/99-nm-veth-test.rules', 'w') as f:
+ f.write('ENV{ID_NET_DRIVER}=="veth", ENV{INTERFACE}=="eth42|eth43", ENV{NM_UNMANAGED}="0"\n')
+ subprocess.check_call(['udevadm', 'control', '--reload'])
+
+ os.makedirs('/etc/NetworkManager/conf.d', exist_ok=True)
+ with open('/etc/NetworkManager/conf.d/99-test-ignore.conf', 'w') as f:
+ f.write('[keyfile]\nunmanaged-devices+=interface-name:eth0,interface-name:en*,interface-name:veth42,interface-name:veth43')
+ subprocess.check_call(['netplan', 'apply'])
+ subprocess.call(['/lib/systemd/systemd-networkd-wait-online', '--quiet', '--timeout=30'])
+
+ @classmethod
+ def tearDownClass(klass):
+ try:
+ os.remove('/run/NetworkManager/conf.d/test-blacklist.conf')
+ except FileNotFoundError:
+ pass
+ try:
+ os.remove('/run/udev/rules.d/99-nm-veth-test.rules')
+ except FileNotFoundError:
+ pass
+
+ def tearDown(self):
+ subprocess.call(['systemctl', 'stop', 'NetworkManager', 'systemd-networkd', 'netplan-wpa-*',
+ 'netplan-ovs-*', 'systemd-networkd.socket'])
+ # NM has KillMode=process and leaks dhclient processes
+ subprocess.call(['systemctl', 'kill', 'NetworkManager'])
+ subprocess.call(['systemctl', 'reset-failed', 'NetworkManager', 'systemd-networkd'],
+ stderr=subprocess.DEVNULL)
+ shutil.rmtree('/etc/netplan', ignore_errors=True)
+ shutil.rmtree('/run/NetworkManager', ignore_errors=True)
+ shutil.rmtree('/run/systemd/network', ignore_errors=True)
+ for f in glob.glob('/run/systemd/system/netplan-*'):
+ os.remove(f)
+ for f in glob.glob('/run/systemd/system/**/netplan-*'):
+ os.remove(f)
+ subprocess.call(['systemctl', 'daemon-reload'])
+ try:
+ os.remove('/run/systemd/generator/netplan.stamp')
+ except FileNotFoundError:
+ pass
+ # Keep the management network (eth0/ens3 from 20-wired.network) up
+ subprocess.check_call(['systemctl', 'restart', 'systemd-networkd'])
+
+ @classmethod
+ def create_devices(klass):
+ '''Create Access Point and Client devices with veth'''
+
+ if os.path.exists('/sys/class/net/eth42'):
+ raise SystemError('eth42 interface already exists')
+
+ # create virtual ethernet devs
+ subprocess.check_call(['ip', 'link', 'add', 'name', 'eth42', 'type',
+ 'veth', 'peer', 'name', 'veth42'])
+ klass.dev_e_ap = 'veth42'
+ klass.dev_e_client = 'eth42'
+ klass.dev_e_ap_ip4 = '192.168.5.1/24'
+ klass.dev_e_ap_ip6 = '2600::1/64'
+ subprocess.check_call(['ip', 'link', 'add', 'name', 'eth43', 'type',
+ 'veth', 'peer', 'name', 'veth43'])
+ klass.dev_e2_ap = 'veth43'
+ klass.dev_e2_client = 'eth43'
+ klass.dev_e2_ap_ip4 = '192.168.6.1/24'
+ klass.dev_e2_ap_ip6 = '2601::1/64'
+ # Creation of the veths introduces a race with newer versions of
+ # systemd, as it will change the initial MAC address after the device
+ # was created and networkd took control. Give it some time, so we read
+ # the correct MAC address
+ time.sleep(0.1)
+ out = subprocess.check_output(['ip', '-br', 'link', 'show', 'dev', 'eth42'],
+ universal_newlines=True)
+ klass.dev_e_client_mac = out.split()[2]
+ out = subprocess.check_output(['ip', '-br', 'link', 'show', 'dev', 'eth43'],
+ universal_newlines=True)
+ klass.dev_e2_client_mac = out.split()[2]
+
+ os.makedirs('/run/NetworkManager/conf.d', exist_ok=True)
+
+ # work around https://launchpad.net/bugs/1615044
+ with open('/run/NetworkManager/conf.d/11-globally-managed-devices.conf', 'w') as f:
+ f.write('[keyfile]\nunmanaged-devices=')
+
+ @classmethod
+ def shutdown_devices(klass):
+ '''Remove test devices'''
+
+ subprocess.check_call(['ip', 'link', 'del', 'dev', klass.dev_e_ap])
+ subprocess.check_call(['ip', 'link', 'del', 'dev', klass.dev_e2_ap])
+ klass.dev_e_ap = None
+ klass.dev_e_client = None
+ klass.dev_e2_ap = None
+ klass.dev_e2_client = None
+
+ subprocess.call(['ip', 'link', 'del', 'dev', 'mybr'],
+ stderr=subprocess.PIPE)
+
+ def setUp(self):
+ '''Create test devices and workdir'''
+
+ self.create_devices()
+ self.addCleanup(self.shutdown_devices)
+ self.workdir_obj = tempfile.TemporaryDirectory()
+ self.workdir = self.workdir_obj.name
+ self.config = '/etc/netplan/01-main.yaml'
+ os.makedirs('/etc/netplan', exist_ok=True)
+
+ # create static entropy file to avoid draining/blocking on /dev/random
+ self.entropy_file = os.path.join(self.workdir, 'entropy')
+ with open(self.entropy_file, 'wb') as f:
+ f.write(b'012345678901234567890')
+
+ def setup_eth(self, ipv6_mode, start_dnsmasq=True):
+ '''Set up simulated ethernet router
+
+ On self.dev_e_ap, run dnsmasq according to ipv6_mode, see
+ start_dnsmasq().
+
+ This is torn down automatically at the end of the test.
+ '''
+ # give our router an IP
+ subprocess.check_call(['ip', 'a', 'flush', 'dev', self.dev_e_ap])
+ subprocess.check_call(['ip', 'a', 'flush', 'dev', self.dev_e2_ap])
+ if ipv6_mode is not None:
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e_ap_ip6, 'dev', self.dev_e_ap])
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e2_ap_ip6, 'dev', self.dev_e2_ap])
+ else:
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e_ap_ip4, 'dev', self.dev_e_ap])
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e2_ap_ip4, 'dev', self.dev_e2_ap])
+ subprocess.check_call(['ip', 'link', 'set', self.dev_e_ap, 'up'])
+ subprocess.check_call(['ip', 'link', 'set', self.dev_e2_ap, 'up'])
+ if start_dnsmasq:
+ self.start_dnsmasq(ipv6_mode, self.dev_e_ap)
+ self.start_dnsmasq(ipv6_mode, self.dev_e2_ap)
+
+ #
+ # Internal implementation details
+ #
+
+ @classmethod
+ def poll_text(klass, logpath, string, timeout=50):
+ '''Poll log file for a given string with a timeout.
+
+ Timeout is given in deciseconds.
+ '''
+ log = ''
+ while timeout > 0:
+ if os.path.exists(logpath):
+ break
+ timeout -= 1
+ time.sleep(0.1)
+ assert timeout > 0, 'Timed out waiting for file %s to appear' % logpath
+
+ with open(logpath) as f:
+ while timeout > 0:
+ line = f.readline()
+ if line:
+ log += line
+ if string in line:
+ break
+ continue
+ timeout -= 1
+ time.sleep(0.1)
+
+ assert timeout > 0, 'Timed out waiting for "%s":\n------------\n%s\n-------\n' % (string, log)
+
+ def start_dnsmasq(self, ipv6_mode, iface):
+ '''Start dnsmasq.
+
+ If ipv6_mode is None, IPv4 is set up with DHCP. If it is not None, it
+ must be a valid dnsmasq mode, i. e. a combination of "ra-only",
+ "slaac", "ra-stateless", and "ra-names". See dnsmasq(8).
+ '''
+ if ipv6_mode is None:
+ if iface == self.dev_e2_ap:
+ dhcp_range = '192.168.6.10,192.168.6.200'
+ else:
+ dhcp_range = '192.168.5.10,192.168.5.200'
+ else:
+ if iface == self.dev_e2_ap:
+ dhcp_range = '2601::10,2601::20'
+ else:
+ dhcp_range = '2600::10,2600::20'
+ if ipv6_mode:
+ dhcp_range += ',' + ipv6_mode
+
+ dnsmasq_log = os.path.join(self.workdir, 'dnsmasq-%s.log' % iface)
+ lease_file = os.path.join(self.workdir, 'dnsmasq-%s.leases' % iface)
+
+ p = subprocess.Popen(['dnsmasq', '--keep-in-foreground', '--log-queries',
+ '--log-facility=' + dnsmasq_log,
+ '--conf-file=/dev/null',
+ '--dhcp-leasefile=' + lease_file,
+ '--bind-interfaces',
+ '--interface=' + iface,
+ '--except-interface=lo',
+ '--enable-ra',
+ '--dhcp-range=' + dhcp_range])
+ self.addCleanup(p.kill)
+
+ if ipv6_mode is not None:
+ self.poll_text(dnsmasq_log, 'IPv6 router advertisement enabled')
+ else:
+ self.poll_text(dnsmasq_log, 'DHCP, IP range')
+
+ def assert_iface(self, iface, expected_ip_a=None, unexpected_ip_a=None):
+ '''Assert that client interface has been created'''
+
+ out = subprocess.check_output(['ip', 'a', 'show', 'dev', iface],
+ universal_newlines=True)
+ if expected_ip_a:
+ for r in expected_ip_a:
+ self.assertRegex(out, r, out)
+ if unexpected_ip_a:
+ for r in unexpected_ip_a:
+ self.assertNotRegex(out, r, out)
+
+ return out
+
+ def assert_iface_up(self, iface, expected_ip_a=None, unexpected_ip_a=None):
+ '''Assert that client interface is up'''
+
+ out = self.assert_iface(iface, expected_ip_a, unexpected_ip_a)
+ if 'bond' not in iface:
+ self.assertIn('state UP', out)
+
+ def generate_and_settle(self, wait_interfaces=None):
+ '''Generate config, launch and settle NM and networkd'''
+
+ # regenerate netplan config
+ out = subprocess.check_output(['netplan', 'apply'], stderr=subprocess.STDOUT, universal_newlines=True)
+ if 'Run \'systemctl daemon-reload\' to reload units.' in out:
+ self.fail('systemd units changed without reload')
+ # start NM so that we can verify that it does not manage anything
+ subprocess.check_call(['systemctl', 'start', 'NetworkManager.service'])
+
+ # Wait for interfaces to be ready:
+ ifaces = wait_interfaces if wait_interfaces is not None else [self.dev_e_client, self.dev_e2_client]
+ for iface_state in ifaces:
+ split = iface_state.split('/', 1)
+ iface = split[0]
+ state = split[1] if len(split) > 1 else None
+ print(iface, end=' ', flush=True)
+ if self.backend == 'NetworkManager':
+ self.nm_wait_connected(iface, 60)
+ else:
+ self.networkd_wait_connected(iface, 60)
+ # wait for iproute2 state change
+ if state:
+ self.wait_output(['ip', 'addr', 'show', iface], state, 30)
+
+ def state(self, iface, state):
+ '''Tell generate_and_settle() to wait for a specific state'''
+ return iface + '/' + state
+
+ def state_dhcp4(self, iface):
+ '''Tell generate_and_settle() to wait for assignment of an IP4 address from DHCP'''
+ return self.state(iface, 'inet 192.168.') # TODO: make this a regex to check for specific DHCP ranges
+
+ def state_dhcp6(self, iface):
+ '''Tell generate_and_settle() to wait for assignment of an IP6 address from DHCP'''
+ return self.state(iface, 'inet6 260') # TODO: make this a regex to check for specific DHCP ranges
+
+ def nm_online_full(self, iface, timeout=60):
+ '''Wait for NetworkManager connection to be completed (incl. IP4 & DHCP)'''
+
+ gi.require_version('NM', '1.0')
+ from gi.repository import NM
+ for t in range(timeout):
+ c = NM.Client.new(None)
+ con = c.get_device_by_iface(iface).get_active_connection()
+ if not con:
+ self.fail('no active connection for %s by NM' % iface)
+ flags = NM.utils_enum_to_str(NM.ActivationStateFlags, con.get_state_flags())
+ if "ip4-ready" in flags:
+ break
+ time.sleep(1)
+ else:
+ self.fail('timed out waiting for %s to get ready by NM' % iface)
+
+ def wait_output(self, cmd, expected_output, timeout=10):
+ for _ in range(timeout):
+ try:
+ out = subprocess.check_output(cmd, universal_newlines=True)
+ except subprocess.CalledProcessError:
+ out = ''
+ if expected_output in out:
+ break
+ sys.stdout.write('.') # waiting indicator
+ time.sleep(1)
+ else:
+ subprocess.call(cmd) # print output of the failed command
+ self.fail('timed out waiting for "{}" to appear in {}'.format(expected_output, cmd))
+
+ def nm_wait_connected(self, iface, timeout=10):
+ self.wait_output(['nmcli', 'dev', 'show', iface], '(connected', timeout)
+
+ def networkd_wait_connected(self, iface, timeout=10):
+ # "State: routable (configured)" or "State: degraded (configured)"
+ self.wait_output(['networkctl', 'status', iface], '(configured', timeout)
+
+ @classmethod
+ def is_active(klass, unit):
+ '''Check if given unit is active or activating'''
+
+ p = subprocess.Popen(['systemctl', 'is-active', unit], stdout=subprocess.PIPE)
+ out = p.communicate()[0]
+ return p.returncode == 0 or out.startswith(b'activating')
+
+
+class IntegrationTestsWifi(IntegrationTestsBase):
+ '''Common functionality for network test cases
+
+ setUp() creates two test wlan devices, one for a simulated access point
+ (self.dev_w_ap), the other for a simulated client device
+ (self.dev_w_client), and two test ethernet devices (self.dev_e_{ap,client}
+ and self.dev_e2_{ap,client}.
+
+ Each test should call self.setup_ap() or self.setup_eth() with the desired
+ configuration.
+ '''
+ @classmethod
+ def setUpClass(klass):
+ super().setUpClass()
+ # ensure we have this so that iw works
+ try:
+ subprocess.check_call(['modprobe', 'cfg80211'])
+ # set regulatory domain "EU", so that we can use 80211.a 5 GHz channels
+ out = subprocess.check_output(['iw', 'reg', 'get'], universal_newlines=True)
+ m = re.match(r'^(?:global\n)?country (\S+):', out)
+ assert m
+ klass.orig_country = m.group(1)
+ subprocess.check_call(['iw', 'reg', 'set', 'EU'])
+ except Exception:
+ raise unittest.SkipTest("cfg80211 (wireless) is unavailable, can't test")
+
+ @classmethod
+ def tearDownClass(klass):
+ subprocess.check_call(['iw', 'reg', 'set', klass.orig_country])
+ super().tearDownClass()
+
+ @classmethod
+ def create_devices(klass):
+ '''Create Access Point and Client devices with mac80211_hwsim and veth'''
+ if os.path.exists('/sys/module/mac80211_hwsim'):
+ raise SystemError('mac80211_hwsim module already loaded')
+ super().create_devices()
+ # create virtual wlan devs
+ before_wlan = set([c for c in os.listdir('/sys/class/net') if c.startswith('wlan')])
+ subprocess.check_call(['modprobe', 'mac80211_hwsim'])
+ # wait 5 seconds for fake devices to appear
+ timeout = 50
+ while timeout > 0:
+ after_wlan = set([c for c in os.listdir('/sys/class/net') if c.startswith('wlan')])
+ if len(after_wlan) - len(before_wlan) >= 2:
+ break
+ timeout -= 1
+ time.sleep(0.1)
+ else:
+ raise SystemError('timed out waiting for fake devices to appear')
+
+ devs = list(after_wlan - before_wlan)
+ klass.dev_w_ap = devs[0]
+ klass.dev_w_client = devs[1]
+
+ # don't let NM trample over our fake AP
+ with open('/run/NetworkManager/conf.d/test-blacklist.conf', 'w') as f:
+ f.write('[main]\nplugins=keyfile\n[keyfile]\nunmanaged-devices+=nptestsrv,%s\n' % klass.dev_w_ap)
+
+ @classmethod
+ def shutdown_devices(klass):
+ '''Remove test devices'''
+ super().shutdown_devices()
+ klass.dev_w_ap = None
+ klass.dev_w_client = None
+ subprocess.check_call(['rmmod', 'mac80211_hwsim'])
+
+ def start_hostapd(self, conf):
+ hostapd_conf = os.path.join(self.workdir, 'hostapd.conf')
+ with open(hostapd_conf, 'w') as f:
+ f.write('interface=%s\ndriver=nl80211\n' % self.dev_w_ap)
+ f.write(conf)
+
+ log = os.path.join(self.workdir, 'hostapd.log')
+ p = subprocess.Popen(['hostapd', '-e', self.entropy_file, '-f', log, hostapd_conf],
+ stdout=subprocess.PIPE)
+ self.addCleanup(p.wait)
+ self.addCleanup(p.terminate)
+ self.poll_text(log, '' + self.dev_w_ap + ': AP-ENABLED', 500)
+
+ def setup_ap(self, hostapd_conf, ipv6_mode):
+ '''Set up simulated access point
+
+ On self.dev_w_ap, run hostapd with given configuration. Setup dnsmasq
+ according to ipv6_mode, see start_dnsmasq().
+
+ This is torn down automatically at the end of the test.
+ '''
+ # give our AP an IP
+ subprocess.check_call(['ip', 'a', 'flush', 'dev', self.dev_w_ap])
+ if ipv6_mode is not None:
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e_ap_ip6, 'dev', self.dev_w_ap])
+ else:
+ subprocess.check_call(['ip', 'a', 'add', self.dev_e_ap_ip4, 'dev', self.dev_w_ap])
+ self.start_hostapd(hostapd_conf)
+ self.start_dnsmasq(ipv6_mode, self.dev_w_ap)
+
+ def assert_iface_up(self, iface, expected_ip_a=None, unexpected_ip_a=None):
+ '''Assert that client interface is up'''
+ super().assert_iface_up(iface, expected_ip_a, unexpected_ip_a)
+ if iface == self.dev_w_client:
+ out = subprocess.check_output(['iw', 'dev', iface, 'link'],
+ universal_newlines=True)
+ # self.assertIn('Connected to ' + self.mac_w_ap, out)
+ self.assertIn('SSID: fake net', out)
diff --git a/tests/integration/bonds.py b/tests/integration/bonds.py
new file mode 100644
index 0000000..763f7e5
--- /dev/null
+++ b/tests/integration/bonds.py
@@ -0,0 +1,675 @@
+#!/usr/bin/python3
+#
+# Integration tests for bonds
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def test_bond_base(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+
+ def test_bond_primary_slave(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s: {}
+ %(e2c)s: {}
+ bonds:
+ mybond:
+ interfaces: [%(ec)s, %(e2c)s]
+ parameters:
+ mode: active-backup
+ primary: %(ec)s
+ addresses: [ '10.10.10.1/24' ]''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'mybond'])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up(self.dev_e2_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 10.10.10.1/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ result = f.read().strip()
+ self.assertIn(self.dev_e_client, result)
+ self.assertIn(self.dev_e2_client, result)
+ with open('/sys/class/net/mybond/bonding/primary') as f:
+ self.assertEqual(f.read().strip(), '%(ec)s' % {'ec': self.dev_e_client})
+
+ def test_bond_all_slaves_active(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ all-slaves-active: true
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/all_slaves_active') as f:
+ self.assertEqual(f.read().strip(), '1')
+
+ def test_bond_mode_8023ad(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: 802.3ad
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), '802.3ad 4')
+
+ def test_bond_mode_8023ad_adselect(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: 802.3ad
+ ad-select: bandwidth
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/ad_select') as f:
+ self.assertEqual(f.read().strip(), 'bandwidth 1')
+
+ def test_bond_mode_8023ad_lacp_rate(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: 802.3ad
+ lacp-rate: fast
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/lacp_rate') as f:
+ self.assertEqual(f.read().strip(), 'fast 1')
+
+ def test_bond_mode_activebackup_failover_mac(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: active-backup
+ fail-over-mac-policy: follow
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), 'active-backup 1')
+ with open('/sys/class/net/mybond/bonding/fail_over_mac') as f:
+ self.assertEqual(f.read().strip(), 'follow 2')
+
+ def test_bond_mode_balance_xor(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: balance-xor
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), 'balance-xor 2')
+
+ def test_bond_mode_balance_rr(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: balance-rr
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), 'balance-rr 0')
+
+ def test_bond_mode_balance_rr_pps(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: balance-rr
+ packets-per-slave: 15
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), 'balance-rr 0')
+ with open('/sys/class/net/mybond/bonding/packets_per_slave') as f:
+ self.assertEqual(f.read().strip(), '15')
+
+ def test_bond_resend_igmp(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ ethb2:
+ match: {name: %(e2c)s}
+ bonds:
+ mybond:
+ addresses: [192.168.9.9/24]
+ interfaces: [ethbn, ethb2]
+ parameters:
+ mode: balance-rr
+ mii-monitor-interval: 50s
+ resend-igmp: 100
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'mybond'])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up(self.dev_e2_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.9.9/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ result = f.read().strip()
+ self.assertIn(self.dev_e_client, result)
+ self.assertIn(self.dev_e2_client, result)
+ with open('/sys/class/net/mybond/bonding/resend_igmp') as f:
+ self.assertEqual(f.read().strip(), '100')
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_bond_mac(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match:
+ name: %(ec)s
+ macaddress: %(ec_mac)s
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ macaddress: 00:01:02:03:04:05
+ dhcp4: yes''' % {'r': self.backend,
+ 'ec': self.dev_e_client,
+ 'ec_mac': self.dev_e_client_mac})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24', '00:01:02:03:04:05'])
+
+ def test_bond_down_delay(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: active-backup
+ mii-monitor-interval: 5
+ down-delay: 10s
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/downdelay') as f:
+ self.assertEqual(f.read().strip(), '10000')
+
+ def test_bond_up_delay(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: active-backup
+ mii-monitor-interval: 5
+ up-delay: 10000
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/updelay') as f:
+ self.assertEqual(f.read().strip(), '10000')
+
+ def test_bond_arp_interval(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-ip-targets: [ 192.168.5.1 ]
+ arp-interval: 50s
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_interval') as f:
+ self.assertEqual(f.read().strip(), '50000')
+
+ def test_bond_arp_targets(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-interval: 50000
+ arp-ip-targets: [ 192.168.5.1 ]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_ip_target') as f:
+ self.assertEqual(f.read().strip(), '192.168.5.1')
+
+ def test_bond_arp_targets_many_lp1829264(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-interval: 50000
+ arp-ip-targets: [ 192.168.5.1, 192.168.5.34 ]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_ip_target') as f:
+ result = f.read().strip()
+ self.assertIn('192.168.5.1', result)
+ self.assertIn('192.168.5.34', result)
+
+ def test_bond_arp_all_targets(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-ip-targets: [192.168.5.1]
+ arp-interval: 50000
+ arp-all-targets: all
+ arp-validate: all
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_all_targets') as f:
+ self.assertEqual(f.read().strip(), 'all 1')
+
+ def test_bond_arp_validate(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-ip-targets: [192.168.5.1]
+ arp-interval: 50000
+ arp-validate: all
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_validate') as f:
+ self.assertEqual(f.read().strip(), 'all 3')
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+ @unittest.skip("NetworkManager does not support setting MAC for a bond")
+ def test_bond_mac(self):
+ pass
+
+ def test_bond_down_delay(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: active-backup
+ mii-monitor-interval: 5
+ down-delay: 10000
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/downdelay') as f:
+ self.assertEqual(f.read().strip(), '10000')
+
+ def test_bond_up_delay(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: active-backup
+ mii-monitor-interval: 5
+ up-delay: 10000
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/updelay') as f:
+ self.assertEqual(f.read().strip(), '10000')
+
+ def test_bond_arp_interval(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-ip-targets: [ 192.168.5.1 ]
+ arp-interval: 50000
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_interval') as f:
+ self.assertEqual(f.read().strip(), '50000')
+
+ def test_bond_arp_targets(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-interval: 50000
+ arp-ip-targets: [ 192.168.5.1 ]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_ip_target') as f:
+ self.assertEqual(f.read().strip(), '192.168.5.1')
+
+ def test_bond_arp_all_targets(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ interfaces: [ethbn]
+ parameters:
+ mode: balance-xor
+ arp-ip-targets: [192.168.5.1]
+ arp-interval: 50000
+ arp-all-targets: all
+ arp-validate: all
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/arp_all_targets') as f:
+ self.assertEqual(f.read().strip(), 'all 1')
+
+ def test_bond_mode_balance_tlb_learn_interval(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ bonds:
+ mybond:
+ parameters:
+ mode: balance-tlb
+ mii-monitor-interval: 5
+ learn-packet-interval: 15
+ interfaces: [ethbn]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('mybond')])
+ self.assert_iface_up(self.dev_e_client, ['master mybond'], ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertEqual(f.read().strip(), self.dev_e_client)
+ with open('/sys/class/net/mybond/bonding/mode') as f:
+ self.assertEqual(f.read().strip(), 'balance-tlb 5')
+ with open('/sys/class/net/mybond/bonding/lp_interval') as f:
+ self.assertEqual(f.read().strip(), '15')
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/bridges.py b/tests/integration/bridges.py
new file mode 100644
index 0000000..b24e6e9
--- /dev/null
+++ b/tests/integration/bridges.py
@@ -0,0 +1,348 @@
+#!/usr/bin/python3
+#
+# Integration tests for bridges
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def test_eth_and_bridge(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp4: yes
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ dhcp4: yes''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e_client),
+ self.dev_e2_client,
+ self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.5.[0-9]+/24'])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+
+ # ensure that they do not get managed by NM for foreign backends
+ expected_state = (self.backend == 'NetworkManager') and 'connected' or 'unmanaged'
+ out = subprocess.check_output(['nmcli', 'dev'], universal_newlines=True)
+ for i in [self.dev_e_client, self.dev_e2_client, 'mybr']:
+ self.assertRegex(out, r'%s\s+(ethernet|bridge)\s+%s' % (i, expected_state))
+
+ def test_bridge_path_cost(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ path-cost:
+ ethbr: 50
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/brif/%s/path_cost' % self.dev_e2_client) as f:
+ self.assertEqual(f.read().strip(), '50')
+
+ def test_bridge_ageing_time(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ ageing-time: 21
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/ageing_time') as f:
+ self.assertEqual(f.read().strip(), '2100')
+
+ def test_bridge_max_age(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ max-age: 12
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/max_age') as f:
+ self.assertEqual(f.read().strip(), '1200')
+
+ def test_bridge_hello_time(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ hello-time: 1
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/hello_time') as f:
+ self.assertEqual(f.read().strip(), '100')
+
+ def test_bridge_forward_delay(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ forward-delay: 10
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/forward_delay') as f:
+ self.assertEqual(f.read().strip(), '1000')
+
+ def test_bridge_stp_false(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ hello-time: 100000
+ max-age: 100000
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/stp_state') as f:
+ self.assertEqual(f.read().strip(), '0')
+
+ def test_bridge_port_priority(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ port-priority:
+ ethbr: 42
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/brif/%s/priority' % self.dev_e2_client) as f:
+ self.assertEqual(f.read().strip(), '42')
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_bridge_mac(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'br0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match:
+ name: %(ec)s
+ macaddress: %(ec_mac)s
+ bridges:
+ br0:
+ interfaces: [ethbr]
+ macaddress: "00:01:02:03:04:05"
+ dhcp4: yes''' % {'r': self.backend,
+ 'ec': self.dev_e_client,
+ 'ec_mac': self.dev_e_client_mac})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4('br0')])
+ self.assert_iface_up(self.dev_e_client, ['master br0'], ['inet '])
+ self.assert_iface_up('br0', ['inet 192.168.5.[0-9]+/24', 'ether 00:01:02:03:04:05'])
+
+ def test_bridge_anonymous(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, 'mybr'])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', [], ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+
+ def test_bridge_isolated(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ bridges:
+ mybr:
+ interfaces: []
+ addresses: [10.10.10.10/24]''' % {'r': self.backend})
+ self.generate_and_settle(['mybr'])
+ self.assert_iface('mybr', ['inet 10.10.10.10/24'])
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+ @unittest.skip("NetworkManager does not support setting MAC for a bridge")
+ def test_bridge_mac(self):
+ pass
+
+ def test_bridge_priority(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybr'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbr:
+ match: {name: %(e2c)s}
+ bridges:
+ mybr:
+ interfaces: [ethbr]
+ parameters:
+ priority: 16384
+ stp: false
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e2_client, self.state_dhcp4('mybr')])
+ self.assert_iface_up(self.dev_e2_client, ['master mybr'], ['inet '])
+ self.assert_iface_up('mybr', ['inet 192.168.6.[0-9]+/24'])
+ lines = subprocess.check_output(['bridge', 'link', 'show', 'mybr'],
+ universal_newlines=True).splitlines()
+ self.assertEqual(len(lines), 1, lines)
+ self.assertIn(self.dev_e2_client, lines[0])
+ with open('/sys/class/net/mybr/bridge/priority') as f:
+ self.assertEqual(f.read().strip(), '16384')
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/ethernets.py b/tests/integration/ethernets.py
new file mode 100644
index 0000000..ce016da
--- /dev/null
+++ b/tests/integration/ethernets.py
@@ -0,0 +1,336 @@
+#!/usr/bin/python3
+#
+# Integration tests for ethernet devices and features common to all device
+# types.
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# AUthor: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, nm_uses_dnsmasq, resolved_in_use, test_backends
+
+
+class _CommonTests():
+
+ def test_eth_mtu(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ enmtus:
+ match: {name: %(e2c)s}
+ mtu: 1492
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e2_client)])
+ self.assert_iface_up(self.dev_e2_client,
+ ['inet 192.168.6.[0-9]+/24', 'mtu 1492'])
+
+ def test_eth_mac(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'set', self.dev_e2_client, 'address', self.dev_e2_client_mac])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ enmac:
+ match: {name: %(e2c)s}
+ macaddress: 00:01:02:03:04:05
+ dhcp4: yes''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e2_client)])
+ self.assert_iface_up(self.dev_e2_client,
+ ['inet 192.168.6.[0-9]+/24', 'ether 00:01:02:03:04:05'])
+
+ # Supposed to fail if tested against NetworkManager < 1.14
+ # Interface globbing was introduced as of NM 1.14+
+ def test_eth_glob(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ englob:
+ match: {name: "eth?2"}
+ addresses: ["172.16.42.99/18", "1234:FFFF::42/64"]
+''' % {'r': self.backend}) # globbing match on "eth42", i.e. self.dev_e_client
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet 172.16.42.99/18', 'inet6 1234:ffff::42/64'])
+
+ def test_manual_addresses(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses: ["172.16.42.99/18", "1234:FFFF::42/64"]
+ dhcp4: yes
+ %(e2c)s:
+ addresses: ["172.16.1.2/24"]
+ gateway4: "172.16.1.1"
+ nameservers:
+ addresses: [172.1.2.3]
+ search: ["fakesuffix"]
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e_client), self.dev_e2_client])
+ if self.backend == 'NetworkManager':
+ self.nm_online_full(self.dev_e_client)
+ self.assert_iface_up(self.dev_e_client,
+ ['inet 172.16.42.99/18',
+ 'inet6 1234:ffff::42/64',
+ 'inet 192.168.5.[0-9]+/24']) # from DHCP
+ self.assert_iface_up(self.dev_e2_client,
+ ['inet 172.16.1.2/24'])
+
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'default via 172.16.1.1',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e2_client]))
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'dev', self.dev_e2_client]))
+
+ # ensure that they do not get managed by NM for foreign backends
+ expected_state = (self.backend == 'NetworkManager') and 'connected' or 'unmanaged'
+ out = subprocess.check_output(['nmcli', 'dev'], universal_newlines=True)
+ for i in [self.dev_e_client, self.dev_e2_client]:
+ self.assertRegex(out, r'%s\s+(ethernet|bridge)\s+%s' % (i, expected_state))
+
+ with open('/etc/resolv.conf') as f:
+ resolv_conf = f.read()
+
+ if self.backend == 'NetworkManager' and nm_uses_dnsmasq:
+ sys.stdout.write('[NM with dnsmasq] ')
+ sys.stdout.flush()
+ self.assertRegex(resolv_conf, 'search.*fakesuffix')
+ # not easy to peek dnsmasq's brain, so check its logging
+ out = subprocess.check_output(['journalctl', '--quiet', '-tdnsmasq', '-ocat', '--since=-30s'],
+ universal_newlines=True)
+ self.assertIn('nameserver 172.1.2.3', out)
+ elif resolved_in_use():
+ sys.stdout.write('[resolved] ')
+ sys.stdout.flush()
+ out = subprocess.check_output(['resolvectl', 'status'], universal_newlines=True)
+ self.assertIn('DNS Servers: 172.1.2.3', out)
+ self.assertIn('fakesuffix', out)
+ else:
+ sys.stdout.write('[/etc/resolv.conf] ')
+ sys.stdout.flush()
+ self.assertRegex(resolv_conf, 'search.*fakesuffix')
+ # /etc/resolve.conf often already has three nameserver entries
+ if 'nameserver 172.1.2.3' not in resolv_conf:
+ self.assertGreaterEqual(resolv_conf.count('nameserver'), 3)
+
+ # change the addresses, make sure that "apply" does not leave leftovers
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses: ["172.16.5.3/20", "9876:BBBB::11/70"]
+ gateway6: "9876:BBBB::1"
+ %(e2c)s:
+ addresses: ["172.16.7.2/30", "4321:AAAA::99/80"]
+ dhcp4: yes
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.state_dhcp4(self.dev_e2_client)])
+ if self.backend == 'NetworkManager':
+ self.nm_online_full(self.dev_e2_client)
+ self.assert_iface_up(self.dev_e_client,
+ ['inet 172.16.5.3/20'],
+ ['inet 192.168.5', # old DHCP
+ 'inet 172.16.42', # old static IPv4
+ 'inet6 1234']) # old static IPv6
+ self.assert_iface_up(self.dev_e2_client,
+ ['inet 172.16.7.2/30',
+ 'inet6 4321:aaaa::99/80',
+ 'inet 192.168.6.[0-9]+/24'], # from DHCP
+ ['inet 172.16.1']) # old static IPv4
+
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'via 9876:bbbb::1',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'default']))
+ self.assertIn(b'default via 192.168.6.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e2_client]))
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'dev', self.dev_e2_client]))
+
+ def test_dhcp6(self):
+ self.setup_eth('slaac')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp6: yes
+ accept-ra: yes''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.state_dhcp6(self.dev_e_client)])
+ self.assert_iface_up(self.dev_e_client, ['inet6 2600:'], ['inet 192.168'])
+
+ def test_ip6_token(self):
+ self.setup_eth('slaac')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp6: yes
+ accept-ra: yes
+ ipv6-address-token: ::42''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.state_dhcp6(self.dev_e_client)])
+ self.assert_iface_up(self.dev_e_client, ['inet6 2600::42/64'])
+
+ def test_link_local_all(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ link-local: [ ipv4, ipv6 ]''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ # Verify IPv4 and IPv6 link local addresses are there
+ self.assert_iface(self.dev_e_client, ['inet6 fe80:', 'inet 169.254.'])
+
+ def test_rename_interfaces(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ idx:
+ match:
+ name: %(ec)s
+ set-name: iface1
+ addresses: [10.10.10.11/24]
+ idy:
+ match:
+ macaddress: %(e2c_mac)s
+ set-name: iface2
+ addresses: [10.10.10.22/24]
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c_mac': self.dev_e2_client_mac})
+ self.generate_and_settle(['iface1', 'iface2'])
+ self.assert_iface_up('iface1', ['inet 10.10.10.11'])
+ self.assert_iface_up('iface2', ['inet 10.10.10.22'])
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_eth_dhcp6_off(self):
+ self.setup_eth('slaac')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp6: no
+ accept-ra: yes
+ addresses: [ '192.168.1.100/24' ]
+ %(e2c)s: {}''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle()
+ self.assert_iface_up(self.dev_e_client, ['inet6 2600:'], [])
+
+ def test_eth_dhcp6_off_no_accept_ra(self):
+ self.setup_eth('slaac')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp6: no
+ accept-ra: no
+ addresses: [ '192.168.1.100/24' ]''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, [], ['inet6 2600:'])
+
+ # TODO: implement link-local handling in NetworkManager backend and move this test into CommonTests()
+ def test_link_local_ipv4(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ link-local: [ ipv4 ]''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ # Verify IPv4 link local address is there, while IPv6 is not
+ self.assert_iface(self.dev_e_client, ['inet 169.254.'], ['inet6 fe80:'])
+
+ # TODO: implement link-local handling in NetworkManager backend and move this test into CommonTests()
+ def test_link_local_ipv6(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ link-local: [ ipv6 ]''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ # Verify IPv6 link local address is there, while IPv4 is not
+ self.assert_iface(self.dev_e_client, ['inet6 fe80:'], ['inet 169.254.'])
+
+ # TODO: implement link-local handling in NetworkManager backend and move this test into CommonTests()
+ def test_link_local_disabled(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses: ["172.16.5.3/20", "9876:BBBB::11/70"] # needed to bring up the interface at all
+ link-local: []''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ # Verify IPv4 and IPv6 link local addresses are not there
+ self.assert_iface(self.dev_e_client,
+ ['inet6 9876:bbbb::11/70', 'inet 172.16.5.3/20'],
+ ['inet6 fe80:', 'inet 169.254.'])
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+ @unittest.skip("NetworkManager does not disable accept_ra: bug LP: #1704210")
+ def test_eth_dhcp6_off(self):
+ self.setup_eth('slaac')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp6: no
+ addresses: [ '192.168.1.100/24' ]''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, [], ['inet6 2600:'])
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/ovs.py b/tests/integration/ovs.py
new file mode 100644
index 0000000..8a6f60d
--- /dev/null
+++ b/tests/integration/ovs.py
@@ -0,0 +1,557 @@
+#!/usr/bin/python3
+#
+# Integration tests for bonds
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2020-2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def _collect_ovs_settings(self, bridge0):
+ d = {}
+ d['show'] = subprocess.check_output(['ovs-vsctl', 'show'])
+ d['ssl'] = subprocess.check_output(['ovs-vsctl', 'get-ssl'])
+ # Get external-ids
+ for tbl in ('Open_vSwitch', 'Controller', 'Bridge', 'Port', 'Interface'):
+ cols = 'name,external-ids'
+ if tbl == 'Open_vSwitch':
+ cols = 'external-ids'
+ elif tbl == 'Controller':
+ cols = '_uuid,external-ids'
+ d['external-ids-%s' % tbl] = subprocess.check_output(['ovs-vsctl', '--columns=%s' % cols, '-f', 'csv', '-d',
+ 'bare', '--no-headings', 'list', tbl])
+ # Get other-config
+ for tbl in ('Open_vSwitch', 'Bridge', 'Port', 'Interface'):
+ cols = 'name,other-config'
+ if tbl == 'Open_vSwitch':
+ cols = 'other-config'
+ d['other-config-%s' % tbl] = subprocess.check_output(['ovs-vsctl', '--columns=%s' % cols, '-f', 'csv', '-d',
+ 'bare', '--no-headings', 'list', tbl])
+ # Get bond settings
+ for col in ('bond_mode', 'lacp'):
+ d['%s-Bond' % col] = subprocess.check_output(['ovs-vsctl', '--columns=name,%s' % col, '-f', 'csv', '-d', 'bare',
+ '--no-headings', 'list', 'Port'])
+ # Get bridge settings
+ d['set-fail-mode-Bridge'] = subprocess.check_output(['ovs-vsctl', 'get-fail-mode', bridge0])
+ for col in ('mcast_snooping_enable', 'rstp_enable', 'protocols'):
+ d['%s-Bridge' % col] = subprocess.check_output(['ovs-vsctl', '--columns=name,%s' % col, '-f', 'csv', '-d', 'bare',
+ '--no-headings', 'list', 'Bridge'])
+ # Get controller settings
+ d['set-controller-Bridge'] = subprocess.check_output(['ovs-vsctl', 'get-controller', bridge0])
+ for col in ('connection_mode',):
+ d['%s-Controller' % col] = subprocess.check_output(['ovs-vsctl', '--columns=_uuid,%s' % col, '-f', 'csv', '-d',
+ 'bare', '--no-headings', 'list', 'Controller'])
+ return d
+
+ def test_cleanup_interfaces(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs0'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patch0-1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patch1-0'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ openvswitch:
+ ports:
+ - [patch0-1, patch1-0]
+ bridges:
+ ovs0: {interfaces: [patch0-1]}
+ ovs1: {interfaces: [patch1-0]}''')
+ self.generate_and_settle(['ovs0', 'ovs1'])
+ # Basic verification that the bridges/ports/interfaces are there in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge ovs0', out)
+ self.assertIn(b' Port patch0-1', out)
+ self.assertIn(b' Interface patch0-1', out)
+ self.assertIn(b' Bridge ovs1', out)
+ self.assertIn(b' Port patch1-0', out)
+ self.assertIn(b' Interface patch1-0', out)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ ethernets:
+ %(ec)s: {addresses: ['1.2.3.4/24']}''' % {'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ # Verify that the netplan=true tagged bridges/ports have been cleaned up
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertNotIn(b'Bridge ovs0', out)
+ self.assertNotIn(b'Port patch0-1', out)
+ self.assertNotIn(b'Interface patch0-1', out)
+ self.assertNotIn(b'Bridge ovs1', out)
+ self.assertNotIn(b'Port patch1-0', out)
+ self.assertNotIn(b'Interface patch1-0', out)
+ self.assert_iface_up(self.dev_e_client, ['inet 1.2.3.4/24'])
+
+ def test_cleanup_patch_ports(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs0'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patch0-1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patchy'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'bond0'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ ethernets:
+ %(ec)s: {addresses: [10.10.10.20/24]}
+ openvswitch:
+ ports: [[patch0-1, patch1-0]]
+ bonds:
+ bond0: {interfaces: [patch1-0, %(ec)s]}
+ bridges:
+ ovs0: {interfaces: [patch0-1, bond0]}''' % {'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, 'ovs0'])
+ # Basic verification that the bridges/ports/interfaces are there in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge ovs0', out)
+ self.assertIn(b' Port patch0-1\n Interface patch0-1\n type: patch', out)
+ self.assertIn(b' Port bond0', out)
+ self.assertIn(b' Interface patch1-0\n type: patch', out)
+ self.assertIn(b' Interface eth42', out)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ ethernets:
+ %(ec)s: {addresses: [10.10.10.20/24]}
+ openvswitch:
+ ports: [[patchx, patchy]]
+ bonds:
+ bond0: {interfaces: [patchx, %(ec)s]}
+ bridges:
+ ovs1: {interfaces: [patchy, bond0]}''' % {'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, 'ovs1'])
+ # Verify that the netplan=true tagged patch ports have been cleaned up
+ # even though the containing bond0 port still exists (with new patch ports)
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge ovs1', out)
+ self.assertIn(b' Port patchy\n Interface patchy\n type: patch', out)
+ self.assertIn(b' Port bond0', out)
+ self.assertIn(b' Interface patchx\n type: patch', out)
+ self.assertIn(b' Interface eth42', out)
+ self.assertNotIn(b'Bridge ovs0', out)
+ self.assertNotIn(b'Port patch0-1', out)
+ self.assertNotIn(b'Interface patch0-1', out)
+ self.assertNotIn(b'Port patch1-0', out)
+ self.assertNotIn(b'Interface patch1-0', out)
+
+ def test_bridge_vlan(self):
+ self.setup_eth(None, True)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'br-%s' % self.dev_e_client])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'br-data'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'br-%s.100' % self.dev_e_client])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ %(ec)s:
+ mtu: 9000
+ bridges:
+ br-%(ec)s:
+ dhcp4: true
+ mtu: 9000
+ interfaces: [%(ec)s]
+ openvswitch: {}
+ br-data:
+ openvswitch: {}
+ addresses: [192.168.20.1/16]
+ vlans:
+ #implicitly handled by OVS because of its link
+ br-%(ec)s.100:
+ id: 100
+ link: br-%(ec)s''' % {'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client,
+ self.state_dhcp4('br-eth42'),
+ 'br-data',
+ 'br-eth42.100'])
+ # Basic verification that the interfaces/ports are set up in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge br-%b' % self.dev_e_client.encode(), out)
+ self.assertIn(b''' Port %(ec)b
+ Interface %(ec)b''' % {b'ec': self.dev_e_client.encode()}, out)
+ self.assertIn(b''' Port br-%(ec)b.100
+ tag: 100
+ Interface br-%(ec)b.100
+ type: internal''' % {b'ec': self.dev_e_client.encode()}, out)
+ self.assertIn(b' Bridge br-data', out)
+ self.assert_iface('br-%s' % self.dev_e_client,
+ ['inet 192.168.5.[0-9]+/16', 'mtu 9000']) # from DHCP
+ self.assert_iface('br-data', ['inet 192.168.20.1/16'])
+ self.assert_iface(self.dev_e_client, ['mtu 9000', 'master ovs-system'])
+ self.assertIn(b'100', subprocess.check_output(['ovs-vsctl', 'br-to-vlan',
+ 'br-%s.100' % self.dev_e_client]))
+ self.assertIn(b'br-%b' % self.dev_e_client.encode(), subprocess.check_output(
+ ['ovs-vsctl', 'br-to-parent', 'br-%s.100' % self.dev_e_client]))
+ self.assertIn(b'br-%b' % self.dev_e_client.encode(), out)
+
+ def test_bridge_base(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovsbr'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', 'del-ssl'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ ethernets:
+ %(ec)s: {}
+ %(e2c)s: {}
+ openvswitch:
+ ssl:
+ ca-cert: /some/ca-cert.pem
+ certificate: /another/certificate.pem
+ private-key: /private/key.pem
+ bridges:
+ ovsbr:
+ addresses: [192.170.1.1/24]
+ interfaces: [%(ec)s, %(e2c)s]
+ openvswitch:
+ fail-mode: secure
+ controller:
+ addresses: [tcp:127.0.0.1, "pssl:1337:[::1]", unix:/some/socket]
+''' % {'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'ovsbr'])
+ # Basic verification that the interfaces/ports are in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge ovsbr', out)
+ self.assertIn(b' Controller "tcp:127.0.0.1"', out)
+ self.assertIn(b' Controller "pssl:1337:[::1]"', out)
+ self.assertIn(b' Controller "unix:/some/socket"', out)
+ self.assertIn(b' fail_mode: secure', out)
+ self.assertIn(b' Port %(ec)b\n Interface %(ec)b' % {b'ec': self.dev_e_client.encode()}, out)
+ self.assertIn(b' Port %(e2c)b\n Interface %(e2c)b' % {b'e2c': self.dev_e2_client.encode()}, out)
+ # Verify the bridge was tagged 'netplan:true' correctly
+ out = subprocess.check_output(['ovs-vsctl', '--columns=name,external-ids', '-f', 'csv', '-d', 'bare',
+ 'list', 'Bridge', 'ovsbr'])
+ self.assertIn(b'netplan=true', out)
+ self.assert_iface('ovsbr', ['inet 192.170.1.1/24'])
+
+ def test_bond_base(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovsbr'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'mybond'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ ethernets:
+ %(ec)s: {}
+ %(e2c)s: {}
+ bonds:
+ mybond:
+ interfaces: [%(ec)s, %(e2c)s]
+ parameters:
+ mode: balance-slb
+ openvswitch:
+ lacp: off
+ bridges:
+ ovsbr:
+ addresses: [192.170.1.1/24]
+ interfaces: [mybond]''' % {'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'ovsbr'])
+ # Basic verification that the interfaces/ports are in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge ovsbr', out)
+ self.assertIn(b' Port mybond', out)
+ self.assertIn(b' Interface %b' % self.dev_e_client.encode(), out)
+ self.assertIn(b' Interface %b' % self.dev_e2_client.encode(), out)
+ # Verify the bond was tagged 'netplan:true' correctly
+ out = subprocess.check_output(['ovs-vsctl', '--columns=name,external-ids', '-f', 'csv', '-d', 'bare', 'list', 'Port'])
+ self.assertIn(b'mybond,netplan=true', out)
+ # Verify bond params
+ out = subprocess.check_output(['ovs-appctl', 'bond/show', 'mybond'])
+ self.assertIn(b'---- mybond ----', out)
+ self.assertIn(b'bond_mode: balance-slb', out)
+ self.assertIn(b'lacp_status: off', out)
+ self.assertRegex(out, br'(slave|member) %b: enabled' % self.dev_e_client.encode())
+ self.assertRegex(out, br'(slave|member) %b: enabled' % self.dev_e2_client.encode())
+ self.assert_iface('ovsbr', ['inet 192.170.1.1/24'])
+
+ def test_bridge_patch_ports(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'br0'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'br1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patch0-1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'patch1-0'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ openvswitch:
+ ports:
+ - [patch0-1, patch1-0]
+ bridges:
+ br0:
+ addresses: [192.168.1.1/24]
+ interfaces: [patch0-1]
+ br1:
+ addresses: [192.168.2.1/24]
+ interfaces: [patch1-0]''')
+ self.generate_and_settle(['br0', 'br1'])
+ # Basic verification that the interfaces/ports are set up in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'])
+ self.assertIn(b' Bridge br0', out)
+ self.assertIn(b''' Port patch0-1
+ Interface patch0-1
+ type: patch
+ options: {peer=patch1-0}''', out)
+ self.assertIn(b' Bridge br1', out)
+ self.assertIn(b''' Port patch1-0
+ Interface patch1-0
+ type: patch
+ options: {peer=patch0-1}''', out)
+ self.assert_iface('br0', ['inet 192.168.1.1/24'])
+ self.assert_iface('br1', ['inet 192.168.2.1/24'])
+
+ def test_bridge_non_ovs_bond(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs-br'])
+ self.addCleanup(subprocess.call, ['ip', 'link', 'del', 'non-ovs-bond'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ %(ec)s: {}
+ %(e2c)s: {}
+ bonds:
+ non-ovs-bond:
+ interfaces: [%(ec)s, %(e2c)s]
+ bridges:
+ ovs-br:
+ interfaces: [non-ovs-bond]
+ openvswitch: {}''' % {'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'ovs-br', 'non-ovs-bond'])
+ # Basic verification that the interfaces/ports are set up in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'], universal_newlines=True)
+ self.assertIn(' Bridge ovs-br', out)
+ self.assertIn(''' Port non-ovs-bond
+ Interface non-ovs-bond''', out)
+ self.assertIn(''' Port ovs-br
+ Interface ovs-br
+ type: internal''', out)
+ self.assert_iface('non-ovs-bond', ['master ovs-system'])
+ self.assert_iface(self.dev_e_client, ['master non-ovs-bond'])
+ self.assert_iface(self.dev_e2_client, ['master non-ovs-bond'])
+
+ def test_vlan_maas(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs0'])
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', '%s.21' % self.dev_e_client], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ bridges:
+ ovs0:
+ addresses: [10.5.48.11/20]
+ interfaces: [%(ec)s.21]
+ macaddress: 00:1f:16:15:78:6f
+ mtu: 1500
+ nameservers:
+ addresses: [10.5.32.99]
+ search: [maas]
+ openvswitch: {}
+ parameters:
+ forward-delay: 15
+ stp: false
+ ethernets:
+ %(ec)s:
+ addresses: [10.5.32.26/20]
+ gateway4: 10.5.32.1
+ mtu: 1500
+ nameservers:
+ addresses: [10.5.32.99]
+ search: [maas]
+ vlans:
+ %(ec)s.21:
+ id: 21
+ link: %(ec)s
+ mtu: 1500''' % {'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, 'ovs0', 'eth42.21'])
+ # Basic verification that the interfaces/ports are set up in OVS
+ out = subprocess.check_output(['ovs-vsctl', 'show'], universal_newlines=True)
+ self.assertIn(' Bridge ovs0', out)
+ self.assertIn(''' Port %(ec)s.21
+ Interface %(ec)s.21''' % {'ec': self.dev_e_client}, out)
+ self.assertIn(''' Port ovs0
+ Interface ovs0
+ type: internal''', out)
+ self.assert_iface('ovs0', ['inet 10.5.48.11/20'])
+ self.assert_iface_up(self.dev_e_client, ['inet 10.5.32.26/20'])
+ self.assert_iface_up('%s.21' % self.dev_e_client, ['%(ec)s.21@%(ec)s' % {'ec': self.dev_e_client}])
+
+ def test_missing_ovs_tools(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['mv', '/usr/bin/ovs-vsctl.bak', '/usr/bin/ovs-vsctl'])
+ subprocess.check_call(['mv', '/usr/bin/ovs-vsctl', '/usr/bin/ovs-vsctl.bak'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ bridges:
+ ovs0:
+ interfaces: [%(ec)s]
+ openvswitch: {}
+ ethernets:
+ %(ec)s: {}''' % {'ec': self.dev_e_client})
+ p = subprocess.Popen(['netplan', 'apply'], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True)
+ (out, err) = p.communicate()
+ self.assertIn('ovs0: The \'ovs-vsctl\' tool is required to setup OpenVSwitch interfaces.', err)
+ self.assertNotEqual(p.returncode, 0)
+
+ def test_settings_tag_cleanup(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs0'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-br', 'ovs1'])
+ self.addCleanup(subprocess.call, ['ovs-vsctl', '--if-exists', 'del-port', 'bond0'])
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ openvswitch:
+ protocols: [OpenFlow13, OpenFlow14, OpenFlow15]
+ ports:
+ - [patch0-1, patch1-0]
+ ssl:
+ ca-cert: /some/ca-cert.pem
+ certificate: /another/cert.pem
+ private-key: /private/key.pem
+ external-ids:
+ somekey: 55:44:33:22:11:00
+ other-config:
+ key: value
+ ethernets:
+ %(ec)s:
+ addresses: [10.5.32.26/20]
+ openvswitch:
+ external-ids:
+ iface-id: mylocaliface
+ other-config:
+ disable-in-band: false
+ %(e2c)s: {}
+ bonds:
+ bond0:
+ interfaces: [patch1-0, %(e2c)s]
+ openvswitch:
+ lacp: passive
+ parameters:
+ mode: balance-tcp
+ bridges:
+ ovs0:
+ addresses: [10.5.48.11/20]
+ interfaces: [patch0-1, %(ec)s, bond0]
+ openvswitch:
+ protocols: [OpenFlow10, OpenFlow11, OpenFlow12]
+ controller:
+ addresses: [unix:/var/run/openvswitch/ovs0.mgmt]
+ connection-mode: out-of-band
+ fail-mode: secure
+ mcast-snooping: true
+ external-ids:
+ iface-id: myhostname
+ other-config:
+ disable-in-band: true
+ hwaddr: aa:bb:cc:dd:ee:ff
+ ovs1:
+ openvswitch:
+ # Add ovs1 as rstp cannot be used if bridge contains a bond interface
+ rstp: true
+
+''' % {'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'ovs0', 'ovs1'])
+ before = self._collect_ovs_settings('ovs0')
+ subprocess.check_call(['netplan', 'apply', '--only-ovs-cleanup'])
+ after = self._collect_ovs_settings('ovs0')
+
+ # Verify interfaces
+ for data in (before['show'], after['show']):
+ self.assertIn(b'Bridge ovs0', data)
+ self.assertIn(b'Port ovs0', data)
+ self.assertIn(b'Interface ovs0', data)
+ self.assertIn(b'Port patch0-1', data)
+ self.assertIn(b'Interface patch0-1', data)
+ self.assertIn(b'Port eth42', data)
+ self.assertIn(b'Interface eth42', data)
+ self.assertIn(b'Bridge ovs1', data)
+ self.assertIn(b'Port ovs1', data)
+ self.assertIn(b'Interface ovs1', data)
+ self.assertIn(b'Port bond0', data)
+ self.assertIn(b'Interface eth42', data)
+ self.assertIn(b'Interface patch1-0', data)
+ # Verify all settings tags have been removed
+ for tbl in ('Open_vSwitch', 'Controller', 'Bridge', 'Port', 'Interface'):
+ self.assertNotIn(b'netplan/', after['external-ids-%s' % tbl])
+ # Verify SSL
+ for s in (b'Private key: /private/key.pem', b'Certificate: /another/cert.pem', b'CA Certificate: /some/ca-cert.pem'):
+ self.assertIn(s, before['ssl'])
+ self.assertNotIn(s, after['ssl'])
+ # Verify Bond
+ self.assertIn(b'bond0,balance-tcp\n', before['bond_mode-Bond'])
+ self.assertIn(b'bond0,\n', after['bond_mode-Bond'])
+ self.assertIn(b'bond0,passive\n', before['lacp-Bond'])
+ self.assertIn(b'bond0,\n', after['lacp-Bond'])
+ # Verify Bridge
+ self.assertIn(b'secure', before['set-fail-mode-Bridge'])
+ self.assertNotIn(b'secure', after['set-fail-mode-Bridge'])
+ self.assertIn(b'ovs0,true\n', before['mcast_snooping_enable-Bridge'])
+ self.assertIn(b'ovs0,false\n', after['mcast_snooping_enable-Bridge'])
+ self.assertIn(b'ovs1,true\n', before['rstp_enable-Bridge'])
+ self.assertIn(b'ovs1,false\n', after['rstp_enable-Bridge'])
+ self.assertIn(b'ovs0,OpenFlow10 OpenFlow11 OpenFlow12\n', before['protocols-Bridge'])
+ self.assertIn(b'ovs0,\n', after['protocols-Bridge'])
+ # Verify global protocols
+ self.assertIn(b'ovs1,OpenFlow13 OpenFlow14 OpenFlow15\n', before['protocols-Bridge'])
+ self.assertIn(b'ovs1,\n', after['protocols-Bridge'])
+ # Verify Controller
+ self.assertIn(b'Controller "unix:/var/run/openvswitch/ovs0.mgmt"', before['show'])
+ self.assertNotIn(b'Controller', after['show'])
+ self.assertIn(b'unix:/var/run/openvswitch/ovs0.mgmt', before['set-controller-Bridge'])
+ self.assertIn(b',out-of-band', before['connection_mode-Controller'])
+ self.assertEqual(b'', after['set-controller-Bridge'])
+ self.assertEqual(b'', after['connection_mode-Controller'])
+ # Verify other-config
+ self.assertIn(b'key=value', before['other-config-Open_vSwitch'])
+ self.assertNotIn(b'key=value', after['other-config-Open_vSwitch'])
+ self.assertIn(b'hwaddr=aa:bb:cc:dd:ee:ff', before['other-config-Bridge'])
+ self.assertNotIn(b'hwaddr=aa:bb:cc:dd:ee:ff', after['other-config-Bridge'])
+ self.assertIn(b'ovs0,disable-in-band=true', before['other-config-Bridge'])
+ self.assertIn(b'ovs0,\n', after['other-config-Bridge'])
+ self.assertIn(b'eth42,disable-in-band=false\n', before['other-config-Interface'])
+ self.assertIn(b'eth42,\n', after['other-config-Interface'])
+ # Verify external-ids
+ self.assertIn(b'somekey=55:44:33:22:11:00', before['external-ids-Open_vSwitch'])
+ self.assertNotIn(b'somekey=55:44:33:22:11:00', after['external-ids-Open_vSwitch'])
+ self.assertIn(b'iface-id=myhostname', before['external-ids-Bridge'])
+ self.assertNotIn(b'iface-id=myhostname', after['external-ids-Bridge'])
+ self.assertIn(b'iface-id=mylocaliface', before['external-ids-Interface'])
+ self.assertNotIn(b'iface-id=mylocaliface', after['external-ids-Interface'])
+ for tbl in ('Bridge', 'Port'):
+ # The netplan=true tag shall be kept unitl the interface is deleted
+ self.assertIn(b'netplan=true', before['external-ids-%s' % tbl])
+ self.assertIn(b'netplan=true', after['external-ids-%s' % tbl])
+
+ @unittest.skip("For debugging only")
+ def test_zzz_ovs_debugging(self): # Runs as the last test, to collect all logs
+ """Display OVS logs of the previous tests"""
+ out = subprocess.check_output(['cat', '/var/log/openvswitch/ovs-vswitchd.log'], universal_newlines=True)
+ print(out)
+ out = subprocess.check_output(['ovsdb-tool', 'show-log'], universal_newlines=True)
+ print(out)
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestOVS(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/regressions.py b/tests/integration/regressions.py
new file mode 100644
index 0000000..25e9a2d
--- /dev/null
+++ b/tests/integration/regressions.py
@@ -0,0 +1,90 @@
+#!/usr/bin/python3
+#
+# Regression tests to catch previously-fixed issues.
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def test_empty_yaml_lp1795343(self):
+ with open(self.config, 'w') as f:
+ f.write('''''')
+ self.generate_and_settle([])
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_lp1802322_bond_mac_rename(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'mybond'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn1:
+ match: {name: %(ec)s}
+ dhcp4: no
+ ethbn2:
+ match: {name: %(e2c)s}
+ dhcp4: no
+ bonds:
+ mybond:
+ interfaces: [ethbn1, ethbn2]
+ macaddress: 00:0a:f7:72:a7:28
+ mtu: 9000
+ addresses: [ 192.168.5.9/24 ]
+ gateway4: 192.168.5.1
+ parameters:
+ down-delay: 0
+ lacp-rate: fast
+ mii-monitor-interval: 100
+ mode: 802.3ad
+ transmit-hash-policy: layer3+4
+ up-delay: 0
+ ''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'mybond'])
+ self.assert_iface_up(self.dev_e_client,
+ ['master mybond', '00:0a:f7:72:a7:28'],
+ ['inet '])
+ self.assert_iface_up(self.dev_e2_client,
+ ['master mybond', '00:0a:f7:72:a7:28'],
+ ['inet '])
+ self.assert_iface_up('mybond', ['inet 192.168.5.[0-9]+/24'])
+ with open('/sys/class/net/mybond/bonding/slaves') as f:
+ self.assertIn(self.dev_e_client, f.read().strip())
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/routing.py b/tests/integration/routing.py
new file mode 100644
index 0000000..d2b3075
--- /dev/null
+++ b/tests/integration/routing.py
@@ -0,0 +1,339 @@
+#!/usr/bin/python3
+#
+# Integration tests for routing functions
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ # Supposed to fail if tested against NetworkManager < 1.12/1.18
+ # The on-link option was introduced as of NM 1.12+ (for IPv4)
+ # The on-link option was introduced as of NM 1.18+ (for IPv6)
+ def test_route_on_link(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ addresses: ["9876:BBBB::11/70"]
+ routes:
+ - to: 2001:f00f:f00f::1/64
+ via: 9876:BBBB::5
+ on-link: true''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet6 9876:bbbb::11/70'])
+ out = subprocess.check_output(['ip', '-6', 'route', 'show', 'dev', self.dev_e_client],
+ universal_newlines=True)
+ # NM routes have a (default) 'metric' in between 'proto static' and 'onlink'
+ self.assertRegex(out, r'2001:f00f:f00f::/64 via 9876:bbbb::5 proto static[^\n]* onlink')
+
+ # Supposed to fail if tested against NetworkManager < 1.8
+ # The from option was introduced as of NM 1.8+
+ def test_route_from(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ addresses: ["192.168.14.2/24"]
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.14.20
+ from: 192.168.14.2''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.14.2'])
+ out = subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client],
+ universal_newlines=True)
+ self.assertIn('10.10.10.0/24 via 192.168.14.20 proto static src 192.168.14.2', out)
+
+ # Supposed to fail if tested against NetworkManager < 1.10
+ # The table option was introduced as of NM 1.10+
+ def test_route_table(self):
+ self.setup_eth(None)
+ table_id = '255' # This is the 'local' FIB of /etc/iproute2/rt_tables
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ dhcp4: no
+ addresses: [ "10.20.10.2/24" ]
+ gateway4: 10.20.10.1
+ routes:
+ - to: 10.0.0.0/8
+ via: 11.0.0.1
+ table: %(tid)s
+ on-link: true''' % {'r': self.backend, 'ec': self.dev_e_client, 'tid': table_id})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet '])
+ out = subprocess.check_output(['ip', 'route', 'show', 'table', table_id, 'dev',
+ self.dev_e_client], universal_newlines=True)
+ # NM routes have a (default) 'metric' in between 'proto static' and 'onlink'
+ self.assertRegex(out, r'10\.0\.0\.0/8 via 11\.0\.0\.1 proto static[^\n]* onlink')
+
+ @unittest.skip("fails due to networkd bug setting routes with dhcp")
+ def test_routes_v4_with_dhcp(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ dhcp4: yes
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ metric: 99''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e_client)])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.5.[0-9]+/24']) # from DHCP
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'10.10.10.0/24 via 192.168.5.254', # from static route
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'metric 99', # check metric from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ def test_routes_v4(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ gateway4: 192.168.5.1
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ metric: 99''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.5.[0-9]+/24']) # from DHCP
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'10.10.10.0/24 via 192.168.5.254', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'metric 99', # check metric from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ def test_routes_v6(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses: ["9876:BBBB::11/70"]
+ gateway6: "9876:BBBB::1"
+ routes:
+ - to: 2001:f00f:f00f::1/64
+ via: 9876:BBBB::5
+ metric: 799''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet6 9876:bbbb::11/70'])
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'via 9876:bbbb::1',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'default']))
+ self.assertIn(b'2001:f00f:f00f::/64 via 9876:bbbb::5',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'metric 799',
+ subprocess.check_output(['ip', '-6', 'route', 'show', '2001:f00f:f00f::/64']))
+
+ def test_routes_default(self):
+ self.setup_eth(None, False)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ - "9876:BBBB::11/70"
+ routes:
+ - to: default
+ via: 192.168.5.1
+ - to: default
+ via: "9876:BBBB::1"
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ metric: 99''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.5.99/24', 'inet6 9876:bbbb::11/70'])
+ # import pdb
+ # pdb.set_trace()
+ self.assertIn(b'default via 192.168.5.1',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'via 9876:bbbb::1',
+ subprocess.check_output(['ip', '-6', 'route', 'show', 'default']))
+ self.assertIn(b'10.10.10.0/24 via 192.168.5.254',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'metric 99', # check metric from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ def test_per_route_mtu(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ gateway4: 192.168.5.1
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ mtu: 777''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assertIn(b'mtu 777', # check mtu from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ def test_per_route_congestion_window(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ gateway4: 192.168.5.1
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ congestion-window: 16''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assertIn(b'initcwnd 16', # check initcwnd from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ def test_per_route_advertised_receive_window(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ gateway4: 192.168.5.1
+ routes:
+ - to: 10.10.10.0/24
+ via: 192.168.5.254
+ advertised-receive-window: 16''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assertIn(b'initrwnd 16', # check initrwnd from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_link_route_v4(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ %(ec)s:
+ addresses:
+ - 192.168.5.99/24
+ gateway4: 192.168.5.1
+ routes:
+ - to: 10.10.10.0/24
+ scope: link
+ metric: 99''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet 192.168.5.[0-9]+/24']) # from DHCP
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'10.10.10.0/24 proto static scope link',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+ self.assertIn(b'metric 99', # check metric from static route
+ subprocess.check_output(['ip', 'route', 'show', '10.10.10.0/24']))
+
+ @unittest.skip("networkd does not handle non-unicast routes correctly yet (Invalid argument)")
+ def test_route_type_blackhole(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ addresses: [ "10.20.10.1/24" ]
+ routes:
+ - to: 10.10.10.0/24
+ via: 10.20.10.100
+ type: blackhole''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet '])
+ self.assertIn(b'blackhole 10.10.10.0/24',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_e_client]))
+
+ def test_route_with_policy(self):
+ self.setup_eth(None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ addresses: [ "10.20.10.1/24" ]
+ routes:
+ - to: 40.0.0.0/24
+ via: 10.20.10.55
+ metric: 50
+ - to: 40.0.0.0/24
+ via: 10.20.10.88
+ table: 99
+ metric: 50
+ routing-policy:
+ - from: 10.20.10.0/24
+ to: 40.0.0.0/24
+ table: 99''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client])
+ self.assert_iface_up(self.dev_e_client, ['inet '])
+ self.assertIn(b'to 40.0.0.0/24 lookup 99',
+ subprocess.check_output(['ip', 'rule', 'show']))
+ self.assertIn(b'40.0.0.0/24 via 10.20.10.88',
+ subprocess.check_output(['ip', 'route', 'show', 'table', '99']))
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/run.py b/tests/integration/run.py
new file mode 100755
index 0000000..deb8e4b
--- /dev/null
+++ b/tests/integration/run.py
@@ -0,0 +1,85 @@
+#!/usr/bin/python3
+#
+# Test runner for netplan integration tests.
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import argparse
+import glob
+import os
+import subprocess
+import textwrap
+import sys
+
+tests_dir = os.path.dirname(os.path.abspath(__file__))
+
+default_backends = [ 'networkd', 'NetworkManager' ]
+fixtures = [ "__init__.py", "base.py", "run.py" ]
+
+possible_tests = []
+testfiles = glob.glob(os.path.join(tests_dir, "*.py"))
+for pyfile in testfiles:
+ filename = os.path.basename(pyfile)
+ if filename not in fixtures:
+ possible_tests.append(filename.split('.')[0])
+
+def dedupe(duped_list):
+ deduped = set()
+ for item in duped_list:
+ real_items = item.split(",")
+ for real_item in real_items:
+ deduped.add(real_item)
+ return deduped
+
+# XXX: omg, this is ugly :)
+parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
+ description=textwrap.dedent("""
+Test runner for netplan integration tests
+
+Available tests:
+{}
+""".format("\n".join(" - {}".format(x) for x in sorted(possible_tests)))))
+
+parser.add_argument('--test', action='append', help="List of tests to be run")
+parser.add_argument('--backend', action='append', help="List of backends to test (NetworkManager, networkd)")
+
+args = parser.parse_args()
+
+requested_tests = set()
+backends = set()
+
+if args.test is not None:
+ requested_tests = dedupe(args.test)
+else:
+ requested_tests.update(possible_tests)
+
+if args.backend is not None:
+ backends = dedupe(args.backend)
+else:
+ backends.update(default_backends)
+
+os.environ["NETPLAN_TEST_BACKENDS"] = ",".join(backends)
+
+returncode = 0
+for test in requested_tests:
+ ret = subprocess.call(['python3', os.path.join(tests_dir, "{}.py".format(test))])
+ if returncode == 0 and ret != 0:
+ returncode = ret
+
+sys.exit(returncode)
diff --git a/tests/integration/scenarios.py b/tests/integration/scenarios.py
new file mode 100644
index 0000000..93f8a4a
--- /dev/null
+++ b/tests/integration/scenarios.py
@@ -0,0 +1,123 @@
+#!/usr/bin/python3
+#
+# Integration tests for complex networking scenarios
+# (ie. mixes of various features, may test real live cases)
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def test_mix_bridge_on_bond(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'bond0'], stderr=subprocess.DEVNULL)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'br0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ bridges:
+ br0:
+ interfaces: [bond0]
+ addresses: ['192.168.0.2/24']
+ bonds:
+ bond0:
+ interfaces: [ethb2]
+ parameters:
+ mode: balance-rr
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ ethb2:
+ match: {name: %(e2c)s}
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'br0', 'bond0'])
+ self.assert_iface_up(self.dev_e2_client, ['master bond0'], ['inet '])
+ self.assert_iface_up('bond0', ['master br0'])
+ self.assert_iface('br0', ['inet 192.168.0.2/24'])
+ with open('/sys/class/net/bond0/bonding/slaves') as f:
+ result = f.read().strip()
+ self.assertIn(self.dev_e2_client, result)
+
+ def test_mix_vlan_on_bridge_on_bond(self):
+ self.setup_eth(None, False)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'bond0'], stderr=subprocess.DEVNULL)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'br0'], stderr=subprocess.DEVNULL)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'br1'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ vlans:
+ vlan1:
+ link: 'br0'
+ id: 1
+ addresses: [ '10.10.10.1/24' ]
+ bridges:
+ br0:
+ interfaces: ['bond0', 'vlan2']
+ parameters:
+ stp: false
+ path-cost:
+ bond0: 1000
+ vlan2: 2000
+ bonds:
+ bond0:
+ interfaces: ['br1']
+ parameters:
+ mode: balance-rr
+ bridges:
+ br1:
+ interfaces: ['ethb2']
+ vlans:
+ vlan2:
+ link: ethbn
+ id: 2
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ ethb2:
+ match: {name: %(e2c)s}
+''' % {'r': self.backend, 'ec': self.dev_e_client, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.dev_e_client, self.dev_e2_client, 'br0', 'br1', 'bond0', 'vlan1', 'vlan2'])
+ self.assert_iface_up('vlan1', ['vlan1@br0'])
+ self.assert_iface_up('vlan2', ['vlan2@' + self.dev_e_client, 'master br0'])
+ self.assert_iface_up(self.dev_e2_client, ['master br1'], ['inet '])
+ self.assert_iface_up('bond0', ['master br0'])
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/tunnels.py b/tests/integration/tunnels.py
new file mode 100644
index 0000000..c336954
--- /dev/null
+++ b/tests/integration/tunnels.py
@@ -0,0 +1,221 @@
+#!/usr/bin/python3
+# Tunnel integration tests. NM and networkd are started on the generated
+# configuration, using emulated ethernets (veth).
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import time
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+class _CommonTests():
+
+ def test_tunnel_sit(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'sit-tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ sit-tun0:
+ mode: sit
+ local: 192.168.5.1
+ remote: 99.99.99.99
+''' % {'r': self.backend})
+ self.generate_and_settle(['sit-tun0'])
+ self.assert_iface('sit-tun0', ['sit-tun0@NONE', 'link.* 192.168.5.1 peer 99.99.99.99'])
+
+ def test_tunnel_ipip(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: ipip
+ local: 192.168.5.1
+ remote: 99.99.99.99
+ ttl: 64
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* 192.168.5.1 peer 99.99.99.99'])
+
+ def test_tunnel_wireguard(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'wg0'], stderr=subprocess.DEVNULL)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'wg1'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ wg0: #server
+ mode: wireguard
+ addresses: [10.10.10.20/24]
+ gateway4: 10.10.10.21
+ key: 4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=
+ mark: 42
+ port: 51820
+ peers:
+ - keys:
+ public: M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=
+ shared: 7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=
+ allowed-ips: [20.20.20.10/24]
+ wg1: #client
+ mode: wireguard
+ addresses: [20.20.20.10/24]
+ gateway4: 20.20.20.11
+ key: KPt9BzQjejRerEv8RMaFlpsD675gNexELOQRXt/AcH0=
+ peers:
+ - endpoint: 10.10.10.20:51820
+ allowed-ips: [0.0.0.0/0]
+ keys:
+ public: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=
+ shared: 7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=
+ keepalive: 21
+''' % {'r': self.backend})
+ self.generate_and_settle(['wg0', 'wg1'])
+ # Wait for handshake/connection between client & server
+ self.wait_output(['wg', 'show', 'wg0'], 'latest handshake')
+ self.wait_output(['wg', 'show', 'wg1'], 'latest handshake')
+ # Verify server
+ out = subprocess.check_output(['wg', 'show', 'wg0', 'private-key'], universal_newlines=True)
+ self.assertIn("4GgaQCy68nzNsUE5aJ9fuLzHhB65tAlwbmA72MWnOm8=", out)
+ out = subprocess.check_output(['wg', 'show', 'wg0', 'preshared-keys'], universal_newlines=True)
+ self.assertIn("7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=", out)
+ out = subprocess.check_output(['wg', 'show', 'wg0'], universal_newlines=True)
+ self.assertIn("public key: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=", out)
+ self.assertIn("listening port: 51820", out)
+ self.assertIn("fwmark: 0x2a", out)
+ self.assertIn("peer: M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=", out)
+ self.assertIn("allowed ips: 20.20.20.0/24", out)
+ self.assertRegex(out, r'latest handshake: (\d+ seconds? ago|Now)')
+ self.assertRegex(out, r'transfer: \d+.*B received, \d+.*B sent')
+ self.assert_iface('wg0', ['inet 10.10.10.20/24'])
+ # Verify client
+ out = subprocess.check_output(['wg', 'show', 'wg1', 'private-key'], universal_newlines=True)
+ self.assertIn("KPt9BzQjejRerEv8RMaFlpsD675gNexELOQRXt/AcH0=", out)
+ out = subprocess.check_output(['wg', 'show', 'wg1', 'preshared-keys'], universal_newlines=True)
+ self.assertIn("7voRZ/ojfXgfPOlswo3Lpma1RJq7qijIEEUEMShQFV8=", out)
+ out = subprocess.check_output(['wg', 'show', 'wg1'], universal_newlines=True)
+ self.assertIn("public key: M9nt4YujIOmNrRmpIRTmYSfMdrpvE7u6WkG8FY8WjG4=", out)
+ self.assertIn("peer: rlbInAj0qV69CysWPQY7KEBnKxpYCpaWqOs/dLevdWc=", out)
+ self.assertIn("endpoint: 10.10.10.20:51820", out)
+ self.assertIn("allowed ips: 0.0.0.0/0", out)
+ self.assertIn("persistent keepalive: every 21 seconds", out)
+ self.assertRegex(out, r'latest handshake: (\d+ seconds? ago|Now)')
+ self.assertRegex(out, r'transfer: \d+.*B received, \d+.*B sent')
+ self.assert_iface('wg1', ['inet 20.20.20.10/24'])
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+ def test_tunnel_gre(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ local: 192.168.5.1
+ remote: 99.99.99.99
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* 192.168.5.1 peer 99.99.99.99'])
+
+ def test_tunnel_gre6(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: ip6gre
+ local: fe80::1
+ remote: 2001:dead:beef::2
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* fe80::1 brd 2001:dead:beef::2'])
+
+ def test_tunnel_vti(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: vti
+ keys: 1234
+ local: 192.168.5.1
+ remote: 99.99.99.99
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* 192.168.5.1 peer 99.99.99.99'])
+
+ def test_tunnel_vti6(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: vti6
+ keys: 1234
+ local: fe80::1
+ remote: 2001:dead:beef::2
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* fe80::1 brd 2001:dead:beef::2'])
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+ def test_tunnel_gre(self):
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'tun0'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ version: 2
+ tunnels:
+ tun0:
+ mode: gre
+ keys: 1234
+ local: 192.168.5.1
+ remote: 99.99.99.99
+''' % {'r': self.backend})
+ self.generate_and_settle(['tun0'])
+ self.assert_iface('tun0', ['tun0@NONE', 'link.* 192.168.5.1 peer 99.99.99.99'])
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/vlans.py b/tests/integration/vlans.py
new file mode 100644
index 0000000..e68f605
--- /dev/null
+++ b/tests/integration/vlans.py
@@ -0,0 +1,103 @@
+#!/usr/bin/python3
+#
+# Integration tests for VLAN virtual devices
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsBase, test_backends
+
+
+class _CommonTests():
+
+ def test_vlan(self):
+ # we create two VLANs on e2c, and run dnsmasq on ID 2002 to test DHCP via VLAN
+ self.setup_eth(None, start_dnsmasq=False)
+ self.start_dnsmasq(None, self.dev_e2_ap)
+ subprocess.check_call(['ip', 'link', 'add', 'link', self.dev_e2_ap,
+ 'name', 'nptestsrv', 'type', 'vlan', 'id', '2002'])
+ subprocess.check_call(['ip', 'a', 'add', '192.168.5.1/24', 'dev', 'nptestsrv'])
+ subprocess.check_call(['ip', 'link', 'set', 'nptestsrv', 'up'])
+ self.start_dnsmasq(None, 'nptestsrv')
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ version: 2
+ renderer: %(r)s
+ ethernets:
+ myether:
+ match: {name: %(e2c)s}
+ dhcp4: yes
+ vlans:
+ nptestone:
+ id: 1001
+ link: myether
+ addresses: [10.9.8.7/24]
+ nptesttwo:
+ id: 2002
+ link: myether
+ dhcp4: true
+ ''' % {'r': self.backend, 'e2c': self.dev_e2_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_e2_client),
+ 'nptestone',
+ self.state_dhcp4('nptesttwo')])
+ self.assert_iface_up('nptestone', ['nptestone@' + self.dev_e2_client, 'inet 10.9.8.7/24'])
+ self.assert_iface_up('nptesttwo', ['nptesttwo@' + self.dev_e2_client, 'inet 192.168.5'])
+ self.assertNotIn(b'default',
+ subprocess.check_output(['ip', 'route', 'show', 'dev', 'nptestone']))
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', 'nptesttwo']))
+
+ def test_vlan_mac_address(self):
+ self.setup_eth(None)
+ self.addCleanup(subprocess.call, ['ip', 'link', 'delete', 'myvlan'], stderr=subprocess.DEVNULL)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ ethernets:
+ ethbn:
+ match: {name: %(ec)s}
+ vlans:
+ myvlan:
+ id: 101
+ link: ethbn
+ macaddress: aa:bb:cc:dd:ee:22
+ ''' % {'r': self.backend, 'ec': self.dev_e_client})
+ self.generate_and_settle([self.dev_e_client, 'myvlan'])
+ self.assert_iface_up('myvlan', ['myvlan@' + self.dev_e_client])
+ with open('/sys/class/net/myvlan/address') as f:
+ self.assertEqual(f.read().strip(), 'aa:bb:cc:dd:ee:22')
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsBase, _CommonTests):
+ backend = 'networkd'
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsBase, _CommonTests):
+ backend = 'NetworkManager'
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/integration/wifi.py b/tests/integration/wifi.py
new file mode 100644
index 0000000..d09b5cf
--- /dev/null
+++ b/tests/integration/wifi.py
@@ -0,0 +1,158 @@
+#!/usr/bin/python3
+#
+# Integration tests for wireless devices
+#
+# These need to be run in a VM and do change the system
+# configuration.
+#
+# Copyright (C) 2018-2021 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import subprocess
+import unittest
+
+from base import IntegrationTestsWifi, test_backends
+
+
+class _CommonTests():
+
+ @unittest.skip("Unsupported matching by driver / wifi matching makes this untestable for now")
+ def test_mapping_for_driver(self):
+ self.setup_ap('hw_mode=b\nchannel=1\nssid=fake net', None)
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ wifis:
+ wifi_ifs:
+ match:
+ driver: mac80211_hwsim
+ dhcp4: yes
+ access-points:
+ "fake net": {}
+ decoy: {}''' % {'r': self.backend})
+ self.generate_and_settle([self.state_dhcp4(self.dev_w_client)])
+ p = subprocess.Popen(['netplan', 'generate', '--mapping', 'mac80211_hwsim'],
+ stdout=subprocess.PIPE)
+ out = p.communicate()[0]
+ self.assertEquals(p.returncode, 1)
+ self.assertIn(b'mac80211_hwsim', out)
+
+ def test_wifi_ipv4_open(self):
+ self.setup_ap('hw_mode=b\nchannel=1\nssid=fake net', None)
+
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ wifis:
+ %(wc)s:
+ dhcp4: yes
+ access-points:
+ "fake net": {}
+ decoy: {}''' % {'r': self.backend, 'wc': self.dev_w_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_w_client)])
+ self.assert_iface_up(self.dev_w_client, ['inet 192.168.5.[0-9]+/24'])
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_w_client]))
+ if self.backend == 'NetworkManager':
+ out = subprocess.check_output(['nmcli', 'dev', 'show', self.dev_w_client],
+ universal_newlines=True)
+ self.assertRegex(out, 'GENERAL.CONNECTION.*netplan-%s-fake net' % self.dev_w_client)
+ self.assertRegex(out, 'IP4.DNS.*192.168.5.1')
+ else:
+ out = subprocess.check_output(['networkctl', 'status', self.dev_w_client],
+ universal_newlines=True)
+ self.assertRegex(out, 'DNS.*192.168.5.1')
+
+ def test_wifi_ipv4_wpa2(self):
+ self.setup_ap('''hw_mode=g
+channel=1
+ssid=fake net
+wpa=1
+wpa_key_mgmt=WPA-PSK
+wpa_pairwise=TKIP
+wpa_passphrase=12345678
+''', None)
+
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ renderer: %(r)s
+ wifis:
+ %(wc)s:
+ dhcp4: yes
+ access-points:
+ "fake net":
+ password: 12345678
+ decoy: {}''' % {'r': self.backend, 'wc': self.dev_w_client})
+ self.generate_and_settle([self.state_dhcp4(self.dev_w_client)])
+ self.assert_iface_up(self.dev_w_client, ['inet 192.168.5.[0-9]+/24'])
+ self.assertIn(b'default via 192.168.5.1', # from DHCP
+ subprocess.check_output(['ip', 'route', 'show', 'dev', self.dev_w_client]))
+ if self.backend == 'NetworkManager':
+ out = subprocess.check_output(['nmcli', 'dev', 'show', self.dev_w_client],
+ universal_newlines=True)
+ self.assertRegex(out, 'GENERAL.CONNECTION.*netplan-%s-fake net' % self.dev_w_client)
+ self.assertRegex(out, 'IP4.DNS.*192.168.5.1')
+ else:
+ out = subprocess.check_output(['networkctl', 'status', self.dev_w_client],
+ universal_newlines=True)
+ self.assertRegex(out, 'DNS.*192.168.5.1')
+
+
+@unittest.skipIf("networkd" not in test_backends,
+ "skipping as networkd backend tests are disabled")
+class TestNetworkd(IntegrationTestsWifi, _CommonTests):
+ backend = 'networkd'
+
+
+@unittest.skipIf("NetworkManager" not in test_backends,
+ "skipping as NetworkManager backend tests are disabled")
+class TestNetworkManager(IntegrationTestsWifi, _CommonTests):
+ backend = 'NetworkManager'
+
+ def test_wifi_ap_open(self):
+ # we use dev_w_client and dev_w_ap in switched roles here, to keep the
+ # existing device blacklisting in NM; i. e. dev_w_client is the
+ # NM-managed AP, and dev_w_ap the manually managed client
+ with open(self.config, 'w') as f:
+ f.write('''network:
+ wifis:
+ renderer: NetworkManager
+ %(wc)s:
+ dhcp4: yes
+ access-points:
+ "fake net":
+ mode: ap''' % {'wc': self.dev_w_client})
+ self.generate_and_settle([self.state(self.dev_w_client, 'inet 10.')])
+ out = subprocess.check_output(['iw', 'dev', self.dev_w_client, 'info'],
+ universal_newlines=True)
+ self.assertIn('type AP', out)
+ self.assertIn('ssid fake net', out)
+
+ # connect the other end
+ subprocess.check_call(['ip', 'link', 'set', self.dev_w_ap, 'up'])
+ subprocess.check_call(['iw', 'dev', self.dev_w_ap, 'connect', 'fake net'])
+ out = subprocess.check_output(['dhclient', '-1', '-v', self.dev_w_ap],
+ stderr=subprocess.STDOUT, universal_newlines=True)
+ self.assertIn('DHCPACK', out)
+ out = subprocess.check_output(['iw', 'dev', self.dev_w_ap, 'info'],
+ universal_newlines=True)
+ self.assertIn('type managed', out)
+ self.assertIn('ssid fake net', out)
+ self.assert_iface_up(self.dev_w_ap, ['inet 10.'])
+
+
+unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2))
diff --git a/tests/parser/__init__.py b/tests/parser/__init__.py
new file mode 100644
index 0000000..47aeeb5
--- /dev/null
+++ b/tests/parser/__init__.py
@@ -0,0 +1,17 @@
+#
+# __init__ for parser tests.
+#
+# Copyright (C) 2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/tests/parser/base.py b/tests/parser/base.py
new file mode 100644
index 0000000..0ba40f6
--- /dev/null
+++ b/tests/parser/base.py
@@ -0,0 +1,169 @@
+#
+# Blackbox tests of netplan's keyfile parser that verify that the generated
+# YAML files look as expected. These are run during "make check" and
+# don't touch the system configuration at all.
+#
+# Copyright (C) 2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from configparser import ConfigParser
+import os
+import sys
+import shutil
+import tempfile
+import unittest
+import ctypes
+import ctypes.util
+import contextlib
+import subprocess
+
+exe_generate = os.path.join(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))), 'generate')
+
+# make sure we point to libnetplan properly.
+os.environ.update({'LD_LIBRARY_PATH': '.:{}'.format(os.environ.get('LD_LIBRARY_PATH'))})
+
+# make sure we fail on criticals
+os.environ['G_DEBUG'] = 'fatal-criticals'
+
+lib = ctypes.CDLL(ctypes.util.find_library('netplan'))
+
+
+# A contextmanager to catch the output on a low level so that it catches output
+# from a subprocess or C library call, in addition to normal python output
+@contextlib.contextmanager
+def capture_stderr():
+ stderr_fd = 2 # 2 = stderr
+ with tempfile.NamedTemporaryFile(mode='w+b') as tmp:
+ stderr_copy = os.dup(stderr_fd)
+ try:
+ sys.stderr.flush()
+ os.dup2(tmp.fileno(), stderr_fd)
+ yield tmp
+ finally:
+ sys.stderr.flush()
+ os.dup2(stderr_copy, stderr_fd)
+ os.close(stderr_copy)
+
+
+class TestKeyfileBase(unittest.TestCase):
+
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ self.confdir = os.path.join(self.workdir.name, 'etc', 'netplan')
+ self.maxDiff = None
+ os.makedirs(self.confdir)
+
+ def tearDown(self):
+ shutil.rmtree(self.workdir.name)
+ super().tearDown()
+
+ def generate_from_keyfile(self, keyfile, netdef_id=None, expect_fail=False, filename=None):
+ '''Call libnetplan with given keyfile string as configuration'''
+ # Autodetect default 'NM-<UUID>' netdef-id
+ ssid = ''
+ if not netdef_id:
+ found_values = 0
+ uuid = 'UNKNOWN_UUID'
+ for line in keyfile.splitlines():
+ if line.startswith('uuid='):
+ uuid = line.split('=')[1]
+ found_values += 1
+ elif line.startswith('ssid='):
+ ssid += '-' + line.split('=')[1]
+ found_values += 1
+ if found_values >= 2:
+ break
+ netdef_id = 'NM-' + uuid
+ if not filename:
+ filename = 'netplan-{}{}.nmconnection'.format(netdef_id, ssid)
+ f = os.path.join(self.workdir.name, 'run/NetworkManager/system-connections/{}'.format(filename))
+ os.makedirs(os.path.dirname(f))
+ with open(f, 'w') as file:
+ file.write(keyfile)
+
+ with capture_stderr() as outf:
+ if expect_fail:
+ self.assertFalse(lib.netplan_parse_keyfile(f.encode(), None))
+ else:
+ self.assertTrue(lib.netplan_parse_keyfile(f.encode(), None))
+ lib._write_netplan_conf(netdef_id.encode(), self.workdir.name.encode())
+ lib.netplan_clear_netdefs()
+ self.assert_nm_regenerate({filename: keyfile}) # check re-generated keyfile
+ with open(outf.name, 'r') as f:
+ output = f.read().strip() # output from stderr (fd=2) on C/library level
+ return output
+
+ def assert_netplan(self, file_contents_map):
+ for uuid in file_contents_map.keys():
+ self.assertTrue(os.path.isfile(os.path.join(self.confdir, '90-NM-{}.yaml'.format(uuid))))
+ with open(os.path.join(self.confdir, '90-NM-{}.yaml'.format(uuid)), 'r') as f:
+ self.assertEqual(f.read(), file_contents_map[uuid])
+
+ def normalize_keyfile(self, file_contents):
+ parser = ConfigParser()
+ parser.read_string(file_contents)
+ sections = parser.sections()
+ res = []
+ # Sort sections and keys
+ sections.sort()
+ for s in sections:
+ items = parser.items(s)
+ if s == 'ipv6' and len(items) == 1 and items[0] == ('method', 'ignore'):
+ continue
+
+ line = '\n[' + s + ']'
+ res.append(line)
+ items.sort(key=lambda tup: tup[0])
+ for k, v in items:
+ # Normalize lines
+ if k == 'addr-gen-mode':
+ v = v.replace('1', 'stable-privacy').replace('0', 'eui64')
+ elif k == 'dns-search' and v != '':
+ # XXX: netplan is loosing information here about which search domain
+ # belongs to the [ipv4] or [ipv6] sections
+ v = '*** REDACTED (in base.py) ***'
+ # handle NM defaults
+ elif k == 'dns-search' and v == '':
+ continue
+ elif k == 'wake-on-lan' and v == '1':
+ continue
+ elif k == 'stp' and v == 'true':
+ continue
+
+ line = (k + '=' + v).strip(';')
+ res.append(line)
+ return '\n'.join(res).strip()+'\n'
+
+ def assert_nm_regenerate(self, file_contents_map):
+ argv = [exe_generate, '--root-dir', self.workdir.name]
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True)
+ (out, err) = p.communicate()
+ self.assertEqual(out, '')
+ con_dir = os.path.join(self.workdir.name, 'run', 'NetworkManager', 'system-connections')
+ if file_contents_map:
+ self.assertEqual(set(os.listdir(con_dir)),
+ set([n for n in file_contents_map]))
+ for fname, contents in file_contents_map.items():
+ with open(os.path.join(con_dir, fname)) as f:
+ generated_keyfile = self.normalize_keyfile(f.read())
+ normalized_contents = self.normalize_keyfile(contents)
+ self.assertEqual(generated_keyfile, normalized_contents,
+ 'Re-generated keyfile does not match')
+ else: # pragma: nocover (only needed for test debugging)
+ if os.path.exists(con_dir):
+ self.assertEqual(os.listdir(con_dir), [])
+ return err
diff --git a/tests/parser/test_keyfile.py b/tests/parser/test_keyfile.py
new file mode 100644
index 0000000..692dba4
--- /dev/null
+++ b/tests/parser/test_keyfile.py
@@ -0,0 +1,1018 @@
+#!/usr/bin/python3
+# Blackbox tests of NetworkManager keyfile parser. These are run during
+# "make check" and don't touch the system configuration at all.
+#
+# Copyright (C) 2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import ctypes
+import ctypes.util
+
+from .base import TestKeyfileBase
+
+rootdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+exe_cli = os.path.join(rootdir, 'src', 'netplan.script')
+# Make sure we can import our development netplan.
+os.environ.update({'PYTHONPATH': '.'})
+
+lib = ctypes.CDLL(ctypes.util.find_library('netplan'))
+lib.netplan_get_id_from_nm_filename.restype = ctypes.c_char_p
+UUID = 'ff9d6ebc-226d-4f82-a485-b7ff83b9607f'
+
+
+class TestNetworkManagerKeyfileParser(TestKeyfileBase):
+ '''Test NM keyfile parser as used by NetworkManager's YAML backend'''
+
+ def test_keyfile_missing_uuid(self):
+ err = self.generate_from_keyfile('[connection]\ntype=ethernets', expect_fail=True)
+ self.assertIn('netplan: Keyfile: cannot find connection.uuid', err)
+
+ def test_keyfile_missing_type(self):
+ err = self.generate_from_keyfile('[connection]\nuuid=87749f1d-334f-40b2-98d4-55db58965f5f', expect_fail=True)
+ self.assertIn('netplan: Keyfile: cannot find connection.type', err)
+
+ def test_keyfile_gsm(self):
+ self.generate_from_keyfile('''[connection]
+id=T-Mobile Funkadelic 2
+uuid={}
+type=gsm
+
+[gsm]
+apn=internet2.voicestream.com
+device-id=da812de91eec16620b06cd0ca5cbc7ea25245222
+home-only=true
+network-id=254098
+password=parliament2
+pin=123456
+sim-id=89148000000060671234
+sim-operator-id=310260
+username=george.clinton.again
+mtu=1042
+
+[ipv4]
+dns-search=
+method=auto
+
+[ipv6]
+dns-search=
+method=auto
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ modems:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ dhcp6: true
+ mtu: 1042
+ apn: "internet2.voicestream.com"
+ device-id: "da812de91eec16620b06cd0ca5cbc7ea25245222"
+ network-id: "254098"
+ pin: "123456"
+ sim-id: "89148000000060671234"
+ sim-operator-id: "310260"
+ username: "george.clinton.again"
+ password: "parliament2"
+ networkmanager:
+ uuid: "{}"
+ name: "T-Mobile Funkadelic 2"
+ passthrough:
+ gsm.home-only: "true"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_cdma(self):
+ self.generate_from_keyfile('''[connection]
+id=T-Mobile Funkadelic 2
+uuid={}
+type=cdma
+
+[cdma]
+number=0123456
+username=testuser
+password=testpass
+mtu=1042
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ modems:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ mtu: 1042
+ username: "testuser"
+ password: "testpass"
+ number: "0123456"
+ networkmanager:
+ uuid: "{}"
+ name: "T-Mobile Funkadelic 2"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_gsm_via_bluetooth(self):
+ self.generate_from_keyfile('''[connection]
+id=T-Mobile Funkadelic 2
+uuid={}
+type=bluetooth
+
+[gsm]
+apn=internet2.voicestream.com
+device-id=da812de91eec16620b06cd0ca5cbc7ea25245222
+home-only=true
+network-id=254098
+password=parliament2
+pin=123456
+sim-id=89148000000060671234
+sim-operator-id=310260
+username=george.clinton.again
+
+[ipv4]
+dns-search=
+method=auto
+
+[ipv6]
+dns-search=
+method=auto
+
+[proxy]'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ nm-devices:
+ NM-{}:
+ renderer: NetworkManager
+ networkmanager:
+ uuid: "{}"
+ name: "T-Mobile Funkadelic 2"
+ passthrough:
+ connection.type: "bluetooth"
+ gsm.apn: "internet2.voicestream.com"
+ gsm.device-id: "da812de91eec16620b06cd0ca5cbc7ea25245222"
+ gsm.home-only: "true"
+ gsm.network-id: "254098"
+ gsm.password: "parliament2"
+ gsm.pin: "123456"
+ gsm.sim-id: "89148000000060671234"
+ gsm.sim-operator-id: "310260"
+ gsm.username: "george.clinton.again"
+ ipv4.dns-search: ""
+ ipv4.method: "auto"
+ ipv6.dns-search: ""
+ ipv6.method: "auto"
+ proxy._: ""
+'''.format(UUID, UUID)})
+
+ def test_keyfile_method_auto(self):
+ self.generate_from_keyfile('''[connection]
+id=Test
+uuid={}
+type=ethernet
+
+[ethernet]
+wake-on-lan=0
+mtu=1500
+cloned-mac-address=00:11:22:33:44:55
+
+[ipv4]
+dns-search=
+method=auto
+ignore-auto-routes=true
+never-default=true
+route-metric=4242
+
+[ipv6]
+addr-gen-mode=eui64
+token=1234::3
+dns-search=
+method=auto
+ignore-auto-routes=true
+never-default=true
+route-metric=4242
+
+[proxy]
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ ethernets:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ dhcp4-overrides:
+ use-routes: false
+ route-metric: 4242
+ dhcp6: true
+ dhcp6-overrides:
+ use-routes: false
+ route-metric: 4242
+ macaddress: "00:11:22:33:44:55"
+ ipv6-address-generation: "eui64"
+ ipv6-address-token: "1234::3"
+ mtu: 1500
+ networkmanager:
+ uuid: "{}"
+ name: "Test"
+ passthrough:
+ proxy._: ""
+'''.format(UUID, UUID)})
+
+ def test_keyfile_method_manual(self):
+ self.generate_from_keyfile('''[connection]
+id=Test
+uuid={}
+type=ethernet
+
+[ethernet]
+mac-address=00:11:22:33:44:55
+
+[ipv4]
+dns-search=foo.local;bar.remote;
+dns=9.8.7.6;5.4.3.2
+method=manual
+address1=1.2.3.4/24,8.8.8.8
+address2=5.6.7.8/16
+gateway=6.6.6.6
+route1=1.1.2.2/16,8.8.8.8,42
+route1_options=onlink=true,initrwnd=33,initcwnd=44,mtu=1024,table=102,src=10.10.10.11
+route2=2.2.3.3/24,4.4.4.4
+
+[ipv6]
+addr-gen-mode=stable-privacy
+dns-search=bar.local
+dns=dead:beef::2;
+method=manual
+address1=1:2:3::9/128
+gateway=6:6::6
+route1=dead:beef::1/128,2001:1234::2
+route1_options=unknown=invalid,
+
+[proxy]
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ ethernets:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ macaddress: "00:11:22:33:44:55"
+ addresses:
+ - "1.2.3.4/24"
+ - "5.6.7.8/16"
+ - "1:2:3::9/128"
+ nameservers:
+ addresses:
+ - 9.8.7.6
+ - 5.4.3.2
+ - dead:beef::2
+ search:
+ - foo.local
+ - bar.remote
+ - bar.local
+ gateway4: 6.6.6.6
+ gateway6: 6:6::6
+ ipv6-address-generation: "stable-privacy"
+ routes:
+ - metric: 42
+ table: 102
+ mtu: 1024
+ congestion-window: 44
+ advertised-receive-window: 33
+ on-link: "true"
+ from: "10.10.10.11"
+ to: "1.1.2.2/16"
+ via: "8.8.8.8"
+ - to: "2.2.3.3/24"
+ via: "4.4.4.4"
+ - to: "dead:beef::1/128"
+ via: "2001:1234::2"
+ wakeonlan: true
+ networkmanager:
+ uuid: "{}"
+ name: "Test"
+ passthrough:
+ ipv4.method: "manual"
+ ipv4.address1: "1.2.3.4/24,8.8.8.8"
+ ipv6.route1: "dead:beef::1/128,2001:1234::2"
+ ipv6.route1_options: "unknown=invalid,"
+ proxy._: ""
+'''.format(UUID, UUID)})
+
+ def _template_keyfile_type(self, nd_type, nm_type, supported=True):
+ self.maxDiff = None
+ file = os.path.join(self.workdir.name, 'tmp/some.keyfile')
+ os.makedirs(os.path.dirname(file))
+ with open(file, 'w') as f:
+ f.write('[connection]\ntype={}\nuuid={}'.format(nm_type, UUID))
+ self.assertEqual(lib.netplan_clear_netdefs(), 0)
+ lib.netplan_parse_keyfile(file.encode(), None)
+ lib._write_netplan_conf('NM-{}'.format(UUID).encode(), self.workdir.name.encode())
+ lib.netplan_clear_netdefs()
+ self.assertTrue(os.path.isfile(os.path.join(self.confdir, '90-NM-{}.yaml'.format(UUID))))
+ t = '\n passthrough:\n connection.type: "{}"'.format(nm_type) if not supported else ''
+ match = '\n match: {}' if nd_type in ['ethernets', 'modems', 'wifis'] else ''
+ with open(os.path.join(self.confdir, '90-NM-{}.yaml'.format(UUID)), 'r') as f:
+ self.assertEqual(f.read(), '''network:
+ version: 2
+ {}:
+ NM-{}:
+ renderer: NetworkManager{}
+ networkmanager:
+ uuid: "{}"{}
+'''.format(nd_type, UUID, match, UUID, t))
+
+ def test_keyfile_ethernet(self):
+ self._template_keyfile_type('ethernets', 'ethernet')
+
+ def test_keyfile_type_modem_gsm(self):
+ self._template_keyfile_type('modems', 'gsm')
+
+ def test_keyfile_type_modem_cdma(self):
+ self._template_keyfile_type('modems', 'cdma')
+
+ def test_keyfile_type_bridge(self):
+ self._template_keyfile_type('bridges', 'bridge')
+
+ def test_keyfile_type_bond(self):
+ self._template_keyfile_type('bonds', 'bond')
+
+ def test_keyfile_type_vlan(self):
+ self._template_keyfile_type('vlans', 'vlan')
+
+ def test_keyfile_type_tunnel(self):
+ self._template_keyfile_type('tunnels', 'ip-tunnel', False)
+
+ def test_keyfile_type_wireguard(self):
+ self._template_keyfile_type('tunnels', 'wireguard', False)
+
+ def test_keyfile_type_other(self):
+ self._template_keyfile_type('nm-devices', 'dummy', False)
+
+ def test_keyfile_type_wifi(self):
+ self.generate_from_keyfile('''[connection]
+type=wifi
+uuid={}
+permissions=
+id=myid with spaces
+interface-name=eth0
+
+[wifi]
+ssid=SOME-SSID
+mode=infrastructure
+hidden=true
+mtu=1500
+cloned-mac-address=00:11:22:33:44:55
+band=a
+channel=12
+bssid=de:ad:be:ef:ca:fe
+
+[wifi-security]
+key-mgmt=ieee8021x
+
+[ipv4]
+method=auto
+dns-search='''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "eth0"
+ dhcp4: true
+ macaddress: "00:11:22:33:44:55"
+ mtu: 1500
+ access-points:
+ "SOME-SSID":
+ hidden: true
+ bssid: "de:ad:be:ef:ca:fe"
+ band: "5GHz"
+ channel: 12
+ auth:
+ key-management: "802.1x"
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+ passthrough:
+ connection.permissions: ""
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+'''.format(UUID, UUID, UUID)})
+
+ def _template_keyfile_type_wifi_eap(self, method):
+ self.generate_from_keyfile('''[connection]
+type=wifi
+uuid={}
+permissions=
+id=testnet
+interface-name=wlan0
+
+[wifi]
+ssid=testnet
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-eap
+
+[802-1x]
+eap={}
+identity=some-id
+anonymous-identity=anon-id
+password=v3rys3cr3t!
+ca-cert=/some/path.key
+client-cert=/some/path.client_cert
+private-key=/some/path.key
+private-key-password=s0s3cr3t!!111
+phase2-auth=chap
+
+[ipv4]
+method=auto
+dns-search='''.format(UUID, method))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "wlan0"
+ dhcp4: true
+ access-points:
+ "testnet":
+ auth:
+ key-management: "eap"
+ method: "{}"
+ anonymous-identity: "anon-id"
+ identity: "some-id"
+ ca-certificate: "/some/path.key"
+ client-certificate: "/some/path.client_cert"
+ client-key: "/some/path.key"
+ client-key-password: "s0s3cr3t!!111"
+ phase2-auth: "chap"
+ password: "v3rys3cr3t!"
+ networkmanager:
+ uuid: "{}"
+ name: "testnet"
+ passthrough:
+ connection.permissions: ""
+ networkmanager:
+ uuid: "{}"
+ name: "testnet"
+'''.format(UUID, method, UUID, UUID)})
+
+ def test_keyfile_type_wifi_eap_peap(self):
+ self._template_keyfile_type_wifi_eap('peap')
+
+ def test_keyfile_type_wifi_eap_tls(self):
+ self._template_keyfile_type_wifi_eap('tls')
+
+ def test_keyfile_type_wifi_eap_ttls(self):
+ self._template_keyfile_type_wifi_eap('ttls')
+
+ def _template_keyfile_type_wifi(self, nd_mode, nm_mode):
+ self.generate_from_keyfile('''[connection]
+type=wifi
+uuid={}
+id=myid with spaces
+
+[ipv4]
+method=auto
+
+[wifi]
+ssid=SOME-SSID
+wake-on-wlan=24
+band=bg
+mode={}'''.format(UUID, nm_mode))
+ wifi_mode = ''
+ ap_mode = ''
+ if nm_mode != nd_mode:
+ wifi_mode = '''
+ passthrough:
+ wifi.mode: "{}"'''.format(nm_mode)
+ if nd_mode != 'infrastructure':
+ ap_mode = '\n mode: "%s"' % nd_mode
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ wakeonwlan:
+ - magic_pkt
+ - gtk_rekey_failure
+ access-points:
+ "SOME-SSID":
+ band: "2.4GHz"{}
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"{}
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+'''.format(UUID, ap_mode, UUID, wifi_mode, UUID)})
+
+ def test_keyfile_type_wifi_ap(self):
+ self.generate_from_keyfile('''[connection]
+type=wifi
+uuid={}
+id=myid with spaces
+
+[ipv4]
+method=shared
+
+[wifi]
+ssid=SOME-SSID
+wake-on-wlan=24
+band=bg
+mode=ap'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ wakeonwlan:
+ - magic_pkt
+ - gtk_rekey_failure
+ access-points:
+ "SOME-SSID":
+ band: "2.4GHz"
+ mode: "ap"
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+'''.format(UUID, UUID, UUID)})
+
+ def test_keyfile_type_wifi_adhoc(self):
+ self._template_keyfile_type_wifi('adhoc', 'adhoc')
+
+ def test_keyfile_type_wifi_unknown(self):
+ self._template_keyfile_type_wifi('infrastructure', 'mesh')
+
+ def test_keyfile_type_wifi_missing_ssid(self):
+ err = self.generate_from_keyfile('''[connection]\ntype=wifi\nuuid={}\nid=myid with spaces'''.format(UUID), expect_fail=True)
+ self.assertFalse(os.path.isfile(os.path.join(self.confdir, '90-NM-{}.yaml'.format(UUID))))
+ self.assertIn('netplan: Keyfile: cannot find SSID for WiFi connection', err)
+
+ def test_keyfile_wake_on_lan(self):
+ self.generate_from_keyfile('''[connection]
+type=ethernet
+uuid={}
+id=myid with spaces
+
+[ethernet]
+wake-on-lan=2
+
+[ipv4]
+method=auto'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ ethernets:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ wakeonlan: true
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+ passthrough:
+ ethernet.wake-on-lan: "2"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_wake_on_lan_nm_default(self):
+ self.generate_from_keyfile('''[connection]
+type=ethernet
+uuid={}
+id=myid with spaces
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=auto'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ ethernets:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_modem_gsm(self):
+ self.generate_from_keyfile('''[connection]
+type=gsm
+uuid={}
+id=myid with spaces
+
+[ipv4]
+method=auto
+
+[gsm]
+auto-config=true'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ modems:
+ NM-{}:
+ renderer: NetworkManager
+ match: {{}}
+ dhcp4: true
+ auto-config: true
+ networkmanager:
+ uuid: "{}"
+ name: "myid with spaces"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_existing_id(self):
+ self.generate_from_keyfile('''[connection]
+type=bridge
+interface-name=mybr
+uuid={}
+id=renamed netplan bridge
+
+[ipv4]
+method=auto'''.format(UUID), netdef_id='mybr')
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ bridges:
+ mybr:
+ renderer: NetworkManager
+ dhcp4: true
+ networkmanager:
+ uuid: "{}"
+ name: "renamed netplan bridge"
+'''.format(UUID)})
+
+ def test_keyfile_yaml_wifi_hotspot(self):
+ self.generate_from_keyfile('''[connection]
+id=Hotspot-1
+type=wifi
+uuid={}
+interface-name=wlan0
+autoconnect=false
+permissions=
+
+[ipv4]
+method=shared
+dns-search=
+
+[ipv6]
+method=ignore
+addr-gen-mode=1
+dns-search=
+
+[wifi]
+ssid=my-hotspot
+mode=ap
+mac-address-blacklist=
+
+[wifi-security]
+group=ccmp;
+key-mgmt=wpa-psk
+pairwise=ccmp;
+proto=rsn;
+psk=test1234
+
+[proxy]'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "wlan0"
+ access-points:
+ "my-hotspot":
+ auth:
+ key-management: "psk"
+ password: "test1234"
+ mode: "ap"
+ networkmanager:
+ uuid: "{}"
+ name: "Hotspot-1"
+ passthrough:
+ connection.autoconnect: "false"
+ connection.permissions: ""
+ ipv6.addr-gen-mode: "1"
+ wifi.mac-address-blacklist: ""
+ wifi-security.group: "ccmp;"
+ wifi-security.pairwise: "ccmp;"
+ wifi-security.proto: "rsn;"
+ proxy._: ""
+ networkmanager:
+ uuid: "{}"
+ name: "Hotspot-1"
+'''.format(UUID, UUID, UUID)})
+
+ def test_keyfile_ip4_linklocal_ip6_ignore(self):
+ self.generate_from_keyfile('''[connection]
+id=netplan-eth1
+type=ethernet
+interface-name=eth1
+uuid={}
+
+[ethernet]
+wake-on-lan=0
+
+[ipv4]
+method=link-local
+
+[ipv6]
+method=ignore
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ ethernets:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "eth1"
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-eth1"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_vlan(self):
+ self.generate_from_keyfile('''[connection]
+id=netplan-enblue
+type=vlan
+interface-name=enblue
+uuid={}
+
+[vlan]
+id=1
+parent=en1
+
+[ipv4]
+method=manual
+address1=1.2.3.4/24
+
+[ipv6]
+method=ignore
+'''.format(UUID), netdef_id='enblue', expect_fail=False, filename="some.keyfile")
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ vlans:
+ enblue:
+ renderer: NetworkManager
+ addresses:
+ - "1.2.3.4/24"
+ id: 1
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-enblue"
+ passthrough:
+ vlan.parent: "en1"
+'''.format(UUID)})
+
+ def test_keyfile_bridge(self):
+ self.generate_from_keyfile('''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+uuid={}
+
+[bridge]
+ageing-time=50
+priority=1000
+forward-delay=12
+hello-time=6
+max-age=24
+stp=false
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''.format(UUID), netdef_id='br0', expect_fail=False, filename="netplan-br0.nmconnection")
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ bridges:
+ br0:
+ renderer: NetworkManager
+ dhcp4: true
+ parameters:
+ ageing-time: "50"
+ forward-delay: "12"
+ hello-time: "6"
+ max-age: "24"
+ priority: 1000
+ stp: false
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-br0"
+'''.format(UUID)})
+
+ def test_keyfile_bridge_default_stp(self):
+ self.generate_from_keyfile('''[connection]
+id=netplan-br0
+type=bridge
+interface-name=br0
+uuid={}
+
+[bridge]
+hello-time=6
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''.format(UUID), netdef_id='br0')
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ bridges:
+ br0:
+ renderer: NetworkManager
+ dhcp4: true
+ parameters:
+ hello-time: "6"
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-br0"
+'''.format(UUID)})
+
+ def test_keyfile_bond(self):
+ self.generate_from_keyfile('''[connection]
+uuid={}
+id=netplan-bn0
+type=bond
+interface-name=bn0
+
+[bond]
+mode=802.3ad
+lacp_rate=10
+miimon=10
+min_links=10
+xmit_hash_policy=none
+ad_select=none
+all_slaves_active=1
+arp_interval=10
+arp_ip_target=10.10.10.10,20.20.20.20
+arp_validate=all
+arp_all_targets=all
+updelay=10
+downdelay=10
+fail_over_mac=none
+num_grat_arp=10
+num_unsol_na=10
+packets_per_slave=10
+primary_reselect=none
+resend_igmp=10
+lp_interval=10
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+'''.format(UUID), netdef_id='bn0')
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ bonds:
+ bn0:
+ renderer: NetworkManager
+ dhcp4: true
+ parameters:
+ mode: "802.3ad"
+ mii-monitor-interval: "10"
+ up-delay: "10"
+ down-delay: "10"
+ lacp-rate: "10"
+ transmit-hash-policy: "none"
+ ad-select: "none"
+ arp-validate: "all"
+ arp-all-targets: "all"
+ fail-over-mac-policy: "none"
+ primary-reselect-policy: "none"
+ learn-packet-interval: "10"
+ arp-interval: "10"
+ min-links: 10
+ all-slaves-active: true
+ gratuitous-arp: 10
+ packets-per-slave: 10
+ resend-igmp: 10
+ arp-ip-targets:
+ - 10.10.10.10
+ - 20.20.20.20
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-bn0"
+'''.format(UUID)})
+
+ def test_keyfile_customer_A1(self):
+ self.generate_from_keyfile('''[connection]
+id=netplan-wlan0-TESTSSID
+type=wifi
+interface-name=wlan0
+uuid={}
+
+[ipv4]
+method=auto
+
+[ipv6]
+method=ignore
+
+[wifi]
+ssid=TESTSSID
+mode=infrastructure
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=s0s3cr1t
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ wifis:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "wlan0"
+ dhcp4: true
+ access-points:
+ "TESTSSID":
+ auth:
+ key-management: "psk"
+ password: "s0s3cr1t"
+ networkmanager:
+ uuid: "ff9d6ebc-226d-4f82-a485-b7ff83b9607f"
+ name: "netplan-wlan0-TESTSSID"
+ networkmanager:
+ uuid: "{}"
+ name: "netplan-wlan0-TESTSSID"
+'''.format(UUID, UUID)})
+
+ def test_keyfile_customer_A2(self):
+ self.generate_from_keyfile('''[connection]
+id=gsm
+type=gsm
+uuid={}
+interface-name=cdc-wdm1
+
+[gsm]
+apn=internet
+
+[ipv4]
+method=auto
+address1=10.10.28.159/24
+address2=10.10.164.254/24
+address3=10.10.246.132/24
+dns=8.8.8.8;8.8.4.4;8.8.8.8;8.8.4.4;8.8.8.8;8.8.4.4;
+
+[ipv6]
+method=auto
+addr-gen-mode=1
+'''.format(UUID))
+ self.assert_netplan({UUID: '''network:
+ version: 2
+ modems:
+ NM-{}:
+ renderer: NetworkManager
+ match:
+ name: "cdc-wdm1"
+ nameservers:
+ addresses:
+ - 8.8.8.8
+ - 8.8.4.4
+ - 8.8.8.8
+ - 8.8.4.4
+ - 8.8.8.8
+ - 8.8.4.4
+ dhcp4: true
+ dhcp6: true
+ apn: "internet"
+ networkmanager:
+ uuid: "{}"
+ name: "gsm"
+ passthrough:
+ ipv4.address1: "10.10.28.159/24"
+ ipv4.address2: "10.10.164.254/24"
+ ipv4.address3: "10.10.246.132/24"
+ ipv6.addr-gen-mode: "1"
+'''.format(UUID, UUID)})
diff --git a/tests/test_cli_get_set.py b/tests/test_cli_get_set.py
new file mode 100644
index 0000000..7a1799b
--- /dev/null
+++ b/tests/test_cli_get_set.py
@@ -0,0 +1,386 @@
+#!/usr/bin/python3
+# Blackbox tests of netplan CLI. These are run during "make check" and don't
+# touch the system configuration at all.
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import sys
+import unittest
+import tempfile
+import io
+import shutil
+
+from contextlib import redirect_stdout
+from netplan.cli.core import Netplan
+
+
+def _call_cli(args):
+ old_sys_argv = sys.argv
+ sys.argv = [old_sys_argv[0]] + args
+ try:
+ f = io.StringIO()
+ with redirect_stdout(f):
+ Netplan().main()
+ return f.getvalue()
+ except Exception as e:
+ return e
+ finally:
+ sys.argv = old_sys_argv
+
+
+class TestSet(unittest.TestCase):
+ '''Test netplan set'''
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory(prefix='netplan_')
+ self.file = '70-netplan-set.yaml'
+ self.path = os.path.join(self.workdir.name, 'etc', 'netplan', self.file)
+ os.makedirs(os.path.join(self.workdir.name, 'etc', 'netplan'))
+
+ def tearDown(self):
+ shutil.rmtree(self.workdir.name)
+
+ def _set(self, args):
+ args.insert(0, 'set')
+ return _call_cli(args + ['--root-dir', self.workdir.name])
+
+ def test_set_scalar(self):
+ self._set(['ethernets.eth0.dhcp4=true'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth0:\n dhcp4: true', f.read())
+
+ def test_set_scalar2(self):
+ self._set(['ethernets.eth0.dhcp4="yes"'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth0:\n dhcp4: \'yes\'', f.read())
+
+ def test_set_global(self):
+ self._set([r'network={renderer: NetworkManager}'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('network:\n renderer: NetworkManager', f.read())
+
+ def test_set_sequence(self):
+ self._set(['ethernets.eth0.addresses=[1.2.3.4/24, \'5.6.7.8/24\']'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('''network:\n ethernets:\n eth0:
+ addresses:
+ - 1.2.3.4/24
+ - 5.6.7.8/24''', f.read())
+
+ def test_set_sequence2(self):
+ self._set(['ethernets.eth0.addresses=["1.2.3.4/24", 5.6.7.8/24]'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('''network:\n ethernets:\n eth0:
+ addresses:
+ - 1.2.3.4/24
+ - 5.6.7.8/24''', f.read())
+
+ def test_set_mapping(self):
+ self._set(['ethernets.eth0={addresses: [1.2.3.4/24], dhcp4: true}'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('''network:\n ethernets:\n eth0:
+ addresses:
+ - 1.2.3.4/24
+ dhcp4: true''', f.read())
+
+ def test_set_origin_hint(self):
+ self._set(['ethernets.eth0.dhcp4=true', '--origin-hint=99_snapd'])
+ p = os.path.join(self.workdir.name, 'etc', 'netplan', '99_snapd.yaml')
+ self.assertTrue(os.path.isfile(p))
+ with open(p, 'r') as f:
+ self.assertEquals('network:\n ethernets:\n eth0:\n dhcp4: true\n', f.read())
+
+ def test_set_empty_origin_hint(self):
+ err = self._set(['ethernets.eth0.dhcp4=true', '--origin-hint='])
+ self.assertIsInstance(err, Exception)
+ self.assertIn('Invalid/empty origin-hint', str(err))
+
+ def test_set_invalid(self):
+ err = self._set(['xxx.yyy=abc'])
+ self.assertIsInstance(err, Exception)
+ self.assertIn('unknown key \'xxx\'\n xxx:\n', str(err))
+ self.assertFalse(os.path.isfile(self.path))
+
+ def test_set_invalid_validation(self):
+ err = self._set(['ethernets.eth0.set-name=myif0'])
+ self.assertIsInstance(err, Exception)
+ self.assertIn('eth0: \'set-name:\' requires \'match:\' properties', str(err))
+ self.assertFalse(os.path.isfile(self.path))
+
+ def test_set_invalid_validation2(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ tunnels:
+ tun0:
+ mode: sit
+ local: 1.2.3.4
+ remote: 5.6.7.8''')
+ err = self._set(['tunnels.tun0.keys.input=12345'])
+ self.assertIsInstance(err, Exception)
+ self.assertIn('tun0: \'input-key\' is not required for this tunnel type', str(err))
+
+ def test_set_append(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3: {dhcp4: yes}''')
+ self._set(['ethernets.eth0.dhcp4=true'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n ethernets:\n', out)
+ self.assertIn(' ens3:\n dhcp4: true', out)
+ self.assertIn(' eth0:\n dhcp4: true', out)
+ self.assertIn(' version: 2', out)
+
+ def test_set_overwrite_eq(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ ethernets:
+ ens3: {dhcp4: "yes"}''')
+ self._set(['ethernets.ens3.dhcp4=yes'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n ethernets:\n', out)
+ self.assertIn(' ens3:\n dhcp4: true', out)
+
+ def test_set_overwrite(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ ethernets:
+ ens3: {dhcp4: "yes"}''')
+ self._set(['ethernets.ens3.dhcp4=true'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n ethernets:\n', out)
+ self.assertIn(' ens3:\n dhcp4: true', out)
+
+ def test_set_delete(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:\n version: 2\n renderer: NetworkManager
+ ethernets:
+ ens3: {dhcp4: yes, dhcp6: yes}
+ eth0: {addresses: [1.2.3.4/24]}''')
+ self._set(['ethernets.eth0.addresses=NULL'])
+ self._set(['ethernets.ens3.dhcp6=null'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n ethernets:\n', out)
+ self.assertIn(' version: 2', out)
+ self.assertIn(' ens3:\n dhcp4: true', out)
+ self.assertNotIn('dhcp6: true', out)
+ self.assertNotIn('addresses:', out)
+ self.assertNotIn('eth0:', out)
+
+ def test_set_delete_file(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ ethernets:
+ ens3: {dhcp4: yes}''')
+ self._set(['network.ethernets.ens3.dhcp4=NULL'])
+ # The file should be deleted if this was the last/only key left
+ self.assertFalse(os.path.isfile(self.path))
+
+ def test_set_delete_file_with_version(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3: {dhcp4: yes}''')
+ out = self._set(['network.ethernets.ens3=NULL'])
+ print(out, flush=True)
+ # The file should be deleted if only "network: {version: 2}" is left
+ self.assertFalse(os.path.isfile(self.path))
+
+ def test_set_invalid_delete(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:\n version: 2\n renderer: NetworkManager
+ ethernets:
+ eth0: {addresses: [1.2.3.4]}''')
+ err = self._set(['ethernets.eth0.addresses'])
+ self.assertIsInstance(err, Exception)
+ self.assertEquals('Invalid value specified', str(err))
+
+ def test_set_escaped_dot(self):
+ self._set([r'ethernets.eth0\.123.dhcp4=false'])
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth0.123:\n dhcp4: false', f.read())
+
+ def test_set_invalid_input(self):
+ err = self._set([r'ethernets.eth0={dhcp4:false}'])
+ self.assertIsInstance(err, Exception)
+ self.assertEquals('Invalid input: {\'network\': {\'ethernets\': {\'eth0\': {\'dhcp4:false\': None}}}}', str(err))
+
+ def test_set_override_existing_file(self):
+ override = os.path.join(self.workdir.name, 'etc', 'netplan', 'some-file.yaml')
+ with open(override, 'w') as f:
+ f.write(r'network: {ethernets: {eth0: {dhcp4: true}, eth1: {dhcp6: false}}}')
+ self._set([r'ethernets.eth0.dhcp4=false'])
+ self.assertFalse(os.path.isfile(self.path))
+ self.assertTrue(os.path.isfile(override))
+ with open(override, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n ethernets:\n eth0:\n dhcp4: false', out) # new
+ self.assertIn('eth1:\n dhcp6: false', out) # old
+
+ def test_set_override_existing_file_escaped_dot(self):
+ override = os.path.join(self.workdir.name, 'etc', 'netplan', 'some-file.yaml')
+ with open(override, 'w') as f:
+ f.write(r'network: {ethernets: {eth0.123: {dhcp4: true}}}')
+ self._set([r'ethernets.eth0\.123.dhcp4=false'])
+ self.assertFalse(os.path.isfile(self.path))
+ self.assertTrue(os.path.isfile(override))
+ with open(override, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth0.123:\n dhcp4: false', f.read())
+
+ def test_set_override_multiple_existing_files(self):
+ file1 = os.path.join(self.workdir.name, 'etc', 'netplan', 'eth0.yaml')
+ with open(file1, 'w') as f:
+ f.write(r'network: {ethernets: {eth0.1: {dhcp4: true}, eth0.2: {dhcp4: true}}}')
+ file2 = os.path.join(self.workdir.name, 'etc', 'netplan', 'eth1.yaml')
+ with open(file2, 'w') as f:
+ f.write(r'network: {ethernets: {eth1: {dhcp4: true}}}')
+ self._set([(r'network={renderer: NetworkManager, version: 2,'
+ r'ethernets:{'
+ r'eth1:{dhcp4: false},'
+ r'eth0.1:{dhcp4: false},'
+ r'eth0.2:{dhcp4: false}},'
+ r'bridges:{'
+ r'br99:{dhcp4: false}}}')])
+ self.assertTrue(os.path.isfile(file1))
+ with open(file1, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth0.1:\n dhcp4: false', f.read())
+ self.assertTrue(os.path.isfile(file2))
+ with open(file2, 'r') as f:
+ self.assertIn('network:\n ethernets:\n eth1:\n dhcp4: false', f.read())
+ self.assertTrue(os.path.isfile(self.path))
+ with open(self.path, 'r') as f:
+ out = f.read()
+ self.assertIn('network:\n bridges:\n br99:\n dhcp4: false', out)
+ self.assertIn(' version: 2', out)
+ self.assertIn(' renderer: NetworkManager', out)
+
+
+class TestGet(unittest.TestCase):
+ '''Test netplan get'''
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ self.file = '00-config.yaml'
+ self.path = os.path.join(self.workdir.name, 'etc', 'netplan', self.file)
+ os.makedirs(os.path.join(self.workdir.name, 'etc', 'netplan'))
+
+ def _get(self, args):
+ args.insert(0, 'get')
+ return _call_cli(args + ['--root-dir', self.workdir.name])
+
+ def test_get_scalar(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3: {dhcp4: yes}''')
+ out = self._get(['ethernets.ens3.dhcp4'])
+ self.assertIn('true', out)
+
+ def test_get_mapping(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3:
+ dhcp4: yes
+ addresses: [1.2.3.4/24, 5.6.7.8/24]''')
+ out = self._get(['ethernets'])
+ self.assertIn('''ens3:
+ addresses:
+ - 1.2.3.4/24
+ - 5.6.7.8/24
+ dhcp4: true''', out)
+
+ def test_get_modems(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ modems:
+ wwan0:
+ apn: internet
+ pin: 1234
+ dhcp4: yes
+ addresses: [1.2.3.4/24, 5.6.7.8/24]''')
+ out = self._get(['modems.wwan0'])
+ self.assertIn('''addresses:
+- 1.2.3.4/24
+- 5.6.7.8/24
+apn: internet
+dhcp4: true
+pin: 1234''', out)
+
+ def test_get_sequence(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3: {addresses: [1.2.3.4/24, 5.6.7.8/24]}''')
+ out = self._get(['network.ethernets.ens3.addresses'])
+ self.assertIn('- 1.2.3.4/24\n- 5.6.7.8/24', out)
+
+ def test_get_null(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ ens3: {dhcp4: yes}''')
+ out = self._get(['ethernets.eth0.dhcp4'])
+ self.assertEqual('null\n', out)
+
+ def test_get_escaped_dot(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ eth0.123: {dhcp4: yes}''')
+ out = self._get([r'ethernets.eth0\.123.dhcp4'])
+ self.assertEquals('true\n', out)
+
+ def test_get_all(self):
+ with open(self.path, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ eth0: {dhcp4: yes}''')
+ out = self._get([])
+ self.assertEquals('''network:
+ ethernets:
+ eth0:
+ dhcp4: true
+ version: 2\n''', out)
+
+ def test_get_network(self):
+ with open(self.path, 'w') as f:
+ f.write('network:\n version: 2\n renderer: NetworkManager')
+ out = self._get(['network'])
+ self.assertEquals('renderer: NetworkManager\nversion: 2\n', out)
diff --git a/tests/test_cli_units.py b/tests/test_cli_units.py
new file mode 100644
index 0000000..0814c18
--- /dev/null
+++ b/tests/test_cli_units.py
@@ -0,0 +1,41 @@
+#!/usr/bin/python3
+# Blackbox tests of netplan CLI. These are run during "make check" and don't
+# touch the system configuration at all.
+#
+# Copyright (C) 2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import unittest
+
+from netplan.cli.commands.apply import NetplanApply
+
+
+class TestCLI(unittest.TestCase):
+ '''Netplan CLI unittests'''
+
+ def test_is_composite_member(self):
+ res = NetplanApply.is_composite_member([{'br0': {'interfaces': ['eth0']}}], 'eth0')
+ self.assertTrue(res)
+
+ def test_is_composite_member_false(self):
+ res = NetplanApply.is_composite_member([
+ {'br0': {'interfaces': ['eth42']}},
+ {'bond0': {'interfaces': ['eth1']}}
+ ], 'eth0')
+ self.assertFalse(res)
+
+ def test_is_composite_member_with_renderer(self):
+ res = NetplanApply.is_composite_member([{'renderer': 'networkd', 'br0': {'interfaces': ['eth0']}}], 'eth0')
+ self.assertTrue(res)
diff --git a/tests/test_configmanager.py b/tests/test_configmanager.py
new file mode 100644
index 0000000..e910f4e
--- /dev/null
+++ b/tests/test_configmanager.py
@@ -0,0 +1,251 @@
+#!/usr/bin/python3
+# Validate ConfigManager methods
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import shutil
+import tempfile
+import unittest
+
+from netplan.configmanager import ConfigManager
+
+
+class TestConfigManager(unittest.TestCase):
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ self.configmanager = ConfigManager(prefix=self.workdir.name, extra_files={})
+ os.makedirs(os.path.join(self.workdir.name, "etc/netplan"))
+ os.makedirs(os.path.join(self.workdir.name, "run/systemd/network"))
+ os.makedirs(os.path.join(self.workdir.name, "run/NetworkManager/system-connections"))
+ with open(os.path.join(self.workdir.name, "newfile.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ ethernets:
+ ethtest:
+ dhcp4: yes
+''', file=fd)
+ with open(os.path.join(self.workdir.name, "newfile_merging.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ ethernets:
+ eth0:
+ dhcp6: on
+ ethbr1:
+ dhcp4: on
+''', file=fd)
+ with open(os.path.join(self.workdir.name, "newfile_emptydict.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ ethernets:
+ eth0: {}
+ bridges:
+ br666: {}
+''', file=fd)
+ with open(os.path.join(self.workdir.name, "ovs_merging.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ openvswitch:
+ ports: [[patchx, patcha], [patchy, patchb]]
+ bridges:
+ ovs0: {openvswitch: {}}
+''', file=fd)
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ openvswitch:
+ ports: [[patcha, patchb]]
+ other-config:
+ disable-in-band: true
+ ethernets:
+ eth0:
+ dhcp4: false
+ ethbr1:
+ dhcp4: false
+ ethbr2:
+ dhcp4: false
+ ethbond1:
+ dhcp4: false
+ ethbond2:
+ dhcp4: false
+ wifis:
+ wlan1:
+ access-points:
+ testAP: {}
+ modems:
+ wwan0:
+ apn: internet
+ pin: 1234
+ dhcp4: yes
+ addresses: [1.2.3.4/24, 5.6.7.8/24]
+ vlans:
+ vlan2:
+ id: 2
+ link: eth99
+ bridges:
+ br3:
+ interfaces: [ ethbr1 ]
+ br4:
+ interfaces: [ ethbr2 ]
+ parameters:
+ stp: on
+ bonds:
+ bond5:
+ interfaces: [ ethbond1 ]
+ bond6:
+ interfaces: [ ethbond2 ]
+ parameters:
+ mode: 802.3ad
+ tunnels:
+ he-ipv6:
+ mode: sit
+ remote: 2.2.2.2
+ local: 1.1.1.1
+ addresses:
+ - "2001:dead:beef::2/64"
+ gateway6: "2001:dead:beef::1"
+ nm-devices:
+ fallback:
+ renderer: NetworkManager
+ networkmanager:
+ passthrough:
+ connection.id: some-nm-id
+ connection.uuid: some-uuid
+''', file=fd)
+ with open(os.path.join(self.workdir.name, "run/systemd/network/01-pretend.network"), 'w') as fd:
+ print("pretend .network", file=fd)
+ with open(os.path.join(self.workdir.name, "run/NetworkManager/system-connections/pretend"), 'w') as fd:
+ print("pretend NM config", file=fd)
+
+ def test_parse(self):
+ self.configmanager.parse()
+ self.assertIn('eth0', self.configmanager.ethernets)
+ self.assertIn('bond6', self.configmanager.bonds)
+ self.assertIn('eth0', self.configmanager.physical_interfaces)
+ self.assertNotIn('bond7', self.configmanager.interfaces)
+ self.assertNotIn('bond6', self.configmanager.physical_interfaces)
+ self.assertNotIn('parameters', self.configmanager.bonds.get('bond5'))
+ self.assertIn('parameters', self.configmanager.bonds.get('bond6'))
+ self.assertIn('wwan0', self.configmanager.modems)
+ self.assertIn('wwan0', self.configmanager.physical_interfaces)
+ self.assertIn('apn', self.configmanager.modems.get('wwan0'))
+ self.assertIn('he-ipv6', self.configmanager.tunnels)
+ self.assertNotIn('he-ipv6', self.configmanager.physical_interfaces)
+ self.assertIn('remote', self.configmanager.tunnels.get('he-ipv6'))
+ self.assertIn('other-config', self.configmanager.openvswitch)
+ self.assertIn('ports', self.configmanager.openvswitch)
+ self.assertEquals(2, self.configmanager.version)
+ self.assertEquals('networkd', self.configmanager.renderer)
+ self.assertIn('fallback', self.configmanager.nm_devices)
+
+ def test_parse_merging(self):
+ self.configmanager.parse(extra_config=[os.path.join(self.workdir.name, "newfile_merging.yaml")])
+ self.assertIn('eth0', self.configmanager.ethernets)
+ self.assertIn('dhcp4', self.configmanager.ethernets['eth0'])
+ self.assertEquals(True, self.configmanager.ethernets['eth0'].get('dhcp6'))
+ self.assertEquals(True, self.configmanager.ethernets['ethbr1'].get('dhcp4'))
+
+ def test_parse_merging_ovs(self):
+ self.configmanager.parse(extra_config=[os.path.join(self.workdir.name, "ovs_merging.yaml")])
+ self.assertIn('eth0', self.configmanager.ethernets)
+ self.assertIn('dhcp4', self.configmanager.ethernets['eth0'])
+ self.assertIn('patchx', self.configmanager.ovs_ports)
+ self.assertIn('patchy', self.configmanager.ovs_ports)
+ self.assertIn('ovs0', self.configmanager.bridges)
+ self.assertEqual({}, self.configmanager.ovs_ports['patchx'].get('openvswitch'))
+ self.assertEqual({}, self.configmanager.ovs_ports['patchy'].get('openvswitch'))
+ self.assertEqual({}, self.configmanager.bridges['ovs0'].get('openvswitch'))
+
+ def test_parse_emptydict(self):
+ self.configmanager.parse(extra_config=[os.path.join(self.workdir.name, "newfile_emptydict.yaml")])
+ self.assertIn('br666', self.configmanager.bridges)
+ self.assertEquals(False, self.configmanager.ethernets['eth0'].get('dhcp4'))
+ self.assertEquals(False, self.configmanager.ethernets['ethbr1'].get('dhcp4'))
+
+ def test_parse_extra_config(self):
+ self.configmanager.parse(extra_config=[os.path.join(self.workdir.name, "newfile.yaml")])
+ self.assertIn('ethtest', self.configmanager.ethernets)
+ self.assertIn('bond6', self.configmanager.bonds)
+
+ def test_add(self):
+ self.configmanager.add({os.path.join(self.workdir.name, "newfile.yaml"):
+ os.path.join(self.workdir.name, "etc/netplan/newfile.yaml")})
+ self.assertIn(os.path.join(self.workdir.name, "newfile.yaml"),
+ self.configmanager.extra_files)
+ self.assertTrue(os.path.exists(os.path.join(self.workdir.name, "etc/netplan/newfile.yaml")))
+
+ def test_backup_missing_dirs(self):
+ backup_dir = self.configmanager.tempdir
+ shutil.rmtree(os.path.join(self.workdir.name, "run/systemd/network"))
+ self.configmanager.backup(backup_config_dir=False)
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "run/NetworkManager/system-connections/pretend")))
+ # no source dir means no backup as well
+ self.assertFalse(os.path.exists(os.path.join(backup_dir, "run/systemd/network/01-pretend.network")))
+ self.assertFalse(os.path.exists(os.path.join(backup_dir, "etc/netplan/test.yaml")))
+
+ def test_backup_without_config_file(self):
+ backup_dir = self.configmanager.tempdir
+ self.configmanager.backup(backup_config_dir=False)
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "run/NetworkManager/system-connections/pretend")))
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "run/systemd/network/01-pretend.network")))
+ self.assertFalse(os.path.exists(os.path.join(backup_dir, "etc/netplan/test.yaml")))
+
+ def test_backup_with_config_file(self):
+ backup_dir = self.configmanager.tempdir
+ self.configmanager.backup(backup_config_dir=True)
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "run/NetworkManager/system-connections/pretend")))
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "run/systemd/network/01-pretend.network")))
+ self.assertTrue(os.path.exists(os.path.join(backup_dir, "etc/netplan/test.yaml")))
+
+ def test_revert(self):
+ self.configmanager.backup()
+ with open(os.path.join(self.workdir.name, "run/systemd/network/01-pretend.network"), 'a+') as fd:
+ print("CHANGED", file=fd)
+ with open(os.path.join(self.workdir.name, "run/systemd/network/01-pretend.network"), 'r') as fd:
+ lines = fd.readlines()
+ self.assertIn("CHANGED\n", lines)
+ self.configmanager.revert()
+ with open(os.path.join(self.workdir.name, "run/systemd/network/01-pretend.network"), 'r') as fd:
+ lines = fd.readlines()
+ self.assertNotIn("CHANGED\n", lines)
+
+ def test_revert_extra_files(self):
+ self.configmanager.add({os.path.join(self.workdir.name, "newfile.yaml"):
+ os.path.join(self.workdir.name, "etc/netplan/newfile.yaml")})
+ self.assertIn(os.path.join(self.workdir.name, "newfile.yaml"),
+ self.configmanager.extra_files)
+ self.assertTrue(os.path.exists(os.path.join(self.workdir.name, "etc/netplan/newfile.yaml")))
+ self.configmanager.revert()
+ self.assertNotIn(os.path.join(self.workdir.name, "newfile.yaml"),
+ self.configmanager.extra_files)
+ self.assertFalse(os.path.exists(os.path.join(self.workdir.name, "etc/netplan/newfile.yaml")))
+
+ def test_cleanup(self):
+ backup_dir = self.configmanager.tempdir
+ self.assertTrue(os.path.exists(backup_dir))
+ self.configmanager.cleanup()
+ self.assertFalse(os.path.exists(backup_dir))
+
+ def test__copy_tree(self):
+ self.configmanager._copy_tree(os.path.join(self.workdir.name, "etc"),
+ os.path.join(self.workdir.name, "etc2"))
+ self.assertTrue(os.path.exists(os.path.join(self.workdir.name, "etc2/netplan/test.yaml")))
+
+ def test__copy_tree_missing_source(self):
+ with self.assertRaises(FileNotFoundError):
+ self.configmanager._copy_tree(os.path.join(self.workdir.name, "nonexistent"),
+ os.path.join(self.workdir.name, "nonexistent2"), missing_ok=False)
diff --git a/tests/test_libnetplan.py b/tests/test_libnetplan.py
new file mode 100644
index 0000000..b4136c8
--- /dev/null
+++ b/tests/test_libnetplan.py
@@ -0,0 +1,153 @@
+#!/usr/bin/python3
+# Blackbox tests of certain libnetplan functions. These are run during
+# "make check" and don't touch the system configuration at all.
+#
+# Copyright (C) 2020-2021 Canonical, Ltd.
+# Author: Lukas Märdian <slyon@ubuntu.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import shutil
+import ctypes
+import ctypes.util
+
+from generator.base import TestBase
+from parser.base import capture_stderr
+from tests.test_utils import MockCmd
+
+rootdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+exe_cli = os.path.join(rootdir, 'src', 'netplan.script')
+# Make sure we can import our development netplan.
+os.environ.update({'PYTHONPATH': '.'})
+
+lib = ctypes.CDLL(ctypes.util.find_library('netplan'))
+lib.netplan_get_id_from_nm_filename.restype = ctypes.c_char_p
+
+
+class TestLibnetplan(TestBase):
+ '''Test libnetplan functionality as used by the NetworkManager backend'''
+
+ def setUp(self):
+ super().setUp()
+ os.makedirs(self.confdir)
+
+ def tearDown(self):
+ shutil.rmtree(self.workdir.name)
+ super().tearDown()
+
+ def test_get_id_from_filename(self):
+ out = lib.netplan_get_id_from_nm_filename(
+ '/run/NetworkManager/system-connections/netplan-some-id.nmconnection'.encode(), None)
+ self.assertEqual(out, b'some-id')
+
+ def test_get_id_from_filename_rootdir(self):
+ out = lib.netplan_get_id_from_nm_filename(
+ '/some/rootdir/run/NetworkManager/system-connections/netplan-some-id.nmconnection'.encode(), None)
+ self.assertEqual(out, b'some-id')
+
+ def test_get_id_from_filename_wifi(self):
+ out = lib.netplan_get_id_from_nm_filename(
+ '/run/NetworkManager/system-connections/netplan-some-id-SOME-SSID.nmconnection'.encode(), 'SOME-SSID'.encode())
+ self.assertEqual(out, b'some-id')
+
+ def test_get_id_from_filename_wifi_invalid_suffix(self):
+ out = lib.netplan_get_id_from_nm_filename(
+ '/run/NetworkManager/system-connections/netplan-some-id-SOME-SSID'.encode(), 'SOME-SSID'.encode())
+ self.assertEqual(out, None)
+
+ def test_get_id_from_filename_invalid_prefix(self):
+ out = lib.netplan_get_id_from_nm_filename('INVALID/netplan-some-id.nmconnection'.encode(), None)
+ self.assertEqual(out, None)
+
+ def test_parse_keyfile_missing(self):
+ f = os.path.join(self.workdir.name, 'tmp/some.keyfile')
+ os.makedirs(os.path.dirname(f))
+ with capture_stderr() as outf:
+ self.assertFalse(lib.netplan_parse_keyfile(f.encode(), None))
+ with open(outf.name, 'r') as f:
+ self.assertIn('netplan: cannot load keyfile', f.read().strip())
+
+ def test_generate(self):
+ self.mock_netplan_cmd = MockCmd("netplan")
+ os.environ["TEST_NETPLAN_CMD"] = self.mock_netplan_cmd.path
+ self.assertTrue(lib.netplan_generate(self.workdir.name.encode()))
+ self.assertEquals(self.mock_netplan_cmd.calls(), [
+ ["netplan", "generate", "--root-dir", self.workdir.name],
+ ])
+
+ def test_delete_connection(self):
+ os.environ["TEST_NETPLAN_CMD"] = exe_cli
+ orig = os.path.join(self.confdir, 'some-filename.yaml')
+ with open(orig, 'w') as f:
+ f.write('''network:
+ ethernets:
+ some-netplan-id:
+ dhcp4: true''')
+ self.assertTrue(os.path.isfile(orig))
+ # Parse all YAML and delete 'some-netplan-id' connection file
+ self.assertTrue(lib.netplan_delete_connection('some-netplan-id'.encode(), self.workdir.name.encode()))
+ self.assertFalse(os.path.isfile(orig))
+
+ def test_delete_connection_id_not_found(self):
+ orig = os.path.join(self.confdir, 'some-filename.yaml')
+ with open(orig, 'w') as f:
+ f.write('''network:
+ ethernets:
+ some-netplan-id:
+ dhcp4: true''')
+ self.assertTrue(os.path.isfile(orig))
+ with capture_stderr() as outf:
+ self.assertFalse(lib.netplan_delete_connection('unknown-id'.encode(), self.workdir.name.encode()))
+ self.assertTrue(os.path.isfile(orig))
+ with open(outf.name, 'r') as f:
+ self.assertIn('netplan_delete_connection: Cannot delete unknown-id, does not exist.', f.read().strip())
+
+ def test_delete_connection_two_in_file(self):
+ os.environ["TEST_NETPLAN_CMD"] = exe_cli
+ orig = os.path.join(self.confdir, 'some-filename.yaml')
+ with open(orig, 'w') as f:
+ f.write('''network:
+ ethernets:
+ some-netplan-id:
+ dhcp4: true
+ other-id:
+ dhcp6: true''')
+ self.assertTrue(os.path.isfile(orig))
+ self.assertTrue(lib.netplan_delete_connection('some-netplan-id'.encode(), self.workdir.name.encode()))
+ self.assertTrue(os.path.isfile(orig))
+ # Verify the file still exists and still contains the other connection
+ with open(orig, 'r') as f:
+ self.assertEquals(f.read(), 'network:\n ethernets:\n other-id:\n dhcp6: true\n')
+
+ def test_write_netplan_conf(self):
+ netdef_id = 'some-netplan-id'
+ orig = os.path.join(self.confdir, 'some-filename.yaml')
+ generated = os.path.join(self.confdir, '10-netplan-{}.yaml'.format(netdef_id))
+ with open(orig, 'w') as f:
+ f.write('''network:
+ version: 2
+ ethernets:
+ some-netplan-id:
+ renderer: networkd
+ match:
+ name: "eth42"
+''')
+ # Parse YAML and and re-write the specified netdef ID into a new file
+ self.assertTrue(lib.netplan_parse_yaml(orig.encode(), None))
+ lib._write_netplan_conf(netdef_id.encode(), self.workdir.name.encode())
+ self.assertEqual(lib.netplan_clear_netdefs(), 1)
+ self.assertTrue(os.path.isfile(generated))
+ with open(orig, 'r') as f:
+ with open(generated, 'r') as new:
+ self.assertEquals(f.read(), new.read())
diff --git a/tests/test_ovs.py b/tests/test_ovs.py
new file mode 100644
index 0000000..393061d
--- /dev/null
+++ b/tests/test_ovs.py
@@ -0,0 +1,148 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import unittest
+
+from unittest.mock import patch, call
+from netplan.cli.ovs import OPENVSWITCH_OVS_VSCTL as OVS
+
+import netplan.cli.ovs as ovs
+
+
+class TestOVS(unittest.TestCase):
+
+ @patch('subprocess.check_call')
+ def test_clear_settings_tag(self, mock):
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/external-ids/key', 'value')
+ mock.assert_called_with([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/external-ids/key'])
+
+ @patch('subprocess.check_output')
+ @patch('subprocess.check_call')
+ def test_clear_global_ssl(self, mock, mock_out):
+ mock_out.return_value = '''
+Private key: /private/key.pem
+Certificate: /another/cert.pem
+CA Certificate: /some/ca-cert.pem
+Bootstrap: false'''
+ ovs.clear_setting('Open_vSwitch', '.', 'netplan/global/set-ssl', '/private/key.pem,/another/cert.pem,/some/ca-cert.pem')
+ mock_out.assert_called_once_with([OVS, 'get-ssl'], universal_newlines=True)
+ mock.assert_has_calls([
+ call([OVS, 'del-ssl']),
+ call([OVS, 'remove', 'Open_vSwitch', '.', 'external-ids', 'netplan/global/set-ssl'])
+ ])
+
+ @patch('subprocess.check_output')
+ @patch('subprocess.check_call')
+ def test_no_clear_global_ssl_different(self, mock, mock_out):
+ mock_out.return_value = '''
+Private key: /private/key.pem
+Certificate: /another/cert.pem
+CA Certificate: /some/ca-cert.pem
+Bootstrap: false'''
+ ovs.clear_setting('Open_vSwitch', '.', 'netplan/global/set-ssl', '/some/key.pem,/other/cert.pem,/some/cert.pem')
+ mock_out.assert_called_once_with([OVS, 'get-ssl'], universal_newlines=True)
+ mock.assert_has_calls([
+ call([OVS, 'remove', 'Open_vSwitch', '.', 'external-ids', 'netplan/global/set-ssl'])
+ ])
+
+ def test_clear_global_unknown(self):
+ with self.assertRaises(Exception):
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/global/set-something', 'INVALID')
+
+ @patch('subprocess.check_output')
+ @patch('subprocess.check_call')
+ def test_clear_global(self, mock, mock_out):
+ mock_out.return_value = 'tcp:127.0.0.1:1337\nunix:/some/socket'
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/global/set-controller', 'tcp:127.0.0.1:1337,unix:/some/socket')
+ mock_out.assert_called_once_with([OVS, 'get-controller', 'ovs0'], universal_newlines=True)
+ mock.assert_has_calls([
+ call([OVS, 'del-controller', 'ovs0']),
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/global/set-controller'])
+ ])
+
+ @patch('subprocess.check_output')
+ @patch('subprocess.check_call')
+ def test_no_clear_global_different(self, mock, mock_out):
+ mock_out.return_value = 'unix:/var/run/openvswitch/ovs0.mgmt'
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/global/set-controller', 'tcp:127.0.0.1:1337,unix:/some/socket')
+ mock_out.assert_called_once_with([OVS, 'get-controller', 'ovs0'], universal_newlines=True)
+ mock.assert_has_calls([
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/global/set-controller'])
+ ])
+
+ @patch('subprocess.check_call')
+ def test_clear_dict(self, mock):
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/other-config/key', 'value')
+ mock.assert_has_calls([
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'other-config', 'key', 'value']),
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/other-config/key'])
+ ])
+
+ @patch('subprocess.check_call')
+ def test_clear_col(self, mock):
+ ovs.clear_setting('Port', 'bond0', 'netplan/bond_mode', 'balance-tcp')
+ mock.assert_has_calls([
+ call([OVS, 'remove', 'Port', 'bond0', 'bond_mode', 'balance-tcp']),
+ call([OVS, 'remove', 'Port', 'bond0', 'external-ids', 'netplan/bond_mode'])
+ ])
+
+ @patch('subprocess.check_call')
+ def test_clear_col_default(self, mock):
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/rstp_enable', 'true')
+ mock.assert_has_calls([
+ call([OVS, 'set', 'Bridge', 'ovs0', 'rstp_enable=false']),
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/rstp_enable'])
+ ])
+
+ @patch('subprocess.check_call')
+ def test_clear_dict_colon(self, mock):
+ ovs.clear_setting('Bridge', 'ovs0', 'netplan/other-config/key', 'fa:16:3e:4b:19:3a')
+ mock.assert_has_calls([
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'other-config', 'key', r'fa\:16\:3e\:4b\:19\:3a']),
+ call([OVS, 'remove', 'Bridge', 'ovs0', 'external-ids', 'netplan/other-config/key'])
+ ])
+ mock.mock_calls
+
+ def test_is_ovs_interface(self):
+ interfaces = dict()
+ interfaces['ovs0'] = {'openvswitch': {'set-fail-mode': 'secure'}}
+ self.assertTrue(ovs.is_ovs_interface('ovs0', interfaces))
+
+ def test_is_ovs_interface_false(self):
+ interfaces = dict()
+ interfaces['br0'] = {'interfaces': ['eth0', 'eth1']}
+ interfaces['eth0'] = {}
+ interfaces['eth1'] = {}
+ self.assertFalse(ovs.is_ovs_interface('br0', interfaces))
+
+ def test_is_ovs_interface_recursive(self):
+ interfaces = dict()
+ interfaces['patchx'] = {'peer': 'patchy', 'openvswitch': {}}
+ interfaces['patchy'] = {'peer': 'patchx', 'openvswitch': {}}
+ interfaces['ovs0'] = {'interfaces': ['bond0']}
+ interfaces['bond0'] = {'interfaces': ['patchx', 'patchy']}
+ self.assertTrue(ovs.is_ovs_interface('ovs0', interfaces))
+
+ def test_is_ovs_interface_invalid_key(self):
+ interfaces = dict()
+ interfaces['ovs0'] = {'openvswitch': {'set-fail-mode': 'secure'}}
+ self.assertFalse(ovs.is_ovs_interface('gretap1', interfaces))
+
+ def test_is_ovs_interface_special_key(self):
+ interfaces = dict()
+ interfaces['renderer'] = 'NetworkManager'
+ self.assertFalse(ovs.is_ovs_interface('renderer', interfaces))
diff --git a/tests/test_sriov.py b/tests/test_sriov.py
new file mode 100644
index 0000000..4e8b834
--- /dev/null
+++ b/tests/test_sriov.py
@@ -0,0 +1,648 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import unittest
+import tempfile
+
+from subprocess import CalledProcessError
+from collections import defaultdict
+from unittest.mock import patch, mock_open, call
+
+import netplan.cli.sriov as sriov
+
+from netplan.configmanager import ConfigManager, ConfigurationError
+
+
+class MockSRIOVOpen():
+ def __init__(self):
+ # now this is a VERY ugly hack to make mock_open() better
+ self.read_queue = []
+ self.write_queue = []
+
+ def sriov_read():
+ action = self.read_queue.pop(0)
+ if isinstance(action, str):
+ return action
+ else:
+ raise action
+
+ def sriov_write(data):
+ if not self.write_queue:
+ return
+ action = self.write_queue.pop(0)
+ if isinstance(action, Exception):
+ raise action
+
+ self.open = mock_open()
+ self.open.return_value.read.side_effect = sriov_read
+ self.open.return_value.write.side_effect = sriov_write
+
+
+def mock_set_counts(interfaces, config_manager, vf_counts, active_vfs, active_pfs):
+ counts = {'enp1': 2, 'enp2': 1}
+ vfs = {'enp1s16f1': None, 'enp1s16f2': None, 'customvf1': None}
+ pfs = {'enp1': 'enp1', 'enpx': 'enp2'}
+ vf_counts.update(counts)
+ active_vfs.update(vfs)
+ active_pfs.update(pfs)
+
+
+class TestSRIOV(unittest.TestCase):
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ os.makedirs(os.path.join(self.workdir.name, 'etc/netplan'))
+ self.configmanager = ConfigManager(prefix=self.workdir.name, extra_files={})
+
+ def _prepare_sysfs_dir_structure(self):
+ # prepare a directory hierarchy for testing the matching
+ # this might look really scary, but that's how sysfs presents devices
+ # such as these
+ os.makedirs(os.path.join(self.workdir.name, 'sys/class/net'))
+
+ # first the VF
+ vf_iface_path = os.path.join(self.workdir.name, 'sys/devices/pci0000:00/0000:00:1f.6/net/enp2s16f1')
+ vf_dev_path = os.path.join(self.workdir.name, 'sys/devices/pci0000:00/0000:00:1f.6')
+ os.makedirs(vf_iface_path)
+ with open(os.path.join(vf_dev_path, 'vendor'), 'w') as f:
+ f.write('0x001f\n')
+ with open(os.path.join(vf_dev_path, 'device'), 'w') as f:
+ f.write('0xb33f\n')
+ os.symlink('../../devices/pci0000:00/0000:00:1f.6/net/enp2s16f1',
+ os.path.join(self.workdir.name, 'sys/class/net/enp2s16f1'))
+ os.symlink('../../../0000:00:1f.6', os.path.join(self.workdir.name, 'sys/class/net/enp2s16f1/device'))
+
+ # now the PF
+ os.path.join(self.workdir.name, 'sys/class/net/enp2')
+ pf_iface_path = os.path.join(self.workdir.name, 'sys/devices/pci0000:00/0000:00:1f.0/net/enp2')
+ pf_dev_path = os.path.join(self.workdir.name, 'sys/devices/pci0000:00/0000:00:1f.0')
+ os.makedirs(pf_iface_path)
+ with open(os.path.join(pf_dev_path, 'vendor'), 'w') as f:
+ f.write('0x001f\n')
+ with open(os.path.join(pf_dev_path, 'device'), 'w') as f:
+ f.write('0x1337\n')
+ os.symlink('../../devices/pci0000:00/0000:00:1f.0/net/enp2',
+ os.path.join(self.workdir.name, 'sys/class/net/enp2'))
+ os.symlink('../../../0000:00:1f.0', os.path.join(self.workdir.name, 'sys/class/net/enp2/device'))
+ # the PF additionally has device links to all the VFs defined for it
+ os.symlink('../../../0000:00:1f.4', os.path.join(pf_dev_path, 'virtfn1'))
+ os.symlink('../../../0000:00:1f.5', os.path.join(pf_dev_path, 'virtfn2'))
+ os.symlink('../../../0000:00:1f.6', os.path.join(pf_dev_path, 'virtfn3'))
+ os.symlink('../../../0000:00:1f.7', os.path.join(pf_dev_path, 'virtfn4'))
+
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_get_vf_count_and_functions(self, gim, gidn):
+ # we mock-out get_interface_driver_name and get_interface_macaddress
+ # to return useful values for the test
+ gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'enp3' else '00:00:00:00:00:00'
+ gidn.side_effect = lambda x: 'foo' if x == 'enp2' else 'bar'
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: networkd
+ enp1:
+ mtu: 9000
+ enp2:
+ match:
+ driver: foo
+ enp3:
+ match:
+ macaddress: 00:01:02:03:04:05
+ enpx:
+ match:
+ name: enp[4-5]
+ enp0:
+ mtu: 9000
+ enp8:
+ virtual-function-count: 7
+ enp9: {}
+ wlp6s0: {}
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+ enp1s16f2:
+ link: enp1
+ macaddress: 01:02:03:04:05:01
+ enp2s16f1:
+ link: enp2
+ enp2s16f2: {link: enp2}
+ enp3s16f1:
+ link: enp3
+ enpxs16f1:
+ match:
+ name: enp[4-5]s16f1
+ link: enpx
+ enp9s16f1:
+ link: enp9
+''', file=fd)
+ self.configmanager.parse()
+ interfaces = ['enp1', 'enp2', 'enp3', 'enp5', 'enp0', 'enp8']
+ vf_counts = defaultdict(int)
+ vfs = {}
+ pfs = {}
+
+ # call the function under test
+ sriov.get_vf_count_and_functions(interfaces, self.configmanager,
+ vf_counts, vfs, pfs)
+ # check if the right vf counts have been recorded in vf_counts
+ self.assertDictEqual(
+ vf_counts,
+ {'enp1': 2, 'enp2': 2, 'enp3': 1, 'enp5': 1, 'enp8': 7})
+ # also check if the vfs and pfs dictionaries got properly set
+ self.assertDictEqual(
+ vfs,
+ {'enp1s16f1': None, 'enp1s16f2': None, 'enp2s16f1': None,
+ 'enp2s16f2': None, 'enp3s16f1': None, 'enpxs16f1': None})
+ self.assertDictEqual(
+ pfs,
+ {'enp1': 'enp1', 'enp2': 'enp2', 'enp3': 'enp3',
+ 'enpx': 'enp5', 'enp8': 'enp8'})
+
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_get_vf_count_and_functions_set_name(self, gim, gidn):
+ # we mock-out get_interface_driver_name and get_interface_macaddress
+ # to return useful values for the test
+ gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'enp3' else '00:00:00:00:00:00'
+ gidn.side_effect = lambda x: 'foo' if x == 'enp1' else 'bar'
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: networkd
+ enp1:
+ match:
+ driver: foo
+ set-name: pf1
+ enp8:
+ match:
+ name: enp[3-8]
+ set-name: pf2
+ virtual-function-count: 7
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+''', file=fd)
+ self.configmanager.parse()
+ interfaces = ['pf1', 'enp8']
+ vf_counts = defaultdict(int)
+ vfs = {}
+ pfs = {}
+
+ # call the function under test
+ sriov.get_vf_count_and_functions(interfaces, self.configmanager,
+ vf_counts, vfs, pfs)
+ # check if the right vf counts have been recorded in vf_counts -
+ # we expect netplan to take into consideration the renamed interface
+ # names here
+ self.assertDictEqual(
+ vf_counts,
+ {'pf1': 1, 'enp8': 7})
+ # also check if the vfs and pfs dictionaries got properly set
+ self.assertDictEqual(
+ vfs,
+ {'enp1s16f1': None})
+ self.assertDictEqual(
+ pfs,
+ {'enp1': 'pf1', 'enp8': 'enp8'})
+
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_get_vf_count_and_functions_many_match(self, gim, gidn):
+ # we mock-out get_interface_driver_name and get_interface_macaddress
+ # to return useful values for the test
+ gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'enp3' else '00:00:00:00:00:00'
+ gidn.side_effect = lambda x: 'foo' if x == 'enp2' else 'bar'
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: networkd
+ enpx:
+ match:
+ name: enp*
+ mtu: 9000
+ enpxs16f1:
+ link: enpx
+''', file=fd)
+ self.configmanager.parse()
+ interfaces = ['enp1', 'wlp6s0', 'enp2', 'enp3']
+ vf_counts = defaultdict(int)
+ vfs = {}
+ pfs = {}
+
+ # call the function under test
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.get_vf_count_and_functions(interfaces, self.configmanager,
+ vf_counts, vfs, pfs)
+
+ self.assertIn('matched more than one interface for a PF device: enpx',
+ str(e.exception))
+
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_get_vf_count_and_functions_not_enough_explicit(self, gim, gidn):
+ # we mock-out get_interface_driver_name and get_interface_macaddress
+ # to return useful values for the test
+ gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'enp3' else '00:00:00:00:00:00'
+ gidn.side_effect = lambda x: 'foo' if x == 'enp2' else 'bar'
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ renderer: networkd
+ enp1:
+ virtual-function-count: 2
+ mtu: 9000
+ enp1s16f1:
+ link: enp1
+ enp1s16f2:
+ link: enp1
+ enp1s16f3:
+ link: enp1
+''', file=fd)
+ self.configmanager.parse()
+ interfaces = ['enp1', 'wlp6s0']
+ vf_counts = defaultdict(int)
+ vfs = {}
+ pfs = {}
+
+ # call the function under test
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.get_vf_count_and_functions(interfaces, self.configmanager,
+ vf_counts, vfs, pfs)
+
+ self.assertIn('more VFs allocated than the explicit size declared: 3 > 2',
+ str(e.exception))
+
+ def test_set_numvfs_for_pf(self):
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['8\n']
+
+ with patch('builtins.open', sriov_open.open):
+ ret = sriov.set_numvfs_for_pf('enp1', 2)
+
+ self.assertTrue(ret)
+ self.assertListEqual(sriov_open.open.call_args_list,
+ [call('/sys/class/net/enp1/device/sriov_totalvfs'),
+ call('/sys/class/net/enp1/device/sriov_numvfs', 'w')])
+ handle = sriov_open.open()
+ handle.write.assert_called_once_with('2')
+
+ def test_set_numvfs_for_pf_failsafe(self):
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['8\n']
+ sriov_open.write_queue = [IOError(16, 'Error'), None, None]
+
+ with patch('builtins.open', sriov_open.open):
+ ret = sriov.set_numvfs_for_pf('enp1', 2)
+
+ self.assertTrue(ret)
+ handle = sriov_open.open()
+ self.assertEqual(handle.write.call_count, 3)
+
+ def test_set_numvfs_for_pf_over_max(self):
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['8\n']
+
+ with patch('builtins.open', sriov_open.open):
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.set_numvfs_for_pf('enp1', 9)
+
+ self.assertIn('cannot allocate more VFs for PF enp1 than supported',
+ str(e.exception))
+
+ def test_set_numvfs_for_pf_over_theoretical_max(self):
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['1337\n']
+
+ with patch('builtins.open', sriov_open.open):
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.set_numvfs_for_pf('enp1', 345)
+
+ self.assertIn('cannot allocate more VFs for PF enp1 than the SR-IOV maximum',
+ str(e.exception))
+
+ def test_set_numvfs_for_pf_read_failed(self):
+ sriov_open = MockSRIOVOpen()
+ cases = (
+ [IOError],
+ ['not a number\n'],
+ )
+
+ with patch('builtins.open', sriov_open.open):
+ for case in cases:
+ sriov_open.read_queue = case
+ with self.assertRaises(RuntimeError):
+ sriov.set_numvfs_for_pf('enp1', 3)
+
+ def test_set_numvfs_for_pf_write_failed(self):
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['8\n']
+ sriov_open.write_queue = [IOError(16, 'Error'), IOError(16, 'Error')]
+
+ with patch('builtins.open', sriov_open.open):
+ with self.assertRaises(RuntimeError) as e:
+ sriov.set_numvfs_for_pf('enp1', 2)
+
+ self.assertIn('failed setting sriov_numvfs to 2 for enp1',
+ str(e.exception))
+
+ def test_perform_hardware_specific_quirks(self):
+ # for now we have no custom quirks defined, so we just
+ # check if the function succeeds
+ sriov_open = MockSRIOVOpen()
+ sriov_open.read_queue = ['0x001f\n', '0x1337\n']
+
+ with patch('builtins.open', sriov_open.open):
+ sriov.perform_hardware_specific_quirks('enp1')
+
+ # it's good enough if it did all the matching
+ self.assertListEqual(sriov_open.open.call_args_list,
+ [call('/sys/class/net/enp1/device/vendor'),
+ call('/sys/class/net/enp1/device/device'), ])
+
+ def test_perform_hardware_specific_quirks_failed(self):
+ sriov_open = MockSRIOVOpen()
+ cases = (
+ [IOError],
+ ['0x001f\n', IOError],
+ )
+
+ with patch('builtins.open', sriov_open.open):
+ for case in cases:
+ sriov_open.read_queue = case
+ with self.assertRaises(RuntimeError) as e:
+ sriov.perform_hardware_specific_quirks('enp1')
+
+ self.assertIn('could not determine vendor and device ID of enp1',
+ str(e.exception))
+
+ @patch('subprocess.check_call')
+ def test_apply_vlan_filter_for_vf(self, check_call):
+ self._prepare_sysfs_dir_structure()
+
+ sriov.apply_vlan_filter_for_vf('enp2', 'enp2s16f1', 'vlan10', 10, prefix=self.workdir.name)
+
+ self.assertEqual(check_call.call_count, 1)
+ self.assertListEqual(check_call.call_args[0][0],
+ ['ip', 'link', 'set', 'dev', 'enp2',
+ 'vf', '3', 'vlan', '10'])
+
+ @patch('subprocess.check_call')
+ def test_apply_vlan_filter_for_vf_failed_no_index(self, check_call):
+ self._prepare_sysfs_dir_structure()
+ # we remove the PF -> VF link, simulating a system error
+ os.unlink(os.path.join(self.workdir.name, 'sys/class/net/enp2/device/virtfn3'))
+
+ with self.assertRaises(RuntimeError) as e:
+ sriov.apply_vlan_filter_for_vf('enp2', 'enp2s16f1', 'vlan10', 10, prefix=self.workdir.name)
+
+ self.assertIn('could not determine the VF index for enp2s16f1 while configuring vlan vlan10',
+ str(e.exception))
+ self.assertEqual(check_call.call_count, 0)
+
+ @patch('subprocess.check_call')
+ def test_apply_vlan_filter_for_vf_failed_ip_link_set(self, check_call):
+ self._prepare_sysfs_dir_structure()
+ check_call.side_effect = CalledProcessError(-1, None)
+
+ with self.assertRaises(RuntimeError) as e:
+ sriov.apply_vlan_filter_for_vf('enp2', 'enp2s16f1', 'vlan10', 10, prefix=self.workdir.name)
+
+ self.assertIn('failed setting SR-IOV VLAN filter for vlan vlan10',
+ str(e.exception))
+
+ @patch('netifaces.interfaces')
+ @patch('netplan.cli.sriov.get_vf_count_and_functions')
+ @patch('netplan.cli.sriov.set_numvfs_for_pf')
+ @patch('netplan.cli.sriov.perform_hardware_specific_quirks')
+ @patch('netplan.cli.sriov.apply_vlan_filter_for_vf')
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_apply_sriov_config(self, gim, gidn, apply_vlan, quirks,
+ set_numvfs, get_counts, netifs):
+ # set up the environment
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp1:
+ mtu: 9000
+ enpx:
+ match:
+ name: enp[2-3]
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+ enp1s16f2:
+ link: enp1
+ customvf1:
+ match:
+ name: enp[2-3]s16f[1-4]
+ link: enpx
+ vlans:
+ vf1.15:
+ renderer: sriov
+ id: 15
+ link: customvf1
+ vf1.16:
+ renderer: sriov
+ id: 16
+ link: foobar
+''', file=fd)
+ # set up all the mock objects
+ netifs.return_value = ['enp1', 'enp2', 'enp5', 'wlp6s0',
+ 'enp1s16f1', 'enp1s16f2', 'enp2s16f1']
+ get_counts.side_effect = mock_set_counts
+ set_numvfs.side_effect = lambda pf, _: False if pf == 'enp2' else True
+ gidn.return_value = 'foodriver'
+ gim.return_value = '00:01:02:03:04:05'
+
+ # call method under test
+ sriov.apply_sriov_config(self.configmanager)
+
+ # make sure config_manager.parse() has been called
+ self.assertTrue(self.configmanager.config)
+ # check if the config got applied as expected
+ # we had 2 PFs, one having two VFs and the other only one
+ self.assertEqual(set_numvfs.call_count, 2)
+ self.assertListEqual(set_numvfs.call_args_list,
+ [call('enp1', 2),
+ call('enp2', 1)])
+ # one of the pfs already had sufficient VFs allocated, so only enp1
+ # changed the vf count and only that one should trigger quirks
+ quirks.assert_called_once_with('enp1')
+ # only one had a hardware vlan
+ apply_vlan.assert_called_once_with('enp2', 'enp2s16f1', 'vf1.15', 15)
+
+ @patch('netifaces.interfaces')
+ @patch('netplan.cli.sriov.get_vf_count_and_functions')
+ @patch('netplan.cli.sriov.set_numvfs_for_pf')
+ @patch('netplan.cli.sriov.perform_hardware_specific_quirks')
+ @patch('netplan.cli.sriov.apply_vlan_filter_for_vf')
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_apply_sriov_config_invalid_vlan(self, gim, gidn, apply_vlan, quirks,
+ set_numvfs, get_counts, netifs):
+ # set up the environment
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp1:
+ mtu: 9000
+ enpx:
+ match:
+ name: enp[2-3]
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+ enp1s16f2:
+ link: enp1
+ customvf1:
+ match:
+ name: enp[2-3]s16f[1-4]
+ link: enpx
+ vlans:
+ vf1.15:
+ renderer: sriov
+ link: customvf1
+''', file=fd)
+ # set up all the mock objects
+ netifs.return_value = ['enp1', 'enp2', 'enp5', 'wlp6s0',
+ 'enp1s16f1', 'enp1s16f2', 'enp2s16f1']
+ get_counts.side_effect = mock_set_counts
+ set_numvfs.side_effect = lambda pf, _: False if pf == 'enp2' else True
+ gidn.return_value = 'foodriver'
+ gim.return_value = '00:01:02:03:04:05'
+
+ # call method under test
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.apply_sriov_config(self.configmanager)
+
+ self.assertIn('no id property defined for SR-IOV vlan vf1.15',
+ str(e.exception))
+ self.assertEqual(apply_vlan.call_count, 0)
+
+ @patch('netifaces.interfaces')
+ @patch('netplan.cli.sriov.get_vf_count_and_functions')
+ @patch('netplan.cli.sriov.set_numvfs_for_pf')
+ @patch('netplan.cli.sriov.perform_hardware_specific_quirks')
+ @patch('netplan.cli.sriov.apply_vlan_filter_for_vf')
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_apply_sriov_config_too_many_vlans(self, gim, gidn, apply_vlan, quirks,
+ set_numvfs, get_counts, netifs):
+ # set up the environment
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp1:
+ mtu: 9000
+ enpx:
+ match:
+ name: enp[2-3]
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+ enp1s16f2:
+ link: enp1
+ customvf1:
+ match:
+ name: enp[2-3]s16f[1-4]
+ link: enpx
+ vlans:
+ vf1.15:
+ renderer: sriov
+ id: 15
+ link: customvf1
+ vf1.16:
+ renderer: sriov
+ id: 16
+ link: customvf1
+''', file=fd)
+ # set up all the mock objects
+ netifs.return_value = ['enp1', 'enp2', 'enp5', 'wlp6s0',
+ 'enp1s16f1', 'enp1s16f2', 'enp2s16f1']
+ get_counts.side_effect = mock_set_counts
+ set_numvfs.side_effect = lambda pf, _: False if pf == 'enp2' else True
+ gidn.return_value = 'foodriver'
+ gim.return_value = '00:01:02:03:04:05'
+
+ # call method under test
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.apply_sriov_config(self.configmanager)
+
+ self.assertIn('interface enp2s16f1 for netplan device customvf1 (vf1.16) already has an SR-IOV vlan defined',
+ str(e.exception))
+ self.assertEqual(apply_vlan.call_count, 1)
+
+ @patch('netifaces.interfaces')
+ @patch('netplan.cli.sriov.get_vf_count_and_functions')
+ @patch('netplan.cli.sriov.set_numvfs_for_pf')
+ @patch('netplan.cli.sriov.perform_hardware_specific_quirks')
+ @patch('netplan.cli.sriov.apply_vlan_filter_for_vf')
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_apply_sriov_config_many_match(self, gim, gidn, apply_vlan, quirks,
+ set_numvfs, get_counts, netifs):
+ # set up the environment
+ with open(os.path.join(self.workdir.name, "etc/netplan/test.yaml"), 'w') as fd:
+ print('''network:
+ version: 2
+ renderer: networkd
+ ethernets:
+ enp1:
+ mtu: 9000
+ enpx:
+ match:
+ name: enp[2-3]
+ enp1s16f1:
+ link: enp1
+ macaddress: 01:02:03:04:05:00
+ enp1s16f2:
+ link: enp1
+ customvf1:
+ match:
+ name: enp*s16f[1-4]
+ link: enpx
+''', file=fd)
+ # set up all the mock objects
+ netifs.return_value = ['enp1', 'enp2', 'enp5', 'wlp6s0',
+ 'enp1s16f1', 'enp1s16f2', 'enp2s16f1']
+ get_counts.side_effect = mock_set_counts
+ set_numvfs.side_effect = lambda pf, _: False if pf == 'enp2' else True
+ gidn.return_value = 'foodriver'
+ gim.return_value = '00:01:02:03:04:05'
+
+ # call method under test
+ with self.assertRaises(ConfigurationError) as e:
+ sriov.apply_sriov_config(self.configmanager)
+
+ self.assertIn('matched more than one interface for a VF device: customvf1',
+ str(e.exception))
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
new file mode 100644
index 0000000..680aa23
--- /dev/null
+++ b/tests/test_terminal.py
@@ -0,0 +1,84 @@
+#!/usr/bin/python3
+# Validate Terminal handling
+#
+# Copyright (C) 2018 Canonical, Ltd.
+# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import fcntl
+import sys
+import os
+import termios
+import unittest
+
+import netplan.terminal
+
+
+@unittest.skipUnless(sys.__stdin__.isatty(), "not supported when run from a script")
+class TestTerminal(unittest.TestCase):
+
+ def setUp(self):
+ self.terminal = netplan.terminal.Terminal(sys.stdin.fileno())
+
+ def test_echo(self):
+ self.terminal.disable_echo()
+ attrs = termios.tcgetattr(self.terminal.fd)
+ self.assertFalse(attrs[3] & termios.ECHO)
+ self.terminal.enable_echo()
+ attrs = termios.tcgetattr(self.terminal.fd)
+ self.assertTrue(attrs[3] & termios.ECHO)
+
+ def test_nonblocking_io(self):
+ orig_flags = flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertFalse(flags & os.O_NONBLOCK)
+ self.terminal.enable_nonblocking_io()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertTrue(flags & os.O_NONBLOCK)
+ self.assertNotEquals(flags, orig_flags)
+ self.terminal.disable_nonblocking_io()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertFalse(flags & os.O_NONBLOCK)
+ self.assertEquals(flags, orig_flags)
+
+ def test_save(self):
+ self.terminal.enable_nonblocking_io()
+ flags = self.terminal.orig_flags
+ self.terminal.save()
+ self.terminal.disable_nonblocking_io()
+ self.assertNotEquals(flags, self.terminal.orig_flags)
+ self.terminal.reset()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertEquals(flags, self.terminal.orig_flags)
+ self.terminal.disable_nonblocking_io()
+ self.terminal.save()
+
+ def test_save_and_restore_with_dict(self):
+ self.terminal.enable_nonblocking_io()
+ orig_settings = dict()
+ self.terminal.save(orig_settings)
+ self.terminal.disable_nonblocking_io()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertNotEquals(flags, orig_settings.get('flags'))
+ self.terminal.reset(orig_settings)
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertEquals(flags, orig_settings.get('flags'))
+ self.terminal.disable_nonblocking_io()
+
+ def test_reset(self):
+ self.terminal.enable_nonblocking_io()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertTrue(flags & os.O_NONBLOCK)
+ self.terminal.reset()
+ flags = fcntl.fcntl(self.terminal.fd, fcntl.F_GETFL)
+ self.assertFalse(flags & os.O_NONBLOCK)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..7b8fd6f
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,295 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2020 Canonical, Ltd.
+# Author: Lukas Märdian <lukas.maerdian@canonical.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; version 3.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import unittest
+import tempfile
+import glob
+import netifaces
+
+import netplan.cli.utils as utils
+from unittest.mock import patch
+
+
+DEVICES = ['eth0', 'eth1', 'ens3', 'ens4', 'br0']
+
+
+# Consider switching to something more standard, like MockProc
+class MockCmd:
+ """MockCmd will mock a given command name and capture all calls to it"""
+
+ def __init__(self, name):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.name = name
+ self.path = os.path.join(self._tmp.name, name)
+ self.call_log = os.path.join(self._tmp.name, "call.log")
+ with open(self.path, "w") as fp:
+ fp.write("""#!/bin/bash
+printf "%%s" "$(basename "$0")" >> %(log)s
+printf '\\0' >> %(log)s
+
+for arg in "$@"; do
+ printf "%%s" "$arg" >> %(log)s
+ printf '\\0' >> %(log)s
+done
+
+printf '\\0' >> %(log)s
+""" % {'log': self.call_log})
+ os.chmod(self.path, 0o755)
+
+ def calls(self):
+ """
+ calls() returns the calls to the given mock command in the form of
+ [ ["cmd", "call1-arg1"], ["cmd", "call2-arg1"], ... ]
+ """
+ with open(self.call_log) as fp:
+ b = fp.read()
+ calls = []
+ for raw_call in b.rstrip("\0\0").split("\0\0"):
+ call = raw_call.rstrip("\0")
+ calls.append(call.split("\0"))
+ return calls
+
+ def set_output(self, output):
+ with open(self.path, "a") as fp:
+ fp.write("cat << EOF\n%s\nEOF" % output)
+
+ def set_timeout(self, timeout_dsec=10):
+ with open(self.path, "a") as fp:
+ fp.write("""
+if [[ "$*" == *try* ]]
+then
+ ACTIVE=1
+ trap 'ACTIVE=0' SIGUSR1
+ trap 'ACTIVE=0' SIGINT
+ while (( $ACTIVE > 0 )) && (( $ACTIVE <= {} ))
+ do
+ ACTIVE=$(($ACTIVE+1))
+ sleep 0.1
+ done
+fi
+""".format(timeout_dsec))
+
+ def set_returncode(self, returncode):
+ with open(self.path, "a") as fp:
+ fp.write("exit %d" % returncode)
+
+
+class TestUtils(unittest.TestCase):
+
+ def setUp(self):
+ self.workdir = tempfile.TemporaryDirectory()
+ os.makedirs(os.path.join(self.workdir.name, 'etc/netplan'))
+ os.makedirs(os.path.join(self.workdir.name,
+ 'run/NetworkManager/system-connections'))
+
+ def _create_nm_keyfile(self, filename, ifname):
+ with open(os.path.join(self.workdir.name,
+ 'run/NetworkManager/system-connections/', filename), 'w') as f:
+ f.write('[connection]\n')
+ f.write('key=value\n')
+ f.write('interface-name=%s\n' % ifname)
+ f.write('key2=value2\n')
+
+ def test_nm_interfaces(self):
+ self._create_nm_keyfile('netplan-test.nmconnection', 'eth0')
+ self._create_nm_keyfile('netplan-test2.nmconnection', 'eth1')
+ ifaces = utils.nm_interfaces(glob.glob(os.path.join(self.workdir.name,
+ 'run/NetworkManager/system-connections/*.nmconnection')),
+ DEVICES)
+ self.assertTrue('eth0' in ifaces)
+ self.assertTrue('eth1' in ifaces)
+ self.assertTrue(len(ifaces) == 2)
+
+ def test_nm_interfaces_globbing(self):
+ self._create_nm_keyfile('netplan-test.nmconnection', 'eth?')
+ ifaces = utils.nm_interfaces(glob.glob(os.path.join(self.workdir.name,
+ 'run/NetworkManager/system-connections/*.nmconnection')),
+ DEVICES)
+ self.assertTrue('eth0' in ifaces)
+ self.assertTrue('eth1' in ifaces)
+ self.assertTrue(len(ifaces) == 2)
+
+ def test_nm_interfaces_globbing2(self):
+ self._create_nm_keyfile('netplan-test.nmconnection', 'e*')
+ ifaces = utils.nm_interfaces(glob.glob(os.path.join(self.workdir.name,
+ 'run/NetworkManager/system-connections/*.nmconnection')),
+ DEVICES)
+ self.assertTrue('eth0' in ifaces)
+ self.assertTrue('eth1' in ifaces)
+ self.assertTrue('ens3' in ifaces)
+ self.assertTrue('ens4' in ifaces)
+ self.assertTrue(len(ifaces) == 4)
+
+ def test_find_matching_iface_too_many(self):
+ # too many matches
+ iface = utils.find_matching_iface(DEVICES, {'name': 'e*'})
+ self.assertEqual(iface, None)
+
+ @patch('netplan.cli.utils.get_interface_macaddress')
+ def test_find_matching_iface(self, gim):
+ # we mock-out get_interface_macaddress to return useful values for the test
+ gim.side_effect = lambda x: '00:01:02:03:04:05' if x == 'eth1' else '00:00:00:00:00:00'
+
+ match = {'name': 'e*', 'macaddress': '00:01:02:03:04:05'}
+ iface = utils.find_matching_iface(DEVICES, match)
+ self.assertEqual(iface, 'eth1')
+
+ @patch('netplan.cli.utils.get_interface_driver_name')
+ def test_find_matching_iface_name_and_driver(self, gidn):
+ # we mock-out get_interface_driver_name to return useful values for the test
+ gidn.side_effect = lambda x: 'foo' if x == 'ens4' else 'bar'
+
+ match = {'name': 'ens?', 'driver': 'f*'}
+ iface = utils.find_matching_iface(DEVICES, match)
+ self.assertEqual(iface, 'ens4')
+
+ @patch('netifaces.ifaddresses')
+ def test_interface_macaddress(self, ifaddr):
+ ifaddr.side_effect = lambda _: {netifaces.AF_LINK: [{'addr': '00:01:02:03:04:05'}]}
+ self.assertEqual(utils.get_interface_macaddress('eth42'), '00:01:02:03:04:05')
+
+ @patch('netifaces.ifaddresses')
+ def test_interface_macaddress_empty(self, ifaddr):
+ ifaddr.side_effect = lambda _: {}
+ self.assertEqual(utils.get_interface_macaddress('eth42'), '')
+
+ def test_netplan_get_filename_by_id(self):
+ file_a = os.path.join(self.workdir.name, 'etc/netplan/a.yaml')
+ file_b = os.path.join(self.workdir.name, 'etc/netplan/b.yaml')
+ with open(file_a, 'w') as f:
+ f.write('network:\n ethernets:\n id_a:\n dhcp4: true')
+ with open(file_b, 'w') as f:
+ f.write('network:\n ethernets:\n id_b:\n dhcp4: true\n id_a:\n dhcp4: true')
+ # netdef:b can only be found in b.yaml
+ basename = os.path.basename(utils.netplan_get_filename_by_id('id_b', self.workdir.name))
+ self.assertEqual(basename, 'b.yaml')
+ # netdef:a is defined in a.yaml, overriden by b.yaml
+ basename = os.path.basename(utils.netplan_get_filename_by_id('id_a', self.workdir.name))
+ self.assertEqual(basename, 'b.yaml')
+
+ def test_netplan_get_filename_by_id_no_files(self):
+ self.assertIsNone(utils.netplan_get_filename_by_id('some-id', self.workdir.name))
+
+ def test_netplan_get_filename_by_id_invalid(self):
+ file = os.path.join(self.workdir.name, 'etc/netplan/a.yaml')
+ with open(file, 'w') as f:
+ f.write('''network:
+ tunnels:
+ id_a:
+ mode: sit
+ local: 0.0.0.0
+ remote: 0.0.0.0
+ key: 0.0.0.0''')
+ self.assertIsNone(utils.netplan_get_filename_by_id('some-id', self.workdir.name))
+
+ def test_systemctl(self):
+ self.mock_systemctl = MockCmd('systemctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_systemctl.path) + os.pathsep + path_env
+ utils.systemctl('start', ['service1', 'service2'])
+ self.assertEquals(self.mock_systemctl.calls(), [['systemctl', 'start', '--no-block', 'service1', 'service2']])
+
+ def test_networkd_interfaces(self):
+ self.mock_networkctl = MockCmd('networkctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_networkctl.path) + os.pathsep + path_env
+ self.mock_networkctl.set_output('''
+ 1 lo loopback carrier unmanaged
+ 2 ens3 ether routable configured
+ 3 wlan0 wlan routable configuring
+174 wwan0 wwan off linger''')
+ res = utils.networkd_interfaces()
+ self.assertEquals(self.mock_networkctl.calls(), [['networkctl', '--no-pager', '--no-legend']])
+ self.assertIn('wlan0', res)
+ self.assertIn('ens3', res)
+
+ def test_networkctl_reconfigure(self):
+ self.mock_networkctl = MockCmd('networkctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_networkctl.path) + os.pathsep + path_env
+ utils.networkctl_reconfigure(['eth0', 'eth1'])
+ self.assertEquals(self.mock_networkctl.calls(), [
+ ['networkctl', 'reload'],
+ ['networkctl', 'reconfigure', 'eth0', 'eth1']
+ ])
+
+ def test_is_nm_snap_enabled(self):
+ self.mock_cmd = MockCmd('systemctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ self.assertTrue(utils.is_nm_snap_enabled())
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', '--quiet', 'is-enabled', 'snap.network-manager.networkmanager.service']
+ ])
+
+ def test_is_nm_snap_enabled_false(self):
+ self.mock_cmd = MockCmd('systemctl')
+ self.mock_cmd.set_returncode(1)
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ self.assertFalse(utils.is_nm_snap_enabled())
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', '--quiet', 'is-enabled', 'snap.network-manager.networkmanager.service']
+ ])
+
+ def test_systemctl_network_manager(self):
+ self.mock_cmd = MockCmd('systemctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ utils.systemctl_network_manager('start')
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', '--quiet', 'is-enabled', 'snap.network-manager.networkmanager.service'],
+ ['systemctl', 'start', '--no-block', 'snap.network-manager.networkmanager.service']
+ ])
+
+ def test_systemctl_is_active(self):
+ self.mock_cmd = MockCmd('systemctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ self.assertTrue(utils.systemctl_is_active('some.service'))
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', '--quiet', 'is-active', 'some.service']
+ ])
+
+ def test_systemctl_is_active_false(self):
+ self.mock_cmd = MockCmd('systemctl')
+ self.mock_cmd.set_returncode(1)
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ self.assertFalse(utils.systemctl_is_active('some.service'))
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', '--quiet', 'is-active', 'some.service']
+ ])
+
+ def test_systemctl_daemon_reload(self):
+ self.mock_cmd = MockCmd('systemctl')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ utils.systemctl_daemon_reload()
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['systemctl', 'daemon-reload']
+ ])
+
+ def test_ip_addr_flush(self):
+ self.mock_cmd = MockCmd('ip')
+ path_env = os.environ['PATH']
+ os.environ['PATH'] = os.path.dirname(self.mock_cmd.path) + os.pathsep + path_env
+ utils.ip_addr_flush('eth42')
+ self.assertEquals(self.mock_cmd.calls(), [
+ ['ip', 'addr', 'flush', 'eth42']
+ ])
diff --git a/tests/validate_docs.sh b/tests/validate_docs.sh
new file mode 100755
index 0000000..d5ee07a
--- /dev/null
+++ b/tests/validate_docs.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+# find everything that looks like
+# {"driver", YAML_SCALAR_NODE,...,
+# extract the thing in quotes.
+
+# sanity check: make sure none have disappeared, as might happen from a reformat.
+count=$(sed -n 's/[ ]\+{"\([a-z0-9-]\+\)", YAML_[A-Z]\+_NODE.*/\1/p' src/parse.c | sort | wc -l)
+# 144 is based on 0.99+da6f776 definitions, and should be updated periodically.
+if [ $count -lt 144 ]; then
+ echo "ERROR: fewer YAML keys defined in src/parse.c than expected!"
+ echo " Has the file been reformatted or refactored? If so, modify"
+ echo " validate_docs.sh appropriately."
+ exit 1
+fi
+
+# iterate through the keys
+for term in $(sed -n 's/[ ]\+{"\([a-z0-9-]\+\)", YAML_[A-Z]\+_NODE.*/\1/p' src/parse.c | sort | uniq); do
+ # it can be documented in the following ways.
+ # 1. "Properties for device type ``blah:``
+ if egrep "## Properties for device type \`\`$term:\`\`" doc/netplan.md > /dev/null; then
+ continue
+ fi
+
+ # 2. "[blah, ]``blah``[, ``blah2``]: (scalar|bool|...)
+ if egrep "\`\`$term\`\`.*\((scalar|bool|mapping|sequence of scalars|sequence of mappings|sequence of sequence of scalars)" doc/netplan.md > /dev/null; then
+ continue
+ fi
+
+ # 3. we give a pass to network and version
+ if [[ $term = "network" ]] || [[ $term = "version" ]]; then
+ continue
+ fi
+
+ # 4. search doesn't get a full description but it's good enough
+ if [[ $term = "search" ]]; then
+ continue
+ fi
+
+ # 5. gratuit_i_ous arp gets a special note
+ if [[ $term = "gratuitious-arp" ]]; then
+ continue
+ fi
+
+ echo ERROR: The key "$term" is defined in the parser but not documented.
+ exit 1
+done
+echo "validate_docs: OK"