Compare commits
6 Commits
75646856aa
...
5385d6fca8
Author | SHA1 | Date | |
---|---|---|---|
|
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)
|
||||
|
63
README.md
63
README.md
@@ -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
10
debian/changelog
vendored
@@ -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
|
||||
|
14
debian/rules
vendored
14
debian/rules
vendored
@@ -1,23 +1,31 @@
|
||||
#!/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
|
||||
|
217
docs/DETAILS.md
217
docs/DETAILS.md
@@ -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,41 +383,45 @@ 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
|
||||
|
||||
# Lab environment
|
||||
!include environments/lab.yaml
|
||||
```
|
||||
|
||||
**Production devices** (`environments/production.yaml`):
|
||||
```yaml
|
||||
# Production SR Linux switches
|
||||
prod-asw100:
|
||||
user: netops
|
||||
type: srlinux
|
||||
@@ -404,12 +430,23 @@ prod-asw120:
|
||||
user: netops
|
||||
type: srlinux
|
||||
|
||||
# Production EOS devices
|
||||
prod-core-01:
|
||||
user: netops
|
||||
type: eos
|
||||
```
|
||||
|
||||
**Usage examples:**
|
||||
```bash
|
||||
# Load all standard configs
|
||||
ipng-router-backup --yaml yaml/*.yaml
|
||||
|
||||
# Load with environment-specific overrides
|
||||
ipng-router-backup --yaml yaml/*.yaml --yaml overrides/emergency.yaml
|
||||
|
||||
# Load only specific environments
|
||||
ipng-router-backup --yaml yaml/00-device-types.yaml --yaml yaml/20-production.yaml
|
||||
```
|
||||
|
||||
### Integration with Git
|
||||
|
||||
```bash
|
||||
@@ -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,7 +496,7 @@ 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
|
||||
|
@@ -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) (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
|
||||
|
@@ -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
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
|
73
src/config.go
Normal file
73
src/config.go
Normal 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
319
src/config_test.go
Normal 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))
|
||||
}
|
||||
}
|
@@ -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=
|
||||
|
364
src/main.go
364
src/main.go
@@ -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)
|
||||
|
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)
|
||||
}
|
||||
}
|
250
src/ssh.go
Normal file
250
src/ssh.go
Normal 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
190
src/ssh_test.go
Normal 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")
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user