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

This commit is contained in:
Matthias Hinrichs
2026-02-18 00:38:31 +01:00
parent f088a6fba9
commit e68e355e3f
9 changed files with 1068 additions and 229 deletions
+44
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@ require (
require ( require (
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+2
View File
@@ -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/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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+506 -146
View File
@@ -1,207 +1,567 @@
<html> <!doctype html>
<html lang="en">
<head> <head>
<title>Server Management</title> <meta charset="utf-8"/>
<script src="https://cdn.tailwindcss.com"></script> <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;
}
/* 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; }
body[data-bs-theme="light"] .hide-theme-light { display: none !important; }
body[data-bs-theme="light"] .hide-theme-dark { display: flex !important; }
</style>
</head> </head>
<body class="bg-gray-100 text-gray-800"> <body >
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler-theme.min.js"></script>
<div class="container mx-auto p-8"> <div class="page">
<h1 class="text-4xl font-bold mb-8">Server Management</h1> <!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg">
<div id="server-table-container" class="bg-white shadow-md rounded-lg overflow-hidden"> <div class="container-fluid">
<table class="min-w-full leading-normal"> <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">
<thead> <span class="navbar-toggler-icon"></span>
<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>
</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>
</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> </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>
</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>
<div id="add-server-form-container" class="hidden mt-8 bg-white shadow-md rounded-lg p-8"> <!-- Page body -->
<h2 class="text-2xl font-bold mb-4">Add New Server</h2> <div class="page-body">
<form id="add-server-form"> <div class="container-fluid">
<div class="mb-4"> <div class="row row-deck row-cards" id="server-list-container">
<label for="name" class="block text-gray-700 text-sm font-bold mb-2">Name</label> <!-- Servers injected here -->
<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>
<div class="mb-4"> <!-- Empty State -->
<label for="ip" class="block text-gray-700 text-sm font-bold mb-2">IP Address</label> <div id="no-servers-message" class="empty d-none">
<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 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 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>
<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>
<div class="mb-4">
<label for="ssh_pass" class="block text-gray-700 text-sm font-bold mb-2">SSH Password</label> <footer class="footer footer-transparent d-print-none">
<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 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 &copy; 2026
</li>
</ul>
</div> </div>
<div class="flex items-center justify-between"> </div>
<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"> </div>
Save Server </footer>
</button> </div>
<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"> </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 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> </button>
</div> </div>
</form> </form>
</div> </div>
</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> <script>
let terminal = null;
let socket = null;
let fitAddon = null;
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
fetchServers(); fetchServers();
const addServerButton = document.getElementById("add-server-button"); // Theme Toggle Logic
const addServerFormContainer = document.getElementById("add-server-form-container"); const themeStorageKey = 'tablerTheme';
const cancelAddServerButton = document.getElementById("cancel-add-server"); const initialTheme = localStorage.getItem(themeStorageKey) || 'light';
const addServerForm = document.getElementById("add-server-form"); document.body.setAttribute('data-bs-theme', initialTheme);
addServerButton.addEventListener("click", () => { const btnDark = document.getElementById('btn-enable-dark');
addServerFormContainer.classList.remove("hidden"); const btnLight = document.getElementById('btn-enable-light');
addServerButton.classList.add("hidden");
if(btnDark) {
btnDark.addEventListener('click', (e) => {
e.preventDefault();
document.body.setAttribute('data-bs-theme', 'dark');
localStorage.setItem(themeStorageKey, 'dark');
}); });
}
cancelAddServerButton.addEventListener("click", () => { if(btnLight) {
addServerFormContainer.classList.add("hidden"); btnLight.addEventListener('click', (e) => {
addServerButton.classList.remove("hidden"); e.preventDefault();
addServerForm.reset(); document.body.setAttribute('data-bs-theme', 'light');
localStorage.setItem(themeStorageKey, 'light');
}); });
}
addServerForm.addEventListener("submit", function(event) { const form = document.getElementById("server-form");
event.preventDefault(); form.addEventListener("submit", (e) => {
const formData = new FormData(addServerForm); e.preventDefault();
const serverData = Object.fromEntries(formData.entries()); const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
const oldName = document.getElementById("oldName").value;
fetch("/servers", { const method = oldName ? "PUT" : "POST";
method: "POST", const url = oldName ? `/servers?oldName=${encodeURIComponent(oldName)}` : "/servers";
headers: {
"Content-Type": "application/json", fetch(url, {
}, method: method,
body: JSON.stringify(serverData), headers: { "Content-Type": "application/json" },
}) body: JSON.stringify(data)
.then(response => { }).then(r => {
if (response.ok) { if(r.ok) {
fetchServers(); fetchServers();
addServerFormContainer.classList.add("hidden"); showToast(oldName ? "Server updated" : "Server registered", "success");
addServerButton.classList.remove("hidden"); // Modal auto closes due to data-bs-dismiss, but we should reset form
addServerForm.reset(); resetForm();
} else { } else {
alert("Failed to add server."); showToast("Operation failed", "danger");
} }
}); });
}); });
document.getElementById('modal-server').addEventListener('hidden.bs.modal', resetForm);
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(!ip) { showToast("IP Address required", "warning"); return; }
btn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
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");
})
.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;
}
});
// 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() { function fetchServers() {
fetch("/servers") fetch("/servers").then(r => r.json()).then(data => {
.then(response => response.json()) const container = document.getElementById("server-list-container");
.then(data => { const empty = document.getElementById("no-servers-message");
const tableContainer = document.getElementById("server-table-container"); container.innerHTML = "";
const noServersMessage = document.getElementById("no-servers-message");
const tableBody = document.getElementById("server-table-body");
tableBody.innerHTML = ""; // Clear existing rows
if(data && data.length > 0) { if(data && data.length > 0) {
tableContainer.classList.remove("hidden"); container.classList.remove("d-none");
noServersMessage.classList.add("hidden"); empty.classList.add("d-none");
data.forEach(server => { data.sort((a,b) => a.name.localeCompare(b.name));
const row = document.createElement("tr");
row.innerHTML = ` data.forEach(s => {
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.name}</td> const div = document.createElement("div");
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.ip}</td> div.className = "col-12 col-md-6 col-lg-4";
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.mac}</td> div.innerHTML = `
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm" id="status-${server.name}">Checking...</td> <div class="card">
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm"> <div class="card-status-top bg-secondary" id="status-bar-${s.name}"></div>
<button onclick="wakeServer('${server.name}')" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Wake</button> <div class="card-body">
<button onclick="shutdownServer('${server.name}')" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">Shutdown</button> <div class="row align-items-center">
<button onclick="rebootServer('${server.name}')" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">Reboot</button> <div class="col-auto">
<button onclick="deleteServer('${server.name}')" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">Delete</button> <span class="avatar rounded" id="status-avatar-${s.name}">
</td> <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>
`; `;
tableBody.appendChild(row); container.appendChild(div);
checkServerStatus(server.name); checkStatus(s.name);
}); });
} else { } else {
tableContainer.classList.add("hidden"); container.classList.add("d-none");
noServersMessage.classList.remove("hidden"); empty.classList.remove("d-none");
} }
}); });
} }
function checkServerStatus(serverName) { function checkStatus(name) {
fetch(`/status?name=${serverName}`) fetch(`/status?name=${name}`).then(r => r.text()).then(status => {
.then(response => response.text()) const badge = document.getElementById(`status-text-${name}`);
.then(status => { const bar = document.getElementById(`status-bar-${name}`);
const statusCell = document.getElementById(`status-${serverName}`); const avatar = document.getElementById(`status-avatar-${name}`);
statusCell.textContent = status;
if(!badge) return;
badge.textContent = status;
badge.className = "badge";
if(status === "Online") { if(status === "Online") {
statusCell.classList.add("text-green-500", "font-bold"); badge.classList.add("bg-success-lt");
statusCell.classList.remove("text-red-500"); bar.className = "card-status-top bg-success";
avatar.className = "avatar rounded bg-success-lt text-success";
} else { } else {
statusCell.classList.add("text-red-500", "font-bold"); badge.classList.add("bg-danger-lt");
statusCell.classList.remove("text-green-500"); bar.className = "card-status-top bg-danger";
avatar.className = "avatar rounded bg-danger-lt text-danger";
} }
}); });
} }
function wakeServer(serverName) { function editServer(name, ip, mac, user, pass) {
fetch(`/wake?name=${serverName}`).then(() => alert(`${serverName} wake signal sent.`)); 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 shutdownServer(serverName) { function deleteServer(name) {
if (confirm(`Are you sure you want to shutdown ${serverName}?`)) { if(confirm(`Are you sure you want to delete ${name}?`)) {
fetch(`/shutdown?name=${serverName}`).then(() => alert(`${serverName} is shutting down.`)); fetch(`/servers?name=${name}`, { method: 'DELETE' }).then(r => {
} fetchServers();
} showToast("Server deleted", "secondary");
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',
})
.then(response => {
if (response.ok) {
fetchServers(); // Refresh the server list
} else {
alert(`Failed to delete ${serverName}.`);
}
}); });
} }
} }
// Refresh server status every 30 seconds 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); setInterval(fetchServers, 30000);
</script> </script>
</body> </body>
</html> </html>
+27 -13
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
@@ -11,48 +12,58 @@ import (
) )
func main() { 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() servers, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading servers: %v\n", err) fmt.Printf("Error loading servers: %v\n", err)
return return
} }
serveractions.LogDebug("Loaded %d servers from config", len(servers))
if len(os.Args) < 2 { command := args[0]
printUsage()
return
}
command := os.Args[1]
switch command { switch command {
case "list": case "list":
listServers(servers) listServers(servers)
case "wake": case "wake":
if len(os.Args) < 3 { if len(args) < 2 {
fmt.Println("Please specify a server name to wake.") fmt.Println("Please specify a server name to wake.")
printUsage() printUsage()
return return
} }
serverName := os.Args[2] serverName := args[1]
serveractions.WakeServer(serverName, servers) serveractions.WakeServer(serverName, servers)
case "wakeall": case "wakeall":
serveractions.WakeAllServers(servers) serveractions.WakeAllServers(servers)
case "status": case "status":
serveractions.CheckServersStatus(servers) serveractions.CheckServersStatus(servers)
case "shutdown": case "shutdown":
if len(os.Args) < 3 { if len(args) < 2 {
fmt.Println("Please specify a server name to shutdown.") fmt.Println("Please specify a server name to shutdown.")
printUsage() printUsage()
return return
} }
serverName := os.Args[2] serverName := args[1]
serveractions.ShutdownServer(serverName, servers) serveractions.ShutdownServer(serverName, servers)
case "reboot": case "reboot":
if len(os.Args) < 3 { if len(args) < 2 {
fmt.Println("Please specify a server name to reboot.") fmt.Println("Please specify a server name to reboot.")
printUsage() printUsage()
return return
} }
serverName := os.Args[2] serverName := args[1]
serveractions.RebootServer(serverName, servers) serveractions.RebootServer(serverName, servers)
case "serve": case "serve":
webserver.StartWebServer(servers) webserver.StartWebServer(servers)
@@ -63,7 +74,9 @@ func main() {
} }
func printUsage() { 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("Commands:")
fmt.Println(" list - List all configured servers") fmt.Println(" list - List all configured servers")
fmt.Println(" wake <server_name> - Wake a specific server") fmt.Println(" wake <server_name> - Wake a specific server")
@@ -83,6 +96,7 @@ func loadConfig() ([]serveractions.Server, error) {
if err != nil { // Handle errors reading the config file if err != nil { // Handle errors reading the config file
// Check if the error is that the file doesn't exist // Check if the error is that the file doesn't exist
if _, ok := err.(viper.ConfigFileNotFoundError); ok { 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 // Config file not found; ignore error and return empty server list
return []serveractions.Server{}, nil return []serveractions.Server{}, nil
} }
+211 -22
View File
@@ -4,38 +4,78 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"os"
"os/exec" "os/exec"
"regexp"
"strings"
"time" "time"
"golang.org/x/crypto/ssh" "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) { func WakeServer(name string, servers []Server) {
for _, s := range servers { for _, s := range servers {
if s.Name == name { if s.Name == name {
fmt.Printf("Sending Wake-on-LAN packet to %s (%s)...\n", s.Name, s.Mac) 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 { if err != nil {
fmt.Printf("Error sending packet: %v\n", err) fmt.Printf("Error sending packet from local network: %v\n", err)
} else { } 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 return
} }
} }
fmt.Printf("Server '%s' not found in configuration.\n", name) 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) { func WakeAllServers(servers []Server) {
fmt.Println("Waking all servers...") fmt.Println("Waking all servers...")
for _, s := range servers { for _, s := range servers {
fmt.Printf("Sending Wake-on-LAN packet to %s (%s)...\n", s.Name, s.Mac) WakeServer(s.Name, servers)
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)
}
} }
} }
@@ -56,17 +96,24 @@ func CheckServersStatus(servers []Server) {
} }
func PingHost(host string) bool { func PingHost(host string) bool {
LogDebug("Pinging host: %s", host)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host) cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
err := cmd.Run() err := cmd.Run()
if err != nil {
LogDebug("Ping failed for %s: %v", host, err)
} else {
LogDebug("Ping successful for %s", host)
}
return err == nil return err == nil
} }
// Creates and sends a magic packet to the specified MAC address. // 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) hwAddr, err := net.ParseMAC(macAddr)
if err != nil { if err != nil {
return fmt.Errorf("invalid mac address '%s': %w", macAddr, err) return fmt.Errorf("invalid mac address '%s': %w", macAddr, err)
@@ -80,14 +127,39 @@ func sendMagicPacket(macAddr string) error {
copy(magicPacket[i*6:], hwAddr) copy(magicPacket[i*6:], hwAddr)
} }
conn, err := net.Dial("udp", "255.255.255.255:9") // List of addresses to try sending the WOL packet to
if err != nil { // List of addresses to try sending the WOL packet to
return err broadcastAddresses := []string{"255.255.255.255"}
}
defer conn.Close()
_, err = conn.Write(magicPacket) // If a valid IP is provided, calculate the directed broadcast address (assuming /24 subnet)
return err 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) { 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) 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{ config := &ssh.ClientConfig{
User: server.SSHUser, User: server.SSHUser,
Auth: []ssh.AuthMethod{ 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.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(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second, 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 { if err != nil {
return fmt.Errorf("failed to dial: %w", err) return fmt.Errorf("failed to connect: %w", err)
} }
defer client.Close() defer client.Close()
LogDebug("SSH connection established to %s", server.IP)
session, err := client.NewSession() session, err := client.NewSession()
if err != nil { if err != nil {
return fmt.Errorf("failed to create session: %w", err) return fmt.Errorf("failed to create session: %w", err)
} }
defer session.Close() defer session.Close()
_, err = session.CombinedOutput(command) LogDebug("Executing SSH command: %s", command)
output, err := session.CombinedOutput(command)
if err != nil { if err != nil {
LogDebug("SSH command failed: %v, Output: %s", err, string(output))
return fmt.Errorf("failed to run command: %w", err) return fmt.Errorf("failed to run command: %w", err)
} }
LogDebug("SSH command output: %s", string(output))
return nil 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
}
+5 -5
View File
@@ -2,9 +2,9 @@ package serveractions
// Server struct to hold server information // Server struct to hold server information
type Server struct { type Server struct {
Name string `json:"name"` Name string `json:"name" mapstructure:"name"`
Mac string `json:"mac"` Mac string `json:"mac" mapstructure:"mac"`
IP string `json:"ip"` IP string `json:"ip" mapstructure:"ip"`
SSHUser string `json:"ssh_user"` SSHUser string `json:"ssh_user" mapstructure:"ssh_user"`
SSHPass string `json:"ssh_pass"` SSHPass string `json:"ssh_pass" mapstructure:"ssh_pass"`
} }
+153
View File
@@ -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
}
}
}
+76
View File
@@ -24,6 +24,8 @@ func StartWebServer(servers []serveractions.Server) {
http.HandleFunc("/shutdown", ws.handleShutdownServer) http.HandleFunc("/shutdown", ws.handleShutdownServer)
http.HandleFunc("/reboot", ws.handleRebootServer) http.HandleFunc("/reboot", ws.handleRebootServer)
http.HandleFunc("/status", ws.handleStatus) http.HandleFunc("/status", ws.handleStatus)
http.HandleFunc("/find-mac", ws.handleFindMac)
http.HandleFunc("/ssh", ws.handleSSH)
if err := http.ListenAndServe(":8080", nil); err != nil { if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Error starting server: %v\n", err) 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) { 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") http.ServeFile(w, r, "index.html")
} }
func (ws *WebServer) handleServers(w http.ResponseWriter, r *http.Request) { func (ws *WebServer) handleServers(w http.ResponseWriter, r *http.Request) {
serveractions.LogDebug("Handling /servers request: %s", r.Method)
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
ws.getServers(w, r) ws.getServers(w, r)
case http.MethodPost: case http.MethodPost:
ws.addServer(w, r) ws.addServer(w, r)
case http.MethodPut:
ws.updateServer(w, r)
case http.MethodDelete: case http.MethodDelete:
ws.deleteServer(w, r) ws.deleteServer(w, r)
default: default:
@@ -52,6 +58,42 @@ func (ws *WebServer) getServers(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ws.servers) 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) { func (ws *WebServer) addServer(w http.ResponseWriter, r *http.Request) {
var newServer serveractions.Server var newServer serveractions.Server
if err := json.NewDecoder(r.Body).Decode(&newServer); err != nil { 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) { func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) {
serverName := r.URL.Query().Get("name") serverName := r.URL.Query().Get("name")
serveractions.LogDebug("Checking status for server: %s", serverName)
if serverName == "" { if serverName == "" {
http.Error(w, "Missing server name", http.StatusBadRequest) http.Error(w, "Missing server name", http.StatusBadRequest)
return return
@@ -162,3 +205,36 @@ func (ws *WebServer) handleStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Server not found", http.StatusNotFound) 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))
}