diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..219ffe1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(go build:*)", + "Bash(make build *)", + "WebSearch" + ], + "additionalDirectories": [ + "c:\\github\\jaydenthorup\\mremotego", + "c:\\github\\jaydenthorup\\mremotego\\bin" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6c02b81 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs grep *)" + ] + } +} diff --git a/.gitignore b/.gitignore index 34ca2d8..9c8a237 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries for programs and plugins -*.exe +*.exe* *.exe~ *.dll *.so diff --git a/Makefile b/Makefile index 7ef5da8..fbe2d31 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ build-cli: # Build the GUI application build-gui: @echo "Building $(GUI_BINARY)..." - @go build -o $(BUILD_DIR)/$(GUI_BINARY) $(GUI_PATH) + @go build -ldflags="-H windowsgui" -o $(BUILD_DIR)/$(GUI_BINARY) $(GUI_PATH) @echo "✓ GUI build complete: $(BUILD_DIR)/$(GUI_BINARY)" # Build for multiple platforms @@ -40,7 +40,7 @@ build-all: @GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-linux-amd64 $(GUI_PATH) @GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-amd64 $(GUI_PATH) @GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-darwin-arm64 $(GUI_PATH) - @GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(GUI_BINARY)-windows-amd64.exe $(GUI_PATH) + @GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $(BUILD_DIR)/$(GUI_BINARY)-windows-amd64.exe $(GUI_PATH) @echo "✓ GUI multi-platform build complete" # Clean build artifacts diff --git a/internal/gui/dialogs.go b/internal/gui/dialogs.go index 93e3140..1b6de77 100644 --- a/internal/gui/dialogs.go +++ b/internal/gui/dialogs.go @@ -2,16 +2,64 @@ package gui import ( "fmt" + "net/url" "strconv" "strings" "time" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/jaydenthorup/mremotego/pkg/models" ) +// stableFormLayout is a two-column form layout with a fixed label column width, +// so field widths stay constant regardless of which rows are shown or hidden. +type stableFormLayout struct{ labelWidth float32 } + +func (l *stableFormLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + pad := theme.Padding() + fieldX := l.labelWidth + pad + fieldW := size.Width - fieldX + y := float32(0) + first := true + for i := 0; i+1 < len(objects); i += 2 { + label, field := objects[i], objects[i+1] + if !label.Visible() && !field.Visible() { + continue + } + if !first { + y += pad + } + first = false + rowH := fyne.Max(label.MinSize().Height, field.MinSize().Height) + label.Move(fyne.NewPos(0, y)) + label.Resize(fyne.NewSize(l.labelWidth, rowH)) + field.Move(fyne.NewPos(fieldX, y)) + field.Resize(fyne.NewSize(fieldW, rowH)) + y += rowH + } +} + +func (l *stableFormLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + pad := theme.Padding() + minH := float32(0) + first := true + for i := 0; i+1 < len(objects); i += 2 { + if !objects[i].Visible() && !objects[i+1].Visible() { + continue + } + if !first { + minH += pad + } + first = false + minH += fyne.Max(objects[i].MinSize().Height, objects[i+1].MinSize().Height) + } + return fyne.NewSize(l.labelWidth+pad, minH) +} + // collectAllFolders recursively collects all folders with their full paths func (w *MainWindow) collectAllFolders(connections []*models.Connection, prefix string, folderMap map[string]*models.Connection, folderNames *[]string) { for _, conn := range connections { @@ -127,89 +175,328 @@ func (w *MainWindow) showAddConnectionDialog() { folderSelect := widget.NewSelect(folderNames, nil) folderSelect.SetSelected("(Root)") + // RDP-specific widgets (shown for any RDP connection) + authLevelSelect := widget.NewSelect([]string{"Always connect, even if authentication fails", "Don't connect if authentication fails", "Warn me if authentication fails"}, nil) + authLevelSelect.SetSelected("Always connect, even if authentication fails") + authLevelLabel := widget.NewLabelWithStyle("Auth Level", fyne.TextAlignTrailing, fyne.TextStyle{}) + authLevelLabel.Hide() + authLevelSelect.Hide() + + useCredSSPCheck := widget.NewCheck("Use CredSSP", nil) + useCredSSPCheck.SetChecked(true) + credSSPLabel := widget.NewLabel("") + credSSPLabel.Hide() + useCredSSPCheck.Hide() + + // RD Gateway widgets + useGatewayCheck := widget.NewCheck("Use RD Gateway", nil) + + gatewayUsageSelect := widget.NewSelect([]string{"Never", "Always", "Detect"}, nil) + gatewayUsageSelect.SetSelected("Always") + gatewayHostnameEntry := widget.NewEntry() + gatewayHostnameEntry.SetPlaceHolder("gateway.example.com") + gatewayCredentialsSelect := widget.NewSelect([]string{ + "Use the same username and password", + "Use a different username and password", + "Use a smart card", + }, nil) + gatewayCredentialsSelect.SetSelected("Use the same username and password") + gatewayUsernameEntry := widget.NewEntry() + gatewayUsernameEntry.SetPlaceHolder("username or op://vault/item/field") + gatewayPasswordEntry := widget.NewEntry() + gatewayPasswordEntry.SetPlaceHolder("password or op://vault/item/field") + gatewayDomainEntry := widget.NewEntry() + gatewayDomainEntry.SetPlaceHolder("gateway domain") + + gatewayProfileSelect := widget.NewSelect([]string{"Use settings from this connection", "Use system default gateway profile"}, nil) + gatewayProfileSelect.SetSelected("Use settings from this connection") + gatewayBypassLocalCheck := widget.NewCheck("Bypass gateway for local addresses", nil) + + gatewayUsageLabel := widget.NewLabelWithStyle("Gateway Usage", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayHostnameLabel := widget.NewLabelWithStyle("Gateway Hostname", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayCredentialsLabel := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayUsernameLabel := widget.NewLabelWithStyle("Gateway Username", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayPasswordLabel := widget.NewLabelWithStyle("Gateway Password", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayDomainLabel := widget.NewLabelWithStyle("Gateway Domain", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayProfileLabel := widget.NewLabelWithStyle("Gateway Profile", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayBypassLocalLabel := widget.NewLabel("") + + // All gateway rows start hidden + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + // 1Password integration storeTo1PasswordCheck := widget.NewCheck("Store password in 1Password", nil) vaultSelect := widget.NewSelect([]string{"DevOps", "Private", "Employee"}, nil) vaultSelect.SetSelected("DevOps") + vaultLabel := widget.NewLabelWithStyle("Vault", fyne.TextAlignTrailing, fyne.TextStyle{}) + vaultLabel.Hide() vaultSelect.Hide() + gatewayCheckLabel := widget.NewLabel("") + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + + var formContainer *fyne.Container + + hideAllGatewayRows := func() { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + storeTo1PasswordCheck.OnChanged = func(checked bool) { if checked { + vaultLabel.Show() vaultSelect.Show() } else { + vaultLabel.Hide() vaultSelect.Hide() } + formContainer.Refresh() } - form := &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Name", Widget: nameEntry}, - {Text: "Protocol", Widget: protocolSelect}, - {Text: "Host", Widget: hostEntry}, - {Text: "Port", Widget: portEntry}, - {Text: "Username", Widget: usernameEntry}, - {Text: "Password", Widget: passwordEntry}, - {Text: "Domain", Widget: domainEntry}, - {Text: "Description", Widget: descriptionEntry}, - {Text: "Folder", Widget: folderSelect}, - {Text: "", Widget: storeTo1PasswordCheck}, - {Text: "Vault", Widget: vaultSelect}, - }, - OnSubmit: func() { - conn := models.NewConnection(nameEntry.Text, models.Protocol(protocolSelect.Selected)) - conn.Host = hostEntry.Text - conn.Username = usernameEntry.Text - conn.Password = passwordEntry.Text - conn.Domain = domainEntry.Text - conn.Description = descriptionEntry.Text - conn.Created = time.Now().Format(time.RFC3339) - conn.Modified = conn.Created - - if portEntry.Text != "" { - if port, err := strconv.Atoi(portEntry.Text); err == nil { - conn.Port = port - } - } else { - conn.Port = conn.Protocol.GetDefaultPort() - } + showGatewayDifferentCreds := func(show bool) { + if show { + gatewayUsernameLabel.Show() + gatewayUsernameEntry.Show() + gatewayPasswordLabel.Show() + gatewayPasswordEntry.Show() + gatewayDomainLabel.Show() + gatewayDomainEntry.Show() + } else { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + formContainer.Refresh() + } - // If user wants to store in 1Password, create the item - if storeTo1PasswordCheck.Checked && conn.Password != "" && !w.manager.IsOnePasswordReference(conn.Password) { - vault := vaultSelect.Selected - reference, err := w.manager.CreateOnePasswordItem(vault, conn.Name, conn.Username, conn.Password) - if err != nil { - dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) - return - } - // Replace password with 1Password reference - conn.Password = reference - dialog.ShowInformation("Success", fmt.Sprintf("Password stored in 1Password vault '%s'", vault), w.window) + gatewayCredentialsSelect.OnChanged = func(selected string) { + showGatewayDifferentCreds(selected == "Use a different username and password") + } + + useGatewayCheck.OnChanged = func(checked bool) { + if checked { + gatewayUsageLabel.Show() + gatewayUsageSelect.Show() + gatewayHostnameLabel.Show() + gatewayHostnameEntry.Show() + gatewayCredentialsLabel.Show() + gatewayCredentialsSelect.Show() + gatewayProfileLabel.Show() + gatewayProfileSelect.Show() + gatewayBypassLocalLabel.Show() + gatewayBypassLocalCheck.Show() + showGatewayDifferentCreds(gatewayCredentialsSelect.Selected == "Use a different username and password") + } else { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + formContainer.Refresh() + } + + protocolSelect.OnChanged = func(selected string) { + if selected == "rdp" { + gatewayCheckLabel.Show() + useGatewayCheck.Show() + authLevelLabel.Show() + authLevelSelect.Show() + credSSPLabel.Show() + useCredSSPCheck.Show() + } else { + hideAllGatewayRows() + useGatewayCheck.SetChecked(false) + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + } + formContainer.Refresh() + } + + labelMinWidth := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}).MinSize().Width + formContainer = container.New(&stableFormLayout{labelWidth: labelMinWidth}, + widget.NewLabelWithStyle("Name", fyne.TextAlignTrailing, fyne.TextStyle{}), nameEntry, + widget.NewLabelWithStyle("Protocol", fyne.TextAlignTrailing, fyne.TextStyle{}), protocolSelect, + widget.NewLabelWithStyle("Host", fyne.TextAlignTrailing, fyne.TextStyle{}), hostEntry, + widget.NewLabelWithStyle("Port", fyne.TextAlignTrailing, fyne.TextStyle{}), portEntry, + widget.NewLabelWithStyle("Username", fyne.TextAlignTrailing, fyne.TextStyle{}), usernameEntry, + widget.NewLabelWithStyle("Password", fyne.TextAlignTrailing, fyne.TextStyle{}), passwordEntry, + widget.NewLabelWithStyle("Domain", fyne.TextAlignTrailing, fyne.TextStyle{}), domainEntry, + widget.NewLabelWithStyle("Description", fyne.TextAlignTrailing, fyne.TextStyle{}), descriptionEntry, + widget.NewLabelWithStyle("Folder", fyne.TextAlignTrailing, fyne.TextStyle{}), folderSelect, + authLevelLabel, authLevelSelect, + credSSPLabel, useCredSSPCheck, + gatewayCheckLabel, useGatewayCheck, + gatewayUsageLabel, gatewayUsageSelect, + gatewayHostnameLabel, gatewayHostnameEntry, + gatewayCredentialsLabel, gatewayCredentialsSelect, + gatewayUsernameLabel, gatewayUsernameEntry, + gatewayPasswordLabel, gatewayPasswordEntry, + gatewayDomainLabel, gatewayDomainEntry, + gatewayProfileLabel, gatewayProfileSelect, + gatewayBypassLocalLabel, gatewayBypassLocalCheck, + widget.NewLabel(""), storeTo1PasswordCheck, + vaultLabel, vaultSelect, + ) + + scroll := container.NewScroll(container.NewPadded(formContainer)) + scroll.SetMinSize(fyne.NewSize(500, 500)) + d := dialog.NewCustomConfirm("Add Connection", "Submit", "Close", scroll, func(confirmed bool) { + if !confirmed { + return + } + + conn := models.NewConnection(nameEntry.Text, models.Protocol(protocolSelect.Selected)) + conn.Host = hostEntry.Text + conn.Username = usernameEntry.Text + conn.Password = passwordEntry.Text + conn.Domain = domainEntry.Text + conn.Description = descriptionEntry.Text + conn.Created = time.Now().Format(time.RFC3339) + conn.Modified = conn.Created + + if conn.Protocol == models.ProtocolRDP { + conn.UseCredSSP = useCredSSPCheck.Checked + switch authLevelSelect.Selected { + case "Don't connect if authentication fails": + conn.RDPAuthenticationLevel = "fail" + case "Warn me if authentication fails": + conn.RDPAuthenticationLevel = "warn" + default: + conn.RDPAuthenticationLevel = "always" } + } - // Add to selected folder or root - selectedFolder := folderSelect.Selected - if selectedFolder == "(Root)" { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + conn.UseGateway = useGatewayCheck.Checked + if conn.UseGateway { + conn.GatewayHostname = gatewayHostnameEntry.Text + switch gatewayUsageSelect.Selected { + case "Never": + conn.GatewayUsageMethod = "never" + case "Detect": + conn.GatewayUsageMethod = "detect" + default: + conn.GatewayUsageMethod = "always" + } + if gatewayProfileSelect.Selected == "Use system default gateway profile" { + conn.GatewayProfileUsageMethod = "default" } else { - // Find the folder and add to its children - folder := folderMap[selectedFolder] - if folder != nil { - folder.Children = append(folder.Children, conn) + conn.GatewayProfileUsageMethod = "explicit" + } + conn.GatewayBypassIfLocal = gatewayBypassLocalCheck.Checked + switch gatewayCredentialsSelect.Selected { + case "Use a different username and password": + conn.GatewayCredentials = "different" + conn.GatewayDomain = gatewayDomainEntry.Text + gwUser := gatewayUsernameEntry.Text + gwPass := gatewayPasswordEntry.Text + if storeTo1PasswordCheck.Checked && gwPass != "" && !w.manager.IsOnePasswordReference(gwPass) { + vault := vaultSelect.Selected + gwTitle := nameEntry.Text + " Gateway" + ref, err := w.manager.CreateOnePasswordItem(vault, gwTitle, gwUser, gwPass) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item for gateway: %w", err), w.window) + return + } + conn.GatewayPassword = ref + conn.GatewayUsername = fmt.Sprintf("op://%s/%s/username", vault, url.PathEscape(gwTitle)) + } else { + conn.GatewayUsername = gwUser + conn.GatewayPassword = gwPass } + case "Use a smart card": + conn.GatewayCredentials = "smartcard" + default: + conn.GatewayCredentials = "same" } + } - if err := w.manager.Save(); err != nil { - dialog.ShowError(err, w.window) + if portEntry.Text != "" { + if port, err := strconv.Atoi(portEntry.Text); err == nil { + conn.Port = port + } + } else { + conn.Port = conn.Protocol.GetDefaultPort() + } + + // If user wants to store in 1Password, create the item + if storeTo1PasswordCheck.Checked && conn.Password != "" && !w.manager.IsOnePasswordReference(conn.Password) { + vault := vaultSelect.Selected + reference, err := w.manager.CreateOnePasswordItem(vault, conn.Name, conn.Username, conn.Password) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) return } + conn.Password = reference + } - w.refreshTree() - dialog.ShowInformation("Success", "Connection added successfully", w.window) - }, - } + // Add to selected folder or root + selectedFolder := folderSelect.Selected + if selectedFolder == "(Root)" { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + } else { + folder := folderMap[selectedFolder] + if folder != nil { + folder.Children = append(folder.Children, conn) + } + } + + if err := w.manager.Save(); err != nil { + dialog.ShowError(err, w.window) + return + } - d := dialog.NewCustom("Add Connection", "Close", form, w.window) - d.Resize(fyne.NewSize(500, 700)) + w.refreshTree() + }, w.window) + d.Resize(fyne.NewSize(620, 600)) d.Show() } @@ -303,108 +590,407 @@ func (w *MainWindow) showEditConnectionDialog(conn *models.Connection) { folderSelect := widget.NewSelect(folderNames, nil) folderSelect.SetSelected(currentFolder) + // RDP-specific widgets + authLevelSelect := widget.NewSelect([]string{"Always connect, even if authentication fails", "Don't connect if authentication fails", "Warn me if authentication fails"}, nil) + switch conn.RDPAuthenticationLevel { + case "fail": + authLevelSelect.SetSelected("Don't connect if authentication fails") + case "warn": + authLevelSelect.SetSelected("Warn me if authentication fails") + default: + authLevelSelect.SetSelected("Always connect, even if authentication fails") + } + authLevelLabel := widget.NewLabelWithStyle("Auth Level", fyne.TextAlignTrailing, fyne.TextStyle{}) + + useCredSSPCheck := widget.NewCheck("Use CredSSP", nil) + useCredSSPCheck.SetChecked(conn.UseCredSSP) + credSSPLabel := widget.NewLabel("") + + if conn.Protocol != "rdp" { + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + } + + // RD Gateway widgets + useGatewayCheck := widget.NewCheck("Use RD Gateway", nil) + useGatewayCheck.SetChecked(conn.UseGateway) + + gatewayUsageSelect := widget.NewSelect([]string{"Never", "Always", "Detect"}, nil) + switch conn.GatewayUsageMethod { + case "never": + gatewayUsageSelect.SetSelected("Never") + case "detect": + gatewayUsageSelect.SetSelected("Detect") + default: + gatewayUsageSelect.SetSelected("Always") + } + + gatewayHostnameEntry := widget.NewEntry() + gatewayHostnameEntry.SetPlaceHolder("gateway.example.com") + gatewayHostnameEntry.SetText(conn.GatewayHostname) + + gatewayCredentialsSelect := widget.NewSelect([]string{ + "Use the same username and password", + "Use a different username and password", + "Use a smart card", + }, nil) + switch conn.GatewayCredentials { + case "different": + gatewayCredentialsSelect.SetSelected("Use a different username and password") + case "smartcard": + gatewayCredentialsSelect.SetSelected("Use a smart card") + default: + gatewayCredentialsSelect.SetSelected("Use the same username and password") + } + + gatewayUsernameEntry := widget.NewEntry() + gatewayUsernameEntry.SetPlaceHolder("username or op://vault/item/field") + gatewayUsernameEntry.SetText(conn.GatewayUsername) + + gatewayPasswordEntry := widget.NewEntry() + gatewayPasswordEntry.SetPlaceHolder("password or op://vault/item/field") + gatewayPasswordEntry.SetText(conn.GatewayPassword) + + gatewayDomainEntry := widget.NewEntry() + gatewayDomainEntry.SetPlaceHolder("gateway domain") + gatewayDomainEntry.SetText(conn.GatewayDomain) + + gatewayProfileSelect := widget.NewSelect([]string{"Use settings from this connection", "Use system default gateway profile"}, nil) + if conn.GatewayProfileUsageMethod == "default" { + gatewayProfileSelect.SetSelected("Use system default gateway profile") + } else { + gatewayProfileSelect.SetSelected("Use settings from this connection") + } + + gatewayBypassLocalCheck := widget.NewCheck("Bypass gateway for local addresses", nil) + gatewayBypassLocalCheck.SetChecked(conn.GatewayBypassIfLocal) + + gatewayUsageLabel := widget.NewLabelWithStyle("Gateway Usage", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayHostnameLabel := widget.NewLabelWithStyle("Gateway Hostname", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayCredentialsLabel := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayUsernameLabel := widget.NewLabelWithStyle("Gateway Username", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayPasswordLabel := widget.NewLabelWithStyle("Gateway Password", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayDomainLabel := widget.NewLabelWithStyle("Gateway Domain", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayProfileLabel := widget.NewLabelWithStyle("Gateway Profile", fyne.TextAlignTrailing, fyne.TextStyle{}) + gatewayBypassLocalLabel := widget.NewLabel("") + + // Set initial gateway row visibility + if !conn.UseGateway { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } else if conn.GatewayCredentials != "different" { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + // 1Password integration for edit storeTo1PasswordCheck := widget.NewCheck("Push password to 1Password", nil) vaultSelect := widget.NewSelect([]string{"DevOps", "Private", "Employee"}, nil) vaultSelect.SetSelected("DevOps") + vaultLabel := widget.NewLabelWithStyle("Vault", fyne.TextAlignTrailing, fyne.TextStyle{}) + vaultLabel.Hide() vaultSelect.Hide() + // Show gateway checkbox only when protocol is RDP + gatewayCheckLabel := widget.NewLabel("") + if conn.Protocol != "rdp" { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + } + + var formContainer *fyne.Container + + hideAllGatewayRows := func() { + gatewayCheckLabel.Hide() + useGatewayCheck.Hide() + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + storeTo1PasswordCheck.OnChanged = func(checked bool) { if checked { + vaultLabel.Show() vaultSelect.Show() } else { + vaultLabel.Hide() vaultSelect.Hide() } + formContainer.Refresh() } - form := &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Name", Widget: nameEntry}, - {Text: "Protocol", Widget: protocolSelect}, - {Text: "Host", Widget: hostEntry}, - {Text: "Port", Widget: portEntry}, - {Text: "Username", Widget: usernameEntry}, - {Text: "Password", Widget: passwordEntry}, - {Text: "Domain", Widget: domainEntry}, - {Text: "Description", Widget: descriptionEntry}, - {Text: "Folder", Widget: folderSelect}, - {Text: "", Widget: storeTo1PasswordCheck}, - {Text: "Vault", Widget: vaultSelect}, - }, - OnSubmit: func() { - // If user wants to push password to 1Password - if storeTo1PasswordCheck.Checked && passwordEntry.Text != "" && !w.manager.IsOnePasswordReference(passwordEntry.Text) { - vault := vaultSelect.Selected - reference, err := w.manager.CreateOnePasswordItem(vault, nameEntry.Text, usernameEntry.Text, passwordEntry.Text) - if err != nil { - dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) - return - } - // Replace password with 1Password reference - conn.Password = reference - dialog.ShowInformation("Success", fmt.Sprintf("Password stored in 1Password vault '%s'", vault), w.window) - } else { - conn.Password = passwordEntry.Text - } + showGatewayDifferentCreds := func(show bool) { + if show { + gatewayUsernameLabel.Show() + gatewayUsernameEntry.Show() + gatewayPasswordLabel.Show() + gatewayPasswordEntry.Show() + gatewayDomainLabel.Show() + gatewayDomainEntry.Show() + } else { + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + } + formContainer.Refresh() + } - conn.Name = nameEntry.Text - conn.Protocol = models.Protocol(protocolSelect.Selected) - conn.Host = hostEntry.Text - conn.Username = usernameEntry.Text - conn.Domain = domainEntry.Text - conn.Description = descriptionEntry.Text - conn.Modified = time.Now().Format(time.RFC3339) + gatewayCredentialsSelect.OnChanged = func(selected string) { + showGatewayDifferentCreds(selected == "Use a different username and password") + } - if port, err := strconv.Atoi(portEntry.Text); err == nil { - conn.Port = port + useGatewayCheck.OnChanged = func(checked bool) { + if checked { + gatewayUsageLabel.Show() + gatewayUsageSelect.Show() + gatewayHostnameLabel.Show() + gatewayHostnameEntry.Show() + gatewayCredentialsLabel.Show() + gatewayCredentialsSelect.Show() + gatewayProfileLabel.Show() + gatewayProfileSelect.Show() + gatewayBypassLocalLabel.Show() + gatewayBypassLocalCheck.Show() + showGatewayDifferentCreds(gatewayCredentialsSelect.Selected == "Use a different username and password") + } else { + gatewayUsageLabel.Hide() + gatewayUsageSelect.Hide() + gatewayHostnameLabel.Hide() + gatewayHostnameEntry.Hide() + gatewayCredentialsLabel.Hide() + gatewayCredentialsSelect.Hide() + gatewayUsernameLabel.Hide() + gatewayUsernameEntry.Hide() + gatewayPasswordLabel.Hide() + gatewayPasswordEntry.Hide() + gatewayDomainLabel.Hide() + gatewayDomainEntry.Hide() + gatewayProfileLabel.Hide() + gatewayProfileSelect.Hide() + gatewayBypassLocalLabel.Hide() + gatewayBypassLocalCheck.Hide() + } + formContainer.Refresh() + } + + protocolSelect.OnChanged = func(selected string) { + if selected == "rdp" { + gatewayCheckLabel.Show() + useGatewayCheck.Show() + authLevelLabel.Show() + authLevelSelect.Show() + credSSPLabel.Show() + useCredSSPCheck.Show() + } else { + hideAllGatewayRows() + useGatewayCheck.SetChecked(false) + authLevelLabel.Hide() + authLevelSelect.Hide() + credSSPLabel.Hide() + useCredSSPCheck.Hide() + } + formContainer.Refresh() + } + + labelMinWidth := widget.NewLabelWithStyle("Gateway Credentials", fyne.TextAlignTrailing, fyne.TextStyle{}).MinSize().Width + formContainer = container.New(&stableFormLayout{labelWidth: labelMinWidth}, + widget.NewLabelWithStyle("Name", fyne.TextAlignTrailing, fyne.TextStyle{}), nameEntry, + widget.NewLabelWithStyle("Protocol", fyne.TextAlignTrailing, fyne.TextStyle{}), protocolSelect, + widget.NewLabelWithStyle("Host", fyne.TextAlignTrailing, fyne.TextStyle{}), hostEntry, + widget.NewLabelWithStyle("Port", fyne.TextAlignTrailing, fyne.TextStyle{}), portEntry, + widget.NewLabelWithStyle("Username", fyne.TextAlignTrailing, fyne.TextStyle{}), usernameEntry, + widget.NewLabelWithStyle("Password", fyne.TextAlignTrailing, fyne.TextStyle{}), passwordEntry, + widget.NewLabelWithStyle("Domain", fyne.TextAlignTrailing, fyne.TextStyle{}), domainEntry, + widget.NewLabelWithStyle("Description", fyne.TextAlignTrailing, fyne.TextStyle{}), descriptionEntry, + widget.NewLabelWithStyle("Folder", fyne.TextAlignTrailing, fyne.TextStyle{}), folderSelect, + authLevelLabel, authLevelSelect, + credSSPLabel, useCredSSPCheck, + gatewayCheckLabel, useGatewayCheck, + gatewayUsageLabel, gatewayUsageSelect, + gatewayHostnameLabel, gatewayHostnameEntry, + gatewayCredentialsLabel, gatewayCredentialsSelect, + gatewayUsernameLabel, gatewayUsernameEntry, + gatewayPasswordLabel, gatewayPasswordEntry, + gatewayDomainLabel, gatewayDomainEntry, + gatewayProfileLabel, gatewayProfileSelect, + gatewayBypassLocalLabel, gatewayBypassLocalCheck, + widget.NewLabel(""), storeTo1PasswordCheck, + vaultLabel, vaultSelect, + ) + + scroll := container.NewScroll(container.NewPadded(formContainer)) + scroll.SetMinSize(fyne.NewSize(500, 500)) + d := dialog.NewCustomConfirm("Edit Connection", "Submit", "Close", scroll, func(confirmed bool) { + if !confirmed { + return + } + + // If user wants to push password to 1Password + if storeTo1PasswordCheck.Checked && passwordEntry.Text != "" && !w.manager.IsOnePasswordReference(passwordEntry.Text) { + vault := vaultSelect.Selected + reference, err := w.manager.CreateOnePasswordItem(vault, nameEntry.Text, usernameEntry.Text, passwordEntry.Text) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item: %w", err), w.window) + return } + conn.Password = reference + } else { + conn.Password = passwordEntry.Text + } - // Handle folder change - selectedFolder := folderSelect.Selected - if selectedFolder != currentFolder { - // Remove from old parent - if parentFolder != nil { - // Remove from parent's children - for i, child := range parentFolder.Children { - if child == conn { - parentFolder.Children = append(parentFolder.Children[:i], parentFolder.Children[i+1:]...) - break - } + conn.Name = nameEntry.Text + conn.Protocol = models.Protocol(protocolSelect.Selected) + conn.Host = hostEntry.Text + conn.Username = usernameEntry.Text + conn.Domain = domainEntry.Text + conn.Description = descriptionEntry.Text + conn.Modified = time.Now().Format(time.RFC3339) + + if conn.Protocol == models.ProtocolRDP { + conn.UseCredSSP = useCredSSPCheck.Checked + switch authLevelSelect.Selected { + case "Don't connect if authentication fails": + conn.RDPAuthenticationLevel = "fail" + case "Warn me if authentication fails": + conn.RDPAuthenticationLevel = "warn" + default: + conn.RDPAuthenticationLevel = "always" + } + } + + conn.UseGateway = useGatewayCheck.Checked + if conn.UseGateway { + conn.GatewayHostname = gatewayHostnameEntry.Text + switch gatewayUsageSelect.Selected { + case "Never": + conn.GatewayUsageMethod = "never" + case "Detect": + conn.GatewayUsageMethod = "detect" + default: + conn.GatewayUsageMethod = "always" + } + if gatewayProfileSelect.Selected == "Use system default gateway profile" { + conn.GatewayProfileUsageMethod = "default" + } else { + conn.GatewayProfileUsageMethod = "explicit" + } + conn.GatewayBypassIfLocal = gatewayBypassLocalCheck.Checked + switch gatewayCredentialsSelect.Selected { + case "Use a different username and password": + conn.GatewayCredentials = "different" + conn.GatewayDomain = gatewayDomainEntry.Text + gwUser := gatewayUsernameEntry.Text + gwPass := gatewayPasswordEntry.Text + if storeTo1PasswordCheck.Checked && gwPass != "" && !w.manager.IsOnePasswordReference(gwPass) { + vault := vaultSelect.Selected + gwTitle := nameEntry.Text + " Gateway" + ref, err := w.manager.CreateOnePasswordItem(vault, gwTitle, gwUser, gwPass) + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to create 1Password item for gateway: %w", err), w.window) + return } + conn.GatewayPassword = ref + conn.GatewayUsername = fmt.Sprintf("op://%s/%s/username", vault, url.PathEscape(gwTitle)) } else { - // Remove from root - for i, c := range w.manager.GetConfig().Connections { - if c == conn { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections[:i], w.manager.GetConfig().Connections[i+1:]...) - break - } - } + conn.GatewayUsername = gwUser + conn.GatewayPassword = gwPass } + case "Use a smart card": + conn.GatewayCredentials = "smartcard" + default: + conn.GatewayCredentials = "same" + } + } else { + conn.GatewayHostname = "" + conn.GatewayUsageMethod = "" + conn.GatewayCredentials = "" + conn.GatewayUsername = "" + conn.GatewayPassword = "" + conn.GatewayDomain = "" + conn.GatewayProfileUsageMethod = "" + conn.GatewayBypassIfLocal = false + } - // Add to new parent - if selectedFolder == "(Root)" { - w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) - } else { - newParent := folderMap[selectedFolder] - if newParent != nil { - newParent.Children = append(newParent.Children, conn) + if port, err := strconv.Atoi(portEntry.Text); err == nil { + conn.Port = port + } + + // Handle folder change + selectedFolder := folderSelect.Selected + if selectedFolder != currentFolder { + // Remove from old parent + if parentFolder != nil { + for i, child := range parentFolder.Children { + if child == conn { + parentFolder.Children = append(parentFolder.Children[:i], parentFolder.Children[i+1:]...) + break + } + } + } else { + for i, c := range w.manager.GetConfig().Connections { + if c == conn { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections[:i], w.manager.GetConfig().Connections[i+1:]...) + break } } } - if err := w.manager.Save(); err != nil { - dialog.ShowError(err, w.window) - return + // Add to new parent + if selectedFolder == "(Root)" { + w.manager.GetConfig().Connections = append(w.manager.GetConfig().Connections, conn) + } else { + newParent := folderMap[selectedFolder] + if newParent != nil { + newParent.Children = append(newParent.Children, conn) + } } + } - w.refreshTree() - w.updateDetailsPanel(conn) - dialog.ShowInformation("Success", "Connection updated successfully", w.window) - }, - } + if err := w.manager.Save(); err != nil { + dialog.ShowError(err, w.window) + return + } - d := dialog.NewCustom("Edit Connection", "Close", form, w.window) - d.Resize(fyne.NewSize(500, 700)) + w.refreshTree() + w.updateDetailsPanel(conn) + }, w.window) + d.Resize(fyne.NewSize(620, 600)) d.Show() } diff --git a/internal/gui/mainwindow.go b/internal/gui/mainwindow.go index 0b707cc..eefc927 100644 --- a/internal/gui/mainwindow.go +++ b/internal/gui/mainwindow.go @@ -358,8 +358,28 @@ func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { // Add action buttons details.Add(widget.NewLabel("")) - connectBtn := widget.NewButton("🚀 Connect", func() { - w.connectToConnection(conn) + + statusContainer := container.NewVBox() + var connectBtn *widget.Button + + connectBtn = widget.NewButton("🚀 Connect", func() { + connectBtn.Disable() + statusContainer.RemoveAll() + statusContainer.Add(widget.NewProgressBarInfinite()) + statusContainer.Refresh() + + go func() { + err := w.launcher.Launch(conn) + w.window.Canvas().Content().Refresh() + + if err != nil { + dialog.ShowError(fmt.Errorf("Failed to launch connection: %w", err), w.window) + } + + connectBtn.Enable() + statusContainer.RemoveAll() + statusContainer.Refresh() + }() }) connectBtn.Importance = widget.HighImportance @@ -374,6 +394,7 @@ func (w *MainWindow) updateDetailsPanel(conn *models.Connection) { actionButtons := container.NewGridWithColumns(3, connectBtn, editBtn, deleteBtn) details.Add(actionButtons) + details.Add(statusContainer) w.detailsCard.SetContent(details) } @@ -443,10 +464,7 @@ func (w *MainWindow) connectToSelected() { func (w *MainWindow) connectToConnection(conn *models.Connection) { if err := w.launcher.Launch(conn); err != nil { dialog.ShowError(fmt.Errorf("Failed to launch connection: %w", err), w.window) - return } - - dialog.ShowInformation("Connected", fmt.Sprintf("Launched connection to %s", conn.Name), w.window) } func (w *MainWindow) editSelected() { @@ -583,18 +601,20 @@ func (w *MainWindow) check1PasswordAuth() { return } - // Check if 1Password CLI is authenticated + // Only verify that the CLI is present in PATH. Running `op whoami` to + // check the live authentication status spawns a visible console window + // on Windows for ~2s at startup. Authentication errors surface when a + // reference is actually resolved in the launcher. opProvider := w.launcher.GetOnePasswordProvider() - if opProvider.IsEnabled() && !opProvider.IsAuthenticated() { - // Show a helpful dialog - content := widget.NewLabel(opProvider.GetAuthenticationInstructions()) + if !opProvider.IsEnabled() { + content := widget.NewLabel("Your connections use 1Password references (op://...) but the 1Password CLI (op) was not found in PATH. Install it and ensure it is on your PATH.") content.Wrapping = fyne.TextWrapWord scrollContainer := container.NewVScroll(content) - scrollContainer.SetMinSize(fyne.NewSize(600, 400)) + scrollContainer.SetMinSize(fyne.NewSize(600, 200)) dialog.ShowCustom( - "1Password CLI Not Authenticated", + "1Password CLI Not Installed", "OK", scrollContainer, w.window, diff --git a/internal/launcher/launcher.go b/internal/launcher/launcher.go index 3b7212e..cfb83f3 100644 --- a/internal/launcher/launcher.go +++ b/internal/launcher/launcher.go @@ -36,8 +36,15 @@ func (l *Launcher) Launch(conn *models.Connection) error { return fmt.Errorf("cannot launch a folder") } - // Resolve 1Password reference if needed (make a copy to avoid modifying the original) + // Resolve 1Password references if needed (make a copy to avoid modifying the original) resolvedConn := *conn + if l.onePasswordProvider.IsReference(conn.Username) { + resolved, err := l.onePasswordProvider.ResolveSecret(conn.Username) + if err != nil { + return fmt.Errorf("failed to resolve username from 1Password: %w", err) + } + resolvedConn.Username = resolved + } if l.onePasswordProvider.IsReference(conn.Password) { resolved, err := l.onePasswordProvider.ResolveSecret(conn.Password) if err != nil { @@ -53,6 +60,22 @@ func (l *Launcher) Launch(conn *models.Connection) error { resolvedConn.Password = resolved } } + if l.onePasswordProvider.IsReference(conn.GatewayUsername) { + resolved, err := l.onePasswordProvider.ResolveSecret(conn.GatewayUsername) + if err != nil { + return fmt.Errorf("failed to resolve gateway username from 1Password: %w", err) + } + resolvedConn.GatewayUsername = resolved + } + if l.onePasswordProvider.IsReference(conn.GatewayPassword) { + resolved, err := l.onePasswordProvider.ResolveSecret(conn.GatewayPassword) + if err != nil { + fmt.Printf("Warning: Failed to resolve gateway password from 1Password: %v (gateway will prompt for credentials)\n", err) + resolvedConn.GatewayPassword = "" + } else { + resolvedConn.GatewayPassword = resolved + } + } switch resolvedConn.Protocol { case models.ProtocolSSH: @@ -310,6 +333,23 @@ func (l *Launcher) launchRDP(conn *models.Connection) error { fmt.Printf("Warning: Failed to store credentials: %v\n", err) } } + if conn.UseGateway && conn.GatewayHostname != "" { + if conn.GatewayCredentials == "different" && conn.GatewayPassword != "" { + if err := l.storeGatewayCredential(conn); err != nil { + fmt.Printf("Warning: Failed to store gateway credentials: %v\n", err) + } + } else if (conn.GatewayCredentials == "same" || conn.GatewayCredentials == "") && conn.Username != "" && conn.Password != "" { + gwUsername := conn.Username + if conn.Domain != "" { + gwUsername = fmt.Sprintf("%s\\%s", conn.Domain, conn.Username) + } + // mstsc looks up RD Gateway credentials by bare hostname (no TERMSRV/ prefix), + // unlike RDP host credentials which use TERMSRV/. + if err := writeWindowsCredential(conn.GatewayHostname, gwUsername, conn.Password); err != nil { + fmt.Printf("Warning: Failed to store gateway credentials: %v\n", err) + } + } + } // Create a temporary .rdp file with connection settings rdpFile, err := l.createRDPFile(conn, target) @@ -524,22 +564,81 @@ func (l *Launcher) createRDPFile(conn *models.Connection, target string) (string rdpContent += "redirectclipboard:i:1\r\n" rdpContent += "redirectposdevices:i:0\r\n" rdpContent += "autoreconnection enabled:i:1\r\n" - rdpContent += "authentication level:i:2\r\n" + + // Authentication level: 0 = always connect, 1 = don't connect on failure, 2 = warn + authLevel := 0 + switch conn.RDPAuthenticationLevel { + case "fail": + authLevel = 1 + case "warn": + authLevel = 2 + } + rdpContent += fmt.Sprintf("authentication level:i:%d\r\n", authLevel) + rdpContent += "prompt for credentials:i:0\r\n" rdpContent += "negotiate security layer:i:1\r\n" + + // Use CredSSP + credSSP := 1 + if !conn.UseCredSSP { + credSSP = 0 + } + rdpContent += fmt.Sprintf("use cred ssp:i:%d\r\n", credSSP) + rdpContent += "remoteapplicationmode:i:0\r\n" rdpContent += "alternate shell:s:\r\n" rdpContent += "shell working directory:s:\r\n" - rdpContent += "gatewayhostname:s:\r\n" - rdpContent += "gatewayusagemethod:i:4\r\n" - rdpContent += "gatewaycredentialssource:i:4\r\n" - rdpContent += "gatewayprofileusagemethod:i:0\r\n" + if conn.UseGateway && conn.GatewayHostname != "" { + rdpContent += fmt.Sprintf("gatewayhostname:s:%s\r\n", conn.GatewayHostname) + + usageMethod := 1 // default: always + switch conn.GatewayUsageMethod { + case "detect": + usageMethod = 2 + case "never": + usageMethod = 4 + } + rdpContent += fmt.Sprintf("gatewayusagemethod:i:%d\r\n", usageMethod) + + credSource := 0 // default: NTLM password, uses Credential Manager silently + switch conn.GatewayCredentials { + case "smartcard": + credSource = 1 + } + rdpContent += fmt.Sprintf("gatewaycredentialssource:i:%d\r\n", credSource) + + if conn.GatewayCredentials == "different" && conn.GatewayUsername != "" { + rdpContent += fmt.Sprintf("gatewayusername:s:%s\r\n", conn.GatewayUsername) + if conn.GatewayDomain != "" { + rdpContent += fmt.Sprintf("gatewaydomain:s:%s\r\n", conn.GatewayDomain) + } + } + } else { + rdpContent += "gatewayhostname:s:\r\n" + rdpContent += "gatewayusagemethod:i:4\r\n" + rdpContent += "gatewaycredentialssource:i:4\r\n" + } + + // Gateway profile usage method: 0 = use default profile, 1 = use explicit settings (default) + profileMethod := 1 + if conn.GatewayProfileUsageMethod == "default" { + profileMethod = 0 + } + rdpContent += fmt.Sprintf("gatewayprofileusagemethod:i:%d\r\n", profileMethod) + rdpContent += "promptcredentialonce:i:0\r\n" rdpContent += "gatewaybrokeringtype:i:0\r\n" - rdpContent += "use redirection server name:i:0\r\n" + rdpContent += "use redirection server name:i:1\r\n" rdpContent += "rdgiskdcproxy:i:0\r\n" rdpContent += "kdcproxyname:s:\r\n" + // Bypass gateway for local addresses + bypassLocal := 0 + if conn.GatewayBypassIfLocal { + bypassLocal = 1 + } + rdpContent += fmt.Sprintf("gatewayisbypassiflocal:i:%d\r\n", bypassLocal) + // Resolution settings if conn.Resolution != "" { // Parse resolution like "1920x1080" @@ -601,17 +700,21 @@ func (l *Launcher) storeWindowsCredential(conn *models.Connection) error { username = fmt.Sprintf("%s\\%s", conn.Domain, conn.Username) } - // Use cmdkey to store the credential - // cmdkey /generic:TERMSRV/hostname /user:username /pass:password - cmd := exec.Command("cmdkey", "/generic:TERMSRV/"+target, "/user:"+username, "/pass:"+conn.Password) - hideConsoleWindow(cmd) + return writeWindowsCredential("TERMSRV/"+target, username, conn.Password) +} + +// storeGatewayCredential stores RD Gateway credentials in Windows Credential Manager +func (l *Launcher) storeGatewayCredential(conn *models.Connection) error { + if runtime.GOOS != "windows" { + return nil + } - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("cmdkey failed: %w, output: %s", err, string(output)) + username := conn.GatewayUsername + if conn.GatewayDomain != "" { + username = fmt.Sprintf("%s\\%s", conn.GatewayDomain, conn.GatewayUsername) } - return nil + return writeWindowsCredential("TERMSRV/"+conn.GatewayHostname, username, conn.GatewayPassword) } // RemoveWindowsCredential removes RDP credentials from Windows Credential Manager @@ -630,20 +733,7 @@ func (l *Launcher) RemoveWindowsCredential(conn *models.Connection) error { target = fmt.Sprintf("%s:%d", conn.Host, port) } - // Use cmdkey to delete the credential - // cmdkey /delete:TERMSRV/hostname - cmd := exec.Command("cmdkey", "/delete:TERMSRV/"+target) - hideConsoleWindow(cmd) - - output, err := cmd.CombinedOutput() - if err != nil { - // Don't return error if credential doesn't exist - if !strings.Contains(string(output), "not found") { - return fmt.Errorf("cmdkey delete failed: %w, output: %s", err, string(output)) - } - } - - return nil + return deleteWindowsCredential("TERMSRV/" + target) } // CleanupAllCredentials removes all MremoteGO-stored credentials from Windows Credential Manager diff --git a/internal/launcher/launcher_unix.go b/internal/launcher/launcher_unix.go index 0ce27cc..41aa414 100644 --- a/internal/launcher/launcher_unix.go +++ b/internal/launcher/launcher_unix.go @@ -11,3 +11,6 @@ import ( func hideConsoleWindow(cmd *exec.Cmd) { // Nothing to do on Unix-like systems } + +func writeWindowsCredential(target, username, password string) error { return nil } +func deleteWindowsCredential(target string) error { return nil } diff --git a/internal/launcher/launcher_windows.go b/internal/launcher/launcher_windows.go index c8d8a3b..6a56d41 100644 --- a/internal/launcher/launcher_windows.go +++ b/internal/launcher/launcher_windows.go @@ -4,8 +4,11 @@ package launcher import ( + "fmt" "os/exec" + "runtime" "syscall" + "unsafe" ) // hideConsoleWindow sets the command attributes to hide console windows on Windows @@ -15,3 +18,84 @@ func hideConsoleWindow(cmd *exec.Cmd) { CreationFlags: 0x08000000, // CREATE_NO_WINDOW } } + +// writeWindowsCredential stores a generic credential in Windows Credential Manager +// using CredWriteW directly, avoiding cmdkey's argument-parsing issues with +// usernames or passwords that contain spaces or special characters. +func writeWindowsCredential(target, username, password string) error { + credWriteW := syscall.NewLazyDLL("advapi32.dll").NewProc("CredWriteW") + + targetPtr, err := syscall.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("invalid target name: %w", err) + } + userPtr, err := syscall.UTF16PtrFromString(username) + if err != nil { + return fmt.Errorf("invalid username: %w", err) + } + + // Encode password as UTF-16LE bytes without null terminator, as expected by + // Windows Credential Manager for RDP credentials consumed by mstsc. + passUTF16, err := syscall.UTF16FromString(password) + if err != nil { + return fmt.Errorf("invalid password: %w", err) + } + passUTF16 = passUTF16[:len(passUTF16)-1] // strip null terminator + passBlobSize := uint32(len(passUTF16) * 2) + + // CREDENTIALW mirrors the Win32 CREDENTIALW struct layout. + type credentialW struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten [2]uint32 + CredentialBlobSize uint32 + CredentialBlob uintptr + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 + } + + var blobPtr uintptr + if passBlobSize > 0 { + blobPtr = uintptr(unsafe.Pointer(&passUTF16[0])) + } + + cred := credentialW{ + Type: 2, // CRED_TYPE_DOMAIN_PASSWORD — required for mstsc to find credentials + TargetName: targetPtr, + UserName: userPtr, + CredentialBlobSize: passBlobSize, + CredentialBlob: blobPtr, + Persist: 2, // CRED_PERSIST_LOCAL_MACHINE + } + + ret, _, callErr := credWriteW.Call(uintptr(unsafe.Pointer(&cred)), 0) + runtime.KeepAlive(passUTF16) // prevent GC until after the syscall + if ret == 0 { + return fmt.Errorf("CredWriteW failed: %w", callErr) + } + return nil +} + +// deleteWindowsCredential removes a generic credential from Windows Credential Manager. +// Returns nil if the credential does not exist. +func deleteWindowsCredential(target string) error { + credDeleteW := syscall.NewLazyDLL("advapi32.dll").NewProc("CredDeleteW") + + targetPtr, err := syscall.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("invalid target name: %w", err) + } + + const credTypeDomainPassword = 2 + const errorNotFound = syscall.Errno(1168) // ERROR_NOT_FOUND + ret, _, callErr := credDeleteW.Call(uintptr(unsafe.Pointer(targetPtr)), credTypeDomainPassword, 0) + if ret == 0 && callErr != errorNotFound { + return fmt.Errorf("CredDeleteW failed: %w", callErr) + } + return nil +} diff --git a/internal/secrets/onepassword.go b/internal/secrets/onepassword.go index 8b1ee06..9ee24b9 100644 --- a/internal/secrets/onepassword.go +++ b/internal/secrets/onepassword.go @@ -21,9 +21,8 @@ func NewOnePasswordProvider() *OnePasswordProvider { // isOnePasswordCLIAvailable checks if the 1Password CLI (op) is installed func isOnePasswordCLIAvailable() bool { - cmd := exec.Command("op", "--version") - hideConsoleWindow(cmd) - return cmd.Run() == nil + _, err := exec.LookPath("op") + return err == nil } // IsEnabled returns whether 1Password CLI is available diff --git a/pkg/models/connection.go b/pkg/models/connection.go index 133072e..4b970d8 100644 --- a/pkg/models/connection.go +++ b/pkg/models/connection.go @@ -35,10 +35,22 @@ type Connection struct { Children []*Connection `yaml:"children,omitempty"` // Advanced options - UseCredSSP bool `yaml:"use_credssp,omitempty"` - ColorDepth int `yaml:"color_depth,omitempty"` // For RDP - Resolution string `yaml:"resolution,omitempty"` // For RDP - ExtraArgs string `yaml:"extra_args,omitempty"` // Additional protocol-specific args + UseCredSSP bool `yaml:"use_credssp,omitempty"` + RDPAuthenticationLevel string `yaml:"rdp_authentication_level,omitempty"` // "none", "prompt", "required" + ColorDepth int `yaml:"color_depth,omitempty"` // For RDP + Resolution string `yaml:"resolution,omitempty"` // For RDP + ExtraArgs string `yaml:"extra_args,omitempty"` // Additional protocol-specific args + + // RD Gateway options (RDP only) + UseGateway bool `yaml:"use_gateway,omitempty"` + GatewayUsageMethod string `yaml:"gateway_usage_method,omitempty"` // "never", "always", "detect" + GatewayHostname string `yaml:"gateway_hostname,omitempty"` + GatewayCredentials string `yaml:"gateway_credentials,omitempty"` // "same", "different", "smartcard" + GatewayUsername string `yaml:"gateway_username,omitempty"` + GatewayPassword string `yaml:"gateway_password,omitempty"` + GatewayDomain string `yaml:"gateway_domain,omitempty"` + GatewayProfileUsageMethod string `yaml:"gateway_profile_usage_method,omitempty"` // "default", "explicit" + GatewayBypassIfLocal bool `yaml:"gateway_bypass_if_local,omitempty"` // Metadata Tags []string `yaml:"tags,omitempty"` @@ -118,22 +130,32 @@ func (c *Connection) DeepCopy() *Connection { } connCopy := &Connection{ - Name: c.Name, - Type: c.Type, - Protocol: c.Protocol, - Host: c.Host, - Port: c.Port, - Username: c.Username, - Password: c.Password, - Domain: c.Domain, - Description: c.Description, - UseCredSSP: c.UseCredSSP, - ColorDepth: c.ColorDepth, - Resolution: c.Resolution, - ExtraArgs: c.ExtraArgs, - Notes: c.Notes, - Created: c.Created, - Modified: c.Modified, + Name: c.Name, + Type: c.Type, + Protocol: c.Protocol, + Host: c.Host, + Port: c.Port, + Username: c.Username, + Password: c.Password, + Domain: c.Domain, + Description: c.Description, + UseCredSSP: c.UseCredSSP, + RDPAuthenticationLevel: c.RDPAuthenticationLevel, + ColorDepth: c.ColorDepth, + Resolution: c.Resolution, + ExtraArgs: c.ExtraArgs, + UseGateway: c.UseGateway, + GatewayUsageMethod: c.GatewayUsageMethod, + GatewayHostname: c.GatewayHostname, + GatewayCredentials: c.GatewayCredentials, + GatewayUsername: c.GatewayUsername, + GatewayPassword: c.GatewayPassword, + GatewayDomain: c.GatewayDomain, + GatewayProfileUsageMethod: c.GatewayProfileUsageMethod, + GatewayBypassIfLocal: c.GatewayBypassIfLocal, + Notes: c.Notes, + Created: c.Created, + Modified: c.Modified, } // Deep copy tags diff --git a/temp/.claude/settings.local.json b/temp/.claude/settings.local.json new file mode 100644 index 0000000..7bdcf00 --- /dev/null +++ b/temp/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Read(//c/github/jaydenthorup/mremotego/**)", + "Bash(go build *)", + "Bash(go *)" + ] + } +} diff --git a/temp/ART1WV-DB002 org.rdp b/temp/ART1WV-DB002 org.rdp new file mode 100644 index 0000000..80b5188 --- /dev/null +++ b/temp/ART1WV-DB002 org.rdp @@ -0,0 +1,46 @@ +full address:s:ART1WV-DB002 +username:s:ambitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:2 +prompt for credentials:i:0 +negotiate security layer:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s://rdg.artegroep.nl +gatewayusagemethod:i:2 +gatewaycredentialssource:i:4 +gatewayprofileusagemethod:i:0 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:0 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:1 \ No newline at end of file diff --git a/temp/ART1WV-DB002 working.rdp b/temp/ART1WV-DB002 working.rdp new file mode 100644 index 0000000..944a606 --- /dev/null +++ b/temp/ART1WV-DB002 working.rdp @@ -0,0 +1,48 @@ +full address:s:ART1WV-DB002 +username:s:AmbitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:0 +prompt for credentials:i:1 +negotiate security layer:i:1 +use cred ssp:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:1 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:1 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:0 +password:s: \ No newline at end of file diff --git a/temp/ART1WV-DB002-minimal.rdp b/temp/ART1WV-DB002-minimal.rdp new file mode 100644 index 0000000..5e66b4e --- /dev/null +++ b/temp/ART1WV-DB002-minimal.rdp @@ -0,0 +1,8 @@ +full address:s:ART1WV-DB002 +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:0 +username:s:AmbitionIT2@arte.local +authentication level:i:0 +prompt for credentials:i:1 diff --git a/temp/ART1WV-DB002.rdp b/temp/ART1WV-DB002.rdp new file mode 100644 index 0000000..944a606 --- /dev/null +++ b/temp/ART1WV-DB002.rdp @@ -0,0 +1,48 @@ +full address:s:ART1WV-DB002 +username:s:AmbitionIT2@arte.local +screen mode id:i:2 +use multimon:i:0 +session bpp:i:32 +compression:i:1 +keyboardhook:i:2 +audiocapturemode:i:0 +videoplaybackmode:i:1 +connection type:i:7 +networkautodetect:i:1 +bandwidthautodetect:i:1 +displayconnectionbar:i:1 +enableworkspacereconnect:i:0 +disable wallpaper:i:0 +allow font smoothing:i:1 +allow desktop composition:i:1 +disable full window drag:i:0 +disable menu anims:i:0 +disable themes:i:0 +disable cursor setting:i:0 +bitmapcachepersistenable:i:1 +audiomode:i:0 +redirectprinters:i:0 +redirectcomports:i:0 +redirectsmartcards:i:0 +redirectclipboard:i:1 +redirectposdevices:i:0 +autoreconnection enabled:i:1 +authentication level:i:0 +prompt for credentials:i:1 +negotiate security layer:i:1 +use cred ssp:i:1 +remoteapplicationmode:i:0 +alternate shell:s: +shell working directory:s: +gatewayhostname:s:rdg.artegroep.nl +gatewayusagemethod:i:1 +gatewaycredentialssource:i:0 +gatewayprofileusagemethod:i:1 +promptcredentialonce:i:1 +gatewaybrokeringtype:i:0 +use redirection server name:i:1 +rdgiskdcproxy:i:0 +kdcproxyname:s: +smart sizing:i:1 +gatewayisbypassiflocal:i:0 +password:s: \ No newline at end of file diff --git a/temp/ART1WV-DB002.xml b/temp/ART1WV-DB002.xml new file mode 100644 index 0000000..a6071ec --- /dev/null +++ b/temp/ART1WV-DB002.xml @@ -0,0 +1,31 @@ + + + + \ No newline at end of file diff --git a/temp/Recording 2026-04-21 132654.mp4 b/temp/Recording 2026-04-21 132654.mp4 new file mode 100644 index 0000000..1c53870 Binary files /dev/null and b/temp/Recording 2026-04-21 132654.mp4 differ diff --git a/temp/Screenshot 2026-04-21 113910.png b/temp/Screenshot 2026-04-21 113910.png new file mode 100644 index 0000000..ceca978 Binary files /dev/null and b/temp/Screenshot 2026-04-21 113910.png differ diff --git a/temp/Screenshot 2026-04-21 114829 (Warning).png b/temp/Screenshot 2026-04-21 114829 (Warning).png new file mode 100644 index 0000000..7d85a43 Binary files /dev/null and b/temp/Screenshot 2026-04-21 114829 (Warning).png differ