Compare commits

..

6 Commits

Author SHA1 Message Date
Pim van Pelt
5385d6fca8 Cut a new release. Simplify Debian build rules, more similar to govpp-snmp-agentx 2025-07-06 17:54:07 +02:00
Pim van Pelt
2a1ed2dd35 Rework tests, and move all source files into main package 2025-07-06 17:38:47 +02:00
Pim van Pelt
db91d59481 Update Makefile 2025-07-06 17:20:10 +02:00
Pim van Pelt
a3d681e420 Update docs 2025-07-06 17:17:45 +02:00
Pim van Pelt
e5f9e59601 Rename yaml/* to etc/ 2025-07-06 17:14:29 +02:00
Pim van Pelt
769d9eb6cd Move to yaml.v3 and mergo. Refactor config parsing into a package. Refactor SSH connections into a package. Create default YAML directory, and update docs 2025-07-06 17:11:22 +02:00
17 changed files with 1125 additions and 1058 deletions

View File

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

View File

@@ -4,9 +4,11 @@ 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
## Quick Start
@@ -24,20 +26,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** (`yaml/00-device-types.yaml`):
```yaml
types:
srlinux:
@@ -45,16 +34,34 @@ make build
- show version
- show platform linecard
- info flat from running
centec:
commands:
- show version
- show running-config
```
**Device config** (`yaml/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 yaml/00-device-types.yaml --yaml yaml/config.yaml --output-dir /backup
# Or use wildcards
ipng-router-backup --yaml yaml/*.yaml --output-dir /backup
# Backup specific devices
ipng-router-backup --config config.yaml --host asw100 --output-dir /backup
ipng-router-backup --yaml yaml/*.yaml --host asw100 --output-dir /backup
```
3. **Check output**:
@@ -75,9 +82,25 @@ 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

10
debian/changelog vendored
View File

@@ -1,3 +1,13 @@
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

16
debian/rules vendored
View File

@@ -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 etc/* debian/ipng-router-backup/etc/ipng-router-backup/
cp docs/router_backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
gzip debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
override_dh_auto_clean:
rm -f ipng-router-backup
[ -d debian/go ] && chmod -R +w debian/go || true
for dir in obj-*; do [ -d "$$dir" ] && chmod -R +w "$$dir" || true; done
rm -rf debian/.gocache debian/go obj-*
override_dh_auto_test:
# Skip tests for now
override_dh_dwz:
# Skip dwz compression due to Go binary format
# Skip dwz compression due to Go binary format

View File

@@ -8,7 +8,8 @@ IPng Networks Router Backup is a SSH-based network device configuration backup t
- **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 automatically using mergo
- **SSH config integration**: Automatically 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
@@ -17,14 +18,12 @@ IPng Networks Router Backup is a SSH-based network device configuration backup t
## 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 simultaneously using the `--yaml` flag, and their contents are automatically merged using the mergo library. Later files override earlier ones, allowing for flexible configuration organization.
### Complete Example
**Main configuration** (`config.yaml`):
```yaml
!include device-types.yaml
devices:
asw100:
user: admin
@@ -45,7 +44,7 @@ devices:
- show ip route summary
```
**Device types file** (`device-types.yaml`):
**Device types file** (`00-device-types.yaml`):
```yaml
types:
srlinux:
@@ -96,66 +95,53 @@ types:
- Type references must exist in the `types` section
- Commands can be specified either via type reference or directly per device
### Include Directive Support
### Configuration Merging with Mergo
The configuration supports `!include` directives for splitting large configurations into multiple files:
```yaml
# Main config.yaml
!include device-types.yaml
devices:
production-device:
user: admin
type: srlinux
```
**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
The tool automatically merges multiple YAML files using the mergo library. Files specified later in the `--yaml` flag override values from earlier files, enabling flexible configuration organization:
**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
├── yaml/
├── 00-device-types.yaml # Device type definitions (loaded first)
│ ├── 10-production.yaml # Production device definitions
├── 20-staging.yaml # Staging device definitions
└── 99-overrides.yaml # Environment-specific overrides
└── config.yaml # Simple single-file config
```
**Usage patterns:**
1. **Include device types at top level:**
```yaml
!include device-types.yaml
devices:
# device definitions here
1. **Load multiple files with automatic merging:**
```bash
ipng-router-backup --yaml yaml/00-device-types.yaml --yaml yaml/10-production.yaml
```
2. **Include under specific sections:**
```yaml
types:
!include types/network-devices.yaml
devices:
!include devices/production.yaml
2. **Use wildcards for directory-based loading:**
```bash
ipng-router-backup --yaml yaml/*.yaml
```
3. **Include files with spaces:**
```yaml
!include "device types/lab environment.yaml"
3. **Override configurations per environment:**
```bash
# Base config + production overrides
ipng-router-backup --yaml base.yaml --yaml production-overrides.yaml
# Base config + development overrides
ipng-router-backup --yaml base.yaml --yaml dev-overrides.yaml
```
**Merging behavior:**
- **Maps are merged**: Device and type definitions from multiple files are combined
- **Arrays are replaced**: Later files completely replace arrays from earlier files
- **Values are overridden**: Later files override individual values from earlier files
- **Types are preserved**: Device types from any file can be referenced by devices in any other file
## Command Line Flags
### Required Flags
- **`--config`**: Path to YAML configuration file
- **`--yaml`**: Path to YAML configuration file(s)
### Optional Flags
@@ -171,25 +157,25 @@ devices:
```bash
# Basic usage - all devices
ipng-router-backup --config /etc/ipng-router-backup/config.yaml
ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml
# Custom output directory
ipng-router-backup --config config.yaml --output-dir /backup/network
ipng-router-backup --yaml *.yaml --output-dir /backup/network
# Specific devices only
ipng-router-backup --config config.yaml --host asw100 --host core-01
ipng-router-backup --yaml *.yaml --host asw100 --host core-01
# Multiple specific devices
ipng-router-backup --config config.yaml --host asw100 --host asw120 --host core-01
ipng-router-backup --yaml *.yaml --host asw100 --host asw120 --host core-01
# Custom SSH port
ipng-router-backup --config config.yaml --port 2222
ipng-router-backup --yaml *.yaml --port 2222
# Using password authentication
ipng-router-backup --config config.yaml --password mypassword
ipng-router-backup --yaml *.yaml --password mypassword
# Using specific SSH key
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
ipng-router-backup --yaml *.yaml --key-file ~/.ssh/network_key
```
## SSH Authentication Methods
@@ -206,7 +192,7 @@ 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 *.yaml
```
**Advantages:**
@@ -217,11 +203,14 @@ ipng-router-backup --config config.yaml
### 2. SSH Key File
Specify a private key file with `--key-file` or use default locations.
Specify a private key file with `--key-file`, use SSH config, or default locations.
```bash
# Explicit key file
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
ipng-router-backup --yaml *.yaml --key-file ~/.ssh/network_key
# SSH config integration (IdentityFile from ~/.ssh/config)
ipng-router-backup --yaml *.yaml
# Tool automatically checks these default locations:
# ~/.ssh/id_rsa
@@ -234,16 +223,49 @@ ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
- Proper permissions (600 recommended)
- Corresponding public key must be on target devices
### SSH Configuration Integration
The tool automatically reads `~/.ssh/config` for each host, supporting:
```bash
# ~/.ssh/config
Host old-switch*
User admin
Port 2222
IdentityFile ~/.ssh/legacy_key
KexAlgorithms +diffie-hellman-group1-sha1
Ciphers aes128-cbc,aes192-cbc,aes256-cbc
HostKeyAlgorithms +ssh-rsa
Host modern-router*
User netops
Port 22
IdentityFile ~/.ssh/modern_key
```
**Supported SSH config options:**
- `Hostname`: Target hostname override
- `Port`: SSH port override
- `User`: Username override (command line takes precedence)
- `IdentityFile`: SSH key file path
- `KexAlgorithms`: Key exchange algorithms (explicit lists only, + syntax ignored for compatibility)
- `Ciphers`: Encryption ciphers (filtered for Go SSH library compatibility)
- `MACs`: Message authentication codes
- `HostKeyAlgorithms`: Host key algorithms (explicit lists only, + syntax ignored for compatibility)
**Legacy device compatibility:**
The tool is designed to work with older network devices that require legacy SSH algorithms while maintaining security for modern devices.
### 3. Password Authentication (Lowest Priority)
Use `--password` flag for password-based authentication.
```bash
# Command line password (not recommended for scripts)
ipng-router-backup --config config.yaml --password mypassword
ipng-router-backup --yaml *.yaml --password mypassword
# Interactive password prompt (when no other auth available)
ipng-router-backup --config config.yaml
ipng-router-backup --yaml *.yaml
# Output: "No SSH key found. Enter SSH password: "
```
@@ -290,7 +312,7 @@ Software Version : v25.3.2
### Basic Backup All Devices
```bash
ipng-router-backup --config /etc/backup/network.yaml --output-dir /backup/$(date +%Y%m%d)
ipng-router-backup --yaml /etc/backup/*.yaml --output-dir /backup/$(date +%Y%m%d)
```
### Backup Specific Device Types
@@ -299,7 +321,7 @@ 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
ipng-router-backup --yaml network.yaml --host asw100 --host asw120 --host asw121
```
### Scheduled Backup with SSH Agent
@@ -317,7 +339,7 @@ BACKUP_DIR="/backup/network/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
ipng-router-backup \
--config /etc/ipng-router-backup/config.yaml \
--yaml /etc/ipng-router-backup/*.yaml \
--output-dir "$BACKUP_DIR"
# Kill SSH agent
@@ -329,7 +351,7 @@ ssh-agent -k
```bash
# Quick backup of single device with password
ipng-router-backup \
--config emergency.yaml \
--yaml emergency.yaml \
--host core-router-01 \
--password emergency123 \
--output-dir /tmp/emergency-backup
@@ -361,53 +383,68 @@ ipng-router-backup \
## Advanced Usage
### Configuration Organization with Includes
### Configuration Organization with Mergo
For large deployments, organize configurations using `!include` directives:
For large deployments, organize configurations using multiple YAML files with automatic merging:
**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
├── yaml/
│ ├── 00-device-types.yaml # All device types (loaded first)
│ ├── 10-common.yaml # Common settings
── 20-production.yaml # Production devices
│ ├── 30-staging.yaml # Staging devices
│ ├── 40-lab.yaml # Lab devices
│ ├── 50-east-datacenter.yaml # East datacenter devices
│ └── 60-west-datacenter.yaml # West datacenter devices
└── overrides/
├── emergency.yaml # Emergency override settings
└── maintenance.yaml # Maintenance mode settings
```
**Main configuration** (`config.yaml`):
**Device types** (`yaml/00-device-types.yaml`):
```yaml
!include types/device-types.yaml
types:
srlinux:
commands:
- show version
- show platform linecard
- info flat from running
eos:
commands:
- show version
- show inventory
- show running-config
```
**Production devices** (`yaml/20-production.yaml`):
```yaml
devices:
# Production environment
!include environments/production.yaml
prod-asw100:
user: netops
type: srlinux
# Lab environment
!include environments/lab.yaml
prod-asw120:
user: netops
type: srlinux
prod-core-01:
user: netops
type: eos
```
**Production devices** (`environments/production.yaml`):
```yaml
# Production SR Linux switches
prod-asw100:
user: netops
type: srlinux
**Usage examples:**
```bash
# Load all standard configs
ipng-router-backup --yaml yaml/*.yaml
prod-asw120:
user: netops
type: srlinux
# Load with environment-specific overrides
ipng-router-backup --yaml yaml/*.yaml --yaml overrides/emergency.yaml
# Production EOS devices
prod-core-01:
user: netops
type: eos
# Load only specific environments
ipng-router-backup --yaml yaml/00-device-types.yaml --yaml yaml/20-production.yaml
```
### Integration with Git
@@ -420,7 +457,7 @@ BACKUP_DIR="/backup/network-configs"
cd "$BACKUP_DIR"
# Run backup
ipng-router-backup --config config.yaml --output-dir .
ipng-router-backup --yaml config.yaml --output-dir .
# Commit changes
git add .
@@ -459,11 +496,11 @@ devices:
#!/bin/bash
# Backup with monitoring
if ipng-router-backup --config config.yaml --output-dir /backup; then
if ipng-router-backup --yaml 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
```
```

View File

@@ -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

View File

@@ -3,7 +3,7 @@
ipng-router-backup \- SSH Router Backup Tool
.SH SYNOPSIS
.B ipng-router-backup
.RI --config " CONFIG_FILE"
.RI --yaml " CONFIG_FILE(S)"
.RI [ --output-dir " DIRECTORY" ]
.RI [ --password " PASSWORD" ]
.RI [ --key-file " KEYFILE" ]
@@ -11,13 +11,14 @@ ipng-router-backup \- SSH Router Backup Tool
.RI [ --host " HOSTNAME" ]...
.SH DESCRIPTION
.B router_backup
is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a YAML configuration file and executes commands, saving the output to files.
is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a
set of YAML configuration file(s) and executes commands, saving the output to files.
.PP
The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization.
.SH OPTIONS
.TP
.BR --config " \fICONFIG_FILE\fR"
YAML configuration file path (required)
.BR --yaml " \fICONFIG_FILE\fR"
YAML configuration file(s) (required)
.TP
.BR --output-dir " \fIDIRECTORY\fR"
Output directory for command output files (default: /tmp)
@@ -73,22 +74,22 @@ For each device, a text file named after the hostname is created in the specifie
.TP
Basic usage:
.EX
ipng-router-backup --config /etc/ipng-router-backup/config.yaml
ipng-router-backup --yaml /etc/ipng-router-backup/*.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:
.EX
ipng-router-backup --config config.yaml --host asw100 --host asw120
ipng-router-backup --yaml config.yaml --host asw100 --host asw120
.EE
.SH FILES
.TP

View File

@@ -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:

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

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

73
src/config.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"fmt"
"io/ioutil"
"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"`
}
type Device struct {
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
}
func readYAMLFile(path string) (map[string]interface{}, error) {
data, err := ioutil.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
}

319
src/config_test.go Normal file
View File

@@ -0,0 +1,319 @@
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))
}
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -4,362 +4,24 @@ package main
import (
"fmt"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"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.1.0"
// Config structures
type Config struct {
Types map[string]DeviceType `yaml:"types"`
Devices map[string]Device `yaml:"devices"`
}
// Config and SSH types are now in separate packages
type DeviceType struct {
Commands []string `yaml:"commands"`
}
// SSH connection methods are now in ssh.go
type Device struct {
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
}
// YAML processing is now handled by the config package
// 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
}
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
}
// 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)
}
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 ""
}
// SSH helper functions are now in ssh.go
func main() {
var configPath string
var yamlFiles []string
var password string
var keyFile string
var port int
@@ -375,7 +37,7 @@ func main() {
fmt.Printf("IPng Networks Router Backup v%s\n", Version)
// Load configuration
config, err := loadConfig(configPath)
cfg, err := ConfigRead(yamlFiles)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
@@ -393,16 +55,16 @@ func main() {
}
// 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 {
if deviceConfig, exists := cfg.Devices[hostname]; exists {
devicesToProcess[hostname] = deviceConfig
} else {
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname)
@@ -422,7 +84,7 @@ func main() {
// If device has a type, get commands from types section
if deviceType != "" {
if typeConfig, exists := config.Types[deviceType]; exists {
if typeConfig, exists := cfg.Types[deviceType]; exists {
commands = typeConfig.Commands
}
}
@@ -461,14 +123,14 @@ func main() {
},
}
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.MarkFlagRequired("config")
rootCmd.MarkFlagRequired("yaml")
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)

View File

@@ -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)
}
}

250
src/ssh.go Normal file
View File

@@ -0,0 +1,250 @@
package main
import (
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"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
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
}
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
}
// Note: Cipher overrides disabled - Go SSH library defaults work better
// 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)
}
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)
}
}
// 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 ""
}

190
src/ssh_test.go Normal file
View File

@@ -0,0 +1,190 @@
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
_ = rb.BackupCommands([]string{"show version"}, outputDir)
// Should not error on directory creation
if _, statErr := os.Stat(outputDir); os.IsNotExist(statErr) {
t.Error("Expected output directory to be created")
}
// Should create the output file even if commands fail
expectedFile := filepath.Join(outputDir, "testhost")
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) {
t.Error("Expected output file to be created")
}
}
func TestBackupCommandsEmptyCommands(t *testing.T) {
tempDir := t.TempDir()
rb := NewRouterBackup("testhost", "testuser", "testpass", "", 22)
err := rb.BackupCommands([]string{}, tempDir)
if err != nil {
t.Errorf("Expected no error for empty commands list, got %v", err)
}
// Should still create the output file
expectedFile := filepath.Join(tempDir, "testhost")
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) {
t.Error("Expected output file to be created even for empty commands")
}
}
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, tempDir)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
// Check that output file was created
outputFile := filepath.Join(tempDir, "testhost")
_, err = os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
// File should be created (it will be empty if all commands fail)
// This test just verifies the file creation works
}
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")
}
}