From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <dcsapak@zita.proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by lists.proxmox.com (Postfix) with ESMTPS id 9F98F64D84
 for <pbs-devel@lists.proxmox.com>; Tue, 21 Jul 2020 11:11:15 +0200 (CEST)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 53BFC16555
 for <pbs-devel@lists.proxmox.com>; Tue, 21 Jul 2020 11:10:45 +0200 (CEST)
Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com
 [212.186.127.180])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS id D75B6164F1
 for <pbs-devel@lists.proxmox.com>; Tue, 21 Jul 2020 11:10:41 +0200 (CEST)
Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1])
 by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 98C1D43300
 for <pbs-devel@lists.proxmox.com>; Tue, 21 Jul 2020 11:10:41 +0200 (CEST)
From: Dominik Csapak <d.csapak@proxmox.com>
To: pbs-devel@lists.proxmox.com
Date: Tue, 21 Jul 2020 11:10:38 +0200
Message-Id: <20200721091040.7632-4-d.csapak@proxmox.com>
X-Mailer: git-send-email 2.20.1
In-Reply-To: <20200721091040.7632-1-d.csapak@proxmox.com>
References: <20200721091040.7632-1-d.csapak@proxmox.com>
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.011 Adjusted score from AWL reputation of From: address
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery
 methods
 NO_DNS_FOR_FROM         0.379 Envelope sender has no MX or A DNS records
 RCVD_IN_DNSWL_MED        -2.3 Sender listed at https://www.dnswl.org/,
 medium trust
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_NONE                0.001 SPF: sender does not publish an SPF Record
 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See
 http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more
 information. [remote.read, node.rs, acl.rs]
Subject: [pbs-devel] [PATCH proxmox-backup 3/5] api2/nodes: add termproxy
 and vncwebsocket api calls
X-BeenThere: pbs-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox Backup Server development discussion
 <pbs-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pbs-devel/>
List-Post: <mailto:pbs-devel@lists.proxmox.com>
List-Help: <mailto:pbs-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel>, 
 <mailto:pbs-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Tue, 21 Jul 2020 09:11:15 -0000

Even though it has nothing to do with vnc, we keep the name of the api
call for compatibility with our xtermjs client.

termproxy:
verifies that the user is allowed to open a console and starts
termproxy with the correct parameters

starts a TcpListener on "localhost:0" so that the kernel decides the
port (instead of trying to rerserving like in pve). Then it
leaves the fd open for termproxy and gives the number as port
and tells it via '--port-as-fd' that it should interpret this
as an open fd

the vncwebsocket api call checks the 'vncticket' (name for compatibility)
and connects the remote side (after an Upgrade) with a local TcpStream
connecting to the port given via WebSocket from the proxmox crate

to make sure that only the client can connect that called termproxy and
no one can connect to an arbitrary port on the host we have to include
the port in the ticket data

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 Cargo.toml        |   6 +-
 src/api2/node.rs  | 276 +++++++++++++++++++++++++++++++++++++++++++++-
 src/config/acl.rs |   2 +
 3 files changed, 275 insertions(+), 9 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 355217e..a077a6c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,9 +38,9 @@ pam-sys = "0.5"
 percent-encoding = "2.1"
 pin-utils = "0.1.0"
 pathpatterns = "0.1.2"
-proxmox = { version = "0.2.0", features = [ "sortable-macro", "api-macro" ] }
+proxmox = { version = "0.2.0", features = [ "sortable-macro", "api-macro", "websocket" ] }
 #proxmox = { git = "ssh://gitolite3@proxdev.maurer-it.com/rust/proxmox", version = "0.1.2", features = [ "sortable-macro", "api-macro" ] }
-#proxmox = { path = "../proxmox/proxmox", features = [ "sortable-macro", "api-macro" ] }
+#proxmox = { path = "../proxmox/proxmox", features = [ "sortable-macro", "api-macro", "websocket" ] }
 proxmox-fuse = "0.1.0"
 pxar = { version = "0.2.1", features = [ "tokio-io", "futures-io" ] }
 #pxar = { path = "../pxar", features = [ "tokio-io", "futures-io" ] }
@@ -50,7 +50,7 @@ serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
 siphasher = "0.3"
 syslog = "4.0"
-tokio = { version = "0.2.9", features = [ "blocking", "fs", "io-util", "macros", "rt-threaded", "signal", "stream", "tcp", "time", "uds" ] }
+tokio = { version = "0.2.9", features = [ "blocking", "fs", "dns", "io-util", "macros", "process", "rt-threaded", "signal", "stream", "tcp", "time", "uds" ] }
 tokio-openssl = "0.4.0"
 tokio-util = { version = "0.3", features = [ "codec" ] }
 tower-service = "0.3.0"
diff --git a/src/api2/node.rs b/src/api2/node.rs
index 13ff282..a688694 100644
--- a/src/api2/node.rs
+++ b/src/api2/node.rs
@@ -1,16 +1,275 @@
+use std::net::TcpListener;
+use std::os::unix::io::AsRawFd;
+
+use anyhow::{bail, format_err, Error};
+use futures::{
+    future::{FutureExt, TryFutureExt},
+    try_join,
+};
+use hyper::body::Body;
+use hyper::http::request::Parts;
+use hyper::upgrade::Upgraded;
+use nix::fcntl::{fcntl, FcntlArg, FdFlag};
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufReadExt, BufReader};
+
 use proxmox::api::router::{Router, SubdirMap};
+use proxmox::api::{
+    api, schema::*, ApiHandler, ApiMethod, ApiResponseFuture, Permission, RpcEnvironment,
+};
 use proxmox::list_subdirs_api_method;
+use proxmox::tools::websocket::WebSocket;
+use proxmox::{identity, sortable};
 
-pub mod tasks;
-mod time;
-pub mod network;
+use crate::api2::types::*;
+use crate::config::acl::PRIV_SYS_CONSOLE;
+use crate::server::WorkerTask;
+use crate::tools;
+
+pub mod disks;
 pub mod dns;
-mod syslog;
 mod journal;
+pub mod network;
+pub(crate) mod rrd;
 mod services;
 mod status;
-pub(crate) mod rrd;
-pub mod disks;
+mod syslog;
+pub mod tasks;
+mod time;
+
+pub const SHELL_CMD_SCHEMA: Schema = StringSchema::new("The command to run.")
+    .format(&ApiStringFormat::Enum(&[
+        EnumEntry::new("login", "Login"),
+        EnumEntry::new("upgrade", "Upgrade"),
+    ]))
+    .schema();
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            node: {
+                schema: NODE_SCHEMA,
+            },
+            cmd: {
+                schema: SHELL_CMD_SCHEMA,
+                optional: true,
+            },
+        },
+    },
+    returns: {
+        type: Object,
+        description: "Object with the user, ticket, port and upid",
+        properties: {
+            user: {
+                description: "",
+                type: String,
+            },
+            ticket: {
+                description: "",
+                type: String,
+            },
+            port: {
+                description: "",
+                type: String,
+            },
+            upid: {
+                description: "",
+                type: String,
+            },
+        }
+    },
+    access: {
+        description: "Restricted to users on realm 'pam'",
+        permission: &Permission::Privilege(&["nodes","{node}"], PRIV_SYS_CONSOLE, false),
+    }
+)]
+/// Call termproxy and return shell ticket
+async fn termproxy(
+    node: String,
+    cmd: Option<String>,
+    _param: Value,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+    let userid = rpcenv
+        .get_user()
+        .ok_or_else(|| format_err!("unknown user"))?;
+    let (username, realm) = crate::auth::parse_userid(&userid)?;
+
+    if realm != "pam" {
+        bail!("only pam users can use the console");
+    }
+
+    let path = format!("/nodes/{}", node);
+
+    // use port 0 and let the kernel decide which port is free
+    let listener = TcpListener::bind("localhost:0")?;
+    let port = listener.local_addr()?.port();
+
+    let ticket = tools::ticket::assemble_term_ticket(
+        crate::auth_helpers::private_auth_key(),
+        &userid,
+        &path,
+        port,
+    )?;
+
+    let mut command = Vec::new();
+    match cmd.as_ref().map(|x| x.as_str()) {
+        Some("login") | None => {
+            command.push("login");
+            if userid == "root@pam" {
+                command.push("-f");
+                command.push("root");
+            }
+        }
+        Some("upgrade") => {
+            bail!("upgrade is not supported yet");
+        }
+        _ => bail!("invalid command"),
+    };
+
+    let upid = WorkerTask::spawn(
+        "termproxy",
+        None,
+        &username,
+        false,
+        move |worker| async move {
+            // move inside the worker so that it survives and does not close the port
+            // remove CLOEXEC from listenere so that we can reuse it in termproxy
+            let fd = listener.as_raw_fd();
+            let mut flags = match fcntl(fd, FcntlArg::F_GETFD) {
+                Ok(bits) => FdFlag::from_bits_truncate(bits),
+                Err(err) => bail!("could not get fd: {}", err),
+            };
+            flags.remove(FdFlag::FD_CLOEXEC);
+            if let Err(err) = fcntl(fd, FcntlArg::F_SETFD(flags)) {
+                bail!("could not set fd: {}", err);
+            }
+
+            let mut arguments: Vec<&str> = Vec::new();
+            let fd_string = fd.to_string();
+            arguments.push(&fd_string);
+            arguments.extend_from_slice(&[
+                "--path",
+                &path,
+                "--perm",
+                "Sys.Console",
+                "--authport",
+                "82",
+                "--port-as-fd",
+                "--",
+            ]);
+            arguments.extend_from_slice(&command);
+
+            let mut cmd = tokio::process::Command::new("/usr/bin/termproxy");
+
+            cmd.args(&arguments);
+            cmd.stdout(std::process::Stdio::piped());
+            cmd.stderr(std::process::Stdio::piped());
+
+            let mut child = cmd.spawn().expect("error executing termproxy");
+
+            let stdout = child.stdout.take().expect("no child stdout handle");
+            let stderr = child.stderr.take().expect("no child stderr handle");
+
+            let worker_stdout = worker.clone();
+            let stdout_fut = async move {
+                let mut reader = BufReader::new(stdout).lines();
+                while let Some(line) = reader.next_line().await? {
+                    worker_stdout.log(line);
+                }
+                Ok(())
+            };
+
+            let worker_stderr = worker.clone();
+            let stderr_fut = async move {
+                let mut reader = BufReader::new(stderr).lines();
+                while let Some(line) = reader.next_line().await? {
+                    worker_stderr.warn(line);
+                }
+                Ok(())
+            };
+
+            let (exit_code, _, _) = try_join!(child, stdout_fut, stderr_fut)?;
+            if !exit_code.success() {
+                match exit_code.code() {
+                    Some(code) => bail!("termproxy exited with {}", code),
+                    None => bail!("termproxy exited by signal"),
+                }
+            }
+
+            Ok(())
+        },
+    )?;
+
+    Ok(json!({
+        "user": username,
+        "ticket": ticket,
+        "port": port,
+        "upid": upid,
+    }))
+}
+
+#[sortable]
+pub const API_METHOD_WEBSOCKET: ApiMethod = ApiMethod::new(
+    &ApiHandler::AsyncHttp(&upgrade_to_websocket),
+    &ObjectSchema::new(
+        "Upgraded to websocket",
+        &sorted!([
+            ("node", false, &NODE_SCHEMA),
+            (
+                "vncticket",
+                false,
+                &StringSchema::new("Terminal ticket").schema()
+            ),
+            ("port", false, &IntegerSchema::new("Terminal port").schema()),
+        ]),
+    ),
+)
+.access(
+    Some("The user needs Sys.Console on /nodes/{node}."),
+    &Permission::Privilege(&["nodes", "{node}"], PRIV_SYS_CONSOLE, false),
+);
+
+fn upgrade_to_websocket(
+    parts: Parts,
+    req_body: Body,
+    param: Value,
+    _info: &ApiMethod,
+    rpcenv: Box<dyn RpcEnvironment>,
+) -> ApiResponseFuture {
+    async move {
+        let username = rpcenv.get_user().unwrap();
+        let node = tools::required_string_param(&param, "node")?.to_owned();
+        let path = format!("/nodes/{}", node);
+        let ticket = tools::required_string_param(&param, "vncticket")?.to_owned();
+        let port: u16 = tools::required_integer_param(&param, "port")? as u16;
+
+        // will be checked again by termproxy
+        tools::ticket::verify_term_ticket(
+            crate::auth_helpers::public_auth_key(),
+            &username,
+            &path,
+            port,
+            &ticket,
+        )?;
+
+        let (ws, response) = WebSocket::new(parts.headers)?;
+
+        tokio::spawn(async move {
+            let conn: Upgraded = match req_body.on_upgrade().map_err(Error::from).await {
+                Ok(upgraded) => upgraded,
+                _ => bail!("error"),
+            };
+
+            let local = tokio::net::TcpStream::connect(format!("localhost:{}", port)).await?;
+            ws.serve_connection(conn, local).await
+        });
+
+        Ok(response)
+    }
+    .boxed()
+}
 
 pub const SUBDIRS: SubdirMap = &[
     ("disks", &disks::ROUTER),
@@ -22,7 +281,12 @@ pub const SUBDIRS: SubdirMap = &[
     ("status", &status::ROUTER),
     ("syslog", &syslog::ROUTER),
     ("tasks", &tasks::ROUTER),
+    ("termproxy", &Router::new().post(&API_METHOD_TERMPROXY)),
     ("time", &time::ROUTER),
+    (
+        "vncwebsocket",
+        &Router::new().upgrade(&API_METHOD_WEBSOCKET),
+    ),
 ];
 
 pub const ROUTER: Router = Router::new()
diff --git a/src/config/acl.rs b/src/config/acl.rs
index e44b016..d5d9c0a 100644
--- a/src/config/acl.rs
+++ b/src/config/acl.rs
@@ -39,6 +39,8 @@ constnamemap! {
         PRIV_REMOTE_MODIFY("Remote.Modify")                 = 1 << 10;
         PRIV_REMOTE_READ("Remote.Read")                     = 1 << 11;
         PRIV_REMOTE_PRUNE("Remote.Prune")                   = 1 << 12;
+
+        PRIV_SYS_CONSOLE("Sys.Console")                     = 1 << 13;
     }
 }
 
-- 
2.20.1