diff options
| author | Oleksandr Kuchmystyi <o.kuchmystyi@vyos.io> | 2026-05-19 12:09:56 +0300 |
|---|---|---|
| committer | Oleksandr Kuchmystyi <o.kuchmystyi@vyos.io> | 2026-05-22 17:50:22 +0300 |
| commit | 81e8f14c5b9b322a998e6530184b07e9e9187ff5 (patch) | |
| tree | 0637a42ef04642439d38f02c1241dfec3c4187ee /src | |
| parent | 51ab39e51bb925307c0f9ad1d905d450ceecc0f5 (diff) | |
| download | vyos-1x-81e8f14c5b9b322a998e6530184b07e9e9187ff5.tar.gz vyos-1x-81e8f14c5b9b322a998e6530184b07e9e9187ff5.zip | |
snmp: T8538: Persist engineBoots counter across reboots
Per RFC 3414 section 2.2 (Replay Protection), the `snmpEngineBoots`
counter must be stored in non-volatile storage and incremented on
every snmpd restart. VyOS was not persisting this value, causing
it to reset to 1 after every reboot.
SNMP managers cache the engineBoots value from previous sessions.
When VyOS resets the counter to 1 after reboot, managers reject
incoming SNMPv3 trap packets as "too old", producing errors such as:
```
usm: Message too old.
reboot count invalid
```
This change introduces `/config/snmp/engineboots.count` as a disk-backed
persist file and it uses to sync the counter into snmpd's conf
before the daemon starts.
Diffstat (limited to 'src')
| -rwxr-xr-x | src/conf_mode/service_snmp.py | 58 | ||||
| -rw-r--r-- | src/system/sync-snmp-engine-boots.py | 84 |
2 files changed, 142 insertions, 0 deletions
diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index b0b57a723..00993d269 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -15,12 +15,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import contextlib from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.defaults import systemd_services from vyos.snmpv3_hashgen import plaintext_to_md5 @@ -33,6 +35,8 @@ from vyos.utils.dict import dict_search from vyos.utils.network import is_addr_assigned from vyos.utils.process import call from vyos.utils.permission import chmod_755 +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.version import get_version_data from vyos import ConfigError from vyos import airbag @@ -46,6 +50,34 @@ default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' systemd_service = systemd_services['snmpd'] + +def _get_engine_boots_and_bump(reset=False): + """ + Read, increment, persist, and return engineBoots counter. + Uses /config/snmp/engineboots.count as persistent storage + across reboots. + + If the 'reset' flag is set, zero will be stored without reading the current state. + """ + persist_count_file = '/config/snmp/engineboots.count' + + # Ensure directory exists atomically + os.makedirs(os.path.dirname(persist_count_file), exist_ok=True) + + count = 0 + + if not reset: + # Read current count, default to 0 on first run or corruption + raw = read_file(persist_count_file, defaultonfailure=str(count)) + with contextlib.suppress(ValueError): + count = int(raw) + + # Persist new value with increment immediately because snmpd will increase + # it automatically after restart the service + write_file(persist_count_file, str(count + 1)) + + return count + def get_config(config=None): if config: conf = config @@ -98,6 +130,26 @@ def get_config(config=None): snmp['script_extensions']['extension_name'][key]['script'] = script_path + # Per RFC 3414 section 2.3 we should reset the engineID to 0: + # > Note, that whenever the local value of snmpEngineID is + # > changed (e.g., through discovery) or when secure communications are + # > first established with an authoritative SNMP engine, the local values + # > of snmpEngineBoots and latestReceivedEngineTime should be set to + # > zero. + # It requires to track changing of this value and reset engineBoots. + if is_node_changed(conf, base + ['v3', 'engineid']): + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + effective=True, + ) + current_engineid = dict_search('v3.engineid', snmp) + prev_engineid = dict_search('v3.engineid', effective_config) + if prev_engineid and current_engineid != prev_engineid: + snmp.update({'engineid_changed': {}}) + return snmp @@ -210,6 +262,12 @@ def generate(snmp): if 'deleted' in snmp: return None + # RFC 3414 compliant: + # - increments by 1 on every snmpd start + # - reset to zero if engineID was changed + with_reset = 'engineid_changed' in snmp + snmp['engine_boots'] = _get_engine_boots_and_bump(reset=with_reset) + if 'v3' in snmp: # SNMPv3 uses a hashed password. If CLI defines a plaintext password, # we will hash it in the background and replace the CLI node! diff --git a/src/system/sync-snmp-engine-boots.py b/src/system/sync-snmp-engine-boots.py new file mode 100644 index 000000000..4213325c4 --- /dev/null +++ b/src/system/sync-snmp-engine-boots.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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/>. +# +# Called via systemd ExecStartPost= after snmpd starts. +# Reads the live engineBoots value from /var/lib/snmp/snmpd.conf and +# writes it to /config/snmp/engineboots.count only if the value differs. +# Fixes T8538: ensures the persistent counter is always in sync with +# what snmpd actually used, so the next restart increments correctly. + +import os +import logging +import contextlib + +import vyos.opmode + +from vyos.utils.file import read_file +from vyos.utils.file import write_file + +SNMPD_CONF = '/var/lib/snmp/snmpd.conf' +PERSIST_FILE = '/config/snmp/engineboots.count' + +# Configure logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.DEBUG) + + +def _read_snmpd_engine_boots() -> int | None: + """Return the engineBoots value from snmpd's persistent conf, or None.""" + + content = read_file(SNMPD_CONF, defaultonfailure='', sudo=True) + for line in content.splitlines(): + if line.startswith('engineBoots'): + parts = line.split() + if len(parts) < 2: + continue + _, value, *_ = parts + with contextlib.suppress(ValueError): + return int(value) + + return None + + +def _read_persist_engine_boots() -> int | None: + """Return the currently saved engineBoots counter, or None.""" + + raw = read_file(PERSIST_FILE, defaultonfailure='') + with contextlib.suppress(ValueError): + return int(raw.strip()) + + return None + + +if __name__ == '__main__': + snmpd_boots = _read_snmpd_engine_boots() + if snmpd_boots is None: + raise vyos.opmode.DataUnavailable( + f'Could not read engineBoots from {SNMPD_CONF}' + ) + + logger.debug(f'engineBoots from snmpd: {snmpd_boots}') + + persist_boots = _read_persist_engine_boots() + logger.debug(f'engineBoots from persist file: {persist_boots}') + + if persist_boots == snmpd_boots: + logger.debug('engineBoots already in sync, nothing to do') + else: + os.makedirs(os.path.dirname(PERSIST_FILE), exist_ok=True) + write_file(PERSIST_FILE, str(snmpd_boots)) + logger.debug(f'engineBoots updated: {persist_boots} -> {snmpd_boots}') |
