diff --git a/internal/importer/mobaconf.go b/internal/importer/mobaconf.go new file mode 100644 index 0000000..42c7e2c --- /dev/null +++ b/internal/importer/mobaconf.go @@ -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) +} diff --git a/internal/importer/mobaconf_test.go b/internal/importer/mobaconf_test.go new file mode 100644 index 0000000..a915e5e --- /dev/null +++ b/internal/importer/mobaconf_test.go @@ -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") + } +}