From 4fd711474ceb21a3c18b4c7562546373528cee86 Mon Sep 17 00:00:00 2001 From: x3 Date: Mon, 21 Sep 2026 23:21:07 +0200 Subject: [PATCH] Passwort nutzen: Auto-Login via sshpass + Keychain, Editor mit Anzeige/Loeschen, Doku --- .../Services/ConnectionServices.swift | 37 ++++++++++++++++ Sources/RemminaMac/Services/PTYSession.swift | 22 +++++++++- Sources/RemminaMac/Views/ContentView.swift | 2 +- .../Views/EmbeddedTerminalView.swift | 2 +- .../RemminaMac/Views/ProfileEditorView.swift | 44 ++++++++++++++++--- docs/ERRORS.md | 11 +++++ docs/USAGE.md | 6 +++ 7 files changed, 113 insertions(+), 11 deletions(-) diff --git a/Sources/RemminaMac/Services/ConnectionServices.swift b/Sources/RemminaMac/Services/ConnectionServices.swift index d83bd4d..dab4ebf 100644 --- a/Sources/RemminaMac/Services/ConnectionServices.swift +++ b/Sources/RemminaMac/Services/ConnectionServices.swift @@ -39,6 +39,43 @@ enum SSHService { NSPasteboard.general.clearContents() NSPasteboard.general.setString(cmd, forType: .string) } + + // MARK: - Passwort / Auto-Login + + /// Gespeichertes Login-Secret: erst Profil-Passwort, dann Key-Passphrase (Keychain, nie JSON). + static func loginSecret(for profile: ConnectionProfile, key: StoredSSHKey?) -> String? { + if let acct = profile.passwordAccount, + let pw = KeychainHelper.load(account: acct), !pw.isEmpty { + return pw + } + if let key, let acct = key.passphraseAccount, + let pp = KeychainHelper.load(account: acct), !pp.isEmpty { + return pp + } + return nil + } + + static func findSshpass() -> String? { + for p in ["/opt/homebrew/bin/sshpass", "/usr/local/bin/sshpass", "/usr/bin/sshpass"] { + if FileManager.default.isExecutableFile(atPath: p) { return p } + } + return nil + } + + /// true = beim Verbinden wird ohne Tippen eingeloggt (sshpass + Keychain-Secret). + static func willAutoLogin(profile: ConnectionProfile, key: StoredSSHKey?) -> Bool { + loginSecret(for: profile, key: key) != nil && findSshpass() != nil + } + + /// Reine Funktion (testbar): mit Secret+sshpass -> sshpass -e … + SSHPASS-Env, + /// sonst direkter Aufruf. Passwort steht NIE in der Befehlszeile (nur im Env). + static func sessionConfig(executable: String, args: [String], password: String?, sshpassPath: String?) + -> (executable: String, arguments: [String], extraEnv: [String: String], autoLogin: Bool) { + if let password, !password.isEmpty, let sshpass = sshpassPath, !sshpass.isEmpty { + return (sshpass, ["-e", executable] + args, ["SSHPASS": password], true) + } + return (executable, args, [:], false) + } } enum RDPService { diff --git a/Sources/RemminaMac/Services/PTYSession.swift b/Sources/RemminaMac/Services/PTYSession.swift index 377aa5a..3b612f0 100644 --- a/Sources/RemminaMac/Services/PTYSession.swift +++ b/Sources/RemminaMac/Services/PTYSession.swift @@ -20,9 +20,11 @@ enum PTYState: Equatable { final class PTYSession: ObservableObject { @Published private(set) var lines: [[StyledText]] = [[]] @Published private(set) var state: PTYState = .never + @Published private(set) var autoLogin = false var executable = "" var arguments: [String] = [] + var customEnv: [String: String] = [:] private var masterFD: Int32 = -1 private var process: Process? @@ -48,6 +50,10 @@ final class PTYSession: ObservableObject { self.arguments = arguments } + func setAutoLogin(_ value: Bool) { + autoLogin = value + } + func start() { cleanupSync() epoch += 1 @@ -79,6 +85,7 @@ final class PTYSession: ObservableObject { var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" if env["LANG"] == nil { env["LANG"] = "de_DE.UTF-8" } + for (k, v) in customEnv { env[k] = v } p.environment = env // closeOnDealloc=false: Parent schließt slave explizit nach run(). p.standardInput = FileHandle(fileDescriptor: slave) @@ -282,11 +289,22 @@ final class TerminalManager: ObservableObject { func session(for profile: ConnectionProfile, key: StoredSSHKey?) -> PTYSession { if let existing = sessions[profile.id] { return existing } let s = PTYSession() + let baseExe: String + let baseArgs: [String] if profile.proto == .sftp { - s.configure(executable: "/usr/bin/sftp", arguments: ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"]) + baseExe = "/usr/bin/sftp" + baseArgs = ["-P", "\(profile.port)"] + (key.map { ["-i", $0.privateKeyPath] } ?? []) + ["\(profile.username)@\(profile.host)"] } else { - s.configure(executable: "/usr/bin/ssh", arguments: SSHService.sshArguments(for: profile, key: key)) + baseExe = "/usr/bin/ssh" + baseArgs = SSHService.sshArguments(for: profile, key: key) } + // Auto-Login nur mit Keychain-Secret + sshpass, sonst interaktiver Prompt im Terminal + let cfg = SSHService.sessionConfig(executable: baseExe, args: baseArgs, + password: SSHService.loginSecret(for: profile, key: key), + sshpassPath: SSHService.findSshpass()) + s.configure(executable: cfg.executable, arguments: cfg.arguments) + s.customEnv = cfg.extraEnv + s.setAutoLogin(cfg.autoLogin) sessions[profile.id] = s return s } diff --git a/Sources/RemminaMac/Views/ContentView.swift b/Sources/RemminaMac/Views/ContentView.swift index 878278c..a1264e5 100644 --- a/Sources/RemminaMac/Views/ContentView.swift +++ b/Sources/RemminaMac/Views/ContentView.swift @@ -104,7 +104,7 @@ struct ContentView: View { } } .sheet(item: $editingProfile) { profile in - ProfileEditorView(profile: profile) { saved in + ProfileEditorView(profile: profile, isNew: !store.profiles.contains(where: { $0.id == profile.id })) { saved in if store.profiles.contains(where: { $0.id == saved.id }) { store.update(saved) } else { diff --git a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift index c06f888..c70dae3 100644 --- a/Sources/RemminaMac/Views/EmbeddedTerminalView.swift +++ b/Sources/RemminaMac/Views/EmbeddedTerminalView.swift @@ -164,7 +164,7 @@ struct EmbeddedTerminalView: View { if checking { return "Prüfe \(profile.host):\(profile.port) …" } switch term.state { case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt." - case .running: return "Verbunden (PTY, TERM=xterm-256color)." + case .running: return term.autoLogin ? "Verbunden (Auto-Login via Keychain)." : "Verbunden (PTY, TERM=xterm-256color)." case .done(_, let msg): return msg } } diff --git a/Sources/RemminaMac/Views/ProfileEditorView.swift b/Sources/RemminaMac/Views/ProfileEditorView.swift index f958202..8bec7f0 100644 --- a/Sources/RemminaMac/Views/ProfileEditorView.swift +++ b/Sources/RemminaMac/Views/ProfileEditorView.swift @@ -4,18 +4,21 @@ struct ProfileEditorView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var store: ProfileStore @State var profile: ConnectionProfile + var isNew: Bool var onSave: (ConnectionProfile) -> Void @State private var password: String = "" @State private var savePassword: Bool = false + @State private var storedPasswordPresent: Bool = false - init(profile: ConnectionProfile, onSave: @escaping (ConnectionProfile) -> Void) { + init(profile: ConnectionProfile, isNew: Bool, onSave: @escaping (ConnectionProfile) -> Void) { _profile = State(initialValue: profile) + self.isNew = isNew self.onSave = onSave } var body: some View { VStack(spacing: 0) { - Text(profile.id.uuidString.prefix(8) == ConnectionProfile().id.uuidString.prefix(8) ? "Profil bearbeiten" : "Profil").font(.headline).padding() + Text(isNew ? "Neues Profil" : "Profil bearbeiten").font(.headline).padding() Form { Section("Allgemein") { TextField("Name", text: $profile.name) @@ -44,8 +47,17 @@ struct ProfileEditorView: View { Text("Kein Key").tag(UUID?.none) ForEach(store.sshKeys) { k in Text(k.name).tag(UUID?.some(k.id)) } } - SecureField("Passwort (optional, Keychain)", text: $password) + if storedPasswordPresent && password.isEmpty { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text("Passwort in Keychain gespeichert") + Spacer() + Button("Löschen", role: .destructive) { deleteStoredPassword() } + } + } + SecureField(storedPasswordPresent ? "Neues Passwort …" : "Passwort (optional, Keychain)", text: $password) Toggle("Passwort in Keychain speichern", isOn: $savePassword) + Text(sshpassHint).font(.caption).foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -68,12 +80,30 @@ struct ProfileEditorView: View { } .padding() } - .frame(width: 520, height: 620) + .frame(width: 520, height: 640) .onAppear { - if let acct = profile.passwordAccount { - password = KeychainHelper.load(account: acct) ?? "" - savePassword = !password.isEmpty + if let acct = profile.passwordAccount, + let stored = KeychainHelper.load(account: acct), !stored.isEmpty { + storedPasswordPresent = true + savePassword = true } } } + + var sshpassHint: String { + if SSHService.findSshpass() != nil { + return "sshpass gefunden → Auto-Login mit Keychain-Passwort aktiv, kein Tippen nötig." + } else { + return "Ohne sshpass: Passwort wird beim Verbinden im Terminal abgefragt. Für Auto-Login: brew install sshpass." + } + } + + func deleteStoredPassword() { + if let acct = profile.passwordAccount { + KeychainHelper.delete(account: acct) + } + password = "" + savePassword = false + storedPasswordPresent = false + } } diff --git a/docs/ERRORS.md b/docs/ERRORS.md index e645249..c176c23 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -17,6 +17,7 @@ Stand: 2026-09-21 · Version 0.1.0 · macOS arm64 · Swift 5.9+ · Xcode 15+ | 8 | 2026-09-21 | SSH-Session stirbt instant ohne Meldung | stderr hing mit am PTY: macOS verwirft ungelesene Master-Daten bei Slave-Close → „Code 255" ohne Text | blockierend | ✅ behoben — stderr→Pipe, TCP-Vorab-Check, Fehler-Banner | | 9 | 2026-09-21 | `swift build` | `Result`: String conformt nicht zu Error (Folgefehler: DispatchQueue-Overload) | blockierend | ✅ behoben — Completion als `(String?) -> Void` | | 10 | 2026-09-21 | Icon-Generator (`RenderIcon.swift`) | 3 Swift-Fehler: `guard let x = f() ?? g()` (nicht optional), `Int * CGFloat`, falscher Preview-Pfad | klein | ✅ behoben | +| 11 | 2026-09-21 | Passwort-Feature | Wunsch: Passwort + Keys speichern UND nutzen (bisher nur gespeichert, nie benutzt) | Feature | ✅ umgesetzt — Auto-Login via sshpass, sonst interaktiv | --- @@ -116,6 +117,16 @@ TEXT: "\n\n— Sitzung beendet (Code 255). —\n" # <- Fehlermeldung fehlt! **Fix:** `TCPCheck.check` nutzt `(String?) -> Void` (nil = ok, sonst deutsche Meldung). **Status:** ✅ behoben. +## #11 — Passwort speichern UND benutzen (User-Wunsch) +**Stand:** Passwort/Key-Passphrase wurden in der Keychain gespeichert, aber nie zum Einloggen benutzt (ssh fragte immer interaktiv). +**Umsetzung:** +- `SSHService.loginSecret()` (Profil-Passwort → Key-Passphrase), `sessionConfig()` (rein, testbar): mit Secret + sshpass → `sshpass -e ssh/sftp …` + `SSHPASS`-Env (nie Befehlszeile/Logs); sonst direkter Aufruf + interaktiver Prompt. +- `PTYSession.customEnv` + `autoLogin`-Flag, `TerminalManager` verdrahtet alles; Status zeigt „Auto-Login via Keychain". +- Editor: Gespeichert-Anzeige (✓), Löschen-Button, sshpass-Hinweis; Titel-Bug (`UUID`-Vergleich mit neuem Random-Objekt) via `isNew` ersetzt. +**Verifikation:** Config-Harness 9/9 (inkl. „Passwort nie in Befehlszeile"); echter Keychain-Roundtrip 7/7 (save/load/delete + Priorität + Fallback). +**Hinweis:** Ohne `sshpass` (`brew install sshpass`) bleibt es beim interaktiven Prompt — hier auf dem Build-Mac ebenfalls nicht installiert, getestet wurde der interaktive Pfad + Logik. +**Status:** ✅ umgesetzt. + ## #10 — Icon-Generator Buildfehler **Log:** `guard let` auf nicht-optionalem Ausdruck (`??` unwrapt schon); `Int * CGFloat`; Preview lag in `.iconset/` statt `/tmp/`. **Fix:** `??`-Fallback direkt zuweisen; `CGFloat(...)`; Pfad korrigiert. diff --git a/docs/USAGE.md b/docs/USAGE.md index f6a80f9..213df77 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -25,6 +25,12 @@ - `Neuen Key generieren`: ruft `/usr/bin/ssh-keygen -t ed25519 -f ~/.ssh/remminamac_` auf. - Passphrase → Keychain (`sshkey-`). +## Passwort speichern & Auto-Login +- Im Profil-Editor: Passwort eingeben + „in Keychain speichern“. Gespeichertes Passwort wird angezeigt (✓) und kann gelöscht werden. In `profiles.json` steht nur die Referenz, nie das Passwort. +- Reihenfolge beim Verbinden: Profil-Passwort zuerst, sonst Key-Passphrase. +- **Auto-Login ohne Tippen:** braucht `sshpass` (`brew install sshpass`). Dann loggt ssh via Keychain-Secret ein (Passwort nur im Prozess-Env `SSHPASS`, nie in Befehlszeile/Logs). Status zeigt „Auto-Login via Keychain". +- **Ohne sshpass:** Passwort/Key-Passphrase wird interaktiv im Terminal abgefragt (Passwort-Modus: Auge-Icon). + ## Quick-Connect - Mit Auswahl: verbindet direkt. Ohne Auswahl: öffnet Editor mit „Quick-Connect“-Vorlage.