|
|
@@ -0,0 +1,548 @@
|
|
|
+#include "shell.h"
|
|
|
+
|
|
|
+#include "util/codec.h"
|
|
|
+#include "util/json.h"
|
|
|
+#include "net/http_request.h"
|
|
|
+#include "net/http_response.h"
|
|
|
+#include "net/http_reqpack.h"
|
|
|
+#include "net/http_define.h"
|
|
|
+#include "net/http_server.h"
|
|
|
+
|
|
|
+#include "HPSocket/HPSocket.h"
|
|
|
+
|
|
|
+#include <atomic>
|
|
|
+#include <cerrno>
|
|
|
+#include <chrono>
|
|
|
+#include <cstring>
|
|
|
+#include <fcntl.h>
|
|
|
+#include <map>
|
|
|
+#include <memory>
|
|
|
+#include <mutex>
|
|
|
+#include <pty.h>
|
|
|
+#include <random>
|
|
|
+#include <signal.h>
|
|
|
+#include <string>
|
|
|
+#include <sys/ioctl.h>
|
|
|
+#include <sys/select.h>
|
|
|
+#include <sys/wait.h>
|
|
|
+#include <thread>
|
|
|
+#include <unistd.h>
|
|
|
+#include <vector>
|
|
|
+
|
|
|
+namespace ngs {
|
|
|
+namespace system {
|
|
|
+namespace {
|
|
|
+
|
|
|
+using ylib::network::http::request;
|
|
|
+using ylib::network::http::response;
|
|
|
+using ylib::network::http::websocket_message;
|
|
|
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_UPGRADE;
|
|
|
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_HEADER;
|
|
|
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_BODY;
|
|
|
+using ylib::network::http::HTTP_SERVER_WEBSOCKET_TYPE_CLOSE;
|
|
|
+
|
|
|
+constexpr int kMaxSessions = 8;
|
|
|
+constexpr int64_t kTicketTtlSec = 60;
|
|
|
+constexpr const char* kWsGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
+
|
|
|
+struct ShellSession {
|
|
|
+ uint64_t connid = 0;
|
|
|
+ IHttpServer* hp = nullptr;
|
|
|
+ int master = -1;
|
|
|
+ pid_t pid = -1;
|
|
|
+ std::atomic<bool> alive{true};
|
|
|
+ std::thread reader;
|
|
|
+};
|
|
|
+
|
|
|
+struct ShellTicket {
|
|
|
+ std::string username;
|
|
|
+ int64_t expire_at = 0;
|
|
|
+};
|
|
|
+
|
|
|
+std::mutex g_mu;
|
|
|
+std::map<uint64_t, std::shared_ptr<ShellSession>> g_sessions;
|
|
|
+std::map<std::string, ShellTicket> g_tickets;
|
|
|
+
|
|
|
+int64_t now_sec() {
|
|
|
+ using clock = std::chrono::system_clock;
|
|
|
+ return std::chrono::duration_cast<std::chrono::seconds>(
|
|
|
+ clock::now().time_since_epoch())
|
|
|
+ .count();
|
|
|
+}
|
|
|
+
|
|
|
+std::string random_hex(size_t bytes) {
|
|
|
+ static thread_local std::mt19937_64 rng{std::random_device{}()};
|
|
|
+ static const char* hex = "0123456789abcdef";
|
|
|
+ std::string out;
|
|
|
+ out.reserve(bytes * 2);
|
|
|
+ for (size_t i = 0; i < bytes; ++i) {
|
|
|
+ const auto v = static_cast<unsigned>(rng() & 0xff);
|
|
|
+ out.push_back(hex[v >> 4]);
|
|
|
+ out.push_back(hex[v & 0xf]);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+void purge_tickets_locked() {
|
|
|
+ const int64_t now = now_sec();
|
|
|
+ for (auto it = g_tickets.begin(); it != g_tickets.end();) {
|
|
|
+ if (it->second.expire_at < now) {
|
|
|
+ it = g_tickets.erase(it);
|
|
|
+ } else {
|
|
|
+ ++it;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+std::string query_param(const std::string& url, const std::string& key) {
|
|
|
+ const auto qpos = url.find('?');
|
|
|
+ if (qpos == std::string::npos) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ std::string qs = url.substr(qpos + 1);
|
|
|
+ const auto hash = qs.find('#');
|
|
|
+ if (hash != std::string::npos) {
|
|
|
+ qs = qs.substr(0, hash);
|
|
|
+ }
|
|
|
+ size_t start = 0;
|
|
|
+ while (start < qs.size()) {
|
|
|
+ size_t amp = qs.find('&', start);
|
|
|
+ if (amp == std::string::npos) {
|
|
|
+ amp = qs.size();
|
|
|
+ }
|
|
|
+ const std::string part = qs.substr(start, amp - start);
|
|
|
+ const auto eq = part.find('=');
|
|
|
+ const std::string k = eq == std::string::npos ? part : part.substr(0, eq);
|
|
|
+ if (k == key) {
|
|
|
+ std::string v = eq == std::string::npos ? "" : part.substr(eq + 1);
|
|
|
+ std::string out;
|
|
|
+ for (size_t i = 0; i < v.size(); ++i) {
|
|
|
+ if (v[i] == '%' && i + 2 < v.size()) {
|
|
|
+ auto hex = [](char c) -> int {
|
|
|
+ if (c >= '0' && c <= '9') return c - '0';
|
|
|
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
|
|
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
|
|
+ return -1;
|
|
|
+ };
|
|
|
+ const int hi = hex(v[i + 1]);
|
|
|
+ const int lo = hex(v[i + 2]);
|
|
|
+ if (hi >= 0 && lo >= 0) {
|
|
|
+ out.push_back(static_cast<char>((hi << 4) | lo));
|
|
|
+ i += 2;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (v[i] == '+') {
|
|
|
+ out.push_back(' ');
|
|
|
+ } else {
|
|
|
+ out.push_back(v[i]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ start = amp + 1;
|
|
|
+ }
|
|
|
+ return "";
|
|
|
+}
|
|
|
+
|
|
|
+bool send_ws(IHttpServer* hp, uint64_t connid, const char* data, size_t len,
|
|
|
+ BYTE opcode = 0x1) {
|
|
|
+ if (!hp || !data || len == 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return hp->SendWSMessage((CONNID)connid, TRUE, 0, opcode,
|
|
|
+ reinterpret_cast<const BYTE*>(data),
|
|
|
+ static_cast<int>(len)) == TRUE;
|
|
|
+}
|
|
|
+
|
|
|
+void close_fd(int& fd) {
|
|
|
+ if (fd >= 0) {
|
|
|
+ ::close(fd);
|
|
|
+ fd = -1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void kill_child(pid_t& pid) {
|
|
|
+ if (pid <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ ::kill(pid, SIGTERM);
|
|
|
+ for (int i = 0; i < 20; ++i) {
|
|
|
+ int st = 0;
|
|
|
+ pid_t r = ::waitpid(pid, &st, WNOHANG);
|
|
|
+ if (r == pid || (r < 0 && errno == ECHILD)) {
|
|
|
+ pid = -1;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ ::usleep(50000);
|
|
|
+ }
|
|
|
+ ::kill(pid, SIGKILL);
|
|
|
+ ::waitpid(pid, nullptr, 0);
|
|
|
+ pid = -1;
|
|
|
+}
|
|
|
+
|
|
|
+void destroy_session_locked(std::shared_ptr<ShellSession> s) {
|
|
|
+ if (!s) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ s->alive.store(false);
|
|
|
+ close_fd(s->master);
|
|
|
+ if (s->reader.joinable()) {
|
|
|
+ if (std::this_thread::get_id() != s->reader.get_id()) {
|
|
|
+ s->reader.join();
|
|
|
+ } else {
|
|
|
+ s->reader.detach();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ kill_child(s->pid);
|
|
|
+}
|
|
|
+
|
|
|
+void reader_loop(std::shared_ptr<ShellSession> s) {
|
|
|
+ char buf[4096];
|
|
|
+ while (s->alive.load()) {
|
|
|
+ if (s->master < 0 || !s->hp) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ fd_set rfds;
|
|
|
+ FD_ZERO(&rfds);
|
|
|
+ FD_SET(s->master, &rfds);
|
|
|
+ timeval tv{};
|
|
|
+ tv.tv_sec = 0;
|
|
|
+ tv.tv_usec = 200000;
|
|
|
+ const int ready = ::select(s->master + 1, &rfds, nullptr, nullptr, &tv);
|
|
|
+ if (ready < 0) {
|
|
|
+ if (errno == EINTR) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (ready == 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (!FD_ISSET(s->master, &rfds)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const ssize_t n = ::read(s->master, buf, sizeof(buf));
|
|
|
+ if (n > 0) {
|
|
|
+ if (!send_ws(s->hp, s->connid, buf, static_cast<size_t>(n), 0x2)) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (n == 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ if (errno == EINTR || errno == EAGAIN) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (s->alive.exchange(false)) {
|
|
|
+ const char* bye = "\r\n\x1b[90m[shell 已退出]\x1b[0m\r\n";
|
|
|
+ send_ws(s->hp, s->connid, bye, std::strlen(bye), 0x1);
|
|
|
+ }
|
|
|
+
|
|
|
+ close_fd(s->master);
|
|
|
+ kill_child(s->pid);
|
|
|
+
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ auto it = g_sessions.find(s->connid);
|
|
|
+ if (it != g_sessions.end() && it->second.get() == s.get()) {
|
|
|
+ g_sessions.erase(it);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+bool spawn_shell(std::shared_ptr<ShellSession> s, unsigned short cols,
|
|
|
+ unsigned short rows, std::string& err) {
|
|
|
+ winsize wsz{};
|
|
|
+ wsz.ws_col = cols > 0 ? cols : 80;
|
|
|
+ wsz.ws_row = rows > 0 ? rows : 24;
|
|
|
+
|
|
|
+ int master = -1;
|
|
|
+ const pid_t pid = forkpty(&master, nullptr, nullptr, &wsz);
|
|
|
+ if (pid < 0) {
|
|
|
+ err = std::string("forkpty 失败: ") + std::strerror(errno);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (pid == 0) {
|
|
|
+ ::setenv("TERM", "xterm-256color", 1);
|
|
|
+ ::setenv("COLORTERM", "truecolor", 1);
|
|
|
+ const char* shell = ::getenv("SHELL");
|
|
|
+ if (!shell || !*shell) {
|
|
|
+ shell = "/bin/bash";
|
|
|
+ }
|
|
|
+ ::execl(shell, shell, "-l", static_cast<char*>(nullptr));
|
|
|
+ ::execl("/bin/bash", "bash", "-l", static_cast<char*>(nullptr));
|
|
|
+ ::execl("/bin/sh", "sh", static_cast<char*>(nullptr));
|
|
|
+ ::_exit(127);
|
|
|
+ }
|
|
|
+
|
|
|
+ const int flags = ::fcntl(master, F_GETFL, 0);
|
|
|
+ if (flags >= 0) {
|
|
|
+ ::fcntl(master, F_SETFL, flags | O_NONBLOCK);
|
|
|
+ }
|
|
|
+
|
|
|
+ s->master = master;
|
|
|
+ s->pid = pid;
|
|
|
+ s->reader = std::thread(reader_loop, s);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+void write_input(const std::shared_ptr<ShellSession>& s, const char* data,
|
|
|
+ size_t len) {
|
|
|
+ if (!s || s->master < 0 || !data || len == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ size_t off = 0;
|
|
|
+ while (off < len && s->alive.load()) {
|
|
|
+ const ssize_t n = ::write(s->master, data + off, len - off);
|
|
|
+ if (n > 0) {
|
|
|
+ off += static_cast<size_t>(n);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (n < 0 && (errno == EINTR || errno == EAGAIN)) {
|
|
|
+ ::usleep(1000);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void resize_pty(const std::shared_ptr<ShellSession>& s, unsigned short cols,
|
|
|
+ unsigned short rows) {
|
|
|
+ if (!s || s->master < 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ winsize wsz{};
|
|
|
+ wsz.ws_col = cols > 0 ? cols : 80;
|
|
|
+ wsz.ws_row = rows > 0 ? rows : 24;
|
|
|
+ ::ioctl(s->master, TIOCSWINSZ, &wsz);
|
|
|
+}
|
|
|
+
|
|
|
+std::string ws_accept_key(const std::string& sec_key) {
|
|
|
+ const std::string material = sec_key + kWsGuid;
|
|
|
+ ylib::buffer dig = ylib::codec::sha1(ylib::buffer(material));
|
|
|
+ return ylib::codec::base64::en(dig);
|
|
|
+}
|
|
|
+
|
|
|
+bool handle_upgrade(request* req, response* resp, websocket_message* ws) {
|
|
|
+ if (!req || !resp || !ws) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ auto fail = [&](ushort code, const char* desc) {
|
|
|
+ resp->send_header(code, desc ? desc : "Error");
|
|
|
+ return false;
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!req->reqpack() || !req->reqpack()->server()) {
|
|
|
+ return fail(500, "Internal Server Error");
|
|
|
+ }
|
|
|
+
|
|
|
+ // Cookie 在升级异步回调里经常读不到;用一次性 ticket(URL query)鉴权。
|
|
|
+ const std::string path = req->filepath();
|
|
|
+ const std::string ticket = query_param(path, "ticket");
|
|
|
+ std::string user;
|
|
|
+ if (!shell_consume_ticket(ticket, &user)) {
|
|
|
+ return fail(401, "Unauthorized");
|
|
|
+ }
|
|
|
+
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ if (static_cast<int>(g_sessions.size()) >= kMaxSessions) {
|
|
|
+ return fail(503, "Service Unavailable");
|
|
|
+ }
|
|
|
+ if (g_sessions.count(ws->connid)) {
|
|
|
+ return fail(409, "Conflict");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ auto* hp =
|
|
|
+ static_cast<IHttpServer*>(req->reqpack()->server()->hpserver());
|
|
|
+ if (!hp) {
|
|
|
+ return fail(500, "Internal Server Error");
|
|
|
+ }
|
|
|
+
|
|
|
+ auto session = std::make_shared<ShellSession>();
|
|
|
+ session->connid = ws->connid;
|
|
|
+ session->hp = hp;
|
|
|
+
|
|
|
+ std::string err;
|
|
|
+ if (!spawn_shell(session, 80, 24, err)) {
|
|
|
+ return fail(500, "Internal Server Error");
|
|
|
+ }
|
|
|
+
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ g_sessions[ws->connid] = session;
|
|
|
+ }
|
|
|
+
|
|
|
+ const std::string accept = ws_accept_key(ws->sec_websocket_key);
|
|
|
+ (*resp->headers())["Upgrade"] = "websocket";
|
|
|
+ (*resp->headers())["Connection"] = "Upgrade";
|
|
|
+ (*resp->headers())["Sec-WebSocket-Accept"] = accept;
|
|
|
+ resp->send_header(101, "Switching Protocols");
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+void handle_body(request* req, response* resp, websocket_message* ws) {
|
|
|
+ if (!resp || !ws) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const int opcode = ws->header.opcode;
|
|
|
+ const ylib::buffer& body = req ? req->body() : ylib::buffer();
|
|
|
+
|
|
|
+ if (opcode == 0x8) {
|
|
|
+ resp->send_ws(reinterpret_cast<const char*>(body.data()), body.length(),
|
|
|
+ 0x8);
|
|
|
+ shell_on_conn_close(ws->connid);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (opcode == 0x9) {
|
|
|
+ resp->send_ws(reinterpret_cast<const char*>(body.data()), body.length(),
|
|
|
+ 0xA);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (opcode == 0xA) {
|
|
|
+ resp->response_done();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ std::shared_ptr<ShellSession> session;
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ auto it = g_sessions.find(ws->connid);
|
|
|
+ if (it != g_sessions.end()) {
|
|
|
+ session = it->second;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!session) {
|
|
|
+ resp->response_done();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (body.length() == 0) {
|
|
|
+ resp->response_done();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const char* p = reinterpret_cast<const char*>(body.data());
|
|
|
+ const size_t n = body.length();
|
|
|
+
|
|
|
+ if (p[0] == '{') {
|
|
|
+ try {
|
|
|
+ ylib::json j = ylib::json::from(std::string(p, n));
|
|
|
+ const std::string type =
|
|
|
+ (j.exist("type") && j["type"].is_string())
|
|
|
+ ? j["type"].to<std::string>(true)
|
|
|
+ : "";
|
|
|
+ if (type == "resize") {
|
|
|
+ unsigned short cols = 80;
|
|
|
+ unsigned short rows = 24;
|
|
|
+ if (j.exist("cols")) {
|
|
|
+ cols = static_cast<unsigned short>(j["cols"].to<int32>(true));
|
|
|
+ }
|
|
|
+ if (j.exist("rows")) {
|
|
|
+ rows = static_cast<unsigned short>(j["rows"].to<int32>(true));
|
|
|
+ }
|
|
|
+ resize_pty(session, cols, rows);
|
|
|
+ resp->response_done();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ } catch (...) {
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ write_input(session, p, n);
|
|
|
+ resp->response_done();
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
+std::string shell_issue_ticket(const std::string& username) {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ purge_tickets_locked();
|
|
|
+ const std::string ticket = random_hex(24);
|
|
|
+ ShellTicket t;
|
|
|
+ t.username = username.empty() ? "admin" : username;
|
|
|
+ t.expire_at = now_sec() + kTicketTtlSec;
|
|
|
+ g_tickets[ticket] = t;
|
|
|
+ return ticket;
|
|
|
+}
|
|
|
+
|
|
|
+bool shell_consume_ticket(const std::string& ticket, std::string* username) {
|
|
|
+ if (ticket.empty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ purge_tickets_locked();
|
|
|
+ auto it = g_tickets.find(ticket);
|
|
|
+ if (it == g_tickets.end()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (username) {
|
|
|
+ *username = it->second.username;
|
|
|
+ }
|
|
|
+ g_tickets.erase(it);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+void shell_ws_handler(request* req, response* resp, websocket_message* ws) {
|
|
|
+ if (!resp) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!ws) {
|
|
|
+ resp->response_done();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ switch (ws->type) {
|
|
|
+ case HTTP_SERVER_WEBSOCKET_TYPE_UPGRADE:
|
|
|
+ handle_upgrade(req, resp, ws);
|
|
|
+ break;
|
|
|
+ case HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_HEADER:
|
|
|
+ resp->response_done();
|
|
|
+ break;
|
|
|
+ case HTTP_SERVER_WEBSOCKET_TYPE_MESSAGE_BODY:
|
|
|
+ handle_body(req, resp, ws);
|
|
|
+ break;
|
|
|
+ case HTTP_SERVER_WEBSOCKET_TYPE_CLOSE:
|
|
|
+ shell_on_conn_close(ws->connid);
|
|
|
+ return;
|
|
|
+ default:
|
|
|
+ resp->response_done();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void shell_on_conn_close(uint64_t connid) {
|
|
|
+ std::shared_ptr<ShellSession> s;
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ auto it = g_sessions.find(connid);
|
|
|
+ if (it == g_sessions.end()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ s = it->second;
|
|
|
+ g_sessions.erase(it);
|
|
|
+ }
|
|
|
+ destroy_session_locked(s);
|
|
|
+}
|
|
|
+
|
|
|
+void shell_shutdown() {
|
|
|
+ std::vector<std::shared_ptr<ShellSession>> all;
|
|
|
+ {
|
|
|
+ std::lock_guard<std::mutex> lock(g_mu);
|
|
|
+ g_tickets.clear();
|
|
|
+ for (auto& kv : g_sessions) {
|
|
|
+ all.push_back(kv.second);
|
|
|
+ }
|
|
|
+ g_sessions.clear();
|
|
|
+ }
|
|
|
+ for (auto& s : all) {
|
|
|
+ destroy_session_locked(s);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace system
|
|
|
+} // namespace ngs
|