Compare commits
3 Commits
88e30a40b1
...
4260067ea8
Author | SHA1 | Date | |
---|---|---|---|
|
4260067ea8 | ||
|
90f5ec4e26 | ||
|
c8df809c29 |
8
debian/changelog
vendored
8
debian/changelog
vendored
@@ -1,3 +1,11 @@
|
|||||||
|
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
|
ipng-router-backup (1.2.3) stable; urgency=low
|
||||||
|
|
||||||
* For routeros, set mikrotik export to terse
|
* For routeros, set mikrotik export to terse
|
||||||
|
@@ -56,3 +56,6 @@ types:
|
|||||||
- system license print # License information
|
- system license print # License information
|
||||||
- / interface print # Interfaces
|
- / interface print # Interfaces
|
||||||
- / export terse # Configuration
|
- / export terse # Configuration
|
||||||
|
exclude:
|
||||||
|
- "^# ....-..-.. ..:..:.. by RouterOS"
|
||||||
|
- "^# .../../.... ..:..:.. by RouterOS"
|
||||||
|
@@ -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 {
|
||||||
|
22
src/main.go
22
src/main.go
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "1.2.3"
|
const Version = "1.2.4"
|
||||||
|
|
||||||
// Config and SSH types are now in separate packages
|
// Config and SSH types are now in separate packages
|
||||||
|
|
||||||
@@ -64,6 +64,8 @@ func main() {
|
|||||||
keyFile = findDefaultSSHKey()
|
keyFile = findDefaultSSHKey()
|
||||||
if keyFile == "" {
|
if keyFile == "" {
|
||||||
log.Fatal("No SSH key found and no password provided")
|
log.Fatal("No SSH key found and no password provided")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Using SSH key: %s\n", keyFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,26 +97,28 @@ func main() {
|
|||||||
totalCount := len(devicesToProcess)
|
totalCount := len(devicesToProcess)
|
||||||
|
|
||||||
for hostname, deviceConfig := range devicesToProcess {
|
for hostname, deviceConfig := range devicesToProcess {
|
||||||
fmt.Printf("\nProcessing device: %s (type: %s)\n", hostname, deviceConfig.Type)
|
fmt.Printf("\n%s: Processing device (type: %s)\n", hostname, deviceConfig.Type)
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +127,17 @@ func main() {
|
|||||||
|
|
||||||
// Connect and backup
|
// Connect and backup
|
||||||
if err := backup.Connect(); err != nil {
|
if err := backup.Connect(); err != nil {
|
||||||
fmt.Printf("Failed to connect to %s: %v\n", hostname, err)
|
fmt.Printf("%s: Failed to connect: %v\n", hostname, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = backup.BackupCommands(commands, outputDir)
|
err = backup.BackupCommands(commands, excludePatterns, outputDir)
|
||||||
backup.Disconnect()
|
backup.Disconnect()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Backup failed for %s: %v\n", hostname, err)
|
fmt.Printf("%s: Backup failed: %v\n", hostname, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Backup completed for %s\n", hostname)
|
fmt.Printf("%s: Backup completed\n", hostname)
|
||||||
successCount++
|
successCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
49
src/ssh.go
49
src/ssh.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -178,7 +179,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 +203,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 +249,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 +261,25 @@ 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)
|
||||||
|
file.WriteString(filteredOutput)
|
||||||
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 +289,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 +297,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 +316,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user