feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
// Package device provides the full certificate deployment flow for RouterOS devices.
//
// The deployment follows these steps:
// 1. Upload cert.pem and key.pem via SFTP
// 2. Import the certificate via RouterOS API (/certificate/import)
// 3. Import the private key via RouterOS API (/certificate/import)
// 4. Determine the certificate name on device
// 5. Assign the certificate to the api-ssl service (/ip/service/set)
// 6. Clean up uploaded PEM files from device filesystem (/file/remove)
package device
import (
"fmt"
"log/slog"
routeros "github.com/go-routeros/routeros/v3"
"golang.org/x/crypto/ssh"
)
// CertDeployRequest is the NATS request payload for certificate deployment.
type CertDeployRequest struct {
DeviceID string `json:"device_id"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
CertName string `json:"cert_name"` // e.g., "portal-device-cert"
SSHPort int `json:"ssh_port"`
}
// CertDeployResponse is the NATS reply payload.
type CertDeployResponse struct {
Success bool `json:"success"`
CertNameOnDevice string `json:"cert_name_on_device,omitempty"`
Error string `json:"error,omitempty"`
}
// DeployCert performs the full certificate deployment flow:
// 1. Upload cert.pem and key.pem files via SFTP
// 2. Import certificate via RouterOS API
// 3. Import key via RouterOS API
// 4. Assign certificate to api-ssl service
// 5. Clean up uploaded PEM files from device filesystem
func DeployCert(sshClient *ssh.Client, apiClient *routeros.Client, req CertDeployRequest) CertDeployResponse {
certFile := req.CertName + ".pem"
keyFile := req.CertName + "-key.pem"
// Step 1: Upload cert via SFTP
slog.Debug("uploading cert file via SFTP", "file", certFile, "device_id", req.DeviceID)
if err := UploadFile(sshClient, certFile, []byte(req.CertPEM)); err != nil {
return CertDeployResponse{Success: false, Error: fmt.Sprintf("SFTP cert upload: %s", err)}
}
// Step 2: Upload key via SFTP
slog.Debug("uploading key file via SFTP", "file", keyFile, "device_id", req.DeviceID)
if err := UploadFile(sshClient, keyFile, []byte(req.KeyPEM)); err != nil {
return CertDeployResponse{Success: false, Error: fmt.Sprintf("SFTP key upload: %s", err)}
}
// Step 3: Import certificate
slog.Debug("importing certificate", "file", certFile, "device_id", req.DeviceID)
importResult := ExecuteCommand(apiClient, "/certificate/import", []string{
"=file-name=" + certFile,
})
if !importResult.Success {
return CertDeployResponse{Success: false, Error: fmt.Sprintf("cert import: %s", importResult.Error)}
}
// Step 4: Import private key
slog.Debug("importing private key", "file", keyFile, "device_id", req.DeviceID)
keyImportResult := ExecuteCommand(apiClient, "/certificate/import", []string{
"=file-name=" + keyFile,
})
if !keyImportResult.Success {
return CertDeployResponse{Success: false, Error: fmt.Sprintf("key import: %s", keyImportResult.Error)}
}
// Determine the certificate name on device.
// RouterOS names imported certs as <filename>_0 by convention.
// Query to find the actual name by looking for certs with a private key.
certNameOnDevice := certFile + "_0"
printResult := ExecuteCommand(apiClient, "/certificate/print", []string{
"=.proplist=name,common-name,private-key",
})
if printResult.Success && len(printResult.Data) > 0 {
// Use the last cert that has a private key (most recently imported)
for _, entry := range printResult.Data {
if name, ok := entry["name"]; ok {
if pk, hasPK := entry["private-key"]; hasPK && pk == "true" {
certNameOnDevice = name
}
}
}
}
// Step 5: Assign to api-ssl service
slog.Debug("assigning certificate to api-ssl", "cert_name", certNameOnDevice, "device_id", req.DeviceID)
assignResult := ExecuteCommand(apiClient, "/ip/service/set", []string{
"=numbers=api-ssl",
"=certificate=" + certNameOnDevice,
})
if !assignResult.Success {
slog.Warn("api-ssl assignment failed (cert still imported)",
"device_id", req.DeviceID,
"error", assignResult.Error,
)
// Don't fail entirely -- cert is imported, assignment can be retried
}
// Step 6: Clean up uploaded PEM files from device filesystem
slog.Debug("cleaning up PEM files", "device_id", req.DeviceID)
ExecuteCommand(apiClient, "/file/remove", []string{"=.id=" + certFile})
ExecuteCommand(apiClient, "/file/remove", []string{"=.id=" + keyFile})
// File cleanup failures are non-fatal
slog.Info("certificate deployed successfully",
"device_id", req.DeviceID,
"cert_name", certNameOnDevice,
)
return CertDeployResponse{
Success: true,
CertNameOnDevice: certNameOnDevice,
}
}

View File

@@ -0,0 +1,115 @@
// Package device handles RouterOS device connections and queries.
package device
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"time"
routeros "github.com/go-routeros/routeros/v3"
)
// buildTLSConfig creates a TLS config using the portal CA cert for verification.
// Falls back to InsecureSkipVerify if caCertPEM is empty or invalid.
func buildTLSConfig(caCertPEM []byte) *tls.Config {
if len(caCertPEM) == 0 {
return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // no CA cert available
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCertPEM) {
slog.Warn("failed to parse CA cert PEM, falling back to insecure TLS")
return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // invalid CA cert
}
return &tls.Config{RootCAs: pool}
}
// ConnectDevice establishes a connection to a RouterOS device.
//
// Connection strategy is governed by tlsMode:
//
// - "auto" (default): Try CA-verified TLS (if caCertPEM provided) ->
// InsecureSkipVerify -> STOP. No plain-text fallback.
// - "portal_ca": Try CA-verified TLS only (strict).
// - "insecure": Skip directly to InsecureSkipVerify TLS (no CA check).
// - "plain": Explicit opt-in for plain-text API connection.
//
// Callers must call CloseDevice when done.
func ConnectDevice(ip string, sslPort, plainPort int, username, password string, timeout time.Duration, caCertPEM []byte, tlsMode string) (*routeros.Client, error) {
sslAddr := fmt.Sprintf("%s:%d", ip, sslPort)
switch tlsMode {
case "plain":
// Explicit opt-in: plain-text connection only
plainAddr := fmt.Sprintf("%s:%d", ip, plainPort)
slog.Debug("connecting to RouterOS device (plain — explicit opt-in)", "address", plainAddr)
client, err := routeros.DialTimeout(plainAddr, username, password, timeout)
if err != nil {
return nil, fmt.Errorf("plain-text connection to %s failed: %w", plainAddr, err)
}
slog.Debug("connected to RouterOS device (plain — explicit opt-in)", "address", plainAddr)
return client, nil
case "insecure":
// Skip CA verification, go straight to InsecureSkipVerify
insecureTLS := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // insecure mode requested
slog.Debug("connecting to RouterOS device (insecure TLS)", "address", sslAddr)
client, err := routeros.DialTLSTimeout(sslAddr, username, password, insecureTLS, timeout)
if err != nil {
return nil, fmt.Errorf("insecure TLS connection to %s failed: %w", sslAddr, err)
}
slog.Debug("connected with insecure TLS", "address", sslAddr)
return client, nil
case "portal_ca":
// Strict CA-verified TLS only
verifiedTLS := buildTLSConfig(caCertPEM)
if verifiedTLS.RootCAs == nil {
return nil, fmt.Errorf("portal_ca mode requires a valid CA cert but none available for %s", sslAddr)
}
slog.Debug("connecting to RouterOS device (CA-verified TLS)", "address", sslAddr)
client, err := routeros.DialTLSTimeout(sslAddr, username, password, verifiedTLS, timeout)
if err != nil {
return nil, fmt.Errorf("CA-verified TLS connection to %s failed: %w", sslAddr, err)
}
slog.Debug("connected with CA-verified TLS", "address", sslAddr)
return client, nil
default:
// "auto" mode: CA-verified -> InsecureSkipVerify -> STOP (no plain-text)
// Tier 1: CA-verified TLS (if CA cert available)
if len(caCertPEM) > 0 {
verifiedTLS := buildTLSConfig(caCertPEM)
if verifiedTLS.RootCAs != nil { // only try if PEM parsed OK
slog.Debug("connecting to RouterOS device (CA-verified TLS)", "address", sslAddr)
client, err := routeros.DialTLSTimeout(sslAddr, username, password, verifiedTLS, timeout)
if err == nil {
slog.Debug("connected with CA-verified TLS", "address", sslAddr)
return client, nil
}
slog.Debug("CA-verified TLS failed, trying insecure TLS", "address", sslAddr, "error", err)
}
}
// Tier 2: InsecureSkipVerify TLS (fallback)
insecureTLS := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // fallback for unprovisioned devices
slog.Debug("connecting to RouterOS device (insecure TLS)", "address", sslAddr)
client, err := routeros.DialTLSTimeout(sslAddr, username, password, insecureTLS, timeout)
if err != nil {
// NO plain-text fallback in auto mode — this is the key security change
return nil, fmt.Errorf("TLS connection to %s failed (auto mode — no plain-text fallback): %w", sslAddr, err)
}
slog.Debug("connected with insecure TLS", "address", sslAddr)
return client, nil
}
}
// CloseDevice closes a RouterOS client connection. Safe to call on a nil client.
func CloseDevice(c *routeros.Client) {
if c == nil {
return
}
c.Close()
}

View File

@@ -0,0 +1,50 @@
package device
import (
"errors"
"strings"
routeros "github.com/go-routeros/routeros/v3"
)
// CommandRequest is the JSON payload received from the Python backend via NATS.
type CommandRequest struct {
DeviceID string `json:"device_id"`
Command string `json:"command"`
Args []string `json:"args"`
}
// CommandResponse is the JSON payload returned to the Python backend via NATS.
type CommandResponse struct {
Success bool `json:"success"`
Data []map[string]string `json:"data"`
Error string `json:"error,omitempty"`
}
// ExecuteCommand runs an arbitrary RouterOS API command on a connected device.
// The command string is the full path (e.g., "/ip/address/print").
// Args are optional RouterOS API arguments (e.g., "=.proplist=.id,address").
func ExecuteCommand(client *routeros.Client, command string, args []string) CommandResponse {
cmdParts := make([]string, 0, 1+len(args))
cmdParts = append(cmdParts, command)
cmdParts = append(cmdParts, args...)
reply, err := client.Run(cmdParts...)
if err != nil {
// RouterOS 7.x returns !empty for empty results (e.g., no firewall rules).
// go-routeros/v3 doesn't recognize this word and returns UnknownReplyError.
// Treat !empty as a successful empty response.
var unkErr *routeros.UnknownReplyError
if errors.As(err, &unkErr) && strings.TrimPrefix(unkErr.Sentence.Word, "!") == "empty" {
return CommandResponse{Success: true, Data: []map[string]string{}}
}
return CommandResponse{Success: false, Data: nil, Error: err.Error()}
}
data := make([]map[string]string, 0, len(reply.Re))
for _, re := range reply.Re {
data = append(data, re.Map)
}
return CommandResponse{Success: true, Data: data}
}

View File

@@ -0,0 +1,61 @@
package device
import (
"crypto/aes"
"crypto/cipher"
"encoding/json"
"fmt"
)
// credentialsJSON is the JSON structure stored in encrypted device credentials.
// Must match the Python backend's encryption format.
type credentialsJSON struct {
Username string `json:"username"`
Password string `json:"password"`
}
// DecryptCredentials decrypts AES-256-GCM encrypted credentials and returns the
// username and password stored within.
//
// The ciphertext format MUST match what Python's cryptography.hazmat.primitives.ciphers.aead.AESGCM
// produces when called as: nonce + AESGCM.encrypt(nonce, plaintext, None)
//
// Layout on disk:
// - bytes [0:12] — 12-byte random nonce (GCM standard)
// - bytes [12:] — ciphertext + 16-byte GCM authentication tag (appended by library)
//
// Go's cipher.AEAD.Open expects the GCM tag appended to the ciphertext, which is exactly
// how Python's cryptography library stores it, so the two are directly compatible.
func DecryptCredentials(ciphertext []byte, key []byte) (username, password string, err error) {
if len(key) != 32 {
return "", "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
}
if len(ciphertext) < 12+16 {
return "", "", fmt.Errorf("ciphertext too short: need at least 28 bytes (12 nonce + 16 tag), got %d", len(ciphertext))
}
block, err := aes.NewCipher(key)
if err != nil {
return "", "", fmt.Errorf("creating AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", fmt.Errorf("creating GCM cipher: %w", err)
}
nonce := ciphertext[:12]
encryptedData := ciphertext[12:]
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
if err != nil {
return "", "", fmt.Errorf("decrypting credentials (wrong key or tampered data): %w", err)
}
var creds credentialsJSON
if err := json.Unmarshal(plaintext, &creds); err != nil {
return "", "", fmt.Errorf("unmarshalling decrypted credentials JSON: %w", err)
}
return creds.Username, creds.Password, nil
}

View File

@@ -0,0 +1,91 @@
package device
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// encrypt is a test helper that encrypts using the same format as Python's AESGCM.
// This verifies Go-side decryption is compatible with Python-side encryption.
func encrypt(t *testing.T, plaintext []byte, key []byte) []byte {
t.Helper()
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCM(block)
require.NoError(t, err)
nonce := make([]byte, 12)
_, err = rand.Read(nonce)
require.NoError(t, err)
// gcm.Seal appends ciphertext+tag after nonce
return gcm.Seal(nonce, nonce, plaintext, nil)
}
func TestDecryptCredentials_RoundTrip(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
creds := credentialsJSON{Username: "admin", Password: "secret123"}
plaintext, err := json.Marshal(creds)
require.NoError(t, err)
ciphertext := encrypt(t, plaintext, key)
username, password, err := DecryptCredentials(ciphertext, key)
require.NoError(t, err)
assert.Equal(t, "admin", username)
assert.Equal(t, "secret123", password)
}
func TestDecryptCredentials_WrongKey(t *testing.T) {
key1 := make([]byte, 32)
key2 := make([]byte, 32)
_, _ = rand.Read(key1)
_, _ = rand.Read(key2)
creds := credentialsJSON{Username: "admin", Password: "secret"}
plaintext, _ := json.Marshal(creds)
ciphertext := encrypt(t, plaintext, key1)
_, _, err := DecryptCredentials(ciphertext, key2)
assert.Error(t, err)
assert.Contains(t, err.Error(), "wrong key or tampered")
}
func TestDecryptCredentials_ShortCiphertext(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
_, _, err := DecryptCredentials([]byte("short"), key)
assert.Error(t, err)
assert.Contains(t, err.Error(), "too short")
}
func TestDecryptCredentials_WrongKeyLength(t *testing.T) {
_, _, err := DecryptCredentials(make([]byte, 50), make([]byte, 16))
assert.Error(t, err)
assert.Contains(t, err.Error(), "32 bytes")
}
func TestDecryptCredentials_TamperedCiphertext(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
creds := credentialsJSON{Username: "admin", Password: "secret"}
plaintext, _ := json.Marshal(creds)
ciphertext := encrypt(t, plaintext, key)
// Flip a byte in the encrypted portion (after 12-byte nonce)
tampered := make([]byte, len(ciphertext))
copy(tampered, ciphertext)
tampered[15] ^= 0xFF
_, _, err := DecryptCredentials(tampered, key)
assert.Error(t, err)
}

View File

@@ -0,0 +1,99 @@
package device
import (
"log/slog"
routeros "github.com/go-routeros/routeros/v3"
)
// FirmwareInfo holds firmware update status collected from a RouterOS device.
type FirmwareInfo struct {
InstalledVersion string `json:"installed_version"`
LatestVersion string `json:"latest_version,omitempty"`
Channel string `json:"channel,omitempty"`
Status string `json:"status"` // "New version is available", "System is already up to date", "check-failed"
Architecture string `json:"architecture"` // CPU architecture (e.g., "arm", "arm64", "mipsbe")
}
// CheckFirmwareUpdate queries a RouterOS device for firmware update status.
//
// It performs two API calls:
// 1. /system/resource/print — to get the architecture and installed version.
// 2. /system/package/update/check-for-updates + /system/package/update/print
// — to get the latest available version from MikroTik's servers.
//
// If the device cannot reach MikroTik's servers (no internet), the function
// returns what it knows (installed version, architecture) with status "check-failed".
// This is non-fatal — the device may simply not have internet access.
func CheckFirmwareUpdate(c *routeros.Client) (FirmwareInfo, error) {
// 1. Get architecture and installed version from /system/resource/print.
resReply, err := c.Run("/system/resource/print")
if err != nil {
return FirmwareInfo{}, err
}
arch := ""
installedVer := ""
if len(resReply.Re) > 0 {
arch = resReply.Re[0].Map["architecture-name"]
installedVer = resReply.Re[0].Map["version"]
}
// 2. Trigger check-for-updates (makes outbound HTTP from device to MikroTik servers).
_, err = c.Run("/system/package/update/check-for-updates")
if err != nil {
slog.Debug("firmware update check failed (device may lack internet)",
"error", err,
"architecture", arch,
)
// Non-fatal: return what we know.
return FirmwareInfo{
InstalledVersion: installedVer,
Architecture: arch,
Status: "check-failed",
}, nil
}
// 3. Read results from /system/package/update/print.
reply, err := c.Run("/system/package/update/print")
if err != nil {
return FirmwareInfo{
InstalledVersion: installedVer,
Architecture: arch,
Status: "check-failed",
}, nil
}
if len(reply.Re) == 0 {
return FirmwareInfo{
InstalledVersion: installedVer,
Architecture: arch,
Status: "check-failed",
}, nil
}
m := reply.Re[0].Map
info := FirmwareInfo{
InstalledVersion: m["installed-version"],
LatestVersion: m["latest-version"],
Channel: m["channel"],
Status: m["status"],
Architecture: arch,
}
// Use the resource-detected values as fallback.
if info.InstalledVersion == "" {
info.InstalledVersion = installedVer
}
slog.Debug("firmware update check complete",
"installed", info.InstalledVersion,
"latest", info.LatestVersion,
"channel", info.Channel,
"status", info.Status,
"architecture", info.Architecture,
)
return info, nil
}

View File

@@ -0,0 +1,110 @@
package device
import (
"fmt"
"log/slog"
routeros "github.com/go-routeros/routeros/v3"
)
// HealthMetrics holds system resource metrics collected from a RouterOS device.
// String fields match the raw RouterOS API values so the subscriber can parse
// and validate them before inserting into TimescaleDB.
type HealthMetrics struct {
CPULoad string `json:"cpu_load"`
FreeMemory string `json:"free_memory"`
TotalMemory string `json:"total_memory"`
FreeDisk string `json:"free_disk"`
TotalDisk string `json:"total_disk"`
Temperature string `json:"temperature"` // empty string if device has no sensor
}
// CollectHealth gathers system health metrics for a RouterOS device.
//
// It combines data already present in DeviceInfo (CPU, memory) with additional
// disk stats from /system/resource/print and temperature from /system/health/print.
//
// Temperature handling:
// - RouterOS v7: /system/health/print returns rows with name/value columns;
// looks for "cpu-temperature" then "board-temperature" as a fallback.
// - RouterOS v6: /system/health/print returns a flat map; looks for
// "cpu-temperature" key directly.
// - If the command fails or no temperature key is found, Temperature is set to "".
func CollectHealth(client *routeros.Client, info DeviceInfo) (HealthMetrics, error) {
health := HealthMetrics{
CPULoad: info.CPULoad,
FreeMemory: info.FreeMemory,
TotalMemory: info.TotalMemory,
}
// Collect disk stats (not included in the default /system/resource/print proplist
// used by DetectVersion, so we query explicitly here).
diskReply, err := client.Run(
"/system/resource/print",
"=.proplist=free-hdd-space,total-hdd-space",
)
if err != nil {
slog.Warn("could not collect disk stats", "error", err)
} else if len(diskReply.Re) > 0 {
m := diskReply.Re[0].Map
health.FreeDisk = m["free-hdd-space"]
health.TotalDisk = m["total-hdd-space"]
}
// Collect temperature from /system/health/print.
// This command may not exist on all devices, so errors are non-fatal.
health.Temperature = collectTemperature(client, info.MajorVersion)
return health, nil
}
// collectTemperature queries /system/health/print and extracts the temperature
// reading. Returns an empty string if the device has no temperature sensor or
// the command is not supported.
func collectTemperature(client *routeros.Client, majorVersion int) string {
reply, err := client.Run("/system/health/print")
if err != nil {
slog.Debug("temperature collection not available", "error", err)
return ""
}
if len(reply.Re) == 0 {
return ""
}
// RouterOS v7 returns rows with "name" and "value" columns.
// RouterOS v6 returns a flat map in a single sentence.
if majorVersion >= 7 {
// v7: iterate rows looking for known temperature keys.
var fallback string
for _, sentence := range reply.Re {
m := sentence.Map
name := m["name"]
value := m["value"]
if name == "cpu-temperature" {
return value
}
if name == "board-temperature" {
fallback = value
}
}
return fallback
}
// v6 (or unknown version): flat map — look for cpu-temperature key directly.
m := reply.Re[0].Map
if temp, ok := m["cpu-temperature"]; ok {
return temp
}
if temp, ok := m["board-temperature"]; ok {
return temp
}
return ""
}
// collectHealthError returns an error for CollectHealth callers when the
// primary resource query fails completely.
func collectHealthError(err error) error {
return fmt.Errorf("collecting health metrics: %w", err)
}

View File

@@ -0,0 +1,61 @@
// Package device provides RouterOS metric collectors for the poller.
package device
import (
"fmt"
"log/slog"
"strconv"
routeros "github.com/go-routeros/routeros/v3"
)
// InterfaceStats holds the traffic counters for a single RouterOS interface.
type InterfaceStats struct {
Name string `json:"name"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
Running bool `json:"running"`
Type string `json:"type"`
}
// CollectInterfaces queries the RouterOS device for per-interface traffic
// counters via /interface/print.
//
// Returns a slice of InterfaceStats. On error, returns an empty slice and the
// error — the caller decides whether to skip the device or log a warning.
func CollectInterfaces(client *routeros.Client) ([]InterfaceStats, error) {
reply, err := client.Run(
"/interface/print",
"=.proplist=name,rx-byte,tx-byte,running,type",
)
if err != nil {
return nil, fmt.Errorf("running /interface/print: %w", err)
}
stats := make([]InterfaceStats, 0, len(reply.Re))
for _, sentence := range reply.Re {
m := sentence.Map
rxBytes, err := strconv.ParseInt(m["rx-byte"], 10, 64)
if err != nil {
slog.Warn("could not parse rx-byte for interface", "interface", m["name"], "value", m["rx-byte"])
rxBytes = 0
}
txBytes, err := strconv.ParseInt(m["tx-byte"], 10, 64)
if err != nil {
slog.Warn("could not parse tx-byte for interface", "interface", m["name"], "value", m["tx-byte"])
txBytes = 0
}
stats = append(stats, InterfaceStats{
Name: m["name"],
RxBytes: rxBytes,
TxBytes: txBytes,
Running: m["running"] == "true",
Type: m["type"],
})
}
return stats, nil
}

View File

@@ -0,0 +1,53 @@
// Package device provides SFTP file upload helpers for RouterOS devices.
//
// RouterOS has a built-in SSH/SFTP server (port 22) that accepts the same
// credentials as the API. Since the RouterOS binary API cannot upload files,
// SFTP is used to push certificate PEM files before importing them.
package device
import (
"fmt"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// NewSSHClient creates an SSH connection to a RouterOS device.
// Uses password authentication (same credentials as API access).
func NewSSHClient(ip string, port int, username, password string, timeout time.Duration) (*ssh.Client, error) {
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // RouterOS self-signed SSH
Timeout: timeout,
}
addr := fmt.Sprintf("%s:%d", ip, port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return nil, fmt.Errorf("SSH dial to %s: %w", addr, err)
}
return client, nil
}
// UploadFile uploads data to a file on the RouterOS device via SFTP.
func UploadFile(sshClient *ssh.Client, remotePath string, data []byte) error {
client, err := sftp.NewClient(sshClient)
if err != nil {
return fmt.Errorf("creating SFTP client: %w", err)
}
defer client.Close()
f, err := client.Create(remotePath)
if err != nil {
return fmt.Errorf("creating remote file %s: %w", remotePath, err)
}
defer f.Close()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("writing to %s: %w", remotePath, err)
}
return nil
}

View File

@@ -0,0 +1,86 @@
package device
import (
"fmt"
"log/slog"
routeros "github.com/go-routeros/routeros/v3"
)
// DeviceInfo holds metadata collected from /system/resource/print and
// /system/routerboard/print.
type DeviceInfo struct {
Version string
MajorVersion int
BoardName string
Architecture string
Uptime string
CPULoad string
FreeMemory string
TotalMemory string
SerialNumber string // from /system/routerboard serial-number
FirmwareVersion string // from /system/routerboard current-firmware
LastConfigChange string // from /system/resource last-config-change (RouterOS 7.x)
}
// DetectVersion queries the RouterOS device for system resource information.
//
// Runs /system/resource/print and parses the response into DeviceInfo.
// The major version is extracted from the first character of the version string
// (e.g. "6.49.10" -> 6, "7.12" -> 7).
func DetectVersion(c *routeros.Client) (DeviceInfo, error) {
reply, err := c.Run("/system/resource/print")
if err != nil {
return DeviceInfo{}, fmt.Errorf("running /system/resource/print: %w", err)
}
if len(reply.Re) == 0 {
return DeviceInfo{}, fmt.Errorf("/system/resource/print returned no sentences")
}
m := reply.Re[0].Map
info := DeviceInfo{
Version: m["version"],
BoardName: m["board-name"],
Architecture: m["architecture-name"],
Uptime: m["uptime"],
CPULoad: m["cpu-load"],
FreeMemory: m["free-memory"],
TotalMemory: m["total-memory"],
LastConfigChange: m["last-config-change"],
}
// Extract major version from first character of version string.
// Valid RouterOS versions start with '6' or '7'.
if len(info.Version) > 0 {
firstChar := info.Version[0]
if firstChar >= '0' && firstChar <= '9' {
info.MajorVersion = int(firstChar - '0')
} else {
slog.Warn("unexpected RouterOS version format", "version", info.Version)
info.MajorVersion = 0
}
}
// Query routerboard info for serial number and firmware version.
// Non-fatal: CHR and x86 devices don't have a routerboard.
rbReply, rbErr := c.Run("/system/routerboard/print")
if rbErr == nil && len(rbReply.Re) > 0 {
rb := rbReply.Re[0].Map
info.SerialNumber = rb["serial-number"]
info.FirmwareVersion = rb["current-firmware"]
} else if rbErr != nil {
slog.Debug("routerboard query failed (normal for CHR/x86)", "error", rbErr)
}
slog.Debug("detected RouterOS version",
"version", info.Version,
"major_version", info.MajorVersion,
"board_name", info.BoardName,
"serial", info.SerialNumber,
"firmware", info.FirmwareVersion,
)
return info, nil
}

View File

@@ -0,0 +1,145 @@
package device
import (
"log/slog"
"strconv"
routeros "github.com/go-routeros/routeros/v3"
)
// WirelessStats holds aggregated wireless metrics for a single wireless interface.
// Metrics are aggregated across all registered clients on that interface.
type WirelessStats struct {
Interface string `json:"interface"`
ClientCount int `json:"client_count"`
AvgSignal int `json:"avg_signal"` // dBm (negative), e.g. -67
CCQ int `json:"ccq"` // 0100 percentage; 0 if not available (v7)
Frequency int `json:"frequency"` // MHz
}
// CollectWireless queries the RouterOS device for wireless registration-table
// entries and aggregates them per interface.
//
// Version routing:
// - majorVersion >= 7: tries /interface/wifi/registration-table/print first;
// falls back to /interface/wireless/registration-table/print if that fails.
// - majorVersion < 7 (including 0 for unknown): uses the classic wireless path.
//
// Returns an empty slice (not an error) when the device has no wireless interfaces.
func CollectWireless(client *routeros.Client, majorVersion int) ([]WirelessStats, error) {
var registrations []map[string]string
var useV7WiFi bool
if majorVersion >= 7 {
// Try the v7 WiFi API first.
regReply, err := client.Run("/interface/wifi/registration-table/print")
if err == nil {
useV7WiFi = true
for _, s := range regReply.Re {
registrations = append(registrations, s.Map)
}
} else {
slog.Debug("v7 wifi registration-table not available, falling back to wireless", "error", err)
// Fall back to classic wireless path.
regReply, err = client.Run("/interface/wireless/registration-table/print")
if err != nil {
slog.Debug("device has no wireless interfaces", "error", err)
return nil, nil
}
for _, s := range regReply.Re {
registrations = append(registrations, s.Map)
}
}
} else {
regReply, err := client.Run("/interface/wireless/registration-table/print")
if err != nil {
slog.Debug("device has no wireless interfaces", "error", err)
return nil, nil
}
for _, s := range regReply.Re {
registrations = append(registrations, s.Map)
}
}
if len(registrations) == 0 {
return nil, nil
}
// Collect frequency per interface so we can include it in the stats.
frequencies := collectWirelessFrequencies(client, majorVersion, useV7WiFi)
// Aggregate registration-table rows per interface.
type ifaceAgg struct {
count int
signal int
ccq int
}
agg := make(map[string]*ifaceAgg)
for _, r := range registrations {
iface := r["interface"]
if iface == "" {
continue
}
if _, ok := agg[iface]; !ok {
agg[iface] = &ifaceAgg{}
}
a := agg[iface]
a.count++
if sig, err := strconv.Atoi(r["signal-strength"]); err == nil {
a.signal += sig
}
if ccq, err := strconv.Atoi(r["tx-ccq"]); err == nil {
a.ccq += ccq
}
}
result := make([]WirelessStats, 0, len(agg))
for iface, a := range agg {
avgSignal := 0
avgCCQ := 0
if a.count > 0 {
avgSignal = a.signal / a.count
avgCCQ = a.ccq / a.count
}
result = append(result, WirelessStats{
Interface: iface,
ClientCount: a.count,
AvgSignal: avgSignal,
CCQ: avgCCQ,
Frequency: frequencies[iface],
})
}
return result, nil
}
// collectWirelessFrequencies returns a map of interface name → frequency (MHz).
// Uses the v7 WiFi API or the classic wireless API based on the useV7WiFi flag.
func collectWirelessFrequencies(client *routeros.Client, majorVersion int, useV7WiFi bool) map[string]int {
freqs := make(map[string]int)
var cmd string
if useV7WiFi {
cmd = "/interface/wifi/print"
} else {
cmd = "/interface/wireless/print"
}
reply, err := client.Run(cmd, "=.proplist=name,frequency")
if err != nil {
slog.Debug("could not collect wireless frequencies", "command", cmd, "error", err)
return freqs
}
for _, s := range reply.Re {
m := s.Map
name := m["name"]
if freq, err := strconv.Atoi(m["frequency"]); err == nil {
freqs[name] = freq
}
}
return freqs
}