// Package api provides the Wails-bound API surface that the frontend calls. // All methods are called from JavaScript and must return JSON-serialisable types. package api import ( "context" "encoding/base64" "encoding/json" "fmt" "net" "path/filepath" "runtime" "sync" "time" "github.com/pquerna/otp/totp" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/exportimport" "github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/preferences" "github.com/andydixon/nexus/internal/secrets" "github.com/andydixon/nexus/internal/sessions" "github.com/andydixon/nexus/internal/sshconfig" "github.com/andydixon/nexus/internal/storage" "github.com/andydixon/nexus/internal/updater" "github.com/google/uuid" ) // appVersion is set at build time via ldflags. var appVersion = "dev" // Service is the main API surface exposed to the frontend via Wails bindings. type Service struct { store *storage.Store secretStore *secrets.SecretStore keychainStore *secrets.KeychainStore sessions *sessions.Manager auditLog *audit.Logger prefs *preferences.Manager ctx context.Context // secretChans maps request IDs to channels awaiting user secret input. secretMu sync.Mutex secretChans map[string]chan secretResponse } // NewService creates a Service wired to the given backing components. func NewService( store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, sess *sessions.Manager, auditLog *audit.Logger, prefs *preferences.Manager, ) *Service { return &Service{ store: store, secretStore: secretStore, keychainStore: keychainStore, sessions: sess, auditLog: auditLog, prefs: prefs, } } // SetContext stores the Wails runtime context and registers event listeners. func (s *Service) SetContext(ctx context.Context) { s.ctx = ctx s.RegisterSecretResponseListener() } // --------------------------------------------------------------------------- // Profile operations // --------------------------------------------------------------------------- // CreateProfile validates and persists a new connection profile. func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error) { if profile.ID == "" { profile.ID = uuid.New().String() } now := time.Now() profile.CreatedAt = now profile.UpdatedAt = now if profile.Tags == nil { profile.Tags = []string{} } if err := s.store.CreateProfile(&profile); err != nil { return models.Profile{}, fmt.Errorf("failed to create profile: %w", err) } s.auditLog.LogProfileCreate(profile.ID, profile.DisplayName) return profile, nil } // GetProfile retrieves a profile by ID. func (s *Service) GetProfile(id string) (models.Profile, error) { p, err := s.store.GetProfile(id) if err != nil { return models.Profile{}, fmt.Errorf("profile not found: %w", err) } return *p, nil } // UpdateProfile persists changes to an existing profile. func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) { profile.UpdatedAt = time.Now() if err := s.store.UpdateProfile(&profile); err != nil { return models.Profile{}, fmt.Errorf("failed to update profile: %w", err) } s.auditLog.LogProfileUpdate(profile.ID, profile.DisplayName) return profile, nil } // DeleteProfile removes a profile and its associated secrets. func (s *Service) DeleteProfile(id string) error { p, err := s.store.GetProfile(id) if err != nil { return fmt.Errorf("profile not found: %w", err) } if err := s.store.DeleteProfile(id); err != nil { return fmt.Errorf("failed to delete profile: %w", err) } // Best-effort secret cleanup. if s.secretStore != nil && s.secretStore.IsUnlocked() { _ = s.secretStore.DeleteProfileSecrets(id) } s.auditLog.LogProfileDelete(id, p.DisplayName) return nil } // ListProfiles returns all stored profiles. func (s *Service) ListProfiles() ([]models.Profile, error) { profiles, err := s.store.ListProfiles() if err != nil { return nil, err } return derefProfiles(profiles), nil } // SearchProfiles searches profiles by display name, host, or tags. func (s *Service) SearchProfiles(query string) ([]models.Profile, error) { profiles, err := s.store.SearchProfiles(query) if err != nil { return nil, err } return derefProfiles(profiles), nil } // ListProfilesByProtocol returns profiles filtered by protocol type. func (s *Service) ListProfilesByProtocol(protocol string) ([]models.Profile, error) { profiles, err := s.store.ListProfilesByProtocol(protocol) if err != nil { return nil, err } return derefProfiles(profiles), nil } // ListProfilesByTag returns profiles that contain the given tag. func (s *Service) ListProfilesByTag(tag string) ([]models.Profile, error) { profiles, err := s.store.ListProfilesByTag(tag) if err != nil { return nil, err } return derefProfiles(profiles), nil } // ListProfilesByFolder returns profiles in the specified folder. func (s *Service) ListProfilesByFolder(folder string) ([]models.Profile, error) { profiles, err := s.store.ListProfilesByFolder(folder) if err != nil { return nil, err } return derefProfiles(profiles), nil } // ListFavourites returns all favourite profiles. func (s *Service) ListFavourites() ([]models.Profile, error) { profiles, err := s.store.ListFavourites() if err != nil { return nil, err } return derefProfiles(profiles), nil } // SetFavourite updates the favourite status of a profile. func (s *Service) SetFavourite(id string, favourite bool) error { return s.store.SetFavourite(id, favourite) } // DuplicateProfile creates a copy of an existing profile with a new ID. func (s *Service) DuplicateProfile(id string) (models.Profile, error) { orig, err := s.store.GetProfile(id) if err != nil { return models.Profile{}, fmt.Errorf("profile not found: %w", err) } dup := *orig dup.ID = uuid.New().String() dup.DisplayName = orig.DisplayName + " (copy)" now := time.Now() dup.CreatedAt = now dup.UpdatedAt = now dup.LastConnected = nil if err := s.store.CreateProfile(&dup); err != nil { return models.Profile{}, fmt.Errorf("failed to duplicate profile: %w", err) } s.auditLog.LogProfileCreate(dup.ID, dup.DisplayName) return dup, nil } // NewProfile returns a new profile with sensible defaults for the given protocol. func (s *Service) NewProfile(protocol string) models.Profile { return models.NewProfile(models.Protocol(protocol)) } // --------------------------------------------------------------------------- // Session operations // --------------------------------------------------------------------------- // ConnectSession loads a profile by ID and starts a session asynchronously. // Returns immediately with session info in "connecting" state; the frontend // should listen for session:state events for status updates. func (s *Service) ConnectSession(profileID string) (models.SessionInfo, error) { profile, err := s.store.GetProfile(profileID) if err != nil { return models.SessionInfo{}, fmt.Errorf("profile not found: %w", err) } sessionID, err := s.sessions.Connect(profile, s.promptSecret) if err != nil { return models.SessionInfo{}, err } // Update last-connected timestamp. now := time.Now() profile.LastConnected = &now profile.UpdatedAt = now _ = s.store.UpdateProfile(profile) info := s.sessions.GetSessionInfo(sessionID) if info == nil { return models.SessionInfo{}, fmt.Errorf("session created but info not found") } return *info, nil } // ConnectQuick opens a session from an ad-hoc (unsaved) profile. func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, error) { if profile.ID == "" { profile.ID = uuid.New().String() } sessionID, err := s.sessions.Connect(&profile, s.promptSecret) if err != nil { return models.SessionInfo{}, err } info := s.sessions.GetSessionInfo(sessionID) if info == nil { return models.SessionInfo{}, fmt.Errorf("session created but info not found") } return *info, nil } // DisconnectSession closes a session by ID. func (s *Service) DisconnectSession(sessionID string) error { return s.sessions.Disconnect(sessionID) } // ReconnectSession closes and re-establishes a session. func (s *Service) ReconnectSession(sessionID string) error { return s.sessions.Reconnect(sessionID, s.promptSecret) } // CancelReconnect cancels an in-progress auto-reconnect for a session. func (s *Service) CancelReconnect(sessionID string) { s.sessions.CancelReconnect(sessionID) } // WriteSession writes base64-encoded data to a session. func (s *Service) WriteSession(sessionID string, data string) error { decoded, err := base64.StdEncoding.DecodeString(data) if err != nil { return fmt.Errorf("invalid base64 data: %w", err) } return s.sessions.WriteToSession(sessionID, decoded) } // ResizeSession sends a terminal size change to a session. func (s *Service) ResizeSession(sessionID string, cols int, rows int) error { return s.sessions.ResizeSession(sessionID, cols, rows) } // ListSessions returns all active sessions. func (s *Service) ListSessions() []models.SessionInfo { return s.sessions.ListSessions() } // RenameSession changes the display name of a session tab. func (s *Service) RenameSession(sessionID string, name string) error { return s.sessions.RenameSession(sessionID, name) } // PinSession toggles the pinned state of a session tab. func (s *Service) PinSession(sessionID string, pinned bool) error { return s.sessions.PinSession(sessionID, pinned) } // CloseAllSessions terminates all active sessions. func (s *Service) CloseAllSessions() { s.sessions.CloseAll() } // --------------------------------------------------------------------------- // RAW-specific // --------------------------------------------------------------------------- // WriteSessionText sends plain text to a RAW session. func (s *Service) WriteSessionText(sessionID string, text string) error { return s.sessions.WriteSessionText(sessionID, text) } // WriteSessionHex sends hex-encoded data to a RAW session. func (s *Service) WriteSessionHex(sessionID string, hexStr string) error { return s.sessions.WriteSessionHex(sessionID, hexStr) } // WriteSessionEscaped sends text with C-style escape sequences to a RAW session. func (s *Service) WriteSessionEscaped(sessionID string, text string) error { return s.sessions.WriteSessionEscaped(sessionID, text) } // GetSessionHistory returns the data history for a RAW session. func (s *Service) GetSessionHistory(sessionID string) ([]map[string]interface{}, error) { entries, err := s.sessions.GetSessionHistory(sessionID) if err != nil { return nil, err } result := make([]map[string]interface{}, len(entries)) for i, e := range entries { result[i] = map[string]interface{}{ "timestamp": e.Timestamp.UnixMilli(), "direction": e.Direction, "data": base64.StdEncoding.EncodeToString(e.Data), } } return result, nil } // ClearSessionHistory clears the data history for a RAW session. func (s *Service) ClearSessionHistory(sessionID string) error { return s.sessions.ClearSessionHistory(sessionID) } // SaveSessionCapture saves the RAW session data to a file. func (s *Service) SaveSessionCapture(sessionID string, path string) error { return s.sessions.SaveSessionCapture(sessionID, path) } // --------------------------------------------------------------------------- // Telnet-specific // --------------------------------------------------------------------------- // SendBreak transmits a Telnet BRK command to a session. func (s *Service) SendBreak(sessionID string) error { return s.sessions.SendBreak(sessionID) } // --------------------------------------------------------------------------- // Secret store // --------------------------------------------------------------------------- // IsSecretStoreInitialised reports whether the encrypted vault has been created. func (s *Service) IsSecretStoreInitialised() bool { if s.secretStore == nil { return false } return s.secretStore.IsInitialised() } // InitialiseSecretStore creates a new encrypted vault with the given master password. func (s *Service) InitialiseSecretStore(masterPassword string) error { if s.secretStore == nil { return fmt.Errorf("secret store is not configured") } return s.secretStore.Initialise(masterPassword) } // UnlockSecretStore decrypts the vault using the master password. func (s *Service) UnlockSecretStore(masterPassword string) error { if s.secretStore == nil { return fmt.Errorf("secret store is not configured") } return s.secretStore.Unlock(masterPassword) } // LockSecretStore zeroes key material and locks the vault. func (s *Service) LockSecretStore() { if s.secretStore != nil { s.secretStore.Lock() } } // IsSecretStoreUnlocked reports whether the vault is currently unlocked. func (s *Service) IsSecretStoreUnlocked() bool { if s.secretStore == nil { return false } return s.secretStore.IsUnlocked() } // SetProfileSecret encrypts and stores a secret for the given profile. func (s *Service) SetProfileSecret(profileID string, secretType string, value string) error { key := fmt.Sprintf("profile:%s:%s", profileID, secretType) // Try the OS keychain first. if s.keychainStore != nil && s.keychainStore.Available() { return s.keychainStore.SetSecret(key, value) } // Fall back to the encrypted file store. if s.secretStore == nil || !s.secretStore.IsUnlocked() { return fmt.Errorf("secret store is locked; unlock it first") } return s.secretStore.SetSecret(key, value) } // DeleteProfileSecrets removes all secrets associated with a profile. func (s *Service) DeleteProfileSecrets(profileID string) error { // Remove from both stores where possible. if s.secretStore != nil && s.secretStore.IsUnlocked() { _ = s.secretStore.DeleteProfileSecrets(profileID) } // OS keychain entries need individual deletion — handled at a higher level // because we cannot enumerate keychain keys by prefix portably. return nil } // --------------------------------------------------------------------------- // TOTP / 2FA // --------------------------------------------------------------------------- // SetTOTPSecret stores a TOTP secret for the given profile, encrypted in // the secret vault (or OS keychain). func (s *Service) SetTOTPSecret(profileID string, secret string) error { return s.SetProfileSecret(profileID, "totp", secret) } // GetTOTPCode generates the current 6-digit TOTP code for the given profile. // Used for the "Test" button in the profile editor. func (s *Service) GetTOTPCode(profileID string) (string, error) { // Look up the TOTP secret from the vault. totpKey := fmt.Sprintf("profile:%s:totp", profileID) var secret string if s.keychainStore != nil && s.keychainStore.Available() { if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" { secret = val } } if secret == "" && s.secretStore != nil && s.secretStore.IsUnlocked() { if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" { secret = val } } if secret == "" { return "", fmt.Errorf("no TOTP secret configured for this profile") } code, err := totp.GenerateCode(secret, time.Now()) if err != nil { return "", fmt.Errorf("failed to generate TOTP code: %w", err) } return code, nil } // HasTOTPSecret checks whether a profile has a TOTP secret stored. func (s *Service) HasTOTPSecret(profileID string) bool { totpKey := fmt.Sprintf("profile:%s:totp", profileID) if s.keychainStore != nil && s.keychainStore.Available() { if val, err := s.keychainStore.GetSecret(totpKey); err == nil && val != "" { return true } } if s.secretStore != nil && s.secretStore.IsUnlocked() { if val, err := s.secretStore.GetSecret(totpKey); err == nil && val != "" { return true } } return false } // DeleteTOTPSecret removes the TOTP secret for the given profile. func (s *Service) DeleteTOTPSecret(profileID string) error { totpKey := fmt.Sprintf("profile:%s:totp", profileID) if s.keychainStore != nil && s.keychainStore.Available() { _ = s.keychainStore.DeleteSecret(totpKey) } if s.secretStore != nil && s.secretStore.IsUnlocked() { _ = s.secretStore.DeleteSecret(totpKey) } return nil } // --------------------------------------------------------------------------- // Known hosts // --------------------------------------------------------------------------- // ListKnownHosts returns all stored host key entries. func (s *Service) ListKnownHosts() ([]models.KnownHost, error) { hosts, err := s.store.ListKnownHosts() if err != nil { return nil, err } result := make([]models.KnownHost, len(hosts)) for i, h := range hosts { result[i] = *h } return result, nil } // DeleteKnownHost removes a known host entry by ID. func (s *Service) DeleteKnownHost(id string) error { return s.store.DeleteKnownHost(id) } // --------------------------------------------------------------------------- // Preferences // --------------------------------------------------------------------------- // GetPreferences returns the current application preferences. func (s *Service) GetPreferences() (models.AppPreferences, error) { p := s.prefs.Get() if p == nil { return models.DefaultAppPreferences(), nil } return *p, nil } // SavePreferences persists the given application preferences. func (s *Service) SavePreferences(prefs models.AppPreferences) error { return s.prefs.Save(&prefs) } // --------------------------------------------------------------------------- // Import / Export // --------------------------------------------------------------------------- // ExportProfiles serialises all profiles to a JSON string. When includeSecrets // is true, decrypted credentials are embedded (use with caution). func (s *Service) ExportProfiles(includeSecrets bool) (string, error) { profiles, err := s.store.ListProfiles() if err != nil { return "", fmt.Errorf("failed to list profiles for export: %w", err) } data, err := json.MarshalIndent(profiles, "", " ") if err != nil { return "", fmt.Errorf("failed to serialise profiles: %w", err) } s.auditLog.LogExport(len(profiles), includeSecrets) return string(data), nil } // ImportProfiles deserialises profiles from a JSON string and persists them. // The strategy parameter controls conflict resolution: // - "skip" ignores profiles whose ID already exists // - "overwrite" replaces existing profiles // - "duplicate" always creates a new profile with a fresh ID func (s *Service) ImportProfiles(jsonData string, strategy string) (int, error) { var profiles []*models.Profile if err := json.Unmarshal([]byte(jsonData), &profiles); err != nil { return 0, fmt.Errorf("invalid JSON: %w", err) } imported := 0 for _, p := range profiles { switch strategy { case "skip": if existing, _ := s.store.GetProfile(p.ID); existing != nil { continue } if err := s.store.CreateProfile(p); err != nil { continue } imported++ case "overwrite": p.UpdatedAt = time.Now() if existing, _ := s.store.GetProfile(p.ID); existing != nil { if err := s.store.UpdateProfile(p); err != nil { continue } } else { if err := s.store.CreateProfile(p); err != nil { continue } } imported++ case "duplicate": p.ID = uuid.New().String() now := time.Now() p.CreatedAt = now p.UpdatedAt = now if err := s.store.CreateProfile(p); err != nil { continue } imported++ default: return 0, fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy) } } s.auditLog.LogImport(imported) return imported, nil } // --------------------------------------------------------------------------- // Audit // --------------------------------------------------------------------------- // ListAuditEntries returns the most recent audit entries. func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) { entries, err := s.auditLog.ListEntries(limit) if err != nil { return nil, err } result := make([]models.AuditEntry, len(entries)) for i, e := range entries { result[i] = *e } return result, nil } // --------------------------------------------------------------------------- // Snippets (command library) // --------------------------------------------------------------------------- // ListSnippets returns all saved command snippets. func (s *Service) ListSnippets() ([]models.Snippet, error) { snippets, err := s.store.ListSnippets() if err != nil { return nil, err } result := make([]models.Snippet, len(snippets)) for i, sn := range snippets { result[i] = *sn } return result, nil } // CreateSnippet saves a new command snippet. func (s *Service) CreateSnippet(snippet models.Snippet) (models.Snippet, error) { if snippet.ID == "" { snippet.ID = uuid.New().String() } now := time.Now() snippet.CreatedAt = now snippet.UpdatedAt = now if snippet.Tags == nil { snippet.Tags = []string{} } if err := s.store.CreateSnippet(&snippet); err != nil { return models.Snippet{}, fmt.Errorf("failed to create snippet: %w", err) } return snippet, nil } // UpdateSnippet updates an existing command snippet. func (s *Service) UpdateSnippet(snippet models.Snippet) (models.Snippet, error) { snippet.UpdatedAt = time.Now() if err := s.store.UpdateSnippet(&snippet); err != nil { return models.Snippet{}, fmt.Errorf("failed to update snippet: %w", err) } return snippet, nil } // DeleteSnippet removes a command snippet by ID. func (s *Service) DeleteSnippet(id string) error { return s.store.DeleteSnippet(id) } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- // SelectFile opens a native file dialog and returns the chosen path. func (s *Service) SelectFile(title string, filters string) (string, error) { if s.ctx == nil { return "", fmt.Errorf("application context not set") } opts := wailsRuntime.OpenDialogOptions{ Title: title, } if filters != "" { opts.Filters = []wailsRuntime.FileFilter{ {DisplayName: "Files", Pattern: filters}, } } path, err := wailsRuntime.OpenFileDialog(s.ctx, opts) if err != nil { return "", fmt.Errorf("file dialog failed: %w", err) } return path, nil } // SaveFile opens a native save dialog and returns the chosen path. func (s *Service) SaveFile(title string, defaultName string) (string, error) { if s.ctx == nil { return "", fmt.Errorf("application context not set") } path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ Title: title, DefaultFilename: defaultName, }) if err != nil { return "", fmt.Errorf("save dialog failed: %w", err) } return path, nil } // GetAppInfo returns build and environment information. func (s *Service) GetAppInfo() map[string]string { return map[string]string{ "version": appVersion, "go": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, "platform": runtime.GOOS + "/" + runtime.GOARCH, } } // RefreshGitHubRepos triggers an on-demand refresh of GitHub repositories for // the given session. The updated list is also emitted as a Wails event. func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { return s.sessions.FetchGitHubRepos(sessionID) } // --------------------------------------------------------------------------- // Port forwarding // --------------------------------------------------------------------------- // ListPortForwards returns the active port-forwarding tunnels for an SSH session. func (s *Service) ListPortForwards(sessionID string) ([]models.PortForward, error) { return s.sessions.ListPortForwards(sessionID) } // StartPortForward activates a new forwarding tunnel on a live SSH session. func (s *Service) StartPortForward(sessionID string, rule models.PortForward) error { return s.sessions.StartPortForward(sessionID, rule) } // StopPortForward terminates an active forwarding tunnel. func (s *Service) StopPortForward(sessionID string, forwardID string) error { return s.sessions.StopPortForward(sessionID, forwardID) } // --------------------------------------------------------------------------- // Session recording // --------------------------------------------------------------------------- // StartRecording begins recording terminal I/O for a session. func (s *Service) StartRecording(sessionID string, cols int, rows int) (string, error) { return s.sessions.StartRecording(sessionID, cols, rows) } // StopRecording stops an active recording for a session. func (s *Service) StopRecording(sessionID string) error { return s.sessions.StopRecording(sessionID) } // IsRecording reports whether a session is currently being recorded. func (s *Service) IsRecording(sessionID string) bool { return s.sessions.IsRecording(sessionID) } // ListRecordings returns metadata for all saved recordings. func (s *Service) ListRecordings() ([]map[string]interface{}, error) { metas, err := s.sessions.ListRecordings() if err != nil { return nil, err } result := make([]map[string]interface{}, len(metas)) for i, m := range metas { result[i] = map[string]interface{}{ "id": m.ID, "sessionId": m.SessionID, "title": m.Title, "width": m.Width, "height": m.Height, "duration": m.Duration, "size": m.Size, "createdAt": m.CreatedAt.Format(time.RFC3339), "filePath": m.FilePath, } } return result, nil } // GetRecording returns the full recording data for playback. func (s *Service) GetRecording(id string) (map[string]interface{}, error) { hdr, events, err := s.sessions.GetRecording(id) if err != nil { return nil, err } evtList := make([][]interface{}, len(events)) for i, e := range events { evtList[i] = []interface{}{e.Elapsed, string(e.Type), e.Data} } return map[string]interface{}{ "header": map[string]interface{}{ "version": hdr.Version, "width": hdr.Width, "height": hdr.Height, "timestamp": hdr.Timestamp, "title": hdr.Title, }, "events": evtList, }, nil } // DeleteRecording removes a saved recording. func (s *Service) DeleteRecording(id string) error { return s.sessions.DeleteRecording(id) } // --------------------------------------------------------------------------- // Encrypted export / import // --------------------------------------------------------------------------- // ExportProfilesEncrypted encrypts and exports selected profiles to a file. // The profileIDs parameter selects which profiles to include; if empty, all // profiles are exported. Returns the file path on success. func (s *Service) ExportProfilesEncrypted(profileIDs []string, password string) (string, error) { if s.ctx == nil { return "", fmt.Errorf("application context not set") } var profiles []models.Profile if len(profileIDs) == 0 { // Export all profiles. all, err := s.store.ListProfiles() if err != nil { return "", fmt.Errorf("failed to list profiles: %w", err) } for _, p := range all { profiles = append(profiles, *p) } } else { for _, id := range profileIDs { p, err := s.store.GetProfile(id) if err != nil { continue // skip missing profiles } profiles = append(profiles, *p) } } if len(profiles) == 0 { return "", fmt.Errorf("no profiles to export") } // Prompt for save location. path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ Title: "Export Encrypted Profiles", DefaultFilename: "nexus-profiles.nexus", Filters: []wailsRuntime.FileFilter{ {DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"}, }, }) if err != nil { return "", fmt.Errorf("save dialog failed: %w", err) } if path == "" { return "", fmt.Errorf("export cancelled") } // Ensure .nexus extension. if filepath.Ext(path) != ".nexus" { path += ".nexus" } if err := exportimport.EncryptAndExport(profiles, password, path); err != nil { return "", err } s.auditLog.LogExport(len(profiles), false) return path, nil } // ImportProfilesEncrypted decrypts and imports profiles from an encrypted file. // Returns the number of profiles successfully imported. func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int, error) { if s.ctx == nil { return 0, fmt.Errorf("application context not set") } // Prompt for file selection. path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ Title: "Import Encrypted Profiles", Filters: []wailsRuntime.FileFilter{ {DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"}, }, }) if err != nil { return 0, fmt.Errorf("open dialog failed: %w", err) } if path == "" { return 0, fmt.Errorf("import cancelled") } profiles, err := exportimport.DecryptAndImport(path, password) if err != nil { return 0, err } // Import using the existing strategy logic. imported := 0 for _, p := range profiles { switch strategy { case "skip": if existing, _ := s.store.GetProfile(p.ID); existing != nil { continue } if err := s.store.CreateProfile(&p); err != nil { continue } imported++ case "overwrite": p.UpdatedAt = time.Now() if existing, _ := s.store.GetProfile(p.ID); existing != nil { if err := s.store.UpdateProfile(&p); err != nil { continue } } else { if err := s.store.CreateProfile(&p); err != nil { continue } } imported++ case "duplicate": p.ID = uuid.New().String() now := time.Now() p.CreatedAt = now p.UpdatedAt = now if err := s.store.CreateProfile(&p); err != nil { continue } imported++ default: return 0, fmt.Errorf("unknown import strategy %q", strategy) } } s.auditLog.LogImport(imported) return imported, nil } // --------------------------------------------------------------------------- // SFTP file browser // --------------------------------------------------------------------------- // SFTPList lists the contents of a remote directory. func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) { entries, err := s.sessions.SFTPList(sessionID, remotePath) if err != nil { return nil, err } result := make([]map[string]interface{}, len(entries)) for i, e := range entries { result[i] = map[string]interface{}{ "name": e.Name, "path": e.Path, "size": e.Size, "isDir": e.IsDir, "mode": e.Mode, "modifiedAt": e.ModifiedAt, } } return result, nil } // SFTPMkdir creates a remote directory (including parents). func (s *Service) SFTPMkdir(sessionID string, remotePath string) error { return s.sessions.SFTPMkdir(sessionID, remotePath) } // SFTPRemove deletes a remote file or empty directory. func (s *Service) SFTPRemove(sessionID string, remotePath string) error { return s.sessions.SFTPRemove(sessionID, remotePath) } // SFTPRename moves or renames a remote file/directory. func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error { return s.sessions.SFTPRename(sessionID, oldPath, newPath) } // SFTPDownload downloads a remote file. Opens a native save dialog for the // local destination. func (s *Service) SFTPDownload(sessionID string, remotePath string) error { if s.ctx == nil { return fmt.Errorf("application context not set") } localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ Title: "Save File", DefaultFilename: filepath.Base(remotePath), }) if err != nil { return fmt.Errorf("save dialog failed: %w", err) } if localPath == "" { return fmt.Errorf("download cancelled") } return s.sessions.SFTPDownload(sessionID, remotePath, localPath) } // SFTPUpload uploads a local file to a remote directory. Opens a native file // dialog to select the local file. func (s *Service) SFTPUpload(sessionID string, remotePath string) error { if s.ctx == nil { return fmt.Errorf("application context not set") } localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ Title: "Select File to Upload", }) if err != nil { return fmt.Errorf("open dialog failed: %w", err) } if localPath == "" { return fmt.Errorf("upload cancelled") } destPath := remotePath + "/" + filepath.Base(localPath) return s.sessions.SFTPUpload(sessionID, localPath, destPath) } // SFTPPwd returns the current working directory on the remote host. func (s *Service) SFTPPwd(sessionID string) (string, error) { return s.sessions.SFTPPwd(sessionID) } // --------------------------------------------------------------------------- // OpenSSH config import // --------------------------------------------------------------------------- // ImportSSHConfig parses the given SSH config file (or the default // ~/.ssh/config if path is empty) and imports the Host blocks as profiles. func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) { if configPath == "" { configPath = sshconfig.DefaultConfigPath() } blocks, err := sshconfig.ParseFile(configPath) if err != nil { return 0, err } profiles := sshconfig.ToProfiles(blocks, folder) imported := 0 for _, p := range profiles { if err := s.store.CreateProfile(&p); err != nil { continue // skip duplicates or errors } imported++ } s.auditLog.LogImport(imported) return imported, nil } // --------------------------------------------------------------------------- // Automatic update checking // --------------------------------------------------------------------------- // CheckForUpdates queries GitHub Releases for a newer version. func (s *Service) CheckForUpdates() (map[string]interface{}, error) { checker := updater.NewChecker(appVersion) info, err := checker.Check(context.Background()) if err != nil { return nil, err } return map[string]interface{}{ "available": info.Available, "currentVersion": info.CurrentVersion, "latestVersion": info.LatestVersion, "releaseURL": info.ReleaseURL, "releaseName": info.ReleaseName, "releaseNotes": info.ReleaseNotes, "publishedAt": info.PublishedAt, }, nil } // TestConnection performs a basic TCP reachability check to the given host and port. // This verifies the network path without establishing a full protocol session. func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { if host == "" { return nil, fmt.Errorf("host is required") } if port <= 0 || port > 65535 { return nil, fmt.Errorf("port must be between 1 and 65535") } addr := fmt.Sprintf("%s:%d", host, port) start := time.Now() conn, err := net.DialTimeout("tcp", addr, 10*time.Second) elapsed := time.Since(start) if err != nil { return nil, fmt.Errorf("connection to %s failed after %dms: %w", addr, elapsed.Milliseconds(), err) } conn.Close() return map[string]interface{}{ "success": true, "host": host, "port": port, "latencyMs": elapsed.Milliseconds(), "remoteAddr": addr, }, nil } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // promptSecret emits a Wails event to request a secret from the user, then // blocks until the frontend responds via the session:secret:response event. // This bridges the synchronous Go SSH auth callback with the asynchronous // frontend prompt UI. func (s *Service) promptSecret(prompt string) (string, error) { if s.ctx == nil { return "", fmt.Errorf("application context not available") } // Generate a unique request ID so we can correlate the response. requestID := uuid.New().String() ch := make(chan secretResponse, 1) // Register a one-shot listener for the response. s.secretMu.Lock() if s.secretChans == nil { s.secretChans = make(map[string]chan secretResponse) } s.secretChans[requestID] = ch s.secretMu.Unlock() // Emit the prompt event to the frontend. wailsRuntime.EventsEmit(s.ctx, "session:secret:prompt", map[string]interface{}{ "requestID": requestID, "message": prompt, "title": "Credentials Required", }) // Block until we receive a response (with a generous timeout). select { case resp := <-ch: s.secretMu.Lock() delete(s.secretChans, requestID) s.secretMu.Unlock() if resp.cancelled { return "", fmt.Errorf("user cancelled secret prompt") } return resp.value, nil case <-time.After(5 * time.Minute): s.secretMu.Lock() delete(s.secretChans, requestID) s.secretMu.Unlock() return "", fmt.Errorf("secret prompt timed out") } } // secretResponse carries the result of a frontend secret prompt. type secretResponse struct { value string cancelled bool } // RegisterSecretResponseListener sets up the Wails event listener that receives // secrets entered by the user. Must be called after SetContext. func (s *Service) RegisterSecretResponseListener() { if s.ctx == nil { return } wailsRuntime.EventsOn(s.ctx, "session:secret:response", func(optionalData ...interface{}) { if len(optionalData) == 0 { return } data, ok := optionalData[0].(map[string]interface{}) if !ok { return } requestID, _ := data["requestID"].(string) value, _ := data["value"].(string) cancelled, _ := data["cancelled"].(bool) s.secretMu.Lock() ch, exists := s.secretChans[requestID] s.secretMu.Unlock() if exists { ch <- secretResponse{value: value, cancelled: cancelled} } }) } // derefProfiles converts a slice of profile pointers to values. func derefProfiles(ptrs []*models.Profile) []models.Profile { result := make([]models.Profile, len(ptrs)) for i, p := range ptrs { result[i] = *p } return result }