Compare commits

...

7 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
6 changed files with 155 additions and 48 deletions

23
debian/changelog vendored
View File

@@ -1,3 +1,26 @@
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 ipng-router-backup (1.2.4) stable; urgency=low
* Add regex exclude patterns to filter unwanted output lines per device type * Add regex exclude patterns to filter unwanted output lines per device type

View File

@@ -142,6 +142,7 @@ types:
- **`--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
@@ -160,6 +161,9 @@ 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

@@ -35,6 +35,9 @@ SSH port number (default: 22)
.BR --host " \fIHOSTNAME\fR" .BR --host " \fIHOSTNAME\fR"
Specific host(s) or glob patterns 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 .TP
.BR --parallel " \fINUMBER\fR"
Maximum number of devices to process in parallel (default: 10)
.TP
.BR --help .BR --help
Show help message Show help message
.SH CONFIGURATION .SH CONFIGURATION
@@ -98,6 +101,11 @@ Process hosts matching patterns:
.EX .EX
ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*" ipng-router-backup --yaml config.yaml --host "asw*" --host "*switch*"
.EE .EE
.TP
Process devices in parallel:
.EX
ipng-router-backup --yaml config.yaml --parallel 20
.EE
.SH FILES .SH FILES
.TP .TP
.I /etc/ipng-router-backup/config.yaml.example .I /etc/ipng-router-backup/config.yaml.example

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"
@@ -29,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

@@ -7,19 +7,34 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const Version = "1.2.4" 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
@@ -28,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",
@@ -57,17 +73,27 @@ func main() {
} }
// 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()
} else { if keyFile != "" {
fmt.Printf("Using SSH key: %s\n", 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
@@ -93,12 +119,41 @@ func main() {
} }
} }
successCount := 0
totalCount := len(devicesToProcess) totalCount := len(devicesToProcess)
for hostname, deviceConfig := range devicesToProcess { // Create channels for work distribution and result collection
fmt.Printf("\n%s: Processing device (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
@@ -122,27 +177,30 @@ func main() {
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("%s: Failed to connect: %v\n", hostname, err)
continue
} }
}
close(workChan)
err = backup.BackupCommands(commands, excludePatterns, outputDir) // Wait for all workers to finish
backup.Disconnect() go func() {
wg.Wait()
close(resultChan)
}()
if err != nil { // Collect results
fmt.Printf("%s: Backup failed: %v\n", hostname, err) successCount := 0
} else { for result := range resultChan {
fmt.Printf("%s: Backup completed\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 {
@@ -160,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,7 +4,6 @@ package main
import ( import (
"fmt" "fmt"
"io/ioutil"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@@ -104,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 {
@@ -127,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, "~/") {
@@ -145,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))
@@ -268,7 +276,10 @@ func (rb *RouterBackup) BackupCommands(commands []string, excludePatterns []stri
fmt.Fprintf(file, "## COMMAND: %s\n", command) fmt.Fprintf(file, "## COMMAND: %s\n", command)
filteredOutput := filterOutput(output, excludePatterns) filteredOutput := filterOutput(output, excludePatterns)
file.WriteString(filteredOutput) 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++