21 Commits

Author SHA1 Message Date
Matthias Hinrichs b8dd300d29 ci: Remove Docker Hub login step from Gitea CI workflow.
Build Docker Container using Multistage Build / build (push) Successful in 10m39s
2026-02-18 12:14:10 +01:00
Matthias Hinrichs 49c976a02a feat: Add web terminal and server dashboard, streamline setup, and configure CI with Docker registry mirror.
Build Docker Container using Multistage Build / build (push) Failing after 32s
2026-02-18 01:35:13 +01:00
Matthias Hinrichs 1e992a53b0 refactor: Increase ping host timeout to 2 seconds and remove the -W 1 argument.
Build Docker Container using Multistage Build / build (push) Failing after 23s
2026-02-18 01:23:39 +01:00
Matthias Hinrichs 43f5893972 updated Dockerfile base images
Build Docker Container using Multistage Build / build (push) Has been cancelled
2026-02-18 01:20:05 +01:00
Matthias Hinrichs da26078383 refactor: Redesign the server list from individual cards to a table layout and refine terminal modal styling.
Build Docker Container using Multistage Build / build (push) Failing after 1m38s
2026-02-18 01:13:48 +01:00
Matthias Hinrichs e68e355e3f 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
2026-02-18 00:38:31 +01:00
matthias f088a6fba9 .gitea/workflows/gitea-ci.yaml aktualisiert
Build Docker Container using Multistage Build / build (push) Successful in 3m46s
2026-01-20 22:35:38 +00:00
matthias 46b40ccba5 .gitea/workflows/gitea-ci.yaml aktualisiert
Build Docker Container using Multistage Build / build (push) Failing after 13s
2026-01-20 22:34:24 +00:00
komodo 87cd754263 [Komodo] matthias: Write Stack File: update docker-compose.yml
Build And Test / build (push) Successful in 9m33s
Build And Test / Build and Publish Docker Image (push) Successful in 4m3s
2026-01-20 22:14:22 +00:00
komodo cbf512e6f8 [Komodo] matthias: Commit Dockerfile: update ./Dockerfile
Build And Test / Build and Publish Docker Image (push) Blocked by required conditions
Build And Test / build (push) Successful in 23s
2026-01-20 22:04:03 +00:00
komodo eecbb7a26f [Komodo] matthias: Commit Dockerfile: update ./Dockerfile
Build And Test / Build and Publish Docker Image (push) Has been cancelled
Build And Test / build (push) Has been cancelled
2026-01-20 21:57:47 +00:00
komodo a5f7abdd14 [Komodo] matthias: Commit Dockerfile: update ./Dockerfile
Build And Test / build (push) Successful in 11m40s
Build And Test / Build and Publish Docker Image (push) Has been cancelled
2026-01-20 21:45:56 +00:00
matthias c358617843 Merge pull request 'chore(deps): update module golang.org/x/crypto to v0.47.0' (#18) from renovate/golang.org-x-crypto-0.x into main
Build And Test / build (push) Successful in 2m50s
Build And Test / Build and Publish Docker Image (push) Successful in 4m37s
Reviewed-on: #18
2026-01-14 00:38:38 +00:00
renovate-bot 5299a692dc chore(deps): update module golang.org/x/crypto to v0.47.0 2026-01-12 17:03:30 +00:00
matthias a697e7407f Merge pull request 'chore(deps): update module golang.org/x/crypto to v0.46.0' (#17) from renovate/golang.org-x-crypto-0.x into main
Build And Test / build (push) Successful in 3m13s
Build And Test / Build and Publish Docker Image (push) Failing after 2m46s
Reviewed-on: #17
2025-12-13 08:31:32 +00:00
renovate-bot 5b95e66a20 chore(deps): update module golang.org/x/crypto to v0.46.0 2025-12-08 21:04:45 +00:00
Matthias Hinrichs 2061bb29c0 fix: aktualisiere das Docker-Image auf v0.1.3 und passe die Verzeichnispfade im Dockerfile und docker-compose.yml an
Build And Test / build (push) Successful in 54s
Build And Test / Build and Publish Docker Image (push) Successful in 39s
2025-11-23 01:22:38 +01:00
Matthias Hinrichs fe774f8009 fix: aktualisiere das Docker-Image auf v0.1.2 und entferne den ls-Befehl aus dem Dockerfile
Build And Test / build (push) Successful in 1m16s
Build And Test / Build and Publish Docker Image (push) Successful in 41s
2025-11-23 01:12:42 +01:00
Matthias Hinrichs 2021399a10 fix: füge ls-Befehl zur Überprüfung des Verzeichnisinhalts hinzu
Build And Test / build (push) Successful in 55s
Build And Test / Build and Publish Docker Image (push) Successful in 1m1s
2025-11-23 01:08:54 +01:00
Matthias Hinrichs 2cefdaa85d fix: füge ls-Befehl zur Ausgabe der Verzeichnisinhalte hinzu
Build And Test / build (push) Successful in 54s
Build And Test / Build and Publish Docker Image (push) Successful in 39s
2025-11-23 00:59:56 +01:00
Matthias Hinrichs db0cde6497 fix: aktualisiere das Docker-Image auf v0.1.1
Build And Test / build (push) Successful in 55s
Build And Test / Build and Publish Docker Image (push) Successful in 38s
2025-11-23 00:50:13 +01:00
13 changed files with 1154 additions and 356 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
+5 -32
View File
@@ -2,65 +2,38 @@
# .gitea/gitea-ci.yaml
#
name: Build And Test
name: Build Docker Container using Multistage Build
run-name: ${{ gitea.actor }} started ci pipeline
on:
push:
branches:
- main
tags:
- 'v*' # Tags, die mit "v" anfangen, z. B. v1.0.0
- "v*" # Tags, die mit "v" anfangen, z. B. v1.0.0
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v6
- name: Use Go
uses: https://github.com/actions/setup-go@v6
with:
go-version: '1.25'
- run: go version
- run: go mod download
- run: CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o manage-servers .
- uses: actions/upload-artifact@v3
with:
name: manage-servers-binary
path: manage-servers
publish:
needs: build
name: Build and Publish Docker Image
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v6
- name: Docker login to Docker Hub
run: |
echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login docker.io \
--username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Docker login to Gitea Registry
run: |
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login git.hnrx.net \
--username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- uses: actions/download-artifact@v3
with:
name: manage-servers-binary
path: .
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."docker.io"]
username = "${{ secrets.DOCKERHUB_USERNAME }}"
password = "${{ secrets.DOCKERHUB_TOKEN }}"
mirrors = ["docker.hnrx.net"]
[registry."git.hnrx.net"]
username = "${{ secrets.DOCKER_USERNAME }}"
password = "${{ secrets.DOCKER_PASSWORD }}"
- run: ls -lha
- name: Build and Push Docker latest Image
if: gitea.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
+29 -7
View File
@@ -1,17 +1,39 @@
FROM docker.hnrx.net/alpine:latest
# Stage 1: Golang-Basisimage zum Bauen der App
FROM golang:latest as build-stage
RUN mkdir /config
RUN go version
WORKDIR /root/
WORKDIR /app
COPY . .
RUN ls -lha
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o manage-servers .
RUN ls -lha
# Stage 2: Nginx zum Ausführen der App
FROM alpine:latest as production-stage
# Create application directory
RUN mkdir -p /app/config
# Set working directory
WORKDIR /app/
# Copy the compiled binary from the builder stage
COPY manage-servers .
COPY --from=build-stage /app/manage-servers .
# Copy the index.html file for the web server
COPY index.html .
COPY --from=build-stage /app/index.html .
# Copy the servers.json file
# COPY servers.json .
RUN ls -la /app/
# Make the binary executable
#RUN chmod +x ./manage-servers
# Expose the port the web server listens on
EXPOSE 8080
+29 -78
View File
@@ -4,20 +4,20 @@ This is a simple Go application designed to manage your servers, providing funct
## Features
* **List Servers:** Display all configured servers with their details.
* **Wake Server:** Send Wake-on-LAN (WoL) magic packets to bring servers online.
* **Shutdown Server:** Remotely shut down servers via SSH.
* **Reboot Server:** Remotely reboot servers via SSH.
* **Server Status:** Check if servers are online using ping.
* **Web Interface:** A user-friendly web interface to perform all management actions.
- **Server Dashboard:** A responsive table view displaying all servers with real-time status, IP, and MAC addresses.
- **Web Terminal:** Integrated xterm.js terminal for direct SSH access to your servers from the browser.
- **Manage Power:**
- **Wake-on-LAN:** Send magic packets to wake up offline servers.
- **Shutdown/Reboot:** Gracefully restart or power off servers via SSH.
- **Status Monitoring:** Reliable active health checks (ICMP ping) compatible with macOS and Linux.
- **Server Management:** Add, edit, or remove servers directly from the UI.
- **MAC Address Lookup:** Automatically discover MAC addresses for local servers.
## Prerequisites
Before you begin, ensure you have the following installed:
* [Go (1.24.6 or newer)](https://golang.org/doc/install)
* [Docker](https://docs.docker.com/get-docker/)
* `git` (for cloning the repository)
- [Go (1.24 or newer)](https://golang.org/doc/install)
- [Docker](https://docs.docker.com/get-docker/)
- `git`
## Getting Started (Local)
@@ -28,94 +28,45 @@ Before you begin, ensure you have the following installed:
cd manage-servers
```
2. **Configure your servers:**
Edit the `servers.json` file to include your server details, including MAC addresses for Wake-on-LAN and SSH credentials for shutdown/reboot.
Example `servers.json`:
```json
[
{
"name": "harvester-01",
"mac": "3c:49:37:05:c2:2e",
"ip": "192.168.110.101",
"ssh_user": "rancher",
"ssh_pass": "maddog07"
}
]
```
3. **Run CLI commands:**
```bash
go run . list
go run . wake harvester-01
go run . status
go run . shutdown harvester-01
go run . reboot harvester-01
```
4. **Run the web server locally:**
2. **Run the application:**
```bash
go run . serve
```
Access the web interface in your browser at `http://localhost:8080`.
The application will automatically create a configuration file (`servers.yaml` used by Viper) if one doesn't exist.
Access the web interface at `http://localhost:8080`.
## Getting Started (Docker)
1. **Build the Docker image:**
The project uses standard Docker Hub images (`golang:latest` and `alpine:latest`).
Navigate to the root of the project and run:
1. **Build the image:**
```bash
docker build -t manage-servers:latest .
```
2. **Run the Docker container:**
2. **Run the container:**
To enable Wake-on-LAN functionality, the Docker container needs to run in `host` network mode. This allows it to send broadcast packets directly onto your physical network.
For Wake-on-LAN to broadcast correctly, the container **must** run in host networking mode:
```bash
docker run --network host -p 8080:8080 manage-servers:latest
docker run -d \
--name manage-servers \
--network host \
manage-servers:latest
```
* `--network host`: Essential for Wake-on-LAN to work correctly.
* `-p 8080:8080`: Maps port 8080 from the container to port 8080 on your host.
3. **Access the web interface:**
Open your web browser and navigate to `http://localhost:8080`.
_Note: Port mapping (`-p 8080:8080`) is not required when using `--network host` as the container shares the host's networking stack._
## Configuration
The `servers.json` file is crucial for the application's functionality. Ensure it's correctly formatted and contains accurate information for all your servers.
Servers can be managed entirely through the web UI ("Register Server" button).
Under the hood, the application persists configuration to `config.yaml` or `servers.json` depending on the environment.
* `name`: A unique identifier for your server.
* `mac`: The MAC address of the server for Wake-on-LAN.
* `ip`: The IP address of the server for SSH connections and status checks.
* `ssh_user`: The username for SSH access.
* `ssh_pass`: The password for SSH access.
### Required Fields for Full Functionality
## Troubleshooting
### Host key verification failed (when pushing to Git)
If you encounter `Host key verification failed` when pushing to your Git repository, it means your system does not trust the SSH key of the Git server. To resolve this, you need to manually add the server's host key to your `~/.ssh/known_hosts` file.
From your terminal, attempt to connect to the server via SSH for the first time:
```bash
ssh git@192.168.200.20
```
When prompted to confirm the host's authenticity, type `yes` and press Enter. This will add the host key to your `known_hosts` file, and you should then be able to push your changes.
### Error loading servers: open servers.json: no such file or directory (in Docker)
This error indicates that the `servers.json` file was not found inside the Docker container. Ensure you have the latest `Dockerfile` (which includes copying `servers.json`) and rebuild your Docker image.
### Sending magic packet from Docker container does not work
If Wake-on-LAN is not working from within the Docker container, it's likely a networking issue. Ensure you are running the Docker container with the `--network host` flag, as described in the "Run the Docker container" section above. This allows the container to send broadcast packets directly onto your physical network.
- **Wake-on-LAN**: Requires valid `MAC Address`.
- **Shutdown/Reboot**: Requires `SSH User` and `SSH Password`.
- **Terminal**: Requires `SSH User` and `SSH Password`.
+4 -4
View File
@@ -2,7 +2,7 @@ version: '3.8'
services:
manage-servers:
image: git.hnrx.net/hnrx/manage-servers:v0.1.0
image: git.hnrx.net/hnrx/manage-servers:0.1.8
container_name: manage-servers
restart: unless-stopped
labels:
@@ -14,10 +14,10 @@ services:
- "traefik.http.routers.manage-servers.tls.certresolver=cloudflare"
- "traefik.http.services.manage-servers.loadbalancer.server.port=8080"
volumes:
- /volume1/docker-data/manage-servers:/config
- /volume1/docker-data/manage-servers:/app/config
networks:
- my-container-macvlan-200
- traefik-proxy
networks:
my-container-macvlan-200:
traefik-proxy:
external: true
+7 -4
View File
@@ -2,20 +2,23 @@ module manage-servers
go 1.24.6
require golang.org/x/crypto v0.45.0
require (
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.47.0
)
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
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
)
+14
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/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=
@@ -22,10 +24,22 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+483 -132
View File
@@ -1,207 +1,558 @@
<html>
<!doctype html>
<html lang="en">
<head>
<title>Server Management</title>
<script src="https://cdn.tailwindcss.com"></script>
<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: 70vh;
max-height: 70vh !important;
width: 100%;
background: #000;
padding: 0 !important;
border-radius: 4px;
overflow: hidden;
}
/* 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>
<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="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>
</aside>
<div class="container mx-auto p-8">
<h1 class="text-4xl font-bold mb-8">Server Management</h1>
<!-- 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 id="server-table-container" class="bg-white shadow-md rounded-lg overflow-hidden">
<table class="min-w-full leading-normal">
<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>
<!-- Page body -->
<div class="page-body">
<div class="container-fluid">
<div class="card">
<table class="table table-vcenter card-table">
<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>
<th>Status</th>
<th>Name</th>
<th>IP Address</th>
<th>MAC Address</th>
<th class="w-1">Actions</th>
</tr>
</thead>
<tbody id="server-table-body">
<!-- Server rows will be inserted here -->
<tbody id="server-list">
</tbody>
</table>
<div id="loading-spinner" class="text-center p-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</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 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>
<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 &copy; 2026
</li>
</ul>
</div>
</div>
</div>
</footer>
</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>
<!-- 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>
<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>
<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="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 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 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">
<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">
<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>
let terminal = null;
let socket = null;
let fitAddon = null;
document.addEventListener("DOMContentLoaded", function () {
fetchServers();
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");
// Theme Toggle Logic
const themeStorageKey = 'tablerTheme';
const initialTheme = localStorage.getItem(themeStorageKey) || 'light';
document.body.setAttribute('data-bs-theme', initialTheme);
addServerButton.addEventListener("click", () => {
addServerFormContainer.classList.remove("hidden");
addServerButton.classList.add("hidden");
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) {
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();
addServerFormContainer.classList.add("hidden");
addServerButton.classList.remove("hidden");
addServerForm.reset();
showToast(oldName ? "Server updated" : "Server registered", "success");
// Modal auto closes due to data-bs-dismiss, but we should reset form
resetForm();
} 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() {
fetch("/servers")
.then(response => response.json())
.then(r => r.json())
.then(data => {
const tableContainer = document.getElementById("server-table-container");
const noServersMessage = document.getElementById("no-servers-message");
const tableBody = document.getElementById("server-table-body");
const spinner = document.getElementById("loading-spinner");
const container = document.getElementById("server-list");
const empty = document.getElementById("no-servers-message"); // Re-targeting correct empty div if name changed in previous step, assuming "no-servers-message"
tableBody.innerHTML = ""; // Clear existing rows
if(spinner) spinner.classList.add("d-none");
container.innerHTML = "";
if(data && data.length > 0) {
tableContainer.classList.remove("hidden");
noServersMessage.classList.add("hidden");
container.closest(".card").classList.remove("d-none"); // Show card
empty.classList.add("d-none");
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>
data.sort((a,b) => a.name.localeCompare(b.name));
data.forEach(s => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><span id="status-text-${s.name}" class="badge bg-secondary-lt">Checking</span></td>
<td><div class="font-weight-medium">${s.name}</div></td>
<td class="text-secondary">${s.ip}</td>
<td class="text-secondary">${s.mac}</td>
<td>
<div class="btn-list flex-nowrap">
<a href="#" onclick="openTerminal('${s.name}')" class="btn 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-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>
<div class="dropdown">
<button class="btn btn-icon dropdown-toggle align-text-top" data-bs-boundary="viewport" data-bs-toggle="dropdown"></button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" onclick="editServer('${s.name}', '${s.ip}', '${s.mac}', '${(s.ssh_user||"").replace(/'/g, "\\'")}', '${(s.ssh_pass||"").replace(/'/g, "\\'")}')">Edit</a>
<a class="dropdown-item" onclick="rebootServer('${s.name}')">Reboot</a>
<a class="dropdown-item" onclick="shutdownServer('${s.name}')">Shutdown</a>
<a class="dropdown-item text-danger" onclick="deleteServer('${s.name}')">Delete</a>
</div>
</div>
</div>
</td>
`;
tableBody.appendChild(row);
checkServerStatus(server.name);
container.appendChild(tr);
checkStatus(s.name);
});
} else {
tableContainer.classList.add("hidden");
noServersMessage.classList.remove("hidden");
container.closest(".card").classList.add("d-none"); // Hide card
empty.classList.remove("d-none");
}
});
}
function checkServerStatus(serverName) {
fetch(`/status?name=${serverName}`)
.then(response => response.text())
.then(status => {
const statusCell = document.getElementById(`status-${serverName}`);
statusCell.textContent = status;
function checkStatus(name) {
fetch(`/status?name=${name}`).then(r => r.text()).then(status => {
const badge = document.getElementById(`status-text-${name}`);
if(!badge) return;
badge.textContent = status;
badge.className = "badge";
if(status === "Online") {
statusCell.classList.add("text-green-500", "font-bold");
statusCell.classList.remove("text-red-500");
badge.classList.add("bg-success-lt");
} else {
statusCell.classList.add("text-red-500", "font-bold");
statusCell.classList.remove("text-green-500");
badge.classList.add("bg-danger-lt");
}
});
}
function wakeServer(serverName) {
fetch(`/wake?name=${serverName}`).then(() => alert(`${serverName} wake signal sent.`));
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 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',
})
.then(response => {
if (response.ok) {
fetchServers(); // Refresh the server list
} else {
alert(`Failed to delete ${serverName}.`);
}
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");
});
}
}
// 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);
</script>
</body>
</html>
+27 -13
View File
@@ -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")
@@ -83,6 +96,7 @@ func loadConfig() ([]serveractions.Server, error) {
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
}
+213 -24
View File
@@ -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 {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
LogDebug("Pinging host: %s", host)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
cmd := exec.CommandContext(ctx, "ping", "-c", "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
}
+5 -5
View File
@@ -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"`
}
+161
View File
@@ -0,0 +1,161 @@
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)
}
// Sanity check for dimensions
if rows <= 0 {
rows = 24
}
if cols <= 0 {
cols = 80
}
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("/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))
}