Commit 3dff9e4f authored by jan.koester's avatar jan.koester
Browse files

test

parent 87158273
Loading
Loading
Loading
Loading
+222 −0
Changes for modules/containers/containers.cpp: 222 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -49,6 +49,14 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *   log {name, lines}     -> {lines[]}
 *   pull {image}          in the background -> {background, job}
 *   removeImage {image}
 *
 * A console: a shell in a running container (containerctl shell) on a
 * pseudo terminal, its bytes base64 in both directions. The browser polls;
 * a console belongs to the user who opened it and is closed after two
 * minutes without a poll.
 *   consoleOpen {name, cols, rows}         -> {id}
 *   consoleIO {id, input?, cols?, rows?}   -> {output, alive}
 *   consoleClose {id}
 */

#include <cstdlib>
@@ -57,8 +65,19 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#include <string>
#include <vector>

#include <map>

#include <dirent.h>
#include <fcntl.h>
#include <poll.h>
#include <pty.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/random.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#include <containerd/api.h>
#include <sysbus/client.h>
@@ -285,6 +304,206 @@ json_object *log(const std::string &name, long lines) {
    return out;
}

// ---- consoles ----

struct Console {
    int fd = -1;
    pid_t pid = -1;
    std::string user;
    time_t seen = 0;
    bool exited = false;
};

std::map<std::string, Console> &consoles() {
    static std::map<std::string, Console> c;
    return c;
}

const size_t MaxConsoles = 16;
const time_t ConsoleIdle = 120;

std::string base64(const std::string &in) {
    static const char t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    std::string out;
    size_t i = 0;
    for (; i + 2 < in.size(); i += 3) {
        unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8 | (unsigned char)in[i + 2];
        out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += t[v & 63];
    }
    if (i + 1 == in.size()) {
        unsigned v = (unsigned char)in[i] << 16;
        out += t[v >> 18]; out += t[(v >> 12) & 63]; out += "==";
    } else if (i + 2 == in.size()) {
        unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8;
        out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += '=';
    }
    return out;
}

bool unbase64(const std::string &in, std::string &out) {
    auto val = [](char c) -> int {
        if (c >= 'A' && c <= 'Z') return c - 'A';
        if (c >= 'a' && c <= 'z') return c - 'a' + 26;
        if (c >= '0' && c <= '9') return c - '0' + 52;
        if (c == '+') return 62;
        if (c == '/') return 63;
        return -1;
    };
    unsigned acc = 0;
    int bits = 0;
    for (char c : in) {
        if (c == '=') break;
        int v = val(c);
        if (v < 0) return false;
        acc = acc << 6 | (unsigned)v;
        bits += 6;
        if (bits >= 8) {
            bits -= 8;
            out += (char)((acc >> bits) & 0xff);
        }
    }
    return true;
}

void closeConsole(Console &c) {
    if (c.pid > 0 && !c.exited) {
        kill(-c.pid, SIGHUP);  // its session: the shell and what it started
        kill(c.pid, SIGHUP);
    }
    if (c.fd >= 0) close(c.fd);
    if (c.pid > 0) waitpid(c.pid, nullptr, c.exited ? WNOHANG : 0);
    c.fd = -1;
    c.pid = -1;
}

// consoles nobody polls any more
void reapConsoles() {
    time_t now = time(nullptr);
    for (auto it = consoles().begin(); it != consoles().end();) {
        if (now - it->second.seen > ConsoleIdle) {
            closeConsole(it->second);
            it = consoles().erase(it);
        } else {
            ++it;
        }
    }
}

void setSize(int fd, long cols, long rows) {
    if (cols < 10 || rows < 3 || cols > 1000 || rows > 500) return;
    struct winsize ws = {};
    ws.ws_col = (unsigned short)cols;
    ws.ws_row = (unsigned short)rows;
    ioctl(fd, TIOCSWINSZ, &ws);
}

json_object *consoleOpen(const webctl_request *req, const std::string &name) {
    reapConsoles();
    if (name.empty()) return error(tr("no container given", "kein Container angegeben"));
    if (consoles().size() >= MaxConsoles) return error(tr("too many open consoles", "zu viele offene Konsolen"), 503);
    sysbus::Message r;
    if (json_object *e = callContainerd(containerd::method::GetContainer, {{"name", name}}, r)) return e;
    if (r.get("state") != "running") return error(tr("%1 isn't running", "%1 läuft nicht", {name}), 409);

    unsigned char raw[16];
    if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return error("getrandom", 500);
    static const char hex[] = "0123456789abcdef";
    std::string id;
    for (unsigned char b : raw) { id += hex[b >> 4]; id += hex[b & 15]; }

    struct winsize ws = {};
    ws.ws_col = 80;
    ws.ws_row = 24;
    int fd = -1;
    pid_t pid = forkpty(&fd, nullptr, nullptr, &ws);
    if (pid < 0) return error(std::string("forkpty: ") + strerror(errno), 500);
    if (pid == 0) {
        // the backend's descriptors stay out of the container
        for (int i = 3; i < 1024; ++i) close(i);
        setenv("TERM", "xterm-256color", 1);
        setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1);
        execlp("containerctl", "containerctl", "shell", name.c_str(), (char *)nullptr);
        _exit(127);
    }
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
    fcntl(fd, F_SETFD, FD_CLOEXEC);
    setSize(fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24));
    Console &c = consoles()[id];
    c.fd = fd;
    c.pid = pid;
    c.user = req->user ? req->user : "";
    c.seen = time(nullptr);
    json_object *out = json_object_new_object();
    json_object_object_add(out, "id", jstr(id));
    return out;
}

Console *consoleOf(const webctl_request *req, const std::string &id, json_object **err) {
    auto it = consoles().find(id);
    if (it == consoles().end() || it->second.user != (req->user ? req->user : "")) {
        *err = error(tr("no such console", "keine solche Konsole"), 404);
        return nullptr;
    }
    it->second.seen = time(nullptr);
    return &it->second;
}

json_object *consoleIO(const webctl_request *req, const std::string &id) {
    json_object *err = nullptr;
    Console *c = consoleOf(req, id, &err);
    if (!c) return err;
    if (webctl::has(req->args, "cols"))
        setSize(c->fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24));
    std::string input;
    if (!unbase64(webctl::str(req->args, "input"), input)) return error(tr("invalid input", "ungültige Eingabe"));
    for (size_t off = 0; off < input.size();) {
        ssize_t n = write(c->fd, input.data() + off, input.size() - off);
        if (n > 0) { off += (size_t)n; continue; }
        if (n < 0 && errno == EINTR) continue;
        if (n < 0 && errno == EAGAIN) {
            pollfd p = {c->fd, POLLOUT, 0};
            if (poll(&p, 1, 200) > 0) continue;
        }
        break;
    }
    // what is there, waiting a little for a reply to what was typed
    std::string out;
    char buf[16384];
    if (!input.empty()) {
        pollfd p = {c->fd, POLLIN, 0};
        poll(&p, 1, 30);
    }
    for (;;) {
        ssize_t n = read(c->fd, buf, sizeof(buf));
        if (n > 0) {
            out.append(buf, (size_t)n);
            if (out.size() >= 256 * 1024) break;
            continue;
        }
        if (n < 0 && errno == EINTR) continue;
        break;  // EAGAIN, or EIO: the shell is gone
    }
    if (!c->exited && waitpid(c->pid, nullptr, WNOHANG) == c->pid) c->exited = true;
    bool alive = !c->exited || !out.empty();
    json_object *o = json_object_new_object();
    json_object_object_add(o, "output", jstr(base64(out)));
    json_object_object_add(o, "alive", json_object_new_boolean(alive));
    if (!alive) {
        closeConsole(*c);
        consoles().erase(id);
    }
    return o;
}

json_object *consoleClose(const webctl_request *req, const std::string &id) {
    json_object *err = nullptr;
    Console *c = consoleOf(req, id, &err);
    if (!c) return err;
    closeConsole(*c);
    consoles().erase(id);
    return webctl::ok();
}

json_object *call(const webctl_request *req) {
    webctl::LanguageScope lang(req->lang);
    std::string action = req->action;
@@ -315,6 +534,9 @@ json_object *call(const webctl_request *req) {
    if (action == "kill") return simple(m::KillContainer, {{"name", name}, {"signal", webctl::str(a, "signal", "KILL")}});
    if (action == "pull") return simple(m::PullImage, {{"image", webctl::str(a, "image")}});
    if (action == "removeImage") return simple(m::RemoveImage, {{"image", webctl::str(a, "image")}});
    if (action == "consoleOpen") return consoleOpen(req, name);
    if (action == "consoleIO") return consoleIO(req, webctl::str(a, "id"));
    if (action == "consoleClose") return consoleClose(req, webctl::str(a, "id"));
    return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404);
}

+16 −0
Changes for www/app.css: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -210,3 +210,19 @@ pre.log { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font
/* folded parts of a dialog (containers: advanced settings) */
dialog details { margin: .4em 0 .8em; }
dialog details > summary { cursor: pointer; color: var(--muted); margin-bottom: .5em; }

/* console (terminal.js) */
.terminal {
  --term-bg: #15171a;
  --term-fg: #d6d8db;
  background: var(--term-bg); color: var(--term-fg);
  font: 14px/1.25 ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
  padding: 6px; border-radius: 6px; overflow: hidden; position: relative;
  outline: none; cursor: text;
}
.terminal:focus { box-shadow: 0 0 0 2px var(--accent, #3b82f6); }
.terminal-screen > div { white-space: pre; height: 1.25em; }
.terminal-measure { position: absolute; visibility: hidden; white-space: pre; }
dialog.console { width: min(1100px, 96vw); max-width: 96vw; }
dialog.console .terminal { height: min(70vh, 640px); }
dialog.console .console-status { margin: .5em 0 0; color: var(--muted, #6b7280); font-size: .875rem; }
+1 −0
Changes for www/index.html: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@
<title>FenrirOS webctl</title>
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/app.css">
<script src="/terminal.js" defer></script>
<script src="/app.js" defer></script>
</head>
<body>
+81 −0
Changes for www/modules/containers.js: 81 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,9 @@
    'Containers': 'Container', 'Images': 'Images', 'New container': 'Neuer Container', 'Pull image': 'Image laden',
    'Name': 'Name', 'Image': 'Image', 'State': 'Status', 'Network': 'Netzwerk', 'Memory': 'Speicher',
    'Start': 'Starten', 'Stop': 'Stoppen', 'Restart': 'Neu starten', 'Log': 'Protokoll', 'Edit': 'Bearbeiten',
    'Console': 'Konsole', 'Console of {0}': 'Konsole von {0}', 'Close': 'Schließen',
    'A shell in {0} as root. Ctrl+Shift+V pastes.': 'Eine Shell in {0} als root. Strg+Umschalt+V fügt ein.',
    'The shell has ended.': 'Die Shell ist beendet.', 'The connection was lost: {0}': 'Die Verbindung ist abgebrochen: {0}',
    'running': 'läuft', 'stopped': 'gestoppt', 'starting': 'startet', 'stopping': 'stoppt', 'exited': 'beendet',
    'failed': 'fehlgeschlagen', 'exit code {0}': 'Exit-Code {0}', 'restarted {0}×': '{0}× neu gestartet',
    'host': 'Host', 'none': 'keins', 'bridge': 'Bridge',
@@ -170,6 +173,83 @@
    });
  }

  // A shell in a running container in the browser: terminal.js, the bytes
  // base64 over the module's consoleIO, polled (libhttppp has no WebSocket)
  function consoleDialog(name) {
    const toB64 = str => {
      let bin = '';
      for (const b of new TextEncoder().encode(str)) bin += String.fromCharCode(b);
      return btoa(bin);
    };
    const fromB64 = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0));

    const screen = h('div', {});
    const status = h('p', {class: 'console-status'}, t('A shell in {0} as root. Ctrl+Shift+V pastes.', name));
    const dlg = h('dialog', {class: 'console'},
      h('h2', {}, t('Console of {0}', name)), screen, status,
      h('div', {class: 'dialog-buttons'}, h('button', {type: 'button', onclick: () => finish()}, t('Close'))));
    document.body.append(dlg);
    dlg.showModal();

    let id = null, pending = '', size = null, closed = false, timer = null, idle = 0;
    const term = new WebctlTerminal(screen, {
      onData: d => { pending += d; poll(true); },
      onResize: (cols, rows) => { size = {cols, rows}; },
    });
    term.focus();

    const finish = async () => {
      if (closed) return;
      closed = true;
      clearTimeout(timer);
      term.dispose();
      dlg.close();
      dlg.remove();
      if (id) try { await call('containers', 'consoleClose', {id}); } catch (e) { /* already gone */ }
    };
    dlg.addEventListener('cancel', ev => { ev.preventDefault(); });  // Escape belongs to the shell

    let busy = false, again = false;
    async function poll(now) {
      if (closed || !id) return;  // before consoleOpen: input waits in pending
      if (busy) { again = true; return; }
      clearTimeout(timer);
      busy = true;
      try {
        const args = {id, input: toB64(pending)};
        pending = '';
        if (size) { Object.assign(args, size); size = null; }
        const r = await call('containers', 'consoleIO', args);
        if (r.output) { term.write(fromB64(r.output)); idle = 0; } else idle++;
        if (!r.alive) {
          status.textContent = t('The shell has ended.');
          id = null;
          return;
        }
      } catch (e) {
        status.textContent = t('The connection was lost: {0}', e.message);
        id = null;
        return;
      } finally {
        busy = false;
      }
      // quick while something happens, slower when idle
      const delay = again || now ? 0 : idle < 10 ? 60 : idle < 50 ? 200 : 600;
      again = false;
      timer = setTimeout(() => poll(false), delay);
    }

    (async () => {
      try {
        const r = await call('containers', 'consoleOpen', {name, cols: term.cols, rows: term.rows});
        id = r.id;
        poll(true);
      } catch (e) {
        status.textContent = e.message;
      }
    })();
  }

  async function containerDialog(name) {
    const images = data.images || [];
    if (!name && !images.length) {
@@ -337,6 +417,7 @@
              up ? h('button', {class: 'small', onclick: () => act('stop', c.name, t('{0} is stopping …', c.name))}, t('Stop'))
                 : h('button', {class: 'small primary', onclick: () => act('start', c.name, t('{0} is running.', c.name))}, t('Start')),
              up ? h('button', {class: 'small', onclick: () => act('restart', c.name)}, t('Restart')) : null,
              up ? h('button', {class: 'small', onclick: () => consoleDialog(c.name)}, t('Console')) : null,
              h('button', {class: 'small', onclick: () => logDialog(c.name)}, t('Log')),
              h('button', {class: 'small', onclick: () => containerDialog(c.name)}, t('Edit')),
              up ? null : h('button', {class: 'small danger', onclick: () => removeContainer(c)}, t('Delete')),

www/terminal.js

0 → 100644
+399 −0

File added.

Preview size limit exceeded, changes collapsed.