summary refs log tree commit diff
path: root/contrib/scripts
diff options
context:
space:
mode:
authorMichael Biebl <biebl@debian.org>2024-05-05 00:07:30 +0200
committerMichael Biebl <biebl@debian.org>2024-05-05 00:07:30 +0200
commit34bb501be08aa2b313d88e67d6e0a7e0a3f9cfa6 (patch)
tree4e6220877828be4c6f261de09ec0cb2d80e32389 /contrib/scripts
parentbba2e4b4de668db525cbfdfc35292e5a0b51671a (diff)
New upstream version 1.47.90 upstream/1.47.90
Diffstat (limited to 'contrib/scripts')
-rwxr-xr-xcontrib/scripts/NM-log85
-rwxr-xr-xcontrib/scripts/anonymize-logs.py193
-rwxr-xr-xcontrib/scripts/btmodem.pl291
-rwxr-xr-xcontrib/scripts/checkpatch-feature-branch.sh61
-rwxr-xr-xcontrib/scripts/checkpatch-git-post-commit-hook23
-rwxr-xr-xcontrib/scripts/checkpatch.pl319
-rwxr-xr-xcontrib/scripts/code-style-git-post-commit-hook21
-rwxr-xr-xcontrib/scripts/find-backports417
-rwxr-xr-xcontrib/scripts/git-backport-merge58
-rwxr-xr-xcontrib/scripts/git-subtree-reimport.sh63
-rwxr-xr-xcontrib/scripts/modemu.pl299
-rwxr-xr-xcontrib/scripts/nm-ci-patch-gtkdoc.sh40
-rwxr-xr-xcontrib/scripts/nm-ci-run.sh299
-rwxr-xr-xcontrib/scripts/nm-code-format-container.sh46
-rwxr-xr-xcontrib/scripts/nm-code-format.sh222
-rwxr-xr-xcontrib/scripts/nm-copr-build-nm-git-bundle.sh95
-rwxr-xr-xcontrib/scripts/nm-copr-build.sh101
-rwxr-xr-xcontrib/scripts/nm-import-openconnect261
-rwxr-xr-xcontrib/scripts/nm-import-openvpn543
-rwxr-xr-xcontrib/scripts/nm-import-vpnc416
-rwxr-xr-xcontrib/scripts/nm-python-black-format.sh100
-rwxr-xr-xcontrib/scripts/nm-setup-git.sh134
-rwxr-xr-xcontrib/scripts/test-create-many-device-setup.sh136
-rwxr-xr-xcontrib/scripts/test-macsec102
-rwxr-xr-xcontrib/scripts/test-ppp.sh108
-rwxr-xr-xcontrib/scripts/test-prefix-delegation.sh151
26 files changed, 4584 insertions, 0 deletions
diff --git a/contrib/scripts/NM-log b/contrib/scripts/NM-log
new file mode 100755
index 00000000..85faea86
--- /dev/null
+++ b/contrib/scripts/NM-log
@@ -0,0 +1,85 @@
+#!/bin/bash
+
+# Util to pretty-print logfile of NetworkManager
+#
+# Unless setting NM_LOG_NO_COLOR it will colorize the output.
+# Suppress coloring with:
+# $ NM_LOG_NO_COLOR=1 NM-log ...
+#
+# If called without arguments, it either reads from stdin (if not
+# connected to a terminal) or it shows the journal content.
+#
+# If called with first argument "j", it always shows the journal content.
+#
+# You can pass multiple filenames.
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+    NM_not_sourced=1
+else
+    unset NM_not_sourced
+fi
+
+NM-show-journal() {
+    local since="$(systemctl show NetworkManager | sed -n 's/^ExecMainStartTimestamp=\(.*\) [A-Z0-9]\+$/\1/p')"
+
+    if [[ "$since" == "" ]]; then
+       echo "error detecting NM. Is it running?"
+       systemctl status NetworkManager
+    else
+       journalctl -o short-precise --since "$since" -b 0 -u NetworkManager "$@"
+    fi
+}
+
+NM-colorize() {
+    if [[ "$NM_LOG_NO_COLOR" == "" ]]; then
+        # poor man's coloring using grep.
+        # TODO: do it somehow better (and more efficient).
+        sed 's/\r$//' | \
+        GREP_COLOR='01;31' grep -a --color=always '^\|^\(.* \)\?<\(warn> \|error>\) \[[0-9.]*\]' | \
+        GREP_COLOR='01;33' grep -a --color=always '^\|^\(.* \)\?<info>  \[[0-9.]*\]\( .*\<is starting\>.*$\)\?' | \
+        GREP_COLOR='01;37' grep -a --color=always '^\|\<platform:\( (.*)\)\? signal: .*$' | \
+        GREP_COLOR='01;34' grep -a --color=always '^\|\<platform\(-linux\)\?:\( (.*)\)\? link: \(add\|adding\|change\|setting\|deleting\|enslaving to master\|releasing \([0-9]\+ \)\?from master\)\>\|\<platform: routing-rule: \(adding or updating:\|delete \)\|\<platform:\( (.*)\)\? address: \(deleting\|adding or updating\) IPv. address:\? \|\<platform:\( (.*)\)\? \(route\|ip4-route\|ip6-route\|qdisc\|tfilter\): \([a-z]\+\|adding or updating\|new\[0x[0-9A-Za-z]*\]\) \|\<platform-linux: sysctl: setting ' | \
+        GREP_COLOR='01;35' grep -a --color=always '^\|\<audit: .*$' | \
+        GREP_COLOR='01;32' grep -a --color=always '^\|\<device (.*): state change: ' |
+        if [[ "$NM_LOG_GREP" != "" ]]; then
+            GREP_COLOR='01;36' grep -a --color=always "^\\|$NM_LOG_GREP"
+        else
+            /bin/cat -
+        fi
+    else
+        /bin/cat -
+    fi
+}
+
+NM-log() {
+    local NM_LOG_GREP=
+
+    while [[ $# -gt 0 ]]; do
+        if [[ "$1" == "-h" ]]; then
+            shift
+            NM_LOG_GREP="${NM_LOG_GREP+$NM_LOG_GREP\\|}\\<$1\\>"
+            shift
+        else
+            break
+        fi
+    done
+
+    (
+        if [ "$1" == "j" ]; then
+            shift
+            NM-show-journal "$@"
+        elif [ "$#" -eq 0 -a -t 0 ]; then
+            NM-show-journal
+        else
+            a="${1--}"
+            shift
+            /usr/bin/less -f "$a" "$@"
+        fi
+    ) | \
+        NM_LOG_GREP="$NM_LOG_GREP" NM-colorize | \
+        LESS=FRSXM less -f -R --shift=5
+}
+
+if [[ "$NM_not_sourced" != "" ]]; then
+    NM-log "$@"
+fi
diff --git a/contrib/scripts/anonymize-logs.py b/contrib/scripts/anonymize-logs.py
new file mode 100755
index 00000000..36b82ed1
--- /dev/null
+++ b/contrib/scripts/anonymize-logs.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+
+from textwrap import wrap
+import subprocess
+import ipaddress
+import argparse
+import os
+import re
+
+
+domains = []
+
+hosts_sub = {}
+host_next = 0
+
+macs_sub = {}
+mac_next = 0
+
+ips_sub = {}
+ip4_next = ipaddress.IPv4Address("0.0.0.0")
+ip6_next = ipaddress.IPv6Address("ffff::")
+
+
+def main(args):
+    must_autoreplace_hostnames = not args.show_hostnames
+    must_replace_hostnames = must_autoreplace_hostnames or args.domain or args.hostname
+
+    init_hostnames_and_domains_sub(args)
+
+    with open(args.log_file) as f:
+        for line in (line.strip() for line in f):
+            if must_replace_hostnames:
+                line = replace_hostnames(line, must_autoreplace_hostnames)
+            if not args.show_macs:
+                line = replace_macs(line)
+            if not args.show_public_ips or args.hide_private_ips:
+                line = replace_ips(line, args.show_public_ips, args.hide_private_ips)
+
+            print(line)
+
+
+def init_hostnames_and_domains_sub(args):
+    global domains
+
+    if not args.show_hostnames:
+        domains.extend(["com", "org", "net", "gov", "es", "it"])
+
+        r = subprocess.run("hostname", capture_output=True)
+        if r.returncode == 0:
+            own_hostname = r.stdout.decode().strip()
+            add_host_sub(own_hostname, ".self")
+
+    # domains and hostname passed explicitly are replaced even with --show-hostnames
+    domains.extend(d.strip(". ") for d in args.domain)
+    domains = "|".join(domains)
+
+    for hostname in args.hostname:
+        add_host_sub(hostname)
+
+
+def add_host_sub(hostname: str, suffix: str = ""):
+    global hosts_sub
+    global host_next
+
+    # if it's a domain-like hostname (i.e example.com) adds .ext at the end
+    if suffix == "" and re.search(r"\.({})$".format(domains), hostname):
+        suffix = ".ext"
+
+    if hostname not in hosts_sub:
+        hosts_sub[hostname] = "hostname{}{}".format(host_next, suffix)
+        host_next += 1
+
+
+def replace_hostnames(line: str, autodetect_from_logs: bool) -> str:
+    global hosts_sub
+
+    # look for known log messages that show hostnames
+    if autodetect_from_logs:
+        match = re.search(r"get-hostname: \"(.*)\"", line)
+        if match:
+            add_host_sub(match.group(1))
+
+        match = re.search(r"set hostname to \"(.*)\"", line)
+        if match:
+            add_host_sub(match.group(1))
+
+        match = re.search(
+            r"hostname changed from (\(none\)|\".*\") to (\(none\)|\".*\")", line
+        )
+        if match:
+            if match.group(1) != "(none)":
+                add_host_sub(match.group(1).strip('"'))
+            if match.group(2) != "(none)":
+                add_host_sub(match.group(2).strip('"'))
+
+    # look for domain-like strings
+    if domains:
+        match = re.search(r"[\w\-\.]+?\.(" + domains + r")\b", line)
+        if match:
+            add_host_sub(match.group(0))
+
+    for orig, repl in hosts_sub.items():
+        line = line.replace(orig, repl)
+
+    return line
+
+
+def replace_macs(line: str) -> str:
+    global macs_sub
+    global mac_next
+
+    macs = re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", line)
+
+    for mac in macs:
+        if mac not in macs_sub:
+            macs_sub[mac] = ":".join(wrap("{:012x}".format(mac_next), width=2))
+            mac_next += 1
+
+        line = line.replace(mac, macs_sub[mac])
+
+    return line
+
+
+def replace_ips(line: str, show_public: bool, hide_private: bool) -> str:
+    global ips_sub
+    global ip4_next
+    global ip6_next
+
+    ips4 = re.findall(r"(?:[0-9]{1,3}\.){3}[0-9]{1,3}", line)
+    ips6 = re.findall(r"(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}", line)
+
+    for addr_str in ips4 + ips6:
+        try:
+            addr = ipaddress.ip_address(addr_str)
+        except:  # not IP
+            continue
+
+        if (addr.is_private and not hide_private) or (addr.is_global and show_public):
+            continue
+
+        if addr.exploded not in ips_sub:
+            if type(addr) is ipaddress.IPv4Address:
+                ips_sub[addr.exploded] = str(ip4_next).replace("0.", "IP4.", 1)
+                ip4_next += 1
+            else:
+                ips_sub[addr.exploded] = str(ip6_next).replace("ffff:", "IPv6:", 1)
+                ip6_next += 1
+
+        line = line.replace(addr_str, ips_sub[addr.exploded])
+
+    return line
+
+
+if __name__ == "__main__":
+    args_parser = argparse.ArgumentParser(
+        prog=os.path.basename(__file__),
+        description="""Anonymize some data from NetworkManager logs.
+
+Note that it only covers some common stuff like MAC and IP addresses or
+hostnames.  Do not trust it and manually review that the log doesn't contain
+sensitive data before sharing it.
+
+Changing IP address can make that problems related to routing are impossible to
+analyze. Because of that, private IPs which are normally not sensitive are not
+hidden by default, and if the problem is related to routing you might need to
+use the --show-public-ips option""",
+        epilog="Options of the type --show-* disable masking that type of data.",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    args_parser.add_argument("-H", "--show-hostnames", action="store_true")
+    args_parser.add_argument("-m", "--show-macs", action="store_true")
+    args_parser.add_argument("-g", "--show-public-ips", action="store_true")
+    args_parser.add_argument("-p", "--hide-private-ips", action="store_true")
+    args_parser.add_argument(
+        "-d",
+        "--domain",
+        action="append",
+        default=[],
+        help='additional domains to hide, like ".xyz", can be passed more than once',
+    )
+    args_parser.add_argument(
+        "-n",
+        "--hostname",
+        action="append",
+        default=[],
+        help="additional hostnames to hide, can be passed more than once",
+    )
+    args_parser.add_argument(
+        "log_file", nargs="?", default="/dev/stdin", help="Log file (by default, stdin)"
+    )
+
+    args = args_parser.parse_args()
+    main(args)
diff --git a/contrib/scripts/btmodem.pl b/contrib/scripts/btmodem.pl
new file mode 100755
index 00000000..feaa32e0
--- /dev/null
+++ b/contrib/scripts/btmodem.pl
@@ -0,0 +1,291 @@
+#!/usr/bin/env perl
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+# Copyright (C) 2019 Red Hat, Inc.
+
+# $ perldoc btmodem.pl if you'd like to read the manual, poor you:
+
+=head1 NAME
+
+btmodem.pl - emulate a bluetooth DUN modem
+
+=head1 SYNOPSIS
+
+btmodem.pl [<hci>] [-- <pppd> ...]
+
+=head1 DESCRIPTION
+
+B<btmodem.pl> registers a Bluetooth DUN profile with Bluez, accepts incoming
+connections and pretends there's modem there.
+
+It answers a basic subset of AT commands, sufficient making ModemManager
+recognize it as a 3GPP capable modem registered to a network.
+
+Upon receiving the dial (ATD) command, it spawns C<pppd> so that
+NetworkManager can establish a connection.
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<< <hci> >>
+
+Create a service on this particular HCI.
+
+Defaults to I<hci0>.
+
+=item B<< <pppd> >>
+
+Specifies extra arguments to be prepended before C<pppd> to the default
+set of I<nodetach notty local logfd 2 nopersist>.
+
+Defaults to I<pppd noauth dump debug 172.31.82.1:172.31.82.2>.
+
+=back
+
+=cut
+
+use strict;
+use warnings;
+
+use IO::Handle;
+use Net::DBus;
+use Net::DBus::Reactor;
+
+# Parse command line arguments
+my $hci_name;
+my @pppd = qw/pppd noauth dump debug 172.31.82.1:172.31.82.2/;
+while (@ARGV) {
+	$_ = shift @ARGV;
+	if ($_ eq '--') {
+		@pppd = @ARGV;
+		last;
+	} else {
+		die "Extra argument: '$_'" if $hci_name;
+		$hci_name = $_;
+	}
+};
+$hci_name ||= 'hci0';
+
+sub modemu
+{
+	my $fh = shift;
+
+	while (<$fh>) {
+		chomp;
+
+		if (/^AT$/ or /^ATE0$/ or /^ATV1$/ or /^AT\+CMEE=1$/ or /^ATX4$/ or /^AT&C1$/ or /^ATZ$/) {
+			# Standard Hayes commands that are basically used to
+			# ensure the modem is in a known state. Accept them all.
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+CPIN\?$/) {
+			# PIN unlocked. Required.
+			print $fh "\r\n";
+			print $fh "+CPIN:READY\r\n";
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+COPS=0$/) {
+			# Select access technology (we just accept 0=automatic)
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+CGREG\?$/) {
+			# 3GPP Registration status.
+			print $fh "\r\n";
+			print $fh "+CGREG: 0,1\r\n";
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+CGDCONT=\?$/) {
+			# Get supported PDP contexts
+			print $fh "\r\n";
+			print $fh "+CGDCONT: (1-10),(\"IP\"),,,(0-1),(0-1)\r\n";
+			print $fh "+CGDCONT: (1-10),(\"IPV6\"),,,(0-1),(0-1)\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+CGACT=0,1$/) {
+			# Activate a PDP context
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^AT\+CGDCONT=1,"(.*)","(.*)"$/) {
+			# Set PDP context. We accept any.
+			print $fh "\r\n";
+			print $fh "OK\r\n";
+
+		} elsif (/^ATD/) {
+			print $fh "\r\n";
+			print $fh "CONNECT 28800000\r\n";
+
+			my $ppp = fork;
+			die "Can't fork: $!" unless defined $ppp;
+			if ($ppp == 0) {
+				close STDIN;
+				close STDOUT;
+				open STDIN, '<&', $fh or die "Can't dup pty to a pppd stdin: $!";
+				open STDOUT, '>&', $fh or die "Can't dup pty to a pppd stdout: $!";
+				close $fh;
+				exec @pppd, qw/nodetach notty local logfd 2 nopersist/;
+				die "Can't exec pppd: $!";
+			}
+			waitpid $ppp, 0;
+		} else {
+			print $fh "\r\n";
+			print $fh "ERROR\r\n";
+		}
+	}
+}
+
+my $bus = Net::DBus->system;
+
+$bus->get_connection->register_object_path("/", sub {
+	my $bus = shift;
+	my $call = shift;
+
+	# We only support the NewConnection call
+	next unless $call->get_type eq &Net::DBus::Binding::Message::MESSAGE_TYPE_METHOD_CALL;
+	if (   $call->get_interface ne 'org.bluez.Profile1'
+	    or $call->get_path ne '/'
+	    or $call->get_member ne 'NewConnection'
+	    or $call->get_signature ne 'oha{sv}') {
+
+	       $bus->send ($bus->make_error_message (
+			replyto => $call,
+			name => ' org.freedesktop.DBus.Error.Failed',
+			description => "Forgive me caller for I don't know what to do"));
+		next;
+	}
+
+	my ($path, $fd, $args) = $call->get_args_list;
+	open (my $fh, "+>&=", $fd) or die $!;
+
+	my $pid = fork;
+	die unless defined $pid;
+
+	if ($pid == 0) {
+		# This allows us to use buffered read for lines from ModemManager
+		# despite not ending with \n
+		IO::Handle->input_record_separator ("\r");
+		$fh->autoflush (1);
+		$fh->blocking (1);
+		modemu ($fh);
+		exit 0;
+		die;
+	}
+
+	$bus->send ($bus->make_method_return_message ($call))
+		unless $call->get_no_reply;
+});
+
+my $bluez = $bus->get_service ('org.bluez');
+my $profile_manager = $bluez->get_object ('/org/bluez', 'org.bluez.ProfileManager1');
+
+$profile_manager->RegisterProfile('/', '00001103-0000-1000-8000-00805f9b34fb', {});
+
+Net::DBus::Reactor->main->run;
+
+=head1 SETTING UP BLUETOOTH
+
+In order for this script useful, you need to have two Bluetooth interfaces
+paired together. It's somewhat easier if you've got two machines to test.
+
+The pairing can be done withing the C<bluetoothctl> shell. Launch it after
+you started C<btmodem.pl>, so that the right profile UUIDs are discovered
+by the client. These commands come in handy:
+
+=over
+
+=item [bluetooth]# B<default-agent>
+
+This makes C<bluetoothctl> ask for pairing PIN in the shell session. That is
+useful if you're ssh-ing into a machine instead of using a desktop shell with
+its own agent. Run this on both machines.
+
+=item [bluetooth]# B<discoverable on>
+
+Broadcast the server service. You don't need to run this on the client.
+
+=item [bluetooth]# B<scan on>
+
+Turn on discovery of the devices. You need to don't run this on the server.
+
+After you've turned the discovery on, wait for a minute or so for your
+server to get discovered.
+
+=item [bluetooth]# B<devices>
+
+List the known devices, both those who've been discovered and those that have
+been paired with.
+
+=item [bluetooth]# B<pair 00:AA:01:00:00:23>
+
+Initiate the pairing. Run it from the machine that has scanning enabled.
+Assumes your server is C<00:AA:01:00:00:23> -- check your real address with the
+C<devices> command.
+
+After a short while, you should see the pairing confirmation prompt on both machines.
+
+=item [bluetooth]# B<trust 00:AA:01:00:00:24>
+
+Allow incoming connections from C<00:AA:01:00:00:24>. Run this on the server.
+
+=item B<nmcli c add type bluetooth ifname '*' gsm.apn internet bluetooth.type dun bluetooth.bdaddr 00:AA:01:00:00:23>
+
+If everything went right, you can now connect.
+
+=back
+
+=head1 EXAMPLES
+
+=over
+
+=item B<btmodem.pl>
+
+Just emulate a DUN modem on I<hci0>, with the default PPP arguments.
+
+=item B<btmodem.pl hci666>
+
+Same as above, just on the I<hci666> interface.
+
+=item B<btmodem.pl -- unshare --net pppd 172.31.82.1:172.31.82.2>
+
+Avoid polluting the namespace with the modem end of PPP connection.
+
+=item B<btmodem.pl -- pppd 10.0.0.1:10.0.0.2>
+
+Override the C<pppd> parameters: no debug logging and different set of
+addresses.
+
+=item B<btmodem.pl mymodem -- pppd 10.0.0.1:10.0.0.2>
+
+Same as above, with a modem name different from default.
+
+=back
+
+=head1 BUGS
+
+Haha. You tell me.
+
+=head1 SEE ALSO
+
+L<ModemManager(8)>, L<pppd(8)>, C<modemu.pl>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2019 Lubomir Rintel
+
+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 2 of the License, or
+(at your option) any later version.
+
+=head1 AUTHOR
+
+Lubomir Rintel C<lkundrak@v3.sk>
+
+Like, it's me who wrote it, but if you're running it it's your problem.
+
+=cut
diff --git a/contrib/scripts/checkpatch-feature-branch.sh b/contrib/scripts/checkpatch-feature-branch.sh
new file mode 100755
index 00000000..d6f72b20
--- /dev/null
+++ b/contrib/scripts/checkpatch-feature-branch.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+
+die() {
+    printf "%s\n" "$@"
+    exit 1
+}
+
+HEAD="${1:-HEAD}"
+
+BASE_DIR="$(dirname "$0")"
+
+if printf '%s' "$HEAD" | grep -q '\.\.'; then
+    # Check the explicitly specified range from the argument.
+    REFS=( $(git log --reverse --format='%H' "$HEAD") ) || die "not a valid range (HEAD is $HEAD)"
+else
+    BASE_REF="refs/remotes/origin"
+    NM_UPSTREAM_REMOTE=
+
+    if [ "$NM_CHECKPATCH_FETCH_UPSTREAM" == 1 ]; then
+        NM_UPSTREAM_REMOTE="nm-upstream-$(date '+%Y%m%d-%H%M%S')-$RANDOM"
+        git remote add "$NM_UPSTREAM_REMOTE" https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git
+        BASE_REF="refs/remotes/$NM_UPSTREAM_REMOTE"
+        git fetch origin "$(git rev-parse "$HEAD")" --no-tags --unshallow
+        git fetch "$NM_UPSTREAM_REMOTE" \
+            --no-tags \
+            "refs/heads/main:$BASE_REF/main" \
+            "refs/heads/nm-*:$BASE_REF/nm-*" \
+            || die "failure to fetch from https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git"
+    fi
+
+    # the argument is only a single ref (or the default "HEAD").
+    # Find all commits that branch off one of the stable branches or main
+    # and lead to $HEAD. These are the commits of the feature branch.
+
+    RANGES=( $(git show-ref | sed 's#^\(.*\) '"$BASE_REF/"'\(main\|nm-1-[0-9]\+\)$#\1..'"$HEAD"'#p' -n) )
+
+    [ "${#RANGES[@]}" != 0 ] || die "cannot detect git-ranges (HEAD is $(git rev-parse "$HEAD"))"
+
+    REFS=( $(git log --reverse --format='%H' "${RANGES[@]}") )
+
+    if [ "${#REFS[@]}" == 0 ] ; then
+        # no refs detected. This means, $HEAD is already on main (or one of the
+        # stable nm-1-* branches. Just check the patch itself.
+        REFS=( "$HEAD" )
+    fi
+
+    if [ -n "$NM_UPSTREAM_REMOTE" ]; then
+        git remote remove "$NM_UPSTREAM_REMOTE"
+    fi
+fi
+
+SUCCESS=0
+for H in "${REFS[@]}"; do
+    export NM_CHECKPATCH_HEADER=$'\n'">>> VALIDATE \"$(git log --oneline -n1 "$H")\""
+    git format-patch -U65535 --stdout -1 "$H" | "$BASE_DIR/checkpatch.pl"
+    if [ $? != 0 ]; then
+        SUCCESS=1
+    fi
+done
+
+exit $SUCCESS
diff --git a/contrib/scripts/checkpatch-git-post-commit-hook b/contrib/scripts/checkpatch-git-post-commit-hook
new file mode 100755
index 00000000..96479107
--- /dev/null
+++ b/contrib/scripts/checkpatch-git-post-commit-hook
@@ -0,0 +1,23 @@
+#!/bin/sh
+
+# contrib/scripts/checkpatch-git-post-commit-hook:
+#   Call this script via ".git/hooks/post-commit"
+
+DISABLED=${NM_HOOK_DISABLED:0}
+
+if [ "$DISABLED" == 1 ]; then
+    echo "COMMIT HOOK DISABLED"
+    exit 0
+fi
+
+FILE=contrib/scripts/checkpatch-feature-branch.sh
+if [ -x "$FILE" ]; then
+    "$FILE"
+    exit 0
+fi
+
+FILE=contrib/scripts/checkpatch.pl
+if [ -x "$FILE" ]; then
+    git format-patch -U65535 --stdout -1 | "$FILE"
+    exit 0
+fi
diff --git a/contrib/scripts/checkpatch.pl b/contrib/scripts/checkpatch.pl
new file mode 100755
index 00000000..c24db35e
--- /dev/null
+++ b/contrib/scripts/checkpatch.pl
@@ -0,0 +1,319 @@
+#!/usr/bin/perl -n
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright (C) 2018,2021 Red Hat, Inc.
+#
+
+# $ perldoc checkpatch.pl for eye-pleasing view of the manual:
+
+=head1 NAME
+
+checkpatch.pl - check for common mistakes
+
+=head1 SYNOPSIS
+
+checkpatch.pl [<file> ...]
+
+=head1 DESCRIPTION
+
+B<checkpatch.pl> checks source files or patches for common mistakes.
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<< <file> >>
+
+A C source file or an unified diff.
+
+=back
+
+=cut
+
+use strict;
+use warnings;
+
+chomp;
+
+our $is_patch;
+our $is_file;
+our $is_commit_message;
+
+our $seen_error;
+our $line;		# Current line
+our $check_line;	# Complain if errors are found on this line
+
+our @functions_seen;
+our $type;
+our $filename;
+our $line_no;
+our $indent;
+our $check_is_todo;
+our $expect_spdx;
+our $subdir;
+
+sub new_hunk
+{
+	$type = undef;
+	$indent = undef;
+}
+
+sub new_file
+{
+	$expect_spdx = 0;
+	$check_is_todo = 1;
+	$filename = $subdir // '';
+	$filename .= shift;
+	@functions_seen = ();
+}
+
+my $header = $ENV{'NM_CHECKPATCH_HEADER'};
+
+sub complain
+{
+	my $message = shift;
+	my $plain_message = shift;
+
+	return unless $check_line;
+
+	if (defined($header)) {
+		warn "$header\n";
+		undef $header;
+	}
+
+	if ($plain_message) {
+		warn "$message\n";
+	} else {
+		warn "$filename:$line_no: $message:\n";
+		warn "> $line\n\n";
+	}
+	$seen_error = 1;
+}
+
+sub check_commit
+{
+	my $commit = shift;
+	my $required = shift;
+	my $commit_id;
+	my $commit_message;
+
+	if ($commit =~ /^([0-9a-f]{5,})\b/) {
+		$commit_id = $1;
+	} else {
+		return unless $required;
+	}
+
+	if ($commit_id and not system 'git rev-parse --git-dir >/dev/null 2>/dev/null') {
+		$commit_message = `git log --abbrev=12 --pretty=format:"%h ('%s')" -1 "$commit_id" 2>/dev/null`;
+		complain "Commit '$commit_id' does not seem to exist" unless $commit_message;
+	}
+
+	$commit_message //= "<12 hex digits> ('<commit subject>')";
+	complain "Refer to the commit id properly: $commit_message" unless $commit =~ /^[0-9a-f]{12} \('/;
+}
+
+if ($is_patch) {
+	# This is a line of an unified diff
+	if (/^@@.*\+(\d+)/) {
+		$line_no = $1 - 1;
+		new_hunk;
+		next;
+	}
+	if (/^\+\+\+ (b\/)?(.*)/) {
+		new_file ($2);
+		next;
+	}
+	s/^([ \+])(.*)/$2/ or next;
+	$line_no++;
+	$check_line = $1 eq '+';
+	$line = $2;
+} elsif ($is_file) {
+	$line_no = $.;
+	$. = 0 if eof;
+	# This is a line from full C file
+	$check_line = 1;
+	$line = $_;
+} elsif ($is_commit_message) {
+	$line_no++;
+	$filename = '(commit message)';
+	$check_line = 1;
+	$line = $_;
+	/^---$/ and $is_commit_message = 0;
+	/^(Reverts|Fixes): *(.*)/ and check_commit ($2, 1);
+	/This reverts commit/ and next;
+	/cherry picked from/ and next;
+	/^git-subtree-dir: (.*)/ and $subdir = "$1/";
+	/\bcommit (.*)/ and check_commit ($1, 0);
+	next;
+} else {
+	# We don't handle these yet
+	/^diff --cc/ and exit 0;
+	$filename = '';
+	$line_no = 1;
+	# We don't know if we're dealing with a patch or a C file yet
+	$is_commit_message = 1 if /^From \S/;
+	$is_file = 1 if /^#/;
+	$is_patch = 1 if /^---/;
+	next;
+}
+
+if ($is_file and $filename ne $ARGV) {
+	new_file ($ARGV);
+	new_hunk;
+}
+
+if ($filename !~ /\.[ch]$/) {
+	if ($check_is_todo) {
+		complain("Resolve todo list \"$filename\" first\n", 1) if $filename =~ /^TODO.txt$/;
+		$check_is_todo = 0;
+	}
+	next;
+}
+
+next if $filename =~ /\/nm-[^\/]+-enum-types\.[ch]$/;
+next if $filename =~ /\b(shared|src)\/systemd\//
+	and not $filename =~ /\/sd-adapt\//
+	and not $filename =~ /\/nm-/;
+next if $filename =~ /\/(n-acd|c-list|c-siphash|n-dhcp4)\//;
+
+$expect_spdx = 1 if $line_no == 1;
+$expect_spdx = 0 if $line =~ /SPDX-License-Identifier/;
+complain ('Missing a SPDX-License-Identifier') if $line_no == 2 and $expect_spdx;
+
+complain ('Tabs are only allowed at the beginning of a line') if $line =~ /[^\t]\t/;
+complain ('Trailing whitespace') if $line =~ /[ \t]$/;
+complain ('Don\'t use glib typedefs for char/short/int/long/float/double') if $line =~ /\bg(char|short|int|long|float|double)\b/;
+complain ("Don't use \"$1 $2\" instead of \"$2 $1\"") if $line =~ /\b(char|short|int|long) +(unsigned|signed)\b/;
+complain ("Don't use \"unsigned int\" but just use \"unsigned\"") if $line =~ /\b(unsigned) +(int)\b/;
+complain ("Please use LGPL-2.1-or-later SPDX tag for new files") if $is_patch and $line =~ /SPDX-License-Identifier/ and not /LGPL-2.1-or-later/;
+complain ("Use a SPDX-License-Identifier instead of Licensing boilerplate") if $is_patch and $line =~ /under the terms of/;
+complain ("Don't use space inside elvis operator ?:") if $line =~ /\?[\t ]+:/;
+complain ("Don't add Emacs editor formatting hints to source files") if $line_no == 1 and $line =~ /-\*-.+-\*-/;
+complain ("XXX marker are reserved for development while work-in-progress. Use TODO or FIXME comment instead?") if $line =~ /\bXXX\b/;
+complain ("This gtk-doc annotation looks wrong") if $line =~ /\*.*\( *(transfer-(none|container|full)|allow none) *\) *(:|\()/;
+complain ("The gtk-doc annotation (allow-none) is deprecated. Use either (nullable) and/or (optional). See https://gi.readthedocs.io/en/latest/annotations/giannotations.html#deprecated-gobject-introspection-annotations") if $line =~ /\*.*\( *(allow-none) *\) *(:|\()/;
+complain ("Prefer nm_assert() or g_return*() to g_assert*()") if $line =~ /g_assert/ and (not $filename =~ /\/tests\//) and (not $filename =~ /\/nm-test-/);
+complain ("Use gs_free_error with GError variables") if $line =~ /\bgs_free\b +GError *\*/;
+complain ("Initialize GError variables to NULL, if you pass them on") if $line =~ /\bGError +\*([a-z0-9_]+);/;
+complain ("Don't use strcmp/g_strcmp0 unless you need to sort. Consider nm_streq()/nm_streq0(),NM_IN_STRSET() for testing equality") if $line =~ /\b(strcmp|g_strcmp0)\b/;
+complain ("Don't use API that uses the numeric source id. Instead, use GSource and API like nm_g_idle_add(), nm_g_idle_add_source(), nm_clear_g_source_inst(), etc.") if $line =~ /\b(g_idle_add|g_idle_add_full|g_timeout_add|g_timeout_add_seconds|g_source_remove|nm_clear_g_source)\b/;
+complain ("Prefer g_snprintf() over snprintf() (for consistency)") if $line =~ /\b(snprintf)\b/;
+complain ("Prefer nm_str_hash()/nm_direct_hash() over g_str_hash()/g_direct_hash(). Those use siphash24") if $line =~ /\b(g_str_hash|g_direct_hash)\b/;
+complain ("Don't use g_direct_equal() for hash tables, pass NULL for pointer equality which avoids the function call") if $line =~ /\b(g_direct_equal)\b/;
+complain ("Prefer nm_pint_hash()/nm_pint64_hash()/nm_pdouble_hash() over g_int_hash()/g_int64_hash()/g_double_hash(). Those use siphash24") if $line =~ /\b(g_int_hash|g_int64_hash|g_double_hash)\b/;
+complain ("Prefer nm_pint_equal()/nm_pint64_equal()/nm_pdouble_equal() over g_int_equal()/g_int64_equal()/g_double_equal(). Those names mirror our nm_p*_hash() functions") if $line =~ /\b(g_int_equal|g_int64_equal|g_double_equal)\b/;
+complain ("Avoid g_clear_pointer() and use nm_clear_pointer() (or nm_clear_g_free(), g_clear_object(), etc.)") if $line =~ /\b(g_clear_pointer)\b/;
+complain ("Define setting properties with _nm_setting_property_define_direct_*() API") if $line =~ /g_param_spec_/ and $filename =~ /\/libnm-core-impl\/nm-setting/;
+complain ("Use nm_g_array_{index,first,last,index_p}() instead of g_array_index(), as it nm_assert()s for valid element size and out-of-bound access") if $line =~ /\bg_array_index\b/;
+complain ("Use spaces instead of tabs") if $line =~ /\t/;
+complain ("Prefer implementing private pointers via _NM_GET_PRIVATE() or _NM_GET_PRIVATE_PTR() (the latter, if the private data has an opqaue pointer in the header file)") if $line =~ /\b(g_type_class_add_private|G_TYPE_INSTANCE_GET_PRIVATE)\b/;
+complain ("Don't use close()/g_close(). Instead, use nm_close() (or nm_close_with_error()).") if $line =~ /\b(close|g_close)\b *\(/;
+complain ("Use nm_memdup() instead of g_memdup(). The latter has a size argument of type guint") if $line =~ /\bg_memdup\b/;
+
+# Further on we process stuff without comments.
+$_ = $line;
+s/\s*\/\*.*\*\///;
+s/\s*\/\*.*//;
+s/\s*\/\/.*//;
+/^\s* \* / and next;
+
+if (/^typedef*/) {
+	# We expect the { on the same line as the typedef. Otherwise it
+	# looks too much like a function declaration
+	complain ('Unexpected line break following a typedef') unless /[;{,]$/;
+	next;
+} elsif (/^[A-Za-z_][A-Za-z0-9_ ]*\*?$/ and /[a-z]/) {
+	# A function type
+	$type = $_;
+	next;
+} elsif ($type and /^([A-Za-z_][A-Za-z0-9_]*)(\s*)\(/) {
+	my @order = qw/^get_property$ ^set_property$ (?<!_iface|_class)_init$ ^constructor$
+		^constructed$ _new$ ^dispose$ ^finalize$ _class_init$/;
+	my @following = ();
+	my @tmp = ();
+
+	# A function name
+	my $name = $1;
+	complain ('No space between function name and arguments') unless $2 eq '';
+
+	# Determine which function must not be preceding this one
+	foreach my $func (reverse @order) {
+		if ($name =~ /$func/) {
+			@following = @tmp;
+			last;
+		}
+		push @tmp, $func;
+	}
+
+	# Check if an out-of-order function was seen
+	foreach my $func (@following) {
+		my @wrong = grep { /$func/ } @functions_seen;
+		complain (join (', ', map { "'$_'" } @wrong)." should follow '$name'") if @wrong;
+	}
+
+	push @functions_seen, $1;
+	$type = undef;
+	next;
+}
+
+if ($type) {
+	# We've seen what looked like a type in a function declaration,
+	# but the function declaration didn't follow.
+	if ($type =~ /^(struct|union)/ and $line eq '{') {
+		complain ("Brace should be one the same line as the '$type' declaration");
+	} else {
+		complain ("Expected a function declaration following '$type', but found something else");
+	}
+	$type = undef;
+}
+
+END {
+	if ($seen_error) {
+		warn "The patch does not validate.\n" if $is_patch;
+		warn "The file does not validate.\n" if $is_file;
+		$? = 1
+	}
+};
+
+=head1 EXAMPLES
+
+=over
+
+=item B<checkpatch.pl hello.c>
+
+Check a single file.
+
+=item B<git diff --cached |checkpatch.pl>
+
+Check the currently staged changes.
+
+=item B<git format-patch -U65535 --stdout -1 |contrib/scripts/checkpatch.pl || :>
+
+A F<.git/hooks/post-commit> oneliner that, wisely, tolerates failures while
+still providing advice. The large line context allows helps checkpatch.pl
+get a better idea about the changes in context of code that does not change.
+
+=back
+
+=head1 BUGS
+
+Proabably too many.
+
+=head1 SEE ALSO
+
+F<CONTRIBUTING>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2018,2021 Red Hat
+
+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 2 of the License, or
+(at your option) any later version.
+
+=head1 AUTHOR
+
+Lubomir Rintel C<lkundrak@v3.sk>
+
+=cut
diff --git a/contrib/scripts/code-style-git-post-commit-hook b/contrib/scripts/code-style-git-post-commit-hook
new file mode 100755
index 00000000..8d464842
--- /dev/null
+++ b/contrib/scripts/code-style-git-post-commit-hook
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+set -e
+
+DISABLED=${NM_HOOK_DISABLED:0}
+
+if [ "$DISABLED" == 1 ]; then
+    echo "COMMIT HOOK DISABLED"
+    exit 0
+fi
+
+FORMATTER=contrib/scripts/nm-code-format.sh
+
+# Filter only C source files
+CHANGED_FILES=$(git log --pretty='' --name-only -n1 | grep -E '\.c$|\.h$' | tr '\n' ' ')
+
+echo $CHANGED_FILES
+
+if [ -x "$FORMATTER" ] && [ ! -z "$CHANGED_FILES" ]; then
+    "$FORMATTER" -n "${CHANGED_FILES}"
+fi
diff --git a/contrib/scripts/find-backports b/contrib/scripts/find-backports
new file mode 100755
index 00000000..c2082570
--- /dev/null
+++ b/contrib/scripts/find-backports
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+
+import subprocess
+import collections
+import os
+import sys
+import re
+import pprint
+
+
+FNULL = open(os.devnull, "w")
+pp = pprint.PrettyPrinter(indent=4, stream=sys.stderr)
+
+DEBUG = os.environ.get("NM_FIND_BACKPORTS_DEBUG", None) == "1"
+
+
+def dbg_log(s):
+    if DEBUG:
+        print(s, file=sys.stderr)
+
+
+def dbg_pprint(obj):
+    if DEBUG:
+        pp.pprint(obj)
+
+
+def print_err(s):
+    print(s, file=sys.stderr)
+
+
+def die(s):
+    print_err(s)
+    sys.exit(1)
+
+
+def memoize(f):
+    memo = {}
+
+    def helper(x):
+        if x not in memo:
+            memo[x] = f(x)
+        return memo[x]
+
+    return helper
+
+
+def re_bin(r):
+    return r.encode("utf8")
+
+
+def _keys_to_dict(itr):
+    d = collections.OrderedDict()
+    for c in itr:
+        d[c] = None
+    return d
+
+
+@memoize
+def git_ref_exists_full_path(ref):
+    val = git_ref_exists(ref)
+    if val:
+        try:
+            subprocess.check_output(["git", "show-ref", "-q", "--verify", str(ref)])
+        except subprocess.CalledProcessError:
+            pass
+        else:
+            return val
+    return None
+
+
+def _git_ref_exists_eval(ref):
+    try:
+        out = subprocess.check_output(
+            ["git", "rev-parse", "--verify", str(ref) + "^{commit}"],
+            stderr=FNULL,
+        )
+    except subprocess.CalledProcessError:
+        return None
+    o = out.decode("ascii").strip()
+    if len(o) == 40:
+        return o
+    raise Exception(f"git-rev-parse for '{ref}' returned unexpected output {out}")
+
+
+_git_ref_exists_cache = {}
+
+
+def git_ref_exists(ref):
+    val = _git_ref_exists_cache.get(ref, False)
+
+    if val is False:
+        val = _git_ref_exists_eval(ref)
+        _git_ref_exists_cache[ref] = val
+        if val and ref != val:
+            _git_ref_exists_cache[val] = val
+
+    return val
+
+
+@memoize
+def git_get_head_name(ref):
+    out = subprocess.check_output(
+        ["git", "rev-parse", "--symbolic-full-name", str(ref)], stderr=FNULL
+    )
+    return out.decode("utf-8").strip()
+
+
+def git_merge_base(a, b):
+    out = subprocess.check_output(["git", "merge-base", str(a), str(b)], stderr=FNULL)
+    out = out.decode("ascii").strip()
+    assert git_ref_exists(out)
+    return out
+
+
+def git_all_commits_grep(rnge, grep=None):
+    if grep:
+        grep = [("--grep=%s" % g) for g in grep]
+        notes = ["-c", "notes.displayref=refs/notes/bugs"]
+    else:
+        grep = []
+        notes = []
+    out = subprocess.check_output(
+        ["git"]
+        + notes
+        + ["log", "--pretty=%H", "--notes", "--reverse"]
+        + grep
+        + [str(rnge)],
+        stderr=FNULL,
+    )
+    return [x for x in out.decode("ascii").split("\n") if x]
+
+
+def git_logg(commits):
+    commits = list(commits)
+    if not commits:
+        return ""
+    out = subprocess.check_output(
+        [
+            "git",
+            "log",
+            "--no-show-signature",
+            "--no-walk",
+            "--pretty=format:%Cred%h%Creset - %Cgreen(%ci)%Creset [%C(yellow)%an%Creset] %s%C(yellow)%d%Creset",
+            "--abbrev-commit",
+            "--date=local",
+        ]
+        + [str(c) for c in commits],
+        stderr=FNULL,
+    )
+    return out.decode("utf-8").strip()
+
+
+@memoize
+def git_all_commits(rnge):
+    return git_all_commits_grep(rnge)
+
+
+@memoize
+def git_all_commits_set(rnge):
+    return set(git_all_commits_grep(rnge))
+
+
+def git_commit_sorted(commits):
+    commits = list(commits)
+    if not commits:
+        return []
+    out = subprocess.check_output(
+        ["git", "log", "--no-walk", "--pretty=%H", "--reverse"]
+        + [str(x) for x in commits],
+        stderr=FNULL,
+    )
+    out = out.decode("ascii")
+    return [x for x in out.split("\n") if x]
+
+
+@memoize
+def git_ref_commit_body(ref):
+    return subprocess.check_output(
+        [
+            "git",
+            "-c",
+            "notes.displayref=refs/notes/bugs",
+            "log",
+            "-n1",
+            "--pretty=%B%n%N",
+            str(ref),
+        ],
+        stderr=FNULL,
+    )
+
+
+@memoize
+def git_ref_commit_body_get_fixes(ref):
+    body = git_ref_commit_body(ref)
+    result = []
+    for mo in re.finditer(re_bin("\\b[fF]ixes: *([0-9a-z]+)\\b"), body):
+        c = mo.group(1).decode("ascii")
+        h = git_ref_exists(c)
+        if h:
+            result.append(h)
+    if result:
+        # The commit that contains a "Fixes:" line, can also contain an "Ignore-Fixes:" line
+        # to disable it. This only makes sense with refs/notes/bugs notes, to fix up a wrong
+        # annotation.
+        for mo in re.finditer(re_bin("\\bIgnore-[fF]ixes: *([0-9a-z]+)\\b"), body):
+            c = mo.group(1).decode("ascii")
+            h = git_ref_exists(c)
+            try:
+                result.remove(h)
+            except ValueError:
+                pass
+
+    return result
+
+
+@memoize
+def git_ref_commit_body_get_cherry_picked_one(ref):
+    ref = git_ref_exists(ref)
+    if not ref:
+        return None
+    body = git_ref_commit_body(ref)
+    result = None
+    for r in [
+        re_bin("\\(cherry picked from commit ([0-9a-z]+)\\)"),
+        re_bin("\\bIgnore-Backport: *([0-9a-z]+)\\b"),
+    ]:
+        for mo in re.finditer(r, body):
+            c = mo.group(1).decode("ascii")
+            h = git_ref_exists(c)
+            if h:
+                if not result:
+                    result = [h]
+                else:
+                    result.append(h)
+    return result
+
+
+@memoize
+def git_ref_commit_body_get_cherry_picked_recurse(ref):
+    ref = git_ref_exists(ref)
+    if not ref:
+        return None
+
+    def do_recurse(result, ref):
+        result2 = git_ref_commit_body_get_cherry_picked_one(ref)
+        if result2:
+            extra = [h2 for h2 in result2 if h2 not in result]
+            if extra:
+                result.extend(extra)
+                for h2 in extra:
+                    do_recurse(result, h2)
+
+    result = []
+    do_recurse(result, ref)
+    return result
+
+
+def git_commits_annotate_fixes(rnge):
+    commits = git_all_commits(rnge)
+    c_dict = _keys_to_dict(commits)
+    for c in git_all_commits_grep(rnge, grep=["[Ff]ixes:"]):
+        ff = git_ref_commit_body_get_fixes(c)
+        if ff:
+            c_dict[c] = ff
+    return c_dict
+
+
+def git_commits_annotate_cherry_picked(rnge):
+    commits = git_all_commits(rnge)
+    c_dict = _keys_to_dict(commits)
+    for c in git_all_commits_grep(
+        ref_head, grep=["cherry picked from commit", "Ignore-Backport:"]
+    ):
+        ff = git_ref_commit_body_get_cherry_picked_recurse(c)
+        if ff:
+            c_dict[c] = ff
+    return c_dict
+
+
+def git_ref_in_history(ref, rnge):
+    return git_ref_exists(ref) in git_all_commits_set(rnge)
+
+
+if __name__ == "__main__":
+    if len(sys.argv) <= 1:
+        ref_head0 = "HEAD"
+    else:
+        ref_head0 = sys.argv[1]
+
+    ref_head = git_ref_exists(ref_head0)
+    if not ref_head:
+        die('Ref "%s" does not exist' % (ref_head0))
+
+    if not git_ref_exists_full_path("refs/notes/bugs"):
+        die(
+            "Notes refs/notes/bugs not found. Read CONTRIBUTING.md file for how to setup the notes"
+        )
+
+    ref_upstreams = []
+    if len(sys.argv) <= 2:
+        head_name = git_get_head_name(ref_head0)
+        match = False
+        if head_name:
+            match = re.match("^refs/(heads|remotes/[^/]*)/nm-1-([0-9]+)$", head_name)
+        if match:
+            i = int(match.group(2))
+            while True:
+                i += 2
+                r = "nm-1-" + str(i)
+                if not git_ref_exists(r):
+                    r = "refs/remotes/origin/nm-1-" + str(i)
+                    if not git_ref_exists(r):
+                        break
+                ref_upstreams.append(r)
+            ref_upstreams.append("main")
+
+    if not ref_upstreams:
+        if len(sys.argv) <= 2:
+            ref_upstreams = ["main"]
+        else:
+            ref_upstreams = list(sys.argv[2:])
+
+    for h in ref_upstreams:
+        if not git_ref_exists(h):
+            die('Upstream ref "%s" does not exist' % (h))
+
+    print_err("Check %s (%s)" % (ref_head0, ref_head))
+    print_err("Upstream refs: %s" % (ref_upstreams))
+
+    print_err('Check patches of "%s"...' % (ref_head))
+    own_commits_list = git_all_commits(ref_head)
+    own_commits_cherry_picked = git_commits_annotate_cherry_picked(ref_head)
+
+    cherry_picks_all = collections.OrderedDict()
+    for c, cherry_picked in own_commits_cherry_picked.items():
+        if cherry_picked:
+            for c2 in cherry_picked:
+                l = cherry_picks_all.get(c2)
+                if not l:
+                    cherry_picks_all[c2] = [c]
+                else:
+                    l.append(c)
+
+    own_commits_cherry_picked_flat = set()
+    for c, p in own_commits_cherry_picked.items():
+        own_commits_cherry_picked_flat.add(c)
+        if p:
+            own_commits_cherry_picked_flat.update(p)
+
+    dbg_log(">>> own_commits_cherry_picked")
+    dbg_pprint(own_commits_cherry_picked)
+
+    dbg_log(">>> cherry_picks_all")
+    dbg_pprint(cherry_picks_all)
+
+    # find all commits on the upstream branches that fix another commit.
+    fixing_commits = {}
+    for ref_upstream in ref_upstreams:
+        ref_str = ref_head + ".." + ref_upstream
+        print_err(f'Check upstream patches "{ref_str}"...')
+        for c, fixes in git_commits_annotate_fixes(ref_str).items():
+            if not fixes:
+                dbg_log(f">>> test {c} : SKIP (does not fix anything)")
+                continue
+            if c in cherry_picks_all:
+                # commit 'c' is already backported. Skip it.
+                dbg_log(f">>> test {c} => {fixes} : SKIP (already backported)")
+                continue
+            dbg_log(f">>> test {c} => {fixes} : process")
+            for f in fixes:
+                if f not in own_commits_cherry_picked_flat:
+                    # commit "c" fixes commit "f", but this is not one of our own commits
+                    # and not interesting.
+                    dbg_log(f">>> fixes {f} not in own_commits_cherry_picked")
+                    continue
+                dbg_log(f">>> take {c} (fixes {fixes})")
+                fixing_commits[c] = fixes
+                break
+
+    extra = collections.OrderedDict(
+        [(c, git_ref_commit_body_get_cherry_picked_recurse(c)) for c in fixing_commits]
+    )
+    extra2 = []
+    for c in extra:
+        is_back = False
+        for e_v in extra.values():
+            if c in e_v:
+                is_back = True
+                break
+        if not is_back:
+            extra2.append(c)
+
+    commits_good = extra2
+
+    commits_good = git_commit_sorted(commits_good)
+
+    print_err(git_logg(commits_good))
+
+    not_in = [
+        c
+        for c in commits_good
+        if not git_ref_in_history(c, f"{ref_head}..{ref_upstreams[0]}")
+    ]
+    if not_in:
+        print_err("")
+        print_err(
+            f'WARNING: The following commits are not from the first reference "{ref_upstreams[0]}".'
+        )
+        print_err(
+            f'  You may want to first backports those patches to "{ref_upstreams[0]}".'
+        )
+        for l in git_logg(git_commit_sorted(not_in)).splitlines():
+            print_err(f"  - {l}")
+        print_err("")
+
+    for c in reversed(commits_good):
+        print("%s" % (c))
diff --git a/contrib/scripts/git-backport-merge b/contrib/scripts/git-backport-merge
new file mode 100755
index 00000000..8ba2244f
--- /dev/null
+++ b/contrib/scripts/git-backport-merge
@@ -0,0 +1,58 @@
+#!/bin/bash
+
+# Uses `git cherry-pick -x` to backport a merge commit to an older branch.
+#
+# Usage:
+#   First checkout the old-stable branch, that is the target for the backport.
+#   Then `git-backport-merge MERGE_REF [REFS...]`
+#     MERGE_REF is the merge commit that should be backported.
+#     [REFS...] is the commits that should be backported. If omitted,
+#       it automatically takes the parent commits of the merge commit.
+
+die() {
+    printf '%s\n' "$*" >&2
+    exit 1
+}
+
+backport_merge() {
+    test "$#" -gt 0 || die "Requires the commit ref to backport (and optimally select the commits to include)"
+
+    local M="${@:$#}"
+    local h
+
+    if test "$#" -eq 1; then
+        local C=($(git log --reverse "--pretty=%H" "$M"^1.."$M"^2))
+    else
+        local C=("${@:1:$#-1}")
+    fi
+
+    local OLD_HEAD="$(git rev-parse HEAD)" || die "failed to get current HEAD"
+
+    test -n "$(git status --porcelain --untracked-files=no)" && die "Working directory contains changes. Abort."
+
+    local M_ID="$(git rev-parse "$M"^{commit})" || die "\"$M\" is not a valid commit"
+
+    trap EXIT 'test -z "$OLD_HEAD" || git reset "$OLD_HEAD" --hard'
+
+    for h in "${C[@]}"; do
+        if ! git cherry-pick --allow-empty -x "$h" ; then
+            git cherry-pick --abort
+            die "failed to cherry-pick commit \"$h\" on top of \"$(git rev-parse HEAD)\""
+        fi
+    done
+
+    local NEW_HEAD="$(git rev-parse HEAD)" || die "failed to get new HEAD"
+
+    git reset --hard "$OLD_HEAD" || die "Failed to reset to previous HEAD \"$OLD_HEAD\""
+
+    git merge --no-ff --no-edit "$NEW_HEAD" || die "Failed to merge old HEAD \"$OLD_HEAD\" with new \"$NEW_HEAD\""
+
+    git commit --amend --allow-empty -C "$M" || die "Failed to amend merge commit \"$(git rev-parse HEAD)\" with commit message from \"$M\""
+
+    git rev-parse "$M" | sed 's/.*/(cherry picked from commit \0)/' | GIT_EDITOR='sh -c "cat >> \"$1\""' git commit --allow-empty --amend || \
+         die "Failed to amend merge commit \"$(git rev-parse HEAD)\" with cherry-picked-from message from \"$M\""
+
+    OLD_HEAD=
+}
+
+backport_merge "$@"
diff --git a/contrib/scripts/git-subtree-reimport.sh b/contrib/scripts/git-subtree-reimport.sh
new file mode 100755
index 00000000..73319044
--- /dev/null
+++ b/contrib/scripts/git-subtree-reimport.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+
+# In our git repository we vendor in several external projects.
+# We do so via git-subtree.
+#
+# Run this script (without arguments) for re-importing the latest
+# version of those projects.
+#
+# You can also specify the projects to reimport on the command line,
+# ./contrib/scripts/git-subtree-reimport.sh  [ c-list | c-rbtree | c-siphash | c-stdaux | n-acd | n-dhcp4 ... ]
+
+set -e
+
+cd "$(dirname "$(readlink -f "$0")")/../.."
+
+reimport() {
+    local d="$1"
+    local project
+    local branch
+
+    if [[ "$d" = c-* ]] ; then
+        project=c-util
+        branch=main
+    else
+        project=nettools
+        branch=master
+    fi
+
+    CMD=( git subtree pull --prefix "src/$d" "git@github.com:$project/$d.git" "$branch" --squash -m \
+"$d: re-import git-subtree for 'src/$d'
+
+  git subtree pull --prefix src/$d git@github.com:$project/$d.git $branch --squash
+" )
+
+    printf '\n>>>> %s >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\n' "$d"
+    printf '>>>'
+    for c in "${CMD[@]}"; do
+        printf ' %q' "$c"
+    done
+    printf '\n'
+
+    "${CMD[@]}" 2>&1
+
+    local REMOTE_COMMIT="$(git rev-parse FETCH_HEAD)"
+
+    echo ">>>>> RESULT:"
+    printf ">>> git diff %s: HEAD:src/%s\n" "$REMOTE_COMMIT" "$d"
+    GIT_PAGER=cat git diff --color=always "$REMOTE_COMMIT:" "HEAD:src/$d"
+}
+
+reimport_all() {
+    local ARGS
+
+    ARGS=( "$@" )
+    if [ "${#ARGS[@]}" = 0 ]; then
+        ARGS=( c-list c-rbtree c-siphash c-stdaux n-acd n-dhcp4 )
+    fi
+    for d in "${ARGS[@]}" ; do
+        reimport "$d"
+    done
+}
+
+reimport_all "$@"
diff --git a/contrib/scripts/modemu.pl b/contrib/scripts/modemu.pl
new file mode 100755
index 00000000..0e90fafa
--- /dev/null
+++ b/contrib/scripts/modemu.pl
@@ -0,0 +1,299 @@
+#!/usr/bin/env perl
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright (C) 2018 Red Hat, Inc.
+#
+
+# $ perldoc modemu.pl for eye-pleasing view of the manual:
+
+=head1 NAME
+
+modemu.pl - emulate a serial modem
+
+=head1 SYNOPSIS
+
+modemu.pl [<name>] [-- <pppd> ...]
+
+=head1 DESCRIPTION
+
+B<modemu.pl> opens a PTY, links the slave side to F</dev> and announces a
+fake kobject via netlink as if it were a real serial device, so that
+ModemManager picks it up.
+
+Then it answers to a very basic subset of AT commands, sufficient making
+ModemManager recognize it as a 3GPP capable modem registered to a network.
+
+Upon receiving the dial (ATD) command, it spawns C<pppd> so that
+NetworkManager can establish a connection.
+
+B<modemu.pl> needs superuser privileges to be able to announce a kobject
+and create a F</dev> node.
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<< <name> >>
+
+Create a modem of given name. Links it to F<< /dev/<name> >>.
+
+Defaults to I<modemu>.
+
+=item B<< <pppd> >>
+
+Specifies extra arguments to be prepended before C<pppd> to the default
+set of I<nodetach notty local logfd 2 nopersist>.
+
+Defaults to I<pppd dump debug 172.31.82.1:172.31.82.2>.
+
+=back
+
+=cut
+
+use strict;
+use warnings;
+
+use Errno;
+use Socket;
+use IO::Pty;
+use IO::Handle;
+
+use constant AF_NETLINK => 16;
+use constant NETLINK_KOBJECT_UEVENT => 15;
+
+# This allows us to use buffered read for lines from ModemManager
+# despite not ending with \n
+IO::Handle->input_record_separator ("\r");
+
+# Parse command line arguments
+my $name;
+my @pppd = qw/pppd dump debug 172.31.82.1:172.31.82.2/;
+while (@ARGV) {
+	$_ = shift @ARGV;
+	if ($_ eq '--') {
+		@pppd = @ARGV;
+		last;
+	} else {
+		die "Extra argument: '$_'" if $name;
+		$name = $_;
+	}
+};
+$name ||= 'modemu';
+
+socket my $fd, AF_NETLINK, SOCK_RAW, NETLINK_KOBJECT_UEVENT
+	or die "Can't create a netlink socket: $!";
+
+my $seqnum = 666;
+sub send_netlink
+{
+	my %props = (@_, SEQNUM => $seqnum++);
+	my $props = join '', map { $_, '=', $props{$_}, "\0" } keys %props;
+
+	my $head = pack 'a8NLLLNLLL',
+		# signature + magic
+		'libudev',
+		0xfeedcafe,
+
+		# 40 octets is the length of this header
+		40, 40, 40 + length ($props),
+
+		# Digest::MurmurHash2::Neutral::murmur_hash2_neutral("tty")
+		0x8afa90c8,
+
+		0x00000000,
+		0x00040002,
+		0x00008010;
+
+	$! = undef;
+	send $fd, "$head$props", 0, pack 'SSLL', AF_NETLINK, 0, 0, 0x0002;
+	# RHEL 7 kernel responds ECONNREFUSED even thoguh the sendto succeeded. Weird.
+	die "Can't send a netlink message: $!" if $! and not $!{ECONNREFUSED};
+}
+
+my $devpath = '/devices/pci0000:00/0000:00:00.0';
+unless (-d "/sys/$devpath") {
+	# Create a virtual device. Older ModemManager likes a hotpluggable bus
+	# (USB, PCI), but there's none on an IBM POWER lpar...
+	warn "No PCI bus to use for parent. Don't expect this to work with ModemManager 1.6";
+	$devpath = '/devices/virtual';
+}
+
+my %props = (
+	DEVPATH			=> "$devpath/$name",
+	SUBSYSTEM		=> 'tty',
+	DEVNAME			=> "/dev/$name",
+
+	# Whitelisting that works for both ModemManager 1.6 and 1.8
+	ID_MM_CANDIDATE		=> '1',
+	ID_MM_DEVICE_PROCESS	=> '1',
+);
+
+sub cleanup
+{
+	unlink "/dev/$name";
+	send_netlink (ACTION => 'remove', %props) if $fd;
+}
+
+# Ensure we clean up before and after.
+END { cleanup };
+$SIG{INT} = sub { cleanup; die };
+$SIG{TERM} = sub { cleanup; die };
+cleanup;
+
+my $pty = new IO::Pty;
+my $ptyname = ttyname $pty;
+symlink $ptyname, "/dev/$name" or die "Can't create /dev/$name: $!";
+send_netlink (ACTION => 'add', %props);
+my ($pdptype, $apn);
+
+# Here's a good refernce of AT command a modern-ish modem probably uses:
+# https://infocenter.nordicsemi.com/index.jsp?topic=%2Fref_at_commands%2FREF%2Fat_commands%2Fpacket_domain%2Fcgact_set.html
+
+while (<$pty>) {
+	chomp;
+
+	if (/^AT$/ or /^ATE0$/ or /^ATV1$/ or /^AT\+CMEE=1$/ or /^ATX4$/ or /^AT&C1$/ or /^ATZ$/) {
+		# Standard Hayes commands that are basically used to
+		# ensure the modem is in a known state. Accept them all.
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+CPIN\?$/) {
+		# PIN unlocked. Required.
+		print $pty "\r\n";
+		print $pty "+CPIN:READY\r\n";
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+COPS=0$/) {
+		# Select access technology (we just accept 0=automatic)
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+CGREG\?$/) {
+		# 3GPP Registration status.
+		print $pty "\r\n";
+		print $pty "+CGREG: 0,1\r\n";
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+	# The PDP (packet data protocol/profile?) context handling below is very
+	# rudimentary: just enough to keep ModemManager 1.18 happy. It basically
+	# just starts with no contexts at all and then expects MM to set and
+	# activate profile number 1.
+
+	} elsif (/^AT\+CGDCONT=\?$/) {
+		# Get supported PDP contexts
+		print $pty "\r\n";
+		print $pty "+CGDCONT: (1-10),(\"IP\"),,,(0-1),(0-1)\r\n";
+		print $pty "+CGDCONT: (1-10),(\"IPV6\"),,,(0-1),(0-1)\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+CGDCONT=1,"(.*)","(.*)"$/) {
+		# Create the PDP context. Remember it, MM is going to check it later
+		($pdptype, $apn) = ($1, $2);
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+
+	} elsif (/^AT\+CGDCONT\?$/) {
+		# List the PDP context we're aware of.
+		print $pty "\r\n";
+		print $pty "+CGDCONT: 1,\"$pdptype\",\"$apn\",\"0.0.0.0\",0,0,0,0\r\n"
+			if defined $pdptype;
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+CGACT\?$/) {
+		# List available PDP contexts with states: profile 1 state 0 (inactive)
+		print $pty "\r\n";
+		print $pty "+CGACT: 0,1\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+CGACT=0,1$/) {
+		# Deactivate a PDP context
+		print $pty "\r\n";
+		print $pty "OK\r\n";
+
+	} elsif (/^AT\+COPS\?$/) {
+		# Current operators
+		# Not strictly required, but allows NetworkManager to just connect
+		# the modem device without explicitly setting an APN
+		print $pty "\r\n";
+		print $pty "+COPS: 0,2,\"65302\",7\r\n"; # MCCMNC
+		print $pty "OK\r\n";
+
+	} elsif (/^ATD/) {
+		print $pty "\r\n";
+		print $pty "CONNECT 28800000\r\n";
+
+		my $ppp = fork;
+		die "Can't fork: $!" unless defined $ppp;
+		if ($ppp == 0) {
+			close STDIN;
+			close STDOUT;
+			open STDIN, '<&', $pty or die "Can't dup pty to a pppd stdin: $!";
+			open STDOUT, '>&', $pty or die "Can't dup pty to a pppd stdout: $!";
+			close $pty;
+			exec @pppd, qw/nodetach notty local logfd 2 nopersist/;
+			die "Can't exec pppd: $!";
+		}
+		waitpid $ppp, 0;
+	} else {
+		print $pty "\r\n";
+		print $pty "ERROR\r\n";
+	}
+}
+
+=head1 EXAMPLES
+
+=over
+
+=item B<modemu.pl>
+
+Just create a modem named I<modemu>, with the default PPP arguments.
+
+=item B<modemu.pl ttyS666>
+
+Same as above, just name the modem I<ttyS666>.
+
+=item B<modemu.pl -- unshare --net pppd 172.31.82.1:172.31.82.2>
+
+Avoid polluting the namespace with the modem end of PPP connection.
+
+=item B<modemu.pl -- pppd 10.0.0.1:10.0.0.2>
+
+Override the C<pppd> parameters: no debug logging and different set of
+addresses.
+
+=item B<modemu.pl mymodem -- pppd 10.0.0.1:10.0.0.2>
+
+Same as above, with a modem name different from default.
+
+=back
+
+=head1 BUGS
+
+Only works on machines with a PCI bus. ModemManager is picky about platform
+devices and accepts PCI and USB buses easily. Which is why pretent to have
+our tty on the PCI root device.
+
+Terminates after a single PPP session. C<pppd> seems to hang up the PTY.
+
+=head1 SEE ALSO
+
+L<ModemManager(8)>, L<pppd(8)>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2018 Lubomir Rintel
+
+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 2 of the License, or
+(at your option) any later version.
+
+=head1 AUTHOR
+
+Lubomir Rintel C<lkundrak@v3.sk>
+
+=cut
diff --git a/contrib/scripts/nm-ci-patch-gtkdoc.sh b/contrib/scripts/nm-ci-patch-gtkdoc.sh
new file mode 100755
index 00000000..e72a0a8f
--- /dev/null
+++ b/contrib/scripts/nm-ci-patch-gtkdoc.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# patch gtk-doc for https://gitlab.gnome.org/GNOME/gtk-doc/merge_requests/2
+
+cd /
+
+patch -f -p 1 --fuzz 0 --reject-file=- <<EOF
+diff --git a/usr/share/gtk-doc/python/gtkdoc/scan.py b/usr/share/gtk-doc/python/gtkdoc/scan.py
+index f1f167235ab2e4c62676fbcfb87ebbe55c95b944..b59dd17abfa5f42b7bb06d239f9c78e5efffbf5d 100644
+--- a/usr/share/gtk-doc/python/gtkdoc/scan.py
++++ b/usr/share/gtk-doc/python/gtkdoc/scan.py
+@@ -427,20 +427,26 @@ def ScanHeader(input_file, section_list, decl_list, get_types, options):
+             elif m9:
+                 # We've found a 'typedef struct _<name> <name>;'
+                 # This could be an opaque data structure, so we output an
+                 # empty declaration. If the structure is actually found that
+                 # will override this.
+                 structsym = m9.group(1).upper()
+                 logging.info('%s typedef: "%s"', structsym, m9.group(2))
+                 forward_decls[m9.group(2)] = '<%s>\n<NAME>%s</NAME>\n%s</%s>\n' % (
+                     structsym, m9.group(2), deprecated, structsym)
+ 
++                bm = re.search(r'^(\S+)(Class|Iface|Interface)\b', m9.group(2))
++                if bm:
++                    objectname = bm.group(1)
++                    logging.info('Found object: "%s"', objectname)
++                    title = '<TITLE>%s</TITLE>' % objectname
++
+             elif re.search(r'^\s*(?:struct|union)\s+_(\w+)\s*;', line):
+                 # Skip private structs/unions.
+                 logging.info('private struct/union')
+ 
+             elif m10:
+                 # Do a similar thing for normal structs as for typedefs above.
+                 # But we output the declaration as well in this case, so we
+                 # can differentiate it from a typedef.
+                 structsym = m10.group(1).upper()
+                 logging.info('%s:%s', structsym, m10.group(2))
+EOF
+
diff --git a/contrib/scripts/nm-ci-run.sh b/contrib/scripts/nm-ci-run.sh
new file mode 100755
index 00000000..e385ef1e
--- /dev/null
+++ b/contrib/scripts/nm-ci-run.sh
@@ -0,0 +1,299 @@
+#!/bin/bash
+
+# Arguments via environment variables:
+#  - CI
+#  - CC
+#  - BUILD_TYPE
+#  - CFLAGS
+#  - WITH_DOCS
+
+set -ex
+
+die() {
+    printf "%s\n" "$@"
+    exit 1
+}
+
+_is_true() {
+    case "$1" in
+        1|y|yes|YES|Yes|on)
+            return 0
+            ;;
+        0|n|no|NO|No|off)
+            return 1
+            ;;
+        "")
+            if [ "$2" == "" ]; then
+                die "not a boolean argument \"$1\""
+            fi
+            _is_true "$2"
+            return $?
+            ;;
+        *)
+            die "not a boolean argument \"$1\""
+            ;;
+    esac
+}
+
+USE_CCACHE=0
+if command -v ccache &>/dev/null; then
+    USE_CCACHE=1
+    export PATH="/usr/lib64/ccache:/usr/lib/ccache${PATH:+:${PATH}}"
+fi
+
+IS_FEDORA=0
+IS_CENTOS=0
+IS_ALPINE=0
+grep -q '^NAME=.*\(CentOS\)' /etc/os-release && IS_CENTOS=1
+grep -q '^NAME=.*\(Fedora\)' /etc/os-release && IS_FEDORA=1
+grep -q '^NAME=.*\(Alpine\)' /etc/os-release && IS_ALPINE=1
+
+###############################################################################
+
+if [ "$BUILD_TYPE" == meson ]; then
+    _TRUE=true
+    _FALSE=false
+elif [ "$BUILD_TYPE" == autotools ]; then
+    _TRUE=yes
+    _FALSE=no
+else
+    die "invalid \$BUILD_TYPE \"$BUILD_TYPE\""
+fi
+
+_WITH_CRYPTO="gnutls"
+_WITH_WERROR=1
+_WITH_LIBTEAM="$_TRUE"
+_WITH_DOCS="$_TRUE"
+_WITH_SYSTEMD_LOGIND="$_TRUE"
+if [ $IS_ALPINE = 1 ]; then
+    _WITH_SYSTEMD_LOGIND="$_FALSE"
+fi
+
+if [ -z "${NMTST_SEED_RAND+x}" ]; then
+    NMTST_SEED_RAND="$SRANDOM"
+    if [ -z "$NMTST_SEED_RAND" ]; then
+        NMTST_SEED_RAND="$(( ( (RANDOM<<15|RANDOM)<<15|RANDOM ) % 0xfffffffe ))"
+    fi
+fi
+export NMTST_SEED_RAND
+
+case "$CI" in
+    ""|"true"|"default"|"gitlab")
+        CI=default
+        ;;
+    *)
+        die "invalid \$CI \"$CI\""
+        ;;
+esac
+
+if [ "$CC" != gcc ]; then
+    _WITH_CRYPTO=nss
+fi
+
+if [ "$WITH_DOCS" != "" ]; then
+    if _is_true "$WITH_DOCS"; then
+        _WITH_DOCS="$_TRUE"
+    else
+        _WITH_DOCS="$_FALSE"
+    fi
+fi
+
+unset _WITH_VALGRIND_CHECKED
+_with_valgrind() {
+    _is_true "$WITH_VALGRIND" 0 || return 1
+
+    test "$_WITH_VALGRIND_CHECKED" = "1" && return 0
+    _WITH_VALGRIND_CHECKED=1
+
+    if [ "$IS_ALPINE" = 1 ]; then
+        # on Alpine we have no debug symbols and the suppressions
+        # don't work. Skip valgrind tests.
+        WITH_VALGRIND=0
+    fi
+
+    # Certain glib2 versions are known to report *lots* of leaks. Disable
+    # valgrind tests in this case.
+    # https://bugzilla.redhat.com/show_bug.cgi?id=1710417
+    if grep -q '^PRETTY_NAME="Fedora 30 (.*)"$' /etc/os-release ; then
+        if rpm -q glib2 | grep -q glib2-2.60.2-1.fc30 ; then
+            WITH_VALGRIND=0
+        fi
+    elif grep -q '^PRETTY_NAME="Fedora 31 (.*)"$' /etc/os-release; then
+        if rpm -q glib2 | grep -q glib2-2.61.0-2.fc31 ; then
+            WITH_VALGRIND=0
+        fi
+    elif grep -q '^PRETTY_NAME="Debian.*sid"$' /etc/os-release; then
+        if dpkg -s libglib2.0-bin | grep -q '^Version: 2.66.4-2$' ; then
+            WITH_VALGRIND=0
+        fi
+    fi
+    if [ "$WITH_VALGRIND" == 0 ]; then
+        echo "Don't use valgrind due to known issues in other packages."
+        return 1
+    fi
+    return 0
+}
+
+###############################################################################
+
+_print_test_logs() {
+    echo ">>>> PRINT TEST LOGS $1 (start)"
+    if test -f test-suite.log; then
+        cat test-suite.log
+    fi
+    echo ">>>> PRINT TEST LOGS $1 (done)"
+    if _with_valgrind; then
+        echo ">>>> PRINT VALGRIND LOGS $1 (start)"
+        find -name '*.valgrind-log' -print0 | xargs -0 grep -H ^ || true
+        echo ">>>> PRINT VALGRIND LOGS $1 (done)"
+    fi
+}
+
+run_autotools() {
+    NOCONFIGURE=1 ./autogen.sh
+    mkdir ./build
+    if [ "$_WITH_WERROR" == 1 ]; then
+        _WITH_WERROR_VAL="error"
+    else
+        _WITH_WERROR_VAL="yes"
+    fi
+    DISABLE_DEPENDENCY_TRACKING=
+    if [ $IS_ALPINE = 1 ]; then
+        DISABLE_DEPENDENCY_TRACKING='--disable-dependency-tracking'
+    fi
+    pushd ./build
+        ../configure \
+            --prefix="$PWD/INST" \
+            $DISABLE_DEPENDENCY_TRACKING \
+            \
+            --enable-introspection=$_WITH_DOCS \
+            --enable-gtk-doc=$_WITH_DOCS \
+            --with-systemd-logind=$_WITH_SYSTEMD_LOGIND \
+            --enable-more-warnings="$_WITH_WERROR_VAL" \
+            --enable-tests=yes \
+            --with-crypto=$_WITH_CRYPTO \
+            \
+            --with-ebpf=no \
+            \
+            --with-iwd=yes \
+            --with-ofono=yes \
+            --enable-teamdctl=$_WITH_LIBTEAM \
+            \
+            --with-dhcpcanon=yes \
+            --with-dhcpcd=yes \
+            --with-dhclient=yes \
+            \
+            --with-netconfig=/bin/nowhere/netconfig \
+            --with-resolvconf=/bin/nowhere/resolvconf \
+            \
+            --enable-ifcfg-rh=yes \
+            --enable-ifupdown=yes \
+            \
+            #end
+
+        if [ "$CONFIGURE_ONLY" != 1 ]; then
+            make -j 6
+            make install
+
+            export NM_TEST_CLIENT_CHECK_L10N=1
+
+            if ! make check -j 6 -k ; then
+                _print_test_logs "first-test"
+                echo ">>>> RUN SECOND TEST (start)"
+                NMTST_DEBUG="debug,TRACE,no-expect-message" make check -k || :
+                echo ">>>> RUN SECOND TEST (done)"
+                _print_test_logs "second-test"
+                die "autotools test failed"
+            fi
+
+            if _with_valgrind; then
+                if ! NMTST_USE_VALGRIND=1 make check -j 3 -k ; then
+                    _print_test_logs "(valgrind test)"
+                    die "autotools+valgrind test failed"
+                fi
+            fi
+        fi
+    popd
+}
+
+###############################################################################
+
+run_meson() {
+    if [ "$_WITH_WERROR" == 1 ]; then
+        _WITH_WERROR_VAL="--werror"
+    else
+        _WITH_WERROR_VAL=""
+    fi
+    meson setup build \
+        \
+        -Dprefix="$PWD/INST" \
+        \
+        --warnlevel 2 \
+        $_WITH_WERROR_VAL \
+        \
+        -D ld_gc=false \
+        -D session_tracking=no \
+        -D systemdsystemunitdir=no \
+        -D systemd_journal=false \
+        -D selinux=false \
+        -D libaudit=no \
+        -D libpsl=false \
+        -D vapi=false \
+        -D introspection=$_WITH_DOCS \
+        -D qt=false \
+        -D crypto=$_WITH_CRYPTO \
+        -D docs=$_WITH_DOCS \
+        \
+        -D ebpf=false \
+        \
+        -D iwd=true \
+        -D ofono=true \
+        -D teamdctl=$_WITH_LIBTEAM \
+        \
+        -D dhclient=/bin/nowhere/dhclient \
+        -D dhcpcanon=/bin/nowhere/dhcpcanon \
+        -D dhcpcd=/bin/nowhere/dhcpd \
+        \
+        -D netconfig=/bin/nowhere/netconfig \
+        -D resolvconf=/bin/nowhere/resolvconf \
+        \
+        -D ifcfg_rh=false \
+        -D ifupdown=true \
+        \
+        #end
+
+    export NM_TEST_CLIENT_CHECK_L10N=1
+
+    if [ "$CONFIGURE_ONLY" != 1 ]; then
+        ninja -C build -v
+        ninja -C build install
+
+        if ! meson test -C build -v --print-errorlogs ; then
+            echo ">>>> RUN SECOND TEST (start)"
+            NMTST_DEBUG="debug,TRACE,no-expect-message" \
+            meson test -C build -v --print-errorlogs || :
+            echo ">>>> RUN SECOND TEST (done)"
+            die "meson test failed"
+        fi
+
+        if _with_valgrind; then
+            if ! NMTST_USE_VALGRIND=1 meson test -C build -v --print-errorlogs ; then
+                _print_test_logs "(valgrind test)"
+                die "meson+valgrind test failed"
+            fi
+        fi
+    fi
+}
+
+###############################################################################
+
+if [ "$BUILD_TYPE" == autotools ]; then
+    run_autotools
+elif [ "$BUILD_TYPE" == meson ]; then
+    run_meson
+fi
+
+if [ "$USE_CCACHE" = 1 ]; then
+    echo "ccache statistics:"
+    ccache -s
+fi
diff --git a/contrib/scripts/nm-code-format-container.sh b/contrib/scripts/nm-code-format-container.sh
new file mode 100755
index 00000000..7a5ce0d4
--- /dev/null
+++ b/contrib/scripts/nm-code-format-container.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+
+set -e
+
+die() {
+    echo "$@" >&2
+    exit 1
+}
+
+DIR="$(realpath "$(dirname "$0")/../../")"
+cd "$DIR"
+
+# The correct clang-format version is the one from the Fedora version used in our
+# gitlab-ci pipeline. Parse it from ".gitlab-ci/config.yml".
+FEDORA_VERSION="$(sed '/^    tier: 1/,/^  - name/!d' .gitlab-ci/config.yml | sed -n "s/^      - '\([0-9]\+\)'$/\1/p" | sed -n 1p)"
+
+test -n "$FEDORA_VERSION" || die "Could not detect the Fedora version in .gitlab-ci/config.yml"
+
+IMAGENAME="nm-code-format:f$FEDORA_VERSION"
+
+ARGS=( "$@" )
+
+if ! podman image exists "$IMAGENAME" ; then
+    echo "Building image \"$IMAGENAME\"..."
+    podman build \
+        --squash-all \
+        --tag "$IMAGENAME" \
+        -f <(cat <<EOF
+FROM fedora:$FEDORA_VERSION
+RUN dnf upgrade -y
+RUN dnf install -y git /usr/bin/clang-format
+EOF
+)
+fi
+
+CMD=( ./contrib/scripts/nm-code-format.sh "${ARGS[@]}" )
+
+podman run \
+    --rm \
+    --name "nm-code-format-f$FEDORA_VERSION" \
+    -v "$DIR:/tmp/NetworkManager:Z" \
+    -w /tmp/NetworkManager \
+    -e "_NM_CODE_FORMAT_CONTAINER=$IMAGENAME" \
+    -ti \
+    "$IMAGENAME" \
+    "${CMD[@]}"
diff --git a/contrib/scripts/nm-code-format.sh b/contrib/scripts/nm-code-format.sh
new file mode 100755
index 00000000..3c18cd77
--- /dev/null
+++ b/contrib/scripts/nm-code-format.sh
@@ -0,0 +1,222 @@
+#!/bin/bash
+
+set -e
+
+die() {
+    printf '%s\n' "$*" >&2
+    exit 1
+}
+
+EXCLUDE_PATHS_TOPLEVEL=(
+    "src/c-list"
+    "src/c-rbtree"
+    "src/c-siphash"
+    "src/c-stdaux"
+    "src/libnm-std-aux/unaligned-fundamental.h"
+    "src/libnm-std-aux/unaligned.h"
+    "src/libnm-systemd-core/src"
+    "src/libnm-systemd-shared/src"
+    "src/linux-headers"
+    "src/n-acd"
+    "src/n-dhcp4"
+)
+
+NM_ROOT="$(git rev-parse --show-toplevel)" || die "not inside a git repository"
+NM_PREFIX="$(git rev-parse --show-prefix)" || die "not inside a git repository"
+
+if [ ! -f "$NM_ROOT/.clang-format" ]; then
+    die "Error: the clang-format file in \"$NM_ROOT\" does not exist"
+fi
+
+if ! command -v clang-format &> /dev/null; then
+    die "Error: clang-format is not installed. On RHEL/Fedora/CentOS run 'dnf install clang-tools-extra'"
+fi
+
+if test -n "$NM_PREFIX"; then
+    EXCLUDE_PATHS=()
+    for e in "${EXCLUDE_PATHS_TOPLEVEL[@]}"; do
+        REGEX="^$NM_PREFIX([^/].*)$"
+        if [[ "$e" =~ $REGEX ]]; then
+            EXCLUDE_PATHS+=("${BASH_REMATCH[1]}")
+        fi
+    done
+else
+    EXCLUDE_PATHS=("${EXCLUDE_PATHS_TOPLEVEL[@]}")
+fi
+
+FILES=()
+HAS_EXPLICIT_FILES=0
+SHOW_FILENAMES=0
+TEST_ONLY=0
+CHECK_UPSTREAM=
+
+usage() {
+    printf "Usage: %s [OPTION]... [FILE]...\n" "$(basename "$0")"
+    printf "Reformat source files using NetworkManager's code-style.\n\n"
+    printf "If no file is given the script runs on the whole codebase.\n"
+    printf "OPTIONS:\n"
+    printf "    -h                 Print this help message.\n"
+    printf "    -i                 Reformat files (the default).\n"
+    printf "    -n|--dry-run       Only check the files (contrary to \"-i\").\n"
+    printf "    -a|--all           Check all files (the default).\n"
+    printf "    -u|--upstream COMMIT Check only files from \`git diff --name-only COMMIT\` (contrary to \"-a\").\n"
+    printf "                       This also affects directories given in the [FILE] list, but not files.\n"
+    printf "                       If this is the last parameter and COMMIT is unspecified/empty, it defaults to \"main\".\n"
+    printf "    -F|--fast          Same as \`-u HEAD^\`.\n"
+    printf "    --show-filenames   Only print the filenames that would be checked/formatted\n"
+    printf "    --                 Separate options from filenames/directories\n"
+    if [ -n "${_NM_CODE_FORMAT_CONTAINER+x}" ] ; then
+        printf "\n"
+        printf "Command runs inside container image \"$_NM_CODE_FORMAT_CONTAINER\".\n"
+        printf "Delete/renew image with \`podman rmi \"$_NM_CODE_FORMAT_CONTAINER\"\`.\n"
+    fi
+}
+
+ls_files_exist() {
+    local OLD_IFS="$IFS"
+    local f
+
+    IFS=$'\n'
+    for f in $(cat) ; do
+        test -f "$f" && printf '%s\n' "$f"
+    done
+    IFS="$OLD_IFS"
+}
+
+ls_files_filter() {
+    local OLD_IFS="$IFS"
+    local f
+
+    IFS=$'\n'
+    for f in $(cat) ; do
+        local found=1
+        local p
+        for p; do
+            [[ "$f" = "$p/"* ]] && found=
+            [[ "$f" = "$p" ]] && found=
+        done
+        test -n "$found" && printf '%s\n' "$f"
+    done
+    IFS="$OLD_IFS"
+}
+
+g_ls_files() {
+    local pattern="$1"
+    shift
+
+    if [ -z "$CHECK_UPSTREAM" ]; then
+        git ls-files -- "$pattern"
+    else
+        git diff --no-renames --name-only "$CHECK_UPSTREAM" -- "$pattern" \
+            | ls_files_exist
+    fi | ls_files_filter "$@"
+}
+
+HAD_DASHDASH=0
+while (( $# )); do
+    if [ "$HAD_DASHDASH" = 0 ]; then
+        case "$1" in
+            -h)
+                usage
+                exit 0
+                ;;
+            --show-filenames)
+                SHOW_FILENAMES=1
+                shift
+                continue
+                ;;
+            -a|--all)
+                CHECK_UPSTREAM=
+                shift
+                continue
+                ;;
+            -u|--upstream)
+                shift
+                CHECK_UPSTREAM="$1"
+                test -n "$CHECK_UPSTREAM" || CHECK_UPSTREAM=main
+                shift || :
+                continue
+                ;;
+            -F|--fast)
+                CHECK_UPSTREAM='HEAD^'
+                shift
+                continue
+                ;;
+            -n|--dry-run)
+                TEST_ONLY=1
+                shift
+                continue
+                ;;
+            -i)
+                TEST_ONLY=0
+                shift
+                continue
+                ;;
+            --)
+                HAD_DASHDASH=1
+                shift
+                continue
+                ;;
+        esac
+    fi
+    if [ -d "$1" ]; then
+        while IFS='' read -r line;
+            do FILES+=("$line")
+        done < <(CHECK_UPSTREAM="$CHECK_UPSTREAM" g_ls_files "${1}/*.[hc]" "${EXCLUDE_PATHS[@]}")
+    elif [ -f "$1" ]; then
+        FILES+=("$1")
+    else
+        usage >&2
+        echo >&2
+        die "Unknown argument \"$1\" which also is neither a file nor a directory."
+    fi
+    shift
+    HAS_EXPLICIT_FILES=1
+done
+
+if [ $HAS_EXPLICIT_FILES = 0 ]; then
+    while IFS='' read -r line; do
+        FILES+=("$line")
+    done < <(CHECK_UPSTREAM="$CHECK_UPSTREAM" g_ls_files '*.[ch]' "${EXCLUDE_PATHS[@]}")
+fi
+
+if [ $SHOW_FILENAMES = 1 ]; then
+    for f in "${FILES[@]}" ; do
+        printf '%s\n' "$f"
+    done
+    exit 0
+fi
+
+if [ "${#FILES[@]}" = 0 ]; then
+    if [ -z "$CHECK_UPSTREAM" ]; then
+        die "Error: no files to check"
+    fi
+    exit 0
+fi
+
+FLAGS_TEST=( --Werror -n --ferror-limit=1 )
+
+if [ $TEST_ONLY = 1 ]; then
+    # We assume that all formatting is correct. In that mode, passing
+    # all filenames to clang-format is significantly faster.
+    #
+    # Only in case of an error, we iterate over the files one by one
+    # until we find the first invalid file.
+    for f in "${FILES[@]}"; do
+        [ -f "$f" ] || die "Error: file \"$f\" does not exist (or is not a regular file)"
+    done
+    clang-format "${FLAGS_TEST[@]}" "${FILES[@]}" &>/dev/null && exit 0
+    for f in "${FILES[@]}"; do
+        [ -f "$f" ] || die "Error: file \"$f\" does not exist (or is not a regular file)"
+        if ! clang-format "${FLAGS_TEST[@]}" "$f" &>/dev/null; then
+            FF="$(mktemp)"
+            trap 'rm -f "$FF"' EXIT
+            clang-format "$f" 2>/dev/null > "$FF"
+            git --no-pager diff "$f" "$FF" || :
+            die "Error: file \"$f\" has style issues."$'\n'"Fix it by running \`\"$0\" -i \"$f\"\` using $(clang-format --version)"
+        fi
+    done
+    die "an unknown error happened."
+fi
+
+clang-format -i "${FILES[@]}"
diff --git a/contrib/scripts/nm-copr-build-nm-git-bundle.sh b/contrib/scripts/nm-copr-build-nm-git-bundle.sh
new file mode 100755
index 00000000..5447a56e
--- /dev/null
+++ b/contrib/scripts/nm-copr-build-nm-git-bundle.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+
+# create a nm-git-bundle.git bundle and a SRPM for building it
+# as a package. This bundle contains the current git history
+# of upstream NetworkManager.
+#
+# The sole purpose of this is to fetch from the bundle to save
+# downloading the entire upstream git repository of NetworkManager.
+#
+# This script is also used by [1] to generate the SRPM.
+# [1] https://copr.fedorainfracloud.org/coprs/networkmanager/NetworkManager-main/package/nm-git-bundle/
+#
+# The purpose is the following. We build (many) NetworkManager packages in
+# copr. The build process runs a script (contrib/scripts/nm-copr-build.sh) that
+# fetches the git repository (and we cannot just do a shallow copy -- because
+# the version number is calculated by counts all the commits in the HEAD's
+# history).  NetworkManager's git repository is relatively large so fetching it
+# over and over is wasteful. The idea is to have a recent git-bundle of the
+# repository, which is hosted close-by in the copr infrastructure. So the build
+# script first tries to download the bundle to get the bulk of the git history,
+# before doing additional fetches from the upstream repository.  From time to
+# time, a new bundle has to be generated in copr.
+
+set -ex
+
+if [ -z "$GIT_URL" ]; then
+    GIT_URL=https://github.com/NetworkManager/NetworkManager
+    #GIT_URL=https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git
+fi
+
+git clone -n "$GIT_URL"
+
+pushd NetworkManager
+
+REFS=(
+    $(git branch -a | sed -n 's#^ *remotes/origin/\(main\|nm-1-[0-9]\+\)$#\1#p')
+)
+
+unset R
+unset H
+for R in "${REFS[@]}"; do
+    H="$(git show-ref --verify --hash "refs/remotes/origin/$R")"
+    git update-ref "refs/heads/$R" "$H"
+done
+
+git bundle create nm-git-bundle.git "${REFS[@]}"
+
+popd
+
+DIR="$(mktemp -d rpmbuild.XXXXXX)"
+
+mkdir -p "$DIR/SOURCES"
+mkdir -p "$DIR/SPECS"
+
+cat <<EOF > "$DIR/SPECS/nm-git-bundle.spec"
+Name: nm-git-bundle
+Version: $(date '+%Y%m%d')
+Release: $(date '+%H%M%S')
+Summary: git-bundle of NetworkManager upstream repository
+
+License: Public Domain
+URL: https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/tree/main/contrib/fedora/rpm/nm-git-bundle.spec
+
+%global GIT_URL 'https://github.com/NetworkManager/NetworkManager'
+#global GIT_URL 'https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git'
+
+Source0: nm-git-bundle.git
+
+
+BuildArch: noarch
+
+
+%description
+A git-bundle of NetworkManager upstream git repository. Useful to safe
+fetching the entire repository from the internet.
+
+
+%install
+mkdir -p %{buildroot}/usr/share/NetworkManager/
+cp %{SOURCE0} %{buildroot}/usr/share/NetworkManager/
+
+
+%files
+/usr/share/NetworkManager/nm-git-bundle.git
+EOF
+
+mv ./NetworkManager/nm-git-bundle.git "$DIR/SOURCES/"
+
+rpmbuild --define "_topdir $DIR"  -bs "$DIR/SPECS/nm-git-bundle.spec"
+
+mv "$DIR/SRPMS/"nm-git-bundle-*.src.rpm .
+mv "$DIR/SPECS/nm-git-bundle.spec" .
+mv "$DIR/SOURCES/nm-git-bundle.git" .
+rm -rf "$DIR"
+
diff --git a/contrib/scripts/nm-copr-build.sh b/contrib/scripts/nm-copr-build.sh
new file mode 100755
index 00000000..94c804fb
--- /dev/null
+++ b/contrib/scripts/nm-copr-build.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+
+# This is the build script used by our copr repository at
+#   https://copr.fedorainfracloud.org/coprs/networkmanager
+#
+# On a new upstream release, add new copr jobs named "NetworkManager-X.Y" and
+# "NetworkManager-X.Y-debug".
+#
+#   - best, look at the latest copr project and replicate the settings.
+#   - add a custom build with the following script:
+#
+#        #!/bin/bash
+#        export GIT_REF=nm-$X-$Y
+#        export DEBUG=0/1
+#        export LTO=
+#        curl https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/raw/main/contrib/scripts/nm-copr-build.sh | bash
+#
+#   - for certain CentOS/EPEL you need to add https://copr.fedorainfracloud.org/coprs/nmstate/nm-build-deps/
+#     as build chroot. See under "Settings/Project Details" for the latest copr project.
+#   - go to "Settings/Integrations" and find the notification URL for the project. Then
+#     go to https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/hooks and add
+#     a push event for the "nm-$X-$Y" branch.
+#
+# environment variables for this script:
+# - GIT_REF: the ref that should be build. Can be "main" or a git sha.
+# - DEBUG: set to 1 to build "--with debug". Otherwise the default is a release
+#     build.
+# - LTO: set to 1/0 to build "--with/--without lto", otherwise the default depends
+#     on the distribution.
+# - NM_GIT_BUNDLE: set to a HTTP url where to fetch the nm-git-bundle-*.noarch.rpm
+#     from. Set to empty to skip it. By default, it fetches the bundle from copr.
+#     See "contrib/scripts/nm-copr-build-nm-git-bundle.sh" script and
+#     https://copr.fedorainfracloud.org/coprs/networkmanager/NetworkManager-main/package/nm-git-bundle/
+
+set -ex
+
+if [[ "$DEBUG" == 1 ]]; then
+    DEBUG="--with debug"
+else
+    DEBUG="--without debug"
+fi
+
+if [ "$LTO" = 0 ]; then
+    LTO='--without lto'
+elif [ "$LTO" = 1 ]; then
+    LTO='--with lto'
+else
+    LTO=
+fi
+
+if [[ -z "$GIT_REF" ]]; then
+    echo "\$GIT_REF is not set!"
+    exit 1
+fi
+
+mkdir NetworkManager
+pushd NetworkManager
+git init .
+
+git remote add origin https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git
+git remote add --no-tags github https://github.com/NetworkManager/NetworkManager
+
+get_nm_git_bundle() {
+    # try to fetch the refs from nm-git-bundle.
+    #
+    # This script runs in copr infrastructure to create the SRPM.
+    # The idea is that this URL is close and downloading it is cheaper
+    # than fetching everything from upstream git.
+    if [ -z "$NM_GIT_BUNDLE" ]; then
+        if [ -n "${NM_GIT_BUNDLE+x}" ]; then
+            return 0
+        fi
+        NM_GIT_BUNDLE='https://download.copr.fedorainfracloud.org/results/networkmanager/NetworkManager-main/fedora-38-x86_64/06008259-nm-git-bundle/nm-git-bundle-20230606-102458.noarch.rpm'
+    fi
+    mkdir nm-git-bundle
+    pushd nm-git-bundle
+    time curl "$NM_GIT_BUNDLE" \
+      | rpm2cpio - \
+      | cpio -idmv
+    popd
+    git remote add nm-git-bundle "$PWD/nm-git-bundle/usr/share/NetworkManager/nm-git-bundle.git"
+    git fetch nm-git-bundle
+}
+
+get_nm_git_bundle
+git fetch github
+git fetch origin
+git remote remove nm-git-bundle || true
+
+GIT_SHA="$(git show-ref --verify --hash "$GIT_REF" 2>/dev/null ||
+           git show-ref --verify --hash "refs/remotes/origin/$GIT_REF" 2>/dev/null ||
+           git rev-parse --verify "refs/remotes/origin/$GIT_REF" 2>/dev/null ||
+           git rev-parse --verify "$GIT_REF^{commit}" 2>/dev/null)"
+
+git checkout -b tmp "$GIT_SHA"
+
+./contrib/fedora/rpm/build_clean.sh -g -S -w test $DEBUG $LTO -s copr
+popd
+
+mv ./NetworkManager/contrib/fedora/rpm/latest/{SOURCES,SPECS}/* .
+rm -rf ./NetworkManager
diff --git a/contrib/scripts/nm-import-openconnect b/contrib/scripts/nm-import-openconnect
new file mode 100755
index 00000000..f14895d3
--- /dev/null
+++ b/contrib/scripts/nm-import-openconnect
@@ -0,0 +1,261 @@
+#!/usr/bin/env lua
+-- SPDX-License-Identifier: GPL-2.0-or-later
+--
+-- Copyright (C) 2015 Red Hat, Inc.
+--
+
+-- Script for importing/converting OpenConnect VPN configuration files for NetworkManager
+-- In general, the implementation follows the logic of import() from
+-- https://git.gnome.org/browse/network-manager-openconnect/tree/properties/nm-openconnect.c
+
+----------------------
+-- Helper functions --
+----------------------
+function read_all(in_file)
+  local f, msg = io.open(in_file, "r")
+  if not f then return nil, msg; end
+  local content = f:read("*all")
+  f:close()
+  return content
+end
+
+function uuid()
+  math.randomseed(os.time())
+  local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
+  local uuid = string.gsub(template, '[xy]', function (c)
+    local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
+    return string.format('%x', v)
+  end)
+  return uuid
+end
+
+function vpn_settings_to_text(vpn_settings)
+  local t = {}
+  for k,v in pairs(vpn_settings) do
+    t[#t+1] = k.."="..v
+  end
+  return table.concat(t, "\n")
+end
+
+function usage()
+  local basename = string.match(arg[0], '[^/\\]+$') or arg[0]
+  print(basename .. " - convert/import OpenConnect VPN configuration to NetworkManager")
+  print("Usage:")
+  print("  " .. basename .. " <input-file> <output-file>")
+  print("    - converts OpenConnect VPN config to NetworkManager keyfile")
+  print("")
+  print("  " .. basename .. " --import <input-file1> <input-file2> ...")
+  print("    - imports OpenConnect VPN config(s) to NetworkManager")
+  os.exit(1)
+end
+
+
+-------------------------------------------
+-- Functions for VPN options translation --
+-------------------------------------------
+function handle_yes(t, option, value)
+  t[option] = "yes"
+end
+function handle_generic(t, option, value)
+  if not value[2] then io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1])) end
+  t[option] = value[2]
+end
+
+-- global variables
+g_con_data = {}
+g_vpn_data = {}
+
+vpn2nm = {
+  ["Description"]     = { nm_opt="id",                  func=handle_generic, tbl=g_con_data },
+  ["Host"]            = { nm_opt="gateway",             func=handle_generic, tbl=g_vpn_data },
+  ["CACert"]          = { nm_opt="cacert",              func=handle_generic, tbl=g_vpn_data },
+  ["Proxy"]           = { nm_opt="proxy",               func=handle_generic, tbl=g_vpn_data },
+  ["CSDEnable"]       = { nm_opt="enable_csd_trojan",   func=handle_yes,     tbl=g_vpn_data },
+  ["CSDWrapper"]      = { nm_opt="csd_wrapper",         func=handle_generic, tbl=g_vpn_data },
+  ["UserCertificate"] = { nm_opt="usercert",            func=handle_generic, tbl=g_vpn_data },
+  ["PrivateKey"]      = { nm_opt="userkey",             func=handle_generic, tbl=g_vpn_data },
+  ["FSID"]            = { nm_opt="pem_passphrase_fsid", func=handle_yes,     tbl=g_vpn_data },
+  ["StokenSource"]    = { nm_opt="stoken_source",       func=handle_generic, tbl=g_vpn_data },
+  ["StokenString"]    = { nm_opt="stoken_string",       func=handle_generic, tbl=g_vpn_data },
+}
+
+------------------------------------------------------
+-- Read and convert the config into the global vars --
+------------------------------------------------------
+function read_and_convert(in_file)
+  local function line_split(str)
+    -- split at '=' character
+    local sep, fields = "=", {}
+    local pattern = string.format("([^%s]+)%s(.+)", sep, sep)
+    fields[1], fields[2] = str:match(pattern)
+    return fields
+  end
+
+  in_text, msg = read_all(in_file)
+  if not in_text then return false, msg end
+
+  -- loop through the config and convert it
+  for line in in_text:gmatch("[^\r\n]+") do
+    repeat
+      -- skip comments and empty lines
+      if line:find("^%s*[#;]") or line:find("^%s*$") then break end
+      -- trim leading and trailing spaces
+      line = line:find("^%s*$") and "" or line:match("^%s*(.*%S)")
+
+      local words = line_split(line)
+      local val = vpn2nm[words[1]]
+      if val then
+        if type(val) == "table" then val.func(val.tbl, val.nm_opt, words)
+        else print(string.format("debug: '%s' : val=%s"..val)) end
+      end
+    until true
+  end
+
+  -- check mandatory parameters
+  if not g_vpn_data["gateway"] then
+    local msg = in_file .. ": Not a valid OpenConnect VPN configuration"
+    return false, msg
+  end
+  return true
+end
+
+--------------------------------------------------------
+-- Create and write connection file in keyfile format --
+--------------------------------------------------------
+function write_vpn_to_keyfile(in_file, out_file)
+  connection = [[
+[connection]
+id=__NAME_PLACEHOLDER__
+uuid=__UUID_PLACEHOLDER__
+type=vpn
+autoconnect=no
+
+[ipv4]
+method=auto
+never-default=true
+
+[ipv6]
+method=auto
+
+[vpn]
+service-type=org.freedesktop.NetworkManager.openconnect
+]]
+
+  connection = connection .. vpn_settings_to_text(g_vpn_data)
+
+  local con_name = g_con_data["id"] or (out_file:gsub(".*/", ""))
+  connection = string.gsub(connection, "__NAME_PLACEHOLDER__", con_name)
+  connection = string.gsub(connection, "__UUID_PLACEHOLDER__", uuid())
+
+  -- write output file
+  local f, err = io.open(out_file, "w")
+  if not f then io.stderr:write(err) return false end
+  f:write(connection)
+  f:close()
+
+  local ofname = out_file:gsub(".*/", "")
+  io.stderr:write("Successfully converted VPN configuration: " .. in_file .. " => " .. out_file .. "\n")
+  io.stderr:write("To use the connection, do:\n")
+  io.stderr:write("# cp " .. out_file .. " /etc/NetworkManager/system-connections\n")
+  io.stderr:write("# chmod 600 /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  io.stderr:write("# nmcli con load /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  return true
+end
+
+---------------------------------------------
+-- Import VPN connection to NetworkManager --
+---------------------------------------------
+function import_vpn_to_NM(filename)
+  local lgi = require 'lgi'
+  local GLib = lgi.GLib
+  local NM = lgi.NM
+
+  -- function creating NMConnection
+  local function create_profile(name)
+    local profile = NM.SimpleConnection.new()
+
+    s_con = NM.SettingConnection.new()
+    s_vpn = NM.SettingVpn.new()
+    s_con[NM.SETTING_CONNECTION_ID] = name
+    s_con[NM.SETTING_CONNECTION_UUID] = uuid()
+    s_con[NM.SETTING_CONNECTION_TYPE] = "vpn"
+    s_vpn[NM.SETTING_VPN_SERVICE_TYPE] = "org.freedesktop.NetworkManager.openconnect"
+    for k,v in pairs(g_vpn_data) do
+      s_vpn:add_data_item(k, v)
+    end
+
+    profile:add_setting(s_con)
+    profile:add_setting(s_vpn)
+    return profile
+  end
+
+  -- callback function for add_connection()
+  local function added_cb(client, result, data)
+    local con,err,code = client:add_connection_finish(result)
+    if con then
+      print(string.format("%s: Imported to NetworkManager: %s - %s",
+                          filename, con:get_uuid(), con:get_id()))
+    else
+      io.stderr:write(code .. ": " .. err .. "\n");
+      return false
+    end
+    main_loop:quit()
+  end
+
+  local profile_name = g_con_data["id"] or string.match(filename, '[^/\\]+$') or filename
+  main_loop = GLib.MainLoop(nil, false)
+  local con = create_profile(profile_name)
+  local client = NM.Client.new()
+
+  -- send the connection to NetworkManager
+  client:add_connection_async(con, true, nil, added_cb, nil)
+
+  -- run main loop so that the callback could be called
+  main_loop:run()
+  return true
+end
+
+
+---------------------------
+-- Main code starts here --
+---------------------------
+local import_mode = false
+local infile, outfile
+
+-- parse command-line arguments
+if not arg[1] or arg[1] == "--help" or arg[1] == "-h" then usage() end
+if arg[1] == "--import" or arg[1] == "-i" then
+  infile = arg[2]
+  if not infile then usage() end
+  import_mode = true
+else
+  infile  = arg[1]
+  outfile = arg[2]
+  if not infile or not outfile then usage() end
+  if arg[3] then usage() end
+end
+
+if import_mode then
+  -- check if lgi is available
+  local success,msg = pcall(require, 'lgi')
+  if not success then
+    io.stderr:write("Lua lgi module is not available, please install it (usually lua-lgi package)\n")
+    -- print(msg)
+    os.exit(1)
+  end
+  -- read configs, convert them and import to NM
+  for i = 2, #arg do
+    ok, err_msg = read_and_convert(arg[i])
+    if ok then import_vpn_to_NM(arg[i])
+    else io.stderr:write(err_msg .. "\n") end
+    -- reset global vars
+    g_con_data = {}
+    g_vpn_data = {}
+  end
+else
+  -- read configs, convert them and write as NM keyfile connection
+  ok, err_msg = read_and_convert(infile)
+  if ok then write_vpn_to_keyfile(infile, outfile)
+  else io.stderr:write(err_msg .. "\n") end
+end
+
diff --git a/contrib/scripts/nm-import-openvpn b/contrib/scripts/nm-import-openvpn
new file mode 100755
index 00000000..6c9f39c4
--- /dev/null
+++ b/contrib/scripts/nm-import-openvpn
@@ -0,0 +1,543 @@
+#!/usr/bin/env lua
+-- SPDX-License-Identifier: GPL-2.0-or-later
+--
+-- Copyright (C) 2015 Red Hat, Inc.
+--
+
+-- Script for importing/converting OpenVPN configuration files for NetworkManager
+-- In general, the implementation follows the logic of import() from
+-- https://git.gnome.org/browse/network-manager-openvpn/tree/properties/import-export.c
+
+
+----------------------
+-- Helper functions --
+----------------------
+function read_all(in_file)
+  local f, msg = io.open(in_file, "r")
+  if not f then return nil, msg; end
+  local content = f:read("*all")
+  f:close()
+  return content
+end
+
+function uuid()
+  math.randomseed(os.time())
+  local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
+  local uuid = string.gsub(template, '[xy]', function (c)
+    local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
+    return string.format('%x', v)
+  end)
+  return uuid
+end
+
+function unquote(str)
+  return (string.gsub(str, "^([\"\'])(.*)%1$", "%2"))
+end
+
+function parse_ipv4_to_bytes(ip_addr)
+  local b1,b2,b3,b4 = ip_addr:match("^(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)$")
+  b1 = tonumber(b1)
+  b2 = tonumber(b2)
+  b3 = tonumber(b3)
+  b4 = tonumber(b4)
+  return b1, b2, b3, b4
+end
+
+function is_ipv4(ip_addr)
+  local b1,b2,b3,b4 = parse_ipv4_to_bytes(ip_addr)
+  if not b1 or (b1 > 255) then return false end
+  if not b2 or (b2 > 255) then return false end
+  if not b3 or (b3 > 255) then return false end
+  if not b4 or (b4 > 255) then return false end
+  return true
+end
+
+function ip_mask_to_prefix(mask)
+  local b, prefix
+  local b1,b2,b3,b4 = parse_ipv4_to_bytes(mask)
+
+  if b4 ~= 0 then
+    prefix = 24
+    b = b4
+  elseif b3 ~= 0 then
+    prefix = 16
+    b = b3
+  elseif b2 ~= 0 then
+    prefix = 8
+    b = b2
+  else
+    prefix = 0
+    b = b1
+  end
+  while b ~= 0 do
+    prefix = prefix + 1
+    b = bit32.band(0x000000FF, bit32.lshift(b, 1))
+  end
+  return prefix
+end
+
+function vpn_settings_to_text(vpn_settings)
+  local t = {}
+  for k,v in pairs(vpn_settings) do
+    t[#t+1] = k.."="..v
+  end
+  return table.concat(t, "\n")
+end
+
+function usage()
+  local basename = string.match(arg[0], '[^/\\]+$') or arg[0]
+  print(basename .. " - convert/import OpenVPN configuration to NetworkManager")
+  print("Usage:")
+  print("  " .. basename .. " <input-file> <output-file>")
+  print("    - converts OpenVPN config to NetworkManager keyfile")
+  print("")
+  print("  " .. basename .. " --import <input-file1> <input-file2> ...")
+  print("    - imports OpenVPN config(s) to NetworkManager")
+  os.exit(1)
+end
+
+
+-------------------------------------------
+-- Functions for VPN options translation --
+-------------------------------------------
+function set_bool(t, option, value)
+  g_switches[option] = true
+end
+function handle_yes(t, option, value)
+  t[option] = "yes"
+end
+function handle_generic(t, option, value)
+  if not value[2] then io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1])) return end
+  t[option] = value[2]
+end
+function handle_generic_unquote(t, option, value)
+  if not value[2] then io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1])) return end
+  t[option] = unquote(value[2])
+end
+function handle_number(t, option, value)
+  if not value[2] then io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1])) return end
+  if not tonumber(value[2]) then
+    io.stderr:write(string.format("Warning: ignoring not numeric value '%s' for option '%s'\n", value[2], value[1]))
+    return
+  end
+  t[option] = value[2]
+end
+function handle_proto(t, option, value)
+  if not value[2] then io.stderr:write("Warning: ignoring invalid option 'proto'\n") end
+  if value[2] == "tcp" or value[3] == "tcp-client" or value[2] == "tcp-server" then
+    t[option] = "yes"
+  end
+end
+function handle_comp_lzo(t, option, value)
+  value[2] = value[2] or "adaptive"
+  if value[2] == "no" then
+    value[2] = "no-by-default"
+  elseif value[2] ~= "yes" and value[2] ~= "adaptive" then
+    io.stderr:write(string.format("Warning: ignoring invalid argument '%s' in option 'comp-lzo'\n", value[2]))
+    return
+  end
+  t[option] = value[2]
+end
+function handle_dev_type(t, option, value)
+  if value[2] ~= "tun" and value[2] ~= "tap" then
+    io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1]))
+  end
+  t[option] = value[2]
+end
+function handle_remote(t, option, value)
+  local rem
+  if not value[2] then io.stderr:write("Warning: ignoring invalid option 'remote'\n") return end
+  rem = value[2]
+  if tonumber(value[3]) then
+    rem = rem .. ":" .. value[3]
+  end
+  if value[4] == "udp" or value[4] == "tcp" then
+    rem = rem .. ":" .. value[4]
+  end
+  if t[option] then
+    t[option] = t[option] .. " " .. rem
+  else
+    t[option] = rem
+  end
+  g_switches[value[1]] = true
+end
+function handle_port(t, option, value)
+  if tonumber(value[2]) then
+    t[option] = value[2]
+  end
+end
+function handle_proxy(t, option, value)
+  if not value[2] then io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1])) return end
+  if value[4] then io.stderr:write(string.format("Warning: the third argument of '%s' is not supported yet\n", value[1])) end
+  t[option[1]] = string.gsub(value[1], "-proxy", "")
+  t[option[2]] = value[2]
+  t[option[3]] = value[3]
+end
+function handle_ifconfig(t, option, value)
+  if not (value[2] and value[3]) then io.stderr:write("Warning: ignoring invalid option 'ifconfig'\n") return end
+  t[option[1]] = value[2]
+  t[option[2]] = value[3]
+end
+function handle_keepalive(t, option, value)
+  if (not (value[2] and value[3])) or (not tonumber(value[2]) or not tonumber(value[3])) then
+    io.stderr:write("Warning: ignoring invalid option 'keepalive'; two numbers required\n")
+    return
+  end
+  t[option[1]] = value[2]
+  t[option[2]] = value[3]
+end
+function handle_path(t, option, value)
+  if value[1] == "pkcs12" then
+    t["ca"] = value[2]
+    t["cert"] = value[2]
+    t["key"] = value[2]
+  else
+    t[option] = value[2]
+  end
+end
+function handle_secret(t, option, value)
+  t[option[1]] = value[2]
+  t[option[2]] = value[3]
+  g_switches[value[1]]= true
+end
+function handle_remote_cert_tls(t, option, value)
+  if value[2] ~= "client" and value[2] ~= "server" then
+    io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1]))
+    return
+  end
+  t[option] = value[2]
+end
+function handle_routes(t, option, value)
+  if not value[2] then io.stderr:write("Warning: invalid option 'route'\n") return end
+  netmask = (value[3] and value[3] ~= "default") and value[3] or "255.255.255.255"
+  gateway = (value[4] and value[4] ~= "default") and value[4] or "0.0.0.0"
+  metric  = (value[5] and value[5] ~= "default") and value[5] or "0"
+
+  if not is_ipv4(value[2]) then
+    if value[2] == "vpn_gateway" or value[2] == "net_gateway" or value[2] == "remote_host" then
+      io.stderr:write(string.format("Warning: sorry, the '%s' keyword is not supported by NetworkManager in option '%s'\n",
+                      value[2], value[1]))
+    else
+      io.stderr:write(string.format("Warning: '%s' is not a valid IPv4 address in option '%s'\n", value[2], value[1]))
+    end
+    return
+  end
+  if not is_ipv4(netmask) then
+    io.stderr:write(string.format("Warning: '%s' is not a valid IPv4 netmask in option '%s'\n", netmask, value[1]))
+    return
+  end
+  if not is_ipv4(gateway) then
+    if gateway == "vpn_gateway" or gateway == "net_gateway" or gateway == "remote_host" then
+      io.stderr:write(string.format("Warning: sorry, the '%s' keyword is not supported by NetworkManager in option '%s'\n",
+                      gateway, value[1]))
+    else
+      io.stderr:write(string.format("Warning: '%s' is not a valid IPv4 gateway in option '%s'\n", gateway, value[1]))
+    end
+    return
+  end
+  if not tonumber(metric) then
+    io.stderr:write(string.format("Warning: '%s' is not a valid metric in option '%s'\n", metric, value[1]))
+    return
+  end
+
+  if not t[option] then t[option] = {} end
+  t[option][#t[option]+1] = {value[2], netmask, gateway, metric}
+end
+function handle_verify_x509_name(t, option, value)
+  if not value[2] then io.stderr:write("Warning: missing argument in option 'verify-x509-name'\n") return end
+  value[2] = unquote(value[2])
+  value[3] = value[3] or "subject"
+  if value[3] ~= "subject" and value[3] ~= "name" and value[3] ~= "name-prefix" then
+    io.stderr:write(string.format("Warning: ignoring invalid value '%s' for type in option '%s'\n", value[3], value[1]))
+    return
+  end
+  t[option] = value[3] .. ":" .. value[2]
+end
+
+-- global variables
+g_vpn_data = {}
+g_ip4_data = {}
+g_switches = {}
+
+vpn2nm = {
+  ["auth"]              = { nm_opt="auth",             func=handle_generic,         tbl=g_vpn_data },
+  ["auth-user-pass"]    = { nm_opt="auth-user-pass",   func=set_bool,               tbl={} },
+  ["ca"]                = { nm_opt="ca",               func=handle_path,            tbl=g_vpn_data },
+  ["cert"]              = { nm_opt="cert",             func=handle_path,            tbl=g_vpn_data },
+  ["cipher"]            = { nm_opt="cipher",           func=handle_generic,         tbl=g_vpn_data },
+  ["client"]            = { nm_opt="client",           func=set_bool,               tbl={} },
+  ["comp-lzo"]          = { nm_opt="comp-lzo",         func=handle_comp_lzo,        tbl=g_vpn_data },
+  ["dev"]               = { nm_opt="dev",              func=handle_generic,         tbl=g_vpn_data },
+  ["dev-type"]          = { nm_opt="dev-type",         func=handle_dev_type,        tbl=g_vpn_data },
+  ["float"]             = { nm_opt="float",            func=handle_yes,             tbl=g_vpn_data },
+  ["fragment"]          = { nm_opt="fragment-size",    func=handle_generic,         tbl=g_vpn_data },
+  ["http-proxy"]        = { nm_opt={"proxy-type", "proxy-server", "proxy-port"}, func=handle_proxy, tbl=g_vpn_data },
+  ["http-proxy-retry"]  = { nm_opt="proxy-retry",      func=handle_yes,             tbl=g_vpn_data },
+  ["ifconfig"]          = { nm_opt={"local-ip", "remote-ip"}, func=handle_ifconfig, tbl=g_vpn_data },
+  ["keepalive"]         = { nm_opt={"ping", "ping-restart"}, func=handle_keepalive, tbl=g_vpn_data },
+  ["key"]               = { nm_opt="key",              func=handle_path,            tbl=g_vpn_data },
+  ["keysize"]           = { nm_opt="keysize",          func=handle_generic,         tbl=g_vpn_data },
+  ["max-routes"]        = { nm_opt="max-routes",       func=handle_number,          tbl=g_vpn_data },
+  ["mssfix"]            = { nm_opt="mssfix",           func=handle_yes,             tbl=g_vpn_data },
+  ["ns-cert-type"]      = { nm_opt="ns-cert-type",     func=handle_remote_cert_tls, tbl=g_vpn_data },
+  ["ping"]              = { nm_opt="ping",             func=handle_number,          tbl=g_vpn_data },
+  ["ping-exit"]         = { nm_opt="ping-exit",        func=handle_number,          tbl=g_vpn_data },
+  ["ping-restart"]      = { nm_opt="ping-restart",     func=handle_number,          tbl=g_vpn_data },
+  ["pkcs12"]            = { nm_opt="client",           func=handle_path,            tbl=g_vpn_data },
+  ["port"]              = { nm_opt="port",             func=handle_port,            tbl=g_vpn_data },
+  ["proto"]             = { nm_opt="proto-tcp",        func=handle_proto,           tbl=g_vpn_data },
+  ["remote"]            = { nm_opt="remote",           func=handle_remote,          tbl=g_vpn_data },
+  ["remote-cert-tls"]   = { nm_opt="remote-cert-tls",  func=handle_remote_cert_tls, tbl=g_vpn_data },
+  ["remote-random"]     = { nm_opt="remote-random",    func=handle_yes,             tbl=g_vpn_data },
+  ["reneg-sec"]         = { nm_opt="reneg-seconds",    func=handle_generic,         tbl=g_vpn_data },
+  ["route"]             = { nm_opt="routes",           func=handle_routes,          tbl=g_ip4_data },
+  ["rport"]             = { nm_opt="port",             func=handle_port,            tbl=g_vpn_data },
+  ["secret"]            = { nm_opt={"static-key", "static-key-direction"}, func=handle_secret, tbl=g_vpn_data },
+  ["socks-proxy"]       = { nm_opt={"proxy-type", "proxy-server", "proxy-port"}, func=handle_proxy, tbl=g_vpn_data },
+  ["socks-proxy-retry"] = { nm_opt="proxy-retry",      func=handle_yes,             tbl=g_vpn_data },
+  ["tls-auth"]          = { nm_opt={"ta", "ta-dir"},   func=handle_secret,          tbl=g_vpn_data },
+  ["tls-cipher"]        = { nm_opt="tls-cipher",       func=handle_generic_unquote, tbl=g_vpn_data },
+  ["tls-client"]        = { nm_opt="client",           func=set_bool,               tbl={} },
+  ["tls-remote"]        = { nm_opt="tls-remote",       func=handle_generic_unquote, tbl=g_vpn_data },
+  ["tun-ipv6"]          = { nm_opt="tun-ipv6",         func=handle_yes,             tbl=g_vpn_data },
+  ["tun-mtu"]           = { nm_opt="tunnel-mtu",       func=handle_generic,         tbl=g_vpn_data },
+  ["verify-x509-name"]  = { nm_opt="verify-x509-name", func=handle_verify_x509_name,tbl=g_vpn_data },
+}
+
+------------------------------------------------------------
+-- Read and convert the config into the global g_vpn_data --
+-----------------------------------------------------------
+function read_and_convert(in_file)
+  local function line_split(line)
+    local t={}
+    local i, idx = 1, 1
+    local delim = "\""
+    while true do
+      local a,b = line:find("%S+", idx)
+      if not a then break end
+
+      local str = line:sub(a,b)
+      local quote = nil
+      if str:sub(1,1) == delim and str:sub(#str,#str) ~= delim then
+        quote = (line.." "):find(delim.."%s", b + 1)
+      end
+
+      if quote then
+        t[i] = line:sub(a, quote)
+        idx = quote + 1
+      else
+        t[i] = str
+        idx = b + 1
+      end
+      i = i + 1
+    end
+    return t
+  end
+
+  in_text, msg = read_all(in_file)
+  if not in_text then return false, msg end
+
+  -- loop through the config and convert it
+  for line in in_text:gmatch("[^\r\n]+") do
+    repeat
+      -- skip comments and empty lines
+      if line:find("^%s*[#;]") or line:find("^%s*$") then break end
+      -- trim leading and trailing spaces
+      line = line:find("^%s*$") and "" or line:match("^%s*(.*%S)")
+
+      local words = line_split(line)
+      local val = vpn2nm[words[1]]
+      if val then
+        if type(val) == "table" then val.func(val.tbl, val.nm_opt, words)
+        else print(string.format("debug: '%s' : val=%s"..val)) end
+      end
+    until true
+  end
+
+  -- check some inter-option dependencies
+  if not g_switches["client"] and not g_switches["secret"] then
+    local msg = in_file .. ": Not a valid OpenVPN client configuration"
+    return false, msg
+  end
+  if not g_switches["remote"] then
+    local msg = in_file .. ": Not a valid OpenVPN configuration (no remote)"
+    return false, msg
+  end
+
+  -- set 'connection-type'
+  g_vpn_data["connection-type"] = "tls"
+  have_sk = g_switches["secret"] ~= nil
+  have_ca = g_vpn_data["ca"] ~= nil
+  have_certs = ve_ca and g_vpn_data["cert"] and g_vpn_data["key"]
+  if g_switches["auth-user-pass"] then
+    if have_certs then
+      g_vpn_data["connection-type"] = "password-tls"
+    elseif have_ca then
+      g_vpn_data["connection-type"] = "tls"
+    end
+  elseif have_certs then g_vpn_data["connection-type"] = "tls"
+  elseif have_sk then g_vpn_data["connection-type"] = "static-key"
+  end
+  return true
+end
+
+
+--------------------------------------------------------
+-- Create and write connection file in keyfile format --
+--------------------------------------------------------
+function write_vpn_to_keyfile(in_file, out_file)
+  connection = [[
+[connection]
+id=__NAME_PLACEHOLDER__
+uuid=__UUID_PLACEHOLDER__
+type=vpn
+autoconnect=no
+
+[ipv4]
+method=auto
+never-default=true
+__ROUTES_PLACEHOLDER__
+
+[ipv6]
+method=auto
+
+[vpn]
+service-type=org.freedesktop.NetworkManager.openvpn
+]]
+  connection = connection .. vpn_settings_to_text(g_vpn_data)
+
+  local routes = ""
+  for idx, r in ipairs(g_ip4_data["routes"] or {}) do
+    routes = routes .. string.format("routes%d=%s/%s,%s,%s\n",
+                                     idx, r[1], ip_mask_to_prefix(r[2]), r[3], r[4])
+  end
+
+  connection = string.gsub(connection, "__NAME_PLACEHOLDER__", (out_file:gsub(".*/", "")))
+  connection = string.gsub(connection, "__UUID_PLACEHOLDER__", uuid())
+  connection = string.gsub(connection, "__ROUTES_PLACEHOLDER__\n", routes)
+
+  -- write output file
+  local f, err = io.open(out_file, "w")
+  if not f then io.stderr:write(err) return false end
+  f:write(connection)
+  f:close()
+
+  local ofname = out_file:gsub(".*/", "")
+  io.stderr:write("Successfully converted VPN configuration: " .. in_file .. " => " .. out_file .. "\n")
+  io.stderr:write("To use the connection, do:\n")
+  io.stderr:write("# cp " .. out_file .. " /etc/NetworkManager/system-connections\n")
+  io.stderr:write("# chmod 600 /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  io.stderr:write("# nmcli con load /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  return true
+end
+
+---------------------------------------------
+-- Import VPN connection to NetworkManager --
+---------------------------------------------
+function import_vpn_to_NM(filename)
+  local lgi = require 'lgi'
+  local GLib = lgi.GLib
+  local NM = lgi.NM
+
+  -- function creating NMConnection
+  local function create_profile(name)
+    local profile = NM.SimpleConnection.new()
+
+    s_con = NM.SettingConnection.new()
+    s_ip4 = NM.SettingIP4Config.new()
+    s_vpn = NM.SettingVpn.new()
+    s_con[NM.SETTING_CONNECTION_ID] = name
+    s_con[NM.SETTING_CONNECTION_UUID] = uuid()
+    s_ip4[NM.SETTING_IP_CONFIG_METHOD] = NM.SETTING_IP4_CONFIG_METHOD_AUTO
+    s_con[NM.SETTING_CONNECTION_TYPE] = "vpn"
+    s_vpn[NM.SETTING_VPN_SERVICE_TYPE] = "org.freedesktop.NetworkManager.openvpn"
+
+    -- add routes
+    local AF_INET = 2
+    for _, r in ipairs(g_ip4_data["routes"] or {}) do
+      route = NM.IPRoute.new(AF_INET, r[1], ip_mask_to_prefix(r[2]), r[3], r[4])
+      s_ip4:add_route(route)
+    end
+
+    -- add vpn data
+    for k,v in pairs(g_vpn_data) do
+      s_vpn:add_data_item(k, v)
+    end
+
+    profile:add_setting(s_con)
+    profile:add_setting(s_vpn)
+    profile:add_setting(s_ip4)
+    return profile
+  end
+
+  -- callback function for add_connection()
+  local function added_cb(client, result, data)
+    local con,err,code = client:add_connection_finish(result)
+    if con then
+      print(string.format("%s: Imported to NetworkManager: %s - %s",
+                          filename, con:get_uuid(), con:get_id()))
+    else
+      io.stderr:write(code .. ": " .. err .. "\n");
+      return false
+    end
+    main_loop:quit()
+  end
+
+  local profile_name = string.match(filename, '[^/\\]+$') or filename
+  main_loop = GLib.MainLoop(nil, false)
+  local con = create_profile(profile_name)
+  local client = NM.Client.new()
+
+  -- send the connection to NetworkManager
+  client:add_connection_async(con, true, nil, added_cb, nil)
+
+  -- run main loop so that the callback could be called
+  main_loop:run()
+  return true
+end
+
+
+---------------------------
+-- Main code starts here --
+---------------------------
+local import_mode = false
+local infile, outfile
+
+-- parse command-line arguments
+if not arg[1] or arg[1] == "--help" or arg[1] == "-h" then usage() end
+if arg[1] == "--import" or arg[1] == "-i" then
+  infile = arg[2]
+  if not infile then usage() end
+  import_mode = true
+else
+  infile  = arg[1]
+  outfile = arg[2]
+  if not infile or not outfile then usage() end
+  if arg[3] then usage() end
+end
+
+if import_mode then
+  -- check if lgi is available
+  local success,msg = pcall(require, 'lgi')
+  if not success then
+    io.stderr:write("Lua lgi module is not available, please install it (usually lua-lgi package)\n")
+    -- print(msg)
+    os.exit(1)
+  end
+  -- read configs, convert them and import to NM
+  for i = 2, #arg do
+    ok, err_msg = read_and_convert(arg[i])
+    if ok then import_vpn_to_NM(arg[i])
+    else io.stderr:write(err_msg .. "\n") end
+    -- reset global vars
+    g_vpn_data = {}
+    g_ip4_data = {}
+    g_switches = {}
+  end
+else
+  -- read configs, convert them and write as NM keyfile connection
+  ok, err_msg = read_and_convert(infile)
+  if ok then write_vpn_to_keyfile(infile, outfile)
+  else io.stderr:write(err_msg .. "\n") end
+end
+
diff --git a/contrib/scripts/nm-import-vpnc b/contrib/scripts/nm-import-vpnc
new file mode 100755
index 00000000..f7d5debb
--- /dev/null
+++ b/contrib/scripts/nm-import-vpnc
@@ -0,0 +1,416 @@
+#!/usr/bin/env lua
+-- SPDX-License-Identifier: GPL-2.0-or-later
+--
+-- Copyright (C) 2015 Red Hat, Inc.
+--
+
+-- Script for importing/converting Cisco VPN configuration files (.pcf) to NetworkManager
+-- In general, the implementation follows the logic of import() from
+-- https://git.gnome.org/browse/network-manager-vpnc/tree/properties/nm-vpnc.c
+
+----------------------
+-- Helper functions --
+----------------------
+function read_all(in_file)
+  local f, msg = io.open(in_file, "r")
+  if not f then return nil, msg; end
+  local content = f:read("*all")
+  f:close()
+  return content
+end
+
+function uuid()
+  math.randomseed(os.time())
+  local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
+  local uuid = string.gsub(template, '[xy]', function (c)
+    local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
+    return string.format('%x', v)
+  end)
+  return uuid
+end
+
+function vpn_settings_to_text(vpn_settings)
+  local t = {}
+  for k,v in pairs(vpn_settings) do
+    t[#t+1] = k.."="..v
+  end
+  return table.concat(t, "\n")
+end
+
+function usage()
+  local basename = string.match(arg[0], '[^/\\]+$') or arg[0]
+  print(basename .. " - convert/import Cisco VPN (.pcf) configuration to NetworkManager")
+  print("Usage:")
+  print("  " .. basename .. " <input-file> <output-file>")
+  print("    - converts Cisco VPN config to NetworkManager keyfile")
+  print("")
+  print("  " .. basename .. " --import <input-file1> <input-file2> ...")
+  print("    - imports Cisco VPN config(s) to NetworkManager")
+  os.exit(1)
+end
+
+
+-------------------------------------------
+-- Functions for VPN options translation --
+-------------------------------------------
+function set_option(t, option, value)
+  g_switches[value[1]] = value[2]
+end
+function handle_generic(t, option, value)
+  t[option] = value[2]
+end
+function handle_yes(t, option, value)
+  t[option] = "yes"
+end
+function handle_bool(t, option, value)
+  if tonumber(value[2]) == 1 then
+    t[option] = "true"
+  elseif tonumber(value[2]) == 0 then
+    t[option] = "false"
+  else
+    io.stderr:write(string.format("Warning: ignoring invalid option '%s'\n", value[1]))
+  end
+end
+function handle_DHGroup(t, option, value)
+  local dhgroups = { [1]="dh1", [2]="dh2", [5]="dh5" }
+  dhgroup = dhgroups[tonumber(value[2])]
+  if not dhgroup then io.stderr:write(string.format("Warning: invalid value for 'DHGroup': %s\n", value[2])) end
+  t[option] = dhgroup
+end
+function handle_PeerTimeout(t, option, value)
+  if not value[2] then io.stderr:write("Warning: ignoring invalid option 'PeerTimeout'\n") end
+  if tonumber(value[2]) == 0 or (tonumber(value[2]) >=10 and tonumber(value[2] <= 86400)) then
+    t[option] = value[2]
+  else io.stderr:write(string.format("Warning: invalid value for 'PeerTimeout': %s\n", value[2])) end
+end
+function handle_(t, option, value)
+  io.stderr:write("Warning: enc_GroupPwd: encrypted group passwords are not supported by this script.\n")
+end
+function handle_TunnelingMode(t, option, value)
+  if value[2] == 1 then
+    io.stderr:write("Warning: TCP tunneling is not supported by vpnc. " ..
+                    "The connection will be used with TCP tunneling disabled, " ..
+                    "however it may not work as expected.\n")
+  end
+end
+function handle_UseLegacyIKEPort(t, option, value)
+  if value[2] ~= 0 then
+    t[option] = 500
+  end
+end
+function handle_routes(t, option, value)
+  local function splitroutes(str)
+    local sep, fields = " ", {}
+    local pattern = string.format("([^%s]+)", sep)
+    str:gsub(pattern,
+             function(c)
+               local c1,c2 = c:match("^(%d+%.%d+%.%d+%.%d+)/(%d+)$")
+               if c1 then
+                 fields[#fields+1] = { c1, c2 }
+               else
+                 io.stderr:write("Warning: ignoring invalid route: '" .. c .. "'\n")
+               end
+             end)
+    return fields
+  end
+  t[option] = splitroutes(value[2])
+end
+
+-- global variables -
+g_vpn_data = {}
+g_vpn_pwds = {}
+g_con_data = {}
+g_ip4_data = {}
+g_switches = {}
+
+vpn2nm = {
+  ["Description"]            = { nm_opt="id",                func=handle_generic, tbl=g_con_data },
+  ["InterfaceName"]          = { nm_opt="interface-name",    func=handle_generic, tbl=g_con_data },
+  ["EnableLocalLAN"]         = { nm_opt="never-default",     func=handle_bool,    tbl=g_ip4_data },
+  ["X-NM-Routes"]            = { nm_opt="routes",            func=handle_routes,  tbl=g_ip4_data },
+  ["Host"]                   = { nm_opt="IPSec gateway",     func=handle_generic, tbl=g_vpn_data },
+  ["GroupName"]              = { nm_opt="IPSec ID",          func=handle_generic, tbl=g_vpn_data },
+  ["Username"]               = { nm_opt="Xauth username",    func=handle_generic, tbl=g_vpn_data },
+  ["UserPassword"]           = { nm_opt="Xauth password",    func=handle_generic, tbl=g_vpn_pwds },
+  ["SaveUserPassword"]       = { nm_opt="",                  func=set_option,     tbl={}         },
+  ["GroupPwd"]               = { nm_opt="IPSec secret",      func=handle_generic, tbl=g_vpn_pwds },
+  ["DHGroup"]                = { nm_opt="IKE DH Group",      func=handle_DHGroup, tbl=g_vpn_data },
+  ["NTDomain"]               = { nm_opt="Domain",            func=handle_generic, tbl=g_vpn_data },
+  ["SingleDES"]              = { nm_opt="Enable Single DES", func=handle_yes,     tbl=g_vpn_data },
+  ["EnableNat"]              = { nm_opt="",                  func=set_option,     tbl={}         },
+  ["X-NM-Use-NAT-T"]         = { nm_opt="",                  func=set_option,     tbl={}         },
+  ["X-NM-Force-NAT-T"]       = { nm_opt="",                  func=set_option,     tbl={}         },
+  ["X-NM-SaveGroupPassword"] = { nm_opt="",                  func=set_option,     tbl={}         },
+  ["UseLegacyIKEPort"]       = { nm_opt="Local Port",        func=handle_UseLegacyIKEPort,      tbl=g_vpn_data },
+  ["PeerTimeout"]            = { nm_opt="DPD idle timeout (our side)", func=handle_PeerTimeout, tbl=g_vpn_data },
+  ["TunnelingMode"]          = { nm_opt="",                  func=handle_TunnelingMode, tbl= {} },
+  ["enc_UserPassword"]       = { nm_opt="",                  func=handle_enc_pwd,       tbl= {} },
+  ["enc_GroupPwd"]           = { nm_opt="",                  func=handle_enc_pwd,       tbl= {} },
+}
+
+------------------------------------------------------
+-- Read and convert the config into the global vars --
+------------------------------------------------------
+function read_and_convert(in_file)
+  local function line_split(str)
+    -- split at '=' character
+    local sep, fields = "=", {}
+    local pattern = string.format("([^%s]+)%s(.+)", sep, sep)
+    fields[1], fields[2] = str:match(pattern)
+    return fields
+  end
+
+  in_text, msg = read_all(in_file)
+  if not in_text then return false, msg end
+
+  -- loop through the config and convert it
+  for line in in_text:gmatch("[^\r\n]+") do
+    repeat
+      -- skip comments and empty lines
+      if line:find("^%s*[#;]") or line:find("^%s*$") then break end
+      -- trim leading and trailing spaces
+      line = line:find("^%s*$") and "" or line:match("^%s*(.*%S)")
+
+      local words = line_split(line)
+      local val = vpn2nm[words[1]]
+      if val then
+        if type(val) == "table" then val.func(val.tbl, val.nm_opt, words)
+        else print(string.format("debug: '%s': val=%s", line, val)) end
+      end
+    until true
+  end
+
+  -- check if mandatory options exist
+  if not g_vpn_data["IPSec gateway"] then
+    local msg = in_file .. ": Not a valid Cisco VPN configuration (no Host)"
+    return false, msg
+  end
+  if not g_vpn_data["IPSec ID"] then
+    local msg = in_file .. ": Not a valid OpenVPN configuration (no GroupName)"
+    return false, msg
+  end
+
+  -- process inter-option dependencies
+  -- NAT traversal mode
+  local natt_mode = {
+    NONE = "none",
+    NATT = "natt",
+    NATT_ALWAYS = "force-natt",
+    CISCO = "cisco-udp"
+  }
+  g_vpn_data["NAT Traversal Mode"] = natt_mode.CISCO
+  if tonumber(g_switches["EnableNat"]) == 0 then
+    g_vpn_data["NAT Traversal Mode"] = natt_mode.NONE
+  elseif tonumber(g_switches["EnableNat"]) == 1 then
+    if tonumber(g_switches["X-NM-Force-NAT-T"]) == 1 then
+      g_vpn_data["NAT Traversal Mode"] = natt_mode.NATT_ALWAYS
+    elseif tonumber(g_switches["X-NM-Use-NAT-T"]) == 1 then
+      g_vpn_data["NAT Traversal Mode"] = natt_mode.NATT
+    end
+  else
+    io.stderr:write("Warning: invalid value for EnableNat\n")
+    g_vpn_data["NAT Traversal Mode"] = natt_mode.CISCO
+  end
+
+  -- set secret flags
+  g_vpn_data["Xauth password-flags"] = 1
+  if tonumber(g_switches["SaveUserPassword"]) == 1 then
+    g_vpn_data["xauth-password-type"] = "save"
+  else
+    g_vpn_data["Xauth password-flags"] = 3
+  end
+  if g_vpn_data["IPSec ID"] then
+    g_vpn_data["IPSec ID-flags"] = 1
+  end
+  if g_switches["X-NM-SaveGroupPassword"] then
+    if tonumber(g_switches["X-NM-SaveGroupPassword"]) == 1 then
+      g_vpn_data["ipsec-secret-type"] = "save"
+      g_vpn_data["IPSec ID-flags"] = 1
+    else
+      g_vpn_data["IPSec ID-flags"] = 3
+    end
+  else
+    g_vpn_data["ipsec-secret-type"] = "save"
+  end
+
+  return true
+end
+
+
+--------------------------------------------------------
+-- Create and write connection file in keyfile format --
+--------------------------------------------------------
+function write_vpn_to_keyfile(in_file, out_file)
+  connection = [[
+[connection]
+id=__NAME_PLACEHOLDER__
+uuid=__UUID_PLACEHOLDER__
+__IFNAME_PLACEHOLDER__
+type=vpn
+autoconnect=no
+
+[ipv4]
+method=auto
+never-default=__NEVER_DEFAULT_PLACEHOLDER__
+__ROUTES_PLACEHOLDER__
+
+[ipv6]
+method=auto
+
+[vpn]
+service-type=org.freedesktop.NetworkManager.vpnc
+]]
+  connection = connection .. vpn_settings_to_text(g_vpn_data)
+  connection = connection .. "\n\n[vpn-secrets]\n"
+  connection = connection .. vpn_settings_to_text(g_vpn_pwds)
+
+  local con_name = g_con_data["id"] or (out_file:gsub(".*/", ""))
+  local ifname = g_con_data["interface-name"]
+  local never_default = g_ip4_data["never-default"] or "false"
+  local routes = ""
+  if ifname then ifname = "interface-name="..ifname.."\n"  else ifname = "" end
+  for idx, r in ipairs(g_ip4_data["routes"] or {}) do
+    routes = routes .. string.format("routes%d=%s/%s\n", idx, r[1], r[2])
+  end
+
+  connection = string.gsub(connection, "__NAME_PLACEHOLDER__", con_name)
+  connection = string.gsub(connection, "__UUID_PLACEHOLDER__", uuid())
+  connection = string.gsub(connection, "__IFNAME_PLACEHOLDER__\n", ifname)
+  connection = string.gsub(connection, "__NEVER_DEFAULT_PLACEHOLDER__", never_default)
+  connection = string.gsub(connection, "__ROUTES_PLACEHOLDER__\n", routes)
+
+  -- write output file
+  local f, err = io.open(out_file, "w")
+  if not f then io.stderr:write(err) return false end
+  f:write(connection)
+  f:close()
+
+  local ofname = out_file:gsub(".*/", "")
+  io.stderr:write("Successfully converted VPN configuration: " .. in_file .. " => " .. out_file .. "\n")
+  io.stderr:write("To use the connection, do:\n")
+  io.stderr:write("# cp " .. out_file .. " /etc/NetworkManager/system-connections\n")
+  io.stderr:write("# chmod 600 /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  io.stderr:write("# nmcli con load /etc/NetworkManager/system-connections/" .. ofname .. "\n")
+  return true
+end
+
+---------------------------------------------
+-- Import VPN connection to NetworkManager --
+---------------------------------------------
+function import_vpn_to_NM(filename)
+  local lgi = require 'lgi'
+  local GLib = lgi.GLib
+  local NM = lgi.NM
+
+  -- function creating NMConnection
+  local function create_profile(name)
+    local profile = NM.SimpleConnection.new()
+    local never_default = g_ip4_data["never-default"] == "true"
+
+    s_con = NM.SettingConnection.new()
+    s_vpn = NM.SettingVpn.new()
+    s_ip4 = NM.SettingIP4Config.new()
+
+    s_con[NM.SETTING_CONNECTION_ID] = name
+    s_con[NM.SETTING_CONNECTION_UUID] = uuid()
+    s_con[NM.SETTING_CONNECTION_INTERFACE_NAME] = g_con_data["interface-name"]
+    s_con[NM.SETTING_CONNECTION_TYPE] = "vpn"
+    s_vpn[NM.SETTING_VPN_SERVICE_TYPE] = "org.freedesktop.NetworkManager.vpnc"
+    s_ip4[NM.SETTING_IP_CONFIG_METHOD] = NM.SETTING_IP4_CONFIG_METHOD_AUTO
+    s_ip4[NM.SETTING_IP_CONFIG_NEVER_DEFAULT] = never_default
+
+    -- add routes
+    local AF_INET = 2
+    for _, r in ipairs(g_ip4_data["routes"] or {}) do
+      route = NM.IPRoute.new(AF_INET, r[1], r[2], nil, -1)
+      s_ip4:add_route(route)
+    end
+
+    -- add vpn data
+    for k,v in pairs(g_vpn_data) do
+      s_vpn:add_data_item(k, v)
+    end
+    -- add vpn secrets
+    for k,v in pairs(g_vpn_pwds) do
+      s_vpn:add_secret(k, v)
+    end
+
+    profile:add_setting(s_con)
+    profile:add_setting(s_vpn)
+    profile:add_setting(s_ip4)
+    return profile
+  end
+
+  -- callback function for add_connection()
+  local function added_cb(client, result, data)
+    local con,err,code = client:add_connection_finish(result)
+    if con then
+      print(string.format("%s: Imported to NetworkManager: %s - %s",
+                          filename, con:get_uuid(), con:get_id()))
+    else
+      io.stderr:write(code .. ": " .. err .. "\n");
+      return false
+    end
+    main_loop:quit()
+  end
+
+  local profile_name = g_con_data["id"] or string.match(filename, '[^/\\]+$') or filename
+  main_loop = GLib.MainLoop(nil, false)
+  local con = create_profile(profile_name)
+  local client = NM.Client.new()
+
+  -- send the connection to NetworkManager
+  client:add_connection_async(con, true, nil, added_cb, nil)
+
+  -- run main loop so that the callback could be called
+  main_loop:run()
+  return true
+end
+
+
+---------------------------
+-- Main code starts here --
+---------------------------
+local import_mode = false
+local infile, outfile
+
+-- parse command-line arguments
+if not arg[1] or arg[1] == "--help" or arg[1] == "-h" then usage() end
+if arg[1] == "--import" or arg[1] == "-i" then
+  infile = arg[2]
+  if not infile then usage() end
+  import_mode = true
+else
+  infile  = arg[1]
+  outfile = arg[2]
+  if not infile or not outfile then usage() end
+  if arg[3] then usage() end
+end
+
+if import_mode then
+  -- check if lgi is available
+  local success,msg = pcall(require, 'lgi')
+  if not success then
+    io.stderr:write("Lua lgi module is not available, please install it (usually lua-lgi package)\n")
+    -- print(msg)
+    os.exit(1)
+  end
+  -- read configs, convert them and import to NM
+  for i = 2, #arg do
+    ok, err_msg = read_and_convert(arg[i])
+    if ok then import_vpn_to_NM(arg[i])
+    else io.stderr:write(err_msg .. "\n") end
+    -- reset global vars
+    g_vpn_data = {}
+    g_vpn_pwds = {}
+    g_con_data = {}
+    g_ip4_data = {}
+    g_switches = {}
+  end
+else
+  -- read configs, convert them and write as NM keyfile connection
+  ok, err_msg = read_and_convert(infile)
+  if ok then write_vpn_to_keyfile(infile, outfile)
+  else io.stderr:write(err_msg .. "\n") end
+end
+
diff --git a/contrib/scripts/nm-python-black-format.sh b/contrib/scripts/nm-python-black-format.sh
new file mode 100755
index 00000000..eae84fdb
--- /dev/null
+++ b/contrib/scripts/nm-python-black-format.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+
+set -e
+
+_print() {
+    printf '%s\n' "$*" >&2
+}
+
+die() {
+    _print "$*"
+    exit 1
+}
+
+NM_ROOT="$(git rev-parse --show-toplevel)" || die "not inside a git repository"
+NM_PREFIX="$(git rev-parse --show-prefix)" || die "not inside a git repository"
+
+cd "$NM_ROOT" || die "failed to cd into \$NM_ROOT\""
+
+if [ ! -f "./src/core/main.c" ]; then
+    die "Error: \"$NM_ROOT\" does not look like NetworkManager source tree"
+fi
+
+BLACK="${BLACK:-black}"
+
+if ! command -v "$BLACK" &> /dev/null; then
+    _print "Error: black is not installed. On RHEL/Fedora/CentOS run 'dnf install black'"
+    exit 77
+fi
+
+OLD_IFS="$IFS"
+
+usage() {
+    printf "Usage: %s [OPTION]...\n" "$(basename "$0")"
+    printf "Reformat python source files using python black.\n\n"
+    printf "OPTIONS:\n"
+    printf "    -i                   Reformat files (this is the default)\n"
+    printf "    -n|--dry-run|--check Only check the files (contrary to \"-i\")\n"
+    printf "    --show-filenames     Only print the filenames that would be checked/formatted\n"
+    printf "    -h                   Print this help message\n"
+}
+
+TEST_ONLY=0
+SHOW_FILENAMES=0
+
+while (( $# )); do
+    case "$1" in
+        -h)
+            usage
+            exit 0
+            ;;
+        -n|--dry-run|--check)
+            TEST_ONLY=1
+            shift
+            continue
+            ;;
+        -i)
+            TEST_ONLY=0
+            shift
+            continue
+            ;;
+        --show-filenames)
+            SHOW_FILENAMES=1
+            shift
+            continue
+            ;;
+        *)
+            usage
+            exit 1
+            ;;
+    esac
+done
+
+IFS=$'\n'
+FILES=()
+FILES+=( $(git ls-tree --name-only -r HEAD | grep '\.py$') )
+FILES+=( $(git grep -l '#!.*\<p[y]thon3\?\>') )
+FILES=( $(printf "%s\n" "${FILES[@]}" | sort -u) )
+
+# Filter out paths that are forked from upstream projects and not
+# ours to reformat.
+FILES=( $(
+    printf "%s\n" "${FILES[@]}" |
+    sed \
+        -e '/^src\/[cn]-[^/]\+\//d' \
+        -e '/^src\/libnm-systemd-[^/]\+\/src\//d'
+) )
+
+IFS="$OLD_IFS"
+
+if [ $SHOW_FILENAMES = 1 ]; then
+    printf '%s\n' "${FILES[@]}"
+    exit 0
+fi
+
+EXTRA_ARGS=()
+if [ $TEST_ONLY = 1 ]; then
+    EXTRA_ARGS+=('--check')
+fi
+
+"$BLACK" "${EXTRA_ARGS[@]}" "${FILES[@]}"
diff --git a/contrib/scripts/nm-setup-git.sh b/contrib/scripts/nm-setup-git.sh
new file mode 100755
index 00000000..32e059ad
--- /dev/null
+++ b/contrib/scripts/nm-setup-git.sh
@@ -0,0 +1,134 @@
+#!/bin/bash
+
+set -e
+
+usage() {
+    printf "%s [--no-test]\n" "$CMD_NAME"
+    printf "\n"
+    printf "This script configures (or shows configuration) to the local git, with\n"
+    printf "settings that might be useful when working on NetworkManager.\n"
+    printf "\n"
+    printf "RUn it without arguments, it only prints and shows what it would do.\n"
+    printf "\n"
+    printf "  --no-test: by default, the script only prints what it\n"
+    printf "    would do. You can also set NO_TEST=1 environment variable.\n"
+    printf "\n"
+}
+
+get_bool() {
+    local name="$1"
+    local val="${!name}"
+
+    case "$val" in
+        1|y|yes|Yes|YES|true|True|TRUE|on|On|ON)
+            echo -n 1
+            return 0
+            ;;
+        0|n|no|No|NO|false|False|FALSE|off|Off|OFF)
+            echo -n 0
+            return 0
+            ;;
+        *)
+            printf "%s" "$2"
+            ;;
+    esac
+}
+
+die() {
+    echo "ERROR: $*"
+    exit 1
+}
+
+_pprint() {
+    local a
+    local sp=''
+
+    for a; do
+        printf "$sp%q" "$a"
+        sp=' '
+    done
+}
+
+call() {
+    local m=""
+
+    [ "$SKIP" = 1 ] && m="SKIP: "
+
+    if [ "$NO_TEST" != 1 ]; then
+        printf "WOULD: %s%s\n" "$m" "$(_pprint "$@")"
+        return 0
+    fi
+    printf "CALL: %s%s\n" "$m" "$(_pprint "$@")"
+    [ "$SKIP" = 1 ] || "$@"
+}
+
+git_config_reset() {
+    local key="$1"
+    local val="$2"
+    local c=(git config --replace-all "$key" "$val")
+
+    test "$#" -eq 2 || die "invalid arguments to git_config_add(): $@"
+
+    if [ "$(git config --get-all "$key")" = "$val" ]; then
+        SKIP=1 call "${c[@]}"
+        return 0
+    fi
+    call "${c[@]}"
+}
+
+git_config_add() {
+    local key="$1"
+    local val="$2"
+    local c=(git config --add "$key" "$val")
+
+    test "$#" -eq 2 || die "invalid arguments to git_config_add(): $@"
+
+    if git config --get-all "$key" | grep -qFx "$val"; then
+        SKIP=1 call "${c[@]}"
+        return 0
+    fi
+    call "${c[@]}"
+}
+
+CMD_NAME="$0"
+NO_TEST="$(get_bool NO_TEST 0)"
+
+for a; do
+    case "$a" in
+        --no-test)
+            NO_TEST=1
+            ;;
+        -h|--help)
+            usage
+            exit 0
+            ;;
+        *)
+            usage
+            die "Invalid argument \"$a\""
+            ;;
+    esac
+done
+
+case "$(git config --get-all remote.origin.url)" in
+    "https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git"| \
+    "git@gitlab.freedesktop.org:NetworkManager/NetworkManager.git"| \
+    "ssh://git@gitlab.freedesktop.org/NetworkManager/NetworkManager")
+        ;;
+    *)
+        die "unexpected git repository. Expected that remote.origin.url is set to \"https://gitlab.freedesktop.org/NetworkManager/NetworkManager.git\""
+        ;;
+esac
+
+git_config_add blame.ignoreRevsFile '.git-blame-ignore-revs'
+git_config_reset blame.markIgnoredLines true
+git_config_reset blame.markUnblamableLines true
+git_config_add notes.displayref 'refs/notes/bugs'
+git_config_add remote.origin.fetch 'refs/notes/bugs:refs/notes/bugs'
+git_config_reset remote.origin.pushurl 'git@gitlab.freedesktop.org:NetworkManager/NetworkManager.git'
+git_config_add 'alias.backport-merge' '! (git show main:contrib/scripts/git-backport-merge || git show origin/main:contrib/scripts/git-backport-merge) | bash -s -'
+
+if [ "$NO_TEST" != 1 ]; then
+    printf "Run with \"--no-test\" or see \"-h\"\n" >&2
+    printf "\n" >&2
+    printf "    \"%s\" --no-test\n" "$CMD_NAME" >&2
+fi
diff --git a/contrib/scripts/test-create-many-device-setup.sh b/contrib/scripts/test-create-many-device-setup.sh
new file mode 100755
index 00000000..55f2a1c6
--- /dev/null
+++ b/contrib/scripts/test-create-many-device-setup.sh
@@ -0,0 +1,136 @@
+#!/bin/bash
+
+set -x
+
+die() {
+    printf '%s\n' "$*" >&1
+    exit 1
+}
+
+ARG_OP="$1"
+shift
+test -n "$ARG_OP" || die "specify the operation (setup, cleanup)"
+
+test "$USER" = root || die "must run as root"
+
+NUM_DEVS="${NUM_DEVS:-50}"
+NUM_VLAN_DEVS="${NUM_VLAN_DEVS:-0}"
+
+
+DNSMASQ_PIDFILE="/tmp/nm-test-create-many-device-setup.dnsmasq.pid"
+NM_TEST_CONF="/etc/NetworkManager/conf.d/99-my-test.conf"
+TEST_NETNS="T"
+
+
+_do_service() {
+    test "$DO_SERVICE" = 1 || return 0
+    "$@"
+}
+
+_dnsmasq_kill() {
+    pkill -F "$DNSMASQ_PIDFILE"
+    rm -rf "$DNSMASQ_PIDFILE"
+}
+
+_link_delete_all() {
+    ip link | sed -n 's/^[0-9]\+:.*\(t-[^@:]\+\)@.*/\1/p' | xargs -n 1 ip link delete
+}
+
+cleanup_base() {
+    ip netns delete "$TEST_NETNS"
+    _dnsmasq_kill
+    _link_delete_all
+    rm -rf "$NM_TEST_CONF"
+    rm -rf /run/NetworkManager/system-connections/c-*.nmconnection
+}
+
+cmd_cleanup() {
+    _do_service systemctl stop NetworkManager
+    cleanup_base
+    systemctl unmask NetworkManager-dispatcher
+    systemctl enable NetworkManager-dispatcher
+    _do_service systemctl start NetworkManager
+}
+
+cmd_setup() {
+
+    _do_service systemctl stop NetworkManager
+    systemctl mask NetworkManager-dispatcher
+    systemctl stop NetworkManager-dispatcher
+
+    cleanup_base
+
+    ip netns add "$TEST_NETNS"
+    ip --netns "$TEST_NETNS" link add t-br0 type bridge
+    ip --netns "$TEST_NETNS" link set t-br0 type bridge stp_state 0
+    ip --netns "$TEST_NETNS" link set t-br0 up
+    ip --netns "$TEST_NETNS" addr add 172.16.0.1/16 dev t-br0
+    ip netns exec "$TEST_NETNS" \
+        dnsmasq \
+            --conf-file=/dev/null \
+            --pid-file="$DNSMASQ_PIDFILE" \
+            --no-hosts \
+            --keep-in-foreground \
+            --bind-interfaces \
+            --except-interface=lo \
+            --clear-on-reload \
+            --listen-address=172.16.0.1 \
+            --dhcp-range=172.16.1.1,172.16.20.1,60 \
+            --no-ping \
+            &
+    disown
+    for i in `seq "$NUM_DEVS"`; do
+        ip --netns "$TEST_NETNS" link add t-a$i type veth peer t-b$i
+        ip --netns "$TEST_NETNS" link set t-a$i up
+        ip --netns "$TEST_NETNS" link set t-b$i up master t-br0
+    done
+    for i in `seq "$NUM_VLAN_DEVS"`; do
+        ip --netns "$TEST_NETNS" link add link t-b1 name t-b1.$i type vlan id $i
+        ip --netns "$TEST_NETNS" link set t-b1.$i up master t-br0
+    done
+
+    cat <<EOF > "$NM_TEST_CONF"
+[main]
+dhcp=internal
+no-auto-default=interface-name:t-a*
+[device-99-my-test]
+match-device=interface-name:t-a*
+managed=1
+[logging]
+level=INFO
+[connectivity]
+enabled=0
+EOF
+
+    _do_service systemctl start NetworkManager
+
+    for i in `seq "$NUM_DEVS"`; do
+      ip --netns "$TEST_NETNS" link set t-a$i netns $$
+    done
+
+    if [ "$DO_ADD_CON" = 1 ]; then
+        for i in `seq "$NUM_DEVS"`; do
+            nmcli connection add save no type ethernet con-name c-a$i ifname t-a$i autoconnect no ipv4.method auto ipv6.method auto
+        done
+    fi
+
+    if [ "$DO_ADD_VLAN_CON" = 1 ]; then
+        for i in `seq "$NUM_VLAN_DEVS"`; do
+            nmcli connection add save no type bridge con-name c-a1.$i-br ifname t-a1.$i.br autoconnect no ipv4.method auto ipv6.method auto bridge.stp 0
+            nmcli connection add save no type vlan   con-name c-a1.$i-po ifname t-a1.$i.po autoconnect no vlan.id $i vlan.parent t-a1 master c-a1.$i-br slave-type bridge
+        done
+    fi
+}
+
+
+case "$ARG_OP" in
+    "setup")
+        cmd_setup
+        ;;
+    "cleanup")
+        cmd_cleanup
+        ;;
+    *)
+        die "Unknown command \"$ARG_OP\""
+        ;;
+esac
diff --git a/contrib/scripts/test-macsec b/contrib/scripts/test-macsec
new file mode 100755
index 00000000..93935865
--- /dev/null
+++ b/contrib/scripts/test-macsec
@@ -0,0 +1,102 @@
+#!/bin/sh
+
+# Test for MACsec in PSK mode
+
+if [ "$#" = 2 ]; then
+    # DHCP helper
+    dev=$1
+    addr=$2
+    net=${addr%.*}
+
+    while [ ! -d "/sys/class/net/$dev" ]; do
+	    sleep 1
+    done
+
+    ip a add $addr/24 dev "$dev"
+
+    dnsmasq --conf-file --no-hosts --keep-in-foreground --listen-address=$addr \
+            --dhcp-range=$net.250,$net.255,60m  -i "$dev" \
+            --bind-interface --except-interface=lo
+
+    exit 0
+fi
+
+TMPDIR=$(mktemp -d /tmp/macsec-XXXXXX)
+ADDR=172.16.10.1
+MKA_CAK=00112233445566778899001122334455
+MKA_CKN=5544332211009988776655443322110055443322110099887766554433221100
+
+trap 'rm -rf "$TMPDIR"; kill $(jobs -p)' EXIT
+
+echo "* Setup..."
+
+# Clean up
+ip netns del macsec-ns 2> /dev/null
+ip link del macsec-veth 2> /dev/null
+# Create namespace
+ip netns add macsec-ns
+# Create interfaces
+ip link add macsec-veth type veth peer name macsec-vethp
+# Move interfaces into namespace
+ip link set macsec-vethp netns macsec-ns
+# Bring up interfaces
+ip link set macsec-veth up
+ip -n macsec-ns link set macsec-vethp up
+
+echo "* Start wpa_supplicant..."
+
+cat <<EOF > $TMPDIR/wpa_supplicant.conf
+ctrl_interface=/run/hostapd1
+eapol_version=3
+ap_scan=0
+fast_reauth=1
+network={
+	key_mgmt=NONE
+	eapol_flags=0
+	macsec_policy=1
+	mka_cak=$MKA_CAK
+	mka_ckn=$MKA_CKN
+}
+EOF
+ip netns exec macsec-ns wpa_supplicant \
+   -c "$TMPDIR/wpa_supplicant.conf" -i macsec-vethp -Dmacsec_linux -dd > /dev/null 2>&1 &
+ip netns exec macsec-ns $0 macsec0 $ADDR > /dev/null 2>&1 &
+
+echo "* Create connections..."
+
+nmcli connection delete test-macsec+ test-veth+ > /dev/null 2>&1
+nmcli connection add type ethernet ifname macsec-veth con-name test-veth+ \
+      ipv4.method disabled ipv6.method ignore
+nmcli connection add type macsec con-name test-macsec+ ifname macsec0 \
+      connection.autoconnect no \
+      macsec.parent macsec-veth macsec.mode psk \
+      macsec.mka-cak $MKA_CAK \
+      macsec.mka-cak-flags 0 \
+      macsec.mka-ckn $MKA_CKN
+
+echo "* Bring up connections..."
+nmcli connection up test-veth+
+nmcli connection up test-macsec+
+
+echo "* Test connectivity..."
+ping $ADDR -c2 -q > /dev/null
+res=$?
+
+echo "* Clean up..."
+
+nmcli connection delete test-macsec+ test-veth+ > /dev/null 2>&1
+ip link del macsec-veth 2> /dev/null
+ip netns del macsec-ns 2> /dev/null
+
+echo
+
+if [ "$res" = 0 ]; then
+	echo "Success"
+else
+	echo "Failure"
+fi
+
+exit $res
+
+
+
diff --git a/contrib/scripts/test-ppp.sh b/contrib/scripts/test-ppp.sh
new file mode 100755
index 00000000..c100e976
--- /dev/null
+++ b/contrib/scripts/test-ppp.sh
@@ -0,0 +1,108 @@
+#!/bin/bash
+
+# test-ppp.sh:
+#
+# Test script that creates an netns and connect it with
+# veth pairs. On the other end, it runs pppoe-server.
+# It also creates a NetworkManager profile that can be activated.
+#
+# Usage:
+#
+# ./test-ppp.sh [setup]: create the setup. This implies a "cleanup"
+#   first.
+# ./test-ppp.sh cleanup: cleanup the things that the script created.
+set -e
+
+export IFACE=net1
+export IFACE_PEER=net1-x
+export CON_NAME="ppp-$IFACE"
+export NETNS=nm-ppp
+export PPP_SERVICE=isp
+export PPP_AUTH=pap
+export PPP_USER=test-user
+export PPP_PASSWD=test-passwd
+export IP_PEER="192.168.133.6"
+export IP_RANGE="192.168.133.100-130"
+
+die() {
+    printf '%s\n' "$*" >&2
+    exit 1
+}
+
+do_cleanup() {
+    pkill -F "/tmp/nm-test-ppp-$IFACE.pid" pppoe-server &>/dev/null || :
+    rm -rf \
+        "/tmp/nm-test-ppp-$IFACE.pid" \
+        "/tmp/nm-test-ppp-allip-$IFACE" \
+        "/tmp/nm-test-ppp-pppoe-server-options-$IFACE" \
+        "/tmp/nm-test-ppp-$IFACE-$PPP_AUTH-secrets"
+    ip --netns "$NETNS" link delete "$IFACE_PEER" &>/dev/null || :
+    ip netns delete "$NETNS" &>/dev/null || :
+
+    nmcli connection delete id ppp-net1 || :
+}
+
+do_setup() {
+    do_cleanup
+
+    ip netns add "$NETNS"
+    ip --netns "$NETNS" link add "$IFACE" type veth peer "$IFACE_PEER"
+    ip --netns "$NETNS" link set "$IFACE_PEER" up
+
+    ip --netns "$NETNS" addr add "$IP_PEER/24" dev "$IFACE_PEER"
+
+    echo "$IP_RANGE" > "/tmp/nm-test-ppp-allip-$IFACE"
+
+    cat <<EOF > "/tmp/nm-test-ppp-pppoe-server-options-$IFACE"
+require-$PPP_AUTH
+lcp-echo-interval 10
+lcp-echo-failure 2
+ms-dns 8.8.8.8
+ms-dns 8.8.4.4
+netmask 255.255.255.0
+defaultroute
+noipdefault
+usepeerdns
+EOF
+
+    echo "$PPP_USER * $PPP_PASSWD $IP_PEER" > "/tmp/nm-test-ppp-$IFACE-$PPP_AUTH-secrets"
+    chmod 600 "/tmp/nm-test-ppp-$IFACE-$PPP_AUTH-secrets"
+    mkdir -p /etc/ppp
+    touch "/etc/ppp/$PPP_AUTH-secrets"
+    ip netns exec "$NETNS" bash -ex <(
+        cat <<'EOF'
+        mount -o bind  "/tmp/nm-test-ppp-$IFACE-$PPP_AUTH-secrets" "/etc/ppp/$PPP_AUTH-secrets" &&
+        exec pppoe-server \
+            -X "/tmp/nm-test-ppp-$IFACE.pid" \
+            -S "$PPP_SERVICE" \
+            -C "$PPP_SERVICE" \
+            -L "$IP_PEER" \
+            -p "/tmp/nm-test-ppp-allip-$IFACE" \
+            -I "$IFACE_PEER" \
+            -O "/tmp/nm-test-ppp-pppoe-server-options-$IFACE"
+EOF
+) &
+
+    ip --netns "$NETNS" link set "$IFACE" netns $$
+
+    nmcli connection add \
+        type pppoe \
+        con-name "$CON_NAME" \
+        ifname "ppp-$IFACE" \
+        pppoe.parent "$IFACE" \
+        service "$PPP_SERVICE" \
+        username "$PPP_USER" \
+        password "$PPP_PASSWD" \
+        autoconnect no
+}
+
+CMD="${1-setup}"
+case "$CMD" in
+    setup| \
+    cleanup)
+        "do_$CMD"
+        ;;
+    *)
+        die "invalid command $1"
+        ;;
+esac
diff --git a/contrib/scripts/test-prefix-delegation.sh b/contrib/scripts/test-prefix-delegation.sh
new file mode 100755
index 00000000..7fc4140e
--- /dev/null
+++ b/contrib/scripts/test-prefix-delegation.sh
@@ -0,0 +1,151 @@
+#!/bin/sh
+
+# Usage: ./test-prefix-delegation {ll|slaac|dhcp-stateful|dhcp-stateless}
+
+MODE=${1:-dhcp-stateful}
+
+cleanup()
+{
+    pkill -F dhcpd.pid
+    pkill -F radvd.pid
+    rm -f radvd.conf
+    rm -f dhcpd.conf
+    rm -f leases.conf
+    nmcli connection delete v1+ v2+
+    ip netns del ns1
+    ip netns del ns2
+    ip link del v1
+    ip link del v2
+}
+
+require()
+{
+    if ! command -v "$1" > /dev/null ; then
+        echo " *** Error: command '$1' not found"
+        exit 1
+    fi
+}
+
+exit_hook()
+{
+    cleanup > /dev/null 2>&1
+}
+
+require nmcli
+require ip
+require jq
+require radvd
+require dhcpd
+
+unalias ip 2> /dev/null
+
+cleanup
+trap exit_hook EXIT
+
+# ns1 is the 'upstream' namespace that provides IPv6 connectivity
+# through RA and DHCPv6. The DHCP server also acts as a delegating
+# router for /60 prefixes.
+
+# ns2 is the 'downstream' namespace where a client obtains IPv6
+# connectivity through RA from NM.
+
+# NM is in the default namespace and has a connection to ns1 with
+# ipv6.method=auto and to ns2 with ipv6.method=shared.
+
+ip netns add ns1
+ip netns add ns2
+
+ip link add v1 type veth peer name v1p
+ip link add v2 type veth peer name v2p
+
+ip link set v1p netns ns1
+ip link set v2p netns ns2
+
+ip link set v1 up
+ip link set v2 up
+
+ip -n ns1 link set v1p up
+ip -n ns1 addr add dev v1p fc01::1/64
+
+ip -n ns2 link set v2p up
+
+if [ "$MODE" = ll ]; then
+    adv_managed=off
+    adv_other=off
+elif [ "$MODE" = slaac ]; then
+    adv_managed=off
+    adv_other=off
+    adv_prefix="prefix fc01::/64 {AdvOnLink on; AdvAutonomous on; AdvRouterAddr off; };"
+elif [ "$MODE" = dhcp-stateless ]; then
+    adv_managed=off
+    adv_other=on
+    adv_prefix="prefix fc01::/64 {AdvOnLink on; AdvAutonomous on; AdvRouterAddr off; };"
+elif [ "$MODE" = dhcp-stateful ]; then
+    adv_managed=on
+    adv_other=off
+    dhcp_range="range6  fc01::1000 fc01::ffff;"
+else
+    echo "Unknown mode '$MODE'"
+    exit 1
+fi
+
+echo "Starting in $MODE mode..."
+
+cat > radvd.conf <<EOF
+interface v1p {
+        AdvSendAdvert on;
+        AdvManagedFlag ${adv_managed};
+        AdvOtherConfigFlag ${adv_other};
+        MinRtrAdvInterval 3;
+        MaxRtrAdvInterval 60;
+        ${adv_prefix}
+};
+EOF
+
+cat > dhcpd.conf <<EOF
+subnet6 fc01::/64 {
+        ${dhcp_range}
+        prefix6 fc01:bbbb:1:: fc01:bbbb:2:: / 60;
+        option dhcp6.name-servers fc01::8888;
+}
+EOF
+
+echo > leases.conf
+ip netns exec ns1 radvd -n -C radvd.conf -p radvd.pid &
+ip netns exec ns1 dhcpd -6 -d -cf dhcpd.conf -lf leases.conf -pf dhcpd.pid &
+
+nmcli connection add type ethernet ifname v1 con-name v1+ ipv4.method disabled ipv6.method auto autoconnect no
+nmcli connection add type ethernet ifname v2 con-name v2+ ipv4.method disabled ipv6.method shared autoconnect no
+
+nmcli connection up v1+
+
+sleep 5
+
+nmcli connection up v2+
+
+sleep 5
+
+ip a show dev v1
+ip a show dev v2
+
+addr=$(ip -j addr show dev v1 | jq -r '.[0].addr_info[] | select(.scope=="link")'.local)
+prefix="fc01:bbbb:1::/32"
+ip netns exec ns1 ip route add $prefix via $addr dev v1p
+
+# kernel does IPv6 autoconf in ns2 ...
+
+sleep 10
+
+# check connectivity to ns1
+if ! ip -n ns2 a show dev v2p | grep 'fc01:bbbb:[a-f0-9\:]\+/64'; then
+    ip -n ns2 a show dev v2p
+    echo "ERROR: no address"
+    exit 1
+fi
+
+if ! ip netns exec ns2 ping -c2 fc01::1; then
+    echo "ERROR: ping failed"
+    exit 1
+fi
+
+echo "OK"