fix: SSH password prompt on auth failure, version from Go backend, visible errors
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s

- ConnectSSH returns NO_CREDENTIALS error when no credential is stored
- Frontend catches auth failures and prompts for username/password
- ConnectSSHWithPassword method for ad-hoc password auth
- Version loaded from Go backend (build-time -ldflags) in settings + unlock screen
- Connection errors shown as alert() instead of silent console.error
- Added UpdateService.CurrentVersion() and WraithApp.GetVersion()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 11:38:08 -04:00
parent b46c20b0d0
commit 163af456b4
5 changed files with 108 additions and 21 deletions

View File

@ -335,6 +335,9 @@ onMounted(async () => {
if (theme) settings.value.terminalTheme = theme; if (theme) settings.value.terminalTheme = theme;
if (fontSize) settings.value.fontSize = Number(fontSize); if (fontSize) settings.value.fontSize = Number(fontSize);
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback); if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
// Load version from Go backend
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
if (ver) currentVersion.value = ver;
} catch (err) { } catch (err) {
console.error("Failed to load settings:", err); console.error("Failed to load settings:", err);
} }
@ -360,7 +363,8 @@ watch(() => settings.value.scrollbackBuffer, (val) => {
// --- Update check state --- // --- Update check state ---
type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error"; type UpdateCheckState = "idle" | "checking" | "found" | "up-to-date" | "error";
const updateCheckState = ref<UpdateCheckState>("idle"); const updateCheckState = ref<UpdateCheckState>("idle");
const currentVersion = ref("0.1.0-dev"); const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
const currentVersion = ref("loading...");
const latestVersion = ref(""); const latestVersion = ref("");
function open(): void { function open(): void {

View File

@ -68,7 +68,7 @@
<!-- Version --> <!-- Version -->
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4"> <p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
v1.0.0-alpha v{{ appVersion }}
</p> </p>
</div> </div>
</div> </div>
@ -76,17 +76,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { Call } from "@wailsio/runtime";
import { useAppStore } from "@/stores/app.store"; import { useAppStore } from "@/stores/app.store";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
const appStore = useAppStore(); const appStore = useAppStore();
const password = ref(""); const password = ref("");
const confirmPassword = ref(""); const confirmPassword = ref("");
const submitting = ref(false); const submitting = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null); const passwordInput = ref<HTMLInputElement | null>(null);
const appVersion = ref("...");
onMounted(() => { onMounted(async () => {
passwordInput.value?.focus(); passwordInput.value?.focus();
try {
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
if (ver) appVersion.value = ver;
} catch { /* ignore */ }
}); });
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {

View File

@ -17,6 +17,7 @@ export const useSessionStore = defineStore("session", () => {
const sessions = ref<Session[]>([]); const sessions = ref<Session[]>([]);
const activeSessionId = ref<string | null>(null); const activeSessionId = ref<string | null>(null);
const connecting = ref(false); const connecting = ref(false);
const lastError = ref<string | null>(null);
const activeSession = computed(() => const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value) ?? null, sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
@ -72,13 +73,38 @@ export const useSessionStore = defineStore("session", () => {
connecting.value = true; connecting.value = true;
try { try {
if (conn.protocol === "ssh") { if (conn.protocol === "ssh") {
// Call Go backend — resolves credentials, builds auth, returns sessionID let sessionId: string;
const sessionId = await Call.ByName(
try {
// Try with stored credentials first
sessionId = await Call.ByName(
`${APP}.ConnectSSH`, `${APP}.ConnectSSH`,
connectionId, connectionId,
120, // cols (will be resized by xterm.js fit addon) 120, // cols (will be resized by xterm.js fit addon)
40, // rows 40, // rows
) as string; ) as string;
} catch (sshErr: any) {
const errMsg = typeof sshErr === "string" ? sshErr : sshErr?.message ?? String(sshErr);
// If no credentials, prompt for username/password
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate")) {
const username = prompt(`Username for ${conn.hostname}:`, "root");
if (!username) throw new Error("Connection cancelled");
const password = prompt(`Password for ${username}@${conn.hostname}:`);
if (password === null) throw new Error("Connection cancelled");
sessionId = await Call.ByName(
`${APP}.ConnectSSHWithPassword`,
connectionId,
username,
password,
120,
40,
) as string;
} else {
throw sshErr;
}
}
sessions.value.push({ sessions.value.push({
id: sessionId, id: sessionId,
@ -106,9 +132,12 @@ export const useSessionStore = defineStore("session", () => {
}); });
activeSessionId.value = sessionId; activeSessionId.value = sessionId;
} }
} catch (err) { } catch (err: any) {
console.error("Connection failed:", err); const msg = typeof err === "string" ? err : err?.message ?? String(err);
// TODO: Show error toast in UI console.error("Connection failed:", msg);
lastError.value = msg;
// Show error as native alert so it's visible without DevTools
alert(`Connection failed: ${msg}`);
} finally { } finally {
connecting.value = false; connecting.value = false;
} }
@ -120,6 +149,7 @@ export const useSessionStore = defineStore("session", () => {
activeSession, activeSession,
sessionCount, sessionCount,
connecting, connecting,
lastError,
activateSession, activateSession,
closeSession, closeSession,
connect, connect,

View File

@ -301,15 +301,9 @@ func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, erro
} }
} }
// Fallback: keyboard-interactive (prompts for password) // If no stored credentials, return an error telling the frontend to prompt
if len(authMethods) == 0 { if len(authMethods) == 0 {
slog.Info("no stored credentials, SSH will attempt keyboard-interactive auth") return "", fmt.Errorf("NO_CREDENTIALS: no credentials configured for this connection — assign a credential or use ConnectSSHWithPassword")
authMethods = append(authMethods, gossh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
// No interactive prompt available yet — return empty
return make([]string, len(questions)), nil
},
))
} }
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows) sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
@ -337,6 +331,53 @@ func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, erro
return sessionID, nil 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. // DisconnectSession closes an active SSH session and its SFTP client.
func (a *WraithApp) DisconnectSession(sessionID string) error { func (a *WraithApp) DisconnectSession(sessionID string) error {
a.SFTP.RemoveClient(sessionID) a.SFTP.RemoveClient(sessionID)

View File

@ -36,6 +36,11 @@ type UpdateService struct {
httpClient *http.Client httpClient *http.Client
} }
// CurrentVersion returns the build-time version string.
func (u *UpdateService) CurrentVersion() string {
return u.currentVersion
}
// NewUpdateService creates an UpdateService that compares against the // NewUpdateService creates an UpdateService that compares against the
// supplied currentVersion (semver without "v" prefix, e.g. "0.2.0"). // supplied currentVersion (semver without "v" prefix, e.g. "0.2.0").
func NewUpdateService(currentVersion string) *UpdateService { func NewUpdateService(currentVersion string) *UpdateService {