diff --git a/package.json b/package.json index ff42ec2..aedc9fb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,10 @@ "@snort/shared": "^1.0.17", "@snort/system": "^1.5.7", "@snort/system-react": "^1.5.7", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.5.0", "classnames": "^2.5.1", "qr-code-styling": "^1.8.4", "react": "^18.3.1", diff --git a/src/api.ts b/src/api.ts index 49b98f5..0828d67 100644 --- a/src/api.ts +++ b/src/api.ts @@ -73,6 +73,7 @@ export interface VmInstance { disk_size: number; disk_id: number; status?: VmStatus; + mac_address: string; template?: VmTemplate; image?: VmOsImage; @@ -106,7 +107,7 @@ export class LNVpsApi { constructor( readonly url: string, readonly publisher: EventPublisher | undefined, - ) {} + ) { } async listVms() { const { data } = await this.#handleResponse>>( @@ -192,6 +193,23 @@ export class LNVpsApi { return data; } + async connect_terminal(id: number) { + const u = `${this.url}/api/v1/console/${id}`; + const auth = await this.#auth_event(u, "GET"); + const ws = new WebSocket(`${u}?auth=${base64.encode( + new TextEncoder().encode(JSON.stringify(auth)), + )}`); + return await new Promise((resolve, reject) => { + ws.onopen = () => { + resolve(ws); + } + ws.onerror = (e) => { + reject(e) + } + }) + } + + async #handleResponse(rsp: Response) { if (rsp.ok) { return (await rsp.json()) as T; @@ -206,25 +224,29 @@ export class LNVpsApi { } } + async #auth_event(url: string, method: string) { + return await this.publisher?.generic((eb) => { + return eb + .kind(EventKind.HttpAuthentication) + .tag(["u", url]) + .tag(["method", method]); + }) + } + + async #auth(url: string, method: string) { + const auth = await this.#auth_event(url, method); + if (auth) { + return `Nostr ${base64.encode( + new TextEncoder().encode(JSON.stringify(auth)), + )}`; + } + } + async #req( path: string, method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH", body?: object, ) { - const auth = async (url: string, method: string) => { - const auth = await this.publisher?.generic((eb) => { - return eb - .kind(EventKind.HttpAuthentication) - .tag(["u", url]) - .tag(["method", method]); - }); - if (auth) { - return `Nostr ${base64.encode( - new TextEncoder().encode(JSON.stringify(auth)), - )}`; - } - }; - const u = `${this.url}${path}`; return await fetch(u, { method, @@ -232,7 +254,7 @@ export class LNVpsApi { headers: { accept: "application/json", "content-type": "application/json", - authorization: (await auth(u, method)) ?? "", + authorization: (await this.#auth(u, method)) ?? "", }, }); } diff --git a/src/pages/vm.tsx b/src/pages/vm.tsx index 857bc50..700028b 100644 --- a/src/pages/vm.tsx +++ b/src/pages/vm.tsx @@ -4,10 +4,18 @@ import VpsInstanceRow from "../components/vps-instance"; import useLogin from "../hooks/login"; import { ApiUrl } from "../const"; import { EventPublisher } from "@snort/system"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import VpsPayment from "../components/vps-payment"; import CostLabel from "../components/cost"; import { AsyncButton } from "../components/button"; +import { AttachAddon } from "@xterm/addon-attach"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; +import "@xterm/xterm/css/xterm.css"; +import { toEui64 } from "../utils"; + +const fit = new FitAddon(); export default function VmPage() { const { state } = useLocation() as { state?: VmInstance }; @@ -15,6 +23,8 @@ export default function VmPage() { const login = useLogin(); const navigate = useNavigate(); const [payment, setPayment] = useState(); + const [term, setTerm] = useState() + const termRef = useRef(null); const renew = useCallback( async function () { @@ -29,6 +39,36 @@ export default function VmPage() { [login, state], ); + async function openTerminal() { + if (!login?.signer || !state) return; + const api = new LNVpsApi( + ApiUrl, + new EventPublisher(login.signer, login.pubkey), + ); + const ws = await api.connect_terminal(state.id); + const te = new Terminal(); + const webgl = new WebglAddon(); + webgl.onContextLoss(() => { + webgl.dispose(); + }); + te.loadAddon(webgl); + te.loadAddon(fit); + te.onResize(({ cols, rows }) => { + ws.send(`${cols}:${rows}`); + }) + const attach = new AttachAddon(ws); + te.loadAddon(attach); + setTerm(te); + } + + useEffect(() => { + if (term && termRef.current) { + term.open(termRef.current); + term.focus(); + fit.fit(); + } + }, [termRef, term, fit]); + useEffect(() => { switch (action) { case "renew": @@ -56,8 +96,8 @@ export default function VmPage() { navigate("/vm/renew", { state })}> Extend Now -
Network
-
+
+
Network:
{(state.ip_assignments?.length ?? 0) === 0 && (
No IP's assigned
)} @@ -69,7 +109,19 @@ export default function VmPage() { {a.ip.split("/")[0]}
))} +
+ {toEui64("2a13:2c0::", state.mac_address)} +
+
+
SSH Key:
+
+ {state.ssh_key?.name} +
+
+ {/* + {!term && Connect Terminal} + {term &&
}*/} )} {action === "renew" && ( diff --git a/src/utils.ts b/src/utils.ts index 538fe52..70ae81e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import { base16 } from "@scure/base"; + export async function openFile(): Promise { return new Promise((resolve) => { const elm = document.createElement("input"); @@ -28,3 +30,14 @@ export async function openFile(): Promise { ); }); } + + +export function toEui64(prefix: string, mac: string) { + const macData = base16.decode(mac.replace(/:/g, "").toUpperCase()); + const macExtended = new Uint8Array([...macData.subarray(0, 3), 0xff, 0xfe, ...macData.subarray(3, 6)]) + macExtended[0] |= 0x02; + return (prefix + base16.encode(macExtended.subarray(0, 2)) + ":" + + base16.encode(macExtended.subarray(2, 4)) + ":" + + base16.encode(macExtended.subarray(4, 6)) + ":" + + base16.encode(macExtended.subarray(6, 8))).toLowerCase(); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 40cb889..cc63cd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1046,6 +1046,40 @@ __metadata: languageName: node linkType: hard +"@xterm/addon-attach@npm:^0.11.0": + version: 0.11.0 + resolution: "@xterm/addon-attach@npm:0.11.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/7646e4a4ec1588f922bfed81e0361db435e4db73656847eda1f5724f7580a522b0ad9b636c76ddb3fad785feb4c594cc813b7e7a990d780d2d7aa78c69ad092f + languageName: node + linkType: hard + +"@xterm/addon-fit@npm:^0.10.0": + version: 0.10.0 + resolution: "@xterm/addon-fit@npm:0.10.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/76926120fc940376afef2cb68b15aec2a99fc628b6e3cc84f2bcb1682ca9b87f982b3c10ff206faf4ebc5b410467b81a7b5e83be37b4ac386586f472e4fa1c61 + languageName: node + linkType: hard + +"@xterm/addon-webgl@npm:^0.18.0": + version: 0.18.0 + resolution: "@xterm/addon-webgl@npm:0.18.0" + peerDependencies: + "@xterm/xterm": ^5.0.0 + checksum: 10c0/682a3f5f128ee09a0cf1b41cbb7b2f925a5e43056e12ba0c523b93a1f5f188045caef9e31f32db933b8a7a1b12d8f9babaddfa11e6f11df0c7b265009103476c + languageName: node + linkType: hard + +"@xterm/xterm@npm:^5.5.0": + version: 5.5.0 + resolution: "@xterm/xterm@npm:5.5.0" + checksum: 10c0/358801feece58617d777b2783bec68dac1f52f736da3b0317f71a34f4e25431fb0b1920244f678b8d673f797145b4858c2a5ccb463a4a6df7c10c9093f1c9267 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -2350,6 +2384,10 @@ __metadata: "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" "@vitejs/plugin-react": "npm:^4.3.1" + "@xterm/addon-attach": "npm:^0.11.0" + "@xterm/addon-fit": "npm:^0.10.0" + "@xterm/addon-webgl": "npm:^0.18.0" + "@xterm/xterm": "npm:^5.5.0" autoprefixer: "npm:^10.4.20" classnames: "npm:^2.5.1" eslint: "npm:^9.8.0"