Files
Jason Staack f1abb75cab feat(02-01): add SSH executor with TOFU host key verification and config normalizer
- SSH RunCommand with typed error classification (auth, hostkey, timeout, connection refused, truncated)
- TOFU host key callback: accept-on-first-connect, verify-on-subsequent, reject-on-mismatch
- NormalizeConfig strips timestamps, normalizes line endings, trims whitespace, collapses blanks
- HashConfig returns 64-char lowercase hex SHA256 of normalized config
- 22 unit tests covering all error kinds, TOFU flows, normalization edge cases, idempotency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:46:04 -05:00

80 lines
2.2 KiB
Go

// Package device provides SSH command execution and config normalization for RouterOS devices.
package device
import (
"crypto/sha256"
"fmt"
"regexp"
"strings"
)
// NormalizationVersion tracks the normalization algorithm version for NATS payloads.
// Increment when the normalization logic changes to allow re-processing.
const NormalizationVersion = 1
// timestampHeaderRe matches the RouterOS export timestamp header line.
// Example: "# 2024/01/15 10:30:00 by RouterOS 7.14"
var timestampHeaderRe = regexp.MustCompile(`(?m)^# \d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} by RouterOS.*\n?`)
// NormalizeConfig deterministically normalizes a RouterOS config export.
//
// Steps:
// 1. Replace \r\n with \n
// 2. Strip the timestamp header line (and the blank line immediately following it)
// 3. Trim trailing whitespace from each line
// 4. Collapse consecutive blank lines (2+ empty lines become 1)
// 5. Ensure exactly one trailing newline
func NormalizeConfig(raw string) string {
// Step 1: Normalize line endings
s := strings.ReplaceAll(raw, "\r\n", "\n")
// Step 2: Strip timestamp header and the blank line immediately following it
loc := timestampHeaderRe.FindStringIndex(s)
if loc != nil {
after := s[loc[1]:]
// Remove the blank line immediately following the timestamp header
if strings.HasPrefix(after, "\n") {
s = s[:loc[0]] + after[1:]
} else {
s = s[:loc[0]] + after
}
}
// Step 3: Trim trailing whitespace from each line
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t")
}
// Step 4: Collapse consecutive blank lines
var result []string
prevBlank := false
for _, line := range lines {
if line == "" {
if prevBlank {
continue
}
prevBlank = true
} else {
prevBlank = false
}
result = append(result, line)
}
// Step 5: Ensure exactly one trailing newline
out := strings.Join(result, "\n")
out = strings.TrimRight(out, "\n")
if out != "" {
out += "\n"
}
return out
}
// HashConfig returns the lowercase hex-encoded SHA256 hash of the normalized config text.
// The hash is 64 characters long and deterministic for the same input.
func HashConfig(normalized string) string {
h := sha256.Sum256([]byte(normalized))
return fmt.Sprintf("%x", h)
}