add web terminal emulator (closes #6)

This commit is contained in:
Nikos Papadakis 2023-12-16 22:40:57 +02:00
parent 62c40358a2
commit ac709e66f5
Signed by untrusted user who does not match committer: nikos
GPG key ID: 78871F9905ADFF02
15 changed files with 586 additions and 87 deletions

160
Cargo.lock generated
View file

@ -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",
]

View file

@ -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" }

View file

@ -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;

166
agent/src/pty.rs Normal file
View file

@ -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<OwnedFd>,
}
impl Pty {
pub fn open() -> io::Result<Self> {
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<PtyChild> {
// 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<Pty> {
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<io::Result<()>> {
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<Result<usize, io::Error>> {
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<Result<(), io::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), io::Error>> {
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<Child> {
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);
}
}

View file

@ -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<Box<dyn Stream<Item = Result<TerminalResponse, Status>> + Send>>;
async fn terminal(
&self,
req: Request<Streaming<TerminalRequest>>,
) -> AgentResult<Self::TerminalStream> {
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)))
}
}

View file

@ -1,5 +1,6 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "xterm";
/* This file is for your main application CSS */

View file

@ -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

View file

@ -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=="
}
}
}

View file

@ -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"
}
}

View file

@ -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)}

View file

@ -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
]

View file

@ -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,

View file

@ -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"""
<div>
<PrymnWeb.Button.primary
:if={not @open}
type="button"
phx-target={@myself}
phx-click="open_terminal"
>
Open Terminal
</PrymnWeb.Button.primary>
<PrymnWeb.Button.primary
:if={@open}
type="button"
phx-target={@myself}
phx-click="close_terminal"
>
Close Terminal
</PrymnWeb.Button.primary>
<div :if={@open} class="mt-2 bg-black p-2">
<div phx-hook="Terminal" id="terminal" />
</div>
</div>
"""
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

View file

@ -71,20 +71,18 @@ defmodule PrymnWeb.ServerLive.Show do
</button>
</div>
</div>
<div :if={@server.status == :registered} class="my-10">
<form phx-change="change_dry_run">
<.input type="checkbox" name="dry_run" value={@dry_run} label="Enable dry-run operations" />
</form>
<div :if={@server.status == :registered} class="my-10 space-y-5 divide-y">
<.live_component
id={"system_info-#{@server.name}"}
module={PrymnWeb.SystemInfo}
agent={assigns[:agent]}
/>
<section class="mt-4">
<form phx-change="change_dry_run">
<.input type="checkbox" name="dry_run" value={@dry_run} label="Enable dry-run operations" />
</form>
</section>
<section class="mt-4">
<h2 class="border-b border-solid border-gray-500 pb-1 text-2xl font-medium">System</h2>
<p class="mt-4">
<section>
<h2 class="my-5 text-xl">System</h2>
<p>
Updates: <%= 0 %> pending updates.
<Button.primary type="button" class="ml-4" phx-click="system_update">
Update now
@ -94,32 +92,11 @@ defmodule PrymnWeb.ServerLive.Show do
</p>
</p>
</section>
<section class="mt-4">
<h2 class="border-b border-solid border-gray-500 pb-1 text-2xl font-medium">
Backups
<section>
<h2 class="my-5 text-xl">
Terminal
</h2>
<.table id="backups" rows={[%{date: "2023-10-11"}, %{date: "2023-10-10"}]}>
<:col :let={backup} label="Date"><%= backup.date %></:col>
<:action>
<Button.primary>Restore</Button.primary>
</:action>
</.table>
</section>
<section class="mt-4">
<h2 class="border-b border-solid border-gray-500 pb-1 text-2xl font-medium">
Manage Services
</h2>
<.table
id="services"
rows={[%{name: "mariadb", status: "Active"}, %{name: "php8.0", status: "Disabled"}]}
>
<:col :let={service} label="Service"><%= service.name %></:col>
<:col :let={service} label="Status"><%= service.status %></:col>
<:action>
<Button.primary>Activate</Button.primary>
<Button.secondary>Deactivate</Button.secondary>
</:action>
</.table>
<.live_component id="terminal" module={PrymnWeb.Terminal} server={@server} />
</section>
</div>
<.back navigate={~p"/servers"}>Back to servers</.back>

View file

@ -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);
}