summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/templates/high-availability/keepalived.conf.j231
-rw-r--r--data/templates/https/nginx.default.j24
-rw-r--r--data/templates/snmp/etc.snmpd.conf.j27
-rw-r--r--debian/control4
-rw-r--r--interface-definitions/high-availability.xml.in49
-rw-r--r--interface-definitions/service_https.xml.in13
-rw-r--r--interface-definitions/service_snmp.xml.in1
-rw-r--r--op-mode-definitions/crypt.xml.in28
-rw-r--r--python/vyos/tpm.py98
-rwxr-xr-xsmoketest/scripts/cli/test_high-availability_vrrp.py53
-rwxr-xr-xsmoketest/scripts/cli/test_service_snmp.py17
-rwxr-xr-xsrc/conf_mode/high-availability.py37
-rwxr-xr-xsrc/helpers/vyos-config-encrypt.py276
-rwxr-xr-xsrc/init/vyos-router67
-rwxr-xr-xsrc/op_mode/image_installer.py79
-rwxr-xr-xsrc/op_mode/image_manager.py21
16 files changed, 747 insertions, 38 deletions
diff --git a/data/templates/high-availability/keepalived.conf.j2 b/data/templates/high-availability/keepalived.conf.j2
index f34ce64e2..240161748 100644
--- a/data/templates/high-availability/keepalived.conf.j2
+++ b/data/templates/high-availability/keepalived.conf.j2
@@ -33,6 +33,24 @@ global_defs {
notify_fifo_script /usr/libexec/vyos/system/keepalived-fifo.py
}
+{# Sync group has own health-check scripts T6020 #}
+{% if vrrp.sync_group is vyos_defined %}
+{% for name, sync_group_config in vrrp.sync_group.items() if sync_group_config.disable is not vyos_defined %}
+{% if sync_group_config.health_check is vyos_defined %}
+vrrp_script healthcheck_sg_{{ name }} {
+{% if sync_group_config.health_check.script is vyos_defined %}
+ script "{{ sync_group_config.health_check.script }}"
+{% elif sync_group_config.health_check.ping is vyos_defined %}
+ script "/usr/bin/ping -c1 {{ sync_group_config.health_check.ping }}"
+{% endif %}
+ interval {{ sync_group_config.health_check.interval }}
+ fall {{ sync_group_config.health_check.failure_count }}
+ rise 1
+}
+{% endif %}
+{% endfor %}
+{% endif %}
+
{% if vrrp.group is vyos_defined %}
{% for name, group_config in vrrp.group.items() if group_config.disable is not vyos_defined %}
{% if group_config.health_check is vyos_defined %}
@@ -132,7 +150,8 @@ vrrp_instance {{ name }} {
{% endfor %}
}
{% endif %}
-{% if group_config.health_check is vyos_defined %}
+{# Sync group member can't use own health check script #}
+{% if group_config.health_check is vyos_defined and group_config._is_sync_group_member is not vyos_defined %}
track_script {
healthcheck_{{ name }}
}
@@ -152,16 +171,12 @@ vrrp_sync_group {{ name }} {
{% endif %}
}
-{# Health-check scripts should be in section sync-group if member is part of the sync-group T4081 #}
-{% if vrrp.group is vyos_defined %}
-{% for name, group_config in vrrp.group.items() if group_config.disable is not vyos_defined %}
-{% if group_config.health_check.script is vyos_defined and name in sync_group_config.member %}
+{% if sync_group_config.health_check is vyos_defined %}
track_script {
- healthcheck_{{ name }}
+ healthcheck_sg_{{ name }}
}
-{% endif %}
-{% endfor %}
{% endif %}
+
{% if conntrack_sync_group is vyos_defined(name) %}
{% set vyos_helper = "/usr/libexec/vyos/vyos-vrrp-conntracksync.sh" %}
notify_master "{{ vyos_helper }} master {{ name }}"
diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2
index 5d17df001..4619361e5 100644
--- a/data/templates/https/nginx.default.j2
+++ b/data/templates/https/nginx.default.j2
@@ -21,6 +21,10 @@ server {
server_name {{ hostname }};
root /srv/localui;
+{% if request_body_size_limit is vyos_defined %}
+ client_max_body_size {{ request_body_size_limit }}M;
+{% endif %}
+
# SSL configuration
{% if certificates.cert_path is vyos_defined and certificates.key_path is vyos_defined %}
ssl_certificate {{ certificates.cert_path }};
diff --git a/data/templates/snmp/etc.snmpd.conf.j2 b/data/templates/snmp/etc.snmpd.conf.j2
index b1ceb0451..9d91192fc 100644
--- a/data/templates/snmp/etc.snmpd.conf.j2
+++ b/data/templates/snmp/etc.snmpd.conf.j2
@@ -141,8 +141,13 @@ trap2sink {{ trap }}:{{ trap_config.port }} {{ trap_config.community }}
# views
{% for view, view_config in v3.view.items() %}
{% if view_config.oid is vyos_defined %}
-{% for oid in view_config.oid %}
+{% for oid, oid_config in view_config.oid.items() %}
view {{ view }} included .{{ oid }}
+{% if oid_config.exclude is vyos_defined %}
+{% for excluded in oid_config.exclude %}
+view {{ view }} excluded .{{ excluded }}
+{% endfor %}
+{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
diff --git a/debian/control b/debian/control
index 726a083f2..dddc4e14c 100644
--- a/debian/control
+++ b/debian/control
@@ -304,6 +304,10 @@ Depends:
# For "run monitor bandwidth"
bmon,
# End Operational mode
+## TPM tools
+ cryptsetup,
+ tpm2-tools,
+## End TPM tools
## Optional utilities
easy-rsa,
tcptraceroute,
diff --git a/interface-definitions/high-availability.xml.in b/interface-definitions/high-availability.xml.in
index aef57f8ae..558404882 100644
--- a/interface-definitions/high-availability.xml.in
+++ b/interface-definitions/high-availability.xml.in
@@ -345,6 +345,55 @@
</completionHelp>
</properties>
</leafNode>
+ <node name="health-check">
+ <properties>
+ <help>Health check</help>
+ </properties>
+ <children>
+ <leafNode name="failure-count">
+ <properties>
+ <help>Health check failure count required for transition to fault</help>
+ <constraint>
+ <validator name="numeric" argument="--positive" />
+ </constraint>
+ </properties>
+ <defaultValue>3</defaultValue>
+ </leafNode>
+ <leafNode name="interval">
+ <properties>
+ <help>Health check execution interval in seconds</help>
+ <constraint>
+ <validator name="numeric" argument="--positive"/>
+ </constraint>
+ </properties>
+ <defaultValue>60</defaultValue>
+ </leafNode>
+ <leafNode name="ping">
+ <properties>
+ <help>ICMP ping health check</help>
+ <valueHelp>
+ <format>ipv4</format>
+ <description>IPv4 ping target address</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv6</format>
+ <description>IPv6 ping target address</description>
+ </valueHelp>
+ <constraint>
+ <validator name="ip-address"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ <leafNode name="script">
+ <properties>
+ <help>Health check script file</help>
+ <constraint>
+ <validator name="script"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ </children>
+ </node>
#include <include/vrrp-transition-script.xml.i>
</children>
</tagNode>
diff --git a/interface-definitions/service_https.xml.in b/interface-definitions/service_https.xml.in
index b60c7ff2e..afe430c0c 100644
--- a/interface-definitions/service_https.xml.in
+++ b/interface-definitions/service_https.xml.in
@@ -138,6 +138,19 @@
<leafNode name='port'>
<defaultValue>443</defaultValue>
</leafNode>
+ <leafNode name="request-body-size-limit">
+ <properties>
+ <help>Maximum request body size in megabytes</help>
+ <valueHelp>
+ <format>u32:1-256</format>
+ <description>Request body size in megabytes</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-256"/>
+ </constraint>
+ </properties>
+ <defaultValue>1</defaultValue>
+ </leafNode>
<node name="certificates">
<properties>
<help>TLS certificates</help>
diff --git a/interface-definitions/service_snmp.xml.in b/interface-definitions/service_snmp.xml.in
index e16e9daa1..f23151ef9 100644
--- a/interface-definitions/service_snmp.xml.in
+++ b/interface-definitions/service_snmp.xml.in
@@ -543,6 +543,7 @@
<leafNode name="exclude">
<properties>
<help>Exclude is an optional argument</help>
+ <multi/>
</properties>
</leafNode>
<leafNode name="mask">
diff --git a/op-mode-definitions/crypt.xml.in b/op-mode-definitions/crypt.xml.in
new file mode 100644
index 000000000..105592a1a
--- /dev/null
+++ b/op-mode-definitions/crypt.xml.in
@@ -0,0 +1,28 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="encryption">
+ <properties>
+ <help>Manage config encryption</help>
+ </properties>
+ <children>
+ <node name="disable">
+ <properties>
+ <help>Disable config encryption using TPM or recovery key</help>
+ </properties>
+ <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --disable</command>
+ </node>
+ <node name="enable">
+ <properties>
+ <help>Enable config encryption using TPM</help>
+ </properties>
+ <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --enable</command>
+ </node>
+ <node name="load">
+ <properties>
+ <help>Load encrypted config volume using TPM or recovery key</help>
+ </properties>
+ <command>sudo ${vyos_libexec_dir}/vyos-config-encrypt.py --load</command>
+ </node>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/python/vyos/tpm.py b/python/vyos/tpm.py
new file mode 100644
index 000000000..f120e10c4
--- /dev/null
+++ b/python/vyos/tpm.py
@@ -0,0 +1,98 @@
+#!/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/>.
+
+import os
+import tempfile
+
+from vyos.util import rc_cmd
+
+default_pcrs = ['0','2','4','7']
+tpm_handle = 0x81000000
+
+def init_tpm(clear=False):
+ """
+ Initialize TPM
+ """
+ code, output = rc_cmd('tpm2_startup' + (' -c' if clear else ''))
+ if code != 0:
+ raise Exception('init_tpm: Failed to initialize TPM')
+
+def clear_tpm_key():
+ """
+ Clear existing key on TPM
+ """
+ code, output = rc_cmd(f'tpm2_evictcontrol -C o -c {tpm_handle}')
+ if code != 0:
+ raise Exception('clear_tpm_key: Failed to clear TPM key')
+
+def read_tpm_key(index=0, pcrs=default_pcrs):
+ """
+ Read existing key on TPM
+ """
+ with tempfile.TemporaryDirectory() as tpm_dir:
+ pcr_str = ",".join(pcrs)
+
+ tpm_key_file = os.path.join(tpm_dir, 'tpm_key.key')
+ code, output = rc_cmd(f'tpm2_unseal -c {tpm_handle + index} -p pcr:sha256:{pcr_str} -o {tpm_key_file}')
+ if code != 0:
+ raise Exception('read_tpm_key: Failed to read key from TPM')
+
+ with open(tpm_key_file, 'rb') as f:
+ tpm_key = f.read()
+
+ return tpm_key
+
+def write_tpm_key(key, index=0, pcrs=default_pcrs):
+ """
+ Saves key to TPM
+ """
+ with tempfile.TemporaryDirectory() as tpm_dir:
+ pcr_str = ",".join(pcrs)
+
+ policy_file = os.path.join(tpm_dir, 'policy.digest')
+ code, output = rc_cmd(f'tpm2_createpolicy --policy-pcr -l sha256:{pcr_str} -L {policy_file}')
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create policy digest')
+
+ primary_context_file = os.path.join(tpm_dir, 'primary.ctx')
+ code, output = rc_cmd(f'tpm2_createprimary -C e -g sha256 -G rsa -c {primary_context_file}')
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create primary key')
+
+ key_file = os.path.join(tpm_dir, 'crypt.key')
+ with open(key_file, 'wb') as f:
+ f.write(key)
+
+ public_obj = os.path.join(tpm_dir, 'obj.pub')
+ private_obj = os.path.join(tpm_dir, 'obj.key')
+ code, output = rc_cmd(
+ f'tpm2_create -g sha256 \
+ -u {public_obj} -r {private_obj} \
+ -C {primary_context_file} -L {policy_file} -i {key_file}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create object')
+
+ load_context_file = os.path.join(tpm_dir, 'load.ctx')
+ code, output = rc_cmd(f'tpm2_load -C {primary_context_file} -u {public_obj} -r {private_obj} -c {load_context_file}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to load object')
+
+ code, output = rc_cmd(f'tpm2_evictcontrol -c {load_context_file} -C o {tpm_handle + index}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to write object to TPM')
diff --git a/smoketest/scripts/cli/test_high-availability_vrrp.py b/smoketest/scripts/cli/test_high-availability_vrrp.py
index 1bb35e422..9ba06aef6 100755
--- a/smoketest/scripts/cli/test_high-availability_vrrp.py
+++ b/smoketest/scripts/cli/test_high-availability_vrrp.py
@@ -264,5 +264,58 @@ class TestVRRP(VyOSUnitTestSHIM.TestCase):
self.assertIn(f' {peer_address_1}', config)
self.assertIn(f' {peer_address_2}', config)
+ def test_check_health_script(self):
+ sync_group = 'VyOS'
+
+ for group in groups:
+ vlan_id = group.lstrip('VLAN')
+ vip = f'100.64.{vlan_id}.1/24'
+ group_base = base_path + ['vrrp', 'group', group]
+
+ self.cli_set(['interfaces', 'ethernet', vrrp_interface, 'vif', vlan_id, 'address', inc_ip(vip, 1) + '/' + vip.split('/')[-1]])
+
+ self.cli_set(group_base + ['interface', f'{vrrp_interface}.{vlan_id}'])
+ self.cli_set(group_base + ['address', vip])
+ self.cli_set(group_base + ['vrid', vlan_id])
+
+ self.cli_set(group_base + ['health-check', 'ping', '127.0.0.1'])
+
+ # commit changes
+ self.cli_commit()
+
+ for group in groups:
+ config = getConfig(f'vrrp_instance {group}')
+ self.assertIn(f'track_script', config)
+
+ self.cli_set(base_path + ['vrrp', 'sync-group', sync_group, 'member', groups[0]])
+
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_delete(base_path + ['vrrp', 'group', groups[0], 'health-check'])
+ self.cli_commit()
+
+ for group in groups[1:]:
+ config = getConfig(f'vrrp_instance {group}')
+ self.assertIn(f'track_script', config)
+
+ config = getConfig(f'vrrp_instance {groups[0]}')
+ self.assertNotIn(f'track_script', config)
+
+ config = getConfig(f'vrrp_sync_group {sync_group}')
+ self.assertNotIn(f'track_script', config)
+
+ self.cli_set(base_path + ['vrrp', 'sync-group', sync_group, 'health-check', 'ping', '127.0.0.1'])
+
+ # commit changes
+ self.cli_commit()
+
+ config = getConfig(f'vrrp_instance {groups[0]}')
+ self.assertNotIn(f'track_script', config)
+
+ config = getConfig(f'vrrp_sync_group {sync_group}')
+ self.assertIn(f'track_script', config)
+
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/smoketest/scripts/cli/test_service_snmp.py b/smoketest/scripts/cli/test_service_snmp.py
index 52a72ec4f..b3daa90d0 100755
--- a/smoketest/scripts/cli/test_service_snmp.py
+++ b/smoketest/scripts/cli/test_service_snmp.py
@@ -229,5 +229,22 @@ class TestSNMPService(VyOSUnitTestSHIM.TestCase):
tmp = call(f'snmpwalk -v 3 -u {snmpv3_user} -a MD5 -A {snmpv3_auth_pw} -x DES -X {snmpv3_priv_pw} -l authPriv 127.0.0.1', stdout=DEVNULL)
self.assertEqual(tmp, 0)
+ def test_snmpv3_view_exclude(self):
+ snmpv3_view_oid_exclude = ['1.3.6.1.2.1.4.21', '1.3.6.1.2.1.4.24']
+
+ self.cli_set(base_path + ['v3', 'group', snmpv3_group, 'view', snmpv3_view])
+ self.cli_set(base_path + ['v3', 'view', snmpv3_view, 'oid', snmpv3_view_oid])
+
+ for excluded in snmpv3_view_oid_exclude:
+ self.cli_set(base_path + ['v3', 'view', snmpv3_view, 'oid', snmpv3_view_oid, 'exclude', excluded])
+
+ self.cli_commit()
+
+ tmp = read_file(SNMPD_CONF)
+ # views
+ self.assertIn(f'view {snmpv3_view} included .{snmpv3_view_oid}', tmp)
+ for excluded in snmpv3_view_oid_exclude:
+ self.assertIn(f'view {snmpv3_view} excluded .{excluded}', tmp)
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py
index 59d49ea67..c726db8b2 100755
--- a/src/conf_mode/high-availability.py
+++ b/src/conf_mode/high-availability.py
@@ -86,16 +86,7 @@ def verify(ha):
raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"')
if 'health_check' in group_config:
- health_check_types = ["script", "ping"]
- from vyos.utils.dict import check_mutually_exclusive_options
- try:
- check_mutually_exclusive_options(group_config["health_check"], health_check_types, required=True)
- except ValueError:
- Warning(f'Health check configuration for VRRP group "{group}" will remain unused ' \
- f'until it has one of the following options: {health_check_types}')
- # XXX: health check has default options so we need to remove it
- # to avoid generating useless config statements in keepalived.conf
- del group_config["health_check"]
+ _validate_health_check(group, group_config)
# Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction
# We also need to make sure VRID is not used twice on the same interface with the
@@ -146,11 +137,22 @@ def verify(ha):
# Check sync groups
if 'vrrp' in ha and 'sync_group' in ha['vrrp']:
for sync_group, sync_config in ha['vrrp']['sync_group'].items():
+ if 'health_check' in sync_config:
+ _validate_health_check(sync_group, sync_config)
+
if 'member' in sync_config:
for member in sync_config['member']:
if member not in ha['vrrp']['group']:
raise ConfigError(f'VRRP sync-group "{sync_group}" refers to VRRP group "{member}", '\
'but it does not exist!')
+ else:
+ ha['vrrp']['group'][member]['_is_sync_group_member'] = True
+ if ha['vrrp']['group'][member].get('health_check') is not None:
+ raise ConfigError(
+ f'Health check configuration for VRRP group "{member}" will remain unused '
+ f'while it has member of sync group "{sync_group}" '
+ f'Only sync group health check will be used'
+ )
# Virtual-server
if 'virtual_server' in ha:
@@ -172,6 +174,21 @@ def verify(ha):
raise ConfigError(f'Port is required but not set for virtual-server "{vs}" real-server "{rs}"')
+def _validate_health_check(group, group_config):
+ health_check_types = ["script", "ping"]
+ from vyos.utils.dict import check_mutually_exclusive_options
+ try:
+ check_mutually_exclusive_options(group_config["health_check"],
+ health_check_types, required=True)
+ except ValueError:
+ Warning(
+ f'Health check configuration for VRRP group "{group}" will remain unused ' \
+ f'until it has one of the following options: {health_check_types}')
+ # XXX: health check has default options so we need to remove it
+ # to avoid generating useless config statements in keepalived.conf
+ del group_config["health_check"]
+
+
def generate(ha):
if not ha or 'disable' in ha:
if os.path.isfile(systemd_override):
diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py
new file mode 100755
index 000000000..8f7359767
--- /dev/null
+++ b/src/helpers/vyos-config-encrypt.py
@@ -0,0 +1,276 @@
+#!/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/>.
+
+import os
+import re
+import shutil
+import sys
+
+from argparse import ArgumentParser
+from cryptography.fernet import Fernet
+from tempfile import NamedTemporaryFile
+from tempfile import TemporaryDirectory
+
+from vyos.tpm import clear_tpm_key
+from vyos.tpm import init_tpm
+from vyos.tpm import read_tpm_key
+from vyos.tpm import write_tpm_key
+from vyos.util import ask_input
+from vyos.util import ask_yes_no
+from vyos.util import cmd
+
+persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath'
+mount_paths = ['/config', '/opt/vyatta/etc/config']
+dm_device = '/dev/mapper/vyos_config'
+
+def is_opened():
+ return os.path.exists(dm_device)
+
+def get_current_image():
+ with open('/proc/cmdline', 'r') as f:
+ args = f.read().split(" ")
+ for arg in args:
+ if 'vyos-union' in arg:
+ k, v = arg.split("=")
+ path_split = v.split("/")
+ return path_split[-1]
+ return None
+
+def load_config(key):
+ if not key:
+ return
+
+ persist_path = cmd(persistpath_cmd).strip()
+ image_name = get_current_image()
+ image_path = os.path.join(persist_path, 'luks', image_name)
+
+ if not os.path.exists(image_path):
+ raise Exception("Encrypted config volume doesn't exist")
+
+ if is_opened():
+ print('Encrypted config volume is already mounted')
+ return
+
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+
+ for path in mount_paths:
+ cmd(f'mount /dev/mapper/vyos_config {path}')
+ cmd(f'chgrp -R vyattacfg {path}')
+
+ os.unlink(key_file)
+
+ return True
+
+def encrypt_config(key, recovery_key):
+ if is_opened():
+ raise Exception('An encrypted config volume is already mapped')
+
+ # Clear and write key to TPM
+ try:
+ clear_tpm_key()
+ except:
+ pass
+ write_tpm_key(key)
+
+ persist_path = cmd(persistpath_cmd).strip()
+ size = ask_input('Enter size of encrypted config partition (MB): ', numeric_only=True, default=512)
+
+ luks_folder = os.path.join(persist_path, 'luks')
+
+ if not os.path.isdir(luks_folder):
+ os.mkdir(luks_folder)
+
+ image_name = get_current_image()
+ image_path = os.path.join(luks_folder, image_name)
+
+ # Create file for encrypted config
+ cmd(f'fallocate -l {size}M {image_path}')
+
+ # Write TPM key for slot #1
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ # Format and add main key to volume
+ cmd(f'cryptsetup -q luksFormat {image_path} {key_file}')
+
+ if recovery_key:
+ # Write recovery key for slot 2
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(recovery_key)
+ recovery_key_file = f.name
+
+ cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}')
+
+ # Open encrypted volume and format with ext4
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+ cmd('mkfs.ext4 /dev/mapper/vyos_config')
+
+ with TemporaryDirectory() as d:
+ cmd(f'mount /dev/mapper/vyos_config {d}')
+
+ # Move /config to encrypted volume
+ shutil.copytree('/config', d, copy_function=shutil.move, dirs_exist_ok=True)
+
+ cmd(f'umount {d}')
+
+ os.unlink(key_file)
+
+ if recovery_key:
+ os.unlink(recovery_key_file)
+
+ for path in mount_paths:
+ cmd(f'mount /dev/mapper/vyos_config {path}')
+ cmd(f'chgrp vyattacfg {path}')
+
+ return True
+
+def decrypt_config(key):
+ if not key:
+ return
+
+ persist_path = cmd(persistpath_cmd).strip()
+ image_name = get_current_image()
+ image_path = os.path.join(persist_path, 'luks', image_name)
+
+ if not os.path.exists(image_path):
+ raise Exception("Encrypted config volume doesn't exist")
+
+ key_file = None
+
+ if not is_opened():
+ with NamedTemporaryFile(dir='/dev/shm', delete=False) as f:
+ f.write(key)
+ key_file = f.name
+
+ cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}')
+
+ # unmount encrypted volume mount points
+ for path in mount_paths:
+ if os.path.ismount(path):
+ cmd(f'umount {path}')
+
+ # If /config is populated, move to /config.old
+ if len(os.listdir('/config')) > 0:
+ print('Moving existing /config folder to /config.old')
+ shutil.move('/config', '/config.old')
+
+ # Temporarily mount encrypted volume and migrate files to /config on rootfs
+ with TemporaryDirectory() as d:
+ cmd(f'mount /dev/mapper/vyos_config {d}')
+
+ # Move encrypted volume to /config
+ shutil.copytree(d, '/config', copy_function=shutil.move, dirs_exist_ok=True)
+ cmd(f'chgrp -R vyattacfg /config')
+
+ cmd(f'umount {d}')
+
+ # Close encrypted volume
+ cmd('cryptsetup -q close vyos_config')
+
+ # Remove encrypted volume image file and key
+ if key_file:
+ os.unlink(key_file)
+ os.unlink(image_path)
+
+ try:
+ clear_tpm_key()
+ except:
+ pass
+
+ return True
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Must specify action.")
+ sys.exit(1)
+
+ parser = ArgumentParser(description='Config encryption')
+ parser.add_argument('--disable', help='Disable encryption', action="store_true")
+ parser.add_argument('--enable', help='Enable encryption', action="store_true")
+ parser.add_argument('--load', help='Load encrypted config volume', action="store_true")
+ args = parser.parse_args()
+
+ tpm_exists = os.path.exists('/sys/class/tpm/tpm0')
+
+ key = None
+ recovery_key = None
+ need_recovery = False
+
+ question_key_str = 'recovery key' if tpm_exists else 'key'
+
+ if tpm_exists:
+ if args.enable:
+ key = Fernet.generate_key()
+ elif args.disable or args.load:
+ try:
+ key = read_tpm_key()
+ need_recovery = False
+ except:
+ print('Failed to read key from TPM, recovery key required')
+ need_recovery = True
+ else:
+ need_recovery = True
+
+ if args.enable and not tpm_exists:
+ print('WARNING: VyOS will boot into a default config when encrypted without a TPM')
+ print('You will need to manually login with default credentials and use "encryption load"')
+ print('to mount the encrypted volume and use "load /config/config.boot"')
+
+ if not ask_yes_no('Are you sure you want to proceed?'):
+ sys.exit(0)
+
+ if need_recovery or (args.enable and not ask_yes_no(f'Automatically generate a {question_key_str}?', default=True)):
+ while True:
+ recovery_key = ask_input(f'Enter {question_key_str}:', default=None).encode()
+
+ if len(recovery_key) >= 32:
+ break
+
+ print('Invalid key - must be at least 32 characters, try again.')
+ else:
+ recovery_key = Fernet.generate_key()
+
+ try:
+ if args.disable:
+ decrypt_config(key or recovery_key)
+
+ print('Encrypted config volume has been disabled')
+ print('Contents have been migrated to /config on rootfs')
+ elif args.load:
+ load_config(key or recovery_key)
+
+ print('Encrypted config volume has been mounted')
+ print('Use "load /config/config.boot" to load configuration')
+ elif args.enable and tpm_exists:
+ encrypt_config(key, recovery_key)
+
+ print('Encrypted config volume has been enabled with TPM')
+ print('Backup the recovery key in a safe place!')
+ print('Recovery key: ' + recovery_key.decode())
+ elif args.enable:
+ encrypt_config(recovery_key)
+
+ print('Encrypted config volume has been enabled without TPM')
+ print('Backup the key in a safe place!')
+ print('Key: ' + recovery_key.decode())
+ except Exception as e:
+ word = 'decrypt' if args.disable or args.load else 'encrypt'
+ print(f'Failed to {word} config: {e}')
diff --git a/src/init/vyos-router b/src/init/vyos-router
index 912a9ef3b..adf892371 100755
--- a/src/init/vyos-router
+++ b/src/init/vyos-router
@@ -64,6 +64,69 @@ disabled () {
grep -q -w no-vyos-$1 /proc/cmdline
}
+# Load encrypted config volume
+mount_encrypted_config() {
+ persist_path=$(/opt/vyatta/sbin/vyos-persistpath)
+ if [ $? == 0 ]; then
+ if [ -e $persist_path/boot ]; then
+ image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//')
+
+ if [ -z "$image_name" ]; then
+ return
+ fi
+
+ if [ ! -f $persist_path/luks/$image_name ]; then
+ return
+ fi
+
+ vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null)
+
+ if [ $? -ne 0 ]; then
+ echo "ERROR: Failed to fetch encryption key from TPM. Encrypted config volume has not been mounted"
+ echo "Use 'encryption load' to load volume with recovery key"
+ echo "or 'encryption disable' to decrypt volume with recovery key"
+ return
+ fi
+
+ echo $vyos_tpm_key | tr -d '\r\n' | cryptsetup open $persist_path/luks/$image_name vyos_config --key-file=-
+
+ if [ $? -ne 0 ]; then
+ echo "ERROR: Failed to decrypt config volume. Encrypted config volume has not been mounted"
+ echo "Use 'encryption load' to load volume with recovery key"
+ echo "or 'encryption disable' to decrypt volume with recovery key"
+ return
+ fi
+
+ mount /dev/mapper/vyos_config /config
+ mount /dev/mapper/vyos_config $vyatta_sysconfdir/config
+
+ echo "Mounted encrypted config volume"
+ fi
+ fi
+}
+
+unmount_encrypted_config() {
+ persist_path=$(/opt/vyatta/sbin/vyos-persistpath)
+ if [ $? == 0 ]; then
+ if [ -e $persist_path/boot ]; then
+ image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//')
+
+ if [ -z "$image_name" ]; then
+ return
+ fi
+
+ if [ ! -f $persist_path/luks/$image_name ]; then
+ return
+ fi
+
+ umount /config
+ umount $vyatta_sysconfdir/config
+
+ cryptsetup close vyos_config
+ fi
+ fi
+}
+
# if necessary, provide initial config
init_bootfile () {
if [ ! -r $BOOTFILE ] ; then
@@ -402,6 +465,8 @@ start ()
&& chgrp ${GROUP} ${vyatta_configdir}
log_action_end_msg $?
+ mount_encrypted_config
+
# T5239: early read of system hostname as this value is read-only once during
# FRR initialisation
tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "system host-name")
@@ -470,6 +535,8 @@ stop()
log_action_end_msg $?
systemctl stop frr.service
+
+ unmount_encrypted_config
}
case "$action" in
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
index d677c2cf8..85ebd19ba 100755
--- a/src/op_mode/image_installer.py
+++ b/src/op_mode/image_installer.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
#
# This file is part of VyOS.
#
@@ -65,6 +65,8 @@ MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root part
MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial, U: USB-Serial)?'
MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?'
MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?'
+MSG_INPUT_COPY_ENC_DATA: str = 'Would you like to copy the encrypted config to the new image?'
+MSG_INPUT_CHOOSE_COPY_ENC_DATA: str = 'From which image would you like to copy the encrypted config?'
MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?'
MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?'
MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.'
@@ -212,14 +214,17 @@ def search_previous_installation(disks: list[str]) -> None:
disks (list[str]): a list of available disks
"""
mnt_config = '/mnt/config'
+ mnt_encrypted_config = '/mnt/encrypted_config'
mnt_ssh = '/mnt/ssh'
mnt_tmp = '/mnt/tmp'
rmtree(Path(mnt_config), ignore_errors=True)
rmtree(Path(mnt_ssh), ignore_errors=True)
Path(mnt_tmp).mkdir(exist_ok=True)
+ Path(mnt_encrypted_config).unlink(missing_ok=True)
print('Searching for data from previous installations')
image_data = []
+ encrypted_configs = []
for disk_name in disks:
for partition in disk.partition_list(disk_name):
if disk.partition_mount(partition, mnt_tmp):
@@ -227,32 +232,61 @@ def search_previous_installation(disks: list[str]) -> None:
for path in Path(mnt_tmp + '/boot').iterdir():
if path.joinpath('rw/config/.vyatta_config').exists():
image_data.append((path.name, partition))
+ if Path(mnt_tmp + '/luks').exists():
+ for path in Path(mnt_tmp + '/luks').iterdir():
+ encrypted_configs.append((path.name, partition))
disk.partition_umount(partition)
- if len(image_data) == 1:
- image_name, image_drive = image_data[0]
- print('Found data from previous installation:')
- print(f'\t{image_name} on {image_drive}')
- if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
- return
-
- elif len(image_data) > 1:
- print('Found data from previous installations')
- if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
- return
-
- image_name, image_drive = select_entry(image_data,
- 'Available versions:',
- MSG_INPUT_CHOOSE_COPY_DATA,
- search_format_selection)
+ image_name = None
+ image_drive = None
+ encrypted = False
+
+ if len(image_data) > 0:
+ if len(image_data) == 1:
+ print('Found data from previous installation:')
+ print(f'\t{" on ".join(image_data[0])}')
+ if ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
+ image_name, image_drive = image_data[0]
+
+ elif len(image_data) > 1:
+ print('Found data from previous installations')
+ if ask_yes_no(MSG_INPUT_COPY_DATA, default=True):
+ image_name, image_drive = select_entry(image_data,
+ 'Available versions:',
+ MSG_INPUT_CHOOSE_COPY_DATA,
+ search_format_selection)
+ elif len(encrypted_configs) > 0:
+ if len(encrypted_configs) == 1:
+ print('Found encrypted config from previous installation:')
+ print(f'\t{" on ".join(encrypted_configs[0])}')
+ if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True):
+ image_name, image_drive = encrypted_configs[0]
+ encrypted = True
+
+ elif len(encrypted_configs) > 1:
+ print('Found encrypted configs from previous installations')
+ if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True):
+ image_name, image_drive = select_entry(encrypted_configs,
+ 'Available versions:',
+ MSG_INPUT_CHOOSE_COPY_ENC_DATA,
+ search_format_selection)
+ encrypted = True
+
else:
print('No previous installation found')
return
+ if not image_name:
+ return
+
disk.partition_mount(image_drive, mnt_tmp)
- copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config)
+ if not encrypted:
+ copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config)
+ else:
+ copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config)
+
Path(mnt_ssh).mkdir()
host_keys: list[str] = glob(f'{mnt_tmp}/boot/{image_name}/rw/etc/ssh/ssh_host*')
for host_key in host_keys:
@@ -279,6 +313,12 @@ def copy_previous_installation_data(target_dir: str) -> None:
dirs_exist_ok=True)
+def copy_previous_encrypted_config(target_dir: str, image_name: str) -> None:
+ if Path('/mnt/encrypted_config').exists():
+ Path(target_dir).mkdir(exist_ok=True)
+ copy('/mnt/encrypted_config', Path(target_dir).joinpath(image_name))
+
+
def ask_single_disk(disks_available: dict[str, int]) -> str:
"""Ask user to select a disk for installation
@@ -712,6 +752,9 @@ def install_image() -> None:
# owner restored on copy of config data by chmod_2775, above
copy_previous_installation_data(f'{DIR_DST_ROOT}/boot/{image_name}/rw')
+ # copy saved encrypted config volume
+ copy_previous_encrypted_config(f'{DIR_DST_ROOT}/luks', image_name)
+
if is_raid_install(install_target):
write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw'
raid.update_default(write_dir)
diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py
index e64a85b95..1510a667c 100755
--- a/src/op_mode/image_manager.py
+++ b/src/op_mode/image_manager.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
#
# This file is part of VyOS.
#
@@ -95,6 +95,15 @@ def delete_image(image_name: Optional[str] = None,
except Exception as err:
exit(f'Unable to remove the image "{image_name}": {err}')
+ # remove LUKS volume if it exists
+ luks_path: Path = Path(f'{persistence_storage}/luks/{image_name}')
+ if luks_path.is_file():
+ try:
+ luks_path.unlink()
+ print(f'The encrypted config for "{image_name}" was successfully deleted')
+ except Exception as err:
+ exit(f'Unable to remove the encrypted config for "{image_name}": {err}')
+
@compat.grub_cfg_update
def set_image(image_name: Optional[str] = None,
@@ -174,6 +183,16 @@ def rename_image(name_old: str, name_new: str) -> None:
except Exception as err:
exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}')
+ # rename LUKS volume if it exists
+ old_luks_path: Path = Path(f'{persistence_storage}/luks/{name_old}')
+ if old_luks_path.is_file():
+ try:
+ new_luks_path: Path = Path(f'{persistence_storage}/luks/{name_new}')
+ old_luks_path.rename(new_luks_path)
+ print(f'The encrypted config for "{name_old}" was successfully renamed to "{name_new}"')
+ except Exception as err:
+ exit(f'Unable to rename the encrypted config for "{name_old}" to "{name_new}": {err}')
+
def list_images() -> None:
"""Print list of available images for CLI hints"""