diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4732b75113..12a96cc230 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ "openssl", "pam", "pin-project", + "pty-process", "rand", "regex", "serde", @@ -123,6 +124,7 @@ dependencies = [ "tokio-openssl", "tokio-stream", "tokio-test", + "tokio-util", "tower", "tower-http", "tracing", @@ -2687,6 +2689,17 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "pty-process" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8749b545e244c90bf74a5767764cc2194f1888bb42f84015486a64c82bea5cc0" +dependencies = [ + "libc", + "rustix 0.38.32", + "tokio", +] + [[package]] name = "publicsuffix" version = "2.2.3" @@ -2960,6 +2973,7 @@ checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" dependencies = [ "bitflags 2.5.0", "errno", + "itoa", "libc", "linux-raw-sys 0.4.13", "windows-sys 0.52.0", @@ -3547,16 +3561,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 52386a3005..95747360d9 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -45,12 +45,14 @@ chrono = { version = "0.4.34", default-features = false, features = [ "clock", ] } pam = "0.8.0" +pty-process = { version = "0.4.0", features = ["async"] } serde_with = "3.6.1" pin-project = "1.1.5" openssl = "0.10.64" hyper = "1.2.0" hyper-util = "0.1.3" tokio-openssl = "0.6.4" +tokio-util = "0.7.11" futures-util = { version = "0.3.30", default-features = false, features = [ "alloc", ] } diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 3025f17b9b..9a23dce344 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -25,6 +25,7 @@ mod event; mod http; mod service; mod state; +mod terminal_ws; mod ws; use agama_lib::{connection, error::ServiceError}; diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 91346ce737..9e68b370a4 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -43,7 +43,9 @@ impl MainServiceBuilder { where P: AsRef, { - let api_router = Router::new().route("/ws", get(super::ws::ws_handler)); + let api_router = Router::new() + .route("/ws", get(super::ws::ws_handler)) + .route("/terminal", get(super::terminal_ws::handler)); let config = ServiceConfig::default(); Self { diff --git a/rust/agama-server/src/web/terminal_ws.rs b/rust/agama-server/src/web/terminal_ws.rs new file mode 100644 index 0000000000..b06fae9807 --- /dev/null +++ b/rust/agama-server/src/web/terminal_ws.rs @@ -0,0 +1,90 @@ +use axum::{ + extract::{ + ws::{close_code, CloseFrame, Message, WebSocket}, + WebSocketUpgrade, + }, + response::IntoResponse, +}; +use pty_process::{OwnedReadPty, OwnedWritePty}; +use std::borrow::Cow; +use tokio::io::AsyncWriteExt; +use tokio_stream::StreamExt; + +pub(crate) async fn handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket)) +} + +fn start_shell() -> Result<(OwnedReadPty, OwnedWritePty, tokio::process::Child), pty_process::Error> +{ + let pty = pty_process::Pty::new()?; + pty.resize(pty_process::Size::new(24, 80))?; + let mut cmd = pty_process::Command::new("bash"); + let child = cmd.spawn(&pty.pts()?)?; + let (pty_out, pty_in) = pty.into_split(); + Ok((pty_out, pty_in, child)) +} + +async fn handle_socket(mut socket: WebSocket) { + tracing::info!("Terminal connected"); + + // TODO: handle failed start of shell + let (pty_out, mut pty_in, mut child) = start_shell().unwrap(); + let mut reader_stream = tokio_util::io::ReaderStream::new(pty_out); + + loop { + tokio::select! { + message = socket.recv() => { + match message { + Some(Ok(Message::Text(s))) => { + let res = pty_in.write_all(s.as_bytes()).await; + if res.is_err() { + tracing::warn!("Failed to write to pty: {:?}", res); + } + + continue; + }, + Some(Ok(Message::Close(_))) => { + tracing::info!("websocket close {:?}", message); + break; + }, + None => { + tracing::info!("Socket closed"); + break; + } + _ => { + tracing::info!("websocket other {:?}", message); + continue + }, + } + }, + shell_output = reader_stream.next() => { + if let Some(some_output) = shell_output { + match some_output { + Ok(output_bytes) => socket.send(Message::Binary(output_bytes.into())).await.unwrap(), + Err(e) => tracing::warn!("Shell error {}", e), + } + + } + }, + status = child.wait() => { + match status { + Err(err) => tracing::warn!("Child status error: {}", err), + Ok(status) => { + let code = status.code().unwrap_or(0); + let msg = format!("Bash exited with code: {code}"); + let res = socket.send(Message::Close(Some(CloseFrame { + code: close_code::NORMAL, + reason: Cow::from(msg), + }))).await; + if res.is_err() { + tracing::warn!("Failed to send close message: {:?}", res); + } + break; + } + } + } + } + } + + tracing::info!("Terminal disconnected."); +} diff --git a/web/package.json b/web/package.json index 6678916e35..8e4f020c4d 100644 --- a/web/package.json +++ b/web/package.json @@ -103,6 +103,9 @@ "@patternfly/patternfly": "^5.1.0", "@patternfly/react-core": "^5.1.1", "@patternfly/react-table": "^5.1.1", + "@xterm/addon-attach": "0.11.0", + "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "5.5.0", "fast-sort": "^3.4.0", "ipaddr.js": "^2.1.0", "react": "^18.2.0", diff --git a/web/src/MainLayout.jsx b/web/src/MainLayout.jsx index 19c7ffc44d..11b553ff04 100644 --- a/web/src/MainLayout.jsx +++ b/web/src/MainLayout.jsx @@ -29,7 +29,7 @@ import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core"; import { Icon } from "~/components/layout"; -import { About, InstallerOptions } from "~/components/core"; +import { About, InstallerOptions, TerminalDialog } from "~/components/core"; import { _ } from "~/i18n"; import { rootRoutes } from "~/router"; import { useProduct } from "~/context/product"; @@ -60,6 +60,7 @@ const Header = () => { + diff --git a/web/src/SimpleLayout.jsx b/web/src/SimpleLayout.jsx index 342a4786dd..370c4f95fa 100644 --- a/web/src/SimpleLayout.jsx +++ b/web/src/SimpleLayout.jsx @@ -26,7 +26,7 @@ import { Page, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core"; -import { InstallerOptions } from "~/components/core"; +import { InstallerOptions, TerminalDialog } from "~/components/core"; import { _ } from "~/i18n"; /** @@ -42,6 +42,7 @@ export default function SimpleLayout({ showOutlet = true, showInstallerOptions = + {showInstallerOptions && } {showInstallerOptions && } diff --git a/web/src/client/http.js b/web/src/client/http.js index d7d6c105d9..3cf170af0c 100644 --- a/web/src/client/http.js +++ b/web/src/client/http.js @@ -54,7 +54,7 @@ class WSClient { /** * @param {URL} url - Websocket URL. */ - constructor(url) { + constructor(url, json = true) { this.url = url.toString(); this.handlers = { @@ -66,6 +66,7 @@ class WSClient { this.reconnectAttempts = 0; this.client = this.buildClient(); + this.json = json; } wsState() { @@ -196,7 +197,7 @@ class WSClient { * @param {object} event - Event object, which is basically a websocket message. */ dispatchEvent(event) { - const eventObject = JSON.parse(event.data); + const eventObject = this.json ? JSON.parse(event.data) : event.data; this.handlers.events.forEach((f) => f(eventObject)); } @@ -375,4 +376,4 @@ class HTTPClient { } } -export { HTTPClient }; +export { WSClient, HTTPClient }; diff --git a/web/src/components/core/Terminal.jsx b/web/src/components/core/Terminal.jsx new file mode 100644 index 0000000000..6be75b3663 --- /dev/null +++ b/web/src/components/core/Terminal.jsx @@ -0,0 +1,88 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React from "react"; +import { WSClient } from "~/client/http"; +import { Terminal as Term } from "@xterm/xterm"; +import { AttachAddon } from "@xterm/addon-attach"; +import "@xterm/xterm/css/xterm.css"; + +const buildWs = () => { + const url = new URL(window.location.toString()); + url.hash = ""; + url.pathname += "api/terminal"; + url.protocol = url.protocol === "http:" ? "ws" : "wss"; + + return new WSClient(url, false); +}; + +const buildTerm = () => { + return new Term({ + rows: 25, + cols: 100, + cursorBlink: true, + screenReaderMode: true, + theme: { + background: "#ffffff", + foreground: "#000000", + cursor: "#000000", + } + }); +}; + +/** + * Simple component that displayes terminal. + * @component + * + * // FIXME: if possible, convert to a functional component + * + * @param {object} props + * @param {Location} props.url url of websocket answering terminal + */ +export default class Terminal extends React.Component { + constructor() { + super(); + this.terminalRef = React.createRef(); + this.ws = buildWs(); + this.term = buildTerm(); + this.ws.onOpen(() => { + if (this.attachAddon === undefined) { + this.attachAddon = new AttachAddon(this.ws.client); + // Attach the socket to term + this.term.loadAddon(this.attachAddon); + } + }); + this.ws.connect(); + } + + componentDidMount() { + this.term.open(this.terminalRef.current); + this.term.input("agetty --show-issue/n"); + this.term.writeln("Welcome to agama shell"); + this.term.focus(); + } + + render() { + return
; + } +} diff --git a/web/src/components/core/TerminalDialog.jsx b/web/src/components/core/TerminalDialog.jsx new file mode 100644 index 0000000000..b6811a8d77 --- /dev/null +++ b/web/src/components/core/TerminalDialog.jsx @@ -0,0 +1,70 @@ +/* + * Copyright (c) [2022-2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// @ts-check + +import React, { useState } from "react"; +import { useLocation } from "react-router-dom"; +import { Button } from "@patternfly/react-core"; +import { Center, Icon } from "~/components/layout"; +import { Popup, Terminal } from "~/components/core"; +import { _ } from "~/i18n"; + +/** + * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps + */ + +/** + * Renders a terminal within a dialog + * + * @todo Write documentation + */ +export default function TerminalDialog() { + const location = useLocation(); + const [isOpen, setIsOpen] = useState(false); + + if (location.pathname.includes("login")) return; + + const open = () => setIsOpen(true); + const close = () => setIsOpen(false); + + return ( + <> +