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:
122
poller/internal/device/cert_deploy.go
Normal file
122
poller/internal/device/cert_deploy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
115
poller/internal/device/client.go
Normal file
115
poller/internal/device/client.go
Normal 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()
|
||||
}
|
||||
50
poller/internal/device/command.go
Normal file
50
poller/internal/device/command.go
Normal 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}
|
||||
}
|
||||
61
poller/internal/device/crypto.go
Normal file
61
poller/internal/device/crypto.go
Normal 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
|
||||
}
|
||||
91
poller/internal/device/crypto_test.go
Normal file
91
poller/internal/device/crypto_test.go
Normal 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)
|
||||
}
|
||||
99
poller/internal/device/firmware.go
Normal file
99
poller/internal/device/firmware.go
Normal 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
|
||||
}
|
||||
110
poller/internal/device/health.go
Normal file
110
poller/internal/device/health.go
Normal 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)
|
||||
}
|
||||
61
poller/internal/device/interfaces.go
Normal file
61
poller/internal/device/interfaces.go
Normal 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
|
||||
}
|
||||
53
poller/internal/device/sftp.go
Normal file
53
poller/internal/device/sftp.go
Normal 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
|
||||
}
|
||||
86
poller/internal/device/version.go
Normal file
86
poller/internal/device/version.go
Normal 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
|
||||
}
|
||||
145
poller/internal/device/wireless.go
Normal file
145
poller/internal/device/wireless.go
Normal 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"` // 0–100 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
|
||||
}
|
||||
Reference in New Issue
Block a user