<?php
ini_set('memory_limit', '32M');
define('ROOT_DIR', file_exists('/opt/etc/nfqws/nfqws.conf') ? '/opt' : '');
define('SCRIPT_NAME', ROOT_DIR ? 'S51nfqws' : 'nfqws-keenetic');
function normalizeString(string $s): string {
// Convert all line-endings to UNIX format.
$s = str_replace(array("\r\n", "\r", "\n"), "\n", $s);
// Don't allow out-of-control blank lines.
$s = preg_replace("/\n{3,}/", "\n\n", $s);
$lastChar = substr($s, -1);
if ($lastChar !== "\n" && !empty($s)) {
$s .= "\n";
}
return $s;
}
function getFiles($path = ROOT_DIR . '/etc/nfqws'): array {
// GLOB_BRACE is unsupported in openwrt
$files = array_filter(glob($path . '/*'), function ($file) {
return is_file($file) && preg_match('/\.(list|list-opkg|list-old|conf|conf-opkg|conf-old|apk-new)$/i', $file);
});
$logfile = ROOT_DIR . '/var/log/nfqws.log';
$basenames = array_map(fn($file) => basename($file), $files);
if (file_exists($logfile)) {
array_push($basenames, basename($logfile));
}
$priority = ['nfqws.conf' => -5, 'user.list' => -4, 'exclude.list' => -3, 'auto.list' => -2, 'nfqws.log' => -1];
usort($basenames, fn($a, $b) => ($priority[$a] ?? 1) - ($priority[$b] ?? -1));
return $basenames;
}
function getFileContent(string $filename, $path = ROOT_DIR . '/etc/nfqws'): string {
return file_get_contents($path . '/' . basename($filename));
}
function getLogContent(string $filename, $path = ROOT_DIR . '/var/log'): string {
$file = file($path . '/' . basename($filename));
$file = array_reverse($file);
return implode("", $file);
}
function saveFile(string $filename, string $content, $path = ROOT_DIR . '/etc/nfqws') {
$filename = basename($filename);
$file = $path . '/' . $filename;
return file_exists($file) && file_put_contents($file, normalizeString($content)) !== false;
}
function saveLog(string $filename, string $content, $path = ROOT_DIR . '/var/log') {
return saveFile($filename, $content, $path);
}
function removeFile(string $filename, $path = ROOT_DIR . '/etc/nfqws') {
$filename = basename($filename);
$file = $path . '/' . $filename;
if (file_exists($file)) {
return unlink($file);
} else {
return false;
}
}
function nfqwsServiceStatus() {
$output = null;
exec(ROOT_DIR . "/etc/init.d/" . SCRIPT_NAME . " status", $output);
return str_contains($output[0] ?? '', 'is running');
}
function nfqwsServiceAction(string $action) {
$output = null;
$retval = null;
exec(ROOT_DIR . "/etc/init.d/" . SCRIPT_NAME . " $action", $output, $retval);
return array('output' => $output, 'status' => $retval);
}
function opkgUpgradeAction() {
$output = null;
$retval = null;
exec("opkg update && opkg upgrade nfqws-keenetic nfqws-keenetic-web", $output, $retval);
if (empty($output)) {
$output[] = 'Nothing to update';
}
return array('output' => $output, 'status' => $retval);
}
function apkUpgradeAction() {
$output = null;
$retval = null;
exec("apk --update-cache add nfqws-keenetic nfqws-keenetic-web", $output, $retval);
if (empty($output)) {
$output[] = 'Nothing to update';
}
return array('output' => $output, 'status' => $retval);
}
function upgradeAction() {
return file_exists('/usr/bin/apk') ? apkUpgradeAction() : opkgUpgradeAction();
}
function authenticate($username, $password) {
$passwdFile = ROOT_DIR . '/etc/passwd';
$shadowFile = ROOT_DIR . '/etc/shadow';
$users = file(file_exists($shadowFile) ? $shadowFile : $passwdFile);
$user = preg_grep("/^$username/", $users);
if ($user) {
list(, $passwdInDB) = explode(':', array_pop($user));
if (empty($passwdInDB)) {
return empty($password);
}
if (crypt($password, $passwdInDB) == $passwdInDB) {
return true;
}
}
return false;
}
function main() {
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(302);
header('Location: index.html');
exit();
}
session_start();
if (!isset($_SESSION['auth']) || !$_SESSION['auth']) {
if ($_POST['cmd'] !== 'login' || !isset($_POST['user']) || !isset($_POST['password']) || !authenticate($_POST['user'], $_POST['password'])) {
http_response_code(401);
exit();
} else {
$_SESSION['auth'] = true;
}
}
switch ($_POST['cmd']) {
case 'filenames':
$files = getFiles();
$response = array('status' => 0, 'files' => $files, 'service' => nfqwsServiceStatus());
break;
case 'filecontent':
if (str_ends_with($_POST['filename'], '.log')) {
$content = getLogContent($_POST['filename']);
} else {
$content = getFileContent($_POST['filename']);
}
$response = array('status' => 0, 'content' => $content, 'filename' => $_POST['filename']);
break;
case 'filesave':
if (str_ends_with($_POST['filename'], '.log')) {
$result = saveLog($_POST['filename'], $_POST['content']);
} else {
$result = saveFile($_POST['filename'], $_POST['content']);
}
$response = array('status' => $result ? 0 : 1, 'filename' => $_POST['filename']);
break;
case 'fileremove':
$result = removeFile($_POST['filename']);
$response = array('status' => $result ? 0 : 1, 'filename' => $_POST['filename']);
break;
case 'reload':
case 'restart':
case 'stop':
case 'start':
$response = nfqwsServiceAction($_POST['cmd']);
break;
case 'upgrade':
$response = upgradeAction();
break;
case 'login':
$response = array('status' => 0);
break;
case 'logout':
$_SESSION['auth'] = false;
$response = array('status' => 0);
break;
default:
http_response_code(405);
exit();
}
header('Content-Type: application/json; charset=utf-8');
http_response_code(200);
echo json_encode($response);
exit();
}
main();
const TLN={eventList:{},update_line_numbers:function(e,t){let n=e.value.split("\n").length-t.children.length;if(n>0){const e=document.createDocumentFragment();for(;n>0;){const t=document.createElement("span");t.className="tln-line",e.appendChild(t),n--}t.appendChild(e)}for(;n<0;)t.removeChild(t.lastChild),n++},append_line_numbers:function(e){const t=document.getElementById(e);if(null==t)return console.warn("[tln.js] Couldn't find textarea of id '"+e+"'");if(-1!=t.className.indexOf("tln-active"))return console.warn("[tln.js] textarea of id '"+e+"' is already numbered");t.classList.add("tln-active"),t.style={};const n=document.createElement("div");n.className="tln-wrapper",t.parentNode.insertBefore(n,t),TLN.update_line_numbers(t,n),TLN.eventList[e]=[];const l=["propertychange","input","keydown","keyup"],o=function(e,t){return function(n){(10!=+e.scrollLeft||37!=n.keyCode&&37!=n.which&&"ArrowLeft"!=n.code&&"ArrowLeft"!=n.key)&&36!=n.keyCode&&36!=n.which&&"Home"!=n.code&&"Home"!=n.key&&13!=n.keyCode&&13!=n.which&&"Enter"!=n.code&&"Enter"!=n.key&&"NumpadEnter"!=n.code||(e.scrollLeft=0),TLN.update_line_numbers(e,t)}}(t,n);for(let n=l.length-1;n>=0;n--)t.addEventListener(l[n],o),TLN.eventList[e].push({evt:l[n],hdlr:o});const r=["change","mousewheel","scroll"],s=function(e,t){return function(){t.scrollTop=e.scrollTop}}(t,n);for(let n=r.length-1;n>=0;n--)t.addEventListener(r[n],s),TLN.eventList[e].push({evt:r[n],hdlr:s})},remove_line_numbers:function(e){const t=document.getElementById(e);if(null==t)return console.warn("[tln.js] Couldn't find textarea of id '"+e+"'");if(-1==t.className.indexOf("tln-active"))return console.warn("[tln.js] textarea of id '"+e+"' isn't numbered");t.classList.remove("tln-active");const n=t.previousSibling;if("tln-wrapper"==n.className&&n.remove(),TLN.eventList[e]){for(let n=TLN.eventList[e].length-1;n>=0;n--){const l=TLN.eventList[e][n];t.removeEventListener(l.evt,l.hdlr)}delete TLN.eventList[e]}}};
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>nfqws-keenetic</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
<link rel="shortcut icon" type="image/x-icon" href="favicon.ico">
<link rel="apple-touch-icon" href="icon.png">
<link rel="icon" href="icon.png" sizes="192x192">
<link rel="manifest" href="manifest.json">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta name="MobileOptimized" content="320">
<meta name="HandheldFriendly" content="true">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1b2434">
<meta name="color-scheme" content="light dark">
<script defer src="tln.min.js?v=v2.8.4"></script>
<script defer src="script.js?v=v2.8.4" type="module"></script>
<link rel="stylesheet" href="tln.min.css?v=v2.8.4">
<link rel="stylesheet" href="style.css?v=v2.8.4">
</head>
<body class="disabled unknown">
<script>
const theme = localStorage.getItem('theme');
if (theme) {
const root = document.querySelector(':root');
root.dataset.theme = theme;
}
</script>
<header>
<h1 class="logo">nfqws-keenetic <span id="status"></span></h1>
<button class="button red" id="save">Save</button>
<button class="button" id="restart">Restart</button>
<button class="button" id="dropdown"></button>
<ul id="dropdown-menu" class="hidden">
<li class="dropdown-item" id="reload">Reload</li>
<li class="dropdown-item" id="stop">Stop</li>
<li class="dropdown-item" id="start">Start</li>
<li class="dropdown-item" id="upgrade">Update</li>
</ul>
</header>
<nav></nav>
<article>
<div class="textarea-container">
<textarea id="config" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus></textarea>
</div>
</article>
<footer>
<a class="footer-tab" href="https://github.com/Anonym-tsk/nfqws-keenetic/" target="_blank">GitHub</a>
<a class="footer-tab" href="https://anonym-tsk.github.io/nfqws-keenetic/" target="_blank">Repository</a>
<span id="theme" class="footer-tab" title="Change theme"></span>
<span id="logout" class="footer-tab" title="Logout"></span>
<span id="version">v2.8.4</span>
</footer>
<div id="overlay"></div>
<div id="alert" class="popup hidden">
<pre class="popup-content"></pre>
<div class="popup-footer">
<button class="popup-yes">Yes</button>
<button class="popup-no">No</button>
<button class="popup-close">Close</button>
</div>
</div>
<div id="login-form" class="popup hidden">
<div class="popup-content">
<input type="text" class="login-input" id="login" autocomplete="false" autofocus placeholder="Login" />
<input type="password" class="login-input" id="password" placeholder="Password" />
</div>
<div class="popup-footer">
<button class="popup-yes">Login</button>
</div>
</div>
</body>
</html>
class UI {
constructor() {
TLN.append_line_numbers('config');
this.$tabs = document.querySelector('nav');
this.buttons = this._initButtons();
this.tabs = this._initTabs();
this.textarea = this._initTextarea();
this.version = this._initVersion();
this.popup = this._initPopups();
this.login = this._initLoginForm();
}
_initTabs() {
const tabs = {};
let currentFile = '';
const add = (filename) => {
const tab = document.createElement('div');
tab.classList.add('nav-tab');
tab.textContent = filename;
const isConf = filename.endsWith('.conf');
const isList = filename.endsWith('.list');
const isLog = filename.endsWith('.log');
if (isLog) {
const clear = document.createElement('div');
clear.classList.add('nav-clear');
clear.setAttribute('title', 'Clear log');
clear.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const yesno = await this.popup.confirm('Clear log?');
if (!yesno) {
return;
}
const result = await saveFile(filename, '');
if (!result.status) {
if (filename === currentFile) {
this.textarea.value = '';
}
} else {
this.popup.alert(`clear ${filename}`, `Error: ${result.status}`);
}
});
tab.appendChild(clear);
} else if (!isConf && !isList) {
tab.classList.add('secondary');
const trash = document.createElement('div');
trash.classList.add('nav-trash');
trash.setAttribute('title', 'Delete file');
trash.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const yesno = await this.popup.confirm('Delete file?');
if (!yesno) {
return;
}
const result = await removeFile(filename);
if (!result.status) {
remove(filename);
} else {
this.popup.alert(`remove ${filename}`, `Error: ${result.status}`);
}
});
tab.appendChild(trash);
}
tab.addEventListener('click', async () => this.loadFile(filename));
this.$tabs.appendChild(tab);
tabs[filename] = tab;
};
const remove = (filename) => {
for (const [key, tab] of Object.entries(tabs)) {
if (key === filename) {
tab.parentNode.removeChild(tab);
delete tabs[key];
if (filename === currentFile) {
this.textarea.save();
activateFirst();
}
break;
}
}
};
const activate = (filename) => {
for (const [key, tab] of Object.entries(tabs)) {
tab.classList.toggle('active', filename === key);
if (filename === key) {
currentFile = filename;
}
}
};
const activateFirst = () => {
Object.values(tabs)[0].click();
};
return {
add,
remove,
activate,
activateFirst,
get currentFileName() {
return currentFile;
}
};
}
_initTextarea() {
const element = document.getElementById('config');
let originalText = element.value;
let textChanged = false;
const save = () => {
originalText = element.value;
textChanged = false;
this.setChanged(false);
};
element.addEventListener('input', _debounce(() => {
textChanged = element.value !== originalText;
this.setChanged(textChanged);
}, 300));
element.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
this.buttons.click();
}
});
return {
get value() {
return element.value;
},
set value(text) {
element.value = text;
save();
// Update line numbers
const event = new Event('input');
element.dispatchEvent(event);
},
get changed() {
return textChanged;
},
save,
disabled(status) {
if (status) {
element.setAttribute('disabled', 'disabled');
} else {
element.removeAttribute('disabled');
}
},
readonly(status) {
if (status) {
element.setAttribute('readonly', 'readonly');
} else {
element.removeAttribute('readonly');
}
},
};
}
_initVersion() {
const element = document.getElementById('version');
const match = element.textContent.match(/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/);
const value = () => {
return match ? [match[1], match[2], match[3]] : null;
};
const checkUpdate = async () => {
if (!value()) {
return;
}
const latest = await getLatestVersion();
if (!latest) {
return;
}
const updateAvailable = compareVersions(value(), latest);
if (updateAvailable) {
const link = document.createElement('a');
const tag = `v${latest[0]}.${latest[1]}.${latest[2]}`;
link.textContent = `(${tag})`;
link.href = `https://github.com/Anonym-tsk/nfqws-keenetic/releases/tag/${tag}`;
link.target = '_blank';
element.appendChild(link);
}
};
return {
get value() {
return value();
},
checkUpdate,
}
}
_initPopups() {
const element = document.getElementById('alert');
const alertContent = element.querySelector('.popup-content');
const buttonClose = element.querySelector('.popup-close');
const buttonYes = element.querySelector('.popup-yes');
const buttonNo = element.querySelector('.popup-no');
const alert = (...text) => {
this.disableUI();
alertContent.textContent = `> ${text.join("\n")}`;
element.classList.add('alert');
element.classList.remove('hidden', 'confirm', 'locked');
};
const hide = () => {
element.classList.add('hidden');
element.classList.remove('locked');
this.enableUI();
}
const confirm = async (text) => {
this.disableUI();
alertContent.textContent = text;
element.classList.add('confirm');
element.classList.remove('hidden', 'alert', 'locked');
return new Promise((resolve) => {
buttonYes.addEventListener('click', function ok() {
buttonYes.removeEventListener('click', ok);
resolve(true);
});
buttonNo.addEventListener('click', function fail() {
buttonNo.removeEventListener('click', fail);
resolve(false);
});
});
};
const process = async (text, fn, ...args) => {
this.disableUI();
alertContent.textContent = `> ${text}\n`;
element.classList.add('alert', 'locked');
element.classList.remove('hidden', 'confirm');
let status = true;
const result = await fn(...args);
if (!result.status) {
alertContent.textContent += Array.from(result.output).join("\n");
} else {
alertContent.textContent += `Error: ${result.status}`;
status = false;
}
element.classList.remove('locked');
return new Promise((resolve) => {
buttonClose.addEventListener('click', function close() {
buttonYes.removeEventListener('click', close);
resolve(status);
});
});
};
Хотите - в HTTP-запросе представляйтесь веб браузером, не хотите - не представляйтесь...
извне
самого web-интерфейса роутера
не пропустит не-web-трафик
location / {
proxy_pass http://127.0.0.1:22;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
?внутренний ip/порт и выбор https|http
Т.е. чтобы приведённый пример работал, нужно чтобы на целевом сервере на 443 порту крутился какой-то реверс-прокси (точнее, TLS termination proxy), который распакует трафик из туннеля и пробросит его на порт, который слушает SSH-сервер. Имхо, SSH-сервер про эти фокусы вообще ничего не знает.
возможно даже радикально слишком улучшено, аж все if fi ушли. (для моего уровня понимания лихо....)
т.е. по сути первой строки с присвоением переменной достаточно? а остальное же можно оставить как было... но можно и по новому варику =результат типа 0 или 1 возврат, как мне "видится"