Compare commits
35 Commits
75646856aa
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
57fc8d3630 | ||
|
64212fce8c | ||
|
83797aaa34 | ||
|
3da4de7711 | ||
|
9a2264e867 | ||
|
6c1993282c | ||
|
53c7bca43e | ||
|
c6775736ac | ||
|
4260067ea8 | ||
|
90f5ec4e26 | ||
|
c8df809c29 | ||
|
88e30a40b1 | ||
|
631a387708 | ||
|
2bba484e6c | ||
|
db98af84b0 | ||
|
963cc3eed6 | ||
|
9475d7b5c0 | ||
|
fd74c41fb3 | ||
|
7442a83c9d | ||
|
7f6b030b31 | ||
|
f05124b703 | ||
|
f2c484e9c1 | ||
|
1afa1e6d43 | ||
|
96c7c3aeaa | ||
|
8032a5a605 | ||
|
d212abcc87 | ||
|
4a95221732 | ||
|
949799acdc | ||
|
372d7125a1 | ||
|
5385d6fca8 | ||
|
2a1ed2dd35 | ||
|
db91d59481 | ||
|
a3d681e420 | ||
|
e5f9e59601 | ||
|
769d9eb6cd |
5
Makefile
5
Makefile
@@ -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)
|
||||
|
77
README.md
77
README.md
@@ -4,9 +4,24 @@ SSH-based network device configuration backup tool with support for multiple dev
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-device backup**: Configure multiple devices in YAML, with `!include` directives
|
||||
- **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
|
||||
- **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,20 +39,7 @@ make build
|
||||
|
||||
1. **Create configuration files**:
|
||||
|
||||
**Main config** (`config.yaml`):
|
||||
```yaml
|
||||
!include device-types.yaml
|
||||
|
||||
devices:
|
||||
asw100:
|
||||
user: netops
|
||||
type: srlinux
|
||||
asw120:
|
||||
user: netops
|
||||
type: srlinux
|
||||
```
|
||||
|
||||
**Device types** (`device-types.yaml`):
|
||||
**Device types** (`00-device-types.yaml`):
|
||||
```yaml
|
||||
types:
|
||||
srlinux:
|
||||
@@ -45,16 +47,33 @@ make build
|
||||
- show version
|
||||
- show platform linecard
|
||||
- info flat from running
|
||||
centec:
|
||||
commands:
|
||||
- show version
|
||||
- show transciever
|
||||
- show running-config
|
||||
```
|
||||
|
||||
**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**:
|
||||
@@ -75,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
|
||||
|
78
debian/changelog
vendored
78
debian/changelog
vendored
@@ -1,3 +1,81 @@
|
||||
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
|
||||
|
18
debian/rules
vendored
18
debian/rules
vendored
@@ -1,26 +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 docs/config.yaml.example debian/ipng-router-backup/etc/ipng-router-backup/config.yaml.example
|
||||
cp docs/device-types.yaml debian/ipng-router-backup/etc/ipng-router-backup/device-types.yaml
|
||||
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
|
||||
|
517
docs/DETAILS.md
517
docs/DETAILS.md
@@ -2,468 +2,247 @@
|
||||
|
||||
## 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 includes**: Split large configurations with `!include` directives
|
||||
- **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 configuration supports `!include` directives for organizing large configurations across multiple files.
|
||||
The tool uses YAML configuration files with two main sections: `types` and `devices`. Multiple YAML files can be loaded and merged automatically.
|
||||
|
||||
### Complete Example
|
||||
|
||||
**Main configuration** (`config.yaml`):
|
||||
```yaml
|
||||
!include device-types.yaml
|
||||
|
||||
devices:
|
||||
asw100:
|
||||
user: admin
|
||||
type: srlinux
|
||||
|
||||
asw120:
|
||||
user: netops
|
||||
type: srlinux
|
||||
|
||||
core-01:
|
||||
user: admin
|
||||
type: eos
|
||||
|
||||
edge-router:
|
||||
user: operator
|
||||
commands:
|
||||
- show version
|
||||
- show ip route summary
|
||||
```
|
||||
|
||||
**Device types file** (`device-types.yaml`):
|
||||
**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:
|
||||
routeros:
|
||||
commands:
|
||||
- show version | exc uptime
|
||||
- show boot images
|
||||
- show transceiver
|
||||
- show running-config
|
||||
- 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
|
||||
|
||||
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: # Direct commands (no type)
|
||||
- show version
|
||||
- show ip route summary
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
### Include Directive Support
|
||||
```bash
|
||||
# Load multiple files - later files override earlier ones
|
||||
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --yaml overrides.yaml
|
||||
|
||||
The configuration supports `!include` directives for splitting large configurations into multiple files:
|
||||
# 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
|
||||
# Main config.yaml
|
||||
!include device-types.yaml
|
||||
types:
|
||||
routeros:
|
||||
commands:
|
||||
- / export terse
|
||||
exclude:
|
||||
- "^# ....-..-.. ..:..:.. by RouterOS" # Remove timestamp headers
|
||||
- "^# .../../.... ..:..:.. by RouterOS" # Alternative date format
|
||||
|
||||
devices:
|
||||
production-device:
|
||||
user: admin
|
||||
type: srlinux
|
||||
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
|
||||
```
|
||||
|
||||
**Include Features:**
|
||||
- **One level deep**: Included files cannot contain their own `!include` directives
|
||||
- **Relative paths**: Paths are relative to the including file's directory
|
||||
- **Absolute paths**: Fully qualified paths are supported
|
||||
- **Quoted paths**: Use quotes for paths containing spaces: `!include "file with spaces.yaml"`
|
||||
- **Proper indentation**: Included content maintains correct YAML indentation
|
||||
|
||||
**Example file structure:**
|
||||
```
|
||||
/etc/ipng-router-backup/
|
||||
├── config.yaml # Main configuration with !include
|
||||
├── device-types.yaml # Device type definitions
|
||||
└── devices/
|
||||
├── production.yaml # Production device definitions
|
||||
└── lab.yaml # Lab device definitions
|
||||
```
|
||||
|
||||
**Usage patterns:**
|
||||
|
||||
1. **Include device types at top level:**
|
||||
```yaml
|
||||
!include device-types.yaml
|
||||
|
||||
devices:
|
||||
# device definitions here
|
||||
```
|
||||
|
||||
2. **Include under specific sections:**
|
||||
```yaml
|
||||
types:
|
||||
!include types/network-devices.yaml
|
||||
|
||||
devices:
|
||||
!include devices/production.yaml
|
||||
```
|
||||
|
||||
3. **Include files with spaces:**
|
||||
```yaml
|
||||
!include "device types/lab environment.yaml"
|
||||
```
|
||||
|
||||
## Command Line Flags
|
||||
## 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/ipng-router-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/ipng-router-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
|
||||
|
||||
### Configuration Organization with Includes
|
||||
|
||||
For large deployments, organize configurations using `!include` directives:
|
||||
|
||||
**Environment-based structure:**
|
||||
```bash
|
||||
network-backup/
|
||||
├── config.yaml # Main config
|
||||
├── types/
|
||||
│ ├── device-types.yaml # All device types
|
||||
│ └── vendor-specific.yaml # Vendor-specific commands
|
||||
├── environments/
|
||||
│ ├── production.yaml # Production devices
|
||||
│ ├── staging.yaml # Staging devices
|
||||
│ └── lab.yaml # Lab devices
|
||||
└── sites/
|
||||
├── datacenter-east.yaml # East datacenter devices
|
||||
└── datacenter-west.yaml # West datacenter devices
|
||||
```
|
||||
|
||||
**Main configuration** (`config.yaml`):
|
||||
```yaml
|
||||
!include types/device-types.yaml
|
||||
|
||||
devices:
|
||||
# Production environment
|
||||
!include environments/production.yaml
|
||||
|
||||
# Lab environment
|
||||
!include environments/lab.yaml
|
||||
```
|
||||
|
||||
**Production devices** (`environments/production.yaml`):
|
||||
```yaml
|
||||
# Production SR Linux switches
|
||||
prod-asw100:
|
||||
user: netops
|
||||
type: srlinux
|
||||
|
||||
prod-asw120:
|
||||
user: netops
|
||||
type: srlinux
|
||||
|
||||
# Production EOS devices
|
||||
prod-core-01:
|
||||
user: netops
|
||||
type: eos
|
||||
```
|
||||
|
||||
### 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
|
@@ -1,98 +0,0 @@
|
||||
# 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 this file to a location of your choice and modify for your environment.
|
||||
#
|
||||
# Usage: ipng-router-backup --config /path/to/your/config.yaml
|
||||
#
|
||||
# YAML !include Support:
|
||||
# You can split large configurations into multiple files using !include directives.
|
||||
# Examples:
|
||||
# !include device-types.yaml
|
||||
# !include devices/production.yaml
|
||||
# !include "devices/lab environment.yaml" # Use quotes for paths with spaces
|
||||
|
||||
# Include device types from separate file
|
||||
!include device-types.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
|
||||
|
||||
# Configuration Tips:
|
||||
#
|
||||
# 1. Authentication Priority (automatic):
|
||||
# - SSH Agent (if SSH_AUTH_SOCK environment variable is set)
|
||||
# - SSH Key file (--key-file flag or default locations)
|
||||
# - Password (--password flag or interactive prompt)
|
||||
#
|
||||
# 2. Running the backup:
|
||||
# # Backup all devices
|
||||
# ipng-router-backup --config /etc/ipng-router-backup/config.yaml
|
||||
#
|
||||
# # Backup specific devices only
|
||||
# ipng-router-backup --config config.yaml --host asw100 --host edge-01
|
||||
#
|
||||
# # Custom output directory
|
||||
# ipng-router-backup --config config.yaml --output-dir /backup/$(date +%Y%m%d)
|
||||
#
|
||||
# 3. Output files:
|
||||
# - Named after device hostname (e.g., 'asw100', 'edge-01')
|
||||
# - Each command output prefixed with "## COMMAND: <command>"
|
||||
# - Files are recreated on each run (not appended)
|
||||
#
|
||||
# 4. Security considerations:
|
||||
# - Use SSH keys instead of passwords when possible
|
||||
# - Consider using SSH agent for additional security
|
||||
# - Restrict SSH access to backup user accounts
|
||||
# - Store configuration files with appropriate permissions (640 recommended)
|
||||
#
|
||||
# 5. Error handling:
|
||||
# - If a device is unreachable, the tool continues with other devices
|
||||
# - Check tool output for connection or authentication failures
|
||||
# - Use --host flag to test individual devices
|
@@ -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
|
@@ -1,3 +1,9 @@
|
||||
# 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:
|
||||
@@ -22,6 +28,7 @@ types:
|
||||
- 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
|
||||
@@ -40,3 +47,15 @@ types:
|
||||
- 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
57
etc/config.yaml.example
Normal 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
77
src/config.go
Normal 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
362
src/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
@@ -3,14 +3,15 @@ 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 (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
)
|
||||
|
@@ -1,3 +1,5 @@
|
||||
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=
|
||||
@@ -16,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=
|
||||
|
510
src/main.go
510
src/main.go
@@ -4,367 +4,46 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"sync"
|
||||
|
||||
"github.com/kevinburke/ssh_config"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
const Version = "1.0.2"
|
||||
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 {
|
||||
// Get SSH config values for this host
|
||||
hostname := ssh_config.Get(rb.hostname, "Hostname")
|
||||
if hostname == "" {
|
||||
hostname = rb.hostname
|
||||
// Connect and backup
|
||||
if err := backup.Connect(); err != nil {
|
||||
fmt.Printf("%s: Failed to connect: %v\n", hostname, err)
|
||||
return false
|
||||
}
|
||||
|
||||
portStr := ssh_config.Get(rb.hostname, "Port")
|
||||
port := rb.port
|
||||
if portStr != "" {
|
||||
if p, err := strconv.Atoi(portStr); err == nil {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
err := backup.BackupCommands(commands, excludePatterns, outputDir)
|
||||
backup.Disconnect()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Note: Cipher overrides disabled - Go SSH library defaults work
|
||||
// if ciphers := ssh_config.Get(rb.hostname, "Ciphers"); ciphers != "" {
|
||||
// config.Ciphers = ...
|
||||
// }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)}
|
||||
}
|
||||
}
|
||||
|
||||
// If SSH agent didn't work, try key file
|
||||
if len(config.Auth) == 0 && keyFile != "" {
|
||||
// Expand ~ in keyFile path
|
||||
if strings.HasPrefix(keyFile, "~/") {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
keyFile = filepath.Join(homeDir, keyFile[2:])
|
||||
}
|
||||
}
|
||||
|
||||
key, err := ioutil.ReadFile(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", hostname, port)
|
||||
client, err := ssh.Dial("tcp4", address, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to %s: %v", 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", 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 with !include support
|
||||
func loadConfig(configPath string) (*Config, error) {
|
||||
processedYAML, err := processIncludes(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process includes: %v", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
err = yaml.Unmarshal([]byte(processedYAML), &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %v", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// processIncludes processes YAML files with !include directives (one level deep)
|
||||
func processIncludes(filePath string) (string, error) {
|
||||
// Read the file
|
||||
data, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file %s: %v", filePath, err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Process !include directives
|
||||
// Match patterns like: !include path/to/file.yaml (excluding commented lines)
|
||||
includeRegex := regexp.MustCompile(`(?m)^(\s*)!include\s+(.+)$`)
|
||||
|
||||
baseDir := filepath.Dir(filePath)
|
||||
|
||||
// Process includes line by line to avoid conflicts
|
||||
lines := strings.Split(content, "\n")
|
||||
var resultLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
// Check if this line matches our include pattern
|
||||
if match := includeRegex.FindStringSubmatch(line); match != nil {
|
||||
leadingWhitespace := match[1]
|
||||
includePath := strings.TrimSpace(match[2])
|
||||
|
||||
// Skip commented lines
|
||||
if strings.Contains(strings.TrimSpace(line), "#") && strings.Index(strings.TrimSpace(line), "#") < strings.Index(strings.TrimSpace(line), "!include") {
|
||||
resultLines = append(resultLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove quotes if present
|
||||
includePath = strings.Trim(includePath, "\"'")
|
||||
|
||||
// Make path relative to current config file
|
||||
if !filepath.IsAbs(includePath) {
|
||||
includePath = filepath.Join(baseDir, includePath)
|
||||
}
|
||||
|
||||
// Read the included file
|
||||
includedData, err := ioutil.ReadFile(includePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read include file %s: %v", includePath, err)
|
||||
}
|
||||
|
||||
// Use the captured leading whitespace as indentation prefix
|
||||
indentPrefix := leadingWhitespace
|
||||
|
||||
// Indent each line of included content to match the !include line's indentation
|
||||
includedLines := strings.Split(string(includedData), "\n")
|
||||
for _, includeLine := range includedLines {
|
||||
if strings.TrimSpace(includeLine) == "" {
|
||||
resultLines = append(resultLines, "")
|
||||
} else {
|
||||
resultLines = append(resultLines, indentPrefix+includeLine)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular line, just copy it
|
||||
resultLines = append(resultLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
content = strings.Join(resultLines, "\n")
|
||||
|
||||
return content, 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",
|
||||
@@ -374,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(¶llel, "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)
|
||||
|
474
src/main_test.go
474
src/main_test.go
@@ -1,474 +0,0 @@
|
||||
// Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 directory and files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create device-types.yaml file
|
||||
deviceTypesPath := filepath.Join(tempDir, "device-types.yaml")
|
||||
deviceTypesContent := `test-type:
|
||||
commands:
|
||||
- show version
|
||||
- show status`
|
||||
|
||||
err := os.WriteFile(deviceTypesPath, []byte(deviceTypesContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create device-types file: %v", err)
|
||||
}
|
||||
|
||||
// Create main config file with !include
|
||||
configPath := filepath.Join(tempDir, "test-config.yaml")
|
||||
configContent := `types:
|
||||
!include device-types.yaml
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test !include functionality
|
||||
func TestProcessIncludes(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create included file
|
||||
includedPath := filepath.Join(tempDir, "included.yaml")
|
||||
includedContent := `test-type:
|
||||
commands:
|
||||
- show version
|
||||
- show status`
|
||||
|
||||
err := os.WriteFile(includedPath, []byte(includedContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included file: %v", err)
|
||||
}
|
||||
|
||||
// Create main file with !include
|
||||
mainPath := filepath.Join(tempDir, "main.yaml")
|
||||
mainContent := `types:
|
||||
!include included.yaml
|
||||
devices:
|
||||
test-device:
|
||||
user: testuser
|
||||
type: test-type`
|
||||
|
||||
err = os.WriteFile(mainPath, []byte(mainContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main file: %v", err)
|
||||
}
|
||||
|
||||
// Process includes
|
||||
result, err := processIncludes(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process includes: %v", err)
|
||||
}
|
||||
|
||||
// Check that include was processed
|
||||
if !strings.Contains(result, "show version") {
|
||||
t.Error("Expected included content to be present in result")
|
||||
}
|
||||
if !strings.Contains(result, "show status") {
|
||||
t.Error("Expected included content to be present in result")
|
||||
}
|
||||
if strings.Contains(result, "!include") {
|
||||
t.Error("Expected !include directive to be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIncludesWithQuotes(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create included file with spaces in name
|
||||
includedPath := filepath.Join(tempDir, "file with spaces.yaml")
|
||||
includedContent := `production-srlinux:
|
||||
commands:
|
||||
- show version`
|
||||
|
||||
err := os.WriteFile(includedPath, []byte(includedContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included file: %v", err)
|
||||
}
|
||||
|
||||
// Create main file with quoted !include
|
||||
mainPath := filepath.Join(tempDir, "main.yaml")
|
||||
mainContent := `types:
|
||||
!include "file with spaces.yaml"`
|
||||
|
||||
err = os.WriteFile(mainPath, []byte(mainContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main file: %v", err)
|
||||
}
|
||||
|
||||
// Process includes
|
||||
result, err := processIncludes(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process includes: %v", err)
|
||||
}
|
||||
|
||||
// Check that include was processed
|
||||
if !strings.Contains(result, "production-srlinux") {
|
||||
t.Error("Expected included content to be present in result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIncludesNonexistentFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main file with include to nonexistent file
|
||||
mainPath := filepath.Join(tempDir, "main.yaml")
|
||||
mainContent := `types:
|
||||
!include nonexistent.yaml`
|
||||
|
||||
err := os.WriteFile(mainPath, []byte(mainContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main file: %v", err)
|
||||
}
|
||||
|
||||
// Process includes should fail
|
||||
_, err = processIncludes(mainPath)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent include file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigWithIncludes(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create device types file
|
||||
typesPath := filepath.Join(tempDir, "types.yaml")
|
||||
typesContent := `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 main config file with includes
|
||||
mainPath := filepath.Join(tempDir, "config.yaml")
|
||||
mainContent := `types:
|
||||
!include types.yaml
|
||||
devices:
|
||||
asw100:
|
||||
user: admin
|
||||
type: srlinux
|
||||
edge-01:
|
||||
user: operator
|
||||
type: eos`
|
||||
|
||||
err = os.WriteFile(mainPath, []byte(mainContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config file: %v", err)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
config, err := loadConfig(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config with includes: %v", err)
|
||||
}
|
||||
|
||||
// Verify types were loaded correctly
|
||||
if len(config.Types) != 2 {
|
||||
t.Errorf("Expected 2 types, got %d", len(config.Types))
|
||||
}
|
||||
|
||||
srlinuxType, exists := config.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 were loaded correctly
|
||||
if len(config.Devices) != 2 {
|
||||
t.Errorf("Expected 2 devices, got %d", len(config.Devices))
|
||||
}
|
||||
|
||||
asw100, exists := config.Devices["asw100"]
|
||||
if !exists {
|
||||
t.Error("Expected 'asw100' device to exist")
|
||||
}
|
||||
if asw100.User != "admin" {
|
||||
t.Errorf("Expected user 'admin', got '%s'", asw100.User)
|
||||
}
|
||||
if asw100.Type != "srlinux" {
|
||||
t.Errorf("Expected type 'srlinux', got '%s'", asw100.Type)
|
||||
}
|
||||
}
|
336
src/ssh.go
Normal file
336
src/ssh.go
Normal 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
322
src/ssh_test.go
Normal 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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user