All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m3s
Root cause: frontend btoa() encoded the PEM before sending to Go []byte parameter. Wails already base64-encodes []byte over JSON bridge, so the vault stored base64(base64(pem)) — garbage. Fix: Go method now accepts string, frontend sends raw PEM. Keys must be re-added after this update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
566 lines
18 KiB
Go
566 lines
18 KiB
Go
package app
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"encoding/base64"
|
|
|
|
"github.com/vstockwell/wraith/internal/ai"
|
|
"github.com/vstockwell/wraith/internal/connections"
|
|
"github.com/vstockwell/wraith/internal/credentials"
|
|
"github.com/vstockwell/wraith/internal/db"
|
|
"github.com/vstockwell/wraith/internal/importer"
|
|
"github.com/vstockwell/wraith/internal/plugin"
|
|
"github.com/vstockwell/wraith/internal/rdp"
|
|
"github.com/vstockwell/wraith/internal/session"
|
|
"github.com/vstockwell/wraith/internal/settings"
|
|
"github.com/vstockwell/wraith/internal/sftp"
|
|
"github.com/vstockwell/wraith/internal/ssh"
|
|
"github.com/vstockwell/wraith/internal/theme"
|
|
"github.com/vstockwell/wraith/internal/updater"
|
|
"github.com/vstockwell/wraith/internal/vault"
|
|
sftplib "github.com/pkg/sftp"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
)
|
|
|
|
// WraithApp is the main application struct that wires together all services
|
|
// and exposes vault management methods to the frontend via Wails bindings.
|
|
type WraithApp struct {
|
|
db *sql.DB
|
|
Vault *vault.VaultService
|
|
Settings *settings.SettingsService
|
|
Connections *connections.ConnectionService
|
|
Themes *theme.ThemeService
|
|
Sessions *session.Manager
|
|
Plugins *plugin.Registry
|
|
SSH *ssh.SSHService
|
|
SFTP *sftp.SFTPService
|
|
RDP *rdp.RDPService
|
|
Credentials *credentials.CredentialService
|
|
AI *ai.AIService
|
|
Updater *updater.UpdateService
|
|
oauthMgr *ai.OAuthManager
|
|
wailsApp *application.App
|
|
unlocked bool
|
|
}
|
|
|
|
// SetWailsApp stores the Wails application reference for event emission.
|
|
// Must be called after application.New() and before app.Run().
|
|
func (a *WraithApp) SetWailsApp(app *application.App) {
|
|
a.wailsApp = app
|
|
}
|
|
|
|
// New creates and initializes the WraithApp, opening the database, running
|
|
// migrations, creating all services, and seeding built-in themes.
|
|
// The version string is the build-time semver (e.g. "0.2.0") used by the
|
|
// auto-updater to check for newer releases.
|
|
func New(version string) (*WraithApp, error) {
|
|
dataDir := dataDirectory()
|
|
dbPath := filepath.Join(dataDir, "wraith.db")
|
|
|
|
slog.Info("opening database", "path", dbPath)
|
|
database, err := db.Open(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
|
|
if err := db.Migrate(database); err != nil {
|
|
return nil, fmt.Errorf("run migrations: %w", err)
|
|
}
|
|
|
|
settingsSvc := settings.NewSettingsService(database)
|
|
connSvc := connections.NewConnectionService(database)
|
|
themeSvc := theme.NewThemeService(database)
|
|
sessionMgr := session.NewManager()
|
|
pluginReg := plugin.NewRegistry()
|
|
|
|
// SSH output handler — emits Wails events to the frontend.
|
|
// The closure captures `app` (the WraithApp being built). The wailsApp
|
|
// field is set after application.New() in main.go, but SSH sessions only
|
|
// start after app.Run(), so wailsApp is always valid at call time.
|
|
var app *WraithApp
|
|
sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) {
|
|
if app != nil && app.wailsApp != nil {
|
|
// Base64 encode binary data for safe transport over Wails events
|
|
app.wailsApp.Event.Emit("ssh:data:"+sessionID, base64.StdEncoding.EncodeToString(data))
|
|
}
|
|
})
|
|
sftpSvc := sftp.NewSFTPService()
|
|
|
|
// RDP service with platform-aware backend factory.
|
|
// On Windows the factory returns a FreeRDPBackend backed by libfreerdp3.dll;
|
|
// on other platforms it falls back to MockBackend for development.
|
|
rdpSvc := rdp.NewRDPService(func() rdp.RDPBackend {
|
|
return rdp.NewProductionBackend()
|
|
})
|
|
|
|
// CredentialService requires the vault to be unlocked, so it starts nil.
|
|
// It is created lazily after the vault is unlocked via initCredentials().
|
|
|
|
// AI copilot services — OAuthManager starts without a vault reference;
|
|
// it will be wired once the vault is unlocked.
|
|
oauthMgr := ai.NewOAuthManager(settingsSvc, nil)
|
|
toolRouter := ai.NewToolRouter()
|
|
toolRouter.SetServices(sshSvc, sftpSvc, rdpSvc, sessionMgr, connSvc)
|
|
convMgr := ai.NewConversationManager(database)
|
|
aiSvc := ai.NewAIService(oauthMgr, toolRouter, convMgr)
|
|
toolRouter.SetAIService(aiSvc)
|
|
|
|
// Seed built-in themes on every startup (INSERT OR IGNORE keeps it idempotent)
|
|
if err := themeSvc.SeedBuiltins(); err != nil {
|
|
slog.Warn("failed to seed themes", "error", err)
|
|
}
|
|
|
|
updaterSvc := updater.NewUpdateService(version)
|
|
|
|
app = &WraithApp{
|
|
db: database,
|
|
Settings: settingsSvc,
|
|
Connections: connSvc,
|
|
Themes: themeSvc,
|
|
Sessions: sessionMgr,
|
|
Plugins: pluginReg,
|
|
SSH: sshSvc,
|
|
SFTP: sftpSvc,
|
|
RDP: rdpSvc,
|
|
AI: aiSvc,
|
|
Updater: updaterSvc,
|
|
oauthMgr: oauthMgr,
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// dataDirectory returns the path where Wraith stores its data.
|
|
// On Windows with APPDATA set, it uses %APPDATA%\Wraith.
|
|
// On macOS/Linux with XDG_DATA_HOME or HOME, it uses the appropriate path.
|
|
// Falls back to the current working directory for development.
|
|
func dataDirectory() string {
|
|
// Windows
|
|
if appData := os.Getenv("APPDATA"); appData != "" {
|
|
return filepath.Join(appData, "Wraith")
|
|
}
|
|
|
|
// macOS / Linux: use XDG_DATA_HOME or fallback to ~/.local/share
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
|
return filepath.Join(xdg, "wraith")
|
|
}
|
|
return filepath.Join(home, ".local", "share", "wraith")
|
|
}
|
|
|
|
// Dev fallback
|
|
return "."
|
|
}
|
|
|
|
// IsFirstRun checks whether the vault has been set up by looking for vault_salt in settings.
|
|
func (a *WraithApp) IsFirstRun() bool {
|
|
salt, _ := a.Settings.Get("vault_salt")
|
|
return salt == ""
|
|
}
|
|
|
|
// CreateVault sets up the vault with a master password. It generates a salt,
|
|
// derives an encryption key, and stores the salt and a check value in settings.
|
|
func (a *WraithApp) CreateVault(password string) error {
|
|
salt, err := vault.GenerateSalt()
|
|
if err != nil {
|
|
return fmt.Errorf("generate salt: %w", err)
|
|
}
|
|
|
|
key := vault.DeriveKey(password, salt)
|
|
a.Vault = vault.NewVaultService(key)
|
|
|
|
// Store salt as hex in settings
|
|
if err := a.Settings.Set("vault_salt", hex.EncodeToString(salt)); err != nil {
|
|
return fmt.Errorf("store salt: %w", err)
|
|
}
|
|
|
|
// Encrypt a known check value — used to verify the password on unlock
|
|
check, err := a.Vault.Encrypt("wraith-vault-check")
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt check value: %w", err)
|
|
}
|
|
if err := a.Settings.Set("vault_check", check); err != nil {
|
|
return fmt.Errorf("store check value: %w", err)
|
|
}
|
|
|
|
a.unlocked = true
|
|
a.initCredentials()
|
|
slog.Info("vault created successfully")
|
|
return nil
|
|
}
|
|
|
|
// Unlock verifies the master password against the stored check value and
|
|
// initializes the vault service for decryption.
|
|
func (a *WraithApp) Unlock(password string) error {
|
|
saltHex, err := a.Settings.Get("vault_salt")
|
|
if err != nil || saltHex == "" {
|
|
return fmt.Errorf("vault not set up — call CreateVault first")
|
|
}
|
|
|
|
salt, err := hex.DecodeString(saltHex)
|
|
if err != nil {
|
|
return fmt.Errorf("decode salt: %w", err)
|
|
}
|
|
|
|
key := vault.DeriveKey(password, salt)
|
|
vs := vault.NewVaultService(key)
|
|
|
|
// Verify by decrypting the stored check value
|
|
checkEncrypted, err := a.Settings.Get("vault_check")
|
|
if err != nil || checkEncrypted == "" {
|
|
return fmt.Errorf("vault check value missing")
|
|
}
|
|
|
|
checkPlain, err := vs.Decrypt(checkEncrypted)
|
|
if err != nil {
|
|
return fmt.Errorf("incorrect master password")
|
|
}
|
|
if checkPlain != "wraith-vault-check" {
|
|
return fmt.Errorf("incorrect master password")
|
|
}
|
|
|
|
a.Vault = vs
|
|
a.unlocked = true
|
|
a.initCredentials()
|
|
slog.Info("vault unlocked successfully")
|
|
return nil
|
|
}
|
|
|
|
// IsUnlocked returns whether the vault is currently unlocked.
|
|
func (a *WraithApp) IsUnlocked() bool {
|
|
return a.unlocked
|
|
}
|
|
|
|
// initCredentials creates the CredentialService after the vault is unlocked
|
|
// and wires the vault to services that need it (OAuth, etc.).
|
|
func (a *WraithApp) initCredentials() {
|
|
if a.Vault != nil {
|
|
a.Credentials = credentials.NewCredentialService(a.db, a.Vault)
|
|
if a.oauthMgr != nil {
|
|
a.oauthMgr.SetVault(a.Vault)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ConnectSSH opens an SSH session to the given connection ID.
|
|
// It resolves credentials from the vault, builds auth methods, and returns a session ID.
|
|
// The frontend calls this instead of SSHService.Connect directly (which takes Go-only types).
|
|
func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, error) {
|
|
conn, err := a.Connections.GetConnection(connectionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("connection not found: %w", err)
|
|
}
|
|
|
|
// Build SSH auth methods from the connection's credential
|
|
var authMethods []gossh.AuthMethod
|
|
username := "root" // default
|
|
|
|
if conn.CredentialID != nil && a.Credentials != nil {
|
|
cred, err := a.Credentials.GetCredential(*conn.CredentialID)
|
|
if err != nil {
|
|
slog.Warn("failed to load credential", "id", *conn.CredentialID, "error", err)
|
|
} else {
|
|
if cred.Username != "" {
|
|
username = cred.Username
|
|
}
|
|
|
|
switch cred.Type {
|
|
case "password":
|
|
pw, err := a.Credentials.DecryptPassword(cred.ID)
|
|
if err != nil {
|
|
slog.Warn("failed to decrypt password", "error", err)
|
|
} else {
|
|
authMethods = append(authMethods, gossh.Password(pw))
|
|
}
|
|
case "ssh_key":
|
|
if cred.SSHKeyID != nil {
|
|
keyPEM, passphrase, err := a.Credentials.DecryptSSHKey(*cred.SSHKeyID)
|
|
if err != nil {
|
|
slog.Warn("failed to decrypt SSH key", "error", err)
|
|
} else {
|
|
var signer gossh.Signer
|
|
if passphrase != "" {
|
|
signer, err = gossh.ParsePrivateKeyWithPassphrase(keyPEM, []byte(passphrase))
|
|
} else {
|
|
signer, err = gossh.ParsePrivateKey(keyPEM)
|
|
}
|
|
if err != nil {
|
|
slog.Warn("failed to parse SSH key", "error", err)
|
|
} else {
|
|
authMethods = append(authMethods, gossh.PublicKeys(signer))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no stored credentials, return an error telling the frontend to prompt
|
|
if len(authMethods) == 0 {
|
|
return "", fmt.Errorf("NO_CREDENTIALS: no credentials configured for this connection — assign a credential or use ConnectSSHWithPassword")
|
|
}
|
|
|
|
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
|
|
if err != nil {
|
|
return "", fmt.Errorf("SSH connect failed: %w", err)
|
|
}
|
|
|
|
// Register SFTP client on the same SSH connection
|
|
if sess, ok := a.SSH.GetSession(sessionID); ok && sess != nil {
|
|
sftpClient, err := sftplib.NewClient(sess.Client)
|
|
if err != nil {
|
|
slog.Warn("failed to create SFTP client", "error", err)
|
|
// Non-fatal — SSH still works without SFTP
|
|
} else {
|
|
a.SFTP.RegisterClient(sessionID, sftpClient)
|
|
}
|
|
}
|
|
|
|
// Update last_connected timestamp
|
|
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
|
|
slog.Warn("failed to update last_connected", "error", err)
|
|
}
|
|
|
|
slog.Info("SSH session started", "sessionID", sessionID, "host", conn.Hostname, "user", username)
|
|
return sessionID, nil
|
|
}
|
|
|
|
// ConnectSSHWithPassword opens an SSH session using an ad-hoc username/password.
|
|
// Used when no stored credential exists — the frontend prompts and passes creds directly.
|
|
func (a *WraithApp) ConnectSSHWithPassword(connectionID int64, username, password string, cols, rows int) (string, error) {
|
|
conn, err := a.Connections.GetConnection(connectionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("connection not found: %w", err)
|
|
}
|
|
|
|
authMethods := []gossh.AuthMethod{
|
|
gossh.Password(password),
|
|
gossh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
|
|
answers := make([]string, len(questions))
|
|
for i := range answers {
|
|
answers[i] = password
|
|
}
|
|
return answers, nil
|
|
}),
|
|
}
|
|
|
|
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
|
|
if err != nil {
|
|
return "", fmt.Errorf("SSH connect failed: %w", err)
|
|
}
|
|
|
|
// Register SFTP client
|
|
if sess, ok := a.SSH.GetSession(sessionID); ok && sess != nil {
|
|
sftpClient, err := sftplib.NewClient(sess.Client)
|
|
if err != nil {
|
|
slog.Warn("failed to create SFTP client", "error", err)
|
|
} else {
|
|
a.SFTP.RegisterClient(sessionID, sftpClient)
|
|
}
|
|
}
|
|
|
|
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
|
|
slog.Warn("failed to update last_connected", "error", err)
|
|
}
|
|
|
|
slog.Info("SSH session started (ad-hoc password)", "sessionID", sessionID, "host", conn.Hostname, "user", username)
|
|
return sessionID, nil
|
|
}
|
|
|
|
// GetVersion returns the build-time version string.
|
|
func (a *WraithApp) GetVersion() string {
|
|
return a.Updater.CurrentVersion()
|
|
}
|
|
|
|
// DisconnectSession closes an active SSH session and its SFTP client.
|
|
func (a *WraithApp) DisconnectSession(sessionID string) error {
|
|
a.SFTP.RemoveClient(sessionID)
|
|
return a.SSH.Disconnect(sessionID)
|
|
}
|
|
|
|
// ConnectRDP opens an RDP session to the given connection ID.
|
|
// It resolves credentials from the vault, builds an RDPConfig, and returns a session ID.
|
|
// width and height are the initial desktop dimensions in pixels.
|
|
func (a *WraithApp) ConnectRDP(connectionID int64, width, height int) (string, error) {
|
|
conn, err := a.Connections.GetConnection(connectionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("connection not found: %w", err)
|
|
}
|
|
|
|
config := rdp.RDPConfig{
|
|
Hostname: conn.Hostname,
|
|
Port: conn.Port,
|
|
Width: width,
|
|
Height: height,
|
|
}
|
|
|
|
if conn.CredentialID != nil && a.Credentials != nil {
|
|
cred, err := a.Credentials.GetCredential(*conn.CredentialID)
|
|
if err != nil {
|
|
slog.Warn("failed to load credential", "id", *conn.CredentialID, "error", err)
|
|
} else {
|
|
if cred.Username != "" {
|
|
config.Username = cred.Username
|
|
}
|
|
if cred.Domain != "" {
|
|
config.Domain = cred.Domain
|
|
}
|
|
|
|
if cred.Type == "password" {
|
|
pw, err := a.Credentials.DecryptPassword(cred.ID)
|
|
if err != nil {
|
|
slog.Warn("failed to decrypt password", "error", err)
|
|
} else {
|
|
config.Password = pw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sessionID, err := a.RDP.Connect(config, connectionID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("RDP connect failed: %w", err)
|
|
}
|
|
|
|
// Update last_connected timestamp
|
|
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
|
|
slog.Warn("failed to update last_connected", "error", err)
|
|
}
|
|
|
|
slog.Info("RDP session started", "sessionID", sessionID, "host", conn.Hostname)
|
|
return sessionID, nil
|
|
}
|
|
|
|
// RDPGetFrame returns the current frame for an RDP session as a base64-encoded
|
|
// string. Go []byte is serialised by Wails as a base64 string over the JSON
|
|
// bridge, so the frontend decodes it with atob() to recover the raw RGBA bytes.
|
|
func (a *WraithApp) RDPGetFrame(sessionID string) (string, error) {
|
|
raw, err := a.RDP.GetFrame(sessionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
// RDPSendMouse forwards a mouse event to an RDP session.
|
|
// flags uses the RDP mouse-event flag constants defined in internal/rdp/input.go.
|
|
func (a *WraithApp) RDPSendMouse(sessionID string, x, y int, flags uint32) error {
|
|
return a.RDP.SendMouse(sessionID, x, y, flags)
|
|
}
|
|
|
|
// RDPSendKey forwards a key event (scancode + press/release) to an RDP session.
|
|
func (a *WraithApp) RDPSendKey(sessionID string, scancode uint32, pressed bool) error {
|
|
return a.RDP.SendKey(sessionID, scancode, pressed)
|
|
}
|
|
|
|
// RDPSendClipboard forwards clipboard text to an RDP session.
|
|
func (a *WraithApp) RDPSendClipboard(sessionID string, text string) error {
|
|
return a.RDP.SendClipboard(sessionID, text)
|
|
}
|
|
|
|
// RDPDisconnect tears down an RDP session.
|
|
func (a *WraithApp) RDPDisconnect(sessionID string) error {
|
|
return a.RDP.Disconnect(sessionID)
|
|
}
|
|
|
|
// ---------- Credential proxy methods ----------
|
|
// CredentialService is nil until the vault is unlocked. These proxies expose
|
|
// it via WraithApp (which IS registered as a Wails service at startup).
|
|
|
|
// ListCredentials returns all stored credentials (no encrypted values).
|
|
func (a *WraithApp) ListCredentials() ([]credentials.Credential, error) {
|
|
if a.Credentials == nil {
|
|
return nil, fmt.Errorf("vault is locked")
|
|
}
|
|
return a.Credentials.ListCredentials()
|
|
}
|
|
|
|
// CreatePassword creates a password credential encrypted via the vault.
|
|
func (a *WraithApp) CreatePassword(name, username, password, domain string) (*credentials.Credential, error) {
|
|
if a.Credentials == nil {
|
|
return nil, fmt.Errorf("vault is locked")
|
|
}
|
|
return a.Credentials.CreatePassword(name, username, password, domain)
|
|
}
|
|
|
|
// CreateSSHKeyCredential imports an SSH private key and creates a Credential
|
|
// record referencing it. privateKeyPEM is the raw PEM string (NOT base64 encoded).
|
|
func (a *WraithApp) CreateSSHKeyCredential(name, username string, privateKeyPEM string, passphrase string) (*credentials.Credential, error) {
|
|
if a.Credentials == nil {
|
|
return nil, fmt.Errorf("vault is locked")
|
|
}
|
|
return a.Credentials.CreateSSHKeyCredential(name, username, []byte(privateKeyPEM), passphrase)
|
|
}
|
|
|
|
// DeleteCredential removes a credential by ID.
|
|
func (a *WraithApp) DeleteCredential(id int64) error {
|
|
if a.Credentials == nil {
|
|
return fmt.Errorf("vault is locked")
|
|
}
|
|
return a.Credentials.DeleteCredential(id)
|
|
}
|
|
|
|
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
|
|
// (groups, connections, host keys) into the database.
|
|
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {
|
|
imp := &importer.MobaConfImporter{}
|
|
result, err := imp.Parse([]byte(fileContent))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse mobaconf: %w", err)
|
|
}
|
|
|
|
// Create groups and track name -> ID mapping
|
|
groupMap := make(map[string]int64)
|
|
for _, g := range result.Groups {
|
|
created, err := a.Connections.CreateGroup(g.Name, nil)
|
|
if err != nil {
|
|
slog.Warn("failed to create group during import", "name", g.Name, "error", err)
|
|
continue
|
|
}
|
|
groupMap[g.Name] = created.ID
|
|
}
|
|
|
|
// Create connections
|
|
for _, c := range result.Connections {
|
|
var groupID *int64
|
|
if id, ok := groupMap[c.GroupName]; ok {
|
|
groupID = &id
|
|
}
|
|
_, err := a.Connections.CreateConnection(connections.CreateConnectionInput{
|
|
Name: c.Name,
|
|
Hostname: c.Hostname,
|
|
Port: c.Port,
|
|
Protocol: c.Protocol,
|
|
GroupID: groupID,
|
|
Notes: c.Notes,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to create connection during import", "name", c.Name, "error", err)
|
|
}
|
|
}
|
|
|
|
// Store host keys
|
|
for _, hk := range result.HostKeys {
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO host_keys (hostname, port, key_type, fingerprint) VALUES (?, ?, ?, ?)`,
|
|
hk.Hostname, hk.Port, hk.KeyType, hk.Fingerprint,
|
|
)
|
|
if err != nil {
|
|
slog.Warn("failed to store host key during import", "hostname", hk.Hostname, "error", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("mobaconf import complete",
|
|
"groups", len(result.Groups),
|
|
"connections", len(result.Connections),
|
|
"hostKeys", len(result.HostKeys),
|
|
)
|
|
return result, nil
|
|
}
|