Compare commits

...

41 Commits

Author SHA1 Message Date
Pim van Pelt
57fc8d3630 Release v1.3.2 2025-07-13 22:23:20 +02:00
Pim van Pelt
64212fce8c Twiddle ssh auth, use password before --key-file flag before homedir before agent 2025-07-13 22:21:27 +02:00
Pim van Pelt
83797aaa34 Release v1.3.1 2025-07-07 09:10:08 +02:00
Pim van Pelt
3da4de7711 Fix lint errors, ensure errors start with 'hostname:' 2025-07-07 09:06:20 +02:00
Pim van Pelt
9a2264e867 Remove old comments; Count auth mechanisms independently 2025-07-07 09:02:41 +02:00
Pim van Pelt
6c1993282c Release v1.3.0 2025-07-07 01:11:49 +02:00
Pim van Pelt
53c7bca43e Add parallelism 2025-07-07 01:08:42 +02:00
Pim van Pelt
c6775736ac Update docs with exclude patterns 2025-07-07 00:54:52 +02:00
Pim van Pelt
4260067ea8 Release v1.2.4 2025-07-07 00:52:43 +02:00
Pim van Pelt
90f5ec4e26 In preparation for parallelism, emit all log lines prefixed by hostname 2025-07-07 00:51:47 +02:00
Pim van Pelt
c8df809c29 Add types.exclude pattern 2025-07-07 00:39:56 +02:00
Pim van Pelt
88e30a40b1 Print description instead of status 2025-07-07 00:23:02 +02:00
Pim van Pelt
631a387708 Cut v1.2.3 2025-07-06 23:48:17 +02:00
Pim van Pelt
2bba484e6c Output terse 2025-07-06 23:46:59 +02:00
Pim van Pelt
db98af84b0 Release v1.2.2 2025-07-06 23:36:16 +02:00
Pim van Pelt
963cc3eed6 Add mikrotik 2025-07-06 23:33:59 +02:00
Pim van Pelt
9475d7b5c0 Allow glob of --host and --yaml; cut release 1.2.1 2025-07-06 23:31:41 +02:00
Pim van Pelt
fd74c41fb3 Add interface status 2025-07-06 23:05:40 +02:00
Pim van Pelt
7442a83c9d Cut v1.2.0 release 2025-07-06 22:44:35 +02:00
Pim van Pelt
7f6b030b31 Use a .new temp file while gathering info, only move it into place on success 2025-07-06 22:43:33 +02:00
Pim van Pelt
f05124b703 Add RC values, update docs, rename manpage 2025-07-06 22:27:51 +02:00
Pim van Pelt
f2c484e9c1 Rework and simplify the docs 2025-07-06 22:22:40 +02:00
Pim van Pelt
1afa1e6d43 Allow all three auth types 2025-07-06 22:16:01 +02:00
Pim van Pelt
96c7c3aeaa Update README 2025-07-06 18:23:45 +02:00
Pim van Pelt
8032a5a605 Cut release 1.1.1 2025-07-06 18:21:32 +02:00
Pim van Pelt
d212abcc87 Add an 'address' field to devices. Can be hostname, ipv4 or ipv6 2025-07-06 18:17:32 +02:00
Pim van Pelt
4a95221732 Reduce spurious logging 2025-07-06 18:03:30 +02:00
Pim van Pelt
949799acdc Add copyright header 2025-07-06 17:56:59 +02:00
Pim van Pelt
372d7125a1 Update path to etc/ 2025-07-06 17:55:11 +02:00
Pim van Pelt
5385d6fca8 Cut a new release. Simplify Debian build rules, more similar to govpp-snmp-agentx 2025-07-06 17:54:07 +02:00
Pim van Pelt
2a1ed2dd35 Rework tests, and move all source files into main package 2025-07-06 17:38:47 +02:00
Pim van Pelt
db91d59481 Update Makefile 2025-07-06 17:20:10 +02:00
Pim van Pelt
a3d681e420 Update docs 2025-07-06 17:17:45 +02:00
Pim van Pelt
e5f9e59601 Rename yaml/* to etc/ 2025-07-06 17:14:29 +02:00
Pim van Pelt
769d9eb6cd Move to yaml.v3 and mergo. Refactor config parsing into a package. Refactor SSH connections into a package. Create default YAML directory, and update docs 2025-07-06 17:11:22 +02:00
Pim van Pelt
75646856aa Add ssh_config parsing 2025-07-06 16:03:51 +02:00
Pim van Pelt
c0cd81dc4c Fix gitignore and add missing device-types.yaml 2025-07-06 14:39:00 +02:00
Pim van Pelt
5c29786217 Simplify feature list 2025-07-06 12:34:34 +00:00
Pim van Pelt
df09fe84fb Cut v1.0.2 2025-07-06 12:31:26 +00:00
Pim van Pelt
8198b90e60 add yaml include feature 2025-07-06 12:27:49 +00:00
Pim van Pelt
9e0469e016 Create an example config file, remove my own working copy 2025-07-06 11:44:19 +00:00
18 changed files with 1701 additions and 874 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
ipng-router-backup
/*.yaml
# Debian packaging artifacts
debian/.debhelper/

View File

@@ -4,7 +4,7 @@
BINARY_NAME=ipng-router-backup
SOURCE_DIR=src
BUILD_DIR=.
GO_FILES=$(SOURCE_DIR)/main.go
GO_FILES=$(SOURCE_DIR)/*.go
# Default target
.PHONY: all
@@ -22,13 +22,14 @@ sync-version:
.PHONY: build
build: sync-version
@echo "Building $(BINARY_NAME)..."
cd $(SOURCE_DIR) && go build -o ../$(BUILD_DIR)/$(BINARY_NAME) main.go
cd $(SOURCE_DIR) && go build -o ../$(BUILD_DIR)/$(BINARY_NAME) .
@echo "Build complete: $(BINARY_NAME)"
# Clean build artifacts
.PHONY: clean
clean:
@echo "Cleaning build artifacts..."
[ -d debian/go ] && chmod -R +w debian/go || true
rm -rf debian/.debhelper debian/.gocache debian/go debian/$(BINARY_NAME) debian/files debian/*.substvars debian/debhelper-build-stamp
rm -f ../$(BINARY_NAME)_*.deb ../$(BINARY_NAME)_*.changes ../$(BINARY_NAME)_*.buildinfo
rm -f $(BUILD_DIR)/$(BINARY_NAME)

View File

@@ -4,11 +4,24 @@ SSH-based network device configuration backup tool with support for multiple dev
## Features
- **Multi-device backup**: Configure multiple routers in YAML
- **Device type templates**: Reusable command sets per device type
- **Flexible authentication**: SSH agent, key files, or password
- **Selective execution**: Backup specific devices with `--host` flags
- **Professional CLI**: Standard flags, version info, and help
- **Multi-device backup**: Configure multiple devices across multiple YAML files with automatic merging
- **Device type templates**: Reusable command sets per device type, overridable per individual device
- **Flexible authentication**: SSH agent, key files, or password with SSH config support
- **SSH config integration**: Automatically uses `~/.ssh/config` settings for legacy device compatibility
- **Modular configuration**: Load and merge multiple YAML files for organized configuration management
## Supported Devices
Pre-configured device types with optimized command sets:
- **Nokia SR Linux** (`srlinux`) - Show version, linecard, fans, power, full config
- **Arista EOS** (`eos`) - Version, inventory, power status, running config
- **Centec Switches** (`centec`) - Version, boot images, transceivers, interfaces, config
- **Cisco IOS/IOS-XE** (`cisco-ios`) - Version, inventory, config, interfaces, CDP neighbors
- **Juniper JunOS** (`junos`) - Version, chassis hardware, configuration, interfaces
- **Mikrotik RouterOS** (`routeros`) - Packages, routerboard info, license, interfaces, config
Each device type includes carefully selected commands for comprehensive backup coverage. You can override commands per device or create custom device types.
## Quick Start
@@ -24,33 +37,43 @@ make build
### Basic Usage
1. **Create configuration file** (`config.yaml`):
1. **Create configuration files**:
```yaml
types:
srlinux:
commands:
- show version
- show platform linecard
- info flat from running
**Device types** (`00-device-types.yaml`):
```yaml
types:
srlinux:
commands:
- show version
- show platform linecard
- info flat from running
centec:
commands:
- show version
- show transciever
- show running-config
```
devices:
asw100:
user: admin
type: srlinux
asw120:
user: admin
type: srlinux
```
**Device config** (`config.yaml`):
```yaml
devices:
asw100:
user: netops
type: srlinux
switch01:
user: admin
type: centec
```
2. **Run backup**:
```bash
# Backup all devices
ipng-router-backup --config config.yaml --output-dir /backup
# Backup all devices (multiple YAML files are automatically merged)
ipng-router-backup --yaml "00-*.yaml" --yaml config.yaml --output-dir /backup
# Backup specific devices
ipng-router-backup --config config.yaml --host asw100 --output-dir /backup
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --output-dir /backup \
--host "asw*"
```
3. **Check output**:
@@ -71,11 +94,27 @@ cat /backup/asw100
The tool automatically tries authentication methods in this order:
1. **SSH Agent** (if `SSH_AUTH_SOCK` is set)
2. **SSH Key File** (`--key-file` or default locations)
2. **SSH Key File** (`--key-file` or from `~/.ssh/config`)
3. **Password** (`--password` flag)
## SSH Configuration
The tool integrates with `~/.ssh/config` for seamless connection to legacy devices:
```bash
# ~/.ssh/config
Host old-router*
User admin
Port 2222
KexAlgorithms +diffie-hellman-group1-sha1
Ciphers aes128-cbc,aes192-cbc,aes256-cbc
HostKeyAlgorithms +ssh-rsa
```
This allows connecting to older routers that require legacy SSH algorithms while maintaining security for modern devices.
## Documentation
- **[Detailed Documentation](docs/DETAILS.md)** - Complete feature guide, configuration reference, and examples
- **[Manual Page](docs/router_backup.1)** - Unix manual page
- **[Manual Page](docs/ipng-router-backup.1)** - Unix manual page
- **[Changelog](debian/changelog)** - Version history and changes

View File

@@ -1,46 +0,0 @@
types:
srlinux:
commands:
- show version
- show platform linecard
- show platform fan-tray
- show platform power-supply
- info flat from running
eos:
commands:
- show version
- show inventory
- show env power
- show running-config
centec:
commands:
- show version | exc uptime
- show boot images
- show transceiver
- show running-config
devices:
csw150:
user: pim
type: centec
csw151:
user: pim
type: centec
asw100:
user: pim
type: srlinux
asw120:
user: pim
type: srlinux
asw121:
user: pim
type: srlinux
asw110:
user: pim
type: eos
asw111:
user: pim
type: eos
asw112:
user: pim
type: eos

85
debian/changelog vendored
View File

@@ -1,3 +1,88 @@
ipng-router-backup (1.3.2) stable; urgency=low
* Fix --key-file authentication priority issue
* Prioritize explicit key file over SSH agent authentication
-- Pim van Pelt <pim@ipng.ch> Sun, 13 Jul 2025 23:30:00 +0100
ipng-router-backup (1.3.1) stable; urgency=low
* Fix golangci-lint issues, replace deprecated io/ioutil
* Add SSH key error messages with hostname prefix
* Independently validate sshkey, agent auth and password methods
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 23:30:00 +0100
ipng-router-backup (1.3.0) stable; urgency=low
* Add --parallel flag for concurrent device processing (default: 10)
* Implement worker pool pattern for much faster multi-device backups
* Maintain atomic file operations and error handling in parallel mode
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 23:00:00 +0100
ipng-router-backup (1.2.4) stable; urgency=low
* Add regex exclude patterns to filter unwanted output lines per device type
* Prefix all log messages with hostname for better multi-device visibility
* Add exclude pattern support for RouterOS timestamp headers
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 22:00:00 +0100
ipng-router-backup (1.2.3) stable; urgency=low
* For routeros, set mikrotik export to terse
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:30:00 +0100
ipng-router-backup (1.2.2) stable; urgency=low
* Add supported devices list to README.md
* Document all 6 pre-configured device types with command summaries
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:15:00 +0100
ipng-router-backup (1.2.1) stable; urgency=low
* Add glob pattern support for --yaml flag (e.g., --yaml "*.yaml")
* Add glob pattern support for --host flag (e.g., --host "asw*")
* Update documentation with glob pattern examples
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:00:00 +0100
ipng-router-backup (1.2.0) stable; urgency=low
* Add atomic file operations with .new suffix for backup reliability
* Add exit codes: 10 (some devices failed), 11 (all devices failed)
* Update manpage filename to ipng-router-backup.1
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 20:00:00 +0100
ipng-router-backup (1.1.1) stable; urgency=low
* Add 'address' field to device configuration for explicit IP/hostname override
* Automatic IPv6 address detection and proper bracket formatting
* Fix output message to show once at end instead of per command
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 18:00:00 +0100
ipng-router-backup (1.1.0) stable; urgency=low
* Replace --config flag with --yaml flag supporting multiple files
* Switch from !include to mergo-based configuration merging
* Add SSH config integration for legacy device compatibility
* Refactor SSH functionality into separate module
* Add comprehensive test coverage
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 15:30:00 +0100
ipng-router-backup (1.0.2) stable; urgency=low
* Add YAML !include directive support for configuration files
* Add docs/device-types.yaml example file to package
-- Pim van Pelt <pim@ipng.ch> Sun, 06 Jul 2025 12:25:00 +0100
ipng-router-backup (1.0.1) stable; urgency=low
* Add version information to help output

17
debian/rules vendored
View File

@@ -1,25 +1,34 @@
#!/usr/bin/make -f
export GO111MODULE = on
export GOPROXY = https://proxy.golang.org,direct
export GOCACHE = $(CURDIR)/debian/.gocache
export GOPATH = $(CURDIR)/debian/go
%:
dh $@
override_dh_auto_build:
cd src && go build -o ../ipng-router-backup main.go
mkdir -p $(GOCACHE) $(GOPATH)
cd src && go build -o ../ipng-router-backup .
override_dh_auto_install:
mkdir -p debian/ipng-router-backup/usr/bin
mkdir -p debian/ipng-router-backup/etc/ipng-router-backup
mkdir -p debian/ipng-router-backup/usr/share/man/man1
cp ipng-router-backup debian/ipng-router-backup/usr/bin/
cp config.yaml debian/ipng-router-backup/etc/ipng-router-backup/config.yaml.example
cp docs/router_backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
cp etc/* debian/ipng-router-backup/etc/ipng-router-backup/
cp docs/ipng-router-backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
gzip debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
override_dh_auto_clean:
rm -f ipng-router-backup
[ -d debian/go ] && chmod -R +w debian/go || true
for dir in obj-*; do [ -d "$$dir" ] && chmod -R +w "$$dir" || true; done
rm -rf debian/.gocache debian/go obj-*
override_dh_auto_test:
# Skip tests for now
override_dh_dwz:
# Skip dwz compression due to Go binary format
# Skip dwz compression due to Go binary format

View File

@@ -2,64 +2,67 @@
## Overview
IPng Networks Router Backup is a SSH-based network device configuration backup tool written in Go. It connects to multiple network devices defined in a YAML configuration file, executes commands via SSH, and saves the output to local files.
IPng Networks Router Backup is a SSH-based network device configuration backup tool written in Go. It connects to multiple network devices defined in YAML configuration files, executes commands via SSH, and saves the output to local files.
## Key Features
- **Multi-device support**: Backup multiple routers in a single run
- **Device type templates**: Define command sets per device type
- **Configuration merging**: Load and merge multiple YAML files using mergo
- **SSH config integration**: Uses `~/.ssh/config` for legacy device compatibility
- **Flexible authentication**: SSH agent, key files, or password authentication
- **Selective execution**: Target specific devices with `--host` flags
- **Automatic file organization**: Output files named by hostname
- **Command identification**: Each command output prefixed with command name
- **Version synchronization**: Automatic version sync between package and binary
- **IPv6 support**: Automatic IPv6 address detection and proper formatting
## Configuration File Format
The tool uses a YAML configuration file with two main sections: `types` and `devices`.
The tool uses YAML configuration files with two main sections: `types` and `devices`. Multiple YAML files can be loaded and merged automatically.
### Complete Example
**Device types** (`00-device-types.yaml`):
```yaml
types:
srlinux:
commands:
- show version
- show platform linecard
- show platform fan-tray
- show platform power-supply
- info flat from running
eos:
commands:
- show version
- show inventory
- show env power
- show running-config
centec:
commands:
- show version | exc uptime
- show boot images
- show transceiver
- show running-config
routeros:
commands:
- system package print detail without-paging
- / export terse
exclude:
- "^# ....-..-.. ..:..:.. by RouterOS" # Filter timestamp headers
- "^# .../../.... ..:..:.. by RouterOS" # Alternative date format
```
**Main configuration** (`config.yaml`):
```yaml
devices:
asw100:
user: admin
type: srlinux
asw120:
user: netops
type: srlinux
core-01:
user: admin
type: eos
address: 192.168.1.100 # Override connection address
ipv6-router:
user: netops
address: 2001:678:d78:500:: # IPv6 address support
edge-router:
user: operator
commands:
commands: # Direct commands (no type)
- show version
- show ip route summary
```
@@ -67,292 +70,179 @@ devices:
### Configuration Fields
#### Types Section
**`types`**: Define reusable command sets for different device types.
- **`<type-name>`**: Arbitrary name for the device type (e.g., `srlinux`, `eos`)
- **`commands`**: Array of CLI commands to execute on devices of this type
- **`<type-name>`**: Device type name (e.g., `srlinux`, `eos`)
- **`commands`**: Array of CLI commands to execute
- **`exclude`** (optional): Array of regex patterns to filter out unwanted lines from output
#### Devices Section
**`devices`**: Define individual network devices to backup.
- **`<hostname>`**: Device hostname or IP address
- **`user`** (required): SSH username for authentication
- **`type`** (optional): Reference to a type definition for commands
- **`<hostname>`**: Device hostname (used for SSH config lookup and output filename)
- **`user`** (required): SSH username
- **`type`** (optional): Reference to a type definition
- **`commands`** (optional): Direct command list (overrides type commands)
- **`address`** (optional): IP address or hostname to connect to (overrides hostname)
### Configuration Validation
### Configuration Merging
- Each device must have a `user` field
- Each device must have either a `type` field (referencing a valid type) or a `commands` field
- Type references must exist in the `types` section
- Commands can be specified either via type reference or directly per device
Files are merged automatically using mergo. Later files override earlier ones:
## Command Line Flags
```bash
# Load multiple files - later files override earlier ones
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --yaml overrides.yaml
# Load files using glob patterns
ipng-router-backup --yaml "*.yaml"
ipng-router-backup --yaml "config/*.yaml"
```
## Output Filtering
The tool supports filtering unwanted lines from command output using regular expressions in the `exclude` field of device types.
### How Exclude Patterns Work
- **Regex matching**: Each line of command output is tested against all exclude patterns
- **Line removal**: Lines matching any pattern are completely removed from the output file
- **Per-device type**: Exclude patterns are defined at the device type level and apply to all devices of that type
### Common Use Cases
```yaml
types:
routeros:
commands:
- / export terse
exclude:
- "^# ....-..-.. ..:..:.. by RouterOS" # Remove timestamp headers
- "^# .../../.... ..:..:.. by RouterOS" # Alternative date format
cisco-ios:
commands:
- show running-config
exclude:
- "^Building configuration" # Remove config build messages
- "^Current configuration" # Remove current config headers
- "^!" # Remove comment lines
debug-device:
commands:
- show logs
exclude:
- "^DEBUG:" # Filter debug messages
- "^TRACE:" # Filter trace messages
```
## Command Line Usage
### Required Flags
- **`--config`**: Path to YAML configuration file
- **`--yaml`**: Path to YAML configuration file(s) or glob patterns (can be repeated)
### Optional Flags
- **`--output-dir`**: Output directory (default: `/tmp`)
- **`--host`**: Specific hostname(s) or glob patterns to process (can be repeated)
- **`--password`**: SSH password
- **`--key-file`**: SSH private key file path
- **`--port`**: SSH port (default: `22`)
- **`--parallel`**: Maximum number of devices to process in parallel (default: `10`)
- **`--output-dir`**: Output directory for backup files (default: `/tmp`)
- **`--host`**: Specific hostname(s) to process (can be repeated)
- **`--password`**: SSH password for authentication
- **`--key-file`**: Path to SSH private key file
- **`--port`**: SSH port number (default: `22`)
- **`--help`**: Show help information
- **`--version`**: Show version information
### Flag Examples
### Examples
```bash
# Basic usage - all devices
ipng-router-backup --config /etc/network-backup/config.yaml
# Basic usage with glob patterns
ipng-router-backup --yaml "*.yaml"
# Multiple files
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml
# Devices matching patterns
ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
# Custom output directory
ipng-router-backup --config config.yaml --output-dir /backup/network
ipng-router-backup --yaml config.yaml --output-dir /backup/network
# Specific devices only
ipng-router-backup --config config.yaml --host asw100 --host core-01
# With password authentication
ipng-router-backup --yaml config.yaml --password mypassword
# Multiple specific devices
ipng-router-backup --config config.yaml --host asw100 --host asw120 --host core-01
# Custom SSH port
ipng-router-backup --config config.yaml --port 2222
# Using password authentication
ipng-router-backup --config config.yaml --password mypassword
# Using specific SSH key
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
# Process more devices in parallel
ipng-router-backup --yaml config.yaml --parallel 20
```
## SSH Authentication Methods
## SSH Authentication
The tool supports multiple SSH authentication methods in the following priority order:
### 1. SSH Agent (Highest Priority)
Automatically used when the `SSH_AUTH_SOCK` environment variable is set.
The tool supports multiple authentication methods in priority order:
### 1. SSH Agent (Recommended)
Automatically used when `SSH_AUTH_SOCK` is set:
```bash
# Start SSH agent and add keys
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa
# Run backup (will use SSH agent automatically)
ipng-router-backup --config config.yaml
ipng-router-backup --yaml config.yaml
```
**Advantages:**
- Most secure (keys remain in memory)
- No password prompts
- Works with hardware security modules
- Single sign-on experience
### 2. SSH Key File
Specify a private key file with `--key-file` or use default locations.
```bash
# Explicit key file
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
ipng-router-backup --yaml config.yaml --key-file ~/.ssh/network_key
# Tool automatically checks these default locations:
# ~/.ssh/id_rsa
# ~/.ssh/id_ed25519
# ~/.ssh/id_ecdsa
# Automatic detection from default locations:
# ~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa
ipng-router-backup --yaml config.yaml
```
**Key File Requirements:**
- Must be in OpenSSH format
- Proper permissions (600 recommended)
- Corresponding public key must be on target devices
### 3. Password Authentication
```bash
ipng-router-backup --yaml config.yaml --password mypassword
```
### 3. Password Authentication (Lowest Priority)
## SSH Configuration Integration
Use `--password` flag for password-based authentication.
The tool automatically reads `~/.ssh/config` for each host:
```bash
# Command line password (not recommended for scripts)
ipng-router-backup --config config.yaml --password mypassword
# ~/.ssh/config
Host old-switch*
User admin
Port 2222
IdentityFile ~/.ssh/legacy_key
KexAlgorithms +diffie-hellman-group1-sha1
HostKeyAlgorithms +ssh-rsa
# Interactive password prompt (when no other auth available)
ipng-router-backup --config config.yaml
# Output: "No SSH key found. Enter SSH password: "
Host modern-router*
User netops
IdentityFile ~/.ssh/modern_key
```
**Security Considerations:**
- Passwords visible in process lists
- Not suitable for automation
- Consider using key-based authentication instead
**Supported options:** Hostname, Port, User, IdentityFile, KexAlgorithms, MACs, HostKeyAlgorithms
## Output Format
### File Naming
Output files are named after the device hostname:
- Output files are named after the device hostname
- Device `asw100` → File `asw100`
- Device `192.168.1.1` → File `192.168.1.1`
### File Content Structure
Each output file contains all command outputs with headers:
### File Content
Each file contains all command outputs with headers:
```
## COMMAND: show version
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Hostname : asw100
Chassis Type : 7220 IXR-D4
Software Version : v25.3.2
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
------------------------------------------------------------------------
## COMMAND: show platform linecard
+-------------+----+-------------+-------------------+---------------------------------+
| Module Type | ID | Admin State | Operational State | Model |
+=============+====+=============+===================+=================================+
| linecard | 1 | N/A | up | imm28-100g-qsfp28+8-400g-qsfpdd |
+-------------+----+-------------+-------------------+---------------------------------+
```
### File Behavior
- **New runs**: Files are truncated and recreated
- **Multiple commands**: All outputs concatenated in single file
- **Command identification**: Each command prefixed with `## COMMAND: <command>`
## Usage Examples
### Basic Backup All Devices
```bash
ipng-router-backup --config /etc/backup/network.yaml --output-dir /backup/$(date +%Y%m%d)
```
### Backup Specific Device Types
Create a config with only the devices you want, or use `--host`:
```bash
# Backup only SR Linux devices
ipng-router-backup --config network.yaml --host asw100 --host asw120 --host asw121
```
### Scheduled Backup with SSH Agent
```bash
#!/bin/bash
# /etc/cron.daily/network-backup
# Start SSH agent
eval "$(ssh-agent -s)"
ssh-add /root/.ssh/network_backup_key
# Run backup
BACKUP_DIR="/backup/network/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
ipng-router-backup \
--config /etc/network-backup/config.yaml \
--output-dir "$BACKUP_DIR"
# Kill SSH agent
ssh-agent -k
```
### Emergency Single Device Backup
```bash
# Quick backup of single device with password
ipng-router-backup \
--config emergency.yaml \
--host core-router-01 \
--password emergency123 \
--output-dir /tmp/emergency-backup
+-------------+----+-------------+-------------------+
| Module Type | ID | Admin State | Operational State |
+=============+====+=============+===================+
| linecard | 1 | N/A | up |
+-------------+----+-------------+-------------------+
```
## Error Handling
### Common Issues and Solutions
**Device Connection Failures:**
- Check SSH connectivity: `ssh user@hostname`
- Verify authentication method
- Check firewall rules and network connectivity
**Configuration Errors:**
- Validate YAML syntax: `yamllint config.yaml`
- Check that all referenced types exist
- Ensure all devices have required fields
**Permission Issues:**
- Verify SSH key permissions (600)
- Check output directory write permissions
- Ensure user has SSH access to target devices
### Common Issues
- **Connection failures**: Check SSH connectivity and authentication
- **Configuration errors**: Validate YAML syntax and required fields
- **Permission issues**: Verify SSH key permissions (600) and output directory access
### Exit Codes
- `0`: Success
- `0`: Success (all devices processed successfully)
- `1`: Configuration error, authentication failure, or connection issues
## Advanced Usage
### Integration with Git
```bash
#!/bin/bash
# Backup and commit to git repository
BACKUP_DIR="/backup/network-configs"
cd "$BACKUP_DIR"
# Run backup
ipng-router-backup --config config.yaml --output-dir .
# Commit changes
git add .
git commit -m "Network backup $(date '+%Y-%m-%d %H:%M:%S')"
git push origin main
```
### Custom Command Sets per Environment
```yaml
types:
production-srlinux:
commands:
- show version
- show system information
- info flat from running
lab-srlinux:
commands:
- show version
- show interface brief
devices:
prod-asw100:
user: readonly
type: production-srlinux
lab-asw100:
user: admin
type: lab-srlinux
```
### Monitoring and Alerting
```bash
#!/bin/bash
# Backup with monitoring
if ipng-router-backup --config config.yaml --output-dir /backup; then
echo "Backup completed successfully" | logger
else
echo "Backup failed!" | logger
# Send alert email
echo "Network backup failed at $(date)" | mail -s "Backup Alert" admin@company.com
fi
```
- `10`: Some devices failed
- `11`: All devices failed

View File

@@ -3,7 +3,7 @@
ipng-router-backup \- SSH Router Backup Tool
.SH SYNOPSIS
.B ipng-router-backup
.RI --config " CONFIG_FILE"
.RI --yaml " CONFIG_FILE(S)"
.RI [ --output-dir " DIRECTORY" ]
.RI [ --password " PASSWORD" ]
.RI [ --key-file " KEYFILE" ]
@@ -11,13 +11,14 @@ ipng-router-backup \- SSH Router Backup Tool
.RI [ --host " HOSTNAME" ]...
.SH DESCRIPTION
.B router_backup
is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a YAML configuration file and executes commands, saving the output to files.
is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a
set of YAML configuration file(s) and executes commands, saving the output to files.
.PP
The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization.
.SH OPTIONS
.TP
.BR --config " \fICONFIG_FILE\fR"
YAML configuration file path (required)
.BR --yaml " \fICONFIG_FILE\fR"
YAML configuration file(s) or glob patterns (required)
.TP
.BR --output-dir " \fIDIRECTORY\fR"
Output directory for command output files (default: /tmp)
@@ -32,7 +33,10 @@ SSH private key file path
SSH port number (default: 22)
.TP
.BR --host " \fIHOSTNAME\fR"
Specific host(s) to process (can be repeated, processes all if not specified)
Specific host(s) or glob patterns to process (can be repeated, processes all if not specified)
.TP
.BR --parallel " \fINUMBER\fR"
Maximum number of devices to process in parallel (default: 10)
.TP
.BR --help
Show help message
@@ -47,6 +51,11 @@ types:
commands:
- show version
- show platform linecard
routeros:
commands:
- / export terse
exclude:
- "^# ....-..-.. ..:..:.. by RouterOS"
.EE
.SS devices
Define individual devices:
@@ -69,26 +78,33 @@ Default SSH keys (~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa)
Password authentication (--password option)
.SH OUTPUT
For each device, a text file named after the hostname is created in the specified directory. Each command output is prefixed with "## COMMAND: <command_name>" for easy identification.
.PP
Output can be filtered using regex patterns defined in the device type's 'exclude' field to remove unwanted lines such as timestamps or debug messages.
.SH EXAMPLES
.TP
Basic usage:
Basic usage with glob patterns:
.EX
ipng-router-backup --config /etc/ipng-router-backup/config.yaml
ipng-router-backup --yaml "*.yaml"
.EE
.TP
Custom output directory:
.EX
ipng-router-backup --config config.yaml --output-dir /home/user/backups
ipng-router-backup --yaml config.yaml --output-dir /home/user/backups
.EE
.TP
Using password authentication:
.EX
ipng-router-backup --config config.yaml --password mysecretpass
ipng-router-backup --yaml config.yaml --password mysecretpass
.EE
.TP
Process specific hosts only:
Process hosts matching patterns:
.EX
ipng-router-backup --config config.yaml --host asw100 --host asw120
ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
.EE
.TP
Process devices in parallel:
.EX
ipng-router-backup --yaml config.yaml --parallel 20
.EE
.SH FILES
.TP
@@ -97,10 +113,16 @@ Example configuration file
.SH EXIT STATUS
.TP
.B 0
Success
Success (all devices processed successfully)
.TP
.B 1
General error (configuration file not found, authentication failure, etc.)
.TP
.B 10
Some devices failed
.TP
.B 11
All devices failed
.SH AUTHOR
Written by Pim van Pelt.
.SH REPORTING BUGS

61
etc/00-device-types.yaml Normal file
View File

@@ -0,0 +1,61 @@
# This file defines several types of router.
#
# The ipng-router-backup tool will read them in order, and merge new contents
# as it reads new files. Use file naming (00-* through 99-*) to force them to
# be read in a specific order.
types:
# Nokia SR Linux devices
srlinux:
commands:
- show version # System version and build info
- show platform linecard # Line card information
- show platform fan-tray # Fan status and health
- show platform power-supply # Power supply status
- info flat from running # Full running configuration
# Arista EOS devices
eos:
commands:
- show version # System version information
- show inventory # Hardware inventory
- show env power # Power supply status
- show running-config # Complete running configuration
# Centec switches
centec:
commands:
- show version | exc uptime # Version info without uptime line
- show boot images # Boot image information
- show transceiver # SFP/transceiver status
- show interface description # Interface status
- show running-config # Running configuration
# Cisco IOS/IOS-XE devices
cisco-ios:
commands:
- show version # IOS version and hardware info
- show inventory # Hardware inventory details
- show running-config # Complete configuration
- show ip interface brief # Interface IP summary
- show cdp neighbors # CDP neighbor information
# Juniper devices
junos:
commands:
- show version # Software and hardware version
- show chassis hardware # Chassis hardware details
- show configuration | display set # Configuration in set format
- show interfaces terse # Interface status summary
# Mikrotik routeros devices
routeros:
commands:
- system package print detail without-paging # Installed Packaged
- system routerboard print # System information
- system license print # License information
- / interface print # Interfaces
- / export terse # Configuration
exclude:
- "^# ....-..-.. ..:..:.. by RouterOS"
- "^# .../../.... ..:..:.. by RouterOS"

57
etc/config.yaml.example Normal file
View File

@@ -0,0 +1,57 @@
# IPng Networks Router Backup Configuration Example
# Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
#
# This file demonstrates how to configure the ipng-router-backup tool.
# Copy these files to a location of your choice and add local overrides
# in a custom YAML file. The tool will read and merge all YAML files in
# the order they appear on the commandline:
#
# Usage: ipng-router-backup --yaml *.yaml
# Devices Section
# Define individual network devices to backup
devices:
# Core switches (SR Linux)
asw100:
user: admin # SSH username
type: srlinux # Reference to type above
asw120:
user: netops # Different user per device if needed
type: srlinux
asw121:
user: admin
type: srlinux
# Distribution switches (Centec)
csw150:
user: admin
type: centec
csw151:
user: admin
type: centec
# Edge routers (Arista EOS)
edge-01:
user: automation
type: eos
edge-02:
user: automation
type: eos
# Special case: Device with custom commands (overrides type)
legacy-router:
user: admin
commands:
- show version
- show running-config
- show ip route summary
# Custom commands specific to this device only
# Example using IP address instead of hostname
192.168.1.100:
user: operator
type: cisco-ios

77
src/config.go Normal file
View File

@@ -0,0 +1,77 @@
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
package main
import (
"fmt"
"os"
"dario.cat/mergo"
"gopkg.in/yaml.v3"
)
// Config structures
type Config struct {
Types map[string]DeviceType `yaml:"types"`
Devices map[string]Device `yaml:"devices"`
}
type DeviceType struct {
Commands []string `yaml:"commands"`
Exclude []string `yaml:"exclude,omitempty"`
}
type Device struct {
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
Address string `yaml:"address,omitempty"`
}
func readYAMLFile(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := yaml.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// ConfigRead loads and merges multiple YAML files into a single config object
func ConfigRead(yamlFiles []string) (*Config, error) {
var finalConfig map[string]interface{}
for _, file := range yamlFiles {
current, err := readYAMLFile(file)
if err != nil {
return nil, fmt.Errorf("failed to parse %s: %v", file, err)
}
if finalConfig == nil {
finalConfig = current
} else {
err := mergo.Merge(&finalConfig, current, mergo.WithOverride)
if err != nil {
return nil, fmt.Errorf("failed to merge %s: %v", file, err)
}
}
}
// Convert back to structured config
out, err := yaml.Marshal(finalConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged config: %v", err)
}
var config Config
if err := yaml.Unmarshal(out, &config); err != nil {
return nil, fmt.Errorf("failed to unmarshal to Config struct: %v", err)
}
return &config, nil
}

362
src/config_test.go Normal file
View File

@@ -0,0 +1,362 @@
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
package main
import (
"os"
"path/filepath"
"testing"
)
func TestConfigRead(t *testing.T) {
tempDir := t.TempDir()
// Create a single config file with types and devices
configPath := filepath.Join(tempDir, "test-config.yaml")
configContent := `types:
test-type:
commands:
- show version
- show status
devices:
test-device:
user: testuser
type: test-type
direct-device:
user: directuser
commands:
- direct command
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create test config file: %v", err)
}
cfg, err := ConfigRead([]string{configPath})
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Test types section
if len(cfg.Types) != 1 {
t.Errorf("Expected 1 type, got %d", len(cfg.Types))
}
testType, exists := cfg.Types["test-type"]
if !exists {
t.Error("Expected 'test-type' to exist in types")
}
if len(testType.Commands) != 2 {
t.Errorf("Expected 2 commands in test-type, got %d", len(testType.Commands))
}
// Test devices section
if len(cfg.Devices) != 2 {
t.Errorf("Expected 2 devices, got %d", len(cfg.Devices))
}
testDevice, exists := cfg.Devices["test-device"]
if !exists {
t.Error("Expected 'test-device' to exist in devices")
}
if testDevice.User != "testuser" {
t.Errorf("Expected user 'testuser', got '%s'", testDevice.User)
}
if testDevice.Type != "test-type" {
t.Errorf("Expected type 'test-type', got '%s'", testDevice.Type)
}
}
func TestConfigReadMerging(t *testing.T) {
tempDir := t.TempDir()
// Create first config file with device types
typesPath := filepath.Join(tempDir, "types.yaml")
typesContent := `types:
test-type:
commands:
- show version
- show status`
err := os.WriteFile(typesPath, []byte(typesContent), 0644)
if err != nil {
t.Fatalf("Failed to create types file: %v", err)
}
// Create second config file with devices
devicesPath := filepath.Join(tempDir, "devices.yaml")
devicesContent := `devices:
test-device:
user: testuser
type: test-type`
err = os.WriteFile(devicesPath, []byte(devicesContent), 0644)
if err != nil {
t.Fatalf("Failed to create devices file: %v", err)
}
// Load and merge configs
cfg, err := ConfigRead([]string{typesPath, devicesPath})
if err != nil {
t.Fatalf("Failed to merge configs: %v", err)
}
// Check that merging worked
if len(cfg.Types) != 1 {
t.Errorf("Expected 1 type, got %d", len(cfg.Types))
}
testType, exists := cfg.Types["test-type"]
if !exists {
t.Error("Expected 'test-type' to exist in merged config")
}
if len(testType.Commands) != 2 {
t.Errorf("Expected 2 commands in test-type, got %d", len(testType.Commands))
}
if len(cfg.Devices) != 1 {
t.Errorf("Expected 1 device, got %d", len(cfg.Devices))
}
testDevice, exists := cfg.Devices["test-device"]
if !exists {
t.Error("Expected 'test-device' to exist in merged config")
}
if testDevice.Type != "test-type" {
t.Errorf("Expected device type 'test-type', got '%s'", testDevice.Type)
}
}
func TestConfigReadOverrides(t *testing.T) {
tempDir := t.TempDir()
// Create base config
basePath := filepath.Join(tempDir, "base.yaml")
baseContent := `devices:
test-device:
user: baseuser
type: base-type`
err := os.WriteFile(basePath, []byte(baseContent), 0644)
if err != nil {
t.Fatalf("Failed to create base file: %v", err)
}
// Create override config
overridePath := filepath.Join(tempDir, "override.yaml")
overrideContent := `devices:
test-device:
user: overrideuser`
err = os.WriteFile(overridePath, []byte(overrideContent), 0644)
if err != nil {
t.Fatalf("Failed to create override file: %v", err)
}
// Load with override (later file should override earlier file)
cfg, err := ConfigRead([]string{basePath, overridePath})
if err != nil {
t.Fatalf("Failed to merge configs: %v", err)
}
testDevice := cfg.Devices["test-device"]
if testDevice.User != "overrideuser" {
t.Errorf("Expected overridden user 'overrideuser', got '%s'", testDevice.User)
}
// Type should be preserved from base config
if testDevice.Type != "base-type" {
t.Errorf("Expected type 'base-type' to be preserved, got '%s'", testDevice.Type)
}
}
func TestConfigReadInvalidFile(t *testing.T) {
_, err := ConfigRead([]string{"/nonexistent/config.yaml"})
if err == nil {
t.Error("Expected error when loading nonexistent config file")
}
}
func TestConfigReadInvalidYAML(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "invalid-config.yaml")
// Create a file with invalid YAML syntax
invalidContent := `types:
test-type:
commands
- invalid yaml`
err := os.WriteFile(configPath, []byte(invalidContent), 0644)
if err != nil {
t.Fatalf("Failed to create invalid config file: %v", err)
}
_, err = ConfigRead([]string{configPath})
if err == nil {
t.Error("Expected error when loading invalid YAML")
}
}
func TestConfigReadEmptyFile(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "empty-config.yaml")
// Create empty file
err := os.WriteFile(configPath, []byte(""), 0644)
if err != nil {
t.Fatalf("Failed to create empty config file: %v", err)
}
cfg, err := ConfigRead([]string{configPath})
if err != nil {
t.Fatalf("Failed to load empty config: %v", err)
}
// Should have empty maps
if len(cfg.Types) != 0 {
t.Errorf("Expected 0 types in empty config, got %d", len(cfg.Types))
}
if len(cfg.Devices) != 0 {
t.Errorf("Expected 0 devices in empty config, got %d", len(cfg.Devices))
}
}
func TestConfigReadComplexMerge(t *testing.T) {
tempDir := t.TempDir()
// Create device types file
typesPath := filepath.Join(tempDir, "types.yaml")
typesContent := `types:
srlinux:
commands:
- show version
- show platform linecard
eos:
commands:
- show version
- show inventory`
err := os.WriteFile(typesPath, []byte(typesContent), 0644)
if err != nil {
t.Fatalf("Failed to create types file: %v", err)
}
// Create production devices file
prodPath := filepath.Join(tempDir, "production.yaml")
prodContent := `devices:
prod-asw100:
user: netops
type: srlinux
prod-core-01:
user: netops
type: eos`
err = os.WriteFile(prodPath, []byte(prodContent), 0644)
if err != nil {
t.Fatalf("Failed to create production file: %v", err)
}
// Create lab devices file
labPath := filepath.Join(tempDir, "lab.yaml")
labContent := `devices:
lab-switch:
user: admin
type: srlinux
commands:
- show version only`
err = os.WriteFile(labPath, []byte(labContent), 0644)
if err != nil {
t.Fatalf("Failed to create lab file: %v", err)
}
// Load merged configuration
cfg, err := ConfigRead([]string{typesPath, prodPath, labPath})
if err != nil {
t.Fatalf("Failed to load merged config: %v", err)
}
// Verify types were loaded correctly
if len(cfg.Types) != 2 {
t.Errorf("Expected 2 types, got %d", len(cfg.Types))
}
srlinuxType, exists := cfg.Types["srlinux"]
if !exists {
t.Error("Expected 'srlinux' type to exist")
}
if len(srlinuxType.Commands) != 2 {
t.Errorf("Expected 2 commands for srlinux type, got %d", len(srlinuxType.Commands))
}
// Verify devices reference the correct types
if len(cfg.Devices) != 3 {
t.Errorf("Expected 3 devices, got %d", len(cfg.Devices))
}
prodDevice, exists := cfg.Devices["prod-asw100"]
if !exists {
t.Error("Expected 'prod-asw100' device to exist")
}
if prodDevice.Type != "srlinux" {
t.Errorf("Expected prod-asw100 type 'srlinux', got '%s'", prodDevice.Type)
}
labDevice, exists := cfg.Devices["lab-switch"]
if !exists {
t.Error("Expected 'lab-switch' device to exist")
}
if len(labDevice.Commands) != 1 {
t.Errorf("Expected 1 custom command for lab-switch, got %d", len(labDevice.Commands))
}
}
func TestConfigReadAddress(t *testing.T) {
tempDir := t.TempDir()
// Create config file with address field
configPath := filepath.Join(tempDir, "address-config.yaml")
configContent := `devices:
router-with-address:
user: testuser
address: 192.168.1.100
router-without-address:
user: testuser`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create test config file: %v", err)
}
cfg, err := ConfigRead([]string{configPath})
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Test device with address
deviceWithAddress, exists := cfg.Devices["router-with-address"]
if !exists {
t.Error("Expected 'router-with-address' to exist")
}
if deviceWithAddress.Address != "192.168.1.100" {
t.Errorf("Expected address '192.168.1.100', got '%s'", deviceWithAddress.Address)
}
// Test device without address (should be empty)
deviceWithoutAddress, exists := cfg.Devices["router-without-address"]
if !exists {
t.Error("Expected 'router-without-address' to exist")
}
if deviceWithoutAddress.Address != "" {
t.Errorf("Expected empty address, got '%s'", deviceWithoutAddress.Address)
}
}

View File

@@ -3,9 +3,11 @@ module router_backup
go 1.21
require (
dario.cat/mergo v1.0.2
github.com/kevinburke/ssh_config v1.2.0
github.com/spf13/cobra v1.8.0
golang.org/x/crypto v0.18.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)
require (

View File

@@ -1,6 +1,10 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
@@ -14,6 +18,5 @@ golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,228 +4,46 @@ package main
import (
"fmt"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"time"
"sync"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"gopkg.in/yaml.v2"
)
const Version = "1.0.1"
const Version = "1.3.2"
// Config structures
type Config struct {
Types map[string]DeviceType `yaml:"types"`
Devices map[string]Device `yaml:"devices"`
}
func processDevice(hostname string, deviceConfig Device, commands []string, excludePatterns []string, password, keyFile string, port int, outputDir string) bool {
// Create backup instance
backup := NewRouterBackup(hostname, deviceConfig.Address, deviceConfig.User, password, keyFile, port)
type DeviceType struct {
Commands []string `yaml:"commands"`
}
type Device struct {
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
}
// RouterBackup handles SSH connections and command execution
type RouterBackup struct {
hostname string
username string
password string
keyFile string
port int
client *ssh.Client
}
// NewRouterBackup creates a new RouterBackup instance
func NewRouterBackup(hostname, username, password, keyFile string, port int) *RouterBackup {
return &RouterBackup{
hostname: hostname,
username: username,
password: password,
keyFile: keyFile,
port: port,
}
}
// Connect establishes SSH connection to the router
func (rb *RouterBackup) Connect() error {
config := &ssh.ClientConfig{
User: rb.username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 30 * time.Second,
// Connect and backup
if err := backup.Connect(); err != nil {
fmt.Printf("%s: Failed to connect: %v\n", hostname, err)
return false
}
// Try SSH agent first if available
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
if conn, err := net.Dial("unix", sshAuthSock); err == nil {
agentClient := agent.NewClient(conn)
config.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}
}
}
err := backup.BackupCommands(commands, excludePatterns, outputDir)
backup.Disconnect()
// If SSH agent didn't work, try key file
if len(config.Auth) == 0 && rb.keyFile != "" {
key, err := ioutil.ReadFile(rb.keyFile)
if err != nil {
return fmt.Errorf("unable to read private key: %v", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return fmt.Errorf("unable to parse private key: %v", err)
}
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Fall back to password if available
if len(config.Auth) == 0 && rb.password != "" {
config.Auth = []ssh.AuthMethod{ssh.Password(rb.password)}
}
if len(config.Auth) == 0 {
return fmt.Errorf("no authentication method available")
}
address := fmt.Sprintf("%s:%d", rb.hostname, rb.port)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return fmt.Errorf("failed to connect to %s: %v", rb.hostname, err)
fmt.Printf("%s: Backup failed: %v\n", hostname, err)
return false
} else {
fmt.Printf("%s: Backup completed\n", hostname)
return true
}
rb.client = client
fmt.Printf("Successfully connected to %s\n", rb.hostname)
return nil
}
// RunCommand executes a command on the router and returns the output
func (rb *RouterBackup) RunCommand(command string) (string, error) {
if rb.client == nil {
return "", fmt.Errorf("no active connection")
}
session, err := rb.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %v", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return "", fmt.Errorf("failed to execute command '%s': %v", command, err)
}
return string(output), nil
}
// BackupCommands runs multiple commands and saves outputs to files
func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
}
filename := rb.hostname
filepath := filepath.Join(outputDir, filename)
// Truncate file at start
file, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file %s: %v", filepath, err)
}
file.Close()
successCount := 0
for i, command := range commands {
fmt.Printf("Running command %d/%d: %s\n", i+1, len(commands), command)
output, err := rb.RunCommand(command)
if err != nil {
fmt.Printf("Error executing '%s': %v\n", command, err)
continue
}
// Append to file
file, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("Failed to open file for writing: %v\n", err)
continue
}
fmt.Fprintf(file, "## COMMAND: %s\n", command)
file.WriteString(output)
file.Close()
fmt.Printf("Output saved to %s\n", filepath)
successCount++
}
fmt.Printf("Summary: %d/%d commands successful\n", successCount, len(commands))
return nil
}
// Disconnect closes SSH connection
func (rb *RouterBackup) Disconnect() {
if rb.client != nil {
rb.client.Close()
fmt.Printf("Disconnected from %s\n", rb.hostname)
}
}
// loadConfig loads the YAML configuration file
func loadConfig(configPath string) (*Config, error) {
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file %s: %v", configPath, err)
}
var config Config
err = yaml.Unmarshal(data, &config)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML: %v", err)
}
return &config, nil
}
// findDefaultSSHKey looks for default SSH keys
func findDefaultSSHKey() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
defaultKeys := []string{
filepath.Join(homeDir, ".ssh", "id_rsa"),
filepath.Join(homeDir, ".ssh", "id_ed25519"),
filepath.Join(homeDir, ".ssh", "id_ecdsa"),
}
for _, keyPath := range defaultKeys {
if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("Using SSH key: %s\n", keyPath)
return keyPath
}
}
return ""
}
func main() {
var configPath string
var yamlFiles []string
var password string
var keyFile string
var port int
var outputDir string
var hostFilter []string
var parallel int
var rootCmd = &cobra.Command{
Use: "ipng-router-backup",
@@ -235,101 +53,176 @@ func main() {
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("IPng Networks Router Backup v%s\n", Version)
// Expand glob patterns in YAML files
var expandedYamlFiles []string
for _, pattern := range yamlFiles {
matches, err := filepath.Glob(pattern)
if err != nil {
log.Fatalf("Invalid glob pattern '%s': %v", pattern, err)
}
if len(matches) == 0 {
log.Fatalf("No files matched pattern '%s'", pattern)
}
expandedYamlFiles = append(expandedYamlFiles, matches...)
}
// Load configuration
config, err := loadConfig(configPath)
cfg, err := ConfigRead(expandedYamlFiles)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Check authentication setup
if password == "" && keyFile == "" {
if os.Getenv("SSH_AUTH_SOCK") != "" {
fmt.Println("Using SSH agent for authentication")
} else {
keyFile = findDefaultSSHKey()
if keyFile == "" {
log.Fatal("No SSH key found and no password provided")
}
hasAuth := 0
if os.Getenv("SSH_AUTH_SOCK") != "" {
fmt.Println("Using SSH agent for authentication")
hasAuth++
}
if keyFile == "" {
keyFile = findDefaultSSHKey()
if keyFile != "" {
fmt.Printf("Using SSH key: %s\n", keyFile)
hasAuth++
}
} else {
fmt.Printf("Using specified SSH key: %s\n", keyFile)
hasAuth++
}
if password != "" {
fmt.Println("Using --password for authentication")
hasAuth++
}
if hasAuth == 0 {
log.Fatal("No authentication mechanisms found.")
}
// Process devices
if len(config.Devices) == 0 {
if len(cfg.Devices) == 0 {
log.Fatal("No devices found in config file")
}
// Filter devices if --host flags are provided
devicesToProcess := config.Devices
devicesToProcess := cfg.Devices
if len(hostFilter) > 0 {
devicesToProcess = make(map[string]Device)
for _, hostname := range hostFilter {
if deviceConfig, exists := config.Devices[hostname]; exists {
devicesToProcess[hostname] = deviceConfig
} else {
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname)
for _, pattern := range hostFilter {
patternMatched := false
for hostname, deviceConfig := range cfg.Devices {
if matched, _ := filepath.Match(pattern, hostname); matched {
devicesToProcess[hostname] = deviceConfig
patternMatched = true
}
}
if !patternMatched {
fmt.Printf("Warning: Host pattern '%s' did not match any devices\n", pattern)
}
}
}
successCount := 0
totalCount := len(devicesToProcess)
for hostname, deviceConfig := range devicesToProcess {
fmt.Printf("\nProcessing device: %s (type: %s)\n", hostname, deviceConfig.Type)
// Create channels for work distribution and result collection
type DeviceWork struct {
hostname string
deviceConfig Device
commands []string
excludePatterns []string
}
type DeviceResult struct {
hostname string
success bool
}
workChan := make(chan DeviceWork, totalCount)
resultChan := make(chan DeviceResult, totalCount)
// Start worker pool
var wg sync.WaitGroup
for i := 0; i < parallel; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for work := range workChan {
fmt.Printf("%s: Processing device (type: %s)\n", work.hostname, work.deviceConfig.Type)
success := processDevice(work.hostname, work.deviceConfig, work.commands, work.excludePatterns, password, keyFile, port, outputDir)
resultChan <- DeviceResult{hostname: work.hostname, success: success}
}
}()
}
// Queue all work
for hostname, deviceConfig := range devicesToProcess {
user := deviceConfig.User
commands := deviceConfig.Commands
deviceType := deviceConfig.Type
var excludePatterns []string
// If device has a type, get commands from types section
// If device has a type, get commands and exclude patterns from types section
if deviceType != "" {
if typeConfig, exists := config.Types[deviceType]; exists {
if typeConfig, exists := cfg.Types[deviceType]; exists {
commands = typeConfig.Commands
excludePatterns = typeConfig.Exclude
}
}
if user == "" {
fmt.Printf("No user specified for %s, skipping\n", hostname)
fmt.Printf("%s: No user specified, skipping\n", hostname)
continue
}
if len(commands) == 0 {
fmt.Printf("No commands specified for %s, skipping\n", hostname)
fmt.Printf("%s: No commands specified, skipping\n", hostname)
continue
}
// Create backup instance
backup := NewRouterBackup(hostname, user, password, keyFile, port)
// Connect and backup
if err := backup.Connect(); err != nil {
fmt.Printf("Failed to connect to %s: %v\n", hostname, err)
continue
workChan <- DeviceWork{
hostname: hostname,
deviceConfig: deviceConfig,
commands: commands,
excludePatterns: excludePatterns,
}
}
close(workChan)
err = backup.BackupCommands(commands, outputDir)
backup.Disconnect()
// Wait for all workers to finish
go func() {
wg.Wait()
close(resultChan)
}()
if err != nil {
fmt.Printf("Backup failed for %s: %v\n", hostname, err)
} else {
fmt.Printf("Backup completed for %s\n", hostname)
// Collect results
successCount := 0
for result := range resultChan {
if result.success {
successCount++
}
}
fmt.Printf("\nOverall summary: %d/%d devices processed successfully\n", successCount, totalCount)
fmt.Printf("Overall summary: %d/%d devices processed successfully\n", successCount, totalCount)
// Set exit code based on results
if successCount == 0 {
os.Exit(11) // All devices failed
} else if successCount < totalCount {
os.Exit(10) // Some devices failed
}
// Exit code 0 (success) when all devices succeeded
},
}
rootCmd.Flags().StringVar(&configPath, "config", "", "YAML configuration file path (required)")
rootCmd.Flags().StringSliceVar(&yamlFiles, "yaml", []string{}, "YAML configuration file paths (required, can be repeated)")
rootCmd.Flags().StringVar(&password, "password", "", "SSH password")
rootCmd.Flags().StringVar(&keyFile, "key-file", "", "SSH private key file path")
rootCmd.Flags().IntVar(&port, "port", 22, "SSH port")
rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files")
rootCmd.Flags().StringSliceVar(&hostFilter, "host", []string{}, "Specific host(s) to process (can be repeated, processes all if not specified)")
rootCmd.Flags().IntVar(&parallel, "parallel", 10, "Maximum number of devices to process in parallel")
rootCmd.MarkFlagRequired("config")
if err := rootCmd.MarkFlagRequired("yaml"); err != nil {
log.Fatal(err)
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)

View File

@@ -1,287 +0,0 @@
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
package main
import (
"os"
"path/filepath"
"testing"
)
func TestNewRouterBackup(t *testing.T) {
rb := NewRouterBackup("test-host", "test-user", "test-pass", "/test/key", 2222)
if rb.hostname != "test-host" {
t.Errorf("Expected hostname 'test-host', got '%s'", rb.hostname)
}
if rb.username != "test-user" {
t.Errorf("Expected username 'test-user', got '%s'", rb.username)
}
if rb.password != "test-pass" {
t.Errorf("Expected password 'test-pass', got '%s'", rb.password)
}
if rb.keyFile != "/test/key" {
t.Errorf("Expected keyFile '/test/key', got '%s'", rb.keyFile)
}
if rb.port != 2222 {
t.Errorf("Expected port 2222, got %d", rb.port)
}
}
func TestFindDefaultSSHKey(t *testing.T) {
// Create a temporary directory to simulate home directory
tempDir := t.TempDir()
sshDir := filepath.Join(tempDir, ".ssh")
err := os.MkdirAll(sshDir, 0755)
if err != nil {
t.Fatalf("Failed to create .ssh directory: %v", err)
}
// Create a fake SSH key
keyPath := filepath.Join(sshDir, "id_rsa")
err = os.WriteFile(keyPath, []byte("fake-key"), 0600)
if err != nil {
t.Fatalf("Failed to create fake SSH key: %v", err)
}
// Temporarily change HOME environment variable
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
result := findDefaultSSHKey()
if result != keyPath {
t.Errorf("Expected SSH key path '%s', got '%s'", keyPath, result)
}
}
func TestFindDefaultSSHKeyNotFound(t *testing.T) {
// Create a temporary directory with no SSH keys
tempDir := t.TempDir()
// Temporarily change HOME environment variable
originalHome := os.Getenv("HOME")
defer os.Setenv("HOME", originalHome)
os.Setenv("HOME", tempDir)
result := findDefaultSSHKey()
if result != "" {
t.Errorf("Expected empty string when no SSH key found, got '%s'", result)
}
}
func TestLoadConfig(t *testing.T) {
// Create a temporary config file
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "test-config.yaml")
configContent := `types:
test-type:
commands:
- show version
- show status
devices:
test-device:
user: testuser
type: test-type
direct-device:
user: directuser
commands:
- direct command
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to create test config file: %v", err)
}
config, err := loadConfig(configPath)
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Test types section
if len(config.Types) != 1 {
t.Errorf("Expected 1 type, got %d", len(config.Types))
}
testType, exists := config.Types["test-type"]
if !exists {
t.Error("Expected 'test-type' to exist in types")
}
if len(testType.Commands) != 2 {
t.Errorf("Expected 2 commands in test-type, got %d", len(testType.Commands))
}
// Test devices section
if len(config.Devices) != 2 {
t.Errorf("Expected 2 devices, got %d", len(config.Devices))
}
testDevice, exists := config.Devices["test-device"]
if !exists {
t.Error("Expected 'test-device' to exist in devices")
}
if testDevice.User != "testuser" {
t.Errorf("Expected user 'testuser', got '%s'", testDevice.User)
}
if testDevice.Type != "test-type" {
t.Errorf("Expected type 'test-type', got '%s'", testDevice.Type)
}
}
func TestLoadConfigInvalidFile(t *testing.T) {
_, err := loadConfig("/nonexistent/config.yaml")
if err == nil {
t.Error("Expected error when loading nonexistent config file")
}
}
func TestLoadConfigInvalidYAML(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "invalid-config.yaml")
// Create invalid YAML content
invalidYAML := `types:
test-type:
commands:
- show version
invalid: [unclosed
`
err := os.WriteFile(configPath, []byte(invalidYAML), 0644)
if err != nil {
t.Fatalf("Failed to create invalid config file: %v", err)
}
_, err = loadConfig(configPath)
if err == nil {
t.Error("Expected error when loading invalid YAML")
}
}
func TestBackupCommandsDirectoryCreation(t *testing.T) {
rb := NewRouterBackup("test-host", "test-user", "", "", 22)
tempDir := t.TempDir()
outputDir := filepath.Join(tempDir, "new-directory")
// Test with empty commands to avoid SSH connection
err := rb.BackupCommands([]string{}, outputDir)
if err != nil {
t.Fatalf("BackupCommands failed: %v", err)
}
// Check if directory was created
if _, err := os.Stat(outputDir); os.IsNotExist(err) {
t.Error("Expected output directory to be created")
}
}
func TestBackupCommandsFileCreation(t *testing.T) {
rb := NewRouterBackup("test-host", "test-user", "", "", 22)
tempDir := t.TempDir()
expectedFilePath := filepath.Join(tempDir, "test-host")
// Test with empty commands to avoid SSH connection
err := rb.BackupCommands([]string{}, tempDir)
if err != nil {
t.Fatalf("BackupCommands failed: %v", err)
}
// Check if file was created
if _, err := os.Stat(expectedFilePath); os.IsNotExist(err) {
t.Error("Expected output file to be created")
}
}
// Benchmark tests
func BenchmarkNewRouterBackup(b *testing.B) {
for i := 0; i < b.N; i++ {
NewRouterBackup("bench-host", "bench-user", "bench-pass", "/bench/key", 22)
}
}
func BenchmarkLoadConfig(b *testing.B) {
// Create a temporary config file
tempDir := b.TempDir()
configPath := filepath.Join(tempDir, "bench-config.yaml")
configContent := `types:
srlinux:
commands:
- show version
- show platform linecard
devices:
device1:
user: user1
type: srlinux
device2:
user: user2
type: srlinux
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
b.Fatalf("Failed to create benchmark config file: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := loadConfig(configPath)
if err != nil {
b.Fatalf("Failed to load config: %v", err)
}
}
}
// Example test to demonstrate usage
func ExampleNewRouterBackup() {
rb := NewRouterBackup("example-host", "admin", "", "/home/user/.ssh/id_rsa", 22)
_ = rb // Use the router backup instance
}
// Table-driven test for multiple scenarios
func TestRouterBackupCreation(t *testing.T) {
tests := []struct {
name string
hostname string
username string
password string
keyFile string
port int
}{
{"Basic", "host1", "user1", "pass1", "/key1", 22},
{"Custom Port", "host2", "user2", "pass2", "/key2", 2222},
{"No Password", "host3", "user3", "", "/key3", 22},
{"No Key", "host4", "user4", "pass4", "", 22},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rb := NewRouterBackup(tt.hostname, tt.username, tt.password, tt.keyFile, tt.port)
if rb.hostname != tt.hostname {
t.Errorf("Expected hostname '%s', got '%s'", tt.hostname, rb.hostname)
}
if rb.username != tt.username {
t.Errorf("Expected username '%s', got '%s'", tt.username, rb.username)
}
if rb.password != tt.password {
t.Errorf("Expected password '%s', got '%s'", tt.password, rb.password)
}
if rb.keyFile != tt.keyFile {
t.Errorf("Expected keyFile '%s', got '%s'", tt.keyFile, rb.keyFile)
}
if rb.port != tt.port {
t.Errorf("Expected port %d, got %d", tt.port, rb.port)
}
})
}
}

336
src/ssh.go Normal file
View File

@@ -0,0 +1,336 @@
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
package main
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/kevinburke/ssh_config"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// RouterBackup handles SSH connections and command execution
type RouterBackup struct {
hostname string
address string
username string
password string
keyFile string
port int
client *ssh.Client
}
// NewRouterBackup creates a new RouterBackup instance
func NewRouterBackup(hostname, address, username, password, keyFile string, port int) *RouterBackup {
return &RouterBackup{
hostname: hostname,
address: address,
username: username,
password: password,
keyFile: keyFile,
port: port,
}
}
// isIPv6 checks if the given address is an IPv6 address
func isIPv6(address string) bool {
ip := net.ParseIP(address)
return ip != nil && ip.To4() == nil
}
// getNetworkType determines the appropriate network type based on the target address
func getNetworkType(address string) string {
if isIPv6(address) {
return "tcp6"
}
return "tcp4"
}
// Connect establishes SSH connection to the router
func (rb *RouterBackup) Connect() error {
// Determine the target address - use explicit address if provided, otherwise use hostname
var targetHost string
if rb.address != "" {
targetHost = rb.address
} else {
// Get SSH config values for this host
targetHost = ssh_config.Get(rb.hostname, "Hostname")
if targetHost == "" {
targetHost = rb.hostname
}
}
portStr := ssh_config.Get(rb.hostname, "Port")
port := rb.port
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
username := ssh_config.Get(rb.hostname, "User")
if rb.username != "" {
username = rb.username
}
keyFile := ssh_config.Get(rb.hostname, "IdentityFile")
if rb.keyFile != "" {
keyFile = rb.keyFile
}
config := &ssh.ClientConfig{
User: username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 30 * time.Second,
}
// Apply SSH config crypto settings with compatibility filtering
if kexAlgorithms := ssh_config.Get(rb.hostname, "KexAlgorithms"); kexAlgorithms != "" && !strings.HasPrefix(kexAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(kexAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.KeyExchanges = finalAlgorithms
}
if macs := ssh_config.Get(rb.hostname, "MACs"); macs != "" {
macList := strings.Split(macs, ",")
for i, mac := range macList {
macList[i] = strings.TrimSpace(mac)
}
config.MACs = macList
}
if hostKeyAlgorithms := ssh_config.Get(rb.hostname, "HostKeyAlgorithms"); hostKeyAlgorithms != "" && !strings.HasPrefix(hostKeyAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(hostKeyAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.HostKeyAlgorithms = finalAlgorithms
}
// If explicit key file is provided, prioritize it over SSH agent
var keyFileAuth ssh.AuthMethod
var agentAuth ssh.AuthMethod
// Try SSH agent if available (but don't add to config.Auth yet)
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
if conn, err := net.Dial("unix", sshAuthSock); err == nil {
agentClient := agent.NewClient(conn)
agentAuth = ssh.PublicKeysCallback(agentClient.Signers)
}
}
// Try key file
if keyFile != "" {
// Expand ~ in keyFile path
if strings.HasPrefix(keyFile, "~/") {
homeDir, err := os.UserHomeDir()
if err == nil {
keyFile = filepath.Join(homeDir, keyFile[2:])
}
}
key, err := os.ReadFile(keyFile)
if err == nil {
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
fmt.Printf("%s: Unable to parse private key: %v\n", rb.hostname, err)
} else {
keyFileAuth = ssh.PublicKeys(signer)
}
}
}
// Prioritize auth methods: explicit key file first, then SSH agent
if keyFileAuth != nil {
config.Auth = []ssh.AuthMethod{keyFileAuth}
if agentAuth != nil {
config.Auth = append(config.Auth, agentAuth)
}
} else if agentAuth != nil {
config.Auth = []ssh.AuthMethod{agentAuth}
}
// Fall back to password if available
if rb.password != "" {
config.Auth = append(config.Auth, ssh.Password(rb.password))
}
if len(config.Auth) == 0 {
return fmt.Errorf("no authentication method available")
}
// Format address properly for IPv6
var address string
if isIPv6(targetHost) {
address = fmt.Sprintf("[%s]:%d", targetHost, port)
} else {
address = fmt.Sprintf("%s:%d", targetHost, port)
}
networkType := getNetworkType(targetHost)
client, err := ssh.Dial(networkType, address, config)
if err != nil {
return fmt.Errorf("failed to connect to %s: %v", targetHost, err)
}
rb.client = client
fmt.Printf("%s: Successfully connected to %s\n", rb.hostname, targetHost)
return nil
}
// RunCommand executes a command on the router and returns the output
func (rb *RouterBackup) RunCommand(command string) (string, error) {
if rb.client == nil {
return "", fmt.Errorf("no active connection")
}
session, err := rb.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %v", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return "", fmt.Errorf("failed to execute command '%s': %v", command, err)
}
return string(output), nil
}
// filterOutput removes lines matching exclude patterns from the output
func filterOutput(output string, excludePatterns []string) string {
if len(excludePatterns) == 0 {
return output
}
lines := strings.Split(output, "\n")
var filteredLines []string
for _, line := range lines {
exclude := false
for _, pattern := range excludePatterns {
if matched, _ := regexp.MatchString(pattern, line); matched {
exclude = true
break
}
}
if !exclude {
filteredLines = append(filteredLines, line)
}
}
return strings.Join(filteredLines, "\n")
}
// BackupCommands runs multiple commands and saves outputs to files
func (rb *RouterBackup) BackupCommands(commands []string, excludePatterns []string, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
}
filename := rb.hostname
finalPath := filepath.Join(outputDir, filename)
tempPath := finalPath + ".new"
// Create temporary file
file, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temporary file %s: %v", tempPath, err)
}
file.Close()
successCount := 0
hasErrors := false
for i, command := range commands {
fmt.Printf("%s: Running command %d/%d: %s\n", rb.hostname, i+1, len(commands), command)
output, err := rb.RunCommand(command)
if err != nil {
fmt.Printf("%s: Error executing '%s': %v\n", rb.hostname, command, err)
hasErrors = true
continue
}
// Append to temporary file
file, err := os.OpenFile(tempPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("%s: Failed to open file for writing: %v\n", rb.hostname, err)
hasErrors = true
continue
}
fmt.Fprintf(file, "## COMMAND: %s\n", command)
filteredOutput := filterOutput(output, excludePatterns)
if _, err := file.WriteString(filteredOutput); err != nil {
fmt.Printf("%s: Failed to write output: %v\n", rb.hostname, err)
hasErrors = true
}
file.Close()
successCount++
}
fmt.Printf("%s: Summary: %d/%d commands successful\n", rb.hostname, successCount, len(commands))
if hasErrors || successCount == 0 {
// Remove .new suffix and log error
if err := os.Remove(tempPath); err != nil {
fmt.Printf("%s: Failed to remove temporary file %s: %v\n", rb.hostname, tempPath, err)
}
return fmt.Errorf("device backup incomplete due to command failures")
}
// All commands succeeded, move file into place atomically
if err := os.Rename(tempPath, finalPath); err != nil {
return fmt.Errorf("failed to move temporary file to final location: %v", err)
}
fmt.Printf("%s: Output saved to %s\n", rb.hostname, finalPath)
return nil
}
// Disconnect closes SSH connection
func (rb *RouterBackup) Disconnect() {
if rb.client != nil {
rb.client.Close()
fmt.Printf("%s: Disconnected\n", rb.hostname)
}
}
// findDefaultSSHKey looks for default SSH keys
func findDefaultSSHKey() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
defaultKeys := []string{
filepath.Join(homeDir, ".ssh", "id_rsa"),
filepath.Join(homeDir, ".ssh", "id_ed25519"),
filepath.Join(homeDir, ".ssh", "id_ecdsa"),
}
for _, keyPath := range defaultKeys {
if _, err := os.Stat(keyPath); err == nil {
// Key discovery logging moved to main.go for hostname context
return keyPath
}
}
return ""
}

322
src/ssh_test.go Normal file
View File

@@ -0,0 +1,322 @@
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
package main
import (
"os"
"path/filepath"
"testing"
)
func TestNewRouterBackup(t *testing.T) {
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "/path/to/key", 2222)
if rb.hostname != "testhost" {
t.Errorf("Expected hostname 'testhost', got '%s'", rb.hostname)
}
if rb.username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", rb.username)
}
if rb.password != "testpass" {
t.Errorf("Expected password 'testpass', got '%s'", rb.password)
}
if rb.keyFile != "/path/to/key" {
t.Errorf("Expected keyFile '/path/to/key', got '%s'", rb.keyFile)
}
if rb.port != 2222 {
t.Errorf("Expected port 2222, got %d", rb.port)
}
if rb.client != nil {
t.Error("Expected client to be nil initially")
}
}
func TestRunCommandWithoutConnection(t *testing.T) {
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
_, err := rb.RunCommand("show version")
if err == nil {
t.Error("Expected error when running command without connection")
}
if err.Error() != "no active connection" {
t.Errorf("Expected 'no active connection' error, got '%s'", err.Error())
}
}
func TestBackupCommandsDirectoryCreation(t *testing.T) {
tempDir := t.TempDir()
outputDir := filepath.Join(tempDir, "nonexistent", "backup")
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
// This should create the directory even without a connection
// and fail gracefully when trying to run commands
err := rb.BackupCommands([]string{"show version"}, []string{}, outputDir)
// Should not error on directory creation
if _, statErr := os.Stat(outputDir); os.IsNotExist(statErr) {
t.Error("Expected output directory to be created")
}
// Should return error when commands fail
if err == nil {
t.Error("Expected error when commands fail")
}
// Should NOT create the output file when commands fail (atomic behavior)
expectedFile := filepath.Join(outputDir, "testhost")
if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
t.Error("Expected output file to NOT be created when commands fail")
}
}
func TestBackupCommandsEmptyCommands(t *testing.T) {
tempDir := t.TempDir()
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
err := rb.BackupCommands([]string{}, []string{}, tempDir)
if err == nil {
t.Error("Expected error for empty commands list (no successful commands)")
}
// Should NOT create the output file when no commands succeed
expectedFile := filepath.Join(tempDir, "testhost")
if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
t.Error("Expected output file to NOT be created when no commands succeed")
}
}
func TestDisconnectWithoutConnection(t *testing.T) {
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
// Should not panic or error when disconnecting without connection
rb.Disconnect()
}
func TestFindDefaultSSHKey(t *testing.T) {
// Test when no SSH keys exist
originalHome := os.Getenv("HOME")
tempDir := t.TempDir()
os.Setenv("HOME", tempDir)
defer os.Setenv("HOME", originalHome)
keyPath := findDefaultSSHKey()
if keyPath != "" {
t.Errorf("Expected empty string when no SSH keys exist, got '%s'", keyPath)
}
// Create .ssh directory and a test key
sshDir := filepath.Join(tempDir, ".ssh")
err := os.MkdirAll(sshDir, 0700)
if err != nil {
t.Fatalf("Failed to create .ssh directory: %v", err)
}
// Create id_rsa key (should be found first)
rsaKeyPath := filepath.Join(sshDir, "id_rsa")
err = os.WriteFile(rsaKeyPath, []byte("fake rsa key"), 0600)
if err != nil {
t.Fatalf("Failed to create RSA key: %v", err)
}
keyPath = findDefaultSSHKey()
if keyPath != rsaKeyPath {
t.Errorf("Expected to find RSA key at '%s', got '%s'", rsaKeyPath, keyPath)
}
// Remove RSA key and create ed25519 key
os.Remove(rsaKeyPath)
ed25519KeyPath := filepath.Join(sshDir, "id_ed25519")
err = os.WriteFile(ed25519KeyPath, []byte("fake ed25519 key"), 0600)
if err != nil {
t.Fatalf("Failed to create ed25519 key: %v", err)
}
keyPath = findDefaultSSHKey()
if keyPath != ed25519KeyPath {
t.Errorf("Expected to find ed25519 key at '%s', got '%s'", ed25519KeyPath, keyPath)
}
}
func TestFindDefaultSSHKeyHomeError(t *testing.T) {
// Test behavior when HOME environment is invalid
originalHome := os.Getenv("HOME")
os.Unsetenv("HOME")
defer os.Setenv("HOME", originalHome)
keyPath := findDefaultSSHKey()
if keyPath != "" {
t.Errorf("Expected empty string when HOME is not set, got '%s'", keyPath)
}
}
func TestBackupCommandsFileOperations(t *testing.T) {
tempDir := t.TempDir()
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
// Create some fake commands (they will fail but we can test file operations)
commands := []string{"show version", "show interfaces"}
err := rb.BackupCommands(commands, []string{}, tempDir)
if err == nil {
t.Error("Expected error when all commands fail")
}
// Check that output file was NOT created (atomic behavior)
outputFile := filepath.Join(tempDir, "testhost")
_, err = os.ReadFile(outputFile)
if err == nil {
t.Error("Expected output file to not exist when all commands fail")
}
// This test verifies that atomic file behavior works correctly
}
func TestRouterBackupConnectionState(t *testing.T) {
rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
// Initially no client
if rb.client != nil {
t.Error("Expected client to be nil initially")
}
// After disconnect, should still be nil (safe to call multiple times)
rb.Disconnect()
if rb.client != nil {
t.Error("Expected client to remain nil after disconnect")
}
}
func TestNewRouterBackupWithAddress(t *testing.T) {
rb := NewRouterBackup("testhost", "192.168.1.100", "testuser", "testpass", "/path/to/key", 2222)
if rb.hostname != "testhost" {
t.Errorf("Expected hostname 'testhost', got '%s'", rb.hostname)
}
if rb.address != "192.168.1.100" {
t.Errorf("Expected address '192.168.1.100', got '%s'", rb.address)
}
}
func TestIsIPv6(t *testing.T) {
// Test IPv4 addresses
if isIPv6("192.168.1.1") {
t.Error("Expected '192.168.1.1' to be detected as IPv4, not IPv6")
}
if isIPv6("10.0.0.1") {
t.Error("Expected '10.0.0.1' to be detected as IPv4, not IPv6")
}
// Test IPv6 addresses
if !isIPv6("2001:678:d78:500::") {
t.Error("Expected '2001:678:d78:500::' to be detected as IPv6")
}
if !isIPv6("::1") {
t.Error("Expected '::1' to be detected as IPv6")
}
if !isIPv6("fe80::1") {
t.Error("Expected 'fe80::1' to be detected as IPv6")
}
// Test invalid addresses
if isIPv6("invalid.address") {
t.Error("Expected 'invalid.address' to not be detected as IPv6")
}
if isIPv6("hostname.example.com") {
t.Error("Expected 'hostname.example.com' to not be detected as IPv6")
}
}
func TestGetNetworkType(t *testing.T) {
// Test IPv4 addresses
if getNetworkType("192.168.1.1") != "tcp4" {
t.Errorf("Expected 'tcp4' for IPv4 address, got '%s'", getNetworkType("192.168.1.1"))
}
// Test IPv6 addresses
if getNetworkType("2001:678:d78:500::") != "tcp6" {
t.Errorf("Expected 'tcp6' for IPv6 address, got '%s'", getNetworkType("2001:678:d78:500::"))
}
if getNetworkType("::1") != "tcp6" {
t.Errorf("Expected 'tcp6' for IPv6 address, got '%s'", getNetworkType("::1"))
}
// Test hostnames (should default to tcp4)
if getNetworkType("hostname.example.com") != "tcp4" {
t.Errorf("Expected 'tcp4' for hostname, got '%s'", getNetworkType("hostname.example.com"))
}
}
func TestIPv6AddressFormatting(t *testing.T) {
// Test that we can create a RouterBackup with IPv6 address
// and that it stores the address correctly
rb := NewRouterBackup("testhost", "2001:678:d78:500::", "testuser", "testpass", "", 22)
if !isIPv6(rb.address) {
t.Error("Expected IPv6 address to be detected as IPv6")
}
if getNetworkType(rb.address) != "tcp6" {
t.Error("Expected IPv6 address to use tcp6 network type")
}
// Test IPv4 for comparison
rb4 := NewRouterBackup("testhost", "192.168.1.1", "testuser", "testpass", "", 22)
if isIPv6(rb4.address) {
t.Error("Expected IPv4 address to not be detected as IPv6")
}
if getNetworkType(rb4.address) != "tcp4" {
t.Error("Expected IPv4 address to use tcp4 network type")
}
}
func TestFilterOutput(t *testing.T) {
// Test with no exclude patterns
input := "line1\nline2\nline3"
result := filterOutput(input, []string{})
if result != input {
t.Errorf("Expected no filtering with empty patterns, got '%s'", result)
}
// Test with matching pattern
input = "# 2025-07-06 21:30:45 by RouterOS\nconfig line 1\nconfig line 2"
excludePatterns := []string{"^# ....-..-.. ..:..:.. by RouterOS"}
expected := "config line 1\nconfig line 2"
result = filterOutput(input, excludePatterns)
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
// Test with multiple patterns
input = "line1\nDEBUG: debug info\nline2\nINFO: info message\nline3"
excludePatterns = []string{"^DEBUG:", "^INFO:"}
expected = "line1\nline2\nline3"
result = filterOutput(input, excludePatterns)
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
// Test with no matches
input = "line1\nline2\nline3"
excludePatterns = []string{"nomatch"}
result = filterOutput(input, excludePatterns)
if result != input {
t.Errorf("Expected no filtering when patterns don't match, got '%s'", result)
}
}