feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,165 @@
|
||||
#!/bin/sh
|
||||
# LPRA agent installer.
|
||||
# Usage (non-interactive):
|
||||
# curl -fsSL {{.Host}}/install.sh | sudo HOST={{.Host}} ENROLL_SECRET=xxxx sh
|
||||
# Usage (interactive, will ask for the secret):
|
||||
# curl -fsSL {{.Host}}/install.sh | sudo sh
|
||||
set -eu
|
||||
|
||||
HOST="${HOST:-{{.Host}}}"
|
||||
{{if .Secret}}ENROLL_SECRET="${ENROLL_SECRET:-{{.Secret}}}"
|
||||
{{end}}
|
||||
BIN_PATH="/usr/local/bin/lpagent"
|
||||
CONF_DIR="/etc/lpagent"
|
||||
CONF_FILE="$CONF_DIR/config.env"
|
||||
STATE_FILE="$CONF_DIR/state.json"
|
||||
SERVICE="/etc/systemd/system/lpagent.service"
|
||||
|
||||
red() { printf '\033[31m%s\033[0m\n' "$1"; }
|
||||
green() { printf '\033[32m%s\033[0m\n' "$1"; }
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||||
|
||||
# Read a line from the real terminal even when the script is piped into `sh`.
|
||||
ask() { # ask VARNAME "prompt"
|
||||
_v=""
|
||||
if [ -r /dev/tty ]; then
|
||||
printf '%s' "$2" > /dev/tty
|
||||
read -r _v < /dev/tty || true
|
||||
fi
|
||||
eval "$1=\$_v"
|
||||
}
|
||||
confirm() { # confirm "prompt" -> returns 0 on yes
|
||||
if [ ! -r /dev/tty ]; then return 1; fi
|
||||
printf '%s [y/N]: ' "$1" > /dev/tty
|
||||
read -r _a < /dev/tty || _a=""
|
||||
case "$_a" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# --- root check -------------------------------------------------------------
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
red "Нужны права root."
|
||||
echo "Запустите через sudo, например:"
|
||||
echo " curl -fsSL $HOST/install.sh | sudo sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- systemd check ----------------------------------------------------------
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
red "systemctl не найден: поддерживаются только системы с systemd."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uninstall() {
|
||||
bold "Удаление агента LPRA…"
|
||||
systemctl stop lpagent 2>/dev/null || true
|
||||
systemctl disable lpagent 2>/dev/null || true
|
||||
rm -f "$SERVICE"
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
rm -f "$BIN_PATH"
|
||||
rm -rf "$CONF_DIR"
|
||||
green "Агент удалён."
|
||||
}
|
||||
|
||||
# --- existing installation --------------------------------------------------
|
||||
if [ -f "$SERVICE" ] || [ -f "$BIN_PATH" ]; then
|
||||
bold "Агент LPRA уже установлен на этой машине."
|
||||
if confirm "Удалить его сейчас?"; then
|
||||
uninstall
|
||||
if confirm "Установить заново?"; then
|
||||
: # fall through to install
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
if confirm "Переустановить / обновить?"; then
|
||||
: # fall through, keep existing state.json (не будет повторной регистрации)
|
||||
else
|
||||
echo "Ничего не делаю."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- architecture -----------------------------------------------------------
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=amd64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
*) red "Неподдерживаемая архитектура: $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# --- machine name -----------------------------------------------------------
|
||||
# Default: <hostname>-<4 random chars>. Only used at the first enrollment.
|
||||
if [ -z "${AGENT_NAME:-}" ]; then
|
||||
SUFFIX="$(head -c16 /dev/urandom 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n' | cut -c1-4)"
|
||||
[ -z "$SUFFIX" ] && SUFFIX="$(date +%s | tail -c 5)"
|
||||
AGENT_NAME="$(hostname 2>/dev/null || echo host)-$SUFFIX"
|
||||
fi
|
||||
|
||||
# --- enroll secret ----------------------------------------------------------
|
||||
# Needed only for the very first enrollment; skipped if state.json already exists.
|
||||
if [ -z "${ENROLL_SECRET:-}" ] && [ ! -f "$STATE_FILE" ]; then
|
||||
ask ENROLL_SECRET "Введите enroll-секрет (из панели, раздел Настройки): "
|
||||
if [ -z "${ENROLL_SECRET:-}" ]; then
|
||||
red "Enroll-секрет обязателен для первой установки."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- downloader -------------------------------------------------------------
|
||||
download() { # download URL DEST
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fSL -o "$2" "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
else
|
||||
red "Нужен curl или wget."; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
bold "Скачивание агента ($ARCH) с $HOST…"
|
||||
TMP="$(mktemp)"
|
||||
download "$HOST/download/agent-linux-$ARCH" "$TMP"
|
||||
install -m 0755 "$TMP" "$BIN_PATH"
|
||||
rm -f "$TMP"
|
||||
green "Бинарник установлен: $BIN_PATH"
|
||||
|
||||
# --- config -----------------------------------------------------------------
|
||||
mkdir -p "$CONF_DIR"
|
||||
chmod 700 "$CONF_DIR"
|
||||
umask 077
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
HOST=$HOST
|
||||
ENROLL_SECRET=${ENROLL_SECRET:-}
|
||||
AGENT_NAME=$AGENT_NAME
|
||||
STATE_PATH=$STATE_FILE
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
|
||||
# --- systemd unit -----------------------------------------------------------
|
||||
cat > "$SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=LPRA remote access agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=$CONF_FILE
|
||||
ExecStart=$BIN_PATH
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable lpagent >/dev/null 2>&1 || true
|
||||
systemctl restart lpagent
|
||||
|
||||
sleep 1
|
||||
bold "Статус сервиса:"
|
||||
systemctl --no-pager --full status lpagent || true
|
||||
echo
|
||||
green "Готово. Машина должна появиться в панели: $HOST"
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--panel: #171c23;
|
||||
--border: #262d38;
|
||||
--text: #e6e9ef;
|
||||
--muted: #8b95a5;
|
||||
--accent: #4f9dff;
|
||||
--accent-hover: #3b86e6;
|
||||
--danger: #ff5c5c;
|
||||
--ok: #37c871;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
header.topbar .brand { font-weight: 600; }
|
||||
header.topbar nav a { margin-left: 18px; color: var(--muted); }
|
||||
header.topbar nav a:hover { color: var(--text); }
|
||||
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
|
||||
.center { max-width: 420px; margin: 8vh auto; padding: 0 16px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 24px; margin-bottom: 20px;
|
||||
}
|
||||
h1 { font-size: 22px; margin: 0 0 18px; }
|
||||
h2 { font-size: 17px; margin: 0 0 14px; }
|
||||
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%; padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
|
||||
}
|
||||
button, .btn {
|
||||
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; font-weight: 500;
|
||||
background: var(--accent); color: #fff;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--accent-hover); text-decoration: none; }
|
||||
.btn.ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn.danger { background: var(--danger); }
|
||||
.btn.small { padding: 6px 10px; font-size: 13px; }
|
||||
.btn.icon { width: 30px; height: 30px; padding: 0; display: inline-flex;
|
||||
align-items: center; justify-content: center; font-size: 14px; line-height: 1; text-decoration: none; }
|
||||
.err { background: #3a1d1d; border: 1px solid #6b2b2b; color: #ffb3b3; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
|
||||
.dot.on { background: var(--ok); }
|
||||
.dot.off { background: #555d6a; }
|
||||
.muted { color: var(--muted); }
|
||||
code, pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #0c0f13; border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
code { padding: 2px 6px; font-size: 13px; }
|
||||
pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; word-break: break-all; }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
#terminal { width: 100%; height: calc(100vh - 150px); }
|
||||
@media (max-width: 600px) {
|
||||
header.topbar { padding: 12px 16px; }
|
||||
header.topbar nav a { margin-left: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rLine truncated
|
||||
//# sourceMappingURL=xterm.js.map
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Машины — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{if .Agents}}
|
||||
<table>
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
<tr>
|
||||
<td><span class="dot {{if .Online}}on{{else}}off{{end}}"></span>{{if .Online}}online{{else}}offline{{end}}</td>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Hostname}}</td>
|
||||
<td class="muted">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted">{{.Seen}}</td>
|
||||
<td>
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small" href="/agents/{{.ID}}/terminal">Терминал</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/files">Файлы</a>
|
||||
<a class="btn ghost small" href="/agents/{{.ID}}/forwards">Порты</a>
|
||||
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" title="Изменить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
|
||||
</a>
|
||||
<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться — переустановите агента.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small icon" type="submit" title="Удалить">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Пока нет подключённых машин. Добавьте первую в разделе
|
||||
<a href="/settings">Настройки</a> — там одна команда для установки.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Изменить машину</div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>{{.Agent.Name}}</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Имя</h2>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/rename" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="name" value="{{.Agent.Name}}" style="max-width:360px" required>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:12px">
|
||||
Hostname: <code>{{.Agent.Hostname}}</code> · ОС: <code>{{.Agent.OS}}/{{.Agent.Arch}}</code> · ID: <code>{{.Agent.ID}}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия клиента</h2>
|
||||
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
|
||||
<p class="muted">
|
||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||
· Сервер: <code>{{.ServerVersion}}</code>
|
||||
{{if .Outdated}}<span style="color:var(--danger)"> · требуется обновление</span>{{else}}<span style="color:var(--ok)"> · актуальна</span>{{end}}
|
||||
</p>
|
||||
{{if .Outdated}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/update"
|
||||
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit">Обновить клиента</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px">Обновление доступно, когда версия клиента отличается от версии сервера.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Удаление</h2>
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова — переустановите агента.</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,287 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Файлы · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<style>
|
||||
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
|
||||
/* Normalize buttons and the file <label> button so they line up on one row. */
|
||||
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
|
||||
}
|
||||
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
|
||||
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||||
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
|
||||
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
|
||||
border-radius:50%; animation:spin .7s linear infinite; display:none; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
#drop { border:1px dashed var(--border); border-radius:10px; }
|
||||
#drop.drag { border-color:var(--accent); background:#12202f; }
|
||||
table.fm td, table.fm th { padding:7px 8px; }
|
||||
.fm .nm { cursor:pointer; }
|
||||
.fm .nm:hover { color:var(--accent); }
|
||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
|
||||
.dlg-b { padding:16px 18px; }
|
||||
textarea#edit { width:100%; height:52vh; background:#0c0f13; color:var(--text);
|
||||
border:1px solid var(--border); border-radius:8px; padding:12px;
|
||||
font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||
.toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
|
||||
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
|
||||
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Файлы — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:1100px">
|
||||
<div class="fm-bar">
|
||||
<button class="btn ghost small" id="up">↑ Вверх</button>
|
||||
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
||||
<span class="spinner" id="spin"></span>
|
||||
<button class="btn ghost small" id="refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
<button class="btn small" id="mkdir">+ Папка</button>
|
||||
<button class="btn small" id="newfile">+ Файл</button>
|
||||
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
|
||||
<span class="spacer" style="flex:1"></span>
|
||||
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
||||
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
||||
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="drop" style="padding:0">
|
||||
<table class="fm">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||||
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
|
||||
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу — просмотр (правка, если < 2 КБ).</p>
|
||||
</div>
|
||||
|
||||
<dialog id="viewer">
|
||||
<div class="dlg-h">
|
||||
<strong id="vname" style="flex:1; overflow:hidden; text-overflow:ellipsis"></strong>
|
||||
<button class="btn small" id="save" style="display:none">Сохранить</button>
|
||||
<button class="btn ghost small" id="closev">Закрыть</button>
|
||||
</div>
|
||||
<div class="dlg-b">
|
||||
<div id="vmsg" class="muted" style="margin-bottom:8px; font-size:13px"></div>
|
||||
<textarea id="edit" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
let busyN = 0;
|
||||
function setBusy(on) { busyN += on ? 1 : -1; if (busyN < 0) busyN = 0; document.getElementById('spin').style.display = busyN > 0 ? 'inline-block' : 'none'; }
|
||||
const api = (op, body) => {
|
||||
setBusy(true);
|
||||
return fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/' + op, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body || {})
|
||||
}).then(r => r.json()).catch(e => ({ok:false, error:'ошибка сети'})).finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const EDIT_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
// Monochrome inline icons (inherit button color via currentColor).
|
||||
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
|
||||
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
|
||||
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
let cwd = '/';
|
||||
let entries = [];
|
||||
const selected = new Set();
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const toast = (msg, err) => { const t=$('toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2200); };
|
||||
const b64ToBytes = b64 => { const bin=atob(b64); const u=new Uint8Array(bin.length); for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i); return u; };
|
||||
const bytesToB64 = bytes => { let s=''; const CH=0x8000; for(let i=0;i<bytes.length;i+=CH) s+=String.fromCharCode.apply(null, bytes.subarray(i,i+CH)); return btoa(s); };
|
||||
const join = (dir, name) => (dir.endsWith('/') ? dir : dir+'/') + name;
|
||||
const parent = p => { if(p==='/'||p==='') return '/'; const i=p.replace(/\/+$/,'').lastIndexOf('/'); return i<=0 ? '/' : p.slice(0,i); };
|
||||
const fmtSize = n => { if(n<1024)return n+' B'; if(n<1048576)return (n/1024).toFixed(1)+' K'; if(n<1073741824)return (n/1048576).toFixed(1)+' M'; return (n/1073741824).toFixed(1)+' G'; };
|
||||
const fmtTime = t => t ? new Date(t*1000).toLocaleString() : '';
|
||||
|
||||
async function load(path) {
|
||||
const r = await api('list', {path});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||||
$('path').value = cwd;
|
||||
render();
|
||||
}
|
||||
|
||||
// Editable path: Enter navigates, or reports a missing directory.
|
||||
async function navTo(p) {
|
||||
p = (p || '').trim() || '/';
|
||||
const r = await api('list', {path: p});
|
||||
if (!r.ok) { toast(r.error || 'нет такого каталога', true); $('path').value = cwd; return; }
|
||||
cwd = r.data.path; entries = r.data.entries || []; selected.clear();
|
||||
$('path').value = cwd; render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tb = $('list'); tb.innerHTML = '';
|
||||
updateSel();
|
||||
for (const e of entries) {
|
||||
const tr = document.createElement('tr');
|
||||
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
|
||||
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
||||
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
|
||||
'<td><div class="act">' +
|
||||
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
|
||||
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
||||
'</div></td>';
|
||||
const nm = tr.querySelector('.nm');
|
||||
nm.onclick = () => e.is_dir ? load(join(cwd, e.name)) : view(e);
|
||||
tr.querySelector('.ck').onchange = ev => { const n=e.name; ev.target.checked?selected.add(n):selected.delete(n); updateSel(); };
|
||||
tr.querySelector('[data-act="del"]').onclick = () => del(e);
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => move(e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]'); if (uz) uz.onclick = () => unzip(e);
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
const esc = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
function updateSel() {
|
||||
const n = selected.size;
|
||||
$('selinfo').textContent = n ? ('выбрано: '+n) : '';
|
||||
$('zipsel').disabled = !n; $('delsel').disabled = !n;
|
||||
}
|
||||
|
||||
async function view(e) {
|
||||
const r = await api('read', {path: join(cwd, e.name)});
|
||||
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||
const d = r.data;
|
||||
$('vname').textContent = e.name;
|
||||
const ta = $('edit'); const save = $('save'); const msg = $('vmsg');
|
||||
if (d.binary) {
|
||||
ta.value = '(бинарный файл, просмотр недоступен)'; ta.readOnly = true; save.style.display='none';
|
||||
msg.textContent = 'Размер: '+fmtSize(d.size);
|
||||
} else {
|
||||
ta.value = new TextDecoder().decode(b64ToBytes(d.content));
|
||||
const editable = d.size < EDIT_LIMIT && !d.truncated;
|
||||
ta.readOnly = !editable;
|
||||
save.style.display = editable ? '' : 'none';
|
||||
msg.textContent = 'Размер: '+fmtSize(d.size) + (d.truncated?' · показано первые 5 МБ':'') + (editable?' · редактируемо':' · только чтение (≥ 5 МБ)');
|
||||
save.onclick = async () => {
|
||||
const bytes = new TextEncoder().encode(ta.value);
|
||||
const w = await api('write', {path: join(cwd, e.name), data: bytesToB64(bytes)});
|
||||
if (w.ok) { toast('сохранено'); $('viewer').close(); load(cwd); } else toast(w.error||'ошибка', true);
|
||||
};
|
||||
}
|
||||
$('viewer').showModal();
|
||||
}
|
||||
$('closev').onclick = () => $('viewer').close();
|
||||
|
||||
async function del(e) {
|
||||
if (e.is_dir) {
|
||||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||||
const r = await api('deltree', {path: join(cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
} else {
|
||||
if (!confirm('Удалить файл "'+e.name+'"?')) return;
|
||||
const r = await api('delete', {path: join(cwd, e.name)});
|
||||
r.ok ? (toast('удалено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function move(e) {
|
||||
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
|
||||
if (!dst) return;
|
||||
const r = await api('move', {src: join(cwd, e.name), dst});
|
||||
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
async function unzip(e) {
|
||||
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
||||
r.ok ? (toast('распаковано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
}
|
||||
|
||||
$('mkdir').onclick = async () => {
|
||||
const n = prompt('Имя новой папки:'); if (!n) return;
|
||||
const r = await api('mkdir', {path: join(cwd, n)});
|
||||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('newfile').onclick = async () => {
|
||||
const n = prompt('Имя нового файла:'); if (!n) return;
|
||||
const r = await api('write', {path: join(cwd, n), data: ''});
|
||||
r.ok ? (toast('создано'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('delsel').onclick = async () => {
|
||||
if (!confirm('Удалить выбранное ('+selected.size+')?')) return;
|
||||
for (const n of selected) {
|
||||
const e = entries.find(x => x.name === n);
|
||||
await api(e && e.is_dir ? 'deltree' : 'delete', {path: join(cwd, n)});
|
||||
}
|
||||
toast('удалено'); load(cwd);
|
||||
};
|
||||
$('zipsel').onclick = async () => {
|
||||
const name = prompt('Имя архива:', 'archive.zip'); if (!name) return;
|
||||
const paths = [...selected].map(n => join(cwd, n));
|
||||
const r = await api('zip', {paths, dest: join(cwd, name)});
|
||||
r.ok ? (toast('архив создан'), load(cwd)) : toast(r.error||'ошибка', true);
|
||||
};
|
||||
$('all').onchange = ev => {
|
||||
selected.clear();
|
||||
if (ev.target.checked) entries.forEach(e => selected.add(e.name));
|
||||
document.querySelectorAll('.ck').forEach(c => c.checked = ev.target.checked);
|
||||
updateSel();
|
||||
};
|
||||
$('up').onclick = () => load(parent(cwd));
|
||||
$('refresh').onclick = () => load(cwd);
|
||||
$('path').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); navTo($('path').value); }
|
||||
});
|
||||
|
||||
// uploads
|
||||
async function uploadFiles(files) {
|
||||
for (const f of files) {
|
||||
if (f.size > 32*1024*1024) { toast('файл '+f.name+' больше 32 МБ', true); continue; }
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
const r = await api('write', {path: join(cwd, f.name), data: bytesToB64(buf)});
|
||||
if (!r.ok) { toast(r.error||('ошибка '+f.name), true); return; }
|
||||
}
|
||||
toast('загружено'); load(cwd);
|
||||
}
|
||||
$('upl').onchange = ev => uploadFiles(ev.target.files);
|
||||
const drop = $('drop');
|
||||
['dragenter','dragover'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); drop.classList.add('drag'); }));
|
||||
['dragleave','drop'].forEach(x => drop.addEventListener(x, e => { e.preventDefault(); if(x!=='drop')drop.classList.remove('drag'); }));
|
||||
drop.addEventListener('drop', e => { drop.classList.remove('drag'); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
||||
|
||||
load('/');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Порты · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<style>
|
||||
.frow { display:flex; align-items:flex-end; gap:14px; flex-wrap:wrap; }
|
||||
.frow label { margin:0 0 6px; }
|
||||
.frow .fld { display:flex; flex-direction:column; }
|
||||
.frow select, .frow input, .frow button {
|
||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||
}
|
||||
.frow select {
|
||||
padding:0 32px 0 12px; border-radius:8px; border:1px solid var(--border);
|
||||
background:#0c0f13; color:var(--text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Порты — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Проброс портов</h1>
|
||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||
Клиент за NAT — трафик идёт исходящим подключением агента.</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table>
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
<td><code>{{.Proto}}</code></td>
|
||||
<td class="muted">:{{.HostPort}}</td>
|
||||
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted">{{.ListenAddr}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Правил пока нет.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Новое правило</h2>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/forwards/add">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="frow">
|
||||
<div class="fld">
|
||||
<label>Тип</label>
|
||||
<select name="proto" id="proto">
|
||||
<option value="http">http (long-poll, работает везде)</option>
|
||||
<option value="tcp">tcp (прямой, низкая задержка, порт 8446)</option>
|
||||
<option value="udp">udp (прямой, порт 8446)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на клиенте</label>
|
||||
<input type="text" name="client_port" placeholder="напр. 22" style="max-width:140px" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Порт на хосте</label>
|
||||
<input type="text" name="host_port" placeholder="напр. 2222" style="max-width:140px" required>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>Слушать на хосте</label>
|
||||
<input type="text" name="listen_addr" value="0.0.0.0" style="max-width:160px">
|
||||
</div>
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="muted" id="note" style="font-size:13px; margin-top:12px"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Режимы</h2>
|
||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||
<li><b>http</b> — туннель поверх HTTP long-poll (через nginx :8445). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>tcp</b> — прямой TCP-мультиплексор через порт <b>8446</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть 8446/tcp на хосте.</li>
|
||||
<li><b>udp</b> — прямой UDP через порт <b>8446</b> хоста. Нужно открыть 8446/udp.</li>
|
||||
</ul>
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:8446</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через :8445 (long-poll). Порт 8446 не требуется.',
|
||||
tcp: 'Требуется открытый порт 8446/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт 8446/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
sel.onchange = upd; upd();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Вход в панель</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<div style="margin-top:18px"><button type="submit">Войти</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройки — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<a href="/settings">Настройки</a>
|
||||
<form method="post" action="/logout" style="display:inline; margin-left:22px">
|
||||
<button class="btn ghost small" type="submit">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Настройки</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Установка на новую машину</h2>
|
||||
<p class="muted">Выполните на Linux-клиенте под root. Скрипт сам скачает бинарник,
|
||||
поставит сервис и зарегистрирует машину.</p>
|
||||
|
||||
<label>Короткая команда (секрет уже вшит в ссылку)</label>
|
||||
<pre id="short">{{.InstallShort}}</pre>
|
||||
|
||||
<details style="margin-top:10px">
|
||||
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
|
||||
<label>curl</label>
|
||||
<pre id="curl">{{.InstallCurl}}</pre>
|
||||
<label>wget</label>
|
||||
<pre id="wget">{{.InstallWget}}</pre>
|
||||
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
|
||||
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Адрес хоста</h2>
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<form method="post" action="/settings/host" class="row">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="text" name="host" value="{{.Host}}" style="max-width:420px">
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||
<div class="row">
|
||||
<code>{{.Secret}}</code>
|
||||
<form method="post" action="/settings/secret/rotate" style="display:inline"
|
||||
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn ghost small" type="submit">Сменить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Администраторы</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" action="/settings/admins/add" style="margin-top:14px">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="row">
|
||||
<input type="text" name="username" placeholder="логин" style="max-width:200px" required>
|
||||
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required>
|
||||
<button type="submit">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Firewall (ufw)</h2>
|
||||
{{if not .UfwInstalled}}
|
||||
<p class="muted">ufw не установлен на хосте — интеграция недоступна.</p>
|
||||
{{else if not .UfwRunnable}}
|
||||
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
|
||||
Запустите сервер под root или настройте sudo для ufw.</p>
|
||||
{{else}}
|
||||
<p class="muted">
|
||||
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
|
||||
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
|
||||
</p>
|
||||
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp — также порт 8446).</p>
|
||||
<form method="post" action="/settings/ufw/toggle" style="display:inline">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
|
||||
<button class="btn {{if .UfwEnabled}}ghost{{end}} small" type="submit">
|
||||
{{if .UfwEnabled}}Выключить интеграцию{{else}}Включить интеграцию{{end}}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{if .UfwRules}}
|
||||
<h2 style="margin-top:18px; font-size:15px">Правила, созданные панелью</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{{range .UfwRules}}
|
||||
<tr>
|
||||
<td><code>{{.Port}}/{{.Proto}}</code></td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
||||
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Отменить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройка — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<div class="card">
|
||||
<h1>Первый запуск</h1>
|
||||
<p class="muted">Создайте учётную запись администратора панели.</p>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/setup">
|
||||
<label>Имя пользователя</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>Пароль (мин. 8 символов)</label>
|
||||
<input type="password" name="password" required>
|
||||
<label>Повтор пароля</label>
|
||||
<input type="password" name="password2" required>
|
||||
<div style="margin-top:18px"><button type="submit">Создать и войти</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Терминал · {{.Agent.Name}} — LPRA</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="stylesheet" href="/static/xterm.css">
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">LPRA · Терминал — {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<span id="status" class="muted" style="margin-left:28px">подключение…</span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap" style="max-width:none">
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/xterm.js"></script>
|
||||
<script src="/static/addon-fit.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const agentID = {{.Agent.ID}};
|
||||
const statusEl = document.getElementById('status');
|
||||
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
theme: { background: '#0c0f13' }
|
||||
});
|
||||
const fit = new FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fit.fit();
|
||||
|
||||
function bytesToB64(bytes) {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin);
|
||||
}
|
||||
function b64ToBytes(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
const base = '/api/panel/term/';
|
||||
let sid = null;
|
||||
let closed = false;
|
||||
let connected = false;
|
||||
|
||||
function post(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
keepalive: true
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
try {
|
||||
const r = await post(base + encodeURIComponent(agentID) + '/open');
|
||||
if (!r.ok) {
|
||||
const body = await r.text().catch(() => '');
|
||||
setStatus('ошибка открытия (' + r.status + ')', 'st-err');
|
||||
term.write('\r\n\x1b[31m[не удалось открыть сессию: HTTP ' + r.status + ']\x1b[0m\r\n');
|
||||
if (body) term.write(body.slice(0, 300).replace(/\n/g, '\r\n') + '\r\n');
|
||||
if (r.status === 404) term.write('\x1b[33mМашина не найдена на сервере — возможно, устаревший бинарник сервера без маршрута /open.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
sid = j.session_id;
|
||||
setStatus('ожидание агента…');
|
||||
sendResize();
|
||||
pollLoop();
|
||||
} catch (e) {
|
||||
setStatus('ошибка сети', 'st-err');
|
||||
term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollLoop() {
|
||||
while (!closed && sid) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(base + sid + '/out', { cache: 'no-store' });
|
||||
} catch (e) {
|
||||
// transient network hiccup; brief pause then retry
|
||||
await new Promise(res => setTimeout(res, 1000));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) { finish('[соединение закрыто]'); return; }
|
||||
const j = await r.json();
|
||||
if (j.closed) { finish('[сессия завершена]'); return; }
|
||||
if (j.frames) {
|
||||
for (const f of j.frames) {
|
||||
if (f.exit) { finish('[сессия завершена]'); return; }
|
||||
if (f.data) {
|
||||
if (!connected) { connected = true; setStatus('подключено', 'st-ok'); }
|
||||
term.write(b64ToBytes(f.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finish(msg) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
setStatus('соединение закрыто', 'muted');
|
||||
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
if (closed || !sid) return;
|
||||
const bytes = new TextEncoder().encode(data);
|
||||
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
|
||||
});
|
||||
|
||||
function sendResize() {
|
||||
if (closed || !sid) return;
|
||||
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
|
||||
}
|
||||
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
|
||||
term.onResize(sendResize);
|
||||
|
||||
window.addEventListener('pagehide', function () {
|
||||
if (sid) navigator.sendBeacon(base + sid + '/close');
|
||||
});
|
||||
|
||||
term.focus();
|
||||
open();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user