diff --git a/Cargo.lock b/Cargo.lock index c4277c7..68ab812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -86,7 +86,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -250,7 +250,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -334,12 +334,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -483,7 +483,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -653,9 +653,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.150" +version = "0.2.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "linux-raw-sys" @@ -663,6 +663,16 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.20" @@ -704,7 +714,7 @@ checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -772,6 +782,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -912,6 +945,7 @@ dependencies = [ "prost", "regex", "reqwest", + "rustix", "serde", "serde_json", "sysinfo", @@ -1045,15 +1079,16 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.24" +version = "0.38.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" dependencies = [ "bitflags 2.4.1", "errno", + "itoa", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1068,6 +1103,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.192" @@ -1161,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1232,7 +1273,7 @@ dependencies = [ "fastrand", "redox_syscall", "rustix", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1271,11 +1312,12 @@ dependencies = [ "libc", "mio", "num_cpus", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.5.5", "tokio-macros", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1654,7 +1696,7 @@ version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -1663,7 +1705,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", ] [[package]] @@ -1672,13 +1723,28 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", ] [[package]] @@ -1687,42 +1753,84 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + [[package]] name = "winreg" version = "0.50.0" @@ -1730,5 +1838,5 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.48.0", ] diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 7dc9224..f6378be 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -13,10 +13,11 @@ once_cell = "1.18.0" prost = "0.12.1" regex = "1.10.2" reqwest = { version = "0.11.18", features = ["blocking", "json"], default-features = false } +rustix = { version = "0.38.28", features = ["fs", "process", "pty", "stdio", "termios"] } serde = { version = "1.0.173", features = ["derive"] } serde_json = "1.0.103" sysinfo = { version = "0.29.2", default-features = false } -tokio = { version = "1.28.2", features = ["rt-multi-thread", "io-util", "process", "macros", "signal"] } +tokio = { version = "1.28.2", features = ["full"] } tokio-stream = { version = "0.1.14", features = ["net", "sync"] } tokio-util = { version = "0.7.10", features = ["codec"] } tonic = { version = "0.10.2" } diff --git a/agent/src/lib.rs b/agent/src/lib.rs index 02e4f28..e43b076 100644 --- a/agent/src/lib.rs +++ b/agent/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod debian; pub mod health; pub mod info; +pub mod pty; pub mod self_update; pub mod server; pub mod task; diff --git a/agent/src/pty.rs b/agent/src/pty.rs new file mode 100644 index 0000000..e1f3ea6 --- /dev/null +++ b/agent/src/pty.rs @@ -0,0 +1,166 @@ +use std::{io, task::ready}; + +use rustix::{ + fd::OwnedFd, + fs::{fcntl_getfl, fcntl_setfl, OFlags}, + process::{ioctl_tiocsctty, setsid}, + pty::{grantpt, ioctl_tiocgptpeer, openpt, unlockpt, OpenptFlags}, + stdio::{dup2_stderr, dup2_stdin, dup2_stdout}, + termios::{tcsetwinsize, Winsize}, +}; +use tokio::{ + io::{unix::AsyncFd, AsyncRead, AsyncWrite}, + process::Child, +}; + +#[derive(Debug)] +pub struct Pty { + fd: AsyncFd, +} + +impl Pty { + pub fn open() -> io::Result { + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?; + grantpt(&master)?; + unlockpt(&master)?; + + // Set nonblocking + let flags = fcntl_getfl(&master)?; + fcntl_setfl(&master, flags | OFlags::NONBLOCK)?; + + let fd = AsyncFd::new(master)?; + + Ok(Self { fd }) + } + + pub fn child(&self) -> io::Result { + // NOTE: Linux v4.13 and above + let fd = ioctl_tiocgptpeer(&self.fd, OpenptFlags::RDWR | OpenptFlags::NOCTTY)?; + let child = PtyChild { fd }; + + Ok(child) + } + + pub fn resize_window(&self, rows: u16, cols: u16) -> io::Result<()> { + let winsize = Winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + tcsetwinsize(&self.fd, winsize)?; + + Ok(()) + } + + pub fn try_clone(&self) -> io::Result { + let fd = self.fd.get_ref().try_clone()?; + + Ok(Pty { + fd: AsyncFd::new(fd)?, + }) + } +} + +impl AsyncRead for Pty { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + loop { + let mut guard = ready!(self.fd.poll_read_ready(cx)?); + + match guard.try_io(|inner| { + let fd = inner.get_ref(); + let n = rustix::io::read(fd, buf.initialize_unfilled())?; + buf.advance(n); + + Ok(()) + }) { + Ok(result) => return std::task::Poll::Ready(result), + Err(_would_block) => continue, + } + } + } +} + +impl AsyncWrite for Pty { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + loop { + let mut guard = ready!(self.fd.poll_write_ready(cx))?; + + match guard.try_io(|inner| Ok(rustix::io::write(inner.get_ref(), buf)?)) { + Ok(result) => return std::task::Poll::Ready(result), + Err(_would_block) => continue, + } + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +#[derive(Debug)] +pub struct PtyChild { + fd: OwnedFd, +} + +impl PtyChild { + pub fn login_tty(&self) -> io::Result<()> { + setsid()?; + ioctl_tiocsctty(&self.fd)?; + dup2_stdin(&self.fd)?; + dup2_stdout(&self.fd)?; + dup2_stderr(&self.fd)?; + + Ok(()) + } +} + +pub fn open_shell(pty_child: PtyChild) -> io::Result { + let mut cmd = tokio::process::Command::new("/bin/bash"); + + unsafe { + cmd.pre_exec(move || { + pty_child.login_tty()?; + Ok(()) + }); + } + + cmd.spawn() +} + +#[cfg(test)] +mod test { + use rustix::fd::AsRawFd; + + use super::*; + + #[tokio::test] + async fn can_open_pty() { + let pty = Pty::open().unwrap(); + let child = pty.child().unwrap(); + + let master_fd = pty.fd.get_ref().as_raw_fd(); + let child_fd = child.fd.as_raw_fd(); + + assert!(master_fd != child_fd); + } +} diff --git a/agent/src/server/agent.rs b/agent/src/server/agent.rs index 24e4538..9c9d405 100644 --- a/agent/src/server/agent.rs +++ b/agent/src/server/agent.rs @@ -1,14 +1,20 @@ use std::{pin::Pin, process::Stdio, sync::Mutex}; -use tokio::process::Command; +use tokio::{io::AsyncWriteExt, process::Command}; use tokio_stream::{ wrappers::{ReceiverStream, WatchStream}, Stream, StreamExt, }; use tokio_util::codec::{BytesCodec, FramedRead}; -use tonic::{Request, Response, Status}; +use tonic::{Request, Response, Status, Streaming}; -use crate::{debian, health::HealthMonitor, info::Info, task::TaskBuilder}; +use crate::{ + debian, + health::HealthMonitor, + info::Info, + pty::{open_shell, Pty}, + task::TaskBuilder, +}; use super::proto::*; @@ -168,4 +174,51 @@ impl agent_server::Agent for AgentService<'static> { Ok(Response::new(Box::pin(stream))) } + + type TerminalStream = Pin> + Send>>; + + async fn terminal( + &self, + req: Request>, + ) -> AgentResult { + let mut in_stream = req.into_inner(); + let mut pty = Pty::open()?; + let pty_clone = pty.try_clone()?; + let pty_child = pty.child()?; + let mut child = open_shell(pty_child)?; + + tokio::spawn(async move { + // TODO: Handle errors inside here + while let Some(result) = in_stream.next().await { + match result { + Ok(req) => { + if let Some(resize) = req.resize { + pty.resize_window(resize.rows as u16, resize.cols as u16) + .unwrap(); + } + + pty.write_all(&req.input[..]).await.unwrap(); + } + Err(err) => { + // Log and ignore the error... + tracing::warn!(%err, "received an incoming stream error"); + } + } + } + + // TODO: Maybe there's a more graceful way to stop the process? + child.kill().await.unwrap(); + }); + + let out_stream = FramedRead::new(pty_clone, BytesCodec::new()).map(|inner| { + inner + .map(|b| TerminalResponse { output: b.to_vec() }) + .map_err(|err| { + tracing::error!(%err, "read error on pseudoterminal"); + Status::internal("terminal read error") + }) + }); + + Ok(Response::new(Box::pin(out_stream))) + } } diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 378c8f9..2c756b8 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -1,5 +1,6 @@ @import "tailwindcss/base"; @import "tailwindcss/components"; @import "tailwindcss/utilities"; +@import "xterm"; /* This file is for your main application CSS */ diff --git a/app/assets/js/app.js b/app/assets/js/app.js index b7a5ad9..e37db63 100644 --- a/app/assets/js/app.js +++ b/app/assets/js/app.js @@ -2,42 +2,68 @@ // to get started and then uncomment the line below. // import "./user_socket.js" -// You can include dependencies in two ways. -// -// The simplest option is to put them in assets/vendor and -// import them using relative paths: -// -// import "../vendor/some-package.js" -// -// Alternatively, you can `npm install some-package --prefix assets` and import -// them using a path starting with the package name: -// -// import "some-package" -// - import "phoenix_html" -import {Socket} from "phoenix" -import {LiveSocket} from "phoenix_live_view" +import { Socket } from "phoenix" +import { LiveSocket } from "phoenix_live_view" import topbar from "../vendor/topbar" import Alpine from "alpinejs" +import { Terminal } from "xterm" +import { FitAddon } from "@xterm/addon-fit" -Alpine.start() -window.Alpine = Alpine +let Hooks = {} + +// TODO: Move this code and xterm into a separate generated file, and load that file only when needed +Hooks.Terminal = { + mounted() { + const term = new Terminal({ + fontFamily: '"Courier New", "DejaVu Sans Mono", "Everson Mono", monospace', + fontSize: 13, + convertEol: true, + theme: { + background: "#24273a", + foreground: "#cad3f5", + cursor: "#f4dbd6", + black: "#494D64", + red: "#ed8796", + green: "#a6da95", + yellow: "#eed49f", + blue: "#8aadf4", + magenta: "#f5bde6", + cyan: "#8bd5ca", + white: "#b8c0e0", + } + }) + + const fitAddon = new FitAddon() + const resizeObserver = new ResizeObserver(() => fitAddon.fit()) + + term.loadAddon(fitAddon) + term.open(this.el) + term.onData(data => this.pushEventTo(this.el, "data_event", data)) + term.onResize(resize => this.pushEventTo(this.el, "resize_event", resize)) + + this.handleEvent("data", payload => term.write(payload.data || "")) + + fitAddon.fit() + resizeObserver.observe(this.el) + } +} let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { - params: {_csrf_token: csrfToken}, + params: { _csrf_token: csrfToken }, dom: { onBeforeElUpdated(from, to) { if (from._x_dataStack) { window.Alpine.clone(from, to) } } - } + }, + hooks: Hooks, }) // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +topbar.config({ barColors: { 0: "#29d" }, shaDowColor: "rgba(0, 0, 0, .3)" }) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) @@ -50,3 +76,5 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +Alpine.start() +window.Alpine = Alpine diff --git a/app/assets/package-lock.json b/app/assets/package-lock.json index ff530ff..05c2e7d 100644 --- a/app/assets/package-lock.json +++ b/app/assets/package-lock.json @@ -5,7 +5,9 @@ "packages": { "": { "dependencies": { - "alpinejs": "^3.13.3" + "@xterm/addon-fit": "^0.9.0-beta.1", + "alpinejs": "^3.13.3", + "xterm": "^5.3.0" } }, "node_modules/@vue/reactivity": { @@ -21,6 +23,14 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==" }, + "node_modules/@xterm/addon-fit": { + "version": "0.9.0-beta.1", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.9.0-beta.1.tgz", + "integrity": "sha512-HmGRUMMamUpQYuQBF2VP1LJ0xzqF85LMFfpaNu84t1Tsrl1lPKJWtqX9FDZ22Rf5q6bnKdbj44TRVAUHgDRbLA==", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/alpinejs": { "version": "3.13.3", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.13.3.tgz", @@ -28,6 +38,11 @@ "dependencies": { "@vue/reactivity": "~3.1.1" } + }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==" } } } diff --git a/app/assets/package.json b/app/assets/package.json index 63b363b..1fb7f99 100644 --- a/app/assets/package.json +++ b/app/assets/package.json @@ -1,5 +1,7 @@ { "dependencies": { - "alpinejs": "^3.13.3" + "@xterm/addon-fit": "^0.9.0-beta.1", + "alpinejs": "^3.13.3", + "xterm": "^5.3.0" } } diff --git a/app/lib/prymn/agents.ex b/app/lib/prymn/agents.ex index 4a28eae..652d963 100644 --- a/app/lib/prymn/agents.ex +++ b/app/lib/prymn/agents.ex @@ -131,6 +131,16 @@ defmodule Prymn.Agents do def sys_update(%Agent{} = agent, request) when is_map(request), do: sys_update(agent, struct(SysUpdateRequest, request)) + def terminal(%Agent{} = agent) do + # TODO: Find a better solve for bi-directional GRPC stream + with {:ok, channel} <- get_channel(agent), + stream <- Stub.terminal(channel) do + stream + else + {:error, error} -> {:error, error} + end + end + defp get_channel(%Agent{} = agent) do case start_connection(agent.host_address) do {:ok, pid} -> {:ok, Connection.get_channel(pid)} diff --git a/app/lib/prymn/application.ex b/app/lib/prymn/application.ex index f698871..b5653d0 100644 --- a/app/lib/prymn/application.ex +++ b/app/lib/prymn/application.ex @@ -15,6 +15,7 @@ defmodule Prymn.Application do {Finch, name: Prymn.Finch}, {Oban, Application.fetch_env!(:prymn, Oban)}, Prymn.Agents.Supervisor, + {Task.Supervisor, name: Prymn.TaskSupervisor}, PrymnWeb.Endpoint ] diff --git a/app/lib/prymn_web/components/system_info.ex b/app/lib/prymn_web/components/system_info.ex index d78a5fb..390e232 100644 --- a/app/lib/prymn_web/components/system_info.ex +++ b/app/lib/prymn_web/components/system_info.ex @@ -3,6 +3,7 @@ defmodule PrymnWeb.SystemInfo do require Logger alias Phoenix.LiveView.AsyncResult + alias PrymnProto.Prymn.SysInfoResponse @impl true def update(assigns, socket) do @@ -59,7 +60,7 @@ defmodule PrymnWeb.SystemInfo do """ end - def handle_async(:get_sys_info, {:ok, {:ok, sys_info}}, socket) do + def handle_async(:get_sys_info, {:ok, %SysInfoResponse{} = sys_info}, socket) do %{sys_info: sys_info_result, agent: agent} = socket.assigns {:noreply, diff --git a/app/lib/prymn_web/components/terminal.ex b/app/lib/prymn_web/components/terminal.ex new file mode 100644 index 0000000..8156a0b --- /dev/null +++ b/app/lib/prymn_web/components/terminal.ex @@ -0,0 +1,120 @@ +defmodule PrymnWeb.Terminal do + use PrymnWeb, :live_component + + alias PrymnProto.Prymn.TerminalRequest + + @impl true + def mount(socket) do + {:ok, assign(socket, :open, false)} + end + + @impl true + def update(assigns, socket) do + socket = + if assigns[:data], + do: push_event(socket, "data", %{"data" => assigns[:data]}), + else: socket + + {:ok, assign(socket, assigns)} + end + + @impl true + def render(assigns) do + ~H""" +
+ + Open Terminal + + + Close Terminal + +
+
+
+
+ """ + end + + @impl true + def handle_event("open_terminal", _params, socket) do + agent = Prymn.Agents.from_server(socket.assigns.server) + pid = self() + + Task.Supervisor.start_child(Prymn.TaskSupervisor, fn -> + # FIXME: Have to wrap this in a Task because gun sends unsolicited messages + # to calling process + stream = Prymn.Agents.terminal(agent) + + {:ok, mux_pid} = + Task.Supervisor.start_child(Prymn.TaskSupervisor, fn -> receive_loop(stream) end) + + send_update(pid, PrymnWeb.Terminal, id: "terminal", mux_pid: mux_pid, open: true) + + case GRPC.Stub.recv(stream, timeout: :infinity) do + {:ok, stream} -> + Enum.map(stream, fn + {:ok, %{output: data}} -> + send(mux_pid, :data) + send_update(pid, PrymnWeb.Terminal, id: "terminal", data: data) + + {:error, _err} -> + send_update(pid, PrymnWeb.Terminal, id: "terminal", open: false) + end) + + {:error, error} -> + dbg(error) + end + end) + + {:noreply, socket} + end + + def handle_event("close_terminal", _params, socket) do + send(socket.assigns.mux_pid, :close) + {:noreply, assign(socket, :open, false)} + end + + def handle_event("data_event", data, socket) when is_binary(data) do + send(socket.assigns.mux_pid, {:data_event, data}) + {:noreply, socket} + end + + def handle_event("resize_event", %{"cols" => cols, "rows" => rows}, socket) do + send(socket.assigns.mux_pid, {:resize_event, rows, cols}) + {:noreply, socket} + end + + defp receive_loop(stream) do + receive do + {:data_event, data} -> + GRPC.Stub.send_request(stream, %TerminalRequest{input: data}) + receive_loop(stream) + + {:resize_event, rows, cols} -> + GRPC.Stub.send_request(stream, %TerminalRequest{ + resize: %TerminalRequest.Resize{rows: rows, cols: cols} + }) + + receive_loop(stream) + + :data -> + receive_loop(stream) + + :close -> + GRPC.Stub.send_request(stream, %TerminalRequest{input: ""}, end_stream: true) + after + 120_000 -> + GRPC.Stub.send_request(stream, %TerminalRequest{input: ""}, end_stream: true) + end + end +end diff --git a/app/lib/prymn_web/live/server_live/show.ex b/app/lib/prymn_web/live/server_live/show.ex index 4e5aa1b..2726d7d 100644 --- a/app/lib/prymn_web/live/server_live/show.ex +++ b/app/lib/prymn_web/live/server_live/show.ex @@ -71,20 +71,18 @@ defmodule PrymnWeb.ServerLive.Show do
-
+
+ <.input type="checkbox" name="dry_run" value={@dry_run} label="Enable dry-run operations" /> +
+
<.live_component id={"system_info-#{@server.name}"} module={PrymnWeb.SystemInfo} agent={assigns[:agent]} /> -
-
- <.input type="checkbox" name="dry_run" value={@dry_run} label="Enable dry-run operations" /> -
-
-
-

System

-

+

+

System

+

Updates: <%= 0 %> pending updates. Update now @@ -94,32 +92,11 @@ defmodule PrymnWeb.ServerLive.Show do

-
-

- Backups +
+

+ Terminal

- <.table id="backups" rows={[%{date: "2023-10-11"}, %{date: "2023-10-10"}]}> - <:col :let={backup} label="Date"><%= backup.date %> - <:action> - Restore - - -
-
-

- Manage Services -

- <.table - id="services" - rows={[%{name: "mariadb", status: "Active"}, %{name: "php8.0", status: "Disabled"}]} - > - <:col :let={service} label="Service"><%= service.name %> - <:col :let={service} label="Status"><%= service.status %> - <:action> - Activate - Deactivate - - + <.live_component id="terminal" module={PrymnWeb.Terminal} server={@server} />

<.back navigate={~p"/servers"}>Back to servers diff --git a/proto/agent.proto b/proto/agent.proto index bc745eb..06f86c2 100644 --- a/proto/agent.proto +++ b/proto/agent.proto @@ -69,9 +69,24 @@ message SysUpdateResponse { int32 progress = 2; } +message TerminalRequest { + message Resize { + uint32 rows = 1; + uint32 cols = 2; + } + + bytes input = 1; + optional Resize resize = 2; +} + +message TerminalResponse { + bytes output = 1; +} + service Agent { rpc Health(google.protobuf.Empty) returns (stream HealthResponse); rpc Exec(ExecRequest) returns (stream ExecResponse); + rpc Terminal(stream TerminalRequest) returns (stream TerminalResponse); rpc GetSysInfo(google.protobuf.Empty) returns (SysInfoResponse); rpc SysUpdate(SysUpdateRequest) returns (stream SysUpdateResponse); }