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
|
BINARY_NAME=ipng-router-backup
|
||||||
SOURCE_DIR=src
|
SOURCE_DIR=src
|
||||||
BUILD_DIR=.
|
BUILD_DIR=.
|
||||||
GO_FILES=$(SOURCE_DIR)/main.go
|
GO_FILES=$(SOURCE_DIR)/*.go
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
@@ -22,13 +22,14 @@ sync-version:
|
|||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: sync-version
|
build: sync-version
|
||||||
@echo "Building $(BINARY_NAME)..."
|
@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)"
|
@echo "Build complete: $(BINARY_NAME)"
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@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 -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 ../$(BINARY_NAME)_*.deb ../$(BINARY_NAME)_*.changes ../$(BINARY_NAME)_*.buildinfo
|
||||||
rm -f $(BUILD_DIR)/$(BINARY_NAME)
|
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
|
## 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
|
- **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
|
## Quick Start
|
||||||
|
|
||||||
@@ -24,20 +26,7 @@ make build
|
|||||||
|
|
||||||
1. **Create configuration files**:
|
1. **Create configuration files**:
|
||||||
|
|
||||||
**Main config** (`config.yaml`):
|
**Device types** (`yaml/00-device-types.yaml`):
|
||||||
```yaml
|
|
||||||
!include device-types.yaml
|
|
||||||
|
|
||||||
devices:
|
|
||||||
asw100:
|
|
||||||
user: netops
|
|
||||||
type: srlinux
|
|
||||||
asw120:
|
|
||||||
user: netops
|
|
||||||
type: srlinux
|
|
||||||
```
|
|
||||||
|
|
||||||
**Device types** (`device-types.yaml`):
|
|
||||||
```yaml
|
```yaml
|
||||||
types:
|
types:
|
||||||
srlinux:
|
srlinux:
|
||||||
@@ -45,16 +34,34 @@ make build
|
|||||||
- show version
|
- show version
|
||||||
- show platform linecard
|
- show platform linecard
|
||||||
- info flat from running
|
- 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**:
|
2. **Run backup**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backup all devices
|
# Backup all devices (multiple YAML files are automatically merged)
|
||||||
ipng-router-backup --config config.yaml --output-dir /backup
|
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
|
# 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**:
|
3. **Check output**:
|
||||||
@@ -75,9 +82,25 @@ cat /backup/asw100
|
|||||||
The tool automatically tries authentication methods in this order:
|
The tool automatically tries authentication methods in this order:
|
||||||
|
|
||||||
1. **SSH Agent** (if `SSH_AUTH_SOCK` is set)
|
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)
|
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
|
## Documentation
|
||||||
|
|
||||||
- **[Detailed Documentation](docs/DETAILS.md)** - Complete feature guide, configuration reference, and examples
|
- **[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
|
ipng-router-backup (1.0.2) stable; urgency=low
|
||||||
|
|
||||||
* Add YAML !include directive support for configuration files
|
* Add YAML !include directive support for configuration files
|
||||||
|
16
debian/rules
vendored
16
debian/rules
vendored
@@ -1,26 +1,34 @@
|
|||||||
#!/usr/bin/make -f
|
#!/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 $@
|
dh $@
|
||||||
|
|
||||||
override_dh_auto_build:
|
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:
|
override_dh_auto_install:
|
||||||
mkdir -p debian/ipng-router-backup/usr/bin
|
mkdir -p debian/ipng-router-backup/usr/bin
|
||||||
mkdir -p debian/ipng-router-backup/etc/ipng-router-backup
|
mkdir -p debian/ipng-router-backup/etc/ipng-router-backup
|
||||||
mkdir -p debian/ipng-router-backup/usr/share/man/man1
|
mkdir -p debian/ipng-router-backup/usr/share/man/man1
|
||||||
cp ipng-router-backup debian/ipng-router-backup/usr/bin/
|
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 etc/* debian/ipng-router-backup/etc/ipng-router-backup/
|
||||||
cp docs/device-types.yaml debian/ipng-router-backup/etc/ipng-router-backup/device-types.yaml
|
|
||||||
cp docs/router_backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
|
cp 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
|
gzip debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
|
||||||
|
|
||||||
override_dh_auto_clean:
|
override_dh_auto_clean:
|
||||||
rm -f ipng-router-backup
|
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:
|
override_dh_auto_test:
|
||||||
# Skip tests for now
|
# Skip tests for now
|
||||||
|
|
||||||
override_dh_dwz:
|
override_dh_dwz:
|
||||||
# Skip dwz compression due to Go binary format
|
# Skip dwz compression due to Go binary format
|
||||||
|
231
docs/DETAILS.md
231
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
|
- **Multi-device support**: Backup multiple routers in a single run
|
||||||
- **Device type templates**: Define command sets per device type
|
- **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
|
- **Flexible authentication**: SSH agent, key files, or password authentication
|
||||||
- **Selective execution**: Target specific devices with `--host` flags
|
- **Selective execution**: Target specific devices with `--host` flags
|
||||||
- **Automatic file organization**: Output files named by hostname
|
- **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
|
## 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
|
### Complete Example
|
||||||
|
|
||||||
**Main configuration** (`config.yaml`):
|
**Main configuration** (`config.yaml`):
|
||||||
```yaml
|
```yaml
|
||||||
!include device-types.yaml
|
|
||||||
|
|
||||||
devices:
|
devices:
|
||||||
asw100:
|
asw100:
|
||||||
user: admin
|
user: admin
|
||||||
@@ -45,7 +44,7 @@ devices:
|
|||||||
- show ip route summary
|
- show ip route summary
|
||||||
```
|
```
|
||||||
|
|
||||||
**Device types file** (`device-types.yaml`):
|
**Device types file** (`00-device-types.yaml`):
|
||||||
```yaml
|
```yaml
|
||||||
types:
|
types:
|
||||||
srlinux:
|
srlinux:
|
||||||
@@ -96,66 +95,53 @@ types:
|
|||||||
- Type references must exist in the `types` section
|
- Type references must exist in the `types` section
|
||||||
- Commands can be specified either via type reference or directly per device
|
- 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:
|
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:
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
**Example file structure:**
|
**Example file structure:**
|
||||||
```
|
```
|
||||||
/etc/ipng-router-backup/
|
/etc/ipng-router-backup/
|
||||||
├── config.yaml # Main configuration with !include
|
├── yaml/
|
||||||
├── device-types.yaml # Device type definitions
|
│ ├── 00-device-types.yaml # Device type definitions (loaded first)
|
||||||
└── devices/
|
│ ├── 10-production.yaml # Production device definitions
|
||||||
├── production.yaml # Production device definitions
|
│ ├── 20-staging.yaml # Staging device definitions
|
||||||
└── lab.yaml # Lab device definitions
|
│ └── 99-overrides.yaml # Environment-specific overrides
|
||||||
|
└── config.yaml # Simple single-file config
|
||||||
```
|
```
|
||||||
|
|
||||||
**Usage patterns:**
|
**Usage patterns:**
|
||||||
|
|
||||||
1. **Include device types at top level:**
|
1. **Load multiple files with automatic merging:**
|
||||||
```yaml
|
```bash
|
||||||
!include device-types.yaml
|
ipng-router-backup --yaml yaml/00-device-types.yaml --yaml yaml/10-production.yaml
|
||||||
|
|
||||||
devices:
|
|
||||||
# device definitions here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Include under specific sections:**
|
2. **Use wildcards for directory-based loading:**
|
||||||
```yaml
|
```bash
|
||||||
types:
|
ipng-router-backup --yaml yaml/*.yaml
|
||||||
!include types/network-devices.yaml
|
|
||||||
|
|
||||||
devices:
|
|
||||||
!include devices/production.yaml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Include files with spaces:**
|
3. **Override configurations per environment:**
|
||||||
```yaml
|
```bash
|
||||||
!include "device types/lab environment.yaml"
|
# 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
|
## Command Line Flags
|
||||||
|
|
||||||
### Required Flags
|
### Required Flags
|
||||||
|
|
||||||
- **`--config`**: Path to YAML configuration file
|
- **`--yaml`**: Path to YAML configuration file(s)
|
||||||
|
|
||||||
### Optional Flags
|
### Optional Flags
|
||||||
|
|
||||||
@@ -171,25 +157,25 @@ devices:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic usage - all devices
|
# 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
|
# 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
|
# 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
|
# 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
|
# Custom SSH port
|
||||||
ipng-router-backup --config config.yaml --port 2222
|
ipng-router-backup --yaml *.yaml --port 2222
|
||||||
|
|
||||||
# Using password authentication
|
# Using password authentication
|
||||||
ipng-router-backup --config config.yaml --password mypassword
|
ipng-router-backup --yaml *.yaml --password mypassword
|
||||||
|
|
||||||
# Using specific SSH key
|
# 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
|
## SSH Authentication Methods
|
||||||
@@ -206,7 +192,7 @@ eval "$(ssh-agent -s)"
|
|||||||
ssh-add ~/.ssh/id_rsa
|
ssh-add ~/.ssh/id_rsa
|
||||||
|
|
||||||
# Run backup (will use SSH agent automatically)
|
# Run backup (will use SSH agent automatically)
|
||||||
ipng-router-backup --config config.yaml
|
ipng-router-backup --yaml *.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Advantages:**
|
**Advantages:**
|
||||||
@@ -217,11 +203,14 @@ ipng-router-backup --config config.yaml
|
|||||||
|
|
||||||
### 2. SSH Key File
|
### 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
|
```bash
|
||||||
# Explicit key file
|
# 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:
|
# Tool automatically checks these default locations:
|
||||||
# ~/.ssh/id_rsa
|
# ~/.ssh/id_rsa
|
||||||
@@ -234,16 +223,49 @@ ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key
|
|||||||
- Proper permissions (600 recommended)
|
- Proper permissions (600 recommended)
|
||||||
- Corresponding public key must be on target devices
|
- 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)
|
### 3. Password Authentication (Lowest Priority)
|
||||||
|
|
||||||
Use `--password` flag for password-based authentication.
|
Use `--password` flag for password-based authentication.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Command line password (not recommended for scripts)
|
# 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)
|
# 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: "
|
# Output: "No SSH key found. Enter SSH password: "
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -290,7 +312,7 @@ Software Version : v25.3.2
|
|||||||
### Basic Backup All Devices
|
### Basic Backup All Devices
|
||||||
|
|
||||||
```bash
|
```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
|
### Backup Specific Device Types
|
||||||
@@ -299,7 +321,7 @@ Create a config with only the devices you want, or use `--host`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backup only SR Linux devices
|
# 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
|
### Scheduled Backup with SSH Agent
|
||||||
@@ -317,7 +339,7 @@ BACKUP_DIR="/backup/network/$(date +%Y%m%d)"
|
|||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
ipng-router-backup \
|
ipng-router-backup \
|
||||||
--config /etc/ipng-router-backup/config.yaml \
|
--yaml /etc/ipng-router-backup/*.yaml \
|
||||||
--output-dir "$BACKUP_DIR"
|
--output-dir "$BACKUP_DIR"
|
||||||
|
|
||||||
# Kill SSH agent
|
# Kill SSH agent
|
||||||
@@ -329,7 +351,7 @@ ssh-agent -k
|
|||||||
```bash
|
```bash
|
||||||
# Quick backup of single device with password
|
# Quick backup of single device with password
|
||||||
ipng-router-backup \
|
ipng-router-backup \
|
||||||
--config emergency.yaml \
|
--yaml emergency.yaml \
|
||||||
--host core-router-01 \
|
--host core-router-01 \
|
||||||
--password emergency123 \
|
--password emergency123 \
|
||||||
--output-dir /tmp/emergency-backup
|
--output-dir /tmp/emergency-backup
|
||||||
@@ -361,53 +383,68 @@ ipng-router-backup \
|
|||||||
|
|
||||||
## Advanced Usage
|
## 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:**
|
**Environment-based structure:**
|
||||||
```bash
|
```bash
|
||||||
network-backup/
|
network-backup/
|
||||||
├── config.yaml # Main config
|
├── yaml/
|
||||||
├── types/
|
│ ├── 00-device-types.yaml # All device types (loaded first)
|
||||||
│ ├── device-types.yaml # All device types
|
│ ├── 10-common.yaml # Common settings
|
||||||
│ └── vendor-specific.yaml # Vendor-specific commands
|
│ ├── 20-production.yaml # Production devices
|
||||||
├── environments/
|
│ ├── 30-staging.yaml # Staging devices
|
||||||
│ ├── production.yaml # Production devices
|
│ ├── 40-lab.yaml # Lab devices
|
||||||
│ ├── staging.yaml # Staging devices
|
│ ├── 50-east-datacenter.yaml # East datacenter devices
|
||||||
│ └── lab.yaml # Lab devices
|
│ └── 60-west-datacenter.yaml # West datacenter devices
|
||||||
└── sites/
|
└── overrides/
|
||||||
├── datacenter-east.yaml # East datacenter devices
|
├── emergency.yaml # Emergency override settings
|
||||||
└── datacenter-west.yaml # West datacenter devices
|
└── maintenance.yaml # Maintenance mode settings
|
||||||
```
|
```
|
||||||
|
|
||||||
**Main configuration** (`config.yaml`):
|
**Device types** (`yaml/00-device-types.yaml`):
|
||||||
```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:
|
devices:
|
||||||
# Production environment
|
prod-asw100:
|
||||||
!include environments/production.yaml
|
user: netops
|
||||||
|
type: srlinux
|
||||||
|
|
||||||
# Lab environment
|
prod-asw120:
|
||||||
!include environments/lab.yaml
|
user: netops
|
||||||
|
type: srlinux
|
||||||
|
|
||||||
|
prod-core-01:
|
||||||
|
user: netops
|
||||||
|
type: eos
|
||||||
```
|
```
|
||||||
|
|
||||||
**Production devices** (`environments/production.yaml`):
|
**Usage examples:**
|
||||||
```yaml
|
```bash
|
||||||
# Production SR Linux switches
|
# Load all standard configs
|
||||||
prod-asw100:
|
ipng-router-backup --yaml yaml/*.yaml
|
||||||
user: netops
|
|
||||||
type: srlinux
|
|
||||||
|
|
||||||
prod-asw120:
|
# Load with environment-specific overrides
|
||||||
user: netops
|
ipng-router-backup --yaml yaml/*.yaml --yaml overrides/emergency.yaml
|
||||||
type: srlinux
|
|
||||||
|
|
||||||
# Production EOS devices
|
# Load only specific environments
|
||||||
prod-core-01:
|
ipng-router-backup --yaml yaml/00-device-types.yaml --yaml yaml/20-production.yaml
|
||||||
user: netops
|
|
||||||
type: eos
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration with Git
|
### Integration with Git
|
||||||
@@ -420,7 +457,7 @@ BACKUP_DIR="/backup/network-configs"
|
|||||||
cd "$BACKUP_DIR"
|
cd "$BACKUP_DIR"
|
||||||
|
|
||||||
# Run backup
|
# Run backup
|
||||||
ipng-router-backup --config config.yaml --output-dir .
|
ipng-router-backup --yaml config.yaml --output-dir .
|
||||||
|
|
||||||
# Commit changes
|
# Commit changes
|
||||||
git add .
|
git add .
|
||||||
@@ -459,11 +496,11 @@ devices:
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Backup with monitoring
|
# 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
|
echo "Backup completed successfully" | logger
|
||||||
else
|
else
|
||||||
echo "Backup failed!" | logger
|
echo "Backup failed!" | logger
|
||||||
# Send alert email
|
# Send alert email
|
||||||
echo "Network backup failed at $(date)" | mail -s "Backup Alert" admin@company.com
|
echo "Network backup failed at $(date)" | mail -s "Backup Alert" admin@company.com
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
@@ -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
|
ipng-router-backup \- SSH Router Backup Tool
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
.B ipng-router-backup
|
.B ipng-router-backup
|
||||||
.RI --config " CONFIG_FILE"
|
.RI --yaml " CONFIG_FILE(S)"
|
||||||
.RI [ --output-dir " DIRECTORY" ]
|
.RI [ --output-dir " DIRECTORY" ]
|
||||||
.RI [ --password " PASSWORD" ]
|
.RI [ --password " PASSWORD" ]
|
||||||
.RI [ --key-file " KEYFILE" ]
|
.RI [ --key-file " KEYFILE" ]
|
||||||
@@ -11,13 +11,14 @@ ipng-router-backup \- SSH Router Backup Tool
|
|||||||
.RI [ --host " HOSTNAME" ]...
|
.RI [ --host " HOSTNAME" ]...
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.B router_backup
|
.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
|
.PP
|
||||||
The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization.
|
The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization.
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
.TP
|
.TP
|
||||||
.BR --config " \fICONFIG_FILE\fR"
|
.BR --yaml " \fICONFIG_FILE\fR"
|
||||||
YAML configuration file path (required)
|
YAML configuration file(s) (required)
|
||||||
.TP
|
.TP
|
||||||
.BR --output-dir " \fIDIRECTORY\fR"
|
.BR --output-dir " \fIDIRECTORY\fR"
|
||||||
Output directory for command output files (default: /tmp)
|
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
|
.TP
|
||||||
Basic usage:
|
Basic usage:
|
||||||
.EX
|
.EX
|
||||||
ipng-router-backup --config /etc/ipng-router-backup/config.yaml
|
ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml
|
||||||
.EE
|
.EE
|
||||||
.TP
|
.TP
|
||||||
Custom output directory:
|
Custom output directory:
|
||||||
.EX
|
.EX
|
||||||
ipng-router-backup --config config.yaml --output-dir /home/user/backups
|
ipng-router-backup --yaml config.yaml --output-dir /home/user/backups
|
||||||
.EE
|
.EE
|
||||||
.TP
|
.TP
|
||||||
Using password authentication:
|
Using password authentication:
|
||||||
.EX
|
.EX
|
||||||
ipng-router-backup --config config.yaml --password mysecretpass
|
ipng-router-backup --yaml config.yaml --password mysecretpass
|
||||||
.EE
|
.EE
|
||||||
.TP
|
.TP
|
||||||
Process specific hosts only:
|
Process specific hosts only:
|
||||||
.EX
|
.EX
|
||||||
ipng-router-backup --config config.yaml --host asw100 --host asw120
|
ipng-router-backup --yaml config.yaml --host asw100 --host asw120
|
||||||
.EE
|
.EE
|
||||||
.SH FILES
|
.SH FILES
|
||||||
.TP
|
.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:
|
types:
|
||||||
# Nokia SR Linux devices
|
# Nokia SR Linux devices
|
||||||
srlinux:
|
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
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dario.cat/mergo v1.0.2
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.8.0
|
||||||
golang.org/x/crypto v0.18.0
|
golang.org/x/crypto v0.18.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
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
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/sys v0.16.0 // 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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kevinburke/ssh_config"
|
|
||||||
"github.com/spf13/cobra"
|
"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
|
// Config and SSH types are now in separate packages
|
||||||
type Config struct {
|
|
||||||
Types map[string]DeviceType `yaml:"types"`
|
|
||||||
Devices map[string]Device `yaml:"devices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeviceType struct {
|
// SSH connection methods are now in ssh.go
|
||||||
Commands []string `yaml:"commands"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Device struct {
|
// YAML processing is now handled by the config package
|
||||||
User string `yaml:"user"`
|
|
||||||
Type string `yaml:"type,omitempty"`
|
|
||||||
Commands []string `yaml:"commands,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RouterBackup handles SSH connections and command execution
|
// SSH helper functions are now in ssh.go
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var yamlFiles []string
|
||||||
var password string
|
var password string
|
||||||
var keyFile string
|
var keyFile string
|
||||||
var port int
|
var port int
|
||||||
@@ -375,7 +37,7 @@ func main() {
|
|||||||
fmt.Printf("IPng Networks Router Backup v%s\n", Version)
|
fmt.Printf("IPng Networks Router Backup v%s\n", Version)
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
config, err := loadConfig(configPath)
|
cfg, err := ConfigRead(yamlFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -393,16 +55,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process devices
|
// Process devices
|
||||||
if len(config.Devices) == 0 {
|
if len(cfg.Devices) == 0 {
|
||||||
log.Fatal("No devices found in config file")
|
log.Fatal("No devices found in config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter devices if --host flags are provided
|
// Filter devices if --host flags are provided
|
||||||
devicesToProcess := config.Devices
|
devicesToProcess := cfg.Devices
|
||||||
if len(hostFilter) > 0 {
|
if len(hostFilter) > 0 {
|
||||||
devicesToProcess = make(map[string]Device)
|
devicesToProcess = make(map[string]Device)
|
||||||
for _, hostname := range hostFilter {
|
for _, hostname := range hostFilter {
|
||||||
if deviceConfig, exists := config.Devices[hostname]; exists {
|
if deviceConfig, exists := cfg.Devices[hostname]; exists {
|
||||||
devicesToProcess[hostname] = deviceConfig
|
devicesToProcess[hostname] = deviceConfig
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname)
|
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 device has a type, get commands from types section
|
||||||
if deviceType != "" {
|
if deviceType != "" {
|
||||||
if typeConfig, exists := config.Types[deviceType]; exists {
|
if typeConfig, exists := cfg.Types[deviceType]; exists {
|
||||||
commands = typeConfig.Commands
|
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(&password, "password", "", "SSH password")
|
||||||
rootCmd.Flags().StringVar(&keyFile, "key-file", "", "SSH private key file path")
|
rootCmd.Flags().StringVar(&keyFile, "key-file", "", "SSH private key file path")
|
||||||
rootCmd.Flags().IntVar(&port, "port", 22, "SSH port")
|
rootCmd.Flags().IntVar(&port, "port", 22, "SSH port")
|
||||||
rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files")
|
rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files")
|
||||||
rootCmd.Flags().StringSliceVar(&hostFilter, "host", []string{}, "Specific host(s) to process (can be repeated, processes all if not specified)")
|
rootCmd.Flags().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 {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
log.Fatal(err)
|
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