feat: Implement WebSocket-based SSH terminal functionality and refactor the frontend using the Tabler.io framework.
Build Docker Container using Multistage Build / build (push) Failing after 4m17s
Build Docker Container using Multistage Build / build (push) Failing after 4m17s
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = ["--debug", "serve"]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "json"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
|
||||
+538
-178
@@ -1,207 +1,567 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Server Management</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 text-gray-800">
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
|
||||
<title>Nexus Node | Server Management</title>
|
||||
<!-- CSS files -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css" rel="stylesheet"/>
|
||||
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler-flags.min.css" rel="stylesheet"/>
|
||||
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler-payments.min.css" rel="stylesheet"/>
|
||||
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler-vendors.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<style>
|
||||
@import url('https://rsms.me/inter/inter.css');
|
||||
:root {
|
||||
--tblr-font-sans-serif: 'Inter Var', -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif;
|
||||
}
|
||||
body {
|
||||
font-feature-settings: "cv03", "cv04", "cv11";
|
||||
}
|
||||
.terminal-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
<div class="container mx-auto p-8">
|
||||
<h1 class="text-4xl font-bold mb-8">Server Management</h1>
|
||||
/* Theme toggle fix */
|
||||
body[data-bs-theme="dark"] .hide-theme-dark { display: none !important; }
|
||||
body[data-bs-theme="dark"] .hide-theme-light { display: flex !important; }
|
||||
|
||||
<div id="server-table-container" class="bg-white shadow-md rounded-lg overflow-hidden">
|
||||
<table class="min-w-full leading-normal">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Name</th>
|
||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">IP</th>
|
||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">MAC</th>
|
||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Status</th>
|
||||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="server-table-body">
|
||||
<!-- Server rows will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
body[data-bs-theme="light"] .hide-theme-light { display: none !important; }
|
||||
body[data-bs-theme="light"] .hide-theme-dark { display: flex !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body >
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler-theme.min.js"></script>
|
||||
<div class="page">
|
||||
<!-- Sidebar -->
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg">
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<h1 class="navbar-brand navbar-brand-autodark">
|
||||
<a href=".">
|
||||
<span class="text-primary"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-server-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||
<path d="M3 12m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||
<path d="M7 8l0 .01"></path>
|
||||
<path d="M7 16l0 .01"></path>
|
||||
</svg></span>
|
||||
Nexus Node
|
||||
</a>
|
||||
</h1>
|
||||
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||
<ul class="navbar-nav pt-lg-3">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="./">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block"><!-- Download SVG icon from http://tabler-icons.io/i/home -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l-2 0l9 -9l9 9l-2 0" /><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" /><path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" /></svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Servers
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">
|
||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"></path>
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nav-link-title">
|
||||
Settings (Coming Soon)
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div id="no-servers-message" class="hidden bg-white shadow-md rounded-lg p-8 text-center">
|
||||
<p class="text-gray-600">No servers found. Please check your configuration.</p>
|
||||
</aside>
|
||||
|
||||
<!-- Navbar (Mobile + Desktop Top Bar for Theme Toggle) -->
|
||||
<header class="navbar navbar-expand-md d-none d-lg-flex d-print-none" >
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="navbar-nav flex-row order-md-last">
|
||||
<div class="d-none d-md-flex">
|
||||
<a href="?theme=dark" id="btn-enable-dark" class="nav-link px-0 hide-theme-dark" title="Enable dark mode" data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" /></svg>
|
||||
</a>
|
||||
<a href="?theme=light" id="btn-enable-light" class="nav-link px-0 hide-theme-light" title="Enable light mode" data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||
<div>
|
||||
<!-- Search or breadcrumbs could go here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<!-- Page header -->
|
||||
<div class="page-header d-print-none">
|
||||
<div class="container-fluid">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<h2 class="page-title">
|
||||
Server Registry
|
||||
</h2>
|
||||
<div class="text-secondary mt-1">Manage your infrastructure</div>
|
||||
</div>
|
||||
<!-- Page title actions -->
|
||||
<div class="col-auto ms-auto d-print-none">
|
||||
<div class="btn-list">
|
||||
<a href="#" class="btn btn-primary d-none d-sm-inline-block" data-bs-toggle="modal" data-bs-target="#modal-server">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>
|
||||
Register Server
|
||||
</a>
|
||||
<a href="#" class="btn btn-primary d-sm-none btn-icon" data-bs-toggle="modal" data-bs-target="#modal-server" aria-label="Create new report">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button id="add-server-button" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
|
||||
Add New Server
|
||||
</button>
|
||||
<!-- Page body -->
|
||||
<div class="page-body">
|
||||
<div class="container-fluid">
|
||||
<div class="row row-deck row-cards" id="server-list-container">
|
||||
<!-- Servers injected here -->
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div id="no-servers-message" class="empty d-none">
|
||||
<div class="empty-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-server-off" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path><path d="M14.503 14.505a3 3 0 0 1 -2.503 -2.505m0 3h-6a3 3 0 0 1 -3 -3v-2a3 3 0 0 1 3 -3"></path><path d="M7 8l0 .01"></path><path d="M7 16l0 .01"></path><path d="M3 3l18 18"></path></svg></div>
|
||||
<p class="empty-title">No servers found</p>
|
||||
<p class="empty-subtitle text-secondary">
|
||||
Add a server to start monitoring your infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="add-server-form-container" class="hidden mt-8 bg-white shadow-md rounded-lg p-8">
|
||||
<h2 class="text-2xl font-bold mb-4">Add New Server</h2>
|
||||
<form id="add-server-form">
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-gray-700 text-sm font-bold mb-2">Name</label>
|
||||
<input type="text" id="name" name="name" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="ip" class="block text-gray-700 text-sm font-bold mb-2">IP Address</label>
|
||||
<input type="text" id="ip" name="ip" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="mac" class="block text-gray-700 text-sm font-bold mb-2">MAC Address</label>
|
||||
<input type="text" id="mac" name="mac" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="ssh_user" class="block text-gray-700 text-sm font-bold mb-2">SSH User</label>
|
||||
<input type="text" id="ssh_user" name="ssh_user" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="ssh_pass" class="block text-gray-700 text-sm font-bold mb-2">SSH Password</label>
|
||||
<input type="password" id="ssh_pass" name="ssh_pass" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
||||
Save Server
|
||||
</button>
|
||||
<button type="button" id="cancel-add-server" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="footer footer-transparent d-print-none">
|
||||
<div class="container-fluid">
|
||||
<div class="row text-center align-items-center flex-row-reverse">
|
||||
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||
<ul class="list-inline list-inline-dots mb-0">
|
||||
<li class="list-inline-item">
|
||||
Nexus Node © 2026
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Modal -->
|
||||
<div class="modal modal-blur fade" id="modal-server" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-title">New Server</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="server-form">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="oldName" name="oldName">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Server Name</label>
|
||||
<input type="text" class="form-control" name="name" id="input-name" placeholder="e.g. edge-server-01" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">IP Address</label>
|
||||
<div class="input-group input-group-flat">
|
||||
<input type="text" class="form-control" name="ip" id="input-ip" placeholder="192.168.1.10" required>
|
||||
<span class="input-group-text">
|
||||
<a href="#" class="link-secondary" title="Auto-find MAC" data-bs-toggle="tooltip" id="btn-find-mac" style="text-decoration: none;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">MAC Address</label>
|
||||
<input type="text" class="form-control" name="mac" id="input-mac" placeholder="00:11:22:33:44:55" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label">SSH Credentials <span class="form-label-description">(Optional for Proxy Wake)</span></label>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" name="ssh_user" id="input-user" placeholder="Username">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" name="ssh_pass" id="input-pass" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="#" class="btn btn-link link-secondary" data-bs-dismiss="modal">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary ms-auto" data-bs-dismiss="modal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>
|
||||
Save Server
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Modal -->
|
||||
<div class="modal modal-blur fade" id="modal-terminal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-full-width modal-dialog-centered" role="document">
|
||||
<div class="modal-content" style="height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="terminal-title">Terminal</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="btn-close-terminal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0 bg-black d-flex flex-column">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toasts -->
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<!-- Toasts injected here via JS -->
|
||||
</div>
|
||||
|
||||
<!-- Core Libs -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
fetchServers();
|
||||
let terminal = null;
|
||||
let socket = null;
|
||||
let fitAddon = null;
|
||||
|
||||
const addServerButton = document.getElementById("add-server-button");
|
||||
const addServerFormContainer = document.getElementById("add-server-form-container");
|
||||
const cancelAddServerButton = document.getElementById("cancel-add-server");
|
||||
const addServerForm = document.getElementById("add-server-form");
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
fetchServers();
|
||||
|
||||
addServerButton.addEventListener("click", () => {
|
||||
addServerFormContainer.classList.remove("hidden");
|
||||
addServerButton.classList.add("hidden");
|
||||
// Theme Toggle Logic
|
||||
const themeStorageKey = 'tablerTheme';
|
||||
const initialTheme = localStorage.getItem(themeStorageKey) || 'light';
|
||||
document.body.setAttribute('data-bs-theme', initialTheme);
|
||||
|
||||
const btnDark = document.getElementById('btn-enable-dark');
|
||||
const btnLight = document.getElementById('btn-enable-light');
|
||||
|
||||
if(btnDark) {
|
||||
btnDark.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.body.setAttribute('data-bs-theme', 'dark');
|
||||
localStorage.setItem(themeStorageKey, 'dark');
|
||||
});
|
||||
}
|
||||
|
||||
cancelAddServerButton.addEventListener("click", () => {
|
||||
addServerFormContainer.classList.add("hidden");
|
||||
addServerButton.classList.remove("hidden");
|
||||
addServerForm.reset();
|
||||
if(btnLight) {
|
||||
btnLight.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.body.setAttribute('data-bs-theme', 'light');
|
||||
localStorage.setItem(themeStorageKey, 'light');
|
||||
});
|
||||
}
|
||||
|
||||
addServerForm.addEventListener("submit", function(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(addServerForm);
|
||||
const serverData = Object.fromEntries(formData.entries());
|
||||
const form = document.getElementById("server-form");
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const oldName = document.getElementById("oldName").value;
|
||||
|
||||
fetch("/servers", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(serverData),
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
fetchServers();
|
||||
addServerFormContainer.classList.add("hidden");
|
||||
addServerButton.classList.remove("hidden");
|
||||
addServerForm.reset();
|
||||
} else {
|
||||
alert("Failed to add server.");
|
||||
}
|
||||
});
|
||||
const method = oldName ? "PUT" : "POST";
|
||||
const url = oldName ? `/servers?oldName=${encodeURIComponent(oldName)}` : "/servers";
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => {
|
||||
if(r.ok) {
|
||||
fetchServers();
|
||||
showToast(oldName ? "Server updated" : "Server registered", "success");
|
||||
// Modal auto closes due to data-bs-dismiss, but we should reset form
|
||||
resetForm();
|
||||
} else {
|
||||
showToast("Operation failed", "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function fetchServers() {
|
||||
fetch("/servers")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const tableContainer = document.getElementById("server-table-container");
|
||||
const noServersMessage = document.getElementById("no-servers-message");
|
||||
const tableBody = document.getElementById("server-table-body");
|
||||
document.getElementById('modal-server').addEventListener('hidden.bs.modal', resetForm);
|
||||
|
||||
tableBody.innerHTML = ""; // Clear existing rows
|
||||
document.getElementById("btn-find-mac").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const btn = e.currentTarget;
|
||||
const originalHtml = btn.innerHTML;
|
||||
const ip = document.getElementById("input-ip").value;
|
||||
const user = document.getElementById("input-user").value;
|
||||
const pass = document.getElementById("input-pass").value;
|
||||
|
||||
if (data && data.length > 0) {
|
||||
tableContainer.classList.remove("hidden");
|
||||
noServersMessage.classList.add("hidden");
|
||||
if(!ip) { showToast("IP Address required", "warning"); return; }
|
||||
|
||||
data.forEach(server => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.name}</td>
|
||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.ip}</td>
|
||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.mac}</td>
|
||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm" id="status-${server.name}">Checking...</td>
|
||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
||||
<button onclick="wakeServer('${server.name}')" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Wake</button>
|
||||
<button onclick="shutdownServer('${server.name}')" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">Shutdown</button>
|
||||
<button onclick="rebootServer('${server.name}')" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">Reboot</button>
|
||||
<button onclick="deleteServer('${server.name}')" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
checkServerStatus(server.name);
|
||||
});
|
||||
} else {
|
||||
tableContainer.classList.add("hidden");
|
||||
noServersMessage.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
||||
|
||||
function checkServerStatus(serverName) {
|
||||
fetch(`/status?name=${serverName}`)
|
||||
.then(response => response.text())
|
||||
.then(status => {
|
||||
const statusCell = document.getElementById(`status-${serverName}`);
|
||||
statusCell.textContent = status;
|
||||
if (status === "Online") {
|
||||
statusCell.classList.add("text-green-500", "font-bold");
|
||||
statusCell.classList.remove("text-red-500");
|
||||
} else {
|
||||
statusCell.classList.add("text-red-500", "font-bold");
|
||||
statusCell.classList.remove("text-green-500");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wakeServer(serverName) {
|
||||
fetch(`/wake?name=${serverName}`).then(() => alert(`${serverName} wake signal sent.`));
|
||||
}
|
||||
|
||||
function shutdownServer(serverName) {
|
||||
if (confirm(`Are you sure you want to shutdown ${serverName}?`)) {
|
||||
fetch(`/shutdown?name=${serverName}`).then(() => alert(`${serverName} is shutting down.`));
|
||||
}
|
||||
}
|
||||
|
||||
function rebootServer(serverName) {
|
||||
if (confirm(`Are you sure you want to reboot ${serverName}?`)) {
|
||||
fetch(`/reboot?name=${serverName}`).then(() => alert(`${serverName} is rebooting.`));
|
||||
}
|
||||
}
|
||||
|
||||
function deleteServer(serverName) {
|
||||
if (confirm(`Are you sure you want to delete ${serverName}? This action cannot be undone.`)) {
|
||||
fetch(`/servers?name=${serverName}`, {
|
||||
method: 'DELETE',
|
||||
fetch(`/find-mac?ip=${ip}&user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`)
|
||||
.then(r => r.ok ? r.text() : r.text().then(t => {throw t}))
|
||||
.then(mac => {
|
||||
document.getElementById("input-mac").value = mac;
|
||||
showToast("MAC Address Found", "success");
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
fetchServers(); // Refresh the server list
|
||||
} else {
|
||||
alert(`Failed to delete ${serverName}.`);
|
||||
}
|
||||
});
|
||||
.catch(e => showToast(e, "danger"))
|
||||
.finally(() => btn.innerHTML = originalHtml);
|
||||
});
|
||||
|
||||
// Terminal cleanup
|
||||
document.getElementById('modal-terminal').addEventListener('hidden.bs.modal', function () {
|
||||
if(socket) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
if(terminal) {
|
||||
terminal.dispose();
|
||||
terminal = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh server status every 30 seconds
|
||||
setInterval(fetchServers, 30000);
|
||||
// Terminal resize observer
|
||||
new ResizeObserver(() => {
|
||||
if(fitAddon) fitAddon.fit();
|
||||
}).observe(document.getElementById('terminal-container'));
|
||||
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById("server-form").reset();
|
||||
document.getElementById("oldName").value = "";
|
||||
document.getElementById("modal-title").textContent = "New Server";
|
||||
}
|
||||
|
||||
function fetchServers() {
|
||||
fetch("/servers").then(r => r.json()).then(data => {
|
||||
const container = document.getElementById("server-list-container");
|
||||
const empty = document.getElementById("no-servers-message");
|
||||
container.innerHTML = "";
|
||||
|
||||
if(data && data.length > 0) {
|
||||
container.classList.remove("d-none");
|
||||
empty.classList.add("d-none");
|
||||
|
||||
data.sort((a,b) => a.name.localeCompare(b.name));
|
||||
|
||||
data.forEach(s => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "col-12 col-md-6 col-lg-4";
|
||||
div.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-status-top bg-secondary" id="status-bar-${s.name}"></div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<span class="avatar rounded" id="status-avatar-${s.name}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-server" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path><path d="M3 12m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path><path d="M7 8l0 .01"></path><path d="M7 16l0 .01"></path></svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h3 class="card-title text-truncate mb-1">${s.name}</h3>
|
||||
<div class="text-secondary text-truncate">${s.ip}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<a href="#" class="btn-action" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a href="#" class="dropdown-item" onclick="editServer('${s.name}', '${s.ip}', '${s.mac}', '${(s.ssh_user||"").replace(/'/g, "\\'")}', '${(s.ssh_pass||"").replace(/'/g, "\\'")}')">Edit</a>
|
||||
<a href="#" class="dropdown-item text-danger" onclick="deleteServer('${s.name}')">Delete</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 row">
|
||||
<div class="col">Status: <span id="status-text-${s.name}" class="badge bg-secondary-lt">Checking</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="#" onclick="openTerminal('${s.name}')" class="btn btn-ghost-primary btn-icon" title="Terminal" data-bs-toggle="tooltip">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-terminal-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9l3 3l-3 3" /><path d="M13 15l3 0" /><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>
|
||||
</a>
|
||||
<a href="#" onclick="wakeServer('${s.name}')" class="btn btn-ghost-success btn-icon" title="Wake-on-LAN" data-bs-toggle="tooltip">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-power" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 6a7.75 7.75 0 1 0 10 0" /><path d="M12 4l0 8" /></svg>
|
||||
</a>
|
||||
<a href="#" onclick="rebootServer('${s.name}')" class="btn btn-ghost-warning btn-icon" title="Reboot" data-bs-toggle="tooltip">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-refresh" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" /><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" /></svg>
|
||||
</a>
|
||||
<a href="#" onclick="shutdownServer('${s.name}')" class="btn btn-ghost-danger btn-icon" title="Shutdown" data-bs-toggle="tooltip">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-plug" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054z" /><path d="M4 20l3.5 -3.5" /><path d="M15 4l-3.5 3.5" /><path d="M20 9l-3.5 3.5" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
checkStatus(s.name);
|
||||
});
|
||||
} else {
|
||||
container.classList.add("d-none");
|
||||
empty.classList.remove("d-none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkStatus(name) {
|
||||
fetch(`/status?name=${name}`).then(r => r.text()).then(status => {
|
||||
const badge = document.getElementById(`status-text-${name}`);
|
||||
const bar = document.getElementById(`status-bar-${name}`);
|
||||
const avatar = document.getElementById(`status-avatar-${name}`);
|
||||
|
||||
if(!badge) return;
|
||||
badge.textContent = status;
|
||||
badge.className = "badge";
|
||||
|
||||
if(status === "Online") {
|
||||
badge.classList.add("bg-success-lt");
|
||||
bar.className = "card-status-top bg-success";
|
||||
avatar.className = "avatar rounded bg-success-lt text-success";
|
||||
} else {
|
||||
badge.classList.add("bg-danger-lt");
|
||||
bar.className = "card-status-top bg-danger";
|
||||
avatar.className = "avatar rounded bg-danger-lt text-danger";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editServer(name, ip, mac, user, pass) {
|
||||
document.getElementById("modal-title").textContent = "Edit Server";
|
||||
document.getElementById("oldName").value = name;
|
||||
document.getElementById("input-name").value = name;
|
||||
document.getElementById("input-ip").value = ip;
|
||||
document.getElementById("input-mac").value = mac;
|
||||
document.getElementById("input-user").value = (user && user !== "undefined") ? user : "";
|
||||
document.getElementById("input-pass").value = (pass && pass !== "undefined") ? pass : "";
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('modal-server'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function deleteServer(name) {
|
||||
if(confirm(`Are you sure you want to delete ${name}?`)) {
|
||||
fetch(`/servers?name=${name}`, { method: 'DELETE' }).then(r => {
|
||||
fetchServers();
|
||||
showToast("Server deleted", "secondary");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wakeServer(name) {
|
||||
fetch(`/wake?name=${name}`).then(() => showToast(`Wake signal sent to ${name}`, "success"));
|
||||
}
|
||||
function rebootServer(name) {
|
||||
if(confirm(`Reboot ${name}?`)) fetch(`/reboot?name=${name}`).then(() => showToast(`Rebooting ${name}`, "warning"));
|
||||
}
|
||||
function shutdownServer(name) {
|
||||
if(confirm(`Shutdown ${name}?`)) fetch(`/shutdown?name=${name}`).then(() => showToast(`Shutting down ${name}`, "danger"));
|
||||
}
|
||||
|
||||
function openTerminal(name) {
|
||||
document.getElementById("terminal-title").textContent = `Terminal: ${name}`;
|
||||
const modalEl = document.getElementById("modal-terminal");
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
|
||||
modalEl.addEventListener('shown.bs.modal', () => {
|
||||
const container = document.getElementById("terminal-container");
|
||||
container.innerHTML = "";
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: { background: "#000", foreground: "#fff" },
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace'
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// Wait a bit for layout to settle
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
const rows = terminal.rows;
|
||||
const cols = terminal.cols;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
socket = new WebSocket(`${protocol}//${window.location.host}/ssh?name=${encodeURIComponent(name)}&rows=${rows}&cols=${cols}`);
|
||||
|
||||
socket.onopen = () => terminal.write("\r\n--- Connected to Nexus Node Proxy ---\r\n");
|
||||
socket.onmessage = (e) => terminal.write(e.data);
|
||||
socket.onclose = () => terminal.write("\r\n--- Disconnected ---\r\n");
|
||||
|
||||
terminal.onData(data => {
|
||||
if(socket && socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||
});
|
||||
}, 200);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function showToast(msg, type="info") {
|
||||
const container = document.querySelector(".toast-container");
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast align-items-center text-white bg-${type} border-0`;
|
||||
el.setAttribute("role", "alert");
|
||||
el.setAttribute("aria-live", "assertive");
|
||||
el.setAttribute("aria-atomic", "true");
|
||||
el.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
${msg}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(el);
|
||||
const toast = new bootstrap.Toast(el);
|
||||
toast.show();
|
||||
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||
}
|
||||
|
||||
setInterval(fetchServers, 30000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -11,48 +12,58 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||
flag.Parse()
|
||||
|
||||
if *debug {
|
||||
serveractions.Debug = true
|
||||
serveractions.LogDebug("Debug mode enabled")
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) < 1 {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading servers: %v\n", err)
|
||||
return
|
||||
}
|
||||
serveractions.LogDebug("Loaded %d servers from config", len(servers))
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
command := args[0]
|
||||
switch command {
|
||||
case "list":
|
||||
listServers(servers)
|
||||
case "wake":
|
||||
if len(os.Args) < 3 {
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Please specify a server name to wake.")
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
serverName := os.Args[2]
|
||||
serverName := args[1]
|
||||
serveractions.WakeServer(serverName, servers)
|
||||
case "wakeall":
|
||||
serveractions.WakeAllServers(servers)
|
||||
case "status":
|
||||
serveractions.CheckServersStatus(servers)
|
||||
case "shutdown":
|
||||
if len(os.Args) < 3 {
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Please specify a server name to shutdown.")
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
serverName := os.Args[2]
|
||||
serverName := args[1]
|
||||
serveractions.ShutdownServer(serverName, servers)
|
||||
case "reboot":
|
||||
if len(os.Args) < 3 {
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Please specify a server name to reboot.")
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
serverName := os.Args[2]
|
||||
serverName := args[1]
|
||||
serveractions.RebootServer(serverName, servers)
|
||||
case "serve":
|
||||
webserver.StartWebServer(servers)
|
||||
@@ -63,7 +74,9 @@ func main() {
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("Usage: go run . <command>")
|
||||
fmt.Println("Usage: go run . [--debug] <command>")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" --debug - Enable debug logging")
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" list - List all configured servers")
|
||||
fmt.Println(" wake <server_name> - Wake a specific server")
|
||||
@@ -75,14 +88,15 @@ func printUsage() {
|
||||
}
|
||||
|
||||
func loadConfig() ([]serveractions.Server, error) {
|
||||
viper.SetConfigName("servers") // name of config file (without extension)
|
||||
viper.SetConfigType("json") // or viper.SetConfigType("YAML")
|
||||
viper.AddConfigPath("./config") // path to look for the config file in
|
||||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
||||
err := viper.ReadInConfig() // Find and read the config file
|
||||
if err != nil { // Handle errors reading the config file
|
||||
viper.SetConfigName("servers") // name of config file (without extension)
|
||||
viper.SetConfigType("json") // or viper.SetConfigType("YAML")
|
||||
viper.AddConfigPath("./config") // path to look for the config file in
|
||||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
||||
err := viper.ReadInConfig() // Find and read the config file
|
||||
if err != nil { // Handle errors reading the config file
|
||||
// Check if the error is that the file doesn't exist
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
os.WriteFile("debug_config.log", []byte("Config file not found\n"), 0644)
|
||||
// Config file not found; ignore error and return empty server list
|
||||
return []serveractions.Server{}, nil
|
||||
}
|
||||
|
||||
@@ -4,38 +4,78 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var Debug bool
|
||||
|
||||
func LogDebug(format string, v ...interface{}) {
|
||||
if Debug {
|
||||
fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", v...)
|
||||
}
|
||||
}
|
||||
|
||||
func WakeServer(name string, servers []Server) {
|
||||
for _, s := range servers {
|
||||
if s.Name == name {
|
||||
fmt.Printf("Sending Wake-on-LAN packet to %s (%s)...\n", s.Name, s.Mac)
|
||||
err := sendMagicPacket(s.Mac)
|
||||
err := sendMagicPacket(s.Mac, s.IP)
|
||||
if err != nil {
|
||||
fmt.Printf("Error sending packet: %v\n", err)
|
||||
fmt.Printf("Error sending packet from local network: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Packet sent successfully.")
|
||||
fmt.Println("Local WOL packet sent successfully.")
|
||||
}
|
||||
|
||||
// Try to wake via proxy if there's another online server in the same subnet
|
||||
LogDebug("Checking for online servers in the same subnet to use as a proxy...")
|
||||
for _, other := range servers {
|
||||
if other.Name != s.Name && other.IP != "" && strings.HasPrefix(other.IP, s.IP[:strings.LastIndex(s.IP, ".")]) {
|
||||
// Only use as proxy if we have credentials
|
||||
if other.SSHUser != "" && other.SSHPass != "" {
|
||||
if PingHost(other.IP) {
|
||||
fmt.Printf("Attempting to wake %s via proxy %s...\n", s.Name, other.Name)
|
||||
err := WakeServerRemote(other, s.Mac)
|
||||
if err != nil {
|
||||
LogDebug("Failed to wake via proxy %s: %v", other.Name, err)
|
||||
} else {
|
||||
fmt.Printf("Wake signal sent via proxy %s.\n", other.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Printf("Server '%s' not found in configuration.\n", name)
|
||||
}
|
||||
|
||||
func WakeServerRemote(proxy Server, targetMac string) error {
|
||||
// Send magic packet from the proxy server using a simple shell command
|
||||
// We use python or similar if available, or just a simple bash command that constructs the packet
|
||||
// constructing the packet in bash:
|
||||
// echo -e $(printf 'f%.0s' {1..12}; printf "$(echo $MAC | sed 's/://g')%.0s" {1..16}) | xxd -r -p | nc -w1 -u -b 255.255.255.255 9
|
||||
|
||||
// A simpler way if wakeonlan is installed: wakeonlan $MAC
|
||||
// But let's assume we might need to construct it or use a common tool.
|
||||
// We'll try common tools first.
|
||||
cmd := fmt.Sprintf("wakeonlan %s || ether-wake %s || (printf '%%b' \"$(printf '\\\\xff\\\\xff\\\\xff\\\\xff\\\\xff\\\\xff'; for i in {1..16}; do printf '%s' | sed 's/\\([0-9a-fA-F]\\{2\\}\\)/\\\\x\\1/g'; done)\" | nc -w1 -u -b 255.255.255.255 9)", targetMac, targetMac, targetMac)
|
||||
|
||||
return executeSSHCommand(proxy, cmd)
|
||||
}
|
||||
|
||||
func WakeAllServers(servers []Server) {
|
||||
fmt.Println("Waking all servers...")
|
||||
for _, s := range servers {
|
||||
fmt.Printf("Sending Wake-on-LAN packet to %s (%s)...\n", s.Name, s.Mac)
|
||||
err := sendMagicPacket(s.Mac)
|
||||
if err != nil {
|
||||
fmt.Printf(" - Error for %s: %v\n", s.Name, err)
|
||||
} else {
|
||||
fmt.Printf(" - Packet sent to %s.\n", s.Name)
|
||||
}
|
||||
WakeServer(s.Name, servers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +96,24 @@ func CheckServersStatus(servers []Server) {
|
||||
}
|
||||
|
||||
func PingHost(host string) bool {
|
||||
LogDebug("Pinging host: %s", host)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
LogDebug("Ping failed for %s: %v", host, err)
|
||||
} else {
|
||||
LogDebug("Ping successful for %s", host)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Creates and sends a magic packet to the specified MAC address.
|
||||
func sendMagicPacket(macAddr string) error {
|
||||
// Supports cross-VLAN by sending to both general and subnet-directed broadcast.
|
||||
func sendMagicPacket(macAddr string, ip string) error {
|
||||
hwAddr, err := net.ParseMAC(macAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid mac address '%s': %w", macAddr, err)
|
||||
@@ -80,14 +127,39 @@ func sendMagicPacket(macAddr string) error {
|
||||
copy(magicPacket[i*6:], hwAddr)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("udp", "255.255.255.255:9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
// List of addresses to try sending the WOL packet to
|
||||
// List of addresses to try sending the WOL packet to
|
||||
broadcastAddresses := []string{"255.255.255.255"}
|
||||
|
||||
_, err = conn.Write(magicPacket)
|
||||
return err
|
||||
// If a valid IP is provided, calculate the directed broadcast address (assuming /24 subnet)
|
||||
if ip != "" {
|
||||
ipParts := strings.Split(ip, ".")
|
||||
if len(ipParts) == 4 {
|
||||
directedBroadcast := fmt.Sprintf("%s.%s.%s.255", ipParts[0], ipParts[1], ipParts[2])
|
||||
if directedBroadcast != "255.255.255.255" {
|
||||
broadcastAddresses = append(broadcastAddresses, directedBroadcast)
|
||||
}
|
||||
broadcastAddresses = append(broadcastAddresses, ip)
|
||||
}
|
||||
}
|
||||
|
||||
ports := []int{7, 9}
|
||||
for _, addr := range broadcastAddresses {
|
||||
for _, port := range ports {
|
||||
fullAddr := fmt.Sprintf("%s:%d", addr, port)
|
||||
LogDebug("Sending magic packet to %s", fullAddr)
|
||||
for i := 0; i < 3; i++ {
|
||||
conn, err := net.Dial("udp", fullAddr)
|
||||
if err == nil {
|
||||
conn.Write(magicPacket)
|
||||
conn.Close()
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShutdownServer(name string, servers []Server) {
|
||||
@@ -122,32 +194,149 @@ func RebootServer(name string, servers []Server) {
|
||||
fmt.Printf("Server '%s' not found in configuration.\n", name)
|
||||
}
|
||||
|
||||
func executeSSHCommand(server Server, command string) error {
|
||||
func GetSSHClient(server Server) (*ssh.Client, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.SSHUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
// Explicitly using ONLY Password and Keyboard-Interactive to avoid
|
||||
// trying local SSH keys which might trigger "Too many attempts"
|
||||
ssh.Password(server.SSHPass),
|
||||
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = server.SSHPass
|
||||
}
|
||||
return answers, nil
|
||||
}),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", server.IP+":22", config)
|
||||
return ssh.Dial("tcp", server.IP+":22", config)
|
||||
}
|
||||
|
||||
func executeSSHCommand(server Server, command string) error {
|
||||
client, err := GetSSHClient(server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
LogDebug("SSH connection established to %s", server.IP)
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
_, err = session.CombinedOutput(command)
|
||||
LogDebug("Executing SSH command: %s", command)
|
||||
output, err := session.CombinedOutput(command)
|
||||
if err != nil {
|
||||
LogDebug("SSH command failed: %v, Output: %s", err, string(output))
|
||||
return fmt.Errorf("failed to run command: %w", err)
|
||||
}
|
||||
LogDebug("SSH command output: %s", string(output))
|
||||
|
||||
return nil
|
||||
}
|
||||
func GetMacAddress(ip string) (string, error) {
|
||||
// Ping the host to ensure it's in the ARP table
|
||||
PingHost(ip)
|
||||
|
||||
LogDebug("Looking for MAC address for IP: %s", ip)
|
||||
// Run arp -a to get the ARP table
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "arp", "-a")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
LogDebug("arp -a failed: %v", err)
|
||||
return "", fmt.Errorf("error running arp: %v", err)
|
||||
}
|
||||
|
||||
LogDebug("arp output received (%d bytes)", len(output))
|
||||
// Regex to find the MAC address for the given IP
|
||||
// Matches: (IP) at MAC or variations
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "("+ip+")") || strings.Contains(line, " "+ip+" ") {
|
||||
LogDebug("Found matching line in arp table: %s", line)
|
||||
// Extract MAC address - usually looking for 6 pairs of hex digits separated by : or -
|
||||
re := regexp.MustCompile(`([0-9a-fA-F]{1,2}[:\-]){5}[0-9a-fA-F]{1,2}`)
|
||||
mac := re.FindString(line)
|
||||
if mac != "" {
|
||||
LogDebug("Extracted MAC: %s", mac)
|
||||
return mac, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("No MAC address found in arp table for IP %s", ip)
|
||||
return "", fmt.Errorf("MAC address not found for IP %s", ip)
|
||||
}
|
||||
|
||||
func GetMacAddressSSH(ip, user, pass string) (string, error) {
|
||||
if user == "" || pass == "" {
|
||||
return "", fmt.Errorf("SSH credentials required for remote MAC lookup")
|
||||
}
|
||||
|
||||
LogDebug("Attempting to get MAC address via SSH for %s@%s", user, ip)
|
||||
|
||||
server := Server{
|
||||
IP: ip,
|
||||
SSHUser: user,
|
||||
SSHPass: pass,
|
||||
}
|
||||
|
||||
// Try common commands to get MAC on Linux/Unix
|
||||
// ip link, ifconfig, etc.
|
||||
cmd := "cat /sys/class/net/$(ip route get 8.8.8.8 | awk '{print $5}')/address 2>/dev/null || ifconfig | grep -E 'ether|HWaddr' | awk '{print $2}' | head -n 1"
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.SSHUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(server.SSHPass),
|
||||
ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = server.SSHPass
|
||||
}
|
||||
return answers, nil
|
||||
}),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", server.IP+":22", config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("SSH dial failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
output, err := session.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("SSH command failed: %w", err)
|
||||
}
|
||||
|
||||
mac := strings.TrimSpace(string(output))
|
||||
// Validate MAC format
|
||||
re := regexp.MustCompile(`([0-9a-fA-F]{1,2}[:\-]){5}[0-9a-fA-F]{1,2}`)
|
||||
mac = re.FindString(mac)
|
||||
|
||||
if mac == "" {
|
||||
return "", fmt.Errorf("could not extract MAC address from SSH output")
|
||||
}
|
||||
|
||||
LogDebug("Successfully retrieved MAC via SSH: %s", mac)
|
||||
return mac, nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package serveractions
|
||||
|
||||
// Server struct to hold server information
|
||||
type Server struct {
|
||||
Name string `json:"name"`
|
||||
Mac string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
SSHPass string `json:"ssh_pass"`
|
||||
Name string `json:"name" mapstructure:"name"`
|
||||
Mac string `json:"mac" mapstructure:"mac"`
|
||||
IP string `json:"ip" mapstructure:"ip"`
|
||||
SSHUser string `json:"ssh_user" mapstructure:"ssh_user"`
|
||||
SSHPass string `json:"ssh_pass" mapstructure:"ssh_pass"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
serveractions "manage-servers/server-actions"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allowing all origins for local tool
|
||||
},
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
serverName := r.URL.Query().Get("name")
|
||||
if serverName == "" {
|
||||
http.Error(w, "Missing server name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var targetServer *serveractions.Server
|
||||
for _, s := range ws.servers {
|
||||
if s.Name == serverName {
|
||||
targetServer = &s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetServer == nil {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if targetServer.SSHUser == "" || targetServer.SSHPass == "" {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err == nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("\r\n[Error] SSH Credentials missing for this node.\r\nPlease edit the server entry and save with a Username and Password.\r\n"))
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := serveractions.GetSSHClient(*targetServer)
|
||||
if err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\n[Error] SSH Connection failed: %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\n[Error] SSH Session failed: %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Request pseudo-terminal
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
|
||||
// Default to 24x80 if not provided
|
||||
rows := 24
|
||||
cols := 80
|
||||
if r.URL.Query().Get("rows") != "" {
|
||||
fmt.Sscanf(r.URL.Query().Get("rows"), "%d", &rows)
|
||||
}
|
||||
if r.URL.Query().Get("cols") != "" {
|
||||
fmt.Sscanf(r.URL.Query().Get("cols"), "%d", &cols)
|
||||
}
|
||||
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\n[Error] Request for PTY failed: %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\n[Error] Shell start failed: %v\r\n", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Proxy stdout/stderr to WebSocket
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, buf[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := stderr.Read(buf)
|
||||
if n > 0 {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, buf[:n]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Proxy WebSocket to stdin
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if _, err := stdin.Write(message); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ func StartWebServer(servers []serveractions.Server) {
|
||||
http.HandleFunc("/shutdown", ws.handleShutdownServer)
|
||||
http.HandleFunc("/reboot", ws.handleRebootServer)
|
||||
http.HandleFunc("/status", ws.handleStatus)
|
||||
http.HandleFunc("/find-mac", ws.handleFindMac)
|
||||
http.HandleFunc("/ssh", ws.handleSSH)
|
||||
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
fmt.Printf("Error starting server: %v\n", err)
|
||||
@@ -31,15 +33,19 @@ func StartWebServer(servers []serveractions.Server) {
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
serveractions.LogDebug("Handling root request: %s %s", r.Method, r.URL.Path)
|
||||
http.ServeFile(w, r, "index.html")
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleServers(w http.ResponseWriter, r *http.Request) {
|
||||
serveractions.LogDebug("Handling /servers request: %s", r.Method)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
ws.getServers(w, r)
|
||||
case http.MethodPost:
|
||||
ws.addServer(w, r)
|
||||
case http.MethodPut:
|
||||
ws.updateServer(w, r)
|
||||
case http.MethodDelete:
|
||||
ws.deleteServer(w, r)
|
||||
default:
|
||||
@@ -52,6 +58,42 @@ func (ws *WebServer) getServers(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(ws.servers)
|
||||
}
|
||||
|
||||
func (ws *WebServer) updateServer(w http.ResponseWriter, r *http.Request) {
|
||||
oldName := r.URL.Query().Get("oldName")
|
||||
if oldName == "" {
|
||||
http.Error(w, "Missing old server name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var updatedServer serveractions.Server
|
||||
if err := json.NewDecoder(r.Body).Decode(&updatedServer); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, server := range ws.servers {
|
||||
if server.Name == oldName {
|
||||
ws.servers[i] = updatedServer
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
viper.Set("servers", ws.servers)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Error writing config file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (ws *WebServer) addServer(w http.ResponseWriter, r *http.Request) {
|
||||
var newServer serveractions.Server
|
||||
if err := json.NewDecoder(r.Body).Decode(&newServer); err != nil {
|
||||
@@ -143,6 +185,7 @@ func (ws *WebServer) handleRebootServer(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
serverName := r.URL.Query().Get("name")
|
||||
serveractions.LogDebug("Checking status for server: %s", serverName)
|
||||
if serverName == "" {
|
||||
http.Error(w, "Missing server name", http.StatusBadRequest)
|
||||
return
|
||||
@@ -162,3 +205,36 @@ func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
http.Error(w, "Server not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (ws *WebServer) handleFindMac(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
user := r.URL.Query().Get("user")
|
||||
pass := r.URL.Query().Get("pass")
|
||||
|
||||
serveractions.LogDebug("Handling /find-mac request for IP: %s (SSH provided: %v)", ip, user != "")
|
||||
if ip == "" {
|
||||
http.Error(w, "Missing IP address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try ARP first
|
||||
mac, err := serveractions.GetMacAddress(ip)
|
||||
if err != nil {
|
||||
serveractions.LogDebug("ARP lookup failed for %s: %v. Trying SSH fallback...", ip, err)
|
||||
// Try SSH fallback if credentials are provided
|
||||
if user != "" && pass != "" {
|
||||
mac, err = serveractions.GetMacAddressSSH(ip, user, pass)
|
||||
if err != nil {
|
||||
serveractions.LogDebug("SSH lookup also failed for %s: %v", ip, err)
|
||||
http.Error(w, "MAC address not found via ARP or SSH", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
serveractions.LogDebug("No SSH credentials for fallback for %s", ip)
|
||||
http.Error(w, "MAC address not found in local network. Please provide SSH credentials for remote lookup.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Write([]byte(mac))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user