mirror of
https://github.com/tale/headplane.git
synced 2026-08-26 04:16:49 +00:00
feat: cleanup and streamline webssh capability
This commit is contained in:
+121
-119
@@ -1,82 +1,95 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import { useState } from 'react';
|
|
||||||
import { LoaderFunctionArgs } from 'react-router';
|
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
ShouldRevalidateFunction,
|
||||||
|
data,
|
||||||
|
useLoaderData,
|
||||||
|
} from 'react-router';
|
||||||
|
import { LoadContext } from '~/server';
|
||||||
|
import { PreAuthKey, User } from '~/types';
|
||||||
|
import { useLiveData } from '~/utils/live-data';
|
||||||
|
import XTerm from './xterm.client';
|
||||||
|
|
||||||
|
import wasm from '~/hp_ssh.wasm?url';
|
||||||
import '~/wasm_exec';
|
import '~/wasm_exec';
|
||||||
|
|
||||||
|
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
// const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
// const user = session.get('user');
|
const user = session.get('user');
|
||||||
// if (!user) {
|
if (!user) {
|
||||||
// throw data('Unauthorized', 401);
|
throw data('Unauthorized', 401);
|
||||||
// }
|
}
|
||||||
// if (user.subject === 'unknown-non-oauth') {
|
if (user.subject === 'unknown-non-oauth') {
|
||||||
// throw data('Only OAuth users are allowed to use WebSSH', 403);
|
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||||
// }
|
}
|
||||||
// const { users } = await context.client.get<{ users: User[] }>(
|
const { users } = await context.client.get<{ users: User[] }>(
|
||||||
// 'v1/user',
|
'v1/user',
|
||||||
// session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
|
);
|
||||||
|
// MARK: This assumes that a user has authenticated with Headscale first
|
||||||
|
// Since the only way to enforce permissions via ACLs is to generate a
|
||||||
|
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||||
|
// authenticated with Headscale first.
|
||||||
|
const lookup = users.find((u) => {
|
||||||
|
const subject = u.providerId?.split('/').pop();
|
||||||
|
if (!subject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return subject === user.subject;
|
||||||
|
});
|
||||||
|
if (!lookup) {
|
||||||
|
throw data(
|
||||||
|
`User with subject ${user.subject} not found within Headscale`,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||||
|
'v1/preauthkey',
|
||||||
|
session.get('api_key')!,
|
||||||
|
{
|
||||||
|
user: lookup.id,
|
||||||
|
reusable: false,
|
||||||
|
ephemeral: true,
|
||||||
|
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// TODO: Enable config to enforce generate_authkeys capability
|
||||||
|
// For now, any user is capable of WebSSH connections
|
||||||
|
// const check = await context.sessions.check(
|
||||||
|
// request,
|
||||||
|
// Capabilities.generate_authkeys,
|
||||||
// );
|
// );
|
||||||
// // MARK: This assumes that a user has authenticated with Headscale first
|
const qp = new URL(request.url).searchParams;
|
||||||
// // Since the only way to enforce permissions via ACLs is to generate a
|
const username = qp.get('username') || undefined;
|
||||||
// // pre-authkey which REQUIRES a user ID, meaning the user has to have
|
const hostname = qp.get('hostname') || undefined;
|
||||||
// // authenticated with Headscale first.
|
const port = qp.get('port')
|
||||||
// const lookup = users.find((u) => {
|
? Number.parseInt(qp.get('port')!, 10)
|
||||||
// const subject = u.providerId?.split('/').pop();
|
: undefined;
|
||||||
// if (!subject) {
|
if (!username || !hostname || !port) {
|
||||||
// return false;
|
throw data('Missing required parameters: username, hostname, port', 400);
|
||||||
// }
|
}
|
||||||
// return subject === user.subject;
|
// TODO: Verify the Host headers to ensure CORS friendly
|
||||||
// });
|
// TODO: Check if the URL actually resolves correctly
|
||||||
// if (!lookup) {
|
// TODO: Keep track of hostname since ephemeral nodes are broken atm
|
||||||
// throw data(
|
return {
|
||||||
// `User with subject ${user.subject} not found within Headscale`,
|
PreAuthKey: preAuthKey.key,
|
||||||
// 404,
|
ControlURL:
|
||||||
// );
|
context.config.headscale.public_url ?? context.config.headscale.url,
|
||||||
// }
|
Hostname: generateHostname(username),
|
||||||
// const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
ssh: {
|
||||||
// 'v1/preauthkey',
|
username,
|
||||||
// session.get('api_key')!,
|
hostname,
|
||||||
// {
|
},
|
||||||
// user: lookup.id,
|
};
|
||||||
// reusable: false,
|
|
||||||
// ephemeral: true,
|
|
||||||
// expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// // TODO: Enable config to enforce generate_authkeys capability
|
|
||||||
// // For now, any user is capable of WebSSH connections
|
|
||||||
// // const check = await context.sessions.check(
|
|
||||||
// // request,
|
|
||||||
// // Capabilities.generate_authkeys,
|
|
||||||
// // );
|
|
||||||
// const qp = new URL(request.url).searchParams;
|
|
||||||
// const username = qp.get('username') || undefined;
|
|
||||||
// const hostname = qp.get('hostname') || undefined;
|
|
||||||
// const port = qp.get('port')
|
|
||||||
// ? Number.parseInt(qp.get('port')!, 10)
|
|
||||||
// : undefined;
|
|
||||||
// if (!username || !hostname || !port) {
|
|
||||||
// throw data('Missing required parameters: username, hostname, port', 400);
|
|
||||||
// }
|
|
||||||
// // TODO: Verify the Host headers to ensure CORS friendly
|
|
||||||
// // TODO: Check if the URL actually resolves correctly
|
|
||||||
// // TODO: Keep track of hostname since ephemeral nodes are broken atm
|
|
||||||
// return {
|
|
||||||
// PreAuthKey: preAuthKey.key,
|
|
||||||
// ControlURL:
|
|
||||||
// context.config.headscale.public_url ?? context.config.headscale.url,
|
|
||||||
// Hostname: generateHostname(username),
|
|
||||||
// ssh: {
|
|
||||||
// username,
|
|
||||||
// hostname,
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateHostname(username: string) {
|
function generateHostname(username: string) {
|
||||||
@@ -98,46 +111,46 @@ function generateHostname(username: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
// const { pause } = useLiveData();
|
const { pause } = useLiveData();
|
||||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||||
// const { PreAuthKey, ControlURL, Hostname, ssh } =
|
const { PreAuthKey, ControlURL, Hostname, ssh } =
|
||||||
// useLoaderData<typeof loader>();
|
useLoaderData<typeof loader>();
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// pause();
|
pause();
|
||||||
// const go = new Go(); // Go is defined by wasm_exec.js
|
const go = new Go(); // Go is defined by wasm_exec.js
|
||||||
// WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||||
// (value) => {
|
(value) => {
|
||||||
// go.run(value.instance);
|
go.run(value.instance);
|
||||||
// const handle = TsWasmNet(
|
const handle = TsWasmNet(
|
||||||
// {
|
{
|
||||||
// PreAuthKey,
|
PreAuthKey,
|
||||||
// ControlURL,
|
ControlURL,
|
||||||
// Hostname,
|
Hostname,
|
||||||
// },
|
},
|
||||||
// {
|
{
|
||||||
// NotifyState: (state) => {
|
NotifyState: (state) => {
|
||||||
// console.log('State changed:', state);
|
console.log('State changed:', state);
|
||||||
// if (state === 'Running') {
|
if (state === 'Running') {
|
||||||
// setIpn(handle);
|
setIpn(handle);
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
// NotifyNetMap: (netmap) => {
|
NotifyNetMap: (netmap) => {
|
||||||
// console.log('NetMap updated:', netmap);
|
console.log('NetMap updated:', netmap);
|
||||||
// },
|
},
|
||||||
// NotifyBrowseToURL: (url) => {
|
NotifyBrowseToURL: (url) => {
|
||||||
// console.log('Browse to URL:', url);
|
console.log('Browse to URL:', url);
|
||||||
// },
|
},
|
||||||
// NotifyPanicRecover: (message) => {
|
NotifyPanicRecover: (message) => {
|
||||||
// console.error('Panic recover:', message);
|
console.error('Panic recover:', message);
|
||||||
// },
|
},
|
||||||
// },
|
},
|
||||||
// );
|
);
|
||||||
|
|
||||||
// handle.Start();
|
handle.Start();
|
||||||
// },
|
},
|
||||||
// );
|
);
|
||||||
// }, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen bg-headplane-900">
|
<div className="w-screen h-screen bg-headplane-900">
|
||||||
@@ -147,18 +160,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
{/* <h1>Session ID: {sessionId}</h1>
|
<XTerm ipn={ipn} username={ssh.username} hostname={ssh.hostname} />
|
||||||
{queue.current.length > 0 && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{queue.current.length} frames queued
|
|
||||||
</p>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* <XTerm ipn={ipn} /> */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{/* Render your terminal component here */}
|
|
||||||
{/* <Terminal ipn={ipn} /> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+29
-13
@@ -1,4 +1,3 @@
|
|||||||
declare function newIPN(config: NewIPNConfig): IPNHandle;
|
|
||||||
declare function TsWasmNet(
|
declare function TsWasmNet(
|
||||||
options: TsWasmNetOptions,
|
options: TsWasmNetOptions,
|
||||||
callbacks: TsWasmNetCallbacks,
|
callbacks: TsWasmNetCallbacks,
|
||||||
@@ -19,6 +18,11 @@ interface TsWasmNetCallbacks {
|
|||||||
|
|
||||||
interface TsWasmNet {
|
interface TsWasmNet {
|
||||||
Start: () => void;
|
Start: () => void;
|
||||||
|
OpenSSH: (
|
||||||
|
hostname: string,
|
||||||
|
username: string,
|
||||||
|
options: XtermConfig,
|
||||||
|
) => SSHSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IPNState =
|
type IPNState =
|
||||||
@@ -30,19 +34,31 @@ type IPNState =
|
|||||||
| 'Starting'
|
| 'Starting'
|
||||||
| 'Running';
|
| 'Running';
|
||||||
|
|
||||||
interface SSHTerminalConfig {
|
interface XtermConfig {
|
||||||
writeFn: (data: string) => void;
|
Rows: number;
|
||||||
writeErrorFn: (error: string) => void;
|
Cols: number;
|
||||||
setReadFn: (cb: (input: string) => void) => void;
|
|
||||||
rows: number;
|
OnStdout: (data: string) => void;
|
||||||
cols: number;
|
OnStderr: (data: string) => void;
|
||||||
timeoutSeconds?: number;
|
OnStdin: (func: (input: string) => void) => void;
|
||||||
onConnectionProgress: (message: string) => void;
|
|
||||||
onConnected: () => void;
|
OnConnect: () => void;
|
||||||
onDone: () => void;
|
OnDisconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interface SSHTerminalConfig {
|
||||||
|
// writeFn: (data: string) => void;
|
||||||
|
// writeErrorFn: (error: string) => void;
|
||||||
|
// setReadFn: (cb: (input: string) => void) => void;
|
||||||
|
// rows: number;
|
||||||
|
// cols: number;
|
||||||
|
// timeoutSeconds?: number;
|
||||||
|
// onConnectionProgress: (message: string) => void;
|
||||||
|
// onConnected: () => void;
|
||||||
|
// onDone: () => void;
|
||||||
|
// }
|
||||||
|
|
||||||
interface SSHSession {
|
interface SSHSession {
|
||||||
close(): boolean;
|
Close(): boolean;
|
||||||
resize(rows: number, cols: number): boolean;
|
Resize(rows: number, cols: number): boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,30 @@ import { Unicode11Addon } from '@xterm/addon-unicode11';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import * as xterm from '@xterm/xterm';
|
import * as xterm from '@xterm/xterm';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { decode, encode } from 'cborg';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type {
|
|
||||||
SSHDataCommand,
|
|
||||||
SSHFrameData,
|
|
||||||
SSHResizeCommand,
|
|
||||||
} from '~/server/agent/dispatcher';
|
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import { useLiveData } from '~/utils/live-data';
|
import { useLiveData } from '~/utils/live-data';
|
||||||
|
|
||||||
interface XTermProps {
|
interface XTermProps {
|
||||||
ws: WebSocket;
|
ipn: TsWasmNet;
|
||||||
sessionId: string;
|
username: string;
|
||||||
queue: Array<Uint8Array>;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RED = new TextEncoder().encode('\x1b[31m');
|
const RED = new TextEncoder().encode('\x1b[31m');
|
||||||
const RESET = new TextEncoder().encode('\x1b[0m');
|
const RESET = new TextEncoder().encode('\x1b[0m');
|
||||||
|
|
||||||
export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||||
const { pause } = useLiveData();
|
const { pause } = useLiveData();
|
||||||
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const term = useRef<xterm.Terminal>(null);
|
const term = useRef<xterm.Terminal>(null);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const inputRef = useRef<((input: string) => void) | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pause();
|
pause();
|
||||||
|
|
||||||
const terminal = new xterm.Terminal({
|
const terminal = new xterm.Terminal({
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
@@ -49,9 +43,13 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
|||||||
|
|
||||||
terminal.loadAddon(new Unicode11Addon());
|
terminal.loadAddon(new Unicode11Addon());
|
||||||
terminal.loadAddon(new ClipboardAddon());
|
terminal.loadAddon(new ClipboardAddon());
|
||||||
terminal.loadAddon(new WebLinksAddon());
|
terminal.loadAddon(
|
||||||
terminal.unicode.activeVersion = '11';
|
new WebLinksAddon((event, uri) => {
|
||||||
|
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
terminal.unicode.activeVersion = '11';
|
||||||
const gl = new WebglAddon();
|
const gl = new WebglAddon();
|
||||||
terminal.loadAddon(gl);
|
terminal.loadAddon(gl);
|
||||||
|
|
||||||
@@ -67,97 +65,61 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) {
|
|||||||
terminal.focus();
|
terminal.focus();
|
||||||
term.current = terminal;
|
term.current = terminal;
|
||||||
|
|
||||||
const handleFrame = (data: Uint8Array) => {
|
let ro: ResizeObserver | null = null;
|
||||||
try {
|
let onUnload: ((e: Event) => void) | null = null;
|
||||||
const frame: SSHFrameData = decode(data);
|
|
||||||
if (frame.op !== 'ssh_frame') {
|
const session = ipn.OpenSSH(hostname, username, {
|
||||||
console.warn('Received unexpected frame type:', frame.op);
|
Rows: terminal.rows,
|
||||||
return;
|
Cols: terminal.cols,
|
||||||
|
|
||||||
|
OnStdout: (data) => terminal.write(data),
|
||||||
|
OnStderr: (data) => {
|
||||||
|
terminal.write(data);
|
||||||
|
console.log('SSH stderr:', data);
|
||||||
|
},
|
||||||
|
OnStdin: (func) => {
|
||||||
|
inputRef.current = func;
|
||||||
|
},
|
||||||
|
OnConnect: () => {
|
||||||
|
console.log('SSH session connected');
|
||||||
|
},
|
||||||
|
|
||||||
|
OnDisconnect: () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
terminal.dispose();
|
||||||
|
if (onUnload) {
|
||||||
|
parent.removeEventListener('unload', onUnload);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is stderr, color it red
|
console.log('SSH session disconnected');
|
||||||
if (frame.payload.channel === 2) {
|
term.current = null;
|
||||||
terminal.write(
|
},
|
||||||
new Uint8Array([...RED, ...frame.payload.frame, ...RESET]),
|
});
|
||||||
);
|
|
||||||
} else {
|
const parent = container.current?.ownerDocument.defaultView ?? window;
|
||||||
terminal.write(frame.payload.frame);
|
ro = new parent.ResizeObserver(() => {
|
||||||
}
|
if (term.current) {
|
||||||
} catch (err) {
|
setIsResizing(true);
|
||||||
console.error('Failed to decode CBOR frame:', err);
|
fit.fit();
|
||||||
|
setTimeout(() => setIsResizing(false), 100);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
for (const buffer of queue) {
|
if (container.current) {
|
||||||
handleFrame(buffer);
|
ro.observe(container.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.onData((input) => {
|
terminal.onResize(({ cols, rows }) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
session.Resize(rows, cols);
|
||||||
ws.send(
|
|
||||||
encode({
|
|
||||||
op: 'ssh_data',
|
|
||||||
payload: {
|
|
||||||
sessionId,
|
|
||||||
data: new TextEncoder().encode(input),
|
|
||||||
},
|
|
||||||
} satisfies SSHDataCommand),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket is not open, cannot send data');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMessage = (event: MessageEvent) => {
|
terminal.onData((data) => {
|
||||||
if (!(event.data instanceof ArrayBuffer)) {
|
inputRef.current?.(data);
|
||||||
console.warn('Received non-binary message from WebSocket');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = event.data.byteLength;
|
|
||||||
console.log(`[WS] Received ${size} bytes at ${Date.now()}`);
|
|
||||||
|
|
||||||
const data = new Uint8Array(event.data);
|
|
||||||
handleFrame(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.addEventListener('message', onMessage);
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
const before = {
|
|
||||||
cols: terminal.cols,
|
|
||||||
rows: terminal.rows,
|
|
||||||
};
|
|
||||||
|
|
||||||
fit.fit();
|
|
||||||
if (before.cols !== terminal.cols || before.rows !== terminal.rows) {
|
|
||||||
console.log(
|
|
||||||
`Resized terminal to ${terminal.cols} cols and ${terminal.rows} rows`,
|
|
||||||
);
|
|
||||||
ws.send(
|
|
||||||
encode({
|
|
||||||
op: 'ssh_resize',
|
|
||||||
payload: {
|
|
||||||
sessionId,
|
|
||||||
width: terminal.cols,
|
|
||||||
height: terminal.rows,
|
|
||||||
},
|
|
||||||
} satisfies SSHResizeCommand),
|
|
||||||
);
|
|
||||||
|
|
||||||
setIsResizing(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsResizing(false);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ro.observe(container.current!);
|
onUnload = (_) => session.Close();
|
||||||
return () => {
|
parent.addEventListener('unload', onUnload);
|
||||||
ws.removeEventListener('message', onMessage);
|
}, []);
|
||||||
term.current?.dispose();
|
|
||||||
ro.disconnect();
|
|
||||||
};
|
|
||||||
}, [ws, queue]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full group">
|
<div className="relative w-full h-full group">
|
||||||
|
|||||||
@@ -41,6 +41,65 @@ func main() {
|
|||||||
ipn.Start(context.Background())
|
ipn.Start(context.Background())
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
"OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 3 {
|
||||||
|
log.Fatal("Usage: OpenSSH(host, user, options)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := args[0]
|
||||||
|
if hostname.IsNull() || hostname.IsUndefined() {
|
||||||
|
log.Fatal("Hostname must be a non-null, non-undefined string")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostname.Type() != js.TypeString {
|
||||||
|
log.Fatal("Hostname must be a string")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
username := args[1]
|
||||||
|
if username.IsNull() || username.IsUndefined() {
|
||||||
|
log.Fatal("Username must be a non-null, non-undefined string")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if username.Type() != js.TypeString {
|
||||||
|
log.Fatal("Username must be a string")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sshOptions, err := hp_ipn.ParseSSHXtermConfig(args[2])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error parsing SSH options:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions)
|
||||||
|
go session.ConnectAndRun()
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"Close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
return session.Close() != nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
"Resize": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 2 {
|
||||||
|
log.Fatal("Usage: Resize(cols, rows)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := args[0].Int()
|
||||||
|
cols := args[1].Int()
|
||||||
|
if cols <= 0 || rows <= 0 {
|
||||||
|
log.Fatal("Columns and rows must be positive integers")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.Resize(cols, rows) == nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,633 +0,0 @@
|
|||||||
//go:build js && wasm
|
|
||||||
|
|
||||||
package hp_ipn
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"syscall/js"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
"tailscale.com/control/controlclient"
|
|
||||||
"tailscale.com/ipn"
|
|
||||||
"tailscale.com/ipn/ipnlocal"
|
|
||||||
"tailscale.com/ipn/ipnserver"
|
|
||||||
"tailscale.com/ipn/store/mem"
|
|
||||||
"tailscale.com/logpolicy"
|
|
||||||
"tailscale.com/logtail"
|
|
||||||
"tailscale.com/net/netmon"
|
|
||||||
"tailscale.com/net/netns"
|
|
||||||
"tailscale.com/net/tsdial"
|
|
||||||
"tailscale.com/safesocket"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/tsd"
|
|
||||||
"tailscale.com/types/logid"
|
|
||||||
"tailscale.com/types/views"
|
|
||||||
"tailscale.com/wgengine"
|
|
||||||
"tailscale.com/wgengine/netstack"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ControlURL defines the URL to be used for connection to Control.
|
|
||||||
var ControlURL = ipn.DefaultControlURL
|
|
||||||
|
|
||||||
func NewIPN(jsConfig js.Value) map[string]any {
|
|
||||||
netns.SetEnabled(false)
|
|
||||||
|
|
||||||
store := &mem.Store{}
|
|
||||||
|
|
||||||
controlURL := ControlURL
|
|
||||||
if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString {
|
|
||||||
controlURL = jsControlURL.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var authKey string
|
|
||||||
if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString {
|
|
||||||
authKey = jsAuthKey.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
var hostname string
|
|
||||||
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
|
|
||||||
hostname = jsHostname.String()
|
|
||||||
} else {
|
|
||||||
hostname = "blah"
|
|
||||||
}
|
|
||||||
|
|
||||||
lpc := getOrCreateLogPolicyConfig(store)
|
|
||||||
c := logtail.Config{
|
|
||||||
Collection: lpc.Collection,
|
|
||||||
PrivateID: lpc.PrivateID,
|
|
||||||
|
|
||||||
// Compressed requests set HTTP headers that are not supported by the
|
|
||||||
// no-cors fetching mode:
|
|
||||||
CompressLogs: false,
|
|
||||||
|
|
||||||
HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}},
|
|
||||||
}
|
|
||||||
|
|
||||||
logtail := logtail.NewLogger(c, log.Printf)
|
|
||||||
logf := logtail.Logf
|
|
||||||
|
|
||||||
// Instead of new(tsd.System), we use tsd.NewSystem() to ensure that
|
|
||||||
// the bus is initialized correctly and that the system is set up
|
|
||||||
sys := tsd.NewSystem()
|
|
||||||
sys.Set(store)
|
|
||||||
|
|
||||||
dialer := &tsdial.Dialer{Logf: logf}
|
|
||||||
netmon, err := netmon.New(sys.Bus.Get(), logf)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("netmon.New: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
|
||||||
Dialer: dialer,
|
|
||||||
SetSubsystem: sys.Set,
|
|
||||||
ControlKnobs: sys.ControlKnobs(),
|
|
||||||
HealthTracker: sys.HealthTracker(),
|
|
||||||
NetMon: netmon,
|
|
||||||
Metrics: sys.UserMetricsRegistry(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sys.Set(eng)
|
|
||||||
|
|
||||||
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("netstack.Create: %v", err)
|
|
||||||
}
|
|
||||||
sys.Set(ns)
|
|
||||||
ns.ProcessLocalIPs = true
|
|
||||||
ns.ProcessSubnets = true
|
|
||||||
|
|
||||||
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
|
||||||
return ns.DialContextTCP(ctx, dst)
|
|
||||||
}
|
|
||||||
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
|
||||||
return ns.DialContextUDP(ctx, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
sys.NetstackRouter.Set(true)
|
|
||||||
sys.Tun.Get().Start()
|
|
||||||
|
|
||||||
logid := logid.PublicID{}
|
|
||||||
|
|
||||||
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
|
|
||||||
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
|
|
||||||
}
|
|
||||||
if err := ns.Start(lb); err != nil {
|
|
||||||
log.Fatalf("failed to start netstack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv.SetLocalBackend(lb)
|
|
||||||
|
|
||||||
log.Printf("Using control URL and auth key: %q, %q", controlURL, authKey)
|
|
||||||
jsIPN := &jsIPN{
|
|
||||||
dialer: dialer,
|
|
||||||
srv: srv,
|
|
||||||
lb: lb,
|
|
||||||
controlURL: controlURL,
|
|
||||||
authKey: authKey,
|
|
||||||
hostname: hostname,
|
|
||||||
}
|
|
||||||
|
|
||||||
ipnO := ipn.Options{
|
|
||||||
AuthKey: authKey,
|
|
||||||
UpdatePrefs: &ipn.Prefs{
|
|
||||||
ControlURL: controlURL,
|
|
||||||
Hostname: hostname,
|
|
||||||
WantRunning: true,
|
|
||||||
LoggedOut: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
state := lb.State()
|
|
||||||
log.Printf("State: %+v", state)
|
|
||||||
|
|
||||||
lb.SetNotifyCallback(func(n ipn.Notify) {
|
|
||||||
log.Printf("NOTIFY: %+v", n)
|
|
||||||
|
|
||||||
st := n.State
|
|
||||||
if st != nil {
|
|
||||||
log.Printf("State changed: %s", *st)
|
|
||||||
if *st == ipn.NeedsLogin {
|
|
||||||
go func() {
|
|
||||||
if err := lb.StartLoginInteractive(context.Background()); err != nil {
|
|
||||||
log.Printf("StartLoginInteractive failed: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Started login interactive")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start safesocket listener before lb.Start
|
|
||||||
ln, err := safesocket.Listen("")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("safesocket.Listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start server loop before backend Start
|
|
||||||
go func() {
|
|
||||||
if err := srv.Run(context.Background(), ln); err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Now start the backend (this can trigger login)
|
|
||||||
log.Printf("Starting local backend with options: %+v", ipnO)
|
|
||||||
if err := lb.Start(ipnO); err != nil {
|
|
||||||
log.Fatalf("Failed to start local backend: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
// "run": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
// if len(args) != 1 {
|
|
||||||
// log.Fatal(`Usage: run({
|
|
||||||
// notifyState(state: int): void,
|
|
||||||
// notifyNetMap(netMap: object): void,
|
|
||||||
// notifyBrowseToURL(url: string): void,
|
|
||||||
// notifyPanicRecover(err: string): void,
|
|
||||||
// })`)
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// jsIPN.run(args[0])
|
|
||||||
// return nil
|
|
||||||
// }),
|
|
||||||
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
if len(args) != 3 {
|
|
||||||
log.Printf("Usage: ssh(hostname, userName, termConfig)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return jsIPN.ssh(
|
|
||||||
args[0].String(),
|
|
||||||
args[1].String(),
|
|
||||||
args[2])
|
|
||||||
}),
|
|
||||||
"login": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
if len(args) != 0 {
|
|
||||||
log.Printf("Usage: login()")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
jsIPN.login()
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *jsIPN) login() {
|
|
||||||
go i.lb.StartLoginInteractive(context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsIPN struct {
|
|
||||||
dialer *tsdial.Dialer
|
|
||||||
srv *ipnserver.Server
|
|
||||||
lb *ipnlocal.LocalBackend
|
|
||||||
controlURL string
|
|
||||||
authKey string
|
|
||||||
hostname string
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsIPNState = map[ipn.State]string{
|
|
||||||
ipn.NoState: "NoState",
|
|
||||||
ipn.InUseOtherUser: "InUseOtherUser",
|
|
||||||
ipn.NeedsLogin: "NeedsLogin",
|
|
||||||
ipn.NeedsMachineAuth: "NeedsMachineAuth",
|
|
||||||
ipn.Stopped: "Stopped",
|
|
||||||
ipn.Starting: "Starting",
|
|
||||||
ipn.Running: "Running",
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsMachineStatus = map[tailcfg.MachineStatus]string{
|
|
||||||
tailcfg.MachineUnknown: "MachineUnknown",
|
|
||||||
tailcfg.MachineUnauthorized: "MachineUnauthorized",
|
|
||||||
tailcfg.MachineAuthorized: "MachineAuthorized",
|
|
||||||
tailcfg.MachineInvalid: "MachineInvalid",
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *jsIPN) Run(jsCallbacks js.Value) {
|
|
||||||
notifyState := func(state ipn.State) {
|
|
||||||
jsCallbacks.Call("notifyState", jsIPNState[state])
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyState(ipn.NoState)
|
|
||||||
|
|
||||||
i.lb.SetNotifyCallback(func(n ipn.Notify) {
|
|
||||||
// Panics in the notify callback are likely due to be due to bugs in
|
|
||||||
// this bridging module (as opposed to actual bugs in Tailscale) and
|
|
||||||
// thus may be recoverable. Let the UI know, and allow the user to
|
|
||||||
// choose if they want to reload the page.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
fmt.Println("Panic recovered:", r)
|
|
||||||
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Printf("NOTIFY: %+v", n)
|
|
||||||
|
|
||||||
if n.Health != nil {
|
|
||||||
log.Printf("NOTIFY HEALTH: %+v", n.Health)
|
|
||||||
for warning := range n.Health.Warnings {
|
|
||||||
log.Printf("Health warning: %s", warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if n.State != nil {
|
|
||||||
notifyState(*n.State)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("NOTIFY NETMAP: %+v", n.NetMap)
|
|
||||||
|
|
||||||
// if nm := n.NetMap; nm != nil {
|
|
||||||
// jsNetMap := jsNetMap{
|
|
||||||
// Self: jsNetMapSelfNode{
|
|
||||||
// jsNetMapNode: jsNetMapNode{
|
|
||||||
// Name: nm.Name,
|
|
||||||
// Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
|
|
||||||
// NodeKey: nm.NodeKey.String(),
|
|
||||||
// MachineKey: nm.MachineKey.String(),
|
|
||||||
// },
|
|
||||||
// MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
|
|
||||||
// },
|
|
||||||
// Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
|
|
||||||
// name := p.Name()
|
|
||||||
// if name == "" {
|
|
||||||
// // In practice this should only happen for Hello.
|
|
||||||
// name = p.Hostinfo().Hostname()
|
|
||||||
// }
|
|
||||||
// addrs := make([]string, p.Addresses().Len())
|
|
||||||
// for i, ap := range p.Addresses().All() {
|
|
||||||
// addrs[i] = ap.Addr().String()
|
|
||||||
// }
|
|
||||||
// return jsNetMapPeerNode{
|
|
||||||
// jsNetMapNode: jsNetMapNode{
|
|
||||||
// Name: name,
|
|
||||||
// Addresses: addrs,
|
|
||||||
// MachineKey: p.Machine().String(),
|
|
||||||
// NodeKey: p.Key().String(),
|
|
||||||
// },
|
|
||||||
// Online: p.Online().Clone(),
|
|
||||||
// TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
|
|
||||||
// }
|
|
||||||
// }),
|
|
||||||
// LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
|
|
||||||
// }
|
|
||||||
// if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
|
|
||||||
// jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
|
|
||||||
// } else {
|
|
||||||
// log.Printf("Could not generate JSON netmap: %v", err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// if n.BrowseToURL != nil {
|
|
||||||
// jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
|
||||||
// }
|
|
||||||
})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
o := ipn.Options{
|
|
||||||
UpdatePrefs: &ipn.Prefs{
|
|
||||||
ControlURL: i.controlURL,
|
|
||||||
RouteAll: false,
|
|
||||||
WantRunning: true,
|
|
||||||
Hostname: i.hostname,
|
|
||||||
},
|
|
||||||
AuthKey: i.authKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Fucking options: %+v", o)
|
|
||||||
err := i.lb.Start(o)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Start error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// go func() {
|
|
||||||
// ln, err := safesocket.Listen("")
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatalf("safesocket.Listen: %v", err)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// err = i.srv.Run(context.Background(), ln)
|
|
||||||
// log.Fatalf("ipnserver.Run exited: %v", err)
|
|
||||||
// }()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
|
||||||
jsSSHSession := &jsSSHSession{
|
|
||||||
jsIPN: i,
|
|
||||||
host: host,
|
|
||||||
username: username,
|
|
||||||
termConfig: termConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
go jsSSHSession.Run()
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
return jsSSHSession.Close() != nil
|
|
||||||
}),
|
|
||||||
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
rows := args[0].Int()
|
|
||||||
cols := args[1].Int()
|
|
||||||
return jsSSHSession.Resize(rows, cols) != nil
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsSSHSession struct {
|
|
||||||
jsIPN *jsIPN
|
|
||||||
host string
|
|
||||||
username string
|
|
||||||
termConfig js.Value
|
|
||||||
session *ssh.Session
|
|
||||||
|
|
||||||
pendingResizeRows int
|
|
||||||
pendingResizeCols int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *jsSSHSession) Run() {
|
|
||||||
writeFn := s.termConfig.Get("writeFn")
|
|
||||||
writeErrorFn := s.termConfig.Get("writeErrorFn")
|
|
||||||
setReadFn := s.termConfig.Get("setReadFn")
|
|
||||||
rows := s.termConfig.Get("rows").Int()
|
|
||||||
cols := s.termConfig.Get("cols").Int()
|
|
||||||
timeoutSeconds := 5.0
|
|
||||||
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
|
|
||||||
timeoutSeconds = jsTimeoutSeconds.Float()
|
|
||||||
}
|
|
||||||
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
|
|
||||||
onConnected := s.termConfig.Get("onConnected")
|
|
||||||
onDone := s.termConfig.Get("onDone")
|
|
||||||
defer onDone.Invoke()
|
|
||||||
|
|
||||||
writeError := func(label string, err error) {
|
|
||||||
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
|
|
||||||
}
|
|
||||||
reportProgress := func(message string) {
|
|
||||||
onConnectionProgress.Invoke(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
|
|
||||||
defer cancel()
|
|
||||||
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
|
|
||||||
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
|
|
||||||
if err != nil {
|
|
||||||
writeError("Dial", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer c.Close()
|
|
||||||
|
|
||||||
config := &ssh.ClientConfig{
|
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
|
||||||
// Host keys are not used with Tailscale SSH, but we can use this
|
|
||||||
// callback to know that the connection has been established.
|
|
||||||
reportProgress("SSH connection established…")
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
User: s.username,
|
|
||||||
}
|
|
||||||
|
|
||||||
reportProgress("Starting SSH client…")
|
|
||||||
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
|
|
||||||
if err != nil {
|
|
||||||
writeError("SSH Connection", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer sshConn.Close()
|
|
||||||
|
|
||||||
sshClient := ssh.NewClient(sshConn, nil, nil)
|
|
||||||
defer sshClient.Close()
|
|
||||||
|
|
||||||
session, err := sshClient.NewSession()
|
|
||||||
if err != nil {
|
|
||||||
writeError("SSH Session", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.session = session
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
stdin, err := session.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
writeError("SSH Stdin", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Stdout = termWriter{writeFn}
|
|
||||||
session.Stderr = termWriter{writeFn}
|
|
||||||
|
|
||||||
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
input := args[0].String()
|
|
||||||
_, err := stdin.Write([]byte(input))
|
|
||||||
if err != nil {
|
|
||||||
writeError("Write Input", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}))
|
|
||||||
|
|
||||||
// We might have gotten a resize notification since we started opening the
|
|
||||||
// session, pick up the latest size.
|
|
||||||
if s.pendingResizeRows != 0 {
|
|
||||||
rows = s.pendingResizeRows
|
|
||||||
}
|
|
||||||
if s.pendingResizeCols != 0 {
|
|
||||||
cols = s.pendingResizeCols
|
|
||||||
}
|
|
||||||
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
writeError("Pseudo Terminal", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = session.Shell()
|
|
||||||
if err != nil {
|
|
||||||
writeError("Shell", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnected.Invoke()
|
|
||||||
err = session.Wait()
|
|
||||||
if err != nil {
|
|
||||||
writeError("Wait", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *jsSSHSession) Close() error {
|
|
||||||
if s.session == nil {
|
|
||||||
// We never had a chance to open the session, ignore the close request.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.session.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *jsSSHSession) Resize(rows, cols int) error {
|
|
||||||
if s.session == nil {
|
|
||||||
s.pendingResizeRows = rows
|
|
||||||
s.pendingResizeCols = cols
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.session.WindowChange(rows, cols)
|
|
||||||
}
|
|
||||||
|
|
||||||
type termWriter struct {
|
|
||||||
f js.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w termWriter) Write(p []byte) (n int, err error) {
|
|
||||||
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
|
|
||||||
w.f.Invoke(string(r))
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsNetMap struct {
|
|
||||||
Self jsNetMapSelfNode `json:"self"`
|
|
||||||
Peers []jsNetMapPeerNode `json:"peers"`
|
|
||||||
LockedOut bool `json:"lockedOut"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsNetMapNode struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Addresses []string `json:"addresses"`
|
|
||||||
MachineKey string `json:"machineKey"`
|
|
||||||
NodeKey string `json:"nodeKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsNetMapSelfNode struct {
|
|
||||||
jsNetMapNode
|
|
||||||
MachineStatus string `json:"machineStatus"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsNetMapPeerNode struct {
|
|
||||||
jsNetMapNode
|
|
||||||
Online *bool `json:"online,omitempty"`
|
|
||||||
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type jsStateStore struct {
|
|
||||||
jsStateStorage js.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
|
|
||||||
jsValue := s.jsStateStorage.Call("getState", string(id))
|
|
||||||
if jsValue.String() == "" {
|
|
||||||
return nil, ipn.ErrStateNotExist
|
|
||||||
}
|
|
||||||
return hex.DecodeString(jsValue.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
|
|
||||||
s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSlice[T any, M any](a []T, f func(T) M) []M {
|
|
||||||
n := make([]M, len(a))
|
|
||||||
for i, e := range a {
|
|
||||||
n[i] = f(e)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
|
|
||||||
n := make([]M, a.Len())
|
|
||||||
for i, v := range a.All() {
|
|
||||||
n[i] = f(v)
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
const logPolicyStateKey = "log-policy"
|
|
||||||
|
|
||||||
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
|
|
||||||
if configBytes, err := state.ReadState(logPolicyStateKey); err == nil {
|
|
||||||
if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil {
|
|
||||||
return config
|
|
||||||
} else {
|
|
||||||
log.Printf("Could not parse log policy config: %v", err)
|
|
||||||
}
|
|
||||||
} else if err != ipn.ErrStateNotExist {
|
|
||||||
log.Printf("Could not get log policy config from state store: %v", err)
|
|
||||||
}
|
|
||||||
config := logpolicy.NewConfig(logtail.CollectionNode)
|
|
||||||
if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil {
|
|
||||||
log.Printf("Could not save log policy config to state store: %v", err)
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests,
|
|
||||||
// so that we can use it with non-CORS-aware servers.
|
|
||||||
type noCORSTransport struct {
|
|
||||||
http.RoundTripper
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
req.Header.Set("js.fetch:mode", "no-cors")
|
|
||||||
resp, err := t.RoundTripper.RoundTrip(req)
|
|
||||||
if err == nil {
|
|
||||||
// In no-cors mode no response properties are returned. Populate just
|
|
||||||
// the status so that callers do not think this was an error.
|
|
||||||
resp.StatusCode = http.StatusOK
|
|
||||||
resp.Status = http.StatusText(http.StatusOK)
|
|
||||||
}
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
@@ -90,7 +90,6 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
|
|||||||
// Configure the local Netstack and Dialer
|
// Configure the local Netstack and Dialer
|
||||||
wgstack.ProcessLocalIPs = true
|
wgstack.ProcessLocalIPs = true
|
||||||
wgstack.ProcessSubnets = true
|
wgstack.ProcessSubnets = true
|
||||||
sys.NetstackRouter.Set(true)
|
|
||||||
|
|
||||||
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
||||||
return true
|
return true
|
||||||
@@ -106,7 +105,8 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
|
|||||||
|
|
||||||
// Dummy logid for the Tailscale backend
|
// Dummy logid for the Tailscale backend
|
||||||
logid := logid.PublicID{}
|
logid := logid.PublicID{}
|
||||||
tun.Start()
|
sys.NetstackRouter.Set(true)
|
||||||
|
sys.Tun.Get().Start()
|
||||||
|
|
||||||
server := ipnserver.New(logf, logid, sys.NetMon.Get())
|
server := ipnserver.New(logf, logid, sys.NetMon.Get())
|
||||||
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
|
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package hp_ipn
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,128 @@ func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options passed from the JS side to pass data to xterm.js.
|
||||||
|
type SSHXtermConfig struct {
|
||||||
|
// Number of rows in the PTY.
|
||||||
|
Rows int
|
||||||
|
|
||||||
|
// Number of columns in the PTY.
|
||||||
|
Cols int
|
||||||
|
|
||||||
|
// Fires when the PTY has output.
|
||||||
|
OnStdout func(data string)
|
||||||
|
|
||||||
|
// Fires when the PTY has an error.
|
||||||
|
OnStderr func(error string)
|
||||||
|
|
||||||
|
// Passes a function to the JS side to provide input.
|
||||||
|
OnStdin js.Value
|
||||||
|
|
||||||
|
// Fires when the PTY is opened.
|
||||||
|
OnConnect func()
|
||||||
|
|
||||||
|
// Fires when the PTY is closed.
|
||||||
|
OnDisconnect func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the provided JS object to validate and extract SSHXtermConfig.
|
||||||
|
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
||||||
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
|
return nil, errors.New("SSHXtermConfig cannot be undefined or null")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := safeInt("Rows", obj)
|
||||||
|
cols := safeInt("Cols", obj)
|
||||||
|
|
||||||
|
if rows <= 0 || cols <= 0 {
|
||||||
|
return nil, errors.New("Rows and Cols must be positive integers")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &SSHXtermConfig{
|
||||||
|
Rows: rows,
|
||||||
|
Cols: cols,
|
||||||
|
}
|
||||||
|
|
||||||
|
onStdout := obj.Get("OnStdout")
|
||||||
|
if onStdout.IsUndefined() || onStdout.IsNull() {
|
||||||
|
return nil, errors.New("OnStdout must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
if onStdout.Type() != js.TypeFunction {
|
||||||
|
return nil, errors.New("OnStdout must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.OnStdout = func(data string) {
|
||||||
|
onStdout.Invoke(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStderr := obj.Get("OnStderr")
|
||||||
|
if onStderr.IsUndefined() || onStderr.IsNull() {
|
||||||
|
return nil, errors.New("OnStderr must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
if onStderr.Type() != js.TypeFunction {
|
||||||
|
return nil, errors.New("OnStderr must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.OnStderr = func(error string) {
|
||||||
|
onStderr.Invoke(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStdin := obj.Get("OnStdin")
|
||||||
|
if onStdin.IsUndefined() || onStdin.IsNull() {
|
||||||
|
return nil, errors.New("OnStdin must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
if onStdin.Type() != js.TypeFunction {
|
||||||
|
return nil, errors.New("OnStdin must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.OnStdin = onStdin
|
||||||
|
|
||||||
|
onConnect := obj.Get("OnConnect")
|
||||||
|
if onConnect.IsUndefined() || onConnect.IsNull() {
|
||||||
|
return nil, errors.New("OnConnect must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
if onConnect.Type() != js.TypeFunction {
|
||||||
|
return nil, errors.New("OnConnect must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.OnConnect = func() {
|
||||||
|
onConnect.Invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect := obj.Get("OnDisconnect")
|
||||||
|
if onDisconnect.IsUndefined() || onDisconnect.IsNull() {
|
||||||
|
return nil, errors.New("OnDisconnect must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
if onDisconnect.Type() != js.TypeFunction {
|
||||||
|
return nil, errors.New("OnDisconnect must be a function")
|
||||||
|
}
|
||||||
|
|
||||||
|
config.OnDisconnect = func() {
|
||||||
|
onDisconnect.Invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SSHXtermConfig) PassStdinHandler(fn func(string)) {
|
||||||
|
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
if len(args) != 1 || args[0].Type() != js.TypeString {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fn(args[0].String())
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("Passing stdin handler to JS: %v", handler)
|
||||||
|
t.OnStdin.Invoke(handler)
|
||||||
|
}
|
||||||
|
|
||||||
// Retrieves a string value from a JS object safely.
|
// Retrieves a string value from a JS object safely.
|
||||||
func safeString(key string, obj js.Value) string {
|
func safeString(key string, obj js.Value) string {
|
||||||
if obj.IsUndefined() || obj.IsNull() {
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
@@ -53,3 +176,17 @@ func safeString(key string, obj js.Value) string {
|
|||||||
|
|
||||||
return val.String()
|
return val.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retrieves an integer value from a JS object safely.
|
||||||
|
func safeInt(key string, obj js.Value) int {
|
||||||
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val := obj.Get(key)
|
||||||
|
if val.IsUndefined() || val.IsNull() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return val.Int()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package hp_ipn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Represents an SSH session over the Tailnet.
|
||||||
|
type SSHSession struct {
|
||||||
|
// Hostname on the Tailnet.
|
||||||
|
Hostname string
|
||||||
|
|
||||||
|
// Username for the SSH connection.
|
||||||
|
Username string
|
||||||
|
|
||||||
|
// Xterm configuration for the SSH session.
|
||||||
|
TermConfig *SSHXtermConfig
|
||||||
|
|
||||||
|
// Handle to the current IPN connection.
|
||||||
|
Ipn *TsWasmIpn
|
||||||
|
|
||||||
|
// Handle to the current SSH session.
|
||||||
|
Pty *ssh.Session
|
||||||
|
|
||||||
|
// Tracks resize notifications for rows.
|
||||||
|
ResizeRows int
|
||||||
|
|
||||||
|
// Tracks resize notifications for columns.
|
||||||
|
ResizeCols int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new SSH session given a hostname and username.
|
||||||
|
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
|
||||||
|
return &SSHSession{
|
||||||
|
Hostname: hostname,
|
||||||
|
Username: username,
|
||||||
|
TermConfig: termConfig,
|
||||||
|
Ipn: i,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHSession) ConnectAndRun() {
|
||||||
|
defer s.TermConfig.OnDisconnect()
|
||||||
|
|
||||||
|
// Default to a 5 second timeout for the connection AFTER dial.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// TODO: Log here
|
||||||
|
log.Printf("Attempting SSH dial to host: %s", net.JoinHostPort(s.Hostname, "22"))
|
||||||
|
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.Hostname, "22"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("SSH dial error: %v", err)
|
||||||
|
s.writeError("Dial", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer conn.Close()
|
||||||
|
sshConf := &ssh.ClientConfig{
|
||||||
|
User: s.Username,
|
||||||
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
// Tailscale SSH doesn't use host keys
|
||||||
|
// TODO: Log that the connection was established
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: LOG: Starting SSH Client
|
||||||
|
sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer sshConn.Close()
|
||||||
|
sshClient := ssh.NewClient(sshConn, nil, nil)
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
pty, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer pty.Close()
|
||||||
|
s.Pty = pty
|
||||||
|
|
||||||
|
pty.Stdout = XtermPipe{s.TermConfig.OnStdout}
|
||||||
|
pty.Stderr = XtermPipe{s.TermConfig.OnStdout}
|
||||||
|
|
||||||
|
// TODO: Set Stdin func
|
||||||
|
stdin, err := pty.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.TermConfig.PassStdinHandler(func(input string) {
|
||||||
|
_, err := stdin.Write([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rows := s.TermConfig.Rows
|
||||||
|
if s.ResizeRows != 0 {
|
||||||
|
rows = s.ResizeRows
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := s.TermConfig.Cols
|
||||||
|
if s.ResizeCols != 0 {
|
||||||
|
cols = s.ResizeCols
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pty.Shell()
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.TermConfig.OnConnect()
|
||||||
|
err = pty.Wait()
|
||||||
|
if err != nil {
|
||||||
|
s.writeError("SSH", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize resizes the terminal for the SSH session.
|
||||||
|
// TODO: This does NOT work correctly from Xterm.js
|
||||||
|
func (s *SSHSession) Resize(rows, cols int) error {
|
||||||
|
// Used to handle resizes while still connecting.
|
||||||
|
if s.Pty == nil {
|
||||||
|
s.ResizeRows = rows
|
||||||
|
s.ResizeCols = cols
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.Pty.WindowChange(rows, cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closes the SSH session.
|
||||||
|
func (s *SSHSession) Close() error {
|
||||||
|
if s.Pty == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.Pty.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick easy formatter for writing errors to the terminal.
|
||||||
|
func (s *SSHSession) writeError(label string, err error) {
|
||||||
|
o := fmt.Sprintf("%s error: %v\r\n", label, err)
|
||||||
|
s.TermConfig.OnStderr(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
// io.Writer "emulator" to pass to the ssh module.
|
||||||
|
type XtermPipe struct {
|
||||||
|
// Function to call when data is written.
|
||||||
|
Send func(data string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements the io.Writer interface for XtermPipe.
|
||||||
|
func (x XtermPipe) Write(data []byte) (int, error) {
|
||||||
|
// Tailscale's webSSH does this to fix issues in xterm.js
|
||||||
|
res := bytes.Replace(data, []byte("\n"), []byte("\n\r"), -1)
|
||||||
|
x.Send(string(res))
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user