Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 104 additions & 20 deletions ui/src/components/Terminal/Terminal.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,53 @@
<template>
<!-- Container that fills the available height and hosts the terminal -->
<div class="ma-0 pa-0 w-100 fill-height position-relative">
<!-- The xterm.js terminal will be mounted here -->
<div ref="terminal" class="terminal" data-test="terminal-container" />
</div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useEventListener } from "@vueuse/core";

// Terminal styles and required classes
import "xterm/css/xterm.css";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { IParams } from "@/interfaces/IParams";
import { InputMessage, MessageKind, ResizeMessage, WebTermDimensions } from "@/interfaces/ITerminal";

const { token } = defineProps<{
token: string;
// Type definitions
import { IParams } from "@/interfaces/IParams";
import {
InputMessage,
MessageKind,
ResizeMessage,
SignatureMessage,
WebTermDimensions,
} from "@/interfaces/ITerminal";

// SSH signing utilities
import {
parsePrivateKeySsh,
createSignatureOfPrivateKey,
createSignerPrivateKey,
} from "@/utils/validate";
import handleError from "@/utils/handleError";

// Props passed to the component
const { token, privateKey } = defineProps<{
token: string; // JWT token for WebSocket authentication
privateKey?: string | null; // Optional SSH private key for challenge-response auth
}>();

const terminal = ref<HTMLElement>({} as HTMLElement);
const xterm = ref<Terminal>({} as Terminal);
const fitAddon = ref<FitAddon>(new FitAddon());
const ws = ref<WebSocket>({} as WebSocket);
const textEncoder = new TextEncoder();
// Refs and runtime state
const terminal = ref<HTMLElement>({} as HTMLElement); // Terminal DOM container
const xterm = ref<Terminal>({} as Terminal); // xterm.js terminal instance
const fitAddon = ref<FitAddon>(new FitAddon()); // Auto-fit terminal to container
const ws = ref<WebSocket>({} as WebSocket); // Active WebSocket connection
const textEncoder = new TextEncoder(); // Converts strings to Uint8Array
const isReady = ref(false); // Tracks if WS is open and ready

// Initialize terminal instance and attach the fit addon
const initializeTerminal = () => {
xterm.value = new Terminal({
cursorBlink: true,
Expand All @@ -35,65 +60,122 @@ const initializeTerminal = () => {
xterm.value.loadAddon(fitAddon.value);
};

// Get terminal dimensions for WebSocket URL
const getWebTermDimensions = (): WebTermDimensions => ({
cols: xterm.value.cols,
rows: xterm.value.rows,
});

// Convert token and dimensions into query params for WS URL
const encodeURLParams = (params: IParams): string => Object.entries(params).map(([key, value]) => `${key}=${value}`).join("&");

const isWebSocketOpen = () => ws.value.readyState === WebSocket.OPEN;
// Check if WebSocket is open and usable
const isWebSocketOpen = () => isReady.value && ws.value.readyState === WebSocket.OPEN;

// Construct the WebSocket URL with protocol, host, and query
const getWebSocketUrl = (dimensions: WebTermDimensions): string => {
const protocol = window.location.protocol === "http:" ? "ws" : "wss";
const wsInfo = { token, ...dimensions };

return `${protocol}://${window.location.host}/ws/ssh?${encodeURLParams(wsInfo)}`;
};

// Set up terminal events for user input and resize events
const setupTerminalEvents = () => {
// Send user input over WebSocket
xterm.value.onData((data) => {
if (!isWebSocketOpen()) return;

const message: InputMessage = {
kind: MessageKind.Input,
data: [...textEncoder.encode(data)],
};

ws.value.send(JSON.stringify(message));
});

xterm.value.onResize((data) => {
// Send terminal resize info over WebSocket
xterm.value.onResize(({ cols, rows }) => {
if (!isWebSocketOpen()) return;

const message: ResizeMessage = {
kind: MessageKind.Resize,
data: { cols: data.cols, rows: data.rows },
data: { cols, rows },
};

ws.value.send(JSON.stringify(message));
});
};

// Write text output to the terminal UI
const writeToTerminal = (data: string) => {
xterm.value.write(data);
};

// Handles signing of SSH challenge using the user's private key
const signWebSocketChallenge = async (
key: string,
base64Challenge: string,
): Promise<string> => {
const challenge = atob(base64Challenge);
const parsedKey = parsePrivateKeySsh(key);

if (parsedKey.type === "ed25519") {
return createSignerPrivateKey(parsedKey, challenge);
}

return decodeURIComponent(await createSignatureOfPrivateKey(parsedKey, challenge));
};

// Initialize WebSocket and its message handling
const setupWebSocketEvents = () => {
ws.value.onopen = () => {
fitAddon.value.fit();
};

ws.value.onmessage = (event) => {
xterm.value.write(event.data);
fitAddon.value.fit(); // Adjust terminal to container
isReady.value = true;

// If using public key auth, expect challenge message first
if (privateKey) {
ws.value.onmessage = async (event) => {
try {
const parsed = JSON.parse(event.data) as SignatureMessage;
if (parsed.kind === MessageKind.Signature) {
const signature = await signWebSocketChallenge(privateKey, parsed.data);
ws.value.send(JSON.stringify({ kind: 3, data: signature }));

// After challenge is signed, switch to raw message handling
ws.value.onmessage = (e) => writeToTerminal(e.data);
}
} catch (err) {
writeToTerminal("\r\nFailed to sign challenge.\r\n");
handleError(err);
ws.value.close();
}
};

return; // Skip password-mode handler
}

// For password-based logins, simply write messages to the terminal
ws.value.onmessage = (event) => {
writeToTerminal(event.data);
};
};

ws.value.onclose = () => {
xterm.value.write("\r\nConnection ended");
writeToTerminal("\r\nConnection ended\r\n");
isReady.value = false;
};
};

// Connect and initialize WebSocket session
const initializeWebSocket = () => {
const dimensions = getWebTermDimensions();
const wsUrl = getWebSocketUrl(dimensions);
ws.value = new WebSocket(wsUrl);
setupWebSocketEvents();
};

// Mount lifecycle: Initialize terminal and WebSocket
onMounted(() => {
initializeTerminal();
xterm.value.open(terminal.value);
Expand All @@ -103,21 +185,23 @@ onMounted(() => {
initializeWebSocket();
});

// Resize the terminal when window is resized
useEventListener(window, "resize", () => {
fitAddon.value.fit();
});

// Cleanup lifecycle: close WebSocket if active
onUnmounted(() => {
if (isWebSocketOpen()) ws.value.close();
});

// Optional expose for testing or parent communication
defineExpose({ xterm, ws });
</script>

<style scoped lang="scss">
.terminal {
position: absolute;
inset: 0;
margin-right: 0px;
inset: 0; // Fills the container completely
}
</style>
91 changes: 57 additions & 34 deletions ui/src/components/Terminal/TerminalDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
/>
<Terminal
v-else
:token
:key="terminalKey"
:token="token"
:privateKey="privateKey ?? null"
/>

</v-card>
</v-dialog>
</template>
Expand All @@ -33,69 +36,76 @@ import { useEventListener } from "@vueuse/core";
import { useRoute } from "vue-router";
import { useDisplay } from "vuetify";
import {
createKeyFingerprint,
createSignatureOfPrivateKey,
createSignerPrivateKey,
parsePrivateKeySsh,
} from "@/utils/validate";
import { IConnectToTerminal, LoginFormData, TerminalAuthMethods } from "@/interfaces/ITerminal";
IConnectToTerminal,
LoginFormData,
TerminalAuthMethods,
} from "@/interfaces/ITerminal";

// Components used in this dialog
import TerminalLoginForm from "./TerminalLoginForm.vue";
import Terminal from "./Terminal.vue";

// Utility to create key fingerprint for private key auth
import { createKeyFingerprint } from "@/utils/validate";

// Props: Device UID to connect the terminal session to
const { deviceUid } = defineProps<{
deviceUid: string;
}>();

const route = useRoute();
const showLoginForm = ref(true);
const showDialog = defineModel<boolean>();
const route = useRoute(); // current route
const showLoginForm = ref(true); // controls whether login or terminal is shown
const terminalKey = ref(0);
const showDialog = defineModel<boolean>(); // controls visibility of dialog

// Vuetify breakpoint info
const { smAndDown, thresholds } = useDisplay();

// Token and private key values for terminal connection
const token = ref("");
const privateKey = ref<LoginFormData["privateKey"]>("");

// Connect to terminal via password or key
const connect = async (params: IConnectToTerminal) => {
const response = await axios.post("/ws/ssh", {
device: deviceUid,
...params,
});

token.value = response.data.token;

showLoginForm.value = false;
};

// Handles private key-based connection
const connectWithPrivateKey = async (params: IConnectToTerminal) => {
const { username, privateKey } = params;
const parsedPrivateKey = parsePrivateKeySsh(privateKey);
const fingerprint = await createKeyFingerprint(privateKey);

let signature;
if (parsedPrivateKey.type === "ed25519") {
const signer = createSignerPrivateKey(parsedPrivateKey, username);
signature = signer;
} else {
signature = decodeURIComponent(await createSignatureOfPrivateKey(
parsedPrivateKey,
username,
));
}

connect({ username, fingerprint, signature });
await connect({ username, fingerprint });
};

const handleSubmit = (params: LoginFormData) => {
// Triggered when the user submits login credentials
const handleSubmit = async (params: LoginFormData) => {
if (params.authenticationMethod === TerminalAuthMethods.Password) {
connect(params);
} else connectWithPrivateKey(params);
await connect(params);
return;
}

await connectWithPrivateKey(params);
privateKey.value = params.privateKey;
showLoginForm.value = false;
};

// Reset state and close the dialog
const close = () => {
showDialog.value = false;
showLoginForm.value = true;
token.value = "";
privateKey.value = "";
terminalKey.value++; // trigger remount
};

// Track timing of ESC presses to close terminal on double ESC
let lastEscPress = 0;

const handleEscKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !showLoginForm.value) {
const currentTime = new Date().getTime();
Expand All @@ -106,11 +116,24 @@ const handleEscKey = (event: KeyboardEvent) => {
}
};

// Bind ESC key listener
useEventListener("keyup", handleEscKey);

watch(() => route.path, (path) => {
if (path === `/devices/${deviceUid}/terminal`) showDialog.value = true;
}, { immediate: true });

defineExpose({ token, handleSubmit, showDialog, showLoginForm, close });
// Auto-open terminal when navigating to specific device route
watch(
() => route.path,
(path) => {
if (path === `/devices/${deviceUid}/terminal`) showDialog.value = true;
},
{ immediate: true },
);

// Expose for test or parent interaction
defineExpose({
token,
handleSubmit,
showDialog,
showLoginForm,
close,
});
</script>
6 changes: 6 additions & 0 deletions ui/src/interfaces/ITerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface WebTermDimensions {
export enum MessageKind {
Input = 1,
Resize,
Signature = 3,
}

export interface ResizeMessage {
Expand All @@ -37,3 +38,8 @@ export interface InputMessage {
kind: MessageKind.Input;
data: number[];
}

export interface SignatureMessage {
kind: MessageKind.Signature;
data: string; // base64-encoded challenge or response
}
Loading