diff options
author | Lukas Märdian <luk@slyon.de> | 2021-10-20 13:22:07 +0200 |
---|---|---|
committer | Lukas Märdian <luk@slyon.de> | 2021-10-20 13:22:07 +0200 |
commit | 8d1727ff671179f971d6ad00d029356947c930b1 (patch) | |
tree | 1443e50d328d4009113a7bdcdadde6f5e9f6f0d4 |
Import netplan.io_0.103.orig.tar.gz
[dgit import orig netplan.io_0.103.orig.tar.gz]
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. @@ -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). + @@ -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" |