Compare commits

...

18 Commits

Author SHA1 Message Date
Pim van Pelt
57fc8d3630 Release v1.3.2 2025-07-13 22:23:20 +02:00
Pim van Pelt
64212fce8c Twiddle ssh auth, use password before --key-file flag before homedir before agent 2025-07-13 22:21:27 +02:00
Pim van Pelt
83797aaa34 Release v1.3.1 2025-07-07 09:10:08 +02:00
Pim van Pelt
3da4de7711 Fix lint errors, ensure errors start with 'hostname:' 2025-07-07 09:06:20 +02:00
Pim van Pelt
9a2264e867 Remove old comments; Count auth mechanisms independently 2025-07-07 09:02:41 +02:00
Pim van Pelt
6c1993282c Release v1.3.0 2025-07-07 01:11:49 +02:00
Pim van Pelt
53c7bca43e Add parallelism 2025-07-07 01:08:42 +02:00
Pim van Pelt
c6775736ac Update docs with exclude patterns 2025-07-07 00:54:52 +02:00
Pim van Pelt
4260067ea8 Release v1.2.4 2025-07-07 00:52:43 +02:00
Pim van Pelt
90f5ec4e26 In preparation for parallelism, emit all log lines prefixed by hostname 2025-07-07 00:51:47 +02:00
Pim van Pelt
c8df809c29 Add types.exclude pattern 2025-07-07 00:39:56 +02:00
Pim van Pelt
88e30a40b1 Print description instead of status 2025-07-07 00:23:02 +02:00
Pim van Pelt
631a387708 Cut v1.2.3 2025-07-06 23:48:17 +02:00
Pim van Pelt
2bba484e6c Output terse 2025-07-06 23:46:59 +02:00
Pim van Pelt
db98af84b0 Release v1.2.2 2025-07-06 23:36:16 +02:00
Pim van Pelt
963cc3eed6 Add mikrotik 2025-07-06 23:33:59 +02:00
Pim van Pelt
9475d7b5c0 Allow glob of --host and --yaml; cut release 1.2.1 2025-07-06 23:31:41 +02:00
Pim van Pelt
fd74c41fb3 Add interface status 2025-07-06 23:05:40 +02:00
9 changed files with 406 additions and 97 deletions

View File

@@ -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 - **SSH config integration**: Automatically uses `~/.ssh/config` settings for legacy device compatibility
- **Modular configuration**: Load and merge multiple YAML files for organized configuration management - **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 ## Quick Start
### Installation ### Installation
@@ -56,11 +69,11 @@ make build
```bash ```bash
# Backup all devices (multiple YAML files are automatically merged) # 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 # Backup specific devices
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --output-dir /backup \ ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --output-dir /backup \
--host asw100 --host "asw*"
``` ```
3. **Check output**: 3. **Check output**:

52
debian/changelog vendored
View File

@@ -1,3 +1,55 @@
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 ipng-router-backup (1.2.0) stable; urgency=low
* Add atomic file operations with .new suffix for backup reliability * Add atomic file operations with .new suffix for backup reliability

View File

@@ -34,6 +34,14 @@ types:
- show version - show version
- show inventory - show inventory
- show running-config - 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`): **Main configuration** (`config.yaml`):
@@ -64,6 +72,7 @@ devices:
#### Types Section #### Types Section
- **`<type-name>`**: Device type name (e.g., `srlinux`, `eos`) - **`<type-name>`**: Device type name (e.g., `srlinux`, `eos`)
- **`commands`**: Array of CLI commands to execute - **`commands`**: Array of CLI commands to execute
- **`exclude`** (optional): Array of regex patterns to filter out unwanted lines from output
#### Devices Section #### Devices Section
- **`<hostname>`**: Device hostname (used for SSH config lookup and output filename) - **`<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 ```bash
# Load multiple files - later files override earlier ones # Load multiple files - later files override earlier ones
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml --yaml overrides.yaml 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 ## Command Line Usage
### Required Flags ### 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 ### Optional Flags
- **`--output-dir`**: Output directory (default: `/tmp`) - **`--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 - **`--password`**: SSH password
- **`--key-file`**: SSH private key file path - **`--key-file`**: SSH private key file path
- **`--port`**: SSH port (default: `22`) - **`--port`**: SSH port (default: `22`)
- **`--parallel`**: Maximum number of devices to process in parallel (default: `10`)
### Examples ### Examples
```bash ```bash
# Basic usage # Basic usage with glob patterns
ipng-router-backup --yaml config.yaml ipng-router-backup --yaml "*.yaml"
# Multiple files # Multiple files
ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml ipng-router-backup --yaml 00-device-types.yaml --yaml config.yaml
# Specific devices only # Devices matching patterns
ipng-router-backup --yaml config.yaml --host asw100 --host core-01 ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
# Custom output directory # Custom output directory
ipng-router-backup --yaml config.yaml --output-dir /backup/network ipng-router-backup --yaml config.yaml --output-dir /backup/network
# With password authentication # With password authentication
ipng-router-backup --yaml config.yaml --password mypassword ipng-router-backup --yaml config.yaml --password mypassword
# Process more devices in parallel
ipng-router-backup --yaml config.yaml --parallel 20
``` ```
## SSH Authentication ## SSH Authentication

View File

@@ -18,7 +18,7 @@ The tool supports multiple device types with predefined command sets, SSH agent
.SH OPTIONS .SH OPTIONS
.TP .TP
.BR --yaml " \fICONFIG_FILE\fR" .BR --yaml " \fICONFIG_FILE\fR"
YAML configuration file(s) (required) YAML configuration file(s) or glob patterns (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)
@@ -33,7 +33,10 @@ SSH private key file path
SSH port number (default: 22) SSH port number (default: 22)
.TP .TP
.BR --host " \fIHOSTNAME\fR" .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 .TP
.BR --help .BR --help
Show help message Show help message
@@ -48,6 +51,11 @@ types:
commands: commands:
- show version - show version
- show platform linecard - show platform linecard
routeros:
commands:
- / export terse
exclude:
- "^# ....-..-.. ..:..:.. by RouterOS"
.EE .EE
.SS devices .SS devices
Define individual devices: Define individual devices:
@@ -70,11 +78,13 @@ Default SSH keys (~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/id_ecdsa)
Password authentication (--password option) Password authentication (--password option)
.SH OUTPUT .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. 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 .SH EXAMPLES
.TP .TP
Basic usage: Basic usage with glob patterns:
.EX .EX
ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml ipng-router-backup --yaml "*.yaml"
.EE .EE
.TP .TP
Custom output directory: Custom output directory:
@@ -87,9 +97,14 @@ Using password authentication:
ipng-router-backup --yaml config.yaml --password mysecretpass ipng-router-backup --yaml config.yaml --password mysecretpass
.EE .EE
.TP .TP
Process specific hosts only: Process hosts matching patterns:
.EX .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 .EE
.SH FILES .SH FILES
.TP .TP

View File

@@ -28,6 +28,7 @@ types:
- show version | exc uptime # Version info without uptime line - show version | exc uptime # Version info without uptime line
- show boot images # Boot image information - show boot images # Boot image information
- show transceiver # SFP/transceiver status - show transceiver # SFP/transceiver status
- show interface description # Interface status
- show running-config # Running configuration - show running-config # Running configuration
# Cisco IOS/IOS-XE devices # Cisco IOS/IOS-XE devices
@@ -46,3 +47,15 @@ types:
- show chassis hardware # Chassis hardware details - show chassis hardware # Chassis hardware details
- show configuration | display set # Configuration in set format - show configuration | display set # Configuration in set format
- show interfaces terse # Interface status summary - 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"

View File

@@ -4,7 +4,7 @@ package main
import ( import (
"fmt" "fmt"
"io/ioutil" "os"
"dario.cat/mergo" "dario.cat/mergo"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -18,6 +18,7 @@ type Config struct {
type DeviceType struct { type DeviceType struct {
Commands []string `yaml:"commands"` Commands []string `yaml:"commands"`
Exclude []string `yaml:"exclude,omitempty"`
} }
type Device struct { type Device struct {
@@ -28,7 +29,7 @@ type Device struct {
} }
func readYAMLFile(path string) (map[string]interface{}, error) { func readYAMLFile(path string) (map[string]interface{}, error) {
data, err := ioutil.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -6,19 +6,35 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"sync"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const Version = "1.2.0" 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() { func main() {
var yamlFiles []string var yamlFiles []string
@@ -27,6 +43,7 @@ func main() {
var port int var port int
var outputDir string var outputDir string
var hostFilter []string var hostFilter []string
var parallel int
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "ipng-router-backup", Use: "ipng-router-backup",
@@ -36,22 +53,47 @@ func main() {
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("IPng Networks Router Backup v%s\n", Version) 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 // Load configuration
cfg, err := ConfigRead(yamlFiles) cfg, err := ConfigRead(expandedYamlFiles)
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
// Check authentication setup // Check authentication setup
if password == "" && keyFile == "" { hasAuth := 0
if os.Getenv("SSH_AUTH_SOCK") != "" { if os.Getenv("SSH_AUTH_SOCK") != "" {
fmt.Println("Using SSH agent for authentication") fmt.Println("Using SSH agent for authentication")
} else { hasAuth++
keyFile = findDefaultSSHKey() }
if keyFile == "" { if keyFile == "" {
log.Fatal("No SSH key found and no password provided") 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 // Process devices
@@ -63,63 +105,102 @@ func main() {
devicesToProcess := cfg.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 _, pattern := range hostFilter {
if deviceConfig, exists := cfg.Devices[hostname]; exists { patternMatched := false
devicesToProcess[hostname] = deviceConfig for hostname, deviceConfig := range cfg.Devices {
} else { if matched, _ := filepath.Match(pattern, hostname); matched {
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname) 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) totalCount := len(devicesToProcess)
for hostname, deviceConfig := range devicesToProcess { // Create channels for work distribution and result collection
fmt.Printf("\nProcessing device: %s (type: %s)\n", hostname, deviceConfig.Type) 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 user := deviceConfig.User
commands := deviceConfig.Commands commands := deviceConfig.Commands
deviceType := deviceConfig.Type 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 deviceType != "" {
if typeConfig, exists := cfg.Types[deviceType]; exists { if typeConfig, exists := cfg.Types[deviceType]; exists {
commands = typeConfig.Commands commands = typeConfig.Commands
excludePatterns = typeConfig.Exclude
} }
} }
if user == "" { if user == "" {
fmt.Printf("No user specified for %s, skipping\n", hostname) fmt.Printf("%s: No user specified, skipping\n", hostname)
continue continue
} }
if len(commands) == 0 { if len(commands) == 0 {
fmt.Printf("No commands specified for %s, skipping\n", hostname) fmt.Printf("%s: No commands specified, skipping\n", hostname)
continue continue
} }
// Create backup instance workChan <- DeviceWork{
backup := NewRouterBackup(hostname, deviceConfig.Address, user, password, keyFile, port) hostname: hostname,
deviceConfig: deviceConfig,
// Connect and backup commands: commands,
if err := backup.Connect(); err != nil { excludePatterns: excludePatterns,
fmt.Printf("Failed to connect to %s: %v\n", hostname, err)
continue
} }
}
close(workChan)
err = backup.BackupCommands(commands, outputDir) // Wait for all workers to finish
backup.Disconnect() go func() {
wg.Wait()
close(resultChan)
}()
if err != nil { // Collect results
fmt.Printf("Backup failed for %s: %v\n", hostname, err) successCount := 0
} else { for result := range resultChan {
fmt.Printf("Backup completed for %s\n", hostname) if result.success {
successCount++ 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 // Set exit code based on results
if successCount == 0 { if successCount == 0 {
@@ -137,8 +218,11 @@ func main() {
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.Flags().IntVar(&parallel, "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 { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)

View File

@@ -4,10 +4,10 @@ package main
import ( import (
"fmt" "fmt"
"io/ioutil"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -103,11 +103,6 @@ func (rb *RouterBackup) Connect() error {
config.KeyExchanges = finalAlgorithms 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 != "" { if macs := ssh_config.Get(rb.hostname, "MACs"); macs != "" {
macList := strings.Split(macs, ",") macList := strings.Split(macs, ",")
for i, mac := range macList { for i, mac := range macList {
@@ -126,15 +121,19 @@ func (rb *RouterBackup) Connect() error {
config.HostKeyAlgorithms = finalAlgorithms 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 sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
if conn, err := net.Dial("unix", sshAuthSock); err == nil { if conn, err := net.Dial("unix", sshAuthSock); err == nil {
agentClient := agent.NewClient(conn) 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 != "" { if keyFile != "" {
// Expand ~ in keyFile path // Expand ~ in keyFile path
if strings.HasPrefix(keyFile, "~/") { 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 { if err == nil {
signer, err := ssh.ParsePrivateKey(key) signer, err := ssh.ParsePrivateKey(key)
if err != nil { 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 { } 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 // Fall back to password if available
if rb.password != "" { if rb.password != "" {
config.Auth = append(config.Auth, ssh.Password(rb.password)) config.Auth = append(config.Auth, ssh.Password(rb.password))
@@ -178,7 +187,7 @@ func (rb *RouterBackup) Connect() error {
} }
rb.client = client rb.client = client
fmt.Printf("Successfully connected to %s\n", targetHost) fmt.Printf("%s: Successfully connected to %s\n", rb.hostname, targetHost)
return nil return nil
} }
@@ -202,8 +211,33 @@ func (rb *RouterBackup) RunCommand(command string) (string, error) {
return string(output), nil 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 // 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 { if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outputDir, err) return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
} }
@@ -223,11 +257,11 @@ func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) erro
hasErrors := false hasErrors := false
for i, command := range commands { 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) output, err := rb.RunCommand(command)
if err != nil { 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 hasErrors = true
continue continue
} }
@@ -235,24 +269,28 @@ func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) erro
// Append to temporary file // Append to temporary file
file, err := os.OpenFile(tempPath, os.O_APPEND|os.O_WRONLY, 0644) file, err := os.OpenFile(tempPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil { 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 hasErrors = true
continue continue
} }
fmt.Fprintf(file, "## COMMAND: %s\n", command) 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() file.Close()
successCount++ successCount++
} }
fmt.Printf("Summary: %d/%d commands successful\n", successCount, len(commands)) fmt.Printf("%s: Summary: %d/%d commands successful\n", rb.hostname, successCount, len(commands))
if hasErrors || successCount == 0 { if hasErrors || successCount == 0 {
// Remove .new suffix and log error // Remove .new suffix and log error
if err := os.Remove(tempPath); err != nil { if err := os.Remove(tempPath); err != nil {
fmt.Printf("Failed to remove temporary file %s: %v\n", tempPath, err) 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") return fmt.Errorf("device backup incomplete due to command failures")
} }
@@ -262,7 +300,7 @@ func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) erro
return fmt.Errorf("failed to move temporary file to final location: %v", err) return fmt.Errorf("failed to move temporary file to final location: %v", err)
} }
fmt.Printf("Output saved to %s\n", finalPath) fmt.Printf("%s: Output saved to %s\n", rb.hostname, finalPath)
return nil return nil
} }
@@ -270,7 +308,7 @@ func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) erro
func (rb *RouterBackup) Disconnect() { func (rb *RouterBackup) Disconnect() {
if rb.client != nil { if rb.client != nil {
rb.client.Close() rb.client.Close()
fmt.Printf("Disconnected from %s\n", rb.hostname) fmt.Printf("%s: Disconnected\n", rb.hostname)
} }
} }
@@ -289,7 +327,7 @@ func findDefaultSSHKey() string {
for _, keyPath := range defaultKeys { for _, keyPath := range defaultKeys {
if _, err := os.Stat(keyPath); err == nil { 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 return keyPath
} }
} }

View File

@@ -57,17 +57,22 @@ func TestBackupCommandsDirectoryCreation(t *testing.T) {
// This should create the directory even without a connection // This should create the directory even without a connection
// and fail gracefully when trying to run commands // 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 // Should not error on directory creation
if _, statErr := os.Stat(outputDir); os.IsNotExist(statErr) { if _, statErr := os.Stat(outputDir); os.IsNotExist(statErr) {
t.Error("Expected output directory to be created") 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") expectedFile := filepath.Join(outputDir, "testhost")
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) { if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
t.Error("Expected output file to be created") 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) rb := NewRouterBackup("testhost", "", "testuser", "testpass", "", 22)
err := rb.BackupCommands([]string{}, tempDir) err := rb.BackupCommands([]string{}, []string{}, tempDir)
if err != nil { if err == nil {
t.Errorf("Expected no error for empty commands list, got %v", err) 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") expectedFile := filepath.Join(tempDir, "testhost")
if _, statErr := os.Stat(expectedFile); os.IsNotExist(statErr) { if _, statErr := os.Stat(expectedFile); !os.IsNotExist(statErr) {
t.Error("Expected output file to be created even for empty commands") 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) // Create some fake commands (they will fail but we can test file operations)
commands := []string{"show version", "show interfaces"} commands := []string{"show version", "show interfaces"}
err := rb.BackupCommands(commands, tempDir) err := rb.BackupCommands(commands, []string{}, tempDir)
if err != nil { if err == nil {
t.Errorf("Unexpected error: %v", err) 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") outputFile := filepath.Join(tempDir, "testhost")
_, err = os.ReadFile(outputFile) _, err = os.ReadFile(outputFile)
if err != nil { if err == nil {
t.Fatalf("Failed to read output file: %v", err) 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 verifies that atomic file behavior works correctly
// This test just verifies the file creation works
} }
func TestRouterBackupConnectionState(t *testing.T) { func TestRouterBackupConnectionState(t *testing.T) {
@@ -281,3 +285,38 @@ func TestIPv6AddressFormatting(t *testing.T) {
t.Error("Expected IPv4 address to use tcp4 network type") 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)
}
}