Move to yaml.v3 and mergo. Refactor config parsing into a package. Refactor SSH connections into a package. Create default YAML directory, and update docs

This commit is contained in:
Pim van Pelt
2025-07-06 17:11:22 +02:00
parent 75646856aa
commit 769d9eb6cd
11 changed files with 441 additions and 490 deletions

View File

@ -26,8 +26,6 @@ make build
**Main config** (`config.yaml`): **Main config** (`config.yaml`):
```yaml ```yaml
!include device-types.yaml
devices: devices:
asw100: asw100:
user: netops user: netops
@ -37,7 +35,7 @@ make build
type: srlinux type: srlinux
``` ```
**Device types** (`device-types.yaml`): **Device types** (`00-device-types.yaml`):
```yaml ```yaml
types: types:
srlinux: srlinux:
@ -51,10 +49,10 @@ make build
```bash ```bash
# Backup all devices # Backup all devices
ipng-router-backup --config config.yaml --output-dir /backup ipng-router-backup --yaml *.yaml --output-dir /backup
# Backup specific devices # Backup specific devices
ipng-router-backup --config config.yaml --host asw100 --output-dir /backup ipng-router-backup --yaml *.yaml --host asw100 --output-dir /backup
``` ```
3. **Check output**: 3. **Check output**:

View File

@ -8,7 +8,7 @@ IPng Networks Router Backup is a SSH-based network device configuration backup t
- **Multi-device support**: Backup multiple routers in a single run - **Multi-device support**: Backup multiple routers in a single run
- **Device type templates**: Define command sets per device type - **Device type templates**: Define command sets per device type
- **Configuration includes**: Split large configurations with `!include` directives - **Configuration includes**: Split large configurations into many files and merge them at runtime
- **Flexible authentication**: SSH agent, key files, or password authentication - **Flexible authentication**: SSH agent, key files, or password authentication
- **Selective execution**: Target specific devices with `--host` flags - **Selective execution**: Target specific devices with `--host` flags
- **Automatic file organization**: Output files named by hostname - **Automatic file organization**: Output files named by hostname
@ -17,14 +17,13 @@ IPng Networks Router Backup is a SSH-based network device configuration backup t
## Configuration File Format ## Configuration File Format
The tool uses a YAML configuration file with two main sections: `types` and `devices`. The configuration supports `!include` directives for organizing large configurations across multiple files. The tool uses a YAML configuration file with two main sections: `types` and `devices`. The
configuration reading multiple files with the `--yaml` flag, merging their contents along the way.
### Complete Example ### Complete Example
**Main configuration** (`config.yaml`): **Main configuration** (`config.yaml`):
```yaml ```yaml
!include device-types.yaml
devices: devices:
asw100: asw100:
user: admin user: admin
@ -45,7 +44,7 @@ devices:
- show ip route summary - show ip route summary
``` ```
**Device types file** (`device-types.yaml`): **Device types file** (`00-device-types.yaml`):
```yaml ```yaml
types: types:
srlinux: srlinux:
@ -155,7 +154,7 @@ devices:
### Required Flags ### Required Flags
- **`--config`**: Path to YAML configuration file - **`--yaml`**: Path to YAML configuration file(s)
### Optional Flags ### Optional Flags
@ -171,25 +170,25 @@ devices:
```bash ```bash
# Basic usage - all devices # Basic usage - all devices
ipng-router-backup --config /etc/ipng-router-backup/config.yaml ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml
# Custom output directory # Custom output directory
ipng-router-backup --config config.yaml --output-dir /backup/network ipng-router-backup --yaml *.yaml --output-dir /backup/network
# Specific devices only # Specific devices only
ipng-router-backup --config config.yaml --host asw100 --host core-01 ipng-router-backup --yaml *.yaml --host asw100 --host core-01
# Multiple specific devices # Multiple specific devices
ipng-router-backup --config config.yaml --host asw100 --host asw120 --host core-01 ipng-router-backup --yaml *.yaml --host asw100 --host asw120 --host core-01
# Custom SSH port # Custom SSH port
ipng-router-backup --config config.yaml --port 2222 ipng-router-backup --yaml *.yaml --port 2222
# Using password authentication # Using password authentication
ipng-router-backup --config config.yaml --password mypassword ipng-router-backup --yaml *.yaml --password mypassword
# Using specific SSH key # Using specific SSH key
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key ipng-router-backup --yaml *.yaml --key-file ~/.ssh/network_key
``` ```
## SSH Authentication Methods ## SSH Authentication Methods
@ -206,7 +205,7 @@ eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_rsa ssh-add ~/.ssh/id_rsa
# Run backup (will use SSH agent automatically) # Run backup (will use SSH agent automatically)
ipng-router-backup --config config.yaml ipng-router-backup --yaml *.yaml
``` ```
**Advantages:** **Advantages:**
@ -221,7 +220,7 @@ Specify a private key file with `--key-file` or use default locations.
```bash ```bash
# Explicit key file # Explicit key file
ipng-router-backup --config config.yaml --key-file ~/.ssh/network_key ipng-router-backup --yaml *.yaml --key-file ~/.ssh/network_key
# Tool automatically checks these default locations: # Tool automatically checks these default locations:
# ~/.ssh/id_rsa # ~/.ssh/id_rsa
@ -240,10 +239,10 @@ Use `--password` flag for password-based authentication.
```bash ```bash
# Command line password (not recommended for scripts) # Command line password (not recommended for scripts)
ipng-router-backup --config config.yaml --password mypassword ipng-router-backup --yaml *.yaml --password mypassword
# Interactive password prompt (when no other auth available) # Interactive password prompt (when no other auth available)
ipng-router-backup --config config.yaml ipng-router-backup --yaml *.yaml
# Output: "No SSH key found. Enter SSH password: " # Output: "No SSH key found. Enter SSH password: "
``` ```
@ -290,7 +289,7 @@ Software Version : v25.3.2
### Basic Backup All Devices ### Basic Backup All Devices
```bash ```bash
ipng-router-backup --config /etc/backup/network.yaml --output-dir /backup/$(date +%Y%m%d) ipng-router-backup --yaml /etc/backup/*.yaml --output-dir /backup/$(date +%Y%m%d)
``` ```
### Backup Specific Device Types ### Backup Specific Device Types
@ -299,7 +298,7 @@ Create a config with only the devices you want, or use `--host`:
```bash ```bash
# Backup only SR Linux devices # Backup only SR Linux devices
ipng-router-backup --config network.yaml --host asw100 --host asw120 --host asw121 ipng-router-backup --yaml network.yaml --host asw100 --host asw120 --host asw121
``` ```
### Scheduled Backup with SSH Agent ### Scheduled Backup with SSH Agent
@ -317,7 +316,7 @@ BACKUP_DIR="/backup/network/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
ipng-router-backup \ ipng-router-backup \
--config /etc/ipng-router-backup/config.yaml \ --yaml /etc/ipng-router-backup/*.yaml \
--output-dir "$BACKUP_DIR" --output-dir "$BACKUP_DIR"
# Kill SSH agent # Kill SSH agent
@ -329,7 +328,7 @@ ssh-agent -k
```bash ```bash
# Quick backup of single device with password # Quick backup of single device with password
ipng-router-backup \ ipng-router-backup \
--config emergency.yaml \ --yaml emergency.yaml \
--host core-router-01 \ --host core-router-01 \
--password emergency123 \ --password emergency123 \
--output-dir /tmp/emergency-backup --output-dir /tmp/emergency-backup
@ -420,7 +419,7 @@ BACKUP_DIR="/backup/network-configs"
cd "$BACKUP_DIR" cd "$BACKUP_DIR"
# Run backup # Run backup
ipng-router-backup --config config.yaml --output-dir . ipng-router-backup --yaml config.yaml --output-dir .
# Commit changes # Commit changes
git add . git add .
@ -459,11 +458,11 @@ devices:
#!/bin/bash #!/bin/bash
# Backup with monitoring # Backup with monitoring
if ipng-router-backup --config config.yaml --output-dir /backup; then if ipng-router-backup --yaml config.yaml --output-dir /backup; then
echo "Backup completed successfully" | logger echo "Backup completed successfully" | logger
else else
echo "Backup failed!" | logger echo "Backup failed!" | logger
# Send alert email # Send alert email
echo "Network backup failed at $(date)" | mail -s "Backup Alert" admin@company.com echo "Network backup failed at $(date)" | mail -s "Backup Alert" admin@company.com
fi fi
``` ```

View File

@ -1,98 +0,0 @@
# IPng Networks Router Backup Configuration Example
# Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
#
# This file demonstrates how to configure the ipng-router-backup tool.
# Copy this file to a location of your choice and modify for your environment.
#
# Usage: ipng-router-backup --config /path/to/your/config.yaml
#
# YAML !include Support:
# You can split large configurations into multiple files using !include directives.
# Examples:
# !include device-types.yaml
# !include devices/production.yaml
# !include "devices/lab environment.yaml" # Use quotes for paths with spaces
# Include device types from separate file
!include device-types.yaml
# Devices Section
# Define individual network devices to backup
devices:
# Core switches (SR Linux)
asw100:
user: admin # SSH username
type: srlinux # Reference to type above
asw120:
user: netops # Different user per device if needed
type: srlinux
asw121:
user: admin
type: srlinux
# Distribution switches (Centec)
csw150:
user: admin
type: centec
csw151:
user: admin
type: centec
# Edge routers (Arista EOS)
edge-01:
user: automation
type: eos
edge-02:
user: automation
type: eos
# Special case: Device with custom commands (overrides type)
legacy-router:
user: admin
commands:
- show version
- show running-config
- show ip route summary
# Custom commands specific to this device only
# Example using IP address instead of hostname
192.168.1.100:
user: operator
type: cisco-ios
# Configuration Tips:
#
# 1. Authentication Priority (automatic):
# - SSH Agent (if SSH_AUTH_SOCK environment variable is set)
# - SSH Key file (--key-file flag or default locations)
# - Password (--password flag or interactive prompt)
#
# 2. Running the backup:
# # Backup all devices
# ipng-router-backup --config /etc/ipng-router-backup/config.yaml
#
# # Backup specific devices only
# ipng-router-backup --config config.yaml --host asw100 --host edge-01
#
# # Custom output directory
# ipng-router-backup --config config.yaml --output-dir /backup/$(date +%Y%m%d)
#
# 3. Output files:
# - Named after device hostname (e.g., 'asw100', 'edge-01')
# - Each command output prefixed with "## COMMAND: <command>"
# - Files are recreated on each run (not appended)
#
# 4. Security considerations:
# - Use SSH keys instead of passwords when possible
# - Consider using SSH agent for additional security
# - Restrict SSH access to backup user accounts
# - Store configuration files with appropriate permissions (640 recommended)
#
# 5. Error handling:
# - If a device is unreachable, the tool continues with other devices
# - Check tool output for connection or authentication failures
# - Use --host flag to test individual devices

View File

@ -3,7 +3,7 @@
ipng-router-backup \- SSH Router Backup Tool ipng-router-backup \- SSH Router Backup Tool
.SH SYNOPSIS .SH SYNOPSIS
.B ipng-router-backup .B ipng-router-backup
.RI --config " CONFIG_FILE" .RI --yaml " CONFIG_FILE(S)"
.RI [ --output-dir " DIRECTORY" ] .RI [ --output-dir " DIRECTORY" ]
.RI [ --password " PASSWORD" ] .RI [ --password " PASSWORD" ]
.RI [ --key-file " KEYFILE" ] .RI [ --key-file " KEYFILE" ]
@ -11,13 +11,14 @@ ipng-router-backup \- SSH Router Backup Tool
.RI [ --host " HOSTNAME" ]... .RI [ --host " HOSTNAME" ]...
.SH DESCRIPTION .SH DESCRIPTION
.B router_backup .B router_backup
is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a YAML configuration file and executes commands, saving the output to files. is a tool for backing up router configurations via SSH. It connects to multiple routers defined in a
set of YAML configuration file(s) and executes commands, saving the output to files.
.PP .PP
The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization. The tool supports multiple device types with predefined command sets, SSH agent authentication, and automatic file organization.
.SH OPTIONS .SH OPTIONS
.TP .TP
.BR --config " \fICONFIG_FILE\fR" .BR --yaml " \fICONFIG_FILE\fR"
YAML configuration file path (required) YAML configuration file(s) (required)
.TP .TP
.BR --output-dir " \fIDIRECTORY\fR" .BR --output-dir " \fIDIRECTORY\fR"
Output directory for command output files (default: /tmp) Output directory for command output files (default: /tmp)
@ -73,22 +74,22 @@ For each device, a text file named after the hostname is created in the specifie
.TP .TP
Basic usage: Basic usage:
.EX .EX
ipng-router-backup --config /etc/ipng-router-backup/config.yaml ipng-router-backup --yaml /etc/ipng-router-backup/*.yaml
.EE .EE
.TP .TP
Custom output directory: Custom output directory:
.EX .EX
ipng-router-backup --config config.yaml --output-dir /home/user/backups ipng-router-backup --yaml config.yaml --output-dir /home/user/backups
.EE .EE
.TP .TP
Using password authentication: Using password authentication:
.EX .EX
ipng-router-backup --config config.yaml --password mysecretpass ipng-router-backup --yaml config.yaml --password mysecretpass
.EE .EE
.TP .TP
Process specific hosts only: Process specific hosts only:
.EX .EX
ipng-router-backup --config config.yaml --host asw100 --host asw120 ipng-router-backup --yaml config.yaml --host asw100 --host asw120
.EE .EE
.SH FILES .SH FILES
.TP .TP

73
src/config/config.go Normal file
View File

@ -0,0 +1,73 @@
package config
import (
"fmt"
"io/ioutil"
"dario.cat/mergo"
"gopkg.in/yaml.v3"
)
// Config structures
type Config struct {
Types map[string]DeviceType `yaml:"types"`
Devices map[string]Device `yaml:"devices"`
}
type DeviceType struct {
Commands []string `yaml:"commands"`
}
type Device struct {
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
}
func readYAMLFile(path string) (map[string]interface{}, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := yaml.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// ConfigRead loads and merges multiple YAML files into a single config object
func ConfigRead(yamlFiles []string) (*Config, error) {
var finalConfig map[string]interface{}
for _, file := range yamlFiles {
current, err := readYAMLFile(file)
if err != nil {
return nil, fmt.Errorf("failed to parse %s: %v", file, err)
}
if finalConfig == nil {
finalConfig = current
} else {
err := mergo.Merge(&finalConfig, current, mergo.WithOverride)
if err != nil {
return nil, fmt.Errorf("failed to merge %s: %v", file, err)
}
}
}
// Convert back to structured config
out, err := yaml.Marshal(finalConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal merged config: %v", err)
}
var config Config
if err := yaml.Unmarshal(out, &config); err != nil {
return nil, fmt.Errorf("failed to unmarshal to Config struct: %v", err)
}
return &config, nil
}

View File

@ -3,14 +3,15 @@ module router_backup
go 1.21 go 1.21
require ( require (
dario.cat/mergo v1.0.2
github.com/kevinburke/ssh_config v1.2.0
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
golang.org/x/crypto v0.18.0 golang.org/x/crypto v0.18.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.16.0 // indirect
) )

View File

@ -1,3 +1,5 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@ -16,6 +18,5 @@ golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -4,362 +4,25 @@ package main
import ( import (
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net"
"os" "os"
"path/filepath" "router_backup/config"
"regexp"
"strconv"
"strings"
"time"
"github.com/kevinburke/ssh_config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"gopkg.in/yaml.v2"
) )
const Version = "1.0.2" const Version = "1.0.2"
// Config structures // Config and SSH types are now in separate packages
type Config struct {
Types map[string]DeviceType `yaml:"types"`
Devices map[string]Device `yaml:"devices"`
}
type DeviceType struct { // SSH connection methods are now in ssh.go
Commands []string `yaml:"commands"`
}
type Device struct { // YAML processing is now handled by the config package
User string `yaml:"user"`
Type string `yaml:"type,omitempty"`
Commands []string `yaml:"commands,omitempty"`
}
// RouterBackup handles SSH connections and command execution // SSH helper functions are now in ssh.go
type RouterBackup struct {
hostname string
username string
password string
keyFile string
port int
client *ssh.Client
}
// NewRouterBackup creates a new RouterBackup instance
func NewRouterBackup(hostname, username, password, keyFile string, port int) *RouterBackup {
return &RouterBackup{
hostname: hostname,
username: username,
password: password,
keyFile: keyFile,
port: port,
}
}
// Connect establishes SSH connection to the router
func (rb *RouterBackup) Connect() error {
// Get SSH config values for this host
hostname := ssh_config.Get(rb.hostname, "Hostname")
if hostname == "" {
hostname = rb.hostname
}
portStr := ssh_config.Get(rb.hostname, "Port")
port := rb.port
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
username := ssh_config.Get(rb.hostname, "User")
if rb.username != "" {
username = rb.username
}
keyFile := ssh_config.Get(rb.hostname, "IdentityFile")
if rb.keyFile != "" {
keyFile = rb.keyFile
}
config := &ssh.ClientConfig{
User: username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 30 * time.Second,
}
// Apply SSH config crypto settings with compatibility filtering
if kexAlgorithms := ssh_config.Get(rb.hostname, "KexAlgorithms"); kexAlgorithms != "" && !strings.HasPrefix(kexAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(kexAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.KeyExchanges = finalAlgorithms
}
// Note: Cipher overrides disabled - Go SSH library defaults work
// if ciphers := ssh_config.Get(rb.hostname, "Ciphers"); ciphers != "" {
// config.Ciphers = ...
// }
if macs := ssh_config.Get(rb.hostname, "MACs"); macs != "" {
macList := strings.Split(macs, ",")
for i, mac := range macList {
macList[i] = strings.TrimSpace(mac)
}
config.MACs = macList
}
if hostKeyAlgorithms := ssh_config.Get(rb.hostname, "HostKeyAlgorithms"); hostKeyAlgorithms != "" && !strings.HasPrefix(hostKeyAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(hostKeyAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.HostKeyAlgorithms = finalAlgorithms
}
// Try SSH agent first if available
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
if conn, err := net.Dial("unix", sshAuthSock); err == nil {
agentClient := agent.NewClient(conn)
config.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}
}
}
// If SSH agent didn't work, try key file
if len(config.Auth) == 0 && keyFile != "" {
// Expand ~ in keyFile path
if strings.HasPrefix(keyFile, "~/") {
homeDir, err := os.UserHomeDir()
if err == nil {
keyFile = filepath.Join(homeDir, keyFile[2:])
}
}
key, err := ioutil.ReadFile(keyFile)
if err != nil {
return fmt.Errorf("unable to read private key: %v", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return fmt.Errorf("unable to parse private key: %v", err)
}
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Fall back to password if available
if len(config.Auth) == 0 && rb.password != "" {
config.Auth = []ssh.AuthMethod{ssh.Password(rb.password)}
}
if len(config.Auth) == 0 {
return fmt.Errorf("no authentication method available")
}
address := fmt.Sprintf("%s:%d", hostname, port)
client, err := ssh.Dial("tcp4", address, config)
if err != nil {
return fmt.Errorf("failed to connect to %s: %v", hostname, err)
}
rb.client = client
fmt.Printf("Successfully connected to %s\n", hostname)
return nil
}
// RunCommand executes a command on the router and returns the output
func (rb *RouterBackup) RunCommand(command string) (string, error) {
if rb.client == nil {
return "", fmt.Errorf("no active connection")
}
session, err := rb.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %v", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return "", fmt.Errorf("failed to execute command '%s': %v", command, err)
}
return string(output), nil
}
// BackupCommands runs multiple commands and saves outputs to files
func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
}
filename := rb.hostname
filepath := filepath.Join(outputDir, filename)
// Truncate file at start
file, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file %s: %v", filepath, err)
}
file.Close()
successCount := 0
for i, command := range commands {
fmt.Printf("Running command %d/%d: %s\n", i+1, len(commands), command)
output, err := rb.RunCommand(command)
if err != nil {
fmt.Printf("Error executing '%s': %v\n", command, err)
continue
}
// Append to file
file, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("Failed to open file for writing: %v\n", err)
continue
}
fmt.Fprintf(file, "## COMMAND: %s\n", command)
file.WriteString(output)
file.Close()
fmt.Printf("Output saved to %s\n", filepath)
successCount++
}
fmt.Printf("Summary: %d/%d commands successful\n", successCount, len(commands))
return nil
}
// Disconnect closes SSH connection
func (rb *RouterBackup) Disconnect() {
if rb.client != nil {
rb.client.Close()
fmt.Printf("Disconnected from %s\n", rb.hostname)
}
}
// loadConfig loads the YAML configuration file with !include support
func loadConfig(configPath string) (*Config, error) {
processedYAML, err := processIncludes(configPath)
if err != nil {
return nil, fmt.Errorf("failed to process includes: %v", err)
}
var config Config
err = yaml.Unmarshal([]byte(processedYAML), &config)
if err != nil {
return nil, fmt.Errorf("failed to parse YAML: %v", err)
}
return &config, nil
}
// processIncludes processes YAML files with !include directives (one level deep)
func processIncludes(filePath string) (string, error) {
// Read the file
data, err := ioutil.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %v", filePath, err)
}
content := string(data)
// Process !include directives
// Match patterns like: !include path/to/file.yaml (excluding commented lines)
includeRegex := regexp.MustCompile(`(?m)^(\s*)!include\s+(.+)$`)
baseDir := filepath.Dir(filePath)
// Process includes line by line to avoid conflicts
lines := strings.Split(content, "\n")
var resultLines []string
for _, line := range lines {
// Check if this line matches our include pattern
if match := includeRegex.FindStringSubmatch(line); match != nil {
leadingWhitespace := match[1]
includePath := strings.TrimSpace(match[2])
// Skip commented lines
if strings.Contains(strings.TrimSpace(line), "#") && strings.Index(strings.TrimSpace(line), "#") < strings.Index(strings.TrimSpace(line), "!include") {
resultLines = append(resultLines, line)
continue
}
// Remove quotes if present
includePath = strings.Trim(includePath, "\"'")
// Make path relative to current config file
if !filepath.IsAbs(includePath) {
includePath = filepath.Join(baseDir, includePath)
}
// Read the included file
includedData, err := ioutil.ReadFile(includePath)
if err != nil {
return "", fmt.Errorf("failed to read include file %s: %v", includePath, err)
}
// Use the captured leading whitespace as indentation prefix
indentPrefix := leadingWhitespace
// Indent each line of included content to match the !include line's indentation
includedLines := strings.Split(string(includedData), "\n")
for _, includeLine := range includedLines {
if strings.TrimSpace(includeLine) == "" {
resultLines = append(resultLines, "")
} else {
resultLines = append(resultLines, indentPrefix+includeLine)
}
}
} else {
// Regular line, just copy it
resultLines = append(resultLines, line)
}
}
content = strings.Join(resultLines, "\n")
return content, nil
}
// findDefaultSSHKey looks for default SSH keys
func findDefaultSSHKey() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
defaultKeys := []string{
filepath.Join(homeDir, ".ssh", "id_rsa"),
filepath.Join(homeDir, ".ssh", "id_ed25519"),
filepath.Join(homeDir, ".ssh", "id_ecdsa"),
}
for _, keyPath := range defaultKeys {
if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("Using SSH key: %s\n", keyPath)
return keyPath
}
}
return ""
}
func main() { func main() {
var configPath string var yamlFiles []string
var password string var password string
var keyFile string var keyFile string
var port int var port int
@ -375,7 +38,7 @@ func main() {
fmt.Printf("IPng Networks Router Backup v%s\n", Version) fmt.Printf("IPng Networks Router Backup v%s\n", Version)
// Load configuration // Load configuration
config, err := loadConfig(configPath) cfg, err := config.ConfigRead(yamlFiles)
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
@ -393,16 +56,16 @@ func main() {
} }
// Process devices // Process devices
if len(config.Devices) == 0 { if len(cfg.Devices) == 0 {
log.Fatal("No devices found in config file") log.Fatal("No devices found in config file")
} }
// Filter devices if --host flags are provided // Filter devices if --host flags are provided
devicesToProcess := config.Devices devicesToProcess := cfg.Devices
if len(hostFilter) > 0 { if len(hostFilter) > 0 {
devicesToProcess = make(map[string]Device) devicesToProcess = make(map[string]config.Device)
for _, hostname := range hostFilter { for _, hostname := range hostFilter {
if deviceConfig, exists := config.Devices[hostname]; exists { if deviceConfig, exists := cfg.Devices[hostname]; exists {
devicesToProcess[hostname] = deviceConfig devicesToProcess[hostname] = deviceConfig
} else { } else {
fmt.Printf("Warning: Host '%s' not found in config file\n", hostname) fmt.Printf("Warning: Host '%s' not found in config file\n", hostname)
@ -422,7 +85,7 @@ func main() {
// If device has a type, get commands from types section // If device has a type, get commands from types section
if deviceType != "" { if deviceType != "" {
if typeConfig, exists := config.Types[deviceType]; exists { if typeConfig, exists := cfg.Types[deviceType]; exists {
commands = typeConfig.Commands commands = typeConfig.Commands
} }
} }
@ -461,14 +124,14 @@ func main() {
}, },
} }
rootCmd.Flags().StringVar(&configPath, "config", "", "YAML configuration file path (required)") rootCmd.Flags().StringSliceVar(&yamlFiles, "yaml", []string{}, "YAML configuration file paths (required, can be repeated)")
rootCmd.Flags().StringVar(&password, "password", "", "SSH password") rootCmd.Flags().StringVar(&password, "password", "", "SSH password")
rootCmd.Flags().StringVar(&keyFile, "key-file", "", "SSH private key file path") rootCmd.Flags().StringVar(&keyFile, "key-file", "", "SSH private key file path")
rootCmd.Flags().IntVar(&port, "port", 22, "SSH port") rootCmd.Flags().IntVar(&port, "port", 22, "SSH port")
rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files") rootCmd.Flags().StringVar(&outputDir, "output-dir", "/tmp", "Output directory for command output files")
rootCmd.Flags().StringSliceVar(&hostFilter, "host", []string{}, "Specific host(s) to process (can be repeated, processes all if not specified)") rootCmd.Flags().StringSliceVar(&hostFilter, "host", []string{}, "Specific host(s) to process (can be repeated, processes all if not specified)")
rootCmd.MarkFlagRequired("config") rootCmd.MarkFlagRequired("yaml")
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
log.Fatal(err) log.Fatal(err)

250
src/ssh.go Normal file
View File

@ -0,0 +1,250 @@
package main
import (
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/kevinburke/ssh_config"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)
// RouterBackup handles SSH connections and command execution
type RouterBackup struct {
hostname string
username string
password string
keyFile string
port int
client *ssh.Client
}
// NewRouterBackup creates a new RouterBackup instance
func NewRouterBackup(hostname, username, password, keyFile string, port int) *RouterBackup {
return &RouterBackup{
hostname: hostname,
username: username,
password: password,
keyFile: keyFile,
port: port,
}
}
// Connect establishes SSH connection to the router
func (rb *RouterBackup) Connect() error {
// Get SSH config values for this host
hostname := ssh_config.Get(rb.hostname, "Hostname")
if hostname == "" {
hostname = rb.hostname
}
portStr := ssh_config.Get(rb.hostname, "Port")
port := rb.port
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
username := ssh_config.Get(rb.hostname, "User")
if rb.username != "" {
username = rb.username
}
keyFile := ssh_config.Get(rb.hostname, "IdentityFile")
if rb.keyFile != "" {
keyFile = rb.keyFile
}
config := &ssh.ClientConfig{
User: username,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 30 * time.Second,
}
// Apply SSH config crypto settings with compatibility filtering
if kexAlgorithms := ssh_config.Get(rb.hostname, "KexAlgorithms"); kexAlgorithms != "" && !strings.HasPrefix(kexAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(kexAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.KeyExchanges = finalAlgorithms
}
// Note: Cipher overrides disabled - Go SSH library defaults work better
// if ciphers := ssh_config.Get(rb.hostname, "Ciphers"); ciphers != "" {
// config.Ciphers = ...
// }
if macs := ssh_config.Get(rb.hostname, "MACs"); macs != "" {
macList := strings.Split(macs, ",")
for i, mac := range macList {
macList[i] = strings.TrimSpace(mac)
}
config.MACs = macList
}
if hostKeyAlgorithms := ssh_config.Get(rb.hostname, "HostKeyAlgorithms"); hostKeyAlgorithms != "" && !strings.HasPrefix(hostKeyAlgorithms, "+") {
// Only apply if it's an explicit list, not a +append
algorithms := strings.Split(hostKeyAlgorithms, ",")
var finalAlgorithms []string
for _, alg := range algorithms {
finalAlgorithms = append(finalAlgorithms, strings.TrimSpace(alg))
}
config.HostKeyAlgorithms = finalAlgorithms
}
// Try SSH agent first if available
if sshAuthSock := os.Getenv("SSH_AUTH_SOCK"); sshAuthSock != "" {
if conn, err := net.Dial("unix", sshAuthSock); err == nil {
agentClient := agent.NewClient(conn)
config.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agentClient.Signers)}
}
}
// If SSH agent didn't work, try key file
if len(config.Auth) == 0 && keyFile != "" {
// Expand ~ in keyFile path
if strings.HasPrefix(keyFile, "~/") {
homeDir, err := os.UserHomeDir()
if err == nil {
keyFile = filepath.Join(homeDir, keyFile[2:])
}
}
key, err := ioutil.ReadFile(keyFile)
if err != nil {
return fmt.Errorf("unable to read private key: %v", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return fmt.Errorf("unable to parse private key: %v", err)
}
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Fall back to password if available
if len(config.Auth) == 0 && rb.password != "" {
config.Auth = []ssh.AuthMethod{ssh.Password(rb.password)}
}
if len(config.Auth) == 0 {
return fmt.Errorf("no authentication method available")
}
address := fmt.Sprintf("%s:%d", hostname, port)
client, err := ssh.Dial("tcp4", address, config)
if err != nil {
return fmt.Errorf("failed to connect to %s: %v", hostname, err)
}
rb.client = client
fmt.Printf("Successfully connected to %s\n", hostname)
return nil
}
// RunCommand executes a command on the router and returns the output
func (rb *RouterBackup) RunCommand(command string) (string, error) {
if rb.client == nil {
return "", fmt.Errorf("no active connection")
}
session, err := rb.client.NewSession()
if err != nil {
return "", fmt.Errorf("failed to create session: %v", err)
}
defer session.Close()
output, err := session.CombinedOutput(command)
if err != nil {
return "", fmt.Errorf("failed to execute command '%s': %v", command, err)
}
return string(output), nil
}
// BackupCommands runs multiple commands and saves outputs to files
func (rb *RouterBackup) BackupCommands(commands []string, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %v", outputDir, err)
}
filename := rb.hostname
filepath := filepath.Join(outputDir, filename)
// Truncate file at start
file, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file %s: %v", filepath, err)
}
file.Close()
successCount := 0
for i, command := range commands {
fmt.Printf("Running command %d/%d: %s\n", i+1, len(commands), command)
output, err := rb.RunCommand(command)
if err != nil {
fmt.Printf("Error executing '%s': %v\n", command, err)
continue
}
// Append to file
file, err := os.OpenFile(filepath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
fmt.Printf("Failed to open file for writing: %v\n", err)
continue
}
fmt.Fprintf(file, "## COMMAND: %s\n", command)
file.WriteString(output)
file.Close()
fmt.Printf("Output saved to %s\n", filepath)
successCount++
}
fmt.Printf("Summary: %d/%d commands successful\n", successCount, len(commands))
return nil
}
// Disconnect closes SSH connection
func (rb *RouterBackup) Disconnect() {
if rb.client != nil {
rb.client.Close()
fmt.Printf("Disconnected from %s\n", rb.hostname)
}
}
// findDefaultSSHKey looks for default SSH keys
func findDefaultSSHKey() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
defaultKeys := []string{
filepath.Join(homeDir, ".ssh", "id_rsa"),
filepath.Join(homeDir, ".ssh", "id_ed25519"),
filepath.Join(homeDir, ".ssh", "id_ecdsa"),
}
for _, keyPath := range defaultKeys {
if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("Using SSH key: %s\n", keyPath)
return keyPath
}
}
return ""
}

View File

@ -1,3 +1,9 @@
# This file defines several types of router.
#
# The ipng-router-backup tool will read them in order, and merge new contents
# as it reads new files. Use file naming (00-* through 99-*) to force them to
# be read in a specific order.
types: types:
# Nokia SR Linux devices # Nokia SR Linux devices
srlinux: srlinux:

57
yaml/config.yaml Normal file
View File

@ -0,0 +1,57 @@
# IPng Networks Router Backup Configuration Example
# Copyright 2025, IPng Networks GmbH, Pim van Pelt <pim@ipng.ch>
#
# This file demonstrates how to configure the ipng-router-backup tool.
# Copy these files to a location of your choice and add local overrides
# in a custom YAML file. The tool will read and merge all YAML files in
# the order they appear on the commandline:
#
# Usage: ipng-router-backup --yaml *.yaml
# Devices Section
# Define individual network devices to backup
devices:
# Core switches (SR Linux)
asw100:
user: admin # SSH username
type: srlinux # Reference to type above
asw120:
user: netops # Different user per device if needed
type: srlinux
asw121:
user: admin
type: srlinux
# Distribution switches (Centec)
csw150:
user: admin
type: centec
csw151:
user: admin
type: centec
# Edge routers (Arista EOS)
edge-01:
user: automation
type: eos
edge-02:
user: automation
type: eos
# Special case: Device with custom commands (overrides type)
legacy-router:
user: admin
commands:
- show version
- show running-config
- show ip route summary
# Custom commands specific to this device only
# Example using IP address instead of hostname
192.168.1.100:
user: operator
type: cisco-ios