]> git.proxmox.com Git - pve-xtermjs.git/commitdiff
initial commit
authorDominik Csapak <d.csapak@proxmox.com>
Wed, 22 Nov 2017 15:45:34 +0000 (16:45 +0100)
committerDominik Csapak <d.csapak@proxmox.com>
Tue, 28 Nov 2017 09:28:46 +0000 (10:28 +0100)
working version of xtermjs client and termproxy as
authentication proxy between a command/socket and a tcp socket

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
22 files changed:
.gitmodules [new file with mode: 0644]
Makefile [new file with mode: 0644]
README [new file with mode: 0644]
debian/changelog [new file with mode: 0644]
debian/compat [new file with mode: 0644]
debian/control [new file with mode: 0644]
debian/copyright [new file with mode: 0644]
debian/docs [new file with mode: 0644]
debian/rules [new file with mode: 0755]
defines.mk [new file with mode: 0644]
src/Makefile [new file with mode: 0644]
src/PVE/CLI/Makefile [new file with mode: 0644]
src/PVE/CLI/termproxy.pm [new file with mode: 0644]
src/PVE/Makefile [new file with mode: 0644]
src/bin/Makefile [new file with mode: 0644]
src/bin/termproxy [new file with mode: 0755]
src/www/Makefile [new file with mode: 0644]
src/www/index.html.tpl.in [new file with mode: 0644]
src/www/main.js [new file with mode: 0644]
src/www/style.css [new file with mode: 0644]
src/www/util.js [new file with mode: 0644]
xtermjs [new submodule]

diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..a22f66b
--- /dev/null
@@ -0,0 +1,3 @@
+[submodule "xtermjs"]
+       path = xtermjs
+       url = ../mirror_xterm.js
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..f16e464
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,47 @@
+include defines.mk
+
+XTERMJSDIR=xtermjs
+SRCDIR=src
+
+ARCH:=$(shell dpkg-architecture -qDEB_BUILD_ARCH)
+GITVERSION:=$(shell cat .git/refs/heads/master)
+
+DEB=${PACKAGE}_${VERSION}_${ARCH}.deb
+
+all: ${DEB}
+       @echo ${DEB}
+
+.PHONY: deb
+deb: ${DEB}
+${DEB}: | submodule
+       rm -rf ${SRCDIR}.tmp
+       cp -rpa ${SRCDIR} ${SRCDIR}.tmp
+       cp -a debian ${SRCDIR}.tmp/
+       cp -ar ${XTERMJSDIR}/dist/* ${SRCDIR}.tmp/www
+       echo "git clone git://git.proxmox.com/git/pve-xtermjs.git\\ngit checkout ${GITVERSION}" > ${SRCDIR}.tmp/debian/SOURCE
+       cd ${SRCDIR}.tmp; dpkg-buildpackage -rfakeroot -b -uc -us
+       lintian ${DEB}
+       @echo ${DEB}
+
+.PHONY: submodule
+submodule:
+       test -f "${XTERMJSDIR}/README.md" || git submodule update --init
+
+.PHONY: download
+download ${SRCDIR}:
+       git submodule foreach 'git pull --ff-only origin master'
+
+.PHONY: upload
+upload: ${DEB}
+       tar cf - ${DEB}|ssh -X repoman@repo.proxmox.com -- upload --product pmg,pve --dist stretch
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+       rm -rf *~ debian/*~ *_${ARCH}.deb ${SRCDIR}.tmp *_all.deb *.changes *.dsc *.buildinfo
+
+.PHONY: dinstall
+dinstall: deb
+       dpkg -i ${DEB}
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..61063ca
--- /dev/null
+++ b/README
@@ -0,0 +1,32 @@
+xterm.js webclient and helper utility
+=====================================
+
+This repository contains the client and helper utility to use
+xterm.js (https://xtermjs.org) for Proxmox VE.
+
+To be able to relay between the gui and a shell program/console,
+we need a tool (called termproxy) to open a port (where our websocketproxy
+connects to) and to open a pty and execute a program
+
+From Client to Server it implements a simple packet-based protocol:
+(everything is a string)
+
+* Normal Message
+    0:LENGTH:MSG
+    where LENGTH is the bytelength of the msg
+
+* Resize Message
+    1:COLS:ROWS:
+    where COLS is the number of columns the client wants to resize to,
+    and ROWS the number of rows, respectively
+
+* Ping Message
+    2
+    used to keep the connection between client and server alive
+    (we have a timeout of 5 minutes)
+
+every other input from the client will be ignored
+
+From server to client, the data will simply sent, without any
+format
+
diff --git a/debian/changelog b/debian/changelog
new file mode 100644 (file)
index 0000000..536e568
--- /dev/null
@@ -0,0 +1,5 @@
+pve-xtermjs (0.1-1~rc1) unstable; urgency=medium
+
+  * initial package
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 20 Nov 2017 08:25:32 +0100
diff --git a/debian/compat b/debian/compat
new file mode 100644 (file)
index 0000000..ec63514
--- /dev/null
@@ -0,0 +1 @@
+9
diff --git a/debian/control b/debian/control
new file mode 100644 (file)
index 0000000..8c1d7d1
--- /dev/null
@@ -0,0 +1,15 @@
+Source: pve-xtermjs
+Section: web
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Build-Depends: debhelper (>= 7.0.0~)
+Standards-Version: 3.8.3
+
+Package: pve-xtermjs
+Architecture: any
+Depends: dtach,
+         libpve-access-control (>= 5.0-7),
+         libpve-common-perl (>= 5.0-22),
+         ${misc:Depends}
+Description: HTML/JS Shell client
+ This is an xterm.js client for PVE Host, Container and Qemu Serial Terminal
diff --git a/debian/copyright b/debian/copyright
new file mode 100644 (file)
index 0000000..79de700
--- /dev/null
@@ -0,0 +1,41 @@
+Copyright (C) 2017 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+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 Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+-------------
+
+xterm.js
+
+Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
+Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/debian/docs b/debian/docs
new file mode 100644 (file)
index 0000000..8696672
--- /dev/null
@@ -0,0 +1 @@
+debian/SOURCE
diff --git a/debian/rules b/debian/rules
new file mode 100755 (executable)
index 0000000..2d33f6a
--- /dev/null
@@ -0,0 +1,4 @@
+#!/usr/bin/make -f
+
+%:
+       dh $@
diff --git a/defines.mk b/defines.mk
new file mode 100644 (file)
index 0000000..1dcef10
--- /dev/null
@@ -0,0 +1,8 @@
+PACKAGE=pve-xtermjs
+VER=0.1
+PKGREL=1~rc1
+VERSION=${VER}-${PKGREL}
+
+BINDIR=${DESTDIR}/usr/bin
+PERLLIBDIR=${DESTDIR}/usr/share/perl5
+WWWBASEDIR=${DESTDIR}/usr/share/${PACKAGE}
diff --git a/src/Makefile b/src/Makefile
new file mode 100644 (file)
index 0000000..f1f27ce
--- /dev/null
@@ -0,0 +1,9 @@
+include ../defines.mk
+
+all:
+
+.PHONY: install
+install:
+       make -C bin install
+       make -C PVE install
+       make -C www install
diff --git a/src/PVE/CLI/Makefile b/src/PVE/CLI/Makefile
new file mode 100644 (file)
index 0000000..79b0db1
--- /dev/null
@@ -0,0 +1,8 @@
+include ../../../defines.mk
+
+all:
+
+.PHONY: install
+install:
+       install -d ${PERLLIBDIR}/PVE/CLI
+       install -m 0644 termproxy.pm ${PERLLIBDIR}/PVE/CLI/
diff --git a/src/PVE/CLI/termproxy.pm b/src/PVE/CLI/termproxy.pm
new file mode 100644 (file)
index 0000000..4832b12
--- /dev/null
@@ -0,0 +1,220 @@
+package PVE::CLI::termproxy;
+
+use strict;
+use warnings;
+
+use PVE::RPCEnvironment;
+use PVE::CLIHandler;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::AccessControl;
+use PVE::PTY;
+use IO::Select;
+use IO::Socket::IP;
+
+use base qw(PVE::CLIHandler);
+
+use constant MAX_QUEUE_LEN => 16*1024;
+
+sub setup_environment {
+    PVE::RPCEnvironment->setup_default_cli_env();
+}
+
+sub listen_and_authenticate {
+    my ($port, $timeout) = @_;
+
+    my $params = {
+       Listen => 1,
+       ReuseAddr => 1,
+       Proto => &Socket::IPPROTO_TCP,
+       GetAddrInfoFlags => 0,
+       LocalAddr => 'localhost',
+       LocalPort => $port,
+    };
+
+    my $socket = IO::Socket::IP->new(%$params) or die "failed to open socket: $!\n";
+
+    alarm 0;
+    local $SIG{ALRM} = sub { die "timed out waiting for client\n" };
+    alarm $timeout;
+    my $client = $socket->accept; # Wait for a client
+    alarm 0;
+    close($socket);
+
+    my $queue;
+    my $n = sysread($client, $queue, 4096);
+    if ($n && $queue =~ s/^([^:]+):([^:]+):(.+)\n//) {
+       my $user = $1;
+       my $path = $2;
+       my $ticket = $3;
+
+       die "authentication failed\n"
+           if !PVE::AccessControl::verify_vnc_ticket($ticket, $user, $path);
+
+       die "aknowledge failed\n"
+           if !syswrite($client, "OK");
+
+    } else {
+       die "malformed authentication string\n";
+    }
+
+    return ($queue, $client);
+}
+
+sub run_pty {
+    my ($cmd, $webhandle, $queue) = @_;
+
+    foreach my $k (keys %ENV) {
+       next if $k eq 'PATH' || $k eq 'USER' || $k eq 'HOME' || $k eq 'LANG' || $k eq 'LANGUAGE';
+       next if $k =~ m/^LC_/;
+       delete $ENV{$k};
+    }
+
+    $ENV{TERM} = 'xterm-256color';
+
+    my $pty = PVE::PTY->new();
+
+    my $pid = fork();
+    die "fork: $!\n" if !defined($pid);
+    if (!$pid) {
+       $pty->make_controlling_terminal();
+       exec {$cmd->[0]} @$cmd
+           or POSIX::_exit(1);
+    }
+
+    $pty->set_size(80,20);
+
+    read_write_loop($webhandle, $pty->master, $queue, $pty);
+
+    $pty->close();
+    waitpid($pid,0);
+    exit(0);
+}
+
+sub read_write_loop {
+    my ($webhandle, $cmdhandle, $queue, $pty) = @_;
+
+    my $select = new IO::Select;
+
+    $select->add($webhandle);
+    $select->add($cmdhandle);
+
+    my @handles;
+
+    # we may have already messages from the first read
+    $queue = process_queue($queue, $cmdhandle);
+
+    my $timeout = 5*60;
+
+    while($select->count && scalar(@handles = $select->can_read($timeout))) {
+       foreach my $h (@handles) {
+           my $buf;
+           my $n = $h->sysread($buf, 4096);
+
+           if ($h == $webhandle) {
+               if ($n && (length($queue) + $n) < MAX_QUEUE_LEN) {
+                   $queue = process_queue($queue.$buf, $cmdhandle, $pty);
+               } else {
+                   return;
+               }
+           } elsif ($h == $cmdhandle) {
+               if ($n) {
+                   syswrite($webhandle, $buf);
+               } else {
+                   return;
+               }
+           }
+       }
+    }
+}
+
+sub process_queue {
+    my ($queue, $handle, $pty) = @_;
+
+    my $msg;
+    while(length($queue)) {
+       ($queue, $msg) = remove_message($queue, $pty);
+       last if !defined($msg);
+       syswrite($handle, $msg);
+    }
+    return $queue;
+}
+
+
+# we try to remove a whole message
+# if we succeed, we return the remaining queue and the msg
+# if we fail, the message is undef and the queue is not changed
+sub remove_message {
+    my ($queue, $pty) = @_;
+
+    my $msg;
+    my $type = substr $queue, 0, 1;
+
+    if ($type eq '0') {
+       # normal message
+       my ($length) = $queue =~ m/^0:(\d+):/;
+       my $begin = 3 + length($length);
+       if (defined($length) && length($queue) >= ($length + $begin)) {
+           $msg = substr $queue, $begin, $length;
+           if (defined($msg)) {
+               # msg contains now $length chars after 0:$length:
+               $queue = substr $queue, $begin + $length;
+           }
+       }
+    } elsif ($type eq '1') {
+       # resize message
+       my ($cols, $rows) = $queue =~ m/^1:(\d+):(\d+):/;
+       if (defined($cols) && defined($rows)) {
+           $queue = substr $queue, (length($cols) + length ($rows) + 4);
+           eval { $pty->set_size($cols, $rows) if defined($pty) };
+           warn $@ if $@;
+           $msg = "";
+       }
+    } elsif ($type eq '2') {
+       # ping
+       $queue = substr $queue, 1;
+       $msg = "";
+    } else {
+       # ignore other input
+       $queue = substr $queue, 1;
+       $msg = "";
+    }
+
+    return ($queue, $msg);
+}
+
+__PACKAGE__->register_method ({
+    name => 'exec',
+    path => 'exec',
+    method => 'POST',
+    description => "Connects a TCP Socket with a commandline",
+    parameters => {
+       additionalProperties => 0,
+       properties => {
+           port => {
+               type => 'integer',
+               description => "The port to listen on."
+           },
+           'extra-args' => get_standard_option('extra-args'),
+       },
+    },
+    returns => { type => 'null'},
+    code => sub {
+       my ($param) = @_;
+
+       my $cmd;
+       if (defined($param->{'extra-args'})) {
+           $cmd = [@{$param->{'extra-args'}}];
+       } else {
+           die "No command given\n";
+       }
+
+       my ($queue, $handle) = listen_and_authenticate($param->{port}, 10);
+
+       run_pty($cmd, $handle, $queue);
+
+       return undef;
+    }});
+
+our $cmddef = [ __PACKAGE__, 'exec', ['port', 'extra-args' ]];
+
+1;
diff --git a/src/PVE/Makefile b/src/PVE/Makefile
new file mode 100644 (file)
index 0000000..b0321d7
--- /dev/null
@@ -0,0 +1,3 @@
+.PHONY: install
+install:
+       make -C CLI install
diff --git a/src/bin/Makefile b/src/bin/Makefile
new file mode 100644 (file)
index 0000000..6a6ed1c
--- /dev/null
@@ -0,0 +1,7 @@
+include         ../../defines.mk
+
+.PHONY: install
+install: termproxy
+       perl -I.. -T -e "use PVE::CLI::termproxy; PVE::CLI::termproxy->verify_api();"
+       install -d ${BINDIR}
+       install -m 0755 termproxy ${BINDIR}
diff --git a/src/bin/termproxy b/src/bin/termproxy
new file mode 100755 (executable)
index 0000000..6af56c6
--- /dev/null
@@ -0,0 +1,10 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use PVE::AccessControl;
+
+use PVE::CLI::termproxy;
+
+PVE::CLI::termproxy->run_cli_handler();
diff --git a/src/www/Makefile b/src/www/Makefile
new file mode 100644 (file)
index 0000000..99914fa
--- /dev/null
@@ -0,0 +1,20 @@
+include ../../defines.mk
+
+SOURCE = \
+       addons/fit/fit.js \
+       index.html.tpl \
+       main.js \
+       style.css \
+       util.js \
+       xterm.css \
+       xterm.js \
+       xterm.js.map
+
+index.html.tpl: index.html.tpl.in
+       sed -e 's/@VERSION@/${VERSION}/' $< >$@.tmp
+       mv $@.tmp $@
+
+.PHONY: install
+install: ${SOURCE}
+       install -d ${WWWBASEDIR}
+       set -e && for i in ${SOURCE}; do install -m 0644 $$i ${WWWBASEDIR}; done
diff --git a/src/www/index.html.tpl.in b/src/www/index.html.tpl.in
new file mode 100644 (file)
index 0000000..8cc74ed
--- /dev/null
@@ -0,0 +1,23 @@
+<!doctype html>
+<html>
+    <head>
+       <title>[% nodename %] - Proxmox Console</title>
+       <link rel="stylesheet" href="/xtermjs/xterm.css?version=@VERSION@" />
+       <link rel="stylesheet" href="/xtermjs/style.css?version=@VERSION@" />
+       <script src="/xtermjs/xterm.js?version=@VERSION@" ></script>
+       <script src="/xtermjs/fit.js?version=@VERSION@" ></script>
+       <script src="/xtermjs/util.js?version=@VERSION@" ></script>
+    </head>
+    <body>
+       <div id="status_bar"></div>
+       <div id="wrap">
+       <div id="terminal-container"></div>
+       </div>
+       <script type="text/javascript">
+           if (typeof(PVE) === 'undefined') PVE = {};
+           PVE.UserName = '[% username %]';
+           PVE.CSRFPreventionToken = '[% token %]';
+       </script>
+       <script src="/xtermjs/main.js?version=@VERSION@" defer ></script>
+    </body>
+</html>
diff --git a/src/www/main.js b/src/www/main.js
new file mode 100644 (file)
index 0000000..a489937
--- /dev/null
@@ -0,0 +1,184 @@
+console.log('xtermjs: starting');
+
+var states = {
+    start:         1,
+    connecting:    2,
+    connected:     3,
+    disconnecting: 4,
+    disconnected:  5,
+};
+
+var term,
+    protocol,
+    socketURL,
+    socket,
+    ticket,
+    path,
+    resize,
+    ping,
+    state = states.start;
+
+function updateState(newState, msg) {
+    var timeout, severity, message;
+    switch (newState) {
+       case states.connecting:
+           message = "Connecting...";
+           timeout = 0;
+           severity = severities.warning;
+           break;
+       case states.connected:
+           message = "Connected";
+           break;
+       case states.disconnecting:
+           message = "Disconnecting...";
+           timeout = 0;
+           severity = severities.warning;
+           break;
+       case states.disconnected:
+           switch (state) {
+               case states.start:
+               case states.connecting:
+                   message = "Connection failed";
+                   timeout = 0;
+                   severity = severities.error;
+                   break;
+               case states.connected:
+               case states.disconnecting:
+                   message = "Connection closed";
+                   timeout = 0;
+                   break;
+               case states.disconnected:
+                   // no state change
+                   break;
+               default:
+                   throw "unknown state";
+           }
+           break;
+       default:
+           throw "unknown state";
+    }
+    if (msg) {
+       message += " (" + msg + ")";
+    }
+    state = newState;
+    showMsg(message, timeout, severity);
+}
+
+var terminalContainer = document.getElementById('terminal-container');
+document.getElementById('status_bar').addEventListener('click', hideMsg);
+
+createTerminal();
+
+function createTerminal() {
+    term = new Terminal();
+
+    term.on('resize', function (size) {
+       if (state === states.connected) {
+           socket.send("1:" + size.cols + ":" + size.rows + ":");
+       }
+    });
+
+    protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
+
+    var params = {};
+    var type = getQueryParameter('console');
+    var vmid = getQueryParameter('vmid');
+    var vmname = getQueryParameter('vmname');
+    var nodename = getQueryParameter('node');
+    var url = '/nodes/' + nodename;
+    switch (type) {
+       case 'kvm':
+           url += '/qemu/' + vmid;
+           path = '/vms/' + vmid;
+           break;
+       case 'lxc':
+           url += '/lxc/' + vmid;
+           path = '/vms/' + vmid;
+           break;
+       case 'shell': 
+           path = '/nodes/' + nodename;
+           break;
+       case 'upgrade':
+           params.upgrade = 1;
+           path = '/nodes/' + nodename;
+           break;
+    }
+    API2Request({
+       method: 'POST',
+       params: params,
+       url: url + '/termproxy',
+       success: function(result) {
+           var port = encodeURIComponent(result.data.port);
+           ticket = result.data.ticket;
+           socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/api2/json' + url + '/vncwebsocket?port=' + port + '&vncticket=' + encodeURIComponent(ticket);
+
+           term.open(terminalContainer, true);
+           socket = new WebSocket(socketURL, 'binary');
+           socket.binaryType = 'arraybuffer';
+           socket.onopen = runTerminal;
+           socket.onclose = stopTerminal;
+           socket.onerror = errorTerminal;
+           window.onbeforeunload = stopTerminal;
+           updateState(states.connecting);
+       },
+       failure: function(msg) {
+           updateState(states.disconnected,msg);
+       }
+    });
+
+}
+
+function runTerminal() {
+    socket.onmessage = function(event) {
+       var answer = Utf8ArrayToStr(event.data);
+       if (state === states.connected) {
+           term.write(answer);
+       } else if(state === states.connecting) {
+           if (answer.slice(0,2) === "OK") {
+               updateState(states.connected);
+               term.write(answer.slice(2));
+           } else {
+               socket.close();
+           }
+       }
+    };
+
+    term.on('data', function(data) {
+       if (state === states.connected) {
+           socket.send("0:" + unescape(encodeURIComponent(data)).length.toString() + ":" +  data);
+       }
+    });
+
+    ping = setInterval(function() {
+       socket.send("2");
+    }, 30*1000);
+
+    window.addEventListener('resize', function() {
+       clearTimeout(resize);
+       resize = setTimeout(function() {
+           // done resizing
+           term.fit();
+       }, 250);
+    });
+
+    socket.send(PVE.UserName + ':' + path + ':' + ticket + "\n");
+
+    setTimeout(function() {term.fit();}, 250);
+}
+
+function stopTerminal(event) {
+    term.off('resize');
+    term.off('data');
+    clearInterval(ping);
+    socket.close();
+    updateState(states.disconnected, event.msg + event.code);
+}
+
+function errorTerminal(event) {
+    term.off('resize');
+    term.off('data');
+    clearInterval(ping);
+    socket.close();
+    term.destroy();
+    updateState(states.disconnected, event.msg + event.code);
+}
diff --git a/src/www/style.css b/src/www/style.css
new file mode 100644 (file)
index 0000000..20959c0
--- /dev/null
@@ -0,0 +1,85 @@
+html,body {
+    height: 100%;
+    min-height: 100%;
+    margin: 0;
+    padding: 0;
+    overflow: hidden;
+    font-family: Consolas,"DejaVu Sans Mono","Liberation Mono",Courier,monospace;
+    background-color: #101010;
+}
+
+.terminal {
+    background-color: #101010;
+    color: #f0f0f0;
+    font-size: 10pt;
+    font-family: Consolas,"DejaVu Sans Mono","Liberation Mono",Courier,monospace;
+    font-variant-ligatures: none;
+    -moz-osx-font-smoothing: grayscale;
+    -webkit-font-smoothing: antialiased;
+}
+
+.terminal .xterm-viewport {
+    background-color: rgba(121, 121, 121, 0);
+    -webkit-background-clip: text;
+    -webkit-text-fill-color: transparent;
+    transition: background-color 800ms linear;
+}
+
+/* fix line height on firefox */
+.xterm-rows > div > span {
+    display: inline-block;
+}
+
+#terminal-container {
+    height: 100%;
+    width: auto;
+}
+
+#wrap {
+    height: 100%;
+    width: auto;
+    box-sizing: border-box;
+    padding: 5px;
+}
+
+#status_bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  z-index: 500;
+  transform: translateY(-100%);
+
+  transition: 0.25s ease-in-out;
+
+  visibility: hidden;
+  opacity: 0;
+
+  padding: 5px;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-content: center;
+
+  line-height: 25px;
+  color: #fff;
+
+  border-bottom: 1px solid rgba(0, 0, 0, 0.9);
+}
+
+#status_bar.open {
+  transform: translateY(0);
+  visibility: visible;
+  opacity: 1;
+}
+
+#status_bar.normal {
+  background: rgba(128,128,128,0.9);
+}
+#status_bar.error {
+  background: rgba(200,55,55,0.9);
+}
+#status_bar.warning {
+  background: rgba(180,180,30,0.9);
+}
diff --git a/src/www/util.js b/src/www/util.js
new file mode 100644 (file)
index 0000000..b2c40e3
--- /dev/null
@@ -0,0 +1,181 @@
+function urlEncode(object) {
+    var i,value, params = [];
+
+    for (i in object) {
+       if (object.hasOwnProperty(i)) {
+           value = object[i];
+           if (value === undefined) value = '';
+           params.push(encodeURIComponent(i) + '=' + encodeURIComponent(String(value)));
+       }
+    }
+
+    return params.join('&');
+}
+
+var msgtimeout;
+var severities = {
+    normal:  1,
+    warning: 2,
+    error:   3,
+};
+
+function showMsg(message, timeout, severity) {
+    var status_bar = document.getElementById('status_bar');
+    clearTimeout(msgtimeout);
+
+    status_bar.classList.remove('normal');
+    status_bar.classList.remove('warning');
+    status_bar.classList.remove('error');
+
+    status_bar.textContent = message;
+
+    severity = severity || severities.normal;
+
+    switch (severity) {
+       case severities.normal: 
+           status_bar.classList.add('normal');
+           break;
+       case severities.warning: 
+           status_bar.classList.add('warning');
+           break;
+       case severities.error: 
+           status_bar.classList.add('error');
+           break;
+       default:
+           throw "unknown severity";
+    }
+
+    status_bar.classList.add('open');
+
+    if (timeout !== 0) {
+       msgtimeout = setTimeout(hideMsg, timeout || 1500);
+    }
+}
+
+function hideMsg() {
+    clearTimeout(msgtimeout);
+    status_bar.classList.remove('open');
+}
+
+function getQueryParameter(name) {
+    var params = location.search.slice(1).split('&');
+    var result = "";
+    params.forEach(function(param) {
+       var components = param.split('=');
+       if (components[0] === name) {
+           result = components.slice(1).join('=');
+       }
+    });
+    return result;
+}
+
+var cur = 0;
+var left = 0;
+
+function Utf8ArrayToStr(arraybuffer) {
+    var array = new Uint8Array(arraybuffer),
+       i = 0,
+       len = array.byteLength,
+       out = "",
+       c;
+
+    while (i < len) {
+       c = array[i++];
+       if (!left && c < 0x80) {
+               out += String.fromCharCode(c);
+       } else if(!left) {
+           switch (c >> 4) {
+               case 12: case 13:
+                   // 110x xxxx 10xx xxxx
+                   cur = (c & 0x1F) << 6;
+                   left = 1;
+                   break;
+               case 14:
+                   // 1110 xxxx 10xx xxxx 10xx xxxx
+                   cur = (c & 0x0F) << 12;
+                   left = 2;
+                   break;
+               case 15:
+                   // 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx
+                   cur = (c & 0x07) << 18;
+                   left = 3;
+                   break;
+               default:
+                   cur = 0;
+                   out += '\ufffd';
+           }
+       } else if (c >= 0x80 && c <= 0xBF) {
+           cur = cur | ((c & 0x3F) << (--left * 6));
+           if (!left) {
+               out += String.fromCharCode(cur);
+               cur = 0;
+           }
+       } else {
+           cur = 0;
+           left = 0;
+           out += '\ufffd';
+       }
+    }
+
+    return out;
+}
+
+function API2Request(reqOpts) {
+    var me = this;
+
+    reqOpts.method = reqOpts.method || 'GET';
+
+    var xhr = new XMLHttpRequest();
+
+    xhr.onload = function() {
+       var scope = reqOpts.scope || this;
+       var result;
+       var errmsg;
+
+       if (xhr.readyState === 4) {
+           var ctype = xhr.getResponseHeader('Content-Type');
+           if (xhr.status === 200) {
+               if (ctype.match(/application\/json;/)) {
+                   result = JSON.parse(xhr.responseText);
+               } else {
+                   errmsg = 'got unexpected content type ' + ctype;
+               }
+           } else {
+               errmsg = 'Error ' + xhr.status + ': ' + xhr.statusText;
+           }
+       } else {
+           errmsg = 'Connection error - server offline?';
+       }
+
+       if (errmsg !== undefined) {
+           if (reqOpts.failure) {
+               reqOpts.failure.call(scope, errmsg);
+           }
+       } else {
+           if (reqOpts.success) {
+               reqOpts.success.call(scope, result);
+           }
+       }
+       if (reqOpts.callback) {
+           reqOpts.callback.call(scope, errmsg === undefined);
+       }
+    }
+
+    var data = urlEncode(reqOpts.params || {});
+
+    if (reqOpts.method === 'GET') {
+       xhr.open(reqOpts.method, "/api2/json" + reqOpts.url + '?' + data);
+    } else {
+       xhr.open(reqOpts.method, "/api2/json" + reqOpts.url);
+    }
+    xhr.setRequestHeader('Cache-Control', 'no-cache');
+    if (reqOpts.method === 'POST' || reqOpts.method === 'PUT') {
+       xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+       xhr.setRequestHeader('CSRFPreventionToken', PVE.CSRFPreventionToken);
+       xhr.send(data);
+    } else if (reqOpts.method === 'GET') {
+       xhr.send();
+    } else {
+       throw "unknown method";
+    }
+}
diff --git a/xtermjs b/xtermjs
new file mode 160000 (submodule)
index 0000000..ea07bf8
--- /dev/null
+++ b/xtermjs
@@ -0,0 +1 @@
+Subproject commit ea07bf8f694a6e9714779b19c174e26162c39196