feat: add MobaXTerm .mobaconf file importer
Implements the plugin.Importer interface to parse MobaXTerm config exports. Extracts SSH (#109#) and RDP (#91#) sessions from Bookmarks sections, host keys from SSH_Hostkeys, and terminal colors from the Colors section. Passwords are intentionally skipped (encrypted). Tested against the real config export at docs/config-export.mobaconf. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c00e2a446f
commit
39471244ac
322
internal/importer/mobaconf.go
Normal file
322
internal/importer/mobaconf.go
Normal file
@ -0,0 +1,322 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/vstockwell/wraith/internal/plugin"
|
||||
)
|
||||
|
||||
// MobaConfImporter parses MobaXTerm .mobaconf configuration files and extracts
|
||||
// connections, groups, host keys, and terminal theme settings.
|
||||
type MobaConfImporter struct{}
|
||||
|
||||
func (m *MobaConfImporter) Name() string { return "MobaXTerm" }
|
||||
func (m *MobaConfImporter) FileExtensions() []string { return []string{".mobaconf"} }
|
||||
|
||||
// Parse reads a .mobaconf file and returns the extracted import result.
|
||||
func (m *MobaConfImporter) Parse(data []byte) (*plugin.ImportResult, error) {
|
||||
result := &plugin.ImportResult{
|
||||
Groups: []plugin.ImportGroup{},
|
||||
Connections: []plugin.ImportConnection{},
|
||||
HostKeys: []plugin.ImportHostKey{},
|
||||
}
|
||||
|
||||
sections := parseSections(string(data))
|
||||
|
||||
// Parse [Bookmarks_N] sections
|
||||
for name, lines := range sections {
|
||||
if !strings.HasPrefix(name, "Bookmarks") {
|
||||
continue
|
||||
}
|
||||
|
||||
groupName := ""
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key == "SubRep" && value != "" {
|
||||
groupName = value
|
||||
result.Groups = append(result.Groups, plugin.ImportGroup{
|
||||
Name: groupName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse session lines within the bookmarks section
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key == "SubRep" || key == "ImgNum" || key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Session lines start with a name (possibly prefixed with *)
|
||||
// and contain = followed by the session definition
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionName := key
|
||||
// Strip leading * from session names (marks favorites)
|
||||
sessionName = strings.TrimPrefix(sessionName, "*")
|
||||
|
||||
conn := parseSessionLine(sessionName, value)
|
||||
if conn != nil {
|
||||
conn.GroupName = groupName
|
||||
result.Connections = append(result.Connections, *conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [SSH_Hostkeys] section
|
||||
if hostKeyLines, ok := sections["SSH_Hostkeys"]; ok {
|
||||
for _, line := range hostKeyLines {
|
||||
key, value := splitKV(line)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
hk := parseHostKeyLine(key, value)
|
||||
if hk != nil {
|
||||
result.HostKeys = append(result.HostKeys, *hk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [Colors] section
|
||||
if colorLines, ok := sections["Colors"]; ok {
|
||||
theme := parseColorSection(colorLines)
|
||||
if theme != nil {
|
||||
result.Theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseSections splits .mobaconf INI content into named sections.
|
||||
// Returns a map of section name -> lines within that section.
|
||||
func parseSections(content string) map[string][]string {
|
||||
sections := make(map[string][]string)
|
||||
currentSection := ""
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = line[1 : len(line)-1]
|
||||
if _, exists := sections[currentSection]; !exists {
|
||||
sections[currentSection] = []string{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentSection != "" && line != "" {
|
||||
sections[currentSection] = append(sections[currentSection], line)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// splitKV splits a line on the first = sign into key and value.
|
||||
func splitKV(line string) (string, string) {
|
||||
idx := strings.Index(line, "=")
|
||||
if idx < 0 {
|
||||
return line, ""
|
||||
}
|
||||
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
|
||||
// parseSessionLine parses a MobaXTerm session definition string.
|
||||
// Format: #type#flags%field1%field2%...
|
||||
// SSH (#109#): fields[0]=host, fields[1]=port, fields[2]=username
|
||||
// RDP (#91#): fields[0]=host, fields[1]=port, fields[2]=username
|
||||
func parseSessionLine(name, value string) *plugin.ImportConnection {
|
||||
// Find the protocol type marker
|
||||
hashIdx := strings.Index(value, "#")
|
||||
if hashIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the type number between # markers
|
||||
rest := value[hashIdx+1:]
|
||||
secondHash := strings.Index(rest, "#")
|
||||
if secondHash < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
typeStr := rest[:secondHash]
|
||||
afterType := rest[secondHash+1:]
|
||||
|
||||
// The remainder after the type and flags section: flags%field1%field2%...
|
||||
// Split on the first % to separate flags from fields, then split the rest
|
||||
parts := strings.Split(afterType, "%")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parts[0] = flags (numeric), parts[1:] = fields
|
||||
fields := parts[1:]
|
||||
|
||||
conn := &plugin.ImportConnection{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "109": // SSH
|
||||
conn.Protocol = "ssh"
|
||||
if len(fields) >= 1 {
|
||||
conn.Hostname = fields[0]
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil && port > 0 {
|
||||
conn.Port = port
|
||||
} else {
|
||||
conn.Port = 22
|
||||
}
|
||||
} else {
|
||||
conn.Port = 22
|
||||
}
|
||||
if len(fields) >= 3 {
|
||||
conn.Username = fields[2]
|
||||
}
|
||||
case "91": // RDP
|
||||
conn.Protocol = "rdp"
|
||||
if len(fields) >= 1 {
|
||||
conn.Hostname = fields[0]
|
||||
}
|
||||
if len(fields) >= 2 {
|
||||
port, err := strconv.Atoi(fields[1])
|
||||
if err == nil && port > 0 {
|
||||
conn.Port = port
|
||||
} else {
|
||||
conn.Port = 3389
|
||||
}
|
||||
} else {
|
||||
conn.Port = 3389
|
||||
}
|
||||
if len(fields) >= 3 {
|
||||
conn.Username = fields[2]
|
||||
}
|
||||
default:
|
||||
// Unknown protocol type — skip
|
||||
return nil
|
||||
}
|
||||
|
||||
if conn.Hostname == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// parseHostKeyLine parses a line from the [SSH_Hostkeys] section.
|
||||
// Key format: keytype@port:hostname
|
||||
// Value: the fingerprint data
|
||||
func parseHostKeyLine(key, value string) *plugin.ImportHostKey {
|
||||
// Parse key format: keytype@port:hostname
|
||||
atIdx := strings.Index(key, "@")
|
||||
if atIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keyType := key[:atIdx]
|
||||
rest := key[atIdx+1:]
|
||||
|
||||
colonIdx := strings.Index(rest, ":")
|
||||
if colonIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
portStr := rest[:colonIdx]
|
||||
hostname := rest[colonIdx+1:]
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &plugin.ImportHostKey{
|
||||
Hostname: hostname,
|
||||
Port: port,
|
||||
KeyType: keyType,
|
||||
Fingerprint: value,
|
||||
}
|
||||
}
|
||||
|
||||
// parseColorSection extracts a terminal theme from the [Colors] section.
|
||||
func parseColorSection(lines []string) *plugin.ImportTheme {
|
||||
colorMap := make(map[string]string)
|
||||
for _, line := range lines {
|
||||
key, value := splitKV(line)
|
||||
if key != "" && value != "" {
|
||||
colorMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least foreground and background to form a theme
|
||||
fg, hasFg := colorMap["ForegroundColour"]
|
||||
bg, hasBg := colorMap["BackgroundColour"]
|
||||
if !hasFg || !hasBg {
|
||||
return nil
|
||||
}
|
||||
|
||||
theme := &plugin.ImportTheme{
|
||||
Name: "MobaXTerm Import",
|
||||
Foreground: rgbToHex(fg),
|
||||
Background: rgbToHex(bg),
|
||||
}
|
||||
|
||||
if cursor, ok := colorMap["CursorColour"]; ok {
|
||||
theme.Cursor = rgbToHex(cursor)
|
||||
}
|
||||
|
||||
// Map MobaXTerm color names to the 16-color array positions
|
||||
// Standard ANSI order: black, red, green, yellow, blue, magenta, cyan, white,
|
||||
// then bright variants in the same order
|
||||
colorNames := [16][2]string{
|
||||
{"Black", ""},
|
||||
{"Red", ""},
|
||||
{"Green", ""},
|
||||
{"Yellow", ""},
|
||||
{"Blue", ""},
|
||||
{"Magenta", ""},
|
||||
{"Cyan", ""},
|
||||
{"White", ""},
|
||||
{"BoldBlack", ""},
|
||||
{"BoldRed", ""},
|
||||
{"BoldGreen", ""},
|
||||
{"BoldYellow", ""},
|
||||
{"BoldBlue", ""},
|
||||
{"BoldMagenta", ""},
|
||||
{"BoldCyan", ""},
|
||||
{"BoldWhite", ""},
|
||||
}
|
||||
|
||||
for i, cn := range colorNames {
|
||||
name := cn[0]
|
||||
if val, ok := colorMap[name]; ok {
|
||||
theme.Colors[i] = rgbToHex(val)
|
||||
}
|
||||
}
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
// rgbToHex converts a "R,G,B" string to "#RRGGBB" hex format.
|
||||
func rgbToHex(rgb string) string {
|
||||
parts := strings.Split(rgb, ",")
|
||||
if len(parts) != 3 {
|
||||
return rgb // Return as-is if not in expected format
|
||||
}
|
||||
|
||||
r, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
g, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
b, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return rgb
|
||||
}
|
||||
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
236
internal/importer/mobaconf_test.go
Normal file
236
internal/importer/mobaconf_test.go
Normal file
@ -0,0 +1,236 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMobaConf(t *testing.T) {
|
||||
data, err := os.ReadFile("../../docs/config-export.mobaconf")
|
||||
if err != nil {
|
||||
t.Skip("config file not found")
|
||||
}
|
||||
|
||||
imp := &MobaConfImporter{}
|
||||
result, err := imp.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result.Groups) == 0 {
|
||||
t.Error("should parse groups")
|
||||
}
|
||||
|
||||
if len(result.Connections) == 0 {
|
||||
t.Error("should parse connections")
|
||||
}
|
||||
|
||||
// Check that we found the expected group
|
||||
foundGroup := false
|
||||
for _, g := range result.Groups {
|
||||
if g.Name == "AAA Vantz's Stuff" {
|
||||
foundGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundGroup {
|
||||
t.Error("should find 'AAA Vantz's Stuff' group")
|
||||
}
|
||||
|
||||
// Check for known SSH connections
|
||||
foundAsgard := false
|
||||
foundDocker := false
|
||||
for _, c := range result.Connections {
|
||||
if c.Name == "Asgard" {
|
||||
foundAsgard = true
|
||||
if c.Hostname != "192.168.1.4" {
|
||||
t.Errorf("Asgard hostname = %q, want 192.168.1.4", c.Hostname)
|
||||
}
|
||||
if c.Port != 22 {
|
||||
t.Errorf("Asgard port = %d, want 22", c.Port)
|
||||
}
|
||||
if c.Protocol != "ssh" {
|
||||
t.Errorf("Asgard protocol = %q, want ssh", c.Protocol)
|
||||
}
|
||||
if c.Username != "vstockwell" {
|
||||
t.Errorf("Asgard username = %q, want vstockwell", c.Username)
|
||||
}
|
||||
}
|
||||
if c.Name == "Docker" {
|
||||
foundDocker = true
|
||||
if c.Hostname != "155.254.29.221" {
|
||||
t.Errorf("Docker hostname = %q, want 155.254.29.221", c.Hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAsgard {
|
||||
t.Error("should find Asgard connection")
|
||||
}
|
||||
if !foundDocker {
|
||||
t.Error("should find Docker connection")
|
||||
}
|
||||
|
||||
// Check for RDP connections
|
||||
foundRDP := false
|
||||
for _, c := range result.Connections {
|
||||
if c.Name == "CLT-VMHOST01" {
|
||||
foundRDP = true
|
||||
if c.Protocol != "rdp" {
|
||||
t.Errorf("CLT-VMHOST01 protocol = %q, want rdp", c.Protocol)
|
||||
}
|
||||
if c.Hostname != "100.64.1.204" {
|
||||
t.Errorf("CLT-VMHOST01 hostname = %q, want 100.64.1.204", c.Hostname)
|
||||
}
|
||||
if c.Port != 3389 {
|
||||
t.Errorf("CLT-VMHOST01 port = %d, want 3389", c.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRDP {
|
||||
t.Error("should find CLT-VMHOST01 RDP connection")
|
||||
}
|
||||
|
||||
// Check host keys were parsed
|
||||
if len(result.HostKeys) == 0 {
|
||||
t.Error("should parse host keys")
|
||||
}
|
||||
|
||||
// Check theme was parsed
|
||||
if result.Theme == nil {
|
||||
t.Error("should parse theme from Colors section")
|
||||
} else {
|
||||
if result.Theme.Foreground != "#ececec" {
|
||||
t.Errorf("theme foreground = %q, want #ececec", result.Theme.Foreground)
|
||||
}
|
||||
if result.Theme.Background != "#242424" {
|
||||
t.Errorf("theme background = %q, want #242424", result.Theme.Background)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHSession(t *testing.T) {
|
||||
line := `#109#0%192.168.1.4%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\ssh-key%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0`
|
||||
conn := parseSessionLine("Asgard", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse SSH session")
|
||||
}
|
||||
if conn.Hostname != "192.168.1.4" {
|
||||
t.Errorf("hostname = %q, want 192.168.1.4", conn.Hostname)
|
||||
}
|
||||
if conn.Port != 22 {
|
||||
t.Errorf("port = %d, want 22", conn.Port)
|
||||
}
|
||||
if conn.Protocol != "ssh" {
|
||||
t.Errorf("protocol = %q, want ssh", conn.Protocol)
|
||||
}
|
||||
if conn.Username != "vstockwell" {
|
||||
t.Errorf("username = %q, want vstockwell", conn.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDPSession(t *testing.T) {
|
||||
line := `#91#4%100.64.1.204%3389%%-1%0%0%0%-1%0%0%-1`
|
||||
conn := parseSessionLine("CLT-VMHOST01", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse RDP session")
|
||||
}
|
||||
if conn.Hostname != "100.64.1.204" {
|
||||
t.Errorf("hostname = %q, want 100.64.1.204", conn.Hostname)
|
||||
}
|
||||
if conn.Port != 3389 {
|
||||
t.Errorf("port = %d, want 3389", conn.Port)
|
||||
}
|
||||
if conn.Protocol != "rdp" {
|
||||
t.Errorf("protocol = %q, want rdp", conn.Protocol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHSessionWithUsername(t *testing.T) {
|
||||
line := `#109#0%192.168.1.105%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1`
|
||||
conn := parseSessionLine("Node 1(top)", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse SSH session")
|
||||
}
|
||||
if conn.Username != "root" {
|
||||
t.Errorf("username = %q, want root", conn.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDPSessionWithUsername(t *testing.T) {
|
||||
line := `#91#4%192.154.253.107%3389%administrator%0%0%0%0%-1%0%0%-1`
|
||||
conn := parseSessionLine("Win Game Host", line)
|
||||
if conn == nil {
|
||||
t.Fatal("should parse RDP session")
|
||||
}
|
||||
if conn.Username != "administrator" {
|
||||
t.Errorf("username = %q, want administrator", conn.Username)
|
||||
}
|
||||
if conn.Hostname != "192.154.253.107" {
|
||||
t.Errorf("hostname = %q, want 192.154.253.107", conn.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRgbToHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"236,236,236", "#ececec"},
|
||||
{"0,0,0", "#000000"},
|
||||
{"255,255,255", "#ffffff"},
|
||||
{"36,36,36", "#242424"},
|
||||
{"128,128,128", "#808080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := rgbToHex(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("rgbToHex(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostKeyLine(t *testing.T) {
|
||||
hk := parseHostKeyLine(
|
||||
"ssh-ed25519@22:192.168.1.4",
|
||||
"0x29ac3a21e1d5166c45aed41398d71cc889b683d01e1a019bf23cb2e1ce1c8276,0x2a8e2417caf686ac4b219cc3b94cd726fb49d2559bd8725ac2281b842845582b",
|
||||
)
|
||||
if hk == nil {
|
||||
t.Fatal("should parse host key")
|
||||
}
|
||||
if hk.Hostname != "192.168.1.4" {
|
||||
t.Errorf("hostname = %q, want 192.168.1.4", hk.Hostname)
|
||||
}
|
||||
if hk.Port != 22 {
|
||||
t.Errorf("port = %d, want 22", hk.Port)
|
||||
}
|
||||
if hk.KeyType != "ssh-ed25519" {
|
||||
t.Errorf("keyType = %q, want ssh-ed25519", hk.KeyType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImporterInterface(t *testing.T) {
|
||||
imp := &MobaConfImporter{}
|
||||
if imp.Name() != "MobaXTerm" {
|
||||
t.Errorf("Name() = %q, want MobaXTerm", imp.Name())
|
||||
}
|
||||
exts := imp.FileExtensions()
|
||||
if len(exts) != 1 || exts[0] != ".mobaconf" {
|
||||
t.Errorf("FileExtensions() = %v, want [.mobaconf]", exts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnknownProtocol(t *testing.T) {
|
||||
line := `#999#0%hostname%22%user`
|
||||
conn := parseSessionLine("Unknown", line)
|
||||
if conn != nil {
|
||||
t.Error("should return nil for unknown protocol type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptySessionLine(t *testing.T) {
|
||||
conn := parseSessionLine("Empty", "")
|
||||
if conn != nil {
|
||||
t.Error("should return nil for empty session line")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user