Skip to content

Commit 42d58ad

Browse files
committed
feat(ui): support SSH private key auth in terminal session
Add support for signing WebSocket SSH challenges using a private key. Pass `privateKey` prop to Terminal component and handle key-based authentication in TerminalDialog. Enhance robustness of WebSocket handling and improve inline comments for maintainability.
1 parent f499552 commit 42d58ad

File tree

4 files changed

+181
-62
lines changed

4 files changed

+181
-62
lines changed
Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,53 @@
11
<template>
2+
<!-- Container that fills the available height and hosts the terminal -->
23
<div class="ma-0 pa-0 w-100 fill-height position-relative">
4+
<!-- The xterm.js terminal will be mounted here -->
35
<div ref="terminal" class="terminal" data-test="terminal-container" />
46
</div>
57
</template>
68

79
<script setup lang="ts">
810
import { onMounted, onUnmounted, ref } from "vue";
911
import { useEventListener } from "@vueuse/core";
12+
13+
// Terminal styles and required classes
1014
import "xterm/css/xterm.css";
1115
import { Terminal } from "xterm";
1216
import { FitAddon } from "xterm-addon-fit";
13-
import { IParams } from "@/interfaces/IParams";
14-
import { InputMessage, MessageKind, ResizeMessage, WebTermDimensions } from "@/interfaces/ITerminal";
1517
16-
const { token } = defineProps<{
17-
token: string;
18+
// Type definitions
19+
import { IParams } from "@/interfaces/IParams";
20+
import {
21+
InputMessage,
22+
MessageKind,
23+
ResizeMessage,
24+
SignatureMessage,
25+
WebTermDimensions,
26+
} from "@/interfaces/ITerminal";
27+
28+
// SSH signing utilities
29+
import {
30+
parsePrivateKeySsh,
31+
createSignatureOfPrivateKey,
32+
createSignerPrivateKey,
33+
} from "@/utils/validate";
34+
import handleError from "@/utils/handleError";
35+
36+
// Props passed to the component
37+
const { token, privateKey } = defineProps<{
38+
token: string; // JWT token for WebSocket authentication
39+
privateKey?: string | null; // Optional SSH private key for challenge-response auth
1840
}>();
1941
20-
const terminal = ref<HTMLElement>({} as HTMLElement);
21-
const xterm = ref<Terminal>({} as Terminal);
22-
const fitAddon = ref<FitAddon>(new FitAddon());
23-
const ws = ref<WebSocket>({} as WebSocket);
24-
const textEncoder = new TextEncoder();
42+
// Refs and runtime state
43+
const terminal = ref<HTMLElement>({} as HTMLElement); // Terminal DOM container
44+
const xterm = ref<Terminal>({} as Terminal); // xterm.js terminal instance
45+
const fitAddon = ref<FitAddon>(new FitAddon()); // Auto-fit terminal to container
46+
const ws = ref<WebSocket>({} as WebSocket); // Active WebSocket connection
47+
const textEncoder = new TextEncoder(); // Converts strings to Uint8Array
48+
const isReady = ref(false); // Tracks if WS is open and ready
2549
50+
// Initialize terminal instance and attach the fit addon
2651
const initializeTerminal = () => {
2752
xterm.value = new Terminal({
2853
cursorBlink: true,
@@ -35,65 +60,122 @@ const initializeTerminal = () => {
3560
xterm.value.loadAddon(fitAddon.value);
3661
};
3762
63+
// Get terminal dimensions for WebSocket URL
3864
const getWebTermDimensions = (): WebTermDimensions => ({
3965
cols: xterm.value.cols,
4066
rows: xterm.value.rows,
4167
});
4268
69+
// Convert token and dimensions into query params for WS URL
4370
const encodeURLParams = (params: IParams): string => Object.entries(params).map(([key, value]) => `${key}=${value}`).join("&");
4471
45-
const isWebSocketOpen = () => ws.value.readyState === WebSocket.OPEN;
72+
// Check if WebSocket is open and usable
73+
const isWebSocketOpen = () => isReady.value && ws.value.readyState === WebSocket.OPEN;
4674
75+
// Construct the WebSocket URL with protocol, host, and query
4776
const getWebSocketUrl = (dimensions: WebTermDimensions): string => {
4877
const protocol = window.location.protocol === "http:" ? "ws" : "wss";
4978
const wsInfo = { token, ...dimensions };
5079
5180
return `${protocol}://${window.location.host}/ws/ssh?${encodeURLParams(wsInfo)}`;
5281
};
5382
83+
// Set up terminal events for user input and resize events
5484
const setupTerminalEvents = () => {
85+
// Send user input over WebSocket
5586
xterm.value.onData((data) => {
5687
if (!isWebSocketOpen()) return;
5788
5889
const message: InputMessage = {
5990
kind: MessageKind.Input,
6091
data: [...textEncoder.encode(data)],
6192
};
93+
6294
ws.value.send(JSON.stringify(message));
6395
});
6496
65-
xterm.value.onResize((data) => {
97+
// Send terminal resize info over WebSocket
98+
xterm.value.onResize(({ cols, rows }) => {
6699
if (!isWebSocketOpen()) return;
67100
68101
const message: ResizeMessage = {
69102
kind: MessageKind.Resize,
70-
data: { cols: data.cols, rows: data.rows },
103+
data: { cols, rows },
71104
};
105+
72106
ws.value.send(JSON.stringify(message));
73107
});
74108
};
75109
110+
// Write text output to the terminal UI
111+
const writeToTerminal = (data: string) => {
112+
xterm.value.write(data);
113+
};
114+
115+
// Handles signing of SSH challenge using the user's private key
116+
const signWebSocketChallenge = async (
117+
key: string,
118+
base64Challenge: string,
119+
): Promise<string> => {
120+
const challenge = atob(base64Challenge);
121+
const parsedKey = parsePrivateKeySsh(key);
122+
123+
if (parsedKey.type === "ed25519") {
124+
return createSignerPrivateKey(parsedKey, challenge);
125+
}
126+
127+
return decodeURIComponent(await createSignatureOfPrivateKey(parsedKey, challenge));
128+
};
129+
130+
// Initialize WebSocket and its message handling
76131
const setupWebSocketEvents = () => {
77132
ws.value.onopen = () => {
78-
fitAddon.value.fit();
79-
};
80-
81-
ws.value.onmessage = (event) => {
82-
xterm.value.write(event.data);
133+
fitAddon.value.fit(); // Adjust terminal to container
134+
isReady.value = true;
135+
136+
// If using public key auth, expect challenge message first
137+
if (privateKey) {
138+
ws.value.onmessage = async (event) => {
139+
try {
140+
const parsed = JSON.parse(event.data) as SignatureMessage;
141+
if (parsed.kind === MessageKind.Signature) {
142+
const signature = await signWebSocketChallenge(privateKey, parsed.data);
143+
ws.value.send(JSON.stringify({ kind: 3, data: signature }));
144+
145+
// After challenge is signed, switch to raw message handling
146+
ws.value.onmessage = (e) => writeToTerminal(e.data);
147+
}
148+
} catch (err) {
149+
writeToTerminal("\r\nFailed to sign challenge.\r\n");
150+
handleError(err);
151+
ws.value.close();
152+
}
153+
};
154+
155+
return; // Skip password-mode handler
156+
}
157+
158+
// For password-based logins, simply write messages to the terminal
159+
ws.value.onmessage = (event) => {
160+
writeToTerminal(event.data);
161+
};
83162
};
84163
85164
ws.value.onclose = () => {
86-
xterm.value.write("\r\nConnection ended");
165+
writeToTerminal("\r\nConnection ended\r\n");
166+
isReady.value = false;
87167
};
88168
};
89169
170+
// Connect and initialize WebSocket session
90171
const initializeWebSocket = () => {
91172
const dimensions = getWebTermDimensions();
92173
const wsUrl = getWebSocketUrl(dimensions);
93174
ws.value = new WebSocket(wsUrl);
94175
setupWebSocketEvents();
95176
};
96177
178+
// Mount lifecycle: Initialize terminal and WebSocket
97179
onMounted(() => {
98180
initializeTerminal();
99181
xterm.value.open(terminal.value);
@@ -103,21 +185,23 @@ onMounted(() => {
103185
initializeWebSocket();
104186
});
105187
188+
// Resize the terminal when window is resized
106189
useEventListener(window, "resize", () => {
107190
fitAddon.value.fit();
108191
});
109192
193+
// Cleanup lifecycle: close WebSocket if active
110194
onUnmounted(() => {
111195
if (isWebSocketOpen()) ws.value.close();
112196
});
113197
198+
// Optional expose for testing or parent communication
114199
defineExpose({ xterm, ws });
115200
</script>
116201

117202
<style scoped lang="scss">
118203
.terminal {
119204
position: absolute;
120-
inset: 0;
121-
margin-right: 0px;
205+
inset: 0; // Fills the container completely
122206
}
123207
</style>

ui/src/components/Terminal/TerminalDialog.vue

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
/>
2121
<Terminal
2222
v-else
23-
:token
23+
:key="terminalKey"
24+
:token="token"
25+
:privateKey="privateKey ?? null"
2426
/>
27+
2528
</v-card>
2629
</v-dialog>
2730
</template>
@@ -33,69 +36,76 @@ import { useEventListener } from "@vueuse/core";
3336
import { useRoute } from "vue-router";
3437
import { useDisplay } from "vuetify";
3538
import {
36-
createKeyFingerprint,
37-
createSignatureOfPrivateKey,
38-
createSignerPrivateKey,
39-
parsePrivateKeySsh,
40-
} from "@/utils/validate";
41-
import { IConnectToTerminal, LoginFormData, TerminalAuthMethods } from "@/interfaces/ITerminal";
39+
IConnectToTerminal,
40+
LoginFormData,
41+
TerminalAuthMethods,
42+
} from "@/interfaces/ITerminal";
43+
44+
// Components used in this dialog
4245
import TerminalLoginForm from "./TerminalLoginForm.vue";
4346
import Terminal from "./Terminal.vue";
4447
48+
// Utility to create key fingerprint for private key auth
49+
import { createKeyFingerprint } from "@/utils/validate";
50+
51+
// Props: Device UID to connect the terminal session to
4552
const { deviceUid } = defineProps<{
4653
deviceUid: string;
4754
}>();
4855
49-
const route = useRoute();
50-
const showLoginForm = ref(true);
51-
const showDialog = defineModel<boolean>();
56+
const route = useRoute(); // current route
57+
const showLoginForm = ref(true); // controls whether login or terminal is shown
58+
const terminalKey = ref(0);
59+
const showDialog = defineModel<boolean>(); // controls visibility of dialog
60+
61+
// Vuetify breakpoint info
5262
const { smAndDown, thresholds } = useDisplay();
63+
64+
// Token and private key values for terminal connection
5365
const token = ref("");
66+
const privateKey = ref<LoginFormData["privateKey"]>("");
5467
68+
// Connect to terminal via password or key
5569
const connect = async (params: IConnectToTerminal) => {
5670
const response = await axios.post("/ws/ssh", {
5771
device: deviceUid,
5872
...params,
5973
});
6074
6175
token.value = response.data.token;
62-
6376
showLoginForm.value = false;
6477
};
6578
79+
// Handles private key-based connection
6680
const connectWithPrivateKey = async (params: IConnectToTerminal) => {
6781
const { username, privateKey } = params;
68-
const parsedPrivateKey = parsePrivateKeySsh(privateKey);
6982
const fingerprint = await createKeyFingerprint(privateKey);
70-
71-
let signature;
72-
if (parsedPrivateKey.type === "ed25519") {
73-
const signer = createSignerPrivateKey(parsedPrivateKey, username);
74-
signature = signer;
75-
} else {
76-
signature = decodeURIComponent(await createSignatureOfPrivateKey(
77-
parsedPrivateKey,
78-
username,
79-
));
80-
}
81-
82-
connect({ username, fingerprint, signature });
83+
await connect({ username, fingerprint });
8384
};
8485
85-
const handleSubmit = (params: LoginFormData) => {
86+
// Triggered when the user submits login credentials
87+
const handleSubmit = async (params: LoginFormData) => {
8688
if (params.authenticationMethod === TerminalAuthMethods.Password) {
87-
connect(params);
88-
} else connectWithPrivateKey(params);
89+
await connect(params);
90+
return;
91+
}
92+
93+
await connectWithPrivateKey(params);
94+
privateKey.value = params.privateKey;
95+
showLoginForm.value = false;
8996
};
9097
98+
// Reset state and close the dialog
9199
const close = () => {
92100
showDialog.value = false;
93101
showLoginForm.value = true;
94102
token.value = "";
103+
privateKey.value = "";
104+
terminalKey.value++; // trigger remount
95105
};
96106
107+
// Track timing of ESC presses to close terminal on double ESC
97108
let lastEscPress = 0;
98-
99109
const handleEscKey = (event: KeyboardEvent) => {
100110
if (event.key === "Escape" && !showLoginForm.value) {
101111
const currentTime = new Date().getTime();
@@ -106,11 +116,24 @@ const handleEscKey = (event: KeyboardEvent) => {
106116
}
107117
};
108118
119+
// Bind ESC key listener
109120
useEventListener("keyup", handleEscKey);
110121
111-
watch(() => route.path, (path) => {
112-
if (path === `/devices/${deviceUid}/terminal`) showDialog.value = true;
113-
}, { immediate: true });
114-
115-
defineExpose({ token, handleSubmit, showDialog, showLoginForm, close });
122+
// Auto-open terminal when navigating to specific device route
123+
watch(
124+
() => route.path,
125+
(path) => {
126+
if (path === `/devices/${deviceUid}/terminal`) showDialog.value = true;
127+
},
128+
{ immediate: true },
129+
);
130+
131+
// Expose for test or parent interaction
132+
defineExpose({
133+
token,
134+
handleSubmit,
135+
showDialog,
136+
showLoginForm,
137+
close,
138+
});
116139
</script>

ui/src/interfaces/ITerminal.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface WebTermDimensions {
2626
export enum MessageKind {
2727
Input = 1,
2828
Resize,
29+
Signature = 3,
2930
}
3031

3132
export interface ResizeMessage {
@@ -37,3 +38,8 @@ export interface InputMessage {
3738
kind: MessageKind.Input;
3839
data: number[];
3940
}
41+
42+
export interface SignatureMessage {
43+
kind: MessageKind.Signature;
44+
data: string; // base64-encoded challenge or response
45+
}

0 commit comments

Comments
 (0)