Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8dd300d29 | |||
| 49c976a02a | |||
| 1e992a53b0 | |||
| 43f5893972 | |||
| da26078383 | |||
| e68e355e3f | |||
| f088a6fba9 | |||
| 46b40ccba5 | |||
| 87cd754263 | |||
| cbf512e6f8 | |||
| eecbb7a26f | |||
| a5f7abdd14 | |||
| c358617843 | |||
| 5299a692dc | |||
| a697e7407f | |||
| 5b95e66a20 | |||
| 2061bb29c0 | |||
| fe774f8009 |
@@ -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
|
||||||
@@ -2,61 +2,32 @@
|
|||||||
# .gitea/gitea-ci.yaml
|
# .gitea/gitea-ci.yaml
|
||||||
#
|
#
|
||||||
|
|
||||||
name: Build And Test
|
name: Build Docker Container using Multistage Build
|
||||||
run-name: ${{ gitea.actor }} started ci pipeline
|
run-name: ${{ gitea.actor }} started ci pipeline
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
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:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v6
|
- 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
|
- name: Docker login to Gitea Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login git.hnrx.net \
|
echo "${{ secrets.DOCKER_PASSWORD }}" | docker login git.hnrx.net \
|
||||||
--username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
--username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
- uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: manage-servers-binary
|
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
buildkitd-config-inline: |
|
buildkitd-config-inline: |
|
||||||
[registry."docker.io"]
|
[registry."docker.io"]
|
||||||
username = "${{ secrets.DOCKERHUB_USERNAME }}"
|
mirrors = ["docker.hnrx.net"]
|
||||||
password = "${{ secrets.DOCKERHUB_TOKEN }}"
|
|
||||||
[registry."git.hnrx.net"]
|
[registry."git.hnrx.net"]
|
||||||
username = "${{ secrets.DOCKER_USERNAME }}"
|
username = "${{ secrets.DOCKER_USERNAME }}"
|
||||||
password = "${{ secrets.DOCKER_PASSWORD }}"
|
password = "${{ secrets.DOCKER_PASSWORD }}"
|
||||||
|
|||||||
+28
-10
@@ -1,21 +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 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 the index.html file for the web server
|
||||||
COPY index.html .
|
COPY --from=build-stage /app/index.html .
|
||||||
|
|
||||||
# Copy the servers.json file
|
RUN ls -la /app/
|
||||||
# COPY servers.json .
|
|
||||||
|
|
||||||
RUN ls -la /root/
|
# Make the binary executable
|
||||||
|
#RUN chmod +x ./manage-servers
|
||||||
RUN chmod +x ./manage-servers
|
|
||||||
|
|
||||||
# Expose the port the web server listens on
|
# Expose the port the web server listens on
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ This is a simple Go application designed to manage your servers, providing funct
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* **List Servers:** Display all configured servers with their details.
|
- **Server Dashboard:** A responsive table view displaying all servers with real-time status, IP, and MAC addresses.
|
||||||
* **Wake Server:** Send Wake-on-LAN (WoL) magic packets to bring servers online.
|
- **Web Terminal:** Integrated xterm.js terminal for direct SSH access to your servers from the browser.
|
||||||
* **Shutdown Server:** Remotely shut down servers via SSH.
|
- **Manage Power:**
|
||||||
* **Reboot Server:** Remotely reboot servers via SSH.
|
- **Wake-on-LAN:** Send magic packets to wake up offline servers.
|
||||||
* **Server Status:** Check if servers are online using ping.
|
- **Shutdown/Reboot:** Gracefully restart or power off servers via SSH.
|
||||||
* **Web Interface:** A user-friendly web interface to perform all management actions.
|
- **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
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, ensure you have the following installed:
|
- [Go (1.24 or newer)](https://golang.org/doc/install)
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/)
|
||||||
* [Go (1.24.6 or newer)](https://golang.org/doc/install)
|
- `git`
|
||||||
* [Docker](https://docs.docker.com/get-docker/)
|
|
||||||
* `git` (for cloning the repository)
|
|
||||||
|
|
||||||
## Getting Started (Local)
|
## Getting Started (Local)
|
||||||
|
|
||||||
@@ -28,94 +28,45 @@ Before you begin, ensure you have the following installed:
|
|||||||
cd manage-servers
|
cd manage-servers
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Configure your servers:**
|
2. **Run the application:**
|
||||||
|
|
||||||
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:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run . serve
|
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)
|
## 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
|
```bash
|
||||||
docker build -t manage-servers:latest .
|
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
|
```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.
|
_Note: Port mapping (`-p 8080:8080`) is not required when using `--network host` as the container shares the host's networking stack._
|
||||||
* `-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`.
|
|
||||||
|
|
||||||
## Configuration
|
## 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.
|
### Required Fields for Full Functionality
|
||||||
* `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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
- **Wake-on-LAN**: Requires valid `MAC Address`.
|
||||||
|
- **Shutdown/Reboot**: Requires `SSH User` and `SSH Password`.
|
||||||
### Host key verification failed (when pushing to Git)
|
- **Terminal**: Requires `SSH User` and `SSH Password`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
manage-servers:
|
manage-servers:
|
||||||
image: git.hnrx.net/hnrx/manage-servers:v0.1.1
|
image: git.hnrx.net/hnrx/manage-servers:0.1.8
|
||||||
container_name: manage-servers
|
container_name: manage-servers
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
@@ -14,10 +14,10 @@ services:
|
|||||||
- "traefik.http.routers.manage-servers.tls.certresolver=cloudflare"
|
- "traefik.http.routers.manage-servers.tls.certresolver=cloudflare"
|
||||||
- "traefik.http.services.manage-servers.loadbalancer.server.port=8080"
|
- "traefik.http.services.manage-servers.loadbalancer.server.port=8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker-data/manage-servers:/config
|
- /volume1/docker-data/manage-servers:/app/config
|
||||||
networks:
|
networks:
|
||||||
- my-container-macvlan-200
|
- traefik-proxy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
my-container-macvlan-200:
|
traefik-proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -2,20 +2,23 @@ module manage-servers
|
|||||||
|
|
||||||
go 1.24.6
|
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 (
|
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
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // 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
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
|||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/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=
|
||||||
@@ -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=
|
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 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+529
-178
@@ -1,207 +1,558 @@
|
|||||||
<html>
|
<!doctype html>
|
||||||
<head>
|
<html lang="en">
|
||||||
<title>Server Management</title>
|
<head>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<meta charset="utf-8"/>
|
||||||
</head>
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||||
<body class="bg-gray-100 text-gray-800">
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
<div class="container mx-auto p-8">
|
/* Theme toggle fix */
|
||||||
<h1 class="text-4xl font-bold mb-8">Server Management</h1>
|
body[data-bs-theme="dark"] .hide-theme-dark { display: none !important; }
|
||||||
|
body[data-bs-theme="dark"] .hide-theme-light { display: flex !important; }
|
||||||
|
|
||||||
<div id="server-table-container" class="bg-white shadow-md rounded-lg overflow-hidden">
|
body[data-bs-theme="light"] .hide-theme-light { display: none !important; }
|
||||||
<table class="min-w-full leading-normal">
|
body[data-bs-theme="light"] .hide-theme-dark { display: flex !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body >
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler-theme.min.js"></script>
|
||||||
|
<div class="page">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<h1 class="navbar-brand navbar-brand-autodark">
|
||||||
|
<a href=".">
|
||||||
|
<span class="text-primary"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-server-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||||
|
<path d="M3 12m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||||
|
<path d="M7 8l0 .01"></path>
|
||||||
|
<path d="M7 16l0 .01"></path>
|
||||||
|
</svg></span>
|
||||||
|
Nexus Node
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<div class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav pt-lg-3">
|
||||||
|
<li class="nav-item active">
|
||||||
|
<a class="nav-link" href="./">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block"><!-- Download SVG icon from http://tabler-icons.io/i/home -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l-2 0l9 -9l9 9l-2 0" /><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7" /><path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">
|
||||||
|
Servers
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">
|
||||||
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-settings" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"></path>
|
||||||
|
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">
|
||||||
|
Settings (Coming Soon)
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Page body -->
|
||||||
|
<div class="page-body">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="card">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>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">IP</th>
|
<th>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">MAC</th>
|
<th>IP Address</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>MAC Address</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 class="w-1">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="server-table-body">
|
<tbody id="server-list">
|
||||||
<!-- Server rows will be inserted here -->
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
<div id="loading-spinner" class="text-center p-5">
|
||||||
<div id="no-servers-message" class="hidden bg-white shadow-md rounded-lg p-8 text-center">
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
<p class="text-gray-600">No servers found. Please check your configuration.</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<footer class="footer footer-transparent d-print-none">
|
||||||
<button id="add-server-button" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
|
<div class="container-fluid">
|
||||||
Add New Server
|
<div class="row text-center align-items-center flex-row-reverse">
|
||||||
</button>
|
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||||
</div>
|
<ul class="list-inline list-inline-dots mb-0">
|
||||||
|
<li class="list-inline-item">
|
||||||
<div id="add-server-form-container" class="hidden mt-8 bg-white shadow-md rounded-lg p-8">
|
Nexus Node © 2026
|
||||||
<h2 class="text-2xl font-bold mb-4">Add New Server</h2>
|
</li>
|
||||||
<form id="add-server-form">
|
</ul>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<label for="name" class="block text-gray-700 text-sm font-bold mb-2">Name</label>
|
</div>
|
||||||
<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>
|
</footer>
|
||||||
<div class="mb-4">
|
</div>
|
||||||
<label for="ip" class="block text-gray-700 text-sm font-bold mb-2">IP Address</label>
|
|
||||||
<input type="text" id="ip" name="ip" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="mac" class="block text-gray-700 text-sm font-bold mb-2">MAC Address</label>
|
|
||||||
<input type="text" id="mac" name="mac" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="ssh_user" class="block text-gray-700 text-sm font-bold mb-2">SSH User</label>
|
|
||||||
<input type="text" id="ssh_user" name="ssh_user" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="ssh_pass" class="block text-gray-700 text-sm font-bold mb-2">SSH Password</label>
|
|
||||||
<input type="password" id="ssh_pass" name="ssh_pass" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
|
||||||
Save Server
|
|
||||||
</button>
|
|
||||||
<button type="button" id="cancel-add-server" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Modal -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-server" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modal-title">New Server</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form id="server-form">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="oldName" name="oldName">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Server Name</label>
|
||||||
|
<input type="text" class="form-control" name="name" id="input-name" placeholder="e.g. edge-server-01" required>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">IP Address</label>
|
||||||
|
<div class="input-group input-group-flat">
|
||||||
|
<input type="text" class="form-control" name="ip" id="input-ip" placeholder="192.168.1.10" required>
|
||||||
|
<span class="input-group-text">
|
||||||
|
<a href="#" class="link-secondary" title="Auto-find MAC" data-bs-toggle="tooltip" id="btn-find-mac" style="text-decoration: none;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"></path><path d="M21 21l-6 -6"></path></svg>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">MAC Address</label>
|
||||||
|
<input type="text" class="form-control" name="mac" id="input-mac" placeholder="00:11:22:33:44:55" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label">SSH Credentials <span class="form-label-description">(Optional for Proxy Wake)</span></label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" class="form-control" name="ssh_user" id="input-user" placeholder="Username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="password" class="form-control" name="ssh_pass" id="input-pass" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a href="#" class="btn btn-link link-secondary" data-bs-dismiss="modal">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary ms-auto" data-bs-dismiss="modal">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>
|
||||||
|
Save Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal Modal -->
|
||||||
|
<div class="modal modal-blur fade" id="modal-terminal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-full-width modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content" style="height: 90vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="terminal-title">Terminal</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="btn-close-terminal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 bg-black">
|
||||||
|
<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>
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
let terminal = null;
|
||||||
fetchServers();
|
let socket = null;
|
||||||
|
let fitAddon = null;
|
||||||
|
|
||||||
const addServerButton = document.getElementById("add-server-button");
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const addServerFormContainer = document.getElementById("add-server-form-container");
|
fetchServers();
|
||||||
const cancelAddServerButton = document.getElementById("cancel-add-server");
|
|
||||||
const addServerForm = document.getElementById("add-server-form");
|
|
||||||
|
|
||||||
addServerButton.addEventListener("click", () => {
|
// Theme Toggle Logic
|
||||||
addServerFormContainer.classList.remove("hidden");
|
const themeStorageKey = 'tablerTheme';
|
||||||
addServerButton.classList.add("hidden");
|
const initialTheme = localStorage.getItem(themeStorageKey) || 'light';
|
||||||
|
document.body.setAttribute('data-bs-theme', initialTheme);
|
||||||
|
|
||||||
|
const btnDark = document.getElementById('btn-enable-dark');
|
||||||
|
const btnLight = document.getElementById('btn-enable-light');
|
||||||
|
|
||||||
|
if(btnDark) {
|
||||||
|
btnDark.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.body.setAttribute('data-bs-theme', 'dark');
|
||||||
|
localStorage.setItem(themeStorageKey, 'dark');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cancelAddServerButton.addEventListener("click", () => {
|
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");
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchServers() {
|
document.getElementById('modal-server').addEventListener('hidden.bs.modal', resetForm);
|
||||||
fetch("/servers")
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const tableContainer = document.getElementById("server-table-container");
|
|
||||||
const noServersMessage = document.getElementById("no-servers-message");
|
|
||||||
const tableBody = document.getElementById("server-table-body");
|
|
||||||
|
|
||||||
tableBody.innerHTML = ""; // Clear existing rows
|
document.getElementById("btn-find-mac").addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const originalHtml = btn.innerHTML;
|
||||||
|
const ip = document.getElementById("input-ip").value;
|
||||||
|
const user = document.getElementById("input-user").value;
|
||||||
|
const pass = document.getElementById("input-pass").value;
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if(!ip) { showToast("IP Address required", "warning"); return; }
|
||||||
tableContainer.classList.remove("hidden");
|
|
||||||
noServersMessage.classList.add("hidden");
|
|
||||||
|
|
||||||
data.forEach(server => {
|
btn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`;
|
||||||
const row = document.createElement("tr");
|
|
||||||
row.innerHTML = `
|
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.name}</td>
|
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.ip}</td>
|
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">${server.mac}</td>
|
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm" id="status-${server.name}">Checking...</td>
|
|
||||||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
|
||||||
<button onclick="wakeServer('${server.name}')" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Wake</button>
|
|
||||||
<button onclick="shutdownServer('${server.name}')" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">Shutdown</button>
|
|
||||||
<button onclick="rebootServer('${server.name}')" class="bg-yellow-500 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded">Reboot</button>
|
|
||||||
<button onclick="deleteServer('${server.name}')" class="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">Delete</button>
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
tableBody.appendChild(row);
|
|
||||||
checkServerStatus(server.name);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tableContainer.classList.add("hidden");
|
|
||||||
noServersMessage.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkServerStatus(serverName) {
|
fetch(`/find-mac?ip=${ip}&user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`)
|
||||||
fetch(`/status?name=${serverName}`)
|
.then(r => r.ok ? r.text() : r.text().then(t => {throw t}))
|
||||||
.then(response => response.text())
|
.then(mac => {
|
||||||
.then(status => {
|
document.getElementById("input-mac").value = mac;
|
||||||
const statusCell = document.getElementById(`status-${serverName}`);
|
showToast("MAC Address Found", "success");
|
||||||
statusCell.textContent = status;
|
|
||||||
if (status === "Online") {
|
|
||||||
statusCell.classList.add("text-green-500", "font-bold");
|
|
||||||
statusCell.classList.remove("text-red-500");
|
|
||||||
} else {
|
|
||||||
statusCell.classList.add("text-red-500", "font-bold");
|
|
||||||
statusCell.classList.remove("text-green-500");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wakeServer(serverName) {
|
|
||||||
fetch(`/wake?name=${serverName}`).then(() => alert(`${serverName} wake signal sent.`));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shutdownServer(serverName) {
|
|
||||||
if (confirm(`Are you sure you want to shutdown ${serverName}?`)) {
|
|
||||||
fetch(`/shutdown?name=${serverName}`).then(() => alert(`${serverName} is shutting down.`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebootServer(serverName) {
|
|
||||||
if (confirm(`Are you sure you want to reboot ${serverName}?`)) {
|
|
||||||
fetch(`/reboot?name=${serverName}`).then(() => alert(`${serverName} is rebooting.`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteServer(serverName) {
|
|
||||||
if (confirm(`Are you sure you want to delete ${serverName}? This action cannot be undone.`)) {
|
|
||||||
fetch(`/servers?name=${serverName}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
})
|
||||||
.then(response => {
|
.catch(e => showToast(e, "danger"))
|
||||||
if (response.ok) {
|
.finally(() => btn.innerHTML = originalHtml);
|
||||||
fetchServers(); // Refresh the server list
|
});
|
||||||
} else {
|
|
||||||
alert(`Failed to delete ${serverName}.`);
|
// Terminal cleanup
|
||||||
}
|
document.getElementById('modal-terminal').addEventListener('hidden.bs.modal', function () {
|
||||||
});
|
if(socket) {
|
||||||
|
socket.close();
|
||||||
|
socket = null;
|
||||||
}
|
}
|
||||||
}
|
if(terminal) {
|
||||||
|
terminal.dispose();
|
||||||
|
terminal = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh server status every 30 seconds
|
// Terminal resize observer
|
||||||
setInterval(fetchServers, 30000);
|
new ResizeObserver(() => {
|
||||||
|
if(fitAddon) fitAddon.fit();
|
||||||
|
}).observe(document.getElementById('terminal-container'));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
document.getElementById("server-form").reset();
|
||||||
|
document.getElementById("oldName").value = "";
|
||||||
|
document.getElementById("modal-title").textContent = "New Server";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchServers() {
|
||||||
|
fetch("/servers")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const 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"
|
||||||
|
|
||||||
|
if(spinner) spinner.classList.add("d-none");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if(data && data.length > 0) {
|
||||||
|
container.closest(".card").classList.remove("d-none"); // Show card
|
||||||
|
empty.classList.add("d-none");
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
container.appendChild(tr);
|
||||||
|
checkStatus(s.name);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
container.closest(".card").classList.add("d-none"); // Hide card
|
||||||
|
empty.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStatus(name) {
|
||||||
|
fetch(`/status?name=${name}`).then(r => r.text()).then(status => {
|
||||||
|
const badge = document.getElementById(`status-text-${name}`);
|
||||||
|
|
||||||
|
if(!badge) return;
|
||||||
|
badge.textContent = status;
|
||||||
|
badge.className = "badge";
|
||||||
|
|
||||||
|
if(status === "Online") {
|
||||||
|
badge.classList.add("bg-success-lt");
|
||||||
|
} else {
|
||||||
|
badge.classList.add("bg-danger-lt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editServer(name, ip, mac, user, pass) {
|
||||||
|
document.getElementById("modal-title").textContent = "Edit Server";
|
||||||
|
document.getElementById("oldName").value = name;
|
||||||
|
document.getElementById("input-name").value = name;
|
||||||
|
document.getElementById("input-ip").value = ip;
|
||||||
|
document.getElementById("input-mac").value = mac;
|
||||||
|
document.getElementById("input-user").value = (user && user !== "undefined") ? user : "";
|
||||||
|
document.getElementById("input-pass").value = (pass && pass !== "undefined") ? pass : "";
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('modal-server'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteServer(name) {
|
||||||
|
if(confirm(`Are you sure you want to delete ${name}?`)) {
|
||||||
|
fetch(`/servers?name=${name}`, { method: 'DELETE' }).then(r => {
|
||||||
|
fetchServers();
|
||||||
|
showToast("Server deleted", "secondary");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wakeServer(name) {
|
||||||
|
fetch(`/wake?name=${name}`).then(() => showToast(`Wake signal sent to ${name}`, "success"));
|
||||||
|
}
|
||||||
|
function rebootServer(name) {
|
||||||
|
if(confirm(`Reboot ${name}?`)) fetch(`/reboot?name=${name}`).then(() => showToast(`Rebooting ${name}`, "warning"));
|
||||||
|
}
|
||||||
|
function shutdownServer(name) {
|
||||||
|
if(confirm(`Shutdown ${name}?`)) fetch(`/shutdown?name=${name}`).then(() => showToast(`Shutting down ${name}`, "danger"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerminal(name) {
|
||||||
|
document.getElementById("terminal-title").textContent = `Terminal: ${name}`;
|
||||||
|
const modalEl = document.getElementById("modal-terminal");
|
||||||
|
const modal = new bootstrap.Modal(modalEl);
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
modalEl.addEventListener('shown.bs.modal', () => {
|
||||||
|
const container = document.getElementById("terminal-container");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: { background: "#000", foreground: "#fff" },
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace'
|
||||||
|
});
|
||||||
|
|
||||||
|
fitAddon = new FitAddon.FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.open(container);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
// Wait a bit for layout to settle
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
const rows = terminal.rows;
|
||||||
|
const cols = terminal.cols;
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
socket = new WebSocket(`${protocol}//${window.location.host}/ssh?name=${encodeURIComponent(name)}&rows=${rows}&cols=${cols}`);
|
||||||
|
|
||||||
|
socket.onopen = () => terminal.write("\r\n--- Connected to Nexus Node Proxy ---\r\n");
|
||||||
|
socket.onmessage = (e) => terminal.write(e.data);
|
||||||
|
socket.onclose = () => terminal.write("\r\n--- Disconnected ---\r\n");
|
||||||
|
|
||||||
|
terminal.onData(data => {
|
||||||
|
if(socket && socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type="info") {
|
||||||
|
const container = document.querySelector(".toast-container");
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `toast align-items-center text-white bg-${type} border-0`;
|
||||||
|
el.setAttribute("role", "alert");
|
||||||
|
el.setAttribute("aria-live", "assertive");
|
||||||
|
el.setAttribute("aria-atomic", "true");
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
${msg}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(el);
|
||||||
|
const toast = new bootstrap.Toast(el);
|
||||||
|
toast.show();
|
||||||
|
el.addEventListener('hidden.bs.toast', () => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(fetchServers, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
</body>
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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")
|
||||||
@@ -75,14 +88,15 @@ func printUsage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() ([]serveractions.Server, error) {
|
func loadConfig() ([]serveractions.Server, error) {
|
||||||
viper.SetConfigName("servers") // name of config file (without extension)
|
viper.SetConfigName("servers") // name of config file (without extension)
|
||||||
viper.SetConfigType("json") // or viper.SetConfigType("YAML")
|
viper.SetConfigType("json") // or viper.SetConfigType("YAML")
|
||||||
viper.AddConfigPath("./config") // path to look for the config file in
|
viper.AddConfigPath("./config") // path to look for the config file in
|
||||||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
viper.AddConfigPath(".") // optionally look for config in the working directory
|
||||||
err := viper.ReadInConfig() // Find and read the config file
|
err := viper.ReadInConfig() // Find and read the config file
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
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()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
|
cmd := exec.CommandContext(ctx, "ping", "-c", "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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user