Compare commits
21 Commits
f2c484e9c1
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
57fc8d3630 | ||
|
64212fce8c | ||
|
83797aaa34 | ||
|
3da4de7711 | ||
|
9a2264e867 | ||
|
6c1993282c | ||
|
53c7bca43e | ||
|
c6775736ac | ||
|
4260067ea8 | ||
|
90f5ec4e26 | ||
|
c8df809c29 | ||
|
88e30a40b1 | ||
|
631a387708 | ||
|
2bba484e6c | ||
|
db98af84b0 | ||
|
963cc3eed6 | ||
|
9475d7b5c0 | ||
|
fd74c41fb3 | ||
|
7442a83c9d | ||
|
7f6b030b31 | ||
|
f05124b703 |
19
README.md
19
README.md
@@ -10,6 +10,19 @@ SSH-based network device configuration backup tool with support for multiple dev
|
||||
- **SSH config integration**: Automatically uses `~/.ssh/config` settings for legacy device compatibility
|
||||
- **Modular configuration**: Load and merge multiple YAML files for organized configuration management
|
||||
|
||||
## Supported Devices
|
||||
|
||||
Pre-configured device types with optimized command sets:
|
||||
|
||||
- **Nokia SR Linux** (`srlinux`) - Show version, linecard, fans, power, full config
|
||||
- **Arista EOS** (`eos`) - Version, inventory, power status, running config
|
||||
- **Centec Switches** (`centec`) - Version, boot images, transceivers, interfaces, config
|
||||
- **Cisco IOS/IOS-XE** (`cisco-ios`) - Version, inventory, config, interfaces, CDP neighbors
|
||||
- **Juniper JunOS** (`junos`) - Version, chassis hardware, configuration, interfaces
|
||||
- **Mikrotik RouterOS** (`routeros`) - Packages, routerboard info, license, interfaces, config
|
||||
|
||||
Each device type includes carefully selected commands for comprehensive backup coverage. You can override commands per device or create custom device types.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
@@ -56,11 +69,11 @@ make build
|
||||
|
||||
```bash
|
||||
# Backup all devices (multiple YAML files are automatically merged)
|
||||
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --output-dir /backup
|
||||
ipng-router-backup --yaml "00-*.yaml" --yaml config.yaml --output-dir /backup
|
||||
|
||||
# Backup specific devices
|
||||
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --output-dir /backup \
|
||||
--host asw100
|
||||
--host "asw*"
|
||||
```
|
||||
|
||||
3. **Check output**:
|
||||
@@ -103,5 +116,5 @@ This allows connecting to older routers that require legacy SSH algorithms while
|
||||
## Documentation
|
||||
|
||||
- **[Detailed Documentation](docs/DETAILS.md)** - Complete feature guide, configuration reference, and examples
|
||||
- **[Manual Page](docs/router_backup.1)** - Unix manual page
|
||||
- **[Manual Page](docs/ipng-router-backup.1)** - Unix manual page
|
||||
- **[Changelog](debian/changelog)** - Version history and changes
|
||||
|
60
debian/changelog
vendored
60
debian/changelog
vendored
@@ -1,3 +1,63 @@
|
||||
ipng-router-backup (1.3.2) stable; urgency=low
|
||||
|
||||
* Fix --key-file authentication priority issue
|
||||
* Prioritize explicit key file over SSH agent authentication
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 13 Jul 2025 23:30:00 +0100
|
||||
|
||||
ipng-router-backup (1.3.1) stable; urgency=low
|
||||
|
||||
* Fix golangci-lint issues, replace deprecated io/ioutil
|
||||
* Add SSH key error messages with hostname prefix
|
||||
* Independently validate sshkey, agent auth and password methods
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 23:30:00 +0100
|
||||
|
||||
ipng-router-backup (1.3.0) stable; urgency=low
|
||||
|
||||
* Add --parallel flag for concurrent device processing (default: 10)
|
||||
* Implement worker pool pattern for much faster multi-device backups
|
||||
* Maintain atomic file operations and error handling in parallel mode
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 23:00:00 +0100
|
||||
|
||||
ipng-router-backup (1.2.4) stable; urgency=low
|
||||
|
||||
* Add regex exclude patterns to filter unwanted output lines per device type
|
||||
* Prefix all log messages with hostname for better multi-device visibility
|
||||
* Add exclude pattern support for RouterOS timestamp headers
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 22:00:00 +0100
|
||||
|
||||
ipng-router-backup (1.2.3) stable; urgency=low
|
||||
|
||||
* For routeros, set mikrotik export to terse
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:30:00 +0100
|
||||
|
||||
ipng-router-backup (1.2.2) stable; urgency=low
|
||||
|
||||
* Add supported devices list to README.md
|
||||
* Document all 6 pre-configured device types with command summaries
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:15:00 +0100
|
||||
|
||||
ipng-router-backup (1.2.1) stable; urgency=low
|
||||
|
||||
* Add glob pattern support for --yaml flag (e.g., --yaml "*.yaml")
|
||||
* Add glob pattern support for --host flag (e.g., --host "asw*")
|
||||
* Update documentation with glob pattern examples
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 21:00:00 +0100
|
||||
|
||||
ipng-router-backup (1.2.0) stable; urgency=low
|
||||
|
||||
* Add atomic file operations with .new suffix for backup reliability
|
||||
* Add exit codes: 10 (some devices failed), 11 (all devices failed)
|
||||
* Update manpage filename to ipng-router-backup.1
|
||||
|
||||
-- Pim van Pelt <pim@ipng.ch> Sun, 07 Jul 2025 20:00:00 +0100
|
||||
|
||||
ipng-router-backup (1.1.1) stable; urgency=low
|
||||
|
||||
* Add 'address' field to device configuration for explicit IP/hostname override
|
||||
|
2
debian/rules
vendored
2
debian/rules
vendored
@@ -18,7 +18,7 @@ override_dh_auto_install:
|
||||
mkdir -p debian/ipng-router-backup/usr/share/man/man1
|
||||
cp ipng-router-backup debian/ipng-router-backup/usr/bin/
|
||||
cp etc/* debian/ipng-router-backup/etc/ipng-router-backup/
|
||||
cp docs/router_backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
|
||||
cp docs/ipng-router-backup.1 debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
|
||||
gzip debian/ipng-router-backup/usr/share/man/man1/ipng-router-backup.1
|
||||
|
||||
override_dh_auto_clean:
|
||||
|
@@ -34,6 +34,14 @@ types:
|
||||
- show version
|
||||
- show inventory
|
||||
- show running-config
|
||||
|
||||
routeros:
|
||||
commands:
|
||||
- system package print detail without-paging
|
||||
- / export terse
|
||||
exclude:
|
||||
- "^# ....-..-.. ..:..:.. by RouterOS" # Filter timestamp headers
|
||||
- "^# .../../.... ..:..:.. by RouterOS" # Alternative date format
|
||||
```
|
||||
|
||||
**Main configuration** (`config.yaml`):
|
||||
@@ -64,6 +72,7 @@ devices:
|
||||
#### Types Section
|
||||
- **`<type-name>`**: Device type name (e.g., `srlinux`, `eos`)
|
||||
- **`commands`**: Array of CLI commands to execute
|
||||
- **`exclude`** (optional): Array of regex patterns to filter out unwanted lines from output
|
||||
|
||||
#### Devices Section
|
||||
- **`<hostname>`**: Device hostname (used for SSH config lookup and output filename)
|
||||
@@ -79,37 +88,82 @@ Files are merged automatically using mergo. Later files override earlier ones:
|
||||
```bash
|
||||
# Load multiple files - later files override earlier ones
|
||||
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --yaml overrides.yaml
|
||||
|
||||
# Load files using glob patterns
|
||||
ipng-router-backup --yaml "*.yaml"
|
||||
ipng-router-backup --yaml "config/*.yaml"
|
||||
```
|
||||
|
||||
## Output Filtering
|
||||
|
||||
The tool supports filtering unwanted lines from command output using regular expressions in the `exclude` field of device types.
|
||||
|
||||
### How Exclude Patterns Work
|
||||
|
||||
- **Regex matching**: Each line of command output is tested against all exclude patterns
|
||||
- **Line removal**: Lines matching any pattern are completely removed from the output file
|
||||
- **Per-device type**: Exclude patterns are defined at the device type level and apply to all devices of that type
|
||||
|
||||
### Common Use Cases
|
||||
|
||||
```yaml
|
||||
types:
|
||||
routeros:
|
||||
commands:
|
||||
- / export terse
|
||||
exclude:
|
||||
- "^# ....-..-.. ..:..:.. by RouterOS" # Remove timestamp headers
|
||||
- "^# .../../.... ..:..:.. by RouterOS" # Alternative date format
|
||||
|
||||
cisco-ios:
|
||||
commands:
|
||||
- show running-config
|
||||
exclude:
|
||||
- "^Building configuration" # Remove config build messages
|
||||
- "^Current configuration" # Remove current config headers
|
||||
- "^!" # Remove comment lines
|
||||
|
||||
debug-device:
|
||||
commands:
|
||||
- show logs
|
||||
exclude:
|
||||
- "^DEBUG:" # Filter debug messages
|
||||
- "^TRACE:" # Filter trace messages
|
||||
```
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
### Required Flags
|
||||
- **`--yaml`**: Path to YAML configuration file(s) (can be repeated)
|
||||
- **`--yaml`**: Path to YAML configuration file(s) or glob patterns (can be repeated)
|
||||
|
||||
### Optional Flags
|
||||
- **`--output-dir`**: Output directory (default: `/tmp`)
|
||||
- **`--host`**: Specific hostname(s) to process (can be repeated)
|
||||
- **`--host`**: Specific hostname(s) or glob patterns to process (can be repeated)
|
||||
- **`--password`**: SSH password
|
||||
- **`--key-file`**: SSH private key file path
|
||||
- **`--port`**: SSH port (default: `22`)
|
||||
- **`--parallel`**: Maximum number of devices to process in parallel (default: `10`)
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
ipng-router-backup --yaml config.yaml
|
||||
# Basic usage with glob patterns
|
||||
ipng-router-backup --yaml "*.yaml"
|
||||
|
||||
# Multiple files
|
||||
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml
|
||||
|
||||
# Specific devices only
|
||||
ipng-router-backup --yaml config.yaml --host asw100 --host core-01
|
||||
# Devices matching patterns
|
||||
ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
|
||||
|
||||
# Custom output directory
|
||||
ipng-router-backup --yaml config.yaml --output-dir /backup/network
|
||||
|
||||
# With password authentication
|
||||
ipng-router-backup --yaml config.yaml --password mypassword
|
||||
|
||||
# Process more devices in parallel
|
||||
ipng-router-backup --yaml config.yaml --parallel 20
|
||||
```
|
||||
|
||||
## SSH Authentication
|
||||
@@ -188,5 +242,7 @@ Software Version : v25.3.2
|
||||
- **Permission issues**: Verify SSH key permissions (600) and output directory access
|
||||
|
||||
### Exit Codes
|
||||
- `0`: Success
|
||||
- `1`: Configuration error, authentication failure, or connection issues
|
||||
- `0`: Success (all devices processed successfully)
|
||||
- `1`: Configuration error, authentication failure, or connection issues
|
||||
- `10`: Some devices failed
|
||||
- `11`: All devices failed
|
@@ -18,7 +18,7 @@ The tool supports multiple device types with predefined command sets, SSH agent
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BR --yaml " \fICONFIG_FILE\fR"
|
||||
YAML configuration file(s) (required)
|
||||
YAML configuration file(s) or glob patterns (required)
|
||||
.TP
|
||||
.BR --output-dir " \fIDIRECTORY\fR"
|
||||
Output directory for command output files (default: /tmp)
|
||||
@@ -33,7 +33,10 @@ SSH private key file path
|
||||
SSH port number (default: 22)
|
||||
.TP
|
||||
.BR --host " \fIHOSTNAME\fR"
|
||||
Specific host(s) to process (can be repeated, processes all if not specified)
|
||||
Specific host(s) or glob patterns to process (can be repeated, processes all if not specified)
|
||||
.TP
|
||||
.BR --parallel " \fINUMBER\fR"
|
||||
Maximum number of devices to process in parallel (default: 10)
|
||||
.TP
|
||||
.BR --help
|
||||
Show help message
|
||||
@@ -48,6 +51,11 @@ types:
|
||||
commands:
|
||||
- show version
|
||||
- show platform linecard
|
||||
routeros:
|
||||
commands:
|
||||
- / export terse
|
||||
exclude:
|
||||
- "^# ....-..-.. ..:..:.. by RouterOS"
|
||||
.EE
|
||||
.SS devices
|
||||
Define individual devices:
|
||||
@@ -70,11 +78,13 @@ Default SSH keys (~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa)
|
||||
Password authentication (--password option)
|
||||
.SH OUTPUT
|
||||
For each device, a text file named after the hostname is created in the specified directory. Each command output is prefixed with "## COMMAND: <command_name>" for easy identification.
|
||||
.PP
|
||||
Output can be filtered using regex patterns defined in the device type's 'exclude' field to remove unwanted lines such as timestamps or debug messages.
|
||||
.SH EXAMPLES
|
||||
.TP
|
||||
Basic usage:
|
||||
Basic usage with glob patterns:
|
||||
.EX
|
||||
ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml
|
||||
ipng-router-backup --yaml "*.yaml"
|
||||
.EE
|
||||
.TP
|
||||
Custom output directory:
|
||||
@@ -87,9 +97,14 @@ Using password authentication:
|
||||
ipng-router-backup --yaml config.yaml --password mysecretpass
|
||||
.EE
|
||||
.TP
|
||||
Process specific hosts only:
|
||||
Process hosts matching patterns:
|
||||
.EX
|
||||
ipng-router-backup --yaml config.yaml --host asw100 --host asw120
|
||||
ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
|
||||
.EE
|
||||
.TP
|
||||
Process devices in parallel:
|
||||
.EX
|
||||
ipng-router-backup --yaml config.yaml --parallel 20
|
||||
.EE
|
||||
.SH FILES
|
||||
.TP
|
||||
@@ -98,10 +113,16 @@ Example configuration file
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Success
|
||||
Success (all devices processed successfully)
|
||||
.TP
|
||||
.B 1
|
||||
General error (configuration file not found, authentication failure, etc.)
|
||||
.TP
|
||||
.B 10
|
||||
Some devices failed
|
||||
.TP
|
||||
.B 11
|
||||
All devices failed
|
||||
.SH AUTHOR
|
||||
Written by Pim van Pelt.
|
||||
.SH REPORTING BUGS
|
@@ -28,6 +28,7 @@ types:
|
||||
- show version | exc uptime # Version info without uptime line
|
||||
- show boot images # Boot image information
|
||||
- show transceiver # SFP/transceiver status
|
||||
- show interface description # Interface status
|
||||
- show running-config # Running configuration
|
||||
|
||||
# Cisco IOS/IOS-XE devices
|
||||
@@ -46,3 +47,15 @@ types:
|
||||
- show chassis hardware # Chassis hardware details
|
||||
- show configuration | display set # Configuration in set format
|
||||
- show interfaces terse # Interface status summary
|
||||
|
||||
# Mikrotik routeros devices
|
||||
routeros:
|
||||
commands:
|
||||
- system package print detail without-paging # Installed Packaged
|
||||
- system routerboard print # System information
|
||||
- system license print # License information
|
||||
- / interface print # Interfaces
|
||||
- / export terse # Configuration
|
||||
exclude:
|
||||
- "^# ....-..-.. ..:..:.. by RouterOS"
|
||||
- "^# .../../.... ..:..:.. by RouterOS"
|
||||
|
@@ -4,7 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -18,6 +18,7 @@ type Config struct {
|
||||
|
||||
type DeviceType struct {
|
||||
Commands []string `yaml:"commands"`
|
||||
Exclude []string `yaml:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
@@ -28,7 +29,7 @@ type Device struct {
|
||||
}
|
||||
|
||||
func readYAMLFile(path string) (map[string]interface{}, error) {
|
||||
data, err := ioutil.ReadFile(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
172
src/main.go
172
src/main.go
@@ -6,19 +6,35 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const Version = "1.1.1"
|
||||
const Version = "1.3.2"
|
||||
|
||||
// Config and SSH types are now in separate packages
|
||||
func processDevice(hostname string, deviceConfig Device, commands []string, excludePatterns []string, password, keyFile string, port int, outputDir string) bool {
|
||||
// Create backup instance
|
||||
backup := NewRouterBackup(hostname, deviceConfig.Address, deviceConfig.User, password, keyFile, port)
|
||||
|
||||
// SSH connection methods are now in ssh.go
|
||||
// Connect and backup
|
||||
if err := backup.Connect(); err != nil {
|
||||
fmt.Printf("%s: Failed to connect: %v\n", hostname, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// YAML processing is now handled by the config package
|
||||
err := backup.BackupCommands(commands, excludePatterns, outputDir)
|
||||
backup.Disconnect()
|
||||
|
||||
// SSH helper functions are now in ssh.go
|
||||
if err != nil {
|
||||
fmt.Printf("%s: Backup failed: %v\n", hostname, err)
|
||||
return false
|
||||
} else {
|
||||
fmt.Printf("%s: Backup completed\n", hostname)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var yamlFiles []string
|
||||
@@ -27,6 +43,7 @@ func main() {
|
||||
var port int
|
||||
var outputDir string
|
||||
var hostFilter []string
|
||||
var parallel int
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "ipng-router-backup",
|
||||
@@ -36,22 +53,47 @@ func main() {
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("IPng Networks Router Backup v%s\n", Version)
|
||||
|
||||
// Expand glob patterns in YAML files
|
||||
var expandedYamlFiles []string
|
||||
for _, pattern := range yamlFiles {
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid glob pattern '%s': %v", pattern, err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
log.Fatalf("No files matched pattern '%s'", pattern)
|
||||
}
|
||||
expandedYamlFiles = append(expandedYamlFiles, matches...)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := ConfigRead(yamlFiles)
|
||||
cfg, err := ConfigRead(expandedYamlFiles)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Check authentication setup
|
||||
if password == "" && keyFile == "" {
|
||||
if os.Getenv("SSH_AUTH_SOCK") != "" {
|
||||
fmt.Println("Using SSH agent for authentication")
|
||||
} else {
|
||||
keyFile = findDefaultSSHKey()
|
||||
if keyFile == "" {
|
||||
log.Fatal("No SSH key found and no password provided")
|
||||
}
|
||||
hasAuth := 0
|
||||
if os.Getenv("SSH_AUTH_SOCK") != "" {
|
||||
fmt.Println("Using SSH agent for authentication")
|
||||
hasAuth++
|
||||
}
|
||||
if keyFile == "" {
|
||||
keyFile = findDefaultSSHKey()
|
||||
if keyFile != "" {
|
||||
fmt.Printf("Using SSH key: %s\n", keyFile)
|
||||
hasAuth++
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Using specified SSH key: %s\n", keyFile)
|
||||
hasAuth++
|
||||
}
|
||||
if password != "" {
|
||||
fmt.Println("Using --password for authentication")
|
||||
hasAuth++
|
||||
}
|
||||
if hasAuth == 0 {
|
||||
log.Fatal("No authentication mechanisms found.")
|
||||
}
|
||||
|
||||
// Process devices
|
||||
@@ -63,63 +105,110 @@ func main() {
|
||||
devicesToProcess := cfg.Devices
|
||||
if len(hostFilter) > 0 {
|
||||
devicesToProcess = make(map[string]Device)
|
||||
for _, hostname := range hostFilter {
|
||||
if deviceConfig, exists := cfg.Devices[hostname]; exists {
|
||||
devicesToProcess[hostname] = deviceConfig
|
||||
} else {
|
||||
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname)
|
||||
for _, pattern := range hostFilter {
|
||||
patternMatched := false
|
||||
for hostname, deviceConfig := range cfg.Devices {
|
||||
if matched, _ := filepath.Match(pattern, hostname); matched {
|
||||
devicesToProcess[hostname] = deviceConfig
|
||||
patternMatched = true
|
||||
}
|
||||
}
|
||||
if !patternMatched {
|
||||
fmt.Printf("Warning: Host pattern '%s' did not match any devices\n", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
totalCount := len(devicesToProcess)
|
||||
|
||||
for hostname, deviceConfig := range devicesToProcess {
|
||||
fmt.Printf("\nProcessing device: %s (type: %s)\n", hostname, deviceConfig.Type)
|
||||
// Create channels for work distribution and result collection
|
||||
type DeviceWork struct {
|
||||
hostname string
|
||||
deviceConfig Device
|
||||
commands []string
|
||||
excludePatterns []string
|
||||
}
|
||||
|
||||
type DeviceResult struct {
|
||||
hostname string
|
||||
success bool
|
||||
}
|
||||
|
||||
workChan := make(chan DeviceWork, totalCount)
|
||||
resultChan := make(chan DeviceResult, totalCount)
|
||||
|
||||
// Start worker pool
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < parallel; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for work := range workChan {
|
||||
fmt.Printf("%s: Processing device (type: %s)\n", work.hostname, work.deviceConfig.Type)
|
||||
|
||||
success := processDevice(work.hostname, work.deviceConfig, work.commands, work.excludePatterns, password, keyFile, port, outputDir)
|
||||
resultChan <- DeviceResult{hostname: work.hostname, success: success}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Queue all work
|
||||
for hostname, deviceConfig := range devicesToProcess {
|
||||
user := deviceConfig.User
|
||||
commands := deviceConfig.Commands
|
||||
deviceType := deviceConfig.Type
|
||||
var excludePatterns []string
|
||||
|
||||
// If device has a type, get commands from types section
|
||||
// If device has a type, get commands and exclude patterns from types section
|
||||
if deviceType != "" {
|
||||
if typeConfig, exists := cfg.Types[deviceType]; exists {
|
||||
commands = typeConfig.Commands
|
||||
excludePatterns = typeConfig.Exclude
|
||||
}
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
fmt.Printf("No user specified for %s, skipping\n", hostname)
|
||||
fmt.Printf("%s: No user specified, skipping\n", hostname)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(commands) == 0 {
|
||||
fmt.Printf("No commands specified for %s, skipping\n", hostname)
|
||||
fmt.Printf("%s: No commands specified, skipping\n", hostname)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create backup instance
|
||||
backup := NewRouterBackup(hostname, deviceConfig.Address, user, password, keyFile, port)
|
||||
|
||||
// Connect and backup
|
||||
if err := backup.Connect(); err != nil {
|
||||
fmt.Printf("Failed to connect to %s: %v\n", hostname, err)
|
||||
continue
|
||||
workChan <- DeviceWork{
|
||||
hostname: hostname,
|
||||
deviceConfig: deviceConfig,
|
||||
commands: commands,
|
||||
excludePatterns: excludePatterns,
|
||||
}
|
||||
}
|
||||
close(workChan)
|
||||
|
||||
err = backup.BackupCommands(commands, outputDir)
|
||||
backup.Disconnect()
|
||||
// Wait for all workers to finish
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Backup failed for %s: %v\n", hostname, err)
|
||||
} else {
|
||||
fmt.Printf("Backup completed for %s\n", hostname)
|
||||
// Collect results
|
||||
successCount := 0
|
||||
for result := range resultChan {
|
||||
if result.success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nOverall summary: %d/%d devices processed successfully\n", successCount, totalCount)
|
||||
fmt.Printf("Overall summary: %d/%d devices processed successfully\n", successCount, totalCount)
|
||||
|
||||
// Set exit code based on results
|
||||
if successCount == 0 {
|
||||
os.Exit(11) // All devices failed
|
||||
} else if successCount < totalCount {
|
||||
os.Exit(10) // Some devices failed
|
||||
}
|
||||
// Exit code 0 (success) when all devices succeeded
|
||||
},
|
||||
}
|
||||
|
||||
@@ -129,8 +218,11 @@ func main() {
|
||||
rootCmd.Flags().IntVar(&port, "port", 22, "SSH port")
|
||||
rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files")
|
||||
rootCmd.Flags().StringSliceVar(&hostFilter, "host", []string{}, "Specific host(s) to process (can be repeated, processes all if not specified)")
|
||||
rootCmd.Flags().IntVar(¶llel, "parallel", 10, "Maximum number of devices to process in parallel")
|
||||
|
||||
rootCmd.MarkFlagRequired("yaml")
|
||||
if err := rootCmd.MarkFlagRequired("yaml"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
|
113
src/ssh.go
113
src/ssh.go
@@ -4,10 +4,10 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -103,11 +103,6 @@ func (rb *RouterBackup) Connect() error {
|
||||
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 {
|
||||
@@ -126,15 +121,19 @@ func (rb *RouterBackup) Connect() error {
|
||||
config.HostKeyAlgorithms = finalAlgorithms
|
||||
}
|
||||
|
||||
// Try SSH agent first if available
|
||||
// If explicit key file is provided, prioritize it over SSH agent
|
||||
var keyFileAuth ssh.AuthMethod
|
||||
var agentAuth ssh.AuthMethod
|
||||
|
||||
// Try SSH agent if available (but don't add to config.Auth yet)
|
||||
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
|
||||
if conn, err := net.Dial("unix", sshAuthSock); err == nil {
|
||||
agentClient := agent.NewClient(conn)
|
||||
config.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}
|
||||
agentAuth = ssh.PublicKeysCallback(agentClient.Signers)
|
||||
}
|
||||
}
|
||||
|
||||
// If SSH agent didn't work, try key file
|
||||
// Try key file
|
||||
if keyFile != "" {
|
||||
// Expand ~ in keyFile path
|
||||
if strings.HasPrefix(keyFile, "~/") {
|
||||
@@ -144,17 +143,27 @@ func (rb *RouterBackup) Connect() error {
|
||||
}
|
||||
}
|
||||
|
||||
key, err := ioutil.ReadFile(keyFile)
|
||||
key, err := os.ReadFile(keyFile)
|
||||
if err == nil {
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
fmt.Errorf("unable to parse private key: %v", err)
|
||||
fmt.Printf("%s: Unable to parse private key: %v\n", rb.hostname, err)
|
||||
} else {
|
||||
config.Auth = append(config.Auth, ssh.PublicKeys(signer))
|
||||
keyFileAuth = ssh.PublicKeys(signer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritize auth methods: explicit key file first, then SSH agent
|
||||
if keyFileAuth != nil {
|
||||
config.Auth = []ssh.AuthMethod{keyFileAuth}
|
||||
if agentAuth != nil {
|
||||
config.Auth = append(config.Auth, agentAuth)
|
||||
}
|
||||
} else if agentAuth != nil {
|
||||
config.Auth = []ssh.AuthMethod{agentAuth}
|
||||
}
|
||||
|
||||
// Fall back to password if available
|
||||
if rb.password != "" {
|
||||
config.Auth = append(config.Auth, ssh.Password(rb.password))
|
||||
@@ -178,7 +187,7 @@ func (rb *RouterBackup) Connect() error {
|
||||
}
|
||||
|
||||
rb.client = client
|
||||
fmt.Printf("Successfully connected to %s\n", targetHost)
|
||||
fmt.Printf("%s: Successfully connected to %s\n", rb.hostname, targetHost)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -202,50 +211,96 @@ func (rb *RouterBackup) RunCommand(command string) (string, error) {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// filterOutput removes lines matching exclude patterns from the output
|
||||
func filterOutput(output string, excludePatterns []string) string {
|
||||
if len(excludePatterns) == 0 {
|
||||
return output
|
||||
}
|
||||
|
||||
lines := strings.Split(output, "\n")
|
||||
var filteredLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
exclude := false
|
||||
for _, pattern := range excludePatterns {
|
||||
if matched, _ := regexp.MatchString(pattern, line); matched {
|
||||
exclude = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exclude {
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// BackupCommands runs multiple commands and saves outputs to files
|
||||
func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) error {
|
||||
func (rb *RouterBackup) BackupCommands(commands []string, excludePatterns []string, outputDir string) error {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
|
||||
}
|
||||
|
||||
filename := rb.hostname
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
finalPath := filepath.Join(outputDir, filename)
|
||||
tempPath := finalPath + ".new"
|
||||
|
||||
// Truncate file at start
|
||||
file, err := os.Create(filepath)
|
||||
// Create temporary file
|
||||
file, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %v", filepath, err)
|
||||
return fmt.Errorf("failed to create temporary file %s: %v", tempPath, err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
successCount := 0
|
||||
hasErrors := false
|
||||
|
||||
for i, command := range commands {
|
||||
fmt.Printf("Running command %d/%d: %s\n", i+1, len(commands), command)
|
||||
fmt.Printf("%s: Running command %d/%d: %s\n", rb.hostname, i+1, len(commands), command)
|
||||
output, err := rb.RunCommand(command)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Error executing '%s': %v\n", command, err)
|
||||
fmt.Printf("%s: Error executing '%s': %v\n", rb.hostname, command, err)
|
||||
hasErrors = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Append to file
|
||||
file, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
// Append to temporary file
|
||||
file, err := os.OpenFile(tempPath, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to open file for writing: %v\n", err)
|
||||
fmt.Printf("%s: Failed to open file for writing: %v\n", rb.hostname, err)
|
||||
hasErrors = true
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(file, "## COMMAND: %s\n", command)
|
||||
file.WriteString(output)
|
||||
filteredOutput := filterOutput(output, excludePatterns)
|
||||
if _, err := file.WriteString(filteredOutput); err != nil {
|
||||
fmt.Printf("%s: Failed to write output: %v\n", rb.hostname, err)
|
||||
hasErrors = true
|
||||
}
|
||||
file.Close()
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
fmt.Printf("Summary: %d/%d commands successful\n", successCount, len(commands))
|
||||
if successCount > 0 {
|
||||
fmt.Printf("Output saved to %s\n", filepath)
|
||||
fmt.Printf("%s: Summary: %d/%d commands successful\n", rb.hostname, successCount, len(commands))
|
||||
|
||||
if hasErrors || successCount == 0 {
|
||||
// Remove .new suffix and log error
|
||||
if err := os.Remove(tempPath); err != nil {
|
||||
fmt.Printf("%s: Failed to remove temporary file %s: %v\n", rb.hostname, tempPath, err)
|
||||
}
|
||||
return fmt.Errorf("device backup incomplete due to command failures")
|
||||
}
|
||||
|
||||
// All commands succeeded, move file into place atomically
|
||||
if err := os.Rename(tempPath, finalPath); err != nil {
|
||||
return fmt.Errorf("failed to move temporary file to final location: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s: Output saved to %s\n", rb.hostname, finalPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -253,7 +308,7 @@ func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) erro
|
||||
func (rb *RouterBackup) Disconnect() {
|
||||
if rb.client != nil {
|
||||
rb.client.Close()
|
||||
fmt.Printf("Disconnected from %s\n", rb.hostname)
|
||||
fmt.Printf("%s: Disconnected\n", rb.hostname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +327,7 @@ func findDefaultSSHKey() string {
|
||||
|
||||
for _, keyPath := range defaultKeys {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
fmt.Printf("Using SSH key: %s\n", keyPath)
|
||||
// Key discovery logging moved to main.go for hostname context
|
||||
return keyPath
|
||||
}
|
||||
}
|
||||
|
@@ -57,17 +57,22 @@ func TestBackupCommandsDirectoryCreation(t *testing.T) {
|
||||
|
||||
// This should create the directory even without a connection
|
||||
// and fail gracefully when trying to run commands
|
||||
_ = rb.BackupCommands([]string{"show version"}, outputDir)
|
||||
err := rb.BackupCommands([]string{"show version"}, []string{}, outputDir)
|
||||
|
||||
// Should not error on directory creation
|
||||
if _, statErr := os.Stat(outputDir); os.IsNotExist(statErr) {
|
||||
t.Error("Expected output directory to be created")
|
||||
}
|
||||
|
||||
// Should create the output file even if commands fail
|
||||
// Should return error when commands fail
|
||||
if err == nil {
|
||||
t.Error("Expected error when commands fail")
|
||||
}
|
||||
|
||||
// Should NOT create the output file when commands fail (atomic behavior)
|
||||
expectedFile := filepath.Join(outputDir, "testhost")
|
||||
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) {
|
||||
t.Error("Expected output file to be created")
|
||||
if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
|
||||
t.Error("Expected output file to NOT be created when commands fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,15 +81,15 @@ func TestBackupCommandsEmptyCommands(t *testing.T) {
|
||||
|
||||
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)
|
||||
err := rb.BackupCommands([]string{}, []string{}, tempDir)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty commands list (no successful commands)")
|
||||
}
|
||||
|
||||
// Should still create the output file
|
||||
// Should NOT create the output file when no commands succeed
|
||||
expectedFile := filepath.Join(tempDir, "testhost")
|
||||
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) {
|
||||
t.Error("Expected output file to be created even for empty commands")
|
||||
if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
|
||||
t.Error("Expected output file to NOT be created when no commands succeed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +165,19 @@ func TestBackupCommandsFileOperations(t *testing.T) {
|
||||
// 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)
|
||||
err := rb.BackupCommands(commands, []string{}, tempDir)
|
||||
if err == nil {
|
||||
t.Error("Expected error when all commands fail")
|
||||
}
|
||||
|
||||
// Check that output file was created
|
||||
// Check that output file was NOT created (atomic behavior)
|
||||
outputFile := filepath.Join(tempDir, "testhost")
|
||||
_, err = os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
if err == nil {
|
||||
t.Error("Expected output file to not exist when all commands fail")
|
||||
}
|
||||
|
||||
// File should be created (it will be empty if all commands fail)
|
||||
// This test just verifies the file creation works
|
||||
// This test verifies that atomic file behavior works correctly
|
||||
}
|
||||
|
||||
func TestRouterBackupConnectionState(t *testing.T) {
|
||||
@@ -281,3 +285,38 @@ func TestIPv6AddressFormatting(t *testing.T) {
|
||||
t.Error("Expected IPv4 address to use tcp4 network type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOutput(t *testing.T) {
|
||||
// Test with no exclude patterns
|
||||
input := "line1\nline2\nline3"
|
||||
result := filterOutput(input, []string{})
|
||||
if result != input {
|
||||
t.Errorf("Expected no filtering with empty patterns, got '%s'", result)
|
||||
}
|
||||
|
||||
// Test with matching pattern
|
||||
input = "# 2025-07-06 21:30:45 by RouterOS\nconfig line 1\nconfig line 2"
|
||||
excludePatterns := []string{"^# ....-..-.. ..:..:.. by RouterOS"}
|
||||
expected := "config line 1\nconfig line 2"
|
||||
result = filterOutput(input, excludePatterns)
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
|
||||
// Test with multiple patterns
|
||||
input = "line1\nDEBUG: debug info\nline2\nINFO: info message\nline3"
|
||||
excludePatterns = []string{"^DEBUG:", "^INFO:"}
|
||||
expected = "line1\nline2\nline3"
|
||||
result = filterOutput(input, excludePatterns)
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
|
||||
// Test with no matches
|
||||
input = "line1\nline2\nline3"
|
||||
excludePatterns = []string{"nomatch"}
|
||||
result = filterOutput(input, excludePatterns)
|
||||
if result != input {
|
||||
t.Errorf("Expected no filtering when patterns don't match, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user