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:
@ -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**:
|
||||||
|
@ -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,7 +458,7 @@ 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
|
||||||
|
@ -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
|
|
@ -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
73
src/config/config.go
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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=
|
||||||
|
365
src/main.go
365
src/main.go
@ -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
250
src/ssh.go
Normal 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 ""
|
||||||
|
}
|
@ -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
57
yaml/config.yaml
Normal 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
|
Reference in New Issue
Block a user