diff options
24 files changed, 781 insertions, 387 deletions
diff --git a/data/templates/accel-ppp/pptp.config.j2 b/data/templates/accel-ppp/pptp.config.j2 index 7fe4b17bf..290e6235d 100644 --- a/data/templates/accel-ppp/pptp.config.j2 +++ b/data/templates/accel-ppp/pptp.config.j2 @@ -9,15 +9,7 @@ ippool {# Common IPv6 definitions #} {% include 'accel-ppp/config_modules_ipv6.j2' %} {# Common authentication protocols (pap, chap ...) #} -{% if authentication.require is vyos_defined %} -{% if authentication.require == 'chap' %} -auth_chap_md5 -{% elif authentication.require == 'mschap' %} -auth_mschap_v1 -{% else %} -auth_{{ authentication.require.replace('-', '_') }} -{% endif %} -{% endif %} +{% include 'accel-ppp/config_modules_auth_protocols.j2' %} [core] thread-count={{ thread_count }} diff --git a/data/templates/frr/rpki.frr.j2 b/data/templates/frr/rpki.frr.j2 index 384cbbe52..59724102c 100644 --- a/data/templates/frr/rpki.frr.j2 +++ b/data/templates/frr/rpki.frr.j2 @@ -11,8 +11,14 @@ rpki {% endif %} {% endfor %} {% endif %} +{% if expire_interval is vyos_defined %} + rpki expire_interval {{ expire_interval }} +{% endif %} {% if polling_period is vyos_defined %} rpki polling_period {{ polling_period }} {% endif %} +{% if retry_interval is vyos_defined %} + rpki retry_interval {{ retry_interval }} +{% endif %} exit ! diff --git a/interface-definitions/high-availability.xml.in b/interface-definitions/high-availability.xml.in index aa23888a4..59f0f1052 100644 --- a/interface-definitions/high-availability.xml.in +++ b/interface-definitions/high-availability.xml.in @@ -276,8 +276,17 @@ <format>ipv6net</format> <description>IPv6 address and prefix length</description> </valueHelp> + <valueHelp> + <format>ipv4</format> + <description>IPv4 address</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 address</description> + </valueHelp> <constraint> <validator name="ip-host"/> + <validator name="ip-address"/> </constraint> </properties> <children> diff --git a/interface-definitions/include/version/pptp-version.xml.i b/interface-definitions/include/version/pptp-version.xml.i index 3e1482ecc..a877d77ff 100644 --- a/interface-definitions/include/version/pptp-version.xml.i +++ b/interface-definitions/include/version/pptp-version.xml.i @@ -1,3 +1,3 @@ <!-- include start from include/version/pptp-version.xml.i --> -<syntaxVersion component='pptp' version='4'></syntaxVersion> +<syntaxVersion component='pptp' version='5'></syntaxVersion> <!-- include end --> diff --git a/interface-definitions/protocols_rpki.xml.in b/interface-definitions/protocols_rpki.xml.in index 6a38b2961..a2a0a2799 100644 --- a/interface-definitions/protocols_rpki.xml.in +++ b/interface-definitions/protocols_rpki.xml.in @@ -67,12 +67,25 @@ </node> </children> </tagNode> + <leafNode name="expire-interval"> + <properties> + <help>Interval to wait before expiring the cache</help> + <valueHelp> + <format>u32:600-172800</format> + <description>Interval in seconds</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 600-172800"/> + </constraint> + </properties> + <defaultValue>7200</defaultValue> + </leafNode> <leafNode name="polling-period"> <properties> - <help>RPKI cache polling period</help> + <help>Cache polling interval</help> <valueHelp> <format>u32:1-86400</format> - <description>Polling period in seconds</description> + <description>Interval in seconds</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-86400"/> @@ -80,6 +93,19 @@ </properties> <defaultValue>300</defaultValue> </leafNode> + <leafNode name="retry-interval"> + <properties> + <help>Retry interval to connect to the cache server</help> + <valueHelp> + <format>u32:1-7200</format> + <description>Interval in seconds</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-7200"/> + </constraint> + </properties> + <defaultValue>600</defaultValue> + </leafNode> </children> </node> </children> diff --git a/interface-definitions/service_ipoe-server.xml.in b/interface-definitions/service_ipoe-server.xml.in index eeec2aeef..23d6e54d1 100644 --- a/interface-definitions/service_ipoe-server.xml.in +++ b/interface-definitions/service_ipoe-server.xml.in @@ -8,6 +8,81 @@ <priority>900</priority> </properties> <children> + <node name="authentication"> + <properties> + <help>Client authentication methods</help> + </properties> + <children> + #include <include/accel-ppp/auth-mode.xml.i> + <tagNode name="interface"> + <properties> + <help>Network interface for client MAC addresses</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces</script> + </completionHelp> + </properties> + <children> + <tagNode name="mac"> + <properties> + <help>Media Access Control (MAC) address</help> + <valueHelp> + <format>macaddr</format> + <description>Hardware (MAC) address</description> + </valueHelp> + <constraint> + <validator name="mac-address"/> + </constraint> + </properties> + <children> + <node name="rate-limit"> + <properties> + <help>Upload/Download speed limits</help> + </properties> + <children> + <leafNode name="upload"> + <properties> + <help>Upload bandwidth limit in kbits/sec</help> + <constraint> + <validator name="numeric" argument="--range 1-4294967295"/> + </constraint> + </properties> + </leafNode> + <leafNode name="download"> + <properties> + <help>Download bandwidth limit in kbits/sec</help> + <constraint> + <validator name="numeric" argument="--range 1-4294967295"/> + </constraint> + </properties> + </leafNode> + </children> + </node> + <leafNode name="vlan"> + <properties> + <help>VLAN monitor for automatic creation of VLAN interfaces</help> + <valueHelp> + <format>u32:1-4094</format> + <description>Client VLAN id</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-4094"/> + </constraint> + <constraintErrorMessage>VLAN IDs need to be in range 1-4094</constraintErrorMessage> + </properties> + </leafNode> + </children> + </tagNode> + </children> + </tagNode> + #include <include/radius-auth-server-ipv4.xml.i> + #include <include/accel-ppp/radius-additions.xml.i> + <node name="radius"> + <children> + #include <include/accel-ppp/radius-additions-rate-limit.xml.i> + </children> + </node> + </children> + </node> <tagNode name="interface"> <properties> <help>Interface to listen dhcp or unclassified packets</help> @@ -107,81 +182,6 @@ #include <include/accel-ppp/client-ip-pool.xml.i> #include <include/accel-ppp/gateway-address-multi.xml.i> #include <include/accel-ppp/client-ipv6-pool.xml.i> - <node name="authentication"> - <properties> - <help>Client authentication methods</help> - </properties> - <children> - #include <include/accel-ppp/auth-mode.xml.i> - <tagNode name="interface"> - <properties> - <help>Network interface for client MAC addresses</help> - <completionHelp> - <script>${vyos_completion_dir}/list_interfaces</script> - </completionHelp> - </properties> - <children> - <tagNode name="mac"> - <properties> - <help>Media Access Control (MAC) address</help> - <valueHelp> - <format>macaddr</format> - <description>Hardware (MAC) address</description> - </valueHelp> - <constraint> - <validator name="mac-address"/> - </constraint> - </properties> - <children> - <node name="rate-limit"> - <properties> - <help>Upload/Download speed limits</help> - </properties> - <children> - <leafNode name="upload"> - <properties> - <help>Upload bandwidth limit in kbits/sec</help> - <constraint> - <validator name="numeric" argument="--range 1-65535"/> - </constraint> - </properties> - </leafNode> - <leafNode name="download"> - <properties> - <help>Download bandwidth limit in kbits/sec</help> - <constraint> - <validator name="numeric" argument="--range 1-65535"/> - </constraint> - </properties> - </leafNode> - </children> - </node> - <leafNode name="vlan"> - <properties> - <help>VLAN monitor for automatic creation of VLAN interfaces</help> - <valueHelp> - <format>u32:1-4094</format> - <description>Client VLAN id</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 1-4094"/> - </constraint> - <constraintErrorMessage>VLAN IDs need to be in range 1-4094</constraintErrorMessage> - </properties> - </leafNode> - </children> - </tagNode> - </children> - </tagNode> - <node name="radius"> - <children> - #include <include/accel-ppp/radius-additions-rate-limit.xml.i> - </children> - </node> - #include <include/radius-auth-server-ipv4.xml.i> - #include <include/accel-ppp/radius-additions.xml.i> - </children> - </node> #include <include/accel-ppp/default-pool.xml.i> #include <include/accel-ppp/default-ipv6-pool.xml.i> </children> diff --git a/interface-definitions/vpn_l2tp.xml.in b/interface-definitions/vpn_l2tp.xml.in index 942690bca..6148e3269 100644 --- a/interface-definitions/vpn_l2tp.xml.in +++ b/interface-definitions/vpn_l2tp.xml.in @@ -13,6 +13,23 @@ <help>Remote access L2TP VPN</help> </properties> <children> + <node name="authentication"> + <properties> + <help>Authentication for remote access L2TP VPN</help> + </properties> + <children> + #include <include/accel-ppp/auth-local-users.xml.i> + #include <include/accel-ppp/auth-mode.xml.i> + #include <include/accel-ppp/auth-protocols.xml.i> + #include <include/radius-auth-server-ipv4.xml.i> + #include <include/accel-ppp/radius-additions.xml.i> + <node name="radius"> + <children> + #include <include/accel-ppp/radius-additions-rate-limit.xml.i> + </children> + </node> + </children> + </node> #include <include/accel-ppp/max-concurrent-sessions.xml.i> #include <include/accel-ppp/mtu-128-16384.xml.i> <leafNode name="mtu"> @@ -117,23 +134,6 @@ #include <include/accel-ppp/client-ipv6-pool.xml.i> #include <include/generic-description.xml.i> #include <include/dhcp-interface.xml.i> - <node name="authentication"> - <properties> - <help>Authentication for remote access L2TP VPN</help> - </properties> - <children> - #include <include/accel-ppp/auth-protocols.xml.i> - #include <include/accel-ppp/auth-mode.xml.i> - #include <include/accel-ppp/auth-local-users.xml.i> - #include <include/radius-auth-server-ipv4.xml.i> - #include <include/accel-ppp/radius-additions.xml.i> - <node name="radius"> - <children> - #include <include/accel-ppp/radius-additions-rate-limit.xml.i> - </children> - </node> - </children> - </node> #include <include/accel-ppp/ppp-options.xml.i> #include <include/accel-ppp/default-pool.xml.i> #include <include/accel-ppp/default-ipv6-pool.xml.i> diff --git a/interface-definitions/vpn_pptp.xml.in b/interface-definitions/vpn_pptp.xml.in index d23086c02..2e2a3bec4 100644 --- a/interface-definitions/vpn_pptp.xml.in +++ b/interface-definitions/vpn_pptp.xml.in @@ -13,6 +13,23 @@ <help>Remote access PPTP VPN</help> </properties> <children> + <node name="authentication"> + <properties> + <help>Authentication for remote access PPTP VPN</help> + </properties> + <children> + #include <include/accel-ppp/auth-local-users.xml.i> + #include <include/accel-ppp/auth-mode.xml.i> + #include <include/accel-ppp/auth-protocols.xml.i> + #include <include/radius-auth-server-ipv4.xml.i> + #include <include/accel-ppp/radius-additions.xml.i> + <node name="radius"> + <children> + #include <include/accel-ppp/radius-additions-rate-limit.xml.i> + </children> + </node> + </children> + </node> #include <include/accel-ppp/max-concurrent-sessions.xml.i> #include <include/accel-ppp/mtu-128-16384.xml.i> <leafNode name="mtu"> @@ -30,85 +47,6 @@ #include <include/name-server-ipv4-ipv6.xml.i> #include <include/accel-ppp/wins-server.xml.i> #include <include/accel-ppp/client-ip-pool.xml.i> - <node name="authentication"> - <properties> - <help>Authentication for remote access PPTP VPN</help> - </properties> - <children> - <leafNode name="require"> - <properties> - <help>Authentication protocol for remote access peer PPTP VPN</help> - <completionHelp> - <list>pap chap mschap mschap-v2</list> - </completionHelp> - <valueHelp> - <format>pap</format> - <description>Require the peer to authenticate itself using PAP [Password Authentication Protocol].</description> - </valueHelp> - <valueHelp> - <format>chap</format> - <description>Require the peer to authenticate itself using CHAP [Challenge Handshake Authentication Protocol].</description> - </valueHelp> - <valueHelp> - <format>mschap</format> - <description>Require the peer to authenticate itself using CHAP [Challenge Handshake Authentication Protocol].</description> - </valueHelp> - <valueHelp> - <format>mschap-v2</format> - <description>Require the peer to authenticate itself using MS-CHAPv2 [Microsoft Challenge Handshake Authentication Protocol, Version 2].</description> - </valueHelp> - <constraint> - <regex>(pap|chap|mschap|mschap-v2)</regex> - </constraint> - </properties> - <defaultValue>mschap-v2</defaultValue> - </leafNode> - #include <include/accel-ppp/auth-mode.xml.i> - <node name="local-users"> - <properties> - <help>Local user authentication for remote access PPTP VPN</help> - </properties> - <children> - <tagNode name="username"> - <properties> - <help>User name for authentication</help> - </properties> - <children> - #include <include/generic-disable-node.xml.i> - <leafNode name="password"> - <properties> - <help>Password for authentication</help> - </properties> - </leafNode> - <leafNode name="static-ip"> - <properties> - <help>Static client IP address</help> - </properties> - <defaultValue>*</defaultValue> - </leafNode> - </children> - </tagNode> - </children> - </node> - <node name="radius"> - <children> - #include <include/accel-ppp/radius-additions-rate-limit.xml.i> - </children> - </node> - #include <include/radius-auth-server-ipv4.xml.i> - #include <include/accel-ppp/radius-additions.xml.i> - <node name="radius"> - <children> - <leafNode name="timeout"> - <defaultValue>30</defaultValue> - </leafNode> - <leafNode name="acct-timeout"> - <defaultValue>30</defaultValue> - </leafNode> - </children> - </node> - </children> - </node> #include <include/accel-ppp/default-pool.xml.i> #include <include/accel-ppp/client-ipv6-pool.xml.i> #include <include/accel-ppp/default-ipv6-pool.xml.i> diff --git a/op-mode-definitions/file.xml.in b/op-mode-definitions/file.xml.in new file mode 100644 index 000000000..549b9ad92 --- /dev/null +++ b/op-mode-definitions/file.xml.in @@ -0,0 +1,86 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="show"> + <children> + <tagNode name="file"> + <properties> + <help>Show the contents of a file, a directory or an image</help> + <completionHelp><imagePath/></completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/file.py --show $3</command> + </tagNode> + </children> + </node> + <node name="copy"> + <properties> + <help>Copy an object</help> + </properties> + <children> + <tagNode name="file"> + <properties> + <help>Copy a file or a directory</help> + <completionHelp><imagePath/></completionHelp> + </properties> + <children> + <tagNode name="to"> + <properties> + <help>Destination path</help> + <completionHelp><imagePath/></completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/file.py --copy $3 $5 + </command> + </tagNode> + </children> + </tagNode> + </children> + </node> + <node name="delete"> + <properties> + <help>Delete an object</help> + </properties> + <children> + <tagNode name="file"> + <properties> + <help>Delete a local file, possibly from an image</help> + <completionHelp><imagePath/></completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/file.py --delete $3</command> + </tagNode> + </children> + </node> + <node name="clone"> + <properties> + <help>Clone an object</help> + </properties> + <children> + <node name="system"> + <properties> + <help>Clone a system object</help> + </properties> + <children> + <tagNode name="config"> + <properties> + <help>Clone the current system configuration to an image</help> + <completionHelp> + <script>${vyos_completion_dir}/list_images.py --no-running</script> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/file.py --clone $4</command> + <children> + <tagNode name="from"> + <properties> + <help>Clone system configuration from an image to another one</help> + <completionHelp> + <list>running</list> + <script>${vyos_completion_dir}/list_images.py</script> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/file.py --clone-from $6 $4</command> + </tagNode> + </children> + </tagNode> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/accel_ppp_util.py b/python/vyos/accel_ppp_util.py index d60402e48..bd0c46a19 100644 --- a/python/vyos/accel_ppp_util.py +++ b/python/vyos/accel_ppp_util.py @@ -144,6 +144,10 @@ def verify_accel_ppp_base_service(config, local_users=True): if "key" not in radius_config: raise ConfigError(f'Missing RADIUS secret key for server "{server}"') + if dict_search('authentication.radius.dynamic_author.server', config): + if not dict_search('authentication.radius.dynamic_author.key', config): + raise ConfigError('DAE/CoA server key required!') + if "name_server_ipv4" in config: if len(config["name_server_ipv4"]) > 2: raise ConfigError( diff --git a/python/vyos/remote.py b/python/vyos/remote.py index 830770d11..129e65772 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -54,7 +54,7 @@ class InteractivePolicy(MissingHostKeyPolicy): def missing_host_key(self, client, hostname, key): print_error(f"Host '{hostname}' not found in known hosts.") print_error('Fingerprint: ' + key.get_fingerprint().hex()) - if is_interactive() and ask_yes_no('Do you wish to continue?'): + if sys.stdin.isatty() and ask_yes_no('Do you wish to continue?'): if client._host_keys_filename\ and ask_yes_no('Do you wish to permanently add this host/key pair to known hosts?'): client._host_keys.add(hostname, key.get_name(), key) @@ -445,8 +445,10 @@ def download(local_path, urlstring, progressbar=False, check_space=False, if raise_error: raise print_error(f'Unable to download "{urlstring}": {err}') + sys.exit(1) except KeyboardInterrupt: print_error('\nDownload aborted by user.') + sys.exit(1) def upload(local_path, urlstring, progressbar=False, source_host='', source_port=0, timeout=10.0): @@ -455,8 +457,10 @@ def upload(local_path, urlstring, progressbar=False, urlc(urlstring, progressbar, False, source_host, source_port, timeout).upload(local_path) except Exception as err: print_error(f'Unable to upload "{urlstring}": {err}') + sys.exit(1) except KeyboardInterrupt: print_error('\nUpload aborted by user.') + sys.exit(1) def get_remote_config(urlstring, source_host='', source_port=0): """ diff --git a/schema/op-mode-definition.rnc b/schema/op-mode-definition.rnc index cbe51e6dc..ad41700b9 100644 --- a/schema/op-mode-definition.rnc +++ b/schema/op-mode-definition.rnc @@ -95,13 +95,15 @@ command = element command # completionHelp tags contain information about allowed values of a node that is used for generating # tab completion in the CLI frontend and drop-down lists in GUI frontends -# It is only meaninful for leaf nodes +# It is only meaningful for leaf nodes # Allowed values can be given as a fixed list of values (e.g. <list>foo bar baz</list>), # as a configuration path (e.g. <path>interfaces ethernet</path>), -# or as a path to a script file that generates the list (e.g. <script>/usr/lib/foo/list-things</script> +# as a path to a script file that generates the list (e.g. <script>/usr/lib/foo/list-things</script>, +# or to enable built-in image path completion (<imagePath/>). completionHelp = element completionHelp { (element list { text })* & (element path { text })* & - (element script { text })* + (element script { text })* & + (element imagePath { empty })? } diff --git a/schema/op-mode-definition.rng b/schema/op-mode-definition.rng index 900f41e27..a255aeb73 100644 --- a/schema/op-mode-definition.rng +++ b/schema/op-mode-definition.rng @@ -162,6 +162,11 @@ <text/> </element> </zeroOrMore> + <optional> + <element name="imagePath"> + <empty/> + </element> + </optional> </interleave> </element> </define> diff --git a/scripts/build-command-op-templates b/scripts/build-command-op-templates index b008596dc..46ad634b9 100755 --- a/scripts/build-command-op-templates +++ b/scripts/build-command-op-templates @@ -100,6 +100,7 @@ def get_properties(p): scripts = c.findall("script") paths = c.findall("path") lists = c.findall("list") + comptype = c.find("imagePath") # Current backend doesn't support multiple allowed: tags # so we get to emulate it @@ -110,8 +111,12 @@ def get_properties(p): comp_exprs.append("/bin/cli-shell-api listActiveNodes {0} | sed -e \"s/'//g\" && echo".format(i.text)) for i in scripts: comp_exprs.append("{0}".format(i.text)) + if comptype is not None: + props["comp_type"] = "imagefiles" + comp_exprs.append("echo -n \"<imagefiles>\"") comp_help = " && ".join(comp_exprs) props["comp_help"] = comp_help + except: props["comp_help"] = [] @@ -127,6 +132,8 @@ def make_node_def(props, command): help = props["help"] help = fill(help, width=64, subsequent_indent='\t\t\t') node_def += f'help: {help}\n' + if "comp_type" in props: + node_def += f'comptype: {props["comp_type"]}\n' if "comp_help" in props: node_def += f'allowed: {props["comp_help"]}\n' if command is not None: diff --git a/smoketest/scripts/cli/test_protocols_rpki.py b/smoketest/scripts/cli/test_protocols_rpki.py index b43c626c4..c52c0dd76 100755 --- a/smoketest/scripts/cli/test_protocols_rpki.py +++ b/smoketest/scripts/cli/test_protocols_rpki.py @@ -52,27 +52,28 @@ class TestProtocolsRPKI(VyOSUnitTestSHIM.TestCase): self.assertEqual(self.daemon_pid, process_named_running(PROCESS_NAME)) def test_rpki(self): - polling = '7200' + expire_interval = '3600' + polling_period = '600' + retry_interval = '300' cache = { '192.0.2.1' : { 'port' : '8080', - 'preference' : '1' - }, - '192.0.2.2' : { - 'port' : '9090', - 'preference' : '2' + 'preference' : '10' }, '2001:db8::1' : { 'port' : '1234', - 'preference' : '3' + 'preference' : '30' }, - '2001:db8::2' : { + 'rpki.vyos.net' : { 'port' : '5678', - 'preference' : '4' + 'preference' : '40' }, } - self.cli_set(base_path + ['polling-period', polling]) + self.cli_set(base_path + ['expire-interval', expire_interval]) + self.cli_set(base_path + ['polling-period', polling_period]) + self.cli_set(base_path + ['retry-interval', retry_interval]) + for peer, peer_config in cache.items(): self.cli_set(base_path + ['cache', peer, 'port', peer_config['port']]) self.cli_set(base_path + ['cache', peer, 'preference', peer_config['preference']]) @@ -82,7 +83,9 @@ class TestProtocolsRPKI(VyOSUnitTestSHIM.TestCase): # Verify FRR configuration frrconfig = self.getFRRconfig('rpki') - self.assertIn(f'rpki polling_period {polling}', frrconfig) + self.assertIn(f'rpki expire_interval {expire_interval}', frrconfig) + self.assertIn(f'rpki polling_period {polling_period}', frrconfig) + self.assertIn(f'rpki retry_interval {retry_interval}', frrconfig) for peer, peer_config in cache.items(): port = peer_config['port'] diff --git a/smoketest/scripts/cli/test_vpn_l2tp.py b/smoketest/scripts/cli/test_vpn_l2tp.py index e253f0e49..c3b5b500d 100755 --- a/smoketest/scripts/cli/test_vpn_l2tp.py +++ b/smoketest/scripts/cli/test_vpn_l2tp.py @@ -39,7 +39,7 @@ class TestVPNL2TPServer(BasicAccelPPPTest.TestCase): pass def test_l2tp_server_authentication_protocols(self): - # Test configuration of local authentication for PPPoE server + # Test configuration of local authentication protocols self.basic_config() # explicitly test mschap-v2 - no special reason diff --git a/smoketest/scripts/cli/test_vpn_pptp.py b/smoketest/scripts/cli/test_vpn_pptp.py index 40dcb7f80..ac46d210d 100755 --- a/smoketest/scripts/cli/test_vpn_pptp.py +++ b/smoketest/scripts/cli/test_vpn_pptp.py @@ -40,165 +40,5 @@ class TestVPNPPTPServer(BasicAccelPPPTest.TestCase): def basic_protocol_specific_config(self): pass - def test_accel_local_authentication(self): - # Test configuration of local authentication - self.basic_config() - - # upload / download limit - user = "test" - password = "test2" - static_ip = "100.100.100.101" - upload = "5000" - download = "10000" - - self.set( - [ - "authentication", - "local-users", - "username", - user, - "password", - password, - ] - ) - self.set( - [ - "authentication", - "local-users", - "username", - user, - "static-ip", - static_ip, - ] - ) - - # commit changes - self.cli_commit() - - # Validate configuration values - conf = ConfigParser(allow_no_value=True, delimiters="=", strict=False) - conf.read(self._config_file) - - # check proper path to chap-secrets file - self.assertEqual(conf["chap-secrets"]["chap-secrets"], self._chap_secrets) - - # basic verification - self.verify(conf) - - # check local users - tmp = cmd(f"sudo cat {self._chap_secrets}") - regex = f"{user}\s+\*\s+{password}\s+{static_ip}\s" - tmp = re.findall(regex, tmp) - self.assertTrue(tmp) - - # Check local-users default value(s) - self.delete(["authentication", "local-users", "username", user, "static-ip"]) - # commit changes - self.cli_commit() - - # check local users - tmp = cmd(f"sudo cat {self._chap_secrets}") - regex = f"{user}\s+\*\s+{password}\s+\*\s" - tmp = re.findall(regex, tmp) - self.assertTrue(tmp) - - def test_accel_radius_authentication(self): - # Test configuration of RADIUS authentication for PPPoE server - self.basic_config() - - radius_server = "192.0.2.22" - radius_key = "secretVyOS" - radius_port = "2000" - radius_port_acc = "3000" - - self.set(["authentication", "mode", "radius"]) - self.set( - ["authentication", "radius", "server", radius_server, "key", radius_key] - ) - self.set( - [ - "authentication", - "radius", - "server", - radius_server, - "port", - radius_port, - ] - ) - self.set( - [ - "authentication", - "radius", - "server", - radius_server, - "acct-port", - radius_port_acc, - ] - ) - - nas_id = "VyOS-PPPoE" - nas_ip = "7.7.7.7" - self.set(["authentication", "radius", "nas-identifier", nas_id]) - self.set(["authentication", "radius", "nas-ip-address", nas_ip]) - - source_address = "1.2.3.4" - self.set(["authentication", "radius", "source-address", source_address]) - - # commit changes - self.cli_commit() - - # Validate configuration values - conf = ConfigParser(allow_no_value=True, delimiters="=", strict=False) - conf.read(self._config_file) - - # basic verification - self.verify(conf) - - # check auth - self.assertTrue(conf["radius"].getboolean("verbose")) - self.assertEqual(conf["radius"]["acct-timeout"], "30") - self.assertEqual(conf["radius"]["timeout"], "30") - self.assertEqual(conf["radius"]["max-try"], "3") - - self.assertEqual(conf["radius"]["nas-identifier"], nas_id) - self.assertEqual(conf["radius"]["nas-ip-address"], nas_ip) - self.assertEqual(conf["radius"]["bind"], source_address) - - server = conf["radius"]["server"].split(",") - self.assertEqual(radius_server, server[0]) - self.assertEqual(radius_key, server[1]) - self.assertEqual(f"auth-port={radius_port}", server[2]) - self.assertEqual(f"acct-port={radius_port_acc}", server[3]) - self.assertEqual(f"req-limit=0", server[4]) - self.assertEqual(f"fail-time=0", server[5]) - - # - # Disable Radius Accounting - # - self.delete(["authentication", "radius", "server", radius_server, "acct-port"]) - self.set( - [ - "authentication", - "radius", - "server", - radius_server, - "disable-accounting", - ] - ) - - # commit changes - self.cli_commit() - - conf.read(self._config_file) - - server = conf["radius"]["server"].split(",") - self.assertEqual(radius_server, server[0]) - self.assertEqual(radius_key, server[1]) - self.assertEqual(f"auth-port={radius_port}", server[2]) - self.assertEqual(f"acct-port=0", server[3]) - self.assertEqual(f"req-limit=0", server[4]) - self.assertEqual(f"fail-time=0", server[5]) - - if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/completion/list_images.py b/src/completion/list_images.py new file mode 100755 index 000000000..eae29c084 --- /dev/null +++ b/src/completion/list_images.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import os +import sys + +from vyos.system.image import is_live_boot +from vyos.system.image import get_running_image + + +parser = argparse.ArgumentParser(description='list available system images') +parser.add_argument('--no-running', action='store_true', + help='do not display the currently running image') + +def get_images(omit_running: bool = False) -> list[str]: + if is_live_boot(): + return [] + images = os.listdir("/lib/live/mount/persistence/boot") + if omit_running: + images.remove(get_running_image()) + if 'grub' in images: + images.remove('grub') + if 'efi' in images: + images.remove('efi') + return sorted(images) + +if __name__ == '__main__': + args = parser.parse_args() + print("\n".join(get_images(omit_running=args.no_running))) + sys.exit(0) diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index 40d7a6c16..4a0b4d0c5 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -36,7 +36,7 @@ from vyos.qos import RateLimiter from vyos.qos import RoundRobin from vyos.qos import TrafficShaper from vyos.qos import TrafficShaperHFSC -from vyos.utils.process import call +from vyos.utils.process import run from vyos.utils.dict import dict_search_recursive from vyos import ConfigError from vyos import airbag @@ -205,8 +205,8 @@ def apply(qos): # Always delete "old" shapers first for interface in interfaces(): # Ignore errors (may have no qdisc) - call(f'tc qdisc del dev {interface} parent ffff:') - call(f'tc qdisc del dev {interface} root') + run(f'tc qdisc del dev {interface} parent ffff:') + run(f'tc qdisc del dev {interface} root') call_dependents() diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index 6df6f3dc7..5f72b983c 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -26,6 +26,7 @@ from vyos.utils.process import call from vyos.utils.dict import dict_search from vyos.accel_ppp_util import get_pools_in_order from vyos.accel_ppp_util import verify_accel_ppp_ip_pool +from vyos.accel_ppp_util import verify_accel_ppp_base_service from vyos import ConfigError from vyos import airbag airbag.enable() @@ -68,18 +69,9 @@ def verify(ipoe): raise ConfigError('Option "client-subnet" incompatible with "vlan"!' 'Use "ipoe client-ip-pool" instead.') + verify_accel_ppp_base_service(ipoe, local_users=False) verify_accel_ppp_ip_pool(ipoe) - if dict_search('authentication.mode', ipoe) == 'radius': - if not dict_search('authentication.radius.server', ipoe): - raise ConfigError('RADIUS authentication requires at least one server') - - for server in dict_search('authentication.radius.server', ipoe): - radius_config = ipoe['authentication']['radius']['server'][server] - if 'key' not in radius_config: - raise ConfigError(f'Missing RADIUS secret key for server "{server}"') - - return None diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 31299a15c..c2dfbdb44 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -68,6 +68,7 @@ def verify(pppoe): return None verify_accel_ppp_base_service(pppoe) + verify_accel_ppp_ip_pool(pppoe) if 'wins_server' in pppoe and len(pppoe['wins_server']) > 2: raise ConfigError('Not more then two WINS name-servers can be configured') @@ -79,13 +80,6 @@ def verify(pppoe): for interface in pppoe['interface']: verify_interface_exists(interface) - verify_accel_ppp_ip_pool(pppoe) - - if dict_search('authentication.radius.dynamic_author.server', pppoe): - if not dict_search('authentication.radius.dynamic_author.key', pppoe): - raise ConfigError('DA/CoE server key required!') - - return None diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 4ca717814..266381754 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -27,7 +27,6 @@ from vyos.utils.dict import dict_search from vyos.accel_ppp_util import verify_accel_ppp_base_service from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import get_pools_in_order -from vyos.base import Warning from vyos import ConfigError from vyos import airbag @@ -64,14 +63,8 @@ def verify(l2tp): return None verify_accel_ppp_base_service(l2tp) - - if dict_search('authentication.radius.dynamic_author.server', l2tp): - if not dict_search('authentication.radius.dynamic_author.key', l2tp): - raise ConfigError('DA/CoE server key required!') - verify_accel_ppp_ip_pool(l2tp) - if 'wins_server' in l2tp and len(l2tp['wins_server']) > 2: raise ConfigError( 'Not more then two WINS name-servers can be configured') diff --git a/src/migration-scripts/pptp/4-to-5 b/src/migration-scripts/pptp/4-to-5 new file mode 100755 index 000000000..d4b3f9a14 --- /dev/null +++ b/src/migration-scripts/pptp/4-to-5 @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# - Move 'require' from 'protocols' in 'authentication' node +# - Migrate to new default values in radius timeout and acct-timeout + +import os + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + + +if len(argv) < 2: + print("Must specify file name!") + exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) +base = ['vpn', 'pptp', 'remote-access'] + +if not config.exists(base): + exit(0) + +#migrate require to protocols +require_path = base + ['authentication', 'require'] +if config.exists(require_path): + protocols = list(config.return_values(require_path)) + for protocol in protocols: + config.set(base + ['authentication', 'protocols'], value=protocol, + replace=False) + config.delete(require_path) +else: + config.set(base + ['authentication', 'protocols'], value='mschap-v2') + +radius_path = base + ['authentication', 'radius'] +if config.exists(radius_path): + if not config.exists(radius_path + ['timeout']): + config.set(radius_path + ['timeout'], value=3) + if not config.exists(radius_path + ['acct-timeout']): + config.set(radius_path + ['acct-timeout'], value=3) + + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1) diff --git a/src/op_mode/file.py b/src/op_mode/file.py new file mode 100755 index 000000000..bf13bed6f --- /dev/null +++ b/src/op_mode/file.py @@ -0,0 +1,383 @@ +#!/usr/bin/python3 + +# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import contextlib +import datetime +import grp +import os +import pwd +import shutil +import sys +import tempfile + +from vyos.remote import download +from vyos.remote import upload +from vyos.utils.io import ask_yes_no +from vyos.utils.io import print_error +from vyos.utils.process import cmd +from vyos.utils.process import run + + +parser = argparse.ArgumentParser(description='view, copy or remove files and directories', + formatter_class=argparse.RawDescriptionHelpFormatter) +parser.epilog = """ +TYPE is one of 'remote', 'image' and 'local'. +A local path is <path> or ~/<path>. +A remote path is <scheme>://<urn>. +An image path is <image>:<path>. + +Clone operation is between images only. +Copy operation does not support directories from remote locations. +Delete operation does not support remote paths. +""" +operations = parser.add_mutually_exclusive_group(required=True) +operations.add_argument('--show', nargs=1, help='show the contents of file PATH of type TYPE', + metavar=('PATH')) +operations.add_argument('--copy', nargs=2, help='copy SRC to DEST', + metavar=('SRC', 'DEST')) +operations.add_argument('--delete', nargs=1, help='delete file PATH', + metavar=('PATH')) +operations.add_argument('--clone', help='clone config from running image to IMG', + metavar='IMG') +operations.add_argument('--clone-from', nargs=2, help='clone config from image SRC to image DEST', + metavar=('SRC', 'DEST')) + +## Helper procedures +def fix_terminal() -> None: + """ + Reset terminal after potential breakage caused by abrupt exits. + """ + run('stty sane') + +def get_types(arg: str) -> tuple[str, str]: + """ + Determine whether the argument shows a local, image or remote path. + """ + schemes = ['http', 'https', 'ftp', 'ftps', 'sftp', 'ssh', 'scp', 'tftp'] + s = arg.split("://", 1) + if len(s) != 2: + return 'local', arg + elif s[0] in schemes: + return 'remote', arg + else: + return 'image', arg + +def zealous_copy(source: str, destination: str) -> None: + # Even shutil.copy2() doesn't preserve ownership across copies. + # So we need to resort to this. + stats = os.stat(source) + shutil.copy2(source, destination) + os.chown(destination, stats.st_uid, stats.st_gid) + +def get_file_type(path: str) -> str: + return cmd(['file', '-sb', path]) + +def print_header(string: str) -> None: + print('#' * 10, string, '#' * 10) + +def octal_to_symbolic(octal: str) -> str: + perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] + result = "" + # We discard all but the last three digits because we're only + # interested in the permission bits. + for i in octal[-3:]: + result += perms[int(i)] + return result + +def get_user_and_group(stats: os.stat_result) -> tuple[str, str]: + try: + user = pwd.getpwuid(stats.st_uid).pw_name + except (KeyError, PermissionError): + user = str(stats.st_uid) + try: + group = grp.getgrgid(stats.st_gid).gr_name + except (KeyError, PermissionError): + group = str(stats.st_gid) + return user, group + +def print_file_info(path: str) -> None: + stats = os.stat(path) + username, groupname = get_user_and_group(stats) + mtime = datetime.datetime.fromtimestamp(stats.st_mtime).strftime("%F %X") + print_header('FILE INFO') + print(f'Path:\t\t{path}') + # File type is determined through `file(1)`. + print(f'Type:\t\t{get_file_type(path)}') + # Owner user and group + print(f'Owner:\t\t{username}:{groupname}') + # Permissions are converted from raw int to octal string to symbolic string. + print(f'Permissions:\t{octal_to_symbolic(oct(stats.st_mode))}') + # Last date of modification + print(f'Modified:\t{mtime}') + +def print_file_data(path: str) -> None: + print_header('FILE DATA') + file_type = get_file_type(path) + # Human-readable files are streamed line-by-line. + if 'text' in file_type: + with open(path, 'r') as f: + for line in f: + print(line, end='') + # tcpdump files go to TShark. + elif 'pcap' in file_type or os.path.splitext(path)[1] == '.pcap': + print(cmd(['sudo', 'tshark', '-r', path])) + # All other binaries get hexdumped. + else: + print(cmd(['hexdump', '-C', path])) + +def parse_image_path(image_path: str) -> str: + """ + my-image:/foo/bar -> /lib/live/mount/persistence/boot/my-image/rw/foo/bar + """ + image_name, path = image_path.split('://', 1) + if image_name == 'running': + image_root = '/' + elif image_name == 'disk-install': + image_root = '/lib/live/mount/persistence/' + else: + image_root = os.path.join('/lib/live/mount/persistence/boot', image_name, 'rw') + if not os.path.isdir(image_root): + print_error(f'Image {image_name} not found.') + sys.exit(1) + return os.path.join(image_root, path) + + +## Show procedures +def show_locally(path: str) -> None: + """ + Display the contents of a local file or directory. + """ + location = os.path.realpath(os.path.expanduser(path)) + # Temporarily redirect stdout to a throwaway file for `less(1)` to read. + # The output could be potentially too hefty for an in-memory StringIO. + temp = tempfile.NamedTemporaryFile('w', delete=False) + try: + with contextlib.redirect_stdout(temp): + # Just a directory. Call `ls(1)` and bail. + if os.path.isdir(location): + print_header('DIRECTORY LISTING') + print('Path:\t', location) + print(cmd(['ls', '-hlFGL', '--group-directories-first', location])) + elif os.path.isfile(location): + print_file_info(location) + print() + print_file_data(location) + else: + print_error(f'File or directory {path} not found.') + sys.exit(1) + sys.stdout.flush() + # Call `less(1)` and wait for it to terminate before going forward. + cmd(['/usr/bin/less', '-X', temp.name], stdout=sys.stdout) + # The stream to the temporary file could break for any reason. + # It's much less fragile than if we streamed directly to the process stdin. + # But anything could still happen and we don't want to scare the user. + except (BrokenPipeError, EOFError, KeyboardInterrupt, OSError): + fix_terminal() + sys.exit(1) + finally: + os.remove(temp.name) + +def show(type: str, path: str) -> None: + if type == 'remote': + temp = tempfile.NamedTemporaryFile(delete=False) + download(temp.name, path) + show_locally(temp.name) + os.remove(temp.name) + elif type == 'image': + show_locally(parse_image_path(path)) + elif type == 'local': + show_locally(path) + else: + print_error(f'Unknown target for showing: {type}') + print_error('Valid types are "remote", "image" and "local".') + sys.exit(1) + + +## Copying procedures +def copy(source_type: str, source_path: str, + destination_type: str, destination_path: str) -> None: + """ + Copy a file or directory locally, remotely or to and from an image. + Directory uploads and downloads not supported. + """ + source = '' + try: + # Download to a temporary file and use that as the source. + if source_type == 'remote': + source = tempfile.NamedTemporaryFile(delete=False).name + download(source, source_path) + # Prepend the image root to the path. + elif source_type == 'image': + source = parse_image_path(source_path) + elif source_type == 'local': + source = source_path + else: + print_error(f'Unknown source type: {source_type}') + print_error(f'Valid source types are "remote", "image" and "local".') + sys.exit(1) + + # Directly upload the file. + if destination_type == 'remote': + if os.path.isdir(source): + print_error(f'Cannot upload {source}. Directory uploads not supported.') + sys.exit(1) + upload(source, destination_path) + # No need to duplicate local copy operations for image copying. + elif destination_type == 'image': + copy('local', source, 'local', parse_image_path(destination_path)) + # Try to preserve metadata when copying. + elif destination_type == 'local': + if os.path.isdir(destination_path): + destination_path = os.path.join(destination_path, os.path.basename(source)) + if os.path.isdir(source): + shutil.copytree(source, destination_path, copy_function=zealous_copy) + else: + zealous_copy(source, destination_path) + else: + print_error(f'Unknown destination type: {source_type}') + print_error(f'Valid destination types are "remote", "image" and "local".') + sys.exit(1) + except OSError: + import traceback + # We can't check for every single user error (eg copying a directory to a file) + # so we just let a curtailed stack trace provide a descriptive error. + print_error(f'Failed to copy {source_path} to {destination_path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + else: + # To prevent a duplicate message. + if destination_type != 'image': + print('Copy successful.') + finally: + # Clean up temporary file. + if source_type == 'remote': + os.remove(source) + + +## Deletion procedures +def delete_locally(path: str) -> None: + """ + Remove a local file or directory. + """ + try: + if os.path.isdir(path): + if (ask_yes_no(f'Do you want to remove {path} with all its contents?')): + shutil.rmtree(path) + print(f'Directory {path} removed.') + else: + print('Operation aborted.') + elif os.path.isfile(path): + if (ask_yes_no(f'Do you want to remove {path}?')): + os.remove(path) + print(f'File {path} removed.') + else: + print('Operation aborted.') + else: + raise OSError(f'File or directory {path} not found.') + except OSError: + import traceback + print_error(f'Failed to delete {path}.') + traceback.print_exception(*sys.exc_info()[:2], None) + sys.exit(1) + +def delete(type: str, path: str) -> None: + if type == 'local': + delete_locally(path) + elif type == 'image': + delete_locally(parse_image_path(path)) + else: + print_error(f'Unknown target for deletion: {type}') + print_error('Valid types are "image" and "local".') + sys.exit(1) + + +## Cloning procedures +def clone(source: str, destination: str) -> None: + if os.geteuid(): + print_error('Only the superuser can run this command.') + sys.exit(1) + if destination == 'running' or destination == 'disk-install': + print_error(f'Cannot clone config to {destination}.') + sys.exit(1) + # If `source` is None, then we're going to copy from the running image. + if source is None or source == 'running': + source_path = '/config' + # For the warning message only. + source = 'the current' + else: + source_path = parse_image_path(source + ':/config') + destination_path = parse_image_path(destination + ':/config') + backup_path = destination_path + '.preclone' + + if not os.path.isdir(source_path): + print_error(f'Source image {source} does not exist.') + sys.exit(1) + if not os.path.isdir(destination_path): + print_error(f'Destination image {destination} does not exist.') + sys.exit(1) + print(f'WARNING: This operation will erase /config data in image {destination}.') + print(f'/config data in {source} image will be copied over in its place.') + print(f'The existing /config data in {destination} image will be backed up to /config.preclone.') + + if ask_yes_no('Are you sure you want to continue?'): + try: + if os.path.isdir(backup_path): + print('Removing previous backup...') + shutil.rmtree(backup_path) + print('Making new backup...') + shutil.move(destination_path, backup_path) + except: + print('Something went wrong during the backup process!') + print('Cowardly refusing to proceed with cloning.') + raise + # Copy new config from image. + try: + shutil.copytree(source_path, destination_path, copy_function=zealous_copy) + except: + print('Cloning failed! Reverting to backup!') + # Delete leftover files from the botched cloning. + shutil.rmtree(destination_path, ignore_errors=True) + # Restore backup before bailing out. + shutil.copytree(backup_path, destination_path, copy_function=zealous_copy) + raise + else: + print(f'Successfully cloned config from {source} to {destination}.') + finally: + shutil.rmtree(backup_path) + else: + print('Operation aborted.') + +if __name__ == '__main__': + args = parser.parse_args() + try: + if args.show: + show(*get_types(args.show[0])) + elif args.copy: + copy(*get_types(args.copy[0]), + *get_types(args.copy[1])) + elif args.delete: + delete(*get_types(args.delete[0])) + elif args.clone_from: + clone(*args.clone_from) + elif args.clone: + # Pass None as source image to copy from local image. + clone(None, args.clone) + except KeyboardInterrupt: + print_error('Operation cancelled by user.') + sys.exit(1) + sys.exit(0) |